@animalabs/context-manager 0.5.2 → 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.
Files changed (110) hide show
  1. package/dist/src/adaptive/index.d.ts +6 -0
  2. package/dist/src/adaptive/index.d.ts.map +1 -1
  3. package/dist/src/adaptive/index.js +8 -0
  4. package/dist/src/adaptive/index.js.map +1 -1
  5. package/dist/src/adaptive/kv-cache-sim.d.ts +128 -0
  6. package/dist/src/adaptive/kv-cache-sim.d.ts.map +1 -0
  7. package/dist/src/adaptive/kv-cache-sim.js +0 -0
  8. package/dist/src/adaptive/kv-cache-sim.js.map +1 -0
  9. package/dist/src/adaptive/kv-control.d.ts +152 -0
  10. package/dist/src/adaptive/kv-control.d.ts.map +1 -0
  11. package/dist/src/adaptive/kv-control.js +368 -0
  12. package/dist/src/adaptive/kv-control.js.map +1 -0
  13. package/dist/src/adaptive/kv-replay.d.ts +76 -0
  14. package/dist/src/adaptive/kv-replay.d.ts.map +1 -0
  15. package/dist/src/adaptive/kv-replay.js +175 -0
  16. package/dist/src/adaptive/kv-replay.js.map +1 -0
  17. package/dist/src/adaptive/render-offsets.d.ts +61 -0
  18. package/dist/src/adaptive/render-offsets.d.ts.map +1 -0
  19. package/dist/src/adaptive/render-offsets.js +88 -0
  20. package/dist/src/adaptive/render-offsets.js.map +1 -0
  21. package/dist/src/adaptive/strategies/kv-stable.d.ts +48 -0
  22. package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -0
  23. package/dist/src/adaptive/strategies/kv-stable.js +102 -0
  24. package/dist/src/adaptive/strategies/kv-stable.js.map +1 -0
  25. package/dist/src/adaptive/summary-tree.d.ts +98 -0
  26. package/dist/src/adaptive/summary-tree.d.ts.map +1 -0
  27. package/dist/src/adaptive/summary-tree.js +214 -0
  28. package/dist/src/adaptive/summary-tree.js.map +1 -0
  29. package/dist/src/context-log.d.ts.map +1 -1
  30. package/dist/src/context-log.js +7 -2
  31. package/dist/src/context-log.js.map +1 -1
  32. package/dist/src/index.d.ts +1 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/message-store.js +1 -1
  37. package/dist/src/message-store.js.map +1 -1
  38. package/dist/src/normalize-tool-messages.d.ts +33 -0
  39. package/dist/src/normalize-tool-messages.d.ts.map +1 -1
  40. package/dist/src/normalize-tool-messages.js +59 -0
  41. package/dist/src/normalize-tool-messages.js.map +1 -1
  42. package/dist/src/strategies/autobiographical.d.ts +101 -39
  43. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  44. package/dist/src/strategies/autobiographical.js +398 -222
  45. package/dist/src/strategies/autobiographical.js.map +1 -1
  46. package/dist/src/types/strategy.d.ts +40 -1
  47. package/dist/src/types/strategy.d.ts.map +1 -1
  48. package/dist/src/types/strategy.js +2 -0
  49. package/dist/src/types/strategy.js.map +1 -1
  50. package/dist/test/adaptive/kv-cache-sim.test.d.ts +16 -0
  51. package/dist/test/adaptive/kv-cache-sim.test.d.ts.map +1 -0
  52. package/dist/test/adaptive/kv-cache-sim.test.js +173 -0
  53. package/dist/test/adaptive/kv-cache-sim.test.js.map +1 -0
  54. package/dist/test/adaptive/kv-control.test.d.ts +14 -0
  55. package/dist/test/adaptive/kv-control.test.d.ts.map +1 -0
  56. package/dist/test/adaptive/kv-control.test.js +130 -0
  57. package/dist/test/adaptive/kv-control.test.js.map +1 -0
  58. package/dist/test/adaptive/kv-replay.test.d.ts +15 -0
  59. package/dist/test/adaptive/kv-replay.test.d.ts.map +1 -0
  60. package/dist/test/adaptive/kv-replay.test.js +112 -0
  61. package/dist/test/adaptive/kv-replay.test.js.map +1 -0
  62. package/dist/test/adaptive/kv-stable-strategy.test.d.ts +10 -0
  63. package/dist/test/adaptive/kv-stable-strategy.test.d.ts.map +1 -0
  64. package/dist/test/adaptive/kv-stable-strategy.test.js +89 -0
  65. package/dist/test/adaptive/kv-stable-strategy.test.js.map +1 -0
  66. package/dist/test/adaptive/render-offsets.test.d.ts +11 -0
  67. package/dist/test/adaptive/render-offsets.test.d.ts.map +1 -0
  68. package/dist/test/adaptive/render-offsets.test.js +85 -0
  69. package/dist/test/adaptive/render-offsets.test.js.map +1 -0
  70. package/dist/test/adaptive/summary-tree.test.d.ts +5 -0
  71. package/dist/test/adaptive/summary-tree.test.d.ts.map +1 -0
  72. package/dist/test/adaptive/summary-tree.test.js +72 -0
  73. package/dist/test/adaptive/summary-tree.test.js.map +1 -0
  74. package/dist/test/chunker-tool-boundary.test.d.ts +12 -0
  75. package/dist/test/chunker-tool-boundary.test.d.ts.map +1 -0
  76. package/dist/test/chunker-tool-boundary.test.js +89 -0
  77. package/dist/test/chunker-tool-boundary.test.js.map +1 -0
  78. package/dist/test/compression-shape-invariants.test.d.ts +22 -0
  79. package/dist/test/compression-shape-invariants.test.d.ts.map +1 -0
  80. package/dist/test/compression-shape-invariants.test.js +312 -0
  81. package/dist/test/compression-shape-invariants.test.js.map +1 -0
  82. package/dist/test/image-strip-render-stats.test.d.ts +2 -0
  83. package/dist/test/image-strip-render-stats.test.d.ts.map +1 -0
  84. package/dist/test/image-strip-render-stats.test.js +130 -0
  85. package/dist/test/image-strip-render-stats.test.js.map +1 -0
  86. package/dist/test/image-stripping.test.d.ts +13 -0
  87. package/dist/test/image-stripping.test.d.ts.map +1 -0
  88. package/dist/test/image-stripping.test.js +104 -0
  89. package/dist/test/image-stripping.test.js.map +1 -0
  90. package/dist/test/normalize-tool-messages.test.js +61 -1
  91. package/dist/test/normalize-tool-messages.test.js.map +1 -1
  92. package/dist/test/render-stats.test.d.ts +2 -0
  93. package/dist/test/render-stats.test.d.ts.map +1 -0
  94. package/dist/test/render-stats.test.js +91 -0
  95. package/dist/test/render-stats.test.js.map +1 -0
  96. package/dist/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +2 -2
  98. package/src/adaptive/index.ts +48 -0
  99. package/src/adaptive/kv-cache-sim.ts +0 -0
  100. package/src/adaptive/kv-control.ts +497 -0
  101. package/src/adaptive/kv-replay.ts +238 -0
  102. package/src/adaptive/render-offsets.ts +116 -0
  103. package/src/adaptive/strategies/kv-stable.ts +123 -0
  104. package/src/adaptive/summary-tree.ts +252 -0
  105. package/src/context-log.ts +7 -2
  106. package/src/index.ts +1 -1
  107. package/src/message-store.ts +1 -1
  108. package/src/normalize-tool-messages.ts +61 -0
  109. package/src/strategies/autobiographical.ts +414 -271
  110. package/src/types/strategy.ts +40 -1
@@ -33,6 +33,54 @@ export type {
33
33
  // Concrete strategies
34
34
  export { FlatProfileStrategy } from './strategies/flat-profile.js';
35
35
  export { OldestFirstStrategy } from './strategies/oldest-first.js';
36
+ export { KvStableStrategy, type KvStableOptions } from './strategies/kv-stable.js';
37
+
38
+ // Summary tree + rendered-unit accounting (shared substrate)
39
+ export {
40
+ SummaryTree,
41
+ nodeTokens,
42
+ type TreeNode,
43
+ type LeafNode,
44
+ type SummaryNode,
45
+ } from './summary-tree.js';
46
+ export {
47
+ renderLayout,
48
+ kvCost,
49
+ earliestDivergenceIndex,
50
+ type RenderLayout,
51
+ type RenderedUnit,
52
+ type Frontier,
53
+ } from './render-offsets.js';
54
+
55
+ // KV-cache simulation + session replay (provider-cache stability measurement)
56
+ export {
57
+ placeMarkers,
58
+ evaluateCacheHit,
59
+ CacheStore,
60
+ MAX_CACHE_MARKERS,
61
+ type CacheMarker,
62
+ type CacheHit,
63
+ type CacheStoreOptions,
64
+ type CacheReadResult,
65
+ } from './kv-cache-sim.js';
66
+ export {
67
+ replaySession,
68
+ type ReplayOptions,
69
+ type ReplayStep,
70
+ type ReplayResult,
71
+ } from './kv-replay.js';
72
+ export {
73
+ replayControlled,
74
+ planControlledFrontier,
75
+ foldDepthCap,
76
+ PRICE,
77
+ MAX_FOLD_LEVEL,
78
+ type ControlOptions,
79
+ type ControlStep,
80
+ type ControlResult,
81
+ type ControlPlanParams,
82
+ type ControlPlan,
83
+ } from './kv-control.js';
36
84
 
37
85
  // Picker
38
86
  export {
Binary file
@@ -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
+ }