From c2691d56982a40fa37553098c0557da5fecb8ddc Mon Sep 17 00:00:00 2001 From: Anarchid Date: Tue, 15 Sep 2026 19:41:17 +0300 Subject: [PATCH 1/4] fix(kv-unified): stale presentation receipt made the label count superlinear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `kvunified:presentation-receipt` persists across folding-strategy switches. After kv-stable has presented from the store for a while, every leaf folded since is "extension" for kv-unified, and `extensionTokens` was keyed exactly in the Pareto label state while `dominates()` never looks at it, so labels differing only in their extension total never collapsed. On the Lynx Knowledge Resident's store (241 chunks, 318 summaries, receipt from six days earlier) the first compile after switching back threw "exact label propagation exceeded ceiling 100000 at 125796" and was still climbing at 1.64M labels / 5.3 GB under a 1M ceiling; with the receipt removed the same store solved in 133 ms. Half-covering receipts are the worst case: on a 28-chunk synthetic forest, 178,797 labels vs 23,630 fresh. Two changes: - `stateKey` keys extension tokens at token-bucket resolution, and omits them when no provider cache is relevant — they feed only `cacheChurn()`'s avoidable-recompute term, which is unpriced then. Exact mode (`tokenBucketSize: 0`) keeps them exact. Same synthetic forest after: 37,460 half-stale (1.6× fresh, the rest is continuity bucketing), 3,642 fully stale. - `loadPersistedState` supersedes a persisted receipt when the folding strategy is not kv-unified (the state is now registered for every strategy), with one warning line. A later switch back starts from an empty chain: one cold-cache turn, which the migration runbook already expects, instead of a hard-down. The production store with its stale receipt now compiles in 332 ms / 423 MB under the original 100k ceiling. Tests: bounded-growth assertion on the synthetic forest, exact keys retained under a relevant cache, and an integration test that a kv-stable restart clears the receipt. Fixes #97. Co-Authored-By: Claude Fable 5.1 --- .../fix-kv-unified-stale-receipt.fixed.md | 9 ++ src/adaptive/kv-unified-pareto.ts | 19 +++- src/strategies/autobiographical.ts | 36 ++++++-- test/adaptive/kv-unified-policy.test.ts | 88 +++++++++++++++++++ test/kv-unified-strategy-integration.test.ts | 48 ++++++++++ 5 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 changelog.d/fix-kv-unified-stale-receipt.fixed.md diff --git a/changelog.d/fix-kv-unified-stale-receipt.fixed.md b/changelog.d/fix-kv-unified-stale-receipt.fixed.md new file mode 100644 index 00000000..26c67b59 --- /dev/null +++ b/changelog.d/fix-kv-unified-stale-receipt.fixed.md @@ -0,0 +1,9 @@ +- kv-unified: a stale presentation receipt no longer makes the Pareto label + count superlinear. Extension tokens are keyed at token-bucket resolution and + only while a provider cache is relevant (they feed only the cache-churn term), + and a non-kv-unified folding strategy now supersedes a persisted + `kvunified:presentation-receipt` on load, so a switch back to kv-unified + starts from an empty chain instead of measuring against a days-old baseline. + A kv-stable → kv-unified switch on a production store went from + "exceeded ceiling 100000 at 125796" (and >5 GB at a 1M ceiling) to a + 332 ms / 423 MB solve under the same ceiling. (#97) diff --git a/src/adaptive/kv-unified-pareto.ts b/src/adaptive/kv-unified-pareto.ts index 19c24135..e3ecb9ed 100644 --- a/src/adaptive/kv-unified-pareto.ts +++ b/src/adaptive/kv-unified-pareto.ts @@ -175,7 +175,7 @@ export class ParetoKvUnifiedPolicySolver { let maxLabelsPerState = 0; const insert = (label: ParetoLabel): void => { if (label.renderedTokens > options.maxTokens) return; - const key = stateKey(label, tokenBucketSize, continuityBucketSize, fidelityBucketSize); + const key = stateKey(label, tokenBucketSize, continuityBucketSize, fidelityBucketSize, cacheRelevant); const current = states.get(key) ?? []; for (const incumbent of current) { if (dominates(incumbent, label)) { @@ -322,7 +322,7 @@ export class ParetoKvUnifiedPolicySolver { const groups = new Map(); for (const label of labels) { if (label.renderedTokens > options.maxTokens) continue; - const key = stateKey(label, tokenBucketSize, continuityBucketSize, fidelityBucketSize); + const key = stateKey(label, tokenBucketSize, continuityBucketSize, fidelityBucketSize, cacheRelevant); const current = groups.get(key); if (current) current.push(label); else groups.set(key, [label]); @@ -774,14 +774,27 @@ function stateKey( tokenBucketSize: number, continuityBucketSize: number, fidelityBucketSize: number, + cacheRelevant: boolean, ): string { const tokenKey = tokenBucketSize > 0 ? Math.ceil(label.renderedTokens / tokenBucketSize) : label.renderedTokens; + // Extension tokens feed only the cache-churn term (avoidable recompute = + // recomputed - extension), so they distinguish labels only while a provider + // cache is relevant, and then only at the token-bucket resolution. Keying + // on the exact value multiplied the live label set by the number of + // distinct extension sums: a presentation receipt that covers half of the + // live leaves (a kv-stable -> kv-unified switch) made the count superlinear + // in forest size and exhausted the ceiling (#97). + const extensionKey = !cacheRelevant + ? 'e*' + : tokenBucketSize > 0 + ? Math.ceil(label.extensionTokens / tokenBucketSize) + : label.extensionTokens; return [ label.remaining.toString(16), tokenKey, - label.extensionTokens, + extensionKey, label.cache.intact ? 1 : 0, label.cache.matchedUnits, label.cache.cachedTokens, diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index 07cfcb6a..99ebbd76 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -1834,14 +1834,15 @@ export class AutobiographicalStrategy implements ResettableStrategy { }); } catch { /* already registered */ } } - if (this.config.foldingStrategy === 'kv-unified') { - try { - this.store.registerState({ - id: this.kvUnifiedReceiptStateId, - strategy: 'snapshot', - }); - } catch { /* already registered */ } - } + // Registered for every folding strategy: a non-kv-unified presenter must + // be able to supersede a receipt left behind by kv-unified (see + // loadPersistedState), not just kv-unified itself. + try { + this.store.registerState({ + id: this.kvUnifiedReceiptStateId, + strategy: 'snapshot', + }); + } catch { /* already registered */ } try { this.store.registerState({ id: this.counterStateId, @@ -2061,6 +2062,25 @@ export class AutobiographicalStrategy implements ResettableStrategy { this.kvUnifiedPendingLayout = null; this.kvUnifiedPendingMarkerUnitIndices = []; this.kvUnifiedPendingImmutablePrefixHash = null; + } else { + // A presentation receipt is kv-unified's record of the LAST accepted + // presentation. Once another folding strategy presents from this store + // the receipt no longer describes the previous turn, and a later switch + // back would measure continuity against a days-old baseline and, worse, + // treat everything folded since as "extension" — which made the Pareto + // label count superlinear and hard-downed a resident (#97). Supersede it + // now; the switch back then starts from an empty chain (one cold-cache + // turn, as the migration runbook already expects). + const receiptState = this.store.getStateJson(this.kvUnifiedReceiptStateId); + if (receiptState && typeof receiptState === 'object') { + this.store.setStateJson(this.kvUnifiedReceiptStateId, null); + console.warn( + `[autobiographical] superseded a persisted kv-unified presentation receipt: ` + + `folding strategy is now ${String(this.config.foldingStrategy ?? 'default')}; ` + + `a later switch back to kv-unified starts from an empty receipt chain`, + ); + } + this.kvUnifiedReceipts = new KvUnifiedReceiptChain(); } } diff --git a/test/adaptive/kv-unified-policy.test.ts b/test/adaptive/kv-unified-policy.test.ts index e1988c01..bfe412b5 100644 --- a/test/adaptive/kv-unified-policy.test.ts +++ b/test/adaptive/kv-unified-policy.test.ts @@ -660,3 +660,91 @@ test('kv-unified infeasibility demands missing L1s instead of deadlocking', () = range: { firstChunkId: 'c-0000', lastChunkId: 'c-0003' }, }]); }); + +test('kv-unified label count stays bounded under a stale half-covering presentation (#97)', () => { + // A presentation receipt that covers only the older half of the live leaves + // (a kv-stable -> kv-unified switch, a restore, a history import) must not + // multiply the Pareto label set: extension tokens feed only the cache term, + // so they may distinguish labels only while a cache is relevant, and then + // only at token-bucket resolution. + const chronicle = buildChronicleWithChain({ + chunkCount: 24, + tokensPerChunk: 100, + mergeThreshold: 2, + recallPairTokens: 40, + }); + const inputs: PickerInputs = { + chunks: chronicle.chunks.map((chunk, index) => ({ ...chunk, rawTokens: 60 + ((index * 29) % 70) })), + summaries: chronicle.summaries, + recallPairTokens: new Map( + [...chronicle.recallPairTokens].map(([id, tokens], index) => [id, tokens + ((index * 11) % 30)]), + ), + headTokens: 0, + tailTokens: 0, + headChunkIds: new Set(), + tailChunkIds: new Set(), + }; + const ordered = [...inputs.chunks].sort((a, b) => a.sequence - b.sequence); + const presentationCovering = (count: number): AcceptedPresentationReference => ({ + currentSeq: 1, + leaves: new Map( + ordered.slice(0, count).map((chunk) => [ + chunk.id, + { repHash: `raw:${chunk.id}`, level: 0, lastChangedSeq: 0 }, + ]), + ), + }); + const rawTotal = inputs.chunks.reduce((sum, chunk) => sum + chunk.rawTokens, 0); + const options = { + maxTokens: Math.floor(rawTotal * 0.75), + tokenBucketSize: 100, + continuityBucketSize: 100, + fidelityBucketSize: 100, + labelCeiling: 200_000, + } as const; + const fresh = new ParetoKvUnifiedPolicySolver(inputs).solve({ + ...options, + presentation: presentationCovering(ordered.length), + }); + const halfStale = new ParetoKvUnifiedPolicySolver(inputs).solve({ + ...options, + presentation: presentationCovering(ordered.length / 2), + }); + const fullyStale = new ParetoKvUnifiedPolicySolver(inputs).solve({ + ...options, + presentation: presentationCovering(0), + }); + assert.equal(fresh.feasible, true); + assert.equal(halfStale.feasible, true); + assert.equal(fullyStale.feasible, true); + if (!fresh.feasible || !halfStale.feasible || !fullyStale.feasible) return; + const freshLabels = fresh.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; + const halfLabels = halfStale.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; + const fullLabels = fullyStale.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; + // Before the fix: fresh 14,256 / half-stale 60,791 (4.3x) / fully stale 41,714. + // The residual over fresh is continuity-loss bucketing, not extension keys. + assert.ok(halfLabels <= freshLabels * 2, `half-stale ${halfLabels} vs fresh ${freshLabels}`); + assert.ok(fullLabels <= freshLabels, `fully stale ${fullLabels} vs fresh ${freshLabels}`); + assert.ok(halfStale.selected.renderedTokens <= options.maxTokens); +}); + +test('kv-unified keeps exact extension keys when a relevant cache is priced', () => { + // With a relevant cache the extension total is part of the priced state, so + // labels that differ in it are still kept apart (at bucket resolution). + const { inputs } = fixture(); + const rawLayout = renderLayout(inputs, new SummaryTree(inputs), new Map()); + const presentation = rawPresentation(inputs); + const result = new ParetoKvUnifiedPolicySolver(inputs).solve({ + maxTokens: 250, + tokenBucketSize: 100, + continuityBucketSize: 100, + fidelityBucketSize: 100, + presentation, + cache: { immutablePrefixHash: 'stable-tools', layout: rawLayout, markers: [] }, + currentImmutablePrefixHash: 'stable-tools', + }); + assert.equal(result.feasible, true); + if (!result.feasible) return; + assert.ok(result.selected.renderedTokens <= 250); + assert.ok((result.propagation?.labelsCreated ?? 0) > 0); +}); diff --git a/test/kv-unified-strategy-integration.test.ts b/test/kv-unified-strategy-integration.test.ts index d1039633..30c6f9f5 100644 --- a/test/kv-unified-strategy-integration.test.ts +++ b/test/kv-unified-strategy-integration.test.ts @@ -256,3 +256,51 @@ test('kv-unified continuity relaxation is audited, expiring, and fail-closed', ( expiresAt: Date.now() + 60_000, }), 1); }); + +test('a non-kv-unified folding strategy supersedes a persisted kv-unified receipt (#97)', async () => { + const first = strategy(); + const manager = await ContextManager.open({ path: STORE, strategy: first }); + manager.addMessage('user', [{ type: 'text', text: 'presented by kv-unified' }]); + await manager.compile( + { maxTokens: 10_000, reserveForResponse: 0 }, + undefined, + { kvUnifiedImmutablePrefixHash: 'immutable-v1' }, + ); + first.beginKvUnifiedSubmission({ submissionId: 's1', requestHash: 'wire1', layoutHash: 'layout1' }); + const markerCount = ( + first as unknown as { kvUnifiedPendingMarkerUnitIndices: number[] } + ).kvUnifiedPendingMarkerUnitIndices.length; + first.reportKvUnifiedAccepted({ + submissionId: 's1', + acceptedAt: 123, + wireReceipt: { + requestHash: 'wire1', + markers: Array.from({ length: markerCount }, (_, ordinal) => ({ + ordinal, + prefixHash: `prefix-${ordinal}`, + estimatedOffset: ordinal + 1, + })), + }, + }); + assert.equal(receipts(first).head?.sequence, 1); + manager.close(); + + // kv-stable presents from the same store: the receipt no longer describes + // the previous turn and must not survive to a later switch back. + const stable = new AutobiographicalStrategy({ + adaptiveResolution: true, + foldingStrategy: 'kv-stable', + headWindowTokens: 0, + recentWindowTokens: 100, + }); + const viaStable = await ContextManager.open({ path: STORE, strategy: stable }); + viaStable.addMessage('user', [{ type: 'text', text: 'presented by kv-stable' }]); + await viaStable.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + viaStable.close(); + + const back = strategy(); + const reopened = await ContextManager.open({ path: STORE, strategy: back }); + assert.equal(receipts(back).head, null, 'switching back starts from an empty receipt chain'); + assert.equal(receipts(back).leaves.size, 0); + reopened.close(); +}); From 45f9b21140bc0eeec9e7068a7491a562a9fff3db Mon Sep 17 00:00:00 2001 From: Anarchid Date: Thu, 17 Sep 2026 16:06:12 +0300 Subject: [PATCH 2/4] fix(kv-unified): drop extension from the label state; supersede on present, not on load Review follow-up on #98 (Tengro): - Extension tokens leave the Pareto label entirely. The previous revision kept them in the state key at bucket resolution while a cache was relevant, but nothing reads them from the label: dominates() ignores them and the cache-churn term recomputes extension from the rendered layout. A bucket was a delay, not a bound. emit()/assignRawRun() lose the extension argument, isExtension() goes with it, and stateKey() no longer takes a relevance flag. - The receipt is superseded by a presentation, not by a load. loadPersistedState() only notes (read-only, via listStates + a null check, no registration side effect) that a receipt exists under a non-kv-unified strategy; the first non-dry-run select by that strategy nulls it, under requireBranchMutation, with one warning. Opening a store to inspect, preview or dry-run it leaves the receipt intact. Hooked on the presentation commit rather than persistResolutions() because that only runs when a resolution changed, and a no-op compile is still a presentation. - The vacuous cache-relevant test is gone with the branch it covered. The integration test now captures console.warn and asserts: a kv-stable load does not supersede and the receipt survives it; the first kv-stable compile supersedes exactly once; a second compile does not warn again; switching back finds an empty chain. Full suite 761/761. Real store with its stale receipt: 309 ms / 434 MB under the original 100k ceiling. Co-Authored-By: Claude Fable 5.1 --- .../fix-kv-unified-stale-receipt.fixed.md | 17 +++-- src/adaptive/kv-unified-pareto.ts | 60 +++++----------- src/strategies/autobiographical.ts | 71 ++++++++++++------- test/adaptive/kv-unified-policy.test.ts | 31 ++------ test/kv-unified-strategy-integration.test.ts | 50 +++++++++---- 5 files changed, 118 insertions(+), 111 deletions(-) diff --git a/changelog.d/fix-kv-unified-stale-receipt.fixed.md b/changelog.d/fix-kv-unified-stale-receipt.fixed.md index 26c67b59..40faadf6 100644 --- a/changelog.d/fix-kv-unified-stale-receipt.fixed.md +++ b/changelog.d/fix-kv-unified-stale-receipt.fixed.md @@ -1,9 +1,12 @@ - kv-unified: a stale presentation receipt no longer makes the Pareto label - count superlinear. Extension tokens are keyed at token-bucket resolution and - only while a provider cache is relevant (they feed only the cache-churn term), - and a non-kv-unified folding strategy now supersedes a persisted - `kvunified:presentation-receipt` on load, so a switch back to kv-unified - starts from an empty chain instead of measuring against a days-old baseline. - A kv-stable → kv-unified switch on a production store went from + count superlinear. Extension tokens are gone from the label state key (and + the label): nothing in dominance or pricing read them — the cache-churn term + recomputes extension from the rendered layout — so keying on them only split + labels that should have collapsed. Separately, the first real presentation + by a non-kv-unified folding strategy now supersedes a persisted + `kvunified:presentation-receipt` (loading, previewing or dry-running with + another strategy leaves it untouched), so a switch back to kv-unified starts + from an empty chain instead of measuring against a days-old baseline. A + kv-stable → kv-unified switch on a production store went from "exceeded ceiling 100000 at 125796" (and >5 GB at a 1M ceiling) to a - 332 ms / 423 MB solve under the same ceiling. (#97) + 309 ms / 434 MB solve under the same ceiling. (#97) diff --git a/src/adaptive/kv-unified-pareto.ts b/src/adaptive/kv-unified-pareto.ts index e3ecb9ed..65056bc2 100644 --- a/src/adaptive/kv-unified-pareto.ts +++ b/src/adaptive/kv-unified-pareto.ts @@ -32,7 +32,6 @@ interface ParetoLabel { active: boolean; remaining: bigint; renderedTokens: number; - extensionTokens: number; continuityLoss: number; fidelityLoss: number; cache: CacheState; @@ -151,7 +150,6 @@ export class ParetoKvUnifiedPolicySolver { active: true, remaining, renderedTokens: 0, - extensionTokens: 0, continuityLoss: 0, fidelityLoss: 0, cache: { intact: cacheRelevant, matchedUnits: 0, cachedTokens: 0 }, @@ -160,7 +158,7 @@ export class ParetoKvUnifiedPolicySolver { approximation: ZERO_APPROXIMATION, }; if (this.inputs.headTokens > 0) { - initial = this.emit(initial, 'head', 'head', this.inputs.headTokens, false, options, markerByUnit); + initial = this.emit(initial, 'head', 'head', this.inputs.headTokens, options, markerByUnit); } const ceiling = options.labelCeiling ?? 1_000_000; @@ -175,7 +173,7 @@ export class ParetoKvUnifiedPolicySolver { let maxLabelsPerState = 0; const insert = (label: ParetoLabel): void => { if (label.renderedTokens > options.maxTokens) return; - const key = stateKey(label, tokenBucketSize, continuityBucketSize, fidelityBucketSize, cacheRelevant); + const key = stateKey(label, tokenBucketSize, continuityBucketSize, fidelityBucketSize); const current = states.get(key) ?? []; for (const incumbent of current) { if (dominates(incumbent, label)) { @@ -206,7 +204,7 @@ export class ParetoKvUnifiedPolicySolver { if (label.remaining === 0n) { let finished = label; if (this.inputs.tailTokens > 0) { - finished = this.emit(label, 'tail', 'tail', this.inputs.tailTokens, false, options, markerByUnit); + finished = this.emit(label, 'tail', 'tail', this.inputs.tailTokens, options, markerByUnit); } if (finished.renderedTokens <= options.maxTokens) { terminal.push({ frontier: reconstructFrontier(finished.trace), renderedTokens: finished.renderedTokens }); @@ -218,7 +216,7 @@ export class ParetoKvUnifiedPolicySolver { const leaf = this.leaves[oldest]; if (leaf.allowedLevels.includes(0)) { const next = this.assign(label, [leaf.id], 0, policy, options); - insert(this.emit(next, 'raw', leaf.id, leaf.rawTokens, this.isExtension([leaf.id], options), options, markerByUnit)); + insert(this.emit(next, 'raw', leaf.id, leaf.rawTokens, options, markerByUnit)); } for (const summaryId of leaf.summaryIds) { const summary = this.forest.summary(summaryId)!; @@ -238,7 +236,7 @@ export class ParetoKvUnifiedPolicySolver { } if (overlap || (mask & bit(oldest)) === 0n) continue; const next = this.assign(label, participants, summary.level, policy, options); - insert(this.emit(next, 'recall', summary.id, summary.recallTokens, this.isExtension(summary.leafIds, options), options, markerByUnit)); + insert(this.emit(next, 'recall', summary.id, summary.recallTokens, options, markerByUnit)); } } @@ -297,7 +295,6 @@ export class ParetoKvUnifiedPolicySolver { active: true, remaining: 0n, renderedTokens: 0, - extensionTokens: 0, continuityLoss: 0, fidelityLoss: 0, cache: { intact: cacheRelevant, matchedUnits: 0, cachedTokens: 0 }, @@ -306,7 +303,7 @@ export class ParetoKvUnifiedPolicySolver { approximation: ZERO_APPROXIMATION, }; if (this.inputs.headTokens > 0) { - initial = this.emit(initial, 'head', 'head', this.inputs.headTokens, false, options, markerByUnit); + initial = this.emit(initial, 'head', 'head', this.inputs.headTokens, options, markerByUnit); } const ceiling = options.labelCeiling ?? 1_000_000; const tokenBucketSize = Math.max(0, Math.floor(options.tokenBucketSize ?? 0)); @@ -322,7 +319,7 @@ export class ParetoKvUnifiedPolicySolver { const groups = new Map(); for (const label of labels) { if (label.renderedTokens > options.maxTokens) continue; - const key = stateKey(label, tokenBucketSize, continuityBucketSize, fidelityBucketSize, cacheRelevant); + const key = stateKey(label, tokenBucketSize, continuityBucketSize, fidelityBucketSize); const current = groups.get(key); if (current) current.push(label); else groups.set(key, [label]); @@ -381,7 +378,6 @@ export class ParetoKvUnifiedPolicySolver { 'recall', summary.id, summary.recallTokens, - this.isExtension(summary.leafIds, options), options, markerByUnit, ); @@ -433,7 +429,6 @@ export class ParetoKvUnifiedPolicySolver { 'raw', leaf.id, leaf.rawTokens, - this.isExtension([leaf.id], options), options, markerByUnit, )); @@ -455,7 +450,6 @@ export class ParetoKvUnifiedPolicySolver { 'tail', 'tail', this.inputs.tailTokens, - false, options, markerByUnit, ); @@ -631,9 +625,8 @@ export class ParetoKvUnifiedPolicySolver { }; } - private emit(label: ParetoLabel, kind: 'head' | 'raw' | 'recall' | 'tail', key: string, tokens: number, extension: boolean, options: ExactPolicySolveOptions, markerByUnit: ReadonlyMap): ParetoLabel { + private emit(label: ParetoLabel, kind: 'head' | 'raw' | 'recall' | 'tail', key: string, tokens: number, options: ExactPolicySolveOptions, markerByUnit: ReadonlyMap): ParetoLabel { const renderedTokens = label.renderedTokens + tokens; - const extensionTokens = label.extensionTokens + (extension ? tokens : 0); if ( this.bufferGapEmissions && label.cache.intact && @@ -646,7 +639,6 @@ export class ParetoKvUnifiedPolicySolver { ...label, active: true, renderedTokens, - extensionTokens, pendingEmissions: [...label.pendingEmissions, { kind, key, tokens, sequence }], }; } @@ -659,7 +651,7 @@ export class ParetoKvUnifiedPolicySolver { if (marker !== undefined) cache.cachedTokens = marker; } else cache.intact = false; } - return { ...label, active: true, renderedTokens, extensionTokens, cache }; + return { ...label, active: true, renderedTokens, cache }; } private flushPendingEmissions( @@ -742,7 +734,6 @@ export class ParetoKvUnifiedPolicySolver { 'raw', id, leaf.rawTokens, - this.isExtension([id], options), options, markerByUnit, ); @@ -750,23 +741,14 @@ export class ParetoKvUnifiedPolicySolver { return next; } let tokens = 0; - let extensionTokens = 0; - for (const id of ids) { - const leafTokens = this.forest.leaf(id)!.rawTokens; - tokens += leafTokens; - if (this.isExtension([id], options)) extensionTokens += leafTokens; - } + for (const id of ids) tokens += this.forest.leaf(id)!.rawTokens; return { ...label, active: true, renderedTokens: label.renderedTokens + tokens, - extensionTokens: label.extensionTokens + extensionTokens, }; } - private isExtension(ids: readonly ChunkId[], options: ExactPolicySolveOptions): boolean { - return options.presentation !== undefined && ids.length > 0 && ids.every((id) => !options.presentation!.leaves.has(id)); - } } function stateKey( @@ -774,27 +756,21 @@ function stateKey( tokenBucketSize: number, continuityBucketSize: number, fidelityBucketSize: number, - cacheRelevant: boolean, ): string { const tokenKey = tokenBucketSize > 0 ? Math.ceil(label.renderedTokens / tokenBucketSize) : label.renderedTokens; - // Extension tokens feed only the cache-churn term (avoidable recompute = - // recomputed - extension), so they distinguish labels only while a provider - // cache is relevant, and then only at the token-bucket resolution. Keying - // on the exact value multiplied the live label set by the number of - // distinct extension sums: a presentation receipt that covers half of the - // live leaves (a kv-stable -> kv-unified switch) made the count superlinear - // in forest size and exhausted the ceiling (#97). - const extensionKey = !cacheRelevant - ? 'e*' - : tokenBucketSize > 0 - ? Math.ceil(label.extensionTokens / tokenBucketSize) - : label.extensionTokens; + // Only quantities that dominance or pricing consult belong in the key. + // Extension tokens (rendered tokens not covered by the accepted + // presentation) used to be keyed here exactly although nothing read them: + // the cache-churn term recomputes extension from the rendered layout, and + // dominates() never looked at them. A presentation receipt covering half of + // the live leaves (a kv-stable -> kv-unified switch) then multiplied the + // live label set by the number of distinct extension sums, superlinear in + // forest size, and exhausted the ceiling (#97). return [ label.remaining.toString(16), tokenKey, - extensionKey, label.cache.intact ? 1 : 0, label.cache.matchedUnits, label.cache.cachedTokens, diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index 99ebbd76..f0250c97 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -1080,6 +1080,9 @@ export class AutobiographicalStrategy implements ResettableStrategy { protected get calibrationStateId(): string { return `${this.ns}/autobio:calibration`; } protected get kvUnifiedReceiptStateId(): string { return `${this.ns}/kvunified:presentation-receipt`; } private kvUnifiedReceipts = new KvUnifiedReceiptChain(); + /** A persisted kv-unified receipt was found while loading under another + * folding strategy; superseded at this strategy's first presentation. */ + private kvUnifiedReceiptSupersedePending = false; private kvUnifiedDraft: { leaves: Map; layout: RenderLayout; @@ -1834,15 +1837,14 @@ export class AutobiographicalStrategy implements ResettableStrategy { }); } catch { /* already registered */ } } - // Registered for every folding strategy: a non-kv-unified presenter must - // be able to supersede a receipt left behind by kv-unified (see - // loadPersistedState), not just kv-unified itself. - try { - this.store.registerState({ - id: this.kvUnifiedReceiptStateId, - strategy: 'snapshot', - }); - } catch { /* already registered */ } + if (this.config.foldingStrategy === 'kv-unified') { + try { + this.store.registerState({ + id: this.kvUnifiedReceiptStateId, + strategy: 'snapshot', + }); + } catch { /* already registered */ } + } try { this.store.registerState({ id: this.counterStateId, @@ -2064,22 +2066,18 @@ export class AutobiographicalStrategy implements ResettableStrategy { this.kvUnifiedPendingImmutablePrefixHash = null; } else { // A presentation receipt is kv-unified's record of the LAST accepted - // presentation. Once another folding strategy presents from this store + // presentation. Once another folding strategy PRESENTS from this store // the receipt no longer describes the previous turn, and a later switch - // back would measure continuity against a days-old baseline and, worse, - // treat everything folded since as "extension" — which made the Pareto - // label count superlinear and hard-downed a resident (#97). Supersede it - // now; the switch back then starts from an empty chain (one cold-cache - // turn, as the migration runbook already expects). - const receiptState = this.store.getStateJson(this.kvUnifiedReceiptStateId); - if (receiptState && typeof receiptState === 'object') { - this.store.setStateJson(this.kvUnifiedReceiptStateId, null); - console.warn( - `[autobiographical] superseded a persisted kv-unified presentation receipt: ` + - `folding strategy is now ${String(this.config.foldingStrategy ?? 'default')}; ` + - `a later switch back to kv-unified starts from an empty receipt chain`, - ); - } + // back would measure continuity against a days-old baseline and treat + // everything folded since as extension (#97). Loading is not + // presenting: an inspection tool, a preview, or a dry run that opens the + // store with a different strategy must leave the receipt alone. Only + // note here that a receipt exists; the first non-dry-run select by a + // non-kv-unified strategy (selectAdaptive, after the resolutions + // commit) supersedes it. + this.kvUnifiedReceiptSupersedePending = + this.store.listStates().some((state) => state.id === this.kvUnifiedReceiptStateId) && + this.store.getStateJson(this.kvUnifiedReceiptStateId) != null; this.kvUnifiedReceipts = new KvUnifiedReceiptChain(); } } @@ -2105,6 +2103,24 @@ export class AutobiographicalStrategy implements ResettableStrategy { this.store.setStateJson(this.resolutionsStateId, out); } + /** This strategy has just presented, so the persisted kv-unified receipt no + * longer describes the previous turn. Null it (the slot keeps its history in + * the record log) so a later switch back to kv-unified starts from an empty + * chain — one cold-cache turn, which the migration runbook already expects — + * instead of measuring against a stale baseline and treating everything + * folded since as extension (#97). */ + protected supersedeKvUnifiedReceipt(): void { + if (!this.store) return; + this.requireBranchMutation('supersedeKvUnifiedReceipt'); + this.kvUnifiedReceiptSupersedePending = false; + this.store.setStateJson(this.kvUnifiedReceiptStateId, null); + console.warn( + `[autobiographical] superseded a persisted kv-unified presentation receipt: ` + + `${String(this.config.foldingStrategy ?? 'default')} has presented from this store; ` + + `a later switch back to kv-unified starts from an empty receipt chain`, + ); + } + /** Persist the current locked-id snapshot. */ protected persistLocks(): void { if (!this.store) return; @@ -7783,6 +7799,13 @@ export class AutobiographicalStrategy implements ResettableStrategy { if (resolutionsChanged && !dryRun) { this.persistResolutions(); } + // A real presentation by a non-kv-unified strategy: the persisted + // kv-unified receipt no longer describes the previous turn (#97). This is + // independent of whether any resolution changed — a no-op compile is + // still a presentation. + if (!dryRun && this.kvUnifiedReceiptSupersedePending && this.config.foldingStrategy !== 'kv-unified') { + this.supersedeKvUnifiedReceipt(); + } // Wire produce ops into the strategy's own production queues so that // requested-but-not-yet-existing summaries actually get built. The diff --git a/test/adaptive/kv-unified-policy.test.ts b/test/adaptive/kv-unified-policy.test.ts index bfe412b5..6a8b04c0 100644 --- a/test/adaptive/kv-unified-policy.test.ts +++ b/test/adaptive/kv-unified-policy.test.ts @@ -664,9 +664,10 @@ test('kv-unified infeasibility demands missing L1s instead of deadlocking', () = test('kv-unified label count stays bounded under a stale half-covering presentation (#97)', () => { // A presentation receipt that covers only the older half of the live leaves // (a kv-stable -> kv-unified switch, a restore, a history import) must not - // multiply the Pareto label set: extension tokens feed only the cache term, - // so they may distinguish labels only while a cache is relevant, and then - // only at token-bucket resolution. + // multiply the Pareto label set. Extension tokens are no longer part of the + // label state at all: nothing in dominance or pricing reads them from the + // label (the cache-churn term recomputes extension from the rendered + // layout), so keying on them only split labels that should have collapsed. const chronicle = buildChronicleWithChain({ chunkCount: 24, tokensPerChunk: 100, @@ -722,29 +723,9 @@ test('kv-unified label count stays bounded under a stale half-covering presentat const halfLabels = halfStale.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; const fullLabels = fullyStale.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; // Before the fix: fresh 14,256 / half-stale 60,791 (4.3x) / fully stale 41,714. - // The residual over fresh is continuity-loss bucketing, not extension keys. + // The residual over fresh is continuity-loss bucketing (the presentation + // still prices continuity), not extension keys. assert.ok(halfLabels <= freshLabels * 2, `half-stale ${halfLabels} vs fresh ${freshLabels}`); assert.ok(fullLabels <= freshLabels, `fully stale ${fullLabels} vs fresh ${freshLabels}`); assert.ok(halfStale.selected.renderedTokens <= options.maxTokens); }); - -test('kv-unified keeps exact extension keys when a relevant cache is priced', () => { - // With a relevant cache the extension total is part of the priced state, so - // labels that differ in it are still kept apart (at bucket resolution). - const { inputs } = fixture(); - const rawLayout = renderLayout(inputs, new SummaryTree(inputs), new Map()); - const presentation = rawPresentation(inputs); - const result = new ParetoKvUnifiedPolicySolver(inputs).solve({ - maxTokens: 250, - tokenBucketSize: 100, - continuityBucketSize: 100, - fidelityBucketSize: 100, - presentation, - cache: { immutablePrefixHash: 'stable-tools', layout: rawLayout, markers: [] }, - currentImmutablePrefixHash: 'stable-tools', - }); - assert.equal(result.feasible, true); - if (!result.feasible) return; - assert.ok(result.selected.renderedTokens <= 250); - assert.ok((result.propagation?.labelsCreated ?? 0) > 0); -}); diff --git a/test/kv-unified-strategy-integration.test.ts b/test/kv-unified-strategy-integration.test.ts index 30c6f9f5..25afefd4 100644 --- a/test/kv-unified-strategy-integration.test.ts +++ b/test/kv-unified-strategy-integration.test.ts @@ -257,7 +257,7 @@ test('kv-unified continuity relaxation is audited, expiring, and fail-closed', ( }), 1); }); -test('a non-kv-unified folding strategy supersedes a persisted kv-unified receipt (#97)', async () => { +test('a non-kv-unified folding strategy supersedes a persisted kv-unified receipt when it presents, not when it loads (#97)', async () => { const first = strategy(); const manager = await ContextManager.open({ path: STORE, strategy: first }); manager.addMessage('user', [{ type: 'text', text: 'presented by kv-unified' }]); @@ -285,22 +285,46 @@ test('a non-kv-unified folding strategy supersedes a persisted kv-unified receip assert.equal(receipts(first).head?.sequence, 1); manager.close(); - // kv-stable presents from the same store: the receipt no longer describes - // the previous turn and must not survive to a later switch back. - const stable = new AutobiographicalStrategy({ + const stableStrategy = () => new AutobiographicalStrategy({ adaptiveResolution: true, foldingStrategy: 'kv-stable', headWindowTokens: 0, recentWindowTokens: 100, }); - const viaStable = await ContextManager.open({ path: STORE, strategy: stable }); - viaStable.addMessage('user', [{ type: 'text', text: 'presented by kv-stable' }]); - await viaStable.compile({ maxTokens: 10_000, reserveForResponse: 0 }); - viaStable.close(); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + const line = args.map(String).join(' '); + if (line.includes('superseded a persisted kv-unified presentation receipt')) warnings.push(line); + else originalWarn(...args); + }; + try { + // Opening (inspection, preview, dry run) is not presenting: the receipt + // must survive a load by another strategy untouched. + const inspector = await ContextManager.open({ path: STORE, strategy: stableStrategy() }); + inspector.close(); + assert.equal(warnings.length, 0, 'a load must not supersede'); + const inspected = strategy(); + const stillThere = await ContextManager.open({ path: STORE, strategy: inspected }); + assert.equal(receipts(inspected).head?.sequence, 1, 'receipt intact after a kv-stable load'); + stillThere.close(); - const back = strategy(); - const reopened = await ContextManager.open({ path: STORE, strategy: back }); - assert.equal(receipts(back).head, null, 'switching back starts from an empty receipt chain'); - assert.equal(receipts(back).leaves.size, 0); - reopened.close(); + // Presenting is: the first kv-stable compile supersedes it, exactly once. + const viaStable = await ContextManager.open({ path: STORE, strategy: stableStrategy() }); + viaStable.addMessage('user', [{ type: 'text', text: 'presented by kv-stable' }]); + await viaStable.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + assert.equal(warnings.length, 1, 'first presentation supersedes'); + viaStable.addMessage('user', [{ type: 'text', text: 'presented again by kv-stable' }]); + await viaStable.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + assert.equal(warnings.length, 1, 'later presentations do not warn again'); + viaStable.close(); + + const back = strategy(); + const reopened = await ContextManager.open({ path: STORE, strategy: back }); + assert.equal(receipts(back).head, null, 'switching back starts from an empty receipt chain'); + assert.equal(receipts(back).leaves.size, 0); + reopened.close(); + } finally { + console.warn = originalWarn; + } }); From 32f8a25f9070babe3b42de25cea060ce921ea1a9 Mon Sep 17 00:00:00 2001 From: Anarchid Date: Thu, 17 Sep 2026 17:55:53 +0300 Subject: [PATCH 3/4] fix(kv-unified): extension is a priced dominance dimension, not a key; supersede only after a successful present Review follow-up on #98 (antra-tess): - P1: dropping extension from the label lost information the final cache term still prices (avoidable recompute = recomputed - extension), so dominance could discard the optimal cut under a relevant cache. The three-chunk reproduction from the review ([1,1,0] score 60 vs the round-2 pick [1,1,1] score 80, with a reported zero error) is now a test against the exhaustive oracle, bucketed and exact. Extension is back on the label; while a cache is relevant it is a fourth dominance dimension (more extension = less avoidable recompute), the bucket-group representatives keep the max-extension label, and any extension a covered label had beyond its representative goes into a new cache component of the approximation envelope, which the score error bound prices at the cache slope and reports as approximationCacheErrorBound. It stays out of the state key, which is what made the count superlinear. - P2: the supersede ran before the hard-budget check and emission, so a failed kv-stable compile erased the receipt without presenting. Moved to the end of the successful select path, after rsEnd(); a regression test accepts a kv-unified presentation, over-budgets a kv-stable compile (OverBudgetError), and finds the receipt intact on switching back. - A cache-relevant variant of the label-growth bound, so the regression the review pointed at (the growth test had no relevant cache) is covered. All three new tests fail on the round-2 source and pass here. Full suite 764/764. Real store with its stale receipt: 332 ms / 436 MB under the original 100k ceiling. Co-Authored-By: Claude Fable 5.1 --- .../fix-kv-unified-stale-receipt.fixed.md | 17 +- src/adaptive/kv-unified-pareto.ts | 98 +++++++++--- src/strategies/autobiographical.ts | 17 +- test/adaptive/kv-unified-policy.test.ts | 146 ++++++++++++++++++ test/kv-unified-strategy-integration.test.ts | 53 ++++++- 5 files changed, 289 insertions(+), 42 deletions(-) diff --git a/changelog.d/fix-kv-unified-stale-receipt.fixed.md b/changelog.d/fix-kv-unified-stale-receipt.fixed.md index 40faadf6..91cda7f0 100644 --- a/changelog.d/fix-kv-unified-stale-receipt.fixed.md +++ b/changelog.d/fix-kv-unified-stale-receipt.fixed.md @@ -1,12 +1,13 @@ - kv-unified: a stale presentation receipt no longer makes the Pareto label - count superlinear. Extension tokens are gone from the label state key (and - the label): nothing in dominance or pricing read them — the cache-churn term - recomputes extension from the rendered layout — so keying on them only split - labels that should have collapsed. Separately, the first real presentation - by a non-kv-unified folding strategy now supersedes a persisted - `kvunified:presentation-receipt` (loading, previewing or dry-running with - another strategy leaves it untouched), so a switch back to kv-unified starts + count superlinear. Extension tokens (rendered tokens not covered by the + accepted presentation) are no longer a state-key dimension; they are a + dominance dimension while a provider cache is relevant (the cache term + prices them) and a cover-envelope term reported in the a-posteriori error + bound (`approximationCacheErrorBound`). Separately, the first successful + presentation by a non-kv-unified folding strategy supersedes a persisted + `kvunified:presentation-receipt` (loading, previewing, dry-running or a + failed compile leave it untouched), so a switch back to kv-unified starts from an empty chain instead of measuring against a days-old baseline. A kv-stable → kv-unified switch on a production store went from "exceeded ceiling 100000 at 125796" (and >5 GB at a 1M ceiling) to a - 309 ms / 434 MB solve under the same ceiling. (#97) + 332 ms / 436 MB solve under the same ceiling. (#97) diff --git a/src/adaptive/kv-unified-pareto.ts b/src/adaptive/kv-unified-pareto.ts index 65056bc2..5697ec26 100644 --- a/src/adaptive/kv-unified-pareto.ts +++ b/src/adaptive/kv-unified-pareto.ts @@ -32,6 +32,11 @@ interface ParetoLabel { active: boolean; remaining: bigint; renderedTokens: number; + /** Rendered tokens not covered by the accepted presentation. Priced by the + * cache term (avoidable recompute = recomputed - extension), so it is a + * dominance dimension while a provider cache is relevant — never a state-key + * dimension (#97). */ + extensionTokens: number; continuityLoss: number; fidelityLoss: number; cache: CacheState; @@ -46,6 +51,9 @@ interface ApproximationEnvelope { token: number; continuity: number; fidelity: number; + /** Extension tokens a covered label had beyond its representative: the + * cache term may be overstated by this many tokens times the cache price. */ + cache: number; } interface AssignmentTrace { @@ -70,6 +78,8 @@ export interface ParetoPropagationStats { readonly approximationTokenErrorBound: number; readonly approximationContinuityErrorBound: number; readonly approximationFidelityErrorBound: number; + /** Extension tokens the cache term may have been overstated by. */ + readonly approximationCacheErrorBound: number; } export type ParetoPolicySolveResult = ExactPolicySolveResult & { @@ -150,6 +160,7 @@ export class ParetoKvUnifiedPolicySolver { active: true, remaining, renderedTokens: 0, + extensionTokens: 0, continuityLoss: 0, fidelityLoss: 0, cache: { intact: cacheRelevant, matchedUnits: 0, cachedTokens: 0 }, @@ -158,7 +169,7 @@ export class ParetoKvUnifiedPolicySolver { approximation: ZERO_APPROXIMATION, }; if (this.inputs.headTokens > 0) { - initial = this.emit(initial, 'head', 'head', this.inputs.headTokens, options, markerByUnit); + initial = this.emit(initial, 'head', 'head', this.inputs.headTokens, false, options, markerByUnit); } const ceiling = options.labelCeiling ?? 1_000_000; @@ -176,14 +187,14 @@ export class ParetoKvUnifiedPolicySolver { const key = stateKey(label, tokenBucketSize, continuityBucketSize, fidelityBucketSize); const current = states.get(key) ?? []; for (const incumbent of current) { - if (dominates(incumbent, label)) { + if (dominates(incumbent, label, cacheRelevant)) { labelsDominated++; return; } } const survivors: ParetoLabel[] = []; for (const incumbent of current) { - if (dominates(label, incumbent)) { + if (dominates(label, incumbent, cacheRelevant)) { incumbent.active = false; labelsDominated++; } else survivors.push(incumbent); @@ -204,7 +215,7 @@ export class ParetoKvUnifiedPolicySolver { if (label.remaining === 0n) { let finished = label; if (this.inputs.tailTokens > 0) { - finished = this.emit(label, 'tail', 'tail', this.inputs.tailTokens, options, markerByUnit); + finished = this.emit(label, 'tail', 'tail', this.inputs.tailTokens, false, options, markerByUnit); } if (finished.renderedTokens <= options.maxTokens) { terminal.push({ frontier: reconstructFrontier(finished.trace), renderedTokens: finished.renderedTokens }); @@ -216,7 +227,7 @@ export class ParetoKvUnifiedPolicySolver { const leaf = this.leaves[oldest]; if (leaf.allowedLevels.includes(0)) { const next = this.assign(label, [leaf.id], 0, policy, options); - insert(this.emit(next, 'raw', leaf.id, leaf.rawTokens, options, markerByUnit)); + insert(this.emit(next, 'raw', leaf.id, leaf.rawTokens, this.isExtension([leaf.id], options), options, markerByUnit)); } for (const summaryId of leaf.summaryIds) { const summary = this.forest.summary(summaryId)!; @@ -236,7 +247,7 @@ export class ParetoKvUnifiedPolicySolver { } if (overlap || (mask & bit(oldest)) === 0n) continue; const next = this.assign(label, participants, summary.level, policy, options); - insert(this.emit(next, 'recall', summary.id, summary.recallTokens, options, markerByUnit)); + insert(this.emit(next, 'recall', summary.id, summary.recallTokens, this.isExtension(summary.leafIds, options), options, markerByUnit)); } } @@ -275,6 +286,7 @@ export class ParetoKvUnifiedPolicySolver { approximationTokenErrorBound: 0, approximationContinuityErrorBound: 0, approximationFidelityErrorBound: 0, + approximationCacheErrorBound: 0, }, }; } @@ -295,6 +307,7 @@ export class ParetoKvUnifiedPolicySolver { active: true, remaining: 0n, renderedTokens: 0, + extensionTokens: 0, continuityLoss: 0, fidelityLoss: 0, cache: { intact: cacheRelevant, matchedUnits: 0, cachedTokens: 0 }, @@ -303,7 +316,7 @@ export class ParetoKvUnifiedPolicySolver { approximation: ZERO_APPROXIMATION, }; if (this.inputs.headTokens > 0) { - initial = this.emit(initial, 'head', 'head', this.inputs.headTokens, options, markerByUnit); + initial = this.emit(initial, 'head', 'head', this.inputs.headTokens, false, options, markerByUnit); } const ceiling = options.labelCeiling ?? 1_000_000; const tokenBucketSize = Math.max(0, Math.floor(options.tokenBucketSize ?? 0)); @@ -328,7 +341,7 @@ export class ParetoKvUnifiedPolicySolver { for (const pool of groups.values()) { const nondominated = pool.filter( (candidate, index) => !pool.some( - (other, otherIndex) => otherIndex !== index && dominates(other, candidate), + (other, otherIndex) => otherIndex !== index && dominates(other, candidate, cacheRelevant), ), ); const representatives = continuityBucketSize > 0 && fidelityBucketSize > 0 @@ -340,9 +353,14 @@ export class ParetoKvUnifiedPolicySolver { [...nondominated].sort((a, b) => a.renderedTokens - b.renderedTokens || representativeOrder(a, b), )[0], + ...(cacheRelevant + ? [[...nondominated].sort((a, b) => + b.extensionTokens - a.extensionTokens || representativeOrder(a, b), + )[0]] + : []), ]) : nondominated; - const covered = coverApproximationPool(pool, representatives); + const covered = coverApproximationPool(pool, representatives, cacheRelevant); labelsDominated += pool.length - covered.length; result.push(...covered); } @@ -378,6 +396,7 @@ export class ParetoKvUnifiedPolicySolver { 'recall', summary.id, summary.recallTokens, + this.isExtension(summary.leafIds, options), options, markerByUnit, ); @@ -429,6 +448,7 @@ export class ParetoKvUnifiedPolicySolver { 'raw', leaf.id, leaf.rawTokens, + this.isExtension([leaf.id], options), options, markerByUnit, )); @@ -450,6 +470,7 @@ export class ParetoKvUnifiedPolicySolver { 'tail', 'tail', this.inputs.tailTokens, + false, options, markerByUnit, ); @@ -491,6 +512,7 @@ export class ParetoKvUnifiedPolicySolver { approximationTokenErrorBound: approximation.token, approximationContinuityErrorBound: approximation.continuity, approximationFidelityErrorBound: approximation.fidelity, + approximationCacheErrorBound: approximation.cache, }, }; } @@ -549,7 +571,7 @@ export class ParetoKvUnifiedPolicySolver { return ( fidelityError + budgetSlope * tokenError + - cacheSlope * tokenError * cachePrice + + cacheSlope * (tokenError + approximation.cache) * cachePrice + rho * continuitySlope * continuityError + hysteresis ); @@ -625,8 +647,9 @@ export class ParetoKvUnifiedPolicySolver { }; } - private emit(label: ParetoLabel, kind: 'head' | 'raw' | 'recall' | 'tail', key: string, tokens: number, options: ExactPolicySolveOptions, markerByUnit: ReadonlyMap): ParetoLabel { + private emit(label: ParetoLabel, kind: 'head' | 'raw' | 'recall' | 'tail', key: string, tokens: number, extension: boolean, options: ExactPolicySolveOptions, markerByUnit: ReadonlyMap): ParetoLabel { const renderedTokens = label.renderedTokens + tokens; + const extensionTokens = label.extensionTokens + (extension ? tokens : 0); if ( this.bufferGapEmissions && label.cache.intact && @@ -639,6 +662,7 @@ export class ParetoKvUnifiedPolicySolver { ...label, active: true, renderedTokens, + extensionTokens, pendingEmissions: [...label.pendingEmissions, { kind, key, tokens, sequence }], }; } @@ -651,7 +675,7 @@ export class ParetoKvUnifiedPolicySolver { if (marker !== undefined) cache.cachedTokens = marker; } else cache.intact = false; } - return { ...label, active: true, renderedTokens, cache }; + return { ...label, active: true, renderedTokens, extensionTokens, cache }; } private flushPendingEmissions( @@ -688,6 +712,10 @@ export class ParetoKvUnifiedPolicySolver { }; } + private isExtension(ids: readonly ChunkId[], options: ExactPolicySolveOptions): boolean { + return options.presentation !== undefined && ids.length > 0 && ids.every((id) => !options.presentation!.leaves.has(id)); + } + private assignRawRun( label: ParetoLabel, ids: readonly ChunkId[], @@ -734,6 +762,7 @@ export class ParetoKvUnifiedPolicySolver { 'raw', id, leaf.rawTokens, + this.isExtension([id], options), options, markerByUnit, ); @@ -741,11 +770,17 @@ export class ParetoKvUnifiedPolicySolver { return next; } let tokens = 0; - for (const id of ids) tokens += this.forest.leaf(id)!.rawTokens; + let extensionTokens = 0; + for (const id of ids) { + const leafTokens = this.forest.leaf(id)!.rawTokens; + tokens += leafTokens; + if (this.isExtension([id], options)) extensionTokens += leafTokens; + } return { ...label, active: true, renderedTokens: label.renderedTokens + tokens, + extensionTokens: label.extensionTokens + extensionTokens, }; } @@ -760,14 +795,12 @@ function stateKey( const tokenKey = tokenBucketSize > 0 ? Math.ceil(label.renderedTokens / tokenBucketSize) : label.renderedTokens; - // Only quantities that dominance or pricing consult belong in the key. - // Extension tokens (rendered tokens not covered by the accepted - // presentation) used to be keyed here exactly although nothing read them: - // the cache-churn term recomputes extension from the rendered layout, and - // dominates() never looked at them. A presentation receipt covering half of - // the live leaves (a kv-stable -> kv-unified switch) then multiplied the - // live label set by the number of distinct extension sums, superlinear in - // forest size, and exhausted the ceiling (#97). + // Extension tokens are deliberately NOT a key dimension. They are priced + // (cache term), so they are a dominance dimension while a cache is relevant + // and a cover-envelope term when bucketed; keying on their exact value + // multiplied the live label set by the number of distinct extension sums — + // superlinear in forest size once a stale receipt covered half the leaves + // (#97). return [ label.remaining.toString(16), tokenKey, @@ -786,6 +819,7 @@ const ZERO_APPROXIMATION: ApproximationEnvelope = Object.freeze({ token: 0, continuity: 0, fidelity: 0, + cache: 0, }); /** Attach every discarded path to one retained representative. The @@ -796,6 +830,7 @@ const ZERO_APPROXIMATION: ApproximationEnvelope = Object.freeze({ function coverApproximationPool( pool: readonly ParetoLabel[], representatives: readonly ParetoLabel[], + cacheRelevant: boolean, ): ParetoLabel[] { const covered = representatives.map((label) => ({ ...label, @@ -809,7 +844,8 @@ function coverApproximationPool( const cost = Math.abs(candidate.renderedTokens - source.renderedTokens) + Math.max(0, candidate.continuityLoss - source.continuityLoss) + - Math.max(0, candidate.fidelityLoss - source.fidelityLoss); + Math.max(0, candidate.fidelityLoss - source.fidelityLoss) + + (cacheRelevant ? Math.max(0, source.extensionTokens - candidate.extensionTokens) : 0); if (cost < chosenCost) { chosen = i; chosenCost = cost; @@ -828,28 +864,40 @@ function coverApproximationPool( target.approximation.fidelity, source.approximation.fidelity + Math.max(0, target.fidelityLoss - source.fidelityLoss), ); + if (cacheRelevant) { + target.approximation.cache = Math.max( + target.approximation.cache, + source.approximation.cache + Math.max(0, source.extensionTokens - target.extensionTokens), + ); + } } return covered; } function maxApproximation(labels: readonly ParetoLabel[]): ApproximationEnvelope { - const result = { token: 0, continuity: 0, fidelity: 0 }; + const result = { token: 0, continuity: 0, fidelity: 0, cache: 0 }; for (const label of labels) { result.token = Math.max(result.token, label.approximation.token); result.continuity = Math.max(result.continuity, label.approximation.continuity); result.fidelity = Math.max(result.fidelity, label.approximation.fidelity); + result.cache = Math.max(result.cache, label.approximation.cache); } return result; } -function dominates(a: ParetoLabel, b: ParetoLabel): boolean { +function dominates(a: ParetoLabel, b: ParetoLabel, cacheRelevant: boolean): boolean { + // Extension is priced only through the cache term (avoidable recompute = + // recomputed - extension), so more extension is better exactly when a + // provider cache is relevant. Without one it is not a dimension at all. + if (cacheRelevant && a.extensionTokens < b.extensionTokens) return false; return ( a.renderedTokens <= b.renderedTokens && a.continuityLoss <= b.continuityLoss && a.fidelityLoss <= b.fidelityLoss && (a.renderedTokens < b.renderedTokens || a.continuityLoss < b.continuityLoss || - a.fidelityLoss < b.fidelityLoss) + a.fidelityLoss < b.fidelityLoss || + (cacheRelevant && a.extensionTokens > b.extensionTokens)) ); } diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index f0250c97..69e4f925 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -7799,14 +7799,6 @@ export class AutobiographicalStrategy implements ResettableStrategy { if (resolutionsChanged && !dryRun) { this.persistResolutions(); } - // A real presentation by a non-kv-unified strategy: the persisted - // kv-unified receipt no longer describes the previous turn (#97). This is - // independent of whether any resolution changed — a no-op compile is - // still a presentation. - if (!dryRun && this.kvUnifiedReceiptSupersedePending && this.config.foldingStrategy !== 'kv-unified') { - this.supersedeKvUnifiedReceipt(); - } - // Wire produce ops into the strategy's own production queues so that // requested-but-not-yet-existing summaries actually get built. The // speculative pre-producer covers most cases ambiently, but when it is @@ -8237,6 +8229,15 @@ export class AutobiographicalStrategy implements ResettableStrategy { rs.head.tokens + rs.tail.tokens + rs.middleRaw.tokens + rs.summaries.l1.tokens + rs.summaries.l2.tokens + rs.summaries.l3.tokens; this._calibrationArmed = true; // exactly one sample per compile + // A real presentation by a non-kv-unified strategy has now succeeded (the + // hard-budget check, emission and structural repair above all throw on + // failure), so the persisted kv-unified receipt no longer describes the + // previous turn (#97). Independent of whether any resolution changed — a + // no-op compile is still a presentation. A failed compile keeps the + // receipt: nothing replaced it. + if (!dryRun && this.kvUnifiedReceiptSupersedePending && this.config.foldingStrategy !== 'kv-unified') { + this.supersedeKvUnifiedReceipt(); + } return merged; } diff --git a/test/adaptive/kv-unified-policy.test.ts b/test/adaptive/kv-unified-policy.test.ts index 6a8b04c0..2e056dec 100644 --- a/test/adaptive/kv-unified-policy.test.ts +++ b/test/adaptive/kv-unified-policy.test.ts @@ -729,3 +729,149 @@ test('kv-unified label count stays bounded under a stale half-covering presentat assert.ok(fullLabels <= freshLabels, `fully stale ${fullLabels} vs fresh ${freshLabels}`); assert.ok(halfStale.selected.renderedTokens <= options.maxTokens); }); + +test('kv-unified keeps extension as a priced dominance dimension under a relevant cache (#98 review)', () => { + // Three chronological chunks a, b, c (100 raw tokens, 20-token L1 each). + // The accepted presentation and the relevant cache cover raw a and b with a + // marker after b; c is extension. a is constrained to L1, so every cut + // diverges from the cached layout at unit 0 and the whole render is + // recomputed; the cache term then differs between cuts only through how + // much of the recompute is unavoidable extension. [1,0,1] beats [1,1,0] on + // fidelity and continuity but folds the new chunk (20 extension tokens vs + // 100), so its cache churn is higher. Dominance that ignores extension + // prunes [1,1,0] and the solver returns [1,1,1] with a reported zero error. + const build = () => { + const chronicle = new MockChronicle({ recallPairTokens: 20 }); + const a = chronicle.addChunk({ id: 'a', rawTokens: 100 }); + const b = chronicle.addChunk({ id: 'b', rawTokens: 100 }); + const c = chronicle.addChunk({ id: 'c', rawTokens: 100 }); + a.salience = 0.2; b.salience = 0.4; c.salience = 0.2; + a.pinLevel = 1; + chronicle.produceL1(['a']); + chronicle.produceL1(['b']); + chronicle.produceL1(['c']); + const inputs: PickerInputs = { + chunks: chronicle.chunks, + summaries: chronicle.summaries, + recallPairTokens: chronicle.recallPairTokens, + headTokens: 0, + tailTokens: 0, + headChunkIds: new Set(), + tailChunkIds: new Set(), + }; + return inputs; + }; + const presentation: AcceptedPresentationReference = { + currentSeq: 1, + leaves: new Map([ + ['a', { repHash: 'raw:a', level: 0, lastChangedSeq: 0 }], + ['b', { repHash: 'raw:b', level: 0, lastChangedSeq: 0 }], + ]), + }; + const cache = { + immutablePrefixHash: 'tools-v1', + layout: { + units: [ + { kind: 'raw' as const, key: 'a', tokens: 100, offset: 0 }, + { kind: 'raw' as const, key: 'b', tokens: 100, offset: 100 }, + ], + totalTokens: 200, + }, + markers: [{ unitIndex: 2, offset: 200 }], + }; + const base = { + maxTokens: 140, + presentation, + cache, + currentImmutablePrefixHash: 'tools-v1', + policy: { + alpha: 0, + budgetUnderLambda: 0, + budgetOverLambda: 0, + continuityLambda: 0, + cacheLambda: 10_000, + cacheScale: 100, + }, + } as const; + const signature = (candidate: ExactPolicyCandidate): string => + ['a', 'b', 'c'].map((id) => `${id}:${candidate.frontier.get(id) ?? 0}`).join('|'); + const oracle = new ExactKvUnifiedPolicySolver(build()).solve({ ...base, candidateSource: 'recursive' }); + assert.equal(oracle.feasible, true); + if (!oracle.feasible) return; + assert.equal(signature(oracle.selected), 'a:1|b:1|c:0'); + for (const buckets of [ + { tokenBucketSize: 100, continuityBucketSize: 100, fidelityBucketSize: 100 }, + { tokenBucketSize: 0, continuityBucketSize: 0, fidelityBucketSize: 0 }, + ]) { + const result = new ParetoKvUnifiedPolicySolver(build()).solve({ ...base, ...buckets }); + assert.equal(result.feasible, true); + if (!result.feasible) return; + assert.equal( + signature(result.selected), + signature(oracle.selected), + `buckets ${buckets.tokenBucketSize}: selected ${signature(result.selected)}`, + ); + assert.equal(result.selected.score, oracle.selected.score); + assert.ok( + result.selected.score - oracle.selected.score <= + (result.propagation?.approximationScoreErrorBound ?? -1) + 1e-9, + ); + } +}); + +test('kv-unified label count stays bounded under a stale presentation with a relevant cache (#97)', () => { + // Same forest as the no-cache growth test, but the stale receipt also + // carries a relevant provider cache, so extension is priced and is a + // dominance dimension. The count must still be bounded (representatives + // per bucket group cap it) and the reported error bound must be honest. + const chronicle = buildChronicleWithChain({ + chunkCount: 24, + tokensPerChunk: 100, + mergeThreshold: 2, + recallPairTokens: 40, + }); + const inputs: PickerInputs = { + chunks: chronicle.chunks.map((chunk, index) => ({ ...chunk, rawTokens: 60 + ((index * 29) % 70) })), + summaries: chronicle.summaries, + recallPairTokens: new Map( + [...chronicle.recallPairTokens].map(([id, tokens], index) => [id, tokens + ((index * 11) % 30)]), + ), + headTokens: 0, + tailTokens: 0, + headChunkIds: new Set(), + tailChunkIds: new Set(), + }; + const ordered = [...inputs.chunks].sort((a, b) => a.sequence - b.sequence); + const older = ordered.slice(0, ordered.length / 2); + const presentation: AcceptedPresentationReference = { + currentSeq: 1, + leaves: new Map(older.map((chunk) => [chunk.id, { repHash: `raw:${chunk.id}`, level: 0, lastChangedSeq: 0 }])), + }; + let offset = 0; + const units = older.map((chunk) => { + const unit = { kind: 'raw' as const, key: chunk.id, tokens: chunk.rawTokens, offset }; + offset += chunk.rawTokens; + return unit; + }); + const rawTotal = inputs.chunks.reduce((sum, chunk) => sum + chunk.rawTokens, 0); + const options = { + maxTokens: Math.floor(rawTotal * 0.75), + tokenBucketSize: 100, + continuityBucketSize: 100, + fidelityBucketSize: 100, + labelCeiling: 200_000, + presentation, + cache: { immutablePrefixHash: 'tools-v1', layout: { units, totalTokens: offset }, markers: [{ unitIndex: units.length, offset }] }, + } as const; + const noCache = new ParetoKvUnifiedPolicySolver(inputs).solve(options); + const withCache = new ParetoKvUnifiedPolicySolver(inputs).solve({ ...options, currentImmutablePrefixHash: 'tools-v1' }); + assert.equal(noCache.feasible, true); + assert.equal(withCache.feasible, true); + if (!noCache.feasible || !withCache.feasible) return; + const noCacheLabels = noCache.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; + const withCacheLabels = withCache.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; + assert.ok(withCacheLabels <= noCacheLabels * 3, `with cache ${withCacheLabels} vs without ${noCacheLabels}`); + assert.equal(withCache.cacheRelevant, true); + assert.ok((withCache.propagation?.approximationCacheErrorBound ?? -1) >= 0); + assert.ok((withCache.propagation?.approximationScoreErrorBound ?? -1) >= 0); +}); diff --git a/test/kv-unified-strategy-integration.test.ts b/test/kv-unified-strategy-integration.test.ts index 25afefd4..3f2e1bf2 100644 --- a/test/kv-unified-strategy-integration.test.ts +++ b/test/kv-unified-strategy-integration.test.ts @@ -1,7 +1,7 @@ import { afterEach, test } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, rmSync } from 'node:fs'; -import { ContextManager, AutobiographicalStrategy } from '../src/index.js'; +import { ContextManager, AutobiographicalStrategy, OverBudgetError } from '../src/index.js'; import type { ContextEntry } from '../src/types/index.js'; import type { KvUnifiedReceiptChain } from '../src/adaptive/kv-unified-receipts.js'; @@ -328,3 +328,54 @@ test('a non-kv-unified folding strategy supersedes a persisted kv-unified receip console.warn = originalWarn; } }); + +test('a failed compile by a non-kv-unified strategy keeps the persisted kv-unified receipt (#98 review)', async () => { + const first = strategy(); + const manager = await ContextManager.open({ path: STORE, strategy: first }); + manager.addMessage('user', [{ type: 'text', text: 'presented by kv-unified' }]); + await manager.compile( + { maxTokens: 10_000, reserveForResponse: 0 }, + undefined, + { kvUnifiedImmutablePrefixHash: 'immutable-v1' }, + ); + first.beginKvUnifiedSubmission({ submissionId: 's1', requestHash: 'wire1', layoutHash: 'layout1' }); + const markerCount = ( + first as unknown as { kvUnifiedPendingMarkerUnitIndices: number[] } + ).kvUnifiedPendingMarkerUnitIndices.length; + first.reportKvUnifiedAccepted({ + submissionId: 's1', + acceptedAt: 123, + wireReceipt: { + requestHash: 'wire1', + markers: Array.from({ length: markerCount }, (_, ordinal) => ({ + ordinal, + prefixHash: `prefix-${ordinal}`, + estimatedOffset: ordinal + 1, + })), + }, + }); + assert.equal(receipts(first).head?.sequence, 1); + manager.close(); + + const stable = new AutobiographicalStrategy({ + adaptiveResolution: true, + foldingStrategy: 'kv-stable', + headWindowTokens: 0, + recentWindowTokens: 100, + }); + const viaStable = await ContextManager.open({ path: STORE, strategy: stable }); + for (let i = 0; i < 4; i++) { + viaStable.addMessage('user', [{ type: 'text', text: `unsummarized filler ${i} ${'x'.repeat(1200)}` }]); + } + await assert.rejects( + viaStable.compile({ maxTokens: 300, reserveForResponse: 0 }), + (error: unknown) => error instanceof OverBudgetError, + 'an over-budget compile must fail, not present', + ); + viaStable.close(); + + const back = strategy(); + const reopened = await ContextManager.open({ path: STORE, strategy: back }); + assert.equal(receipts(back).head?.sequence, 1, 'no presentation replaced the receipt, so it survives'); + reopened.close(); +}); From bd94014b50bb934d9137280b4912b529510215de Mon Sep 17 00:00:00 2001 From: Anarchid Date: Mon, 21 Sep 2026 12:48:47 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(kv-unified):=20review=20round=205=20?= =?UTF-8?q?=E2=80=94=20discriminating=20growth=20test,=20retry-safe=20supe?= =?UTF-8?q?rsede,=20scoped=20claims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The relevant-cache label-growth test compared with-cache against no-cache at the same staleness, a ratio that was already 1.5 before the fix. It now compares half-stale against fresh with the cache relevant in both (fails on main's solver: 91,356 vs 29,620) and checks the reported score bound against an all-zero-bucket solve of the same forest. - supersedeKvUnifiedReceipt() cleared its pending flag before the write, so a throwing write was never retried. Flag now clears after the write; regression test injects one write failure and asserts the next compile supersedes. - Integration test now runs a real dryRun compile in the "must not supersede" block instead of only opening and closing a manager. - Changelog and comments scoped: the benchmark number was measured with no relevant provider cache (#105 dominates otherwise); only a dryRun compile is a non-presenting preview; adaptiveResolution:false never supersedes; the leaf engine under a relevant cache is not covered (#107). - Stale round-2 claim removed from the no-cache growth test's comment. Co-Authored-By: Claude Fable 5.1 --- .../fix-kv-unified-stale-receipt.fixed.md | 31 +++--- src/strategies/autobiographical.ts | 16 ++-- test/adaptive/kv-unified-policy.test.ts | 96 +++++++++++++------ test/kv-unified-strategy-integration.test.ts | 82 +++++++++++++++- 4 files changed, 173 insertions(+), 52 deletions(-) diff --git a/changelog.d/fix-kv-unified-stale-receipt.fixed.md b/changelog.d/fix-kv-unified-stale-receipt.fixed.md index 91cda7f0..af893183 100644 --- a/changelog.d/fix-kv-unified-stale-receipt.fixed.md +++ b/changelog.d/fix-kv-unified-stale-receipt.fixed.md @@ -1,13 +1,20 @@ - kv-unified: a stale presentation receipt no longer makes the Pareto label - count superlinear. Extension tokens (rendered tokens not covered by the - accepted presentation) are no longer a state-key dimension; they are a - dominance dimension while a provider cache is relevant (the cache term - prices them) and a cover-envelope term reported in the a-posteriori error - bound (`approximationCacheErrorBound`). Separately, the first successful - presentation by a non-kv-unified folding strategy supersedes a persisted - `kvunified:presentation-receipt` (loading, previewing, dry-running or a - failed compile leave it untouched), so a switch back to kv-unified starts - from an empty chain instead of measuring against a days-old baseline. A - kv-stable → kv-unified switch on a production store went from - "exceeded ceiling 100000 at 125796" (and >5 GB at a 1M ceiling) to a - 332 ms / 436 MB solve under the same ceiling. (#97) + count superlinear in the DAG engine. Extension tokens (rendered tokens not + covered by the accepted presentation) are no longer a state-key dimension; + they are a dominance dimension while a provider cache is relevant (the + cache term prices them) and a cover-envelope term reported in the + a-posteriori error bound (`approximationCacheErrorBound`). The leaf engine + (auto-selected when a pin or lock leaves a protected hole inside a summary) + is fixed only while no provider cache is relevant; with one it has no + representative cap and is barely improved (#107). Separately, the first + successful non-dry-run adaptive presentation by a non-kv-unified folding + strategy supersedes a persisted `kvunified:presentation-receipt`, so a + switch back to kv-unified starts from an empty chain instead of measuring + against a days-old baseline. Loading, a `dryRun` compile and a failed + compile leave the receipt untouched; a host preview that runs a + non-dry-run compile counts as a presentation, and + `adaptiveResolution: false` never supersedes. A kv-stable → kv-unified + switch on a production store went from "exceeded ceiling 100000 at 125796" + (and >5 GB at a 1M ceiling) to a 332 ms / 436 MB solve under the same + ceiling, measured with no relevant provider cache; with one, the same + solve is dominated by #105, which this change does not address. (#97) diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index 3cd859b7..89fa0c7d 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -2071,11 +2071,14 @@ export class AutobiographicalStrategy implements ResettableStrategy { // the receipt no longer describes the previous turn, and a later switch // back would measure continuity against a days-old baseline and treat // everything folded since as extension (#97). Loading is not - // presenting: an inspection tool, a preview, or a dry run that opens the - // store with a different strategy must leave the receipt alone. Only - // note here that a receipt exists; the first non-dry-run select by a - // non-kv-unified strategy (selectAdaptive, after the resolutions - // commit) supersedes it. + // presenting: an inspection tool that opens the store with a different + // strategy, or a compile with `dryRun: true`, must leave the receipt + // alone. Only note here that a receipt exists; the first non-dry-run + // ADAPTIVE select by a non-kv-unified strategy (selectAdaptive, after + // the resolutions commit) supersedes it. A caller that previews through + // a non-dry-run compile is presenting as far as this class can tell, + // and `adaptiveResolution: false` (selectHierarchical writes nothing by + // design) never supersedes. this.kvUnifiedReceiptSupersedePending = this.store.listStates().some((state) => state.id === this.kvUnifiedReceiptStateId) && this.store.getStateJson(this.kvUnifiedReceiptStateId) != null; @@ -2113,8 +2116,9 @@ export class AutobiographicalStrategy implements ResettableStrategy { protected supersedeKvUnifiedReceipt(): void { if (!this.store) return; this.requireBranchMutation('supersedeKvUnifiedReceipt'); - this.kvUnifiedReceiptSupersedePending = false; this.store.setStateJson(this.kvUnifiedReceiptStateId, null); + // Cleared only after the write: if it throws, a later compile retries. + this.kvUnifiedReceiptSupersedePending = false; console.warn( `[autobiographical] superseded a persisted kv-unified presentation receipt: ` + `${String(this.config.foldingStrategy ?? 'default')} has presented from this store; ` + diff --git a/test/adaptive/kv-unified-policy.test.ts b/test/adaptive/kv-unified-policy.test.ts index 2e056dec..bf2546ec 100644 --- a/test/adaptive/kv-unified-policy.test.ts +++ b/test/adaptive/kv-unified-policy.test.ts @@ -665,9 +665,10 @@ test('kv-unified label count stays bounded under a stale half-covering presentat // A presentation receipt that covers only the older half of the live leaves // (a kv-stable -> kv-unified switch, a restore, a history import) must not // multiply the Pareto label set. Extension tokens are no longer part of the - // label state at all: nothing in dominance or pricing reads them from the - // label (the cache-churn term recomputes extension from the rendered - // layout), so keying on them only split labels that should have collapsed. + // state key. No provider cache is relevant here, so nothing prices them and + // they are inert in dominance too; keying on them only split labels that + // should have collapsed. (The relevant-cache case, where extension is a + // priced dominance dimension, is covered by the two tests below.) const chronicle = buildChronicleWithChain({ chunkCount: 24, tokensPerChunk: 100, @@ -820,10 +821,14 @@ test('kv-unified keeps extension as a priced dominance dimension under a relevan }); test('kv-unified label count stays bounded under a stale presentation with a relevant cache (#97)', () => { - // Same forest as the no-cache growth test, but the stale receipt also - // carries a relevant provider cache, so extension is priced and is a - // dominance dimension. The count must still be bounded (representatives - // per bucket group cap it) and the reported error bound must be honest. + // Same forest as the no-cache growth test, but every receipt also carries a + // relevant provider cache, so extension is priced and is a dominance + // dimension. The comparison is half-stale against FRESH with the cache + // relevant in both: comparing against a no-cache solve at the same + // staleness does not discriminate (that ratio was already 1.5 before the + // fix). The count must stay bounded (representatives per bucket group cap + // it) and the reported error bound must cover the regret against an + // unbucketed solve of the same forest. const chronicle = buildChronicleWithChain({ chunkCount: 24, tokensPerChunk: 100, @@ -842,17 +847,28 @@ test('kv-unified label count stays bounded under a stale presentation with a rel tailChunkIds: new Set(), }; const ordered = [...inputs.chunks].sort((a, b) => a.sequence - b.sequence); - const older = ordered.slice(0, ordered.length / 2); - const presentation: AcceptedPresentationReference = { - currentSeq: 1, - leaves: new Map(older.map((chunk) => [chunk.id, { repHash: `raw:${chunk.id}`, level: 0, lastChangedSeq: 0 }])), + const receiptCovering = (count: number) => { + const covered = ordered.slice(0, count); + const presentation: AcceptedPresentationReference = { + currentSeq: 1, + leaves: new Map(covered.map((chunk) => [chunk.id, { repHash: `raw:${chunk.id}`, level: 0, lastChangedSeq: 0 }])), + }; + let offset = 0; + const units = covered.map((chunk) => { + const unit = { kind: 'raw' as const, key: chunk.id, tokens: chunk.rawTokens, offset }; + offset += chunk.rawTokens; + return unit; + }); + return { + presentation, + cache: { + immutablePrefixHash: 'tools-v1', + layout: { units, totalTokens: offset }, + markers: [{ unitIndex: units.length, offset }], + }, + currentImmutablePrefixHash: 'tools-v1', + }; }; - let offset = 0; - const units = older.map((chunk) => { - const unit = { kind: 'raw' as const, key: chunk.id, tokens: chunk.rawTokens, offset }; - offset += chunk.rawTokens; - return unit; - }); const rawTotal = inputs.chunks.reduce((sum, chunk) => sum + chunk.rawTokens, 0); const options = { maxTokens: Math.floor(rawTotal * 0.75), @@ -860,18 +876,38 @@ test('kv-unified label count stays bounded under a stale presentation with a rel continuityBucketSize: 100, fidelityBucketSize: 100, labelCeiling: 200_000, - presentation, - cache: { immutablePrefixHash: 'tools-v1', layout: { units, totalTokens: offset }, markers: [{ unitIndex: units.length, offset }] }, } as const; - const noCache = new ParetoKvUnifiedPolicySolver(inputs).solve(options); - const withCache = new ParetoKvUnifiedPolicySolver(inputs).solve({ ...options, currentImmutablePrefixHash: 'tools-v1' }); - assert.equal(noCache.feasible, true); - assert.equal(withCache.feasible, true); - if (!noCache.feasible || !withCache.feasible) return; - const noCacheLabels = noCache.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; - const withCacheLabels = withCache.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; - assert.ok(withCacheLabels <= noCacheLabels * 3, `with cache ${withCacheLabels} vs without ${noCacheLabels}`); - assert.equal(withCache.cacheRelevant, true); - assert.ok((withCache.propagation?.approximationCacheErrorBound ?? -1) >= 0); - assert.ok((withCache.propagation?.approximationScoreErrorBound ?? -1) >= 0); + const fresh = new ParetoKvUnifiedPolicySolver(inputs).solve({ ...options, ...receiptCovering(ordered.length) }); + const halfStale = new ParetoKvUnifiedPolicySolver(inputs).solve({ ...options, ...receiptCovering(ordered.length / 2) }); + assert.equal(fresh.feasible, true); + assert.equal(halfStale.feasible, true); + if (!fresh.feasible || !halfStale.feasible) return; + assert.equal(fresh.cacheRelevant, true); + assert.equal(halfStale.cacheRelevant, true); + const freshLabels = fresh.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; + const halfLabels = halfStale.propagation?.labelsCreated ?? Number.POSITIVE_INFINITY; + // Before the fix: fresh 29,620 / half-stale 91,356 (3.1x, and growing with + // the forest). After: 1.44x, flat. + assert.ok(halfLabels <= freshLabels * 2, `half-stale ${halfLabels} vs fresh ${freshLabels}`); + + // Honesty of the reported bound: an all-zero-bucket solve of the same + // half-stale forest is exact, so the bucketed selection's regret against it + // must sit inside the a-posteriori score bound (which includes the cache + // envelope component). + const exact = new ParetoKvUnifiedPolicySolver(inputs).solve({ + ...options, + ...receiptCovering(ordered.length / 2), + tokenBucketSize: 0, + continuityBucketSize: 0, + fidelityBucketSize: 0, + labelCeiling: 2_000_000, + }); + assert.equal(exact.feasible, true); + if (!exact.feasible) return; + const cacheBound = halfStale.propagation?.approximationCacheErrorBound; + const scoreBound = halfStale.propagation?.approximationScoreErrorBound; + assert.ok(cacheBound !== undefined && cacheBound >= 0, `cache bound ${cacheBound}`); + assert.ok(scoreBound !== undefined && scoreBound >= 0, `score bound ${scoreBound}`); + const regret = halfStale.selected.score - exact.selected.score; + assert.ok(regret <= (scoreBound ?? -1) + 1e-9, `regret ${regret} vs bound ${scoreBound}`); }); diff --git a/test/kv-unified-strategy-integration.test.ts b/test/kv-unified-strategy-integration.test.ts index 3f2e1bf2..de7b1960 100644 --- a/test/kv-unified-strategy-integration.test.ts +++ b/test/kv-unified-strategy-integration.test.ts @@ -299,14 +299,16 @@ test('a non-kv-unified folding strategy supersedes a persisted kv-unified receip else originalWarn(...args); }; try { - // Opening (inspection, preview, dry run) is not presenting: the receipt - // must survive a load by another strategy untouched. + // Opening is not presenting, and neither is a `dryRun` compile: the + // receipt must survive both untouched. (A preview that goes through a + // NON-dry-run compile is a presentation as far as the strategy can tell.) const inspector = await ContextManager.open({ path: STORE, strategy: stableStrategy() }); + await inspector.compile({ maxTokens: 10_000, reserveForResponse: 0 }, undefined, { dryRun: true }); inspector.close(); - assert.equal(warnings.length, 0, 'a load must not supersede'); + assert.equal(warnings.length, 0, 'a load or a dry-run compile must not supersede'); const inspected = strategy(); const stillThere = await ContextManager.open({ path: STORE, strategy: inspected }); - assert.equal(receipts(inspected).head?.sequence, 1, 'receipt intact after a kv-stable load'); + assert.equal(receipts(inspected).head?.sequence, 1, 'receipt intact after a kv-stable load and dry run'); stillThere.close(); // Presenting is: the first kv-stable compile supersedes it, exactly once. @@ -379,3 +381,75 @@ test('a failed compile by a non-kv-unified strategy keeps the persisted kv-unifi assert.equal(receipts(back).head?.sequence, 1, 'no presentation replaced the receipt, so it survives'); reopened.close(); }); + +test('a failed supersede write is retried by the next presentation (#98 review)', async () => { + const first = strategy(); + const manager = await ContextManager.open({ path: STORE, strategy: first }); + manager.addMessage('user', [{ type: 'text', text: 'presented by kv-unified' }]); + await manager.compile( + { maxTokens: 10_000, reserveForResponse: 0 }, + undefined, + { kvUnifiedImmutablePrefixHash: 'immutable-v1' }, + ); + first.beginKvUnifiedSubmission({ submissionId: 's1', requestHash: 'wire1', layoutHash: 'layout1' }); + const markerCount = ( + first as unknown as { kvUnifiedPendingMarkerUnitIndices: number[] } + ).kvUnifiedPendingMarkerUnitIndices.length; + first.reportKvUnifiedAccepted({ + submissionId: 's1', + acceptedAt: 123, + wireReceipt: { + requestHash: 'wire1', + markers: Array.from({ length: markerCount }, (_, ordinal) => ({ + ordinal, + prefixHash: `prefix-${ordinal}`, + estimatedOffset: ordinal + 1, + })), + }, + }); + assert.equal(receipts(first).head?.sequence, 1); + manager.close(); + + const stable = new AutobiographicalStrategy({ + adaptiveResolution: true, + foldingStrategy: 'kv-stable', + headWindowTokens: 0, + recentWindowTokens: 100, + }); + const viaStable = await ContextManager.open({ path: STORE, strategy: stable }); + const store = (stable as unknown as { store: { setStateJson(id: string, value: unknown): void } }).store; + const originalSet = store.setStateJson.bind(store); + let failures = 0; + store.setStateJson = (id: string, value: unknown) => { + if (id.endsWith('/kvunified:presentation-receipt') && failures === 0) { + failures++; + throw new Error('injected receipt write failure'); + } + originalSet(id, value); + }; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + if (!args.map(String).join(' ').includes('superseded a persisted kv-unified presentation receipt')) { + originalWarn(...args); + } + }; + try { + viaStable.addMessage('user', [{ type: 'text', text: 'presented by kv-stable' }]); + await assert.rejects( + viaStable.compile({ maxTokens: 10_000, reserveForResponse: 0 }), + /injected receipt write failure/, + ); + // The flag must still be raised: the same instance retries and succeeds. + await viaStable.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + assert.equal(failures, 1); + } finally { + console.warn = originalWarn; + store.setStateJson = originalSet; + } + viaStable.close(); + + const back = strategy(); + const reopened = await ContextManager.open({ path: STORE, strategy: back }); + assert.equal(receipts(back).head, null, 'the retried supersede emptied the chain'); + reopened.close(); +});