From 14f341b979b339f62cb259bb5b196018b61b75fa Mon Sep 17 00:00:00 2001 From: Greta-2425 Date: Mon, 19 Oct 2026 09:41:15 -0400 Subject: [PATCH] fix(kv-unified): don't credit a cache marker whose prefix changed in 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 --- changelog.d/kv-unified-render-state.fixed.md | 31 + src/adaptive/index.ts | 1 + src/adaptive/kv-cache-sim.ts | 18 +- src/adaptive/kv-unified-policy.ts | 24 + src/adaptive/render-offsets.ts | 10 +- src/strategies/autobiographical.ts | 405 +++++- test/kv-unified-render-state.test.ts | 1183 ++++++++++++++++++ 7 files changed, 1626 insertions(+), 46 deletions(-) create mode 100644 changelog.d/kv-unified-render-state.fixed.md create mode 100644 test/kv-unified-render-state.test.ts diff --git a/changelog.d/kv-unified-render-state.fixed.md b/changelog.d/kv-unified-render-state.fixed.md new file mode 100644 index 00000000..1311dd80 --- /dev/null +++ b/changelog.d/kv-unified-render-state.fixed.md @@ -0,0 +1,31 @@ +- With `foldingStrategy: 'kv-unified'`, the solver no longer counts as cached + a prefix whose bytes changed after the request that cached it while its + messages kept their place in the solved layout. A message changes in place + when `ContextManager.editMessage` rewrites it; when its image is stripped, + once newer images reach `maxLiveImages` or leave it no room in + `maxLiveImageBytes`, or once the conversation from it on runs past + `imageStripDepthTokens` (all on by default); when its tool result is + replaced, once newer results of the same tool reach `toolResultMaxLastN`; + when a late result moves up over the stub pairing repair had put after its + call; or when a different `maxMessageTokens` cuts it. A recall pair changes + in place under a different `maxMessageTokens`, `recallEnvelope`, + `carrierPolicy`, `summaryContextLabel` or `summaryParticipant`. The provider + serves nothing past such a change, but every cache marker after it was still + credited, so the solver priced no churn for a rewrite the provider charges. + An accepted request's cache reference now records, for each marker, a hash + of the entries up to it as the strategy emitted them. Before each solve, a + marker whose prefix no longer renders that way is left out of the reference + the solver weighs, and so is a marker on the head window's end once the head + window no longer ends at its message. The check renders the presentation the + reference kept, so a candidate that renders raw a message the reference had + folded, holding an image or a tool result newer than an earlier raw one, can + change that earlier one and still be credited. `beforeUser` and `afterUser` + injections, which `compile` adds after the strategy's selection, are outside + the record. A reference written before this release has no record, and its + markers are credited as before until the next request is accepted. Two new + warnings, each logged at most once per strategy instance, mean the check + itself has gone wrong: `[kv-unified] the cache check couldn't render recall + …`, after which the markers past that recall pair aren't credited, and + `[kv-unified] the cache check rebuilt the prefix through …`, when a compile + that kept the accepted presentation sent something other than the check + rebuilt. diff --git a/src/adaptive/index.ts b/src/adaptive/index.ts index b075dec7..ea7311d3 100644 --- a/src/adaptive/index.ts +++ b/src/adaptive/index.ts @@ -75,6 +75,7 @@ export { type PresentedLeaf, type AcceptedPresentationReference, type ProviderCacheReference, + type CacheRenderRecord, type KvUnifiedWelfarePolicy, type ExactPolicySolveOptions, type ExactPolicyCandidate, diff --git a/src/adaptive/kv-cache-sim.ts b/src/adaptive/kv-cache-sim.ts index d0aab783..fab89074 100644 --- a/src/adaptive/kv-cache-sim.ts +++ b/src/adaptive/kv-cache-sim.ts @@ -28,12 +28,14 @@ * that prefix. `unitIndex === units.length` caches the whole render (the * pure-append breakpoint). * - * Cache identity: two rendered units are byte-identical iff they share - * (kind, key) — the same identity `render-offsets` uses — because a recall unit - * keyed by a summary id renders that summary's fixed recall pair, a raw unit - * keyed by a chunk id renders that chunk verbatim, and head/tail are fixed - * blocks. So a content hash over the unit (kind,key) sequence is a faithful, - * deterministic stand-in for the cached bytes. + * Cache identity: two rendered units match when they share (kind, key), the + * same identity `render-offsets` uses: a recall unit keyed by a summary id + * renders that summary's fixed recall pair, a raw unit keyed by a chunk id + * renders that chunk, and head/tail are fixed blocks. This simulator takes + * the (kind, key) sequence as the cached bytes. A raw or head unit's bytes + * can change in place under its key (an edit, an image stripped, a tool + * result replaced or moved, a cut), which it doesn't see; kv-unified's + * accepted cache reference records and checks those (CacheRenderRecord). * * Pure and deterministic (no Date/random; the store tracks a logical step clock * advanced by the caller). @@ -162,7 +164,9 @@ export function evaluateCacheHit( * Deterministic prefix-hash chain over a layout's unit identities. Returns * `H[k]` = a 64-bit-ish hash (two 32-bit FNV/xorshift lanes, joined) of the * concatenation of the first `k` units' `(kind,key)`. `H[0]` is the empty - * prefix. Two layouts share `H[k]` iff their first `k` units are byte-identical. + * prefix. Two layouts share `H[k]` iff their first `k` units share their + * identities, which this simulator takes as their bytes (see Cache identity + * above). */ function prefixHashes(layout: RenderLayout): string[] { const H = new Array(layout.units.length + 1); diff --git a/src/adaptive/kv-unified-policy.ts b/src/adaptive/kv-unified-policy.ts index 6bbb7299..37dae8cc 100644 --- a/src/adaptive/kv-unified-policy.ts +++ b/src/adaptive/kv-unified-policy.ts @@ -33,6 +33,30 @@ export interface ProviderCacheReference { readonly immutablePrefixHash: string; readonly layout: RenderLayout; readonly markers: readonly CacheMarker[]; + /** Per marker, which entry it sat on and a hash of the request's entries up + * to it, as the strategy emitted them, so a later compile can tell a + * prefix that changed in place under the same unit keys. Absent on a + * reference an earlier version wrote. */ + readonly render?: CacheRenderRecord; +} + +/** + * What an accepted request's prefixes were, byte for byte, as the strategy + * emitted them, where a later compile can change them under the same unit + * keys. A unit's key is its message id, or `head`, or a summary id. What + * renders under a key can still change: an edit rewrites a message, an image + * is stripped, last-N replaces a tool result, pairing repair moves or stubs + * one, the cap cuts a message or a recall answer, a recall's envelope or + * label changes. A provider serves a marker's prefix only while every byte + * before it is unchanged, so a marker whose prefix no longer matches is left + * out of the reference the solver gets. The context manager's message-position + * injections, which it adds after the strategy's selection, aren't in it. + */ +export interface CacheRenderRecord { + /** Per marker, parallel to `markers`: the identity of the entry the marker + * sat on (the message it ends on, or its layout key), and a hash of every + * entry up to it, by participant and whole content. */ + readonly prefixes: readonly { readonly through: string; readonly hash: string }[]; } export interface KvUnifiedWelfarePolicy { diff --git a/src/adaptive/render-offsets.ts b/src/adaptive/render-offsets.ts index 16772849..9e22280f 100644 --- a/src/adaptive/render-offsets.ts +++ b/src/adaptive/render-offsets.ts @@ -35,9 +35,13 @@ export type Frontier = ReadonlyMap; export interface RenderedUnit { kind: 'head' | 'raw' | 'recall' | 'tail'; /** - * Stable identity for prefix comparison. Two units are byte-identical iff - * they share (kind, key): 'head'/'tail' for the fixed blocks, the chunk id - * for a raw shard, the summary id for a recall pair. + * Stable identity for prefix comparison: 'head'/'tail' for the fixed + * blocks, the chunk id for a raw shard, the summary id for a recall pair. + * Two units that share (kind, key) are the same part of the conversation, + * but what they render can still change in place under the key (an edit, + * an image stripped, a tool result replaced or moved, a cut, a recall's + * envelope), which kv-unified's accepted cache reference records and checks + * (CacheRenderRecord). */ key: string; tokens: number; diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index b9f135b0..b0600b22 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -62,7 +62,7 @@ import { KvUnifiedReceiptChain, type SerializedReceiptChain, } from '../adaptive/kv-unified-receipts.js'; -import type { PresentedLeaf, ProviderCacheReference } from '../adaptive/kv-unified-policy.js'; +import type { CacheRenderRecord, PresentedLeaf, ProviderCacheReference } from '../adaptive/kv-unified-policy.js'; import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js'; import type { FoldingSolver, @@ -1190,6 +1190,16 @@ interface CompressionAttemptTrace { budgetTokens?: number; } +/** What kv-unified's cache check rebuilt at a compile's solve, for the + * compile's record to compare against what it sent (kvUnifiedSelfCheck): + * the reference's layout, the identities its markers end on, and each one's + * prefix hash in the rebuild. */ +interface KvUnifiedRebuilt { + layout: RenderLayout; + wanted: Set; + hashes: Map; +} + function sha256Json(value: unknown): string { return createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex'); } @@ -1812,10 +1822,25 @@ export class AutobiographicalStrategy implements ResettableStrategy { layout: RenderLayout; immutablePrefixHash?: string; markerUnitIndices: number[]; + render?: CacheRenderRecord; } | null = null; private kvUnifiedPendingLayout: RenderLayout | null = null; private kvUnifiedPendingMarkerUnitIndices: number[] = []; private kvUnifiedPendingImmutablePrefixHash: string | null = null; + private kvUnifiedPendingRender: CacheRenderRecord | null = null; + /** The accepted cache reference as this compile's solver gets it: the + * markers whose prefix has changed in place since it was accepted are left + * out (kvUnifiedCacheForSolve). Set by selectAdaptive before buildPicker. */ + private kvUnifiedSolveCache: ProviderCacheReference | null = null; + /** Content arrays' and blocks' hashes (kvUnifiedEntryFingerprint), by the + * object: the store and the passes replace one rather than change it. It + * keeps what this compile and the one before fingerprinted, so it follows + * the raw population rather than every message the store holds. */ + private kvUnifiedHashes = { current: new Map(), previous: new Map() }; + /** Whether the check has said once that it couldn't render a recall pair. */ + private kvUnifiedRecallRenderWarned = false; + /** Whether the self-check has said once that the rebuild differed from what was sent. */ + private kvUnifiedSelfCheckWarned = false; /** Set once this strategy has said that an accepted request's cache markers * didn't match its compile's (reportKvUnifiedAccepted), so a mismatch that * repeats every turn is said once. */ @@ -3036,6 +3061,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { this.kvUnifiedPendingLayout = null; this.kvUnifiedPendingMarkerUnitIndices = []; this.kvUnifiedPendingImmutablePrefixHash = null; + this.kvUnifiedPendingRender = null; } } else { // A presentation receipt is kv-unified's record of the LAST accepted @@ -12067,6 +12093,13 @@ export class AutobiographicalStrategy implements ResettableStrategy { const preparedTotal = this.preparedWindowTokens === undefined ? undefined : Math.min(totalBudget, this.preparedWindowTokens); + if (this.config.foldingStrategy === 'kv-unified') { + this.kvUnifiedHashes = { current: new Map(), previous: this.kvUnifiedHashes.current }; + } + const kvUnifiedCheck = this.config.foldingStrategy === 'kv-unified' + ? this.kvUnifiedCacheForSolve(store, messages, { headStart, headEnd, recentStart: effectiveRecentStart, msgCap }) + : null; + this.kvUnifiedSolveCache = kvUnifiedCheck?.cache ?? null; const picker = this.buildPicker( pickerInputs, preparedTotal === undefined @@ -12364,8 +12397,6 @@ export class AutobiographicalStrategy implements ResettableStrategy { // one continuous user message instead of N turns. // - bodyGroupId absent: emit normally (raw L0 message, or Q+A summary pair). const emittedAncestors = new Set(); - const summaryLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?'; - const summaryParticipant = this.config.summaryParticipant ?? 'Claude'; // Emission-overflow refusal: the picker planned this layout to fit, so an // overrun here is estimator drift. The grace window (prefixBudget derives @@ -12456,22 +12487,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { const ancestor = currentRun.ancestor; if (!emittedAncestors.has(ancestor.id)) { emittedAncestors.add(ancestor.id); - const rendered = this.renderSummaryAnswer(ancestor, msgCap); - const questionEntry: ContextEntry = { - index: entries.length, - participant: 'Context Manager', - content: [{ type: 'text', text: summaryLabel }], - sourceRelation: 'derived', - summaries: rendered.refs, - }; - const answerEntry: ContextEntry = { - index: entries.length + 1, - participant: summaryParticipant, - content: rendered.content, - sourceRelation: 'derived', - cacheLayoutKey: ancestor.id, - summaries: rendered.refs, - }; + const [questionEntry, answerEntry] = this.recallPairEntries(ancestor, msgCap, entries.length); // Price the same pair the planner selected. responseContent may // carry signed-empty thinking whose exact generation cost is in // summary.tokens; re-estimating the rendered blocks here would @@ -12575,22 +12591,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { continue; } emittedAncestors.add(ancestor.id); - const rendered = this.renderSummaryAnswer(ancestor, msgCap); - const questionEntry: ContextEntry = { - index: entries.length, - participant: 'Context Manager', - content: [{ type: 'text', text: summaryLabel }], - sourceRelation: 'derived', - summaries: rendered.refs, - }; - const answerEntry: ContextEntry = { - index: entries.length + 1, - participant: summaryParticipant, - content: rendered.content, - sourceRelation: 'derived', - cacheLayoutKey: ancestor.id, - summaries: rendered.refs, - }; + const [questionEntry, answerEntry] = this.recallPairEntries(ancestor, msgCap, entries.length); const pairTokens = this.recallPairCost(ancestor); if (totalTokens + pairTokens > prefixBudget) { throw emissionOverBudget(totalTokens + pairTokens); @@ -12696,6 +12697,10 @@ export class AutobiographicalStrategy implements ResettableStrategy { this.kvUnifiedDraft.markerUnitIndices = this.reconcileKvUnifiedMarkerIndices( merged, this.kvUnifiedDraft.layout, headMessageIds, tailMessageIds, ); + // Acceptance keeps no reference without the immutable prefix hash. + if (this.kvUnifiedDraft.immutablePrefixHash !== undefined) { + this.kvUnifiedDraft.render = this.kvUnifiedRenderRecord(merged, this.kvUnifiedDraft.layout, kvUnifiedCheck?.rebuilt ?? null); + } } this.assertMiddleCoverage(); @@ -12865,6 +12870,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { this.kvUnifiedPendingLayout = this.kvUnifiedDraft.layout; this.kvUnifiedPendingMarkerUnitIndices = [...this.kvUnifiedDraft.markerUnitIndices]; this.kvUnifiedPendingImmutablePrefixHash = this.kvUnifiedDraft.immutablePrefixHash ?? null; + this.kvUnifiedPendingRender = this.kvUnifiedDraft.render ?? null; } isKvUnifiedEnabled(): boolean { @@ -12897,6 +12903,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { ? this.kvUnifiedPendingLayout!.totalTokens : this.kvUnifiedPendingLayout!.units[unitIndex].offset, })), + ...(this.kvUnifiedPendingRender ? { render: this.kvUnifiedPendingRender } : {}), }; } else if ( args.wireReceipt && @@ -12931,6 +12938,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { this.kvUnifiedPendingLayout = null; this.kvUnifiedPendingMarkerUnitIndices = []; this.kvUnifiedPendingImmutablePrefixHash = null; + this.kvUnifiedPendingRender = null; this.persistKvUnifiedReceipts(); } @@ -12961,6 +12969,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { this.kvUnifiedPendingLayout = null; this.kvUnifiedPendingMarkerUnitIndices = []; this.kvUnifiedPendingImmutablePrefixHash = null; + this.kvUnifiedPendingRender = null; } private _calibrationArmed = false; @@ -13654,6 +13663,306 @@ export class AutobiographicalStrategy implements ResettableStrategy { return indices; } + /** The draft's render record (CacheRenderRecord), from the entries this + * compile emits: for each marked entry, in order, its identity and the hash + * of every entry up to it. Reconcile refuses a marked entry with no + * identity, and acceptance keeps a reference only when the wire carried one + * marker per reconciled unit, while it carries one per marked entry. So an + * accepted reference's prefixes run parallel to its markers, and the check + * credits one whose don't as it is. */ + private kvUnifiedRenderRecord( + entries: readonly ContextEntry[], + layout: RenderLayout, + rebuilt: KvUnifiedRebuilt | null, + ): CacheRenderRecord { + const compare = rebuilt && !this.kvUnifiedSelfCheckWarned && this.kvUnifiedKeepsPresentation(layout, rebuilt.layout) + ? rebuilt + : null; + const marked: { through: string; hash: string }[] = []; + const sent = new Map(); + for (const prefix of this.kvUnifiedPrefixHashes(entries, (entry) => + entry.cacheMarker === true || (compare?.wanted.has(this.kvUnifiedIdentity(entry)!) ?? false))) { + if (prefix.entry.cacheMarker === true) marked.push({ through: prefix.through, hash: prefix.hash }); + if (compare?.wanted.has(prefix.through)) sent.set(prefix.through, prefix.hash); + } + if (compare) this.kvUnifiedSelfCheck(compare, sent); + return { prefixes: marked }; + } + + /** Whether a layout keeps the reference's presentation: the reference's + * units, by kind and key, or the first of them where the newest messages + * were removed, then a raw unit for each newer chunk. A newer chunk shown + * folded doesn't keep it: the rebuild renders newer messages raw, and + * those can strip or replace an earlier image or tool result that the + * emission, folding them, leaves as it was. */ + private kvUnifiedKeepsPresentation(layout: RenderLayout, kept: RenderLayout): boolean { + return layout.units.every((unit, i) => + i < kept.units.length + ? unit.kind === kept.units[i]!.kind && unit.key === kept.units[i]!.key + : unit.kind === 'raw'); + } + + /** The check's rebuild renders the continuation that keeps the + * reference's presentation, so where this compile kept it, the rebuild + * must hash each marked prefix as this compile sent it. A difference is + * the rebuild's: one that renders a message differently drops markers the + * provider would serve, and one that misses a change the emission made, + * such as a pass it lacks, credits a prefix that changed. Said once per + * instance, naming where they first differ, after which the instance + * stops comparing. */ + private kvUnifiedSelfCheck(rebuilt: KvUnifiedRebuilt, sent: Map): void { + const through = [...rebuilt.wanted].find((id) => rebuilt.hashes.get(id) !== sent.get(id)); + if (through === undefined) return; + this.kvUnifiedSelfCheckWarned = true; + this.diag.warn( + `[kv-unified] the cache check rebuilt the prefix through ${through} differently from what this compile ` + + `sent with the accepted presentation kept, so its credit can be wrong until the rebuild ` + + `(kvUnifiedKeepEntries) matches the emission`, + ); + } + + /** The accepted cache reference as this compile's solver should credit it. + * What renders under an unchanged unit key can differ from the request + * that cached it: an edit, an image stripped, a tool result replaced by + * last-N or moved by pairing repair, a message or a recall answer cut, a + * recall's envelope or label. A provider serves a marker's prefix only + * while every byte before it is unchanged. So this rebuilds the entries of + * the continuation that keeps the reference's presentation, with what its + * recall units don't cover raw (kvUnifiedKeepEntries), and + * leaves out each marker whose prefix there no longer hashes as it was + * sent, or whose entry it can't find. The record comes from what was sent + * and only the check rebuilds, so a rebuild that renders a message + * 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. Where a compile keeps the reference's + * presentation, its record compares the two (kvUnifiedSelfCheck). A + * candidate that folds a newer image's or result's message + * can render an earlier one as the reference had it, and is credited less + * than the provider serves; one that renders raw a chunk the reference + * showed folded can tip a newest-first count, and is credited as before + * this check. A reference without a record that describes its markers, + * such as one an earlier version wrote, is credited as it is. A marker on + * the reference's head unit stands for the head's end, so it holds only + * while its message ends this compile's head window. */ + private kvUnifiedCacheForSolve( + store: MessageStoreView, + messages: readonly StoredMessage[], + window: { headStart: number; headEnd: number; recentStart: number; msgCap: number }, + ): { cache: ProviderCacheReference | null; rebuilt: KvUnifiedRebuilt | null } { + const cache = this.kvUnifiedReceipts.cache; + const prefixes = cache?.render?.prefixes; + // No marker to leave out, or no record that describes the markers. + if ( + !cache || cache.markers.length === 0 || !Array.isArray(prefixes) || prefixes.length !== cache.markers.length || + !prefixes.every((prefix) => typeof prefix?.through === 'string' && typeof prefix.hash === 'string') + ) return { cache, rebuilt: null }; + const entries = this.kvUnifiedKeepEntries(store, messages, cache, window); + const wanted = new Set(prefixes.map((prefix) => prefix.through)); + const now = new Map( + this.kvUnifiedPrefixHashes(entries, (entry) => wanted.has(this.kvUnifiedIdentity(entry)!)) + .map((prefix) => [prefix.through, prefix.hash]), + ); + + const headLast = window.headEnd > window.headStart ? messages[window.headEnd - 1]?.id : undefined; + const markers = cache.markers.filter((marker, i) => { + const { through, hash } = prefixes[i]!; + if (cache.layout.units[marker.unitIndex - 1]?.kind === 'head' && through !== headLast) return false; + return now.get(through) === hash; + }); + return { + cache: markers.length === cache.markers.length + ? cache + : { immutablePrefixHash: cache.immutablePrefixHash, layout: cache.layout, markers }, + rebuilt: { layout: cache.layout, wanted, hashes: now }, + }; + } + + /** Adds to `into` the messages a summary covers, through its sources down + * to the L1s, whose sources (sourceLevel 0) are messages. `seen` walks + * each summary once; a summary that no longer exists covers nothing. */ + private kvUnifiedLeaves(id: string, into: Set, seen: Set): void { + if (seen.has(id)) return; + seen.add(id); + const summary = this.summaryById(id); + if (!summary) return; + for (const source of summary.sourceIds) { + if (summary.sourceLevel === 0) into.add(source); + else this.kvUnifiedLeaves(source, into, seen); + } + } + + /** The entries the continuation that keeps the reference's presentation + * would emit, as selectAdaptive emits them, after the passes that run on + * them before markers are placed. The windows are this compile's. + * Outside the head window, a message renders folded when one of the + * reference's recall units covers it (kvUnifiedLeaves) and it wasn't a + * raw unit, and raw otherwise: the reference's raw units, and what no + * recall unit of it covers, which is what came after it or has left the + * head window since. Membership rather than position decides, so + * removing messages, newest ones included, leaves the rest classed as + * the reference had them. Where a run of folded messages starts, the + * emission puts the run's recall pairs, so the rebuild puts there the + * recall pair of each recall unit the reference's layout has after the + * raw unit before it and with a message still present, as the emission + * renders it (recallPairEntries). Raw messages are cut by cappedForSend; + * a body's raw shards in the middle join their text into one entry, as + * the middle's shard path does, and shards in the head and recent + * windows join through mergeAdjacentBodyGroupRaw. */ + private kvUnifiedKeepEntries( + store: MessageStoreView, + messages: readonly StoredMessage[], + cache: ProviderCacheReference, + window: { headStart: number; headEnd: number; recentStart: number; msgCap: number }, + ): ContextEntry[] { + const keptRaw = new Set(); + const folded = new Set(); + // A recall unit none of whose messages remain renders nowhere, in the + // emission as here. + const present = new Set(messages.map((m) => m.id)); + const rendered = new Set(); + for (const unit of cache.layout.units) { + if (unit.kind === 'raw') keptRaw.add(unit.key); + else if (unit.kind === 'recall') { + const leaves = new Set(); + this.kvUnifiedLeaves(unit.key, leaves, new Set()); + for (const id of leaves) { + folded.add(id); + if (present.has(id)) rendered.add(unit.key); + } + } + } + const raw = (i: number): boolean => keptRaw.has(messages[i]!.id) || !folded.has(messages[i]!.id); + // Each recall unit, under the raw unit before it in the layout ('' for none). + const recallAfter = new Map(); + let anchor = ''; + for (const unit of cache.layout.units) { + if (unit.kind === 'raw') anchor = unit.key; + else if (unit.kind === 'recall' && rendered.has(unit.key)) { + const keys = recallAfter.get(anchor); + if (keys) keys.push(unit.key); + else recallAfter.set(anchor, [unit.key]); + } + } + const entries: ContextEntry[] = []; + const pushRaw = (msg: StoredMessage): void => { + entries.push({ + index: entries.length, sourceMessageId: msg.id, sourceRelation: 'copy', participant: msg.participant, + content: this.cappedForSend(store, msg, window.msgCap).content, + }); + }; + let pending: readonly string[] = recallAfter.get('') ?? []; + const afterRaw = (id: string): void => { + pending = recallAfter.get(id) ?? []; + }; + const pushFolded = (): void => { + for (const key of pending) { + // Pending holds only recall units with a message present, so the summary exists. + const summary = this.summaryById(key)!; + // A pair this can't render is left out, so the markers past it don't + // match and are dropped, rather than the check refusing the compile. + try { + entries.push(...this.recallPairEntries(summary, window.msgCap, entries.length)); + } catch (error) { + if (!this.kvUnifiedRecallRenderWarned) { + this.kvUnifiedRecallRenderWarned = true; + this.diag.warn( + `[kv-unified] the cache check couldn't render recall ${summary.id} ` + + `(${error instanceof Error ? error.message : String(error)}); the markers past it aren't credited`, + ); + } + } + } + pending = []; + }; + for (let i = window.headStart; i < window.headEnd && i < messages.length; i++) pushRaw(messages[i]!); + const segments: ReadonlyArray = window.headStart > 0 + ? [[0, window.headStart], [window.headEnd, window.recentStart]] + : [[window.headEnd, window.recentStart]]; + for (const [segStart, segEnd] of segments) { + let i = segStart; + while (i < segEnd && i < messages.length) { + const msg = messages[i]!; + if (msg.bodyGroupId) { + const groupStart = i; + i = bodyEnd(Math.min(segEnd, messages.length), (k) => messages[k]!, groupStart) + 1; + let run: { parts: string[]; ids: string[] } | null = null; + const flush = (): void => { + if (!run) return; + entries.push({ + index: entries.length, sourceMessageIds: run.ids, cacheLayoutKey: run.ids.at(-1), + sourceRelation: 'copy', participant: msg.participant, content: [{ type: 'text', text: run.parts.join('') }], + }); + run = null; + }; + for (let k = groupStart; k < i; k++) { + const shard = messages[k]!; + if (!raw(k)) { + flush(); + pushFolded(); + continue; + } + run ??= { parts: [], ids: [] }; + run.ids.push(shard.id); + for (const block of shard.content) if (block.type === 'text') run.parts.push(block.text); + afterRaw(shard.id); + } + flush(); + continue; + } + if (raw(i)) { + pushRaw(msg); + afterRaw(msg.id); + } else pushFolded(); + i++; + } + } + for (let i = Math.max(window.recentStart, window.headEnd); i < messages.length; i++) pushRaw(messages[i]!); + const merged = this.mergeAdjacentBodyGroupRaw(entries, store, messages as StoredMessage[]); + this.postProcessToolEntries(merged); + this.applyImageStripping(merged, store); + return merged; + } + + /** The identity of an entry a marker can sit on: the message it ends on, + * or its layout key, as reconcileKvUnifiedMarkerIndices reads it. */ + private kvUnifiedIdentity(entry: ContextEntry): string | undefined { + return lastSourceOf(entry) ?? entry.cacheLayoutKey; + } + + /** For each entry `marks` picks, in order, its identity and the hash of + * every entry up to it, each by its participant and whole content, raw + * replay forms included. */ + private kvUnifiedPrefixHashes( + entries: readonly ContextEntry[], + marks: (entry: ContextEntry) => boolean, + ): { through: string; hash: string; entry: ContextEntry }[] { + const prefixes: { through: string; hash: string; entry: ContextEntry }[] = []; + const sent = createHash('sha256'); + for (const entry of entries) { + sent.update(`${this.kvUnifiedEntryFingerprint(entry)}\n`); + const through = this.kvUnifiedIdentity(entry); + if (through !== undefined && marks(entry)) prefixes.push({ through, hash: sent.copy().digest('hex'), entry }); + } + return prefixes; + } + + /** An entry's fingerprint: its participant and its content's hash. An + * entry whose content is its message's own, as most are, costs a lookup. */ + private kvUnifiedEntryFingerprint(entry: ContextEntry): string { + const content = this.kvUnifiedHash(entry.content, () => + sha256Json(entry.content.map((block) => this.kvUnifiedHash(block, () => sha256Json(block))))); + return `${JSON.stringify(entry.participant)}:${content}`; + } + + private kvUnifiedHash(object: object, compute: () => string): string { + let hash = this.kvUnifiedHashes.current.get(object); + if (hash === undefined) { + hash = this.kvUnifiedHashes.previous.get(object) ?? compute(); + this.kvUnifiedHashes.current.set(object, hash); + } + return hash; + } + /** * Walk an entries array; for every run of consecutive entries that * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair) @@ -13840,7 +14149,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { }, } : {}), - ...(this.kvUnifiedReceipts.cache ? { cache: this.kvUnifiedReceipts.cache } : {}), + ...(this.kvUnifiedSolveCache ? { cache: this.kvUnifiedSolveCache } : {}), ...(immutablePrefixHash ? { currentImmutablePrefixHash: immutablePrefixHash } : {}), requireExplicitPolicy: true, }); @@ -17164,6 +17473,30 @@ export class AutobiographicalStrategy implements ResettableStrategy { return { content, refs }; } + /** The recall pair the adaptive middle emits for a folded run: the + * summary label's question, and the summary's answer rendered under the + * message cap, which ends the pair's layout unit. */ + private recallPairEntries(summary: SummaryEntry, msgCap: number, index: number): [ContextEntry, ContextEntry] { + const rendered = this.renderSummaryAnswer(summary, msgCap); + return [ + { + index, + participant: 'Context Manager', + content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'What do you remember from earlier?' }], + sourceRelation: 'derived', + summaries: rendered.refs, + }, + { + index: index + 1, + participant: this.config.summaryParticipant ?? 'Claude', + content: rendered.content, + sourceRelation: 'derived', + cacheLayoutKey: summary.id, + summaries: rendered.refs, + }, + ]; + } + /** * A single recall answer for the live window, with its layout reference: * `liveWindowAnswerProse` cut to a `maxMessageTokens` cap, then wrapped in diff --git a/test/kv-unified-render-state.test.ts b/test/kv-unified-render-state.test.ts new file mode 100644 index 00000000..c1639cf2 --- /dev/null +++ b/test/kv-unified-render-state.test.ts @@ -0,0 +1,1183 @@ +/** + * kv-unified credits a cached prefix through the accepted request's markers, + * matching the reference's units by kind and key. A message under an + * unchanged key can still render differently from the request that cached + * it: an edit rewrites it, an image is stripped once newer ones reach + * maxLiveImages or it falls past imageStripDepthTokens, a tool result is + * replaced once newer results of its tool reach toolResultMaxLastN, or a late + * result moves up over the stub pairing repair put after its call. A provider + * serves a marker's prefix only while every byte before it is unchanged. + * + * The cut cases compile, accept with one wire marker per breakpoint, change + * something, and compile again: the selected cut's churn must be what the + * wire allows, which is everything the reference carried less the prefix + * through the last accepted breakpoint before the first message whose bytes + * changed. The tie cases accept, append without changing anything in place, + * and compile again: every marker must still be credited, so a rebuild that + * renders a message differently from the emission fails here instead of + * quietly dropping markers. And wherever a compile here keeps the accepted + * presentation, the strategy's self-check compares its rebuild with what it + * sent: no test here may make it warn, unless it expects to. + */ +import { after, afterEach, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ContentBlock } from '@animalabs/membrane'; +import { AutobiographicalStrategy, ContextManager } from '../src/index.js'; +import type { KvUnifiedReceiptChain } from '../src/adaptive/kv-unified-receipts.js'; + +/** Every [kv-unified] warning a test here gives, in order. A test that + * expects one takes it from here; the self-check's fails any other test. */ +const kvWarnings: string[] = []; +const SELF_CHECK = 'the cache check rebuilt the prefix through'; +const consoleWarn = console.warn; +before(() => { + console.warn = (...args: unknown[]) => { + const line = args.map(String).join(' '); + if (line.includes('[kv-unified]')) kvWarnings.push(line); + consoleWarn(...args); + }; +}); +after(() => { + console.warn = consoleWarn; +}); +afterEach(() => { + const seen = kvWarnings.splice(0).filter((line) => line.includes(SELF_CHECK)); + assert.deepEqual(seen, [], 'no compile here finds its rebuild differing from what it sent'); +}); +/** Takes the warnings holding `needle`, leaving the rest for afterEach. */ +const taken = (needle: string): string[] => { + const hits = kvWarnings.filter((line) => line.includes(needle)); + const rest = kvWarnings.filter((line) => !line.includes(needle)); + kvWarnings.splice(0, kvWarnings.length, ...rest); + return hits; +}; + +const KV_UNIFIED = { + policy: { + alpha: 0.7, + budgetLowRatio: 0.5, + budgetHighRatio: 0.9, + budgetUnderLambda: 10, + budgetOverLambda: 10, + cacheLambda: 1, + cacheScale: 1000, + cacheReadPrice: 0.1, + cacheWritePrice: 1.25, + continuityLambda: 1, + continuityScale: 1000, + continuityRecencyHalfLifeTokens: 1000, + continuityRecencyFloor: 0.2, + continuityStableHalfLife: 10, + continuityStableFloor: 0.25, + }, + tokenBucketSize: 100, + continuityBucketSize: 100, + fidelityBucketSize: 100, + labelCeiling: 10_000, + adoptEpsilon: 0, + treeifyNonContiguousSummaries: false, + preserveGapBearingSummaries: false, +}; +const CHURN_PRICE = Math.max(0, KV_UNIFIED.policy.cacheWritePrice - KV_UNIFIED.policy.cacheReadPrice); +const BIG = { maxTokens: 1_000_000, reserveForResponse: 0 }; +const HASH = { kvUnifiedImmutablePrefixHash: 'immutable-v1' }; + +const PROSE = 'lorem ipsum dolor sit amet consectetur '.repeat(2_100); +const plain = (k: number): string => `${k} ${PROSE.slice(0, 5_800)}`; +const text = (t: string): ContentBlock => ({ type: 'text', text: t }); +const image = (): ContentBlock => + ({ type: 'image', source: { type: 'base64', data: 'AAAA', mediaType: 'image/png' }, tokenEstimate: 1600 } as unknown as ContentBlock); +const call = (id: string, q: string): ContentBlock => + ({ type: 'tool_use', id, name: 'lookup', input: { q } } as unknown as ContentBlock); +const result = (id: string, content: string): ContentBlock => + ({ type: 'tool_result', toolUseId: id, content } as unknown as ContentBlock); + +const replayed = (t: string, id: string): ContentBlock => + ({ type: 'text', text: t, rawItem: { type: 'message', id, role: 'user', content: [{ type: 'input_text', text: t }] } } as unknown as ContentBlock); + +type Msg = [string, ContentBlock[]]; +type Manager = Awaited>; +type Wire = { participant: string; content: ContentBlock[]; cacheBreakpoint?: boolean }; +type Unit = { kind: string; key: string; tokens: number; offset: number }; +type Layout = { units: Unit[]; totalTokens: number }; +type Cache = { layout: Layout; markers: Array<{ unitIndex: number; offset: number }>; render?: unknown }; +type Solved = { feasible: boolean; cacheRelevant?: boolean; selected?: { layout: Layout; cacheChurn: number } }; + +const MIDDLE: Msg[] = [ + ['u', [text(plain(0))]], ['a', [text(plain(1))]], ['u', [text(plain(2))]], ['a', [text(plain(3))]], +]; +const CLOSE: Msg[] = [['u', [text('q')]], ['a', [text('ok')]], ['u', [text('next')]]]; +const SMALL: Msg[] = [['a', [text('ok2')]], ['u', [text('next2')]]]; + +const bytes = (m: Wire): string => JSON.stringify({ participant: m.participant, content: m.content }); +const breakpoints = (wire: Wire[]): number[] => wire.flatMap((m, i) => (m.cacheBreakpoint ? [i] : [])); +const firstChange = (before: Wire[], after: Wire[]): number => { + let i = 0; + while (i < before.length && i < after.length && bytes(before[i]!) === bytes(after[i]!)) i++; + return i; +}; + +const internals = (s: AutobiographicalStrategy) => s as unknown as { + kvUnifiedReceipts: KvUnifiedReceiptChain; + kvUnifiedSolveCache: Cache | null; + kvUnifiedDraft: { render?: unknown } | null; + _lastKvUnified: { lastResult?: Solved } | null; + store: { getStateJson(id: string): unknown; setStateJson(id: string, value: unknown): void }; + kvUnifiedReceiptStateId: string; +}; +const reference = (s: AutobiographicalStrategy): Cache | null => + internals(s).kvUnifiedReceipts.cache as unknown as Cache | null; +const solved = (s: AutobiographicalStrategy): Solved | undefined => internals(s)._lastKvUnified?.lastResult; +/** How many of the reference's markers the solve gets: the check's output, + * or the reference itself in a build without the check, as a red-first run + * against the base has. */ +const solveMarkers = (s: AutobiographicalStrategy): number | undefined => + 'kvUnifiedSolveCache' in s ? internals(s).kvUnifiedSolveCache?.markers.length : reference(s)?.markers.length; + +let submissions = 0; +function begin(s: AutobiographicalStrategy): string { + const id = `s${++submissions}`; + s.beginKvUnifiedSubmission({ submissionId: id, requestHash: `wire-${id}`, layoutHash: `layout-${id}` }); + return id; +} +function settle(s: AutobiographicalStrategy, id: string, wire: Wire[]): void { + s.reportKvUnifiedAccepted({ + submissionId: id, + acceptedAt: submissions, + wireReceipt: { + requestHash: `wire-${id}`, + markers: breakpoints(wire).map((_, ordinal) => ({ ordinal, prefixHash: `p${ordinal}`, estimatedOffset: ordinal + 1 })), + }, + }); +} +const accept = (s: AutobiographicalStrategy, wire: Wire[]): void => settle(s, begin(s), wire); + +/** The churn the wire allows for the selected cut: the reference's markers in + * order are the accepted request's breakpoints in order, and one survives + * when its breakpoint comes before the first message whose bytes changed. */ +function wireChurn(before: Wire[], after: Wire[], ref: Cache, layout: Layout): number { + const points = breakpoints(before); + assert.equal(points.length, ref.markers.length, "premise: one recorded marker per breakpoint on the accepted request's wire"); + const changed = firstChange(before, after); + const end = (k: number): number => (k === 0 ? 0 : layout.units[k - 1]!.offset + layout.units[k - 1]!.tokens); + const cached = Math.max(0, ...ref.markers.filter((_, i) => points[i]! < changed).map((m) => end(m.unitIndex))); + const carried = layout.units.slice(0, ref.layout.units.length).reduce((n, u) => n + u.tokens, 0); + return Math.max(0, carried - cached) * CHURN_PRICE; +} + +function strategyFor(config: Record): AutobiographicalStrategy { + return new AutobiographicalStrategy({ + adaptiveResolution: true, + foldingStrategy: 'kv-unified', + compressionModel: 'mock', + kvUnified: { ...KV_UNIFIED, policy: { ...KV_UNIFIED.policy } }, + imageStripDepthTokens: 0, + ...config, + }); +} + +async function open(config: Record, dir = mkdtempSync(join(tmpdir(), 'cm-kv-render-state-')), membrane?: unknown) { + const strategy = strategyFor(config); + const manager = await ContextManager.open({ path: join(dir, 'store'), strategy, ...(membrane ? { membrane: membrane as never } : {}) }); + return { dir, strategy, manager }; +} +const add = (manager: Manager, messages: Msg[]): void => { + for (const [p, content] of messages) manager.addMessage(p, content); +}; +const compile = async (manager: Manager, dryRun = false): Promise => + (await manager.compile(BIG, undefined, dryRun ? { ...HASH, dryRun: true } : HASH)).messages as Wire[]; +const ids = (manager: Manager): string[] => + (manager as unknown as { messageStore: { getAll(): Array<{ id: string }> } }).messageStore.getAll().map((m) => m.id); + +type CutCase = { + config: Record; + first: Msg[]; + /** What happens between the two compiles. */ + change: (manager: Manager) => void; + regions: [number, number, number]; + changed: number | null; +}; + +const CUTS: Record = { + 'everything in the recent window: the oldest message loses its image to a newer one': { + config: { headWindowTokens: 0, recentWindowTokens: 1_000_000, maxLiveImages: 1 }, + first: [['u', [image(), text('A ' + plain(0))]], ['a', [text(plain(1))]], ['u', [text(plain(2))]], ['a', [text('ok')]], ['u', [text('next')]]], + change: (m) => add(m, [['a', [text(plain(3))]], ['u', [image(), text('B ' + plain(4))]], ...SMALL]), + regions: [0, 0, 9], + changed: 0, + }, + 'everything in the recent window: the oldest message falls past the strip depth and loses its image': { + // The depth walk sums from the newest message and strips the one that + // takes it over the depth. With the image's message it reaches about + // 7,600 tokens at the first compile, under 9,000, and about 11,600 at the + // second, over it. + config: { headWindowTokens: 0, recentWindowTokens: 1_000_000, maxLiveImages: 0, imageStripDepthTokens: 9_000 }, + first: [['u', [image(), text('A ' + plain(0))]], ['a', [text(plain(1))]], ['u', [text(plain(2))]], ['a', [text('ok')]], ['u', [text('next')]]], + change: (m) => add(m, [['a', [text(plain(3))]], ['u', [text('B ' + plain(4))]], ...SMALL]), + regions: [0, 0, 9], + changed: 0, + }, + 'raw in the middle: the fifth message loses its image to a newer one': { + config: { headWindowTokens: 0, recentWindowTokens: 300, maxLiveImages: 1 }, + first: [...MIDDLE, ['u', [image(), text('A ' + plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + change: (m) => add(m, [['a', [text(plain(6))]], ['u', [image(), text('B ' + plain(7))]], ...SMALL]), + regions: [0, 11, 2], + changed: 4, + }, + "raw in the middle: the fifth message's image no longer fits maxLiveImageBytes beside a newer one": { + config: { headWindowTokens: 0, recentWindowTokens: 300, maxLiveImages: 0, maxLiveImageBytes: 5 }, + first: [...MIDDLE, ['u', [image(), text('A ' + plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + change: (m) => add(m, [['a', [text(plain(6))]], ['u', [image(), text('B ' + plain(7))]], ...SMALL]), + regions: [0, 11, 2], + changed: 4, + }, + "raw in the middle: the fifth message's tool result is replaced by a newer one of the same tool": { + config: { headWindowTokens: 0, recentWindowTokens: 300, toolResultMaxLastN: 1 }, + first: [...MIDDLE.slice(0, 3), ['a', [call('call-1', 'first')]], ['u', [result('call-1', plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + change: (m) => add(m, [['a', [call('call-2', 'second')]], ['u', [result('call-2', plain(7))]], ...SMALL]), + regions: [0, 11, 2], + changed: 4, + }, + 'raw in the middle: the third message is edited in place': { + config: { headWindowTokens: 0, recentWindowTokens: 300 }, + first: [...MIDDLE, ['u', [text(plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + change: (m) => { + m.editMessage(ids(m)[2]!, [text('edited ' + plain(2))]); + add(m, [['a', [text(plain(6))]], ['u', [text(plain(7))]], ...SMALL]); + }, + regions: [0, 11, 2], + changed: 2, + }, + "raw in the middle: an edit changes only the third message's raw replay form": { + config: { headWindowTokens: 0, recentWindowTokens: 300 }, + first: [...MIDDLE.slice(0, 2), ['u', [replayed(plain(2), 'msg-1')]], ...MIDDLE.slice(3), ['u', [text(plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + change: (m) => { + m.editMessage(ids(m)[2]!, [replayed(plain(2), 'msg-2')]); + add(m, [['a', [text(plain(6))]], ['u', [text(plain(7))]], ...SMALL]); + }, + regions: [0, 11, 2], + changed: 2, + }, + 'raw in the middle: an edit before an image stripped later decides the cut': { + config: { headWindowTokens: 0, recentWindowTokens: 300, maxLiveImages: 1 }, + first: [...MIDDLE, ['u', [image(), text('A ' + plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + change: (m) => { + m.editMessage(ids(m)[2]!, [text('edited ' + plain(2))]); + add(m, [['a', [text(plain(6))]], ['u', [image(), text('B ' + plain(7))]], ...SMALL]); + }, + regions: [0, 11, 2], + changed: 2, + }, + "a tool call's late result moves up over the stub after it": { + config: { headWindowTokens: 0, recentWindowTokens: 300 }, + first: [...MIDDLE, ['u', [text(plain(4))]], ['a', [text(plain(5))]], ['u', [text('q')]], ['a', [call('call-1', 'late')]], ['u', [text('next')]]], + change: (m) => add(m, [['u', [result('call-1', 'the late result')]], ...SMALL]), + regions: [0, 6, 6], + changed: 8, + }, + 'the head window: its message loses its image to a newer one': { + config: { headWindowTokens: 1_700, recentWindowTokens: 300, maxLiveImages: 1 }, + first: [['u', [image(), text('A')]], ['a', [text(plain(1))]], ['u', [text(plain(2))]], ['a', [text(plain(3))]], ['u', [text(plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + change: (m) => add(m, [['a', [text(plain(6))]], ['u', [image(), text('B ' + plain(7))]], ...SMALL]), + regions: [1, 10, 2], + changed: 0, + }, + "the head window: its tool result is replaced by a newer one of the same tool": { + config: { headWindowTokens: 2_700, recentWindowTokens: 300, toolResultMaxLastN: 1 }, + first: [['u', [text('look it up')]], ['a', [call('call-1', 'first')]], ['u', [result('call-1', plain(2))]], ['a', [text(plain(3))]], ['u', [text(plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + change: (m) => add(m, [['a', [call('call-2', 'second')]], ['u', [result('call-2', plain(7))]], ...SMALL]), + regions: [3, 8, 2], + changed: 2, + }, + 'nothing changes in place: every marker is credited': { + config: { headWindowTokens: 0, recentWindowTokens: 300, maxLiveImages: 1 }, + first: [...MIDDLE, ['u', [image(), text('A ' + plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + change: (m) => add(m, [['a', [text(plain(6))]], ['u', [text('B ' + plain(7))]], ...SMALL]), + regions: [0, 11, 2], + changed: null, + }, +}; + +describe('kv-unified credits only a prefix whose bytes are unchanged', () => { + for (const [name, c] of Object.entries(CUTS)) { + it(name, async () => { + const { dir, strategy, manager } = await open(c.config); + try { + add(manager, c.first); + const one = await compile(manager); + accept(strategy, one); + const ref = reference(strategy); + assert.ok(ref, 'premise: the accepted request keeps a cache reference'); + c.change(manager); + const two = await compile(manager); + const stats = manager.getRenderStats()!; + assert.deepEqual([stats.head.messages, stats.middleRaw.messages, stats.tail.messages], c.regions, + 'premise: the messages render in the head window, the middle and the recent window as named'); + const changed = firstChange(one, two); + assert.equal(changed === one.length ? null : changed, c.changed, 'premise: the first message whose bytes change'); + const solve = solved(strategy); + assert.ok(solve?.feasible && solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + } +}); + +type TieCase = { config: Record; first: Msg[]; premise: (wire: Wire[]) => boolean; what: string }; +const has = (wire: Wire[], test: (block: ContentBlock) => boolean): boolean => wire.some((m) => m.content.some(test)); +const textOf = (block: ContentBlock): string => { + const b = block as { type: string; text?: string; content?: unknown }; + return b.type === 'text' ? b.text ?? '' : b.type === 'tool_result' && typeof b.content === 'string' ? b.content : ''; +}; + +const TIES: Record = { + 'an image stripped before the request was accepted': { + config: { headWindowTokens: 0, recentWindowTokens: 1_000_000, maxLiveImages: 1 }, + first: [['u', [image(), text('A ' + plain(0))]], ['a', [text(plain(1))]], ['u', [image(), text('B ' + plain(2))]], ...CLOSE], + premise: (w) => has(w, (b) => textOf(b).includes('image not shown')), + what: 'a stripped image', + }, + 'a tool result last-N replaced before the request was accepted': { + config: { headWindowTokens: 0, recentWindowTokens: 300, toolResultMaxLastN: 1 }, + first: [...MIDDLE.slice(0, 1), ['a', [call('call-1', 'one')]], ['u', [result('call-1', plain(2))]], ['a', [call('call-2', 'two')]], ['u', [result('call-2', plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + premise: (w) => has(w, (b) => textOf(b).includes("left out: this context shows only the tool's last result")), + what: 'a replaced tool result', + }, + 'a tool call whose result never came, stubbed by pairing repair': { + config: { headWindowTokens: 0, recentWindowTokens: 300 }, + first: [...MIDDLE.slice(0, 1), ['a', [call('call-1', 'lost')]], ['u', [text(plain(2))]], ['a', [text(plain(3))]], ...CLOSE], + premise: (w) => has(w, (b) => textOf(b).includes("this call's result isn't shown in this context")), + what: 'a pairing stub', + }, + 'a message the cap cuts': { + config: { headWindowTokens: 0, recentWindowTokens: 300, maxMessageTokens: 500 }, + first: [...MIDDLE, ...CLOSE], + premise: (w) => has(w, (b) => textOf(b).includes('[truncated')), + what: 'a cut message', + }, + "a body's raw shards in the middle, the first holding an image the composite leaves out": { + config: { headWindowTokens: 0, recentWindowTokens: 300 }, + first: [['u', [text('intro')]], ['a', [text('hi')]], ['u', [image(), text(PROSE.slice(0, 80_000))]], ['a', [text(plain(1))]], ...CLOSE], + premise: (w) => w.some((m) => m.content.length === 1 && textOf(m.content[0]!).length >= 70_000) && + !has(w, (b) => (b as { type: string }).type === 'image'), + what: 'a middle composite of text alone', + }, + "a body's raw shards in the recent window, which the merge joins": { + config: { headWindowTokens: 0, recentWindowTokens: 1_000_000 }, + first: [['u', [text('intro')]], ['a', [text('hi')]], ['u', [text(PROSE.slice(0, 80_000))]], ['a', [text(plain(1))]], ...CLOSE], + premise: (w) => w.some((m) => m.content.some((b) => textOf(b).length >= 70_000)), + what: 'a merged body', + }, + "a tool call whose input toolUseInputMaxTokens cuts": { + config: { headWindowTokens: 0, recentWindowTokens: 300, toolUseInputMaxTokens: 50 }, + first: [...MIDDLE.slice(0, 1), ['a', [call('call-1', 'x'.repeat(4_000))]], ['u', [result('call-1', plain(2))]], ['a', [text(plain(3))]], ...CLOSE], + premise: (w) => has(w, (b) => (b as { type: string; input?: { _truncated?: boolean } }).type === 'tool_use' && + (b as { input?: { _truncated?: boolean } }).input?._truncated === true), + what: 'a cut tool input', + }, + "the head window ending in a body's shards, which the merge joins": { + // 28,000 tokens hold the two short messages and the body's seven shards, + // and not the message after it. + config: { headWindowTokens: 28_000, recentWindowTokens: 300 }, + first: [['u', [text('intro')]], ['a', [text('hi')]], ['u', [text(PROSE.slice(0, 80_000))]], ['a', [text(plain(1))]], ...CLOSE], + premise: (w) => w[2]?.cacheBreakpoint === true && textOf(w[2].content[0]!).length >= 70_000, + what: "a marker on the merged body that ends the head", + }, + 'the head window, with an image and a tool call and result': { + config: { headWindowTokens: 6_000, recentWindowTokens: 300, maxLiveImages: 1 }, + first: [['u', [image(), text('A')]], ['a', [call('call-1', 'head')]], ['u', [result('call-1', plain(2))]], ['a', [text(plain(3))]], ['u', [image(), text('B ' + plain(4))]], ['a', [text(plain(5))]], ...CLOSE], + premise: (w) => has(w, (b) => textOf(b).includes('image not shown')) && has(w, (b) => (b as { type: string }).type === 'tool_result'), + what: 'the head window', + }, +}; + +describe('with nothing changed in place, every marker is still credited', () => { + for (const [name, c] of Object.entries(TIES)) { + it(name, async () => { + const { dir, strategy, manager } = await open(c.config); + try { + add(manager, c.first); + const one = await compile(manager); + assert.ok(c.premise(one), `premise: the accepted request holds ${c.what}`); + accept(strategy, one); + const ref = reference(strategy); + assert.ok(ref && ref.markers.length > 0, 'premise: a reference with markers'); + add(manager, SMALL); + const two = await compile(manager); + assert.equal(firstChange(one, two), one.length, 'premise: nothing the accepted request sent changes'); + assert.equal(solveMarkers(strategy), ref.markers.length, 'every marker survives the check'); + const solve = solved(strategy); + assert.ok(solve?.feasible && solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout), 'every marker is credited'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + } +}); + +describe('with history folded', () => { + // A mock membrane writes the summaries. Everything renders raw at the first + // compile, so the history forms chunks to summarize; at a smaller budget the + // solve folds the oldest chunks into recall pairs. + const membrane = { + complete: async () => ({ + stopReason: 'end_turn', + content: [{ type: 'text', text: 'summary ' + 'word '.repeat(20) }], + usage: { inputTokens: 100, outputTokens: 30 }, + }), + }; + const turn = (k: number): Msg => [k % 2 ? 'a' : 'u', [text(`turn ${k} ` + 'word '.repeat(60))]]; + const FOLDED = { headWindowTokens: 0, recentWindowTokens: 600, targetChunkTokens: 60 }; + const BODY = 'lorem ipsum dolor sit amet consectetur '.repeat(400); + /** Forty turns (and a body after the sixth when asked), compiled raw, then + * summarized. */ + async function folded(config: Record, body: boolean, dir?: string) { + const opened = await open({ ...FOLDED, ...config }, dir, membrane); + if (dir === undefined) { + for (let k = 0; k < 40; k++) { + if (body && k === 6) opened.manager.addMessage('u', [text(BODY)]); + add(opened.manager, [turn(k)]); + } + await compile(opened.manager); + for (let i = 0; i < 16; i++) await opened.manager.tick(); + } + return opened; + } + const at = (maxTokens: number) => ({ maxTokens, reserveForResponse: 0 }); + const unitOf = (ref: Cache, m: { unitIndex: number }) => ref.layout.units[m.unitIndex - 1]; + + type FoldCase = { budget: number; pin?: number; body?: boolean; premise: (ref: Cache, manager: Manager) => string | null }; + const FOLDS: Record = { + 'a marker on a recall answer is still credited when nothing changed in place': { + // The first third's marker lands on a recall answer. + budget: 2_000, + premise: (ref) => (ref.markers.some((m) => unitOf(ref, m)?.kind === 'recall') ? null : 'a marker sits on a recall unit'), + }, + 'a marker on a recall answer after a raw unit, with a message pinned raw, is still credited': { + // Pinning the third message raw puts a raw unit between the recall units. + budget: 2_000, + pin: 2, + premise: (ref) => (ref.markers.some((m) => unitOf(ref, m)?.kind === 'recall' && + ref.layout.units.slice(0, m.unitIndex - 1).some((unit) => unit.kind === 'raw')) + ? null : 'a marker sits on a recall unit after a raw unit'), + }, + "a body whose first shards are folded and the rest raw is still credited": { + budget: 6_200, + body: true, + premise: (ref, manager) => { + const shards = new Set((manager as unknown as { messageStore: { getAll(): Array<{ id: string; bodyGroupId?: string }> } }) + .messageStore.getAll().filter((m) => m.bodyGroupId).map((m) => m.id)); + const raw = ref.layout.units.filter((unit) => unit.kind === 'raw' && shards.has(unit.key)).length; + return raw > 0 && raw < shards.size && ref.layout.units.some((unit) => unit.kind === 'recall') + ? null : 'some of the body\'s shards are raw units and the rest folded'; + }, + }, + }; + for (const [name, c] of Object.entries(FOLDS)) { + it(name, async () => { + const { dir, strategy, manager } = await folded({}, c.body === true); + try { + if (c.pin !== undefined) manager.pinRange(ids(manager)[c.pin]!, ids(manager)[c.pin]!); + const one = (await manager.compile(at(c.budget), undefined, HASH)).messages as Wire[]; + accept(strategy, one); + const ref = reference(strategy)!; + const missing = c.premise(ref, manager); + assert.equal(missing, null, `premise: ${missing}`); + add(manager, [turn(40), turn(41)]); + const two = (await manager.compile(at(c.budget), undefined, HASH)).messages as Wire[]; + assert.equal(firstChange(one, two), one.length, 'premise: nothing the accepted request sent changes'); + assert.equal(solveMarkers(strategy), ref.markers.length, 'every marker survives the check'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + } + + it('a recall answer under another summary participant is not credited', async () => { + const first = await folded({}, false); + let { strategy, manager } = first; + try { + const one = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + accept(strategy, one); + const ref = reference(strategy)!; + assert.equal(ref.layout.units[0]?.kind, 'recall', 'premise: the request opens with a recall pair'); + await manager.close(); + ({ strategy, manager } = await folded({ summaryParticipant: 'Assistant' }, false, first.dir)); + add(manager, [turn(40), turn(41)]); + const two = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + assert.equal(firstChange(one, two), 1, 'premise: the first recall answer changed, and its question did not'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(first.dir, { recursive: true, force: true }); + } + }); + + it('a marker past an L2 recall answer is still credited when nothing changed in place', async () => { + // A tight budget folds the oldest turns to their L2, whose messages the + // rebuild reaches through its L1 sources. + let found = false; + for (const budget of [1_700, 1_650, 1_750, 1_800, 1_600]) { + const { dir, strategy, manager } = await folded({}, false); + try { + const one = (await manager.compile(at(budget), undefined, HASH)).messages as Wire[]; + accept(strategy, one); + const ref = reference(strategy)!; + const level = (key: string) => (strategy as unknown as { summaries: Array<{ id: string; level: number }> }) + .summaries.find((x) => x.id === key)?.level ?? 0; + const deep = ref.layout.units.findIndex((unit) => unit.kind === 'recall' && level(unit.key) >= 2); + if (deep < 0 || !ref.markers.some((m) => m.unitIndex > deep + 1)) continue; + add(manager, [turn(40), turn(41)]); + let two: Wire[]; + try { + two = (await manager.compile(at(budget), undefined, HASH)).messages as Wire[]; + } catch (error) { + // Two more turns may not fit this budget; anything else fails the test. + if ((error as Error).name !== 'OverBudgetError') throw error; + continue; + } + found = true; + assert.equal(firstChange(one, two), one.length, 'premise: nothing the accepted request sent changes'); + assert.equal(solveMarkers(strategy), ref.markers.length, 'every marker survives the check'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + if (found) break; + } + assert.ok(found, 'premise: at some budget, a marker past an L2 recall unit, and room for two more turns'); + }); + + it('with every raw unit after the last recall unit removed, and a new turn after, still credits the marker on that recall unit', async () => { + // Whether a message renders folded is decided by which summaries cover + // it, so a rewind of the messages after the folded history leaves it folded. + let found: { budget: number; j: number } | null = null; + for (const budget of [2_000, 2_400, 3_000, 1_800, 3_600]) { + const { dir, strategy, manager } = await folded({}, false); + try { + const one = (await manager.compile(at(budget), undefined, HASH)).messages as Wire[]; + accept(strategy, one); + const ref = reference(strategy)!; + const j = ref.layout.units.map((unit) => unit.kind).lastIndexOf('recall'); + if (j < 0 || !ref.markers.some((m) => m.unitIndex === j + 1)) continue; + found = { budget, j }; + const after = ref.layout.units.slice(j + 1); + assert.ok(after.length > 0 && after.every((unit) => unit.kind === 'raw'), 'premise: raw units after it'); + assert.ok(ref.markers.some((m) => m.unitIndex > j + 1), 'premise: a marker past it too, which the check must drop'); + manager.removeMessages(after[0]!.key, after.at(-1)!.key); + add(manager, [['u', [text('again ' + 'word '.repeat(700))]]]); + const hooks = strategy as unknown as { kvUnifiedKeepEntries(...args: unknown[]): unknown }; + const keep = hooks.kvUnifiedKeepEntries.bind(strategy); + let rebuilt = 0; + hooks.kvUnifiedKeepEntries = (...args) => { rebuilt++; return keep(...args); }; + await manager.compile(at(budget), undefined, HASH); + assert.ok(rebuilt > 0, 'premise: the check rebuilt'); + const kept = internals(strategy).kvUnifiedSolveCache?.markers.map((m) => (m as { unitIndex: number }).unitIndex); + assert.deepEqual(kept, ref.markers.filter((m) => m.unitIndex <= j + 1).map((m) => m.unitIndex), + 'the markers through the recall unit are credited, and none past it'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + break; + } + assert.ok(found, 'premise: at some budget, a marker sits on the last recall unit'); + }); + + it('with the messages of the first of several recall units removed, credits no marker past it', async () => { + // The emission renders no pair for a summary none of whose messages remain, + // so the rebuild mustn't either, though later pairs share its place. + const { dir, strategy, manager } = await folded({}, false); + try { + const one = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + accept(strategy, one); + const ref = reference(strategy)!; + assert.deepEqual(ref.layout.units.slice(0, 2).map((unit) => unit.kind), ['recall', 'recall'], 'premise: two recall units open the layout'); + assert.ok(ref.markers.length > 0 && ref.markers.every((m) => m.unitIndex > 1), 'premise: every marker past the first'); + const first = (strategy as unknown as { summaries: Array<{ id: string; sourceIds: string[]; level: number }> }) + .summaries.find((x) => x.id === ref.layout.units[0]!.key)!; + assert.equal(first.level, 1, 'premise: an L1, whose sources are messages'); + for (const id of first.sourceIds) manager.removeMessage(id); + add(manager, [turn(40), turn(41)]); + await manager.compile(at(2_000), undefined, HASH); + assert.equal(solveMarkers(strategy), 0, 'no marker past the removed pair is credited'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('with one message of a recall unit removed, still credits every marker past it', async () => { + // The unit renders through its other messages, with its pair unchanged. + const { dir, strategy, manager } = await folded({}, false); + try { + const one = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + accept(strategy, one); + const ref = reference(strategy)!; + assert.equal(ref.layout.units[0]?.kind, 'recall', 'premise: a recall unit opens the layout'); + assert.ok(ref.markers.length > 0 && ref.markers.every((m) => m.unitIndex > 1), 'premise: every marker past it'); + const first = (strategy as unknown as { summaries: Array<{ id: string; sourceIds: string[] }> }) + .summaries.find((x) => x.id === ref.layout.units[0]!.key)!; + assert.ok(first.sourceIds.length > 1, 'premise: it covers more than one message'); + manager.removeMessage(first.sourceIds[1]!); + add(manager, [turn(40), turn(41)]); + const two = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + assert.equal(firstChange(one, two), one.length, 'premise: nothing the accepted request sent changes'); + assert.equal(solveMarkers(strategy), ref.markers.length, 'every marker survives the check'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("with the reference naming a summary that no longer exists, credits the markers before it and none from it on", async () => { + // The forest check refuses a store whose chunks point at a missing + // summary, so the reference is what names one here, as it would after + // the summary it accepted has gone from the store. + const { dir, strategy, manager } = await folded({}, false); + try { + // Pinning the third message raw puts a raw unit, and a marker, between recall units. + manager.pinRange(ids(manager)[2]!, ids(manager)[2]!); + const one = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + accept(strategy, one); + const ref = reference(strategy)!; + const j = ref.layout.units.findIndex((unit, i) => unit.kind === 'recall' && + ref.markers.some((m) => m.unitIndex <= i) && ref.markers.some((m) => m.unitIndex > i)); + assert.ok(j > 0, 'premise: a recall unit between two markers'); + (ref.layout.units[j] as { key: string }).key = 'L1-gone'; + add(manager, [turn(40), turn(41)]); + const two = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + assert.ok(two.length > 0, 'the compile goes on'); + const kept = internals(strategy).kvUnifiedSolveCache?.markers.map((m) => (m as { unitIndex: number }).unitIndex); + assert.deepEqual(kept, ref.markers.filter((m) => m.unitIndex <= j).map((m) => m.unitIndex), + 'the markers before it are credited, and none from it on'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("a recall pair the check can't render drops the markers from it on, says so once, and doesn't refuse the compile", async () => { + const { dir, strategy, manager } = await folded({}, false); + try { + // Pinning the third message raw puts a raw unit, and a marker, between recall units. + manager.pinRange(ids(manager)[2]!, ids(manager)[2]!); + const one = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + accept(strategy, one); + const ref = reference(strategy)!; + // A recall unit with a marker before it and one after it. + const j = ref.layout.units.findIndex((unit, i) => unit.kind === 'recall' && + ref.markers.some((m) => m.unitIndex <= i) && ref.markers.some((m) => m.unitIndex > i)); + assert.ok(j > 0, `premise: a recall unit between two markers (${ref.layout.units.map((u) => u.kind[0] + u.kind[1]).join(' ')}; markers ${ref.markers.map((m) => m.unitIndex)})`); + const target = ref.layout.units[j]!.key; + // The emission renders its pairs as before; only the check's rebuild fails on that one. + const s = strategy as unknown as { + kvUnifiedKeepEntries(...args: unknown[]): unknown; + recallPairEntries(summary: { id: string }, ...args: unknown[]): unknown; + }; + const keep = s.kvUnifiedKeepEntries.bind(strategy); + const pair = s.recallPairEntries.bind(strategy); + let rebuilding = false; + s.kvUnifiedKeepEntries = (...args) => { rebuilding = true; try { return keep(...args); } finally { rebuilding = false; } }; + s.recallPairEntries = (summary, ...args) => { + if (rebuilding && summary.id === target) throw new Error('unrenderable'); + return pair(summary, ...args); + }; + add(manager, [turn(40), turn(41)]); + const two = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + assert.ok(two.length > 0, 'the compile goes on'); + assert.equal(firstChange(one, two), one.length, 'premise: nothing the accepted request sent changes'); + const kept = internals(strategy).kvUnifiedSolveCache?.markers.map((m) => (m as { unitIndex: number }).unitIndex); + assert.deepEqual(kept, ref.markers.filter((m) => m.unitIndex <= j).map((m) => m.unitIndex), + 'the markers before the pair are credited, and none from it on'); + add(manager, [turn(42), turn(43)]); + await manager.compile(at(2_000), undefined, HASH); + assert.equal(taken("couldn't render recall").length, 1, 'said once over two compiles'); + // The rebuild without the pair isn't what was sent, which the self-check sees too. + assert.equal(taken(SELF_CHECK).length, 1, 'and the self-check says so, once'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a recall pair rendered under another summary label is not credited', async () => { + const first = await folded({}, false); + let { strategy, manager } = first; + try { + const one = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + accept(strategy, one); + const ref = reference(strategy)!; + assert.equal(ref.layout.units[0]?.kind, 'recall', 'premise: the request opens with a recall pair'); + await manager.close(); + ({ strategy, manager } = await folded({ summaryContextLabel: 'And before that?' }, false, first.dir)); + add(manager, [turn(40), turn(41)]); + const two = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + assert.equal(firstChange(one, two), 0, 'premise: the first recall question changed'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(first.dir, { recursive: true, force: true }); + } + }); + + it('reuses the hashes of what an append leaves unchanged', async () => { + const { dir, strategy, manager } = await open({ headWindowTokens: 0, recentWindowTokens: 1_000_000 }); + try { + add(manager, [...MIDDLE, ...CLOSE]); + accept(strategy, await compile(manager)); + const s = strategy as unknown as { kvUnifiedHash(object: object, compute: () => string): string }; + const original = s.kvUnifiedHash.bind(strategy); + let computed = 0; + s.kvUnifiedHash = (object, compute) => original(object, () => { computed++; return compute(); }); + add(manager, SMALL); + await compile(manager); + // Each new message's content array and its one block, perhaps once in + // the check and once in the record; the seven unchanged messages none. + assert.ok(computed > 0 && computed <= 4 * SMALL.length, `only what the append brought is hashed (${computed})`); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('keeps the memo to what the last two compiles fingerprinted', async () => { + const { dir, strategy, manager } = await folded({}, false); + try { + const store = (manager as unknown as { messageStore: { getAll(): Array<{ id: string; content: ContentBlock[] }> } }).messageStore; + const oldest = store.getAll()[0]!.content; + const memo = () => (strategy as unknown as { kvUnifiedHashes: { current: Map; previous: Map } }).kvUnifiedHashes; + const one = (await manager.compile(BIG, undefined, HASH)).messages as Wire[]; + accept(strategy, one); + assert.ok(memo().current.has(oldest), 'premise: a raw compile fingerprints the oldest message'); + // The first folded compile's check still rebuilds the raw reference, so + // the oldest message is fingerprinted there; two compiles later it's gone. + for (let k = 0; k < 3; k++) { + const wire = (await manager.compile(at(2_000), undefined, HASH)).messages as Wire[]; + accept(strategy, wire); + } + assert.ok(reference(strategy)!.layout.units[0]?.kind === 'recall', 'premise: the oldest message is folded now'); + assert.ok(!memo().current.has(oldest) && !memo().previous.has(oldest), 'what no longer renders leaves the memo'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('the head window', () => { + it('starting past the first message, after a reset, keeps every marker when nothing changed', async () => { + const { dir, strategy, manager } = await open({ headWindowTokens: 1_700, recentWindowTokens: 300 }); + try { + add(manager, MIDDLE); + await manager.resetHeadWindow('a new topic'); + add(manager, [['u', [text(plain(4))]], ['a', [text(plain(5))]], ...CLOSE]); + const one = await compile(manager); + assert.ok((internals(strategy) as unknown as { getHeadWindowStartIndex(s: unknown): number }) + .getHeadWindowStartIndex((manager as unknown as { messageStore: unknown }).messageStore) > 0, 'premise: the head starts past the first message'); + accept(strategy, one); + const ref = reference(strategy)!; + add(manager, SMALL); + const two = await compile(manager); + assert.equal(firstChange(one, two), one.length, 'premise: nothing the accepted request sent changes'); + assert.equal(solveMarkers(strategy), ref.markers.length, 'every marker survives the check'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('shrunk so its old last message is in the recent window does not credit the old head marker', async () => { + // The head holds two messages and the recent window everything after it. + // A smaller headWindowTokens then puts the second in the recent window: + // the reference's head unit no longer ends where its marker sat. + const first = await open({ headWindowTokens: 4_500, recentWindowTokens: 1_000_000 }); + let { strategy, manager } = first; + try { + add(manager, [['u', [text('A ' + plain(0))]], ['a', [text(plain(1))]], ['u', [text(plain(2))]], ...CLOSE]); + const one = await compile(manager); + accept(strategy, one); + const ref = reference(strategy)!; + assert.equal(manager.getRenderStats()!.head.messages, 2, 'premise: the head holds two messages'); + assert.equal(ref.markers[0]?.unitIndex, 1, 'premise: the first marker ends the head'); + await manager.close(); + ({ strategy, manager } = await open({ headWindowTokens: 2_500, recentWindowTokens: 1_000_000 }, first.dir)); + add(manager, SMALL); + const two = await compile(manager); + assert.equal(manager.getRenderStats()!.head.messages, 1, 'premise: the head holds one message now'); + assert.equal(firstChange(one, two), one.length, 'premise: nothing the accepted request sent changes'); + // The solve is handed every marker but the head's. + const kept = internals(strategy).kvUnifiedSolveCache?.markers.map((m) => (m as { unitIndex: number }).unitIndex); + assert.deepEqual(kept, ref.markers.slice(1).map((m) => m.unitIndex), "every marker but the head's survives the check"); + } finally { + await manager.close(); + rmSync(first.dir, { recursive: true, force: true }); + } + }); + + it('reset after the request was accepted is not credited past where the request changed', async () => { + const { dir, strategy, manager } = await open({ headWindowTokens: 1_700, recentWindowTokens: 300 }); + try { + add(manager, [...MIDDLE, ['u', [text(plain(4))]], ['a', [text(plain(5))]], ...CLOSE]); + const one = await compile(manager); + accept(strategy, one); + const ref = reference(strategy)!; + await manager.resetHeadWindow('a new topic'); + add(manager, SMALL); + const two = await compile(manager); + assert.equal(firstChange(one, two), 0, 'premise: the reset changed what is sent from its first message'); + assert.ok(ref.markers.length > 0, 'premise: the accepted request kept markers'); + // The layout's units change with the head, so the wire's prefix maps to + // no unit boundary of it: nothing is credited. + assert.equal(solveMarkers(strategy), 0, 'no marker is credited'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant && solve.selected.cacheChurn > 0, 'the solve prices the rewrite'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('grown over an edited message does not credit the old head marker for the larger head', async () => { + // The head holds the first two messages. A larger headWindowTokens then + // puts the next two in the head too, and the third is edited: the + // provider serves only the first two messages' prefix, which no longer + // ends a unit. + const first = await open({ headWindowTokens: 4_500, recentWindowTokens: 300 }); + let { strategy, manager } = first; + try { + add(manager, [['u', [text('A ' + plain(0))]], ...MIDDLE.slice(1), ['u', [text(plain(4))]], ['a', [text(plain(5))]], ...CLOSE]); + const one = await compile(manager); + accept(strategy, one); + const ref = reference(strategy)!; + assert.equal(manager.getRenderStats()!.head.messages, 2, 'premise: the head holds two messages'); + assert.equal(ref.markers[0]?.unitIndex, 1, 'premise: the first marker ends the head'); + await manager.close(); + ({ strategy, manager } = await open({ headWindowTokens: 8_500, recentWindowTokens: 300 }, first.dir)); + manager.editMessage(ids(manager)[2]!, [text('edited ' + plain(2))]); + add(manager, SMALL); + const two = await compile(manager); + assert.equal(manager.getRenderStats()!.head.messages, 4, 'premise: the head holds four messages now'); + assert.equal(firstChange(one, two), 2, 'premise: the third message changed'); + assert.equal(solveMarkers(strategy), 0, 'no marker is credited'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant && solve.selected.cacheChurn > 0, 'the solve prices the rewrite'); + } finally { + await manager.close(); + rmSync(first.dir, { recursive: true, force: true }); + } + }); +}); + +describe('the self-check, where a compile keeps the accepted presentation', () => { + type Hooks = { + kvUnifiedKeepEntries(...args: unknown[]): unknown; + applyImageStripping(...args: unknown[]): unknown; + }; + /** Runs `during` in place of `method` while the check rebuilds, so the + * rebuild drifts from the emission and the emission doesn't. */ + function drift(s: AutobiographicalStrategy, change: (hooks: Hooks, rebuilding: () => boolean) => void): void { + const hooks = s as unknown as Hooks; + const keep = hooks.kvUnifiedKeepEntries.bind(s); + let rebuilding = false; + hooks.kvUnifiedKeepEntries = (...args) => { rebuilding = true; try { return keep(...args); } finally { rebuilding = false; } }; + change(hooks, () => rebuilding); + } + + it('says once when the rebuild misses a change the emission made, which it would otherwise credit', async () => { + const { dir, strategy, manager } = await open({ headWindowTokens: 0, recentWindowTokens: 1_000_000, maxLiveImages: 1 }); + try { + add(manager, [['u', [image(), text('A ' + plain(0))]], ['a', [text(plain(1))]], ...CLOSE]); + const one = await compile(manager); + accept(strategy, one); + const ref = reference(strategy)!; + // The rebuild lacks the strip, as it would lack a pass the emission gained. + drift(strategy, (hooks, rebuilding) => { + const strip = hooks.applyImageStripping.bind(strategy); + hooks.applyImageStripping = (...args) => (rebuilding() ? undefined : strip(...args)); + }); + add(manager, [['a', [text('ok2')]], ['u', [image(), text('B ' + plain(2))]]]); + const two = await compile(manager); + assert.equal(firstChange(one, two), 0, 'premise: the newer image stripped the first'); + assert.equal(solveMarkers(strategy), ref.markers.length, 'premise: the drifted rebuild credits every marker'); + add(manager, SMALL); + await compile(manager); + assert.equal(taken(SELF_CHECK).length, 1, 'said once over two compiles'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("leaves the record holding only the markers' prefixes, so the next change is still caught", async () => { + const { dir, strategy, manager } = await open({ headWindowTokens: 0, recentWindowTokens: 1_000_000, maxLiveImages: 1 }); + try { + add(manager, [['u', [image(), text('A ' + plain(0))]], ['a', [text(plain(1))]], ...CLOSE]); + accept(strategy, await compile(manager)); + // This compile keeps the presentation, so its record step also compares. + add(manager, [...SMALL, ['a', [text(plain(3))]], ['u', [text(plain(4))]]]); + const two = await compile(manager); + accept(strategy, two); + const ref = reference(strategy)!; + assert.equal((ref as unknown as { render: { prefixes: unknown[] } }).render.prefixes.length, ref.markers.length, + 'one prefix per marker'); + add(manager, [['a', [text('ok5')]], ['u', [image(), text('B ' + plain(5))]]]); + const three = await compile(manager); + assert.equal(firstChange(two, three), 0, 'premise: the newer image stripped the first'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(two, three, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('with the newest messages removed after acceptance, credits the markers on what remains', async () => { + // The middle's thirds carry markers, and the tail one sits on the newest message. + const { dir, strategy, manager } = await open({ headWindowTokens: 0, recentWindowTokens: 300 }); + try { + add(manager, [...MIDDLE, ...CLOSE]); + const one = await compile(manager); + accept(strategy, one); + const ref = reference(strategy)!; + // Removing the last two messages takes the tail marker's message with them. + const all = ids(manager); + manager.removeMessages(all[all.length - 2]!, all[all.length - 1]!); + const remaining = new Set(ids(manager)); + const hooks = strategy as unknown as { kvUnifiedSelfCheck(...args: unknown[]): unknown }; + const check = hooks.kvUnifiedSelfCheck.bind(strategy); + let compared = 0; + hooks.kvUnifiedSelfCheck = (...args) => { compared++; return check(...args); }; + const two = await compile(manager); + assert.equal(compared, 1, 'the self-check compares the shortened presentation'); + assert.equal(firstChange(one, two), two.length, 'premise: what remains is sent as it was'); + const units = (l: Layout) => l.units.map((unit) => `${unit.kind}:${unit.key}`); + const now = units(solved(strategy)!.selected!.layout); + assert.deepEqual(now, units(ref.layout).slice(0, now.length), "premise: the reference's first units, so the self-check compares"); + assert.ok(now.length < ref.layout.units.length, 'premise: fewer of them'); + // Each marker ends on the message its record prefix names. + const through = (ref as unknown as { render: { prefixes: { through: string }[] } }).render.prefixes.map((p) => p.through); + const expected = ref.markers.filter((_, i) => remaining.has(through[i]!)); + assert.ok(expected.length > 0 && expected.length < ref.markers.length, + `premise: markers on both sides of the cut (${through.map((id) => (remaining.has(id) ? 'kept' : 'removed')).join(', ')})`); + const kept = internals(strategy).kvUnifiedSolveCache?.markers.map((m) => (m as { unitIndex: number }).unitIndex); + assert.deepEqual(kept, expected.map((m) => m.unitIndex), 'the markers on what remains are credited, and only those'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('says once when the rebuild renders a message differently, here the last marker\'s, which drops that marker', async () => { + const { dir, strategy, manager } = await open({ headWindowTokens: 0, recentWindowTokens: 300 }); + try { + add(manager, [...MIDDLE, ...CLOSE]); + const one = await compile(manager); + accept(strategy, one); + const ref = reference(strategy)!; + assert.ok(ref.markers.length > 1, 'premise: several markers, so the difference is past the first'); + const last = (ref as unknown as { render: { prefixes: { through: string }[] } }).render.prefixes.at(-1)!.through; + drift(strategy, (hooks) => { + const keep = hooks.kvUnifiedKeepEntries; + hooks.kvUnifiedKeepEntries = (...args) => { + const entries = keep(...args) as Array<{ content: ContentBlock[]; sourceMessageId?: string; sourceMessageIds?: string[] }>; + const at = entries.findIndex((e) => (e.sourceMessageId ?? e.sourceMessageIds?.at(-1)) === last); + entries[at] = { ...entries[at]!, content: [...entries[at]!.content, text('drift')] }; + return entries; + }; + }); + add(manager, SMALL); + const two = await compile(manager); + assert.equal(firstChange(one, two), one.length, 'premise: nothing the accepted request sent changes'); + assert.equal(solveMarkers(strategy), ref.markers.length - 1, 'premise: the drifted rebuild drops the last marker alone'); + add(manager, [['a', [text('ok3')]], ['u', [text('next3')]]]); + await compile(manager); + assert.equal(taken(SELF_CHECK).length, 1, 'said once over two compiles'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('the render record across submissions and dry runs', () => { + const c = CUTS['raw in the middle: the fifth message loses its image to a newer one']!; + + it('a compile without an immutable prefix hash takes no record, since acceptance keeps no reference', async () => { + const { dir, strategy, manager } = await open(c.config); + try { + add(manager, c.first); + await manager.compile(BIG); + assert.ok(internals(strategy).kvUnifiedDraft, 'premise: the compile made a draft'); + assert.equal(internals(strategy).kvUnifiedDraft?.render, undefined); + await compile(manager); + assert.ok(internals(strategy).kvUnifiedDraft?.render, 'premise: with the hash, the draft takes one'); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a dry compile leaves the draft\'s record as the real compile made it', async () => { + const { dir, strategy, manager } = await open(c.config); + try { + add(manager, c.first); + await compile(manager); + const record = internals(strategy).kvUnifiedDraft?.render; + assert.ok(record, 'premise: the real compile made a record'); + c.change(manager); + await compile(manager, true); + assert.equal(internals(strategy).kvUnifiedDraft?.render, record); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('acceptance keeps the record taken when its submission began, not a later compile\'s', async () => { + const { dir, strategy, manager } = await open(c.config); + try { + add(manager, c.first); + const one = await compile(manager); + const id = begin(strategy); + const record = internals(strategy).kvUnifiedDraft?.render; + c.change(manager); + await compile(manager); + assert.notEqual(internals(strategy).kvUnifiedDraft?.render, record, 'premise: the later compile made its own record'); + settle(strategy, id, one); + assert.equal(reference(strategy)?.render, record); + } finally { + await manager.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('a reference loaded from the store', () => { + const c = CUTS['everything in the recent window: the oldest message loses its image to a newer one']!; + + it('keeps its render record: an unchanged prefix keeps every marker, and a changed one is not credited', async () => { + const first = await open(c.config); + let { strategy, manager } = first; + try { + add(manager, c.first); + const one = await compile(manager); + accept(strategy, one); + const ref = reference(strategy)!; + await manager.close(); + ({ strategy, manager } = await open(c.config, first.dir)); + add(manager, SMALL); + await compile(manager); + assert.equal(solveMarkers(strategy), ref.markers.length, 'an unchanged prefix keeps every marker'); + assert.equal(solved(strategy)?.cacheRelevant, true, 'the solve weighs the cache'); + assert.equal(solved(strategy)?.selected?.cacheChurn, 0); + await manager.close(); + ({ strategy, manager } = await open(c.config, first.dir)); + c.change(manager); + const two = await compile(manager); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.selected.cacheChurn > 0, 'premise: the image was stripped, so nothing is cached'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(first.dir, { recursive: true, force: true }); + } + }); + + it('cut by a lower maxMessageTokens after a reopen is not credited past the first message cut', async () => { + const config = { headWindowTokens: 0, recentWindowTokens: 300 }; + const first = await open(config); + let { strategy, manager } = first; + try { + add(manager, [...MIDDLE, ['u', [text(plain(4))]], ['a', [text(plain(5))]], ...CLOSE]); + const one = await compile(manager); + accept(strategy, one); + const ref = reference(strategy)!; + await manager.close(); + ({ strategy, manager } = await open({ ...config, maxMessageTokens: 500 }, first.dir)); + add(manager, SMALL); + const two = await compile(manager); + assert.equal(firstChange(one, two), 0, 'premise: the cap cuts the first message'); + const solve = solved(strategy)!; + assert.ok(solve.selected && solve.cacheRelevant, 'premise: a solve that weighs the cache'); + assert.equal(solve.selected.cacheChurn, wireChurn(one, two, ref, solve.selected.layout)); + } finally { + await manager.close(); + rmSync(first.dir, { recursive: true, force: true }); + } + }); + + const RECORDS: Record unknown> = { + 'without a render record, as an earlier version wrote it,': () => undefined, + 'with a record that has fewer prefixes than markers': (render) => ({ prefixes: render.prefixes.slice(0, 1) }), + 'with a record of another shape': () => ({ headIds: [], markerHashes: ['x'] }), + 'with a record holding a null prefix': (render) => ({ prefixes: [null, ...render.prefixes.slice(1)] }), + 'with a record holding a null prefix after a good one': (render) => ({ prefixes: render.prefixes.map((p, i) => (i === 1 ? null : p)) }), + "with a record whose hashes aren't strings": (render) => ({ prefixes: render.prefixes.map((p) => ({ ...(p as object), hash: 5 })) }), + }; + const m = CUTS['raw in the middle: the fifth message loses its image to a newer one']!; + for (const [name, persist] of Object.entries(RECORDS)) { + it(`${name} is credited as before once`, async () => { + const first = await open(m.config); + let { strategy, manager } = first; + try { + add(manager, m.first); + const one = await compile(manager); + accept(strategy, one); + const { store, kvUnifiedReceiptStateId: id } = internals(strategy); + const persisted = store.getStateJson(id) as { cache: { render?: { prefixes: unknown[] } } }; + assert.ok(persisted.cache.render && persisted.cache.render.prefixes.length > 1, 'premise: this version persists a record of several prefixes'); + const replaced = persist(persisted.cache.render); + if (replaced === undefined) delete persisted.cache.render; + else persisted.cache.render = replaced as { prefixes: unknown[] }; + store.setStateJson(id, persisted); + await manager.close(); + ({ strategy, manager } = await open(m.config, first.dir)); + m.change(manager); + const two = await compile(manager); + assert.equal(firstChange(one, two), 4, 'premise: the fifth message lost its image'); + assert.equal(solved(strategy)?.cacheRelevant, true, 'the reference still takes part in the solve'); + assert.equal(solved(strategy)?.selected?.cacheChurn, 0, 'every marker is credited, as before this check'); + assert.ok(wireChurn(one, two, internals(strategy).kvUnifiedReceipts.cache as unknown as Cache, solved(strategy)!.selected!.layout) > 0, + 'the wire allows less than that: the fallback over-credits this once'); + // The next acceptance writes a record, and the next change is caught. + accept(strategy, two); + const ref = reference(strategy)!; + add(manager, [['a', [text(plain(8))]], ['u', [image(), text('C ' + plain(9))]], ...SMALL]); + const three = await compile(manager); + const solve = solved(strategy)!; + assert.ok(firstChange(two, three) < two.length, 'premise: the second image was stripped'); + assert.equal(solve.selected!.cacheChurn, wireChurn(two, three, ref, solve.selected!.layout)); + } finally { + await manager.close(); + rmSync(first.dir, { recursive: true, force: true }); + } + }); + } +});