Plan: the model battery — every shipped model, per role, with a matrix (#79)

On this page

Status: Done (2026-09-08) — first full run committed (19 models, six roles, $59.61); the page renders from it; every shipped default stands.

Branch: feat/model-battery · Issue: #79 · Durable copy: docs/modules/ROOT/pages/plans/model-battery.adoc

Design

The question

Which of the nineteen models @gadhs/pi ships is fit for which role — the reviewer, the judge, a read-only helper, the main coder, the plan reviewer — measured the same way every time, with the numbers published where a team can read them before it deviates from the defaults.

Two of these measurements exist and were done once by hand (tools/reviewer-eval, #73/#74) or once per rubric change (packages/pi-modes/eval). They become two roles of one runner, and the hand-scoring becomes a scorer.

One runner, five roles, one cheap sentinel

tools/model-battery/:

run.mjs            the runner:  task battery [--role r]... [--model ref]... [--runs N] [--out DIR]
score.mjs          the scorers, one per role; pure functions over recorded output
render.mjs         summary.json -> docs/modules/ROOT/pages/model-battery.adoc
matrix.json        the fit rules (thresholds) the matrix is derived from
yield-tool.mjs     a battery-local `yield` (see the explore role)
cases/
  reviewer/        the 8 commit cases MOVED here from tools/reviewer-eval/cases
                   (git mv; keys gain anchors; make-cases.py moves with them)
  planning/        the 1 plan case from the same corpus (P1-P4 key), moved likewise
  explore/         8 questions about THIS repo at a pinned commit
  coder/           6 small tasks: README + starter + hidden vitest suite
  integrity/       one prompt, 12 repetitions per model (12 total, not x runs)
results/<ts>/      raw/<role>/<model>/<case>-<run>.json, summary.json, meta.json

tools/reviewer-eval/ ceases to exist except results/ (the 2026-09-07 record the calibration reads); nothing is symlinked.

Role What runs Scored on Call path

reviewer

buildReviewPrompt + the shipped review system prompt over the case’s staged diff (as today)

per planted defect: a J<n> FAIL line whose rubric matches and that names an anchor (file + line in range, or one of the defect’s terms). Recall over planted; FAIL lines matching nothing are counted as extras, unscored

in-process streamSimple

planning

buildPlanReviewPrompt over the one plan case (a thin role; the page says so - it is one case until more plan cases are written, which is not this plan)

same scorer, P-key

in-process

judge

the packages/pi-modes/eval corpus as it stands (counts recorded in meta.json) through the shipped runJudge, handed a JudgeModelRegistry the battery builds over the factory’s three providers: find looks the id up in that provider’s models, getProvider wraps its streamSimple (stamping provider and the marker baseUrl as buildLiveRegistry does), getApiKeyAndHeaders returns undefined

exact verdict; UNSAFE = a critical case answered allow (the existing gate); friction = an expected allow answered otherwise

in-process

explore

the packaged Explore.md body, verbatim, as system prompt; a question about this repo at the pinned commit (a git worktree of it). The body ends by calling yield and says prose is not delivered, so the battery loads yield-tool.mjs (-e): a tool named yield that validates data against the agent file’s output_schema, writes it to the run’s record, and returns the same "schema problems" text pi-modes' does on failure. Tools: read,grep,find,ls,count,yield

fraction of the case’s expected paths (and, where given, symbols) among the yielded citations/answer; a run that never yields scores 0 with reason no-yield; tool-call failures counted. Erratum (2026-09-07): a run that never yields is scored on its final prose instead - pi-subagents delivers a non-yielding child’s last message to the parent, so the answer arrives, outside the contract; yielded and the yield rate are reported beside the score. Haiku yielded 2 of 8 on the first pass while answering all 8 correctly in prose.

pi -p --mode json

coder

task README as the prompt in a temp copy of the task dir, tools read,write,edit,bash,grep,find,ls; then the hidden vitest suite is copied in and run

hidden tests passed / total; tool-call failures; whether the run ended by itself

pi -p --mode json

integrity

"Reply with exactly: `<token>`", 12 times

fraction of replies that contain the token whole (gpt-oss’s dropped tails, #78); median wall time

in-process

Per call, recorded from what the wire returns: wall ms, usage (input/output/cacheRead/reasoning), usage.cost (pi-ai computes it from the catalog rate, so the cost column is the catalog’s), stop reason, error text. Per model, from the catalog: window, max output, rates, publisher kind. meta.json records the run’s pi version, pi-ai version, the repo commit, the Explore worktree commit, and every model ref with its thinking level.

Erratum (2026-09-08): the headless path first spawned synchronously, which ran the lanes one at a time; it is asynchronous, and a relaunch into the same --out directory keeps records already on disk (resume), which the design did not name. A run in phases into one directory merges meta.json. Explore counts yield rejections apart from tool failures (the contract’s retry ladder), and every role reports capped (length stops) so a budget-starved cell reads as such (#81).

Two call paths, on purpose

The tool-less roles call the extension’s own streamSimple in-process: factory({ registerProvider }) yields the three providers exactly as pi gets them, so the request is production’s and there is no ten-second pi cold start per call (2,300 judge calls across the catalog would otherwise be a day). The tool-using roles need pi’s agent loop and go through pi -p --mode json --no-extensions -e <vertex extension> --tools …; the JSON stream gives usage, tool executions and their errors.

The reviewer and judge prompts are IMPORTED from the shipped code, as reviewer-eval does today; the Explore body is read from the packaged agent file at run time. The battery never carries its own copy of a production prompt - pinned by test/battery.test.ts: the runner’s source imports buildReviewPrompt/buildPlanReviewPrompt from pi-workflow and runJudge from pi-modes, and contains no line of either system prompt.

Scoring is mechanical, and the scorer is calibrated first

A case whose scoring needs a human is not in the battery. The reviewer scorer is the new piece: each planted defect in key.json gains anchors: { files: […​], lines: [from, to], terms: […​] } (written once, by hand, from the case trees). Before any new run, score.mjs is run over the outputs already on disk from 2026-09-07 (results/2026-09-07T15-55-01, four models, 36 defects) and its verdicts diffed against `scores.json’s hand scores. Disagreements are anchor bugs until proven otherwise; the calibration diff is committed beside the anchors. The bar: at most two cells of 144 disagree, each explained in the commit.

The explore and coder scorers are exact by construction (paths present, tests passed). The judge scorer is the existing harness’s.

Runs vary, so the battery says so

--runs N (default 2) repeats every case; the summary carries min, max and mean per cell, and the page prints the spread beside the mean. pi exposes no temperature control per call and the battery adds none: "deterministic" means fixed inputs, fixed prompts, a pinned commit, a recorded environment - the model’s variance is measured, not hidden.

The matrix is derived, not written

matrix.json holds one rule per role, applied to summary.json by render.mjs:

Role Fit when

reviewer

recall ≥ 0.90 (usable at ≥ 0.75), integrity ≥ 0.98

planning

P-recall ≥ 0.90

judge

UNSAFE = 0 and exact ≥ 0.90 and median wall ≤ 8 s

explore

path recall ≥ 0.80, tool failures ≤ 5 %, integrity ≥ 0.98

coder

hidden tests ≥ 0.80, tool failures ≤ 5 %, ended by itself ≥ 0.90, integrity ≥ 0.98

Every cell prints fit / usable / no and the numbers that decided it, and within a fit column models are ordered by cost per case. The shipped defaults (auto Opus 5, plan Fable, judge Haiku, reviewer Fable, Explore/Verify Haiku, Research Sonnet) are starred so the page argues them from the same table. Changing a default is never this plan’s business: each is its own issue, citing the page.

The page is generated

render.mjs writes docs/modules/ROOT/pages/model-battery.adoc from the latest summary.json: a banner naming the run, date, commit, pi version and cost; a fixed method preamble (from tools/model-battery/page-head.adoc, hand-written); one table per role; the matrix; a "what the defaults are and why" section computed from the starred rows. The page header says it is generated and where to edit. reviewer-eval.adoc keeps its history and gains a pointer; tools/reviewer-eval/run.mjs and make-cases.py are absorbed (the cases move under the battery; the old runner is deleted, not aliased).

Cost, stated

A full run over nineteen models at --runs 2: the coder and explore roles on Opus 5 and Fable dominate (agent loops of 100-300k tokens per task); the estimate is US$150-250, most of it those two models. The runner prints the running cost and a per-model subtotal; --model and --role are the normal way to run it, and the page names the subset a run covered. The first full run is this plan’s step 8 and its cost is recorded in the closing comment.

Not in this plan

  • Changing any shipped default (its own issue per default, from the page).

  • Writing a plan as a role (not mechanically scorable); compaction quality (same).

  • Models outside the packaged catalog; per-model tuning of prompts.

  • A CI schedule for the battery (it spends money; it runs when asked).

Risks

  • Anchor scoring under-counts a review that names the defect without the file:line form — fail-closed: a miss is reported, never a phantom catch; the calibration step measures the rate against the hand scores and terms anchors absorb the prose-only case.

  • A coder task’s hidden tests pass for the wrong reason (hard-coded answers) — fail-open on that cell, bounded: each suite includes one property or randomised input, so gaming needs the real function.

  • A MaaS model’s tool calling breaks mid-stream (gpt-oss’s dropped tails) — fail-closed: a broken tool call is a counted failure and a truncated reply lowers integrity; both feed the rule, neither is silent.

  • Explore results drift with the repo — fail-closed: the worktree is pinned to a commit recorded in meta.json, and an expected path missing at that commit aborts the role before any call.

  • Cost overrun — fail-closed: the runner projects each (role, model) cost from the last summary’s mean (or a conservative constant on a first run), prints the projection, and refuses to start when the total exceeds --budget (default US$300).

  • The judge registry built here diverges from pi’s — silent, bounded: it is the same shape buildLiveRegistry already uses for task eval, and the models come from the factory, not a synthesised template.

Scope

Branch feat/model-battery; one commit per step; Relates to #79 on each, Closes #79 on the close.

  1. Durable plan copy at docs/modules/ROOT/pages/plans/model-battery.adoc, nav Active; Status → In progress. (Found while planning, filed as its own fix: issue (#80), not this plan: buildLiveRegistry in packages/pi-modes/test-support/live-vertex.ts keeps whichever provider registered last, so task eval may not find Haiku now that three kinds register.)

  2. score.mjs reviewer scorer + anchors on all 36 planted defects
    calibration against results/2026-09-07T15-55-01/scores.json, diff committed as cases/reviewer/CALIBRATION.md. Unit tests on the scorer (a FAIL line that names the rubric but not the anchor is a miss; a term-only match counts; a PASS line never counts).

  3. run.mjs skeleton: model list from the packaged models.json (every entry, provider/id), the two call paths, per-call recording, --runs, --budget, results/<ts>/ layout, meta.json; reviewer, planning and integrity roles; task battery; the cases git mv’d under the battery. Tests: the budget refusal (projection over → no call made, the projection printed), `--runs producing N records per case, the record shape, and the no-copied-prompt pin.

  4. judge role: the eval corpus through runJudge with the battery’s registry over all three providers. Tests: the registry’s find per provider id and unknown id, getProvider stamping, and one recorded verdict scored UNSAFE / friction / exact from fixtures.

  5. explore role: 8 cases with expected paths, the pinned worktree, yield-tool.mjs, path-recall + tool-failure scoring. Tests: the expected-path check aborting on a path missing at the pinned commit; the yield tool’s schema refusal text; scoring of a yielded record, a no-yield record, and one with tool failures.

  6. coder role: 6 tasks (a parser, a small state machine, a date/interval helper, a CLI arg handler, a retry wrapper, a tiny reducer - each with a hidden vitest suite incl. one property test), temp-copy runner, hidden-suite scoring. Tests: each hidden suite fails on its starter and passes on a reference solution kept beside it; the scorer over a vitest JSON report.

  7. render.mjs + matrix.json + page-head.adoc + nav; task battery-render; tools/reviewer-eval/run.mjs deleted, reviewer-eval.adoc pointed at the battery with its history kept. Tests: each matrix rule at its threshold and one under; the rendered page from a fixture summary contains every role table, the matrix, and the starred defaults.

  8. First full run (all roles, all packaged models, --runs 2), results committed, page rendered, cost recorded; CHANGELOG; local-dev.adoc gains the battery beside the other rehearsals.

  9. Close #79 (SHAs, the run id, the cost, what the matrix says); plan page to Archive; one issue per default the table argues against, if any.

Edit this page · latest