dsh-switchman 1.10.3 → 1.11.1

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.
@@ -3,21 +3,31 @@
3
3
  * The trigger half of the port (design §4/§5): decides whether a fold
4
4
  * should run NOW. Called only at idle boundaries (agent/status idle) —
5
5
  * never inside llm/stream, never while a fold for the same session is in
6
- * flight. Three tiers, checked emergency-first:
6
+ * flight. Tiers, checked emergency-first:
7
7
  *
8
8
  * - emergency : view usage >= trimEmergencyPercent of the context window.
9
- * Forced MECHANICAL fold (no side-model round trip — at 95%
10
- * the session cannot afford the latency or the failure).
11
- * - pressure : view usage >= trimPressurePercent. Side-model fold.
9
+ * Forced MECHANICAL T1 fold (no side-model round trip — at
10
+ * 95% the session cannot afford the latency or the failure).
11
+ * - pressure : view usage >= trimPressurePercent. Side-model T1 fold.
12
12
  * - growth : growth since the last fold's post-fold low (the anchor
13
13
  * that keeps the gate armed after folds committing at high
14
14
  * view points) > trimGrowthTokens AND the contiguous
15
15
  * compressible increment >= trimIncrementTokens. The
16
16
  * primary, cache-friendly gate (the bili heuristic).
17
+ * - ladder T3 : active-T2 summary mass/count gates met (docs/ladder-fold.md).
18
+ * - ladder T2 : active-T1 summary mass/count gates met.
19
+ *
20
+ * The ladder (T2/T3 distillation) is a MAINTENANCE path: raw T1 folds stay
21
+ * the primary cadence and ladder folds only fire when the summary
22
+ * population itself has grown worth reclaiming (mass gates — bili #379:
23
+ * block count is not a need signal; the count floor only filters
24
+ * degenerate runs). A ladder fold rewrites the wire from its first consumed
25
+ * summary onward (bili #1249), so the mass gates keep it rare.
17
26
  *
18
27
  * Side-model folds are additionally throttled by trimMaxFoldsPerHour and a
19
28
  * hard minimum interval between folds; emergency folds have their own
20
- * cooldown so a stuck-at-95% session folds at most once per window.
29
+ * cooldown so a stuck-at-95% session folds at most once per window; ladder
30
+ * folds observe a longer interval (LADDER_MIN_INTERVAL_MS) after ANY fold.
21
31
  * Everything is derived from measured inputs — no clock, no host access —
22
32
  * which keeps the decision unit-testable (golden thresholds).
23
33
  */
@@ -30,10 +40,18 @@ export const FOLD_MIN_INTERVAL_MS = 60_000;
30
40
  /** Minimum interval between emergency (mechanical) folds. */
31
41
  export const EMERGENCY_COOLDOWN_MS = 90_000;
32
42
 
43
+ /** Minimum wall-clock interval between ladder (T2/T3 distillation) folds,
44
+ * measured from ANY fold: every fold rebuilds the serialized view, and
45
+ * interleaving a distillation between raw folds would churn the tail twice
46
+ * per minute for marginal reclaim. */
47
+ export const LADDER_MIN_INTERVAL_MS = 120_000;
48
+
33
49
  /** The foldable span of a captured messages array for one ledger: from the
34
- * first segment boundary strictly after the frontier to the last segment
35
- * end that still clears the protected tail. Returns { startIdx, endIdx }
36
- * or null when nothing is foldable (empty history, everything folded, or
50
+ * first segment boundary strictly after the frontier AND after the task
51
+ * intent (the first user message — bili's protected zone: the session's
52
+ * opening task statement is never folded) to the last segment end that
53
+ * still clears the protected tail. Returns { startIdx, endIdx } or null
54
+ * when nothing is foldable (empty history, everything folded, or
37
55
  * protected tail covers it all). */
38
56
  export function computeFoldableRange(messages, ledger, protectedTail) {
39
57
  if (!Array.isArray(messages) || messages.length === 0) return null;
@@ -54,9 +72,22 @@ export function computeFoldableRange(messages, ledger, protectedTail) {
54
72
  // system/message over exactly that node). Covers both the frontier-null
55
73
  // first fold and any frontier-set fold that lands on the head.
56
74
  if (messages[0]?.role === "system") startFrom = Math.max(startFrom, 1);
75
+ // Task-intent protection: the first user message states the task; the
76
+ // segment containing it is never folded (its summary would become the
77
+ // only carrier of the task for the rest of the session — the dominant
78
+ // early-drift failure this guard prevents).
79
+ const firstUserIdx = messages.findIndex((message) => message?.role === "user");
80
+ if (firstUserIdx >= 0) {
81
+ for (const [start, end] of segmentBounds(messages)) {
82
+ if (start <= firstUserIdx && firstUserIdx <= end) {
83
+ startFrom = Math.max(startFrom, end + 1);
84
+ break;
85
+ }
86
+ }
87
+ }
57
88
  let best = null;
58
89
  for (const [start, end] of segmentBounds(messages)) {
59
- if (start < startFrom) continue; // behind the frontier
90
+ if (start < startFrom) continue; // behind the frontier / intent
60
91
  if (end >= tailStart) break; // segment would cut into the protected tail
61
92
  if (best === null) best = { startIdx: start, endIdx: end };
62
93
  else best.endIdx = end; // extend the contiguous span
@@ -64,66 +95,154 @@ export function computeFoldableRange(messages, ledger, protectedTail) {
64
95
  return best;
65
96
  }
66
97
 
98
+ /** The best ladder RUN for one tier from precomputed runs
99
+ * (index.js: ladderRunsOf — maximal ledger-consecutive runs of active
100
+ * same-tier blocks with their summary mass). Picks the run with the
101
+ * largest mass among those meeting the count floor; null when none does. */
102
+ function bestLadderRun(runs, minBlocks) {
103
+ let best = null;
104
+ for (const run of Array.isArray(runs) ? runs : []) {
105
+ if (!Number.isFinite(run?.mass) || !Array.isArray(run?.consumes) || run.consumes.length === 0) continue;
106
+ if (run.consumes.length < minBlocks) continue;
107
+ if (best === null || run.mass > best.mass) best = run;
108
+ }
109
+ return best;
110
+ }
111
+
67
112
  /** Evaluate the trigger. input: {
68
113
  * foldable: { startIdx, endIdx } | null, // computeFoldableRange result
69
114
  * messages, // captured raw messages (increment measurement)
70
115
  * viewTokens, // outbound-view token estimate of the current history
71
116
  * windowTokens, // context window size, or null when unknown
72
- * thresholds: { growthTokens, incrementTokens, pressurePercent, emergencyPercent, maxFoldsPerHour },
117
+ * thresholds: { growthTokens, incrementTokens, pressurePercent, emergencyPercent, maxFoldsPerHour,
118
+ * ladderT2Tokens, ladderT2MinBlocks, ladderT3Tokens, ladderT3MinBlocks },
73
119
  * now, lastFoldAt, foldsThisHour, lastEmergencyAt,
120
+ * ledger, // the session ledger (growth anchor fields)
121
+ * ladderRuns: { t2: [{ consumes, mass, count }], t3: [...] }, // census runs
74
122
  * }.
75
- * Returns { action: "none"|"fold"|"emergency", tier, reason, startIdx, endIdx, incrementTokens }. */
123
+ * Returns { action: "none"|"fold"|"emergency", kind: "raw"|"ladder", tier,
124
+ * reason, startIdx, endIdx, incrementTokens, consumes? }. */
76
125
  export function evaluateTrigger(input) {
77
- const none = { action: "none", tier: null, reason: "", startIdx: -1, endIdx: -1, incrementTokens: 0 };
78
- const foldable = input?.foldable ?? null;
79
- if (foldable === null) return { ...none, reason: "nothing-foldable" };
80
- const { startIdx, endIdx } = foldable;
126
+ const none = { action: "none", kind: "raw", tier: null, reason: "", startIdx: -1, endIdx: -1, incrementTokens: 0, consumes: null };
81
127
  const thresholds = input?.thresholds ?? {};
82
128
  const now = Number.isFinite(input?.now) ? input.now : Date.now();
83
- const incrementTokens = estimateTokensOf(input?.messages ?? [], startIdx, endIdx);
84
- if (incrementTokens < 1) return { ...none, reason: "empty-increment" };
85
-
86
- // Tier c — emergency: window pressure, mechanical, cooldown-gated.
87
- const windowTokens = Number.isFinite(input?.windowTokens) && input.windowTokens > 0 ? input.windowTokens : null;
88
- const emergencyPercent = Number.isFinite(thresholds.emergencyPercent) && thresholds.emergencyPercent > 0 ? thresholds.emergencyPercent : 95;
89
- if (windowTokens !== null) {
90
- const usagePct = (input.viewTokens / windowTokens) * 100;
91
- if (usagePct >= emergencyPercent) {
92
- const lastEmergencyAt = Number.isFinite(input?.lastEmergencyAt) ? input.lastEmergencyAt : 0;
93
- if (now - lastEmergencyAt < EMERGENCY_COOLDOWN_MS) return { ...none, reason: "emergency-cooldown" };
94
- return { action: "emergency", tier: "emergency", reason: `usage ${usagePct.toFixed(1)}% >= ${emergencyPercent}%`, startIdx, endIdx, incrementTokens };
95
- }
96
- }
129
+ const foldable = input?.foldable ?? null;
130
+ const incrementTokens = foldable === null ? 0 : estimateTokensOf(input?.messages ?? [], foldable.startIdx, foldable.endIdx);
97
131
 
98
- // Side-model folds (pressure + growth) share the cadence gates.
132
+ // Side-model folds (pressure + growth + ladder) share the cadence gates.
99
133
  const lastFoldAt = Number.isFinite(input?.lastFoldAt) ? input.lastFoldAt : 0;
100
134
  const hourly = Array.isArray(input?.foldsThisHour) ? input.foldsThisHour.filter((ts) => now - ts < 3_600_000).length : 0;
101
- const maxFoldsPerHour = Number.isInteger(thresholds.maxFoldsPerHour) && thresholds.maxFoldsPerHour > 0 ? thresholds.maxFoldsPerHour : 6;
102
- if (hourly >= maxFoldsPerHour) return { ...none, reason: "hourly-cap" };
103
- if (now - lastFoldAt < FOLD_MIN_INTERVAL_MS) return { ...none, reason: "min-interval" };
135
+ const maxFoldsPerHour = Number.isInteger(thresholds.maxFoldsPerHour) && thresholds.maxFoldsPerHour > 0 ? thresholds.maxFoldsPerHour : 30;
136
+ const hourlyBlocked = hourly >= maxFoldsPerHour;
104
137
 
105
- // Tier b — pressure: soft window threshold, side-model.
106
- const pressurePercent = Number.isFinite(thresholds.pressurePercent) && thresholds.pressurePercent > 0 ? thresholds.pressurePercent : 60;
107
- if (windowTokens !== null) {
108
- const usagePct = (input.viewTokens / windowTokens) * 100;
109
- if (usagePct >= pressurePercent) {
110
- return { action: "fold", tier: "pressure", reason: `usage ${usagePct.toFixed(1)}% >= ${pressurePercent}%`, startIdx, endIdx, incrementTokens };
138
+ if (foldable !== null && incrementTokens >= 1) {
139
+ const { startIdx, endIdx } = foldable;
140
+ // Tier c — emergency: window pressure, mechanical, cooldown-gated.
141
+ const windowTokens = Number.isFinite(input?.windowTokens) && input.windowTokens > 0 ? input.windowTokens : null;
142
+ const emergencyPercent = Number.isFinite(thresholds.emergencyPercent) && thresholds.emergencyPercent > 0 ? thresholds.emergencyPercent : 95;
143
+ if (windowTokens !== null) {
144
+ const usagePct = (input.viewTokens / windowTokens) * 100;
145
+ if (usagePct >= emergencyPercent) {
146
+ const lastEmergencyAt = Number.isFinite(input?.lastEmergencyAt) ? input.lastEmergencyAt : 0;
147
+ if (now - lastEmergencyAt < EMERGENCY_COOLDOWN_MS) {
148
+ // [2026-11-07]-[fix ladder starvation under sustained emergency
149
+ // pressure]-[when usage stays above the emergency line the 90 s
150
+ // cooldown returned "none" every boundary and the lower-priority
151
+ // ladder never ran — the summary population grew unbounded,
152
+ // defeating T2/T3's aging goal. During cooldown a READY ladder
153
+ // is the only side-model path that can relieve pressure, so it
154
+ // cuts in here; every other case keeps the exact
155
+ // "emergency-cooldown" row. Inlined on purpose: the main ladder
156
+ // section below keeps its original position and semantics.]
157
+ const runs = input?.ladderRuns ?? {};
158
+ const t3R = bestLadderRun(runs.t3, Number.isInteger(thresholds.ladderT3MinBlocks) && thresholds.ladderT3MinBlocks > 0 ? thresholds.ladderT3MinBlocks : 2);
159
+ const t2R = bestLadderRun(runs.t2, Number.isInteger(thresholds.ladderT2MinBlocks) && thresholds.ladderT2MinBlocks > 0 ? thresholds.ladderT2MinBlocks : 3);
160
+ const t3M = Number.isFinite(thresholds.ladderT3Tokens) && thresholds.ladderT3Tokens > 0 ? thresholds.ladderT3Tokens : 6_000;
161
+ const t2M = Number.isFinite(thresholds.ladderT2Tokens) && thresholds.ladderT2Tokens > 0 ? thresholds.ladderT2Tokens : 8_000;
162
+ const pick = t3R !== null && t3R.mass >= t3M && (t2R === null || t2R.mass < t2M || t3R.mass >= t2R.mass) ? t3R
163
+ : (t2R !== null && t2R.mass >= t2M ? t2R : null);
164
+ // The 120 s LADDER_MIN_INTERVAL_MS is NOT applied here on
165
+ // purpose: the emergency fold that opened this cooldown just
166
+ // advanced lastFoldAt, so the interval (120 s) always exceeds
167
+ // the remaining cooldown (90 s) and the cut-in could never
168
+ // fire — a structural starvation loop (observed live). Inside
169
+ // the cooldown the hourly cap is the only cadence gate; the
170
+ // cooldown itself is the spacing.
171
+ if (pick !== null && !hourlyBlocked) {
172
+ return {
173
+ action: "fold",
174
+ kind: "ladder",
175
+ tier: pick === t3R ? "ladder-3" : "ladder-2",
176
+ reason: `ladder mass ~${Math.round(pick.mass)} blocks=${pick.consumes.length} (emergency-cooldown cut-in)`,
177
+ startIdx: -1,
178
+ endIdx: -1,
179
+ incrementTokens: 0,
180
+ consumes: pick.consumes.slice(),
181
+ };
182
+ }
183
+ return { ...none, reason: "emergency-cooldown" };
184
+ }
185
+ return { action: "emergency", kind: "raw", tier: "emergency", reason: `usage ${usagePct.toFixed(1)}% >= ${emergencyPercent}%`, startIdx, endIdx, incrementTokens, consumes: null };
186
+ }
187
+ }
188
+ if (!hourlyBlocked && now - lastFoldAt >= FOLD_MIN_INTERVAL_MS) {
189
+ // Tier b — pressure: soft window threshold, side-model.
190
+ const pressurePercent = Number.isFinite(thresholds.pressurePercent) && thresholds.pressurePercent > 0 ? thresholds.pressurePercent : 65;
191
+ if (windowTokens !== null) {
192
+ const usagePct = (input.viewTokens / windowTokens) * 100;
193
+ if (usagePct >= pressurePercent) {
194
+ return { action: "fold", kind: "raw", tier: "pressure", reason: `usage ${usagePct.toFixed(1)}% >= ${pressurePercent}%`, startIdx, endIdx, incrementTokens, consumes: null };
195
+ }
196
+ }
197
+ // Tier a — growth gate: the primary cache-friendly heuristic.
198
+ const growthTokens = Number.isFinite(thresholds.growthTokens) && thresholds.growthTokens > 0 ? thresholds.growthTokens : 22_500;
199
+ const incrementGate = Number.isFinite(thresholds.incrementTokens) && thresholds.incrementTokens > 0 ? thresholds.incrementTokens : 25_000;
200
+ // Growth anchor: the post-fold low when known (keeps the gate armed
201
+ // after folds that commit at high view points), else the pre-fold
202
+ // view for ledgers written before lastFoldLowTokens existed.
203
+ const anchor = Number.isFinite(input?.ledger?.lastFoldLowTokens) && input.ledger.lastFoldLowTokens > 0
204
+ ? input.ledger.lastFoldLowTokens
205
+ : Number.isFinite(input?.ledger?.lastFoldViewTokens) ? input.ledger.lastFoldViewTokens : 0;
206
+ const growth = input.viewTokens - anchor;
207
+ if (growth > growthTokens && incrementTokens >= incrementGate) {
208
+ return { action: "fold", kind: "raw", tier: "growth", reason: `growth ~${Math.round(growth)} > ${growthTokens} and increment ~${incrementTokens} >= ${incrementGate}`, startIdx, endIdx, incrementTokens, consumes: null };
209
+ }
111
210
  }
112
211
  }
113
212
 
114
- // Tier a — growth gate: the primary cache-friendly heuristic.
115
- const growthTokens = Number.isFinite(thresholds.growthTokens) && thresholds.growthTokens > 0 ? thresholds.growthTokens : 22_500;
116
- const incrementGate = Number.isFinite(thresholds.incrementTokens) && thresholds.incrementTokens > 0 ? thresholds.incrementTokens : 50_000;
117
- // Growth anchor: the post-fold low when known (keeps the gate armed
118
- // after folds that commit at high view points), else the pre-fold
119
- // view for ledgers written before lastFoldLowTokens existed.
120
- const anchor = Number.isFinite(input?.ledger?.lastFoldLowTokens) && input.ledger.lastFoldLowTokens > 0
121
- ? input.ledger.lastFoldLowTokens
122
- : Number.isFinite(input?.ledger?.lastFoldViewTokens) ? input.ledger.lastFoldViewTokens : 0;
123
- const growth = input.viewTokens - anchor;
124
- if (growth > growthTokens && incrementTokens >= incrementGate) {
125
- return { action: "fold", tier: "growth", reason: `growth ~${Math.round(growth)} > ${growthTokens} and increment ~${incrementTokens} >= ${incrementGate}`, startIdx, endIdx, incrementTokens };
213
+ // Ladder maintenance tiers (T3 before T2 — the deeper distillation is
214
+ // the larger reclaim when both are ready). Mass gates carry bili #198's
215
+ // minimum-benefit idea: a sub-threshold distill never fires, so the
216
+ // trigger cannot enter a zero-yield reset loop.
217
+ const ladderRuns = input?.ladderRuns ?? {};
218
+ const t3Blocks = Number.isInteger(thresholds.ladderT3MinBlocks) && thresholds.ladderT3MinBlocks > 0 ? thresholds.ladderT3MinBlocks : 2;
219
+ const t3MassGate = Number.isFinite(thresholds.ladderT3Tokens) && thresholds.ladderT3Tokens > 0 ? thresholds.ladderT3Tokens : 6_000;
220
+ const t2Blocks = Number.isInteger(thresholds.ladderT2MinBlocks) && thresholds.ladderT2MinBlocks > 0 ? thresholds.ladderT2MinBlocks : 3;
221
+ const t2MassGate = Number.isFinite(thresholds.ladderT2Tokens) && thresholds.ladderT2Tokens > 0 ? thresholds.ladderT2Tokens : 8_000;
222
+ const t3Run = bestLadderRun(ladderRuns.t3, t3Blocks);
223
+ const t2Run = bestLadderRun(ladderRuns.t2, t2Blocks);
224
+ if (t3Run !== null || t2Run !== null) {
225
+ if (now - lastFoldAt < LADDER_MIN_INTERVAL_MS) return { ...none, reason: "ladder-min-interval" };
226
+ if (hourlyBlocked) return { ...none, reason: "hourly-cap" };
227
+ const run = t3Run !== null && (t2Run === null || t3Run.mass >= t2Run.mass) ? t3Run : t2Run;
228
+ const tier = t3Run !== null && (t2Run === null || t3Run.mass >= t2Run.mass) ? "ladder-3" : "ladder-2";
229
+ const gate = tier === "ladder-3" ? t3MassGate : t2MassGate;
230
+ // The run passed its count floor; the mass gate decides readiness.
231
+ if (run.mass < gate) return { ...none, reason: "below-thresholds" };
232
+ return {
233
+ action: "fold",
234
+ kind: "ladder",
235
+ tier,
236
+ reason: `ladder mass ~${Math.round(run.mass)} >= ${gate} blocks=${run.consumes.length}`,
237
+ startIdx: -1,
238
+ endIdx: -1,
239
+ incrementTokens: 0,
240
+ consumes: run.consumes.slice(),
241
+ };
126
242
  }
127
243
 
244
+ if (foldable === null) return { ...none, reason: "nothing-foldable" };
245
+ if (incrementTokens < 1) return { ...none, reason: "empty-increment" };
246
+ if (hourlyBlocked) return { ...none, reason: "hourly-cap" };
128
247
  return { ...none, reason: "below-thresholds" };
129
248
  }
@@ -8,6 +8,15 @@
8
8
  * (the golden test pins this), because a stable serialized prefix between
9
9
  * folds is exactly what keeps provider prompt-cache hits alive.
10
10
  *
11
+ * LADDER VIEW (docs/ladder-fold.md): the rebuild is a deterministic
12
+ * three-pass overlay. Pass 1 locates every T1 block — active AND consumed —
13
+ * on the raw messages (a consumed T1 summary is the ladder's raw material,
14
+ * so it must render). Pass 2 replaces each active T2 block's consumed-run
15
+ * summary messages with the T2 summary; pass 3 does the same for active T3
16
+ * blocks over the T2 summaries. Fail-open: a ladder block whose consumed
17
+ * run is missing, non-adjacent, or already covered is skipped for this
18
+ * view, never guessed.
19
+ *
11
20
  * Message refs: messages at the llm/stream boundary are plain JSON-ish
12
21
  * objects ({id?, role, content blocks / text}). An explicit id is trusted
13
22
  * only when it looks addressable (short, no whitespace/control chars — the
@@ -25,6 +34,13 @@
25
34
 
26
35
  import { createHash } from "node:crypto";
27
36
 
37
+ /** Ladder-block probe, inlined to keep the state.js ⇄ view.js import graph
38
+ * acyclic (state.js imports this module's estimators). A ladder block is
39
+ * an active distillation of other blocks: tier 2 or 3, no raw refRange. */
40
+ function isLadderBlock(block) {
41
+ return block !== null && typeof block === "object" && (block.tier === 2 || block.tier === 3);
42
+ }
43
+
28
44
  /** Cheap char → token estimate (the brief's char/4 heuristic). Applies to
29
45
  * ASCII/latin text; CJK text tokenizes far denser (see below). */
30
46
  export const TOKENS_PER_CHAR = 1 / 4;
@@ -226,14 +242,18 @@ export function rangeIsSegmentAligned(messages, startIdx, endIdx) {
226
242
 
227
243
  // ---- fold blocks ------------------------------------------------------------
228
244
 
229
- /** Deterministic wire text of one fold block's synthetic message. */
245
+ /** Deterministic wire text of one fold block's synthetic message. The
246
+ * [SWITCHMAN:TRIM] content prefix is the downstream recognition anchor
247
+ * (surface.js, session tooling) and stays IDENTICAL for every tier — the
248
+ * ladder lives in the ledger, not in the wire text shape. */
230
249
  export function renderSummaryText(block) {
231
250
  return `[SWITCHMAN:TRIM] ${block.summary}`;
232
251
  }
233
252
 
234
253
  /** The ONE synthetic message replacing a folded range (brief: synthetic
235
254
  * user message; id carries the acp_summary_ prefix downstream tooling
236
- * skips). Pure — same block, same bytes. */
255
+ * skips). Pure — same block, same bytes. The ladder replaces messages BY
256
+ * this id (id-suffix = block id), which is why ids must stay stable. */
237
257
  export function summaryMessageOf(block) {
238
258
  return {
239
259
  id: `acp_summary_${block.id}`,
@@ -284,20 +304,107 @@ function locateBlock(positions, block, afterIdx) {
284
304
  return span === expected ? [firstIdx, lastIdx] : null;
285
305
  }
286
306
 
307
+ // ---- ladder passes ------------------------------------------------------------
308
+
309
+ /** Summary-id positions of one intermediate view: id suffix (the block id)
310
+ * -> index, for every synthetic message. One index per id (a block renders
311
+ * at most once — single consumption). */
312
+ function summaryPositionsOf(messages) {
313
+ const prefix = "acp_summary_";
314
+ const positions = new Map();
315
+ messages.forEach((message, index) => {
316
+ const id = message?.id;
317
+ if (typeof id !== "string" || !id.startsWith(prefix)) return;
318
+ positions.set(id.slice(prefix.length), index);
319
+ });
320
+ return positions;
321
+ }
322
+
323
+ /** Validate one ladder block's consumed run against a positions map: every
324
+ * consumed id present, indices strictly consecutive ascending. Returns
325
+ * [firstIdx, lastIdx] or null (fail-open skip). */
326
+ function locateLadderRun(positions, block) {
327
+ const consumes = Array.isArray(block?.consumes) ? block.consumes : [];
328
+ if (consumes.length === 0) return null;
329
+ let prev = null;
330
+ let firstIdx = -1;
331
+ for (const id of consumes) {
332
+ const at = positions.get(id);
333
+ if (at === undefined) return null;
334
+ if (prev !== null && at !== prev + 1) return null;
335
+ prev = at;
336
+ if (firstIdx < 0) firstIdx = at;
337
+ }
338
+ return [firstIdx, prev];
339
+ }
340
+
341
+ /** Apply one tier's ladder pass to the working view. Participation (docs/
342
+ * ladder-fold.md): ACTIVE tier-`tier` blocks replace their consumed runs —
343
+ * AND inactive tier-`tier` blocks consumed by an ACTIVE tier-`tier+1` block
344
+ * render their summary as pure ANCHOR material (a T3's consumed T2 summary
345
+ * must exist in the intermediate view or the T3 pass could never locate
346
+ * its run). Anchors replaced by the next pass do not double-count
347
+ * `applied`. Entries that fail their adjacency/completeness check are
348
+ * skipped (never guessed); valid runs are disjoint by construction (single
349
+ * consumption) and applied from the highest start index backward, so
350
+ * earlier indices stay valid while splicing. Returns the new array. */
351
+ function applyLadderTier(messages, ladderBlocks, tier, state) {
352
+ if (ladderBlocks.length === 0) return messages;
353
+ const participants = [];
354
+ for (const block of ladderBlocks) {
355
+ if (block.tier !== tier) continue;
356
+ if (block.active === true) {
357
+ participants.push(block);
358
+ continue;
359
+ }
360
+ // Inactive block: an anchor only when an ACTIVE next-tier block
361
+ // directly consumed it.
362
+ const consumedByActive = ladderBlocks.some((other) => other.active === true && other.tier === tier + 1 && Array.isArray(other.consumes) && other.consumes.includes(block.id));
363
+ if (consumedByActive) participants.push(block);
364
+ }
365
+ if (participants.length === 0) return messages;
366
+ const positions = summaryPositionsOf(messages);
367
+ const runs = [];
368
+ for (const block of participants) {
369
+ const run = locateLadderRun(positions, block);
370
+ if (run === null) continue;
371
+ runs.push({ block, firstIdx: run[0], lastIdx: run[1] });
372
+ }
373
+ runs.sort((a, b) => b.firstIdx - a.firstIdx); // apply back-to-front
374
+ const out = messages.slice();
375
+ for (const run of runs) {
376
+ if (run.lastIdx >= out.length) continue;
377
+ // Splice the run out and drop the ladder summary in its place.
378
+ out.splice(run.firstIdx, run.lastIdx - run.firstIdx + 1, summaryMessageOf(run.block));
379
+ if (run.block.active === true) state.applied += 1;
380
+ }
381
+ return out;
382
+ }
383
+
287
384
  /** Build the outbound view: apply every applicable fold block to a copy of
288
385
  * the messages array. Pure (never mutates the input; the assembly splices
289
- * the result into the live request array). Blocks apply in positional
290
- * order; a block that cannot be located, overlaps an already-applied one,
291
- * or reaches into the last protectedTail messages is skipped. Returns
292
- * { messages, applied, foldedMessages } (foldedMessages = live messages
293
- * the applied blocks replaced). */
386
+ * the result into the live request array). The rebuild is deterministic
387
+ * three-pass ladder overlay (docs/ladder-fold.md):
388
+ * 1. every T1 block — active AND consumed — locates on raw messages and
389
+ * splices its summary (consumed T1 summaries are the ladder's material);
390
+ * 2. active T2 blocks replace their consumed runs of T1 summaries;
391
+ * 3. active T3 blocks replace their consumed runs of T2 summaries.
392
+ * A block that cannot be located, overlaps an already-applied one, or
393
+ * reaches into the last protectedTail messages is skipped. Returns
394
+ * { messages, applied, foldedMessages, unlocated } (foldedMessages = live
395
+ * raw messages the applied T1 blocks replaced). */
294
396
  export function buildView(messages, ledger, options = {}) {
295
397
  const protectedTail = Number.isInteger(options.protectedTail) && options.protectedTail > 0 ? options.protectedTail : 0;
296
398
  const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
399
+ const t1Blocks = blocks.filter((block) => block !== null && typeof block === "object" && block.refRange !== null);
400
+ // Ladder blocks participate regardless of active: ACTIVE ones replace
401
+ // their consumed runs; INACTIVE ones may be pure anchors for the next
402
+ // tier's pass (applyLadderTier decides per block).
403
+ const ladderBlocks = blocks.filter((block) => block !== null && typeof block === "object" && isLadderBlock(block));
297
404
  const positions = refPositions(messages);
298
405
  const located = [];
299
406
  let unlocated = 0;
300
- for (const block of blocks) {
407
+ for (const block of t1Blocks) {
301
408
  const range = locateBlock(positions, block, -1);
302
409
  if (range !== null) located.push({ block, firstIdx: range[0], lastIdx: range[1] });
303
410
  else unlocated += 1;
@@ -322,17 +429,26 @@ export function buildView(messages, ledger, options = {}) {
322
429
  applied += 1;
323
430
  }
324
431
  for (let i = at; i < messages.length; i++) out.push(messages[i]);
325
- return { messages: out, applied, foldedMessages, unlocated };
432
+ // Ladder passes, ascending tier (T3 anchors are T2 summaries that only
433
+ // exist after the T2 pass ran).
434
+ const ladderState = { applied };
435
+ let ladderOut = applyLadderTier(out, ladderBlocks, 2, ladderState);
436
+ ladderOut = applyLadderTier(ladderOut, ladderBlocks, 3, ladderState);
437
+ applied = ladderState.applied;
438
+ return { messages: ladderOut, applied, foldedMessages, unlocated };
326
439
  }
327
440
 
328
- /** Epoch-boundary detection: every block failed to LOCATE inside the current
329
- * re-send — the host replaced history (its own compaction) and the ledger
330
- * aged out as a whole. The fail-open skip stays correct, but a growth
331
- * baseline carried over from the previous epoch does not: the caller
332
- * re-anchors it exactly once per epoch. */
441
+ /** Epoch-boundary detection: every T1 block failed to LOCATE inside the
442
+ * current re-send — the host replaced history (its own compaction) and the
443
+ * ledger aged out as a whole. Only raw-range blocks count (ladder blocks
444
+ * locate through their consumed T1 summaries, which the T1 pass renders);
445
+ * a ladder-only ledger can never read stale on its own. The fail-open skip
446
+ * stays correct, but a growth baseline carried over from the previous epoch
447
+ * does not: the caller re-anchors it exactly once per epoch. */
333
448
  export function ledgerIsStale(view, ledger) {
334
- const blocksCount = Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0;
335
- return blocksCount > 0 && view.applied === 0 && view.unlocated === blocksCount;
449
+ const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
450
+ const t1Count = blocks.filter((block) => block !== null && typeof block === "object" && block.refRange !== null).length;
451
+ return t1Count > 0 && view.applied === 0 && view.unlocated === t1Count;
336
452
  }
337
453
 
338
454
  /** Install the built view for one dispatch. Host immutability varies: probe