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..af893183 --- /dev/null +++ b/changelog.d/fix-kv-unified-stale-receipt.fixed.md @@ -0,0 +1,20 @@ +- kv-unified: a stale presentation receipt no longer makes the Pareto label + 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/adaptive/kv-unified-pareto.ts b/src/adaptive/kv-unified-pareto.ts index 19c24135..5697ec26 100644 --- a/src/adaptive/kv-unified-pareto.ts +++ b/src/adaptive/kv-unified-pareto.ts @@ -32,6 +32,10 @@ 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; @@ -47,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 { @@ -71,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 & { @@ -178,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); @@ -277,6 +286,7 @@ export class ParetoKvUnifiedPolicySolver { approximationTokenErrorBound: 0, approximationContinuityErrorBound: 0, approximationFidelityErrorBound: 0, + approximationCacheErrorBound: 0, }, }; } @@ -331,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 @@ -343,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); } @@ -497,6 +512,7 @@ export class ParetoKvUnifiedPolicySolver { approximationTokenErrorBound: approximation.token, approximationContinuityErrorBound: approximation.continuity, approximationFidelityErrorBound: approximation.fidelity, + approximationCacheErrorBound: approximation.cache, }, }; } @@ -555,7 +571,7 @@ export class ParetoKvUnifiedPolicySolver { return ( fidelityError + budgetSlope * tokenError + - cacheSlope * tokenError * cachePrice + + cacheSlope * (tokenError + approximation.cache) * cachePrice + rho * continuitySlope * continuityError + hysteresis ); @@ -696,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[], @@ -764,9 +784,6 @@ 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)); - } } function stateKey( @@ -778,10 +795,15 @@ function stateKey( const tokenKey = tokenBucketSize > 0 ? Math.ceil(label.renderedTokens / tokenBucketSize) : label.renderedTokens; + // 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, - label.extensionTokens, label.cache.intact ? 1 : 0, label.cache.matchedUnits, label.cache.cachedTokens, @@ -797,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 @@ -807,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, @@ -820,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; @@ -839,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 1951b31d..89fa0c7d 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -1081,6 +1081,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; @@ -2062,6 +2065,24 @@ 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 treat + // everything folded since as extension (#97). Loading is not + // 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; + this.kvUnifiedReceipts = new KvUnifiedReceiptChain(); } } @@ -2086,6 +2107,25 @@ 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.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; ` + + `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; @@ -8270,6 +8310,14 @@ export class AutobiographicalStrategy implements ResettableStrategy { for (const [id, level] of pendingResolutionChanges) this.resolutions.set(id, level); this.persistResolutions(); } + // Same commit point for the receipt left behind by kv-unified: a real + // presentation by a non-kv-unified strategy has now succeeded, so that + // receipt no longer describes the previous turn (#97). Independent of + // whether any resolution changed — a no-op compile is still a + // presentation. A rejected compile keeps it: nothing replaced it. + if (!dryRun && this.kvUnifiedReceiptSupersedePending && this.config.foldingStrategy !== 'kv-unified') { + this.supersedeKvUnifiedReceipt(); + } this.rsEnd(); // Closed-loop calibration bookkeeping: the committed render stats total // (in CURRENT calibrated units) is what this compile claims the request diff --git a/test/adaptive/kv-unified-policy.test.ts b/test/adaptive/kv-unified-policy.test.ts index e1988c01..bf2546ec 100644 --- a/test/adaptive/kv-unified-policy.test.ts +++ b/test/adaptive/kv-unified-policy.test.ts @@ -660,3 +660,254 @@ 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 are no longer part of the + // 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, + 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 (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 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 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, + 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 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', + }; + }; + 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, ...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 d1039633..de7b1960 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'; @@ -256,3 +256,200 @@ 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 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' }]); + 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 stableStrategy = () => new AutobiographicalStrategy({ + adaptiveResolution: true, + foldingStrategy: 'kv-stable', + headWindowTokens: 0, + recentWindowTokens: 100, + }); + 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 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 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 and dry run'); + stillThere.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; + } +}); + +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(); +}); + +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(); +});