@animalabs/context-manager 0.5.3 → 0.5.5

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 (123) 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 +166 -0
  10. package/dist/src/adaptive/kv-control.d.ts.map +1 -0
  11. package/dist/src/adaptive/kv-control.js +434 -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/picker.d.ts +13 -0
  18. package/dist/src/adaptive/picker.d.ts.map +1 -1
  19. package/dist/src/adaptive/picker.js.map +1 -1
  20. package/dist/src/adaptive/render-offsets.d.ts +61 -0
  21. package/dist/src/adaptive/render-offsets.d.ts.map +1 -0
  22. package/dist/src/adaptive/render-offsets.js +88 -0
  23. package/dist/src/adaptive/render-offsets.js.map +1 -0
  24. package/dist/src/adaptive/strategies/kv-stable.d.ts +48 -0
  25. package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -0
  26. package/dist/src/adaptive/strategies/kv-stable.js +153 -0
  27. package/dist/src/adaptive/strategies/kv-stable.js.map +1 -0
  28. package/dist/src/adaptive/summary-tree.d.ts +98 -0
  29. package/dist/src/adaptive/summary-tree.d.ts.map +1 -0
  30. package/dist/src/adaptive/summary-tree.js +214 -0
  31. package/dist/src/adaptive/summary-tree.js.map +1 -0
  32. package/dist/src/context-log.d.ts.map +1 -1
  33. package/dist/src/context-log.js +7 -2
  34. package/dist/src/context-log.js.map +1 -1
  35. package/dist/src/context-manager.d.ts +10 -5
  36. package/dist/src/context-manager.d.ts.map +1 -1
  37. package/dist/src/context-manager.js +9 -0
  38. package/dist/src/context-manager.js.map +1 -1
  39. package/dist/src/index.d.ts +1 -0
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +2 -0
  42. package/dist/src/index.js.map +1 -1
  43. package/dist/src/message-store.js +1 -1
  44. package/dist/src/message-store.js.map +1 -1
  45. package/dist/src/phase-channel.d.ts +13 -0
  46. package/dist/src/phase-channel.d.ts.map +1 -0
  47. package/dist/src/phase-channel.js +55 -0
  48. package/dist/src/phase-channel.js.map +1 -0
  49. package/dist/src/strategies/autobiographical.d.ts +107 -44
  50. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  51. package/dist/src/strategies/autobiographical.js +437 -230
  52. package/dist/src/strategies/autobiographical.js.map +1 -1
  53. package/dist/src/types/index.d.ts +1 -1
  54. package/dist/src/types/index.d.ts.map +1 -1
  55. package/dist/src/types/index.js.map +1 -1
  56. package/dist/src/types/strategy.d.ts +70 -7
  57. package/dist/src/types/strategy.d.ts.map +1 -1
  58. package/dist/src/types/strategy.js +2 -0
  59. package/dist/src/types/strategy.js.map +1 -1
  60. package/dist/test/adaptive/kv-cache-sim.test.d.ts +16 -0
  61. package/dist/test/adaptive/kv-cache-sim.test.d.ts.map +1 -0
  62. package/dist/test/adaptive/kv-cache-sim.test.js +173 -0
  63. package/dist/test/adaptive/kv-cache-sim.test.js.map +1 -0
  64. package/dist/test/adaptive/kv-control.test.d.ts +14 -0
  65. package/dist/test/adaptive/kv-control.test.d.ts.map +1 -0
  66. package/dist/test/adaptive/kv-control.test.js +130 -0
  67. package/dist/test/adaptive/kv-control.test.js.map +1 -0
  68. package/dist/test/adaptive/kv-replay.test.d.ts +15 -0
  69. package/dist/test/adaptive/kv-replay.test.d.ts.map +1 -0
  70. package/dist/test/adaptive/kv-replay.test.js +112 -0
  71. package/dist/test/adaptive/kv-replay.test.js.map +1 -0
  72. package/dist/test/adaptive/kv-stable-strategy.test.d.ts +10 -0
  73. package/dist/test/adaptive/kv-stable-strategy.test.d.ts.map +1 -0
  74. package/dist/test/adaptive/kv-stable-strategy.test.js +89 -0
  75. package/dist/test/adaptive/kv-stable-strategy.test.js.map +1 -0
  76. package/dist/test/adaptive/pin-at-level.test.d.ts +16 -0
  77. package/dist/test/adaptive/pin-at-level.test.d.ts.map +1 -0
  78. package/dist/test/adaptive/pin-at-level.test.js +133 -0
  79. package/dist/test/adaptive/pin-at-level.test.js.map +1 -0
  80. package/dist/test/adaptive/render-offsets.test.d.ts +11 -0
  81. package/dist/test/adaptive/render-offsets.test.d.ts.map +1 -0
  82. package/dist/test/adaptive/render-offsets.test.js +85 -0
  83. package/dist/test/adaptive/render-offsets.test.js.map +1 -0
  84. package/dist/test/adaptive/summary-tree.test.d.ts +5 -0
  85. package/dist/test/adaptive/summary-tree.test.d.ts.map +1 -0
  86. package/dist/test/adaptive/summary-tree.test.js +72 -0
  87. package/dist/test/adaptive/summary-tree.test.js.map +1 -0
  88. package/dist/test/image-strip-render-stats.test.d.ts +2 -0
  89. package/dist/test/image-strip-render-stats.test.d.ts.map +1 -0
  90. package/dist/test/image-strip-render-stats.test.js +130 -0
  91. package/dist/test/image-strip-render-stats.test.js.map +1 -0
  92. package/dist/test/image-stripping.test.d.ts +13 -0
  93. package/dist/test/image-stripping.test.d.ts.map +1 -0
  94. package/dist/test/image-stripping.test.js +104 -0
  95. package/dist/test/image-stripping.test.js.map +1 -0
  96. package/dist/test/pins.test.js +40 -0
  97. package/dist/test/pins.test.js.map +1 -1
  98. package/dist/test/render-stats.test.d.ts +2 -0
  99. package/dist/test/render-stats.test.d.ts.map +1 -0
  100. package/dist/test/render-stats.test.js +91 -0
  101. package/dist/test/render-stats.test.js.map +1 -0
  102. package/dist/test/speculation-cap.test.d.ts +9 -3
  103. package/dist/test/speculation-cap.test.d.ts.map +1 -1
  104. package/dist/test/speculation-cap.test.js +175 -3
  105. package/dist/test/speculation-cap.test.js.map +1 -1
  106. package/dist/tsconfig.tsbuildinfo +1 -1
  107. package/package.json +2 -2
  108. package/src/adaptive/index.ts +48 -0
  109. package/src/adaptive/kv-cache-sim.ts +0 -0
  110. package/src/adaptive/kv-control.ts +574 -0
  111. package/src/adaptive/kv-replay.ts +238 -0
  112. package/src/adaptive/picker.ts +13 -0
  113. package/src/adaptive/render-offsets.ts +116 -0
  114. package/src/adaptive/strategies/kv-stable.ts +161 -0
  115. package/src/adaptive/summary-tree.ts +252 -0
  116. package/src/context-log.ts +7 -2
  117. package/src/context-manager.ts +13 -2
  118. package/src/index.ts +3 -0
  119. package/src/message-store.ts +1 -1
  120. package/src/phase-channel.ts +57 -0
  121. package/src/strategies/autobiographical.ts +453 -282
  122. package/src/types/index.ts +1 -0
  123. package/src/types/strategy.ts +71 -3
@@ -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,574 @@
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
+ maxLevel: number = MAX_FOLD_LEVEL,
111
+ ): number {
112
+ if (chunk.pinned || flatZone.has(chunk.id)) return 0;
113
+ const age = Math.max(0, now - chunk.sequence);
114
+ const ratio = age / Math.max(1, flatZoneChunks);
115
+ if (ratio < 1) return 0; // just outside the flat zone → still raw
116
+ const band = Math.floor(Math.log(ratio) / Math.log(Math.max(2, mergeThreshold))) + 1;
117
+ return Math.max(0, Math.min(maxLevel, band));
118
+ }
119
+
120
+ /** Deepest summary level that actually exists in the tree (0 if none). The
121
+ * fold depth is bounded by this — NOT a hardcoded constant — so the controller
122
+ * uses L4/L5… summaries when production has built them (else it floors at L3 and
123
+ * refuses deeper summaries that exist; cf. the lab L3-floor-with-L4-present). */
124
+ function maxAvailableLevel(tree: SummaryTree): number {
125
+ let m = 0;
126
+ for (const s of tree.allSummaries()) if (s.level > m) m = s.level;
127
+ return m;
128
+ }
129
+
130
+ /**
131
+ * Deepen `frontier` in place to bring rendered tokens ≤ `target`, leveled
132
+ * (L1 groups, then L2, then L3) and oldest-first *within the reach window*. A
133
+ * chunk is editable this turn only if the raw tokens newer than it are <
134
+ * `reachTokens` — this bounds the divergence depth (and thus the per-turn KV
135
+ * perturbation) to ≈ reach. Each chunk is bounded by its saliency cap;
136
+ * flat-zone/pinned chunks (cap 0) are never touched.
137
+ *
138
+ * `reachTokens` is the soft cap P; `windowTokens` is the hard wall W. If folding
139
+ * within reach can't fit under W, reach is lifted (emergency) and folding
140
+ * continues to caps — yielding P minimally to keep W. Returns final tokens.
141
+ */
142
+ function shedToTarget(
143
+ frontier: Map<ChunkId, number>,
144
+ inputs: PickerInputs,
145
+ tree: SummaryTree,
146
+ ordered: PickerChunk[],
147
+ caps: Map<ChunkId, number>,
148
+ target: number,
149
+ reachTokens: number,
150
+ windowTokens: number,
151
+ maxFoldLevel: number,
152
+ pinCaps: ReadonlyMap<ChunkId, number>,
153
+ ): number {
154
+ // Raw tokens strictly newer than each chunk (its distance from the live end).
155
+ const newerTokens = new Map<ChunkId, number>();
156
+ let acc = 0;
157
+ for (let i = ordered.length - 1; i >= 0; i--) {
158
+ newerTokens.set(ordered[i].id, acc);
159
+ acc += ordered[i].rawTokens;
160
+ }
161
+
162
+ const foldWithinReach = (reach: number, ignoreCaps = false): boolean => {
163
+ for (let level = 1; level <= maxFoldLevel; level++) {
164
+ for (const c of ordered) {
165
+ // oldest-first within reach
166
+ if ((newerTokens.get(c.id) ?? 0) >= reach) continue;
167
+ if ((frontier.get(c.id) ?? 0) >= level) continue;
168
+ const ancestor = tree.ancestorAt(c.id, level);
169
+ if (!ancestor) continue; // summary not produced yet → can't fold here
170
+ // Group-consistency + reach: fold the WHOLE group to `level` only if
171
+ // every covered leaf permits it (within the saliency cap unless
172
+ // `ignoreCaps`, and within reach). The picker raises whole groups, so a
173
+ // partially-eligible group would be an unreachable target (it
174
+ // oscillates). A uniform fold keeps the frontier group-consistent and
175
+ // the picker convergent. `rawZone`/`frozen` always block (their cap is 0
176
+ // and stays 0 even under ignoreCaps).
177
+ let eligible = true;
178
+ for (const leafId of ancestor.leafChunkIds) {
179
+ const cap = caps.get(leafId) ?? 0;
180
+ // A V2 pin-max-level is a HARD cap: never fold a pinned chunk deeper
181
+ // than its bound, even in the W emergency (pins are hard-protected).
182
+ const pinCap = pinCaps.get(leafId);
183
+ if (pinCap !== undefined && level > pinCap) {
184
+ eligible = false;
185
+ break;
186
+ }
187
+ // Normal: respect the saliency depth cap. Emergency (ignoreCaps): only
188
+ // the hard-protected set (cap = −1: flat zone / pins / locked) blocks;
189
+ // the age-extended-raw band (cap 0) and shallower-than-cap content all
190
+ // become foldable to fit under W.
191
+ const capBlocks = ignoreCaps ? cap < 0 : cap < level;
192
+ if (capBlocks || (newerTokens.get(leafId) ?? 0) >= reach) {
193
+ eligible = false;
194
+ break;
195
+ }
196
+ }
197
+ if (!eligible) continue;
198
+ for (const leafId of ancestor.leafChunkIds) {
199
+ if ((frontier.get(leafId) ?? 0) < level) frontier.set(leafId, level);
200
+ }
201
+ if (renderLayout(inputs, tree, frontier).totalTokens <= target) return true;
202
+ }
203
+ }
204
+ return false;
205
+ };
206
+
207
+ foldWithinReach(reachTokens);
208
+ let tokens = renderLayout(inputs, tree, frontier).totalTokens;
209
+ // Hard wall: W is the only hard constraint. If folding within reach + caps
210
+ // still exceeds W, the emergency lifts BOTH the reach cap (P) AND the saliency
211
+ // depth cap — the cap is a soft *relevance* shaper, never a feasibility wall.
212
+ // It still never folds rawZone/frozen (their cap stays 0). (docs
213
+ // adaptive-resolution-design.md §12.4: fixes OverBudgetError-at-L2 when deeper
214
+ // summaries exist and would fit.)
215
+ if (tokens > windowTokens) {
216
+ foldWithinReach(Infinity, /* ignoreCaps */ true);
217
+ tokens = renderLayout(inputs, tree, frontier).totalTokens;
218
+ }
219
+ return tokens;
220
+ }
221
+
222
+ /**
223
+ * Raise `frontier` resolution in place (UN-fold toward raw) to fill up to
224
+ * `target`, leveled (L3→L2, L2→L1, L1→raw) and **youngest-first within the reach
225
+ * window** — the mirror image of `shedToTarget`. Un-folding is a prefix
226
+ * perturbation too, so it obeys the same reach cap (bounded divergence) and only
227
+ * lowers whole groups (group-consistent → the picker reaches it via `lower`
228
+ * ops). Youngest-first spends the budget on the most recent foldable content
229
+ * (highest value, shallowest divergence). Stops before exceeding `target`.
230
+ * rawZone/frozen chunks are never touched. Returns final tokens.
231
+ */
232
+ function expandToTarget(
233
+ frontier: Map<ChunkId, number>,
234
+ inputs: PickerInputs,
235
+ tree: SummaryTree,
236
+ ordered: PickerChunk[],
237
+ target: number,
238
+ reachTokens: number,
239
+ rawZone: ReadonlySet<ChunkId>,
240
+ immovable: ReadonlySet<ChunkId>,
241
+ maxFoldLevel: number,
242
+ ): number {
243
+ const newerTokens = new Map<ChunkId, number>();
244
+ let acc = 0;
245
+ for (let i = ordered.length - 1; i >= 0; i--) {
246
+ newerTokens.set(ordered[i].id, acc);
247
+ acc += ordered[i].rawTokens;
248
+ }
249
+
250
+ let tokens = renderLayout(inputs, tree, frontier).totalTokens;
251
+ for (let level = maxFoldLevel; level >= 1 && tokens < target; level--) {
252
+ for (let oi = ordered.length - 1; oi >= 0; oi--) {
253
+ if (tokens >= target) break;
254
+ const c = ordered[oi]; // youngest-first
255
+ if ((frontier.get(c.id) ?? 0) !== level) continue;
256
+ if (rawZone.has(c.id) || immovable.has(c.id)) continue;
257
+ if ((newerTokens.get(c.id) ?? 0) >= reachTokens) continue;
258
+ const node = tree.ancestorAt(c.id, level);
259
+ if (!node) continue;
260
+ // Lower the WHOLE group to level-1 only if every covered leaf is at this
261
+ // level and within reach (group-consistent + reach-bounded).
262
+ let eligible = true;
263
+ for (const leafId of node.leafChunkIds) {
264
+ if (
265
+ (frontier.get(leafId) ?? 0) !== level ||
266
+ rawZone.has(leafId) || immovable.has(leafId) ||
267
+ (newerTokens.get(leafId) ?? 0) >= reachTokens
268
+ ) {
269
+ eligible = false;
270
+ break;
271
+ }
272
+ }
273
+ if (!eligible) continue;
274
+ for (const leafId of node.leafChunkIds) frontier.set(leafId, level - 1);
275
+ const nt = renderLayout(inputs, tree, frontier).totalTokens;
276
+ if (nt <= target) tokens = nt; // accept the un-fold
277
+ else for (const leafId of node.leafChunkIds) frontier.set(leafId, level); // overshoot → revert
278
+ }
279
+ }
280
+ return tokens;
281
+ }
282
+
283
+ /** Earliest source sequence whose resolution changed `prev → next`. */
284
+ function earliestChangedSequence(
285
+ prev: Frontier,
286
+ next: ReadonlyMap<ChunkId, number>,
287
+ ordered: PickerChunk[],
288
+ ): number {
289
+ for (const c of ordered) {
290
+ if ((prev.get(c.id) ?? 0) !== (next.get(c.id) ?? 0)) return c.sequence;
291
+ }
292
+ return Number.MAX_SAFE_INTEGER;
293
+ }
294
+
295
+ function unitsBeforeSequence(layout: RenderLayout, tree: SummaryTree, boundary: number): number {
296
+ let count = 0;
297
+ for (const u of layout.units) {
298
+ const seq =
299
+ u.kind === 'raw' ? tree.leaf(u.key)?.sequence ?? 0
300
+ : u.kind === 'recall' ? tree.summary(u.key)?.firstSequence ?? 0
301
+ : u.kind === 'head' ? -1
302
+ : Number.MAX_SAFE_INTEGER;
303
+ if (seq < boundary) count++;
304
+ else break;
305
+ }
306
+ return count;
307
+ }
308
+
309
+ function rawTailSet(ordered: PickerChunk[], tailTokens: number): Set<ChunkId> {
310
+ const s = new Set<ChunkId>();
311
+ if (tailTokens <= 0) return s;
312
+ let used = 0;
313
+ for (let i = ordered.length - 1; i >= 0; i--) {
314
+ s.add(ordered[i].id);
315
+ used += ordered[i].rawTokens;
316
+ if (used >= tailTokens) break;
317
+ }
318
+ return s;
319
+ }
320
+
321
+ const EMPTY_SET: ReadonlySet<ChunkId> = new Set();
322
+ const EMPTY_LEVEL_MAP: ReadonlyMap<ChunkId, number> = new Map();
323
+
324
+ export interface ControlPlanParams {
325
+ /** Carried frontier (F_prev). */
326
+ previous: ReadonlyMap<ChunkId, number>;
327
+ /** Fold (deepen) when rendered tokens exceed this (high-watermark). */
328
+ foldAtTokens: number;
329
+ /** Un-fold (raise toward raw) when rendered tokens are below this
330
+ * (low-watermark). Default = targetTokens. The band (foldAt − expandAt) is a
331
+ * no-op dead zone that prevents fold↔unfold oscillation. Set below foldAt. */
332
+ expandAtTokens?: number;
333
+ /** The attractor both fold and un-fold aim for (soft target). */
334
+ targetTokens: number;
335
+ /** Hard wall W — fold past the reach cap only to keep under this. */
336
+ windowTokens: number;
337
+ /** Per-turn divergence reach (perturbation cap P) — bounds BOTH fold and
338
+ * un-fold divergence. Default = windowTokens. */
339
+ reachTokens?: number;
340
+ /** Chunks forced to raw and never folded (flat zone / head / tail / pinned). */
341
+ rawZone: ReadonlySet<ChunkId>;
342
+ /** Chunks kept at their carried resolution and never touched (locked). */
343
+ frozen?: ReadonlySet<ChunkId>;
344
+ /**
345
+ * V2 pin-at-level-k: chunks fixed to render at EXACTLY the given fold level
346
+ * (0 = raw). Held there and never folded or un-folded — the frontier cut
347
+ * passes through that L_k node. A level deeper than the tree has produced is
348
+ * clamped to the deepest available. (`ProtectedRange.level`.)
349
+ */
350
+ fixedLevels?: ReadonlyMap<ChunkId, number>;
351
+ /**
352
+ * V2 pin-max-level: per-chunk HARD cap on fold depth — the chunk may fold no
353
+ * deeper than this level, enforced in normal AND emergency shedding, and any
354
+ * carried resolution deeper than the cap is un-folded to it at plan start.
355
+ * (`ProtectedRange.maxLevel`.)
356
+ */
357
+ pinCaps?: ReadonlyMap<ChunkId, number>;
358
+ /** Newest source sequence — the age reference for the log-age fold caps. */
359
+ now: number;
360
+ /** Base-k summary grouping. Default 6. */
361
+ mergeThreshold?: number;
362
+ }
363
+
364
+ export interface ControlPlan {
365
+ resolutions: Map<ChunkId, number>;
366
+ tokens: number;
367
+ /** A fold (deepen) happened this turn. */
368
+ folded: boolean;
369
+ /** An un-fold (raise toward raw, to use budget headroom) happened this turn. */
370
+ expanded: boolean;
371
+ /** Even full folding under caps could not fit under W (terminal). */
372
+ escalated: boolean;
373
+ }
374
+
375
+ /**
376
+ * Plan one turn's frontier under the controller policy — shared by the replay
377
+ * harness (`replayControlled`) and `KvStableStrategy`. Carries F_prev forward,
378
+ * holds the raw zone raw and the frozen set fixed, then drives the rendered size
379
+ * toward `targetTokens` from EITHER side, both bounded by the `reachTokens`
380
+ * divergence cap (KV-continuity preserving in both directions):
381
+ * - over `foldAtTokens` → deepen, oldest-first, under the log-age saliency
382
+ * caps, yielding the reach cap only as needed to stay under the hard wall W;
383
+ * - under `expandAtTokens` → un-fold, youngest-first, to use budget headroom.
384
+ * The [expandAt, foldAt] dead band leaves F_prev untouched (zero perturbation)
385
+ * when already in range. Pure and deterministic.
386
+ */
387
+ export function planControlledFrontier(
388
+ inputs: PickerInputs,
389
+ tree: SummaryTree,
390
+ p: ControlPlanParams,
391
+ ): ControlPlan {
392
+ const ordered = [...inputs.chunks].sort((a, b) => a.sequence - b.sequence);
393
+ const frozen = p.frozen ?? EMPTY_SET;
394
+ const fixedLevels = p.fixedLevels ?? EMPTY_LEVEL_MAP;
395
+ const pinCaps = p.pinCaps ?? EMPTY_LEVEL_MAP;
396
+ const mergeThreshold = p.mergeThreshold ?? 6;
397
+ const reach = p.reachTokens ?? p.windowTokens;
398
+ const expandAt = p.expandAtTokens ?? p.targetTokens;
399
+ // Fold as deep as the tree actually goes (L4/L5… when produced), not a constant.
400
+ const maxFoldLevel = maxAvailableLevel(tree);
401
+
402
+ // Immovable = never folded OR un-folded by the shed/expand passes: the locked
403
+ // (frozen) set plus V2 pin-at-level-k chunks (held at their fixed level).
404
+ const immovable = new Set<ChunkId>(frozen);
405
+ for (const id of fixedLevels.keys()) immovable.add(id);
406
+
407
+ const F = new Map<ChunkId, number>();
408
+ for (const c of ordered) {
409
+ if (p.rawZone.has(c.id)) {
410
+ F.set(c.id, 0);
411
+ } else if (fixedLevels.has(c.id)) {
412
+ // Pin-at-k: fix to exactly k, clamped to the deepest produced level for
413
+ // this chunk (can't render at a level whose summary doesn't exist yet).
414
+ const k = Math.max(0, fixedLevels.get(c.id)!);
415
+ F.set(c.id, k === 0 ? 0 : Math.min(k, tree.maxLevel(c.id)));
416
+ } else if (frozen.has(c.id)) {
417
+ F.set(c.id, p.previous.get(c.id) ?? c.currentResolution);
418
+ } else {
419
+ // Carry F_prev, but enforce a pin-max-level immediately: un-fold anything
420
+ // carried deeper than its cap down to the cap (shallower summaries — its
421
+ // ancestors — are guaranteed to exist). This is the intended divergence
422
+ // cost of adding/tightening a pin (design §7).
423
+ let lvl = p.previous.get(c.id) ?? 0;
424
+ const cap = pinCaps.get(c.id);
425
+ if (cap !== undefined && lvl > cap) lvl = Math.max(0, cap);
426
+ F.set(c.id, lvl);
427
+ }
428
+ }
429
+
430
+ let tokens = renderLayout(inputs, tree, F).totalTokens;
431
+ const before = tokens;
432
+ let folded = false;
433
+ let expanded = false;
434
+ let escalated = false;
435
+ if (tokens > p.foldAtTokens) {
436
+ const flatZoneChunks = p.rawZone.size;
437
+ const caps = new Map<ChunkId, number>();
438
+ for (const c of ordered) {
439
+ let cap: number;
440
+ if (p.rawZone.has(c.id) || immovable.has(c.id)) {
441
+ cap = -1; // hard-protected sentinel: never fold, even in the W emergency
442
+ } else {
443
+ cap = foldDepthCap(c, p.now, p.rawZone, flatZoneChunks, mergeThreshold, maxFoldLevel);
444
+ const pinCap = pinCaps.get(c.id);
445
+ if (pinCap !== undefined) cap = Math.min(cap, Math.max(0, pinCap));
446
+ }
447
+ caps.set(c.id, cap);
448
+ }
449
+ tokens = shedToTarget(F, inputs, tree, ordered, caps, p.targetTokens, reach, p.windowTokens, maxFoldLevel, pinCaps);
450
+ folded = tokens !== before; // a real fold only if it actually changed the render
451
+ if (tokens > p.windowTokens) escalated = true;
452
+ } else if (tokens < expandAt) {
453
+ tokens = expandToTarget(F, inputs, tree, ordered, p.targetTokens, reach, p.rawZone, immovable, maxFoldLevel);
454
+ expanded = tokens !== before; // a real un-fold only if it actually changed
455
+ }
456
+ return { resolutions: F, tokens, folded, expanded, escalated };
457
+ }
458
+
459
+ /**
460
+ * Run the controller over a session (the exported PickerInputs snapshot) as
461
+ * growing history, measuring billed recompute and KV perturbation per turn.
462
+ */
463
+ export function replayControlled(fullInputs: PickerInputs, opts: ControlOptions): ControlResult {
464
+ const mergeThreshold = opts.mergeThreshold ?? 6;
465
+ const markerCount = opts.markerCount ?? 4;
466
+ const targetSteps = Math.max(1, opts.targetSteps ?? 120);
467
+ const HW = opts.highWatermark ?? opts.budgetTokens;
468
+ const LW = opts.lowWatermark ?? Math.round(0.8 * opts.budgetTokens);
469
+ const reachTokens = opts.reachTokens ?? opts.budgetTokens;
470
+
471
+ const fullTree = new SummaryTree(fullInputs);
472
+ const availableAt = new Map<string, number>();
473
+ for (const s of fullTree.allSummaries()) availableAt.set(s.id, s.lastSequence);
474
+
475
+ const allChunks = [...fullInputs.chunks].sort((a, b) => a.sequence - b.sequence);
476
+ const seqValues = allChunks.map((c) => c.sequence);
477
+ const stride = Math.max(1, Math.ceil(seqValues.length / targetSteps));
478
+ const stepSeqs: number[] = [];
479
+ for (let i = 0; i < seqValues.length; i += stride) stepSeqs.push(seqValues[i]);
480
+ const last = seqValues[seqValues.length - 1];
481
+ if (stepSeqs[stepSeqs.length - 1] !== last) stepSeqs.push(last);
482
+
483
+ const steps: ControlStep[] = [];
484
+ let fprev: Frontier = new Map<ChunkId, number>();
485
+ const cache = new CacheStore({ ttlSteps: opts.cacheTtlSteps });
486
+ let prevNow = -1;
487
+ let totalRecomputed = 0;
488
+ let totalBilled = 0;
489
+ let totalCached = 0;
490
+ let totalRendered = 0;
491
+ let sumSqPerturb = 0;
492
+ let maxPerturbation = 0;
493
+ let foldEvents = 0;
494
+ let escalations = 0;
495
+
496
+ for (const now of stepSeqs) {
497
+ const visible = allChunks.filter((c) => c.sequence <= now);
498
+ const summaries = new Map<string, SummaryEntry>();
499
+ const recallPairTokens = new Map<string, number>();
500
+ for (const [id, s] of fullInputs.summaries) {
501
+ const lastSeq = availableAt.get(id);
502
+ if (lastSeq !== undefined && lastSeq >= 0 && lastSeq <= now) {
503
+ summaries.set(id, s);
504
+ const rp = fullInputs.recallPairTokens?.get(id);
505
+ if (rp !== undefined) recallPairTokens.set(id, rp);
506
+ }
507
+ }
508
+ const inputs: PickerInputs = {
509
+ chunks: visible, summaries, recallPairTokens,
510
+ headTokens: 0, tailTokens: 0, headChunkIds: new Set(), tailChunkIds: new Set(),
511
+ };
512
+ const tree = new SummaryTree(inputs);
513
+ const flatZone = rawTailSet(visible, opts.attendedWindowTokens);
514
+ // Plan this turn's frontier via the shared controller policy (the same code
515
+ // KvStableStrategy runs): carry F_prev, hold the flat zone raw, fold to LW
516
+ // when over HW, and un-fold to LW when under it — both reach-bounded, with
517
+ // [LW, HW] a no-op dead band; never past the hard wall W.
518
+ const plan = planControlledFrontier(inputs, tree, {
519
+ previous: fprev, foldAtTokens: HW, expandAtTokens: LW, targetTokens: LW,
520
+ windowTokens: opts.windowTokens, reachTokens, rawZone: flatZone, now, mergeThreshold,
521
+ });
522
+ const F = plan.resolutions;
523
+ const folded = plan.folded || plan.expanded; // any active prefix change this turn
524
+ const escalated = plan.escalated;
525
+
526
+ const layout = renderLayout(inputs, tree, F);
527
+ const hit = cache.read(layout);
528
+ const recomputed = layout.totalTokens - hit.cachedTokens;
529
+
530
+ // Perturbation = recompute beyond genuinely new content (prefix churn).
531
+ let newContent = 0;
532
+ for (const c of visible) if (c.sequence > prevNow) newContent += c.rawTokens;
533
+ const perturbation = Math.max(0, recomputed - newContent);
534
+
535
+ const billed = PRICE.miss * recomputed + PRICE.cacheRead * hit.cachedTokens;
536
+
537
+ // Write breakpoints for future turns, hinting at the change boundary.
538
+ const boundarySeq = earliestChangedSequence(fprev, F, visible);
539
+ const boundaryUnitIndex = unitsBeforeSequence(layout, tree, boundarySeq);
540
+ cache.write(layout, placeMarkers(layout, markerCount, { boundaryUnitIndex }));
541
+ cache.tick();
542
+
543
+ let deepestLevel = 0;
544
+ for (const lvl of F.values()) if (lvl > deepestLevel) deepestLevel = lvl;
545
+
546
+ steps.push({
547
+ now, numChunks: visible.length, renderedTokens: layout.totalTokens,
548
+ cachedTokens: hit.cachedTokens, recomputedTokens: recomputed, billed,
549
+ perturbation, folded, escalated, deepestLevel,
550
+ });
551
+ totalRecomputed += recomputed;
552
+ totalBilled += billed;
553
+ totalCached += hit.cachedTokens;
554
+ totalRendered += layout.totalTokens;
555
+ sumSqPerturb += perturbation * perturbation;
556
+ if (perturbation > maxPerturbation) maxPerturbation = perturbation;
557
+ if (folded) foldEvents++;
558
+ if (escalated) escalations++;
559
+
560
+ fprev = F;
561
+ prevNow = now;
562
+ }
563
+
564
+ return {
565
+ steps,
566
+ totalRecomputed,
567
+ totalBilled,
568
+ overallHitRate: totalRendered > 0 ? totalCached / totalRendered : 0,
569
+ maxPerturbation,
570
+ rmsPerturbation: Math.sqrt(sumSqPerturb / Math.max(1, steps.length)),
571
+ foldEvents,
572
+ escalations,
573
+ };
574
+ }