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 |
|---|---|---|---|
|
|
per planted defect: a |
in-process |
|
|
same scorer, P-key |
in-process |
|
the |
exact verdict; UNSAFE = a critical case answered |
in-process |
|
the packaged |
fraction of the case’s expected paths (and, where given, symbols) among
the yielded |
|
|
task README as the prompt in a temp copy of the task dir, tools
|
hidden tests passed / total; tool-call failures; whether the run ended by itself |
|
|
"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
termsanchors 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
buildLiveRegistryalready uses fortask 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.
-
Durable plan copy at
docs/modules/ROOT/pages/plans/model-battery.adoc, nav Active; Status → In progress. (Found while planning, filed as its ownfix:issue (#80), not this plan:buildLiveRegistryinpackages/pi-modes/test-support/live-vertex.tskeeps whichever provider registered last, sotask evalmay not find Haiku now that three kinds register.) -
score.mjsreviewer scorer + anchors on all 36 planted defects
calibration againstresults/2026-09-07T15-55-01/scores.json, diff committed ascases/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). -
run.mjsskeleton: model list from the packagedmodels.json(every entry,provider/id), the two call paths, per-call recording,--runs,--budget,results/<ts>/layout,meta.json;reviewer,planningandintegrityroles;task battery; the casesgit mv’d under the battery. Tests: the budget refusal (projection over → no call made, the projection printed), `--runsproducing N records per case, the record shape, and the no-copied-prompt pin. -
judgerole: the eval corpus throughrunJudgewith the battery’s registry over all three providers. Tests: the registry’sfindper provider id and unknown id,getProviderstamping, and one recorded verdict scored UNSAFE / friction / exact from fixtures. -
explorerole: 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. -
coderrole: 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. -
render.mjs+matrix.json+page-head.adoc+ nav;task battery-render;tools/reviewer-eval/run.mjsdeleted,reviewer-eval.adocpointed 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. -
First full run (all roles, all packaged models,
--runs 2), results committed, page rendered, cost recorded; CHANGELOG;local-dev.adocgains the battery beside the other rehearsals. -
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.