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 }); + } + }); + } +});