Skip to content

fix(cache): slide the store TTL, pin frozen decisions, and repair lost ones - #40

Merged
OsherElhadad merged 7 commits into
mainfrom
feat/i25-freeze-ttl
Aug 10, 2026
Merged

fix(cache): slide the store TTL, pin frozen decisions, and repair lost ones#40
OsherElhadad merged 7 commits into
mainfrom
feat/i25-freeze-ttl

Conversation

@OsherElhadad

@OsherElhadad OsherElhadad commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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.Get refreshed LRU recency but never e.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

Failure mode Fix
Get refreshed recency but not lifetime Sliding TTLGet refreshes expires. An entry nobody reads still expires on its original deadline.
1800 s default < task wall clock store.DefaultTTL 1800 s → 10000 s, still ttl_seconds-configurable.
LRU could drop a decision before its TTL Replay decisions are pinned, capped at half the entry cap.
A Get miss couldn't be told from a loss store.FrozenLoser + depth repair, for the reproducible offloaders only (part C, below).
Pinned entries were immortal (TTL enforced only in Get, and a finished session is never read again) — half the cache leaked and, past ~max/2 lifetime freezes, pinning silently stopped working for every later session Eviction reclaims expired entries first, pinned included; a refresh can reclaim a freed pin slot.
cg:len: (apply's cache boundary) was not pinned, so it competed for a pool this PR halved — losing it makes TailOnly fail open on every index cg:len: is pinned (it is 2–4 bytes).
cg:res: and cg:sum1: had independent TTLs/pin slots, so losing only the summary made the replay hit and emit different bytes One JSON key; three helpers deleted.
The store hardcoded component key prefixes (layering inversion) Owners declare them via store.Options.PinPrefixes.
frozen_dropped counted live, readable entries (marked at freeze time when over the pin cap), so the next ordinary re-freeze looked like a repair and frozen_flips read 0 Losses are recorded only where losses happen (remove); a repeat drop of an already-marked key is not double-counted.
One global loss-mark map evicted an arbitrary victim, so a busy session could delete another session's fresh mark and that session flipped unrepaired Oldest-first (FIFO) eviction keeps the newest marks.

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.

reapplyFrozen returning false conflates "no frozen decision" with "the decision
existed 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:

  • a separate longer-lived fingerprint index — a second copy of the same information, with
    its own lifetime bug waiting to happen;
  • eviction exemption — necessary but not sufficient (a TTL expiry still loses the entry),
    so it is included, capped at max/2;
  • a per-session generation counter — detects that something was lost, but not which
    message, which is exactly what the repair needs.

Behavior. never frozen → obey the tail gate (a new compaction stays in the uncached
tail). frozen, then lost → re-derive it even at depth. This is safe rather than a
fail-open exception, because a deterministic offloader's replacement is a pure function of
(content, component config) and the marker key is sha256(original): re-deriving
reproduces 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.md rather than a config flag: there is no
sane value for "please flip my cached prefix instead", so a boolean would only be a way to
ask for the bug back.

extract_llm is deliberately excluded from the repair. Its replacement is a sampled
model output — internal/cheapmodel/anthropic.go sends no temperature and no seed — so
re-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

  1. The freeze contract is implemented twice. mask/failed_run use
    freeze/reapplyFrozen (cg:frz:); extract_llm used its own result cache
    (cg:res: plus a separate cg:sum1: key). The shipped codesmart config has no
    mask
    and failed_run self-skips on a cached agent — so scoping this fix to cg: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; and
    the store no longer hardcodes component prefixes — they arrive via
    store.Options.PinPrefixes. Fully collapsing the two mechanisms into one helper is still
    feat(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.
  2. frozen_hits/misses initially read 0 on a live smoke run while the pipeline replayed
    every turn, because only reapplyFrozen fed the counters. Fixed — otherwise the
    observability 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 codesmart omits mask. So the mechanism is measured
deterministically — 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:

store behavior flips cache-write tokens premium @ $2.30/M
write-only TTL (old) 50 191,681 $0.44
sliding TTL (new) 0 0 $0.00

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-pystan 14.0% cw/cr, compile-compcert 4.7%).

The fix is UNMEASURED on the shipped default. codesmart (the proxy default) has no
mask, failed_run self-skips on a cached agent, and extract_llm made 0 acts at its
3000-token floor — so on that config nothing is ever frozen and this fix is latent. A
codesmart arm was run first and measured exactly that: frozen_hits 0, frozen_dropped
0 over 145 requests. An honest null result, and the reason the benchmark arm was switched
to general (which includes mask). What is demonstrated below is that the mechanism
works where it is reachable — not that the shipped default improves.

Both arms ran to completion (2 trials each, 93 vs 114 proxy requests).

metric before (write-only TTL) after (sliding + pinned + repair) delta
frozen_dropped 184 0 −100%
frozen_hits (replays landed) 759 1,105 +45.6%
mask acts 78 94 +20.5%
mask saved (cumulative tok) 631,090 1,747,696 +177%
mask saved (unique tok) 21,179 36,242 +71.1%
proxy savings 42.2% 59.4% +17.2 pp
cache-read tokens 4,191,174 5,317,999 +26.9%
cache-write tokens 251,608 232,297 −7.7%
cache-hit rate 94.27% 95.73% +1.5 pp
frozen_flips 0 0

The 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 mask keeps 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:

  • Both arms' tasks hit the agent wall-clock cap (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.
  • general adds ~4.6 s/request of its own (extract_llm 514 s cumulative), which itself
    pushes 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 codesmart run completed them.
  • The frozen_repaired figure in the before arm counts any re-Put of a lost key by the
    normal tail-gated path, not a depth repair (that build has the repair behavior reverted).
    frozen_dropped is the meaningful before/after signal.
  • Cost is higher in absolute terms after ($2.12 vs $1.80) because the after arm ran longer
    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.
  • SWE-bench control: 2 of 3 tasks in the first arm died in docker compose environment
    build (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.

entry kind size 1000-entry ceiling
rewind stash (full tool output, TB mean ~4k tok) 15.6 KiB 15.3 MiB
compacted projection (extract_llm) 2.3 KiB 2.3 MiB
marker line (mask/failed_run) 0.2 KiB 0.2 MiB

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 large
payloads) stay fully evictable.

Tests

CGO_ENABLED=1 go build -tags cg_skeleton ./...          # clean
CGO_ENABLED=1 go test  -tags cg_skeleton ./...          # all packages ok
CGO_ENABLED=1 go test -race -tags cg_skeleton ./store/... ./components/...   # ok
make lint                                               # go vet + gofmt clean

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; a
forced store miss does not flip an established compaction; a never-frozen message stays
verbatim at depth; a Nop store 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 not
counted 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_llm decision leaves the
cached-prefix message verbatim and triggers no re-derivation call

(components/all/freeze_repair_test.go). That last one fails on 3f7d700 with
verbatim=false, sameAsTurn1=false, i.e. it reproduces the reviewer's finding before
fixing it.

Each behavioral fix was verified to fail without it: reverting the sliding TTL fails
TestMemorySlidingTTLOnGet; reverting the repair fails
TestForcedStoreMissDoesNotFlipEstablishedCompaction with the masked→full diff printed.

Observability

/stats gains frozen_hits, frozen_misses, frozen_dropped, frozen_repaired,
frozen_flips (= dropped − repaired; should be 0). Additive only — verified against
the live endpoint that all 16 pre-existing fields are present and deploy/harbor's
st.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 on
    the unmerged origin/docs/terminal-bench branch, not on main. It should be marked
    measured when that branch lands.
  • The related MaxCachedIdx fail-open (prevLen miss ⇒ TailOnly true for every index,
    11.2% of TB requests) is documented in docs/design.md but left to its own change —
    inverting TailOnly is A3, not this issue. The sliding TTL does shrink the window, since
    cg:len: is read every turn and so no longer expires mid-session.

@OsherElhadad OsherElhadad 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.

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.expires refresh in store.GetTestMemorySlidingTTLOnGet and TestMemoryUnreadEntryStillExpires fail.
  • stubbing frozenLost to falseTestForcedStoreMissDoesNotFlipEstablishedCompaction fails with the masked→full diff printed, exactly as described. TestFlipCostOverLongSession reproduces flips=50 / 191,681 tok / $0.440.

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 = -1TailOnly 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:135codesmart 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

  1. Don't let extract_llm mutate at depth on an unverified re-derivation (blockers 1+2) — or verify the bytes before splicing.
  2. One key for result+summary (finding 3).
  3. Expired pinned entries must be evictable (finding 4).
  4. Pin or protect cg:len: (finding 5).
  5. Stop scoring over-cap decisions as dropped-then-repaired (findings 6+7).

Everything else is comments and docs.

OsherElhadad pushed a commit that referenced this pull request Aug 10, 2026
… 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>
@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

Thanks — this was a good catch list. All 10 items addressed in f6aa72c; every fix has a test that fails without it. Full gates re-run: build, test -tags cg_skeleton ./..., -race -count=5 on store+components/offload (419 s), gofmt -l clean, go vet clean.

BLOCKER 1 — you're right, and the trade argument was wrong

extract_llm is now excluded from the repair path entirely. I'd argued the repair was worth it because the alternative was a guaranteed suffix cache-write. That reasoning doesn't hold: if the re-derived bytes differ, the suffix is cache-written either way, so the repair branch just adds a model call. No upside — so there was never a trade to make. repairLostResult is deleted.

Confirmed your reproduction rather than taking it on faith: components/all/freeze_repair_test.go drives extract_llm with a stub model returning a different valid projection per call. On 3f7d700 the depth message comes back re-projected with fresh bytes (verbatim=false, sameAsTurn1=false) plus an extra model call; on f6aa72c it's left verbatim with no re-derivation call.

mask/failed_run keep the repair, for exactly the reason you gave — prefix + headPeek(content) + Marker(sha256(content)) is pure in (content, config) and position-independent. That distinction is now the documented precondition for the repair, not an incidental property.

BLOCKER 2 — same fix

Covered 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 codesmart config. Stated plainly in the PR body now (see below) rather than implied.

HIGH 3 — one key

cg:res: + cg:sum1: collapsed into one JSON value (cachedResult{p,s}). Deleted summaryKey/getSummary/putSummary. A replay now returns both parts or misses outright — an unreadable payload is also treated as absent, so a half-decision can never be spliced.

HIGH 4 — pins are reclaimable

evictOldest now reclaims expired entries first, pinned included, and a refresh can claim a pin slot that has since freed. Test: 10 dead-session freezes fill the cap, expire, and 30 ordinary Puts must both drop pinnedN and let a new session pin again.

HIGH 5 — cg:len: pinned

Added to the pin set. You're right that deferring A3 wasn't safe while this PR halved the pool it competes for — the change was making that fail-open more likely, not less. Test asserts it survives eviction pressure. Left TailOnly's inversion itself to A3.

Mediums

  • 6/7 — root cause fixed rather than patched: noteLost no longer fires at freeze time for an over-cap entry. It's present and readable, so calling it "dropped" both inflated the count with live entries and made the next re-freeze look like a repair. Losses are recorded only in remove; a repeat drop of an already-marked key isn't double-counted.
  • 8 — doc corrected: counts drop events, so dropped−repaired is a running balance of outstanding marks, not distinct broken keys.
  • 9 — loss-mark budget evicts oldest-first, so a busy session can't delete another's fresh mark. Marked ponytail: — it's one shared FIFO budget, not a per-session quota; the marks are session-scoped and short-lived, so age is a good enough proxy. Per-session quotas if that's ever shown to starve.
  • 10docs/reference/routes.md now documents all five fields, including that frozen_misses is a lookup counter dominated by the ordinary "not compacted yet" case, and that frozen_dropped is the one measuring harm.

On the write-up

  • Layering inversion fixed: the store no longer hardcodes component prefixes — they arrive via store.Options.PinPrefixes. Combined with the one-key change and the removal of the second repair helper, the divergence that produced blockers 1–3 is gone. Fully collapsing the two mechanisms into one helper I've left to feat(components): add xdedup — cross-turn duplicate tool-output references (39.8x measured re-send factor) #27/C3, since only the reproducible offloaders can share the repair path — that's a real asymmetry, not just duplication.
  • Shipped-default caveat is now a callout at the top of the benchmark section: the fix is unmeasured on codesmart, the codesmart arm's null result is reported as such, and what the general arm shows is that the mechanism works where reachable — not that the default improves.
  • Dropped the −24.7% cw/req normalisation. Agreed it's reading signal into an artifact with arms truncated at different points. frozen_dropped 184 → 0 is the claim; it needs no normalisation.
  • SWE control: picked one framing — not matched, so no conclusion drawn, including no "no regression" claim.

#35 coordination

The cg:len: pin is in store.Options.PinPrefixes (store.LenPrefix) and touches no apply logic, so it should compose with S10's tracker-eviction work rather than conflict — if #35 changes how the boundary is derived, the pin keeps protecting whatever key it lands on. Happy to rebase either way.

Osher-Elhadad added 7 commits August 10, 2026 09:11
…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>
@OsherElhadad
OsherElhadad merged commit d1c8510 into main Aug 10, 2026
5 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Aug 10, 2026
OsherElhadad added a commit that referenced this pull request Aug 10, 2026
…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>
OsherElhadad added a commit that referenced this pull request Aug 10, 2026
… 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>
OsherElhadad pushed a commit that referenced this pull request Aug 10, 2026
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>
OsherElhadad pushed a commit that referenced this pull request Aug 10, 2026
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>
OsherElhadad added a commit that referenced this pull request Aug 10, 2026
…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>
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(cache): freeze-store lifetime — sliding TTL, longer default, and no cache-destructive regression on a store miss

2 participants