perf(extract_llm): economic gate, global result cache, and the honest verdict (#28, rebased) - #51
Conversation
…obally 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>
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>
…tion 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>
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>
… 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>
Verified independently before mergeOrdering fix confirmed by reading the merged tree, not just the description — Both guard tests mutation-verified myself. The Your diagnosis of why the old coverage was insufficient is right and worth restating: Also good catch that the rebase was never actually finished: the branch sat on #42 while Full gates re-run on the final tree: build, On your two judgement calls — both rightThe rate inconsistency: documenting both is the correct choice. 82× at $0.20/MTok, 55× at $0.30/MTok, with the gate reasoning on the more generous rate so the shipped decline is the conservative one. Silently mixing them would have been the failure mode; picking the bigger number for the headline would have been worse. Either figure is an order of magnitude past break-even, so the conclusion doesn't depend on the choice — which is itself worth saying, because a conclusion that survives both plausible parameterisations is stronger than one tuned to a rate. Replacing the stale "clear win: net +$0.0287" was right, not overreach. That figure was produced by the H3 attribution bug and I'd already retracted it in the fix-round comment; leaving it in the body while the doc said otherwise would have shipped a contradiction. Marking it as the third result traced to a measurement artifact is the honest framing. That count now matters beyond this PR: three of this component's results were measurement artifacts, and all three pointed the same way — making the component look better than it is. That pattern is why the final Terminal-Bench arm excluded What this PR now establishesOn prompt-caching backends A negative result, honestly measured, with the component disabled where it loses money and instrumented so the next person can tell. Merging. |
Closes #28. Supersedes #34 (rebased; force-push blocked by branch protection).
extract_llmis the only component that spends money to save money, and on Terminal-Bench it lost: 271 calls, $3.26, 1,592,467 ms of added latency against 197,548 unique tokens saved — 82× 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 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 10× haircut.What's implemented
A. Prompt-cache the preamble — and the measurement that matters. The ~1,463-token invariant contract now goes in a stable
systemblock withcache_control(a leading system message on the OpenAI backend, which has no explicit breakpoints). Measured against the gateway, this is INERT onclaude-haiku-4-5, whose minimum cacheable prefix is 4096 tokens — a sub-minimum breakpoint is silently ignored:claude-haiku-4-5write=0 read=0— inertclaude-haiku-4-5write=5401thenread=5401claude-sonnet-5write=2653thenread=2653Shipped because it is free and correct where it wins, but
/statsexposesprompt_cache_read_tokensso nobody infers a cache win from placement.B. Reusing the agent's cached prefix — PROTOTYPED AND REJECTED, with numbers. It works mechanically (a 103,019-token prefix read from cache, no write, no invalidation) but cache-read is cheap, not free:
At ~1.7M contexts with 4 concurrent calls/turn that is ~$2.04/turn vs ~$0.016. Plus: risks a cache-write on the agent's own prefix (11.5× a read), and couples the compaction model to the agent model.
C. Global content-hash result cache. Re-keyed on
sha256(content + prompt version + model + config fingerprint)with no session prefix — 82 of 103 unique contents recurred across sessions. A version/model/config change misses rather than serving stale. Old entries inert once (noted); a session-scoped entry is honored as a migration read. Contrast #27's deliberately session-scoped index: reference vs derived result.D. Economic gate (on by default).
expected_saving = removed × (1 + expected replays) × per-token valuevs the analytic size-aware cost of a call. Per-token value fromCtx.CacheAware; ratio learned from results; cost from real pricing (CHEAP_MODEL_PRICE_*), never a constant. Every decision records a reason.E. Derived triggering. Context pressure + growth rate replace
min_tokens, which stays honored when set explicitly. A merely growing context no longer fires every step.F. Metrics.
/statsgains anextractblock: calls, calls avoided, calls suppressed, prompt-cache behavior, cost, gross value, and NET value after cost — the honest headline, previously impossible to see because cost lived in a field separate from savings. Purely additive; every existing key keeps its name sodeploy/harbor/*.pykeeps parsing.Two deadlocks found by benchmarking
Both the same shape — a pessimistic estimate suppressing every call, so nothing was observed, so the estimate could never correct:
Also fixed:
/compacthard-coded the context window as unknown, silently disabling every fraction-based trigger and the new pressure logic on that path, so offline eval measured a different component than ships.Benchmark results
Replay of real captures,
aws/claude-haiku-4-5.forced= pre-#28,gated= new default.Terminal-Bench (20 requests):
SWE-bench (19 requests):
The verdict — stated plainly
On a non-caching backend the gate is a strict improvement, but not a profit. The tables above were produced before the H3 attribution fix, when
/statscredited the whole pipeline's savings to this one component. With extract-scoped attribution the best available result is break-even, not the reported +$0.0287. What survives the correction is real and worth having: 68% less waste on Terminal-Bench, 26 calls → 1 on SWE-bench, latency roughly halved. A genuine positive result needs outputs above ~1,800 tokens on non-caching traffic, which neither capture contains.That attribution error is the third benchmark result in this PR traced to a measurement artifact, alongside the flat $0.012/call cost and the 0.45 compression-ratio prior. All three pointed the same direction — making the component look better than it is.
On a caching backend
extract_llmis still net-negative even after #28. The gate reduces the loss and cuts latency ~30%, but it cannot make the component pay when saved tokens bill at the cache-read rate and break-even is ~30,500 tokens/output against a largest-observed output of 2,053. Recommendation: disable it by default on caching backends — prefercodesafe(no LLM pass) or dropextract_llmfromcodesmartfor caching traffic. This is documented as the verdict rather than dressed up as a win.Break-even figures are pinned by test, so drift fails CI instead of silently invalidating the documented verdict.
Update: the external arm re-derived the economics, and it is worse
The final Terminal-Bench arm running on merged
mainexcludedextract_llmentirely and independently re-derived its economics with a harsher result:The improvement plan's ~8× implicitly priced those tokens as fresh input. They were never fresh input: every one of them was inside the cached prefix, so the correct rate is the cache-read rate and the honest ratio is an order of magnitude worse. The 1,592,467 ms of blocking time is not priced at that ratio at all.
Priced instead at the $0.30/MTok sonnet-class rate the gate itself uses, the tokens are worth $0.0593 and the ratio is 55×. Both figures are the same conclusion; neither is within two orders of magnitude of paying for $3.26. The gate reasons with the more generous of the two, so the shipped decline is the conservative one.
This PR's verdict was right, and understated. Nothing in the recommendation changes — the component is already hard-declined on caching backends — but the docs now carry the corrected arithmetic instead of the 8× figure.
Cache-safety: ordering of the two result-cache reads
Restored during the rebase, and the reason it matters:
repairLostResultto prevent.So the order is:
getResult(depth-safe) → tail gate →getResultGlobal, and only the same-session path may bypass the gate. A global hit is treated as a new decision: tail-only, then frozen into the session so later turns replay it from the depth-safe path.TestGlobalCacheHitIsNotSplicedAtDepthfails if the ordering is flattened — verified by flattening it and watching it fail, then restoring. The invariant is named at the call site so the next person does not have to rediscover it.Rebase onto current
mainThis branch is a rebase of #34 onto
mainat5768b00, replayed across nine merges (#33, #36, #40, #41, #42, #43, #44, #49, #50). Force-pushing the original branch is blocked by branch protection, so this supersedes #34 — same pattern as #37 → #42. Two merges required adaptation:getResult/getResultGlobalnow return(cachedResult, bool)carrying{Projected, Summary}in one JSON value, andputResult/putResultGlobaltake four arguments;getSummary/putSummary/summaryKeyand their global twins are gone. Adopted on both the session and global paths. The comments record why: the splitcg:res:+cg:sum1:keys had independent TTLs and pin slots, so a replay could hit one and miss the other and emit half a decision — projected text with the summary segment silently gone. Splitting the pair across two global keys would re-create that bug cross-session, so the global namespace keeps them together too./statsblock and this PR'sextractblock both extend the same function; both are additive and both are kept.Shipping guard, re-verified
AllowOnCachingBackend(allow_on_caching_backend) and the hard decline inevaluateGatesurvived the rebase intact, and there is now an end-to-end test that fails if either is ever lost:TestNoDefaultConfigRunsExtractLLMOnCachingBackenddrives the bare defaults and thecodesmartpreset config against a ~240k-token output — far above the ~30,500-token cached break-even, so the economics alone would permit the call and only the hard decline can suppress it. Mutation-checked: forcingallowCached = trueat construction makes it fail. The escape hatch is asserted too, so the guard is a default and not a wall.Verification
CGO_ENABLED=1 go build -tags cg_skeleton ./...✅CGO_ENABLED=1 go test -tags cg_skeleton ./...✅go test -race✅ (the component runs concurrent per-output calls)make lint✅go test -race -count=2 ./components/... ./proxy/...✅gofmt -lclean ·go vet ./...✅TestNoDefaultConfigRunsExtractLLMOnCachingBackend(the shipping guard, end to end through the presets).TestGlobalCacheHitIsNotSplicedAtDepthconfirmed to fail when the read ordering is flattened.Docs
docs/components/extract_llm.mdrewritten (economics, triggering, caching, measured tables, honest verdict),docs/reference/config.md(pricing env),docs/reference/presets.md(codesmart/aggressivebehavior change + verdict).Assisted-By: Claude Opus 5