@animalabs/context-manager 0.5.3 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/adaptive/index.d.ts +6 -0
- package/dist/src/adaptive/index.d.ts.map +1 -1
- package/dist/src/adaptive/index.js +8 -0
- package/dist/src/adaptive/index.js.map +1 -1
- package/dist/src/adaptive/kv-cache-sim.d.ts +128 -0
- package/dist/src/adaptive/kv-cache-sim.d.ts.map +1 -0
- package/dist/src/adaptive/kv-cache-sim.js +0 -0
- package/dist/src/adaptive/kv-cache-sim.js.map +1 -0
- package/dist/src/adaptive/kv-control.d.ts +152 -0
- package/dist/src/adaptive/kv-control.d.ts.map +1 -0
- package/dist/src/adaptive/kv-control.js +368 -0
- package/dist/src/adaptive/kv-control.js.map +1 -0
- package/dist/src/adaptive/kv-replay.d.ts +76 -0
- package/dist/src/adaptive/kv-replay.d.ts.map +1 -0
- package/dist/src/adaptive/kv-replay.js +175 -0
- package/dist/src/adaptive/kv-replay.js.map +1 -0
- package/dist/src/adaptive/render-offsets.d.ts +61 -0
- package/dist/src/adaptive/render-offsets.d.ts.map +1 -0
- package/dist/src/adaptive/render-offsets.js +88 -0
- package/dist/src/adaptive/render-offsets.js.map +1 -0
- package/dist/src/adaptive/strategies/kv-stable.d.ts +48 -0
- package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -0
- package/dist/src/adaptive/strategies/kv-stable.js +102 -0
- package/dist/src/adaptive/strategies/kv-stable.js.map +1 -0
- package/dist/src/adaptive/summary-tree.d.ts +98 -0
- package/dist/src/adaptive/summary-tree.d.ts.map +1 -0
- package/dist/src/adaptive/summary-tree.js +214 -0
- package/dist/src/adaptive/summary-tree.js.map +1 -0
- package/dist/src/context-log.d.ts.map +1 -1
- package/dist/src/context-log.js +7 -2
- package/dist/src/context-log.js.map +1 -1
- package/dist/src/message-store.js +1 -1
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/strategies/autobiographical.d.ts +68 -39
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +301 -213
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/strategy.d.ts +40 -1
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js +2 -0
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/adaptive/kv-cache-sim.test.d.ts +16 -0
- package/dist/test/adaptive/kv-cache-sim.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-cache-sim.test.js +173 -0
- package/dist/test/adaptive/kv-cache-sim.test.js.map +1 -0
- package/dist/test/adaptive/kv-control.test.d.ts +14 -0
- package/dist/test/adaptive/kv-control.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-control.test.js +130 -0
- package/dist/test/adaptive/kv-control.test.js.map +1 -0
- package/dist/test/adaptive/kv-replay.test.d.ts +15 -0
- package/dist/test/adaptive/kv-replay.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-replay.test.js +112 -0
- package/dist/test/adaptive/kv-replay.test.js.map +1 -0
- package/dist/test/adaptive/kv-stable-strategy.test.d.ts +10 -0
- package/dist/test/adaptive/kv-stable-strategy.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-stable-strategy.test.js +89 -0
- package/dist/test/adaptive/kv-stable-strategy.test.js.map +1 -0
- package/dist/test/adaptive/render-offsets.test.d.ts +11 -0
- package/dist/test/adaptive/render-offsets.test.d.ts.map +1 -0
- package/dist/test/adaptive/render-offsets.test.js +85 -0
- package/dist/test/adaptive/render-offsets.test.js.map +1 -0
- package/dist/test/adaptive/summary-tree.test.d.ts +5 -0
- package/dist/test/adaptive/summary-tree.test.d.ts.map +1 -0
- package/dist/test/adaptive/summary-tree.test.js +72 -0
- package/dist/test/adaptive/summary-tree.test.js.map +1 -0
- package/dist/test/image-strip-render-stats.test.d.ts +2 -0
- package/dist/test/image-strip-render-stats.test.d.ts.map +1 -0
- package/dist/test/image-strip-render-stats.test.js +130 -0
- package/dist/test/image-strip-render-stats.test.js.map +1 -0
- package/dist/test/image-stripping.test.d.ts +13 -0
- package/dist/test/image-stripping.test.d.ts.map +1 -0
- package/dist/test/image-stripping.test.js +104 -0
- package/dist/test/image-stripping.test.js.map +1 -0
- package/dist/test/render-stats.test.d.ts +2 -0
- package/dist/test/render-stats.test.d.ts.map +1 -0
- package/dist/test/render-stats.test.js +91 -0
- package/dist/test/render-stats.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/adaptive/index.ts +48 -0
- package/src/adaptive/kv-cache-sim.ts +0 -0
- package/src/adaptive/kv-control.ts +497 -0
- package/src/adaptive/kv-replay.ts +238 -0
- package/src/adaptive/render-offsets.ts +116 -0
- package/src/adaptive/strategies/kv-stable.ts +123 -0
- package/src/adaptive/summary-tree.ts +252 -0
- package/src/context-log.ts +7 -2
- package/src/message-store.ts +1 -1
- package/src/strategies/autobiographical.ts +314 -263
- package/src/types/strategy.ts +40 -1
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KV-stable context controller (see `docs/kv-stable-context-control.md`).
|
|
3
|
+
*
|
|
4
|
+
* A receding-horizon policy that replaces the `value − λ·KVcost` solve. One
|
|
5
|
+
* objective — minimize real billed recompute — under a constraint hierarchy with
|
|
6
|
+
* a single hard wall (the physical window W) and shaped soft constraints
|
|
7
|
+
* (flat zone, perturbation cap, pins/saliency, operating budget). State (the
|
|
8
|
+
* carried frontier + the persistent cache) makes it a controller, not a
|
|
9
|
+
* per-turn solve.
|
|
10
|
+
*
|
|
11
|
+
* This prototype uses a hysteresis band [LW, HW] as the controllable operating
|
|
12
|
+
* point on the cost↔continuity frontier:
|
|
13
|
+
* - append every turn; if rendered tokens exceed HW, shed down to ≤ LW;
|
|
14
|
+
* otherwise do nothing (pure append → zero perturbation, full cache hit);
|
|
15
|
+
* - shed oldest-first, leveled (L1 groups, then L2, then L3), each chunk
|
|
16
|
+
* capped by the saliency field (log-age + flat zone + pins) — base-k
|
|
17
|
+
* grouping driven by budget, not a fixed clock;
|
|
18
|
+
* - W is the only hard wall; if folding everything to its cap can't fit under
|
|
19
|
+
* W, escalate (don't thrash).
|
|
20
|
+
*
|
|
21
|
+
* Pure and deterministic.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ChunkId } from './folding-strategy.js';
|
|
25
|
+
import type { PickerInputs, PickerChunk } from './picker.js';
|
|
26
|
+
import type { SummaryEntry } from '../types/strategy.js';
|
|
27
|
+
import { SummaryTree } from './summary-tree.js';
|
|
28
|
+
import { renderLayout, type RenderLayout, type Frontier } from './render-offsets.js';
|
|
29
|
+
import { placeMarkers, CacheStore } from './kv-cache-sim.js';
|
|
30
|
+
|
|
31
|
+
/** Provider price multipliers relative to base input tokens (Anthropic-ish). */
|
|
32
|
+
export const PRICE = { miss: 1.0, cacheRead: 0.1, cacheWrite: 1.25 } as const;
|
|
33
|
+
|
|
34
|
+
/** Deepest fold level the saliency field will ever allow. */
|
|
35
|
+
export const MAX_FOLD_LEVEL = 3;
|
|
36
|
+
|
|
37
|
+
export interface ControlOptions {
|
|
38
|
+
/** Physical context window — the ONLY hard wall. */
|
|
39
|
+
windowTokens: number;
|
|
40
|
+
/** Operating budget B (< W). High-watermark HW defaults to this. */
|
|
41
|
+
budgetTokens: number;
|
|
42
|
+
/** Shed down to this low-watermark when HW is breached. (HW − LW) sets fold
|
|
43
|
+
* frequency/amplitude. Default 0.8·budget. */
|
|
44
|
+
lowWatermark?: number;
|
|
45
|
+
/** High-watermark; fold is triggered above it. Default = budgetTokens. */
|
|
46
|
+
highWatermark?: number;
|
|
47
|
+
/** Per-turn divergence reach: how far back (tokens from the live end) a fold
|
|
48
|
+
* may normally edit — the structural perturbation cap P, and the primary
|
|
49
|
+
* cost↔continuity knob. Smaller = gentler per-turn KV perturbation (shallow
|
|
50
|
+
* divergence) but less efficient compression → more/again folding. Exceeded
|
|
51
|
+
* ONLY to avoid the hard wall W. Default = budgetTokens (effectively
|
|
52
|
+
* unbounded). */
|
|
53
|
+
reachTokens?: number;
|
|
54
|
+
/** Attended window kept raw + stable (the flat zone). */
|
|
55
|
+
attendedWindowTokens: number;
|
|
56
|
+
/** Base-k summary grouping (matches mergeThreshold). Default 6. */
|
|
57
|
+
mergeThreshold?: number;
|
|
58
|
+
/** Cache breakpoints per turn (1..4). Default 4. */
|
|
59
|
+
markerCount?: number;
|
|
60
|
+
/** Approx replay steps (down-sample). Default 120. */
|
|
61
|
+
targetSteps?: number;
|
|
62
|
+
/** Cache TTL in steps. Default Infinity. */
|
|
63
|
+
cacheTtlSteps?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ControlStep {
|
|
67
|
+
now: number;
|
|
68
|
+
numChunks: number;
|
|
69
|
+
renderedTokens: number;
|
|
70
|
+
cachedTokens: number;
|
|
71
|
+
recomputedTokens: number;
|
|
72
|
+
/** Billed recompute at provider multipliers. */
|
|
73
|
+
billed: number;
|
|
74
|
+
/** Prefix churn = recompute beyond genuinely new content (the KV perturbation
|
|
75
|
+
* of the attended region). 0 on a pure-append turn. */
|
|
76
|
+
perturbation: number;
|
|
77
|
+
/** True when the controller folded this turn (a perturbation event). */
|
|
78
|
+
folded: boolean;
|
|
79
|
+
/** True when even full folding could not fit under W (terminal). */
|
|
80
|
+
escalated: boolean;
|
|
81
|
+
deepestLevel: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ControlResult {
|
|
85
|
+
steps: ControlStep[];
|
|
86
|
+
totalRecomputed: number;
|
|
87
|
+
totalBilled: number;
|
|
88
|
+
overallHitRate: number;
|
|
89
|
+
/** Worst single-turn perturbation (the continuity ceiling). */
|
|
90
|
+
maxPerturbation: number;
|
|
91
|
+
/** RMS per-turn perturbation (variance-continuity). */
|
|
92
|
+
rmsPerturbation: number;
|
|
93
|
+
/** Count of turns that perturbed the prefix. */
|
|
94
|
+
foldEvents: number;
|
|
95
|
+
/** Count of terminal (over-W) turns. */
|
|
96
|
+
escalations: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Per-chunk maximum fold depth from the saliency field: flat zone and pins are
|
|
101
|
+
* raw (0); otherwise log-age banded against the base-k geometry. The "finest
|
|
102
|
+
* requirement wins" — this is the cap the shedder may not exceed.
|
|
103
|
+
*/
|
|
104
|
+
export function foldDepthCap(
|
|
105
|
+
chunk: PickerChunk,
|
|
106
|
+
now: number,
|
|
107
|
+
flatZone: ReadonlySet<ChunkId>,
|
|
108
|
+
flatZoneChunks: number,
|
|
109
|
+
mergeThreshold: number,
|
|
110
|
+
): number {
|
|
111
|
+
if (chunk.pinned || flatZone.has(chunk.id)) return 0;
|
|
112
|
+
const age = Math.max(0, now - chunk.sequence);
|
|
113
|
+
const ratio = age / Math.max(1, flatZoneChunks);
|
|
114
|
+
if (ratio < 1) return 0; // just outside the flat zone → still raw
|
|
115
|
+
const band = Math.floor(Math.log(ratio) / Math.log(Math.max(2, mergeThreshold))) + 1;
|
|
116
|
+
return Math.max(0, Math.min(MAX_FOLD_LEVEL, band));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Deepen `frontier` in place to bring rendered tokens ≤ `target`, leveled
|
|
121
|
+
* (L1 groups, then L2, then L3) and oldest-first *within the reach window*. A
|
|
122
|
+
* chunk is editable this turn only if the raw tokens newer than it are <
|
|
123
|
+
* `reachTokens` — this bounds the divergence depth (and thus the per-turn KV
|
|
124
|
+
* perturbation) to ≈ reach. Each chunk is bounded by its saliency cap;
|
|
125
|
+
* flat-zone/pinned chunks (cap 0) are never touched.
|
|
126
|
+
*
|
|
127
|
+
* `reachTokens` is the soft cap P; `windowTokens` is the hard wall W. If folding
|
|
128
|
+
* within reach can't fit under W, reach is lifted (emergency) and folding
|
|
129
|
+
* continues to caps — yielding P minimally to keep W. Returns final tokens.
|
|
130
|
+
*/
|
|
131
|
+
function shedToTarget(
|
|
132
|
+
frontier: Map<ChunkId, number>,
|
|
133
|
+
inputs: PickerInputs,
|
|
134
|
+
tree: SummaryTree,
|
|
135
|
+
ordered: PickerChunk[],
|
|
136
|
+
caps: Map<ChunkId, number>,
|
|
137
|
+
target: number,
|
|
138
|
+
reachTokens: number,
|
|
139
|
+
windowTokens: number,
|
|
140
|
+
): number {
|
|
141
|
+
// Raw tokens strictly newer than each chunk (its distance from the live end).
|
|
142
|
+
const newerTokens = new Map<ChunkId, number>();
|
|
143
|
+
let acc = 0;
|
|
144
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
145
|
+
newerTokens.set(ordered[i].id, acc);
|
|
146
|
+
acc += ordered[i].rawTokens;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const foldWithinReach = (reach: number): boolean => {
|
|
150
|
+
for (let level = 1; level <= MAX_FOLD_LEVEL; level++) {
|
|
151
|
+
for (const c of ordered) {
|
|
152
|
+
// oldest-first within reach
|
|
153
|
+
if ((newerTokens.get(c.id) ?? 0) >= reach) continue;
|
|
154
|
+
if ((frontier.get(c.id) ?? 0) >= level) continue;
|
|
155
|
+
const ancestor = tree.ancestorAt(c.id, level);
|
|
156
|
+
if (!ancestor) continue; // summary not produced yet → can't fold here
|
|
157
|
+
// Group-consistency + reach: fold the WHOLE group to `level` only if
|
|
158
|
+
// every covered leaf permits it (cap ≥ level) and is within reach. The
|
|
159
|
+
// picker raises whole groups, so a partially-eligible group would be an
|
|
160
|
+
// unreachable target (it oscillates). A uniform fold keeps the frontier
|
|
161
|
+
// group-consistent and the picker convergent.
|
|
162
|
+
let eligible = true;
|
|
163
|
+
for (const leafId of ancestor.leafChunkIds) {
|
|
164
|
+
if ((caps.get(leafId) ?? 0) < level || (newerTokens.get(leafId) ?? 0) >= reach) {
|
|
165
|
+
eligible = false;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!eligible) continue;
|
|
170
|
+
for (const leafId of ancestor.leafChunkIds) {
|
|
171
|
+
if ((frontier.get(leafId) ?? 0) < level) frontier.set(leafId, level);
|
|
172
|
+
}
|
|
173
|
+
if (renderLayout(inputs, tree, frontier).totalTokens <= target) return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
foldWithinReach(reachTokens);
|
|
180
|
+
let tokens = renderLayout(inputs, tree, frontier).totalTokens;
|
|
181
|
+
// Hard wall: yield the reach cap (P) only as far as needed to keep under W.
|
|
182
|
+
if (tokens > windowTokens) {
|
|
183
|
+
foldWithinReach(Infinity);
|
|
184
|
+
tokens = renderLayout(inputs, tree, frontier).totalTokens;
|
|
185
|
+
}
|
|
186
|
+
return tokens;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Raise `frontier` resolution in place (UN-fold toward raw) to fill up to
|
|
191
|
+
* `target`, leveled (L3→L2, L2→L1, L1→raw) and **youngest-first within the reach
|
|
192
|
+
* window** — the mirror image of `shedToTarget`. Un-folding is a prefix
|
|
193
|
+
* perturbation too, so it obeys the same reach cap (bounded divergence) and only
|
|
194
|
+
* lowers whole groups (group-consistent → the picker reaches it via `lower`
|
|
195
|
+
* ops). Youngest-first spends the budget on the most recent foldable content
|
|
196
|
+
* (highest value, shallowest divergence). Stops before exceeding `target`.
|
|
197
|
+
* rawZone/frozen chunks are never touched. Returns final tokens.
|
|
198
|
+
*/
|
|
199
|
+
function expandToTarget(
|
|
200
|
+
frontier: Map<ChunkId, number>,
|
|
201
|
+
inputs: PickerInputs,
|
|
202
|
+
tree: SummaryTree,
|
|
203
|
+
ordered: PickerChunk[],
|
|
204
|
+
target: number,
|
|
205
|
+
reachTokens: number,
|
|
206
|
+
rawZone: ReadonlySet<ChunkId>,
|
|
207
|
+
frozen: ReadonlySet<ChunkId>,
|
|
208
|
+
): number {
|
|
209
|
+
const newerTokens = new Map<ChunkId, number>();
|
|
210
|
+
let acc = 0;
|
|
211
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
212
|
+
newerTokens.set(ordered[i].id, acc);
|
|
213
|
+
acc += ordered[i].rawTokens;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let tokens = renderLayout(inputs, tree, frontier).totalTokens;
|
|
217
|
+
for (let level = MAX_FOLD_LEVEL; level >= 1 && tokens < target; level--) {
|
|
218
|
+
for (let oi = ordered.length - 1; oi >= 0; oi--) {
|
|
219
|
+
if (tokens >= target) break;
|
|
220
|
+
const c = ordered[oi]; // youngest-first
|
|
221
|
+
if ((frontier.get(c.id) ?? 0) !== level) continue;
|
|
222
|
+
if (rawZone.has(c.id) || frozen.has(c.id)) continue;
|
|
223
|
+
if ((newerTokens.get(c.id) ?? 0) >= reachTokens) continue;
|
|
224
|
+
const node = tree.ancestorAt(c.id, level);
|
|
225
|
+
if (!node) continue;
|
|
226
|
+
// Lower the WHOLE group to level-1 only if every covered leaf is at this
|
|
227
|
+
// level and within reach (group-consistent + reach-bounded).
|
|
228
|
+
let eligible = true;
|
|
229
|
+
for (const leafId of node.leafChunkIds) {
|
|
230
|
+
if (
|
|
231
|
+
(frontier.get(leafId) ?? 0) !== level ||
|
|
232
|
+
rawZone.has(leafId) || frozen.has(leafId) ||
|
|
233
|
+
(newerTokens.get(leafId) ?? 0) >= reachTokens
|
|
234
|
+
) {
|
|
235
|
+
eligible = false;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!eligible) continue;
|
|
240
|
+
for (const leafId of node.leafChunkIds) frontier.set(leafId, level - 1);
|
|
241
|
+
const nt = renderLayout(inputs, tree, frontier).totalTokens;
|
|
242
|
+
if (nt <= target) tokens = nt; // accept the un-fold
|
|
243
|
+
else for (const leafId of node.leafChunkIds) frontier.set(leafId, level); // overshoot → revert
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return tokens;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Earliest source sequence whose resolution changed `prev → next`. */
|
|
250
|
+
function earliestChangedSequence(
|
|
251
|
+
prev: Frontier,
|
|
252
|
+
next: ReadonlyMap<ChunkId, number>,
|
|
253
|
+
ordered: PickerChunk[],
|
|
254
|
+
): number {
|
|
255
|
+
for (const c of ordered) {
|
|
256
|
+
if ((prev.get(c.id) ?? 0) !== (next.get(c.id) ?? 0)) return c.sequence;
|
|
257
|
+
}
|
|
258
|
+
return Number.MAX_SAFE_INTEGER;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function unitsBeforeSequence(layout: RenderLayout, tree: SummaryTree, boundary: number): number {
|
|
262
|
+
let count = 0;
|
|
263
|
+
for (const u of layout.units) {
|
|
264
|
+
const seq =
|
|
265
|
+
u.kind === 'raw' ? tree.leaf(u.key)?.sequence ?? 0
|
|
266
|
+
: u.kind === 'recall' ? tree.summary(u.key)?.firstSequence ?? 0
|
|
267
|
+
: u.kind === 'head' ? -1
|
|
268
|
+
: Number.MAX_SAFE_INTEGER;
|
|
269
|
+
if (seq < boundary) count++;
|
|
270
|
+
else break;
|
|
271
|
+
}
|
|
272
|
+
return count;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function rawTailSet(ordered: PickerChunk[], tailTokens: number): Set<ChunkId> {
|
|
276
|
+
const s = new Set<ChunkId>();
|
|
277
|
+
if (tailTokens <= 0) return s;
|
|
278
|
+
let used = 0;
|
|
279
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
280
|
+
s.add(ordered[i].id);
|
|
281
|
+
used += ordered[i].rawTokens;
|
|
282
|
+
if (used >= tailTokens) break;
|
|
283
|
+
}
|
|
284
|
+
return s;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const EMPTY_SET: ReadonlySet<ChunkId> = new Set();
|
|
288
|
+
|
|
289
|
+
export interface ControlPlanParams {
|
|
290
|
+
/** Carried frontier (F_prev). */
|
|
291
|
+
previous: ReadonlyMap<ChunkId, number>;
|
|
292
|
+
/** Fold (deepen) when rendered tokens exceed this (high-watermark). */
|
|
293
|
+
foldAtTokens: number;
|
|
294
|
+
/** Un-fold (raise toward raw) when rendered tokens are below this
|
|
295
|
+
* (low-watermark). Default = targetTokens. The band (foldAt − expandAt) is a
|
|
296
|
+
* no-op dead zone that prevents fold↔unfold oscillation. Set below foldAt. */
|
|
297
|
+
expandAtTokens?: number;
|
|
298
|
+
/** The attractor both fold and un-fold aim for (soft target). */
|
|
299
|
+
targetTokens: number;
|
|
300
|
+
/** Hard wall W — fold past the reach cap only to keep under this. */
|
|
301
|
+
windowTokens: number;
|
|
302
|
+
/** Per-turn divergence reach (perturbation cap P) — bounds BOTH fold and
|
|
303
|
+
* un-fold divergence. Default = windowTokens. */
|
|
304
|
+
reachTokens?: number;
|
|
305
|
+
/** Chunks forced to raw and never folded (flat zone / head / tail / pinned). */
|
|
306
|
+
rawZone: ReadonlySet<ChunkId>;
|
|
307
|
+
/** Chunks kept at their carried resolution and never touched (locked). */
|
|
308
|
+
frozen?: ReadonlySet<ChunkId>;
|
|
309
|
+
/** Newest source sequence — the age reference for the log-age fold caps. */
|
|
310
|
+
now: number;
|
|
311
|
+
/** Base-k summary grouping. Default 6. */
|
|
312
|
+
mergeThreshold?: number;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface ControlPlan {
|
|
316
|
+
resolutions: Map<ChunkId, number>;
|
|
317
|
+
tokens: number;
|
|
318
|
+
/** A fold (deepen) happened this turn. */
|
|
319
|
+
folded: boolean;
|
|
320
|
+
/** An un-fold (raise toward raw, to use budget headroom) happened this turn. */
|
|
321
|
+
expanded: boolean;
|
|
322
|
+
/** Even full folding under caps could not fit under W (terminal). */
|
|
323
|
+
escalated: boolean;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Plan one turn's frontier under the controller policy — shared by the replay
|
|
328
|
+
* harness (`replayControlled`) and `KvStableStrategy`. Carries F_prev forward,
|
|
329
|
+
* holds the raw zone raw and the frozen set fixed, then drives the rendered size
|
|
330
|
+
* toward `targetTokens` from EITHER side, both bounded by the `reachTokens`
|
|
331
|
+
* divergence cap (KV-continuity preserving in both directions):
|
|
332
|
+
* - over `foldAtTokens` → deepen, oldest-first, under the log-age saliency
|
|
333
|
+
* caps, yielding the reach cap only as needed to stay under the hard wall W;
|
|
334
|
+
* - under `expandAtTokens` → un-fold, youngest-first, to use budget headroom.
|
|
335
|
+
* The [expandAt, foldAt] dead band leaves F_prev untouched (zero perturbation)
|
|
336
|
+
* when already in range. Pure and deterministic.
|
|
337
|
+
*/
|
|
338
|
+
export function planControlledFrontier(
|
|
339
|
+
inputs: PickerInputs,
|
|
340
|
+
tree: SummaryTree,
|
|
341
|
+
p: ControlPlanParams,
|
|
342
|
+
): ControlPlan {
|
|
343
|
+
const ordered = [...inputs.chunks].sort((a, b) => a.sequence - b.sequence);
|
|
344
|
+
const frozen = p.frozen ?? EMPTY_SET;
|
|
345
|
+
const mergeThreshold = p.mergeThreshold ?? 6;
|
|
346
|
+
const reach = p.reachTokens ?? p.windowTokens;
|
|
347
|
+
const expandAt = p.expandAtTokens ?? p.targetTokens;
|
|
348
|
+
|
|
349
|
+
const F = new Map<ChunkId, number>();
|
|
350
|
+
for (const c of ordered) {
|
|
351
|
+
if (p.rawZone.has(c.id)) F.set(c.id, 0);
|
|
352
|
+
else if (frozen.has(c.id)) F.set(c.id, p.previous.get(c.id) ?? c.currentResolution);
|
|
353
|
+
else F.set(c.id, p.previous.get(c.id) ?? 0);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let tokens = renderLayout(inputs, tree, F).totalTokens;
|
|
357
|
+
const before = tokens;
|
|
358
|
+
let folded = false;
|
|
359
|
+
let expanded = false;
|
|
360
|
+
let escalated = false;
|
|
361
|
+
if (tokens > p.foldAtTokens) {
|
|
362
|
+
const flatZoneChunks = p.rawZone.size;
|
|
363
|
+
const caps = new Map<ChunkId, number>();
|
|
364
|
+
for (const c of ordered) {
|
|
365
|
+
caps.set(
|
|
366
|
+
c.id,
|
|
367
|
+
p.rawZone.has(c.id) || frozen.has(c.id)
|
|
368
|
+
? 0
|
|
369
|
+
: foldDepthCap(c, p.now, p.rawZone, flatZoneChunks, mergeThreshold),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
tokens = shedToTarget(F, inputs, tree, ordered, caps, p.targetTokens, reach, p.windowTokens);
|
|
373
|
+
folded = tokens !== before; // a real fold only if it actually changed the render
|
|
374
|
+
if (tokens > p.windowTokens) escalated = true;
|
|
375
|
+
} else if (tokens < expandAt) {
|
|
376
|
+
tokens = expandToTarget(F, inputs, tree, ordered, p.targetTokens, reach, p.rawZone, frozen);
|
|
377
|
+
expanded = tokens !== before; // a real un-fold only if it actually changed
|
|
378
|
+
}
|
|
379
|
+
return { resolutions: F, tokens, folded, expanded, escalated };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Run the controller over a session (the exported PickerInputs snapshot) as
|
|
384
|
+
* growing history, measuring billed recompute and KV perturbation per turn.
|
|
385
|
+
*/
|
|
386
|
+
export function replayControlled(fullInputs: PickerInputs, opts: ControlOptions): ControlResult {
|
|
387
|
+
const mergeThreshold = opts.mergeThreshold ?? 6;
|
|
388
|
+
const markerCount = opts.markerCount ?? 4;
|
|
389
|
+
const targetSteps = Math.max(1, opts.targetSteps ?? 120);
|
|
390
|
+
const HW = opts.highWatermark ?? opts.budgetTokens;
|
|
391
|
+
const LW = opts.lowWatermark ?? Math.round(0.8 * opts.budgetTokens);
|
|
392
|
+
const reachTokens = opts.reachTokens ?? opts.budgetTokens;
|
|
393
|
+
|
|
394
|
+
const fullTree = new SummaryTree(fullInputs);
|
|
395
|
+
const availableAt = new Map<string, number>();
|
|
396
|
+
for (const s of fullTree.allSummaries()) availableAt.set(s.id, s.lastSequence);
|
|
397
|
+
|
|
398
|
+
const allChunks = [...fullInputs.chunks].sort((a, b) => a.sequence - b.sequence);
|
|
399
|
+
const seqValues = allChunks.map((c) => c.sequence);
|
|
400
|
+
const stride = Math.max(1, Math.ceil(seqValues.length / targetSteps));
|
|
401
|
+
const stepSeqs: number[] = [];
|
|
402
|
+
for (let i = 0; i < seqValues.length; i += stride) stepSeqs.push(seqValues[i]);
|
|
403
|
+
const last = seqValues[seqValues.length - 1];
|
|
404
|
+
if (stepSeqs[stepSeqs.length - 1] !== last) stepSeqs.push(last);
|
|
405
|
+
|
|
406
|
+
const steps: ControlStep[] = [];
|
|
407
|
+
let fprev: Frontier = new Map<ChunkId, number>();
|
|
408
|
+
const cache = new CacheStore({ ttlSteps: opts.cacheTtlSteps });
|
|
409
|
+
let prevNow = -1;
|
|
410
|
+
let totalRecomputed = 0;
|
|
411
|
+
let totalBilled = 0;
|
|
412
|
+
let totalCached = 0;
|
|
413
|
+
let totalRendered = 0;
|
|
414
|
+
let sumSqPerturb = 0;
|
|
415
|
+
let maxPerturbation = 0;
|
|
416
|
+
let foldEvents = 0;
|
|
417
|
+
let escalations = 0;
|
|
418
|
+
|
|
419
|
+
for (const now of stepSeqs) {
|
|
420
|
+
const visible = allChunks.filter((c) => c.sequence <= now);
|
|
421
|
+
const summaries = new Map<string, SummaryEntry>();
|
|
422
|
+
const recallPairTokens = new Map<string, number>();
|
|
423
|
+
for (const [id, s] of fullInputs.summaries) {
|
|
424
|
+
const lastSeq = availableAt.get(id);
|
|
425
|
+
if (lastSeq !== undefined && lastSeq >= 0 && lastSeq <= now) {
|
|
426
|
+
summaries.set(id, s);
|
|
427
|
+
const rp = fullInputs.recallPairTokens?.get(id);
|
|
428
|
+
if (rp !== undefined) recallPairTokens.set(id, rp);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const inputs: PickerInputs = {
|
|
432
|
+
chunks: visible, summaries, recallPairTokens,
|
|
433
|
+
headTokens: 0, tailTokens: 0, headChunkIds: new Set(), tailChunkIds: new Set(),
|
|
434
|
+
};
|
|
435
|
+
const tree = new SummaryTree(inputs);
|
|
436
|
+
const flatZone = rawTailSet(visible, opts.attendedWindowTokens);
|
|
437
|
+
// Plan this turn's frontier via the shared controller policy (the same code
|
|
438
|
+
// KvStableStrategy runs): carry F_prev, hold the flat zone raw, fold to LW
|
|
439
|
+
// when over HW, and un-fold to LW when under it — both reach-bounded, with
|
|
440
|
+
// [LW, HW] a no-op dead band; never past the hard wall W.
|
|
441
|
+
const plan = planControlledFrontier(inputs, tree, {
|
|
442
|
+
previous: fprev, foldAtTokens: HW, expandAtTokens: LW, targetTokens: LW,
|
|
443
|
+
windowTokens: opts.windowTokens, reachTokens, rawZone: flatZone, now, mergeThreshold,
|
|
444
|
+
});
|
|
445
|
+
const F = plan.resolutions;
|
|
446
|
+
const folded = plan.folded || plan.expanded; // any active prefix change this turn
|
|
447
|
+
const escalated = plan.escalated;
|
|
448
|
+
|
|
449
|
+
const layout = renderLayout(inputs, tree, F);
|
|
450
|
+
const hit = cache.read(layout);
|
|
451
|
+
const recomputed = layout.totalTokens - hit.cachedTokens;
|
|
452
|
+
|
|
453
|
+
// Perturbation = recompute beyond genuinely new content (prefix churn).
|
|
454
|
+
let newContent = 0;
|
|
455
|
+
for (const c of visible) if (c.sequence > prevNow) newContent += c.rawTokens;
|
|
456
|
+
const perturbation = Math.max(0, recomputed - newContent);
|
|
457
|
+
|
|
458
|
+
const billed = PRICE.miss * recomputed + PRICE.cacheRead * hit.cachedTokens;
|
|
459
|
+
|
|
460
|
+
// Write breakpoints for future turns, hinting at the change boundary.
|
|
461
|
+
const boundarySeq = earliestChangedSequence(fprev, F, visible);
|
|
462
|
+
const boundaryUnitIndex = unitsBeforeSequence(layout, tree, boundarySeq);
|
|
463
|
+
cache.write(layout, placeMarkers(layout, markerCount, { boundaryUnitIndex }));
|
|
464
|
+
cache.tick();
|
|
465
|
+
|
|
466
|
+
let deepestLevel = 0;
|
|
467
|
+
for (const lvl of F.values()) if (lvl > deepestLevel) deepestLevel = lvl;
|
|
468
|
+
|
|
469
|
+
steps.push({
|
|
470
|
+
now, numChunks: visible.length, renderedTokens: layout.totalTokens,
|
|
471
|
+
cachedTokens: hit.cachedTokens, recomputedTokens: recomputed, billed,
|
|
472
|
+
perturbation, folded, escalated, deepestLevel,
|
|
473
|
+
});
|
|
474
|
+
totalRecomputed += recomputed;
|
|
475
|
+
totalBilled += billed;
|
|
476
|
+
totalCached += hit.cachedTokens;
|
|
477
|
+
totalRendered += layout.totalTokens;
|
|
478
|
+
sumSqPerturb += perturbation * perturbation;
|
|
479
|
+
if (perturbation > maxPerturbation) maxPerturbation = perturbation;
|
|
480
|
+
if (folded) foldEvents++;
|
|
481
|
+
if (escalated) escalations++;
|
|
482
|
+
|
|
483
|
+
fprev = F;
|
|
484
|
+
prevNow = now;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
steps,
|
|
489
|
+
totalRecomputed,
|
|
490
|
+
totalBilled,
|
|
491
|
+
overallHitRate: totalRendered > 0 ? totalCached / totalRendered : 0,
|
|
492
|
+
maxPerturbation,
|
|
493
|
+
rmsPerturbation: Math.sqrt(sumSqPerturb / Math.max(1, steps.length)),
|
|
494
|
+
foldEvents,
|
|
495
|
+
escalations,
|
|
496
|
+
};
|
|
497
|
+
}
|