feat(summarize): the pre-expiry summary gate — compact only when the write was already due, off the hot path - #234
feat(summarize): the pre-expiry summary gate — compact only when the write was already due, off the hot path#234amiddavid wants to merge 23 commits into
Conversation
…hat "native" compaction means Answers two questions about compacting a nearly-full context before the prompt cache goes cold. The trigger: reuse `(*ExtractSweep).sweeping`'s predicate rather than the cold gate. The cold gate is unsafe on keep-alive'd accounts — a ping never calls `modes.Tracker.TurnAt`, so `ColdCache` reads true while the provider entry is live, which is the -$708 mechanism `promexport.go` documents. Pre-expiry reads `remaining` negative there and declines. The fill half of the condition already exists (`Trigger.MinRequestFrac`); the cache-state half does not, and lifting the predicate into `components` keeps one fact with one reader. Native compaction is two questions: Claude Code's auto-compact is client-side (measured median 167,425 = ~84% of a hardcoded 200,000 across eight tenants) and cannot be invoked by a proxy without lying about usage. The Anthropic API's `compact_20260112` can — its threshold is a per-request field, so setting it just under the current input count is a de-facto on-demand invocation — but the `compaction` block must be echoed back on every later turn, which Claude Code will not do, so the proxy has to own that state. Records what is not measured, chiefly how often the pre-expiry window would fire. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…live timer Three corrections after review. The trigger is two-tier, not one: unconditional at 0.99, and at 0.9 only when the cache is within seconds of expiring. The second tier is a statement about a moment when nothing is in flight, so a pipeline gate can only observe it if the agent happens to send a turn inside that slice — which is what made firing rate the design's largest unknown. proxy/keepalive.go is already that timer, already holds the bytes last sent upstream and the credential, and already makes an upstream request on its own initiative. Having its ping send the summarized body when fill is >= 0.9 fires at 280s by construction and takes the ~19.6s summarizer call off the agent's critical path. The TTL refresh is not an added bonus: the provider refreshes an entry on every read as well as every write, so the uncompacted turn buys the same five minutes for free. The gain is entirely in what the next miss costs — $0.50 against $5.88 on opus at 0.9 of 1M. The 167K auto-compact mode was measured on eight tenants and does not describe every deployment; a client that knows it has 1M compacts near the ceiling, and for that client the 0.9-1.0 band is exactly what nothing protects. Withdraws the "just fix the advertised window" caveat as a general claim. Adds the honest version of influencing the client: forwarding fewer tokens genuinely lowers the reported input_tokens, so compaction likely delays the client's own auto-compact rather than racing it. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…re-expiry window summarize fired whenever its size thresholds were met, which on a large session means compacting while the prompt cache is warm — invalidating a live prefix and paying a cache-write of the whole suffix at 1.25x fresh. Measured on production traffic (snapshot 2026-09-04): every session that reached 90% of a 1M window and kept running went cold in that band repeatedly — minimum 2 full-prefix rewrites, median 7, maximum 46 — paying $571 that would have been $40 on a compacted prefix, and 110 of 126 rewrites landed on the way UP through the 90-99% band rather than at the ceiling. So the default is now `min_request_frac: 0.9` AND `cache_state: pre_expiry`. 0.9 is not an optimum; the trade is linear across 0.6-0.95 and this is its conservative end. THE CONTROL FLOW HAD TO CHANGE FIRST. summarize declined by returning before its checkpoint replay, which was survivable while every trigger was a roughly monotone size threshold. Under a gate that is true for seconds at a time, turn N emitted [head, summary, tail] and turn N+1 emitted the transcript FULL — diverging from the cached prefix at the first summarized message and re-writing the whole suffix. The feature would have cost money on nearly every turn to save it on a few. Offload now gates without returning, exactly as extract_llm_sweep does; the gates suppress the model call, never the splice. Replay also moved above model resolution, so a deployment with no summarizer still replays, and tryReuse now reports a valid checkpoint as stale under `resummarize_tokens: 0` instead of reporting nothing. The pre-expiry predicate moves out of extract_llm_sweep into components.CachePhase so both callers read one derivation. They answer Unknown oppositely on purpose: the sweep's ask needs a cache that provably exists, while a size-gated compactor must fire where the cache-aware path never ran at all — MaxCachedIdx is -1 there, so declining would protect nothing and disable the component. The fill fraction refuses to resolve against a guessed window. DefaultStatic answers 200,000 for every Opus and Fable against a real 1,000,000 and reports ok=true, so 0.9 would have fired at 180k. modelinfo gained an ExactResolver capability and the gate declines unless the figure is published for that model. The wrong table entries are a pre-existing defect extract_llm also consumes and get their own issue. cg:sum: is now pinned: the checkpoint stopped being a saving and became the only thing keeping a gated turn in the summarized shape. Both example configs set `cache_state: any` — they drive a raw endpoint, and the ceiling this default relies on is the client's own compaction. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
min_request_frac compared frac*window against schema.MessagesTokens. Those are
two different rulers, and the mismatch made the fraction unreachable rather
than merely inaccurate.
MessagesTokens counts message TEXT only -- no system prompt, no tool
declarations, no JSON envelope. A context window is stated in the tokens the
provider bills, which include all of it. Measured on this deployment's
uncompacted traffic the provider's count runs a median 3.38x higher (p25 2.43,
p90 6.80 -- dash/overview.go's EstimatorDivergence, computed over requests
where nothing was compacted so the two describe the same prompt).
So `MessagesTokens >= 0.9 * 1_000_000` really asked for about 3M provider
tokens on a 1M model: the request is rejected upstream, or the client compacts,
long before it can be true. The 0.9 default this branch had just installed as
summarize's shipped trigger could not fire at all, on any model -- 0.9 of 200k
lands at ~608k billed against a 200k limit too.
It went unnoticed because the tests were written in the broken units as well:
they asserted "0.6 of 200k does not fire on a ~4k request", which is true under
both the wrong arithmetic and the right one.
The fix measures the fill the way the window is measured:
- Ctx.PrevBilledInput carries the provider's own input count (fresh + cache
read + cache write) for the session's PREVIOUS turn. One turn stale by
construction, and deliberately so: a transcript only grows, so it is a
sound lower bound and a gate that opens one turn late is the harmless
direction.
- apply.RecordBilledInput is the only writer, exported so the host can call
it from the response path under the session id apply itself derived
(Trace.Session). Re-deriving the key from the outgoing body would hash a
transcript this pipeline had just rewritten, producing a different key on
exactly the sessions where compaction happens.
- store.BilledPrefix is pinned, for the same reason SumPrefix is: the trigger
declines when the figure is absent, so losing the key turns the component
off rather than merely costing a re-measurement.
- Trigger.Fires takes the Ctx and ANDs two separate conjuncts instead of
max()-ing them. Taking the larger of two numbers on two different rulers is
meaningless. min_request_tokens keeps its own ruler (MessagesTokens)
unchanged, so no existing config that sets it changes meaning.
- FracResolvable additionally requires PrevBilledInput > 0, so a session's
first turn counts window_not_exact rather than reading an unknown fill as
an empty one.
Tests are rewritten in the units that matter, with the case the old ones could
not express: a request whose message text is nowhere near the threshold, on a
session the provider has already billed past it, must fire. The fill-conjunct
table now asserts as a precondition that the fixture's MessagesTokens is below
the floor, so the test cannot quietly stop being about the ruler.
Also promotes summarize's four Report.Events names to constants and files
EventFreshSummary on the fresh path, so a second reader can identify a paid
summary rather than infer it from the absence of a replay name.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
The 0.9 default could not fire. Fixed in 85130c6.Found while building the dashboard measurement for this trigger:
So It went unnoticed because the tests were written in the broken units too: they asserted "0.6 of 200k does not fire on a ~4k request", which is true under both the wrong arithmetic and the right one. The fixMeasure the fill the way the window is measured — the provider's own reported input count:
TestsRewritten in the units that matter, with the case the old ones could not express: a request whose message text is nowhere near the threshold, on a session the provider has already billed past it, must fire. The fill-conjunct table now asserts as a precondition that the fixture's Verified on a clean Two Still not measuredThe firing rate remains unmeasured, and this defect is why it matters: the mechanism is only now capable of firing. The condition has to be met once per session rather than per turn, so the population that gets no benefit is sessions that reach the fill threshold and never have a turn land near cache expiry. 🤖 Generated with Claude Code |
The cache-aware trigger shipped on an argument -- a measurement of what cold
full-prefix rewrites COST -- not on a measurement of what compacting SAVED.
This is the latter, so the default can be defended with evidence rather than
with arithmetic about a counterfactual.
An EPISODE is one summary plus the span of work after it: from the turn a
summary was produced (t0) until the session has been billed 10% more of the
model's context window. Inside that span every turn that re-sent the compacted
transcript instead of the full one is worth something, and the sum of those
turn-by-turn amounts IS the saving -- realized, not projected.
# Almost nothing new is computed
The economics were already here and already right:
- Event.baselineDeltaUSD prices a removal as `SavedUnique x cacheWriteRate +
(Saved - SavedUnique) x repeatRate`, and repeatRate already prices the
re-sent remainder at the cache-READ rate on a turn whose cache hit and the
cache-CREATION rate on a turn whose cache missed, with three documented
guards against inflation.
- request_components.saved_usd stores that per component per request, priced
at write time with the rates then in force.
- requests.cache_miss_reason already labels each turn hit / ttl_expiry /
cold_start / prefix_change / unknown, via Event.AttributeCache.
So both halves of the question -- what the cold rewrites would have cost
uncompacted, and that the warm reads are cheaper too -- are existing columns.
This SCOPES them into spans and SPLITS them by the cache state of the turn that
earned them. No second pricing path to keep in agreement with repeatRate.
# The judgement calls
- The span is measured in PROVIDER-billed input, not tokens_before. The same
units error the trigger just had: a 10% span sized against message-text
tokens would really be ~34% of the window. tokens_before is still read for
the one thing it is sound for -- detecting a DROP, which is a comparison of
one measure against itself and is how a client-side compaction is spotted.
- prefix_change turns are credited to NO bucket. That verdict means the prompt
had changed, which on a summarized session is frequently our own doing, so
crediting it would pay this component for the misses it caused.
- The invalidation debit comes from t0's cache_write, not from its label: a
PARTIAL hit reads as `hit` (repeatRate's first guard says why), so a debit
derived from the label would be zero on exactly the turns where we rewrote a
live prefix. Stated as an upper bound, since some of that write was growth
that would have been paid anyway.
- The turn that closes one span and opens the next is credited ONCE -- its
saving to the span it closed, the new summary's costs to the span it opened.
Crediting both would make the sum of episodes exceed the money that existed.
- Only CLOSED episodes contribute money. Open ones would grow because the
query window moved; voided ones are not comparable. Both are counted.
- recorded and inferred are separate populations, never summed -- the same
discipline declcredit.go keeps between a measured saving and a modelled one.
inferred is the only way to see the old size-only trigger's episodes, and
therefore the only available comparison.
- Coverage is on the same panel: qualifying conversations that produced NO
summary, and what their expired-cache rewrites cost. Without it the panel is
survivorship and always positive.
- A conversation whose model window is not exactly published is excluded and
counted, never measured against the substring table's 200,000-for-any-Opus.
The walk is a pure function over ordered rows, so every case above is expressed
as a sequence of turns in a test rather than as database fixtures.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
One live run that exercises the whole chain: a session reaches the fill threshold, its cache goes near-expiry, summarize fires, the chat continues, and the Components tab figures are checked against the database by hand. The plan turns a $25 multi-hour run into a sub-$1 fifteen-minute one by pinning a small window in an operator price list -- the first link of the resolver chain and one that reports its answers as exact, so the fill gate engages at 27,000 tokens instead of 900,000. That exercises the arithmetic and the plumbing, which is what has been wrong twice; the doc says plainly what it therefore does not prove. The negative controls are the point. Every positive check would also pass for a trigger that fired unconditionally, so the plan requires four declines with their named gates -- including the guessed-window and the no-previous-billed cases, which are precisely the two defects this branch fixed and which arithmetic review did not catch. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…cold too
Two changes to when and how summarize compacts, both forced by a live run that
showed the previous design turning its own best case into its worst case.
# The run
haiku, real 200k window, min_request_frac 0.9, cache_state pre_expiry. Built a
session to 196,208 billed tokens, waited 250s to land in the pre-expiry window:
turn 18 trigger fired; the summarizer call HUNG FOR ITS FULL 300s BUDGET
(cg_latency_ms=300,128 against upstream_ms=2,838). It failed open, so
the full 197k transcript went upstream -- and the cache entry, which
had ~50s of life on arrival, had died during our own stall. 197,879
tokens written, model call wasted.
turn 19 arrived 310s idle -> Cold -> `pre_expiry` does not permit Cold ->
declined in 3ms. No summary.
turn 20 400 prompt is too long: 203,705 tokens > 200,000 maximum
The same call shape measured directly against the same gateway completes in
5-14s on haiku (6-19s on sonnet, so haiku is the faster choice for it). The hang
is unexplained -- gateway queueing after a 17-request burst is the leading
hypothesis and is unproven. It does not need explaining to be fixed.
# Only ever spend a cache write that was going to be spent anyway
That rule reorders the cache phases, opposite to what the first design assumed:
warm without: a cheap read with: a write AND a live entry destroyed
near expiry without: read now, maybe a full rewrite later
with: small write now, cheap reads after -- better GIVEN an
assumption about when the next turn arrives
expired without: a FULL rewrite (the entry is gone; this turn pays a write
regardless)
with: a SMALL write -- strictly better, unconditionally
So expired is the RELIABLE half and pre-expiry is the half carrying an
assumption. Expired is also the reachable half: pre-expiry needs a request inside
a 60s slice of a 300s lifetime, while any gap past the TTL produces an expired
turn on the next request. A session that only ever goes fully cold would, under
`pre_expiry` alone, never compact and grow into the provider's limit -- which is
exactly turn 20 above.
Default is now `pre_expiry_or_cold`: compact whenever it cannot hurt.
Cold is accepted ONLY FROM THE CLOCK (coldByArithmetic: a known TTL and idle time
whose difference is <= 0), never from Ctx.ColdCache. That flag is a known false
positive on keep-alive'd sessions -- proxy/keepalive.go never updates the turn
tracker, the -$708 mechanism promexport.go:807 documents -- so trusting it would
compact LIVE prefixes on exactly the sessions someone is paying pings to protect.
# The summary is produced off the hot path
The firing turn starts the work on a DETACHED context and forwards immediately;
a later turn splices the result, waiting up to summary_wait_seconds (120) if one
is still in flight. This deletes the timeout defect rather than tuning it: there
is no budget to get wrong on the hot path because the hot path does not wait.
- Detached context, because c.Ctx is cancelled when the response is written --
a background call inheriting it would be cancelled essentially always.
- The span is COPIED before the goroutine starts: req.Input is live and the
caller keeps mutating it.
- Single-flight per session, which is not an optimisation: without it a session
that keeps firing queues one ~48k-token call per turn and the last writer's
checkpoint wins arbitrarily.
- The detached call's budget is summarizeCallTimeout, the same one
CONTEXT_GURU_SUMMARIZE_TIMEOUT tunes. A second private constant was the first
move and was wrong twice: an operator tuning the summarize timeout expects it
to apply, and a budget unreachable from config cannot be shortened when a
provider goes bad. "Landing late is still useful" is already expressed by the
WAIT CAP being shorter than the budget.
- NO KEEP-ALIVE PING, and the reasoning is written down so nobody adds one: a
ping holds the OLD, FULL prefix alive at 0.1x -- the prefix we are about to
replace. Expiry costs nothing once a summary exists.
- A request with NO SESSION ID is summarized inline, because the checkpoint is
keyed by session: with no key there is no later turn that could find it, so
async would spend a call and discard the result every turn forever. This
keeps the library API and /compact exactly as they were.
Expect ONE turn per episode to stall a few seconds: the trigger fires only after
a 240s+ idle gap, so the user is active immediately after and the next request
usually arrives while the call runs. Ten seconds against a 197k write (~$0.22 on
haiku, ~$2.70 on Opus at 0.9 of 1M) is a good trade. The cap is for the
pathological tail, not the common case.
# Accounting corrections
- THE COMPACTION TURN IS DEBIT-ONLY. At t0 nothing has been saved -- we have
only spent. Crediting its own saved_usd front-loaded a saving at the
cache-CREATION rate for work that had not paid off yet.
- Self-caused writes inside the span are debited, not just t0's. A
prefix_change turn inside a span we opened is our doing; it was already
excluded from the credit, and excluding it from the debit too was having it
both ways.
- UNFINISHED AND VOIDED SPANS NOW CARRY A NET, reported apart from the settled
total. Excluding them was survivorship one level below the coverage line:
an unfinished span is exactly the case where we paid and have not recouped.
It is not automatically a loss -- we are behind only when 1.25C > 0.1F, i.e.
when the compacted prefix exceeds ~8% of the full one -- so the net is
computed rather than presumed negative.
# Tests
Every test asserting a summary appears on the triggering turn was migrated, not
deleted: the property they guard is unchanged, only the turn it appears on moved.
commissionThenSplice is the new primitive, and WaitForSummaryForTest /
WaitForAllSummariesForTest drain on the PRODUCTION channel rather than sleeping,
so a passing test exercises the real synchronisation.
TestSummarizeTimeoutIsCountedAndLeavesInputIntact now pins the opposite contract
to the one it used to: a blown deadline must NOT reach the caller, must leave no
checkpoint, and must move the counter -- which is the only place a degraded
summarizer shows up now that nothing waits for it.
Verified: gofmt, go vet, go test ./... and go test -race over
components/apply/dash all clean, plus mkdocs --strict.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
… episodes visible
Two defects the real-Claude-Code validation run found in the async execution
model, both of which made the compaction-episode panel overstate its own saving
-- the one direction a savings figure must never lean.
# The run
A genuine Claude Code session on claude-haiku-4-5 (real 200k window) through an
isolated test proxy: 18 turns, transcript grown to 136,998 billed tokens, then a
490s gap to force a real ttl_expiry. It behaved exactly as designed --
turn 17 ttl_expiry, above the fill -> summary_started, cg_ms=51 (the hot path
did NOT wait; this was 300,128ms before the async change)
turn 18 warm, so cache_state_declined_warm -> and reused_checkpoint anyway.
Billed input 136,998 -> 31,580: a 77% reduction on a turn the gate
declined, which is the gate-don't-return fix working.
-- and then reported `episodes: 0`.
# Defect 1: no request row carried a t0
The episode walk identified t0 by EventFreshSummary. That event is filed where a
summary is COMMITTED, which since the async change happens on a detached
goroutine with no request row and no Report to file against. So on the proxy path
no row ever carries it, and the walk found nothing: the coverage half correctly
reported one qualifying conversation with no episode, and the episode half was
blind to the very thing it exists to measure.
t0 now keys on EventSummaryStarted (or EventFreshSummary, which still reaches a
row on the inline sessionless path). Commissioning is also the better definition:
t0 carries the DEBITS and no credit, which is true of the turn that spent the
money whether or not the summary it paid for ever landed -- and a summary paid
for and lost is precisely what this measurement must not quietly drop.
# Defect 2: the spend stopped being attributable, then was counted twice
proxy's own TestCGLLMCostIsChargedToTheRequestThatSpentIt caught the first half:
cg_llm_cost_usd = 0 on the commissioning turn, because the goroutine finishes
after that row is written. The episode panel charges that spend as a debit, so a
missing cost inflates the reported net.
Fixed by attributing it one turn late, the same deliberate lag
Ctx.PrevBilledInput carries:
- cheapmodel.ReplayUsage attributes usage incurred earlier to the sink scoping
a later request. Narrow by design -- a general "add arbitrary usage" is an
invitation to double-count.
- store.UsagePrefix (pinned) holds what a detached call used until the next
turn of that session takes it. ADDITIVE, because two calls can complete
between one turn and the next and the second must not erase the first.
- takeDeferredUsage clears BEFORE replaying: replaying without clearing charges
one call to every later turn, a worse error than the missing cost it fixes.
It also refuses an all-zero record, which would otherwise increment the
sink's CALL count for a call already counted.
Then the fix double-counted, at exactly 2x, and TestOurOwnSpendCountsTheCacheTiersToo
caught that: WithCallSink CHAINS to whatever sink already scopes the context, and
the goroutine inherits the commissioning request's context -- so when the call
finished before that row was written, the cost landed there AND was replayed onto
the next turn. cheapmodel.WithDetachedSink installs a sink with no parent, making
the attribution single-valued. Process-wide totals are unaffected: those are
counted by the model wrapper, not by walking the sink chain.
Also renames the goroutine's `store` local to `st` -- it shadowed the store
PACKAGE, which the same goroutine calls into. It compiled, which is what makes it
worth renaming.
# Tests
The three cgllm tests now drain between turns so exactly one call's cost is
attributed, keeping their assertions about the RATE rather than about which row
carries it. The property each pins is unchanged: not another tenant's row, and
not silently zero.
TestRegistrationRateLimitedPerIP failed once in the full-suite run and passes
4/4 in isolation -- load-sensitive timing, not this branch.
Verified: gofmt, go vet, go test ./... and go test -race over
components/apply/dash all clean.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…s we did not cause
Two more defects the real-Claude-Code run found, both of which made the
compaction-episode panel report a LOSS on what is in fact the cheapest possible
moment to compact.
# The span axis was not monotone, so an episode could never close
The span was "this turn's billed size exceeds t0's size plus 10% of the window".
But compaction REDUCES what a turn sends: on the live run the billed input fell
from 154,584 to 31,682 the moment the summary landed. So the span sat waiting for
the transcript to regrow past a size the compaction had just removed. The episode
stayed `open` forever and contributed to no total -- the same blindness as having
no episode at all, reached from the opposite direction.
The span now closes on CUMULATIVE billed input since t0, which is monotone by
construction, is in the provider's units, and is what "10% more of the context has
been SPENT" actually means.
# A cold t0's cache write is not our cost
t0 on the live run was a genuine ttl_expiry: the entry had already lapsed, so that
turn was going to write its whole prefix whatever we did. Charging its
154,581-token write to us produced a $0.193 debit against $0.136 of credit -- a
reported net LOSS on the exact moment the trigger's default was widened to catch,
and on the one case where compacting is unconditionally better.
causedWriteUSD applies the same rule the trigger itself follows -- only a write
that would NOT have happened otherwise is ours:
- ttl_expiry / cold_start: the entry was gone, the write was due, debit is zero.
- hit: the entry was LIVE and we rewrote it anyway, so it is ours. A partial hit
also reads as `hit`, which is the conservative direction here.
- prefix_change / unknown: inside a span we opened, the thing that changed the
prompt is usually us. Charged.
# Verified against the same rows
Restarting the proxy over the EXISTING database re-read the identical recorded
turns through the fixed walk -- same input, only the arithmetic changed:
before: state=open invalidation_debit=$0.209 net=-$0.036
after: state=closed invalidation_debit=$0 net=+$0.149
# The fixtures were lying, and that is the third finding
row() derived TokensBefore from `billed`, so it FELL whenever a turn sent less --
which is what every turn after a summary does. A falling tokens_before is exactly
how this walk detects a CLIENT-side compaction, so every fixture in the file was
silently voiding its own episode and asserting against a voided one. TokensBefore
is now derived from ts and therefore monotone, which is the real relationship: our
outgoing shrinks while the client's transcript keeps growing.
The span fixtures moved to per-turn sizes that sum, and two of them had to be
corrected again after that: uneven sizes closed one span at turn 5 and silently
dropped turn 6's credit, and my own new test's 31,682 x 3 was short of the 100,000
span it claimed to pass.
New tests pin both fixes, each written from the live run's own numbers:
TestTheSpanClosesOnCumulativeSpendEvenWhenEachTurnGetsSmaller and
TestAColdT0IsNotChargedForAWriteThatWasDueAnyway (which also asserts the mirror:
a t0 whose entry was live IS charged).
Verified: gofmt, go vet, go test ./... and go test -race over
components/apply/dash all clean.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Review round 1 of 3 — three findings, one of which repeats Defect 1's shapeI read the async path ( 1. The episode span closes on the FIRST turn after t0, so the panel measures ~one turn — not "several cold misses"
Those two cannot both hold. Billed input re-counts the whole prefix every turn, so a single turn of a qualifying conversation is already several times The move from per-turn size to cumulative spend was right (a per-turn axis never closes — Why the tests do not show it: every span fixture keeps t0 tiny and the window at 1,000,000 while using the live run's per-turn sizes. Two directions, either fine by me: keep spend as the axis and state the threshold as a multiple of the window (widen 2.
|
… detached calls All three ranked findings plus the three smaller notes. Review: #234 (comment) # 1. The episode span measured one turn, not several cold misses The reviewer is right, and my own fixture comment stated the defect out loud: "t0 is deliberately SMALL: cumulative spend closes the span, so a large t0 would close it on its own turn." At the live run's real 200,000 window the span was 20,000 against a next-turn 31,682 -- it closed on the turn immediately after t0, a span of seconds in which no cold miss can occur, so ColdCreditUSD was structurally empty. Defect 1's shape one level up: the axis became a spend while the threshold kept its growth-shaped 0.10. Neither obvious axis works. Per-turn SIZE falls as soon as a summary lands (154,584 -> 31,682) so a span waiting for growth never closes; cumulative SPEND re-counts the whole prefix every turn so one turn exceeds the span. The axis is now cumulative NEW CONTENT in the provider's own units: - fresh_input: new by definition. - cache_write on a turn that HIT: the newly-written tail, absent from the entry that turn read. - cache_write on a turn that MISSED: re-creation of a prefix that already existed. Excluded -- counting it calls the whole transcript new every time an entry expires, which is what let a cold t0 close its own span. - cache_read: the re-sent prefix. Never new. Monotone by construction, comparable to the window, and growth-shaped. On the live run the post-summary turns add 3,323 / 158 / 275 of written tail against a 20,000 target, so the span covers tens of turns and minutes-to-hours of wall clock -- long enough for the idle gaps a cold miss needs. queryFrac's upper bound moves from 1 to 8, so an operator can ask for several windows' worth when investigating span length. TestAtProductionScaleTheSpanSurvivesMoreThanOneTurn is the test that was missing: the live run's real window and real per-turn read/write figures, asserting the span is still open after four turns and that t0's 167,263-token re-creation counts as no new content at all. # 2. observe mode could never fire the shipped default Also right, and the same defect resolveWindow fixes for /compact arriving through the other door. PrevBilledInput's only writer used tr.Session into tn.Store; in observe mode applyMode returns an empty Trace and the off-path run reads tn.Shadow. So the fill was permanently 0, window_not_exact on every turn, and an operator evaluating whether to enable this would have seen it save nothing -- in the one mode whose entire purpose is that projection. The response path now also records into tn.Shadow under the session the observe run derives. apply.SessionIDFor exposes that derivation and BodyOpts uses the same code, so the two cannot key on different ids. Derived on the RESPONSE path, so observe keeps paying only the enqueue for its measurement -- the property modes.go documents. # 3. No global bound, no drain maxConcurrentSummaries (8) bounds detached calls across sessions, acquired NON-BLOCKING: a saturated proxy declines to compact rather than queueing work nobody waits for. The refusal releases the per-session flight first, or the session would be wedged reporting the wrong reason forever. The two refusals now have separate gate names -- summary_already_in_flight (ordinary, self-correcting) and summary_concurrency_full (the deployment is shedding compaction) -- because one name for both reports a busy deployment as a busy session. summarizeUnresolved counts commissioned-but-unresolved calls, incremented at commissioning and decremented on resolution, so a non-zero value means calls are genuinely outstanding. Not a drain: a call in flight at exit is still money spent with no checkpoint, and this at least makes the outstanding count visible while the process lives. # Smaller - takeDeferredUsage's get/clear raced deferUsage's get/add/put and could erase a record unread, losing a cost -- the one direction the panel must not lean. deferredUsageMu serializes both; the lock is released as soon as the record is cleared, since the replay touches only that request's own sink. - extract_llm's fraction is left best-effort and now SAYS so, in the code and in its doc table. Adopting FracResolvable there would make it fire less on exactly the deployments that cannot report a billed figure, for no gain in correctness: its real gate is the per-candidate economics. - waitFor's `cap` parameter renamed to `limit`. Verified: gofmt, go vet, go test ./... and go test -race over components/apply/dash all clean, plus mkdocs --strict. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Round 1 addressed in
|
Review round 2 of 3 — the span axis is right; the counters it all rests on reach nobodyVerified in
Four things below. The first is the one that matters, and it undoes most of what #3's fix was for. 1.
|
…ession id structural Review round 2: #234 (comment) # 1. The async counters reached nobody AsyncSummaryStats had no caller anywhere in the repo. All six counters were maintained and served to nothing while three comments claimed they reached /stats -- and changing the signature from four returns to six compiled without touching another file, which is the proof there was no reader. That mattered more than an ordinary dead accessor: producing the summary off the hot path removed every OTHER way to see that path working. Inline, a slow or failing summarizer showed up as request latency, as a reverted component, and in that request's row. Detached, the request is already answered and no row carries the work until the session's next turn. These counters are not decoration; they are the only signal that exists, and the review found them pointing nowhere. Now on /stats as seven fields (the six counts plus the concurrency bound, which travels beside them for the reason llm_call_timeout_ms travels with its timeout total: a refusal count is meaningless without the ceiling it was measured against). TestAsyncSummaryCountersReachStats pins the route as a table, so a seventh counter added without a snapshot field fails there rather than silently going nowhere. Two repo guards fired on the new fields and both were right: - promexport_coverage_test: a Snapshot field must be exported to Prometheus or listed with a reason. They join the block whose own preamble says not to grow the exposition inside an unrelated PR -- seven new cg_* series do not belong in a change about the trigger. Started-vs-Committed is named as the pair to export first when someone does. - stats_golden_test: the /stats contract stays reviewed, so all seven are listed there. # 2. summarizeUnresolved's comment claimed something impossible It said an operator watching across a restart sees what was lost. A process-memory counter cannot, and it dies with the process exactly like the started/committed gap it was meant to explain. Taking the reviewer's recommendation over my own offer: NO shutdown hook. A hook that waits on in-flight summaries adds a path that can hang the process for a cost-saving measure nobody is awaiting. Instead, commissioning and resolution each emit a log line, so an unmatched pair survives the restart and is queryable afterwards. The resolution line is in a defer, so it fires on every exit -- success, error, timeout, recovered panic -- because a missed line would fake the signal being preserved. # 3. SessionIDFor's comment was false, and the copies had already drifted The comment claimed BodyOpts called the same function. It did not: BodyOpts derived inline, and the two had already diverged on which body shape they accept -- SessionIDFor fell back to `input` where BodyOpts requires an array `messages`. So a body carrying `input` produced a session id from one and nothing from the other, and the billed-input record landed under an id no pipeline would read. The bug was invisible because both functions were individually reasonable. Made structural rather than re-commented, in two shared pieces: - messagesArray is the one gate on a body's message array. - sessionIDFrom is the one derivation of the id from normalized messages. BodyOpts and SessionIDFor both call both. There is now no copy to drift. # 4. The observe fix had no test A permanent-zero defect fixed with nothing pinning it. TestObserveModeRecordsBilledInputIntoTheShadowStore drives a real observe-mode request and asserts the cg:bin: record lands in the shadow store under the id the observe run derives. Verified to FAIL with the fix disabled and pass with it -- otherwise it pins nothing, which is the failure mode the repo's anti-vacuity convention exists for. # Minor newContentBilled assumes a breakpoint keeps up with the tail, which the live run does show. Where breakpoints lag, the same tokens are billed fresh on arrival and written later, counting twice and closing the span in roughly half the new content it claims. Named in the comment, with the direction: it errs toward a SHORTER span, which under-reports this component rather than over-reporting it, and a client that never writes degenerates to counting fresh input only, which is correct. Verified: gofmt, go vet, go test ./... and go test -race over components/apply/dash all clean. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Round 2 addressed in
|
Review round 3 of 3 — signing offVerified in
One small note, not worth a fourth round: Sign-offAcross three rounds this PR fixed: a measurement axis that closed every span on the first turn after t0 (with a production-scale test that fails under the old axis), a permanent zero in the mode operators use to decide adoption, an unbounded detached-call path, a lost-update race on the summarizer's own cost, six counters that reached nobody, and a duplicated session-id derivation that had already drifted. None of those were request-path correctness defects, and the fail-open and reversibility invariants held throughout every version I read — but four of the six were silent-zero failures, which is the class this component can least afford, and all four are now pinned by a test that discriminates. Approving. Two things are open by explicit acknowledgement rather than oversight, both in the PR description, and neither blocks: |
… copy of it Round 3's one remaining note, which was right and worth closing rather than deferring: #234 (comment) TestAsyncSummaryCountersReachStats re-executed the /stats handler's own assignment expression against a local Snapshot, then asserted the result matched AsyncSummaryStats. So it pinned a COPY of the route: swapping two fields in proxy.go alone would have passed, because the test performed the same swap. That is the same shape as the defect the test was written to prevent -- a check that cannot fail for the thing it names -- which makes it worse than a weak test. The whole reason these counters needed pinning is that they were maintained and read by nobody while three comments claimed otherwise. It now calls h.stats and decodes the JSON, the way stats_golden_test does. Two assertions: - every async field is present ON THE WIRE, not merely in the struct; - summarize_async_concurrency equals offload.MaxConcurrentSummaries(). The bound is what makes the field-to-source pairing assertable at all: it is a distinctive constant, so reading it back proves the field carries ITS source rather than merely existing. The six counts are zero in a fresh process, so key existence is all they can honestly be held to here -- and stats_golden_test already pins that over the real handler. Verified against the exact defect described: mis-wiring SummarizeAsyncConcurrency in proxy.go alone now FAILS the test ("on the wire = 0, want 8"), where the previous version passed. Verified: gofmt, go vet, go test ./... and go test -race over components/apply/dash all clean. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Round 3's note closed in
|
|
Confirmed in Agreed that this was worth closing rather than deferring, and for the reason you give: a test that re-performs the swap it is meant to detect is an instance of the defect it was written to catch, which makes it worse than no test — it reads as coverage. Nothing further from me. My three rounds are used and everything raised in them is addressed; the sign-off stands as posted, with the two acknowledged gaps ( A formal GitHub approval still has to come from an account other than the author's; that is the repo owner's call. |
Acceptance run:
|
Acceptance confirmed — with one precise residual worth namingI re-derived the headline rather than reading it: turn 15 wrote 149,363 tokens, turn 17 wrote 32,123, and the 117,240-token difference at haiku's 1.25/MTok write rate is $0.14655 — matching your billed figure. The detail I would keep in view: the reported credit is 13% below the billed delta, i.e. the two-rulers imprecision under-reports this component. That is the safe direction and it is why the acceptance still stands — but it also means anyone who later "fixes" One residual, small and precise. This run confirms the credit computation on live traffic and the closing logic only at
That closes everything I raised. Three rounds, six findings, all addressed with tests that discriminate — and the two that changed the design rather than the code (the span axis and observe mode) were both cases where the first fix was reasoned from the wrong end. Good work. The firing rate stays open by agreement, and the formal approval remains the repo owner's to resolve, since GitHub refuses self-approval on a shared account. |
…s by design The reviewing session on #234 flagged a precise residual after acceptance: the run verifies the credit computation on live traffic and the CLOSING logic only at a narrowed span. At the shipped span the episode is correctly open, so the settled total for the run is $0 -- and a closed episode at the shipped span remains unobserved on real traffic. The axis is verified either way, because closing is span-parametric: the same rows close at span=0.002. But an operator opening the panel on a freshly summarized session sees populated credits beside a $0 total, which looks broken and is not. A summarized session adds new content slowly by construction -- five turns after the summary on the live run had accrued 3,486 of a 20,000 target -- so open is the normal early state. Recorded in the operator-facing doc rather than only in a PR comment, which is where a note like this scrolls away. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Both post-acceptance points actioned1. The imprecision is now #240Filed with the measured pair, and you were right that a paragraph here would scroll away — it is the kind of number that is very hard to reconstruct later. I re-derived it rather than copying your figure, and it comes to 12.42%:
Your framing of why it matters is the part I made the centre of the issue: it under-reports, so a fix moves every savings figure in the product upward — the direction needing the most justification. Doing that deliberately, with the arithmetic on record, is a very different act from someone later "correcting a tokenizer" and silently inflating the headline. And your point that it is the third site of one root cause is in the issue as the argument for filing rather than patching: Own issue, own branch off 2. The residual is now in the operator-facing doc —
|
Review — four blockers, and a live run that says the mechanism does not currently fire on real trafficReviewed by four independent passes over Credit where it is due, since most of this comment is critical. The hot-path claim is real and measured: the firing turn adds 0.378 ms. BlockersB1 —
|
| model id | production chain | frac gate |
|---|---|---|
claude-haiku-4-5 |
200,000 exact | can fire |
aws/claude-sonnet-5 |
1,000,000 exact | can fire |
aws/claude-opus-5 |
1,000,000 exact | can fire |
aws/claude-sonnet-5[1m] |
1,000,000 exact=false | never fires |
aws/claude-opus-5[1m] |
200,000 exact=false | never fires, and 5× low |
None of production's 42 prices.yaml entries carries a window:, so Table.Window returns ok=false and the operator table cannot rescue it.
Two honest scope corrections. (i) On the gateway credential the test proxy used, the [1m] ids 403 ("team can only access aws/claude-sonnet-5, aws/claude-opus-5"), so no traffic on them reaches the gate there — but they are routable for other credentials on this host, and Claude Code is configured with exactly aws/claude-opus-5[1m]. (ii) The 5×-low Opus window is currently saved from being acted on by exact=false — but OutputFloor/IsHuge/extract_llm's frac use the non-exact Window(), where too-low is only safe in one direction.
Fix: strip a trailing [...] suffix in normalize(). A MODEL_PRICES entry with a trailing * is a working operator-side workaround in the meantime.
Also note the cold-start window: refreshIfStale fetches asynchronously, so round-1 lookups return exact=false even for models in the map. After every restart, summarize silently cannot fire until the map lands.
The empirical arm — the mechanism does not fire on real agent traffic
Real claude -p session, 14 turns, claude-haiku-4-5, shipped defaults (min_request_frac/cache_state absent so applySummarizeTriggerDefaults installed 0.9 / pre_expiry_or_cold):
0 of 14 turns fired, with both gates independently closed on every turn. Peak fill 150,840 = 0.754 of the window (threshold 180,000). cache_miss_reason was hit on 13 turns and cold_start on 1 — zero pre_expiry, zero ttl_expiry. Firing required scripting a deliberate 310 s idle gap after forcing billed input to 191k.
The PR concedes pre-expiry is hard to reach and argues expired is "the reachable half". On real traffic neither occurs, because an active agent keeps hitting the cache. The intended path appears unreachable in normal agent use.
The one forced episode lost money: −$0.0144, from provider usage blocks at production rates, including the summarizer's measured $0.0727. Break-even is roughly six more warm turns than the session had. acted was verified before crediting anything (verdict=acted, saved=139,297).
The dashboard reported net_usd = +$0.376 for that episode — wrong sign and wrong magnitude, from B3 plus a second cause: the credit is priced at the cache-write rate on turns that were cache hits (10.3× per turn).
The stall moved rather than disappeared. Firing turn: 0.378 ms. Next turn: 88,917 ms. The PR predicts "a typical episode carries ONE turn that stalls for a few seconds" and sets defaultSummaryWait = 120s for "the pathological tail, not the common case" — the first episode measured 89 s. Worth re-examining whether 120 s is the right cap and whether the wait honors the incoming request's cancellation.
Taken together: the acceptance run validated a path that normal usage does not take, and it used
claude-haiku-4-5— the one model class with no[1m]suffix, and therefore the only one where the feature could have fired at all.
Should-fix
Gating
trigger.cache_stateis unvalidated on 2 of the 3 components that accept it, and inert on both.trigger.go:97claims constructors reject a bad value; onlysummarizedoes (summarize.go:246-250).extract_llmandextractacceptcache_state: pre_expiryywithout error — and neither consults the key (zeroCacheAllows/CachePhasehits), so even a validcache_state: coldis silently inert on a maximally warm turn. The settings form advertises it on all three with the hint "Restrict firing by the prompt cache's state". Fix: a sharedTrigger.Validate()called by every constructor, and suppress both keys fromTriggerFieldsfor components that do not consult them.pre_expiry_secondsis unvalidated against the TTL.600on a 300 s TTL makesremaining <= preExpirytrue for the entire lifetime → every warm request becomesPreExpiry→ compaction every turn, on a config the form accepts silently. Fix: reject>= 300(or clamp to a fraction ofCacheTTLMs) wherecache_stateis already validated.- Four readers of "is the cache cold", three disagreeing.
coldByArithmeticdrops the 60 s clock-skew marginapply.cacheIsColdapplies to the same timestamps (apply.go:977-982,coldMarginat:884), so for a full minute of every session's expiry the gate says cold while apply says warm — converting the PR's "strictly better, unconditionally" case into the harmful one. This also refutes the framing atcachephase.go:81-85: theColdCache-first check only makes the classifier more willing to say cold, and the reachable disagreement is the opposite one. All in a file whose comment says it exists so there is "one fact, one reader". Firesdrops the frac gate entirely whenPrevBilledInput == 0(trigger.go:205).summarizeis protected by pairing withFracResolvable;extract_llm.go:667callsFireswithout it, so the gate goes unapplied on turns with no prior billed input — firing more thanmain, the opposite of that call site's comment. Reach: theoretical for shipped presets (codesmart/houseset onlymin_request_tokens: 3000), real via the settings form, which offers the key.Fireschanged frommax()to AND — a real semantic change, currently untested. No shipped config sets both thresholds, so no operator is affected today.- Provider generalization. OpenAI is wrong in both cache modes: under
auto,cacheAware=false→Unknown→ permits compaction every turn, silently discarding OpenAI's real automatic prefix cache; underon,apply.cacheTTL'sdefault:arm assumes a 1-hour TTL, so the gate never permits. No middle setting exists. A second hardcoded 5 min lives atdash/event.go:868. Bedrock and Vertex-fronted Claude are fine (both in the family switch; dispatch is on the provider argument, so theaws/prefix never reaches it).
Async
- Latent data race:
summarize_async.go:313hands the live requestCtxto the detached goroutine, which readsc.StoreviaeffectiveMode— contradicting the discipline stated 55 lines earlier ("Everything the goroutine needs, read HERE while we are still on the request's goroutine"). The race detector trips on a test that writesc.StoreafterOffloadreturns. No production write exists today (Ctxis per-request and never pooled), so this is latent — but it is exactly the shape where the nextCtxfield write becomes a silent production race. One-line fix (hoisteffectiveMode, passmodeintocommitAsyncSummary) verified against all 8 audit tests under-race. - The three new pinned namespaces saturate the store's pin budget.
store/store.go:171-225pinsSumPrefix,BilledPrefix,UsagePrefix, all session-keyed — andcg:bin:is written on every response, not just summarizing sessions. MeasuredpinnedN == pinCapexactly (2,500 at defaults). Once saturated, admission silently declines, so newly-written cache-destructive entries (cg:frozen:,cg:result:) go unpinned and LRU-evictable — the loss pinning exists to prevent.store.go:100already warns these share the budget. No metric exposes saturation. recover()is silent — no log, no counter.- Goroutine exit depends on the
Modelclient honoringctx; an assumption, not enforced.
Dashboard
- A turn that closes one span and opens another is debited twice.
:418declarescreditedwith the intended rule;:510discards it (_ = credited). The existing test misses it twice over — its fixture usesmiss(CacheTTLExpiry)socausedWriteUSDreturns 0, and it asserts the new episode's cost without asserting the closing one is zero. - The panel renders all zeroes, in green, in the normal state.
open_net_usd,open_turns,voided_net_usdand the wholeepisodes[]drill-down are computed server-side and rendered nowhere inapp.js(grep count: 0 each). An open span showsOur cost=$0.00 Net=$0.00in good-text green while the real position is −$0.0025.64d488fdocuments this state and points at a per-episode row that is not rendered — a false-green resolved by a doc change. - The query plan collapses without
sqlite_stat1. Same 50,000-request DB, same query: aborted at 11 m 14 s with no stats vs 0.078 s with them (50,000² ≈ 2.5×10⁹ index probes).janitor.go:82runsPRAGMA optimizeevery 5 min so production usually has stats — exposure is the cold window plus the staleness case that comment itself warns about. ACROSS JOINbarrier does not help (SQLite cannot reorder a LEFT JOIN's operands); the problem is index choice. Measured fix:CREATE INDEX idx_rc_request_comp ON request_components(request_id, component)→ 0.081 s, stats-independent. ?span=NaN/?fill=NaNdefeatqueryFrac's guard.- The dataset read is unbounded and reports no truncation; the route is uncached on a main tab and shares the KV-cache semaphore.
- The credit side inherits
saved_usd's defects —saved_usdis the panel's only credit source (:765,:529-538) — while the debit side is computed independently from billed columns and correctly splits 5 m/1 h. The credit side structurally cannot split them:modelinfo.Pricehas no 1 h field. See saved_usd prices our own token count at the provider's rate, under-reporting savings by ~12% (measured) #240; cite the 12.4% content divergence, not the 3.38× (which is mostly scope, not part of the removed content). WindowUnknownexcludes rather than mis-buckets, so no wrong number is published — but coverage goes to zero on a[1m]deployment.
Security (details in a separate pass; nothing cross-tenant)
- Unvalidated summary content.
summaryWrapper(summarize.go:771) concatenates the cheap model's raw reply with no length cap, no marker stripping and no untrusted-content framing — then wraps it in authoritative instructions. Structural/role injection is blocked, so this is content-level steering from any page, file or command output the agent reads. Cheap fix: cap the reply against the span it replaces, and strip<<cg:/</summary>before wrapping (which also closes the forged-marker path, since the wrapper appends its own marker and one inside the reply is never legitimate). - The detached goroutine holds a live tenant credential for up to 300 s after the request completes.
Docs / nits
docs/reference/config.mdstatestrigger.cache_statedefaults topre_expiry; the code shipspre_expiry_or_cold(summarize.go:185). The doc drops the half the PR body calls both "the reliable half" and "the reachable half".docs/components/summarize.md:91has it right.- No upgrade note: an existing
summarize: {trigger: {min_request_tokens: 40000}}silently acquiresmin_request_frac: 0.9andcache_state: pre_expiry_or_cold, going from "fires on every 40k turn" to almost-never (or never, per B4). There is no CHANGELOG. newContentBilledclassifies onCacheRead > 0rather than the cache verdict; an unpriced episode still publishes dollars.
Not attributable to this PR
Seven components/offload tests (TestGatePermitsHighReuseContent, TestBreakEvenSizesMatchTheDocumentedVerdict, and five more) fail on origin/main at -count=2 — pre-existing extract_econ global-state leakage, independently reproduced. Not a regression here, but it means the PR's "go test -race clean" claim holds only at -count=1, and it deserves its own issue.
Claims that could not be verified here
The EstimatorDivergence percentiles; Defect 2's narrative (the 300,128 ms hang against upstream_ms=2,838, the 5–14 s / 6–19 s comparators, turn 20's 400 prompt is too long); the acceptance-run figures; and the −$708 keep-alive figure. All are production measurements quoted from the repo or the PR body, internally consistent but not reproducible from the tree.
Follow-up: live-run results — one blocker now confirmed by observation, and a correction to B4's severity in my previous commentThe empirical arm finished after I posted. It changes two things, one in each direction. Total spend $1.5855, wall time ~1 h 05 min, Correction — B4 is a latent resolver defect, not a live production blocker. My previous comment overstated it.I wrote that the
So on the ids this gateway actually serves, What remains CONFIRMED is the underlying defect: B1 is now confirmed by live observation — and the reachable trigger is ordinary concurrency, not a clock coincidenceThis is the strongest new result. FORCED config with Observed: Honest scope limit, from the agent and worth preserving: in this run the 13 permitted turns resolved to checkpoint replays ( A third, distinct dashboard defect: the credit is priced at the write rate on turns that hitSeparate from B3 (missing debit). Episode credit is priced at the cache-write rate ($9.5e-7) on turns whose cache hit, where the counterfactual is a cache read ($7.6e-8) — 10.3× per turn ($0.1323 vs $0.0129). Combined with B3, the panel reported Note the two are independent, so fixing only B3 still leaves the sign wrong. The rest, unchanged from my previous comment
The SHIPPED and FORCED configurations were kept strictly apart throughout, and |
Final note: the episode panel is live on a real deployment — a reviewer hypothesis to the contrary is refutedClosing a conflict between two of my reviewers, because it changes how urgent B3 is. One pass argued the panel currently publishes nothing on this host: with That premise is wrong, and the live arm already contradicted it ( Egress to the map works, The consequence runs the other way from the hypothesis: F1–F11 are live, not dormant. In particular B3 (the summarizer's own call never debited) and the write-rate-on-a-hit mispricing are affecting the Components tab on a running deployment today — which is why the Two smaller points from the same pass that stand on their own and are worth keeping:
For the record on method: this is the second correction I've posted against my own review in this pass. Both came from checking a claim that happened to be convenient — the first that a defect was live when it was latent, this one that a panel was dark when it is lit. |
Correction (3rd): the measured loss was understated 2.8×, the dominant defect is not the one I told you to fix first, and the
|
| Defect | Magnitude |
|---|---|
| Missing summarizer debit (B3) | $0.0727 |
| Credit priced at cache-write rate on turns that hit | $0.3583 |
fix the debit only -> +$0.3033 still reports a PROFIT on a losing episode
fix the mispricing only -> +$0.0177
measured truth -> −$0.03974
So my earlier framing — "treat B3's one-line fix as correspondingly more urgent" — was wrong on priority. B3 alone is not sufficient; it moves +$0.376 to +$0.303 and still publishes a profit on an episode that lost money. The mispricing is the dominant term and the one that flips the sign. Both need fixing, and if only one is done first it should be the rate, not the debit.
The rate defect remains two independent confirmations of the same line — the dashboard pass found it statically from creditTurn/Event.baselineDeltaUSD, the live pass found it on the wire — with the same mechanism and commit.
One honesty note on the decomposition: the two named defects sum to $0.4310 against a measured gap of $0.41574, leaving a −$0.0153 residue (3.7%) unaccounted for. So the decomposition is close but not exact, and there is likely a third smaller term. I'd rather state that than present the split as complete.
3. The [1m] resolver defect's reach is INCONCLUSIVE, and my credential premise was wrong
I wrote that the [1m] ids "are routable for other credentials on this host." That is now definitively refuted, not merely unsupported: the local settings file points at the same gateway host with the same key the test proxy loaded, so the 403 came from the identical credential — and it was a model-authorization refusal, with auth itself succeeding.
Since the client demonstrably works on that key with that model name, the client cannot be sending aws/claude-opus-5[1m] unchanged in the request body's model field. modelinfo reads the window from that field, so the resolver defect's reach is inconclusive and most likely latent-only — it needs whatever the body actually carries to be observed, which was not spent (~$0.01, one command, documented in the report's §1b).
Net effect: the normalize() fix is still correct and still worth making, but it should be treated as defensive hardening with no demonstrated production impact — not as a blocker. That is a further downgrade from my first correction.
Standing after three corrections
Unchanged and confirmed: 0 of 14 turns fired under shipped defaults (peak fill 0.754 vs 0.9; zero pre_expiry, zero ttl_expiry); the live observation of the CachePhaseUnknown gate opening over an 8-message cached prefix on 13 of 26 turns, reachable by ordinary concurrency; the free hot path (0.378 ms) with the stall relocated to the next turn (88,917 ms); and the panel being live rather than dark on this deployment.
Changed: the episode loss is 2.8× larger, the credit mispricing outranks the missing debit by ~5×, and the [1m] defect is latent with inconclusive reach.
Three of the corrections in this review have been against my own published claims, and each came from re-checking something convenient — a defect I called live that was latent, a panel I called dark that was lit, and a fix I called urgent that was the smaller half. The underlying findings survive; my confidence calibration on them did not.
A live review run drove real `claude -p` traffic through this panel and found it reporting `net_usd = +$0.376` for an episode whose measured effect was **-$0.03974**. Wrong sign. Three independent defects, all in the same direction. **1. The credit was priced at the cache-WRITE rate on turns whose cache HIT.** The dominant term, $0.358 of the $0.416 gap. The panel inherited `request_components.saved_usd`, which is `unique x cacheWriteRate + (gross - unique) x repeatRate`. The repeat term is right; the unique term has no meaning inside an episode. Nothing summarize removes inside a span is new content -- that is what makes it summarizable -- so the counterfactual for a removed span is never "this enters the prompt for the first time", it is "this is re-sent at whatever rate this turn paid". Going async is what exposed it. The stash is written by the detached goroutine, which has no Report, so the checkpoint key first reaches `rep.CacheKeys` on the turn that REPLAYS it -- one turn after the summary was commissioned. `Recorder.MarkUnique` sees a key it has never seen, returns the whole removal as unique, and the row lands priced at the creation rate. That turn is a cache hit, and unlike t0 it IS credited. So async moved the write-rate term off the uncredited compaction turn and onto a credited warm one. `creditTurn` now prices the credit itself: the tokens removed (`saved_gross`, newly selected) at the rate that turn's verdict earns -- creation where the entry had lapsed, read where it hit. The bucket labels and the arithmetic now agree, where before a figure priced at the write rate sat in the bucket documented as "billed at the cache-read rate". **2. The summarizer's own call was never debited on the async path.** `f61e8c0` moved the detached call's cost onto t0+1 (`ReplayUsage` + `store.UsagePrefix`), because the goroutine finishes after t0's row is written. t0+1 carries `EventAwaitedCheckpoint`, which `isFreshSummary` classifies as a replay -- so the charge, gated on `isFreshSummary`, wrote the cost to the database and then skipped reading it back. The repo had said so twice already, in `f61e8c0`'s own message and in `proxy/cgllm_test.go`; the proxy side was fixed and this side was never updated to follow. Our model spend is now charged on every turn in the span. **3. A turn that closed one span and opened another was debited twice.** `credited` was declared with the rule written out above it and then discarded as `_ = credited`. The rule is now structural (`closing && opensNew`), and costs are charged to the span the turn OPENS -- they are that span's investment -- so the sum of episodes can no longer exceed the money that existed. Each fixture now states the QUANTITY removed and each assertion names the RATE it expects, so a figure priced on the wrong side of a 12.5:1 ratio fails loudly instead of matching a plausible number. Two assertions exist only to reject the old arithmetic. The production-scale case pins the real live-run shapes, where the first replay turn's stored `saved_usd` is $0.14 against a correct ~$0.0105. The credit's remaining error is its quantity, not its rate: `saved_gross` is our own tokenizer over message text, ~12.4% below the provider's count of the same content (#240). That under-reports, and the panel now says so in `credit_quantity_note`. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
A review observed the shipped cache gate opening over a live 8-message cached prefix on 13 of 26 turns of a real session, reached by ordinary concurrency rather than by any exotic deployment. Three separate causes, all letting compaction proceed against a cache entry that had not been shown to be dead. **1. Unknown does not imply there is no live prefix.** `cachephase.go` justified permitting `CachePhaseUnknown` by asserting that "on those deployments MaxCachedIdx stays -1 ... There is no live prefix whose invalidation we would be avoiding." The implication is false. A request can carry `MaxCachedIdx >= 0` with no readable TTL, and Unknown then means only that we cannot say how much life the entry has left -- the state where compacting is most expensive and this gate was most permissive. Two shapes remain reachable beyond the concurrency one below: apply's legacy no-Tracker path, which every library consumer of `BodyFull`/`BodyOpts` takes, and `cache_mode: on` against a provider whose TTL this repo does not derive. `CacheAllows` now refuses Unknown when `MaxCachedIdx` says a prefix exists, scoped to callers that asked for a cache state at all so components that do not consult it are unaffected. The guard belongs here and not at the splice: a gated turn must still replay, or it reverts to the full transcript at the worst possible moment. That is also why one false positive was session-persistent rather than confined to its turn. **2. `IdleMs == 0` meant "unknown" where it should mean "zero idle".** `CacheRemaining` returned not-ok for a request arriving in the same millisecond as the previous one, and `CachePhase` turned that into Unknown, which the gate permits. Two concurrent turns are enough, and an agent issuing parallel sub-requests does that routinely. A turn with genuinely zero idle is the warmest cache there can be. `IdleMs` is now negative for unknown and zero for zero, apply records `nowMs == prevAt` as the real zero it is, and a backwards clock stays unknown rather than inventing warmth we have not measured. **3. The compaction gate used the sweep's cold threshold.** `coldByArithmetic` required only `remaining <= 0`, dropping the clock-skew allowance `apply.cacheIsCold` applies to the same two timestamps -- so for a full minute of every session's expiry it said cold and permitted a rewrite while apply still called that entry warm and the rest of the pipeline treated its prefix as live. That converts the case this design calls "strictly better, unconditionally" into the harmful one. The fix is NOT to collapse the two cold tests into one, and a test caught the attempt: `extract_llm_sweep` needs an entry that still EXISTS and summarize's gate needs one that is certainly GONE, so the safe error runs in opposite directions. `CachePhase` keeps calling nominal expiry Cold, which is what makes the sweep stand down at the right moment; `CertainlyColdByClock` carries the margin for the compactor. The window between them is a deliberate dead zone where compaction declines -- the honest answer for a window in which we cannot tell whether the entry is alive or dead. Every existing guard in `cachephase_test.go` set `MaxCachedIdx: -1`, the one value that makes Unknown safe, which is why none of them caught (1). The new cases pin the live-prefix refusal, that the component still works where Unknown genuinely means no prefix, the concurrent-turn classification, and the dead zone's width. All four were verified to fail with each fix removed. The keep-alive false positive in `coldByArithmetic` is NOT fixed here and the docstring now says so rather than implying otherwise: `IdleMs` derives from the same `prevAt` that `keepalive.go` never updates, so on a kept-alive session the function still does what it was written to prevent. That needs a design decision -- either keepalive updates the tracker, or the cold signal stops deriving from prevAt -- and it gets its own issue. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…ing a model id `normalize()` stripped a provider prefix but not a trailing `[...]` variant suffix, so an id like `aws/claude-opus-5[1m]` missed the LiteLLM map entirely and the chain fell through to `DefaultStatic`. Two consequences, both silent: - `DefaultStatic` is never exact, and `Trigger.FracResolvable` refuses to act on a guess. summarize ships `min_request_frac: 0.9`, so on any suffixed id the fraction gate could never fire -- and a gate that never fires looks exactly like a gate that is working. - `DefaultStatic`'s substring table answers 200,000 for every Opus, against a real 1,000,000. `exact=false` stops the trigger acting on that, but `OutputFloor`, `IsHuge` and `extract_llm`'s fraction all read the non-exact `Window()`, where five times too low is only safe in one direction. DEFENSIVE, with no demonstrated production impact. The review that found this corrected its own severity claim twice: the gateway it tested refuses these ids with a model-authorization error, and the client demonstrably works on that same key with the un-suffixed name -- so what the request body actually carries in `model`, which is the only field this reads, was never observed. The defect is real and the fix is right; it is not the live outage the first report called it. The suffix is stripped only when the bracket closes at the very end, so an id containing `[` keeps it and a malformed unterminated bracket is not silently rewritten into a different lookup. Both are asserted. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…g ones nothing reads Three review findings about `trigger:`, all of them silent failures. **`cache_state` was validated by one constructor out of three.** `CacheAllows`' docstring promises that "constructors validate the string and refuse a bad one at config time", and only summarize did. `extract` and `extract_llm` accepted `cache_state: pre_expiryy` without complaint. The check moves to a shared `Trigger.Validate(component)` that every constructor now calls, so it cannot drift apart again. **`pre_expiry_seconds` was unvalidated against the cache lifetime.** A window at or above the TTL swallows the whole lifetime: `remaining <= preExpiry` is then true for every request that has an entry at all, so `CachePhase` returns PreExpiry always and Warm never, and a component gated on pre-expiry rewrites a live prefix on EVERY turn -- the most expensive thing this pipeline can do. The settings form accepted `600` on a 300 s TTL silently. Now refused, against the shortest lifetime this repo derives, with a test that first demonstrates a one-second-old entry really does classify as PreExpiry under a 600 s window. **`extract` and `extract_llm` offered a `cache_state` control that does nothing.** Neither calls `CacheAllows` or `CachePhase` anywhere -- the cache phase belongs to `extract_llm_sweep`, which declares its own fields -- yet both inherited the keys from `TriggerFields`, hinted "Restrict firing by the prompt cache's state". An operator setting `cache_state: cold` there would reasonably believe the component had been told to wait for a cold cache, and it would go on firing on maximally warm turns. They now use `TriggerFieldsNoCache`. Advertising a key a component ignores is worse than not offering it; a component that starts consulting the phase switches back and gets them. Also pins the `max()` -> AND change in `Fires`, which was a real semantic change with nothing asserting it. No shipped config sets both thresholds, so it was invisible -- which is exactly when a test is worth writing, before someone relies on the old behaviour. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
….Validate Replacing summarize's local cache_state check with the shared validator left "fmt" and "slices" unreferenced, so the tree did not compile. I read a deduplicated build output as success and committed it; `go build` and `go vet` are both clean now and were re-run against the real output rather than a truncated one. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
… make its panics visible
Two review findings on the async path, plus the honest resolution of a third.
**The goroutine read the request's `Ctx`.** `commitAsyncSummary` took the live
`*Ctx` and called `effectiveMode(c, s.mode)` from inside the detached goroutine,
which reads `c.Store` -- contradicting the discipline stated 55 lines earlier
("Everything the goroutine needs, read HERE while we are still on the request's
goroutine"). No production write to a `Ctx` field exists today (a `Ctx` is
per-request and never pooled), so it was latent rather than a live race; the race
detector trips on it, and it is exactly the shape where the NEXT field write
becomes a silent production race. `mode` is now read on the request's goroutine
and passed in, so the goroutine holds no `Ctx` at all.
**`recover()` was silent.** `_ = recover()` swallowed the only evidence that
anything had gone wrong. Nothing on the hot path waits for this call, so a
panicking summarizer showed up as a growing started/committed gap and no other
trace anywhere -- a component failing invisibly, which this repo has been bitten by
before. Now a counter (`summarize_async_panics`, through `/stats` with a
`notExportedWhy` reason and the golden list) and an ERROR line carrying the panic
value, because a count says something broke and only the value says what.
**The inert `cache_state` on `extract` / `extract_llm` is NOT fixed here, and the
docstring now says why.** Both embed a `Trigger` and call neither `CacheAllows` nor
`CachePhase`, so `cache_state: cold` on either is silently ignored while the form
describes it as "Restrict firing by the prompt cache's state". Both obvious fixes
were tried and both break a contract this repo already keeps:
- hiding the keys from those components' fields fails
`TestEveryComponentDeclaresExactlyItsConfigurableKeys` -- a key the config struct
accepts must be declared, or it is settable in YAML and invisible in the UI;
- refusing the value in the constructor fails
`TestEveryDeclaredFieldReachesTheDocumentAndNothingElseMoves` -- a declared field
must accept every value it declares.
Both contracts are right, and together they say the defect is upstream of
validation: the key should not be in those components' config at all. That wants
`Trigger` split into a size-only embedded struct plus the cache keys, which is a
config-shape change with its own compatibility surface. Its own issue. Honouring
the key on those components is not the answer either -- they only ever rewrite the
uncached tail (`Ctx.TailOnly`), so they never invalidate the cached prefix, which
is exactly why they have no economic reason to wait for a cache state.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…d three smaller review findings **The panel was false-green in its normal state.** `open_net_usd`, `open_turns` and `voided_net_usd` are computed per provenance group and were rendered nowhere (grep count: 0 each). A summarized session adds new content slowly by construction -- five turns after a summary on a live run had accrued 3,486 of a 20,000 target -- so "open" is where a healthy episode spends most of its life. The table therefore showed a settled total of `$0.00` in good-text green while the real committed position was negative. A review found it; an earlier commit of mine had documented that state in prose rather than fixing it, which was the wrong end of the problem. Now the Net cell renders an em-dash when nothing in the group has settled, and a new exposure line above the coverage line reports the open spans, their turns and their current net, with voided spans reported apart and never averaged in. **`?span=NaN` defeated the range guard.** Every comparison against NaN is false, so `f <= 0 || f > 8` passed it through; the span then became NaN, which compares false against everything, so no episode ever closed and the panel reported an empty measurement rather than an error. NaN and Inf are now rejected first. **A missing index made the query statistics-dependent.** Neither `idx_rc_request` nor `idx_rc_comp` covers the `(request_id, component)` pair the episode query joins on. Measured on a 50,000-request database: 0.078s with `sqlite_stat1`, ABORTED AT 11m14s without it. `janitor.go` runs `PRAGMA optimize` every five minutes so a live deployment usually has statistics, which makes the exposure the cold window after a restart plus the staleness case that comment itself warns about. The new index takes the no-statistics plan to 0.081s -- statistics-independent rather than merely fast on a warm database. A `CROSS JOIN` barrier does not help and was tried: SQLite cannot reorder a LEFT JOIN's operands, so the problem was index choice, not join order. It goes in `additiveDDL`, beside the two existing indexes on `requests`, because a version bump discards every request row to add an index that changes no row's shape. **The summarizer's reply is now treated as untrusted before being wrapped.** `summaryWrapper` concatenated the cheap model's raw output with no marker stripping and then framed it in "Use this summary as the older context ... Continue the task accordingly". Structural injection was already impossible -- the reply becomes one user-role text message, so the #103 leaked-`tool_use` class is not reachable -- but the span being summarized is agent-read material, and our own control strings are never legitimate inside a reply because the wrapper appends the real marker itself. Both marker spellings `expand` accepts are stripped, including the JSON-escaped one that `expand.rawMarkerRe` exists for, plus the `</summary>` delimiter the summarizer's own prompt uses. Deliberately NOT attempted: detecting instructions in the reply (undecidable, and a filter that half-works invites reliance on it) or capping its length (never-worse already reverts a grown turn, and truncation would turn a long legitimate summary into a misleading one). Every new assertion was verified to fail with its fix removed. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
`finishEpisode`'s comment said "leave every dollar at zero", and that was true of
the credits and the invalidation debit — both are priced through guards that return
0 on an unknown rate — but NOT of `summarizer_cost_usd`, which is a stored
per-request figure that does not depend on the model's rates at all. So an unpriced
episode published a real summarizer cost beside four zeros and a zero net.
The rendered panel was safe: every cell goes through `usdOrNA` with the group's
priced flag. A JSON consumer of this public route was not. A review flagged the
general form ("an unpriced episode still publishes dollars"); this is the one
field where it was true.
The fixture needed a `cgCost` to exercise it at all — without one the new
assertion passed vacuously, which is the failure mode this repo's test discipline
exists to catch. Verified to fail with the fix removed.
Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…w's empirical work became Every number this PR argues from came out of a real `claude -p` run through an isolated proxy, and a measurement nobody else can re-run is an assertion. The review on #234 built such a run and found what static review had not; these are the three arms that work, checked in with the doc. export CG_SCEN_UPSTREAM="https://your-gateway.example.com" export CG_SCEN_SRC=/path/to/a/frozen/checkout tmux new -d -s scen 'scripts/scenarios/run-all.sh' **Arm A, the firing rate.** Shipped defaults, no injected idle -- the only arm whose result does not depend on any of this feature's code being right. It reports how many turns fired, WHICH gate closed, the inter-turn gap distribution (the cache gate needs ~240s of idle), and where the client runs its own compaction. That last one decides whether 0.9 is reachable at all: if the client caps below it the firing rate is a structural zero rather than a statistical one. **Arm B, three cold events in one span.** `ColdCreditUSD` measures an expiry that happens AFTER a summary, re-creating the compacted prefix instead of the full one. One such event cannot distinguish "the credit is computed" from "the credit accumulates per event", and the accumulation is what makes the amortisation model true rather than anecdotal. It prints the counterfactual explicitly -- t0's own re-creation of the full prefix, measured rather than modelled -- and the prevented rewrite per event. **Arm C, warm turns only.** The rate defect a review measured: a credit on a turn whose cache HIT priced at the cache-WRITE rate. It asserts the panel's figure equals `sum(saved_gross) x read rate` and does NOT equal the sum of the stored `saved_usd`, so a regression reads as the panel agreeing with the wrong one. It also queries a second time at a tiny span, because an arm that only ever reports an open episode cannot check the settled total. Isolation is the reason this is a library rather than something retyped per run: own proxy binary, port, config and dashboard db per arm, and a private `CLAUDE_CONFIG_DIR`. The production Context Guru is not in the request path at any point. That matters more than it sounds -- Claude Code's `settings.json` env OVERRIDES the process env, so `ANTHROPIC_BASE_URL=... claude` does nothing, and the first attempt at this sent every request to the production Guru without a word. Two rig traps are documented because both produced silently EMPTY runs, which is the failure mode that certifies nothing: - `PATH` replaced rather than prepended drops `~/.local/bin`, so every turn exits 127 while the log shows only "no rows". - A gap of 310s is past a 5-minute TTL and still inside `ColdMargin`, so the provider has already dropped the entry and the gate correctly refuses to say so. The gap has to exceed TTL + margin. The source is built from a frozen checkout on purpose: the binary under test has to be a known commit, and an arm reading its work files from a live tree can have them change underneath it mid-run. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…e the hand check to the span Both arms read the provenance GROUP's credit fields, which accumulate over CLOSED episodes only — an open episode's figures are reported apart in `open_net_usd`, by design. So on a run whose episode was open the check printed `0.00000000` for every bucket, which reads exactly like "the credit is broken" when the credit is fine and sitting in the other field. Both arms produced that reading on their first real run. Arm B's hand derivation also summed `saved_gross` over every turn after t0 rather than over the turns the panel actually counted. On the live run that disagreed by three whole cold events, because the span had closed before any of them happened — so the hand figure and the panel figure were two different populations and neither was wrong. It now scopes to t0 plus (turns-1), and says so explicitly when the span contains no cold event, because that is a finding about the span rather than about the credit. Arm C's verdict was prose a reader had to adjudicate. It is now four PASS/FAIL lines: the read credit equals gross x the read rate, does NOT equal the stored `saved_usd`, the cold bucket is zero, and the summarizer's cost is charged. An arm whose verdict needs interpreting is an arm that will be read as passing. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Review addressed — with a scenario suite that reproduces two of your findings on live traffic, and one finding of its ownThank you for this. Four blockers, three self-corrections, and an empirical arm that found the thing static review could not. Two of your findings I was able to confirm on the wire to the last digit, and the corrections you posted against your own claims made the triage materially better — I have followed your revised priority (rate before debit) rather than my own reading. Every fix below was verified to fail with the fix removed. The scenario suite is checked in —
|
| Arm | Config | Answers |
|---|---|---|
| A | shipped defaults, no injected idle | how often do both gates open on traffic nobody arranged? |
| B | forced, then three cold events in one span | does the cold credit accumulate per prevented rewrite? |
| C | forced, then warm only | is a warm turn's credit at the cache-read rate? |
Two rig traps are documented in lib.sh because both produced silently empty runs: PATH being replaced rather than prepended (every turn exits 127 while the log just shows no rows), and a gap of 310s — past the TTL but inside ColdMargin — where the provider has already dropped the entry and the gate still, correctly, refuses to say so.
Your findings, confirmed on the wire
The credit mispricing — confirmed exactly, and it is a 12.5x ratio on identical content
Arm B, real Claude Code on claude-haiku-4-5. The same removal, on two consecutive turns that both hit:
| row | verdict | saved_gross |
saved_unique |
saved_usd |
|---|---|---|---|---|
| #44 | hit | 123,251 | 123,251 | $0.15406375 |
| #45 | hit | 123,251 | 0 | $0.01232510 |
0.15406375 / 0.01232510 = 12.5 exactly — same content, same verdict, priced 12.5x apart purely because the stash key was first seen on #44. That is the mechanism precisely as you described it: on the async path the goroutine has no Report, so the checkpoint key first reaches rep.CacheKeys on the turn that replays it, MarkUnique calls the whole removal new content, and unlike t0 that turn is credited.
creditTurn no longer inherits saved_usd. It prices the credit from saved_gross at the rate that turn's own verdict earns — creation where the entry lapsed, read where it hit. Nothing summarize removes inside a span is new content; that is what makes it summarizable, so the "unique at the creation rate" term has no meaning here. The bucket labels and the arithmetic now agree, where before a figure priced at the write rate sat in the bucket documented as "billed at the cache-read rate".
B3, the missing summarizer debit — confirmed live
Same run:
#43 (t0) cg_llm_cost_usd = 0.000000 ttl_expiry summary_started
#44 cg_llm_cost_usd = 0.008315 hit reused_checkpoint
The cost is on t0+1, which isFreshSummary classifies as a replay, exactly as you said. Our model spend is now charged on every turn in the span. That slightly over-attributes another component's extraction call inside the span — the conservative direction, and the only rule that cannot silently miss our own call.
The double debit — fixed, and the fixture that hid it is fixed too
credited was declared with the rule written out above it and discarded as _ = credited. It is now structural (closing && opensNew), and costs go to the span the turn opens — that span's investment. You were right that the old test missed it twice over: its fixture used miss(CacheTTLExpiry) so causedWriteUSD returned 0. It now uses a hit, and asserts the closing span is charged neither cost.
B1 — the live-prefix gate. Fixed at two levels, and your concurrency framing was the better one
You were right that "same clock instant" was needlessly narrow and that ordinary concurrency reaches it. Two fixes, because your three reachable states have two different root causes:
IdleMs now distinguishes zero from unknown. CacheRemaining returned not-ok for a request arriving in the same millisecond as the previous one, and CachePhase turned that into Unknown. A turn with genuinely zero idle is the warmest cache there can be. IdleMs is negative for unknown, zero for zero; apply records nowMs == prevAt as the real zero it is; a backwards clock stays unknown rather than inventing warmth. That closes your case (a) at the root and classifies a concurrent turn as Warm, so the gate refuses it on the phase itself.
CacheAllows refuses Unknown when MaxCachedIdx >= 0, your suggested guard, scoped to callers that asked for a cache state. That covers (b) and (c), which genuinely have no readable TTL. cachephase.go's claim that Unknown implies no live prefix is corrected in place rather than left standing.
Your point that this belongs in the gate and not at the splice is in the code as a comment, with the reason: a gated turn must still replay or it reverts to the full transcript at the worst moment. That is also why one false positive was session-persistent.
Every existing guard in cachephase_test.go used MaxCachedIdx: -1 — the one value that makes Unknown safe. That is why none of them caught it, and the new cases say so.
On the cold margin — I tried your "one fact, one reader" fix and a test stopped me
You were right that coldByArithmetic dropped apply's 60s allowance. My first attempt put the margin in CachePhase so both readers shared one threshold, and TestSweepFiresOnlyInThePreExpiryWindow failed — correctly. The two readers answer different questions and the safe error runs opposite ways: extract_llm_sweep needs an entry that still exists (so assume it's gone), summarize's gate needs one that is certainly gone (so assume it may live). CachePhase therefore keeps calling nominal expiry Cold, and CertainlyColdByClock carries the margin for the compactor. The window between them is a deliberate dead zone where compaction declines — the honest answer for a window where we cannot tell.
Arm B measured what that costs: at 310s idle on a 300s TTL the provider had already dropped the entry (zero cache read, a full 183,660-token rewrite) and the gate still refused. Real conservatism with a real price.
B2 — not fixed, and no longer implied to be — #243
You said this needs a design decision rather than a patch, and I agree. CertainlyColdByClock's docstring now says so directly instead of leaving the impression the keep-alive case is handled. The issue records both options with their costs, plus a conservative interim: refuse keepalive + cache_state: cold on the same tenant.
B4 — fixed as defensive hardening, at your revised severity
normalize() strips a trailing [...]. Written up as latent with no demonstrated production impact, citing your own two corrections, because your final position on reach is the right one to record.
Arm A — the firing rate, and it is a sharper result than either of us had
53 turns, shipped defaults, real Claude Code on haiku, no injected idle:
peak fill 0.996 <-- the gate needs 0.900
turns at or over the fill gate 12 of 53
cache verdicts {'hit': 50, 'cold_start': 1, 'unknown': 1, 'prefix_change': 1}
TURNS THAT FIRED 0 of 53
gates (summed) {'below_request_trigger': 40, 'cache_state_declined_warm': 51, 'window_not_exact': 2}
inter-turn gaps (s) min=0.1 median=7.3 max=44.3
gaps >= 240s (the idle pre_expiry needs) 0 of 52
CLIENT COMPACTED yes — client's own ceiling 199,184 = 0.996 of the window
Your 0-of-14 reproduces, and the diagnosis is not the one either of us assumed. The fill gate is not the obstacle: the session passed 0.9, 12 turns qualified, and Claude Code let the context reach 99.6% of the window before compacting. So "the client caps below our gate" is refuted on this model and version — which also means the min_request_frac: 0.5 in my earlier acceptance config was a test lever justified by a wrong reason.
The binding constraint is cache_state_declined_warm on 51 turns: pre_expiry needs ~240s of idle and the largest gap in a working session was 44s. An active agent keeps hitting its own cache. My acceptance run fired because I scripted a 394s gap; nothing in normal use produces one.
So the shipped default is, in practice, gated on a person stepping away for five minutes and coming back to a nearly-full context. That is a real and arguably valuable case — it is exactly when a cold rewrite of a 199k prefix costs the most — but it is not a general win and should not be argued for as one. docs/reference/config.md now carries these numbers instead of its previous claim that the default was pre_expiry.
This is the decision I am handing back rather than making: lower the fraction, widen pre_expiry_seconds, or accept the feature as a narrow safety net and document it as one. The mechanism is not broken; whether the default earns its place is a judgement.
Arm B's own finding — the cold bucket is empty at the shipped span, for a second reason
Three forced cold events inside one span. The credit accumulates and hand-checks exactly:
EPISODE (span=0.25) turns=7 cold=0.46219125 read=0.03697530 cg=0.00831500 net=0.49085155
hand 3 x 123,251 x 1.25e-06 = 0.46219125 ✓
3 x 123,251 x 1.00e-07 = 0.03697530 ✓
Each cold event re-created a compacted prefix (48,967 / 66,107 / 66,216 tokens) where an uncompacted session would have re-created ~175,361 — ~115,000 tokens of prevented rewrite per event, three times over.
At the shipped span=0.10 the cold credit is $0.00, because the episode closes after two warm turns — 20,095 of a 20,000 target — and those two turns took ~30 seconds. A cold event needs ~6 minutes of idle. So a cold event essentially cannot fall inside a 10% span on a session whose turns append 16k-token tool outputs.
This is a second, distinct reason the headline bucket is structurally near-empty. #234 already fixed one ("cumulative spend closes the span in one turn"); this one is "cumulative new content closes it in two or three, which is half a minute of wall time". The 10% figure came from the original specification and it is measured in the right units now — it is simply short relative to the timescale a cache expiry lives on. Worth deciding alongside the firing-rate question.
Also visible on those cold turns: below_request_trigger and reused_checkpoint together. The gate declined and the turn replayed anyway — the gate-without-returning fix, on live cold turns, which is precisely where reverting to the full transcript would cost the most.
The false-green panel — you were right, and my earlier commit was the wrong fix
open_net_usd, open_turns and voided_net_usd were computed and rendered nowhere. The table showed a settled $0.00 in good-text green while the committed position was negative, and 64d488f documented that state in prose instead of fixing it. Arm B is the demonstration: at span 0.25 the settled total is $0.00 and the real position is +$0.49, entirely inside open_net_usd.
The Net cell now renders an em-dash when nothing in a group has settled, and a new exposure line reports the open spans, their turns and their net, with voided reported apart and never averaged in.
Also fixed
?span=NaNdefeated the range guard — every comparison against NaN is false. NaN and Inf rejected first.- The measured index.
idx_rc_request_compadded, inadditiveDDLrather than behind a version bump, because a bump discards every request row to add an index that changes no row's shape. Your 11m14s → 0.081s measurement is in the DDL comment, including that aCROSS JOINbarrier does not help and why. pre_expiry_secondsvalidated against the shortest cache lifetime, with a test that first demonstrates a one-second-old entry really does classify PreExpiry under a 600s window.Trigger.Validateshared by all three constructors, soCacheAllows' docstring stops promising something one of three did.max()→ AND pinned in a test. You were right that a real semantic change had nothing asserting it.- The detached goroutine no longer reads the live
Ctx—effectiveModehoisted, so it holds noCtxat all. recover()is no longer silent — a counter and an ERROR line carrying the panic value.summaryWrappersanitizes the reply: both marker spellingsexpandaccepts, including the JSON-escaped onerawMarkerReexists for, plus</summary>. Deliberately not attempted: detecting instructions (undecidable, and a filter that half-works invites reliance on it) or capping length (never-worse already reverts a grown turn, and truncation makes a long legitimate summary misleading).- An unpriced episode no longer publishes dollars.
summarizer_cost_usdis a stored figure independent of the model's rates, so it was the one field that survived. The rendered panel masked it; a JSON consumer of this public route did not. docs/reference/config.mdsaid the default waspre_expiry. Corrected, with arm A's numbers.
Filed rather than fixed here
- #243 — B2, the keep-alive clock the cold gate trusts.
- #244 — the pin budget saturates silently and no metric says so. Your
pinnedN == pinCapmeasurement is in it, with the argument thatcg:bin:is the cheapest namespace to lose and should probably not hold a slot. - #245 — OpenAI wrong in both cache modes, in opposite directions. Includes your verification that Bedrock and Vertex are fine, because the model-id prefix makes it look as though they might not be.
- #246 — the seven
-count=2failures onmain. You were right to checkmainbefore attributing them. - #247 — the inert
cache_stateonextract/extract_llm. Both obvious fixes break an existing contract, and both dead ends are written into the issue and intoTrigger.Validate's docstring: hiding the keys fails the field-declaration contract, refusing the value fails the form contract. The real fix is splittingTrigger, which is a config-shape change with a migration. - #240 —
saved_usd's ruler. Arm B measures it again: 123,251 removed tokens by our count against ~115,000–126,000 by the provider's. Your steer to cite the 12.4% content divergence rather than the 3.38x scope figure is what the panel's newcredit_quantity_notesays.
Still open
The span width and the firing rate are now both measured, and neither is a code defect. They are arguments about the shipped defaults and they are the repo owner's call. Formal approval remains blocked by the shared account.
🤖 Generated with Claude Code
summarizenow compacts only at a moment when the cache write it costs was going to be paid anyway, and produces the summary off the hot path so that deciding to compact cannot itself become the expensive outcome. A dashboard panel measures what each summary actually earned.Three defects were found and fixed by this work, two of them in the first version of it. They are the most interesting part of the diff.
The rule
Compacting rewrites history, and rewriting history the provider is holding in its prompt cache invalidates that entry — the next turn re-writes the whole suffix at 1.25x fresh. There are two moments when that costs nothing:
So the default is
cache_state: pre_expiry_or_cold— compact whenever it cannot hurt. Note the ordering: expired is the reliable half and pre-expiry is the half carrying an assumption. The first version of this PR had that backwards, because it reasoned about not destroying something valuable rather than about riding a payment already being made.Expired is also the reachable half. Pre-expiry needs a request to land inside a 60-second slice of a 5-minute lifetime; any gap past the TTL produces an expired turn on the next request.
Cold is decided by the clock, never by the flag.
Ctx.ColdCacheis a known false positive on keep-alive'd sessions —proxy/keepalive.gonever updates the turn tracker, the −$708 mechanismpromexport.go:807documents — so trusting it would compact live prefixes on exactly the sessions someone is paying pings to protect.coldByArithmeticrequires a known TTL and idle time whose difference is ≤ 0.Defect 1 — the fill fraction could never fire (
85130c6)min_request_fraccomparedfrac × windowagainstschema.MessagesTokens. Two different rulers:MessagesTokenscounts message text only, while a context window is stated in the tokens the provider bills. This repo already measures the gap —dash/overview.go'sEstimatorDivergence, over requests where nothing was compacted, reports a median 3.38x.So
0.9 × 1,000,000really asked for ~3M provider tokens on a 1M model. The default this PR installs could not fire on any model. It went unnoticed because the tests were written in the broken units too: "0.6 of 200k does not fire on a ~4k request" is true under both the wrong arithmetic and the right one.Fixed by measuring the fill the way the window is measured:
Ctx.PrevBilledInputcarries the provider's own count for the session's previous turn, written on the response path under the session id apply itself derived. One turn stale by construction and deliberately so — a transcript only grows, so it is a sound lower bound, and a gate that opens one turn late is the harmless direction.Firesnow ANDs two separate conjuncts instead ofmax()-ing them, because taking the larger of two numbers on two different rulers is meaningless;min_request_tokenskeeps its own ruler unchanged.Defect 2 — a slow summarizer turned the best case into the worst (
ab545aa)Found by running it. haiku, real 200k window, 196,208 billed tokens, 250s wait to land in the window:
cg_latency_ms=300,128againstupstream_ms=2,838). Failed open, so the full 197k transcript went upstream — and the entry, which had ~50s of life on arrival, had died during our own stall. 197,879 tokens written, model call wasted.pre_expirydidn't permit Cold → declined in 3ms. No summary.400 prompt is too long: 203,705 tokens > 200,000 maximumsummarizeCallTimeoutis 300s whilepre_expiry_secondsis 60, so a slow call guaranteed the entry died while we held the request. The same call shape measured directly completes in 5–14s on haiku (6–19s on sonnet — haiku is the faster choice for it, which also answers whether the model was to blame). The hang itself is unexplained; gateway queueing after a 17-request burst is the leading hypothesis and is unproven.It doesn't need explaining to be fixed. The firing turn now starts the work on a detached context and forwards immediately; a later turn splices the result, waiting up to
summary_wait_seconds(120) if one is still in flight. That deletes the defect rather than tuning it — there is no budget to get wrong on the hot path because the hot path does not wait.summarizeCallTimeout— the same oneCONTEXT_GURU_SUMMARIZE_TIMEOUTtunes. A second private constant was my first move and was wrong twice: an operator tuning the summarize timeout expects it to apply, and a budget unreachable from config cannot be shortened when a provider goes bad. "Landing late is still useful" is already expressed by the wait cap being shorter than the budget./compactare unchanged.Expect one turn per episode to stall a few seconds. The trigger fires only after a 240s+ idle gap, so the user is active immediately after and the next request usually arrives while the call runs. Ten seconds against a 197k write (~$0.22 on haiku, ~$2.70 on Opus at 0.9 of 1M) is a good trade on a turn already waiting on a model.
Defect 3 — declining on a gated turn sent the transcript full
The original control flow returned before the checkpoint replay. Harmless under a size threshold, which is roughly monotone; fatal under a gate true for seconds at a time — turn N sends
[head, summary, tail], turn N+1 sends the transcript full, and the provider re-writes the whole suffix. The feature would have paid a cache-write on nearly every turn to save one on a few.Offloadnow gates without returning: the gates suppress the model call, never the splice.Measuring what it earned
A "What each summary earned" panel on the Components tab follows every summary for 10% more of the model's context window: cold rewrites avoided, warm reads made cheaper, minus what the summary itself cost.
It introduces no new pricing, which is why it can be trusted —
Event.baselineDeltaUSDandrepeatRatealready price the counterfactual per request at the rates then in force, andcache_miss_reasonalready labels every turn. The panel scopes existing columns into spans and splits them by the cache state of the turn that earned them.The accounting rules that took argument:
prefix_changeturns are credited to nothing, and their writes are debited: inside a summarized span the thing that changes the prompt is us. Turn 18 above was recorded asprefix_change, which is exactly the case.cache_write, not the label — a partial hit reads ashit, so a label-derived debit would be zero on the turns where we rewrote a live prefix. Stated as an upper bound.1.25C > 0.1F, so the net is computed rather than presumed.Tests
Every test asserting a summary appears on the triggering turn was migrated, not deleted — the property is unchanged, only the turn it lands on moved.
commissionThenSpliceis the new primitive, and the drain helpers wait on the production channel rather than sleeping, so a passing test exercises the real synchronisation.TestSummarizeTimeoutIsCountedAndLeavesInputIntactnow pins the opposite contract to the one it used to: a blown deadline must not reach the caller, must leave no checkpoint, and must move the counter — which is the only place a degraded summarizer shows up now that nothing waits for it.Nine other tests broke when the fill default landed, all anti-vacuity guards firing because their fixtures have no resolvable window. Each now sets
min_request_frac: 0. One of them,apply/writeback_test.go, was passing while its premise had silently died.Verification
gofmt·go vet ./...·go test ./...·go test -raceover components/apply/dash (no data races) · static pure-Go build ·CGO_ENABLED=0 go test -p 1across config/components/apply/proxy/store/dash ·mkdocs build --strict. All clean.Design and what is still open
The full design is
docs/proposals/pre-expiry-summary-gate.md; the validation plan isdocs/proposals/timely-compact-validation.md.Done, after review: an end-to-end acceptance run against a real Claude Code session on
claude-haiku-4-5, with a cold turn after the summary landed — full results.cold_credit_usd = $0.128, hand-checked against the raw rows to the last digit, on a turn that re-created a 32,123-token compacted prefix instead of the ~149,363-token full one.invalidation_debit_usdis $0 there because t0 was itself an expiry — the case that previously reported a loss on the cheapest moment to compact.new_content_billedis 3,486 against a 20,000 target after five turns, so the span axis behaves at production scale where the pre-review version closed on turn 2. And the hot path did not wait: 55 ms, against 300,128 ms on the synchronous run that motivated the change.One imprecision worth stating rather than glossing: the credit's QUANTITY is summarize's own message-text token count, not the billed difference — turn 17's billed arithmetic gives ~$0.147 against the reported $0.128. Pre-existing behaviour of the savings pipeline (#240), the same two-rulers issue in a third place, and now stated on the panel itself in
credit_quantity_note.Its RATE was separately wrong, and a second review caught it. The panel inherited
request_components.saved_usd, whoseunique × cacheWriteRateterm has no meaning inside an episode — nothing summarize removes there is new content, which is what makes it summarizable. Going async is what exposed it: the stash is written by the detached goroutine, which has noReport, so the checkpoint key first reachesrep.CacheKeyson the turn that replays it,MarkUniquecalls the whole removal new, and unlike t0 that turn is credited. Measured on live rows — the same 123,251-token removal on two consecutive cache hits:saved_uniquesaved_usdExactly 12.5x apart on identical content.
creditTurnnow prices the credit itself, from the removed tokens at the rate that turn's own verdict earns. Two further defects in the same direction went with it: the summarizer's own call was never debited on the async path (its cost lands on t0+1, which the walk classified as a replay), and a turn that closed one span while opening another was debited twice. Together these had a measured episode reporting +$0.376 where the truth was −$0.0397 — wrong sign.Now measured, and it is the most important number here: the firing rate is effectively zero on continuous agent traffic. A second review built a live arm; it is now checked in as
scripts/scenarios/a-firing-rate.shand documented indocs/proposals/timely-compact-validation.md. Shipped defaults, 53 turns, real Claude Code onclaude-haiku-4-5, no injected idle:The fill fraction is not the obstacle. The session passed 0.9, twelve turns qualified, and Claude Code let the context reach 99.6% of the window before compacting — so the theory that the client caps below our gate is refuted on this model and version. The binding constraint is
cache_state_declined_warmon 51 turns:pre_expiryneeds roughly 240s of idle and the largest gap in a working session was 44s. An active agent keeps hitting its own cache.So the shipped default is, in practice, gated on a person stepping away for five minutes and coming back to a nearly-full context. That is a real case, and arguably the most valuable one — it is exactly when a cold rewrite of a 199k prefix costs the most — but it is not a general win and this PR should not be read as claiming one. Three options, none of which this branch takes on its own authority: lower the fraction, widen
pre_expiry_seconds, or accept the feature as a narrow safety net and document it as one.A second measured finding, about the panel's span rather than the trigger.
scripts/scenarios/b-cold-events.shforces three cache expiries after a summary. The cold credit accumulates and hand-checks exactly —3 × 123,251 × 1.25e-06 = $0.46219125, matching the panel to the last digit, each event a prevented rewrite of ~115,000 tokens. But at the shippedspan=0.10the cold bucket is $0.00, because the episode closes after two warm turns (20,095 of a 20,000 target, ~30 seconds of wall time) while a cache expiry needs six minutes. This PR already fixed one reason that bucket was structurally empty (cumulative spend closed the span in one turn); this is a second, and it is about the 10% figure being short relative to the timescale a cache entry lives on rather than about the arithmetic.Both are arguments about the shipped defaults, measured rather than asserted, and both are the repo owner's call.
Filed separately, deliberately not on this branch: #233 (
DefaultStatic()reports 200K for every 1M Claude model), #235 (twoinstall.shtests fail on any host with the binary already onPATH), #238 (extend an existing summary instead of discarding it when the covered span moves), #239 (learn the client's own compaction point instead of assuming a fraction of the model window), #240 (saved_usdprices our token count at the provider's rate), #241 (offer provider-native compaction where no summarizer model exists), #243 (the cold gate trusts a clockkeepalive.gonever updates — a design decision, not a patch), #244 (the store's pin budget saturates silently and no metric says so), #245 (the cache gate is wrong on OpenAI-shaped backends in both modes, in opposite directions), #246 (sevencomponents/offloadtests fail onmainat-count=2), #247 (splitTriggersoextract/extract_llmcannot acceptcache_statekeys they ignore).🤖 Generated with Claude Code