Skip to content

fix(store): give rewind payloads their own reserve, and refuse a removal whose original cannot be stored - #188

Merged
amiddavid merged 9 commits into
mainfrom
fix/stash-durability-187
Sep 3, 2026
Merged

fix(store): give rewind payloads their own reserve, and refuse a removal whose original cannot be stored#188
amiddavid merged 9 commits into
mainfrom
fix/stash-durability-187

Conversation

@amiddavid

@amiddavid amiddavid commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #187.

The cause, established

The issue's hypothesis is correct, and the store's own comment said so before the fix did: "The rewind stashes (bare content hashes, the large payloads the expand loop resolves) stay fully evictable."

A stash payload's key is the marker id — a bare lowercase-hex content hash the model reads out of the request and hands back to context_guru_expand (expand.Marker, offload.hashKey / extract.ContentKey). It therefore carries none of the pinned cg:* prefixes, and it cannot be given one: marker bytes sit inside the provider's cached prefix, so prefixing the key would rewrite every marker. Meanwhile the decision records that reference it are pinned (cg:res:, cg:xres:, cg:frz:).

The arithmetic that turns that into 209 failed expands:

  • one store per process. cmd/context-guru-proxy builds cfg.NewStore() once (main.go:480); every concurrent session shares it. The iteration 024 arm configs set no store: block, so: 1,000 entries, pin cap 500, DefaultTTL 10,000 s.
  • one reversible removal writes five entries — the payload (unpinned), cg:own: (unpinned), cg:xseen: (unpinned), cg:res: (pinned), cg:xres: (pinned). The payload competed for ~1/5 of the evictable half while the two records naming it were exempt.

Measured directly against the shipped defaults of the day (600 removals writing that real key mix):

removals=600  stash_live=100  res_live=350  xres_live=350  own_live=100  xseen_live=100  frozen_dropped=500

100 of 600 payloads survived against 350 of 600 of each pinned decision — the payload was 3.5x less durable than the record that pointed at it. 500 markers had been stamped for content the store could no longer produce.

The other two candidate causes are refuted

  • The stash expired. DefaultTTL is 10,000 s and sliding (refreshed on Get, and components re-Put on every replay turn); the runs are shorter. The reproduction above evicts payloads with the clock stationary, so expiry is not needed to produce the symptom.
  • The store did not persist. The process store is *store.Memory (Persists() == true), and effectiveMode already degrades a full marker to off when it is not — so a non-persisting store cannot leave a dangling marker. Arm A ran the same store and scored 0.
  • The key was written under a session id nothing reads. Both resolution paths are session-independent bare Gets: proxy.repairExpandErrorsexpand.Resolve(tn.Store, id) (proxy.go:1297) and the response continuation loop (proxy.go:1653). offload.OwnsKey — the only session-scoped read — gates the management GET /expand endpoint alone.

What changed

store.Stasher / store.PutStash — a reserve for rewind payloads, and a boolean. An optional Store capability (same pattern as FrozenLoser), because the store cannot recognise these keys on its own; stores without it keep the legacy behavior. Memory flags such entries, exempts them from LRU eviction, and bounds them at max_entries / 2 — the same share the pins get.

The return value is the fix, not the reserve. A live payload is never evicted to admit a new one. When the reserve is full PutStash returns false, and the caller declines the removal:

  • offload.commitMark now returns a bool; all twelve offloaders (mask, failed_run, collapse, dedup, extract, extract_llm, the sweep's drop, linecap, skeleton, smartcrush, readlifecycle, agentdiet) continue — the message is left verbatim, not cut, and no marker is stamped.
  • cmdfilter builds its marker token inline rather than through commitMark, so it honors the same contract in its own file.
  • summarize replaces the span it covers, so it cannot leave one message alone: it skips the whole checkpoint.
  • The two refresh paths (reapplyFrozen, summarize's checkpoint replay) count a refusal but still replay — refusing there would flip an already-cached message. A refresh of a payload that is present is never refused, for the same reason.

Only the TTL releases a slot, and PutStash collects expired ones before declaring the reserve full, so a long-lived proxy recovers.

Visibility. stash_refused, stash_live, stash_capacity, stash_expired at /stats (added to the golden contract) and cg_stash_refused_total, cg_stash_reserve_entries, cg_stash_expired_total at /metrics. stash_refused is the leading indicator: expand_unresolved_missing cannot move until an agent happens to call expand, so a proxy that had stopped being able to promise reversibility read as healthy until one did.

DefaultMaxEntries 1,000 → 5,000. 1,000 was an order of magnitude under the observed volume (hundreds of removals × five entries × eight concurrent workers). The doc comment says plainly that entry count is a poor proxy for memory in this namespace and that an operator with large payloads should size max_entries against bytes.

What happens now when the budget is exhausted

The removal does not happen. The tool output is forwarded verbatim, stash_refused increments, and the component records a stash_reserve_exhausted gate. Nothing that was promised stops being deliverable; the pipeline saves fewer tokens and says so.

That is the property #187 asked for by name. Before, every extra removal both consumed a slot and added a pinned decision, so broken promises grew with the amount of work done. Now what grows is the count of removals declined — a refusal to promise instead of a promise broken. TestBrokenPromisesDoNotGrowWithLoad asserts exactly that at 40 / 200 / 1000 removals against a 50-slot reserve: broken = 0 at every load, refusals strictly rising.

Mutations run, and what each broke

The bug is still representable (nothing here is a type-level impossibility), so every test was verified by reverting its subject in a scratch tree and confirming failure.

# Mutation Caught by
M1 evictOldest stops exempting stashes (the original #187 mechanism) TestAnAcceptedStashSurvivesThePressureThatEvictsThePlainCache (payload evicted after acceptance), TestAFullReserveRefusesInsteadOfEvictingALivePayload, TestBrokenPromisesDoNotGrowWithLoad30/40, 190/200, 990/1000 advertised-reversible removals cannot be reversed
M2 PutStash evicts the oldest payload and reports success instead of refusing all four store tests, plus both rendered-output tests: 5 markers on the wire against a 2-payload reserve, 3 of them resolving to nothing
M3 commitMark ignores the refusal and stamps the marker anyway TestNoMarkerReachesTheWireWithoutItsPayload and the proxy end-to-end test — the body sent UPSTREAM offers the model <<cg:…>> and the store cannot produce it
M4 /stats handler drops stash_live / stash_capacity proxy test: rendered stash_capacity = 0, want 2
M5 /stats handler drops stash_refused (computed, then thrown away) proxy test: rendered stash_refused = 0 while removals were being declined
M6 PutStash never reclaims expired payloads TestTheTTLReleasesReserveSlotsthe reserve stayed full after its payloads expired: it never recovers
M7 a refresh of a payload that is present but holds no slot is refused TestRefreshingALivePayloadIsNeverRefused
M8 cmdfilter stamps its inline marker despite the refusal TestCmdfilterRefusesRatherThanStampingAnUnbackedMarker
M9 summarize emits the checkpoint despite the refusal TestSummarizeSkipsTheCheckpointWhenTheSpanCannotBeStashedrestructured the transcript (2 messages, was 7)… the summary's marker is the only route back to a span that is now gone
M10 DefaultMaxEntries back to 1,000 nothing. Deliberate — see below

M7 and M8 initially escaped, which is why they are here: the first refresh test used a key that already held a slot, and the first cmdfilter mutation removed the only use of an import so it failed to compile rather than failing a test. Both were re-cut to compile and to bind, and tests were added for them.

Two of the three new tests assert on rendered output, not on return values: json.Marshal of the rewritten messages (offload) and the body the fake upstream actually received (proxy), each scanned for markers in both spellings — encoding/json HTML-escapes <, and every marker follows a newline, so on the wire a marker usually exists only as <<cg:HASH>>. The first draft of that check scanned only the plain form, reported zero markers on a body full of them, and would have passed while the wire carried unbacked ones.

What I could not establish

  • That eviction is what produced iteration 024's 209 specifically. The mechanism is established from the code and reproduced against the exact shipped defaults and the exact key mix; what is not available is a store-level counter from that run (results.md records no frozen_dropped / store figures, and the raw run artifacts are another session's live evidence). The reproduction shows frozen_dropped = 500 alongside the evicted payloads, so a re-run should show frozen_dropped > 0 in arm B and 0 in arm A if this is the whole story.
  • DefaultMaxEntries = 5000 is not test-pinned (M10 breaks nothing). A test asserting the constant would assert it against itself; its effect is covered by the reserve tests, which are parameterised on MaxEntries. The number is sane, not measured — the refusal counter is what makes a wrong choice visible.
  • A byte budget. The reserve counts entries, and a payload is a whole tool output while a pinned decision is a marker line. Sizing the reserve in bytes is the honest follow-up; it is not in this PR.
  • fix(store): pin-budget exhaustion can reopen the TailOnly fail-open on /compact #47 shares this root cause — one fixed entry budget serving unrelated namespaces with a single max/2 exemption, which is why the reproduction dropped 500 pinned decisions too. Not fixed here. The larger default relieves the pin pressure fix(store): pin-budget exhaustion can reopen the TailOnly fail-open on /compact #47 describes (the pin cap goes 500 → 2,500) without addressing its TailOnly fail-open.

Impact on iteration 024's published results

From the issue, so that whoever reads this knows a re-run is needed to separate the two:

  • The reward result is CONSERVATIVE, not threatened. Arm B won 8 tasks to 0 (clustered p = 0.0078, harm bound 11.2%) while carrying 209 failed expands. That was a handicap the winning arm bore.
  • The latency conclusion is CONFOUNDED by this defect and was stated too strongly. results.md reports arm B taking 13% more turns on pairs both arms solved and calls the latency hypothesis "refuted". 209 failed expands over 75 runs is ~2.8 per run against ~33 turns/run, so failed-expand round-trips could account for a meaningful share of that 13%. It should read "not supported, and partly confounded by this defect."
  • Cost figures are mildly contaminated — a failed expand costs the tokens and the turn and delivers nothing.
  • Most generally: iteration 024 measured "the mechanism plus a reversibility bug", not the mechanism. Re-running after this fix is the only way to separate them.

Verification

gofmt -l . clean · CGO_ENABLED=1 go vet ./... clean · CGO_ENABLED=1 go test ./... all packages pass (Go 1.26.4, eval box).


Review round 2 (3f12b9d)

The seven inline findings, the doc finding and the ordering note are all addressed. The two themes were fixed as invariants, because in both cases the flagged sites were not the whole set.

Theme 1 — nothing is recorded before the gate

commitMark can refuse, so anything written ahead of it may describe work that did not happen — and the dangerous half is not the lost saving. A frozen cg:res: decision with no splice behind it is read on a later turn by the same-session replay path, which deliberately bypasses the cache-tail gate on the reasoning that these bytes were already sent; once a reserve slot frees, the compaction lands inside the provider's cached prefix and the whole suffix is re-written at ~11.5x the read price. The reversibility fix had introduced a reversibility-adjacent bug.

Audited all twelve commitMark callers. Ten were already clean. Four sites recorded state early — the three in the review, plus the cross-session cache hit (extract_llm.go:~975), which the review did not name: its own comment argues the branch is a NEW decision for this session, which is why it is tail-gated, so its payload write can refuse like any other.

Enforced rather than commented:

  • commitMark's doc states the rule and names what it covers (splice, frozen decision, metrics, debug counters, rep.Replay).
  • TestNoStateIsRecordedBeforeTheCommitGate drives registered offloaders against a saturated reserve and asserts no marker on the wire and no decision write, with a healthy-store precondition so a component that silently stopped acting cannot pass it vacuously.
  • TestEveryOffloadComponentIsHeldToTheCommitGate requires every registered Offload to be driven or listed in gateExempt with the test that pins it — so the thirteenth caller has to declare itself. Same mechanism promexport_coverage_test.go uses for /stats fields.
  • cmdfilter no longer hand-rolls its own token, never-worse check and PutStash. It differed from tryMark only in the recovery hint tryMark already takes as a parameter, and that duplicate is exactly why M8 escaped a whole review round in that file alone.

A replay is now a different operation from a new removal. Gating alone would have made the replay paths worse: refusing there sends the original in full where the cached prefix holds the compacted bytes, which is the cache-destructive direction and cannot un-send a marker that went out turns ago. commitRefresh never refuses and reports a missing payload as a diagnosis. #188 already stated that rule for reapplyFrozen; extract_llm's and the sweep's same-session replays were missed.

Theme 2 — the reserve's budgets

  • stash_max_bytes, default 256 MiB. Promoted from follow-up to fix, as the review suggested it might be. Entries are a poor proxy for memory in this one namespace — every other exempt entry is a marker line or an integer, a payload is a whole tool output — so max_entries named a memory figure spanning two orders of magnitude depending on nothing the operator chose. /stats and /metrics publish stash_bytes against stash_max_bytes beside the entry pair, so which budget bound is visible.
  • A guaranteed evictable floor. pinCap and stashCap are each max/2, so together they could occupy the whole entry cap — and a cache with nothing evictable fails silently, not loudly: the next plain Put is evicted by its own insert, turning cg:keep: (the flag that stops the expand loop), cg:sum:, cg:own: and cg:xseen: into no-ops, on precisely the workload the reserve was built for. A quarter of max_entries is now held back from both exemptions.
  • Refusals are O(1). sweepExpired makes one pass instead of one per reclaimed entry, and nextExpiry skips the walk entirely when nothing can have expired. Before, every refused PutStash walked the whole list under the global mutex.

Per-session partitioning was considered and declined. No session reads another's payload (OwnsKey enforces it), so what is shared is the budget, not the data; a fair-share cap would need a share number as arbitrary as the one it replaced, plus an ownership rule for a content-hash key two sessions can legitimately both stash. Sizing against the real resource addresses the same failure more honestly.

Two counters, because they were opposite outcomes

stash_refused and stash_missing shared a counter. Every operator-facing description of a refusal promises "the content was left verbatim and nothing became irreversible" — true of a declined removal, false of a replay whose payload has gone, which is the only case that actually breaks the #187 guarantee. So the number an operator watches to confirm nothing broke was incremented by things breaking.

stash_missing also grows with turn count, not with distinct dangling markers — a missing payload cannot be restored, because the replayed bytes must stay byte-identical to the turn that created them — and both the counter and routes.md say so.

summarize

  • The reserve is consulted before the model call. The span is all the marker key depends on. Previously a saturated reserve paid a ~57k-prompt-token call and discarded it, then did it again every turn, because a refusal saves no checkpoint and so nothing about the next turn changes. A probe, not an early claim: claiming the slot would leak a payload whenever the call then failed. agentdiet gets the same probe, weaker on purpose (smallest candidate, so it skips only when nothing at all fits).
  • A refusal no longer flips cached content. Once a checkpoint has been emitted, earlier turns sent [msg0, summary, tail]; a bare return nil, nil sent the transcript full. When the checkpoint is stale-but-valid — tryReuse verified the prefix hash and declined only on size — it is re-emitted instead, through an emitCheckpoint shared with tryReuse so the two cannot drift. A session with no checkpoint still sends the full transcript, and that case is asserted so the fallback cannot become unconditional.

Docs

docs/reference/config.md — the page an operator reads when stash_refused tells them to raise max_entries — documented max_entries as 1000 and eviction as pinning-only. Now: 5,000, stash_max_bytes, both exemptions, the floor and why its absence is silent, and explicit guidance that stash_refused means raise a budget while stash_missing means raise ttl_seconds. Also corrected in docs/design.md (both sites) and docs/how-to/recover-context.md (defaults, the rewind bullet, the pin-cap sentence, the troubleshooting note). routes.md separates the two counters. The four statsGoldenTopLevel keys are back in alphabetical order.

Verification

gofmt -l . clean · CGO_ENABLED=1 go vet ./... clean · CGO_ENABLED=1 go test ./... all packages pass (Go 1.26.4, eval box).

23 mutations, each reverting one subject whole in a scratch tree, all caught. Full table in the commit body. The ones worth reading: M11 (floor removed) reports 0 of 200 entries are evictable and the cg:keep: write vanishing — the reviewer's predicted failure, reproduced; M25 (bare return) sends 19 messages where 2 were cached; M30 (/stats wire dropped) reads stash_missing = 0 while the live counter reads 1.

Four tests were vacuous or wrong on the first cut, recorded rather than quietly fixed because the shapes recur here: one asserted an unchanged message, which is also what a component that never ran leaves behind; one was blind because round4 renders a ~1e-6 USD saving as 0.0000; one assumed a replay could refuse, which by the new design it cannot; and one cross-session fixture used the same content in both sessions, where the payload is already present and the write is a refresh that cannot refuse. M27 also initially "passed" only by breaking the build, and was re-cut to compile and bind.

Still open, deliberately


Review round 3 (a67c597)

Seven more findings, all fixed. Two were mine to answer for: a regression this PR introduced, and finding 1 of round 2 having been half-applied by me.

The regression: a degraded-mode replay stopped declaring itself irreversible

commitMark's non-full branch sets rep.Irreversible, and that flag is what exempts a deliberate lossy drop from components/pipeline.go's "dropped content without stashing a cache_key" revert. When the replay branches stopped calling commitMark, they stopped setting it — so under marker_mode: summary/off, every replay turn reverted the whole component and sent the transcript verbatim: a full-suffix cache write per turn, which is the harm this PR exists to prevent.

Confirmed by execution, two turns of extract_llm at markerSummary:

turn 1 (fresh, via commitMark): keys=0 Irreversible=true  Replays=0
turn 2 (replay branch):         keys=0 Irreversible=false Replays=1

commitRefresh now takes the rep/eff pair and owns rep.Irreversible symmetrically with commitMark, so the two cannot drift and a third caller cannot reintroduce it.

A third instance predates this PR. reapplyFrozen has never set the flag, and a summary-mode replacement carries no <<cg:HASH>> to parse so it returns no keys either — meaning a summary-mode mask has been reverted on every turn from turn 2 onward, independently of the reserve work. Fixed here under the same rule, since leaving it would mean the shared helper was correct and its oldest caller was not.

The fresh paths were still booking before the gate

Round 2 moved the replay paths' metrics and left the fresh ones:

  • extract_llmRecordExtractionSaving, RecordExtractionValue, e.ratios.observe, calls[k].Accepted and calls[k].SavedTokens all ran inside runCall: in a goroutine, before wg.Wait(), and therefore before phase 3 exists. On a saturated reserve every candidate is declined and the run reported the full saving anyway. The arithmetic now rides in the outT slot phase 3 already reads.
  • The ratio feed is the consequential one — it prices future calls, so the damage outlives the turn. A declined splice now observes nothing: crediting the achieved ratio keeps the gate authorising calls whose output is discarded, while crediting 0 would claim the workload is incompressible, which is false. A model that produced nothing is separate evidence and is still observed as ratio 0.
  • extract_sweepadjudicate booked while building its drop list, and the local sweep_drop_would_not_shrink pre-check covers neither refusal phase 3 can now raise: it is descriptor-only rather than marker-inclusive, and cannot see the reserve at all. Accepted/SavedTokens are now filled from what was applied rather than from what the adjudicator judged spent — the two diverge exactly when the reserve refuses — with a distinct rejection reason for "adjudicated spent, but no drop could be applied".
  • Two knock-ons, both caught by existing tests: the per-call debug record is emitted in the goroutine, so it would have logged accepted=false on every call (TestExtractLLMLogsOneRecordPerCall failed on it); and both ledger appends had to move after phase 3, because rep.Calls takes a copy.

The invariant test now covers the class

Following the reviewer's suggestion, which is better than the three point fixes it replaces: TestNoStateIsRecordedBeforeTheCommitGate asserts metric deltas — gross saved tokens, gross value, and every ledger row's accepted/saved_tokens — alongside store writes, and extract_llm and extract_llm_sweep are driven by the table rather than exempted from it. gateExempt is down to agentdiet and summarize, each naming the test that pins it.

Also

  • agentdiet's reserve gate now counts. Gating without incrementing stashRefusals made one component's refusals invisible to the counter docs/reference/config.md names as the signal to raise a budget.
  • evictableFloor is at least 1. max/4 is 0 for max_entries of 2 or 3, so the two exemptions could reach the whole cap and the floor documented here as unconditional had a hole at the bottom of the range.
  • TestExtractLLMReportsNoSavingsForASpliceItDeclined was blind in the half its own name claims — ~4 tokens at the agentFreshPerMTok fallback is ~1.2e-5 USD against round4's 1e-4 step. Only the rates fixture was wrong; the assertions are unchanged. It is the second time that trap landed in this change, so the fixture is now factored into a pricedCtx() helper carrying the reason.

Verification

gofmt -l . clean · go vet ./... clean · go test ./... all packages pass · ./dash passes standalone (70s).

30 mutations, every one caught by a TEST failure rather than a build break. Round 1's 23 were re-run against this commit and three no longer landed — M17, M23, M33 — because this commit changed the code they patch; a mutation that does not land proves nothing, so they were re-cut and re-verified. One of them (M17) had been "caught" in round 1 only via a goto that failed to compile, so that result was not evidence either. The harness now distinguishes a build break from a test failure.

New this round: M34 (replay drops the flag), M35 (the pre-existing twin in reapplyFrozen), M36 (extract_llm books early → 6072 tokens booked, ratio moved 0.12 → 0.45, accepted=true on a verbatim request), M37 (sweep books early → 86,458 tokens), M38 (agentdiet stops counting), M39 (bare max/4 → 0 evictable at max_entries: 2), M40 (metric ungated with rep.Replay left guarded — proves the savings half now binds alone), M41/M42 (both fresh paths caught by the table test alone), M43 (an Offload in neither the table nor gateExempt).


Review round 4 (c7e41ad)

One HIGH regression from round 3, its MEDIUM consequence, and three LOW findings.

The regression, same shape as the one round 3 fixed

Moving the metrics out of adjudicate's verdict loop deleted removed's only assignment and left the if removed == 0 read. Go then guarantees it stays 0, so every adjudication stamped "adjudicated: nothing was spent" — including ones that dropped content. A ledger row could carry accepted=true, a large saved_tokens, and "nothing was spent" simultaneously: exactly the self-contradictory row this work exists to eliminate. The compiler could not catch it because the variable is still read, so it built and the suite stayed green.

The rejection now tests len(drop) == 0 — which is what the comment above it already claimed, since drop is "what the adjudicator judged spent". The token total gets its own name, judgedTokens, for the cg.sweep.ask debug row it was really serving; that field had gone permanently 0, taking the sweep's economics out of the only place a run's decisions can be reconstructed from.

Its consequence: the reserve-exhausted rejection reason added in round 3 was dead code, because adjudicate always left a non-empty Rejection so the Rejection == "" guard never opened — meaning the case that reason exists to distinguish still read as a plain rejection. Fixed by the above, and now asserted by name.

The ledger carries the wire's figure

saved was content − descriptor, but in markerFull the text spliced is descriptor + marker + recovery hint, so every candidate was overstated by the marker's tokens — while the comment beside it claimed the figure was "what reached the wire". Measured against the spliced message instead. 86,458 claimed against 86,201 actually sent on the stocked fixture, ~23 tokens per drop.

Deliberately not applied to extract_llm's projection-only figure: pre-existing, unclaimed, and moving a published savings number for unrelated reasons belongs in its own change.

Also

  • The ratio comment argued one direction only. Under a persistently saturated reserve nothing advances r.total, so ratio() stays pinned to its prior and exploring() keeps granting its per-session budget instead of self-terminating — a small permanent spend on exactly the deployments this PR is for. The comment now states it as a trade, not a win.
  • The single-flight follower is guarded rather than fragile. It returned with before still 0 while phase 3 spliced its projection, booking observe(0,0) and two zero savings and setting Accepted on a row whose Component is "" — harmless only through three independent zero-guards. Now gated on before > 0. The follower's saving stays unbooked (its leader books the shared result once); the guard removes the pretence of measuring it, not the under-count.

Tests for the three things nothing pinned

  • TestSweepTellsAReserveRefusalApartFromNothingSpent — both rejection strings by name, one subtest each, with preconditions so neither can pass by landing in the other's case.
  • TestADegradedModeReplayDeclaresItselfIrreversible gains an extract_sweep_drop subtest — the one replay branch of four with nothing pinning it.
  • TestSweepReportsItsOwnEconomicsAndTheWiresSeparately — the debug row's removed_tokens is non-zero after a turn that dropped, and the ledger total equals the exact token delta of the messages actually sent.

Split out: #194

The reapplyFrozen fix is a pre-existing main defect, verified independently at the merge base (51fcd91), where reapplyFrozen takes no rep parameter at all. On the reviewer's recommendation it is now #194 off main, closing #193 — a summary-mode revert bug that is live today should not ship only when this PR does, and this PR still carries the open reserve-lifetime question in #190.

This branch keeps the same three lines for now and will be rebased onto main once #194 lands, at which point its state.go diff reduces to a single call-site change. Not stripped pre-emptively, because this branch's tests depend on them.

Verification

gofmt -l . clean · go vet ./... clean · go test ./... all packages pass, on both branches.

5 new mutations, all caught by a test failure. Three of the five did not bind on the first attempt, recorded because the reasons recur: M44 as first cut was behaviour-equivalent to the fix rather than a revert of the bug; M45 had no assertion to fail because nothing read the debug row (that escape is what produced the debug-row assertion); M48 named a test that did not exist, so go test reported "no tests to run" and exited 0. The harness now treats that as not-caught — the third distinct way a mutation has looked like evidence without being any.


Review rounds 5 and 6 (b01f153)

A correct fix voided a test — and the accurate framing matters

Moving the sweep's saving to the wire basis (round 4's request) made saved == 0 by construction on a declined replay, because sweepDrop only calls SetMessageText on success. So TestSweepReportsNoSavingsForARefusedReplay could no longer fail.

The accurate statement is that the failure mode was eliminated, not that a hole is unguarded. The reviewer filed it as the latter and then corrected it: a mutation that books above the ok gate now books nothing, so it is behaviour-equivalent rather than an escaped bug. Both halves of the resolution were then verified independently:

  • the test is re-scoped to rep.Replay, which sits inside the ok branch and still binds;
  • TestSweepReplayBooksWhatTheReplayedMessageActuallySaved guards the basis — revert the replay measurement to the descriptor and it fails, and nothing else in the package does.

Renamed to TestSweepRecordsNoReplayForADropItDeclined, since the old name claimed the half that is now structural.

The ledger's reason is derived, not enumerated

"Three reasons, not two" was still short by at least two paths: RefusedObligation and VerdictUnusable both skip without raising the pre-check gate, so a model answering drop with a needed_by for every candidate produced a row saying the adjudicator found nothing spent — when it judged every output spent and we declined each one for self-contradiction.

The reason now derives from spentJudged, counted where the verdict is read, and the gates carry which skip it was. Counted as a.Drop || a.RefusedObligation, because extract.Judge returns early on a self-contradictory drop without setting a.Drop — so counting a.Drop alone missed precisely the path that made the reason wrong. The two label paths are skipped before Judge runs, so their answer is unknown and is not counted either way.

Accounting no longer keys on a labelling field — and the fix for that had a bug

calls[k].Component != "" is just rep.Component, which pipeline.go always sets in production. So the coupling was invisible there and broke the tests: most fixtures in this package pass a bare &components.Report{}, and from those the whole booking block was skipped, making a future regression inside it uncatchable. Now an explicit out[k].called.

That repair introduced its own bug and the test written for it caught it: the accept branch did out[k] = outT{…}, a struct literal that silently reset called, so phase 3 skipped the booking for every real call. Reintroducing the literal fails exactly one test — the positive-direction one that did not exist before this round.

Because every existing test asserted "books nothing when declined" — which a component that books nothing ever also satisfies. TestExtractLLMBooksItsOutcomeEvenWithAnUnnamedReport is the counterpart, and it deliberately uses a bare Report because that is the fixture shape the coupling broke.

A comment that asserted what the key cannot express

A cg:res: hit does not prove "this session already sent these bytes": resultKey is cg:res:<session>:<id> with no component tag, extract_llm keys the same namespace off the same ContentKey, and the shipped housellm preset runs both in one pipeline. Corrected to state what a hit actually proves.

The design fix is #197, with the reachability analysis — the obvious path through a refused reserve slot turns out not to exist, and unreachability is not established either. Not fixed here, on the same argument accepted for #195: a change altering which records a component replays changes savings, inside a PR whose numbers feed the re-run. That leaves an invariant holding by accident of the current pipeline shapes rather than by construction — documented and filed is not as good as fixed, only better than asserted.

Verification

gofmt -l . clean · go vet ./... clean · go test ./... all packages pass.

Mutations are now scored through five gates — landed exactly once, compiled, the test ran, it failed, and the failure output contains the subject's own assertion text — because "did it fail" scored two earlier mutations as evidence when they were not.

Eight ways a check on this PR looked like evidence without being any, in two kinds:

Failures of evidence (a harness gate catches these): a non-compiling mutant; round4 rendering the delta 0.0000; a precondition a never-ran component also satisfies; go test -run on a non-existent name exiting 0; a behaviour-equivalent mutant; a vacuity guard that could not fail.

Failures of test scope (no gate catches these): a measurement change voiding a test that never mentioned the measurement; and a whole assertion direction missing, where every test pinned the negative case and none the positive. The habits that do catch them: after changing how a quantity is computed, re-run the mutations for every test asserting on that quantity; and for each invariant, ask whether a component that did nothing at all would also pass.

amiddavid added a commit that referenced this pull request Sep 3, 2026
… and it is a shared result cache

David asked what happened to the unexplained recovery channel. It is explained, and
the answer was in a comment: store.ResultPrefix is documented as "extract_llm's
replayed result", and the sweep's drop path writes into it through putResult --
putResult and getResult being shared helpers in components/offload/state.go keyed
cg:res:<session>:<content-id>. One cache, two writers, by construction rather than by
collision.

That resolves the ordering objection an earlier draft foundered on. extract_llm runs
BEFORE the sweep within a request, so the sweep cannot feed it in the same turn; the
cache is content-addressed rather than positional and persists across turns, so
extract_llm need not have seen the content first -- only encounter a content id on a
later turn that the sweep already ruled on.

And the control flow narrows which ids those are. The marker skip is at
extract_llm.go:848 and the cache lookup at :868, so a marker-bearing message never
reaches getResult. All 364 replays were on unmarked content, which leaves the same
content recurring at a fresh position -- routine agent behaviour, and consistent with
a 99.2% hit rate.

The two previously-checked candidate paths are recorded so they are not re-proposed:
expand-restore dies on kept_verbatim_after_expand (9,478 against 3,522, and that gate
SKIPS rather than compacts), and the boundary-moving theory dies on cached_prefix
dominating at 29,302.

The consequence for cost is not reassuring and is stated: the cg:res: entries are
pinned so #187 never touched them, but #188 REFUSES removals when the payload reserve
is full, and no removal means no putResult and no replay. The recovery therefore
scales with how often the sweep acts and moves in both directions under that fix -- a
5,000-entry store admits more, refusals suppress more. The -$7.95 should not be
expected to reproduce.

Signed-off-by: David Amid <david.amid@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>

@amiddavid amiddavid left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review of the rewind-reserve change. The core idea — refuse a removal whose original cannot be stored, rather than making it irreversibly — is sound, and the per-component commitMark gating is applied consistently. The findings cluster on two themes:

  1. Callers that record state or metrics before the gate. putResult, RecordExtractionValue, dbgReapply and rep.Replay run ahead of an apply that can now no-op, so a saturated reserve leaves frozen decisions for splices that never happened (cache-destructive on a later turn) and reports savings that never occurred.
  2. Reserve capacity as a shared, TTL-only resource. Pins + stash can occupy the full entry cap, leaving nothing evictable; and one session can hold 2,500 slots for ~2.8 h across every session in the process.

7 findings inline, most severe first. One doc finding has no diff line to anchor to:

LOW — docs/reference/config.md:27 is stale. It still documents max_entries default 1000 (now 5,000) and describes eviction as pinning-only, with no mention of the rewind reserve or that max_entries/2 is now unavailable to any non-stash key. The stale 1000 is also in docs/design.md:204,628 and docs/how-to/recover-context.md:55. docs/reference/routes.md was updated; the config reference — the page an operator reads when stash_refused tells them to raise max_entries — was not.

Also noted, not filed: the four new keys in proxy/stats_golden_test.go:82 break the otherwise-alphabetical ordering of statsGoldenTopLevel (harmless, the test compares sets).

Generated with Claude Code

Comment thread components/offload/extract_llm.go Outdated
return
}
commitMark(c, rep, eff, key, content)
if !commitMark(c, rep, eff, key, content) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — the frozen decision is recorded before we know the splice happened.

commitMark can now refuse here, and this return correctly leaves the output verbatim. But putResult / putResultGlobal at extract_llm.go:1385-1388 already ran before apply, so cg:res: now claims "this session sent these compacted bytes" for a message that went upstream unchanged.

On a later turn the same-session replay path (extract_llm.go:870-880) hits that record and deliberately bypasses the tail gate — the comment there reasons that these bytes were already sent, so splicing is safe at any depth. Once a reserve slot frees, the compaction gets spliced into a message that is by then inside the provider's cached prefix, forcing a full-suffix cache write at ~11.5x read price. That is exactly the harm TestGlobalCacheHitIsNotSplicedAtDepth exists to prevent.

Fix: record the decision only once the splice actually happened — have apply report success and gate putResult/putResultGlobal on it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and the audit found a third site with the same shape.

apply now reports whether it spliced, and putResult / putResultGlobal wait for it (extract_llm.go, phase 3). The harm you describe is the reason this is treated as an invariant rather than three edits — a frozen decision with no splice behind it is the same broken promise one layer up, and the replay path's deliberate tail-gate bypass is what converts it into the cache write.

The third site: the cross-session hit (extract_llm.go:~975, not in the review). Its own comment argues the branch is a NEW decision for this session — "THIS session never sent these compacted bytes, so the provider's cached prefix holds the ORIGINAL" — which is exactly why it is tail-gated, so its payload write can refuse like any other new removal. It froze into the session before the splice too. TestExtractLLMFreezesNoDecisionForACrossSessionHitItDidNotSplice pins it.

That fixture took two attempts and the failure is worth recording: driving both sessions over the same content does not reproduce the refusal, because a payload is keyed by its content hash, so session B finds session A's payload already there and its write is a refresh that cannot refuse. The state where this is reachable is the payload gone while the pinned cg:xres: decision naming it survives — which is #187 itself, the two having different durabilities. The test now builds that.

Stated in code so the thirteenth caller cannot reintroduce it: commitMark's contract spells the rule out, TestNoStateIsRecordedBeforeTheCommitGate drives registered offloaders against a saturated reserve asserting no marker on the wire and no decision write, and TestEveryOffloadComponentIsHeldToTheCommitGate requires every registered Offload to be either driven or listed in gateExempt with the test that pins it — the same mechanism promexport_coverage_test.go uses for /stats fields.

Mutations: M17 (freeze before splice) → 2 dangling decisions; M18 (cross-session) → cg:res:sessB with no splice; M28 (gateExempt emptied) → every undriven Offload named.

Comment thread components/offload/extract_llm.go Outdated
}
commitMark(c, rep, eff, key, content)
if !commitMark(c, rep, eff, key, content) {
return // the store cannot back the marker; leave this output verbatim

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — replay metrics fire even when the replay is refused.

At extract_llm.go:874-879, metrics.RecordExtractionValue, dbgReapply++ and rep.Replay("reapplied_same_session") all run unconditionally around apply(...), which after this change silently does nothing when the stash is refused.

A run with an exhausted reserve therefore reports replays and token savings that never occurred — over-reporting the specific metric the iteration-024 re-run will be judged on. Gate all three on apply having spliced.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, though the design answer turned out to be sharper than "gate all three".

All three now sit inside if apply(...) { ... }. But gating alone would have left a worse problem: on the replay path a refusal sends the original in full where the provider's cached prefix holds the compacted bytes — a full-suffix cache write, to protect a reversibility promise that a refusal cannot restore anyway, because the marker went out turns ago. That is the rule #188 already stated for reapplyFrozen and summarize's checkpoint replay, and these two replay paths (extract_llm's and the sweep's) were missed.

So a replay is now a different operation: commitRefresh never refuses, and reports a missing payload as a diagnosis rather than a permission. Metrics on that path are then accurate by construction rather than by gating.

What remains reachable, and what the test pins, is the marker-inclusive decline: the projection is smaller than the content while projection+marker is not, so the message is left verbatim while the saving was computed from the projection alone. TestExtractLLMReportsNoSavingsForASpliceItDeclined sits in that gap deliberately.

My first version of this test asserted the reserve could refuse a replay, which it now cannot — the premise was wrong, not the code. Re-cut onto the decline above, plus TestExtractLLMReplaysADanglingDecisionRatherThanFlippingCachedBytes for the direction the replay must not take.

Mutations: M21 (metrics ungated) → rep.Replay fired for a verbatim message; M23 (replay refuses) → original sent at depth.

return "", false
}
commitMark(c, rep, eff, key, content)
if !commitMark(c, rep, eff, key, content) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH / MEDIUM — same two problems on the sweep path.

  1. putResult(c, cands[k].id, desc, "") at extract_sweep.go:483 runs before applySweepDrop, so a refusal here leaves a frozen cg:res: decision for a drop that never happened — same dangling-decision-then-late-splice shape as extract_llm.go.
  2. metrics.RecordExtractionValue at extract_sweep.go:309 sits before the ok check on this function's return, so refused drops still book token savings. (rep.Replay on that path is correctly guarded — worth matching.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed.

1. The drop now happens before the decision (extract_sweep.go phase 3), and sweepDescriptor is computed once at the write rather than twice.

2. RecordExtractionValue moved inside the ok branch. Your parenthetical was the useful part — rep.Replay being correctly guarded right beside it is exactly what made the unguarded metric easy to read past.

The replay path also no longer routes through the same function as a new drop: applySweepDropReplay uses commitRefresh (never refuses, diagnoses a missing payload), because a replay declining would send the output back in full where the cached prefix holds the removed form. Sharing one function made the two cases answer a refused payload identically, which is how the reviewed asymmetry got lost.

Mutations: M19 (freeze before drop) → 11 dangling decisions; M20 (metric above the ok check) → value rose for a drop that did not happen.

M20 escaped twice before it bound, and both reasons generalise. First, the test asserted an unchanged message — which is also what a component that never ran leaves behind; the precondition now requires a result-cache hit, since CacheLookups counts misses too and was being satisfied by the miss further down the loop. Second, at real cache-read rates the saving under test is ~1e-6 USD and round4 renders it 0.0000, so the assertion literally could not see the booked value; the fixture now sets SelfRates high enough to be observable without changing which branch runs.

Comment thread store/store.go
// stashCap bounds the rewind reserve at half the entry cap, the same share the pins get.
// Half and not more because the two halves are the two things reversibility needs at once:
// the payload to serve, and the decision that keeps the message it came out of byte-stable.
func (m *Memory) stashCap() int { return m.max / 2 }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM/HIGH — the two exemptions can together consume the entire entry cap, leaving nothing evictable.

stashCap() is m.max/2 and the pin cap is also m.max/2, so pinnedN + stashN can reach max. Previously pins were bounded at half, so half the cache was always evictable; now it can be zero.

Once saturated, every plain Put pushes to the front and evictOldest finds the just-inserted entry as the only non-exempt one and removes it — writes to the unpinned namespaces become silent no-ops:

  • cg:keep: (state.go:286) -> isKeptVerbatim always false -> content the agent just expanded is re-compacted, i.e. the expand loop this code explicitly guards against.
  • cg:sum: (state.go:371) -> summarize can never checkpoint.
  • cg:own: -> GET /expand refuses.
  • cg:xseen: -> the economic gate misprices recurrence.

Reaching this state coincides with reserve saturation (2 pinned + 1 stash per removal), so it happens on precisely the workload this PR targets. Suggest sizing the two exemptions against a shared budget that leaves a guaranteed-evictable floor.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. This was the finding I could reproduce most exactly, and your description of the mechanism is what the test asserts.

A quarter of max_entries is now held back from both exemptions: exemptRoom() admits a new pin or stash only while pinnedN + stashN < max - max/4, so something is always evictable. Each exemption keeps its own max/2 cap so neither starves the other, and each keeps its existing over-cap behaviour — a pin degrades to an ordinary evictable entry, a stash is refused — so there is no new failure shape, only a second bound.

TestTheExemptionsLeaveAGuaranteedEvictableFloor drives the real key mix until it saturates, then asserts a cg:keep: write and a cg:sum: write survive their own Put, and that at least max/4 entries are evictable. With the floor removed (M11) it reports exactly what you predicted:

a cg:keep: write did not survive its own Put: ... content the agent just expanded
  will be re-compacted — the expand loop that flag exists to stop
a cg:sum: checkpoint did not survive its own Put: summarize can never checkpoint
only 0 of 200 entries are evictable, want at least the max/4 = 50 floor

Two side effects worth flagging, since the joint bound changes what the reserve can reach:

  • With the real mix (three exempt entries per removal) the joint budget now binds before the reserve's own max/2 capmax/4 removals rather than max/2 payloads. Two existing tests encoded the old bound and were retargeted: one resized, one moved to bare PutStash so its subject stays the reserve cap alone.
  • docs/reference/config.md now describes both exemptions and the floor, including why a cache with nothing evictable fails silently rather than loudly.

Comment thread store/store.go
// and the mechanism declines new work rather than doing it irreversibly. Capacity becomes a
// configured quantity (max_entries) and exhaustion becomes a counter, in exchange for
// reversibility no longer being load-dependent.
func (m *Memory) PutStash(key string, payload []byte) bool {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — one busy session can disable marker-mode offloading for every session in the process.

A stash slot is now released only by TTL, the TTL default is 10,000 s and sliding, and the store is a single process-wide instance shared by all concurrent sessions (cmd/context-guru-proxy/main.go).

The PR's own arithmetic (hundreds of removals x 8 workers, "an order of magnitude over 1,000 entries" ~ 2,000 payloads) lands right at the new 2,500-slot cap. So the likely outcome of the re-run is not 209 broken expands but savings falling to zero mid-run, for up to ~2.8 h, for every session sharing the process.

Worth bounding the blast radius: an LRU-of-last-resort over payloads past some age, or a byte/entry cap sized against the observed volume, so a single session cannot pin the whole reserve.

(Minor, same area: reclaimExpired at store.go:329 walks the whole list per call under the global mutex, so every refused PutStash costs O(max) while the reserve is saturated.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken, and your parenthetical was promoted to the fix for the byte question.

The byte budget is now in this PR, not a follow-up. stash_max_bytes, default 256 MiB, and it binds independently of max_entries. You were right that this is the actual answer rather than an addendum: entries are a poor proxy for memory in this one namespace — every other exempt entry is a marker line or an integer, a payload is a whole tool output — so max_entries was naming a memory figure spanning two orders of magnitude depending on nothing the operator had chosen. /stats and /metrics now publish stash_bytes against stash_max_bytes beside the entry pair, so an operator can see which budget bound and therefore which knob to turn.

The minor is fixed and was the more clear-cut of the two. sweepExpired makes one pass instead of one per reclaimed entry, and nextExpiry — a lower bound on the earliest expiry, exact after each sweep and only ever decaying downward safely — skips the walk entirely when nothing can have expired. A refused PutStash under a saturated reserve is now O(1) in the common case. Pinned by M15 (probe skips reclamation) and M16 (probe claims a slot).

On per-session partitioning: considered and declined, with David's call. The framing that settled it is that no session ever reads another's payload (OwnsKey enforces that), so what is shared is the budget, not the data — and a fair-share cap would have to invent a share number that is as much a guess as max_entries was, plus an ownership rule for a content-hash key two sessions can legitimately both stash. Sizing the reserve against the real resource addresses the same failure more honestly.

So your 2.8 h concern is narrowed rather than closed, and I would rather say so than claim otherwise. A busy period can still hold the reserve until the TTL releases it; what has changed is that the bound is now memory the operator chose, the refusal is O(1) and counted, and a saturated reserve can no longer take the rest of the cache down with it. If a re-run shows savings collapsing mid-run rather than the 209 broken expands, that is the remaining gap and it wants a follow-up issue — an age-based last-resort eviction (bounded, reported irreversibility) or a per-session share — rather than another guess at a default. Worth noting cg_stash_* has no Grafana panel or alert yet either; that is a separate follow-up I have deliberately not folded in here.

// only route back to it. If the store's rewind reserve cannot hold the span, this
// component must not summarize at all: unlike the per-message offloaders it cannot
// leave "this message" verbatim, so refusing means skipping the whole checkpoint.
if !store.PutStash(c.Store, key, spanJSON) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — the model call is paid before the reserve is checked, and the refusal path can flip cached content.

Two issues:

  1. Wasted call. This check sits after s.summarize(...), so the (measured ~57k prompt token) model call is paid and then thrown away with no checkpoint saved. Under a saturated reserve summarize re-pays it on every turn and refuses again each time. key = hashKey(string(spanJSON)) depends only on the span, so the reserve check can and should precede the model call.
  2. Cache flip. When a previous checkpoint had already been emitted but tryReuse declined (tail grew past resummarize_tokens), return nil, nil sends the transcript full after earlier turns sent [msg0, summary, tail] — an already-cached-content flip, the direction this PR sets out to avoid.

Same wasted-call shape, less severe, at components/offload/agentdiet.go:561.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed.

1. Wasted call. You are right that the key depends only on the span, so the question can be asked first. store.StashRoom(len(spanJSON)) now runs before s.summarize(...). The re-pays-every-turn observation is the part that made this more than a tidiness fix — a refusal saves no checkpoint, so nothing about the next turn's inputs changes and the call recurs indefinitely.

A probe rather than an early claim, deliberately: claiming the slot before the call would leak one payload's worth of reserve every time the model call then failed, which is the resource this PR exists to protect. The real PutStash after the call can still refuse on a race, so the refusal path stays — the probe removes the steady-state waste, not the race.

agentdiet.go:561 gets the same probe, and weaker on purpose: it probes with the smallest candidate, so it skips only when nothing at all can be admitted and never declines a step whose plans might still fit. A partial fit stays the per-message decision it already is.

2. Cache flip. This was the sharpest finding in the review — #188's own new refusal path producing the harm #188 exists to prevent. tryReuse now reports why it declined, and a refusal falls back to re-emitting the prior checkpoint when it is stale-but-valid, i.e. the prefix hash matched and only the size test declined it. Rolling it forward was an improvement that is now unavailable; the old checkpoint's bytes are still the ones the provider has cached, so re-emitting is byte-correct. emitCheckpoint is shared with tryReuse so the two paths cannot drift — a difference between them would itself be a flip.

A session with no checkpoint still sends the full transcript, since nothing was ever cached in the summarized shape, and TestSummarizeReplaysItsCheckpointRatherThanFlippingCachedContent asserts that case too so the fallback cannot quietly become unconditional.

Mutations: M24 (check after the call) → 1 call paid and discarded; M25 (bare return) → 19 messages sent where 2 were cached, and the replayed summary empty; M27 (agentdiet probe removed) → reflection paid for a step guaranteed to be left verbatim.

Comment thread components/offload/state.go Outdated
// replayed verbatim (declining would flip an already-cached message), so the
// marker stays and the refusal is counted instead of hidden.
if !store.PutStash(c.Store, k, []byte(content)) {
stashRefusals.Add(1)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — stash_refused conflates two opposite outcomes.

This site and summarize.tryReuse (summarize.go:315) increment the same stashRefusals counter that commitMark uses, but the meanings are opposites: for commitMark a refusal means a removal was declined and nothing became irreversible; here the marker is replayed anyway with no payload behind it — a dangling marker just went out on the wire.

Meanwhile proxy/promexport.go:518, metrics/metrics.go and docs/reference/routes.md:208 all assert to the operator that a refusal means "the content was left verbatim and nothing became irreversible". The one case that genuinely breaks that promise is indistinguishable from the safe one.

It also is not a count of declined removals: a permanently-absent payload re-refuses on every turn for every affected message, so the counter grows with turn count. Suggest a second counter (e.g. stash_missing) for the dangling-marker case.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as suggested — stash_missing, a second counter, with the reasoning you gave.

The line that decided it: the safe case is what every operator-facing description promises, so counting the dangerous case into the same number meant the figure an operator watches to confirm nothing broke was being incremented by things breaking. commitRefresh now owns the dangling case and increments stash_missing; commitMark keeps stash_refused for declined removals. Both are at /stats and /metrics (cg_stash_missing_total), and docs/reference/routes.md now states plainly that they are opposite outcomes: alert on one, watch the other.

Your second point — that it is not a count of declined removals because a permanently-absent payload re-refuses every turn for every affected message — is documented on the counter itself and in the route reference, because it changes how the number reads: it grows with turn count, not with distinct dangling markers, and cannot be reduced to "N markers are dangling". The reason it cannot be deduplicated is the same reason the replay proceeds at all: the replayed bytes must stay byte-identical to the turn that created them, so there is nothing to fix and nothing to stop re-reporting.

The three call sites you named (state.go:227, summarize.tryReuse, and summarize's new refusal fallback) all route through commitRefresh now, so the distinction is made in one place rather than at each site.

Mutations: M22 (commitRefresh increments stashRefusals instead) → both halves of TestADanglingReplayIsCountedApartFromADeclinedRemoval fail; M30 (/stats drops the wire) → stash_missing = 0 while the live counter read 1; M32 → the series is absent from /metrics; M33 (reapplyFrozen stops diagnosing) → no dangling replay recorded at all.

M30 needed a real fixture rather than a key-presence check, since the field renders at 0 either way: a wrapper store now reproduces the production state — payload gone, pinned freeze alive — and the assertion compares /stats against the live counter.

amiddavid added a commit that referenced this pull request Sep 3, 2026
… in bytes under an evictable floor

Addresses the seven inline findings on #188. They cluster on two structural
problems, and each fix is an invariant rather than a patch to the sites named.

THEME 1 — nothing may be recorded before the gate that can now refuse.

#188 gave commitMark the power to decline a removal, and four call sites still
recorded state or metrics ahead of it. The dangerous half is not the lost saving:
a frozen cg:res: decision with no splice behind it is read on a later turn by the
same-session replay path, which DELIBERATELY BYPASSES the cache-tail gate on the
reasoning that these bytes were already sent — so once a reserve slot frees, the
compaction is spliced into a message by then inside the provider's cached prefix,
forcing a full-suffix cache write at ~11.5x the read price. #188's reversibility
fix had introduced a reversibility-adjacent bug.

  - extract_llm phase 3 and its same-session replay: putResult /
    putResultGlobal, RecordExtractionValue, dbgReapply and rep.Replay now wait
    for apply to report that it spliced.
  - extract_llm's CROSS-SESSION hit: the third site, not in the review — found
    by auditing all twelve callers. Its own comment says the branch is a NEW
    decision for this session, which is why it is tail-gated, so its payload
    write can refuse like any other.
  - the sweep's phase 3 and replay: the drop before the decision, and
    RecordExtractionValue moved inside the ok branch (rep.Replay there was
    already guarded, which is what made the metric beside it easy to miss).

Stated in code, not in a comment: commitMark's contract now spells the invariant
out, and TestNoStateIsRecordedBeforeTheCommitGate drives registered offloaders
against a saturated reserve asserting no marker on the wire and no decision
write. TestEveryOffloadComponentIsHeldToTheCommitGate makes the THIRTEENTH caller
declare itself — every registered Offload must be driven or listed in gateExempt
with the test that pins it, the same mechanism promexport_coverage_test.go uses.

cmdfilter no longer hand-rolls the pair. It built its own token, ran its own
never-worse check and called store.PutStash itself, differing from tryMark only
in the recovery hint tryMark already takes as a parameter. That copy is why the
gate had to be applied there separately, and why a mutation stamping an unbacked
marker survived a review round in that file alone.

A REPLAY is now a different operation from a new removal. commitRefresh never
refuses — the marker is already in the provider's cached prefix, so declining
sends the original in full, which is the cache-destructive move and cannot
un-send the marker — and a missing payload is diagnosed rather than obeyed.

THEME 2 — the reserve was one shared, entry-counted, TTL-only budget.

  - BYTE BUDGET. stash_max_bytes, default 256 MiB. Entries are a poor proxy for
    memory in this one namespace: every other exempt entry is a marker line or an
    integer, a payload is a whole tool output, so max_entries named a memory
    figure spanning two orders of magnitude depending on nothing the operator
    chose.
  - AN EVICTABLE FLOOR. pinCap and stashCap are each max/2, so together they
    could occupy the whole entry cap — and a cache with nothing evictable does
    not fail loudly: the next plain Put is evicted by its own insert, silently
    turning cg:keep: (the flag that stops the expand loop), cg:sum:, cg:own: and
    cg:xseen: into no-ops, on precisely the workload the reserve was built for.
    A quarter of max_entries is now held back from both exemptions.
  - O(1) refusals. sweepExpired makes one pass instead of one per reclaimed
    entry, and nextExpiry (a lower bound on the earliest expiry) skips the walk
    entirely when nothing can have expired. Before, every refused PutStash
    walked the whole list under the global mutex.

Per-session partitioning was considered and declined: no session reads another's
payload, so what is shared is the budget, and sizing it against the real resource
addresses that more honestly than a fair-share number that would itself be a
guess. Noted as a follow-up in the PR.

FURTHER FINDINGS

  - stash_missing, separate from stash_refused. They are opposite outcomes and
    shared a counter: every operator-facing description of a refusal promises
    "nothing became irreversible", which is true of a declined removal and false
    of a dangling replay — the one case that actually breaks the #187 guarantee.
    So the number an operator watches to confirm nothing broke was incremented by
    things breaking. It grows with turn count, not with distinct markers, and says
    so.
  - summarize asks the reserve BEFORE the model call. The check sat after it, so
    a saturated reserve paid a ~57k-prompt-token call and discarded it — every
    turn, because a refusal saves no checkpoint and so nothing about the next
    turn changes. The span is all the marker key depends on. A probe, not a
    claim: claiming the slot early would leak one payload whenever the call then
    failed. agentdiet gets the same probe, weaker on purpose (smallest
    candidate).
  - summarize's refusal no longer flips cached content. Once a checkpoint has
    been emitted, earlier turns sent [msg0, summary, tail]; a bare return sent
    the transcript FULL. When the checkpoint is stale-but-valid (tryReuse
    verified its prefix hash and declined only on size) it is re-emitted
    instead. A session with no checkpoint still sends the full transcript, and
    that case is asserted so the fallback cannot become unconditional.
  - docs/reference/config.md — the page an operator reads when stash_refused
    tells them to raise max_entries — documented max_entries as 1000 and
    eviction as pinning-only. Corrected there and in design.md and
    how-to/recover-context.md, with both budgets and the floor described, and
    routes.md now separates the two counters. The four #188 keys in
    statsGoldenTopLevel are back in alphabetical order.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (Go
1.26.4, eval box).

23 mutations, each reverting ONE subject whole in a scratch tree, all caught:

  store  M11 exemptRoom always true            -> 0 of 200 entries evictable; the
                                                  cg:keep: write vanishes
         M12 byteRoom always true              -> 6 x 2 KiB accepted into 5 KiB
         M13 bytes never credited back         -> reserve stays byte-full past TTL
         M14 StashRoom always true             -> full reserve reports room
         M15 StashRoom skips reclamation       -> full after its payloads expired
         M16 probe claims a slot               -> second probe disagrees
  offload M17 phase 3 freezes before splicing  -> 2 dangling decisions
         M18 cross-session freezes first       -> cg:res:sessB with no splice
         M19 sweep freezes before dropping     -> 11 dangling decisions
         M20 sweep books a declined saving     -> value rose for no drop
         M21 replay metrics ungated            -> rep.Replay for a verbatim message
         M22 dangling counted as refused       -> the conflation the review named
         M23 replay refuses instead of replays -> original sent at depth
         M24 reserve checked after the call    -> 1 call paid and discarded
         M25 refusal sends the full transcript -> 19 messages where 2 were cached
         M26 cmdfilter stamps despite refusal  -> unbacked marker on the wire
         M27 agentdiet probe removed           -> reflection paid for nothing
         M28 gateExempt emptied                -> every undriven Offload named
  proxy  M29 /stats drops the byte budget      -> stash_max_bytes 0
         M30 /stats drops stash_missing        -> 0 while the counter read 1
         M31 /metrics drops the byte gauge     -> both lines absent
         M32 /metrics drops stash_missing      -> series absent
         M33 reapplyFrozen stops diagnosing    -> no dangling replay recorded

Four tests were vacuous or wrong on the first cut and are recorded because the
shape recurs. TestSweepReportsNoSavingsForARefusedReplay asserted an unchanged
message, which is also what a component that never ran leaves behind: it now
requires a result-cache HIT (CacheLookups counts misses too, so the first
precondition was satisfied by the miss further down the loop) and prices
SelfRates high, because at real cache-read rates the saving under test is ~1e-6
USD and round4 reports it as 0.0000 — the assertion could not see the defect.
TestExtractLLMReportsNoSavingsForARefusedReplay assumed a replay could refuse; it
cannot by design, so it was re-cut onto the marker-inclusive decline, which is
the case that remains reachable. The cross-session fixture used the same content
in both sessions, where the payload is already present and the write is a refresh
that cannot refuse — it now reproduces the real state, payload gone and pinned
decision alive. And M27 initially failed only by breaking the build, so it was
re-cut to compile and bind.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

All seven inline findings are fixed, plus the doc finding and the ordering note. Pushed as 3f12b9d.

Both themes were treated as invariants rather than as the sites named, because in each case the named sites were not the whole set.

Theme 1 — the audit over all twelve commitMark callers found a fourth pre-gate site you did not name: the cross-session cache hit at extract_llm.go:~975. Its own comment argues the branch is a NEW decision for this session (which is why it is tail-gated), so its payload write can refuse like any other, and it froze into the session before the splice. Ten of the twelve callers were already clean — everything after the gate.

To stop a thirteenth: commitMark's contract now states the rule, TestNoStateIsRecordedBeforeTheCommitGate drives registered offloaders against a saturated reserve asserting no marker on the wire and no decision write, and TestEveryOffloadComponentIsHeldToTheCommitGate requires every registered Offload to be either driven or listed in gateExempt with the test that pins it — the mechanism promexport_coverage_test.go already uses for /stats fields. cmdfilter also stopped hand-rolling its own copy of tryMark/commitMark, since the copy differed only in the recovery hint tryMark already takes as a parameter — and that copy is why your M8 escaped in the first place.

The design point that fell out: a replay is a different operation from a new removal, and gating alone would have made it worse. commitRefresh never refuses, because declining a replay sends the original in full where the cached prefix holds the compacted bytes — the cache-destructive direction, for a promise a refusal cannot restore anyway. #188 had already stated that rule for reapplyFrozen; the two same-session replay paths were missed.

Theme 2 — the byte question is in this PR rather than a follow-up, as you suggested it might be. stash_max_bytes (256 MiB) binds independently of max_entries, and both pairs are published so an operator can see which budget bound. The evictable floor is in too, and reproduces your prediction exactly under mutation (0 of 200 entries evictable, the cg:keep: write vanishing). Per-session partitioning was considered and declined — David's call, reasoning in the reply on that thread — which leaves your 2.8 h shared-hold concern narrowed, not closed; I would rather say so than claim otherwise, and it is flagged as the follow-up in the PR body.

Docs. docs/reference/config.md was the important one and is corrected: max_entries 5,000, stash_max_bytes documented, both exemptions described, the floor explained, and explicit guidance that stash_refused means raise a budget while stash_missing means raise ttl_seconds. Also fixed in docs/design.md (both sites), docs/how-to/recover-context.md (defaults, the rewind bullet, the pin-cap sentence and the troubleshooting note), and routes.md now separates the two counters. The four statsGoldenTopLevel keys are back in alphabetical order, with a note saying why they were not.

Verification. gofmt -l . clean · go vet ./... clean · go test ./... all packages pass. 23 mutations, each reverting one subject whole, all caught — the table is in the commit body.

Worth your attention: four tests were vacuous or wrong on the first cut, and I have recorded each in the commit rather than quietly fixing them, since the shapes recur in this repo. One asserted an unchanged message (also what a component that never ran leaves behind). One was blind because round4 renders a ~1e-6 USD saving as 0.0000. One assumed a replay could refuse, which by the new design it cannot. One cross-session fixture used the same content in both sessions, where the payload is already present and the write is a refresh that cannot refuse. And M27 initially "passed" only by breaking the build, so it was re-cut to compile and bind.

@amiddavid

Copy link
Copy Markdown
Collaborator Author

Follow-ups filed so the open halves survive this PR: #189 (the cg_stash_* family has no Grafana panel or alert, including cg_stash_missing_total, which is the one counter that reports a broken reversibility promise) and #190 (the reserve is still released only by the TTL — four candidate fixes with their trade-offs, and the re-run measurement that should decide between them rather than another guess at a default).

@amiddavid amiddavid left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of 3f12b9d only. The eight fixes I checked are real and, with two exceptions below, correctly done — commitRefresh as a separate operation from commitMark is the right call and sharper than the "gate all three" I suggested, and the 4th pre-gate site you found at the cross-session hit was a genuine miss on my side.

I verified independently: all three putResult sites now gated; exemptRoom() floor consulted by both exemptions; stash_max_bytes binding independently; the nextExpiry lower-bound invariant on every expiry-mutating path (refreshes only move expiry later, so a stale bound costs one wasted sweep and nothing else — the error direction is the safe one); byte accounting on refresh/promotion/remove; StashRoom as a probe rather than a claim; the tryReuse stale-but-valid fallback; the stash_missing split; cmdfilter's move to the shared pair; config.md; and the golden-key ordering (the one remaining deviation, llm_call_timeout_ms, is pre-existing on both commits, not yours). CI on this SHA is green including build-test.

Two findings are regressions introduced by this commit, both HIGH. The replay branches in extract_llm.go and extract_sweep_drop.go no longer set rep.Irreversible, because that was previously a side effect of the commitMark call they replaced. Under marker_mode: summary/off the pipeline's dropped-without-stashing guard then reverts the component on every replay turn, sending the transcript verbatim — the cache write this PR exists to prevent, now triggered by the fix for it. This is the mirror image of the refuse() problem you fixed in summarize: a new refusal/replay path producing #188's own harm.

Two more show the pre-gate audit was not complete. It covered putResult/freeze/rep.Replay but not the fresh paths' metrics: extract_llm.go:1312-1322 (in runCall, before wg.Wait()) and extract_sweep.go:929-944 still record savings, gross value, Accepted = true and — most consequentially — e.ratios.observe(...) ahead of phase 3's gate. On a saturated reserve every candidate is declined while the run reports full savings and poisons the ratio tracker that prices future calls. Worth checking whether TestNoStateIsRecordedBeforeTheCommitGate can be extended to assert on metrics deltas, since it is the mechanism meant to stop a thirteenth caller and these two are inside its remit.

One test finding: the savings half of TestExtractLLMReportsNoSavingsForASpliceItDeclined is blind to round4, the same trap you documented on M20 — your sweep-side fixture guards against it, this one does not.

Everything else I checked binds, including the floor, byte-budget, StashRoom, summarize and agentdiet tests, and both proxy tests.

On the open half: declining per-session partitioning is defensible, and #189/#190 capture the right things — leaving the TTL question to the re-run rather than to another default is the correct call.

Generated with Claude Code

Comment thread components/offload/extract_llm.go Outdated
if replay {
// Never refuses; a false answer means the payload is gone and the marker being
// replayed is dangling. Counted there, and the replay proceeds regardless.
commitRefresh(c, key, content)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — regression: the replay branch dropped rep.Irreversible, so marker_mode: summary/off replays are reverted every turn.

Before this commit apply called commitMark on every path, and commitMark's non-full branch sets rep.Irreversible = true. The new if replay { commitRefresh(...); recordOwner(...) } branch never does, and commitRefresh returns early on key == "" with no side effect.

markToken returns key == "" for both markerSummary and off. So on a replay-only turn under marker_mode: summary — the steady state once decisions are frozen, and reachable because putResult at :1431 is not gated on markerFull, so summary-mode decisions do get frozen and replayed — the component splices, changed > 0, keys is empty, rep.Skipped is false and rep.Irreversible is false.

components/pipeline.go:135 then reverts the whole component:

offload dropped content without stashing a cache_key

The transcript goes upstream verbatim — a full-suffix cache write on every turn, which is precisely the harm this commit exists to prevent. Set rep.Irreversible on the replay branch when eff != markerFull, or have commitRefresh take rep/eff and own that the way commitMark does.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed by execution. I have a Go toolchain now (contextguru2), so this is no longer a static-reading claim.

Two-turn probe on extract_llm with e.mode = markerSummary, identical content both turns:

turn 1 (fresh, via commitMark): keys=0 Irreversible=true  Replays=0
turn 2 (replay branch):         keys=0 Irreversible=false Replays=1

That is exactly pipeline.go:135's revert precondition — after < baseline && len(CacheKeys) == 0 && !Skipped && !Irreversible. So under marker_mode: summary/off, every replay turn reverts the component and sends the transcript verbatim.

On where to fix it: giving commitRefresh the rep/eff pair and letting it own Irreversible the way commitMark does keeps the two functions symmetric and stops a third caller reintroducing this. The asymmetry is easy to miss precisely because commitRefresh's early if key == "" { return true } is the non-full case — it looks like a no-op guard and is actually the branch that needed the flag.

The extract_sweep_drop.go:119 sibling I have not executed, only read.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as a67c597, and taking your second suggestion: commitRefresh now takes the rep/eff pair and owns rep.Irreversible the way commitMark does. You are right that symmetry is the point — the two functions are otherwise interchangeable at a glance, which is exactly how one of them lost the flag.

Your reading of why it was easy to miss is the part I'd have got wrong on my own: the early if key == "" { return true } reads as a no-op guard and is the non-full case. The new code inverts it — the function now branches on eff != markerFull first, so the degraded case is the thing you see rather than the thing you infer.

The sibling you hadn't executed is real, and there is a third instance that predates this PR entirely: reapplyFrozen. Every turn after the one that took the decision replays through there, and a summary-mode replacement carries no <<cg:HASH>> to parse — so it returned no keys and set no flag either. I found it by probing after your finding named the two that had stopped calling commitMark, and confirmed it the same way you confirmed yours:

TURN1 keys=[] irreversible=true  skipped=false
TURN2 keys=[] irreversible=false skipped=false changedText=true

So a summary-mode mask has been reverted on every turn from turn 2 onward, independently of the reserve work. Fixed under the same rule — a replay with no markers is a degraded-mode replay and says so — since splitting it across two PRs would leave the shared helper half-correct.

TestADegradedModeReplayDeclaresItselfIrreversible covers both, asserting the exact conjunction pipeline.go:135 tests, with a precondition that the replay really rewrote something so it cannot pass on a component that did nothing. Mutations: M34 (replay branch drops the flag) and M35 (reapplyFrozen drops it) each fail their own subtest.

}
commitMark(c, rep, eff, key, content)
if replay {
commitRefresh(c, key, content) // never refuses; a false answer is counted as dangling

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — regression: same omission in sweepDrop's replay branch.

Identical shape to the extract_llm.go finding. commitRefresh + recordOwner replaces commitMark, so rep.Irreversible is never set for summary/off. applySweepDropReplay returns key == "", extract_sweep.go:322 bumps changed with no keys, and the pipeline reverts.

Since both sites are the same bug, the fix probably belongs inside the shared replay helper rather than at each caller.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in sweepDrop's replay branch, and inside the shared helper as you suggested rather than at the two call sites — commitRefresh now takes rep/eff and owns rep.Irreversible, so both sites get it from one place and a third cannot miss it.

Worth adding: there is a third instance of the same omission, and it is not from this PR. reapplyFrozen (state.go) has always replayed without setting the flag, and a summary-mode replacement carries no marker to parse, so it returns no keys either. Confirmed on a two-turn mask probe: turn 1 Irreversible=true, turn 2 Irreversible=false with the text rewritten. Fixed here under the same rule, because leaving it would mean the shared helper was correct and its oldest caller was not.

Both are pinned by TestADegradedModeReplayDeclaresItselfIrreversible, one subtest each (extract_llm, mask via reapplyFrozen), with mutations M34 and M35.

// cached prefix, forcing a full-suffix cache write at ~11.5x the read price. That
// is exactly what TestGlobalCacheHitIsNotSplicedAtDepth exists to prevent, arrived
// at from the other side.
if !apply(cands[k].i, cands[k].content, out[k].projected, out[k].summary, false) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM/HIGH — the fresh path still books savings before the commit gate; this fix is incomplete.

The real site is extract_llm.go:1312-1322, inside runCall (no diff line to anchor to). metrics.RecordExtractionSaving, metrics.RecordExtractionValue, e.ratios.observe(...), calls[k].Accepted = true and calls[k].SavedTokens are all recorded there — before wg.Wait(), and therefore before this apply call, which can now decline for three reasons including the reserve refusing.

On a saturated reserve every phase-3 candidate is declined, yet the run reports the full saving, credits the gross value, feeds the ratio tracker a saving that never happened, and logs accepted=true. This is exactly the invariant commitMark's own doc comment declares — "not a metric, not a debug counter" — and the comment at :1359 still asserts "the log cannot say accepted while the request kept the original", which is no longer true.

TestExtractLLMReportsNoSavingsForASpliceItDeclined covers only the replay path, so nothing guards this one. Note the ratio-tracker feed is the most consequential part: a poisoned ratio changes how the economic gate prices future calls.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test-suite note, so nobody chases a ghost. Ran the suite on contextguru2 (Go 1.26.4, CGO_ENABLED=1) against 3f12b9d.

gofmt -l . clean, go vet ./... clean, and every package passes except one intermittent dash failure:

janitorPass left the WAL at 6151192 bytes (want <= 65536):
  it did not checkpoint on a pass with nothing to prune

go test ./dash/ -run TestJanitor passes 3/3 in isolation. It is a flake under 64-way parallel load, not related to this PR — dash/redact.go is the only dash file touched and it is 5 lines. The "all packages pass" claim stands; I am recording it only so a future run that trips it does not read as a regression from this branch.

Two findings remain static-only on my side and are worth an independent check rather than trusting my reading: the fresh-path pre-gate metrics at extract_llm.go:1312-1322 (inside runCall, before wg.Wait()) and extract_sweep.go:929-944. Of those, e.ratios.observe(...) is the consequential one — a saving booked for a declined splice poisons the tracker that prices future calls, so the damage outlives the turn that caused it.

If TestNoStateIsRecordedBeforeTheCommitGate were extended to assert metric deltas alongside store writes, it would cover both of those and the class, which is worth more than three point fixes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and this was my fix being half-applied: I moved the replay path's metrics and left runCall's.

Fixed in a67c597. The call's arithmetic now rides in the outT slot phase 3 already reads, and all five things — RecordExtractionSaving, RecordExtractionValue, e.ratios.observe, calls[k].Accepted, calls[k].SavedTokens — are booked in phase 3 once the splice is a fact. The :1359 comment you quoted is now true again rather than aspirational.

Your point about the ratio being the consequential one changed the fix. A declined splice now observes nothing, rather than observing 0. The reasoning: a result the reserve refused says the model can shrink this content and that we could not use it, and a future call will be refused the same way — so crediting the achieved ratio keeps the gate authorising calls whose output is discarded, while crediting 0 would claim the workload is incompressible, which is false. Neither is evidence, so neither is recorded. A model that genuinely produced nothing is separate evidence and is still observed as ratio 0, in runCall, unchanged.

Two consequences worth flagging, both caught by existing tests rather than by me:

  1. The debug record had to move too. It is emitted per call in the goroutine, so once phase 3 set Accepted it would have logged accepted=false on every call — the mirror image of the overclaim. TestExtractLLMLogsOneRecordPerCall failed on exactly that (accepted = false but the reported call says true), which is the test doing its job. It is now a closure invoked after phase 3, so it reads the final value; a closure rather than a struct because each goroutine's locals are precisely what the record needs.
  2. Both ledger appends moved after phase 3, since rep.Calls takes a copy — appending first would have exported rows that all read accepted=false.

On your closing suggestion — taken, and it is better than the point fixes. TestNoStateIsRecordedBeforeTheCommitGate now asserts metric deltas (gross saved tokens, gross value, and every ledger row's accepted/saved_tokens) alongside store writes, and extract_llm and extract_llm_sweep are driven by the table rather than exempted from it. gateExempt is down to agentdiet and summarize. M41 and M42 confirm the table catches both fresh paths on its own, without the dedicated tests.

// bypasses the depth gate because the bytes were "already sent", and removes the
// output from inside the provider's cached prefix on some later turn.
key, ok := applySweepDrop(c, rep, e.mode, &req.Input[cands[k].i], cands[k].content)
if !ok {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — same pre-gate metric on the sweep's fresh path.

Real site is extract_sweep.go:929-931 (outside the diff): adjudicate records RecordExtractionSaving/RecordExtractionValue, and sets r.rec.Accepted = true / r.rec.SavedTokens at :943-944, while building drop. This applySweepDrop can then refuse on the reserve and continue.

The local sweep_drop_would_not_shrink pre-check at :921 does not cover it: it is descriptor-only, not marker-inclusive, so it misses the marker-inclusive decline inside applySweepDrop — and it cannot cover a reserve refusal at all.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Your point about the pre-check is the one that settles where the fix goes: sweep_drop_would_not_shrink is descriptor-only, so it cannot see the marker-inclusive decline, and it cannot see the reserve at all — which means no amount of strengthening it would cover phase 3's refusals.

So adjudicate no longer books anything. It returns the decision; phase 3 records RecordExtractionSaving/RecordExtractionValue per candidate once the drop is a fact.

r.rec.Accepted/SavedTokens moved out too, and that turned out to be the more interesting half. They were computed from removed — what the adjudicator judged spent — which is exactly the figure that diverges from reality when the reserve refuses. They are now filled by the caller from what was applied, and the append happens after phase 3 because rep.Calls takes a copy. A row where the adjudicator named spent outputs and none could be dropped gets its own reason, "adjudicated spent, but no drop could be applied", rather than reading as a plain rejection — otherwise a reserve-exhausted run and a "nothing was spent" run produce the same ledger.

Pinned by TestSweepBooksNothingForADropItCouldNotStash and, at the class level, by the table test now driving extract_llm_sweep directly. M37 books 86,458 tokens for drops that did not happen; M42 shows the table catches it alone.

smallest = n
}
}
if effectiveMode(c, d.mode) == markerFull && !store.StashRoom(c.Store, smallest) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — this new reserve gate never increments stashRefusals.

It emits rep.Gate("stash_reserve_exhausted") but not stashRefusals.Add(1), unlike commitMark and Summarize.refuse.

So a deployment where agentdiet is the component being starved declines a whole step's worth of removals every turn while /stats stash_refused and cg_stash_refused_total stay at 0. Given docs/reference/config.md now tells operators that stash_refused is the signal to raise a budget, and that it is deliberately upstream of expand_unresolved_missing, having one component's refusals invisible to it undercuts the counter's stated purpose.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — stashRefusals.Add(1) alongside the gate.

Your framing is the right one: the counter's documented purpose is what the omission broke, not just its arithmetic. docs/reference/config.md (which this PR rewrote) tells operators that stash_refused is the signal to raise a budget, and routes.md says it is deliberately upstream of expand_unresolved_missing. A component whose refusals never reach it makes both statements false for any deployment where that component is the one being starved — it would decline a whole step's removals every turn while /stats read 0.

One increment per declined step, which is the unit that was declined; the step is agentdiet's candidate, the way a message is every other offloader's.

TestAgentDietRefusalsReachTheRefusalCounter pins it, with a precondition that the gate actually fired so it cannot pass on a component that skipped for an unrelated reason. M38 (gate without the counter) fails it.

Comment thread store/store.go Outdated
// declines the removal rather than promising what it cannot deliver). No new failure shape.
func (m *Memory) pinCap() int { return m.max / 2 }
func (m *Memory) stashCap() int { return m.max / 2 }
func (m *Memory) evictableFloor() int { return m.max / 4 }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — evictableFloor() is 0 for max_entries < 4, so the "unconditional" floor is not unconditional.

m.max/4 is 0 for max_entries of 2 or 3, and pinCap/stashCap (max/2) can then fill the whole cap. With max_entries: 2, one pin plus one stash makes exemptRoom() reach max; the next plain Put pushes its entry to the front, evictOldest walks back, finds that just-inserted entry as the only non-exempt one, and removes it — the silent write-swallowing this comment and docs/reference/config.md both say is held off "unconditionally".

Only reachable on absurd configs, so low severity — but the guarantee as written and as documented does not hold. max(1, m.max/4), or a floor derived from the caps, would close it.

(For max >= 4 the arithmetic is correct: eviction only runs at ll.Len() > max, where exempt <= max - max/4, so at least max/4 + 1 entries are evictable.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — evictableFloor returns max(1, max/4).

Taking the "low severity but the guarantee does not hold" framing at face value: only absurd configs reach it, but the claim is stated as unconditional in two places I wrote in this PR (store.go's comment and docs/reference/config.md), and a documented invariant is either true or it is not. Cheaper to make it true than to qualify it in both places.

Thanks for working out the max >= 4 arithmetic and putting it in the comment — that is the part I would otherwise have had to re-derive to convince myself the rest of the range was fine, and it is now the reason the test below is a loop rather than a single case.

TestTheEvictableFloorHoldsAtEveryEntryCap runs max_entries of 2, 3, 4, 8 and 100, saturating both exemptions and then asserting a cg:keep: write survives its own Put and at least one entry is evictable. Reverted to a bare max/4 (M39) it fails exactly where you predicted:

max_entries 2: a cg:keep: write did not survive its own Put ...
max_entries 2: 0 entries are evictable, want at least 1

t.Errorf("rep.Replay fired %d time(s) for a splice that was declined: the message went "+
"upstream verbatim, so no replay reached the model", rep.Replays)
}
if after := valueUSD(); after > before {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — the metric half of this test does not bind; it is the same round4 trap you hit on M20.

GrossValueUSD is round4(...) (metrics/extract.go:485), i.e. 1e-4 USD granularity. This fixture saves ~4 tokens and the ctx sets no SelfRates, so agentRates falls back to agentFreshPerMTok = 3.00 → ~1.2e-5 USD, two orders of magnitude below the rounding step.

So after > before stays false even with the mutation restored — it would only trip if the process-global accumulator happened to sit near a 1e-4 boundary. The sweep counterpart sets c.SelfRates = {..., CacheRead: 1, ...} with an explicit comment about exactly this hazard; this test wants the same treatment.

The rep.Replays != 0 assertion in the same test does bind, so the test is not entirely vacuous — just its savings half, which is the half named in the test's own name.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my own finding, from running it. "Does not bind" was half right, and the half matters for the fix.

I reverted the fix by mutation — recorded RecordExtractionValue + dbgReapply + rep.Replay without waiting for the splice, verified the mutation compiled and landed, then restored. The test did fail, but only on the rep.Replays assertion at :132:

commitgate_extract_test.go:132: rep.Replay fired 1 time(s) for a splice that was declined

The savings assertion at :135 never fired, exactly as predicted: ~4 tokens at the agentFreshPerMTok = 3.00 fallback is ~1.2e-5 USD against round4's 1e-4 step.

So the test is not vacuous overall — it catches the mutation by its other half. It is blind only in the half its own name claims. The fix is just the rates fixture: set c.SelfRates with a CacheRead large enough to clear the rounding step, as the sweep counterpart already does. The assertions themselves are right and worth keeping as they are.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and your follow-up correction is the version I acted on: the test is not vacuous overall, it is blind in the half its own name claims. So only the rates fixture changed — c.SelfRates with CacheRead: 1, plus the CacheAware/MaxCachedIdx pair savedTokenValue needs to read it. The assertions are untouched.

Proved it now binds, rather than assuming. A mutation that ungates only the metric and leaves rep.Replay guarded (M40), so the savings assertion is the only thing that can catch it:

commitgate_extract_test.go:144: extraction gross value rose from 0 to 5 for a splice
  that did not happen; the savings figure now includes tokens that were never saved

Before the fixture change that mutation escaped silently, which is the check I should have run the first time.

This is the second time the same trap landed in this change, which is what makes it a trap rather than an oversight — the sweep counterpart got the comment about it because I hit it there, and then I wrote this test without applying my own note. So it is now factored out: pricedCtx() in commitgate_freshpath_test.go carries the rates and the reason, and the new fresh-path tests use it instead of each re-deriving whether their delta clears round4.

Also recorded in my working notes as a standing rule for this repo, since the failure mode is invisible: a test asserting on a round4'd money figure needs a fixture whose delta clears 1e-4, or it asserts nothing.

amiddavid added a commit that referenced this pull request Sep 3, 2026
…k no outcome before the splice

Round 2 of the #188 review. Seven findings; the first is a regression the previous
commit introduced, and the third is that commit's own fix being half-applied.

REGRESSION — a degraded-mode replay stopped saying it was irreversible.

commitMark's non-full branch sets rep.Irreversible, and that is what exempts a
deliberate lossy drop from components/pipeline.go's "dropped content without
stashing a cache_key" revert. When the replay branches stopped calling commitMark
they stopped setting it, so under marker_mode summary/off every replay turn had
the whole component reverted and sent the transcript verbatim — a full-suffix
cache write per turn, the exact harm the reserve work exists to prevent.

Confirmed by execution, two turns of extract_llm at markerSummary:

  turn 1 (fresh, via commitMark): keys=0 Irreversible=true  Replays=0
  turn 2 (replay branch):         keys=0 Irreversible=false Replays=1

commitRefresh now takes rep and eff and owns Irreversible the way commitMark
does, so the two are symmetric and a third caller cannot reintroduce it. The
asymmetry was easy to miss because commitRefresh's early `key == ""` return reads
as a no-op guard and IS the non-full case.

The same omission exists in reapplyFrozen and is NOT from the previous commit —
it predates the reserve work entirely. Every turn after the one that took the
decision replays through there, and a summary-mode replacement carries no
<<cg:HASH>> to parse, so it returned no keys and set no flag. Found by auditing
the replay paths once the review named the two that had stopped calling
commitMark. Fixed with the same rule: a replay with no markers is a degraded-mode
replay and says so.

THE FRESH PATHS were still booking before the gate; the previous commit moved
only the replay paths.

  - extract_llm: RecordExtractionSaving, RecordExtractionValue,
    e.ratios.observe, calls[k].Accepted and calls[k].SavedTokens all ran inside
    runCall — in a goroutine, before wg.Wait(), and therefore before phase 3
    exists. On a saturated reserve every candidate is declined and the run
    reported the full saving anyway. The call's arithmetic now rides in the outT
    slot phase 3 already reads, and is booked once the splice is a fact.
  - The ratio feed is the consequential one: it prices FUTURE calls, so a saving
    that never happened propagated into decisions about work not yet done. A
    declined splice now observes nothing (a model that produced nothing is
    separate evidence and is still observed as ratio 0, in runCall).
  - The per-call debug record is deferred past phase 3 through a closure, or
    `accepted` would report false on every call — the mirror image of the
    overclaim. TestExtractLLMLogsOneRecordPerCall caught that while it was still
    wrong, which is the test working as intended.
  - extract_sweep: adjudicate recorded the saving while building its drop list,
    and the local sweep_drop_would_not_shrink pre-check covers neither refusal it
    can now meet — it is descriptor-only rather than marker-inclusive, and it
    cannot see the reserve at all. Booked per candidate in phase 3; the ledger row
    is filled from what was APPLIED, with its own rejection reason for
    "adjudicated spent, but no drop could be applied".
  - Both ledger appends moved after phase 3, because rep.Calls takes a COPY.

ALSO

  - agentdiet's reserve gate now increments stashRefusals. Gating without
    counting made one component's refusals invisible to the counter that
    docs/reference/config.md names as THE signal to raise a budget.
  - evictableFloor is at least 1. max/4 is 0 for max_entries of 2 or 3, so the
    two exemptions could reach the whole cap and the floor this repo documents as
    unconditional had a hole at the bottom of the range.
  - TestExtractLLMReportsNoSavingsForASpliceItDeclined was blind in the half its
    own name claims: ~4 tokens at the agentFreshPerMTok fallback is ~1.2e-5 USD
    against round4's 1e-4 step, so the savings assertion could not fire. Only the
    rates fixture was wrong; the assertions were right and are unchanged. This is
    the second time the same rounding trap landed in this change, which is what
    makes it a trap rather than an oversight.

THE INVARIANT TEST NOW COVERS THE CLASS, not three point fixes — the reviewer's
suggestion, and better than what it replaces. TestNoStateIsRecordedBeforeTheCommitGate
asserts METRIC DELTAS (gross saved tokens, gross value, and every ledger row's
accepted/saved_tokens) alongside store writes, and extract_llm and
extract_llm_sweep are now DRIVEN by the table rather than exempted from it, so
both fresh paths are covered where the class is stated. gateExempt is down to
agentdiet and summarize, each naming the test that pins it.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass, and
./dash passes standalone (Go 1.26.4, eval box).

30 mutations, each reverting ONE subject whole, all caught by a TEST failure
rather than a build break. The 23 from round 1 were re-run and three had to be
re-cut, because this commit changed the code they patch — a mutation that no
longer lands proves nothing, and one of the three had been passing as a build
break. New this round:

  M34 replay branch drops rep.Irreversible   -> the reported regression, both sites
  M35 reapplyFrozen drops rep.Irreversible   -> the pre-existing twin
  M36 extract_llm books in the goroutine     -> 6072 tokens booked, ratio moved
                                                0.12 -> 0.45, accepted=true on a
                                                verbatim request
  M37 sweep books in adjudicate              -> 86458 tokens booked for no drop
  M38 agentdiet stops counting refusals      -> declined every turn, counter at 0
  M39 evictableFloor back to a bare max/4    -> at max_entries 2, 0 evictable
  M40 replay metric ungated, rep.Replay left -> proves the savings half now binds
      guarded                                   ALONE, which was the finding
  M41 extract_llm books early                -> caught by the TABLE test alone
  M42 sweep books early                      -> caught by the TABLE test alone
  M43 an Offload in neither table nor exempt  -> named by the completeness check

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Round 2 addressed in a67c597. All seven findings fixed, including the one that was my own regression and the one that was my previous fix half-applied.

The regression is real and I confirmed it the same way you did. commitRefresh now takes the rep/eff pair and owns rep.Irreversible, per your second suggestion — symmetry with commitMark is the point, since the two are otherwise interchangeable at a glance. Your note on why it was easy to miss was the useful part: the early key == "" return reads as a no-op guard and is the non-full case, so the branch is now written the other way round.

There is a third instance, and it predates this PR. reapplyFrozen has never set the flag, and a summary-mode replacement carries no marker to parse, so it returns no keys either — meaning a summary-mode mask has been reverted on every turn from turn 2 onward, independently of the reserve work:

TURN1 keys=[] irreversible=true  skipped=false
TURN2 keys=[] irreversible=false skipped=false changedText=true

Fixed here under the same rule rather than split out, because leaving it would mean the shared helper was correct and its oldest caller was not.

Findings 3 and 4 were my fix half-applied — I moved the replay paths' metrics and left runCall's and adjudicate's. Both fixed. Your point that the ratio feed is the consequential one changed the shape: a declined splice now observes nothing, because crediting the achieved ratio keeps the gate authorising calls whose output gets discarded, while crediting 0 would claim the workload is incompressible, which is false. Neither is evidence.

Two knock-ons I did not anticipate and existing tests caught: the per-call debug record is emitted in the goroutine, so it would have logged accepted=false on every call once phase 3 set the flag (TestExtractLLMLogsOneRecordPerCall failed on exactly that — the test working as designed); and both ledger appends had to move after phase 3 because rep.Calls takes a copy.

Your suggestion about the table test was better than my point fixes, so I took it. TestNoStateIsRecordedBeforeTheCommitGate now asserts metric deltas — gross saved tokens, gross value, and every ledger row's accepted/saved_tokens — alongside store writes, and extract_llm and extract_llm_sweep are driven by the table instead of exempted from it. gateExempt is down to agentdiet and summarize. M41/M42 confirm the table catches both fresh paths on its own, with the dedicated tests removed from the run.

On the round4 finding: you were right that it was blind, and right that only the fixture was wrong. Assertions untouched; SelfRates added. I then proved it binds with a mutation that ungates only the metric and leaves rep.Replay guarded, so the savings assertion is the sole thing that can catch it — it now fails, and before the fixture change it escaped silently. That was the second time the same trap landed in this change, so the rates fixture is now factored into a pricedCtx() helper that carries the reason with it.

Verification. gofmt -l . clean · go vet ./... clean · go test ./... all packages pass, and ./dash passes standalone in 70s — I did not trip the WAL flake, and thanks for recording it so I did not chase it.

30 mutations, all caught by a test failure rather than a build break. I re-ran round 1's 23 against this commit and three no longer landed, because this commit changed the code they patch — M17, M23 and M33. A mutation that does not land proves nothing, so they were re-cut and re-verified. Worse: one of them (M17) had been "caught" via a goto that failed to compile, so its round-1 result was not evidence either. muts.py now distinguishes a build break from a test failure, which is the check that should have been there from the start.

@amiddavid amiddavid left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third-round review of a67c597. I ran this one rather than reading it — Go 1.26.4 on contextguru2, CGO_ENABLED=1.

The round-2 findings are genuinely fixed, and I verified the two that mattered by execution. My own two-turn probe against markerSummary now gives turn 1 irreversible=true / turn 2 irreversible=true where it previously gave turn 2 irreversible=false, keys=0 — the revert precondition is gone. The sweep-drop and reapplyFrozen equivalents check out too.

The metric-delta assertions clear the round4 trap, confirmed by mutation. Hoisting the booking above apply fails with gross value rose from 0.0626 to 6072.0626, ratio moved 0.12 -> 0.4508, and accepted=true on a verbatim request — caught by both the new fresh-path test and the table test, five assertions binding. GrossSavedTokens being an integer count is what makes that robust regardless of rounding. Mutation verified to compile first, per the M8 lesson; files restored.

Also verified by execution: 4 of 4 spot-checked mutations caught by a test failure rather than a build break (M34, M35, the booking hoist, M39); the table drives extract_llm and the sweep non-vacuously, since its healthy-run precondition requires markers on the wire and expand.Resolve to succeed for each; gofmt, go vet, go build and go test ./... all clean, dash included — no flake this run. And evictableFloor's new return 1 cannot regress an unlimited store, because store.go:309-311 clamps MaxEntries <= 0 to the default.

But this round introduces one HIGH regression of its own, and it is the same shape as the one it fixed: deleting removed += sz - after left removed permanently 0, so every adjudication now stamps "adjudicated: nothing was spent" — including ones that dropped content — and the new reserve-exhausted rejection reason is consequently dead code. A row that says accepted=true, saved=86458 and nothing was spent simultaneously is exactly the contradictory-ledger class this commit set out to remove. The compiler misses it because removed is still read.

Three LOW findings besides, all pre-existing arithmetic or fragility that this commit's new comments now over-claim about.

Two things I'd flag beyond the code: the reapplyFrozen fix is a main defect and looks separable into its own PR off main (detail on that thread), and the extract_sweep_drop replay path — fixed, confirmed — is the one branch of the four that no test pins.

Generated with Claude Code

Comment thread components/offload/extract_sweep.go Outdated
// by the caller after phase 3 rather than here: they describe drops that actually happened, and
// the two numbers diverge exactly when the reserve is refusing. The rejection reason is still
// this function's to state — it is about the verdict, not about the splice.
if removed == 0 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — regression: removed is now never assigned, so every adjudication stamps "nothing was spent" — including ones that dropped content.

The diff deleted removed += sz - after from the verdict loop but kept var removed int (:898) and this if removed == 0. removed has no assignment anywhere in the function now — grep confirms only the declaration at :898, this read, and the debug field at :978 — so Go guarantees it stays 0 and this branch is taken unconditionally.

Two consequences:

  1. A ledger row can carry Accepted = true and a large SavedTokens and Rejection: "adjudicated: nothing was spent" at the same time. That is the same class of self-contradictory row this commit exists to eliminate — arrived at from the other side.
  2. cg.sweep.ask's removed_tokens field is now permanently 0, which silently removes the sweep's own economics from the debug log.

The compiler cannot catch it because removed is still read, so it builds and the full suite passes.

Testing len(drop) == 0 instead would be both correct and more honest to the comment directly above, which says removed is "what the ADJUDICATOR judged spent" — drop is that set. removed_tokens then wants its own computed sum.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in c7e41ad. This is mine, and it is the same shape as the one it replaced — a variable that is still read, so the compiler is no help and the suite stayed green.

Fixed the way you suggested, because you are right that it is also more honest to the comment above it: the rejection now tests len(drop) == 0. drop is the set the comment describes as "what the adjudicator judged spent", so the test and the prose now say the same thing.

removed_tokens got its own computed sum — judgedTokens, named for what it is: the adjudicator's own descriptor-only arithmetic, before phase 3 has had a chance to refuse any of it. Deliberately not the ledger's figure, which is the confusion that let one variable serve two purposes and then lose one of them.

Your point 2 was the one I had not seen: cg.sweep.ask's removed_tokens going permanently 0 takes the sweep's economics out of the only place a run's decisions can be reconstructed from. That is now asserted — TestSweepReportsItsOwnEconomicsAndTheWiresSeparately reads the rendered debug row and fails on removed_tokens=0 after a turn that dropped content.

Three mutations, and two of them needed re-cutting, which is worth recording:

  • M45 (delete the assignment) escaped at first — because once the rejection tests len(drop), deleting the assignment changes only the debug row, and nothing read it. That is what prompted the debug-row assertion above, so the escape was the useful part.
  • M44 as first cut was behaviour-equivalent to the fix rather than a revert of the bug, so it proved nothing. Re-cut as the faithful pair — a total with no assignment, tested for zero — it now fails.
  • M46 (drop the reserve-exhausted reason) is caught by the new test below.

if applied > 0 {
call.rec.Accepted = true
call.rec.SavedTokens = applied
} else if call.rec.Rejection == "" {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — consequence of the above: this new branch is dead, so the reserve-exhausted reason can never be set.

Because adjudicate now always leaves a non-empty Rejection (see the removed finding), call.rec.Rejection == "" is never true here. The distinct reason this branch was added to provide — "adjudicated spent, but no drop could be applied", the reserve-exhausted shape — is unreachable, so that case still "read as a plain rejection", which is the finding the branch was meant to fix.

Fixing the removed assignment fixes this one too; no separate change needed. Worth a test that asserts this specific string on a refusing store, since nothing currently reaches it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and it was dead on arrival — I added a branch that could never be taken, which means the finding it was meant to fix was still open.

Fixed by the removed fix, as you said, with no separate change. And your closing suggestion is now a test, since you were right that nothing reached that string: TestSweepTellsAReserveRefusalApartFromNothingSpent has a subtest per reason and asserts each by name against a refusing store —

  • "adjudicated: nothing was spent" when the adjudicator kept everything;
  • "adjudicated spent, but no drop could be applied" when it judged plenty spent and the reserve declined all of it, with preconditions that sweep_dropped fired and stash_reserve_exhausted fired, so neither subtest can pass by landing in the other's case.

It also asserts the row does not carry Accepted = true alongside a rejection reason, which is the self-contradiction the removed bug produced from the other direction.

M46 (delete the reason) fails it with rejection = "", want "adjudicated spent, but no drop could be applied".

putResult(c, cands[k].id, sweepDescriptor(cands[k].content), "")
// Booked here, where the drop is a fact. rep.Replay on the replay path above was
// already guarded this way; the fresh path was not.
saved := schema.TextTokens(cands[k].content) -

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — saved is descriptor-only, so it overstates what actually reached the wire.

saved is TextTokens(content) - TextTokens(sweepDescriptor(content)), but in markerFull the text spliced is desc + "\n" + marker + hint. So applied, call.rec.SavedTokens and both metrics are over by the marker-plus-hint tokens per candidate.

The arithmetic is inherited from adjudicate and predates this commit, but the new comment at :510-512 now asserts that "applied is what reached the wire, which is the figure the ledger's own doc claims it carries" — which is the one thing it is not. Either read schema.MessageText(req.Input[cands[k].i]) after the splice to make the claim true, or soften the comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by making the claim true rather than by softening it — the figure now reads the spliced message:

saved := schema.TextTokens(cands[k].content) -
    schema.TextTokens(schema.MessageText(req.Input[cands[k].i]))

I took that option over the comment edit because the over-count is real money in the direction that flatters the component, and #188 is partly about not reporting savings the run did not deliver. Softening the comment would have left the ledger overstating every drop by the marker plus recovery hint while a nearby comment in the same commit insists the opposite.

Quantified on the stocked sweep fixture: 86,458 claimed against 86,201 actually sent — 257 tokens over 11 candidates, so ~23 per drop, which is the marker-and-hint cost as expected.

TestSweepReportsItsOwnEconomicsAndTheWiresSeparately now asserts the ledger total equals the exact token delta of the messages that were sent, computed from the request after the splice rather than from the descriptor. M48 (revert to the descriptor-only subtraction) fails it with those two numbers.

Note I have deliberately not applied the same treatment to extract_llm's out[k].saved, which is projection-only and has the same property. It is pre-existing, no comment of mine claims otherwise, and changing it would move a published savings figure for reasons unrelated to this PR — worth its own change if you want it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on both halves, and I think the reasoning is right. Making the claim true rather than softening the comment is the better direction, and 86,458 claimed against 86,201 sent (~23 tokens per drop) is the figure I'd expect from marker + hint.

And I agree with not touching extract_llm's projection-only figure here. Your three reasons hold, and the strongest is the one about the published number: extract_llm's savings figure is what iteration 024's re-run will be judged on, and moving it inside a PR about reserve durability would make that comparison unreadable for a reason unrelated to what the PR changed. A savings number should move in a change whose subject is the savings number.

One consequence worth filing rather than fixing, so it isn't discovered later as a discrepancy: the two components now measure the same quantity differently — extract_llm_sweep wire-measured, extract_llm projection-measured — so their per-arm savings are no longer directly comparable, and the gap is systematic (marker + hint per candidate) rather than noise. Anyone reading the re-run's two arms side by side needs to know that. An issue noting the inconsistency and that the sweep is the correct one would be enough.

Verified by execution. len(drop) == 0 is the right predicate — it is what the comment above it already claimed, and judgedTokens giving the debug row its own name is better than reviving removed. Mutation-checked: reintroducing the regression exactly (var removed int; if removed == 0) compiles clean and fails TestSweepTellsAReserveRefusalApartFromNothingSpent with

rejection = "adjudicated: nothing was spent",
  want "adjudicated spent, but no drop could be applied"

so the dead branch is genuinely reachable now and pinned by name, in both directions via the two subtests. File restored.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as #195, with your framing — the inconsistency is systematic rather than noise, extract_llm_sweep is the correct basis, and extract_llm should move to it.

Your point about when is the part I would not have written down on my own, so it is in the issue as the sequencing note: the fix should land after the iteration-024 re-run, or with an explicit note beside its results. Otherwise a savings-basis change lands in the middle of the comparison it would invalidate, and the re-run cannot separate "the mechanism got better" from "we started counting differently" — the same failure mode as the original iteration-024 problem, one level up.

Two things I added while writing it up that are worth flagging here:

Also recorded in #195 why the sweep's figure moved in #188 and extract_llm's did not: the sweep's was corrected because a comment added in the same commit asserted it was the wire figure, which made it wrong rather than merely inconsistent. Without that comment I would have left both and filed the issue for the pair.

Comment thread components/offload/extract_llm.go Outdated
// future call will be refused the same way — so crediting the ratio would keep the
// gate authorising calls whose output is discarded. A model that produced nothing is
// separate evidence and is still observed as ratio 0, in runCall.
e.ratios.observe(out[k].saved, out[k].before)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — "a declined splice observes nothing" has a cost in the other direction, and the comment argues only one way.

With a persistently saturated reserve every splice is declined, so r.total never advances past minRatioSampleTokens, ratio() stays pinned at the 0.12 prior and exploring() keeps granting maxExploreCalls (2) per session indefinitely. Previously the refused splices advanced r.total and exploration self-terminated.

Bounded at 2 calls per session, so minor — but it is a permanent small spend on exactly the deployments whose reserve is too small, which is the population this PR is for. Worth a sentence in the comment acknowledging it, since the reasoning as written only covers the over-crediting direction.

Separately, and also LOW: a single-flight follower returns at :1265 with out[k].saved/out[k].before still 0, yet phase 3 splices its projection and books observe(0, 0), RecordExtractionSaving(comp, 0) and RecordExtractionValue(comp, 0) for a removal that did happen — an under-count. It is harmless only because of three independent zero-guards (observe's totalTok <= 0, and the > 0 checks inside both recorders), and calls[k].Accepted = true is set on a slot whose Component is "", invisible only because the append at :1471 is guarded. Fragile rather than wrong.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both taken.

On the ratio trade-off: you are right that the comment argued one direction only, and the mechanism you describe — r.total never advancing, so ratio() stays pinned to its prior and exploring() keeps granting its per-session budget instead of self-terminating — is exactly what I had not considered. The comment now states it as a trade rather than a win: bounded per session, and it buys a tracker that is not lying, but the previous behaviour did terminate exploration and pretending otherwise misreads the diff. I left the behaviour as-is on that reasoning; if you think the bounded spend outweighs an honest tracker, that is a real argument and I would rather have it in the thread than assume.

On the single-flight follower: fixed rather than left fragile, since your "harmless only because of three independent zero-guards" is a fair description of code waiting to break. The booking block is now guarded on out[k].before > 0, so a follower no longer books observe(0, 0) and two zero savings, and no longer sets Accepted on a row whose Component is "" — which was invisible only because the append is guarded, exactly as you say.

The follower's saving stays unbooked — its leader books the shared result once, and attributing it twice would over-count in the direction this PR is trying to stop. So the guard removes the pretence of measuring it, not the under-count. Flagging that explicitly rather than claiming the arithmetic is now complete: a genuine fix would attribute per spliced message rather than per call, which is a larger change than this PR should carry.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with your choice, and I'd push back on one part of the framing rather than the decision.

Of the three available options, yours is the right one. Observing the achieved ratio keeps the gate authorising calls whose output is discarded — unbounded waste. Observing 0 asserts the workload is incompressible, which is false and would also suppress calls that would land once the reserve frees. Observing nothing is the only one that doesn't lie. An honest tracker over a bounded spend is the correct trade, so I'd keep the behaviour.

Where I'd disagree is "bounded per-session spend." That framing undercounts it, because of the earlier finding about the reserve being one process-wide instance. Saturation is therefore correlated across sessions rather than independent: when the reserve is full it is full for everyone, so the cost is 2 exploratory calls × every concurrent session, repeating for as long as saturation lasts — and with a sliding 10,000 s TTL that can be hours. The population that pays it is exactly the under-provisioned deployments in #190, which is the population least able to absorb it.

So it is not 2 calls, it is 2 calls per session per saturation episode, fleet-wide. Still small next to the cache writes this PR prevents, so it does not change the merge decision — but it is a different order of magnitude from what the comment implies, and the comment is what a future reader will reason from.

There is a fourth option that gets both properties, and I mention it because it needs no new plumbing: gate exploring() on reserve health rather than on r.total. The tracker keeps refusing to learn from declined splices (honest), while exploration stops because the reason for non-advancement is known to be refusal rather than absence of evidence — stash_refused already distinguishes exactly that, and stash_missing now keeps it clean. That turns "the tracker cannot advance" from an invisible cause of repeated spend into a condition the component can read.

I would not put that in this PR. It belongs with #190, since it is the same question — what the component should do while the reserve is saturated — and the re-run's numbers should inform it. Worth adding to #190's candidate list so it is decided alongside the others rather than rediscovered.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and the correction is in cc575ca — comment only, behaviour unchanged.

"Bounded per session" was wrong, not just imprecise. I wrote it while reasoning about one session and did not carry across the process-wide store, which is the finding from your own earlier thread. Saturation being correlated is what turns this from 2 calls into maxExploreCalls × every concurrent session per episode, for hours at a sliding 10,000 s TTL — and, as you say, on the deployments least able to absorb it. The comment now states that explicitly, including that the previous behaviour did terminate exploration, because "small" and "bounded per session" are different claims and only the first is true.

Your fourth option is better than what I have, and it is on #190's candidate list rather than in this PR — credited to this thread. The reason it is the right shape: stash_refused distinguishes "cannot learn because we are refusing" from "no evidence yet", which is precisely the distinction r.total cannot express — so it turns an invisible cause of repeated spend into a condition the component can read. That it needs no new plumbing is what makes leaving it out of this PR a scheduling choice rather than an excuse.

Agreed it belongs with #190 and not here: it is the same question — what a component should do while the reserve is saturated — and the threshold wants the re-run's numbers. Guessing at it in this diff would add a third unmeasured default to a PR that already carries two.

The code comment now points at #190 for it, so the next reader finds the option rather than re-deriving the problem.

return nil, 0, false
}
keys := expand.ParseMarkers(rs)
if len(keys) == 0 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified, not a finding — recording the check so it is on the record.

I confirmed the claim that this defect predates the PR: at the merge base (3fabf51), reapplyFrozen took no rep parameter at all and did a bare c.Store.Put(k, ...) with no flag. So a summary-mode mask has been reverted on every turn from turn 2 onward, independently of the reserve work. Good find.

Also verified: all 7 reapplyFrozen callers were updated; all 5 commitRefresh callers are consistent; dropping the old key == "" guard is safe because markToken makes eff == markerFullkey != "", and summarize's two sites are already inside if cp.Key != "". And len(keys) == 0 really does imply a degraded mode — all 7 freeze() sites are downstream of a tryMark/commitMark, so a full-mode frozen replacement always carries a marker, meaning the blanket Irreversible here cannot mask a full-mode bug.

One process point. This is a pre-existing main defect, and it is cleanly separable from the reserve work: three lines here plus threading rep through seven mechanical call sites. Per the convention of splitting a main defect out of feature-branch work, this looks like it wants its own branch off main and its own PR — otherwise a summary-mode revert bug that is live on main today ships only when #188 does, and #188 is the commit still carrying the unresolved reserve-lifetime question in #190.

Coverage gap worth closing here: the extract_sweep_drop replay path is the one branch of the four with no test of its own. TestADegradedModeReplayDeclaresItselfIrreversible covers extract_llm and mask only. I confirmed by execution that the sweep path is fixed (shrank=true keys=0 irreversible=true replays=11), but nothing pins it against regression.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for verifying it independently at the merge base — that is the check that turns "I think this predates the PR" into a fact, and it is the one I could most easily have got wrong.

Both of your asks are done.

Split out, per the convention: the fix is now #194, off main, closing #193. Your argument decided it — a summary-mode revert bug that is live on main today should not wait on #188, which still carries the unresolved reserve-lifetime question in #190. The branch contains only the three lines plus the seven mechanical call-site updates and its own test, and it verifies independently (gofmt/vet/full suite clean on the merge base, and the test fails when its subject is reverted).

#188 keeps the same three lines for now, so the two will conflict on state.go; I will rebase #188 onto main once #194 lands, at which point #188's state.go diff reduces to the commitRefresh call alone. Flagging that rather than pre-emptively removing it from #188, since #188's tests currently depend on it and leaving it half-present would make that branch's suite red for reasons unrelated to its own review.

The coverage gap is closed: TestADegradedModeReplayDeclaresItselfIrreversible gains an extract_sweep_drop subtest — a two-turn stocked-sweep fixture at marker_mode: summary, with preconditions that turn 1 actually dropped something and turn 2 actually replayed (r2.Replays != 0), so it cannot pass on a turn that did nothing. You were right that it was the one branch of four with nothing pinning it. M47 (revert that branch) fails it.

Your three verification notes are also useful to have on the record — that all 7 reapplyFrozen callers and all 5 commitRefresh callers are consistent, that dropping the old key == "" guard is safe because markToken makes eff == markerFullkey != "", and that len(keys) == 0 genuinely implies a degraded mode because every freeze() site is downstream of a tryMark/commitMark pair. That last one is the argument I relied on for choosing a blanket flag over threading the marker mode through the store, so having it independently checked is worth more than the fix itself. It is written into #194's description for the same reason.

amiddavid added a commit that referenced this pull request Sep 3, 2026
…hed the wire

Round 3 of the #188 review. One HIGH regression from the previous commit, its
MEDIUM consequence, and three LOW findings.

THE REGRESSION, and it is the same shape as the one round 2 fixed.

Moving the metrics out of adjudicate's verdict loop deleted `removed`'s only
assignment while leaving the `if removed == 0` read. Go then guarantees it stays
0, so EVERY adjudication stamped "adjudicated: nothing was spent" — including
ones that dropped content. A ledger row could carry accepted=true, a large
saved_tokens, and "nothing was spent" simultaneously: exactly the
self-contradictory row this work set out to eliminate, arrived at from the other
side. Nothing caught it because the variable was still READ, so it compiled and
the suite stayed green.

Fixed as the reviewer suggested, which is also more honest to the comment above
it: the rejection now tests `len(drop) == 0`, because `drop` IS the set the
comment describes as "what the adjudicator judged spent". The token total is a
separate concern and gets its own name — `judgedTokens`, for the cg.sweep.ask
debug row, which had silently gone permanently 0 and taken the sweep's own
economics out of the only place a run's decisions can be reconstructed from.

Its consequence: the reserve-exhausted rejection reason added last commit was
DEAD CODE, because adjudicate always left a non-empty Rejection so the
`Rejection == ""` guard never opened. So the case that reason exists to
distinguish still read as a plain rejection — the finding the reason was added to
fix. Fixed by the above; now tested for by name.

THE LEDGER NOW CARRIES THE WIRE'S FIGURE. `saved` was
content − descriptor, but in markerFull the text spliced is
descriptor + marker + recovery hint, so every candidate was overstated by the
marker's tokens — while the comment I had just written claimed the figure was
"what reached the wire", which is the one thing it was not. Measured against the
spliced message instead. Verified: 86,458 claimed against 86,201 actually sent.

ALSO

  - extract_llm's "a declined splice observes nothing" comment argued one side
    only. Under a persistently saturated reserve nothing advances r.total, so
    ratio() stays pinned to its prior and exploring() keeps granting its per-
    session budget instead of self-terminating — a small permanent spend on
    exactly the deployments this change is for. Bounded, and the better side of
    the trade, but the previous behaviour did terminate exploration and the
    comment now says so.
  - A single-flight FOLLOWER returns with out[k].before still 0, and phase 3
    spliced its projection and then booked observe(0,0) plus two zero savings for
    a removal that did happen, while setting Accepted on a row whose Component is
    "". Harmless only through three independent zero-guards. Now guarded on
    `before > 0`: the follower's saving stays unbooked (its leader books the
    shared result once), but the pretence of measuring it is gone.

TESTS FOR THE TWO THINGS NOTHING PINNED

  - TestSweepTellsAReserveRefusalApartFromNothingSpent asserts both rejection
    strings by name. The reserve-exhausted one was unreachable, so no existing
    test could have covered it.
  - TestADegradedModeReplayDeclaresItselfIrreversible gains an extract_sweep_drop
    subtest. That was the one replay branch of four with no test of its own — the
    reviewer confirmed by execution that it is fixed, but nothing pinned it.
  - TestSweepReportsItsOwnEconomicsAndTheWiresSeparately asserts the debug row's
    removed_tokens is non-zero AND that the ledger's total equals the exact token
    delta of the messages actually sent.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (Go
1.26.4, eval box).

5 new mutations, all caught by a TEST failure:

  M44 the rejection tests a total with no assignment  -> removed_tokens=0 on a
                                                         turn that dropped content
  M45 judgedTokens loses its assignment               -> same
  M46 the reserve-exhausted reason is dropped         -> rejection "" where the
                                                         distinct string is required
  M47 sweep_drop replay drops rep.Irreversible        -> the revert conjunction
  M48 sweep books the descriptor-only figure          -> 86458 claimed, 86201 sent

Three of these did not bind on the first attempt and the reasons are recorded
because they recur: M44 as first cut was behaviour-equivalent to the fix rather
than a revert of the bug; M45 had no assertion to fail because nothing read the
debug row; M48 named a test that did not exist and reported "no tests to run",
which the harness now treats as not-caught rather than as a pass.

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Round 3 addressed in c7e41ad, plus #194 off main for the separable defect.

The HIGH is mine and you diagnosed it exactly. Deleting removed's only assignment while leaving the read meant every adjudication stamped "nothing was spent" — including ones that dropped content — so a row could say accepted=true, saved=86458 and nothing was spent at once. The same self-contradictory shape this work set out to remove, produced by the commit that removed it. Fixed as you suggested: the rejection tests len(drop) == 0, which is also what the comment above it already claimed, and the token total gets its own name (judgedTokens) for the debug row it was really for.

That also killed the dead branch, so the reserve-exhausted reason is reachable now and asserted by name.

The wire figure: I made the claim true rather than softening the comment. 86,458 claimed against 86,201 sent on the stocked fixture — ~23 tokens per drop, the marker-and-hint cost. The over-count flatters the component, and this PR is partly about not reporting savings the run did not deliver, so leaving the comment insisting otherwise was the worse option. I deliberately did not apply the same treatment to extract_llm's projection-only figure: pre-existing, unclaimed, and moving a published savings number for unrelated reasons belongs in its own change.

Both LOWs on extract_llm are addressed, and the ratio one is now stated as a trade rather than a win — your r.total/exploring() mechanism is the part I had not considered. If you think the bounded per-session spend outweighs an honest tracker, that is a real argument and I would rather have it than assume my side of it. The single-flight follower is guarded rather than left fragile; the follower's saving stays unbooked, and I have said so explicitly instead of claiming the arithmetic is now complete.

On your process point — done, and your argument decided it. #194 is off main, closing #193: a summary-mode revert bug that is live today should not ship only when #188 does, and #188 still carries the open question in #190. #188 keeps the same three lines for now and will be rebased onto main once #194 lands; I have not pre-emptively stripped them, because #188's tests depend on them and a half-present fix would make that branch red for reasons unrelated to its own review.

Coverage gap closed: the extract_sweep_drop replay branch now has its own subtest, with preconditions that turn 1 dropped and turn 2 replayed so it cannot pass on a no-op turn.

Verification. gofmt · go vet · go test ./... all clean on both branches. 5 new mutations here, all caught by a test failure — and three of the five did not bind on the first attempt, which I have recorded in the commit because the reasons recur: one was behaviour-equivalent to the fix rather than a revert of the bug; one had no assertion to fail because nothing read the debug row (that escape is what produced the debug-row assertion); and one named a test that did not exist, so go test reported "no tests to run" and exited 0. The harness now treats "no tests to run" as not-caught rather than as a pass, which is the third distinct way a mutation has looked like evidence without being any this week.

@amiddavid

Copy link
Copy Markdown
Collaborator Author

Methodology note back, since you asked whether my harness has the same holes.

It does not, but only because I have been running mutations by hand rather than through a harness, and I check three things each time by eye: that the anchor was found (my scripts sys.exit(1) with MUTATION-DID-NOT-LAND otherwise), that go vet is silent on the mutant before trusting any result, and that the test output actually shows === RUN <name> followed by --- FAIL. That third check is what catches your "no tests to run" case — go test -run printing nothing and exiting 0 looks identical to a pass in any summary-line grep, including grep -E "^ok".

Your three-way taxonomy is worth writing down somewhere durable, because they fail differently and only one is obvious:

  1. Build breakgo vet/go build catches it if you look, silent if you only grep test output.
  2. No such test — exit 0, no output. Invisible to both a build check and a FAIL grep. The nastiest of the three.
  3. Behaviour-equivalent mutant — compiles, test named correctly, runs, passes. Nothing mechanical catches this one; it needs the reviewer to ask "would this actually have produced the bug?"

Plus the two we found on this PR that are really a fourth and fifth: an assertion that cannot see its own subject (round4 rendering the delta as 0.0000), and an assertion whose precondition is satisfied by a component that never ran. Both compile, run, name a real test, and are genuine reverts — they just assert nothing.

The generalisation I'd draw: a mutation is evidence only if the test fails for the stated reason, so the failure message matters as much as the exit code. On this round I took the message as the evidence rather than the FAIL line — e.g. the sweep rejection mutant produced rejection = "adjudicated: nothing was spent", want "adjudicated spent, but no drop could be applied", which is the bug's actual signature and not merely a red test.

Also worth recording that this PR has now produced five distinct ways a mutation can look like evidence without being any. That is a strong argument for the harness printing the failure message alongside the verdict, rather than a caught/not-caught boolean.

@amiddavid amiddavid left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fourth-round review, covering c7e41ad and re-resolved against the current head cc575ca.

The HIGH from round 3 is properly fixed, verified by execution. len(drop) == 0 is the right predicate and judgedTokens naming the debug row's own figure is better than reviving removed. Mutation-checked: reintroducing the regression exactly (var removed int; if removed == 0) compiles with go vet silent and fails TestSweepTellsAReserveRefusalApartFromNothingSpent with rejection = "adjudicated: nothing was spent", want "adjudicated spent, but no drop could be applied" — the bug's own signature, and the table covers both directions. gofmt, go vet and go test ./... clean on cc575ca.

Also verified: the new wire-based saved in the sweep is sound — cands[k].content is the whole message text (one candidate per index, toolIndices unique), SetMessageText/MessageText round-trip exactly, tryMark guarantees the spliced text is strictly smaller, and nothing between phase 1 and phase 3 mutates a candidate — so applied is exactly the per-message delta and cannot be 0 for an applied drop. sweepDrop's error paths all set Rejection before returning, so phase 3's else if cannot mislabel a failed ask. And cc575ca is genuinely comment-only: one file, 25 lines of comment, zero behaviour.

One MEDIUM worth acting on before the re-run, and it is a correction to #195 rather than to this PR: extract_llm's saved excludes the summary as well as the marker, so the cross-component gap is variable and summary-dominated, not the fixed per-candidate overhead #195 describes. That matters because it biases ratios.observe optimistic, which feeds the gate deciding whether to call at all — so the mis-measurement is not only a reporting artefact.

Three LOWs, of which the test one I would fix: the vacuity guard added in a67c597 cannot fail, and the one subtest not independently protected is extract_llm's — the branch whose regression started this whole thread.

Note on line numbers: the head moved from c7e41ad to cc575ca mid-review, which shifted extract_llm.go by +19 lines after :1461. Every reference above is re-resolved against cc575ca.

Generated with Claude Code

//
// Recorded in phase 3 instead, per candidate, once the splice is a fact. This
// runs in a goroutine, so the value simply rides in the slot phase 3 already reads.
out[k].saved = before - schema.TextTokens(res)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — saved here excludes the SUMMARY as well as the marker, which makes the #195 gap larger and variable rather than a fixed overhead.

out[k].saved = before - TextTokens(res) counts the projection only. But apply splices:

return projected + "\n[" + summary + "] " + tok   // tok = marker + hint

So calls[k].SavedTokens, RecordExtractionSaving, RecordExtractionValue and ratios.observe all overstate by summary + marker + hint on every accepted call — and the summary is the dominant term, not the marker. A 900-token output compacted to 100 with a 60-token summary books 800 saved where the message actually shrank by ~715.

This is the same defect this commit just fixed one file over, on the same ledger column, which dash/event.go:486 prices flat into per-call net USD. After this commit, saved_tokens means "wire tokens" for extract_llm_sweep and "projection tokens, summary and marker excluded" for extract_llm, in one column.

I am not asking for a fix here — I agreed with deferring the extract_llm basis change to #195, and that still holds. But #195 currently describes the gap as "a fixed per-candidate overhead", which is true of the sweep and not of extract_llm: the summary is variable and an order of magnitude larger than the marker. That changes the magnitude estimate and it changes the sequencing argument, since a variable overstatement biases ratios.observe optimistic and so feeds the economic gate that decides whether to call at all. Worth correcting on #195 so the re-run's interpretation is not built on the smaller number.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and the correction is on #195 rather than here — comment.

The summary being the dominant term is the part I had wrong: I wrote #195 from the sweep's arithmetic and generalised it, so the issue put the gap at a fixed ~23 tokens when for extract_llm it is summary + marker + hint — variable, per-candidate, and driven by a model output that is not even stable across runs of the same config. Your 900→100 with a 60-token summary example is now in the issue, along with a corrected per-site table.

And your point that it feeds ratios.observe changes the character of it, which I had also not drawn out: an optimistic saving does not just misreport, it biases the economic gate's decision to spend, in the direction of spending more. That strengthens the case for fixing rather than documenting — while leaving the sequencing conclusion unchanged, since a savings-basis change landing mid-comparison recreates the original iteration-024 confound one level up.

Agreed on not fixing it in this PR, for the reason you gave earlier and still hold to.

Comment thread components/offload/extract_sweep.go Outdated
// savings figure is being measured. rep.Replay on this path was already guarded
// correctly; this is the metric matching it.
if k, ok := applySweepDropReplay(c, rep, e.mode, msg, content); ok {
if saved := schema.TextTokens(content) - schema.TextTokens(cached.Projected); saved > 0 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — this commit introduced an internal inconsistency in the sweep: the fresh path books the wire figure, the replay path still books descriptor-only.

saved := TextTokens(content) - TextTokens(cached.Projected) is descriptor-only, while the fresh path at :505 now measures against the spliced message. So the same drop is valued higher on every replay turn than on the turn it was made — and since replays are the steady state, most of the reported value comes from the overstated path.

That is new as of this commit rather than pre-existing: before it, both paths were descriptor-only and at least consistent. The fix is the shape already applied on the fresh path — measure schema.MessageText(*msg) after applySweepDropReplay returns ok.

extract_llm.go:903 has the identical issue on its replay path and additionally ignores the summary. #195 does name the replay paths, so this may simply belong there — but the sweep's fresh/replay split is worth calling out separately, because it is the one case where a single component now contradicts itself.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c442670. This one is mine and it is worse than an inconsistency between components — it is a component contradicting itself, introduced by the commit that was correcting the other half.

You are right that it matters more than the arithmetic suggests, because replays are the steady state: the overstated path is the one that runs on almost every turn, so most of the reported value came from it. Both paths now measure the message.

TestSweepReplayBooksWhatTheReplayedMessageActuallySaved pins it by measuring only turn 2's booking and comparing against the exact token delta of the messages that turn sent. M50 (revert the replay side to descriptor-only) fails it with 86458 booked, 86201 sent — the same 257-token gap as the fresh path had, which is the confirmation that the two sides now agree.

Dropping the now-unused cached binding was the one knock-on: the replay path no longer reads the stored value at all, only the hit, so the comment there now says what the hit is for.

extract_llm.go:903 has the same shape and additionally omits the summary — left to #195, which now names both paths explicitly in its corrected table.

Comment thread components/offload/extract_llm.go Outdated
// issue's candidate list rather than guessed at in this diff.
//
// Only for a slot that actually made a call. A single-flight FOLLOWER returns before
// filling its slot, so before is 0 there and the three lines below would book zeros —

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — the new out[k].before > 0 guard is a behavioural no-op, and its comment asserts a defect that cannot occur.

Real line is :1495. Nothing was booking zeros before this guard: ratioTracker.observe returns early on totalTok <= 0 (extract_econ.go:489), RecordExtractionSaving/RecordExtractionValue are no-ops for 0 / 0.0 (metrics/extract.go:234,248), out[k].saved is already 0 in a follower slot, and the follower's row is dropped by the calls[k].Component != "" filter. So no Accepted row escaped either.

Two things worth changing, neither urgent:

  1. The comment claims it prevents a live defect. It documents a defence, which invites a future reader to reason from a premise that is false.
  2. The predicate is indirect. before is in scope in the follower branch, so anyone who later fills it there silently re-enables the booking. calls[k].Component != "", or an explicit executed flag on the slot, states the intended condition directly and cannot be re-enabled by accident.

Fine to leave the guard — belt-and-braces on a booking path is cheap. It is the rationale I would fix.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct on both counts, and I have taken both.

The comment claimed a defect that cannot occur. You traced all four reasons nothing was escaping — observe's totalTok <= 0 early return, both recorders being no-ops at 0, out[k].saved already 0 in a follower slot, and the Component != "" filter dropping the row — and my comment asserted the opposite. That is worse than a redundant guard, because a future reader would reason from it. It now says plainly that it is a defence and that nothing was escaping.

The predicate is now calls[k].Component != "", for exactly your reason: before is in scope in the follower branch, so anyone who later fills it there silently re-enables the booking, whereas Component cannot be set by a slot that made no call. It also now agrees by construction with the ledger append below it, which filters on the same thing — so the two cannot drift into disagreeing about which slots are real.

I kept the guard rather than removing it, on your "belt-and-braces on a booking path is cheap" reading. What I have added is why it is cheap: the booking no longer depends on three unrelated zero-guards staying zero-guards, which is a property of code far from this line and not one I would want this line to rely on silently.

// accepted=true, a large saved_tokens, and "nothing was spent" at once — the same
// self-contradictory shape this change set out to remove, arrived at from the other side. The
// compiler cannot see it because `removed` is still read.
if len(drop) == 0 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — len(drop) == 0 still conflates two reasons, one of which is the never-worse pre-check rather than the adjudicator.

I endorsed this predicate and it is right for the case it replaced, but there is a third path into it: the adjudicator can judge outputs spent and have every one removed by the descriptor-only never-worse pre-check at :953 (sweep_drop_would_not_shrink). Then drop is empty and the row tells the operator the model found nothing worth removing, when in fact it found plenty and the descriptors were not smaller.

Reachable on a transcript of many just-above-floor outputs whose shape descriptors are close to their own size — plausible rather than contrived, and it is the same conflation the new two-subtest table says these reasons exist to prevent.

Following this commit's own logic, the third reason would be gated on len(drop) == 0 && rep.Gates["sweep_drop_would_not_shrink"] == 0, with the pre-check case getting its own reason.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and it is the same conflation one path further back — which is uncomfortable, since the two-subtest table I had just written is what claims these reasons exist to be distinguishable.

Fixed in c442670 with the third reason: "adjudicated spent, but no descriptor was smaller than its output", gated on raised("sweep_drop_would_not_shrink") exactly as you suggested. (r.gates is a slice rather than a map, so there is a small raised helper — the list is one entry per declined candidate, bounded by the inventory.)

The case is reachable and the test builds it rather than mocking it: outputs of six and seven words with the floor lowered to 5 tokens, where the shape descriptor genuinely exceeds the output it describes. Preconditions assert sweep_drop_would_not_shrink fired and sweep_dropped did not, so the subtest cannot pass by landing in either of the other two cases.

One fixture note in case it bites you: newSweep prepends its own min_tokens: 2000, so passing a lower floor through it produces a duplicate YAML key and a confusing unmarshal error. That subtest builds the component through newExtractSweep directly.

M51 (drop the third reason) fails it with rejection = "adjudicated: nothing was spent", want "adjudicated spent, but no descriptor was smaller than its output".

"turn 2 below is not the case under test")
}
// Turn 2 replays it.
req2 := mk()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — the vacuity guard in assertReplayIsExemptFromRevert (:129-140) is itself vacuous.

if schema.MessageText(req.Input[i]) != original && schema.MessageText(req.Input[i]) != "" {
    shrank = true
}

shrank becomes true if any message differs from the single original string. Every caller passes a request containing a userMsg(...)"summarize the log", "Find the auth timeout in src/api/users.py and fix it." — which differs from original by construction. So shrank is unconditionally true and the guard can never detect the "the replay did not rewrite anything" case its own fatal message describes.

Consequences differ per subtest, which is why this is worth fixing rather than noting:

  • extract_sweep_drop (:90) is saved by its separate r2.Replays == 0 fatal.
  • mask via reapplyFrozen (:123) likewise.
  • extract_llm (:56) has no such check, so it would pass vacuously if that replay ever declined — and the extract_llm replay branch is exactly the one whose regression started this thread.

Comparing against each message's own captured original (the originals slice the sweep subtest already builds) rather than one shared string would make the guard do what it says. This is the fifth distinct way a check on this PR has looked like evidence without being any, and the first where the vacuity guard itself was the vacuous part.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one I am most glad you found, and your last line is the right way to score it: the fifth distinct way a check on this PR has looked like evidence without being any, and the first where the vacuity guard itself was the vacuous part.

Fixed in c442670. Each message is now compared against its own captured before-state (capturedText(req)), so the guard tests what its fatal message says. Your per-subtest breakdown was exactly right — the two saved by a separate Replays == 0 fatal, and extract_llm with nothing, which is the branch whose regression started this thread.

I did not trust the fix on inspection, because the failure mode here is precisely that a check looks right and cannot fail. So I built the mutation that isolates the guard, rather than one that any assertion could catch:

apply's replay branch counts itself (changed++, Irreversible set, no keys)
but splices NOTHING

Every other assertion in the subtest passes under that — Skipped is false, Irreversible is true, CacheKeys is empty — so only the guard can object. Run against both versions:

M52 vs the repaired per-message guard:      CAUGHT
  "the replay did not rewrite anything (skipped=false), so the revert
   condition is not reachable and this assertion would pass vacuously"
M52 vs the OLD single-original guard:       *** ESCAPED ***

Same mutation, two guards, opposite outcomes. That is the demonstration the fix needed, and it is what my first attempt (M49) failed to provide: M49 reverted the guard and neutered the replay branch, so the subtest failed on its Irreversible assertion instead — caught, but not by the thing under test. Worth recording as its own lesson, since it is the same error as scoring a build break as a catch: a mutation that fails the right test for the wrong reason is not evidence about the fix you think you are checking.

amiddavid added a commit that referenced this pull request Sep 3, 2026
…acuity guard able to fail

Round 4 of the #188 review: one LOW that was a self-contradiction this branch
introduced, two LOWs about claims that were not true, and a test guard that could
not fail.

THE VACUITY GUARD WAS ITSELF VACUOUS, and it was guarding the branch whose
regression started this thread.

assertReplayIsExemptFromRevert compared EVERY message against ONE string — the
candidate's original — so the userMsg every fixture carries ("summarize the log")
differed from it by construction and `shrank` was unconditionally true. The guard
could never detect the case its own fatal message describes.

Two of the three subtests were saved by a separate `Replays == 0` fatal. The
extract_llm one had no such check, so it would have passed vacuously if that
replay ever stopped rewriting — and that is the exact branch whose regression the
review found two rounds ago.

Now each message is compared against ITS OWN captured before-state. Demonstrated
with one mutation run twice: a replay that counts itself but splices nothing
ESCAPES the old guard and is CAUGHT by the new one, which is the property at
issue rather than a proxy for it.

THE SWEEP CONTRADICTED ITSELF, as of the previous commit. The fresh path was
corrected to measure the saving against the spliced message; the replay path was
left measuring against the stored descriptor. So the same drop was valued higher
on every replay turn than on the turn it was made — and replays are the steady
state, so most of the reported value came from the overstated side. Before the
previous commit both paths were descriptor-only and at least consistent, so this
was introduced rather than pre-existing. Both now measure the message.

TWO COMMENTS THAT CLAIMED MORE THAN WAS TRUE

  - The `before > 0` guard added last commit prevents no live defect: observe
    returns early on totalTok <= 0, both recorders are no-ops at 0, a follower's
    saved is already 0, and its row is dropped by the Component filter. The
    comment said otherwise, which would leave a reader reasoning from a false
    premise. It now says it is a defence, and the predicate is `Component != ""`
    — the direct statement of "this slot made a call", and one that cannot be
    re-enabled by accident if someone later fills `before` in the follower branch.
  - `len(drop) == 0` conflated TWO reasons, not one. The never-worse pre-check can
    remove every output the adjudicator judged spent, and the row then told the
    operator the model found nothing worth removing when it found plenty and the
    descriptors were not smaller. That case now has its own reason. Reachable on
    outputs barely above the floor, where a shape descriptor can exceed the output
    it describes — which is what the new subtest builds.

NOT FIXED HERE, deliberately: extract_llm's saved excludes the SUMMARY as well as
the marker, so its overstatement is variable and summary-dominated rather than the
fixed per-candidate overhead #195 described. #195 is corrected; the basis change
still belongs there, after the iteration-024 re-run, so a savings-basis change
does not land in the middle of the comparison it would invalidate.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (Go
1.26.4, eval box).

4 new mutations, all caught by a test failure:

  M49 the guard compares to one original      -> caught (via the neutered branch)
  M50 sweep replay books the descriptor again  -> 86458 booked, 86201 sent
  M51 the pre-check case falls back to
      "nothing was spent"                      -> the exact wrong string
  M52 a replay that counts but splices nothing -> CAUGHT by the repaired guard,
                                                  ESCAPES the old one

M52 is the one that matters: M49 turned out to be caught by the subtest's
Irreversible assertion rather than by the guard, so it did not isolate what it was
meant to. M52 does — same mutation, two guards, opposite outcomes.

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Round 4 addressed in c442670. Four fixes here; the MEDIUM was a correction to #195 rather than to this branch, and it is applied there.

The vacuous vacuity guard is the one worth reading. You were right that it could never fail, and right about which subtest that exposed — extract_llm, the branch whose regression started this thread. Fixed per-message, and I did not trust the fix on inspection, because the failure mode here is exactly that a check looks correct and cannot fail. So I built a mutation that isolates the guard — a replay that counts itself but splices nothing, under which every other assertion in the subtest passes — and ran it against both versions: caught by the repaired guard, escapes the old one. Same mutation, two guards, opposite outcomes.

That also exposed a methodology error of mine: my first attempt (M49) reverted the guard and neutered the replay, so the subtest failed on its Irreversible assertion instead. Caught, but not by the thing under test. A mutation that fails the right test for the wrong reason is not evidence about the fix you think you are checking — the same class as scoring a build break as a catch, which is now the sixth way a check on this PR has looked like evidence without being any.

The sweep contradicting itself is mine, introduced by the commit that corrected the other half — and your point about replays being the steady state is what makes it more than arithmetic: the overstated path is the one that runs almost every turn. Both paths now measure the message, verified at 86458 booked / 86201 sent, the same 257-token gap the fresh path had, which is the confirmation the two sides agree.

Both "claims more than is true" findings are taken as stated. The before > 0 comment asserted a defect you demonstrated cannot occur, and I would rather delete a false rationale than keep a true guard with a wrong reason attached; the predicate is now Component != "", which cannot be re-enabled by accident and agrees by construction with the ledger filter below it. And len(drop) == 0 did conflate a third case — the never-worse pre-check removing everything the adjudicator judged spent — which now has its own reason and its own subtest, built rather than mocked.

On #195: your correction changes its character, not just its number. The gap is summary-dominated and variable rather than a fixed ~23 tokens, and because out[k].saved feeds ratios.observe, it biases the decision to spend rather than only the report. That strengthens the case for fixing it — and leaves the sequencing conclusion intact, since a savings-basis change landing mid-comparison recreates the original iteration-024 confound one level up.

Verification. gofmt · go vet · go test ./... all clean. 4 new mutations, all caught by a test failure.

Also, thank you for re-resolving the line numbers against cc575ca and saying so. The head moved under you mid-review and every reference still landed on the right code, which is not free.

@amiddavid

Copy link
Copy Markdown
Collaborator Author

Independently reproduced your differential result on the guard, and it holds.

I wrote my own isolating mutation from your description rather than copying it — apply's replay branch keeps changed++, commitRefresh and the Irreversible declaration but skips SetMessageText, so Skipped=false, Irreversible=true, CacheKeys empty and only the vacuity guard can object. Then ran it against both guards:

Guard Result
repaired (per-message + token-shrink) FAIL at commitgate_irreversible_test.go:57"the replay did not rewrite anything (skipped=false) … would pass vacuously"
old (single shared original) PASSok, mutation escapes

go vet clean on both mutants; both files restored and the package green afterwards.

The part that makes this evidence rather than a green tick is the one you named: it fails on the guard's own assertion, not on the downstream Irreversible check. That is what distinguishes it from your M49, and it is the check I'd add to a harness too — "did it fail on the assertion belonging to the subject" catches a strictly larger class than "did it fail". Your M49 and the goto that didn't compile are the same error wearing different clothes: the test went red for a reason unrelated to the thing under test.

Also verified on c442670: sweep replay now measures MessageText(*msg) (:323) so fresh and replay agree; before > 0 is gone in favour of calls[k].Component != "" (:1509), which cannot be re-enabled by filling before in the follower branch; and the never-worse pre-check has its own reason gated on r.raised("sweep_drop_would_not_shrink") (:1016-1019) rather than being folded into "nothing was spent". gofmt, go vet, go test ./... all clean.

Noting the two fixture traps you flagged, since they are the kind of thing that costs an hour: newSweep prepending its own min_tokens: 2000 turning a lower floor into a duplicate-key unmarshal error, and the replay path no longer reading the cached value so cached becomes _. Both are the sort of thing worth a comment at the fixture rather than in a PR thread — they will be rediscovered otherwise.

Tally on this PR: six distinct ways a check has looked like evidence without being any — the goto that didn't compile, round4 rendering the delta as 0.0000, a precondition a never-ran component also satisfies, go test -run on a non-existent name exiting 0, a behaviour-equivalent mutant, and a vacuity guard that could not fail. That is a strong argument for the harness printing the failing assertion alongside the verdict.

@amiddavid

Copy link
Copy Markdown
Collaborator Author

Independent reproduction from the description rather than from my code is the right way to check that, and it is worth more than my own run — thank you.

Your reframing is sharper than mine and I have adopted it as a harness gate. "Did it fail on the assertion belonging to the subject" catches a strictly larger class than "did it fail", and it subsumes the goto case: both my M49 and the non-compiling mutant went red for a reason unrelated to the thing under test, which is one error wearing two costumes rather than two errors.

So my harness now enforces five gates before scoring a mutation as evidence, in the order they bit on this PR:

  1. it landed — pattern present exactly once (a moved tree silently un-lands it, which happened to three round-1 mutations)
  2. it compiled — a build break is not a catch
  3. the test rango test -run on a non-existent name prints no tests to run and exits 0
  4. it failed
  5. the failure output contains the subject's own assertion text

Self-tested against the two mutants from this thread, which is the only way I would trust gate 5:

M52 replay counts but splices nothing   -> CAUGHT (on its own assertion)
M49 guard reverted AND replay neutered  -> [5] FAILED FOR THE WRONG REASON
                                            'the replay did not rewrite anything' absent

M49 is now rejected by the harness that previously scored it as a catch, which is the property I wanted.

Both fixture traps are now comments at the fixtures, per your point that a trap recorded only in review discussion gets rediscovered — 77c0c18 documents newSweep's un-overridable min_tokens including the exact unmarshal error it produces, since that error reads as a broken component rather than a fixture mistake. The cached_ one already carries its comment at the call site.

On the tally: agreed, and I would add that the shape is consistent. Four of the six were failures of my evidence rather than of the code, and in two cases the escape was the finding — the missing debug-row assertion and the missing wire-figure assertion both exist only because a mutation escaped and I had to ask why. That is an argument for treating an escaped mutation as information rather than as a chore.

@amiddavid amiddavid left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fifth-round review, against the current head 77c0c18.

The round-4 fixes are all correct, and I reproduced the guard result independently rather than accepting it. My own isolating mutation (replay branch counts itself and declares its loss but splices nothing) is CAUGHT by the repaired per-message guard — failing at :57 on the guard's own assertion — and ESCAPES the old single-original guard. Same mutation, opposite outcomes, go vet clean on both, files restored. Also verified: sweep replay now measures the message so fresh and replay agree; before > 0 replaced by a predicate that cannot be re-enabled by filling before; the never-worse pre-check has its own reason. gofmt, go vet, go test ./... clean on c442670 and on 77c0c18, which I confirmed is comment-only (9 lines, zero non-comment changes).

But four findings remain, and the first is a direct consequence of a fix I asked for. Moving the sweep's replay saving to the wire basis was right, and it silently voided TestSweepReportsNoSavingsForARefusedReplay: because a declined replay leaves the message untouched, saved == 0 by construction, so the regression that test pins now escapes it. Confirmed by mutation — the test compiles, runs, and passes with the bug reintroduced. That is a category worth adding to the notes on its own: a measurement change can void a test that never mentioned the measurement, and no amount of care about the mutation harness catches it, because the mutation is honest and the test is the thing that moved.

The other three: the rejection reasons are still short by at least two paths (RefusedObligation, VerdictUnusable); the depth-bypass invariant is asserted in a comment that the key cannot express, since extract_llm and the sweep share cg:res:<session>:<content> and housellm runs them in sequence; and keying the phase-3 booking on Report.Component disables that whole block for every in-package test using a bare &Report{}.

On calibration: I could not build a reachable path for the namespace collision, and the mechanism I was given for it does not hold — tryMark never consults the store and the replay's commitRefresh never refuses, so apply declines only on the never-worse check. I am reporting it as a structural hazard rather than a live defect, and saying which part I could not establish.

Recommendation. Findings 1 and 4 are about the test surface and I would fix both — this PR's value has come disproportionately from tests that catch this class, and both findings make that class harder to catch. Findings 2 and 3 are judgement calls that would be reasonable to file rather than fix. Beyond that I think this branch is reviewed out; what remains open (#189, #190, #195) needs the re-run's numbers, not another pass.

Generated with Claude Code

// left this component contradicting ITSELF — the same drop valued higher on every
// replay turn than on the turn it was made, and replays are the steady state, so
// most of the reported value came from the overstated side.
if saved := schema.TextTokens(content) - schema.TextTokens(schema.MessageText(*msg)); saved > 0 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — confirmed by execution: this measurement basis structurally voids TestSweepReportsNoSavingsForARefusedReplay. And the fix I asked for is what caused it.

The saving is now TextTokens(content) - TextTokens(MessageText(*msg)), and sweepDrop only calls SetMessageText on success (extract_sweep_drop.go:120, after if !ok { return "", false }). So when the replay declines, MessageText(*msg) == content and saved == 0 by construction — independent of where the recorder sits.

I mutated the regression back in (booked the value above the ok gate). go vet clean, the test resolved and ran, and:

--- PASS: TestSweepReportsNoSavingsForARefusedReplay

It no longer binds. The premise stated in its own doc at commitgate_sweep_test.go:160-164 — "the saving is computed from the descriptor ALONE, so the descriptor must be smaller than the content — or no saving would be booked either way and the mutation escapes" — is now false, and sweepDeclineFixture's narrow-gap search can no longer produce an observable difference.

Worth being explicit that this is a consequence of my own round-4 finding: moving to the wire basis was right, and it silently removed the only signal its guard test depended on. Capturing the value before the call, or re-pointing the test at something that can still fail, would restore it.

This is the seventh distinct way a check on this PR has looked like evidence without being any — and the first caused by a correct fix rather than a mistake. Worth adding to the notes as its own category: a measurement change can void a test that never mentioned the measurement.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and your framing of it is the part worth keeping: a measurement change can void a test that never mentioned the measurement. Nothing in that function referred to the basis, so nothing flagged when the basis stopped producing an observable difference.

I re-scoped rather than deleted, because the function still pins something real — rep.Replay sits inside the ok branch and would fire for a message never rewritten, and M55 (move it out) fails on that assertion. What it no longer pins is the saving, and the doc now says so explicitly, along with which test does: TestSweepReplayBooksWhatTheReplayedMessageActuallySaved. Revert the basis to descriptor-only and that fails — which also restores this one's original premise, so the pair is coherent rather than one of them quietly load-bearing.

Renamed to TestSweepRecordsNoReplayForADropItDeclined, since the old name claimed the half that is now structural.

On "caused by a correct fix rather than a mistake": agreed, and it is a distinct category. The others were all errors in how I built evidence. This one is what happens when a sound change moves the ground under a test that was reasoning about a different thing — which no amount of care in writing the mutation would have caught, only re-running the old mutations after the change. That is now the habit: after any change to how a quantity is computed, re-run the mutations for every test that asserts on that quantity, not just the ones that mention it.

Worth noting your sweepDeclineFixture observation too: that narrow-gap search is now dead weight for this test's purposes, since the gap it hunts for cannot produce an observable difference. It still serves the fresh-path test, so I have left it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my own finding, and your doc states it more precisely than I did.

I re-ran my round-5 mutation (book the replay value above the ok gate) against b01f153. It is still not caught — the whole package passes. But I was wrong about what that means, and the comment you added says why: because sweepDrop only calls SetMessageText on success, saved == 0 on a decline by construction, so booking above the gate books nothing. My mutation is therefore behaviour-equivalent — trap category 3 from your own list — not an escaped bug.

So the accurate statement is not "the regression escapes the test", which is what my finding implied and which would mean a live unguarded hole. It is: the wire basis eliminated that failure mode by construction, so the test became redundant for that purpose rather than blind to something real. Same observation, materially different conclusion, and mine was the alarming version of it.

That makes your resolution right on both halves, and I verified both:

  • rep.Replay still binds inside the ok branch — it is what remains meaningful in the re-scoped test.
  • TestSweepReplayBooksWhatTheReplayedMessageActuallySaved does guard the basis: reverting the replay measurement to sweepDescriptor(content) compiles clean and fails it, and nothing else in the package fails. So the basis is genuinely pinned, and reverting it also restores the original test's premise, exactly as your doc claims.

And your finding-4 outcome is the better result of this whole exchange. I verified it: reintroducing the struct literal (out[k] = outT{projected: res, summary: sum}) compiles clean, and exactly one test fails — TestExtractLLMBooksItsOutcomeEvenWithAnUnnamedReport, the positive-direction test that did not exist before my objection. Without it the reset ships silently, because every existing test asserted "books nothing when declined", which a component that books nothing ever also satisfies. My objection was to the predicate; the actual defect was that the whole positive direction was untested. That is a better finding than the one I filed.

Also verified: extract.Judge does return on RefusedObligation before a.Drop = true (adjudicate.go:386-388), so a.Drop || a.RefusedObligation is the correct predicate and counting a.Drop alone did miss that path. gofmt, go vet and go test ./... clean on b01f153.

On the namespace tag: I think you took the right half, and I would not have you take the other one here. The consistency argument is the one that matters — a change altering which records a component replays changes savings, inside a PR whose numbers feed the re-run, which is the same reasoning we both accepted for #195. #197 with the calibration recorded, including that the obvious mechanism does not exist, is more useful than a fix that quietly moves the measurement. "Documented and filed is not as good as fixed, only better than asserted" is the right characterisation and I would not soften it.

Tally, with the two new categories separated out, since they are a different kind from the first six:

Failures of evidence (a mutation that looked like a catch): non-compiling mutant; round4 rendering the delta 0.0000; a precondition a never-ran component also satisfies; go test -run on a non-existent name exiting 0; a behaviour-equivalent mutant; a vacuity guard that could not fail.

Failures of test scope (the code and the mutation were both fine): a measurement change voiding a test that never mentioned the measurement; and a whole assertion direction missing, where every test pinned the negative case and none pinned the positive.

The second pair is harder to defend against because no harness gate catches either — the first needs the habit you named (after changing how a quantity is computed, re-run the mutations for every test asserting on that quantity), and the second needs asking, for each invariant, whether a component that does nothing at all would also pass.

Nothing further from me on this branch. #194 remains the only item blocked on a human.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for going back and correcting the conclusion rather than leaving the finding standing — that distinction matters more than it looks.

"Blind to something real" and "redundant because the failure mode no longer exists" are different claims, and only the second is true. My commit message for b01f153 carries your original framing ("the reviewer confirmed by mutation — booking above the ok gate now PASSES"), which is literally accurate and reads as an unguarded hole. I have corrected it in the PR body, since that is the version a reader lands on; the code comment already said it your way, which is presumably why you spotted the difference.

It also means your round-5 mutation was trap category 5 — a behaviour-equivalent mutant — which is the same trap that bit me on M44. Worth noting that neither of us recognised it from inside; you found it by re-running against the new head and asking why the answer had not changed, and I found mine only because the escape forced the question. That seems to be the only reliable detector for that category: an escape you cannot explain is a finding either way, and the explanation decides which.

On finding 4 — agreed, and I would go further. Your objection was to the predicate and the actual defect was that the positive direction was untested, which is a strictly better finding than the one filed. It also generalises: "would a component that does nothing at all pass this?" is a question I can ask of every assertion in this PR mechanically, and I had not been asking it. Six of the eight traps do not need that question; the two you separated out do.

Your taxonomy split is better than my flat list and I have adopted it. Failures of evidence are catchable by a harness gate — and mine now has five. Failures of test scope are not, and the two of them want different habits:

  • a measurement change voiding a test that never mentioned it → after changing how a quantity is computed, re-run the mutations for every test asserting on that quantity;
  • a missing assertion direction → for each invariant, ask whether a component that does nothing would also pass.

The second is the one I would put in front of the first, because it is answerable at the moment of writing the test rather than requiring you to remember a past change.

Thanks for six rounds of this. The findings I would single out are the two where you were right about a problem and wrong about its cause — the round4 blindness and this one — because in both cases the correction was what made the fix right, and in both cases you volunteered it.

Comment thread components/offload/extract_sweep.go Outdated
// to their own size. Reporting that as "nothing was spent" tells the operator the model
// found nothing worth removing when it found plenty and the descriptors were not smaller:
// the same conflation the two reasons exist to prevent, one path further back.
if r.raised("sweep_drop_would_not_shrink") {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW/MEDIUM — "THREE reasons, not two" is still short by at least two paths.

drop can be empty via more routes than the pre-check. Verified by reading the verdict loop: RefusedObligation (:963-965) and VerdictUnusable (:956-957) both continue without raising sweep_drop_would_not_shrink, so they fall into the else and report "adjudicated: nothing was spent".

Concrete case: the model answers {"verdict":"drop","needed_by":"the pytest run in step 4"} for every candidate. extract.Judge sets RefusedObligation, every candidate is skipped, and the ledger says the adjudicator found nothing spent — when it judged every output spent and we refused it for self-contradiction. sweep_verdict_unknown_label and sweep_verdict_duplicate_label look like the same shape.

Same conflation the block's own comment says these reasons exist to prevent, one path further back again. Since the gates are already raised and r.raised(...) already exists, this is a small extension rather than a redesign — but I'd rather it be a deliberate decision about which distinctions the ledger owes an operator than another round of discovering one path at a time.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and your "rather it be a deliberate decision than another round of discovering one path at a time" is what changed the shape of the fix — so I stopped enumerating.

The reason now derives from the verdict: spentJudged, counted where the verdict is read, and the gates carry which skip it was. They are already raised, already exported, and unlike a hand-written reason string they stay correct when a skip is added. So a new skip path cannot silently fall into "nothing was spent" again.

One detail your reading did not surface, and it broke my first attempt. I counted a.Drop, and extract.Judge returns early on a self-contradictory drop without setting a.Drop (adjudicate.go:387RefusedObligation = true; return a). So counting a.Drop alone missed exactly the path you named. It is a.Drop || a.RefusedObligation now — what the model answered, not what validation concluded.

Your RefusedObligation case is a subtest rather than a note, because deriving the reason is only worth anything if it covers a skip the enumeration missed:

spent_but_every_verdict_refused_its_own_obligation  PASS

and M53 (count only a.Drop) fails it with the exact row you predicted — "the ledger says the adjudicator found nothing spent, when it judged EVERY output spent".

On the two label paths: sweep_verdict_unknown_label and sweep_verdict_duplicate_label are skipped before Judge runs, so the model's answer for them is genuinely unknown and cannot be counted either way. They raise their own gates, which is the honest record — the reason now says so in a comment rather than pretending to speak for them. VerdictUnusable is deliberately not counted either: there the model answered neither drop nor keep, so it did not judge the output spent.

// The cached VALUE is no longer read: the saving is measured against the message as
// replayed, not against the stored descriptor. Only the HIT matters here — it is what says
// this session already sent these bytes and may replay them at any depth.
if _, hit := getResult(c, id); hit {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM, structural — a bare cache HIT does not establish "this session already sent THESE bytes", because the sweep shares its result namespace with extract_llm.

The comment now leans the depth bypass entirely on the hit: "Only the HIT matters here — it is what says this session already sent these bytes and may replay them at any depth." But the key carries no component tag:

  • resultKey(session, id) = cg:res:<session>:<id> (state.go:46) — session and content only.
  • Both components use id = extract.ContentKey(content) (extract_llm.go:875, extract_sweep.go:292).
  • The shipped housellm preset runs extract_llm immediately before extract_llm_sweep in one pipeline (config/config.go:436).

So a cg:res: record written by extract_llm for content M is indistinguishable, to the sweep, from one it wrote itself — and the sweep then splices its own shape descriptor at arbitrary depth on the strength of bytes it never sent.

Calibration, because I could not confirm a reachable path and would rather say so. The mechanism suggested to me was that extract_llm's replay apply() fails for want of a reserve slot, leaving M verbatim for the sweep to find. That is wrong: tryMark/markToken never consult the store, and the replay path's commitRefresh never refuses — so apply declines only on the never-worse check. Reaching this state needs M to appear verbatim, at cached depth, with a live cg:res: from extract_llm and no marker, and I have not built that. It may well be unreachable today.

What I would not do is leave the invariant asserted in a comment when the key cannot express it. Namespacing the sweep's records, or storing a component tag in cachedResult, makes the claim true by construction and costs one field — cheaper than establishing unreachability now and re-establishing it after the next pipeline change.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the principle, and I have taken the half that does not change behaviour mid-review: the comment no longer asserts what the key cannot express. It now states what a hit actually proves — that some component in this session recorded a decision for these bytes — and points at #197 for the design fix.

Your calibration is the reason #197 is worth reading rather than skimming, so it is reproduced there: the obvious mechanism (extract_llm's replay apply() failing for want of a reserve slot) does not exist, because tryMark/markToken never consult the store and the replay path's commitRefresh never refuses — so apply declines only on the marker-inclusive never-worse check. I would have written that path into the issue as the motivating case if you had not checked it.

Where I have deliberately not followed you is on fixing it here. Option 2 (a component tag in cachedResult, checked on read) is the right fix and I agree it costs one field — but it changes which records a component will replay, and therefore savings, inside a PR whose subject is reserve durability and whose numbers feed the iteration-024 re-run. That is the same argument you accepted for leaving extract_llm's savings basis to #195, and I think it applies identically here. #197 records option 2 as the recommendation so the next person does not re-derive the options.

What I want to be straight about: that leaves an invariant holding by accident of the current pipeline shapes rather than by construction, which is exactly the thing you argued against. My position is only that the accident is documented and filed rather than asserted — not that documenting it is as good as fixing it.

Comment thread components/offload/extract_llm.go Outdated
// The follower's saving stays UNBOOKED either way — its leader books the shared result
// once, and attributing it twice would over-count. Booking per spliced message rather
// than per call is the real fix and is larger than this change.
if calls[k].Component != "" {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — the predicate is right for production and wrong for the tests, which is the opposite trade from the one it replaced.

calls[k].Component is just rep.Component (:1273), and components/pipeline.go:81 always sets it, so production is unaffected — the commit's comment is correct that this is a no-op there.

But the coupling has a cost the before > 0 version did not: every in-package test that drives ExtractLLM.Offload with a bare &components.Report{} now takes a path where Accepted, SavedTokens, ratios.observe and both recorders are skipped wholesale. I confirmed bare components.Report{} is used widely across this package's tests. So a future regression inside that block cannot be caught from those fixtures — and given how much of this PR's value came from tests that catch exactly this class, that is the wrong thing to make harder.

The stated intent ("only a slot that actually MADE A CALL", robust against someone later filling before in the follower branch) is expressible without coupling accounting to a labelling field: an explicit out[k].called = true set beside the calls[k] = ... assignment says it directly, cannot be re-enabled by accident, and does not depend on how a fixture initialises its Report.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and this is the better trade — the version I replaced was wrong in production-invisible ways, and I replaced it with something wrong in test-invisible ways, which is worse given where this PR's value has come from.

It is now an explicit out[k].called, set beside the calls[k] = ... assignment. Direct, cannot be re-enabled by filling before in the follower branch, and independent of how a fixture initialises its Report.

Your finding also exposed a missing test, and then that test caught a bug in my own fix. Every existing test asserted "books nothing when declined" — which a component that books nothing ever also satisfies, so nothing covered the positive direction. TestExtractLLMBooksItsOutcomeEvenWithAnUnnamedReport is that counterpart, and it deliberately uses a bare &components.Report{} because that is the fixture shape your finding is about. It asserts on the ratio tracker rather than rep.Calls, since the ledger append is legitimately filtered on Component and with an unnamed component there is no row — which is itself the argument for not keying accounting on the same field.

It failed immediately on my fix. The accept branch did out[k] = outT{projected: res, summary: sum} — a struct literal that silently reset called, so phase 3 skipped the booking for every real call. Field assignment now, with a note that the follower path may keep its literal precisely because it has no called to preserve, which is what made it easy to miss.

Three mutations bind it: M56 (never set the flag), M57 (reset it via the literal — the bug the fix introduced), M58 (key on Component again). All three fail on that test's own assertion.

M56 initially escaped against TestExtractLLMLogsOneRecordPerCall, which passes when accepted is false on both sides — which is how the missing positive test surfaced at all.

amiddavid added a commit that referenced this pull request Sep 3, 2026
…ing accounting on a label

Round 5 of the #188 review. Four findings, one of which was caused by a CORRECT
fix from round 4, and one of my repairs introduced a bug that its own new test
then caught.

A MEASUREMENT CHANGE VOIDED A TEST THAT NEVER MENTIONED THE MEASUREMENT.

Moving the sweep's saving to the wire basis was right, and it made
TestSweepReportsNoSavingsForARefusedReplay unable to fail: sweepDrop only calls
SetMessageText on success, so on a decline `saved == 0` BY CONSTRUCTION and the
recorder's position is unobservable. The reviewer confirmed by mutation — booking
above the ok gate now PASSES.

Re-scoped rather than deleted, and the doc says what it still pins: rep.Replay,
which sits inside the ok branch and would fire for a message never rewritten. What
guards the saving is now TestSweepReplayBooksWhatTheReplayedMessageActuallySaved —
revert the basis and it fails, which also restores this test's original premise.
Renamed to TestSweepRecordsNoReplayForADropItDeclined so the name stops claiming
the half that is now structural.

THE LEDGER'S REASON IS DERIVED, NOT ENUMERATED.

"THREE reasons, not two" was still short by at least two paths. RefusedObligation
and VerdictUnusable both `continue` without raising the pre-check gate, so a model
answering drop with a needed_by for EVERY candidate produced a row saying the
adjudicator found nothing spent — when it judged every output spent and we
declined each one for self-contradiction.

Enumerating skips was the mistake. The reason now derives from spentJudged,
counted where the verdict is read, and the GATES carry which skip it was — they are
already raised and stay correct when a skip is added. Counted as
`a.Drop || a.RefusedObligation`, because extract.Judge returns early on a
self-contradictory drop WITHOUT setting a.Drop, so counting a.Drop alone missed
precisely the path that made the reason wrong. VerdictUnusable is deliberately not
counted: the model answered neither drop nor keep, so it judged nothing spent.

ACCOUNTING NO LONGER KEYS ON A LABELLING FIELD. `calls[k].Component != ""` is just
rep.Component, which pipeline.go always sets in production — so the coupling was
invisible there and broke the TESTS: most fixtures in this package pass a bare
&components.Report{}, and from those the whole booking block was skipped, making
a future regression inside it uncatchable. Now an explicit out[k].called flag.

That repair introduced its own bug, and the test written for it caught it: the
accept branch did `out[k] = outT{...}`, a struct literal that silently reset
`called`, so phase 3 skipped the booking for every real call. Field assignment
now. The follower path may keep its literal because it has no `called` to
preserve, which is what made this easy to miss.

AND ONE COMMENT THAT ASSERTED WHAT THE KEY CANNOT EXPRESS. A cg:res: hit does not
prove "this session already sent THESE bytes": resultKey is
cg:res:<session>:<id> with no component tag, extract_llm keys the same namespace
off the same ContentKey, and the shipped housellm preset runs both in one
pipeline. So a hit proves only that SOME component in this session decided about
these bytes. Corrected to say that; the design point is #197, with the reachability
analysis — the obvious path through a refused reserve slot turns out not to exist,
and unreachability is not established either.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (Go
1.26.4, eval box).

6 mutations, all caught ON THEIR OWN ASSERTION — the harness now enforces five
gates (landed exactly once, compiled, the test ran, it failed, and the failure
output contains the subject's assertion text), because "did it fail" scored two
earlier mutations as evidence when they were not:

  M53 count only a.Drop                -> the RefusedObligation row reads
                                          "nothing was spent"
  M54 enumerate one skip gate again    -> the same, plus the wrong string
  M55 rep.Replay outside the ok gate   -> fired for a message never rewritten
  M56 the called flag is never set     -> ratio tracker does not move
  M57 accept branch resets outT        -> same; this is the bug the fix introduced
  M58 booking keyed on Component again -> same, from a bare Report fixture

M56 initially ESCAPED against TestExtractLLMLogsOneRecordPerCall, which passes
when accepted is false on both sides. Every existing test asserted "books nothing
when declined", which a component that books nothing ever also satisfies — so the
positive counterpart was missing. TestExtractLLMBooksItsOutcomeEvenWithAnUnnamedReport
is that test, and it deliberately uses a bare Report because that is the fixture
shape the Component coupling broke.

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Round 5 addressed in b01f153. You were right that this was not a clean pass — and two of the four are the most interesting findings of the review so far, for opposite reasons.

The voided test is a new category. A measurement change can void a test that never mentioned the measurement — and it was caused by a correct fix, mine, made at your request. Every earlier trap on this PR was an error in how I built evidence; this one is what happens when a sound change moves the ground under a test reasoning about something adjacent. No care in writing the mutation would have caught it. The habit it produces: after changing how a quantity is computed, re-run the mutations for every test that asserts on that quantity, not only the ones that mention it. Re-scoped rather than deleted, with the doc naming what it still pins and which test now guards the saving.

The reasons are now derived rather than enumerated, which is the deliberate decision you asked for instead of another round of one path at a time. spentJudged is counted where the verdict is read; the gates carry which skip it was. One detail your reading did not surface and it broke my first attempt: extract.Judge returns early on a self-contradictory drop without setting a.Drop, so counting a.Drop missed exactly the RefusedObligation path you named. It is a.Drop || a.RefusedObligation — what the model answered, not what validation concluded.

Your predicate finding exposed a missing test, and that test then caught a bug in my own fix. Every test in this package asserted "books nothing when declined", which a component that books nothing ever also satisfies — so the positive direction was uncovered. The new counterpart failed immediately: the accept branch did out[k] = outT{...}, a struct literal that silently reset called, so phase 3 skipped the booking for every real call. That is the shape of your original objection turned inside out, and it would have shipped.

On the shared namespace I have taken half and said why. The comment no longer asserts what the key cannot express, and #197 carries the design fix with your calibration — including that the obvious mechanism does not exist, which I would have written in as the motivating case had you not checked it. I have not added the component tag here, on the same argument you accepted for #195: it changes which records a component replays, and therefore savings, inside a PR whose numbers feed the re-run. But I want to be straight that this leaves an invariant holding by accident of the current pipeline shapes rather than by construction — documented and filed is not as good as fixed, only better than asserted.

Verification. gofmt · go vet · go test ./... all clean. 6 mutations, all caught on their own assertion, through the five-gate harness. M56 escaped first against an existing test that passes when accepted is false on both sides — which is how the missing positive test surfaced.

Tally now eight, with the two newest being of a different kind from the first six: a test voided by a correct measurement change, and a suite that only ever asserted the negative direction.

…val whose original cannot be stored

The store kept "this output was dropped" and evicted "here is what it was". Pinned
namespaces protect the DECISION records (cg:res:, cg:xres:, cg:frz:, …); a stash payload's
key is the marker id itself — a bare content hash — so it can carry no prefix and was
plain-LRU. One reversible removal writes five entries, of which two are pinned and one is
the payload, against a process-wide 1,000-entry cache shared by every concurrent session.
So a marker was stamped, the request told the model it could have the content back, and the
content was gone: expand_unresolved_missing 209 in iteration 024's arm B, 0 in the arm
where the sweep was inert.

Measured on the shipped defaults, 600 removals: 100 of 600 payloads survived against 350 of
600 of each pinned decision — the payload was 3.5x less durable than the record naming it.

The reserve (store.Stasher / PutStash) is half the entry cap and LRU pressure cannot take
one; only the TTL can. Its boolean is the load-bearing half: when the reserve is full the
payload is REFUSED, and every writer — commitMark and the three paths that stash outside it
— declines the removal and leaves the content verbatim rather than stamping a marker nothing
can resolve. So the guarantee no longer degrades as the mechanism succeeds: outstanding
promises stay good however long the run gets, and the cost of a full reserve lands on
removals not yet made. stash_refused / stash_live / stash_capacity / stash_expired publish
it at /stats and /metrics, upstream of expand_unresolved_missing, which cannot move until an
agent happens to call expand.

Default max_entries 1,000 -> 5,000: 1,000 was an order of magnitude under the observed
volume, and the refusal is what makes overrunning it visible rather than silent.

Adjacent to #47 by the same root cause — one fixed entry budget serving unrelated
namespaces. Not fixed here; the larger cap relieves the pin pressure it describes.

Closes #187

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
… in bytes under an evictable floor

Addresses the seven inline findings on #188. They cluster on two structural
problems, and each fix is an invariant rather than a patch to the sites named.

THEME 1 — nothing may be recorded before the gate that can now refuse.

#188 gave commitMark the power to decline a removal, and four call sites still
recorded state or metrics ahead of it. The dangerous half is not the lost saving:
a frozen cg:res: decision with no splice behind it is read on a later turn by the
same-session replay path, which DELIBERATELY BYPASSES the cache-tail gate on the
reasoning that these bytes were already sent — so once a reserve slot frees, the
compaction is spliced into a message by then inside the provider's cached prefix,
forcing a full-suffix cache write at ~11.5x the read price. #188's reversibility
fix had introduced a reversibility-adjacent bug.

  - extract_llm phase 3 and its same-session replay: putResult /
    putResultGlobal, RecordExtractionValue, dbgReapply and rep.Replay now wait
    for apply to report that it spliced.
  - extract_llm's CROSS-SESSION hit: the third site, not in the review — found
    by auditing all twelve callers. Its own comment says the branch is a NEW
    decision for this session, which is why it is tail-gated, so its payload
    write can refuse like any other.
  - the sweep's phase 3 and replay: the drop before the decision, and
    RecordExtractionValue moved inside the ok branch (rep.Replay there was
    already guarded, which is what made the metric beside it easy to miss).

Stated in code, not in a comment: commitMark's contract now spells the invariant
out, and TestNoStateIsRecordedBeforeTheCommitGate drives registered offloaders
against a saturated reserve asserting no marker on the wire and no decision
write. TestEveryOffloadComponentIsHeldToTheCommitGate makes the THIRTEENTH caller
declare itself — every registered Offload must be driven or listed in gateExempt
with the test that pins it, the same mechanism promexport_coverage_test.go uses.

cmdfilter no longer hand-rolls the pair. It built its own token, ran its own
never-worse check and called store.PutStash itself, differing from tryMark only
in the recovery hint tryMark already takes as a parameter. That copy is why the
gate had to be applied there separately, and why a mutation stamping an unbacked
marker survived a review round in that file alone.

A REPLAY is now a different operation from a new removal. commitRefresh never
refuses — the marker is already in the provider's cached prefix, so declining
sends the original in full, which is the cache-destructive move and cannot
un-send the marker — and a missing payload is diagnosed rather than obeyed.

THEME 2 — the reserve was one shared, entry-counted, TTL-only budget.

  - BYTE BUDGET. stash_max_bytes, default 256 MiB. Entries are a poor proxy for
    memory in this one namespace: every other exempt entry is a marker line or an
    integer, a payload is a whole tool output, so max_entries named a memory
    figure spanning two orders of magnitude depending on nothing the operator
    chose.
  - AN EVICTABLE FLOOR. pinCap and stashCap are each max/2, so together they
    could occupy the whole entry cap — and a cache with nothing evictable does
    not fail loudly: the next plain Put is evicted by its own insert, silently
    turning cg:keep: (the flag that stops the expand loop), cg:sum:, cg:own: and
    cg:xseen: into no-ops, on precisely the workload the reserve was built for.
    A quarter of max_entries is now held back from both exemptions.
  - O(1) refusals. sweepExpired makes one pass instead of one per reclaimed
    entry, and nextExpiry (a lower bound on the earliest expiry) skips the walk
    entirely when nothing can have expired. Before, every refused PutStash
    walked the whole list under the global mutex.

Per-session partitioning was considered and declined: no session reads another's
payload, so what is shared is the budget, and sizing it against the real resource
addresses that more honestly than a fair-share number that would itself be a
guess. Noted as a follow-up in the PR.

FURTHER FINDINGS

  - stash_missing, separate from stash_refused. They are opposite outcomes and
    shared a counter: every operator-facing description of a refusal promises
    "nothing became irreversible", which is true of a declined removal and false
    of a dangling replay — the one case that actually breaks the #187 guarantee.
    So the number an operator watches to confirm nothing broke was incremented by
    things breaking. It grows with turn count, not with distinct markers, and says
    so.
  - summarize asks the reserve BEFORE the model call. The check sat after it, so
    a saturated reserve paid a ~57k-prompt-token call and discarded it — every
    turn, because a refusal saves no checkpoint and so nothing about the next
    turn changes. The span is all the marker key depends on. A probe, not a
    claim: claiming the slot early would leak one payload whenever the call then
    failed. agentdiet gets the same probe, weaker on purpose (smallest
    candidate).
  - summarize's refusal no longer flips cached content. Once a checkpoint has
    been emitted, earlier turns sent [msg0, summary, tail]; a bare return sent
    the transcript FULL. When the checkpoint is stale-but-valid (tryReuse
    verified its prefix hash and declined only on size) it is re-emitted
    instead. A session with no checkpoint still sends the full transcript, and
    that case is asserted so the fallback cannot become unconditional.
  - docs/reference/config.md — the page an operator reads when stash_refused
    tells them to raise max_entries — documented max_entries as 1000 and
    eviction as pinning-only. Corrected there and in design.md and
    how-to/recover-context.md, with both budgets and the floor described, and
    routes.md now separates the two counters. The four #188 keys in
    statsGoldenTopLevel are back in alphabetical order.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (Go
1.26.4, eval box).

23 mutations, each reverting ONE subject whole in a scratch tree, all caught:

  store  M11 exemptRoom always true            -> 0 of 200 entries evictable; the
                                                  cg:keep: write vanishes
         M12 byteRoom always true              -> 6 x 2 KiB accepted into 5 KiB
         M13 bytes never credited back         -> reserve stays byte-full past TTL
         M14 StashRoom always true             -> full reserve reports room
         M15 StashRoom skips reclamation       -> full after its payloads expired
         M16 probe claims a slot               -> second probe disagrees
  offload M17 phase 3 freezes before splicing  -> 2 dangling decisions
         M18 cross-session freezes first       -> cg:res:sessB with no splice
         M19 sweep freezes before dropping     -> 11 dangling decisions
         M20 sweep books a declined saving     -> value rose for no drop
         M21 replay metrics ungated            -> rep.Replay for a verbatim message
         M22 dangling counted as refused       -> the conflation the review named
         M23 replay refuses instead of replays -> original sent at depth
         M24 reserve checked after the call    -> 1 call paid and discarded
         M25 refusal sends the full transcript -> 19 messages where 2 were cached
         M26 cmdfilter stamps despite refusal  -> unbacked marker on the wire
         M27 agentdiet probe removed           -> reflection paid for nothing
         M28 gateExempt emptied                -> every undriven Offload named
  proxy  M29 /stats drops the byte budget      -> stash_max_bytes 0
         M30 /stats drops stash_missing        -> 0 while the counter read 1
         M31 /metrics drops the byte gauge     -> both lines absent
         M32 /metrics drops stash_missing      -> series absent
         M33 reapplyFrozen stops diagnosing    -> no dangling replay recorded

Four tests were vacuous or wrong on the first cut and are recorded because the
shape recurs. TestSweepReportsNoSavingsForARefusedReplay asserted an unchanged
message, which is also what a component that never ran leaves behind: it now
requires a result-cache HIT (CacheLookups counts misses too, so the first
precondition was satisfied by the miss further down the loop) and prices
SelfRates high, because at real cache-read rates the saving under test is ~1e-6
USD and round4 reports it as 0.0000 — the assertion could not see the defect.
TestExtractLLMReportsNoSavingsForARefusedReplay assumed a replay could refuse; it
cannot by design, so it was re-cut onto the marker-inclusive decline, which is
the case that remains reachable. The cross-session fixture used the same content
in both sessions, where the payload is already present and the write is a refresh
that cannot refuse — it now reproduces the real state, payload gone and pinned
decision alive. And M27 initially failed only by breaking the build, so it was
re-cut to compile and bind.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…k no outcome before the splice

Round 2 of the #188 review. Seven findings; the first is a regression the previous
commit introduced, and the third is that commit's own fix being half-applied.

REGRESSION — a degraded-mode replay stopped saying it was irreversible.

commitMark's non-full branch sets rep.Irreversible, and that is what exempts a
deliberate lossy drop from components/pipeline.go's "dropped content without
stashing a cache_key" revert. When the replay branches stopped calling commitMark
they stopped setting it, so under marker_mode summary/off every replay turn had
the whole component reverted and sent the transcript verbatim — a full-suffix
cache write per turn, the exact harm the reserve work exists to prevent.

Confirmed by execution, two turns of extract_llm at markerSummary:

  turn 1 (fresh, via commitMark): keys=0 Irreversible=true  Replays=0
  turn 2 (replay branch):         keys=0 Irreversible=false Replays=1

commitRefresh now takes rep and eff and owns Irreversible the way commitMark
does, so the two are symmetric and a third caller cannot reintroduce it. The
asymmetry was easy to miss because commitRefresh's early `key == ""` return reads
as a no-op guard and IS the non-full case.

The same omission exists in reapplyFrozen and is NOT from the previous commit —
it predates the reserve work entirely. Every turn after the one that took the
decision replays through there, and a summary-mode replacement carries no
<<cg:HASH>> to parse, so it returned no keys and set no flag. Found by auditing
the replay paths once the review named the two that had stopped calling
commitMark. Fixed with the same rule: a replay with no markers is a degraded-mode
replay and says so.

THE FRESH PATHS were still booking before the gate; the previous commit moved
only the replay paths.

  - extract_llm: RecordExtractionSaving, RecordExtractionValue,
    e.ratios.observe, calls[k].Accepted and calls[k].SavedTokens all ran inside
    runCall — in a goroutine, before wg.Wait(), and therefore before phase 3
    exists. On a saturated reserve every candidate is declined and the run
    reported the full saving anyway. The call's arithmetic now rides in the outT
    slot phase 3 already reads, and is booked once the splice is a fact.
  - The ratio feed is the consequential one: it prices FUTURE calls, so a saving
    that never happened propagated into decisions about work not yet done. A
    declined splice now observes nothing (a model that produced nothing is
    separate evidence and is still observed as ratio 0, in runCall).
  - The per-call debug record is deferred past phase 3 through a closure, or
    `accepted` would report false on every call — the mirror image of the
    overclaim. TestExtractLLMLogsOneRecordPerCall caught that while it was still
    wrong, which is the test working as intended.
  - extract_sweep: adjudicate recorded the saving while building its drop list,
    and the local sweep_drop_would_not_shrink pre-check covers neither refusal it
    can now meet — it is descriptor-only rather than marker-inclusive, and it
    cannot see the reserve at all. Booked per candidate in phase 3; the ledger row
    is filled from what was APPLIED, with its own rejection reason for
    "adjudicated spent, but no drop could be applied".
  - Both ledger appends moved after phase 3, because rep.Calls takes a COPY.

ALSO

  - agentdiet's reserve gate now increments stashRefusals. Gating without
    counting made one component's refusals invisible to the counter that
    docs/reference/config.md names as THE signal to raise a budget.
  - evictableFloor is at least 1. max/4 is 0 for max_entries of 2 or 3, so the
    two exemptions could reach the whole cap and the floor this repo documents as
    unconditional had a hole at the bottom of the range.
  - TestExtractLLMReportsNoSavingsForASpliceItDeclined was blind in the half its
    own name claims: ~4 tokens at the agentFreshPerMTok fallback is ~1.2e-5 USD
    against round4's 1e-4 step, so the savings assertion could not fire. Only the
    rates fixture was wrong; the assertions were right and are unchanged. This is
    the second time the same rounding trap landed in this change, which is what
    makes it a trap rather than an oversight.

THE INVARIANT TEST NOW COVERS THE CLASS, not three point fixes — the reviewer's
suggestion, and better than what it replaces. TestNoStateIsRecordedBeforeTheCommitGate
asserts METRIC DELTAS (gross saved tokens, gross value, and every ledger row's
accepted/saved_tokens) alongside store writes, and extract_llm and
extract_llm_sweep are now DRIVEN by the table rather than exempted from it, so
both fresh paths are covered where the class is stated. gateExempt is down to
agentdiet and summarize, each naming the test that pins it.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass, and
./dash passes standalone (Go 1.26.4, eval box).

30 mutations, each reverting ONE subject whole, all caught by a TEST failure
rather than a build break. The 23 from round 1 were re-run and three had to be
re-cut, because this commit changed the code they patch — a mutation that no
longer lands proves nothing, and one of the three had been passing as a build
break. New this round:

  M34 replay branch drops rep.Irreversible   -> the reported regression, both sites
  M35 reapplyFrozen drops rep.Irreversible   -> the pre-existing twin
  M36 extract_llm books in the goroutine     -> 6072 tokens booked, ratio moved
                                                0.12 -> 0.45, accepted=true on a
                                                verbatim request
  M37 sweep books in adjudicate              -> 86458 tokens booked for no drop
  M38 agentdiet stops counting refusals      -> declined every turn, counter at 0
  M39 evictableFloor back to a bare max/4    -> at max_entries 2, 0 evictable
  M40 replay metric ungated, rep.Replay left -> proves the savings half now binds
      guarded                                   ALONE, which was the finding
  M41 extract_llm books early                -> caught by the TABLE test alone
  M42 sweep books early                      -> caught by the TABLE test alone
  M43 an Offload in neither table nor exempt  -> named by the completeness check

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hed the wire

Round 3 of the #188 review. One HIGH regression from the previous commit, its
MEDIUM consequence, and three LOW findings.

THE REGRESSION, and it is the same shape as the one round 2 fixed.

Moving the metrics out of adjudicate's verdict loop deleted `removed`'s only
assignment while leaving the `if removed == 0` read. Go then guarantees it stays
0, so EVERY adjudication stamped "adjudicated: nothing was spent" — including
ones that dropped content. A ledger row could carry accepted=true, a large
saved_tokens, and "nothing was spent" simultaneously: exactly the
self-contradictory row this work set out to eliminate, arrived at from the other
side. Nothing caught it because the variable was still READ, so it compiled and
the suite stayed green.

Fixed as the reviewer suggested, which is also more honest to the comment above
it: the rejection now tests `len(drop) == 0`, because `drop` IS the set the
comment describes as "what the adjudicator judged spent". The token total is a
separate concern and gets its own name — `judgedTokens`, for the cg.sweep.ask
debug row, which had silently gone permanently 0 and taken the sweep's own
economics out of the only place a run's decisions can be reconstructed from.

Its consequence: the reserve-exhausted rejection reason added last commit was
DEAD CODE, because adjudicate always left a non-empty Rejection so the
`Rejection == ""` guard never opened. So the case that reason exists to
distinguish still read as a plain rejection — the finding the reason was added to
fix. Fixed by the above; now tested for by name.

THE LEDGER NOW CARRIES THE WIRE'S FIGURE. `saved` was
content − descriptor, but in markerFull the text spliced is
descriptor + marker + recovery hint, so every candidate was overstated by the
marker's tokens — while the comment I had just written claimed the figure was
"what reached the wire", which is the one thing it was not. Measured against the
spliced message instead. Verified: 86,458 claimed against 86,201 actually sent.

ALSO

  - extract_llm's "a declined splice observes nothing" comment argued one side
    only. Under a persistently saturated reserve nothing advances r.total, so
    ratio() stays pinned to its prior and exploring() keeps granting its per-
    session budget instead of self-terminating — a small permanent spend on
    exactly the deployments this change is for. Bounded, and the better side of
    the trade, but the previous behaviour did terminate exploration and the
    comment now says so.
  - A single-flight FOLLOWER returns with out[k].before still 0, and phase 3
    spliced its projection and then booked observe(0,0) plus two zero savings for
    a removal that did happen, while setting Accepted on a row whose Component is
    "". Harmless only through three independent zero-guards. Now guarded on
    `before > 0`: the follower's saving stays unbooked (its leader books the
    shared result once), but the pretence of measuring it is gone.

TESTS FOR THE TWO THINGS NOTHING PINNED

  - TestSweepTellsAReserveRefusalApartFromNothingSpent asserts both rejection
    strings by name. The reserve-exhausted one was unreachable, so no existing
    test could have covered it.
  - TestADegradedModeReplayDeclaresItselfIrreversible gains an extract_sweep_drop
    subtest. That was the one replay branch of four with no test of its own — the
    reviewer confirmed by execution that it is fixed, but nothing pinned it.
  - TestSweepReportsItsOwnEconomicsAndTheWiresSeparately asserts the debug row's
    removed_tokens is non-zero AND that the ledger's total equals the exact token
    delta of the messages actually sent.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (Go
1.26.4, eval box).

5 new mutations, all caught by a TEST failure:

  M44 the rejection tests a total with no assignment  -> removed_tokens=0 on a
                                                         turn that dropped content
  M45 judgedTokens loses its assignment               -> same
  M46 the reserve-exhausted reason is dropped         -> rejection "" where the
                                                         distinct string is required
  M47 sweep_drop replay drops rep.Irreversible        -> the revert conjunction
  M48 sweep books the descriptor-only figure          -> 86458 claimed, 86201 sent

Three of these did not bind on the first attempt and the reasons are recorded
because they recur: M44 as first cut was behaviour-equivalent to the fix rather
than a revert of the bug; M45 had no assertion to fail because nothing read the
debug row; M48 named a test that did not exist and reported "no tests to run",
which the harness now treats as not-caught rather than as a pass.

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…et-wide, not per-session

Comment only; no behaviour change.

The previous comment called the cost of recording no ratio observation for a
declined splice "bounded per session". That under-counts it by the number of
concurrent sessions, and the comment is what a future reader will reason from.

The store is ONE process-wide instance, so saturation is CORRELATED rather than
independent: when the reserve is full it is full for every session at once. The
spend is maxExploreCalls x every concurrent session, repeating for as long as the
episode lasts — and with a sliding 10,000 s TTL an episode can run for hours. The
deployments that pay it are the under-provisioned ones in #190, which are the
least able to absorb it.

The trade still stands and the behaviour is unchanged: observing the achieved
ratio would authorise calls whose output is discarded, and observing 0 would
assert the workload is incompressible — false, and it would also suppress calls
that WOULD land once the reserve frees. Observing nothing is the only one of the
three that does not lie. But "small" and "bounded per session" are different
claims, and only the first is true.

Also records the option that gets both properties, so it is not rediscovered:
gate exploring() on RESERVE HEALTH rather than on r.total. stash_refused already
distinguishes "cannot learn because we are refusing" from "no evidence yet",
which is precisely what r.total cannot express. Deliberately not implemented here
— it is the same question as #190 and wants the re-run's numbers, so it is on
that issue's candidate list instead of being guessed at in this diff.

Raised by the reviewer, who also worked out the fleet-wide arithmetic.

Verification: gofmt -l . clean, go vet ./... clean, go test ./... all packages
pass. No test accompanies this commit because it changes no behaviour; the
behaviour it describes is pinned by
TestExtractLLMBooksNothingForAFreshSpliceItCouldNotStash, which asserts the
ratio tracker does not move on a declined splice.

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…acuity guard able to fail

Round 4 of the #188 review: one LOW that was a self-contradiction this branch
introduced, two LOWs about claims that were not true, and a test guard that could
not fail.

THE VACUITY GUARD WAS ITSELF VACUOUS, and it was guarding the branch whose
regression started this thread.

assertReplayIsExemptFromRevert compared EVERY message against ONE string — the
candidate's original — so the userMsg every fixture carries ("summarize the log")
differed from it by construction and `shrank` was unconditionally true. The guard
could never detect the case its own fatal message describes.

Two of the three subtests were saved by a separate `Replays == 0` fatal. The
extract_llm one had no such check, so it would have passed vacuously if that
replay ever stopped rewriting — and that is the exact branch whose regression the
review found two rounds ago.

Now each message is compared against ITS OWN captured before-state. Demonstrated
with one mutation run twice: a replay that counts itself but splices nothing
ESCAPES the old guard and is CAUGHT by the new one, which is the property at
issue rather than a proxy for it.

THE SWEEP CONTRADICTED ITSELF, as of the previous commit. The fresh path was
corrected to measure the saving against the spliced message; the replay path was
left measuring against the stored descriptor. So the same drop was valued higher
on every replay turn than on the turn it was made — and replays are the steady
state, so most of the reported value came from the overstated side. Before the
previous commit both paths were descriptor-only and at least consistent, so this
was introduced rather than pre-existing. Both now measure the message.

TWO COMMENTS THAT CLAIMED MORE THAN WAS TRUE

  - The `before > 0` guard added last commit prevents no live defect: observe
    returns early on totalTok <= 0, both recorders are no-ops at 0, a follower's
    saved is already 0, and its row is dropped by the Component filter. The
    comment said otherwise, which would leave a reader reasoning from a false
    premise. It now says it is a defence, and the predicate is `Component != ""`
    — the direct statement of "this slot made a call", and one that cannot be
    re-enabled by accident if someone later fills `before` in the follower branch.
  - `len(drop) == 0` conflated TWO reasons, not one. The never-worse pre-check can
    remove every output the adjudicator judged spent, and the row then told the
    operator the model found nothing worth removing when it found plenty and the
    descriptors were not smaller. That case now has its own reason. Reachable on
    outputs barely above the floor, where a shape descriptor can exceed the output
    it describes — which is what the new subtest builds.

NOT FIXED HERE, deliberately: extract_llm's saved excludes the SUMMARY as well as
the marker, so its overstatement is variable and summary-dominated rather than the
fixed per-candidate overhead #195 described. #195 is corrected; the basis change
still belongs there, after the iteration-024 re-run, so a savings-basis change
does not land in the middle of the comparison it would invalidate.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (Go
1.26.4, eval box).

4 new mutations, all caught by a test failure:

  M49 the guard compares to one original      -> caught (via the neutered branch)
  M50 sweep replay books the descriptor again  -> 86458 booked, 86201 sent
  M51 the pre-check case falls back to
      "nothing was spent"                      -> the exact wrong string
  M52 a replay that counts but splices nothing -> CAUGHT by the repaired guard,
                                                  ESCAPES the old one

M52 is the one that matters: M49 turned out to be caught by the subtest's
Irreversible assertion rather than by the guard, so it did not isolate what it was
meant to. M52 does — same mutation, two guards, opposite outcomes.

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… overridden

newSweep prepends its own `min_tokens: 2000`, so passing one in extraYAML does not
override it — YAML rejects the duplicate key and newExtractSweep fails with

	yaml: unmarshal errors: line 3: mapping key "min_tokens" already defined at line 1

which reads as a broken component rather than as a fixture mistake. It cost a
round trip while writing the never-worse pre-check subtest, which needs a floor
BELOW the shape descriptor's own size and therefore has to call newExtractSweep
directly.

Documented at the fixture rather than left in a PR thread, on the reviewer's
point that a trap recorded only in review discussion gets rediscovered. Comment
only; no behaviour and no test outcome changes.

Verification: gofmt -l . clean, go vet ./... clean, go test ./... all packages
pass.

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing accounting on a label

Round 5 of the #188 review. Four findings, one of which was caused by a CORRECT
fix from round 4, and one of my repairs introduced a bug that its own new test
then caught.

A MEASUREMENT CHANGE VOIDED A TEST THAT NEVER MENTIONED THE MEASUREMENT.

Moving the sweep's saving to the wire basis was right, and it made
TestSweepReportsNoSavingsForARefusedReplay unable to fail: sweepDrop only calls
SetMessageText on success, so on a decline `saved == 0` BY CONSTRUCTION and the
recorder's position is unobservable. The reviewer confirmed by mutation — booking
above the ok gate now PASSES.

Re-scoped rather than deleted, and the doc says what it still pins: rep.Replay,
which sits inside the ok branch and would fire for a message never rewritten. What
guards the saving is now TestSweepReplayBooksWhatTheReplayedMessageActuallySaved —
revert the basis and it fails, which also restores this test's original premise.
Renamed to TestSweepRecordsNoReplayForADropItDeclined so the name stops claiming
the half that is now structural.

THE LEDGER'S REASON IS DERIVED, NOT ENUMERATED.

"THREE reasons, not two" was still short by at least two paths. RefusedObligation
and VerdictUnusable both `continue` without raising the pre-check gate, so a model
answering drop with a needed_by for EVERY candidate produced a row saying the
adjudicator found nothing spent — when it judged every output spent and we
declined each one for self-contradiction.

Enumerating skips was the mistake. The reason now derives from spentJudged,
counted where the verdict is read, and the GATES carry which skip it was — they are
already raised and stay correct when a skip is added. Counted as
`a.Drop || a.RefusedObligation`, because extract.Judge returns early on a
self-contradictory drop WITHOUT setting a.Drop, so counting a.Drop alone missed
precisely the path that made the reason wrong. VerdictUnusable is deliberately not
counted: the model answered neither drop nor keep, so it judged nothing spent.

ACCOUNTING NO LONGER KEYS ON A LABELLING FIELD. `calls[k].Component != ""` is just
rep.Component, which pipeline.go always sets in production — so the coupling was
invisible there and broke the TESTS: most fixtures in this package pass a bare
&components.Report{}, and from those the whole booking block was skipped, making
a future regression inside it uncatchable. Now an explicit out[k].called flag.

That repair introduced its own bug, and the test written for it caught it: the
accept branch did `out[k] = outT{...}`, a struct literal that silently reset
`called`, so phase 3 skipped the booking for every real call. Field assignment
now. The follower path may keep its literal because it has no `called` to
preserve, which is what made this easy to miss.

AND ONE COMMENT THAT ASSERTED WHAT THE KEY CANNOT EXPRESS. A cg:res: hit does not
prove "this session already sent THESE bytes": resultKey is
cg:res:<session>:<id> with no component tag, extract_llm keys the same namespace
off the same ContentKey, and the shipped housellm preset runs both in one
pipeline. So a hit proves only that SOME component in this session decided about
these bytes. Corrected to say that; the design point is #197, with the reachability
analysis — the obvious path through a refused reserve slot turns out not to exist,
and unreachability is not established either.

VERIFICATION

gofmt -l . clean, go vet ./... clean, go test ./... all packages pass (Go
1.26.4, eval box).

6 mutations, all caught ON THEIR OWN ASSERTION — the harness now enforces five
gates (landed exactly once, compiled, the test ran, it failed, and the failure
output contains the subject's assertion text), because "did it fail" scored two
earlier mutations as evidence when they were not:

  M53 count only a.Drop                -> the RefusedObligation row reads
                                          "nothing was spent"
  M54 enumerate one skip gate again    -> the same, plus the wrong string
  M55 rep.Replay outside the ok gate   -> fired for a message never rewritten
  M56 the called flag is never set     -> ratio tracker does not move
  M57 accept branch resets outT        -> same; this is the bug the fix introduced
  M58 booking keyed on Component again -> same, from a bare Report fixture

M56 initially ESCAPED against TestExtractLLMLogsOneRecordPerCall, which passes
when accepted is false on both sides. Every existing test asserted "books nothing
when declined", which a component that books nothing ever also satisfies — so the
positive counterpart was missing. TestExtractLLMBooksItsOutcomeEvenWithAnUnnamedReport
is that test, and it deliberately uses a bare Report because that is the fixture
shape the Component coupling broke.

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amiddavid
amiddavid force-pushed the fix/stash-durability-187 branch from b01f153 to 13b32e5 Compare September 3, 2026 15:19
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Rebased onto main now that #194 has mergedb01f15313b32e5, force-pushed with --force-with-lease. No content changes beyond the rebase; the pre-rebase head is tagged locally as pre-rebase-188 in case a comparison is wanted.

state.go's diff has collapsed to what I said it would, which was the point of splitting #194 out:

 	for _, k := range keys {
-		c.Store.Put(k, []byte(content)) // refresh the stashed original for expand
+		commitRefresh(c, rep, markerFull, k, content)
 	}

The rep parameter, the len(keys) == 0Irreversible block and the seven mechanical call-site updates are all on main from #194, so this branch no longer carries them.

One conflict, in comment prose only. #194 and this branch each carried a version of the reapplyFrozen doc block. I kept main's — it is the fuller one, it is what a reader will have seen on main, and it includes the measured turn-1/turn-2 evidence. The substantive line above auto-merged cleanly.

One deliberate overlap left in place. main now has TestASummaryModeReplayIsNotRevertedFromTurnTwoOnward (from #194) and this branch has a mask via reapplyFrozen subtest inside TestADegradedModeReplayDeclaresItselfIrreversible, both covering the same property. I have kept both rather than dropping the subtest: the table's claim is that all four replay branches are held to the same assertion through the same helper, and removing one leaves a gap in exactly the coverage the table exists to provide. If you would rather not carry the duplication, dropping the subtest is a two-line change and the property stays covered by main's test — say which you prefer.

Verification after the rebase, on the box against the new base: gofmt -l . clean · go vet ./... clean · go test ./... all packages pass. CI re-running.

Remaining open, all filed and none blocking this branch: #189, #190, #192, #195, #197.

@amiddavid

Copy link
Copy Markdown
Collaborator Author

Rebase verified, and thanks for the heads-up on the stale tree — removed. git diff b01f153 13b32e5 is only main's summarymodereplay_test.go arriving plus the state.go prose, with the rest unrelated proxy/cheapmodel test churn from main. state.go against main is the single call-site change we both expected. Nothing to review.

On the duplication: keep it, and I think "duplication with a reason" undersells it — the two tests have different subjects that happen to share an assertion.

  • TestASummaryModeReplayIsNotRevertedFromTurnTwoOnward (main) asserts a property of reapplyFrozen.
  • the mask via reapplyFrozen subtest asserts that all replay branches are held to the same assertion through the same helper — i.e. uniformity, not the property itself.

Those fail differently. Special-case the helper so it suits extract_llm and extract_sweep_drop but not reapplyFrozen, and the table catches it while the standalone test stays green. Break reapplyFrozen itself and both fire. Dropping the subtest loses the first case entirely, and it is the case that matters for a helper three other branches depend on.

The real cost is drift, in one specific direction: someone sees two tests for "the same thing" and deletes the table entry as redundant, which is exactly the reasoning that looks correct and isn't. A sentence in the standalone test saying it is not a substitute for the table entry, because the table's subject is uniformity rather than the property, would cost nothing and pre-empt that.

One thing I checked while forming that view, and it is worth recording as a non-finding. The table has three subtests, but there are five commitRefresh call sites — extract_llm.go:814, extract_sweep_drop.go:121, state.go:248, and summarize.go:343/:439. That looked like a gap in the finding-1 family, so I traced it:

  • Only extract_llm and extract_sweep_drop pass a computed eff, so they are the only sites that can reach commitRefresh's eff != markerFull branch — the one that sets Irreversible.
  • state.go:248 passes hardcoded markerFull and the degraded case is handled by the len(keys) == 0 block above it, which is what the mask subtest covers.
  • summarize.go's two sites also pass hardcoded markerFull, and summarize handles the degraded replay separately and correctly at summarize.go:176-178if len(reusedKeys) == 0 { rep.Irreversible = true } on the reused path.

So the four branches are genuinely covered and summarize is not a fifth hole. But that took reading five call sites to establish, and the table's completeness rests on convention rather than on anything mechanical. gateExempt exists precisely because that convention failed once for components. The equivalent here would be a check that every commitRefresh caller passing a non-constant eff appears in the table or is listed with the test that pins it — which is the same mechanism, applied to the thing this PR series kept finding new instances of. Worth considering for #197 or its own issue rather than this branch; I am not asking for it here.

On the review-required wall: agreed that is a human gate rather than a CI one, and #194 clearing it is the precedent.

Six rounds, and the two corrections were mine to make — a finding that is right about a problem and wrong about its cause is still worth filing, but only if the cause gets corrected once it is known. Thanks for pushing back where I was wrong about the cause both times.

…ndant

Comment only; no behaviour and no test outcome changes.

main's TestASummaryModeReplayIsNotRevertedFromTurnTwoOnward (from #194) and this
branch's `mask via reapplyFrozen` subtest assert the same thing, and the obvious
reading is that one is redundant. They are not: they have different SUBJECTS that
happen to share an assertion.

  - the standalone test asserts a property of reapplyFrozen.
  - the subtest asserts that all four replay branches are held to the SAME
    assertion through the same helper — uniformity, not the property.

They fail differently, which is the test of whether both are needed. Special-case
the helper so it suits extract_llm and extract_sweep_drop but not reapplyFrozen
and the table catches it while the standalone test stays green; break
reapplyFrozen itself and both fire. Dropping the subtest loses the first case
entirely, and that is the case that matters for a helper three other branches
depend on.

The real risk is drift in one specific direction — someone sees two tests for the
same thing and deletes the table entry as redundant, which is reasoning that looks
correct and is not. Each now says it is not a substitute for the other, and why.

Also records at the table that it has NO completeness check, unlike
TestNoStateIsRecordedBeforeTheCommitGate's gateExempt. There are five
commitRefresh call sites and three subtests. The coverage is complete today —
only extract_llm and extract_sweep_drop pass a computed eff and so can reach the
eff != markerFull branch, while state.go and summarize.go's two sites pass
markerFull and handle the degraded case themselves — but establishing that took
reading five call sites, and nothing fails if a sixth is added. #198 carries the
mechanism; two instances of that exact defect were found during this review, one
introduced and one pre-existing (#193), which is why it is filed rather than left
as a convention.

Both points are the reviewer's, including the framing that the two tests have
different subjects rather than being duplication-with-a-reason, which is what I
had called it.

Verification: gofmt -l . clean, go vet ./... clean, go test ./... all packages
pass (Go 1.26.4, eval box).

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Your framing is better than mine and I have used yours. "Different subjects that happen to share an assertion" is the accurate description; "duplication with a reason" was me conceding a point I did not actually need to concede.

Both tests now say they are not a substitute for the other, and why — c6ff72e. The standalone test carries your failure-mode argument verbatim in substance: special-case the helper so it suits extract_llm and extract_sweep_drop but not reapplyFrozen, and the table catches it while the standalone stays green; break reapplyFrozen and both fire. And the subtest points back. Your drift direction is the specific thing the note pre-empts — someone sees two tests for the same thing and deletes the table entry, which is reasoning that looks correct and is not.

Your non-finding is now #198, and it is the most useful thing to come out of this round precisely because it is not a defect. The part I would not have arrived at is the classification: only the two sites passing a computed eff can reach commitRefresh's eff != markerFull branch, so "which callers need a table entry" is a mechanically decidable property rather than a judgement — which is what makes a gateExempt-style check worth building instead of just documenting. #198 records your three implementation options and picks go/ast classification for that reason.

I also noted it at the table rather than only in the issue: the table now says it has no completeness check, that the coverage is complete today, and how that was established. A reader who finds the table in six months should not have to re-derive five call sites to know whether to trust it.

Filed rather than fixed here, on your own reasoning and mine agreeing for once: it is test infrastructure rather than a defect, and this branch is long.

On the last point — agreed, and I would put it the other way round. A finding that is right about a problem and wrong about its cause is worth filing because the cause gets corrected once it is known; the alternative is not filing it, and then the problem stays. Both times the correction came from you unprompted, which is the part that made the fixes right rather than merely applied. Thank you for the six rounds.

amiddavid added a commit that referenced this pull request Sep 3, 2026
…ee, and a drop is not a compaction

Two caveats from the #188 review thread, recorded so neither lives only in a message.

SEMANTICS. extract_llm stores a compaction -- a property of the content, reusable
wherever that content appears. The sweep stores a DROP, "spent for this transcript's
obligations", which is why it never publishes to the cross-session cg:xres:
namespace. A replay therefore applies an obligation judgement as a content
projection, at a fresh position, possibly many turns later.

The hazard is not staleness in general but its direction: recurrence is evidence
AGAINST spentness. Replays land only on unmarked content, so the ids are content
reappearing at a fresh position, and content reappears because the agent re-ran the
command or re-read the file -- which it does because it needs it now. So the replay
applies "was spent when the sweep looked" exactly when the agent has demonstrated
renewed need, against a contract defining spent as needed for none of "the step you
are on right now". If a tag is added it should distinguish compaction from drop,
which carries meaning, not component from component, which does not.

COST. This section recorded the channel as $11.58 at $0.00. But the replay reaches
apply WITHOUT passing the cache-tail depth gate (replay at :890, gate at :932), and
that bypass is justified per-MESSAGE -- "this session already sent these compacted
bytes" -- while the lookup is keyed per-CONTENT. At a fresh position the provider
holds the ORIGINAL, so splicing the compaction changes cached bytes and forces a
cache-write of the suffix: a real cost on a ledger extract_llm does not carry, and
the same cost the sweep's econ trigger prices at 11.5x a cache read.

All 364 replays are in that category -- the marker skip at :848 precedes the lookup
at :868 -- so this is the whole channel, not a tail. If it holds, the 57% recovery is
not free, only charged where nobody was reading.

It cannot be settled here: cache_read_tokens, cache_write_tokens and
fresh_input_tokens all read 0 in every arm-seed, unpopulated in this build rather
than measured as zero. A re-run with those live settles it against arm A, where no
replays exist.

In the channel's favour, review established the BYTES ARE IDENTICAL: a sweep record
replayed by extract_llm writes what the sweep's own splice writes. So the
cross-component path cannot flip content and a component tag would prevent no byte
difference. The open questions are semantic and economic, not correctness.

Signed-off-by: David Amid <david.amid@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid added a commit that referenced this pull request Sep 3, 2026
…imply a fresh position

The previous commit claimed all 364 recovery-channel replays landed at fresh
positions, and derived from that a hypothesis that the 57% cost recovery might be
paying a cache-write per replay. The #188 reviewer caught the bad step and is right.

The invalid inference was that because the marker skip precedes the cache lookup,
replays must be on content at fresh positions. Unmarked does not imply fresh. The
client re-sends its OWN original transcript every turn -- collapse.go:108 states it
directly, "the agent re-sends the original each turn, so the only way the
representation stays stable is to re-derive the same bytes", and failed_run.go:121 and
agentdiet.go:393 say the same. So a message compacted last turn arrives unmarked again
this turn at the SAME position and the replay re-derives identical bytes. That is the
steady state everywhere on every turn, and it is precisely what the depth bypass
exists for: the provider already holds the compacted form there, so nothing is
re-written and no cache-write arises.

What survives is bounded rather than universal. The bypass is justified per-message
while the key is per-content, so a subpopulation can splice at depth into content the
provider holds in original form -- but it is self-limiting, because new content is
APPENDED, its index exceeds MaxCachedIdx, TailOnly puts it in the tail on arrival
where the gate would permit acting anyway, and by the time that position is at depth
it was compacted on its arrival turn. Reaching the hazard needs the boundary to have
moved (a compaction reset) or the component not to have acted on arrival.

Iteration 024 cannot size it: 364 is the total, not the fresh-position count, and
cache_read_tokens / cache_write_tokens / fresh_input_tokens all read 0 in every
arm-seed -- unpopulated in this build rather than measured as zero.

So $11.58 at $0.00 stands for the steady-state replays, with an unsized minority
possibly carrying an unrecorded cache-write. The semantic caveat in the same section
-- a drop replayed as a compaction -- is unaffected and stands.

Signed-off-by: David Amid <david.amid@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid added a commit that referenced this pull request Sep 3, 2026
…uing it locally

Section 2b noted that 209 unresolved expands went unnoticed for two iterations. The
reason generalises and is now stated citably on #200, so this file points at it
instead of making a local version of the argument.

The shape: the benign reading is what a counter's description promises, so the
dangerous case hides behind a reassuring one and the failure is silent by
construction. expand_unresolved_missing sat at 209 looking like ordinary expand
traffic; usage_reported was false on 4,015 of 4,015 requests and was found two
iterations later while chasing something else. Both are this iteration's
contribution to that argument rather than illustrations of it, and #200 carries them
as its supporting evidence alongside expand/unresolved.go's malformed-vs-missing and
#188's stash_missing-vs-stash_refused.

Signed-off-by: David Amid <david.amid@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid
amiddavid merged commit 5873a0b into main Sep 3, 2026
6 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Sep 3, 2026
@amiddavid
amiddavid deleted the fix/stash-durability-187 branch September 3, 2026 17:55
amiddavid added a commit that referenced this pull request Sep 3, 2026
…window

Comment and docs only; no behaviour and no test outcome changes.

`unreadable_body` is reachable ONLY from the sniffed path — Handler.stream, taken
when neither proxy-injected tool is advertised on the request — because that is
the only place usage is read from a bounded head+tail window rather than the whole
body. With either tool advertised, which `inject_expand: always` guarantees from
the first turn, a non-streamed response is read whole at proxy.go's `default:`
branch and the window cannot apply.

Recorded because the call-site distinction has now misled two sessions in opposite
directions. I floated the spliced window as a competing explanation for iteration
024's usage_reported=false on 4,015 of 4,015 requests; it cannot be one, because
those runs set INJECT_EXPAND=always so `advertised` was true and the full body was
read. The reviewer on #188 hit the mirror image, writing a parseUsage test that
passed on the stream branch while the traffic in question took the buffered one.
Both are the same error: reading the two `responseUsage` call sites as
interchangeable when the bytes reaching them are not.

Neither counter changes, and `valid_json` in the shape record already settles
which case a reader has without knowing their path — this only saves them
forming the wrong hypothesis first.

Credit for the correction, and for checking it against the actual line numbers
rather than the plausible reading, to the coref validation session holding the
iteration 024 measurement.

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

fix(store): stash payloads are evicted while pinned decisions survive, so a reversible removal silently becomes irreversible

3 participants