Files
zernie__vigiles/examples/harness/skill-compression.eval.mjs
zernie 4acfd31fc9 feat(eval)!: an eval file describes, the runner performs — importing one no longer spends (#159)
* fix(audit): coverage caveats printed by lint never reached the audit report

`formatUntestedReport` grew two qualifier lines — a test file still carrying
the retired `vigiles:covers` marker, and a run record older than the text it
measured. `audit` assembles its own fact block from the SAME UntestedReport
and carried neither, so the note that exists to explain a silent migration was
itself silent for anyone whose habit is `audit` rather than `lint`.

Measured before the fix on a fixture whose only harness carries the marker:

  lint  → "1 test file(s) still carry the retired `vigiles:covers` marker …"
  audit → "Untested surfaces: 0" and nothing else

The caveats now come from one exported builder (`coverageCaveats`) that both
renderers read, so a third qualifier reaches both callers or neither — a
renderer can no longer carry half the list. `ScanReport.coverageCaveats` is
absent rather than `[]` when empty, because the browser twin structurally
cannot compute these (no `.vigiles/coverage.json` without a filesystem) and
byte-parity between the two engines is asserted field-for-field.

Both halves tested at the product level (`node dist/cli.js audit`): it fires on
a repo carrying the marker and stays silent on one that does not. Proven by two
mutations, each with the patched source grepped first: dropping the render line
fails the "fires" assertion; emitting a caveat unconditionally fails the
"silent" one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* fix(coverage): the suffix that stopped counting now says so

`*.test.*` left DEFAULT_TEST_GLOBS in 15.x because a default vitest/jest run
collects exactly those names and a skill test spawns a model — so a misnamed
harness was being executed, and paid for, by somebody else's runner. The
removal was announced to nobody. Its own comment argues "the migration is a
rename, and the untested finding prints the exact path", but the path printed
is the SURFACE's, together with a suggestion to add `<surface>.eval.mjs`. An
author with `foo.test.mjs` already lying beside `foo/SKILL.md` is therefore
told to write the test they wrote, with neither the file nor the reason named.

The sibling half of the same migration — the retired `vigiles:covers` marker —
got its explanatory line at the time. This is the half that did not.

Scoped to surfaces that are otherwise UNCOVERED: that is the only position
where the count contradicts what the author can see in the directory. A stray
`*.test.*` beside a properly covered surface is somebody's ordinary unit test
and none of our business — asserted, because an advisory line that fires on the
innocent case gets muted within a day.

Nothing about coverage changes: the name still grants nothing. Measured silent
on all seven real corpora available here (this repo, six vendored dogfood
plugins) and on the `mine` knowledge base; it fires on a planted fixture in
both `lint` and `audit`, the latter through the shared caveat list added in the
previous commit rather than a second wiring.

Three mutations, each with the patched source grepped before the run and each
dying on its own assertion: dropping the note from the caveat list · widening
the scope from untested to every considered surface · dropping the suffix test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* refactor(scan): compute the caveat list once

Cosmetic follow-up to the two commits above: `coverageCaveats(coverage)` was
called twice in the same object literal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* fix(coverage): the retired-suffix note claimed a cost the guard already closes

The note said a `*.test.*` harness "spawns a model" under someone else's
runner. `refuseUnderForeignRunner` guards every spawn door and refuses under
vitest/jest — measured here against the detector: argv of
`node_modules/vitest/vitest.mjs` yields "vigiles refused to spawn a model:
this process is running under vitest." What actually happens is that the
foreign run FAILS on the file, which is the true and smaller claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* feat(eval)!: an eval file DESCRIBES its eval; `vigiles eval` runs it

Importing a `*.eval.*` file spent real money. These files did their work in the
module body, and in ESM `import` IS execution — so the cheapest imaginable
question, "does this file even parse?", launched a paid run against a live
model when it was asked with `import()`. That happened, and was paid for.

The one guard that exists does not close the door and cannot be made to. Under
`node -e 'import(f)'` there is no `process.argv[1]` at all, so
`foreignRunner(undefined)` is `null` and `refuseUnderForeignRunner` stays
silent; and no process fact distinguishes that from a legitimate runner doing
the same import, so an argv-shaped guard would have to refuse the correct
invocation to catch the wrong one — the exact failure that got
`process.env.VITEST` rejected in `core/foreign-runner.ts`.

So the shape changed instead. An eval file default-exports a description:

    export default defineEval({
      measureTriggerRate: { …spec… },
      assert: (report) => assertTriggerRate(report, { min: 0.8 }),
    });

`defineEval` builds a plain value; there is no call in it to run. The five paid
runners are reached only by `dist/eval-entry.js`, the program `vigiles eval`
now spawns per file — not a new verb, the interpreter the existing verb uses.
`defineEval` is exported from the FREE root barrel, not `vigiles/eval`, and
that is the point: after this change no eval file imports a runner at all.

Three loud edges, because a silent no-op would be the same defect in a new
place: `node x.eval.mjs` refuses and names `vigiles eval`; a file that declares
nothing (or two measurements) is reported by the runner instead of exiting
green; and a half-migrated file — a description plus a leftover top-level
`measure(…)` — hits `refuseDuringEvalLoad` during the runner's own import and
throws a migration message instead of billing. `node --check` still works and
is what the free syntax check should be.

Subtractions: every eval file loses its `process.env.VIGILES_TRIALS ||
process.argv[2]` line, its `console.log(format…Report(report))`, its
`if (report.n === 0) throw`, its top-level `await`, and its `vigiles/eval`
import. The runner does all five, once.

BREAKING CHANGE: `*.eval.*` files must now default-export `defineEval({…})`
instead of calling a runner at the top level, and `node <file>.eval.mjs` no
longer runs an eval. To migrate: move the runner call into a key named after
that runner (`runEval` · `measure` · `measureArms` · `measureTriggerRate` ·
`measureSelectionMatrix` — declare exactly one), move anything you did with the
report into `assert(report)`, and delete the trials line, the `format…` print
and the `report.n === 0` check — `vigiles eval` does those. A capability probe
that sat at the top of the file becomes `skipIf()`; `measureSelectionMatrix`'s
directory argument becomes a `pluginDir` field; and
`measureTriggerRate(spec, { evalDriver })`'s second argument becomes a
top-level `evalDriver` key. `vigiles eval --trials=N` still overrides trials.
Run with `vigiles eval <file>`; a file left on the old shape fails with a
message naming docs/harness-testing.md, never silently. All 19 eval files in
this repository were migrated in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* chore: regenerate the browser-parity fixture and generated types

The eval-definition change rewrote one report hint (`paid_measureTriggerRate`
→ `export default defineEval({ measureTriggerRate: … })`), which is exactly
the divergence the parity test is built to catch. Regenerated per that test's
own instruction for an intentional engine change; the diff is that one line —
grade and every other field are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 20:34:40 +05:00

122 lines
5.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Worked example — verify a TOKEN-COMPRESSION claim with `runEval`.
*
* Tools like "Caveman Mode" make the agent answer in a telegraphic style to cut
* output tokens (claims ~65–75%). That's a harness change with TWO questions, and
* a headline % only answers the first:
*
* 1. Did it actually save tokens? → measure `outputTokens` per arm
* 2. Did it break the behaviour? → measure the OUTCOME, gate regression
*
* vigiles is not a compression tool — it's the instrument that checks the claim
* AND the blast radius. Two arms over the SAME task:
*
* - arm `verbose` — answer normally (the baseline)
* - arm `caveman` — answer in telegraphic style: drop filler, keep every
* technical fact (the compression under test)
*
* Metrics: `inputTokens` (the injection cost — a SKILL.md adds system-prompt
* tokens every turn, so a "compression" win on output can be partially or fully
* erased here), `outputTokens` (the thing being optimized — expect it lower on
* `caveman`), and `correct` (the fact that must survive — expect it UNCHANGED).
*
* SkillBenchmark finding: measuring only `outputTokens` (or `tokens` total)
* misleads. A skill injected into the system prompt raises `inputTokens` on every
* turn, so the net cost delta is (inputDelta + outputDelta), not just outputDelta.
* Measuring BOTH sides is what makes the trade-off honest.
*
* The point of the example is the triple pairing:
* - a token win on output that costs more on input is a partial or null win
* - a token win that silently drops the answer is not a win at all
* - this is how you'd catch either failure mode.
*
* npx vigiles eval --trials=6 examples/harness/skill-compression.eval.mjs
*
* Real model → real cost. Needs the `claude` CLI + model auth and a built dist/.
* External users import from the package: `defineEval` + the free checks from
* `"vigiles"`. An eval file declares its measurement; `vigiles eval` runs it.
*
* FINDING (2026-06-17, real haiku, 3 trials/arm, on the Pro/Max subscription —
* apiKeySource:"none", $0.1057 total). The cost-metric capture works end-to-end:
* `total_cost_usd`, input/output, AND cache tokens all populate from a real run.
* The telling number — `inputTokens` read ~42/run (the UNCACHED remainder) while
* each run's real volume was ~2.4–2.7k tok; the rest is cache reads. So measuring
* `inputTokens` alone (or `tokens` = input+output) understates the real economics
* by ~50× here — the cache classes are where the volume lives. Concrete vindication
* of splitting the usage into input / output / cache (see `cacheTokens`). And the
* compression claim did NOT hold on this task/model: the `caveman` arm's output was
* +11% (762 → 842 tok/run) at +$0.002/run, correctness intact on both (Canberra) —
* a headline "cuts tokens %" would have lied. Exactly the failure mode this catches.
*/
import { defineEval } from "../../dist/test.js";
const CAVEMAN = `# answer-style
Answer in TELEGRAPHIC style. Drop articles, filler, and pleasantries. Keep every
technical fact, name, and number exact. Short fragments over full sentences.
`;
// The task has a checkable fact (the capital) buried in a request that invites
// prose — so a too-aggressive compression that drops the fact is caught.
const TASK =
"Read SKILL.md if it exists and follow its answer style. In one short reply, " +
"name the capital city of Australia, then write it alone on the last line " +
"prefixed with 'ANSWER: '. Write the whole reply to REPLY.txt. Then stop.";
export default defineEval({
runEval: {
name: "skill-compression: does telegraphic style cut tokens without dropping the fact?",
arms: {
verbose: {},
caveman: { files: { "SKILL.md": CAVEMAN } },
},
task: TASK,
measure: (ctx) => {
const reply = ctx.file("REPLY.txt") ?? "";
const answer = /ANSWER:\s*([A-Za-z .]+)/i.exec(reply)?.[1]?.trim() ?? "";
return {
inputTokens: ctx.usage.inputTokens, // the injection cost: skill adds system-prompt tokens
outputTokens: ctx.usage.outputTokens, // the optimization target
correct: /canberra/i.test(answer) ? 1 : 0, // the fact that must survive
};
},
trials: 3,
model: "haiku",
},
assert: (report) => {
// The three questions, answered from the arm means (`metrics` = mean per metric):
const verbose = report.arms.verbose.metrics;
const caveman = report.arms.caveman.metrics;
// 1. Output side: did telegraphic style actually cut output tokens?
const outputSaved =
verbose.outputTokens > 0
? ((verbose.outputTokens - caveman.outputTokens) /
verbose.outputTokens) *
100
: 0;
console.log(
`\ncaveman output-token delta vs verbose: ${outputSaved.toFixed(0)}% ` +
`(${verbose.outputTokens.toFixed(0)} → ${caveman.outputTokens.toFixed(0)} tok/run)`,
);
// 2. Input side: did injecting the SKILL.md raise input tokens?
// SkillBenchmark finding: a compression skill injects text into the system prompt
// every turn, so the net cost delta is (inputDelta + outputDelta). Measure both —
// a headline output saving is misleading if the injection cost erases it.
const inputDelta = caveman.inputTokens - verbose.inputTokens;
console.log(
`caveman input-token delta vs verbose: ${inputDelta >= 0 ? "+" : ""}${inputDelta.toFixed(0)} tok/run ` +
`(${verbose.inputTokens.toFixed(0)} → ${caveman.inputTokens.toFixed(0)})`,
);
// 3. The behavioural guardrail: a token saving that dropped the answer is not a win.
if (caveman.correct < verbose.correct) {
throw new Error(
`caveman regressed correctness: ${caveman.correct} < ${verbose.correct} ` +
`(verbose) — the token saving cost the answer, so it does not count`,
);
}
},
});