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 shipped stack is the packed distribution, staged as tools/consumer-sim.mjs stages it (pack, flat npm root, settings.json with packages: ["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.json holds the request payload fields on both paths — pi-ai’s onPayload option in-process (pi-vertex passes it through on all three kinds), before_provider_request headless — 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-subagent entries 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 is costIncomplete with that reason until #110 lands.

  • Erratum (2026-09-10): off is 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 to low, 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' yield tells a parentless session to answer normally (observed) — so yielded is 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: streamSimple options — reasoning, thinkingBudgets, maxTokens, temperature, samplingParams (pi-ai SimpleStreamOptions).

  • agent-loop: --thinking for the level; the lane’s isolated agent dir gets a settings.json with thinkingBudgets; temperature / max tokens / sampling are applied by the battery’s extension (yield-tool.mjs grows a before_provider_request handler reading BATTERY_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.

Storestore/<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.

Manifestbattery.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.json evidences 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: true on 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.

  • --force deleting 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)

  1. 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 (-e on each loader file, the permission system’s ask under -p, pi-modes' model switch, whether gadhs-subagent entries 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.

  2. Subject and store. lib/subject.mjs (parse, sweep, key, validate), lib/store.mjs replacing results.mjs (paths, fingerprint, stale, force), record shape; lib/knobs.mjs applying 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.

  3. Tasks, drivers, scorers, runners. lib/task.mjs; tasks/.json for the eight; drivers/{review,judge}.mjs (the review driver imports reflection-review.ts; the judge driver calls runJudge); scorers/ including matchers; runners/{single-call,agent-loop}.mjs from catalog.mjs and headless.mjs. The role tests move with what they test as regression tests (explore’s blind-authorship test, coder’s sandbox and runHidden, the review scorer’s calibration); a unit test per task file that it loads, resolves its prompt source and lists its cases.

  4. Stack. lib/stack.mjs presets and agent-dir seeding; the costIncomplete rule; the pinned mode model. Unit tests: the seeded overlay names the subject; an event stream without trace entries sets costIncomplete; a stream whose session model differs from the subject is refused.

  5. CLI and manifest. cli.mjs verbs; battery.json (matrix rules moved in, matrix.json deleted); 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 makes fetch and any spawn of pi throw, so "the battery’s unit tests never call a model" is enforced, not asserted.

  6. Render and retire. render.mjs over store + manifest (not measured, stale, measuredAt, experiments tables); page-head.adoc rewritten 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.

  7. Smoke fill. One cheap manifest entry end to end on the real wire — integrity on 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.mjs and roles/integrity.mjs become tasks/coder.json + scorers/hidden-suite.mjs and tasks/integrity.json + scorers/intact.mjs; roles/review.mjs becomes drivers/review.mjs; score.mjs becomes scorers/anchors.mjs.

  • Deleted: run.mjs, roles/, lib/results.mjs, matrix.json, results/ (56 MB; history keeps it), the battery-render verb.

  • Docs: testing.adoc and local-dev.adoc (the verb), tuning.adoc and modes.adoc (the page references), .gitignore (store transcripts), biome.json excludes (store; tasks stay 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 2 writes records under store/judge/none/vertex-anthropic_claude-haiku-4-5_off/ with subject, stack and fingerprint; a second invocation keeps them; --force replaces them.

  • battery run --task integrity --model haiku --max-tokens 64 --temperature 0 (single-call) records a length stop and request-knobs.json naming 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 1 loads 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 REF prints the same table.

  • battery fill with an empty store projects the manifest’s cost and refuses over budget; after the smoke fill the page shows the integrity cells with measuredAt, every other cell not measured, and a matrix row is argued from bare-ref, bare-stack cells only.

  • Editing Explore.md marks every explore record stale on the next render; fill --stale re-measures only those.

  • node tools/task.mjs validate green; the battery’s unit tests never call a model or provision over the network.

Edit this page · latest