Plan: the battery as permutations — task × subject × stack, one store (#108)
On this page
Status: Done (2026-09-10) — seven units landed on refactor/battery-permutations; the store holds the integrity smoke fill (19 models, 228 records, $0.11); every other fill is its own budgeted run, the #102 judge bake-off first.
Branch: refactor/battery-permutations · Issue: #108 · Close step:
the MR’s Closes #108; after merge one line on the issue with the merge
SHA and what was deferred.
Errata
-
Erratum (2026-09-10): the
shippedstack is the packed distribution, staged astools/consumer-sim.mjsstages it (pack, flat npm root,settings.jsonwithpackages: ["npm:@gadhs/pi"]) and cached under~/.cache/gadhs-battery/stack/<hash>per workspace state — not the loader files from the workspace: the third-party loaders'../../../@gotgenes/…paths resolve only in an installed flat root (spike, unit 1). This also measures what a consumer actually runs. -
Erratum (2026-09-10):
request-knobs.jsonholds the request payload fields on both paths — pi-ai’sonPayloadoption in-process (pi-vertex passes it through on all three kinds),before_provider_requestheadless — not the options passed; the payload is the evidence. -
Erratum (2026-09-10): helper cost under a stack is read from the lane’s session file (the
gadhs-subagententries land there; the JSON event stream does not carry appended entries). The judge’s own calls are priced nowhere by pi-modes (#110), so a stack lane on which the judge fired iscostIncompletewith that reason until #110 lands. -
Erratum (2026-09-10):
offis never refused. The plan’s validator would have refused a level the model’s map marks null, but production’s reviews and the integrity sentinel send no reasoning at all and every packaged model accepts that; the record carries the level the request was built with (effective: Gemini floored tolow, a null-mapped Claude sent no thinking field) beside what was asked. -
Erratum (2026-09-10): under the shipped stack an agent-loop lane is a top-level session — pi-modes'
yieldtells a parentless session to answer normally (observed) — soyieldedis structurally false there and the answer is scored as prose. The stack axis measures the bolt-ons' effect on the work, not the subagent contract. -
Erratum (2026-09-10): the smoke fill covered every packaged model on
integrity(228 records, $0.11) rather than two — the projection said it was under a dollar and a fuller first page was worth it.
Design
What is wrong with the battery’s shape
tools/model-battery measures many models × one knob (a thinking level
on the ref) × eight roles fused in code: a role (roles/explore.mjs) is
prompt + tools + corpus + scorer + timeout in one module, an arm is a new
JS export, every agent-loop lane runs bare pi (runHeadless hardwires
--no-extensions -e pi-vertex), and a result is a timestamped run
directory the page renders whole, so re-measuring one model after an
upstream change means a new whole or a hand-merged phase. The operator
wants three things the shape cannot give: permutations of what a model
allows to be tuned (level AND budget, max tokens, temperature, sampling)
chosen per subject from the CLI; targeted measurement, where a full battery
is a manifest of slices filled where missing and re-measured where stale;
and the bolt-ons as a variable — the same task and subject under bare pi
versus the shipped stack, so a difference is the extensions'.
The old results go: measured with production’s thinking only, and a fresh store is cheaper than a compatibility layer (pre-1.0, no migration). The expensive part survives — the cases: blind explore corpora, calibrated review anchors, hidden coder suites, the judge corpus.
Three axes and a store
permutation = task × subject × stack (the unit of measurement) record = permutation × case × run (one call, with its fingerprint) store = tools/model-battery/store/ (records keyed by permutation) manifest = tools/model-battery/battery.json (the permutations the page argues from)
Task — what is asked and how it is graded. A JSON file under tasks/
(or any path given to --task):
{ "prompt": { "production": "explore" } | { "file": "…" } | { "text": "…" },
"append": ["notes/ripwire-first.md"],
"path": "agent-loop" | "single-call",
"driver": "review" | "judge", // single-call tasks that CALL a production function
"tools": ["read","grep","find","ls","count","yield"],
"cases": "cases/explore",
"scorer": "path-recall",
"timeoutMs": 600000, "sandbox": false, "typicalTokens": 150000,
"env": { "BATTERY_RIPWIRE_BIN": "$ripwire" } }
prompt.production names a shipped prompt read at run time (Explore.md’s
body, pi’s default agent prompt) — never a copy, as now. The reviewer,
planning and judge tasks are production functions (buildReviewPrompt
over a staged diff, runJudge over a policy), so they name a driver
under drivers/. Scorers are code — scorers/{path-recall,anchors,verdict,
hidden-suite,intact,matchers}.mjs, each { score(kase, view), aggregate(records) }
in a registry; matchers (expected paths / regex / json-schema over
the final text) makes an arbitrary prompt over an arbitrary case directory
scorable. Mechanical scoring only, no LLM-as-judge. The eight roles become
eight task files; the ripwire arms, two files differing in append,
tools and env.
Subject — a model and its knobs, parsed by lib/subject.mjs:
--model REF provider/id, or a unique substring of a packaged id (haiku);
ambiguity is refused with the candidates listed
--thinking LEVEL off|minimal|low|medium|high|xhigh|max
--thinking-budget N tokens for the level (Anthropic, Google; MaaS when the catalog says so)
--max-tokens N --temperature X --sampling k=v[,k=v] (sampling: OpenAI-compatible only)
--sweep knob=a,b,c repeatable; applies to the --model it follows, until the next --model
--sweep: a model’s fixed knobs are its base; each sweep multiplies the
base by its values, two sweeps cross, and a swept knob replaces the same
fixed knob. --model A --sweep thinking=off,low --model B yields A:off,
A:low, B. Every subject gets a canonical key,
vertex-anthropic/claude-haiku-4-5:low@budget=1024,maxTokens=4000 (knobs
sorted, absent ones omitted — the production subject is the bare ref);
its directory name maps /→, :→, @→__, ,→+, keeps =,
and is never parsed back (the record carries the key). A validator refuses
a knob the model’s API or the task’s path cannot honour — before any call,
never dropped; battery knobs REF prints the table, which is written
from the spike (unit 1), not from memory.
Knobs on the wire (lib/knobs.mjs, one module, two paths):
-
single-call:
streamSimpleoptions —reasoning,thinkingBudgets,maxTokens,temperature,samplingParams(pi-aiSimpleStreamOptions). -
agent-loop:
--thinkingfor the level; the lane’s isolated agent dir gets asettings.jsonwiththinkingBudgets; temperature / max tokens / sampling are applied by the battery’s extension (yield-tool.mjsgrows abefore_provider_requesthandler readingBATTERY_KNOBS) per API family. A knob the spike shows unreachable on a path is invalid for that path in the table, not silently absent.
On both paths lib/knobs.mjs writes request-knobs.json into the lane’s
evidence — the streamSimple options passed, or the payload fields the
handler set (reported back through the file named in BATTERY_KNOBS_OUT)
— so a record shows the knob reached the request.
Stack — which extensions a lane runs under (lib/stack.mjs):
--stack bare pi-vertex only (+ the battery's tools extension) — today's measurement --stack shipped every loader in packages/pi/loaders/, from the workspace, in lexical order --stack PATH[,…] an explicit list of extension entry files
Single-call tasks ignore the stack (no session); their key records none. An
agent-loop lane under a stack gets an agent dir seeded the way
tools/consumer-sim.mjs seeds one (settings.json, trust.json,
auth.json) plus what the battery adds: a gadhs-pi-modes.json overlay
with the mode’s model pinned to the subject — pi-modes switches the
session model on mode entry and would otherwise measure the wrong model.
The stack key is the preset name or the sorted list; its fingerprint is
each entry’s package version (content hash for a bare file). Cost under a
stack includes the helpers it spawns: the lane folds the gadhs-subagent
trace entries (#105) when the JSON event stream carries them; when it does
not, the record says costIncomplete: true and the summary shows it.
Store — store/<task>/<stack>/<subjectKey>/<case>-<run>.json plus the
.evidence/ directory beside each record as now (transcripts stay
gitignored). A record keeps today’s shape (wallMs, usage, stopReason,
score, turns) and gains subject, stack and a fingerprint:
"fingerprint": { "promptSha": "…", "caseSha": "…", "corpusPin": "22f65a2…",
"stack": { "shipped": { "@gadhs/pi-modes": "0.16.0", … } },
"catalog": { "contextWindow": 200000, "maxTokens": 64000, "cost": {…} },
"commit": "…", "piVersion": "0.85.1", "piAiVersion": "0.84.3", "measuredAt": "…" }
A record is stale when its promptSha, caseSha, corpusPin or stack
fingerprint differs from what the same permutation would use now. A model
swapped upstream behind the same id is not detectable: pi-ai 0.84.3
declares responseModel on the message and populates it for no provider.
The page prints measuredAt per cell and re-measurement is a deliberate
--refresh; the record stores responseModel if it ever arrives.
summary.json is derived from the store, per permutation, with today’s
aggregates; battery summarise rebuilds it from disk. Replacement: a slice
already present is kept (today’s resume) unless --force, which deletes
that slice’s records first — git history holds the old ones.
Manifest — battery.json:
{ "matrix": { …today's matrix.json: defaults, roles→task, fit/usable rules, rank… },
"permutations": [
{ "task": "judge", "stack": "bare", "runs": 3,
"subjects": ["vertex-anthropic/claude-haiku-4-5:off", "…:minimal", "…:low",
"…:low@budget=1024", "vertex-anthropic/claude-sonnet-4-6:off", "…:low"] },
{ "task": "explore", "stack": "bare", "runs": 2, "subjects": "every-packaged" },
{ "task": "coder", "stack": ["bare", "shipped"], "runs": 2, "subjects": ["vertex-anthropic/claude-opus-5"] } ] }
battery fill runs every manifest permutation that is missing (with
--stale, stale too), under the budget gate projected over the whole
cross product. battery run runs the permutation on the command line
whether or not the manifest lists it. The page renders the manifest’s
cells only (operator’s choice; extras stay in summary.json): the matrix
reads each role’s production subject (bare ref, bare stack) so defaults
stay argued from production settings, other permutations of the task
render in an experiments table beside it, a cell with no records reads
not measured, a stale one carries its mark and what changed.
The CLI
node tools/task.mjs battery run --task judge --model haiku --sweep thinking=off,minimal,low \
--model sonnet --sweep thinking=off,low --runs 3
node tools/task.mjs battery run --task explore --model gemini-3.8-flash --thinking-budget 2048 --stack shipped
node tools/task.mjs battery run --task ./my-task.json --model haiku --runs 1 --budget 5
node tools/task.mjs battery fill [--stale] [--refresh REF] [--budget USD] [--jobs N]
node tools/task.mjs battery summarise | render | tasks | models | knobs REF
run.mjs becomes cli.mjs with one verb table; task.mjs keeps ONE
battery verb that forwards to it, and battery-render is deleted (an
alias for a tools/-only surface). --out goes: there is one store.
--jobs, --budget and the fail-closed projection stay as they are.
What does not change
Prompts are production’s, read at run time; scoring is mechanical; the
coder sandbox (bwrap, SANDBOX_HIDDEN, repoUntouched), the corpora and
their pins, the blind-authorship test, writeEvidence, the budget gate and
inLanes are kept under the new names. Defaults remain starred and argued;
a shipped default change is still its own issue citing the page.
Risks, by failure shape
-
Knob accepted but not honoured (silent → fail-closed): the validity table refuses it before any call;
request-knobs.jsonevidences what reached the request; a contract test pins the table per API family. -
Helper cost missing from a stack lane (silent under-count → fail-visible):
costIncomplete: trueon the record, summary and page; a unit test feeds a stream without trace entries and asserts the flag. -
pi-modes switching the model off the subject (silent wrong measurement → fail-closed): the seeded overlay pins it; the runner reads the session model from the JSON stream and refuses a record that differs.
-
Stale record read as current (silent → fail-visible): fingerprints compared at render; the cell carries what changed.
-
Cost overrun under a cross product (fail-closed): projected over every permutation before any call, as today.
-
--forcedeleting records (destructive): the named slice only; the CLI prints what it deletes; git history holds the old records.
Scope
Units, in order (one commit each; the test category is named per unit)
-
Spike → table. A scratch permutation of one prompt on one model per API family (Anthropic, Gemini, MaaS) on both paths, checking on the wire: level, budget, max tokens, temperature, sampling; the shipped stack headless (
-eon each loader file, the permission system’saskunder-p, pi-modes' model switch, whethergadhs-subagententries appear in--mode json). Output: `lib/knobs.mjs’s validity table and `lib/stack.mjs’s seeding, and the spike script deleted. Contract tests: the table per API family and path, and the payload fields the handler sets for each. Findings that contradict this plan are errata. -
Subject and store.
lib/subject.mjs(parse, sweep, key, validate),lib/store.mjsreplacingresults.mjs(paths, fingerprint, stale, force), record shape;lib/knobs.mjsapplying to both paths with the evidence file. Unit tests for parse/sweep/key/escaping, stale detection and force; a property test (fast-check) that the subject key round-trips through parse for arbitrary knob sets. -
Tasks, drivers, scorers, runners.
lib/task.mjs;tasks/.jsonfor the eight;drivers/{review,judge}.mjs(the review driver importsreflection-review.ts; the judge driver callsrunJudge);scorers/includingmatchers;runners/{single-call,agent-loop}.mjsfromcatalog.mjsandheadless.mjs. The role tests move with what they test as regression tests (explore’s blind-authorship test, coder’s sandbox andrunHidden, the review scorer’s calibration); a unit test per task file that it loads, resolves its prompt source and lists its cases. -
Stack.
lib/stack.mjspresets and agent-dir seeding; thecostIncompleterule; the pinned mode model. Unit tests: the seeded overlay names the subject; an event stream without trace entries setscostIncomplete; a stream whose session model differs from the subject is refused. -
CLI and manifest.
cli.mjsverbs;battery.json(matrix rules moved in,matrix.jsondeleted); projection over permutations. Unit tests: verb parsing, manifest expansion (every-packaged, stack lists), projection over the cross product, refusal over budget with no execute call. A vitest setup file makesfetchand any spawn ofpithrow, so "the battery’s unit tests never call a model" is enforced, not asserted. -
Render and retire.
render.mjsover store + manifest (not measured, stale, measuredAt, experiments tables);page-head.adocrewritten for the three axes;git rm -r results/; the page regenerated from the empty store (a banner saying so, no numbers invented); docs. Unit tests: the matrix reads only bare-ref/bare-stack cells; a stale cell renders its mark; an empty store renders the banner. -
Smoke fill. One cheap manifest entry end to end on the real wire —
integrityon two models, runs 1 (under $1) — to prove the store, fingerprint, summary and page agree. Every other fill, the #102 judge bake-off first, is its own budgeted run under its own issue.
Files
-
New:
tools/model-battery/{cli.mjs,battery.json},lib/{subject,knobs,stack,task,store}.mjs,runners/.mjs,drivers/.mjs,scorers/.mjs,tasks/.json,store/(committed records, transcripts gitignored), tests for each. -
Rewritten:
render.mjs,page-head.adoc,yield-tool.mjs(the knobs hook),docs/modules/ROOT/pages/model-battery.adoc(generated). -
Moved, kept whole:
lib/{budget,ripwire,workspace}.mjs;roles/coder.mjsandroles/integrity.mjsbecometasks/coder.json+scorers/hidden-suite.mjsandtasks/integrity.json+scorers/intact.mjs;roles/review.mjsbecomesdrivers/review.mjs;score.mjsbecomesscorers/anchors.mjs. -
Deleted:
run.mjs,roles/,lib/results.mjs,matrix.json,results/(56 MB; history keeps it), thebattery-renderverb. -
Docs:
testing.adocandlocal-dev.adoc(the verb),tuning.adocandmodes.adoc(the page references),.gitignore(store transcripts),biome.jsonexcludes (store;tasksstay linted),CHANGELOG.adoc. -
Not shipped —
tools/only; no package version moves, no release.
Out of scope, filed or dropped
-
Detecting an upstream model swap (needs pi-ai to populate
responseModel; an upstream note when the GitHub identity exists). -
Ad-hoc
--prompt "…"without a task file (a task file by path covers it; a flag can follow if it is missed). -
ADC confinement in the sandbox (#106), the verify role (#91), the explore fit threshold (#101), the judge decision itself (#102).
-
Re-measuring the coder role under the #98 masks, and every other slice: the manifest’s fills after unit 7 are separate, budgeted runs.
Acceptance
-
battery run --task judge --model haiku --thinking off --runs 1 --budget 2writes records understore/judge/none/vertex-anthropic_claude-haiku-4-5_off/with subject, stack and fingerprint; a second invocation keeps them;--forcereplaces them. -
battery run --task integrity --model haiku --max-tokens 64 --temperature 0(single-call) records alengthstop andrequest-knobs.jsonnaming both options; the same knobs on an agent-loop task name the payload fields the handler set. -
battery run --task explore --model gemini-3.8-flash --stack shipped --runs 1loads every loader (session_start in the debug log names them), runs on the subject’s model, and the record’s cost is complete or marked. -
A knob invalid for the model or path is refused before any call, naming the table row;
battery knobs REFprints the same table. -
battery fillwith an empty store projects the manifest’s cost and refuses over budget; after the smoke fill the page shows the integrity cells withmeasuredAt, every other cell not measured, and a matrix row is argued from bare-ref, bare-stack cells only. -
Editing
Explore.mdmarks every explore record stale on the next render;fill --stalere-measures only those. -
node tools/task.mjs validategreen; the battery’s unit tests never call a model or provision over the network.