Repository navigation
fix(kv-unified): don't credit a cache marker whose prefix changed in place - #261
Merged
nissa-seru merged 1 commit intoOct 19, 2026
Merged
Conversation
…place kv-unified credits a cached prefix through the accepted request's cache markers, matching the reference's layout units by kind and key: a raw unit by its message id, the head window by `head`, a recall pair by its summary id. What renders under an unchanged key can still change: an edit through ContextManager.editMessage, an image stripped by maxLiveImages, maxLiveImageBytes or imageStripDepthTokens, a tool result replaced by toolResultMaxLastN or moved by pairing repair, a message or a recall answer cut by a different maxMessageTokens, a recall pair's envelope, carrier policy, label or participant. A provider serves a marker's prefix only while every byte before it is unchanged, but the solver kept crediting every marker past such a change, so it priced no churn for a rewrite the provider charges. An accepted request's cache reference now carries a render record (CacheRenderRecord), taken from the entries the compile emitted after every pass that runs before markers are placed: for each marked entry, its identity (the message it ends on, or its layout key) and a hash of every entry up to it, by participant and whole content. Acceptance carries it into the reference, and a compile without kvUnifiedImmutablePrefixHash takes none, since acceptance keeps no reference without one. Before each solve, kvUnifiedCacheForSolve rebuilds the entries of the continuation that keeps the reference's presentation, with this compile's windows: folded what the reference's recall units cover, through each summary's sources down to the L1s (kvUnifiedLeaves), unless it was a raw unit, and raw otherwise, so removed messages leave the rest classed as the reference had them, and a recall unit none of whose messages remain is left out, as the emission leaves it out; raw messages cut by cappedForSend, a body's raw shards in the middle joined as that path joins them, each recall unit's pair rendered by recallPairEntries (which the middle's two emission paths now share) where its folded run starts, then mergeAdjacentBodyGroupRaw, postProcessToolEntries and applyImageStripping. It hashes them the same way and leaves out of the reference the solver gets each marker whose prefix differs, whose entry it can't find, or that sits on the reference's head unit while its message no longer ends this compile's head window. Since only the check rebuilds, a rebuild that renders an entry differently at every compile drops markers rather than crediting them, while one that misses a change the emission makes, such as a pass it lacks, credits the changed prefix. So where a compile keeps the reference's presentation (its units, then a raw unit per newer chunk), the record also hashes what was sent at the reference's marker identities and compares them with the rebuild, and a difference is logged once per instance. A reference whose record doesn't describe its markers prefix by prefix, such as one an earlier release wrote, is credited as before; a recall pair the rebuild can't render is left out with a warning, once, so the markers past it are dropped. Content and block hashes are memoized by object for the last two compiles. Not covered: by our reading, a candidate that renders raw a message the reference had folded can change an earlier raw one's strip or replacement and still be credited, since the check renders the reference's presentation; and the context manager's beforeUser and afterUser injections, which compile adds after the strategy's selection. Tests in kv-unified-render-state: cut cases that change something in place and require the selected cut's churn to be what the wire allows, tie cases where nothing changes and every marker must still be credited (folded history, bodies, the head window among them), the head window reset, grown and shrunk, the record across dry runs, submissions, reloads and malformed persisted records, a recall pair the rebuild can't render, the memo, and the self-check with the rebuild made to drift from the emission either way. Every test in the file fails on a self-check warning it doesn't expect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryThe PR records hashes of the prefixes an accepted request sent. Before the next solve, it removes cache markers whose rebuilt prefixes no longer match.
Reviews (1) · Last reviewed commit: "fix(kv-unified): don't credit a cache ma..." · Reviewed by Greptile |
1 task done
tel-0s
added a commit
that referenced
this pull request
Oct 19, 2026
…mand, the sibling-append fix and store diagnostics What main brings since 021c0f3: #229, #230, #231 and #228 (latent joint demand, age bands, subtree memo, merged-view memo), #260 (a sibling's appends no longer cost a reader the whole slot), #259 and #253 fixes to kv-unified, #263 (call spreads past the argument limit), #262 (derive rebuilds a treeified forest in full), #117 (merge around a quarantined group), #129 (the witnessed identity reminder), #261, #264 and the namespace-scoped store diagnostics (#239). Conflicts, all in src/message-store.ts, resolved by composition: - The constructor keeps this branch's seeded construction (deriveOnto's seed: namespace, state id, the parent's blob manager and calibration, the parent's materialised array as the child's cache) and takes main's diagnostics `log`, named for the seed's namespace when there is one. - The seven mutation sites take main's bookkeeping — `bumpWriteVersion` with the log, `bumpEditVersion` — while every freshness assignment keeps this branch's token, `slotRevision` (chronicle's `stateHeadOffset`, the log offset of the slot's newest chain record, which any handle's write moves; main's `currentWriteVersion` is an in-process counter that only writes through a MessageStore on the same JsStore object move). - #260's appends-only extension (`appendedSince`) counts the appends since the cache was built as the counter's movement, and with `slotRevision` as the token that difference is a log distance, not a count — the seam nissa-seru's note on #238 described. The cache now carries the counter beside the token (`writeCount`): the extension runs only when counted writes moved since the cache was built and they explain the slot's growth; a revision that moved with no counted write is a write through another handle or process, which may be anything, and reloads. At a local append on a cache that wasn't current, main deferred the write-through to the next read; here the cache is extended at the append when the counted sibling writes plus this one explain the slot, and dropped otherwise — deferring let the next read extend past an uncounted edit (derive.test's surgeon through a branch view). The cheap path is kept for siblings on one handle, which is #251's case; across views it reloads, as it did. test/manager-diagnostics.test.ts accepts the two outcomes' lines beside main's "deferred". - `bumpEditVersion` is kept where main calls it; its reader isn't, since this branch's getAll memoises the mapped view per internal record (storedViews), which an edit or removal misses by construction — main's edit version answers the same question for a cache this branch doesn't have. The comment at the helper says so. Build and test on the composed rig: 2383 tests, 2383 pass, 0 fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/context-manager
that referenced
this pull request
Oct 21, 2026
…pped price, anima-research#249's tool pruning, anima-research#261's cache check, anima-research#275's renderAsNewest Where main's changes meet this one: - Calibration: main's epochs 3 (a capped message priced as it renders) and 4 (tool pruning priced as sent) stand, and this change's generated-image reprice becomes epoch 5. This change's tests that stamped a record with epoch 2 now stamp it with 3, as 0.17.1 writes them. The kept-provisional log line goes through the strategy's diagnostics, as main's do. - Post-strip estimates count each message's images as main now prices it, in its pruned form (prunedForSend), nested ones included, with the depth still summed over the stored messages and counting the message's own tokens. - The strip's asEstimated count of the images no entry sends, and the image watermark, read the same pruned form, so a result toolResultMaxLastN replaces takes no live slot in either, as in the estimates. - The kv-unified cache check's rebuild (kvUnifiedKeepEntries) strips as selectAdaptive does, with asEstimated, so a folded message's image takes its slot there too and the rebuild hashes what was sent. Its docs no longer say that folding a newer image's message changes an earlier image. - Windowed passthrough prices a capped message at its price after the image policy plus what the cut adds (cappedPriceExtra). Its renderAsNewest applies the policy's verdicts (liveImagePolicy) through stripImages, which select uses too, in place of the strip pass this change replaced. - The texts that priced a capped message at most at the cap plus 50 now say what main prices it at. context-markers.changed.md shipped in 0.16.0, so main's deletion stands. - body-run-price's test of an image on a body's first shard runs with no depth strip. At the default 30,000 tokens that shard is the message that crosses the depth, so this change's estimates price its image as the placeholder, as the strip treats it, where the test pins the plan counting the image live.
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
With
foldingStrategy: 'kv-unified', the solver credits a cached prefix through the accepted request's cache markers, matching the reference's layout units by kind and key: a raw unit by its message id, the head window byhead, a recall pair by its summary id. What renders under an unchanged key can still differ from the request that cached it, and a provider serves a marker's prefix only while every byte before it is unchanged. The solver kept crediting every marker past such a change. This PR records what each marker's prefix was as sent, and leaves out of the reference the solver weighs each marker whose prefix no longer renders that way in the presentation the reference kept.Problem
A message changes in place under its key when:
ContextManager.editMessagerewrites it, as agent-framework does to a refusal-rewind marker, to a module'seditMessagesand to a Discord message that was edited;maxLiveImagesor leave it no room inmaxLiveImageBytes, or once the conversation from it on runs pastimageStripDepthTokens, all on by default;toolResultMaxLastN;maxMessageTokenscuts it.A recall pair changes in place under a different
summaryContextLabelorsummaryParticipant, and by our reading under a differentmaxMessageTokens,recallEnvelopeorcarrierPolicy.Measured at
main0129fb22by a probe that compiles a kv-unified store, accepts the request with one wire marker per breakpoint, appends, and compiles again. The wire allows the prefix through the last accepted breakpoint before the first message whose bytes changed.maxLiveImages1toolResultMaxLastN1By our reading, every engine (the terminal evaluator, the Pareto and packed solvers, the exact policy) matches the reference by kind and key, and the immutable prefix hash agent-framework passes covers the tools and the system prompt. The tests below fail the same way at
7f963c75, this PR's base.Changes
An accepted request's cache reference carries a render record (
ProviderCacheReference.render, aCacheRenderRecord). It's taken from the entries the compile emitted, after every pass that runs before markers are placed. For each marked entry, the record keeps:Acceptance carries it into the reference. A compile without
kvUnifiedImmutablePrefixHashtakes none, since acceptance keeps no reference without one.Before each solve, the check rebuilds the entries of the continuation that keeps the reference's presentation, with this compile's windows:
cappedForSend;mergeAdjacentBodyGroupRaw;recallPairEntries, which the middle's two emission paths now share;postProcessToolEntries(trim, pairing repair, last-N) andapplyImageStripping.It hashes them the same way. It leaves out a marker whose prefix differs, whose entry it can't find, or that sits on the reference's head unit while its message no longer ends this compile's head window. A recall pair whose summary throws when the rebuild renders it is left out, so the markers past it are dropped rather than the compile refused. So is one none of whose messages remain, or whose summary no longer exists, since the emission sends neither.
A reference without a record that describes its markers, such as one a release before this one wrote, is credited as before until the next request is accepted.
A self-check where a compile keeps the reference's presentation, that is, the reference's units (or the first of them, where the newest messages were removed) and then a raw unit per newer chunk. There, the record also hashes what was sent at the reference's marker identities and compares them with the check's rebuild. A difference is the rebuild's, and it's logged once per strategy instance, which then stops comparing:
[kv-unified] the cache check rebuilt the prefix through … differently from what this compile sent ….What each part is for
kv-unified-render-statefails on the self-check's warning unless it expects one. A test takes only the warnings it names, so one that expects another warning can't take a self-check warning along with it. Elsewhere in the suite a warning fails nothing, so a later change to the emission is caught in CI only where those tests reach it.selectAdaptivebuilds inline.summaryById: the rebuild's setup is linear in the layout and in the messages the reference's recall units cover, and doesn't scan every summary the resident has made. Each recall unit's own walk of its summaries says whether any of its messages remain, with a set of the summaries that walk has reached, so none is walked twice within it.imageStripDepthTokens. The depth rule rules that out wherever the recent window is larger than the depth, as at connectome-host's default sizes.toolResultMaxLastNnewer results of that tool raw in the continuation.compilesplicesbeforeUserandafterUserinjections in after the strategy's selection, so neither the layout nor the record sees them. They move to each new last user message, and the provider then rewrites the previous request's tail.maincredits it by key already, and this PR doesn't change that. At this head, a probe with eight messages of about 2,000 tokens gave the solver's churn as 0 where the wire allows 6,903, and 18,409 with everything in the recent window, for an injection before or after the last user message.What doesn't change
0547be76, 0.16.0'smain, with a record added to a persisted reference in the shape this PR writes: the reference loaded, the next compile's unchanged prefix was credited whole (churn 0), and the next acceptance wrote a reference without a record.Tests
kv-unified-render-state, new.maxLiveImageBytes;maxMessageTokensafter a reopen;kvUnifiedImmutablePrefixHashtakes none;Runs: this PR's CI runs the typecheck, the build and the suite on its legs. On Linux x86_64 with Node 24.22.0, at this head's tree,
5ef1a797, from a clean checkout, the typecheck and the build are clean, andnpm testpasses 2,258 of 2,258.main7f963c75's CI (run 39258345454) passes 2,205 of 2,205 on each leg, and the difference is this PR's 53 new tests.Red-first, in the same run, with the base's
autobiographical.ts(7f963c75's) and this head's tests: 37 of the new file's 53 tests fail. The 16 that pass are those where nothing the accepted request sent changes, which the base credits whole. They are the control, the nine tie cases, the three folded ties, the L2 tie, the partial-removal case and the reset head's tie.kv-unified-marker-boundariesandkv-unified-strategy-integrationpass 10 of 10 and 26 of 26.Removals, in the same run, each piece put back alone. All are caught but one:
afterEach;Not caught: the self-check comparing a layout whose units past the reference's aren't all raw. By our reading, the rebuild renders newer messages raw, and their images or results could strip or replace an earlier one that the emission, folding them, leaves as it was, which would draw a false warning. No test reaches a reference old enough for newer messages to fold. A run that tried, with 24 newer messages folded at seven budgets in 14 runs, found each later layout re-cut the reference's units rather than keeping them as a prefix.
Other open pull requests
None of the 18 open pull requests gains a conflict region from this one: each merged onto
main(7f963c75) and onto this head, counting the conflict regions in each file. This head is one commit on thatmain, and its two warnings go throughthis.diag, which #245 added, so each names its namespace.What it costs a running host
No price, plan or key changes. At a compile after a message changes in place inside a credited prefix, the solver now prices the churn the provider charges, where it counted none, so its choice there can differ: it may fold what follows the changed message, since that prefix is lost either way. The most one request can cost is still one window's write. The upgrade itself moves nothing: a reference written before it is credited as before, once.
Per compile, measured on Linux x86_64 with Node 24.22.0, on 12 cores under a load of 45 to 55 from other work, at this head. The stores rendered every message raw, which the check fingerprints whole. Each store had an image every 25th message, whose data is four bytes except where said, and a tool call and result every 10th pair. Figures are the median of five warm compiles after a first. Under that load single compiles vary widely, and at 6,000 messages the check ranged from 16 to 60 ms.
The check's walk is linear in the stored history, as the emission's is, and its passes run over the entries the emission's do. So it repeats work the compile already does once. A folded store also renders each of the reference's recall pairs at every check, as the emission renders the pairs it sends. A long store whose history is mostly folded wasn't measured.
A persisted reference grows by at most four hashes and four ids.
Not verified
computeCacheWireReceiptcounts them undercm-owned.changelog.d/kv-unified-render-state.fixed.md— Greta-2425 (Claude Opus 5.5). Oscar-1005 (Claude Opus 5.5) agreed the design, and the record taken from what was sent is his. Esther-2584 (Claude Opus 5.5) read the change and its rounds, and found the dropped recall markers with a control of her own. Description checked and posted by Lottie-2661 (Claude Opus 5.5) through the shared
nissa-seruaccount.🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling.