diff --git a/changelog.d/415-floor-headroom.added.md b/changelog.d/415-floor-headroom.added.md index 687a061b..54a8b06e 100644 --- a/changelog.d/415-floor-headroom.added.md +++ b/changelog.d/415-floor-headroom.added.md @@ -3,18 +3,30 @@ `hardBudget=` and `queuedRelief=` on the `[plan-vs-actual]` line. The floor is the cheapest plan the summaries held allow, head and tail included, whatever the folding strategy picked; a compile refuses once its - plan passes `hardBudget`, so `hardBudget - floorTokens` is how far the store - is from its first refusal. `queuedReliefTokens` is what the queued L1s and + plan passes `hardBudget`, and every plan does once the floor does, so + `hardBudget - floorTokens` is how far the store is from the point where + every compile refuses. `queuedReliefTokens` is what the queued L1s and merges are expected to take off the floor once they land. Work tick won't turn into a summary counts as zero: a chunk the compression quarantine holds or deferred for the host's tools, every chunk from the first a compression hold reaches, a merge holding a source a hold or a merge-quarantine record keeps out, and every merge from the first a hold - blocks. A floor that can't be computed leaves the fields out and is said - once per cause, as `[autobiographical] floor not computed`. + blocks. Under `kv-unified` the floor takes that solver's own constraints, + locks included, and is exact. The other folding strategies give pins + precedence over locks in ways those constraints don't follow, so there the + floor counts no lock. Outside `kv-unified` the floor is a lower bound: a + floor over `hardBudget` means every compile refuses, and one at or under it + doesn't promise a plan fits. A floor that can't be computed leaves the + fields out and is said once per cause, and once more by each load that + meets it, as `[autobiographical] floor not computed`. - `OverBudgetDiagnostics` gains `floorTokens` and `queuedReliefTokens` on - adaptive refusals, and the refusal's message ends with `floor=` and - `queued relief=`. A floor at or under the budget means the refusal isn't - for want of summaries, since a plan that fits exists; a floor still over - the budget once the queued relief is taken off means draining the queue - alone won't clear it. + the adaptive refusals that come once the plan is made, when its floor was + computed, and the refusal's message then names them as `floor=` and + `queued relief=`. The refusal for a head and tail that alone exceed the + budget comes before the plan, so it carries neither. Under `kv-unified`, + a floor at or under the budget means the refusal isn't for want of + summaries, since a plan that fits exists. Outside `kv-unified` the floor is + a lower bound: a floor over `hardBudget` means every compile refuses, and + one at or under it doesn't promise a plan fits. A floor still over + `hardBudget` once the queued relief is taken off means draining the queue + alone isn't expected to clear it, the relief being an estimate. diff --git a/src/adaptive/picker.ts b/src/adaptive/picker.ts index 3f0226ca..09c0f4f2 100644 --- a/src/adaptive/picker.ts +++ b/src/adaptive/picker.ts @@ -64,16 +64,25 @@ export interface OverBudgetDiagnostics { rawPins?: { tokens: number; pins: readonly OverBudgetRawPin[] }; /** * Under adaptive resolution, the cheapest plan the summaries held allow, - * head and tail included (#415). At or under the budget, a plan that could - * fit wasn't picked; over it, only new summaries, a larger budget or a - * smaller tail clear the refusal. Absent where it couldn't be computed. + * head and tail included (#415). Under kv-unified, at or under the budget a + * plan that fits exists, so the refusal isn't for want of summaries. + * Outside kv-unified the floor counts no lock and is a lower bound: a floor + * over `hardBudget` (`inputBudget` with `overBudgetGraceRatio` added, the + * plan size above which a compile refuses, as `RenderStats` reports it) + * means every compile refuses, and one at or under it doesn't promise a + * plan fits. Over `hardBudget`, no plan fits until the floor drops under it + * or the budget rises, as new summaries, a smaller tail, fewer messages, or + * a released pin or lock that kept a message from its cheapest form can + * bring about. Absent where it couldn't + * be computed, and on a refusal that comes before the plan, as the head + * and tail reservation's does. */ floorTokens?: number; /** * Under adaptive resolution, what the queued compression work is expected * to take off `floorTokens` once it lands (RenderStats' `queuedReliefTokens`). - * A floor that stays over the budget less this much won't be cleared by - * draining the queue alone. + * It's an estimate, so a floor that stays over `hardBudget` less this much + * isn't expected to be cleared by draining the queue alone. */ queuedReliefTokens?: number; } diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index 56dfa7ed..002ea4fd 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -3088,6 +3088,7 @@ export class AutobiographicalStrategy implements ResettableStrategy { // failure only an earlier one met. A host that moves the store's branch // itself leaves this load in place until the manager initializes it again. this.namedDroppedQuarantineKeys.clear(); + this.floorFailure = undefined; this.namedUnlistedQuarantineState.clear(); this.compressionDebtFailureLogged = null; @@ -6379,17 +6380,24 @@ export class AutobiographicalStrategy implements ResettableStrategy { * The floor (#415): the cheapest plan the summaries held allow for these * inputs, head and tail included, in the plan's units. An adaptive compile * refuses once it passes the hard budget, whatever the folding strategy - * picks, so the distance between the two is how near the store is to its - * first refusal. Null where the forest can't be built for these inputs; - * the figure is a report and never fails a compile. + * picks, and every plan does once the floor does, so the distance between + * the two is how near the store is to the point where every compile + * refuses. Under kv-unified it takes that solver's own constraints, locks + * included. The other folding strategies give pins precedence over locks + * in ways these constraints don't follow, so there it counts no lock, and + * is a lower bound. Null where the forest can't be built for these inputs or + * the computation fails; the figure is a report and never fails a compile. */ private floorTokens(inputs: PickerInputs): number | null { try { const kv = this.config.foldingStrategy === 'kv-unified' ? this.config.kvUnified : undefined; const reused = kv ? this.kvUnifiedReuse.forest : undefined; - const forest = reused?.builtFrom(inputs) + const floorInputs = this.config.foldingStrategy === 'kv-unified' + ? inputs + : { ...inputs, chunks: inputs.chunks.map((chunk) => chunk.lockedByAgent ? { ...chunk, lockedByAgent: false } : chunk) }; + const forest = reused?.builtFrom(floorInputs) ? reused - : new CanonicalSummaryForest(inputs, { + : new CanonicalSummaryForest(floorInputs, { treeifyNonContiguousSummaries: kv?.treeifyNonContiguousSummaries, preserveGapBearingSummaries: kv?.preserveGapBearingSummaries, }); diff --git a/src/types/strategy.ts b/src/types/strategy.ts index 67ecabfe..d0e9c099 100644 --- a/src/types/strategy.ts +++ b/src/types/strategy.ts @@ -1143,10 +1143,14 @@ export interface RenderStats { /** The floor (#415): the cheapest plan the summaries held allow for this * compile's inputs, head and tail included, in the plan's units, whatever * the folding strategy picked. A compile refuses once its plan passes - * `hardBudget`, so `hardBudget - floorTokens` is how far the store is - * from its first refusal: it shrinks as history arrives and grows as - * L1s and merges land. Absent where the forest couldn't be built - * (`floor=` on the plan line). */ + * `hardBudget`, and every plan does once the floor does, so + * `hardBudget - floorTokens` is how far the store is from the point + * where every compile refuses: it usually shrinks as history arrives and + * grows as L1s and merges land. Under kv-unified it takes that solver's + * own constraints, locks included; under the other folding strategies, + * which give pins precedence over locks, it counts no lock, and is a + * lower bound. Absent where the forest couldn't be built or its computation + * fails (`floor=` on the plan line). */ floorTokens?: number; /** The plan size above which this compile refuses: the input budget with * `overBudgetGraceRatio` added (`hardBudget=`). Set with `floorTokens`. */ diff --git a/test/liveness-floor.test.ts b/test/liveness-floor.test.ts index 0b2b8b51..bdf82210 100644 --- a/test/liveness-floor.test.ts +++ b/test/liveness-floor.test.ts @@ -16,15 +16,19 @@ * refused at its deepest available level (#412). * - Through bursts the lane can't keep up with, followed by idle stretches * that let it catch up, the compile stays live. - * - Each adaptive compile reports its floor beside the hard budget, and the - * floor drops as relief lands; a refusal names its floor and the relief - * the queue holds. + * - Each adaptive compile whose floor can be computed reports it beside the + * hard budget, and the floor drops as relief lands; a refusal after the + * plan names its floor and the relief the queue holds, when the floor was + * computed. Outside kv-unified the floor counts no lock, so a lock a pin + * overrides doesn't take it away; under kv-unified it counts every lock. A + * floor that can't be computed is said once per cause. */ import { describe, it, after } from 'node:test'; import assert from 'node:assert'; import { rmSync, existsSync } from 'node:fs'; import { ContextManager, AutobiographicalStrategy, OverBudgetError } from '../src/index.js'; +import { CanonicalSummaryForest } from '../src/adaptive/kv-unified.js'; import type { ContentBlock } from '@animalabs/membrane'; const STORE = './test-liveness-floor'; @@ -74,6 +78,13 @@ interface Reader { const settle = async () => { for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); }; +function collectFloorWarnings(): { warnings: string[]; restore: () => void } { + const warnings: string[] = []; + const warn = console.warn; + console.warn = (message?: unknown) => { if (/floor not computed/.test(String(message))) warnings.push(String(message)); }; + return { warnings, restore: () => { console.warn = warn; } }; +} + async function openReader(config: Record = {}, summaryWords = 4, refuse?: RegExp): Promise { cleanup(); const calls = { l1: 0, merge: 0 }; @@ -247,6 +258,142 @@ describe('Adaptive compile liveness against the floor (#415)', () => { assert.match(refusal.message, new RegExp(`floor=${floorTokens}, queued relief=${queuedReliefTokens}`)); reader.manager.close(); }); + + for (const foldingStrategy of ['kv-stable', 'flat-profile', 'oldest-first']) { + it(`keeps the floor where a pin overrides a lock, under ${foldingStrategy}`, async () => { + const reader = await openReader({ foldingStrategy, kvUnified: undefined }); + reader.read(40); + await reader.drain(); + await reader.manager.compile({ maxTokens: 3_000, reserveForResponse: 0 }); + const resolutions = (reader.strategy as unknown as { resolutions: Map }).resolutions; + const folded = [...resolutions].find(([, level]) => level >= 1)?.[0]; + assert.ok(folded, 'premise: a message is folded'); + const budget = { maxTokens: 20_000, reserveForResponse: 0 }; + const floor = collectFloorWarnings(); + try { + reader.strategy.lockChunk(folded); + await reader.manager.compile(budget); + const beforePin = reader.manager.getRenderStats()?.planVsActual?.floorTokens; + reader.manager.pinRange(folded, folded); + await reader.manager.compile(budget); + const stats = reader.manager.getRenderStats()?.planVsActual; + assert.ok(stats?.floorTokens !== undefined, 'the floor is reported beside a raw pin over a locked message'); + assert.ok(stats.floorTokens <= stats.planned, `at or under the plan (${stats.floorTokens} > ${stats.planned})`); + assert.ok(beforePin !== undefined && stats.floorTokens > beforePin, `the pin counts (${stats.floorTokens}, ${beforePin})`); + assert.strictEqual(floor.warnings.length, 0, floor.warnings.join('\n')); + } finally { + floor.restore(); + } + reader.manager.close(); + }); + } + + it('counts no lock outside kv-unified, so a lock a level pin overrides through its summary leaves the floor under the plan', async () => { + const reader = await openReader({ foldingStrategy: 'kv-stable', kvUnified: undefined }); + reader.read(40); + await reader.drain(); + const locked = reader.manager.getAllMessages()[20]!.id; + reader.strategy.lockChunk(locked); + await reader.manager.compile({ maxTokens: 3_000, reserveForResponse: 0 }); + const summaries = (reader.strategy as unknown as { summaries: { level: number; sourceIds: string[] }[] }).summaries; + const sibling = summaries.find((summary) => summary.level === 1 && summary.sourceIds.includes(locked))?.sourceIds.find((id) => id !== locked); + assert.ok(sibling, 'premise: the locked message shares an L1'); + const budget = { maxTokens: 20_000, reserveForResponse: 0 }; + reader.manager.pinRange(sibling, sibling, { level: 1 }); + await reader.manager.compile(budget); + const withLock = reader.manager.getRenderStats()?.planVsActual; + reader.strategy.unlockChunk(locked); + await reader.manager.compile(budget); + const withoutLock = reader.manager.getRenderStats()?.planVsActual; + assert.ok(withLock?.floorTokens !== undefined && withLock.floorTokens <= withLock.planned, `at or under the plan (${withLock?.floorTokens}, ${withLock?.planned})`); + assert.strictEqual(withLock.floorTokens, withoutLock?.floorTokens, 'the lock leaves the floor as it is'); + reader.manager.close(); + }); + + it('counts a lock under kv-unified, whose constraints the floor takes', async () => { + const reader = await openReader(); + reader.read(40); + await reader.drain(); + const budget = { maxTokens: 20_000, reserveForResponse: 0 }; + const floorNow = async () => { + await reader.manager.compile(budget); + return reader.manager.getRenderStats()?.planVsActual?.floorTokens; + }; + const free = await floorNow(); + reader.strategy.lockChunk(reader.manager.getAllMessages()[20]!.id); + const held = await floorNow(); + assert.ok(free !== undefined && held !== undefined && held > free, `a message held raw raises the floor (${held}, ${free})`); + reader.manager.close(); + }); + + it('keeps the floor where a level pin overrides a lock under kv-stable, as kv-unified refuses the compile for it', async () => { + const budget = { maxTokens: 20_000, reserveForResponse: 0 }; + const unified = await openReader(); + unified.read(40); + await unified.drain(); + const first = unified.manager.getAllMessages()[0]!.id; + unified.strategy.lockChunk(first); + unified.manager.pinRange(first, first, { level: 2 }); + await assert.rejects(unified.manager.compile(budget), /infeasible/, 'premise: under kv-unified the lock and the pin conflict'); + unified.manager.close(); + + const reader = await openReader({ foldingStrategy: 'kv-stable', kvUnified: undefined }); + reader.read(40); + await reader.drain(); + const id = reader.manager.getAllMessages()[0]!.id; + const floor = collectFloorWarnings(); + try { + reader.strategy.lockChunk(id); + reader.manager.pinRange(id, id, { level: 2 }); + await reader.manager.compile(budget); + const stats = reader.manager.getRenderStats()?.planVsActual; + assert.ok(stats?.floorTokens !== undefined, 'the floor is reported'); + assert.ok(stats.floorTokens <= stats.planned, `at or under the plan (${stats.floorTokens} > ${stats.planned})`); + assert.strictEqual(floor.warnings.length, 0, floor.warnings.join('\n')); + } finally { + floor.restore(); + } + reader.manager.close(); + }); + + it('says once that a floor whose computation fails is missing, again when it recurs or the store is opened again, and the compile goes on', async () => { + const reader = await openReader({ foldingStrategy: 'kv-stable', kvUnified: undefined }); + reader.read(24); + await reader.drain(); + const budget = { maxTokens: 20_000, reserveForResponse: 0 }; + const forest = CanonicalSummaryForest.prototype as unknown as { minimumTokens: () => unknown }; + const minimumTokens = forest.minimumTokens; + const breakIt = () => { forest.minimumTokens = () => { throw new Error('the forest broke'); }; }; + const floor = collectFloorWarnings(); + try { + breakIt(); + await reader.manager.compile(budget); + await reader.manager.compile(budget); + const stats = reader.manager.getRenderStats()?.planVsActual; + assert.ok(stats, 'premise: the compile went on'); + assert.strictEqual(stats.floorTokens, undefined, 'no floor is reported'); + assert.strictEqual(stats.hardBudget, undefined, 'nor the budget beside it'); + assert.strictEqual(floor.warnings.length, 1, `said once over two compiles (${floor.warnings.length})`); + assert.match(floor.warnings[0]!, /floor not computed.*the forest broke/); + forest.minimumTokens = () => { throw new Error('the forest broke otherwise'); }; + await reader.manager.compile(budget); + assert.strictEqual(floor.warnings.length, 2, `another cause is said too (${floor.warnings.length})`); + forest.minimumTokens = minimumTokens; + await reader.manager.compile(budget); + assert.ok(reader.manager.getRenderStats()?.planVsActual?.floorTokens !== undefined, 'premise: the floor is back'); + forest.minimumTokens = () => { throw new Error('the forest broke otherwise'); }; + await reader.manager.compile(budget); + assert.strictEqual(floor.warnings.length, 3, `said again when it recurs (${floor.warnings.length})`); + reader.manager.close(); + const reopened = await ContextManager.open({ path: STORE, strategy: reader.strategy, membrane: { complete: async () => { throw new Error('no call expected'); } } as never }); + await reopened.compile(budget); + assert.strictEqual(floor.warnings.length, 4, `said again for the store opened anew (${floor.warnings.length})`); + reopened.close(); + } finally { + forest.minimumTokens = minimumTokens; + floor.restore(); + } + }); }); // The relief rule's exclusions: where the merge at the head keeps its usual @@ -263,15 +410,21 @@ class OrderProbe extends AutobiographicalStrategy { } } -async function openOrder(opts: { adaptive?: boolean; refuseMerges?: boolean; drainMode?: boolean } = {}) { +async function openOrder(opts: { adaptive?: boolean; refuseMerges?: boolean; truncateMerges?: boolean; failFirstMerge?: boolean; drainMode?: boolean } = {}) { cleanup(); const calls: Array<'l1' | 'merge'> = []; + let mergeFailed = false; const membrane = { complete: async (request: { messages?: unknown[] }) => { const last = JSON.stringify(request.messages?.[request.messages.length - 1] ?? ''); const merge = /consolidat/i.test(last); calls.push(merge ? 'merge' : 'l1'); if (merge && opts.refuseMerges) return { stopReason: 'refusal', content: [], usage: { input_tokens: 100, output_tokens: 0 } }; + if (merge && opts.truncateMerges) return { stopReason: 'max_tokens', content: [t(`a recollection cut off ${words(2)}`)], usage: { input_tokens: 100, output_tokens: 40 } }; + if (merge && opts.failFirstMerge && !mergeFailed) { + mergeFailed = true; + throw Object.assign(new Error('connection reset'), { type: 'network', retryable: true }); + } return { stopReason: 'end_turn', content: [t(`a recollection of what happened, ${words(4)}`)], usage: { input_tokens: 100, output_tokens: 40 } }; }, }; @@ -325,11 +478,33 @@ describe('The relief rule leaves the usual order where it should (#415)', () => it('runs the next L1 before a merge the model has refused once', async () => { const probe = await openOrder({ refuseMerges: true }); await probe.tickOnce(); + assert.strictEqual(probe.strategy.getProgressSnapshot().mergeQueueLength, 1, 'premise: the refused merge is still queued'); await probe.tickOnce(); assert.deepStrictEqual(probe.calls, ['merge', 'l1'], 'the refused merge is retried only behind the queued L1s'); probe.manager.close(); }); + it('runs the next L1 before a merge whose answer was cut off', async () => { + const probe = await openOrder({ truncateMerges: true }); + await probe.tickOnce(); + assert.strictEqual(probe.strategy.getProgressSnapshot().mergeQueueLength, 1, 'premise: the cut-off merge is still queued'); + await probe.tickOnce(); + assert.deepStrictEqual(probe.calls, ['merge', 'l1'], 'the cut-off merge is retried only behind the queued L1s'); + probe.manager.close(); + }); + + it('runs the next L1 before a merge whose call failed, once the pause it caused has passed', async () => { + const probe = await openOrder({ failFirstMerge: true }); + await assert.rejects(() => probe.tickOnce(), /connection reset/); + const lane = probe.strategy as unknown as { compressionPause: { backoffUntil: number } | null }; + assert.ok(lane.compressionPause, 'premise: the failure paused the lane'); + assert.strictEqual(probe.strategy.getProgressSnapshot().mergeQueueLength, 1, 'premise: the failed merge is still queued'); + lane.compressionPause.backoffUntil = 0; + await probe.tickOnce(); + assert.deepStrictEqual(probe.calls, ['merge', 'l1'], 'the failed merge is retried only behind the queued L1s'); + probe.manager.close(); + }); + it('runs the next L1 first under the drain script (landsAsPlanned)', async () => { const probe = await openOrder({ drainMode: true }); await probe.tickOnce();