`skills:eval` ran every case with `--max-turns 1`, so it measured one
thing: whether a skill is the model's FIRST tool call. That is right for a
skill a user reaches directly, and it leaves a whole class unmeasured — a
skill reached from inside another skill's body. `testing` tells the model
that picking a fixture is `/test-servers` and that it has to load it, and
`test-servers` scores 5/5 while every one of those five cases asks for it
by name. A skill only ever reachable through a hand-off would score a
clean 100% with the hand-off silently never firing.
An eval case is now one of two shapes, and exactly one:
{ "prompt": "…", "expect": "test-servers" } first move, 1 turn
{ "prompt": "…", "chain": ["testing", "test-servers"] } hand-off, 14 turns
A chain must END with the skill whose file it lives in. The case exists to
measure whether THIS skill is reachable, so the file that goes red is the
one belonging to the skill that stopped being reached; anchoring on the
first link would file a `test-servers` measurement under `testing`, where
a `test-servers` description edit would never be seen. Links are checked
against the repo's model-invoked set, which is why both `verify:skills`
and `collectCases` now validate in a second pass — `test-servers` sorts
before `testing`, so a per-directory check would reject a live chain as
unknown purely because of where the alphabet put it.
Scoring is an ordered SUBSEQUENCE, not a prefix and not a contiguous run:
the model may load something before the chain starts and something
unrelated in between, and neither changes the claim that A led to B.
`collectSkillInvocations` therefore returns an ordered array rather than a
Set — a B, A, B run has to stay distinguishable from one that never
reached B from A.
The two rates are reported in separate columns and never summed. A
hand-off is a second-hop load over many turns; a first-move rate is the
model's opening move. `CHAIN_MAX_TURNS` (14) and `CHAIN_THRESHOLD` are
their own knobs for the same reason, and `CHAIN_THRESHOLD` defaults to
**0.5** rather than inheriting 0.8: see the measurement below.
Read-only containment no longer leans on `--max-turns 1`, which was doing
much of it by itself. The deny list gains the agentic and network tools —
`Task` in particular, whose subagent the flag does not reach.
Measured, `skills:eval -- test-servers`:
First move (1 turn) 7/7 at 100%, unchanged
Hand-off (14 turns) 67% and 33% at RUNS=3
So the hand-off is real and unreliable — which is the fact nothing could
observe before this change, and the reason 0.5 rather than 0.8 is the
default bar: at 0.8 both committed cases are red no matter how strongly
the first skill points at the second, and the column stops carrying
signal. A `["pr-flow", "test-servers"]` probe measured 0% and was dropped
rather than kept: `pr-flow` says nothing about fixtures, so the case had
no lever short of broadening a description onto another skill's ground.
Both findings are written up in `docs/skill-authoring.md`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABdTnqyBodw6mHVVkGehqe
Signed-off-by: cliffhall <cliff@futurescale.com>
520 lines
20 KiB
JavaScript
Executable file
520 lines
20 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
// Trigger eval for the model-invoked skills in `.claude/skills` (#2163).
|
|
//
|
|
// `verify:skills` asserts a skill is well-formed and that its cases exist. This
|
|
// runs them: each prompt is executed headless in a FRESH session and we assert
|
|
// on whether the Skill tool fired with the expected skill.
|
|
//
|
|
// Seeing a skill fire once tells us Claude found it, not that it finds it
|
|
// reliably — the measurement is a hit rate over repeated fresh sessions. It
|
|
// needs negative cases too: a skill that fires on everything is a context
|
|
// regression, and it is the failure nobody notices by hand. `verify:skills`
|
|
// requires both.
|
|
//
|
|
// NOT part of `validate`, `local:gate`, or CI, and that is a decision rather
|
|
// than an omission. A trigger eval cannot be a gate: it spends metered model
|
|
// calls on every push, the measurement IS a hit rate over samples so it is
|
|
// non-deterministic by construction, and it goes red on a rate limit or an
|
|
// expired token — failures unrelated to the diff, whose first consequence is
|
|
// that people stop trusting the gate. Run it when adding a skill or editing a
|
|
// model-invoked skill's description.
|
|
//
|
|
// The cost of that split, stated plainly: neither `verify:skills` nor the
|
|
// authoritative `claude plugin validate` CI step can detect a well-formed skill
|
|
// whose description simply never matches anything. Both check structure.
|
|
// Closing that would need a deterministic trigger oracle, which does not exist.
|
|
//
|
|
// Usage:
|
|
// npm run skills:eval # every model-invoked skill's cases
|
|
// npm run skills:eval -- testing # one skill's cases
|
|
// npm run skills:eval -- testing test-servers # several skills' cases
|
|
// RUNS=5 THRESHOLD=0.8 npm run skills:eval
|
|
//
|
|
// Two kinds of case, measured and reported separately (#2204). A `expect` case
|
|
// is a FIRST-MOVE measurement: one turn, does the model reach for the skill
|
|
// before anything else. A `chain` case is a HAND-OFF measurement: many turns,
|
|
// does loading skill A actually lead the model to load skill B. The two numbers
|
|
// are not comparable — a hand-off is a second-hop load that only happens once
|
|
// the run has established it needs one — so they never share a column.
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { claudeSpawnArgs, probeClaudeVersion } from "./lib/claude-cli.mjs";
|
|
import {
|
|
isChainCase,
|
|
parseClaudeVersion,
|
|
parseSkill,
|
|
validateEvalCases,
|
|
} from "./lib/skill-manifest.mjs";
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const SKILLS_DIR = path.join(ROOT, ".claude", "skills");
|
|
|
|
const THRESHOLD = Number(process.env.THRESHOLD ?? 0.8);
|
|
// A hand-off is a harder thing to hit than a first move, and what counts as
|
|
// acceptable is a separate judgement rather than one inherited from a number
|
|
// tuned for the other measurement. 0.5 is the weakest claim worth asserting —
|
|
// the pointer is taken more often than not. It is deliberately not 0.8: the
|
|
// committed `testing` -> `test-servers` cases measure 33-67% (RUNS=3) against a
|
|
// pointer that is live and stated in the first paragraph of `testing`'s body,
|
|
// so an 0.8 bar would mark every hand-off red regardless of how strongly the
|
|
// first skill points at the second, and the column would stop carrying signal.
|
|
const CHAIN_THRESHOLD = Number(process.env.CHAIN_THRESHOLD ?? 0.5);
|
|
const RUNS = Number(process.env.RUNS ?? 3);
|
|
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
|
|
|
|
/**
|
|
* Turns a hand-off case gets.
|
|
*
|
|
* `--max-turns 1` is what makes a first-move case a first-move case, so a
|
|
* chained case needs a budget wide enough for the run to establish that it
|
|
* needs the second skill. #2204 measured the `testing` -> `test-servers`
|
|
* hand-off going 9-12 tool calls without reaching it; a budget under that
|
|
* cannot distinguish "the hand-off does not fire" from "the run was cut short".
|
|
*/
|
|
const CHAIN_MAX_TURNS = Number(process.env.CHAIN_MAX_TURNS ?? 14);
|
|
|
|
/** Collect the committed cases for every model-invoked skill (optionally one). */
|
|
/**
|
|
* Collect the committed cases, and the set of skill names that are OURS.
|
|
*
|
|
* `only` narrows which cases run, but it must NOT narrow `ours`: a negative
|
|
* case means "no skill of this repo fired", so excluding the repo's other
|
|
* model-invoked skills from that set would let `skills:eval -- testing` score a
|
|
* `local-dev` invocation as foreign and pass a negative case it should fail
|
|
* (Copilot). Every model-invoked skill is inspected; `only` is applied when
|
|
* enqueueing.
|
|
*
|
|
* A name that matches no model-invoked skill is a hard error rather than an
|
|
* empty run: a typo would otherwise enqueue nothing and the eval would report a
|
|
* green 0/0, which reads exactly like a clean pass of the skill you meant.
|
|
*
|
|
* Collection is two passes. A hand-off case names other skills, and its links
|
|
* are checked against the repo's model-invoked set — which is only complete
|
|
* once every directory has been parsed. Validating inside the first pass would
|
|
* make the check depend on directory order: `test-servers` sorts before
|
|
* `testing`, so a chain through `testing` would be rejected as unknown purely
|
|
* because of where the alphabet put it.
|
|
*
|
|
* @param {string | string[] | undefined} only One or more skill names.
|
|
* @param {string} [skillsDir]
|
|
* @returns {{ cases: object[], ours: Set<string> }}
|
|
*/
|
|
export function collectCases(only, skillsDir = SKILLS_DIR) {
|
|
const wanted =
|
|
only === undefined || only === null
|
|
? null
|
|
: new Set(Array.isArray(only) ? only : [only]);
|
|
const cases = [];
|
|
const ours = new Set();
|
|
const files = [];
|
|
for (const dir of readdirSync(skillsDir).sort()) {
|
|
const skillFile = path.join(skillsDir, dir, "SKILL.md");
|
|
if (
|
|
!statSync(path.join(skillsDir, dir)).isDirectory() ||
|
|
!existsSync(skillFile)
|
|
)
|
|
continue;
|
|
const skill = parseSkill(dir, readFileSync(skillFile, "utf8"));
|
|
// Skipping a BROKEN skill here would let `skills:eval` run the remaining
|
|
// cases and exit 0 while omitting the very skill that was just broken —
|
|
// reporting a green measurement of a set that quietly shrank (Copilot).
|
|
// Only a well-formed, deliberately name-only skill is skipped.
|
|
if (skill.errors.length > 0) {
|
|
throw new Error(
|
|
`${dir}/SKILL.md does not parse (${skill.errors[0]}). Run \`npm run verify:skills\`.`,
|
|
);
|
|
}
|
|
if (!skill.modelInvoked) continue;
|
|
ours.add(dir);
|
|
|
|
const evalsFile = path.join(skillsDir, dir, "evals", "evals.json");
|
|
if (!existsSync(evalsFile)) {
|
|
throw new Error(
|
|
`${dir} is model-invoked but has no evals/evals.json. Run \`npm run verify:skills\`.`,
|
|
);
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(evalsFile, "utf8"));
|
|
} catch (e) {
|
|
throw new Error(
|
|
`${dir}/evals/evals.json is not valid JSON — ${e.message}`,
|
|
);
|
|
}
|
|
files.push({ dir, parsed });
|
|
}
|
|
for (const { dir, parsed } of files) {
|
|
const invalid = validateEvalCases(dir, parsed, ours);
|
|
if (invalid.length > 0) {
|
|
throw new Error(`${dir}/evals/evals.json: ${invalid.join("; ")}`);
|
|
}
|
|
if (wanted && !wanted.has(dir)) continue;
|
|
for (const c of parsed) cases.push({ ...c, from: dir });
|
|
}
|
|
if (wanted) {
|
|
const unknown = [...wanted].filter((n) => !ours.has(n));
|
|
if (unknown.length > 0) {
|
|
throw new Error(
|
|
`no model-invoked skill named ${unknown.map((n) => `\`${n}\``).join(", ")} — ` +
|
|
`known: ${[...ours].sort().join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
return { cases, ours };
|
|
}
|
|
|
|
/**
|
|
* Extract the payloads the `Skill` tool was invoked with from a chunk of
|
|
* `--output-format stream-json` output.
|
|
*
|
|
* Pure and separately tested: everything below spawns a real CLI, so the
|
|
* parsing and the process-outcome handling are unreachable from the happy path
|
|
* of an eval run and would otherwise only ever be exercised by the thing they
|
|
* are supposed to measure.
|
|
*
|
|
* The invocations come back as an ORDERED array, repeats included, rather than
|
|
* a set. A hand-off case asserts that one skill was loaded *after* another, so
|
|
* occurrence order is the observation — and collapsing repeats would make a
|
|
* run that loaded B, then A, then B again indistinguishable from one that never
|
|
* reached B from A (#2204).
|
|
*
|
|
* @param {string} text One or more newline-delimited JSON events. A trailing
|
|
* partial line is ignored, so this can be fed incrementally.
|
|
* @returns {{ invoked: string[], rest: string, result: string | null }}
|
|
*/
|
|
export function collectSkillInvocations(text) {
|
|
const lines = text.split("\n");
|
|
const rest = lines.pop() ?? "";
|
|
const invoked = [];
|
|
let result = null;
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
let evt;
|
|
try {
|
|
evt = JSON.parse(line);
|
|
} catch {
|
|
// A malformed line is noise from the CLI, not an observation.
|
|
continue;
|
|
}
|
|
if (evt?.type === "result") result = evt.subtype ?? null;
|
|
if (evt?.type !== "assistant") continue;
|
|
for (const block of evt.message?.content ?? []) {
|
|
if (block?.type !== "tool_use" || block.name !== "Skill") continue;
|
|
// Don't assume the input field's name — match on the whole payload.
|
|
invoked.push(JSON.stringify(block.input ?? {}));
|
|
}
|
|
}
|
|
return { invoked, rest, result };
|
|
}
|
|
|
|
/**
|
|
* Terminal `result` subtypes that mean the session ran to a real conclusion.
|
|
*
|
|
* `error_max_turns` is a SUCCESSFUL observation here, not a failure: with
|
|
* `--max-turns 1`, a run in which the model invokes a skill necessarily hits
|
|
* the limit and the CLI exits 1 — so treating a nonzero exit as failure would
|
|
* reject exactly the runs the eval is trying to count. Verified against the
|
|
* CLI: a firing prompt ends `{subtype: "error_max_turns", num_turns: 2}`.
|
|
*/
|
|
const CONCLUSIVE_RESULTS = new Set(["success", "error_max_turns"]);
|
|
|
|
/**
|
|
* Whether a finished run produced a usable observation.
|
|
*
|
|
* The failure that matters is the opposite one: an auth error, a rate limit or
|
|
* a missing CLI observes NOTHING, and counting that as "no skill invoked"
|
|
* passes every negative case and reads as a trigger miss on every positive one,
|
|
* so a run that never happened comes back as a plausible hit rate (Copilot).
|
|
* Classifying on the terminal event rather than the exit code separates the two.
|
|
*
|
|
* @param {{ result: string | null, code: number | null }} outcome
|
|
* @returns {string | null} A reason to reject, or null if the run is usable.
|
|
*/
|
|
export function runRejection({ result, code }) {
|
|
if (result === null) {
|
|
return `produced no terminal \`result\` event (exit ${code})`;
|
|
}
|
|
if (!CONCLUSIVE_RESULTS.has(result)) {
|
|
return `ended \`${result}\` (exit ${code})`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The skill names a `Skill` tool_use payload actually asked for.
|
|
*
|
|
* Structural rather than substring: matching `"testing"` against the raw JSON
|
|
* counts `{"skill":"not-testing"}` as a hit and inflates the measured rate
|
|
* (Copilot). The field name is still not assumed — every string value in the
|
|
* payload is a candidate, compared by equality.
|
|
*
|
|
* @param {string} payload JSON text as recorded by `collectSkillInvocations`.
|
|
* @returns {string[]}
|
|
*/
|
|
export function invokedSkillNames(payload) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(payload);
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
return [];
|
|
return Object.values(parsed).filter((v) => typeof v === "string");
|
|
}
|
|
|
|
/**
|
|
* Whether one sample satisfies a case.
|
|
*
|
|
* A negative case asserts that no skill **of this repo's** fired. Asserting
|
|
* that nothing fired at all would fail for a contributor who happens to have an
|
|
* unrelated skill in `~/.claude/skills`, or when a bundled skill matches — a
|
|
* false failure about someone else's environment rather than about these
|
|
* skills (Copilot).
|
|
*
|
|
* @param {string | null} expect Skill name, or null for a negative case.
|
|
* @param {Iterable<string>} invoked
|
|
* @param {Set<string> | null} [ours] Repo skill names. Null counts any skill.
|
|
*/
|
|
export function sampleHit(expect, invoked, ours = null) {
|
|
const names = [...invoked].flatMap(invokedSkillNames);
|
|
if (expect === null) {
|
|
return ours === null ? names.length === 0 : !names.some((n) => ours.has(n));
|
|
}
|
|
return names.includes(expect);
|
|
}
|
|
|
|
/**
|
|
* Whether one sample satisfies a hand-off case.
|
|
*
|
|
* The chain has to appear as an ordered SUBSEQUENCE of what fired, not as a
|
|
* prefix and not as a contiguous run. Two reasons, both of which a stricter
|
|
* match gets wrong: the model is free to load an unrelated skill in between,
|
|
* and it may well load something before the chain's first link — neither
|
|
* changes the fact that A led to B, which is the only claim the case makes.
|
|
*
|
|
* Nothing is asserted about foreign skills here, unlike a negative case. A
|
|
* hand-off case names exactly what it wants and a contributor's own
|
|
* `~/.claude/skills` entry firing alongside it says nothing either way.
|
|
*
|
|
* @param {string[]} chain Ordered skill names, ending with the owning skill.
|
|
* @param {Iterable<string>} invoked
|
|
* @returns {boolean}
|
|
*/
|
|
export function chainHit(chain, invoked) {
|
|
const names = [...invoked].flatMap(invokedSkillNames);
|
|
let want = 0;
|
|
for (const name of names) {
|
|
if (name === chain[want]) want++;
|
|
if (want === chain.length) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Score one sample against whichever kind of case it belongs to.
|
|
*
|
|
* @param {{ expect?: string | null, chain?: string[] }} c
|
|
* @param {Iterable<string>} invoked
|
|
* @param {Set<string> | null} ours
|
|
*/
|
|
export function caseHit(c, invoked, ours) {
|
|
return isChainCase(c)
|
|
? chainHit(c.chain, invoked)
|
|
: sampleHit(c.expect, invoked, ours);
|
|
}
|
|
|
|
/**
|
|
* Tools no eval run may use, first-move or hand-off.
|
|
*
|
|
* A skill may inject `!`-prefixed shell commands on load, and those run BEFORE
|
|
* its content reaches the model — so the deny list is what keeps a measurement
|
|
* from having side effects. It matters more for a hand-off case than for a
|
|
* first-move one: `--max-turns 1` was doing much of the containment by itself,
|
|
* and a 14-turn budget removes that (#2204). Hence the agentic and network
|
|
* tools here too — `Task` would spawn a subagent whose own tool policy this
|
|
* flag does not reach.
|
|
*
|
|
* The cost is stated rather than hidden: denying `Bash` also changes the path
|
|
* a run can take toward the second skill, since investigating a repo by hand
|
|
* often starts there. `Read`/`Glob`/`Grep` remain, which is enough to reach a
|
|
* hand-off, but a chained rate is a measurement under this policy and not a
|
|
* prediction of an unrestricted session.
|
|
*/
|
|
const DISALLOWED_TOOLS = [
|
|
"Bash",
|
|
"Write",
|
|
"Edit",
|
|
"NotebookEdit",
|
|
"Task",
|
|
"Agent",
|
|
"SlashCommand",
|
|
"WebFetch",
|
|
"WebSearch",
|
|
"KillShell",
|
|
].join(",");
|
|
|
|
/**
|
|
* Drive one fresh session and return the payloads the `Skill` tool was called
|
|
* with.
|
|
*
|
|
* `spawnFn` is injectable so the stream handling and — more importantly — the
|
|
* exit handling are testable. Without a seam here, changing the nonzero branch
|
|
* back to resolving an empty set would leave every test green while the eval
|
|
* silently reported a plausible hit rate for runs that never happened (Copilot).
|
|
*
|
|
* @param {string} prompt
|
|
* @param {{ spawnFn?: typeof spawn, cwd?: string, maxTurns?: number }} [opts]
|
|
* @returns {Promise<string[]>} Skill payloads, in the order they fired.
|
|
*/
|
|
export function runPrompt(
|
|
prompt,
|
|
{
|
|
spawnFn = spawn,
|
|
cwd = ROOT,
|
|
platform = process.platform,
|
|
maxTurns = 1,
|
|
} = {},
|
|
) {
|
|
return new Promise((resolve, reject) => {
|
|
// The prompt goes in on STDIN, not in argv. `claude -p` with piped stdin
|
|
// reads the prompt from it (verified), and that removes two problems at
|
|
// once: on Windows the CLI is a `.cmd` shim, so it can only be started
|
|
// through a shell, and `cmd.exe` would re-parse any prompt containing a
|
|
// metacharacter as syntax (Copilot). It also keeps the prompt out of the
|
|
// process table.
|
|
const { command, args, options } = claudeSpawnArgs(
|
|
[
|
|
"-p",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
"--max-turns",
|
|
String(maxTurns),
|
|
// Keep the run read-only, across every turn it is given.
|
|
"--disallowedTools",
|
|
DISALLOWED_TOOLS,
|
|
],
|
|
{ cwd, stdio: ["pipe", "pipe", "inherit"] },
|
|
platform,
|
|
);
|
|
const p = spawnFn(command, args, options);
|
|
|
|
let buf = "";
|
|
const invoked = [];
|
|
let result = null;
|
|
p.stdout.on("data", (chunk) => {
|
|
const parsed = collectSkillInvocations(buf + chunk.toString());
|
|
buf = parsed.rest;
|
|
for (const payload of parsed.invoked) invoked.push(payload);
|
|
if (parsed.result !== null) result = parsed.result;
|
|
});
|
|
p.on("error", reject);
|
|
p.on("close", (code) => {
|
|
const rejection = runRejection({ result, code });
|
|
if (rejection !== null) {
|
|
reject(new Error(`\`claude -p\` ${rejection} for prompt: ${prompt}`));
|
|
return;
|
|
}
|
|
resolve(invoked);
|
|
});
|
|
p.stdin?.end(prompt);
|
|
});
|
|
}
|
|
|
|
async function pool(items, n, fn) {
|
|
const out = new Array(items.length);
|
|
let i = 0;
|
|
await Promise.all(
|
|
Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
while (i < items.length) {
|
|
const idx = i++;
|
|
out[idx] = await fn(items[idx]);
|
|
}
|
|
}),
|
|
);
|
|
return out;
|
|
}
|
|
|
|
async function main() {
|
|
if (probeClaudeVersion(parseClaudeVersion) === null) {
|
|
console.error(
|
|
"skills:eval — no usable `claude` CLI on PATH. This eval needs one.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const { cases, ours } = collectCases(
|
|
process.argv.length > 2 ? process.argv.slice(2) : undefined,
|
|
);
|
|
if (cases.length === 0) {
|
|
console.error("skills:eval — no cases found.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const jobs = cases.flatMap((c) => Array.from({ length: RUNS }, () => c));
|
|
const results = await pool(jobs, CONCURRENCY, async (c) => ({
|
|
c,
|
|
invoked: await runPrompt(c.prompt, {
|
|
maxTurns: isChainCase(c) ? CHAIN_MAX_TURNS : 1,
|
|
}),
|
|
}));
|
|
|
|
/** Score and print one group, returning how many of its cases fell short. */
|
|
const report = (group, heading, threshold) => {
|
|
if (group.length === 0) return 0;
|
|
console.log(`\n${heading}`);
|
|
let failed = 0;
|
|
for (const c of group) {
|
|
const mine = results.filter((r) => r.c === c);
|
|
const passes = mine.filter((r) => caseHit(c, r.invoked, ours)).length;
|
|
const rate = passes / mine.length;
|
|
const ok = rate >= threshold;
|
|
if (!ok) failed++;
|
|
const label = isChainCase(c)
|
|
? c.chain.join(" → ")
|
|
: (c.expect ?? "(no skill)");
|
|
console.log(
|
|
`${ok ? "PASS" : "FAIL"} ${(rate * 100).toFixed(0).padStart(3)}% ${label.padEnd(26)} ${c.prompt}`,
|
|
);
|
|
}
|
|
return failed;
|
|
};
|
|
|
|
const direct = cases.filter((c) => !isChainCase(c));
|
|
const chained = cases.filter(isChainCase);
|
|
const directFailed = report(direct, "First move (1 turn)", THRESHOLD);
|
|
const chainedFailed = report(
|
|
chained,
|
|
`Hand-off (${CHAIN_MAX_TURNS} turns)`,
|
|
CHAIN_THRESHOLD,
|
|
);
|
|
|
|
// Reported as two numbers, never one. A hand-off rate is a second-hop load
|
|
// over many turns and a first-move rate is the model's opening move; summing
|
|
// them would produce a figure that describes neither, and a handful of
|
|
// hand-off cases would quietly move a headline everyone reads as trigger
|
|
// reliability (#2204).
|
|
console.log(
|
|
`\n${direct.length - directFailed}/${direct.length} first-move cases at or above ${THRESHOLD * 100}%.`,
|
|
);
|
|
console.log(
|
|
chained.length === 0
|
|
? "No hand-off cases in this selection."
|
|
: `${chained.length - chainedFailed}/${chained.length} hand-off cases at or above ${CHAIN_THRESHOLD * 100}%.`,
|
|
);
|
|
process.exit(directFailed + chainedFailed > 0 ? 1 : 0);
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
) {
|
|
main().catch((e) => {
|
|
console.error(e.message ?? e);
|
|
process.exit(1);
|
|
});
|
|
}
|