fix(gate/cache): assign every gate:matrix flag, and wire the cache write path - #169
Merged
Conversation
…ite path Closes #165. Closes #166. ## #165 — gate:matrix silently dropped four flags `parseArgs` held a `valued` set and `assignValue` held an `else if` chain, and the two disagreed: `--from-cache`, `--site-key`, `--task-key` and `--repair-model` were consumed by the parser, handed over, and fell off the end of the chain. No error — they were in `valued`, so they never tripped `unknown argument` either. Closed structurally rather than by adding four branches: one `VALUED_FLAGS` table maps each flag to its assigner, the parser derives its accepted set from those keys, and `assignValue` throws on a key it does not recognise instead of returning. A flag cannot now be accepted-but-unassigned. The test walks the table rather than naming the four that were broken, and a second test asserts the sample map covers every flag so a new one cannot slip past uncovered. **`--repair-model` was dropped twice, and the issue names only the first.** Assigning it is not sufficient: nothing in `run-matrix.ts` ever read `args.repairModel`, so `ReplayRunner` still defaulted to `StubRepairModelClient` and the run still reported a self-heal rate of 0 and a `cost_repair` of zero that both look measured. The flag now builds an `AnthropicRepairModelClient` and passes it to `runVersionLive`. Constructed before anything boots, so a missing `ANTHROPIC_API_KEY` is a named refusal rather than a stub run — the whole reason that client throws at construction. `--repair-model` under `--dry-run` is refused outright: no step fails, so the repair loop never runs and no token is ever spent. Any pre-existing artifact produced with `--repair-model` reported stub numbers. ## #166 — nothing populated the cache `writeCacheRow()` had no caller outside `tests/`. The read half landed with #118, so `gate:matrix --from-cache <dir>` resolved programs out of a directory no shipped code path could fill, and `cacheHitRate()` could only report `no_data` for a reason unrelated to the experiment. `paragent compile --to-cache <dir>` closes it, via `src/cache/ingest.ts`. The compiler CLI owns the write because the compiler is the only stage holding `steps_total` — the `ProgramRef` `resolveProgram` needs to prove completeness — and because a measurement harness that populated on first run would make its populating run differ from every run after it, inside one reported sample. All-or-nothing: the authority runs over the whole bundle before anything is persisted, so a rejected step cannot leave a resolvable-looking prefix on disk. `src/cache/` stays library-only and takes a structurally-typed bundle, so the dependency runs one way and the compiler still does not import the cache's types. Verified end to end: compile --to-cache -> `gate:matrix --from-cache` reports `cache HIT prog-traj-gate-live-... (12 steps)` and rows carry `program_source: cache`. ### Finding: the pre-check and the authority disagree, in the safe direction Now that the authority actually runs on real data, the two can be compared. On the committed 12-step live bundle the compiler marks 1 row poolable and `writeCacheRow` marks 7. One rule accounts for the gap: where a row's whole locator chain is tainted but the row carries `flow_topology`, `buildPoolRow` degrades it to a `topology_only` pool row carrying no locator at all, while `decidePoolEligibility` refuses it as `topology_only_degraded`. Legal (a pre-check may be stricter, never looser) and nothing tenant-derived escapes either way. Deliberately not reconciled here: changing it changes what every committed bundle artifact claims about pool eligibility, which wants its own ADR rather than a rider on a wiring fix. Reported by `--to-cache` on every run and recorded in docs/gate/compiler.md and docs/architecture.md. ## Tests - Table-driven flag coverage in `tests/unit/gate-matrix.test.ts`. - `tests/integration/cache-ingest-bundle.test.ts`: round trip to a complete resolveProgram HIT, rows on disk in both files, the authority overriding the pre-check, whole-bundle rejection leaving an empty directory, and the real committed 12-step bundle. - Canary suite unmodified and green, as #166 requires. Both guards were checked by reverting: reintroducing the accepted-but-unassigned flag shape fails 2 tests; dropping the two-pass write leaves a prefix on disk. npm run ci green (534 unit, 33 integration); npm run test:canary green (52). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CAt2F7SR83iuqvnM4GzAf
5 tasks
The divergence between decidePoolEligibility and writeCacheRow was recorded as an open finding with no tracking issue to reach. Filed as #170, which lays out the three ways it could go; both docs now link it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016CAt2F7SR83iuqvnM4GzAf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #165. Closes #166.
Two wiring bugs in the same area:
gate:matrixaccepted flags it dropped, and the cache had a read path with no write path. Both had the same shape — a surface that looks connected and is not — and both produced numbers that look measured.Track
Changes
#165 — four flags accepted, consumed, and dropped
parseArgsheld avaluedset;assignValueheld anelse ifchain. They disagreed about--from-cache,--site-key,--task-keyand--repair-model: the parser consumed the value, handed it over, and the chain fell off its end. No error, and nounknown argumenteither — the flags were invalued, which is exactly what made them invisible.Closed structurally, per the issue's second bullet, rather than by adding four branches. One
VALUED_FLAGStable maps each flag to its assigner; the parser derives its accepted set from those keys;assignValuethrows on an unrecognised key instead of returning. A flag cannot be accepted-but-unassigned any more, because there is only one list.--repair-modelwas dropped twice, and the issue names only the first. Assigning it is not sufficient — nothing inrun-matrix.tsever readargs.repairModel, soReplayRunnerstill fell back toStubRepairModelClient. Fixing onlyassignValuewould have closed the issue's letter and left its entire stated harm in place: a run asking for the real model would still report a self-heal rate of 0 and acost_repairof zero, both looking like measurements. The flag now constructs anAnthropicRepairModelClientand passes it through torunVersionLive, before anything boots — so a missingANTHROPIC_API_KEYis a named refusal rather than a stub run, which is the whole reason that client throws at construction.--repair-modelunder--dry-runis refused outright: no step fails, so the repair loop never runs and no token is ever spent.Affected artifacts: any past run invoked with
--repair-modelreported stub numbers. As #165 says, check the run's own rows rather than the command that was typed — a stub run hascost_repairall-zero on every row and norepair_context_level.#166 — nothing populated the cache
writeCacheRow()had no caller outsidetests/. The read half landed with #118, sogate:matrix --from-cache <dir>resolved programs out of a directory no shipped code path could fill, andcacheHitRate()could only ever reportno_datafor a reason that had nothing to do with the experiment.Ownership decision — the compiler CLI writes (
paragent compile --to-cache <dir>, via the newsrc/cache/ingest.ts):steps_total.resolveProgramrefuses to return anything it cannot prove complete (ADR-0013), and theProgramRefcarrying that proof is written by the compiler. Whoever writes rows must already hold the whole bundle.gate:matrixpopulated on first run, the populating run would be a file replay and every run after it a cache hit — inside one reported sample. That is the "comparing two different things" hazard Measure the fresh-reasoning baseline cost (the §9 denominator) #39 warns about.paragent cache writewould need a third reader of the bundle shape.All-or-nothing. The authority runs over the whole bundle before anything is persisted, so a rejection on step 7 cannot leave steps 0–6 on disk. A prefix would not be a correctness hazard —
resolveProgramfails closed and reportsincomplete— but a cache that half-holds a program is a worse thing to debug than one that does not hold it.src/cache/stays library-only and takes a structurally-typed bundle, so the dependency runs one way and the compiler still does not import the cache's types.Finding: the pre-check and the authority disagree, in the safe direction
Requested by #166 item 3. Now that the authority actually runs on real data, the two can be compared. On the committed 12-step live bundle the compiler marks 1 row poolable and
writeCacheRowmarks 7:One rule accounts for the whole gap. Where a row's entire locator chain is tainted but the row carries
flow_topology,buildPoolRowdegrades it to atopology_onlypool row — carrying no locator at all, only "a click happened here, inmain, between a click and a fill" — whiledecidePoolEligibilityrefuses it outright astopology_only_degraded.Legal (a pre-check may be stricter, never looser; the dangerous direction stays pinned by
live-bundle-pool.test.ts) and nothing tenant-derived escapes either way. Deliberately not reconciled here: changingdecidePoolEligibilitychanges what every committed bundle artifact claims about pool eligibility, which is a privacy-adjacent decision that wants its own ADR, not a rider on a wiring fix. It is reported on every--to-cacherun and recorded indocs/gate/compiler.mdanddocs/architecture.mdrather than smoothed over.Docs
docs/architecture.md: break 1 marked closed, the diagram edge drawn solid, the redlinkStyleremoved, thesrc/cache/andsrc/compiler/rows updated. Scoped to break 1 — the full re-derivation is docs/architecture.md is 62 commits stale and now states things that are false #168's job and is deliberately not attempted here.docs/gate/compiler.md:--to-cachein the CLI section, and the measured pre-check/authority divergence.docs/gate/runner.md: corrects a claim that was false — it said the real repair client was "opt-in viagate:matrix --repair-model", which did not work until this PR.Test plan
npm run cipasses locally — 534 unit, 33 integrationnpm run test:canarypasses — 52 tests, suite unmodified, as Nothing populates the cache at runtime: writeCacheRow has no caller outside tests #166 requiresNew tests:
tests/unit/gate-matrix.test.ts— table-driven over the flag list, plus a coverage check so a new flag cannot slip past untested, plus the unknown-key throw.tests/integration/cache-ingest-bundle.test.ts— round trip to a completeresolveProgramHIT read by a separate store on the same directory; rows in bothpool.jsonlandtenant.jsonl; the authority overriding the pre-check; whole-bundle rejection leaving an empty directory; the real committed 12-step bundle.Both guards were checked by reverting rather than only by passing:
End-to-end, the loop the issue asks about:
Rows carry
program_source: cache. That line was unreachable from any shipped path before this PR.Hard rules
--dry-runrows stay all-zero and--repair-modelis now refused under--dry-runrather than appearing to price a repair.docs/README.mdneeds no new row;npm run lint:docsis clean (65 docs).Contracts
contracts/.Note for the reviewer
The
--repair-modelclient construction is wired but unexercised against a real key — no Docker daemon orANTHROPIC_API_KEYin this environment, so the live path stops at the pre-flight. What is proven is that the flag now reaches a client constructor at all; the self-heal rate stays structurally 0 until someone runs it with a key, which is the exit criterion #27 names.🤖 Generated with Claude Code
Generated by Claude Code