diff --git a/changelog.d/kv-unified-packed-selective.changed.md b/changelog.d/kv-unified-packed-selective.changed.md new file mode 100644 index 00000000..c48ed569 --- /dev/null +++ b/changelog.d/kv-unified-packed-selective.changed.md @@ -0,0 +1,6 @@ +- Use reusable packed label records and bucket tables in the kv-unified DAG + solver. Preserve the object implementation as a diagnostic reference. +- Select terminal candidates with conservative rounding bounds and exact cache + accounting, refining all possible winners, ties, and normalization-floor + witnesses. Keep the selected result exact and defer remaining diagnostic + candidate-list evaluations until requested, without changing policy or grids. diff --git a/docs/kv-unified-packed-selective.md b/docs/kv-unified-packed-selective.md new file mode 100644 index 00000000..2cfa5b6a --- /dev/null +++ b/docs/kv-unified-packed-selective.md @@ -0,0 +1,178 @@ +# Packed propagation and selective exact scoring + +This follow-up to PR #110 targets the two remaining measured costs: label +storage/grouping and repeated terminal leaf scans. It does not change policy +weights, grids, hard budgets, adoption thresholds, or label ceilings, and it +does not introduce a solve deadline. The certificate remains disabled in the +performance checks. + +## Storage + +The default DAG engine uses integer label handles and fixed-size binary64 +records. Branches copy one contiguous record; transforms own their handles; +pruning returns rejected slots to a free list. Cache keys and comparison +fields are grouped in the first half of each record. Traces are immutable +parent/action indices in paged uint32 storage; pending assignments are only +materialized when needed by a surviving path or a branch. + +Bucket lookup reuses generation-stamped numeric tables. Group membership and +representative handles are stored in reusable integer arrays, so each prune +does not allocate a Map entry and member array for every bucket. Unbounded or +non-numeric key spaces use sparse lookup with the same packed members; the +dense allocation limit only chooses storage and never changes the candidate +set or stops a solve. + +Packed batches own their handles. Branching clones before either path can +mutate the original. Pruning reads every source envelope before updating a +retained representative, then releases only discarded handles. The object +engine remains available as `storage: 'objects'` for differential checks. + +## Exact selection without every leaf scan + +Each trace action caches its per-leaf fidelity/continuity sum, identity match, +and first emitted index for each render unit. A candidate estimate combines +those action sums instead of rescanning every leaf. + +The cache cost is **exact**, not an estimate from propagation state: emitted +units are deduplicated, their earliest assigned leaf indices are sorted, and +the current token offsets and extension costs are accumulated in the same +chronological order as the exact evaluator. This handles gap-bearing ownership, +protected holes, changed receipt token costs, and fractional token counts. + +Fidelity and continuity use conservative binary64 intervals. Both the grouped +sum and the exact evaluator sum the same nonnegative, precomputed floating-point +leaf terms. For a complete disjoint cut of n leaves, grouping uses at most 2n +additions and the exact evaluation at most n. The bound uses +`16 * Number.EPSILON * (n + 1)`, an outward-rounded error and endpoints, and a +subnormal allowance. If the bound is not usable, exact evaluation is used. +This is a rounding bound on a specific retained cut, not the solver's much +larger bucket-approximation error bound. + +Selection proceeds in three stages: + +1. Compute the exact cache floor. Refine every candidate whose continuity + interval could lower the best exact continuity floor, even if that candidate + is otherwise a welfare loser. +2. With both floors fixed, evaluate score intervals using the same monotone + nonnegative score expression as the original scorer. Find upper bounds for + the best candidate and the best candidate matching the accepted presentation. +3. Exactly evaluate every candidate whose lower bound could meet either upper + bound. Apply the original score/token/frontier tie-break and hysteresis rule. + +Selection, both floors, and scores are exact within the unchanged retained +candidate set; the existing bucket approximation is not removed. Public candidate +list inspection performs any remaining exact evaluations and returns the +complete sorted list, including the same selected object. Normal solving does +not inspect that diagnostic list. Captured budgets and token costs are retained +if callers later mutate the inputs. + +`terminalEvaluation: 'full'` forces exhaustive rescoring on the packed engine. +`exactTerminalEvaluations` reports evaluations needed during the solve, before +later diagnostic-list inspection. + +## Validation and benchmarking + +Differential tests compare object storage, packed/full, and packed/selective +on varied forests, including every retained candidate, both normalization +floors, selected scores/frontiers, and all existing approximation-envelope +statistics. They also check intervals against every feasible small-forest cut, +cache corrections against full rendering, floor-setting welfare losers, exact +ties and hysteresis boundaries, non-finite fallback behavior, snapshots, and +label-slot reuse and growth. + +Use the saved structural fixture without opening a resident store: + +```sh +bun scripts/benchmark-kv-unified-general.mjs --fixture /path/to/solver-inputs.json \ + --objects --warm --budget 500000 --report /path/to/reference.json +bun scripts/benchmark-kv-unified-general.mjs --fixture /path/to/solver-inputs.json \ + --full-scoring --warm --budget 500000 --report /path/to/packed-full.json +bun scripts/benchmark-kv-unified-general.mjs --fixture /path/to/solver-inputs.json \ + --warm --budget 500000 --report /path/to/packed-selective.json +``` + +`--verify-estimates` exactly evaluates every estimate and asserts that its +fidelity/continuity intervals contain the exact result and that cache churn +and presentation identity match. Its timing is diagnostic, not the normal +selective-scoring timing. + +### Large structural fixture + +On the 73,918-message / 3,345-summary fixture, paired fresh-process Bun 1.3.14 runs +against the object backend produced: + +| Case | Object/full | Packed/selective | Reduction | Exact evaluations / retained cuts | +| --- | ---: | ---: | ---: | ---: | +| Warm cache, forced 500k budget | 8.721 s | 5.194 s | 40.4% | 3 / 6,719 | +| Warm cache, newly pinned middle leaf | 8.736 s | 5.229 s | 40.1% | 3 / 6,538 | +| Warm cache, 30k appended raw tokens | 9.149 s | 5.509 s | 39.8% | 3 / 6,648 | + +The evaluation count includes the feasibility-witness check. All selected +frontier hashes, scores, floors, and pre-existing propagation/envelope +statistics match exactly. End-of-run RSS was 1.12–1.13 GB, versus 1.26–1.30 GB +for the object backend; these are process observations, not allocation limits. +An additional diagnostic run checked every one of the 6,538 pinned-case +estimates against its exact evaluation. These are single-run local timings, +not a cross-machine latency guarantee. + +Validation: build and typecheck pass; **822 tests pass / 0 fail**, versus +813 / 0 on the PR #110 parent (9 additional tests). The tests and benchmarks +do not exercise provider calls or a live resident compile. + +For a controlled sequential replay with differential decision checks: + +```sh +bun scripts/replay-kv-unified-messages.mjs --fixture /path/to/solver-inputs.json \ + --metadata /path/to/replay-metadata.json --output /path/to/new-replay \ + --count 500 --verify-every 25 +``` + +This verifies the selected frontier hash, all selected loss terms, both floors, +and score against the object engine at warm-up, every 25th message, and every +layout movement. Reference solves are excluded from measured solve time. It +uses a frozen summary catalogue, fresh atomic-layout cache markers, and accepted +results carried forward, not historical provider/cache-TTL reconstruction. + +### 500-message replay + +Revision `028b065` completed **500 / 500**, with **0 failures** and the +certificate disabled. Timings include canonical forest/solver construction, +solve, and selected frontier materialization. One warm-up is excluded; +percentiles use nearest rank. + +| Statistic | Previous replay | Packed/selective | Reduction | +| --- | ---: | ---: | ---: | +| Minimum | 2.692 s | 1.815 s | 32.6% | +| Mean | 3.122 s | 2.044 s | 34.5% | +| Median | 2.837 s | 1.878 s | 33.8% | +| p90 | 3.544 s | 2.280 s | 35.7% | +| p95 | 4.959 s | 3.145 s | 36.6% | +| p99 | 8.069 s | 4.927 s | 38.9% | +| Maximum | 9.486 s | 5.611 s | 40.9% | + +420 solves were under 2 seconds, 75 were between 2 and 5 seconds, and 5 +were between 5 and 5.62 seconds. The last-100 median was 1.874 seconds. +Only 2–3 exact terminal evaluations were needed per measured solve. + +All **23 reference checks** passed (22 measured samples plus warm-up), +including all four layout-changing solves. Those transitions changed +105 / 1,346 / 1,186 / 100 leaf resolutions and took +2.045 / 1.857 / 1.870 / 1.879 seconds. Reference checks compare frontier +hashes, selected loss terms, score, and both normalization floors. Tokens, +movement counts, terminal-label counts, and maximum labels per state match +the prior replay on every row. The old replay did not record frontier hashes +or scores, so full decision equality is claimed for the sampled reference +checks, not for all 500 historical rows. + +The previous timing column is the archived pre-rebase `5c55a26` replay, not +a fresh 500-sample run of the #110 parent. The paired large-fixture table +above uses the current object backend. Replay high-water observed RSS was +3.285 GB, including the additional object-reference solves in the same +process; it is not a packed-only memory comparison. + +Local receipts: `/Users/antra/sill-cm/data/solver-fixtures/packed-selective-20260921.t0hKsG/`. +The `replay-500/` directory contains `config.json`, `rows.jsonl`, and +`summary.json`. CI also passed on macOS and Ubuntu with Node 20 and 24. + +The implementation is on a separate follow-up branch; no live runtime or +resident configuration was changed. diff --git a/scripts/benchmark-kv-unified-general.mjs b/scripts/benchmark-kv-unified-general.mjs index d16a2fdf..527edbdb 100644 --- a/scripts/benchmark-kv-unified-general.mjs +++ b/scripts/benchmark-kv-unified-general.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import { createHash } from 'node:crypto'; import { CanonicalSummaryForest } from '../src/adaptive/kv-unified.ts'; import { ParetoKvUnifiedPolicySolver } from '../src/adaptive/kv-unified-pareto.ts'; +import { TerminalPolicyEvaluator } from '../src/adaptive/kv-unified-terminal.ts'; const arg = (name) => { const index = process.argv.indexOf(name); @@ -12,6 +13,21 @@ if (!arg('--fixture')) throw new Error('--fixture PATH required'); const fixture = JSON.parse(fs.readFileSync(arg('--fixture'), 'utf8'), (_key, value) => value?.$map ? new Map(value.$map) : value?.$set ? new Set(value.$set) : value); const { inputs } = fixture; +let verifiedEstimates = 0; +if (process.argv.includes('--verify-estimates')) { + const estimate = TerminalPolicyEvaluator.prototype.estimate; + TerminalPolicyEvaluator.prototype.estimate = function (...args) { + const bounded = estimate.apply(this, args); + const exact = bounded.exact(); + if (!(bounded.fidelity.lower <= exact.fidelityLoss && exact.fidelityLoss <= bounded.fidelity.upper) || + !(bounded.continuity.lower <= exact.continuityLoss && exact.continuityLoss <= bounded.continuity.upper) || + bounded.cacheChurn !== exact.cacheChurn || bounded.matchesPresentation !== exact.matchesPresentation) { + throw new Error('terminal estimate did not enclose the exact candidate'); + } + verifiedEstimates++; + return bounded; + }; +} const changes = []; if (process.argv.includes('--pin')) { const chunk = inputs.chunks.find((chunk, index) => index > inputs.chunks.length / 2 && @@ -37,6 +53,8 @@ const phases = []; const diagnosticStop = new Error('diagnostic stopped after propagation; no layout selected'); const options = { ...fixture.options, hysteresisCertificate: false, + ...(process.argv.includes('--objects') ? { storage: 'objects' } : {}), + ...(process.argv.includes('--full-scoring') ? { terminalEvaluation: 'full' } : {}), ...(arg('--budget') ? { maxTokens: Number(arg('--budget')) } : {}), ...(process.argv.includes('--warm') ? { currentImmutablePrefixHash: fixture.options.cache?.immutablePrefixHash } : {}), ...(process.argv.includes('--initial') ? { presentation: undefined, cache: undefined } : {}), @@ -51,12 +69,14 @@ const started = performance.now(); const forest = new CanonicalSummaryForest(inputs, fixture.forestOptions); try { const result = new ParetoKvUnifiedPolicySolver(inputs, forest).solve(options); + const selectedFrontier = result.feasible ? result.selected.frontier : undefined; const report = { ms: performance.now() - started, rssMB: process.memoryUsage().rss / 1e6, feasible: result.feasible, phases, maxTokens: options.maxTokens, warm: Boolean(options.cache && options.currentImmutablePrefixHash === options.cache.immutablePrefixHash), - initial: !options.presentation, changes, certificateEnabled: false }; + initial: !options.presentation, changes, certificateEnabled: false, + verifiedEstimates, diagnosticVerification: process.argv.includes('--verify-estimates') }; if (result.feasible) { - const frontier = [...result.selected.frontier].sort(([a], [b]) => a.localeCompare(b)); + const frontier = [...selectedFrontier].sort(([a], [b]) => a.localeCompare(b)); Object.assign(report, { tokens: result.selected.renderedTokens, score: result.selected.score, cacheFloor: result.cacheFloor, continuityFloor: result.continuityFloor, diff --git a/scripts/replay-kv-unified-messages.mjs b/scripts/replay-kv-unified-messages.mjs index 1c5a1816..ffaa6850 100644 --- a/scripts/replay-kv-unified-messages.mjs +++ b/scripts/replay-kv-unified-messages.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { CanonicalSummaryForest } from '../src/adaptive/kv-unified.ts'; import { ParetoKvUnifiedPolicySolver } from '../src/adaptive/kv-unified-pareto.ts'; import { SummaryTree } from '../src/adaptive/summary-tree.ts'; @@ -19,6 +20,8 @@ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); const full = [...fixture.inputs.chunks].sort((a, b) => a.sequence - b.sequence); const count = Number(arg('--count') ?? 500); const limit = Number(arg('--limit') ?? count); +const verifyEvery = Number(arg('--verify-every') ?? 0); +if (!Number.isInteger(verifyEvery) || verifyEvery < 0) throw new Error('invalid verify-every'); if (!Number.isInteger(count) || count < 1 || count >= full.length || !Number.isInteger(limit) || limit < 1 || limit > count) { throw new Error('invalid count/limit'); } @@ -128,6 +131,7 @@ const config = { toTimestamp: new Date(metadata.messages[first + limit - 1].timestamp).toISOString(), prefixMessages: first, snapshotMessages: full.length, maxTokens: fixture.options.maxTokens, tailWindowTokens: metadata.recentWindowTokens, certificateEnabled: false, + referenceVerification: verifyEvery > 0 ? `object engine every ${verifyEvery} samples, at warm-up, and on every movement; outside solve timing` : 'disabled', presentation: 'one initial warm-up excluded; each successful message-prefix solve accepted before the next', cache: 'previous accepted atomic layout; fresh token-weighted markers; stable immutable prefix; no wall-clock TTL simulation', summaries: 'snapshot catalogue, exclude future-source summaries; not historical compression-time reconstruction', @@ -160,6 +164,7 @@ function summary() { medianMs: quantile(0.5), p90Ms: quantile(0.9), p95Ms: quantile(0.95), p99Ms: quantile(0.99), maxMs: times.at(-1) ?? null, histogram, maxRssMB: Math.max(0, ...rows.map((row) => row.rssMB)), movementSolves: rows.filter((row) => row.ok && row.moves > 0).length, + referenceChecks: rows.filter((row) => row.referenceVerified).length, slowest: rows.filter((row) => row.ok).sort((a, b) => b.solveMs - a.solveMs).slice(0, 10), updatedAt: new Date().toISOString(), }; @@ -173,7 +178,7 @@ for (let step = 0; step <= limit; step++) { const solveStarted = performance.now(); try { const forest = new CanonicalSummaryForest(inputs, fixture.forestOptions); - const result = new ParetoKvUnifiedPolicySolver(inputs, forest).solve({ + const solveOptions = { ...fixture.options, hysteresisCertificate: false, presentation, cache, currentImmutablePrefixHash: cache ? prefixHash : undefined, onProgress: (event) => { @@ -182,7 +187,8 @@ for (let step = 0; step <= limit; step++) { lastProgress = performance.now(); } }, - }); + }; + const result = new ParetoKvUnifiedPolicySolver(inputs, forest).solve(solveOptions); if (!result.feasible) throw new Error(`infeasible: ${JSON.stringify(result.feasibility)}`); const frontier = result.selected.frontier; row.solveMs = performance.now() - solveStarted; @@ -191,6 +197,24 @@ for (let step = 0; step <= limit; step++) { row.moves = inputs.chunks.filter((chunk) => (frontier.get(chunk.id) ?? 0) !== chunk.currentResolution).length; row.labels = result.propagation?.terminalLabels; row.maxLabelsPerState = result.propagation?.maxLabelsPerState; + row.exactTerminalEvaluations = result.propagation?.exactTerminalEvaluations; + const decision = (solved) => ({ + tokens: solved.selected.renderedTokens, score: solved.selected.score, + fidelityLoss: solved.selected.fidelityLoss, continuityLoss: solved.selected.continuityLoss, + cacheChurn: solved.selected.cacheChurn, cacheFloor: solved.cacheFloor, continuityFloor: solved.continuityFloor, + frontierHash: createHash('sha256').update(JSON.stringify([...solved.selected.frontier] + .sort(([a], [b]) => a.localeCompare(b)))).digest('hex'), + }); + Object.assign(row, decision(result)); + if (verifyEvery > 0 && (step % verifyEvery === 0 || row.moves > 0)) { + const referenceStarted = performance.now(); + const reference = new ParetoKvUnifiedPolicySolver(inputs, forest).solve({ ...solveOptions, storage: 'objects', onProgress: undefined }); + if (!reference.feasible || JSON.stringify(decision(reference)) !== JSON.stringify(decision(result))) { + throw new Error(`object-engine decision mismatch at step ${step}`); + } + row.referenceVerified = true; + row.referenceMs = performance.now() - referenceStarted; + } sequence++; const leaves = new Map(); for (const chunk of inputs.chunks) { diff --git a/src/adaptive/kv-unified-packed-storage.ts b/src/adaptive/kv-unified-packed-storage.ts new file mode 100644 index 00000000..08218d82 --- /dev/null +++ b/src/adaptive/kv-unified-packed-storage.ts @@ -0,0 +1,197 @@ +import type { FrontierTraceSource } from './kv-unified-terminal.js'; + +export interface PackedEmission { + readonly kind: 'raw' | 'recall'; + readonly key: string; + readonly tokens: number; + readonly sequence: number; +} + +const TRACE_SHIFT = 18; +const TRACE_SIZE = 1 << TRACE_SHIFT; +const TRACE_MASK = TRACE_SIZE - 1; +const EMPTY_EMISSIONS: readonly PackedEmission[] = Object.freeze([]); + +export const LABEL_STRIDE = 16; +export enum LabelField { + Tokens, Extension, Continuity, Fidelity, + Intact, CachedUnits, Matched, CachedTokens, + TokenError, ContinuityError, FidelityError, CacheError, + Trace, PendingAction, +} + +/** Immutable trace nodes use two integers. Pages avoid copying a growing + * arena; rejected labels do not allocate a trace node until it is needed. */ +export class PackedTraceArena { + private parents: Uint32Array[] = []; + private actions: Uint32Array[] = []; + private definitions: Array<{ ids: readonly string[]; level: number }> = [{ ids: [], level: 0 }]; + private length = 0; + + action(ids: readonly string[], level: number): number { + this.definitions.push({ ids, level }); + return this.definitions.length - 1; + } + + append(parent: number, action: number): number { + if (action === 0) return parent; + const id = ++this.length; + if (id > 0xffffffff) throw new Error('kv-unified trace index exhausted'); + const page = id >>> TRACE_SHIFT; + while (this.parents.length <= page) { + this.parents.push(new Uint32Array(TRACE_SIZE)); + this.actions.push(new Uint32Array(TRACE_SIZE)); + } + this.parents[page][id & TRACE_MASK] = parent; + this.actions[page][id & TRACE_MASK] = action; + return id; + } + + reference(id: number): FrontierTraceSource { + const arena = this; + return { + forEachAssignment(visit) { + for (let cursor = id; cursor !== 0;) { + const page = cursor >>> TRACE_SHIFT; + const offset = cursor & TRACE_MASK; + const action = arena.definitions[arena.actions[page][offset]]; + visit(action.ids, action.level); + cursor = arena.parents[page][offset]; + } + }, + }; + } + + get nodes(): number { return this.length; } +} + +/** Owned labels are integer handles into reusable packed numeric records. A batch + * owns its handles: transforms may update them, pruning frees discarded ones, + * and branching explicitly clones. Trace nodes remain immutable. */ +export class PackedLabels { + private capacity = 1024; + private length = 0; + private free = 0; + private freeNext = new Uint32Array(this.capacity); + readonly traces = new PackedTraceArena(); + + // One contiguous record permits native copyWithin for branching. Integer + // handles are uint32-sized and exactly representable in binary64. + data = new Float64Array(this.capacity * LABEL_STRIDE); + pending: Array = []; + + private grow(): void { + const capacity = this.capacity * 2; + if (capacity > 0x7fffffff) throw new Error('kv-unified label index exhausted'); + const data = new Float64Array(capacity * LABEL_STRIDE); data.set(this.data); this.data = data; + const free = new Uint32Array(capacity); free.set(this.freeNext); this.freeNext = free; + this.capacity = capacity; + } + + allocate(): number { + if (this.free) { + const id = this.free; this.free = this.freeNext[id]; return id; + } + const id = ++this.length; + if (id >= this.capacity) this.grow(); + return id; + } + + initial(cacheRelevant: boolean, externalIds: readonly string[]): number { + const id = this.allocate(); + const at = id * LABEL_STRIDE; + this.data.fill(0, at, at + LABEL_STRIDE); + this.data[at + LabelField.Intact] = cacheRelevant ? 1 : 0; + this.pending[id] = EMPTY_EMISSIONS; + if (externalIds.length) this.data[at + LabelField.PendingAction] = this.traces.action(externalIds, 0); + return id; + } + + clone(source: number): number { + this.finishTrace(source); + const id = this.allocate(); + this.data.copyWithin(id * LABEL_STRIDE, source * LABEL_STRIDE, (source + 1) * LABEL_STRIDE); + this.pending[id] = this.pending[source]; + return id; + } + + release(id: number): void { + this.pending[id] = EMPTY_EMISSIONS; + this.freeNext[id] = this.free; this.free = id; + } + + finishTrace(id: number): number { + const at = id * LABEL_STRIDE; + if (this.data[at + LabelField.PendingAction]) { + this.data[at + LabelField.Trace] = this.traces.append(this.data[at + LabelField.Trace], this.data[at + LabelField.PendingAction]); + this.data[at + LabelField.PendingAction] = 0; + } + return this.data[at + LabelField.Trace]; + } + + assign(id: number, action: number, fidelity: number, continuity: number): void { + if (!action) return; + this.finishTrace(id); + const at = id * LABEL_STRIDE; + this.data[at + LabelField.PendingAction] = action; + this.data[at + LabelField.Fidelity] += fidelity; + this.data[at + LabelField.Continuity] += continuity; + } + + get slots(): number { return this.length; } +} + +/** Reusable grouping metadata. Large/non-numeric key spaces use a Map only + * for key-to-group lookup; member chains and extrema remain packed. */ +export class PackedBuckets { + private epoch = 0; + private readonly epochs: Uint32Array; + private readonly slots: Uint32Array; + private readonly sparse = new Map(); + count = 0; + head = new Int32Array(1024); + next = new Int32Array(1024); + fidelity = new Uint32Array(1024); + continuity = new Uint32Array(1024); + tokens = new Uint32Array(1024); + extension = new Uint32Array(1024); + size = new Uint32Array(1024); + + constructor(denseSlots: number) { + // This only selects the representation, never the number of retained + // labels or the solver's stopping condition. + const dense = Number.isSafeInteger(denseSlots) && denseSlots > 0 && denseSlots <= 4_000_000 ? denseSlots : 0; + this.epochs = new Uint32Array(dense); + this.slots = new Uint32Array(dense); + } + + begin(labels: number): void { + this.count = 0; + this.sparse.clear(); + this.epoch = (this.epoch + 1) >>> 0; + if (this.epoch === 0) { this.epochs.fill(0); this.epoch = 1; } + if (labels > this.next.length) this.next = new Int32Array(Math.max(labels, this.next.length * 2)); + } + + group(key: string | number): number { + const dense = typeof key === 'number' && Number.isInteger(key) && key >= 0 && key < this.epochs.length; + if (dense && this.epochs[key] === this.epoch) return this.slots[key]; + if (!dense) { + const found = this.sparse.get(key); + if (found !== undefined) return found; + } + const group = this.count++; + if (group >= this.head.length) { + const length = this.head.length * 2; + const head = new Int32Array(length); head.set(this.head); this.head = head; + for (const key of ['fidelity', 'continuity', 'tokens', 'extension', 'size'] as const) { + const copy = new Uint32Array(length); copy.set(this[key]); this[key] = copy; + } + } + this.head[group] = -1; this.size[group] = 0; + this.fidelity[group] = this.continuity[group] = this.tokens[group] = this.extension[group] = 0; + if (dense) { this.epochs[key] = this.epoch; this.slots[key] = group; } + else this.sparse.set(key, group); + return group; + } +} diff --git a/src/adaptive/kv-unified-packed.ts b/src/adaptive/kv-unified-packed.ts new file mode 100644 index 00000000..792582d1 --- /dev/null +++ b/src/adaptive/kv-unified-packed.ts @@ -0,0 +1,398 @@ +import type { PickerInputs } from './picker.js'; +import { CanonicalSummaryForest, SparseLabelCeilingError, type MinimumTokenResult } from './kv-unified.js'; +import { + ExactKvUnifiedPolicySolver, continuityLeafLoss, fidelityLeafLoss, normalizePolicy, +} from './kv-unified-policy.js'; +import { TerminalPolicyEvaluator, type FrontierTraceReference } from './kv-unified-terminal.js'; +import { scoreBoundedCandidates } from './kv-unified-selective.js'; +import { PackedBuckets, PackedLabels, LABEL_STRIDE, LabelField } from './kv-unified-packed-storage.js'; +import type { ApproximationEnvelope, ParetoPolicySolveResult, ParetoSolveOptions } from './kv-unified-pareto.js'; + +interface Action { + trace: number; + ids: readonly string[]; + level: number; + fidelity: number; + continuity: number; + tokens: number; + extension: number; + key?: string; + sequence: number; +} + +/** The same select/expand/grid algorithm as the object reference engine, + * using owned numeric handles and reusable bucket metadata. */ +export class PackedDagSolver { + private readonly s = new PackedLabels(); + private readonly leaves; + private readonly chunks; + private readonly age = new Map(); + private readonly newest: number; + private readonly policy; + private readonly cacheRelevant: boolean; + private readonly markers: Map; + private readonly cacheClasses: Map; + private readonly buckets: PackedBuckets; + private readonly tBucket: number; + private readonly kBucket: number; + private readonly fBucket: number; + private kBins = 0; + private fBins = 0; + private gridKeys = 0; + private readonly numericCache: boolean; + private readonly started = performance.now(); + private states = 0; + private created = 1; + private expanded = 0; + private dominated = 0; + private maximum = 1; + private errorScratch = new Float64Array(16); + private readonly members: number[] = []; + private readonly representatives: number[] = []; + + constructor(private readonly inputs: PickerInputs, private readonly forest: CanonicalSummaryForest, + private readonly options: ParetoSolveOptions, private readonly buffered: boolean, + private readonly approximationBound: (error: ApproximationEnvelope) => number, + private readonly knownFeasibility: Extract) { + this.policy = normalizePolicy(options.policy); + this.leaves = forest.orderedLeaves(); + this.chunks = new Map(inputs.chunks.map((chunk) => [chunk.id, chunk])); + this.newest = inputs.chunks.reduce((value, chunk) => Math.max(value, chunk.sequence), 0); + let age = 0; + for (let i = this.leaves.length - 1; i >= 0; i--) { + const leaf = this.leaves[i]; this.age.set(leaf.id, age + leaf.rawTokens / 2); age += leaf.rawTokens; + } + this.cacheRelevant = options.cache !== undefined && options.currentImmutablePrefixHash !== undefined && + options.cache.immutablePrefixHash === options.currentImmutablePrefixHash; + this.markers = new Map((options.cache?.markers ?? []).map((marker) => [marker.unitIndex, marker.offset])); + this.cacheClasses = new Map([...this.markers.keys()].filter((index) => index > 0).sort((a, b) => a - b) + .map((index, i) => [index, i + 1])); + this.tBucket = Math.max(0, Math.floor(options.tokenBucketSize ?? 0)); + this.kBucket = Math.max(0, options.continuityBucketSize ?? 0); + this.fBucket = Math.max(0, options.fidelityBucketSize ?? 0); + if (this.tBucket > 0 && this.kBucket > 0 && this.fBucket > 0) { + let maxK = 0, maxF = 0; + for (const leaf of this.leaves) { + const level = Math.max(...leaf.availableLevels); + const previous = options.presentation?.leaves.get(leaf.id)?.level ?? 0; + maxK += leaf.rawTokens * Math.max(1, previous, Math.abs(level - previous)); + if (!leaf.externallyAccounted) maxF += fidelityLeafLoss(this.chunks.get(leaf.id)!, level, this.newest, this.policy); + } + this.kBins = Math.ceil(maxK / this.kBucket) + 2; + this.fBins = Math.ceil(maxF / this.fBucket) + 2; + const keys = (Math.ceil(options.maxTokens / this.tBucket) + 2) * this.kBins * this.fBins; + if (Number.isSafeInteger(keys)) this.gridKeys = keys; + else this.kBins = this.fBins = 0; + } + const cacheKeys = this.gridKeys * (this.cacheClasses.size + 1); + this.numericCache = this.gridKeys > 0 && Number.isSafeInteger(cacheKeys); + this.buckets = new PackedBuckets(this.cacheRelevant ? (this.numericCache ? cacheKeys : 0) : this.gridKeys); + } + + private key(id: number): string | number { + const s = this.s; + const t = this.tBucket > 0 ? Math.ceil(s.data[(id) * LABEL_STRIDE + LabelField.Tokens] / this.tBucket) : s.data[(id) * LABEL_STRIDE + LabelField.Tokens]; + const k = this.kBucket > 0 ? Math.floor(s.data[(id) * LABEL_STRIDE + LabelField.Continuity] / this.kBucket) : 'c*'; + const f = this.fBucket > 0 ? Math.floor(s.data[(id) * LABEL_STRIDE + LabelField.Fidelity] / this.fBucket) : 'f*'; + if (this.cacheRelevant && s.data[(id) * LABEL_STRIDE + LabelField.Intact]) return [ + '0', t, 1, s.data[(id) * LABEL_STRIDE + LabelField.Matched], s.data[(id) * LABEL_STRIDE + LabelField.CachedTokens], + s.pending[id].map((emission) => `${emission.sequence}/${emission.kind}/${emission.key}`).join(','), k, f, + ].join(':'); + const numeric = this.kBins > 0 && typeof k === 'number' && typeof f === 'number' && + k >= 0 && k < this.kBins && f >= 0 && f < this.fBins; + const grid = numeric ? (t * this.kBins + k) * this.fBins + f : `${t}:${k}:${f}`; + if (!this.cacheRelevant) return grid; + const c = this.cacheClasses.get(s.data[(id) * LABEL_STRIDE + LabelField.CachedUnits]) ?? 0; + return this.numericCache && typeof grid === 'number' ? c * this.gridKeys + grid : `broken:${c}:${grid}`; + } + + private order(a: number, b: number): number { + const s = this.s; + return s.data[(a) * LABEL_STRIDE + LabelField.Fidelity] - s.data[(b) * LABEL_STRIDE + LabelField.Fidelity] || s.data[(a) * LABEL_STRIDE + LabelField.Continuity] - s.data[(b) * LABEL_STRIDE + LabelField.Continuity] || s.data[(a) * LABEL_STRIDE + LabelField.Tokens] - s.data[(b) * LABEL_STRIDE + LabelField.Tokens] || + (this.cacheRelevant ? s.data[(b) * LABEL_STRIDE + LabelField.Extension] - s.data[(a) * LABEL_STRIDE + LabelField.Extension] : 0) || s.data[(a) * LABEL_STRIDE + LabelField.Matched] - s.data[(b) * LABEL_STRIDE + LabelField.Matched]; + } + + private dominates(a: number, b: number): boolean { + const s = this.s; + return (!this.cacheRelevant || s.data[(a) * LABEL_STRIDE + LabelField.Extension] >= s.data[(b) * LABEL_STRIDE + LabelField.Extension]) && s.data[(a) * LABEL_STRIDE + LabelField.Tokens] <= s.data[(b) * LABEL_STRIDE + LabelField.Tokens] && + s.data[(a) * LABEL_STRIDE + LabelField.Continuity] <= s.data[(b) * LABEL_STRIDE + LabelField.Continuity] && s.data[(a) * LABEL_STRIDE + LabelField.Fidelity] <= s.data[(b) * LABEL_STRIDE + LabelField.Fidelity] && + (s.data[(a) * LABEL_STRIDE + LabelField.Tokens] < s.data[(b) * LABEL_STRIDE + LabelField.Tokens] || s.data[(a) * LABEL_STRIDE + LabelField.Continuity] < s.data[(b) * LABEL_STRIDE + LabelField.Continuity] || s.data[(a) * LABEL_STRIDE + LabelField.Fidelity] < s.data[(b) * LABEL_STRIDE + LabelField.Fidelity] || + (this.cacheRelevant && s.data[(a) * LABEL_STRIDE + LabelField.Extension] > s.data[(b) * LABEL_STRIDE + LabelField.Extension])); + } + + private prune(incoming: number[]): number[] { + const s = this.s, b = this.buckets; + this.states++; + b.begin(incoming.length); + const grid = this.kBucket > 0 && this.fBucket > 0; + for (let pos = 0; pos < incoming.length; pos++) { + const id = incoming[pos]; + if (s.data[(id) * LABEL_STRIDE + LabelField.Tokens] > this.options.maxTokens) { s.release(id); continue; } + const group = b.group(this.key(id)); + b.next[pos] = b.head[group]; b.head[group] = pos; + if (b.size[group]++ === 0) b.fidelity[group] = b.continuity[group] = b.tokens[group] = b.extension[group] = id; + else if (grid) { + if (this.order(id, b.fidelity[group]) < 0) b.fidelity[group] = id; + if ((s.data[(id) * LABEL_STRIDE + LabelField.Continuity] - s.data[(b.continuity[group]) * LABEL_STRIDE + LabelField.Continuity] || this.order(id, b.continuity[group])) < 0) b.continuity[group] = id; + if ((s.data[(id) * LABEL_STRIDE + LabelField.Tokens] - s.data[(b.tokens[group]) * LABEL_STRIDE + LabelField.Tokens] || this.order(id, b.tokens[group])) < 0) b.tokens[group] = id; + if (this.cacheRelevant && (s.data[(b.extension[group]) * LABEL_STRIDE + LabelField.Extension] - s.data[(id) * LABEL_STRIDE + LabelField.Extension] || this.order(id, b.extension[group])) < 0) b.extension[group] = id; + } + } + const result: number[] = []; + for (let group = 0; group < b.count; group++) { + if (b.size[group] === 1) { result.push(incoming[b.head[group]]); continue; } + const kept = this.representatives; kept.length = 0; + if (grid) { + const f = b.fidelity[group], k = b.continuity[group], t = b.tokens[group], e = b.extension[group]; + kept.push(f); + if (k !== f) kept.push(k); + if (t !== f && t !== k) kept.push(t); + if (this.cacheRelevant && e !== f && e !== k && e !== t) kept.push(e); + } else { + const pool = this.members; pool.length = 0; + for (let pos = b.head[group]; pos >= 0; pos = b.next[pos]) pool.push(incoming[pos]); + pool.reverse(); + for (const id of pool) if (!pool.some((other) => other !== id && this.dominates(other, id))) kept.push(id); + } + if (this.errorScratch.length < kept.length * 4) this.errorScratch = new Float64Array(kept.length * 8); + const errors = this.errorScratch; + for (let i = 0; i < kept.length; i++) { + const id = kept[i]; + errors[i * 4] = s.data[(id) * LABEL_STRIDE + LabelField.TokenError]; errors[i * 4 + 1] = s.data[(id) * LABEL_STRIDE + LabelField.ContinuityError]; + errors[i * 4 + 2] = s.data[(id) * LABEL_STRIDE + LabelField.FidelityError]; errors[i * 4 + 3] = s.data[(id) * LABEL_STRIDE + LabelField.CacheError]; + } + // Read every original envelope before writing any representative. The + // max reductions are order-independent; ties still use F/K/T/E order. + for (let pos = b.head[group]; pos >= 0; pos = b.next[pos]) { + const source = incoming[pos]; + let chosen = 0, best = Infinity; + for (let i = 0; i < kept.length; i++) { + const target = kept[i]; + const cost = Math.abs(s.data[(target) * LABEL_STRIDE + LabelField.Tokens] - s.data[(source) * LABEL_STRIDE + LabelField.Tokens]) + + Math.max(0, s.data[(target) * LABEL_STRIDE + LabelField.Continuity] - s.data[(source) * LABEL_STRIDE + LabelField.Continuity]) + + Math.max(0, s.data[(target) * LABEL_STRIDE + LabelField.Fidelity] - s.data[(source) * LABEL_STRIDE + LabelField.Fidelity]) + + (this.cacheRelevant ? Math.max(0, s.data[(source) * LABEL_STRIDE + LabelField.Extension] - s.data[(target) * LABEL_STRIDE + LabelField.Extension]) : 0); + if (cost < best) { best = cost; chosen = i; } + } + const target = kept[chosen], at = chosen * 4; + errors[at] = Math.max(errors[at], s.data[(source) * LABEL_STRIDE + LabelField.TokenError] + Math.abs(s.data[(target) * LABEL_STRIDE + LabelField.Tokens] - s.data[(source) * LABEL_STRIDE + LabelField.Tokens])); + errors[at + 1] = Math.max(errors[at + 1], s.data[(source) * LABEL_STRIDE + LabelField.ContinuityError] + Math.max(0, s.data[(target) * LABEL_STRIDE + LabelField.Continuity] - s.data[(source) * LABEL_STRIDE + LabelField.Continuity])); + errors[at + 2] = Math.max(errors[at + 2], s.data[(source) * LABEL_STRIDE + LabelField.FidelityError] + Math.max(0, s.data[(target) * LABEL_STRIDE + LabelField.Fidelity] - s.data[(source) * LABEL_STRIDE + LabelField.Fidelity])); + if (this.cacheRelevant) errors[at + 3] = Math.max(errors[at + 3], s.data[(source) * LABEL_STRIDE + LabelField.CacheError] + Math.max(0, s.data[(source) * LABEL_STRIDE + LabelField.Extension] - s.data[(target) * LABEL_STRIDE + LabelField.Extension])); + } + for (let i = 0; i < kept.length; i++) { + const id = kept[i]; + s.data[(id) * LABEL_STRIDE + LabelField.TokenError] = errors[i * 4]; s.data[(id) * LABEL_STRIDE + LabelField.ContinuityError] = errors[i * 4 + 1]; + s.data[(id) * LABEL_STRIDE + LabelField.FidelityError] = errors[i * 4 + 2]; s.data[(id) * LABEL_STRIDE + LabelField.CacheError] = errors[i * 4 + 3]; + result.push(id); + } + for (let pos = b.head[group]; pos >= 0; pos = b.next[pos]) if (!kept.includes(incoming[pos])) s.release(incoming[pos]); + this.dominated += b.size[group] - kept.length; + } + this.created += result.length; + this.maximum = Math.max(this.maximum, result.length); + const ceiling = this.options.labelCeiling ?? 1_000_000; + if (result.length > ceiling) throw new SparseLabelCeilingError(ceiling, result.length); + if (this.states % 1000 === 0) this.progress('propagate', result.length); + return result; + } + + private progress(phase: string, labels: number): void { + this.options.onProgress?.({ phase, labels, states: this.states, elapsedMs: performance.now() - this.started }); + } + + private emit(id: number, kind: 'head' | 'raw' | 'recall' | 'tail', key: string, + tokens: number, extension: boolean, sequence = 0): void { + const s = this.s; + s.data[(id) * LABEL_STRIDE + LabelField.Tokens] += tokens; + if (extension) s.data[(id) * LABEL_STRIDE + LabelField.Extension] += tokens; + if (this.buffered && s.data[(id) * LABEL_STRIDE + LabelField.Intact] && (kind === 'raw' || kind === 'recall')) { + s.pending[id] = [...s.pending[id], { kind, key, tokens, sequence }]; return; + } + if (s.data[(id) * LABEL_STRIDE + LabelField.Intact]) { + const previous = this.options.cache?.layout.units[s.data[(id) * LABEL_STRIDE + LabelField.Matched]]; + if (previous?.kind === kind && previous.key === key) { + s.data[(id) * LABEL_STRIDE + LabelField.Matched]++; + const marker = this.markers.get(s.data[(id) * LABEL_STRIDE + LabelField.Matched]); + if (marker !== undefined) { s.data[(id) * LABEL_STRIDE + LabelField.CachedTokens] = marker; s.data[(id) * LABEL_STRIDE + LabelField.CachedUnits] = s.data[(id) * LABEL_STRIDE + LabelField.Matched]; } + } else s.data[(id) * LABEL_STRIDE + LabelField.Intact] = 0; + } + } + + private flush(id: number, before: number): void { + const s = this.s; + if (!this.buffered || !s.data[(id) * LABEL_STRIDE + LabelField.Intact] || s.pending[id].length === 0) return; + const ordered = [...s.pending[id]].sort((a, b) => a.sequence - b.sequence || a.kind.localeCompare(b.kind) || a.key.localeCompare(b.key)); + let consumed = 0; + while (consumed < ordered.length && ordered[consumed].sequence < before) { + const emission = ordered[consumed++]; + const previous = this.options.cache?.layout.units[s.data[(id) * LABEL_STRIDE + LabelField.Matched]]; + if (previous?.kind === emission.kind && previous.key === emission.key) { + s.data[(id) * LABEL_STRIDE + LabelField.Matched]++; + const marker = this.markers.get(s.data[(id) * LABEL_STRIDE + LabelField.Matched]); + if (marker !== undefined) { s.data[(id) * LABEL_STRIDE + LabelField.CachedTokens] = marker; s.data[(id) * LABEL_STRIDE + LabelField.CachedUnits] = s.data[(id) * LABEL_STRIDE + LabelField.Matched]; } + } else { s.data[(id) * LABEL_STRIDE + LabelField.Intact] = 0; s.pending[id] = []; return; } + } + s.pending[id] = ordered.slice(consumed); + } + + private action(ids: readonly string[], level: number, summaryId?: string): Action { + let fidelity = 0, continuity = 0, tokens = 0, extension = 0, sequence = Infinity; + const presentation = this.options.presentation; + for (const id of ids) { + const chunk = this.chunks.get(id)!; + sequence = Math.min(sequence, chunk.sequence); + if (level > 0) fidelity += fidelityLeafLoss(chunk, level, this.newest, this.policy); + continuity += continuityLeafLoss(chunk, level, level === 0 ? `raw:${id}` : `summary:${summaryId}`, + presentation?.leaves.get(id), presentation?.currentSeq ?? 0, this.age.get(id)!, this.policy); + if (level === 0) { + tokens += chunk.rawTokens; + if (presentation && !presentation.leaves.has(id)) extension += chunk.rawTokens; + } + } + if (summaryId) { + const summary = this.forest.summary(summaryId)!; + tokens = summary.recallTokens; + if (presentation && summary.leafIds.length > 0 && summary.leafIds.every((id) => !presentation.leaves.has(id))) extension = tokens; + } + return { trace: ids.length ? this.s.traces.action(ids, level) : 0, ids, level, fidelity, continuity, tokens, extension, key: summaryId, sequence }; + } + + private apply(id: number, action: Action): void { + const s = this.s; + if (!action.trace) return; + s.assign(id, action.trace, action.fidelity, action.continuity); + if (!s.data[(id) * LABEL_STRIDE + LabelField.Intact]) { s.data[(id) * LABEL_STRIDE + LabelField.Tokens] += action.tokens; s.data[(id) * LABEL_STRIDE + LabelField.Extension] += action.extension; return; } + if (action.key) this.emit(id, 'recall', action.key, action.tokens, action.extension > 0, action.sequence); + else for (const leafId of action.ids) { + const leaf = this.forest.leaf(leafId)!; + this.emit(id, 'raw', leafId, leaf.rawTokens, + this.options.presentation !== undefined && !this.options.presentation.leaves.has(leafId), leaf.sequence); + } + } + + private children(summaryId: string, incoming: number[], active: ReadonlySet | undefined, limit: number): number[] { + const summary = this.forest.summary(summaryId)!; + const children = [ + ...summary.directLeafIds.map((id) => ({ kind: 'leaf' as const, id, sequence: this.forest.leaf(id)!.sequence })), + ...summary.childSummaryIds.map((id) => ({ kind: 'summary' as const, id, sequence: this.forest.summary(id)!.firstSequence })), + ].sort((a, b) => a.sequence - b.sequence || a.id.localeCompare(b.id)).filter((child) => !active || + (child.kind === 'leaf' ? active.has(child.id) : this.forest.summary(child.id)!.leafIds.some((id) => active.has(id)))); + let labels = incoming; + for (let at = 0; at < children.length;) { + const child = children[at]; + if (this.cacheRelevant && this.buffered) for (const id of labels) this.flush(id, Math.min(child.sequence, limit)); + if (child.kind === 'summary') { + labels = this.summary(child.id, labels, active, Math.min(limit, children[at + 1]?.sequence ?? Infinity)); + at++; continue; + } + const ids: string[] = []; + while (at < children.length && children[at].kind === 'leaf') { + const id = children[at++].id; + if (!this.forest.leaf(id)!.externallyAccounted) ids.push(id); + } + if (ids.some((id) => !this.forest.leaf(id)!.allowedLevels.includes(0))) { + for (const id of labels) this.s.release(id); + labels = []; + } else { + const action = this.action(ids, 0); + for (const id of labels) this.apply(id, action); + } + labels = this.prune(labels); + } + return labels; + } + + private summary(summaryId: string, incoming: number[], active: ReadonlySet | undefined, limit: number): number[] { + this.expanded += incoming.length; + const summary = this.forest.summary(summaryId)!; + const live = summary.leafIds.filter((id) => (!active || active.has(id)) && !this.forest.leaf(id)!.externallyAccounted); + const participants = live.filter((id) => this.forest.leaf(id)!.allowedLevels.includes(summary.level)); + const holes = live.filter((id) => !this.forest.leaf(id)!.allowedLevels.includes(summary.level)); + let selected: number[] = []; + if (participants.length) { + const action = this.action(participants, summary.level, summaryId); + selected = incoming.map((id) => { const copy = this.s.clone(id); this.apply(copy, action); return copy; }); + if (holes.length) selected = this.children(summaryId, selected, new Set(holes), limit); + } + const expanded = this.children(summaryId, incoming, active, limit); + return this.prune(selected.concat(expanded)); + } + + solve(): ParetoPolicySolveResult { + const feasibility = this.knownFeasibility; + const s = this.s; + const initial = s.initial(this.cacheRelevant, this.leaves.filter((leaf) => leaf.externallyAccounted).map((leaf) => leaf.id)); + if (this.inputs.headTokens > 0) this.emit(initial, 'head', 'head', this.inputs.headTokens, false); + let labels = [initial]; + for (let at = 0; at < this.forest.roots.length; at++) { + const root = this.forest.roots[at]; + if (this.cacheRelevant && this.buffered) for (const id of labels) this.flush(id, root.firstSequence); + if (root.kind === 'summary') labels = this.summary(root.id, labels, undefined, this.forest.roots[at + 1]?.firstSequence ?? Infinity); + else { + const leaf = this.forest.leaf(root.id)!; + if (!leaf.externallyAccounted) { + const action = this.action([leaf.id], 0); + for (const id of labels) this.apply(id, action); + } + labels = this.prune(labels); + } + } + this.progress('propagated', labels.length); + const evaluator = new TerminalPolicyEvaluator(this.inputs, this.forest, this.options); + const terminal: Array<{ trace: FrontierTraceReference; tokens: number }> = []; + const error: ApproximationEnvelope = { token: 0, continuity: 0, fidelity: 0, cache: 0 }; + for (const id of labels) { + error.token = Math.max(error.token, s.data[(id) * LABEL_STRIDE + LabelField.TokenError]); error.continuity = Math.max(error.continuity, s.data[(id) * LABEL_STRIDE + LabelField.ContinuityError]); + error.fidelity = Math.max(error.fidelity, s.data[(id) * LABEL_STRIDE + LabelField.FidelityError]); error.cache = Math.max(error.cache, s.data[(id) * LABEL_STRIDE + LabelField.CacheError]); + this.flush(id, Infinity); + if (this.inputs.tailTokens > 0) this.emit(id, 'tail', 'tail', this.inputs.tailTokens, false); + if (s.data[(id) * LABEL_STRIDE + LabelField.Tokens] <= this.options.maxTokens) terminal.push({ + trace: s.traces.reference(s.finishTrace(id)), tokens: s.data[(id) * LABEL_STRIDE + LabelField.Tokens], + }); + s.release(id); + } + const roundoff = 32 * Number.EPSILON * this.leaves.length * Math.max(1, this.options.maxTokens); + if (!terminal.some((candidate) => { + if (Math.abs(candidate.tokens - feasibility.floorTokens) > roundoff) return false; + const prepared = evaluator.candidate(candidate.trace, candidate.tokens); + return [...feasibility.frontier].every(([id, level]) => prepared.frontier.get(id) === level); + })) { + const byLevel = new Map(); + for (const [id, level] of feasibility.frontier) { const ids = byLevel.get(level); if (ids) ids.push(id); else byLevel.set(level, [id]); } + let trace = 0; + for (const [level, ids] of byLevel) trace = s.traces.append(trace, s.traces.action(ids, level)); + terminal.push({ trace: s.traces.reference(trace), tokens: feasibility.floorTokens }); + } + const stats = { + statesVisited: this.states, candidatesGenerated: this.created, maxCandidatesAtState: this.maximum, terminalCandidates: terminal.length, + }; + const full = this.options.terminalEvaluation === 'full'; + const candidates = full ? terminal.map((candidate) => evaluator.candidate(candidate.trace, candidate.tokens)) : undefined; + const bounded = full ? undefined : terminal.map((candidate) => evaluator.estimate(candidate.trace, candidate.tokens)); + this.progress('evaluated', terminal.length); + const result = full + ? new ExactKvUnifiedPolicySolver(this.inputs, this.forest).scorePreparedCandidates(candidates!, this.options, stats, this.cacheRelevant) + : scoreBoundedCandidates(bounded!, this.options, stats, this.cacheRelevant, evaluator.orderedLeafIds); + this.progress('scored', terminal.length); + const propagation = { + labelsCreated: this.created, labelsExpanded: this.expanded, labelsDominated: this.dominated, + states: this.states, maxLabelsPerState: this.maximum, terminalLabels: terminal.length, + tokenBucketSize: this.tBucket, continuityBucketSize: this.kBucket, fidelityBucketSize: this.fBucket, + approximationBounded: true, approximationScoreErrorBound: this.approximationBound(error), + approximationTokenErrorBound: error.token, approximationContinuityErrorBound: error.continuity, + approximationFidelityErrorBound: error.fidelity, approximationCacheErrorBound: error.cache, + storageMode: 'packed' as const, packedLabelSlots: s.slots, packedTraceNodes: s.traces.nodes, + terminalEvaluationMode: full ? 'full' as const : 'selective' as const, + exactTerminalEvaluations: evaluator.exactEvaluations, + }; + // Do not spread result: its candidate-list getter deliberately performs + // the remaining exact work only when an observer requests that list. + return { + feasible: true, selected: result.selected, cacheFloor: result.cacheFloor, + continuityFloor: result.continuityFloor, cacheRelevant: result.cacheRelevant, + enumeration: result.enumeration, propagation, + get candidates() { return result.candidates; }, + }; + } +} diff --git a/src/adaptive/kv-unified-pareto.ts b/src/adaptive/kv-unified-pareto.ts index cc0d24bd..f9c32979 100644 --- a/src/adaptive/kv-unified-pareto.ts +++ b/src/adaptive/kv-unified-pareto.ts @@ -2,6 +2,7 @@ import type { ChunkId } from './folding-strategy.js'; import type { PickerInputs } from './picker.js'; import { certifyCarriedLayout, type HysteresisCertificate } from './kv-unified-certificate.js'; import { TerminalPolicyEvaluator } from './kv-unified-terminal.js'; +import { PackedDagSolver } from './kv-unified-packed.js'; import { CanonicalSummaryForest, SparseLabelCeilingError, @@ -51,7 +52,7 @@ interface ParetoLabel { approximation: ApproximationEnvelope; } -interface ApproximationEnvelope { +export interface ApproximationEnvelope { token: number; continuity: number; fidelity: number; @@ -67,6 +68,11 @@ interface AssignmentTrace { } export interface ParetoPropagationStats { + readonly storageMode?: 'packed' | 'objects'; + readonly packedLabelSlots?: number; + readonly packedTraceNodes?: number; + readonly terminalEvaluationMode?: 'full' | 'selective'; + readonly exactTerminalEvaluations?: number; readonly labelsCreated: number; readonly labelsExpanded: number; readonly labelsDominated: number; @@ -99,6 +105,10 @@ export type ParetoSolveOptions = ExactPolicySolveOptions & { engine?: 'auto' | 'leaf' | 'dag'; /** Opt-in prototype: prove the hysteresis selection before propagating labels. */ hysteresisCertificate?: boolean; + /** Diagnostic reference path; the normal DAG path uses packed storage. */ + storage?: 'packed' | 'objects'; + /** Diagnostic exhaustive rescoring; selection otherwise uses exact bounds. */ + terminalEvaluation?: 'full' | 'selective'; /** Optional diagnostic observer; never used to choose a cut or stop a solve. */ onProgress?: (event: { phase: string; elapsedMs: number; states: number; labels: number }) => void; }; @@ -139,6 +149,12 @@ export class ParetoKvUnifiedPolicySolver { if (certified) return certified; } if (options.engine !== 'leaf') { + if (options.storage !== 'objects') { + const feasibility = this.forest.minimumTokens(options.maxTokens); + if (!feasibility.feasible) return { feasible: false, feasibility }; + return new PackedDagSolver(this.inputs, this.forest, options, gapBearingOwnership || internalHoles, + (error) => this.approximationBound(options, normalizePolicy(options.policy), error), feasibility).solve(); + } this.bufferGapEmissions = gapBearingOwnership || internalHoles; try { return this.solveDag(options); diff --git a/src/adaptive/kv-unified-policy.ts b/src/adaptive/kv-unified-policy.ts index b3f86088..0524d89b 100644 --- a/src/adaptive/kv-unified-policy.ts +++ b/src/adaptive/kv-unified-policy.ts @@ -231,12 +231,8 @@ export class ExactKvUnifiedPolicySolver { const candidates: ExactPolicyCandidate[] = unscored.map((candidate) => { const cacheExcess = Math.max(0, candidate.cacheChurn - cacheFloor); const continuityExcess = Math.max(0, candidate.continuityLoss - continuityFloor); - const score = - candidate.fidelityLoss + - candidate.budgetPenalty + - quadratic(cacheExcess, policy.cacheScale, policy.cacheLambda) + - continuityMultiplier * - quadratic(continuityExcess, policy.continuityScale, policy.continuityLambda); + const score = policyScore(candidate.fidelityLoss, candidate.budgetPenalty, + candidate.cacheChurn, candidate.continuityLoss, cacheFloor, continuityFloor, policy, continuityMultiplier); const result = { get frontier() { return candidate.frontier; }, get layout() { return candidate.layout; }, @@ -248,13 +244,8 @@ export class ExactKvUnifiedPolicySolver { matching.set(result, candidate.matchesPresentation); return result; }); - candidates.sort((a, b) => - a.score - b.score || - a.renderedTokens - b.renderedTokens || - frontierSignature(a.frontier, this.orderedChunks.map((chunk) => chunk.id)).localeCompare( - frontierSignature(b.frontier, this.orderedChunks.map((chunk) => chunk.id)), - ), - ); + const leafIds = this.orderedChunks.map((chunk) => chunk.id); + candidates.sort((a, b) => comparePolicyCandidates(a, b, leafIds)); let selected = candidates[0]; const epsilon = Number.isFinite(options.adoptEpsilon) && (options.adoptEpsilon ?? 0) > 0 @@ -488,13 +479,25 @@ function clamp(value: number, low: number, high: number): number { return Math.min(high, Math.max(low, value)); } -function normalizeContinuityMultiplier(value: number | undefined): number { +export function normalizeContinuityMultiplier(value: number | undefined): number { if (value === undefined) return 1; // Malformed relaxation fails closed: it must never make continuity cheaper. if (!Number.isFinite(value) || value < 0 || value > 1) return 1; return value; } +export function comparePolicyCandidates(a: ExactPolicyCandidate, b: ExactPolicyCandidate, + leafIds: readonly ChunkId[]): number { + return a.score - b.score || a.renderedTokens - b.renderedTokens || + frontierSignature(a.frontier, leafIds).localeCompare(frontierSignature(b.frontier, leafIds)); +} + +export function policyScore(fidelity: number, budget: number, cache: number, continuity: number, + cacheFloor: number, continuityFloor: number, policy: KvUnifiedWelfarePolicy, multiplier: number): number { + return fidelity + budget + quadratic(Math.max(0, cache - cacheFloor), policy.cacheScale, policy.cacheLambda) + + multiplier * quadratic(Math.max(0, continuity - continuityFloor), policy.continuityScale, policy.continuityLambda); +} + function frontierSignature( frontier: ReadonlyMap, leafIds: readonly ChunkId[], diff --git a/src/adaptive/kv-unified-selective.ts b/src/adaptive/kv-unified-selective.ts new file mode 100644 index 00000000..2460d844 --- /dev/null +++ b/src/adaptive/kv-unified-selective.ts @@ -0,0 +1,135 @@ +import type { ChunkId } from './folding-strategy.js'; +import type { ExactCutEnumerationStats } from './kv-unified.js'; +import { + comparePolicyCandidates, normalizeContinuityMultiplier, normalizePolicy, policyScore, + type ExactPolicyCandidate, type ExactPolicySolveOptions, type ExactPolicySolveResult, type UnscoredCandidate, +} from './kv-unified-policy.js'; + +export interface MetricInterval { readonly lower: number; readonly upper: number } +export interface BoundedPolicyCandidate { + readonly renderedTokens: number; + readonly budgetPenalty: number; + readonly fidelity: MetricInterval; + readonly continuity: MetricInterval; + /** Exact chronological cache calculation, not a propagation estimate. */ + readonly cacheChurn: number; + readonly matchesPresentation: boolean; + exact(): UnscoredCandidate; +} + +const bitsBuffer = new ArrayBuffer(8); +const floating = new Float64Array(bitsBuffer); +const bits = new BigUint64Array(bitsBuffer); +function nextUp(value: number): number { + if (Number.isNaN(value) || value === Infinity) return value; + if (value === 0) return Number.MIN_VALUE; + floating[0] = value; + bits[0] += value > 0 ? 1n : -1n; + return floating[0]; +} +function nextDown(value: number): number { return -nextUp(-value); } + +/** Both sums use the same nonnegative binary64 leaf terms. The grouped sum + * performs at most 2n additions and the oracle at most n. 16*epsilon*(n+1) + * exceeds their combined gamma bounds (including the scale conversion) when + * it is below 1/4. Outward rounding and a subnormal allowance widen it further. + * A zero sum of nonnegative floating terms is exactly zero. */ +export function nonnegativeSumInterval(value: number, leaves: number): MetricInterval { + const factor = 16 * Number.EPSILON * (leaves + 1); + if (!Number.isFinite(value) || value < 0 || factor >= 0.25) return { lower: 0, upper: Infinity }; + if (value === 0) return { lower: 0, upper: 0 }; + const error = nextUp(value * factor + (leaves + 1) * Number.MIN_VALUE); + return { lower: Math.max(0, nextDown(value - error)), upper: nextUp(value + error) }; +} + +/** Preserve exact floors, exact winner/hysteresis/ties, and the full public + * candidate list. Only metrics needed for the decision are evaluated now; + * inspecting candidates later performs the remaining exact work lazily. */ +export function scoreBoundedCandidates(source: readonly BoundedPolicyCandidate[], options: ExactPolicySolveOptions, + stats: ExactCutEnumerationStats, cacheRelevant: boolean, leafIds: readonly ChunkId[]): Extract { + if (source.length === 0) throw new Error('kv-unified has no feasible terminal candidates'); + const policy = normalizePolicy(options.policy); + const multiplier = normalizeContinuityMultiplier(options.continuityMultiplier); + const epsilon = options.presentation && Number.isFinite(options.adoptEpsilon) && (options.adoptEpsilon ?? 0) > 0 + ? options.adoptEpsilon! : 0; + const exact: Array = new Array(source.length); + const refine = (index: number) => exact[index] ??= source[index].exact(); + let fullyEvaluate = source.some((candidate) => + !Number.isFinite(candidate.fidelity.upper) || !Number.isFinite(candidate.continuity.upper) || + !Number.isFinite(candidate.cacheChurn) || !Number.isFinite(candidate.budgetPenalty)); + + // Cache churn is exact for every candidate. Continuity gets its own floor + // search, independent of whether a candidate could ever win on welfare. + let cacheFloor = Infinity; + let continuityFloor = Infinity; + if (!fullyEvaluate) { + let witness = 0; + for (let i = 0; i < source.length; i++) { + cacheFloor = Math.min(cacheFloor, source[i].cacheChurn); + if (source[i].continuity.upper < source[witness].continuity.upper) witness = i; + } + continuityFloor = source[witness].continuity.upper === 0 ? 0 : refine(witness).continuityLoss; + for (let i = 0; i < source.length; i++) { + if (source[i].continuity.lower < continuityFloor) continuityFloor = Math.min(continuityFloor, refine(i).continuityLoss); + } + } + + const lower = new Float64Array(source.length); + let upperBest = Infinity, upperMatching = Infinity; + if (!fullyEvaluate) for (let i = 0; i < source.length; i++) { + const c = source[i]; + lower[i] = policyScore(c.fidelity.lower, c.budgetPenalty, c.cacheChurn, c.continuity.lower, + cacheFloor, continuityFloor, policy, multiplier); + const upper = policyScore(c.fidelity.upper, c.budgetPenalty, c.cacheChurn, c.continuity.upper, + cacheFloor, continuityFloor, policy, multiplier); + if (!Number.isFinite(lower[i]) || !Number.isFinite(upper)) { fullyEvaluate = true; break; } + upperBest = Math.min(upperBest, upper); + if (epsilon > 0 && c.matchesPresentation) upperMatching = Math.min(upperMatching, upper); + } + if (fullyEvaluate) { + cacheFloor = Infinity; continuityFloor = Infinity; + for (let i = 0; i < source.length; i++) { + const c = refine(i); + cacheFloor = Math.min(cacheFloor, c.cacheChurn); + continuityFloor = Math.min(continuityFloor, c.continuityLoss); + } + } + + const scored: Array = new Array(source.length); + const score = (index: number): ExactPolicyCandidate => { + if (scored[index]) return scored[index]!; + const c = refine(index); + return scored[index] = { + get frontier() { return c.frontier; }, get layout() { return c.layout; }, + renderedTokens: c.renderedTokens, fidelityLoss: c.fidelityLoss, continuityLoss: c.continuityLoss, + cacheChurn: c.cacheChurn, budgetPenalty: c.budgetPenalty, + cacheExcess: Math.max(0, c.cacheChurn - cacheFloor), continuityExcess: Math.max(0, c.continuityLoss - continuityFloor), + score: policyScore(c.fidelityLoss, c.budgetPenalty, c.cacheChurn, c.continuityLoss, cacheFloor, continuityFloor, policy, multiplier), + }; + }; + let best: ExactPolicyCandidate | undefined; + let carried: ExactPolicyCandidate | undefined; + let candidates: ExactPolicyCandidate[] | undefined; + if (fullyEvaluate) { + const matching = new Set(source.flatMap((candidate, index) => candidate.matchesPresentation ? [score(index)] : [])); + candidates = source.map((_candidate, index) => score(index)).sort((a, b) => comparePolicyCandidates(a, b, leafIds)); + best = candidates[0]; + if (epsilon > 0) carried = candidates.find((candidate) => matching.has(candidate)); + } + for (let i = 0; !fullyEvaluate && i < source.length; i++) { + const matching = epsilon > 0 && source[i].matchesPresentation; + if (!fullyEvaluate && lower[i] > upperBest && (!matching || lower[i] > upperMatching)) continue; + const c = score(i); + if (!best || comparePolicyCandidates(c, best, leafIds) < 0) best = c; + if (matching && (!carried || comparePolicyCandidates(c, carried, leafIds) < 0)) carried = c; + } + if (!best) throw new Error('kv-unified score intervals failed to retain a winner'); + const selected = carried && carried.score <= best.score + epsilon ? carried : best; + return { + feasible: true, selected, cacheFloor, continuityFloor, cacheRelevant, enumeration: stats, + get candidates() { + return candidates ??= source.map((_candidate, index) => score(index)) + .sort((a, b) => comparePolicyCandidates(a, b, leafIds)); + }, + }; +} diff --git a/src/adaptive/kv-unified-terminal.ts b/src/adaptive/kv-unified-terminal.ts index cb85dfe3..36dfb0d0 100644 --- a/src/adaptive/kv-unified-terminal.ts +++ b/src/adaptive/kv-unified-terminal.ts @@ -6,6 +6,7 @@ import { type ExactPolicySolveOptions, type UnscoredCandidate, } from './kv-unified-policy.js'; import type { RenderLayout, RenderedUnit } from './render-offsets.js'; +import { nonnegativeSumInterval, type BoundedPolicyCandidate } from './kv-unified-selective.js'; export interface FrontierTrace { readonly parent: FrontierTrace | null; @@ -13,6 +14,27 @@ export interface FrontierTrace { readonly level: number; } +export interface FrontierTraceSource { + forEachAssignment(visit: (ids: readonly ChunkId[], level: number) => void): void; +} + +export type FrontierTraceReference = FrontierTrace | FrontierTraceSource | null; + +interface CompiledAction { + readonly fidelity: number; + readonly continuity: number; + readonly matches: boolean; + readonly leaves: number; + /** Pairs: emitted unit code, earliest assigned leaf index. */ + readonly emissions: Uint32Array; +} + +export function visitFrontierTrace(trace: FrontierTraceReference, + visit: (ids: readonly ChunkId[], level: number) => void): void { + if (trace && 'forEachAssignment' in trace) trace.forEachAssignment(visit); + else for (let cursor = trace; cursor; cursor = cursor.parent) visit(cursor.ids, cursor.level); +} + /** Precompute per-leaf terms once, then sum in exactly the oracle's order. * Frontier Maps and rendered layouts remain lazy until a caller needs them. * This removes billions of repeated powers/hash lookups and keeps only one @@ -22,6 +44,7 @@ export class TerminalPolicyEvaluator { private readonly ids: readonly string[]; private readonly index: Map; private readonly ranges = new WeakMap(); + private readonly actions = new WeakMap>(); private readonly stride: number; private readonly fidelity: Float64Array; private readonly continuity: Float64Array; @@ -33,17 +56,23 @@ export class TerminalPolicyEvaluator { private readonly unitKinds: RenderedUnit['kind'][]; private readonly unitExtension: Uint8Array; private readonly emitted: Uint32Array; + private readonly firstEmission: Uint32Array; + private readonly orderedEmissions: Uint32Array; + private readonly emissionAtLeaf: Uint32Array; private readonly previousUnits: Uint32Array; private readonly markerUnits: ReadonlySet; private readonly headCode: number; private readonly tailCode: number; private generation = 0; private readonly policy; + private readonly maxTokens: number; + private evaluations = 0; readonly cacheRelevant: boolean; constructor(private readonly inputs: PickerInputs, private readonly forest: CanonicalSummaryForest, private readonly options: ExactPolicySolveOptions) { this.policy = normalizePolicy(options.policy); + this.maxTokens = options.maxTokens; this.chunks = [...inputs.chunks].sort((a, b) => a.sequence - b.sequence || a.id.localeCompare(b.id)); this.ids = this.chunks.map((chunk) => chunk.id); this.index = new Map(this.chunks.map((chunk, index) => [chunk.id, index])); @@ -62,6 +91,9 @@ export class TerminalPolicyEvaluator { this.unitKinds = new Array(this.tailCode + 1); this.unitExtension = new Uint8Array(this.tailCode + 1); this.emitted = new Uint32Array(this.tailCode + 1); + this.firstEmission = new Uint32Array(this.tailCode + 1); + this.orderedEmissions = new Uint32Array(this.tailCode + 1); + this.emissionAtLeaf = new Uint32Array(this.chunks.length); const presentation = options.presentation; for (const summary of summaries) { const code = summaryCode.get(summary.id)!; @@ -108,27 +140,119 @@ export class TerminalPolicyEvaluator { } } - private fill(trace: FrontierTrace | null): void { + get orderedLeafIds(): readonly string[] { return this.ids; } + get exactEvaluations(): number { return this.evaluations; } + + private compileAction(ids: readonly string[], level: number): CompiledAction { + let levels = this.actions.get(ids); + const cached = levels?.get(level); + if (cached) return cached; + let fidelity = 0, continuity = 0, matches = true; + const units = new Map(); + for (const id of ids) { + const index = this.index.get(id)!; + const at = index * this.stride + level; + fidelity += this.fidelity[at]; continuity += this.continuity[at]; + matches &&= this.matches[at] === 1; + const code = this.representations[at]; + if (code) units.set(code, Math.min(units.get(code) ?? Infinity, index)); + } + const emissions = new Uint32Array(units.size * 2); + let cursor = 0; + for (const [code, index] of units) { emissions[cursor++] = code; emissions[cursor++] = index; } + const result = { fidelity, continuity, matches, leaves: ids.length, emissions }; + if (!levels) { levels = new Map(); this.actions.set(ids, levels); } + levels.set(level, result); + return result; + } + + /** Sum compiled action terms instead of scanning every leaf for every cut. + * Cache is exact: deduplicate emitted units, sort their first assigned leaf + * indices, and perform the same chronological offset/extension arithmetic. + * No cache-prefix estimate from propagation is trusted here. */ + estimate(trace: FrontierTraceReference, renderedTokens: number): BoundedPolicyCandidate { + let exact: UnscoredCandidate | undefined; + const refine = () => exact ??= this.candidate(trace, renderedTokens); + let fidelity = 0, continuity = 0, matches = true, leaves = 0, unitCount = 0; + const generation = ++this.generation; + visitFrontierTrace(trace, (ids, level) => { + const action = this.compileAction(ids, level); + fidelity += action.fidelity; continuity += action.continuity; + matches &&= action.matches; leaves += action.leaves; + if (!this.cacheRelevant) return; + for (let i = 0; i < action.emissions.length; i += 2) { + const code = action.emissions[i], index = action.emissions[i + 1]; + if (this.emitted[code] !== generation) { + this.emitted[code] = generation; this.firstEmission[code] = index; + this.orderedEmissions[unitCount++] = code; + } else this.firstEmission[code] = Math.min(this.firstEmission[code], index); + } + }); + // Internal solver traces are complete disjoint cuts. Partial diagnostic + // traces keep the old default-raw behavior by using the exact evaluator. + if (leaves !== this.ids.length || !Number.isFinite(fidelity) || !Number.isFinite(continuity)) { + const c = refine(); + return { renderedTokens, budgetPenalty: c.budgetPenalty, cacheChurn: c.cacheChurn, + fidelity: { lower: c.fidelityLoss, upper: c.fidelityLoss }, + continuity: { lower: c.continuityLoss, upper: c.continuityLoss }, + matchesPresentation: c.matchesPresentation!, exact: refine }; + } + let cacheChurn = 0; + if (this.cacheRelevant) { + // Convert to first-leaf indices so TypedArray's native numeric sort can + // be used without a JS comparator or per-emission objects. + for (let i = 0; i < unitCount; i++) { + const code = this.orderedEmissions[i], index = this.firstEmission[code]; + this.emissionAtLeaf[index] = code; this.orderedEmissions[i] = index; + } + const ordered = this.orderedEmissions.subarray(0, unitCount); + ordered.sort(); + let offset = 0, cached = 0, prefix = 0, extension = 0; + let intact = true; + const emit = (code: number) => { + const tokens = this.unitTokens[code]; offset += tokens; + if (this.unitExtension[code]) extension += tokens; + if (intact) { + if (this.previousUnits[prefix] === code) { + prefix++; + if (this.markerUnits.has(prefix)) cached = offset; + } else intact = false; + } + }; + if (this.unitTokens[this.headCode] > 0) emit(this.headCode); + for (const index of ordered) emit(this.emissionAtLeaf[index]); + if (this.unitTokens[this.tailCode] > 0) emit(this.tailCode); + cacheChurn = Math.max(0, offset - cached - extension) * Math.max(0, this.policy.cacheWritePrice - this.policy.cacheReadPrice); + } + return { + renderedTokens, budgetPenalty: budgetPenalty(renderedTokens, this.maxTokens, this.policy), + cacheChurn, fidelity: nonnegativeSumInterval(fidelity, leaves), continuity: nonnegativeSumInterval(continuity, leaves), + matchesPresentation: matches, exact: refine, + }; + } + + private fill(trace: FrontierTraceReference): void { this.levels.fill(0); // Valid cut traces assign each leaf exactly once, so traversal direction // is immaterial. Reuse numeric indices for shared summary/raw-run actions. - for (let cursor = trace; cursor; cursor = cursor.parent) { - let ranges = this.ranges.get(cursor.ids); + visitFrontierTrace(trace, (ids, level) => { + let ranges = this.ranges.get(ids); if (!ranges) { - const indices = cursor.ids.map((id) => this.index.get(id)!).sort((a, b) => a - b); + const indices = ids.map((id) => this.index.get(id)!).sort((a, b) => a - b); const spans: number[] = []; for (const index of indices) { if (spans.length > 0 && spans[spans.length - 1] === index) spans[spans.length - 1]++; else spans.push(index, index + 1); } ranges = Uint32Array.from(spans); - this.ranges.set(cursor.ids, ranges); + this.ranges.set(ids, ranges); } - for (let i = 0; i < ranges.length; i += 2) this.levels.fill(cursor.level, ranges[i], ranges[i + 1]); - } + for (let i = 0; i < ranges.length; i += 2) this.levels.fill(level, ranges[i], ranges[i + 1]); + }); } - candidate(trace: FrontierTrace | null, renderedTokens: number): UnscoredCandidate { + candidate(trace: FrontierTraceReference, renderedTokens: number): UnscoredCandidate { + this.evaluations++; this.fill(trace); let fidelityLoss = 0; let continuityLoss = 0; @@ -207,7 +331,7 @@ export class TerminalPolicyEvaluator { get frontier() { return getFrontier(); }, get layout() { return getLayout(); }, fidelityLoss, continuityLoss, renderedTokens, cacheChurn, matchesPresentation, - budgetPenalty: budgetPenalty(renderedTokens, this.options.maxTokens, this.policy), + budgetPenalty: budgetPenalty(renderedTokens, this.maxTokens, this.policy), }; } } diff --git a/test/adaptive/kv-unified-packed.test.ts b/test/adaptive/kv-unified-packed.test.ts new file mode 100644 index 00000000..94caa2c1 --- /dev/null +++ b/test/adaptive/kv-unified-packed.test.ts @@ -0,0 +1,142 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CanonicalSummaryForest } from '../../src/adaptive/kv-unified.js'; +import { ParetoKvUnifiedPolicySolver } from '../../src/adaptive/kv-unified-pareto.js'; +import { TerminalPolicyEvaluator, type FrontierTrace } from '../../src/adaptive/kv-unified-terminal.js'; +import { renderLayout } from '../../src/adaptive/render-offsets.js'; +import { SummaryTree } from '../../src/adaptive/summary-tree.js'; +import type { PickerInputs } from '../../src/adaptive/picker.js'; +import { MockChronicle, buildChronicleWithChain } from './harness.js'; +import { PackedLabels, PackedBuckets, LABEL_STRIDE, LabelField } from '../../src/adaptive/kv-unified-packed-storage.js'; + +test('packed label growth, reuse, and branching preserve immutable trace ancestry', () => { + const labels = new PackedLabels(); + const root = labels.initial(true, ['external']); + labels.data[root * LABEL_STRIDE + LabelField.Tokens] = 123.5; + const action = labels.traces.action(['a'], 1); + labels.assign(root, action, 1.5, 2.25); + const copies = Array.from({ length: 3000 }, () => labels.clone(root)); + const trace = labels.traces.reference(labels.finishTrace(copies[0])); + for (const id of copies) { + assert.equal(labels.data[id * LABEL_STRIDE + LabelField.Tokens], 123.5); + assert.equal(labels.data[id * LABEL_STRIDE + LabelField.Continuity], 2.25); + labels.release(id); + } + const reused = labels.clone(root); + labels.assign(reused, labels.traces.action(['b'], 2), 9, 7); + const original: Array<[readonly string[], number]> = []; + trace.forEachAssignment((ids, level) => original.push([ids, level])); + assert.deepEqual(original, [[['a'], 1], [['external'], 0]]); + const fork: Array<[readonly string[], number]> = []; + labels.traces.reference(labels.finishTrace(reused)).forEachAssignment((ids, level) => fork.push([ids, level])); + assert.deepEqual(fork, [[['b'], 2], [['a'], 1], [['external'], 0]]); + assert.equal(labels.slots, 3001, 'freed slots are reused'); +}); + +test('reusable buckets keep numeric/string identities separate and reset each generation', () => { + const buckets = new PackedBuckets(100); + for (let pass = 0; pass < 100; pass++) { + buckets.begin(2500); + assert.equal(buckets.group(17), 0); + assert.equal(buckets.group('17'), 1); + assert.equal(buckets.group(17), 0); + assert.equal(buckets.group(-1), 2); + for (let i = 1000; i < 3500; i++) buckets.group(i); + assert.equal(buckets.count, 2503); + } +}); + +test('delayed exact evaluation retains captured budgets and partial traces retain default-raw semantics', () => { + const chronicle = buildChronicleWithChain({ chunkCount: 4, tokensPerChunk: 90, mergeThreshold: 2, recallPairTokens: 55 }); + const inputs: PickerInputs = { chunks: chronicle.chunks, summaries: chronicle.summaries, + recallPairTokens: chronicle.recallPairTokens, headTokens: 0, tailTokens: 0, + headChunkIds: new Set(), tailChunkIds: new Set() }; + const forest = new CanonicalSummaryForest(inputs); + const options = { maxTokens: 400 }; + const evaluator = new TerminalPolicyEvaluator(inputs, forest, options); + let trace: FrontierTrace | null = null; + for (const chunk of inputs.chunks) trace = { parent: trace, ids: [chunk.id], level: 0 }; + const expected = evaluator.candidate(trace, 360); + const estimated = evaluator.estimate(trace, 360); + options.maxTokens = 100; + for (const chunk of inputs.chunks) chunk.rawTokens *= 10; + assert.deepEqual(estimated.exact(), expected); + const partial = evaluator.estimate(null, 360); + assert.deepEqual(partial.exact(), expected); +}); + +test('packed/full and packed/selective agree with object storage, including every retained candidate and error envelope', () => { + let state = 73418; + const random = () => ((state = (Math.imul(state, 1664525) + 1013904223) >>> 0) / 2 ** 32); + for (let run = 0; run < 90; run++) { + const chronicle = run % 3 === 0 ? new MockChronicle({ recallPairTokens: 30 }) : + buildChronicleWithChain({ chunkCount: 8, tokensPerChunk: 70, mergeThreshold: 2, recallPairTokens: 30 }); + if (run % 3 === 0) { + for (let i = 0; i < 9; i++) chronicle.addChunk({ id: `c${i}`, rawTokens: 70 }); + const a = chronicle.produceL1(['c0', 'c3', 'c6']); + const b = chronicle.produceL1(['c1', 'c4', 'c7']); + chronicle.produceL1(['c2', 'c5', 'c8']); + chronicle.produceUpper(2, [a.id, b.id]); + } + for (const chunk of chronicle.chunks) { + chunk.rawTokens = 10 + Math.floor(random() * 200) + (run % 4 === 0 ? random() : 0); + chunk.salience = random(); + } + for (const [id] of chronicle.recallPairTokens) chronicle.recallPairTokens.set(id, 10 + random() * 50); + const inputs: PickerInputs = { chunks: chronicle.chunks, summaries: chronicle.summaries, + recallPairTokens: chronicle.recallPairTokens, headTokens: run % 7, tailTokens: run % 13, + headChunkIds: new Set(), tailChunkIds: new Set() }; + const before = new CanonicalSummaryForest(inputs, { preserveGapBearingSummaries: true }); + const cuts = before.enumerateExactCuts().candidates; + const previousCut = cuts[Math.floor(random() * cuts.length)]; + const layout = renderLayout(inputs, new SummaryTree(inputs), previousCut.frontier); + const previous = new Map(inputs.chunks.slice(0, run % 2 ? inputs.chunks.length : Math.floor(inputs.chunks.length / 2)).map((chunk) => { + const level = previousCut.frontier.get(chunk.id) ?? 0; + const id = before.leaf(chunk.id)!.summaryIds.find((id) => before.summary(id)!.level === level); + return [chunk.id, { level, repHash: level === 0 ? `raw:${chunk.id}` : `summary:${id}`, lastChangedSeq: run % 3 }]; + })); + if (run % 4 === 0) inputs.chunks[2].pinned = true; + if (run % 5 === 0) { (inputs.headChunkIds as Set).add(inputs.chunks[0].id); inputs.headTokens += 100; } + const forest = new CanonicalSummaryForest(inputs, { preserveGapBearingSummaries: true }); + const floor = forest.minimumTokens(); + assert.ok(floor.feasible); + const options = { + maxTokens: floor.floorTokens + 1 + random() * 500, adoptEpsilon: run % 2 ? 20 : 0, + presentation: { currentSeq: 7, leaves: previous }, + cache: { immutablePrefixHash: 'same', layout, markers: [{ unitIndex: Math.max(1, Math.floor(layout.units.length / 2)), offset: 100 }, + { unitIndex: layout.units.length, offset: layout.totalTokens }] }, + currentImmutablePrefixHash: run % 6 ? 'same' : 'changed', + tokenBucketSize: run % 7 ? 50 : 0, continuityBucketSize: run % 7 ? 30 : 0, fidelityBucketSize: run % 7 ? 100 : 0, + policy: { continuityLambda: 50 + run, cacheLambda: 100 + run, continuityScale: 100, cacheScale: 100 }, + }; + const solver = new ParetoKvUnifiedPolicySolver(inputs, forest); + const reference = solver.solve({ ...options, storage: 'objects' }); + assert.ok(reference.feasible); + for (const terminalEvaluation of ['full', 'selective'] as const) { + const packed = solver.solve({ ...options, terminalEvaluation }); + assert.ok(packed.feasible); + assert.deepEqual(packed.selected, reference.selected, `selected run ${run}/${terminalEvaluation}`); + assert.equal(packed.cacheFloor, reference.cacheFloor); + assert.equal(packed.continuityFloor, reference.continuityFloor); + assert.deepEqual(packed.candidates, reference.candidates, `candidates run ${run}/${terminalEvaluation}`); + for (const [key, value] of Object.entries(reference.propagation!)) { + assert.equal((packed.propagation as unknown as Record)[key], value, `stat ${key} run ${run}`); + } + } + // Check the bounds themselves against every exact candidate, not only the + // eventual winner; also verify chronological cache corrections exactly. + const evaluator = new TerminalPolicyEvaluator(inputs, forest, options); + for (const cut of forest.enumerateExactCuts({ maxTokens: options.maxTokens }).candidates) { + let trace: FrontierTrace | null = null; + const groups = new Map(); + for (const [id, level] of cut.frontier) { const ids = groups.get(level); if (ids) ids.push(id); else groups.set(level, [id]); } + for (const [level, ids] of groups) trace = { parent: trace, ids, level }; + const estimate = evaluator.estimate(trace, cut.renderedTokens); + const exact = estimate.exact(); + assert.ok(estimate.fidelity.lower <= exact.fidelityLoss && exact.fidelityLoss <= estimate.fidelity.upper); + assert.ok(estimate.continuity.lower <= exact.continuityLoss && exact.continuityLoss <= estimate.continuity.upper); + assert.equal(estimate.cacheChurn, exact.cacheChurn); + assert.equal(estimate.matchesPresentation, exact.matchesPresentation); + } + } +}); diff --git a/test/adaptive/kv-unified-selective.test.ts b/test/adaptive/kv-unified-selective.test.ts new file mode 100644 index 00000000..84cee097 --- /dev/null +++ b/test/adaptive/kv-unified-selective.test.ts @@ -0,0 +1,85 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { nonnegativeSumInterval, scoreBoundedCandidates, type BoundedPolicyCandidate } from '../../src/adaptive/kv-unified-selective.js'; +import { ExactKvUnifiedPolicySolver, type ExactPolicySolveOptions, type UnscoredCandidate } from '../../src/adaptive/kv-unified-policy.js'; +import type { PickerInputs } from '../../src/adaptive/picker.js'; + +const inputs: PickerInputs = { chunks: [{ id: 'x', sequence: 0, rawTokens: 100, currentResolution: 0, + lockedByAgent: false, pinned: false }], summaries: new Map(), headTokens: 0, tailTokens: 0, + headChunkIds: new Set(), tailChunkIds: new Set() }; +const stats = { statesVisited: 0, candidatesGenerated: 0, maxCandidatesAtState: 0, terminalCandidates: 0 }; + +function row(index: number, fidelity: number, continuity = 0, cache = 0, matches = false): UnscoredCandidate { + return { frontier: new Map([['x', index]]), layout: { units: [], totalTokens: 100 }, renderedTokens: 100, + fidelityLoss: fidelity, continuityLoss: continuity, cacheChurn: cache, budgetPenalty: 0, matchesPresentation: matches }; +} +function bounds(value: UnscoredCandidate, refined: Set, slack = 1e-8): BoundedPolicyCandidate { + return { renderedTokens: value.renderedTokens, budgetPenalty: value.budgetPenalty, cacheChurn: value.cacheChurn, + fidelity: { lower: Math.max(0, value.fidelityLoss - slack), upper: value.fidelityLoss + slack }, + continuity: value.continuityLoss === 0 ? { lower: 0, upper: 0 } : + { lower: Math.max(0, value.continuityLoss - slack), upper: value.continuityLoss + slack }, + matchesPresentation: value.matchesPresentation!, exact: () => { refined.add(value); return value; } }; +} + +test('selective scoring refines the winner and carried contender, preserving the complete lazy candidate API', () => { + const values = Array.from({ length: 2000 }, (_, index) => row(index, index, 0, 0, index === 1)); + const refined = new Set(); + const options = { maxTokens: 200, adoptEpsilon: 2, presentation: { currentSeq: 1, leaves: new Map() } }; + const result = scoreBoundedCandidates(values.map((value) => bounds(value, refined)), options, stats, false, ['x']); + assert.equal(result.selected.frontier.get('x'), 1); + assert.equal(refined.size, 2, 'losing candidates need no exact evaluation during selection'); + const reference = new ExactKvUnifiedPolicySolver(inputs).scorePreparedCandidates(values, options, stats, false); + assert.deepEqual(result.selected, reference.selected); + assert.deepEqual(result.candidates, reference.candidates); + assert.equal(refined.size, 2000, 'candidate-list inspection performs deferred exact work'); + assert.ok(result.candidates.includes(result.selected)); +}); + +test('a welfare loser still establishes an exact normalization floor', () => { + const values = [row(0, 10, 10, 5), row(1, 12, 4, 8), row(2, 10000, 2, 0)]; + const refined = new Set(); + const candidates = values.map((value) => bounds(value, refined, 3)); + const options = { maxTokens: 200, policy: { continuityScale: 1, cacheScale: 1, continuityLambda: 1, cacheLambda: 1 } }; + const reference = new ExactKvUnifiedPolicySolver(inputs).scorePreparedCandidates(values, options, stats, true); + const result = scoreBoundedCandidates(candidates, options, stats, true, ['x']); + assert.equal(result.continuityFloor, 2); + assert.equal(result.cacheFloor, 0); + assert.ok(refined.has(values[2])); + assert.equal(result.selected.frontier.get('x'), 1); + assert.equal(result.selected.score, 80); + assert.deepEqual(result.selected, reference.selected); +}); + +test('overlapping bounds, exact ties, and the hysteresis boundary follow the original comparison rule', () => { + const values = [row(2, 1), row(1, 1), row(0, 3, 0, 0, true)]; + for (const adoptEpsilon of [0, 2, 2 - Number.EPSILON, NaN]) { + const options: ExactPolicySolveOptions = { maxTokens: 200, adoptEpsilon, presentation: { currentSeq: 1, leaves: new Map() } }; + const reference = new ExactKvUnifiedPolicySolver(inputs).scorePreparedCandidates(values, options, stats, false); + const result = scoreBoundedCandidates(values.map((value) => bounds(value, new Set(), 10)), options, stats, false, ['x']); + assert.deepEqual(result.selected, reference.selected); + if (adoptEpsilon === 0 || Number.isNaN(adoptEpsilon)) assert.equal(result.selected.frontier.get('x'), 1); + } +}); + +test('positive-sum intervals cover differently grouped sums across binary64 magnitudes', () => { + for (const scale of [Number.MIN_VALUE, 1e-250, 1, 1e100, 1e250]) { + const terms = Array.from({ length: 2000 }, (_, index) => scale * (index % 13 + 1)); + const forward = terms.reduce((sum, value) => sum + value, 0); + let grouped = 0; + for (let i = terms.length; i > 0; i -= 17) grouped += terms.slice(Math.max(0, i - 17), i).reduce((sum, value) => sum + value, 0); + const bound = nonnegativeSumInterval(grouped, terms.length); + assert.ok(bound.lower <= forward && forward <= bound.upper, `scale ${scale}`); + } + assert.deepEqual(nonnegativeSumInterval(0, 100000), { lower: 0, upper: 0 }); +}); + +test('non-finite score arithmetic uses the complete original sort instead of interval decisions', () => { + const values = [row(0, 0, 1e308), row(1, 1), row(2, 2)]; + const options = { maxTokens: 200, continuityMultiplier: 0, policy: { continuityScale: 1 } }; + const refined = new Set(); + const reference = new ExactKvUnifiedPolicySolver(inputs).scorePreparedCandidates(values, options, stats, false); + const result = scoreBoundedCandidates(values.map((value) => bounds(value, refined)), options, stats, false, ['x']); + assert.equal(refined.size, values.length); + assert.deepEqual(result.selected, reference.selected); + assert.deepEqual(result.candidates, reference.candidates); +});