fix(cache): slide the store TTL, pin frozen decisions, and repair lost ones - #40
Conversation
OsherElhadad
left a comment
There was a problem hiding this comment.
Independent review — verdict: changes requested (GitHub won't let me --request-changes on my own account's PR, so this is a --comment carrying that verdict)
Parts A and B are correct and well-tested. Part C's reasoning is right — the fail direction genuinely does invert for an established compaction, and that is the best insight in this PR. But the implementation of C, and the pinning that supports it, have several defects I reproduced with tests in a scratch worktree. Two of them are blockers; one is a permanent resource leak; two are metrics that fail in the flattering direction (the same class of bug the author already fixed once in b7b7cb0 — it is still there on a different code path).
What I verified myself: build, go test ./... (19 pkgs ok), go test -race -count=5 ./store/... ./components/offload/... (clean, 436s), make lint (clean), the two revert-the-fix checks below, and every "PROBE" claim below is a test I wrote and ran on 3f7d700. mkdocs is not installed on this box, so --strict is unverified. The live benchmark numbers I take on trust.
Revert checks confirmed as claimed:
- removing the
e.expiresrefresh instore.Get→TestMemorySlidingTTLOnGetandTestMemoryUnreadEntryStillExpiresfail. - stubbing
frozenLosttofalse→TestForcedStoreMissDoesNotFlipEstablishedCompactionfails with the masked→full diff printed, exactly as described.TestFlipCostOverLongSessionreproducesflips=50 / 191,681 tok / $0.44→0.
Is "re-derive at depth" safe for every offloader? Answer: no.
mask and failed_run: yes, provably. mask's replacement is prefix + headPeek(content, keepHeadChars) + expand.Marker(sha256(content)) and failed_run's is a constant prefix + the same marker — both pure in (content, component config), and neither depends on the message's index: keep_recent/runs[:len-1] only gate whether the component is a candidate, never what bytes it emits. Config can't drift mid-session either, because there is no hot-reload path (config.Load is called once in cmd/context-guru-proxy/main.go:97) and a restart drops the whole in-memory store, so no stale lostFrozen mark can survive a config change. The purity argument holds for these two. Good.
extract_llm: no. The replacement is a sampled LLM output. internal/cheapmodel/anthropic.go:44-48 sends only model/max_tokens/messages — no temperature: 0, no seed, nothing that pins determinism, and RunExtractionSummary picks a strategy by strategyOrder and validates the candidate rather than reproducing it. components/offload/extract_llm.go:248 lifts the tail gate on repairLostResult, then a fresh model call produces whatever it produces and apply() splices it into a message inside the cached prefix.
I reproduced both failure modes with a stub model:
BLOCKER 1 — extract_llm.go:248: repair with a differing model output is a deliberate deep mutation, scored as a success. Model returns a different-but-valid projection on re-derivation:
model calls=1 dropped=1 repaired=1 flips=0
was "PROJECTED SHORT\n<<cg:44ab6ef8...>> [full output: call context_guru_expand]"
now "verbose tool output line with detail\nverbose...\n<<cg:44ab6ef8...>> [...]"
The cached-prefix message changed bytes, the suffix is re-written, and frozen_flips reads 0. This is precisely the "converts a rare TTL flip into a deliberate deep mutation" case, and the observability added to catch it reports success. The PR body's "the LLM may not reproduce the bytes exactly — but the alternative is a guaranteed full-suffix cache-write" is not a valid trade here: both branches cost one cache-write of the same suffix, and the LLM branch also costs a model call plus its latency on the hot path. There is no upside. Do instead: don't lift the tail gate for extract_llm. Either (a) restrict repairLostResult to re-emitting bytes you can prove, or (b) if you keep the repair, gate the splice on newProjection == previouslyCachedProjection — which requires keeping a fingerprint of the emitted bytes, i.e. the thing the design note rejected. (b) is the honest version; (a) is the lazy one. At minimum, frozen_repaired must not be credited unless the bytes match.
BLOCKER 2 — extract_llm.go:248 + :262: the repair lifts the gate, then the model may not run at all. With a failing/absent model (rate limit, 15s llmCallTimeout, throttled step via llmAllowedThisRequest, skipFileReads, sz < floor), the message is forwarded verbatim at depth — the exact flip C exists to prevent — and the counters correctly say dropped=1 repaired=0 flips=1, so the repair path is a no-op that spent a model call:
model calls=1 dropped=1 repaired=0 flips=1
PROBE: repair authorized depth mutation but the model failed -> forwarded VERBATIM at depth
So on the config that actually ships (codesmart, where extract_llm is the only replay mechanism), part C is unreliable by construction. Reinforces the fix for blocker 1.
High severity
3. state.go:243 / store.go:203: cg:sum1: and cg:res: are two independently-lived keys, so the replay can flip bytes on the HIT path with nothing reported lost. extract_llm.go:230 renders projected + "\n[" + summary + "] " + tok when the summary exists and projected + "\n" + tok when it doesn't. The two keys get separate pin slots and separate sliding TTLs, and repairLostResult only inspects the cg:res: key. Reproduced two ways:
# summary key expires while the result key keeps being read (only res is Get every turn)
first="PROJECTED\n[one line summary] <<cg:875b...>>..."
second="PROJECTED\n<<cg:875b...>>..."
# summary evicted past the pin cap while its result stays pinned
id1: result=true summary=false repairLostResult=false
That is a silent representation flip counted as a frozen_hits success, invisible to frozen_dropped/repaired/flips. Fix: store the summary with the result under one key (one JSON blob), so they cannot diverge. That also deletes summaryKey/getSummary/putSummary and one of the three pinned prefixes — smaller diff than what's here.
4. store.go:203-211, 320-330: pinned entries are immortal, so the pin budget is permanently exhausted and half the cache is leaked. TTL is only enforced inside Get, evictOldest skips pinned entries, and nothing sweeps. A pinned entry whose session ended is never Get again → never expires → never evicted → pinnedN never decrements. Reproduced: 60 short sessions × 10 freezes over 6000s of fake clock, then +24h and 5000 rewind Puts:
after 600 freezes over 6000s: pinnedN=500 (cap=500) llLen=600
after 24h + 5000 rewinds: pinnedN=500 llLen=1000
PROBE: pinning is permanently disabled for all future sessions (pinnedN=500)
Two consequences for a long-running proxy (which is the deployment model): (a) 500 dead entries hold half the 1000-entry cache forever — the PR's memory table assumes the pinned subset is live working state; it isn't, it's garbage; (b) after ~500 lifetime frozen keys, every future session's decisions are unpinned, i.e. the eviction-exemption half of the fix silently stops working and the store starts reporting dropped for them at freeze time. The docs' "capped so one pathological session cannot pin the whole cache" is true; what actually happens is that finished sessions pin it. Fix: check expires in evictOldest — an expired pinned entry is evictable (that is one if, and it also repairs (b)). A lazy sweep on Put would do too.
5. store.go:203-211: pinning makes the MaxCachedIdx fail-open more likely, which is not acceptable to defer as A3 given this PR causes it. cg:len: (the prevLen key behind MaxCachedIdx, apply/apply.go:312) is not in frozenNamespace, so it stays fully evictable — while up to half the cache is now unevictable. Reproduced with MaxEntries: 20: 10 frozen keys + 10 ordinary Puts evicts cg:len:s, → MaxCachedIdx = -1 → TailOnly true for every index → the pipeline is licensed to mutate the whole cached prefix. That is a bigger cache-destructive event than the one this PR fixes, and this PR halves the pool it competes for. The design note claims the sliding TTL "shrinks that window" — true for expiry, false for eviction, and the eviction pressure is now worse. Fix: add cg:len: to the pinned set (it is 2-4 bytes per session), or pin it explicitly. Cheap and removes the interaction entirely.
Medium — metrics that fail in the flattering direction
6. store.go:189-196 + :203-211: the b7b7cb0 fix is incomplete — the flattering pin-cap metric returns on the second Put of an over-cap key, which is the normal case (mask/failed_run re-freeze every turn):
turn1: dropped=2 repaired=0 flips=2
turn2: dropped=2 repaired=2 flips=0 ; key 'c' present=true pinned=false
dropped=4 repaired=4 → flips=0 is exactly what b7b7cb0's commit message says was wrong; it now takes two turns instead of one. The over-cap decisions are still unpinned and unprotected. Root cause: noteLost is called at freeze time for a live, readable entry, so "dropped" is overloaded to mean both "gone" and "unprotected". Split them: a frozen_unpinned gauge for the pin-cap case, and reserve dropped/repaired for actual loss. That also fixes:
7. store.go:208: frozen_dropped counts entries that are still present and readable. MaxEntries: 4, four freezes → frozen_dropped=2 while both of those keys Get successfully. The headline "184 → 0" is therefore not purely a loss count; some fraction of the 184 may be over-cap-but-live. Worth re-checking against the run's max_entries.
8. store.go:236-241: the FrozenLossStats doc is wrong. It says "Both count each key once, however many turns observe it"; noteLost increments lostN unconditionally, so three expire/re-freeze cycles of one key give dropped=3 repaired=2. Either dedupe or fix the comment. (flips can't go negative — verified: repairedN only increments on a map-hit that is then deleted.)
9. store.go:221-229: lostFrozen is one global bounded map, so sessions starve each other's repairs. The bound is m.max (1000) with arbitrary single-key eviction. Reproduced: session A's loss mark is silently deleted by session B's over-cap traffic, so A's compaction flips with no repair and no report. The issue explicitly asked for "cap that exemption per session"; the pin cap is global and the loss set is global. At 1000 keys and 100 sessions this is unlikely in practice, but the failure is silent — say so in the comment, or key the bound per session.
10. metrics/metrics.go:222-231: the "frozen_misses is dominated by never-frozen-yet" caveat lives only in a Go struct comment. Nobody reading /stats sees it. Put it in docs/reference/routes.md:12, which currently doesn't mention any of the five new fields.
Low / process
11. Backward compatibility of /stats: verified OK. All five fields are additive; deploy/harbor/{measure,swebench,replay,replay2}.py use st.get(...) with defaults throughout and reference no removed key.
12. Finding #1 confirmed as stated, and it is the most valuable thing in this PR. config/config.go:135 — codesmart has no mask; failed_run.go:118 self-skips whenever c.CacheAware; extract_llm replays through cg:res:/cg:sum1: only. A cg:frz:-scoped fix would indeed have been inert on the shipped default. But it is papered over, not unified, and the papering is where bugs 1, 2 and 3 come from: store.go:65-73 now hardcodes three component-owned key prefixes inside the store package (layering inversion — the store knows about mask's naming), and state.go carries two parallel helpers (repairLostFreeze / repairLostResult) with different arguments and different guarantees. One store.Pin(key) call at the freeze site, or one shared freeze() that extract_llm also uses, would make frozenNamespace unnecessary. Worth doing here rather than deferring to #27/C3, since C's correctness depends on it.
13. Methodology — the preset switch is legitimate, but the conclusion is over-claimed. The codesmart null result is real (verified mechanically: no mask, failed_run self-skips, and min_tokens: 3000 at config.go:160 gates extract_llm off), and running general to exercise the mechanism is the right call, not preset-shopping — provided the PR says plainly that the fix is unmeasured on the shipped default config, which it does not. It should. The −24.7% cache-write/request normalisation is defensible as a mechanism measurement but not as a cost result: with both tasks truncated by the wall clock at different points, requests are not an exchangeable unit (later requests carry longer prefixes, so cw/req is not flat in request index). Present it as directional, next to frozen_dropped 184→0 which needs no normalisation and is the real evidence. On the SWE control: the body says "not quoting a delta" and then quotes reward 1.0 → 1.0, 11 steps, $0.14 — a null delta is still a delta. Trivial, but say "one unmatched trial pair, no signal" instead.
14. Security: clean. No credential, token, or gateway URL in the diff, tests, docs, or PR body (grepped the full origin/main...HEAD diff for key/bearer/token/password patterns and internal hostnames).
15. Docs. docs/design.md:170-201 explains the inverted fail direction clearly and a future maintainer will not "fix" it back — that section is genuinely good. Two corrections needed: (a) "a pure function of (content, component config)" is false for extract_llm, which is the caller that matters most — scope the claim to the deterministic offloaders and state the extract_llm exception in the doc, not only at the call site; (b) "The sliding TTL shrinks that window" for MaxCachedIdx needs the eviction half (finding 5). mkdocs build --strict not verified (mkdocs not installed here) — please confirm.
Minimum to land
- Don't let
extract_llmmutate at depth on an unverified re-derivation (blockers 1+2) — or verify the bytes before splicing. - One key for result+summary (finding 3).
- Expired pinned entries must be evictable (finding 4).
- Pin or protect
cg:len:(finding 5). - Stop scoring over-cap decisions as dropped-then-repaired (findings 6+7).
Everything else is comments and docs.
… its counters Review found the concurrency primitives sound but the semantics wrong in six places, including three where the code and the documentation disagreed — worse than a bug, because the docs were the specification. S1: the tail protection was INERT on the primary workload. It pruned only the positions cacheinject wanted, never breakpoints the caller set — and claude-code sets its own on the newest message, inside exactly the span a pending compaction replaces. The doomed tail was cache-written anyway, so async paid the 11.5x rewrite AND lost a slot: strictly worse than sync while reporting success. It now either strips those (async.strip_caller_breakpoints) or declines the turn via DeclineTailProtection, and the host then does not defer (async_tail_unprotected_turns). Declining is the default because overriding a directive an agent deliberately placed is a change to someone else's request. S6: the generation advanced only on commit, so a job from turn 1 read its own generation as current after any number of later turns and committed against a transcript long since replaced. The guard could only ever fire on a dedup collision, never on staleness — the documented invariant was not the implemented one. It now advances per TURN. The honest consequence is that async discards much of what it computes at agent turn rates; stale_discarded is how you see it, and the docs now say so instead of calling it a tuning nit. S7: async_realized_saved_tokens was a tautology, recorded on every async turn that saved anything with no check the saving came from deferred work. It re-reported the inline saving, so "realized == total saved" was true by construction. Now gated on the session having had a compaction land, with a test asserting a STRICT subset. S2/S3/S4: the protection also fired when there was nothing to protect. A session's first turn placed zero breakpoints (prevLen 0 blocked everything, on precisely the turn that must establish the cache — an existing test asserted this as correct and encoded the bug); cache_mode: off suppressed breakpoints forever; and the span was off by one turn, guarding this turn's new messages rather than the previous turn's tail that the pending job actually replaces. Turn accounting also moved out of the cache-aware branch, since a turn happens whether or not the backend caches. Follow-ups in the same pass: observe's real off-path model spend is labelled rather than hidden (S8); a session producing repeated unproductive jobs stops buying cheap-model calls (S9); eviction seeds a recreated session above every generation ever issued so a surviving in-flight job cannot commit over it (S10); Stop bounds its wait at 2s instead of inheriting the cheap model's 5-minute client timeout, which main.go's deferred Close would otherwise hang on (S11); and store.Buffer forwards the optional FrozenLost capability structurally, so #40's signal is not disabled by the wrapper. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
|
Thanks — this was a good catch list. All 10 items addressed in BLOCKER 1 — you're right, and the trade argument was wrong
Confirmed your reproduction rather than taking it on faith:
BLOCKER 2 — same fixCovered by the exclusion: the gate is no longer lifted for the one component whose result isn't guaranteed. Noted at the call site that the model may not run at all (throttle, timeout, floor) as an independent reason the lifted gate was unsafe. And yes — this means part C does not help the shipped HIGH 3 — one key
HIGH 4 — pins are reclaimable
HIGH 5 —
|
…t ones A frozen compaction could expire mid-task and hand the provider a DIFFERENT representation of an already-cached message, forcing a re-write of the whole suffix at 11.5x the cache-read price. Store.Get refreshed LRU recency but never e.expires, and the default TTL was 1800s — under Terminal-Bench's ~1975s mean wall clock, so a decision died roughly every 69 turns however often it was replayed. Three changes, one per failure mode: - Sliding TTL: Get refreshes expires. The TTL reclaims state for FINISHED sessions; a decision an ongoing session replays every turn is by definition live. An entry nobody reads still expires on its original deadline. - Default TTL 1800s -> 10000s (store.DefaultTTL), still ttl_seconds-configurable. Steady-state memory is unchanged: the 1000-entry cap, not the TTL, bounds it. - cg:frz: entries are pinned against LRU eviction, capped at half the entry cap so one session cannot pin the cache and starve the rewind stashes expand needs. The third part is the design question. A Get miss cannot distinguish "no frozen decision" from "the decision existed and was lost", and the two want OPPOSITE behavior: fail-open means forwarding the original, but once the provider has cached the compacted bytes, forwarding the original IS the destructive act. So the store keeps the FACT of a dropped freeze (store.FrozenLoser, a bounded key set — only the knowledge has to survive, not the payload) and mask/failed_run lift the depth restriction for exactly those keys. Re-deriving is safe because an offloader's replacement is a pure function of (content, config) and the marker key is sha256(original), so it reproduces the same bytes the provider cached and re-establishes the freeze; the never-worse and kept-verbatim guards still apply, so nothing new is ever dropped. Observability, without which the fix is unverifiable on a benchmark run: /stats gains frozen_hits, frozen_misses, frozen_dropped, frozen_repaired and frozen_flips (= dropped - repaired, the drops that actually cost a cache-write). Fields are added only — deploy/harbor parses this endpoint. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
… cache The freeze-replay contract is implemented twice under different names. mask and failed_run use freeze/reapplyFrozen (cg:frz:); extract_llm uses its own per-content result cache (cg:res:, plus cg:sum1: for the summary line it re-emits) — and that is the one that carries the load in the shipped coding config, where mask is absent by design and failed_run self-skips on a cached agent. Scoping the lifetime fix to cg:frz: alone would have been measurably inert on exactly the traffic that motivated it. So the pin, the loss signal and the depth-repair now cover both namespaces: a lost result-cache entry un-compacts an already-cached message exactly the way a lost frozen mask does, and gets the same treatment. The rewind stashes (bare content hashes — the large originals the expand loop resolves) stay fully evictable; only the small replay decisions are pinned. For extract_llm the repair costs a model call and the LLM may not reproduce the bytes exactly, which is noted at the call site; the alternative is a guaranteed full-suffix cache-write, dearer by a wide margin. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…ounters A smoke run on the shipped coding config reported frozen_hits/misses = 0 while the pipeline was replaying compactions every turn: only reapplyFrozen fed the counters, and that config replays entirely through extract_llm's result cache. The counters would have been blind on exactly the traffic the freeze-lifetime fix targets, which defeats their purpose — verifying the fix on a benchmark run. getResult now feeds the same hit/miss counters as reapplyFrozen. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Found while reviewing the counters before trusting them for the benchmark write-up: at the pin cap, Put called noteLost(key) and then the repair check immediately deleted that same mark and incremented repairedN. A store at its pin cap therefore reported dropped=4 repaired=4 — frozen_flips = dropped - repaired = 0 — while four decisions were in fact unprotected. The metric failed in the flattering direction, which is the one that would have made a broken fix look like a working one. The repair check now runs once, before either branch, so it also catches the case where the entry still exists (over the cap the key stays in the map, unpinned) and cannot double-count a decision that is immediately unprotected again. repaired can no longer exceed dropped, and a test asserts that invariant. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
The issue's cost hypothesis (~15 reverts ≈ 2.5M cache-write tokens) had to be validated rather than accepted, and a live benchmark alone cannot isolate it — the effect only appears in sessions longer than the old TTL, and the shipped coding config leaves mask out entirely. So the mechanism is measured directly: replay a 120-turn session (26 s/request, the measured gateway latency) through mask and count the tokens a provider must re-write because a message inside the already-cached prefix changed representation. write-only TTL (old): 50 flips, 191,681 cache-write tokens, $0.44 premium sliding TTL (new): 0 flips, 0 cache-write tokens, $0.00 Same mechanism and direction as the hypothesis, for one 120-turn session; the issue's 2.5M figure covered the whole 89-task suite. The test asserts BOTH that the old behavior still reproduces flips (so the fixture cannot silently stop exercising the bug) and that the new one eliminates them. DisableSlidingTTLForTest is the test seam that makes the before/after comparison possible in a single process. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
remove() gated the loss signal on e.pinned, so a replay decision that missed the pin cap vanished silently: unreported by FrozenLost, and therefore never repaired. That is exactly backwards — an unpinned decision is the one MOST likely to be dropped, since it has no eviction protection left. Keyed on the namespace instead. Found by writing a test to pin down what the counters mean under the old semantics, which is also why DisableSlidingTTLForTest now suppresses the loss signal: the old store had none, so the before/after comparison must not hand the "before" arm a repair path it never had. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…reclaimable Review of the freeze-lifetime work found the depth-repair path unsound for one of its two callers, and the pin/loss bookkeeping unsound in five more ways. All from review; each fix has a test that fails without it. extract_llm no longer gets the lost-decision repair. Its replacement is a SAMPLED model output (cheapmodel sends no temperature and no seed), so re-deriving could splice DIFFERENT bytes into the provider's cached prefix — the exact corruption the repair exists to prevent — while the observability scored it as a success. The trade argued in the previous commit was simply wrong: if the bytes differ, the suffix is cache-written either way, so re-deriving buys a model call for nothing. There is no upside. Two more hazards agreed: after lifting the gate the model may not run at all (throttle, timeout, floor), leaving the output verbatim at depth; and the entry is pinned anyway, so the common case is that it is never lost. mask/failed_run keep the repair — their replacement is prefix + headPeek(content) + Marker(sha256(content)), pure in (content, config) and position-independent, so re-deriving is genuinely reproducible. Also: - Expired entries are now evicted FIRST, pinned included. The TTL was only enforced in Get, and a finished session's decisions are never read again, so pinned entries were immortal: pinnedN ratcheted to max/2 and stayed, leaking half the cache and silently disabling pinning for every later session. A refresh can also reclaim a freed slot. - cg:len: (apply's prev-turn count, the MaxCachedIdx boundary) is pinned. It was competing for a pool this work halved, and losing it makes TailOnly fail open on every index — so the change was making that fail-open MORE likely, not less. - cg:res: and cg:sum1: are one JSON key. As two independently-TTL'd, independently- pinned keys, losing only the summary made the replay HIT and silently emit different bytes (the "[summary] " segment vanishing) with nothing reported lost. Deletes summaryKey/getSummary/putSummary. - The store no longer hardcodes component key prefixes; owners pass them via Options.PinPrefixes. - Over-cap entries are no longer marked lost at freeze time. They are present and readable, so counting them inflated frozen_dropped with live entries and made the next ordinary re-freeze look like a repair — flips reading 0 while nothing was wrong. - The loss-mark budget evicts oldest-first instead of an arbitrary key, so a busy session can no longer delete another session's fresh mark and leave it flipping unrepaired. - FrozenLossStats documents what it actually counts (drop EVENTS, a running balance), and routes.md documents all five /stats fields, including that frozen_misses is a lookup counter dominated by the ordinary "not compacted yet" case. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
f6aa72c to
2b27342
Compare
…rve merges (#49) Five PRs landed in quick succession (#33, #36, #40, #42, #43), each updating its own docs. Nobody checked they were coherent together, and several pages described behaviour that no longer exists. The largest error: every one of the nine preset compositions in docs/reference/presets.md still ended in `cacheinject`, which #36 removed from all of them in favour of the new `cachesplit` marker component. `agent`, `aggressive` and `general` were also missing `extract_llm`, `general` was documented only in prose, and `balanced` was called "the default" when the proxy has defaulted to `codesmart` for some time. Each of the eleven pipelines is now verified component-for-component against the `presets` map. `cachesplit` was a registered component with no page and no nav entry; it has both now. That was the only registered/documented gap — every other components.Register call already had one. Reference pages were the other systematic gap. docs/reference/routes.md documented 5 of the ~45 fields the `Snapshot` struct serves; it now covers all of them, grouped, including `discarded_changes`/`top_discarded` (#36), the SSE quintet (#33), the cmdfilter ledgers (#42) and the observe namespace (#43). config.md gained the `store` block, five missing env vars, and lost a `cacheinject` example. Corrections carrying evidence discipline rather than just names: - cacheinject's placement section was headed "measured, not asserted" over a simulation, and its one live post-fix reading (n=1, +7.9% cost per step, +61.9% cache-write, mechanism unexplained, 0 of 106 marks landing where the suspected mechanism requires) was buried below the favourable numbers. The simulation is now labelled as one, and the negative live reading leads. - The root cause of the discarded `tool_result` breakpoint was attributed to bifrost. It is this repo's own `toolMessage()` in `normalize`. - routes.md now warns that `saved_tokens` is cumulative: the unique totals behind the two studies are 234,119 and 15,457 tokens, 21x and 8x smaller. - cmdfilter.md said 23 filters and first-line selectors; it is 24 filters over six leading lines. The four filters predicted to matter fired zero times and apt+gcc carried ~73% of live savings — recorded as a failed prediction, not quietly dropped. - The `repairLostResult` removal and extract_llm's exclusion from freeze-repair (its replacement is a sampled model output) are now in design.md. - docs described `extract` as the LLM component. `extract_llm` is; `extract` never calls a model. Historical results pages keep their original pipeline names, annotated with what changed since, rather than being rewritten to numbers the runs did not produce. Untouched: docs/results/terminal-bench-*.md and improvement-plan.md (held on #23), and extract_llm's economics (#34's subject). mkdocs build --strict passes; no orphan pages, no dangling nav entries. Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> Co-authored-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
… verdict (#28, rebased) (#51) * perf(extract_llm): gate LLM calls on expected value, cache results globally extract_llm is the only component that spends money to save money, and on Terminal-Bench it lost: 271 calls, $3.26, ~1,592s of added latency against ~197,548 unique tokens saved — ~8x underwater once those tokens are priced at the rate they would actually have been billed. ~93% of its realized value came from the replay cache, not from the LLM. The cause is arithmetic. A request to a caching backend is ~99.95% cached, so a token removed from a cached region saves the cache-read rate ($0.30/MTok), not the fresh-input rate ($3/MTok) — a 10x haircut. Break-even for one ~$0.012 call is therefore ~12,700 tokens of output under caching versus ~1,270 without it, and most tool outputs are nowhere near that. The component already carried this insight as a comment on skip_file_reads; this turns it into an actual gate that applies to every candidate. Economic gate (on by default): call the LLM only when expected_saving = removed x (1 + expected replays) x per-token value exceeds the observed mean cost of a call. The per-token value comes from Ctx.CacheAware, the compression ratio is learned from accepted results (repeated misses drive it to zero and shut the gate), and the call cost is computed from real token usage times real model pricing (CHEAP_MODEL_PRICE_*), never a hard-coded constant. Every decision records a reason. Global result cache: an extraction is a context-free derived result, so re-key it on sha256(content + prompt version + model + config fingerprint) with no session prefix — 82 of 103 unique contents recurred across sessions, and the old key threw that reuse away. A version, model, or config change misses rather than serving a stale extraction. Old entries are inert once; a session-scoped entry is still honored as a migration read. (Contrast #27's xdedup index, session-scoped on purpose: that makes a conversational reference, this caches a derived result.) Derived triggering: no per-workload threshold. Context pressure plus growth rate replace min_tokens, which stays honored when set explicitly. A merely growing context no longer fires on every step. Prompt-cache the preamble, with the measurement that matters: the ~1,463-token invariant contract now goes in a stable system block with cache_control (a leading system message on OpenAI, which has no explicit breakpoints). Measured against the gateway, this is INERT on claude-haiku-4-5, whose minimum cacheable prefix is 4096 tokens — a sub-minimum breakpoint is silently ignored, write=0 read=0. It caches on claude-sonnet-5 (minimum 1024). Shipped because it is free and correct where it wins, but /stats exposes prompt_cache_read_tokens so nobody infers a cache win from placement. Reusing the agent's cached prefix (part B) prototyped and REJECTED, with numbers: it works mechanically (a 103,019-token prefix read from cache, no write, no invalidation), but costs $0.034 at 103k, $0.153 at 500k and $0.513 at 1.7M tokens — 8.5x to 128x a dedicated cheap-model call, and ~$2.04/turn at 4 concurrent calls. It also risks a cache-write on the agent's own prefix (11.5x a read) and couples the compaction model to the agent model. Metrics: /stats gains an extract block with calls, calls avoided by cache, calls suppressed by the gate, prompt-cache behavior, cost, gross value and NET value after cost — the honest headline, previously impossible to see because cost was reported in a field separate from savings. Purely additive; every existing key keeps its name so deploy/harbor/*.py keeps parsing. Tests cover the cached-system-block shape on both backends, cross-session cache reuse, a version bump missing rather than serving stale, gate suppression when cache-aware, permission on a non-caching backend and for recurring content, the cost model against known tokens times known price, the documented break-even sizes, and that the trigger does not fire every step on a growing context. Refs #28 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> * fix(extract_llm): price calls by size and allow bounded exploration Benchmarking the gate on real captures found two deadlocks of the same shape: a pessimistic estimate suppressing every call, so nothing was ever observed, so the estimate could never correct itself. A gate that cannot revise its own prior is an off switch, not a gate. 1. Flat per-call cost. The gate priced every call at the ~$0.012 Terminal-Bench average — roughly 5x the true cost on a workload with small outputs — and suppressed everything. Observed cost was $0.0024/call, and calls that were ~2.2x profitable were declined. Cost is now analytic and size-aware (preamble 1,463 tok + shown content + overhead, at real rates), reconciled with the observed mean once real calls exist, so it is right on the first call with no observations at all. 2. Pessimistic compression ratio. The default was 0.45; measured on real captures an accepted extraction removes only 31-254 tokens per call on 400-2,000-token outputs — a real ratio near 0.12. Corrected, with the direction of conservatism made explicit: for a SPENDING gate, conservative means UNDER-estimating the saving. But a pessimistic prior on a workload below break-even then suppressed everything and forwent a genuine +$0.0094 net, so the tracker now gets a bounded budget of 3 exploratory calls to learn whether this workload actually compresses. Also fixes /compact hard-coding the context window as unknown, which silently disabled every fraction-based trigger AND the new pressure-based triggering on that endpoint — so offline replay/eval measured a different component than ships. Measured after these fixes (replay of real captures, aws/claude-haiku-4-5; forced = pre-#28 behavior, gated = new default): Terminal-Bench, non-caching: net +$0.0287 vs +$0.0091 (3.2x), 17,286 vs 6,664 tokens saved, 4,654 vs 10,287 ms avg latency SWE-bench, non-caching: 13 calls -> 2, net -$0.0571 -> -$0.0016 (-97% waste) Both, caching backend: loss reduced and latency ~30% lower, but still NEGATIVE — the gate cannot make the component pay when saved tokens bill at the cache-read rate The honest verdict, now documented: on a non-caching backend extract_llm earns its place; on a caching backend it does not, even after #28. Docs recommend codesafe (no LLM pass) or dropping extract_llm from codesmart for caching traffic. Break-even sizes are pinned by test so a drift in these figures fails CI rather than silently invalidating the documented verdict. Refs #28 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> * fix(extract_llm): correct savings attribution, seen-flag, and exploration scope Three high-severity review defects, plus the shipping decision. H3 (worst): /stats credited the WHOLE pipeline's savings to extract_llm. It passed snap.SavedTokens — every component's savings — valued against extract_llm's cost alone, so on a preset like codesmart the component displayed as comfortably positive while its own arithmetic proves it negative. It inverted the conclusion in the single field an operator reads. This also invalidated our own benchmark table: the previously reported "+$0.0287 net win" on non-caching traffic was produced BY the bug. With honest attribution the best result on these captures is break-even, not profit. Docs corrected. ExtractSnapshot now takes a per-saved-token RATE and applies it internally to the component's own GrossSavedTokens, so passing a pipeline-wide total is no longer expressible. Regression test fails if anyone re-wires it. H1: markSeenContent fired after the gate ALLOWED a call, so first sight reclassified itself as recurring and collected a 50% valuation bump (6 expected reuses vs 4) it had not earned — the gate over-firing, opposite in direction to the two pessimistic priors fixed earlier. It is now test-and-set on OBSERVATION, returning the prior value; a suppressed candidate still counts as seen, which is correct since recurrence is a property of the content, not of what we spent. The flag-setting path now has tests; previously only the pre-computed bool did. H2: the exploration budget was per-PROCESS. The tracker lives on a Pipeline held for proxy lifetime, so the first session spent the whole budget and every later session inherited an unrevisable prior — the self-justifying-prior failure at process scope. Now per-session (budget 3 -> 2). The learned ratio is also shrunk toward the prior and capped: minRatioSampleTokens is one medium output, so a raw n=1 mean could drop the cached break-even from ~30,500 to ~7,000 permanently. PR #37: exploration spends wall clock as well as money, and an agent on a task deadline feels the former more (#37 measured 17.8s over 2 calls saving 0 tokens). Speculative calls now stop once observed mean latency reaches 6s. SHIPPING DECISION, in code rather than prose: extract_llm is disabled by default on prompt-caching backends (allow_on_caching_backend: true overrides). Every caching workload measured is net-negative even with a correct gate — break-even ~30,500 tokens/output against a largest-observed 2,053 — so the gate could reduce the loss but never eliminate it. codesmart is the proxy default; shipping a component our own numbers say loses money, guarded only by a doc note, is not a defensible default. On caching traffic it now makes zero calls and costs nothing. Medium items taken: - Floor dropped from the cache fingerprint except in "auto" mode. It is derived from context pressure, so it rotated the key as the window filled and discarded the cross-session reuse the key exists to capture; strategyOrder reads it only on the "auto" branch, so elsewhere it cannot change the result. - PromptVersion is now DERIVED from a hash of the prompt constants. A manual constant only works while every future editor remembers to bump it, and one omission serves extractions produced under rules that no longer exist, with no symptom. semanticsVersion remains as the manual hatch for validation-gate changes. - Cross-session reuse gated on RECOVERABILITY rather than restricted to deletion-only: a cached result can be a lossy rewrite steered by another session's goal, which is acceptable only while expand can recover the original. With marker_mode summary/off (or a non-persisting store) the global cache falls back to same-session reuse, on both the read and write side. Verified deletion-only results are always global. Gates: build, test -tags cg_skeleton, -race -count=5, gofmt -l (clean), go vet. Refs #28 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> * fix(extract_llm): evaluate the tail gate before the global result lookup Only the SAME-SESSION replay may bypass the tail gate. That session already sent the compacted bytes, so the provider's cached prefix holds the compacted form and replaying is byte-identical at any depth. A cross-session global hit has no such guarantee: the receiving session never compacted that content, so its cached prefix holds the ORIGINAL, and splicing another session's result at depth mutates already-cached content and forces a suffix re-write at 11.5x the read price -- exactly the harm #40 removed repairLostResult to prevent. Restore the ordering: getResult, then the tail gate, then getResultGlobal only for messages the gate permits. A global hit is then frozen into the session so later turns replay it from the depth-safe path. TestGlobalCacheHitIsNotSplicedAtDepth fails if the ordering is flattened again; name that invariant at the call site. Also adapt to #40's unified result-cache key and document why the global namespace must keep the pair together too: splitting projected text and summary across two global keys would re-create the half-a-decision bug cross-session, where independent TTLs let a hit on one and a miss on the other emit projected text with the summary segment silently gone. Add TestNoDefaultConfigRunsExtractLLMOnCachingBackend, which drives the bare defaults and the codesmart preset end to end with an output far above the cached break-even, so the economics alone would permit the call. It fails if allow_on_caching_backend or the allowCached wiring is ever lost in a rebase. Update the measured verdict: the 197,548 saved tokens sit in the cached prefix, so at cache-read price they are worth $0.0395 against $3.26 and 1,592,467 ms -- 82x underwater, not the 8x in the improvement plan, which priced them as fresh input. Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> * docs(extract_llm): state the 82x cache-read verdict and name the rate used The improvement plan's ~8x figure implicitly priced the 197,548 saved tokens as fresh input. They sat in the cached prefix, so they bill at the cache-read rate: $0.0395 against $3.26 and 1,592,467 ms of blocking time -- 82x underwater. A later Terminal-Bench arm that excluded the component entirely re-derived this independently. Name which cache-read rate each figure uses, since the gate reasons at $0.30/MTok (55x) while the issue quoted $0.20/MTok (82x); the gate's is the more generous of the two, so the shipped decline is the conservative one. Assisted-By: Claude Opus 5 Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> --------- Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> Co-authored-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Re-measures context-guru on TB after the 15 cache/filter/observe PRs landed on main, as a fifth arm alongside the original four. The original study is left unchanged below it. Config is cgfinal = [format, dedup, cmdfilter, extract, cachesplit], chosen on per-component evidence rather than maximal token reduction: extract_llm is 82x underwater once its saved tokens are priced at the cache-read rate they actually bill at, failed_run acted 0 times while burning 28.8 s, and cacheinject was removed from every preset by #36. Result on 81 clean tasks: 61 solved vs baseline 53, total $79.32 vs $94.85, own LLM cost $0 vs the previous arm's $2.97, added latency 38.5 ms vs 449.8 ms. Two framing decisions the numbers force: The -16.4% aggregate is single-task sensitive -- path-tracing alone accounts for most of it, and an independent re-derivation with a stricter degenerate rule gave -13.7% dropping to -2.8% on the same exclusion. The median per-task ratio, -7.8% with 49/81 cheaper, is the figure to quote for a normal task. Both are published because they differ by 9 points. The one result needing no caveat is cache-write/cache-read returning to 1.86%, identical to baseline, where the previous arm ran 2.86%. That is the cache-write tax this study named as the deciding term on TB, and being a ratio rather than a sum it holds under every exclusion rule tried. Records what could NOT be verified: #40's freeze-TTL work has all five frozen_* counters at zero because its only callers are the three components this config excludes, so the arm is not evidence for or against it and none of the cost improvement may be credited to it. cachesplit likewise has zero legal opportunity on TB, because the Agent SDK never appends the git snapshot the CLI does. Regressions published rather than omitted: system-administration is +17.2% cost AND -2 solved, security +25.6%, fresh_input 3.8x baseline, and small tasks still inflate up to +311% at n=1 -- size-gating remains an unclaimed win. Also states plainly that cgfinal's raw model cost nearly ties the old arm and its cache-read is higher, so it wins mainly by not spending $2.97 on haiku. Limitations: headroom and rtk cannot be re-derived because their trial artifacts are pruned from disk, so those columns are cited rather than recomputed; single trial per task; one task still running at report time. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Re-measures context-guru on TB after the 15 cache/filter/observe PRs landed on main, as a fifth arm alongside the original four. The original study is left unchanged below it. Config is cgfinal = [format, dedup, cmdfilter, extract, cachesplit], chosen on per-component evidence rather than maximal token reduction: extract_llm is 82x underwater once its saved tokens are priced at the cache-read rate they actually bill at, failed_run acted 0 times while burning 28.8 s, and cacheinject was removed from every preset by #36. Result on 81 clean tasks: 61 solved vs baseline 53, total $79.32 vs $94.85, own LLM cost $0 vs the previous arm's $2.97, added latency 38.5 ms vs 449.8 ms. Two framing decisions the numbers force: The -16.4% aggregate is single-task sensitive -- path-tracing alone accounts for most of it, and an independent re-derivation with a stricter degenerate rule gave -13.7% dropping to -2.8% on the same exclusion. The median per-task ratio, -7.8% with 49/81 cheaper, is the figure to quote for a normal task. Both are published because they differ by 9 points. The one result needing no caveat is cache-write/cache-read returning to 1.86%, identical to baseline, where the previous arm ran 2.86%. That is the cache-write tax this study named as the deciding term on TB, and being a ratio rather than a sum it holds under every exclusion rule tried. Records what could NOT be verified: #40's freeze-TTL work has all five frozen_* counters at zero because its only callers are the three components this config excludes, so the arm is not evidence for or against it and none of the cost improvement may be credited to it. cachesplit likewise has zero legal opportunity on TB, because the Agent SDK never appends the git snapshot the CLI does. Regressions published rather than omitted: system-administration is +17.2% cost AND -2 solved, security +25.6%, fresh_input 3.8x baseline, and small tasks still inflate up to +311% at n=1 -- size-gating remains an unclaimed win. Also states plainly that cgfinal's raw model cost nearly ties the old arm and its cache-read is higher, so it wins mainly by not spending $2.97 on haiku. Limitations: headroom and rtk cannot be re-derived because their trial artifacts are pruned from disk, so those columns are cited rather than recomputed; single trial per task; one task still running at report time. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…rm (#59) * docs(benchmark): add the Terminal-Bench 2.0 four-way study + improvement plan Second benchmark of the study, after SWE-bench Verified: 89 open-ended terminal tasks, claude-code on aws/claude-sonnet-5, run live through the harness. Four arms, same as SWE: baseline (off passthrough), context-guru (codesmart), headroom (hd-cache), rtk. The claude-code trajectory parser, the cache-aware cost model and the summarizer are agent-specific, not benchmark-specific, so every number is computed identically to the SWE arms. Harnesses: terminalbench.py / _headroom.py / _rtk.py (thin adaptations of the SWE ones, dataset + jobs-root differ) and gen_tb_docs.py for the per-arm pages. What the run shows: the agent is ~98% cached here too, so cache-read is again the largest cost term — but cache-write, a rounding error on SWE-bench, becomes the deciding term on TB's ~1.7M-token contexts. Six baseline trials are degenerate (baseline aborted in 2-6 steps where the arms ran 50-160), which inflates the apparent regression; over the 83 clean tasks context-guru is -9.7% and headroom -16.0%, with only rtk regressing. That correction is stated up front on the comparison page and the six tasks are queued for re-run. improvement-plan.md carries the synthesis of both benchmarks: cost tracks agent steps (r=0.95), one cache-write costs 11.5 cache-reads, unique token removal is 0.02-0.13% of the billed total, and cache_control placement is metadata rather than hashed content — so moving a breakpoint is free. Also fixes swebench.py: captures and dumps now live under the run's jobs-root instead of a fixed /tmp path that start_proxy unlinks, which is how an earlier 472-request capture was truncated mid-analysis. Adds the cacheonly arm that isolates the prompt-cache lever from token reduction. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> * docs(benchmark): correct the Terminal-Bench cost conclusion and retract the xdedup premise Two corrections to the TB study, both from re-deriving the numbers from the row files rather than trusting the per-arm totals. 1. Six baseline trials are degenerate: the baseline aborted in 2-6 steps while the compaction arms ran 50-160. mteb-leaderboard, polyglot-rust-c and extract-moves-from-video alone account for $11.5 of apparent regression. On the 83 clean tasks context-guru costs $90.34 vs baseline $100.17 (-9.8% including its own haiku cost, -12.7% on model cost alone), solves +2, and takes 8.3% fewer steps. So TB does not invert the SWE result; the +1.7% headline was an artifact. headroom recomputes to about -16%; rtk remains a genuine regression. What IS different on TB survives the correction: cache-write, a rounding error on SWE-bench, is the deciding term on 1.7M-token contexts. 2. The cross-turn dedup premise is refuted. Measured on the raw captures (1,325 requests / 51 sessions), 232 of 232 re-sent large outputs live at exactly one stable message index, and 100% of consecutive turn pairs have the previous turn as a byte-identical prefix. The agent appends; it does not re-send. Those 5.46M tokens sit in the cached prefix and already bill at the cache-read rate, so an xdedup component would have no legal opportunity to act, and rewriting them would convert reads into writes at 11.5x. Independently re-checked: 0 of 77 large outputs ever appeared at a second index. Also documents the baseline's two-stage merge in REPRODUCE, because the intermediate rows file sums to $71.44 and does not reproduce the published $100.81 -- that gap is what surfaced both errors. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> * docs(benchmark): retract improvement-plan B2 — the expand tool works, the bug was a latency tautology The plan asserted that context_guru_expand is 'referenced 1,496x and callable 0x' because the tool is never registered on the streaming path, and made registering it the single biggest reward lever. Both halves are wrong. expand.Inject does fire on real streaming requests, and proxy.serve does buffer and aggregate SSE when markers are present -- there is no streaming short-circuit. More decisively, a live SWE run recorded bounces=1 with 3,372 tokens restored: RecordExpand has exactly one reachable call site, inside the continuation loop, only after a model-issued expand call resolves against the store, and all traffic was SSE. So restoration completed through the streaming path. The 4.8M figure was cumulative, re-counting each compaction every turn history is re-sent. Unique is 234,119 tokens behind 103 markers on TB and 15,457 behind 29 on SWE -- 21x and 8x smaller. Demand is genuinely low, not blocked. The real defect was a tautology: hasMarkers tested the raw body for the escaped marker sequence, and the injected tool description itself contains it, so every SSE response was buffered and the documented zero-added-latency fast path never engaged. Fixed by scoping the check to messages + system; marker-free TTFB went 1007ms -> 43ms, and live buffering fell from an implied 100% to 27.3%. Both this and the retracted C1 were premise errors from trusting the change-log dumps -- which only record messages a component already acted on -- over the raw request captures. That lesson is now recorded in the section. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> * docs(benchmark): record the mechanism-verification rule that four wrong premises taught An aggregate moving in the predicted direction is not evidence the predicted mechanism operated. Four premises in this plan were wrong, and each failed the same way: a derived artifact was trusted over the raw request stream. - C1 xdedup: a 39.8x re-send factor was read as tokens re-sent as new bytes. 232 of 232 large outputs sit at one stable message index; the agent appends, so those tokens are cached-prefix reads and the component could never have acted. - B2 expand: 'never registered on the streaming path' was false; a live agent restored 3,372 tokens through it. The 4.8M was a cumulative re-count against a 234k unique figure. - prefixpin: early-index churn measured 0 in ~6,500 comparisons on claude-code. An earlier 52% reading was concurrent sessions sharing a byte-identical first message and being diffed against each other. - async cache-write: -45%/-39% was read as the tail-protection working, but the protection only stripped context-guru's own breakpoints and never the agent's, so lower cache-write came from writing fewer breakpoints instead. Three of the four produced a number pointing the right way for the wrong reason, which is why they survived review. Records the five countermeasures, the most useful being: group lineages by append-only prefix match rather than a first-message hash, and instrument 'did the component act' separately from 'did the metric improve'. Also revises F2: cacheinject is not a dead component. It read as inert partly because its breakpoints were discarded by the writeback layer before reaching the wire (46 applied, 0 forwarded). Once forwarded, placement measures mildly harmful, so the open question is whether it belongs in the default preset. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> * docs(benchmark): extend the mechanism-verification rule with four more instances, two of them mine Four more premises fell the same way since F-1 was written, taking the count to eight: - cacheinject read as 'provably inert' when it was in fact applying 46 breakpoints and forwarding 0 -- the writeback layer discarded every one. Two benchmark studies concluded things about breakpoint placement while measuring a component whose output never left the process. - the follow-on claim that placement is HARMFUL (+61.9% cache-write/step) does not survive either: 0 of 106 marks land above the agent's own breakpoint, so the proposed mechanism is ruled out, and the arm's acted=0 is a tautology of its design rather than proof the delta was placement. - cachesplit cannot fire on Terminal-Bench at all. TB runs the Agent SDK, which never appends the git/env snapshot the CLI does: all 73 captured requests carry 3 system blocks and zero volatile-tail markers. Zero legal opportunity, the same shape as the refuted xdedup premise. - the same split is a silent no-op on Bedrock Converse, where cachePoint is its own array entry after the block, so the volatile half is inserted before it and the breakpoint still covers the churn -- while reporting Changed: true. Two of these were mine as orchestrator, and one was an UNFAVOURABLE number I accepted without checking its mechanism. That is the more useful half of the lesson: the bias is not optimism, it is incuriosity, and skepticism applied only to good news is not skepticism. Adds four countermeasures: a component reporting that it acted is not evidence it acted usefully; check the favourable metric had the opportunity to be caused by your change; verify the verifier (two 'defects' here were bugs in the checking script); and a sum over heterogeneous tasks can be one task -- an interim TB delta read -40.2% with a single trial carrying half of it, so report the median per-task ratio and a leave-one-out beside any aggregate. Rewrites F2's cacheinject entry as the full three-stage arc, since it is the clearest worked example of the rule in the document. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> * docs(benchmark): publish the merged-system Terminal-Bench arm Re-measures context-guru on TB after the 15 cache/filter/observe PRs landed on main, as a fifth arm alongside the original four. The original study is left unchanged below it. Config is cgfinal = [format, dedup, cmdfilter, extract, cachesplit], chosen on per-component evidence rather than maximal token reduction: extract_llm is 82x underwater once its saved tokens are priced at the cache-read rate they actually bill at, failed_run acted 0 times while burning 28.8 s, and cacheinject was removed from every preset by #36. Result on 81 clean tasks: 61 solved vs baseline 53, total $79.32 vs $94.85, own LLM cost $0 vs the previous arm's $2.97, added latency 38.5 ms vs 449.8 ms. Two framing decisions the numbers force: The -16.4% aggregate is single-task sensitive -- path-tracing alone accounts for most of it, and an independent re-derivation with a stricter degenerate rule gave -13.7% dropping to -2.8% on the same exclusion. The median per-task ratio, -7.8% with 49/81 cheaper, is the figure to quote for a normal task. Both are published because they differ by 9 points. The one result needing no caveat is cache-write/cache-read returning to 1.86%, identical to baseline, where the previous arm ran 2.86%. That is the cache-write tax this study named as the deciding term on TB, and being a ratio rather than a sum it holds under every exclusion rule tried. Records what could NOT be verified: #40's freeze-TTL work has all five frozen_* counters at zero because its only callers are the three components this config excludes, so the arm is not evidence for or against it and none of the cost improvement may be credited to it. cachesplit likewise has zero legal opportunity on TB, because the Agent SDK never appends the git snapshot the CLI does. Regressions published rather than omitted: system-administration is +17.2% cost AND -2 solved, security +25.6%, fresh_input 3.8x baseline, and small tasks still inflate up to +311% at n=1 -- size-gating remains an unclaimed win. Also states plainly that cgfinal's raw model cost nearly ties the old arm and its cache-read is higher, so it wins mainly by not spending $2.97 on haiku. Limitations: headroom and rtk cannot be re-derived because their trial artifacts are pruned from disk, so those columns are cited rather than recomputed; single trial per task; one task still running at report time. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> --------- Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com> Co-authored-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Closes #25
A frozen compaction could expire mid-task and hand the provider a different
representation of an already-cached message, forcing a re-write of the whole suffix at
11.5× the cache-read price.
Store.Getrefreshed LRU recency but nevere.expires,and the default TTL was 1800 s — under Terminal-Bench's ~1975 s mean wall clock, so a
decision died roughly every 69 turns however often it was replayed.
What changed
Getrefreshed recency but not lifetimeGetrefreshesexpires. An entry nobody reads still expires on its original deadline.store.DefaultTTL1800 s → 10000 s, stillttl_seconds-configurable.Getmiss couldn't be told from a lossstore.FrozenLoser+ depth repair, for the reproducible offloaders only (part C, below).Get, and a finished session is never read again) — half the cache leaked and, past ~max/2lifetime freezes, pinning silently stopped working for every later sessioncg:len:(apply's cache boundary) was not pinned, so it competed for a pool this PR halved — losing it makesTailOnlyfail open on every indexcg:len:is pinned (it is 2–4 bytes).cg:res:andcg:sum1:had independent TTLs/pin slots, so losing only the summary made the replay hit and emit different bytesstore.Options.PinPrefixes.frozen_droppedcounted live, readable entries (marked at freeze time when over the pin cap), so the next ordinary re-freeze looked like a repair andfrozen_flipsread 0remove); a repeat drop of an already-marked key is not double-counted.Part C: a store miss must never force a cache-destructive regression
The design question, and the reason this is not a two-line diff.
reapplyFrozenreturningfalseconflates "no frozen decision" with "the decisionexisted and was lost", and those want opposite behavior. The fail-open invariant in
CLAUDE.md says forward the original — correct for a new compaction. But once the
provider has cached the compacted bytes, forwarding the original is itself the
destructive act. The fail direction inverts for an established compaction.
What state has to survive, and where. Only the fact of the freeze — not its payload.
That is one key in a bounded set, so it lives in the store (
FrozenLoser.FrozenLost)rather than in a second content index. The three options the issue floated were weighed:
its own lifetime bug waiting to happen;
so it is included, capped at
max/2;message, which is exactly what the repair needs.
Behavior.
never frozen→ obey the tail gate (a new compaction stays in the uncachedtail).
frozen, then lost→ re-derive it even at depth. This is safe rather than afail-open exception, because a deterministic offloader's replacement is a pure function of
(content, component config)and the marker key issha256(original): re-derivingreproduces the same bytes the provider cached and re-establishes the freeze. The
component's own never-worse and kept-verbatim guards still apply, so the repair only ever
lifts the depth restriction — it never authorizes new content loss.
Documented as a policy note in
docs/design.mdrather than a config flag: there is nosane value for "please flip my cached prefix instead", so a boolean would only be a way to
ask for the bug back.
extract_llmis deliberately excluded from the repair. Its replacement is a sampledmodel output —
internal/cheapmodel/anthropic.gosends notemperatureand no seed — sore-deriving could splice different bytes into the cached prefix, which is the exact
corruption the repair exists to prevent. And the trade does not pay even ignoring that:
if the bytes differ the suffix is cache-written either way, so the repair branch would
buy a model call for nothing. There is no upside. (An earlier revision of this PR argued
the opposite; that reasoning was wrong.) Two further hazards made the same point: after
lifting the gate the model may not run at all (throttle, timeout, floor), leaving the
output verbatim at depth; and its entry is pinned anyway, so the common case is that it is
never lost. Re-enabling it would require deterministic decoding plus verifying the
re-derived bytes against the stored hash before splicing.
Two findings worth flagging
mask/failed_runusefreeze/reapplyFrozen(cg:frz:);extract_llmused its own result cache(
cg:res:plus a separatecg:sum1:key). The shippedcodesmartconfig has nomaskandfailed_runself-skips on a cached agent — so scoping this fix tocg:frz:alone would have been measurably inert on the exact traffic that motivated it.
The split has now been narrowed rather than merely worked around: the projection and its
summary are one JSON key (they must live and die together — as two independently
TTL'd, independently pinned keys, losing only the summary made the replay hit and
silently emit different bytes), which deleted
summaryKey/getSummary/putSummary; andthe store no longer hardcodes component prefixes — they arrive via
store.Options.PinPrefixes. Fully collapsing the two mechanisms into one helper is stillfeat(components): add xdedup — cross-turn duplicate tool-output references (39.8x measured re-send factor) #27/C3 territory, since only the reproducible offloaders can share the repair path.
frozen_hits/missesinitially read 0 on a live smoke run while the pipeline replayedevery turn, because only
reapplyFrozenfed the counters. Fixed — otherwise theobservability added to verify this fix would have been blind on the shipped config.
Measurements
Direct measurement of the mechanism (
TestFlipCostOverLongSession)The hypothesis had to be validated, and a live run cannot isolate it: the effect only
appears past the old TTL, and
codesmartomitsmask. So the mechanism is measureddeterministically — a 120-turn session at 26 s/request (this gateway's measured latency),
counting tokens the provider must re-write because a cached-prefix message changed
representation:
Same mechanism and direction as the issue's estimate; its 2.5M-token figure covered the
whole 89-task suite, this is one 120-turn session. The test asserts the old behavior still
reproduces flips, so the fixture cannot silently stop exercising the bug.
Live runs
Two arms of the same binary lineage, differing only in the three behaviors (the
"before" build keeps the new counters so both arms measure identically), on the two
longest-horizon Terminal-Bench tasks from the arms where cache-write was worst
(
rstan-to-pystan14.0% cw/cr,compile-compcert4.7%).Both arms ran to completion (2 trials each, 93 vs 114 proxy requests).
frozen_droppedfrozen_hits(replays landed)maskactsmasksaved (cumulative tok)masksaved (unique tok)frozen_flipsThe headline, and the only claim I make from this run: the old semantics dropped 184 live
frozen decisions mid-session; the new semantics dropped none. That needs no
normalisation. Two secondary signals point the same way — the replay lands more often (759
→ 1,105) and
maskkeeps more unique content compacted (21,179 → 36,242 tok).I am not claiming a cost or cache-write win from this run. Both arms were truncated by
the agent wall-clock cap at different points, so absolute totals are not comparable, and
cache-write per request is not flat in request index — normalising by requests would be
reading signal into an artifact. That is the mechanism this issue is about, observed on real
traffic — each drop is a decision that would flip an already-cached message's
representation unless repaired.
Honest caveats, not cherry-picked:
exception: true, reward 0 in both).These two tasks time out on this gateway at ~26 s/request — the same effect documented
for 11 of 89 TB tasks. So reward is not measurable here, only tokens/counters. Reward
is neither improved nor worsened by this change on this evidence.
generaladds ~4.6 s/request of its own (extract_llm514 s cumulative), which itselfpushes long tasks toward the cap. That is perf(extract_llm): prompt-cache the preamble, global result cache, economic gate, and value-based triggering (measured ~8x underwater) #28's problem, not this one, but it is why
these tasks timed out where the published
codesmartrun completed them.frozen_repairedfigure in the before arm counts any re-Putof a lost key by thenormal tail-gated path, not a depth repair (that build has the repair behavior reverted).
frozen_droppedis the meaningful before/after signal.before hitting the wall-clock cap. With both arms truncated at different points, absolute
cost and cache-write totals are not comparable in either direction.
docker composeenvironmentbuild (shared-host contention), so the two SWE arms are not matched and I draw no
conclusion from them — including no "no regression" claim, which one trial cannot support.
Memory impact of the longer TTL
Unchanged at steady state — the cap is on entry count (1000), not bytes or time, so
the TTL cannot raise the ceiling; it only slows how fast an idle store shrinks below it.
extract_llm)mask/failed_run)Worst case, all 1000 entries max-size rewind stashes: 15.3 MiB. The pinned subset is
capped at
max/2= 500 small entries — ≤1.14 MiB — and rewind stashes (the largepayloads) stay fully evictable.
Tests
New tests: sliding TTL with a fake clock (a continuously-read entry survives 100× its TTL);
an unread entry still expires on its original deadline; the LRU cap still evicts; frozen
entries survive LRU pressure; the pin cap holds; a dropped decision is distinguishable from
a never-frozen one;
repaired ≤ dropped; an unpinned decision's loss is still reported; aforced store miss does not flip an established compaction; a never-frozen message stays
verbatim at depth; a
Nopstore degrades safely; both replay namespaces feed the counters.From this review round: expired pinned entries are reclaimed (and a new session can pin
again afterwards);
cg:len:survives eviction pressure; a live over-cap entry is notcounted as dropped and re-freezing it manufactures no drop/repair pair; the newest loss
mark survives budget overflow; projection+summary share one key (expired ⇒ miss
outright, unreadable ⇒ miss, never a half-decision); and — with a stub model that returns a
different valid projection per call — a lost
extract_llmdecision leaves thecached-prefix message verbatim and triggers no re-derivation call
(
components/all/freeze_repair_test.go). That last one fails on3f7d700withverbatim=false, sameAsTurn1=false, i.e. it reproduces the reviewer's finding beforefixing it.
Each behavioral fix was verified to fail without it: reverting the sliding TTL fails
TestMemorySlidingTTLOnGet; reverting the repair failsTestForcedStoreMissDoesNotFlipEstablishedCompactionwith the masked→full diff printed.Observability
/statsgainsfrozen_hits,frozen_misses,frozen_dropped,frozen_repaired,frozen_flips(=dropped − repaired; should be 0). Additive only — verified againstthe live endpoint that all 16 pre-existing fields are present and
deploy/harbor'sst.get(...)access patterns still parse, since those scripts read this endpoint.Notes
docs/results/improvement-plan.md§A2 could not be updated here: it exists only onthe unmerged
origin/docs/terminal-benchbranch, not onmain. It should be markedmeasured when that branch lands.
MaxCachedIdxfail-open (prevLenmiss ⇒TailOnlytrue for every index,11.2% of TB requests) is documented in
docs/design.mdbut left to its own change —inverting
TailOnlyis A3, not this issue. The sliding TTL does shrink the window, sincecg:len:is read every turn and so no longer expires mid-session.