Skip to content

Rebase + split of #161: cache attribution (draft, review only) - #181

Closed
teamchong wants to merge 7 commits into
mainfrom
pr161-attribution
Closed

Rebase + split of #161: cache attribution (draft, review only)#181
teamchong wants to merge 7 commits into
mainfrom
pr161-attribution

Conversation

@teamchong

Copy link
Copy Markdown
Owner

Rebase and split of #161 by @qba-leo-martens onto current main. All commits retain Leo's authorship — this is his work, reorganised, not reauthored. #161 remains the original record.

Opened as a draft: this is for review, not merge.

What changed vs #161

Two clusters, and they are not equally ready

Cluster 1 — cache attribution (the actual point of #161). Digest the MARKED span rather than the boundary message; report history-image hash from the history message; divide the dashboard hit-rate by the right field; decide cold from the provider's accounting; treat a transient 5xx as not-a-dead-cache.

This is verified working on live traffic. Sample turn:

cache_read_tokens          46,924
cache_create_tokens         1,533
input_tokens                    2   (fresh portion only)
cache_prefix_bytes      3,550,281
cache_prefix_marked_bytes 3,585,192   <- 34,911 bytes marked but not cached

That ~35KB delta is the gap the PR exists to surface, and it's non-zero on real traffic.

Cluster 2 — image pricing/caps (fix(images), ~1,099 lines). This is arguably its own feature PR. It is live-affecting: on the same turn, image_count 65 -> wire_images 63, collapsed_images 61. It is actively dropping images. It deserves separate scrutiny and possibly separate landing.

Known defect, not yet fixed

The branch renames telemetry fields with no migration:

main this branch
cached_tokens cache_read_tokens
input_tokens (incl. cached) input_tokens (fresh only)

Any consumer still reading cached_tokens sees a cliff to zero at the cutover and will read it as "caching broke". It didn't; the field moved. Historical charts break at the boundary. Worth fixing before merge, or at minimum documenting.

Status

Currently running live locally as a soak test since 23:25. Not proposed for merge until cluster 2 gets its own review.

qbq-leo-martens and others added 7 commits August 5, 2026 23:18
…essages[0]

`historyImageSha8` hashed the image blocks on `messages[0]`, described in its
own docstring as "the synthetic history message". It is not: whenever a slab
anchor exists, `collapseHistory` returns `[...head, syntheticUser, ...tail]`
and transform.ts passes `protectedPrefix = slabAnchorIdx + 1`, so on every
Claude Code request `messages[0]` is the protected slab message. The field
that exists to prove history-image byte-identity was therefore reporting SLAB
stability under the history name — a drifting collapse boundary still showed a
rock-steady `history_image_sha8` in events.jsonl, which is precisely the
signal #11 attribution relies on. Locate the synthetic message by its banner,
the same way `cachePrefixDigest` does.

Also split the pinned-prefix digest per layer. `cache_prefix_sha8` proves THAT
the cacheable prefix moved but never WHICH layer moved, and the layers fail for
different reasons and need different fixes: tools drift when the client loads a
deferred tool, system drifts when volatile text (env, git status) rides inside
the pinned span, and the imaged head drifts when a collapse boundary or marker
placement moves. Emit `cache_prefix_tools_sha8` / `cache_prefix_system_sha8` /
`cache_prefix_head_sha8` alongside the aggregate so one session of telemetry
names the culprit instead of narrowing it to "somewhere in the prefix".

Motivation: a production session showed 530/530 requests with a unique
`cache_prefix_sha8` at a constant `cache_prefix_bytes`, 0 cache_read across
87M cache_create tokens — with the aggregate hash alone the cause could not be
attributed, and `history_image_sha8` looked stable because it was measuring
the wrong message.

(cherry picked from commit fa73143)
…t attribution

`cachePrefixDigest` hashed every block up to and including the message that
carries pxpipe's imaged prefix. That is not the span Anthropic caches: caching
ends at the LAST cache_control marker, and after a history collapse the two
differ by construction. The synthetic message's newest freeze chunk re-renders
on every turn BY DESIGN (append-only rendering: only completed chunks are
frozen) and it sits AFTER the pinned marker — so the boundary-scoped digest
changes every single turn even when the cached span is byte-stable, and
`cache_prefix_sha8` reads "pxpipe busted its own cache" on healthy traffic.

Emit `cache_prefix_marked_sha8` / `_marked_bytes` / `_marker_pos` alongside:
the digest through the breakpoint, its size, and where the breakpoint sits as
`m<msg>.b<block>`. The marked digest is the one that must hold turn over turn;
the marker position makes a roaming breakpoint — a bust cause in its own right,
previously invisible — a one-field diagnosis.

Tests pin the contract that decides whether cache_read happens at all: two
consecutive turns of one session keep the marked span byte-identical while the
live tail grows.

(cherry picked from commit d7b8655)
…xists

renderStatsTableFragment read s.eventsWithBaseline, but stats.ts emits
eventsWithUsage; the missing field made the comparison false for every
session, so the "cache hit (by events)" row silently rendered "-" no matter
what the cache did. The type carried the stale name too, so tsc had no
chance to catch it.

Test pins the value to its own row (the table renders as one line, so a
bare toContain passes on a neighbouring number) and fails with '-' against
the old expression.

(cherry picked from commit c6c5db6c2916b89cc5ef8114adbdbe02f5acd383)
The 100-image limit is a property of the wire, not of pxpipe: it counts
the images the client sent (pasted screenshots, pictures a tool returned)
together with every image we add. We priced only our own, so a request
that arrived already full got imaged further and came back 400/500 —
two sessions became unresumable.

Every imaging path now spends from one shared headroom:

  imageHeadroom() = cap - margin - ours - theirs

and the caller's images are counted once, before any rewrite, at both
nesting levels (top-level and inside tool_result).

The gate is quantitative, not boolean. A first cut asked "is there ANY
room left?" and then emitted a whole 15-page slab into it: 94 client
images + a 400k slab put 109 images on the wire. Now:

  - the slab must fit whole or not at all — imaging half of it would
    re-key the cache prefix on every turn whose client-image count moved;
  - each tool_result pages against the LIVE headroom, not a fixed 10;
  - after rendering we verify the real page count, because paging is
    budgeted at denseGeo.cols while rendering happens at o.cols;
  - the history collapse is skipped entirely at zero headroom — its
    budget floors at 1, so it would otherwise emit exactly one image
    and still fail the request.

Degrading means "do not emit this turn", never "un-emit": each turn is
re-derived from the client's own text transcript, so our images are
never stranded there.

Telemetry: native_images and image_budget_skips reach the event log, and
passthrough_reasons loses an allow-list that swallowed exactly the two
reasons worth diagnosing (kept_sharp, image_budget).

Measured, 300 turns + 60k slab + 60k tool_result:

  clients= 0 | slab=3 toolres=3 hist=50 | wire= 56 | text  233k
  clients=50 | slab=3 toolres=3 hist=44 | wire=100 | text  206k
  clients=80 | slab=3 toolres=3 hist=11 | wire= 97 | text  809k
  clients=90 | slab=3 toolres=1 hist= 0 | wire= 94 | text 1053k

Client images always win; we shrink to fit. What remains is a cliff, not
a slope: the collapse is our only reducer and it is image-based, so at a
full wire we drop from compressed straight to raw.

(cherry picked from commit cdae551)
…ge count

Three gaps, all of the same kind: the code knew something and never said it.

markCacheDead() existed but nothing ever called it. A rejected request never
populated a prefix cache, so the append-only freeze it was protecting protects
nothing — but only the transform side knew that, and the proxy never told it.
responseLeftNoCache() now names the rule and the response path applies it:
413, any 5xx, and a 400 whose body says the prompt is too long. Everything
else stays warm on purpose. A 401, a 404, a rate limit say nothing about the
cache, and guessing "cold" there would re-cut a live grid and burn the whole
prefix as cache_create — the exact cost this module exists to avoid.

imageCount is what we RENDERED, and the wire disagrees: the history collapse
replaces whole messages, so a tool_result image inside the collapsed range is
never sent. Measured on a tool-heavy shape: 91 rendered, 49 on the wire.
info.wireImages now counts the outgoing body itself, and the event carries it
whenever it differs from the render count.

The event log gains history_freeze_step, history_budget_trimmed and
history_pack_fill, so the adaptive grid is finally observable: until now
nothing recorded whether it had coarsened, why, or whether a repack landed on
a cache we believed dead.

The dashboard says the same in words, but only when there is something to say:
"6 images came from your side and count against the same 100-image request cap
· 48 rendered pages never went out — the history collapse absorbed those
messages (49 on the wire)". A quiet turn stays quiet.

Not fixed here: those 48 absorbed pages are content loss, not just miscounting.
An imaged tool_result serializes into the history as "[tool_result]\n[image]",
so the tool output survives only as the factsheet's exact identifiers. The
cure is ordering — collapse before imaging — which changes the outgoing bytes
for every session and re-keys every cache once, so it wants its own change.

(cherry picked from commit 0f2ac30)
…clock

The history collapse may re-cut its grid for density only when no prefix cache
exists — a re-cut changes every chunk's bytes and re-keys the whole prefix. That
decision was made from an idle timer set to the ephemeral 5-minute TTL, and it
was wrong most of the times it fired.

Measured over 143 gaps on a production host: past a 5.5-minute gap the cache was
still warm in 66% of cases, past 15 minutes in 40%, past an hour in 13%. Claude
Code marks some blocks with the 1-hour TTL, so the short constant never described
this traffic. The visible damage was one session repacked three times in an hour
on ~10-minute gaps — freeze step 10 → 80 → 160 → 1280, each step re-keying the
prefix at 60-98k cache_create. Every one of those turns had just *written* a
cache, which the clock could not see.

noteCacheOutcome() now feeds each response's usage back into the session store: a
cache_read proves the prefix was live, a cache_create proves one was just
written. Either way the next turn must not re-cut.

Absence of caching is deliberately NOT read as a dead cache. A request with no
cache_control marker reports both counters zero for its whole life, and treating
that as "cold" repacks the grid on every turn to reclaim something that never
existed — the append-only e2e tests caught this, which is why `everCacheAlive`
gates it. Cold now means: one existed, and stopped appearing.

The clock remains only as a backstop for responses whose accounting never
arrived, at an hour rather than five minutes — the point where the measurements
say warmth becomes the exception.

Repack cost this addresses: 1.41M cache_create tokens across 20 requests, 20% of
all create in the sample, a quarter of it spent re-keying caches that were still
alive.

Two mutations verified against the tests: dropping the everCacheAlive gate fails
3, reverting to the timer fails 5.

(cherry picked from commit 570ce26)
Production settled a rule I had guessed. Of 20871 requests on ber-dev-tr-ai the
5xx population was 177 × `529 overloaded`, 2 × `500`, 1 × `503` — and 129 of 250
repacks fired directly after one of them.

A 529 means the provider declined to process the request. The prefix cache it
never touched is still there, so marking the session dead re-cut the grid and
threw a live cache away for nothing. That is the same failure the wall-clock rule
had, reintroduced through a blanket `status >= 500`.

`responseLeftNoCache` now fires only on a 413 or a 400 whose body says the prompt
was too long — the two cases where the request itself was refused for its size.

Nothing is lost by dropping the 5xx branch: a cache that genuinely died shows up
on the next turn as a response reporting neither a read nor a write, which
noteCacheOutcome already handles, accurately and for free.

(cherry picked from commit 67bb85ccc66bb2cc97e6eda15859845542c02471)
@teamchong teamchong closed this Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants