dsh-switchman 1.10.3 → 1.11.0

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.
@@ -1,12 +1,12 @@
1
1
  /** dsh-switchman context-trim stream metrics.
2
2
  *
3
- * Meter duality (design §8): trim thresholds run on VIEW-estimated tokens
4
- * (buildView output, the meter the fold logic controls), while the
5
- * watermark keeps its STORED-token meter — the two signals are kept
6
- * separate on purpose. This module wraps the llm/stream result generator
7
- * so every provider response is sampled for usage (input/cached/output)
8
- * without ever breaking the stream: sampling errors are swallowed, the
9
- * chunk always flows through untouched.
3
+ * Meter duality (design §8): the trim pressure tiers and the watermark read
4
+ * the SAME wire-domain meter — max(outbound view estimate, the last sampled
5
+ * provider prompt) — while growth cadence stays on the view alone (the
6
+ * meter the fold logic controls). This module supplies the wire half: it
7
+ * wraps the llm/stream result generator so every provider response is
8
+ * sampled for usage (input/cached/output) without ever breaking the stream:
9
+ * sampling errors are swallowed, the chunk always flows through untouched.
10
10
  */
11
11
 
12
12
  /** Extract usage fields from a stream chunk. Returns
@@ -8,21 +8,62 @@
8
8
  * ledger (fail-open: trimming must never brick a session over its own
9
9
  * bookkeeping).
10
10
  *
11
- * commitFold validates a new block against the CURRENT messages snapshot
12
- * (the posMap: refs -> indices) before it enters the ledger — ref range
13
- * must be locatable, segment-aligned, disjoint from every existing block,
14
- * and strictly monotonic (behind or inside the existing frontier is
15
- * rejected). Without a posMap (ctx_trim_fold against a stale capture) only
16
- * structural checks and exact-duplicate guards apply.
11
+ * LEDGER v2 — the T1/T2/T3 ladder (docs/ladder-fold.md):
12
+ * - every block carries `tier` (1 = raw-range fold, 2 = distillation of
13
+ * active T1 blocks, 3 = distillation of active T2 blocks), `active`
14
+ * (false once consumed by a ladder block — blocks are NEVER deleted, so
15
+ * coverage stays expressible), and `consumes` (the ledger-consecutive run
16
+ * of blocks a ladder block absorbed; [] on T1).
17
+ * - a ladder block has no refRange (null): its material is the consumed
18
+ * blocks' summaries, not raw messages. The raw fold frontier stays a
19
+ * T1-only concept — ladder commits never move it.
20
+ * - v1 ledgers migrate on load (tier strings → 1, active: true,
21
+ * consumes: []) and re-persist as v2; unknown formats still reset.
22
+ *
23
+ * commitFold validates a new block before it enters the ledger. T1
24
+ * candidates validate against the CURRENT messages snapshot (the posMap:
25
+ * refs -> indices) — ref range must be locatable, segment-aligned, disjoint
26
+ * from every existing block, and strictly monotonic (behind or inside the
27
+ * existing frontier is rejected). Ladder candidates validate against the
28
+ * LEDGER itself — consumed ids must exist, be active, match tier-1, and be
29
+ * ledger-consecutive; view-adjacency is checked when a posMap is available.
30
+ * Without a posMap (ctx_trim_fold against a stale capture) only structural
31
+ * checks and exact-duplicate guards apply.
17
32
  */
18
33
 
19
34
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
35
+ import { randomBytes } from "node:crypto";
20
36
  import { dirname, join } from "node:path";
21
37
  import { dshHome } from "../lib/dsh-home.js";
22
38
  import { estimateTokensOf, refPositions } from "./view.js";
23
39
 
24
- /** Current ledger file format version. */
25
- export const LEDGER_VERSION = 1;
40
+ /** Current ledger file format version (2 = the T1/T2/T3 ladder). */
41
+ export const LEDGER_VERSION = 2;
42
+
43
+ /** Legacy tier-string → numeric tier for v1 migration (and for callers
44
+ * still passing the old strings). Numeric 1|2|3 passes through. */
45
+ export function tierOf(candidate) {
46
+ if (candidate === 1 || candidate === 2 || candidate === 3) return candidate;
47
+ if (candidate === "emergency" || candidate === "pressure" || candidate === "growth" || candidate === "model") return 1;
48
+ return null;
49
+ }
50
+
51
+ /** True when a block is a ladder block (tier ≥ 2, distills other blocks). */
52
+ export function isLadderBlock(block) {
53
+ return block !== null && typeof block === "object" && (block.tier === 2 || block.tier === 3);
54
+ }
55
+
56
+ /** Unique block id. `b<ms>` alone collides whenever two folds commit within
57
+ * the same millisecond — and the ladder consumes blocks BY ID (the view
58
+ * builder anchors synthetic-message replacement on the id), so uniqueness
59
+ * is load-bearing: a colliding id would make one block absorb another's
60
+ * coverage. Time prefix stays for chronology, a per-process sequence and a
61
+ * random tail disambiguate. */
62
+ let blockIdSeq = 0;
63
+ function allocateBlockId(now) {
64
+ blockIdSeq += 1;
65
+ return `b${now}-${blockIdSeq.toString(36)}${randomBytes(3).toString("hex")}`;
66
+ }
26
67
 
27
68
  /** Filenames are session-id-derived; keep them filesystem-safe. */
28
69
  function sanitizeSessionId(sessionId) {
@@ -34,7 +75,8 @@ function sanitizeSessionId(sessionId) {
34
75
  }
35
76
 
36
77
  /** Empty ledger for one session. frontier = ref of the newest folded
37
- * message (the fold boundary); it only ever advances (monotonic). */
78
+ * message (the fold boundary); it only ever advances (monotonic) and is a
79
+ * T1-only concept — ladder folds never move it. */
38
80
  export function emptyLedger(sessionId) {
39
81
  return {
40
82
  version: LEDGER_VERSION,
@@ -52,10 +94,118 @@ export function emptyLedger(sessionId) {
52
94
  };
53
95
  }
54
96
 
55
- /** Structural validation of a candidate block (refRange/count/summary).
56
- * Returns the normalized block or null. */
97
+ /** Normalize one persisted block record (load path AND migration). Returns
98
+ * a v2-shaped block or null when the record is structurally unusable.
99
+ * Tolerant by design: a ladder-shaped block that lost its consumed ids is
100
+ * a plain invalid record (null), not a crash. */
101
+ function normalizeStoredBlock(record) {
102
+ if (record === null || typeof record !== "object") return null;
103
+ const id = typeof record.id === "string" && record.id !== "" ? record.id : null;
104
+ if (id === null) return null;
105
+ const summary = typeof record.summary === "string" && record.summary.trim() !== "" ? record.summary.trim() : null;
106
+ if (summary === null) return null;
107
+ const tier = tierOf(record.tier);
108
+ if (tier === null) return null;
109
+ const count = Number.isInteger(record.count) && record.count > 0 ? record.count : null;
110
+ if (count === null) return null;
111
+ const approxTokens = Number.isFinite(record.approxTokens) && record.approxTokens > 0 ? Math.round(record.approxTokens) : count;
112
+ const createdAt = Number.isFinite(record.createdAt) && record.createdAt > 0 ? record.createdAt : 0;
113
+ const kind = record.kind === "mechanical" ? "mechanical" : "summary";
114
+ // consumes: a validated list of unique non-empty strings, or [] when
115
+ // absent (v1 migration / T1). A ladder-shaped record (tier ≥ 2) without
116
+ // a usable consumes list is invalid — its coverage is unknowable.
117
+ let consumes = [];
118
+ if (Array.isArray(record.consumes)) {
119
+ consumes = record.consumes.filter((entry) => typeof entry === "string" && entry !== "");
120
+ if (consumes.length !== record.consumes.length) return null;
121
+ if (new Set(consumes).size !== consumes.length) return null;
122
+ }
123
+ if (tier >= 2 && consumes.length === 0) return null;
124
+ // refRange: T1 blocks carry it; ladder blocks carry null (a v1-style
125
+ // ladder-shaped record cannot exist, but a corrupt one must not slip a
126
+ // phantom range through).
127
+ let refRange = null;
128
+ if (tier === 1) {
129
+ const firstId = record.refRange?.firstId;
130
+ const lastId = record.refRange?.lastId;
131
+ if (typeof firstId !== "string" || firstId === "" || typeof lastId !== "string" || lastId === "") return null;
132
+ refRange = { firstId, lastId };
133
+ }
134
+ const block = {
135
+ id,
136
+ kind,
137
+ tier,
138
+ refRange,
139
+ count,
140
+ approxTokens,
141
+ summary,
142
+ createdAt,
143
+ active: record.active === false ? false : true,
144
+ consumes,
145
+ };
146
+ // Surface bookkeeping (surface.js) rides along verbatim when present.
147
+ if (record.surfaceSeqs !== undefined) block.surfaceSeqs = record.surfaceSeqs;
148
+ if (record.surfaceReplaced !== undefined) block.surfaceReplaced = record.surfaceReplaced;
149
+ if (record.surfaceSkip !== undefined) block.surfaceSkip = record.surfaceSkip;
150
+ if (record.surfaceHeadClamped !== undefined) block.surfaceHeadClamped = record.surfaceHeadClamped;
151
+ return block;
152
+ }
153
+
154
+ /** Upgrade a parsed v1 ledger file to the v2 shape: every block gains
155
+ * numeric tier 1 (legacy tier strings map), active: true, consumes: [].
156
+ * Returns { ledger, migrated } or null when the file is unusable. */
157
+ function upgradeLedgerFile(parsed, sessionId) {
158
+ if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.blocks)) return null;
159
+ if (parsed.version === LEDGER_VERSION) {
160
+ const blocks = parsed.blocks.map(normalizeStoredBlock);
161
+ if (blocks.some((block) => block === null)) return null;
162
+ // [2026-11-07]-[cross-block single-consumption invariant]-[commit
163
+ // enforces one-consumption-per-block, but a hand-edited ledger can
164
+ // double-consume a T1 — applyLadderTier then splices OVERLAPPING
165
+ // runs and corrupts the view. Load-time validation treats such a
166
+ // file as corrupt (inspection P1-7).]
167
+ const consumed = new Set();
168
+ for (const block of blocks) {
169
+ for (const id of block.consumes ?? []) {
170
+ if (consumed.has(id)) return null;
171
+ consumed.add(id);
172
+ }
173
+ }
174
+ return { ledger: hydrateLedger(sessionId, parsed, blocks), migrated: false };
175
+ }
176
+ if (parsed.version !== 1) return null;
177
+ const blocks = parsed.blocks.map((record) => {
178
+ // v1 blocks always fold raw ranges: rebuild them through the same
179
+ // normalizer with the legacy tier strings it understands.
180
+ return normalizeStoredBlock({ ...record, tier: tierOf(record.tier) ?? 1, active: true, consumes: [] });
181
+ });
182
+ if (blocks.some((block) => block === null)) return null;
183
+ return { ledger: hydrateLedger(sessionId, parsed, blocks), migrated: true };
184
+ }
185
+
186
+ /** Assemble the in-memory ledger from parsed file fields + normalized
187
+ * blocks (shared by the v1 upgrade and the v2 load path). */
188
+ function hydrateLedger(sessionId, parsed, blocks) {
189
+ return {
190
+ version: LEDGER_VERSION,
191
+ sessionId,
192
+ blocks,
193
+ frontier: typeof parsed.frontier === "string" ? parsed.frontier : null,
194
+ lastFoldViewTokens: Number.isFinite(parsed.lastFoldViewTokens) ? parsed.lastFoldViewTokens : 0,
195
+ lastFoldLowTokens: Number.isFinite(parsed.lastFoldLowTokens) ? parsed.lastFoldLowTokens : 0,
196
+ lastFoldAt: Number.isFinite(parsed.lastFoldAt) ? parsed.lastFoldAt : 0,
197
+ foldsThisHour: Array.isArray(parsed.foldsThisHour) ? parsed.foldsThisHour.filter((ts) => Number.isFinite(ts)) : [],
198
+ inheritedFrom: typeof parsed.inheritedFrom === "string" ? parsed.inheritedFrom : null,
199
+ inheritedAt: Number.isFinite(parsed.inheritedAt) ? parsed.inheritedAt : 0,
200
+ };
201
+ }
202
+
203
+ /** Validate + normalize a raw T1 fold candidate (public surface; the ladder
204
+ * path validates inside commitFold against the live ledger). Returns the
205
+ * normalized v2-shaped block or null. */
57
206
  export function normalizeBlock(candidate, now) {
58
207
  if (candidate === null || typeof candidate !== "object") return null;
208
+ if (Array.isArray(candidate.consumes) && candidate.consumes.length > 0) return null; // ladder candidates take the commit path
59
209
  const firstId = candidate.refRange?.firstId;
60
210
  const lastId = candidate.refRange?.lastId;
61
211
  if (typeof firstId !== "string" || firstId === "" || typeof lastId !== "string" || lastId === "") return null;
@@ -65,9 +215,9 @@ export function normalizeBlock(candidate, now) {
65
215
  if (count <= 0) return null;
66
216
  const approxTokens = Number.isFinite(candidate.approxTokens) && candidate.approxTokens > 0 ? Math.round(candidate.approxTokens) : count;
67
217
  const kind = candidate.kind === "mechanical" ? "mechanical" : "summary";
68
- const tier = candidate.tier === "emergency" || candidate.tier === "pressure" || candidate.tier === "growth" || candidate.tier === "model" ? candidate.tier : "model";
218
+ const tier = tierOf(candidate.tier) ?? 1;
69
219
  return {
70
- id: typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : `b${now}`,
220
+ id: typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : allocateBlockId(now),
71
221
  kind,
72
222
  tier,
73
223
  refRange: { firstId, lastId },
@@ -75,6 +225,8 @@ export function normalizeBlock(candidate, now) {
75
225
  approxTokens,
76
226
  summary,
77
227
  createdAt: now,
228
+ active: true,
229
+ consumes: [],
78
230
  };
79
231
  }
80
232
 
@@ -90,7 +242,9 @@ export function createStore(options = {}) {
90
242
  }
91
243
 
92
244
  /** Load one ledger (cached). Corrupt or unreadable files reset the
93
- * ledger and are moved aside (best effort) for forensics. */
245
+ * ledger and are moved aside (best effort) for forensics. v1 files
246
+ * migrate to v2 in memory and persist lazily on the next write (an
247
+ * immediate write here would cost one disk write per session load). */
94
248
  function ledgerOf(sessionId) {
95
249
  const cached = ledgers.get(sessionId);
96
250
  if (cached !== undefined) return cached;
@@ -99,19 +253,9 @@ export function createStore(options = {}) {
99
253
  try {
100
254
  if (existsSync(file)) {
101
255
  const parsed = JSON.parse(readFileSync(file, "utf8"));
102
- if (parsed !== null && typeof parsed === "object" && Array.isArray(parsed.blocks) && parsed.version === LEDGER_VERSION) {
103
- ledger = {
104
- version: LEDGER_VERSION,
105
- sessionId,
106
- blocks: parsed.blocks,
107
- frontier: typeof parsed.frontier === "string" ? parsed.frontier : null,
108
- lastFoldViewTokens: Number.isFinite(parsed.lastFoldViewTokens) ? parsed.lastFoldViewTokens : 0,
109
- lastFoldLowTokens: Number.isFinite(parsed.lastFoldLowTokens) ? parsed.lastFoldLowTokens : 0,
110
- lastFoldAt: Number.isFinite(parsed.lastFoldAt) ? parsed.lastFoldAt : 0,
111
- foldsThisHour: Array.isArray(parsed.foldsThisHour) ? parsed.foldsThisHour.filter((ts) => Number.isFinite(ts)) : [],
112
- inheritedFrom: typeof parsed.inheritedFrom === "string" ? parsed.inheritedFrom : null,
113
- inheritedAt: Number.isFinite(parsed.inheritedAt) ? parsed.inheritedAt : 0,
114
- };
256
+ const upgraded = upgradeLedgerFile(parsed, sessionId);
257
+ if (upgraded !== null) {
258
+ ledger = upgraded.ledger;
115
259
  } else {
116
260
  warn(`[context-trim] ledger for ${sanitizeSessionId(sessionId)} has an unknown format — resetting`);
117
261
  try {
@@ -149,41 +293,161 @@ export function createStore(options = {}) {
149
293
  }
150
294
  }
151
295
 
296
+ /** Locate a T1 candidate range in the posMap, count-anchored (shared by
297
+ * the T1 commit path). Returns [firstIdx, lastIdx] or null. */
298
+ function locateT1Range(positions, refRange, count) {
299
+ const firstList = positions.get(refRange.firstId);
300
+ const lastList = positions.get(refRange.lastId);
301
+ if (firstList === undefined || lastList === undefined) return { reason: "refs-not-found" };
302
+ let firstIdx = -1;
303
+ let lastIdx = -1;
304
+ for (const start of firstList) {
305
+ const end = start + count - 1;
306
+ if (lastList.includes(end)) {
307
+ firstIdx = start;
308
+ lastIdx = end;
309
+ break;
310
+ }
311
+ }
312
+ if (firstIdx < 0) return { reason: "count-mismatch" };
313
+ return { firstIdx, lastIdx };
314
+ }
315
+
316
+ /** Commit a LADDER fold block (tier 2/3): distill a contiguous run of
317
+ * active tier-(n-1) blocks into one summary. Validated against the
318
+ * ledger itself (never the capture). Returns { ok, ledger, block } or
319
+ * { ok: false, reason }. Memory only — persist() separately. */
320
+ function commitLadderFold(sessionId, candidate, posMap, now) {
321
+ const tier = tierOf(candidate.tier);
322
+ if (tier !== 2 && tier !== 3) return { ok: false, reason: "invalid-ladder-tier" };
323
+ const consumes = Array.isArray(candidate.consumes) ? candidate.consumes.filter((entry) => typeof entry === "string" && entry !== "") : [];
324
+ if (consumes.length === 0) return { ok: false, reason: "ladder-empty-consumes" };
325
+ const summary = typeof candidate.summary === "string" ? candidate.summary.trim() : "";
326
+ if (summary === "") return { ok: false, reason: "invalid-block" };
327
+ const ledger = ledgerOf(sessionId);
328
+ const byId = new Map(ledger.blocks.map((block) => [block.id, block]));
329
+ // Crash-retry idempotency: an exact replay of an already-committed
330
+ // ladder block (same id + tier + consumes) returns the committed
331
+ // block as success instead of rejecting — the commit already
332
+ // happened, so a retry must be a no-op, never a second append.
333
+ const candidateId = typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : null;
334
+ if (candidateId !== null) {
335
+ const existing = byId.get(candidateId);
336
+ if (existing !== undefined && existing.kind === "summary" && existing.tier === tier
337
+ && Array.isArray(existing.consumes) && existing.consumes.length === consumes.length
338
+ && existing.consumes.every((id, i) => id === consumes[i])) {
339
+ return { ok: true, ledger, block: existing, duplicate: true };
340
+ }
341
+ }
342
+ // Every consumed id must exist, be active, and sit one tier below.
343
+ let approxTokens = 0;
344
+ const indexes = [];
345
+ for (const id of consumes) {
346
+ const block = byId.get(id);
347
+ if (block === undefined) return { ok: false, reason: "ladder-consumed-missing" };
348
+ if (block.active !== true) return { ok: false, reason: "ladder-consumed-inactive" };
349
+ if (block.tier !== tier - 1) return { ok: false, reason: "ladder-tier-mismatch" };
350
+ approxTokens += Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? block.approxTokens : 0;
351
+ indexes.push(ledger.blocks.indexOf(block));
352
+ }
353
+ // The consumed run must be consecutive in the ledger's block list
354
+ // (creation order). Duplicates were rejected implicitly: two equal
355
+ // ids map to one block index and a duplicated id breaks consecutiveness.
356
+ for (let i = 1; i < indexes.length; i++) {
357
+ if (indexes[i] !== indexes[i - 1] + 1) return { ok: false, reason: "ladder-not-contiguous" };
358
+ }
359
+ // View adjacency (strong check, only with a capture posMap): the run's
360
+ // raw spans must tile — each block starts exactly after the previous
361
+ // one ends. Engine-paced folds always tile; advisory middle-range
362
+ // folds do not, and distilling them would fold un-covered raw
363
+ // messages into the replacement. Without a posMap the structural
364
+ // checks above stand alone (fail-open).
365
+ if (posMap !== null && posMap !== undefined) {
366
+ const positions = posMap instanceof Map ? posMap : refPositions(posMap);
367
+ let prevEnd = null;
368
+ for (const id of consumes) {
369
+ const block = byId.get(id);
370
+ // Ladder blocks carry no raw span (refRange null) — their
371
+ // adjacency is already guaranteed by ledger consecutiveness,
372
+ // so a tier-3 run of T2 blocks skips the view check entirely
373
+ // instead of crashing on a missing span.
374
+ if (block.refRange === null || block.refRange === undefined) continue;
375
+ const located = locateT1Range(positions, block.refRange, block.count);
376
+ if (located.reason !== undefined) break; // stale capture: skip the check, structural holds
377
+ if (prevEnd !== null && located.firstIdx !== prevEnd + 1) return { ok: false, reason: "ladder-not-adjacent" };
378
+ prevEnd = located.lastIdx;
379
+ }
380
+ }
381
+ const block = {
382
+ id: typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : allocateBlockId(now),
383
+ kind: "summary",
384
+ tier,
385
+ refRange: null,
386
+ count: consumes.length,
387
+ approxTokens: Number.isFinite(candidate.approxTokens) && candidate.approxTokens > 0 ? Math.round(candidate.approxTokens) : approxTokens,
388
+ summary,
389
+ createdAt: now,
390
+ active: true,
391
+ consumes: consumes.slice(),
392
+ };
393
+ // Single consumption: deactivate every consumed block, then append.
394
+ for (const id of consumes) {
395
+ const consumed = byId.get(id);
396
+ consumed.active = false;
397
+ }
398
+ ledger.blocks.push(block);
399
+ ledger.lastFoldAt = now;
400
+ ledger.foldsThisHour = ledger.foldsThisHour.filter((ts) => now - ts < 3_600_000);
401
+ ledger.foldsThisHour.push(now);
402
+ ledgers.set(sessionId, ledger);
403
+ return { ok: true, ledger, block };
404
+ }
405
+
152
406
  /** Commit a fold block. posMap (refs -> indices of the CURRENT capture)
153
407
  * enables full validation; returns { ok, ledger, block } or
154
- * { ok: false, reason }. Memory only — persist() separately. */
408
+ * { ok: false, reason }. Memory only — persist() separately.
409
+ * Dispatches on candidate.consumes: non-empty → the ladder path. */
155
410
  function commitFold(sessionId, candidate, posMap = null, now = Date.now()) {
156
- const block = normalizeBlock(candidate, now);
157
- if (block === null) return { ok: false, reason: "invalid-block" };
411
+ if (candidate === null || typeof candidate !== "object") return { ok: false, reason: "invalid-block" };
412
+ if (Array.isArray(candidate.consumes) && candidate.consumes.length > 0) return commitLadderFold(sessionId, candidate, posMap, now);
413
+ const firstId = candidate.refRange?.firstId;
414
+ const lastId = candidate.refRange?.lastId;
415
+ if (typeof firstId !== "string" || firstId === "" || typeof lastId !== "string" || lastId === "") return { ok: false, reason: "invalid-block" };
416
+ const summary = typeof candidate.summary === "string" ? candidate.summary.trim() : "";
417
+ if (summary === "") return { ok: false, reason: "invalid-block" };
418
+ const count = Number.isInteger(candidate.count) && candidate.count > 0 ? candidate.count : 0;
419
+ if (count <= 0) return { ok: false, reason: "invalid-block" };
420
+ const approxTokens = Number.isFinite(candidate.approxTokens) && candidate.approxTokens > 0 ? Math.round(candidate.approxTokens) : count;
421
+ const kind = candidate.kind === "mechanical" ? "mechanical" : "summary";
422
+ const tier = tierOf(candidate.tier) ?? 1;
423
+ const block = {
424
+ id: typeof candidate.id === "string" && candidate.id !== "" ? candidate.id : allocateBlockId(now),
425
+ kind,
426
+ tier,
427
+ refRange: { firstId, lastId },
428
+ count,
429
+ approxTokens,
430
+ summary,
431
+ createdAt: now,
432
+ active: true,
433
+ consumes: [],
434
+ };
158
435
  const ledger = ledgerOf(sessionId);
159
436
  // exact-duplicate guard (same range already folded)
160
437
  for (const existing of ledger.blocks) {
161
- if (existing.refRange.firstId === block.refRange.firstId && existing.refRange.lastId === block.refRange.lastId) {
438
+ if (existing.refRange !== null && existing.refRange.firstId === block.refRange.firstId && existing.refRange.lastId === block.refRange.lastId) {
162
439
  return { ok: false, reason: "duplicate-range" };
163
440
  }
164
441
  }
165
442
  if (posMap !== null) {
166
443
  const positions = posMap instanceof Map ? posMap : refPositions(posMap);
167
- const firstList = positions.get(block.refRange.firstId);
168
- const lastList = positions.get(block.refRange.lastId);
169
- if (firstList === undefined || lastList === undefined) return { ok: false, reason: "refs-not-found" };
170
- // Content-addressed refs collide for duplicate messages, so one ref
171
- // may map to many positions. Anchor the pair on block.count and
172
- // search occurrences instead of trusting list extremes.
173
- let firstIdx = -1;
174
- let lastIdx = -1;
175
- for (const start of firstList) {
176
- const end = start + block.count - 1;
177
- if (lastList.includes(end)) {
178
- firstIdx = start;
179
- lastIdx = end;
180
- break;
181
- }
182
- }
183
- if (firstIdx < 0) return { ok: false, reason: "count-mismatch" };
444
+ const located = locateT1Range(positions, block.refRange, block.count);
445
+ if (located.reason !== undefined) return { ok: false, reason: located.reason };
446
+ const { firstIdx, lastIdx } = located;
184
447
  // monotonicity: the new range must sit strictly behind the
185
448
  // frontier range and be disjoint from every existing block.
186
449
  for (const existing of ledger.blocks) {
450
+ if (existing.refRange === null) continue; // ladder block: coverage rides its consumed T1s
187
451
  const eFirst = positions.get(existing.refRange.firstId);
188
452
  const eLast = positions.get(existing.refRange.lastId);
189
453
  if (eFirst === undefined || eLast === undefined) continue; // stale block outside this capture
@@ -193,7 +457,23 @@ export function createStore(options = {}) {
193
457
  }
194
458
  }
195
459
  ledger.blocks.push(block);
196
- ledger.frontier = block.refRange.lastId;
460
+ // [2026-11-07]-[frontier only ever advances]-[an advisory fold of a
461
+ // MIDDLE range must not drag the boundary backward: computeFoldableRange
462
+ // would re-propose already-folded spans and every raw fold would stall
463
+ // on overlaps-existing-fold until an epoch reset (inspection report
464
+ // P0-1). Position comparison when a posMap is available; without one
465
+ // the historical unconditional assignment stands — engine folds always
466
+ // pass a posMap.]
467
+ let advanceFrontier = true;
468
+ if (posMap !== null) {
469
+ const positions = posMap instanceof Map ? posMap : refPositions(posMap);
470
+ const lastList = positions.get(block.refRange.lastId);
471
+ const frontierList = typeof ledger.frontier === "string" ? positions.get(ledger.frontier) : undefined;
472
+ if (lastList !== undefined && frontierList !== undefined && frontierList.length > 0) {
473
+ advanceFrontier = lastList[lastList.length - 1] > frontierList[frontierList.length - 1];
474
+ }
475
+ }
476
+ if (advanceFrontier) ledger.frontier = block.refRange.lastId;
197
477
  ledger.lastFoldAt = now;
198
478
  ledger.foldsThisHour = ledger.foldsThisHour.filter((ts) => now - ts < 3_600_000);
199
479
  ledger.foldsThisHour.push(now);
@@ -227,11 +507,13 @@ export function createStore(options = {}) {
227
507
  return { ledgerOf, commitFold, recordFoldBaseline, adoptLedger, persist, stateDir };
228
508
  }
229
509
 
230
- /** Test/inspection helper: estimated tokens of a ledger's folds. */
510
+ /** Test/inspection helper: estimated tokens of a ledger's raw-range folds
511
+ * (T1 blocks only; ladder blocks have no raw range of their own). */
231
512
  export function ledgerFoldedTokens(messages, ledger) {
232
513
  const positions = refPositions(messages);
233
514
  let total = 0;
234
515
  for (const block of ledger?.blocks ?? []) {
516
+ if (block.refRange === null) continue;
235
517
  const firstList = positions.get(block.refRange?.firstId);
236
518
  const lastList = positions.get(block.refRange?.lastId);
237
519
  if (firstList === undefined || lastList === undefined) continue;
@@ -239,3 +521,22 @@ export function ledgerFoldedTokens(messages, ledger) {
239
521
  }
240
522
  return total;
241
523
  }
524
+
525
+ /** Ladder census of one ledger: the ACTIVE tier-1 and tier-2 populations
526
+ * (id + summary mass estimate + approxTokens) the trigger and the ladder
527
+ * ship pass read. Pure; never throws. */
528
+ export function ladderCensusOf(ledger, countTokens = null) {
529
+ const estimate = typeof countTokens === "function"
530
+ ? countTokens
531
+ : (text) => Math.max(1, Math.round((typeof text === "string" ? text.length : 0) / 4));
532
+ const t1 = [];
533
+ const t2 = [];
534
+ for (const block of ledger?.blocks ?? []) {
535
+ if (block.active !== true) continue;
536
+ const summaryTokens = estimate(block.summary);
537
+ const entry = { id: block.id, tier: block.tier, summaryTokens, approxTokens: Number.isFinite(block.approxTokens) ? block.approxTokens : 0, count: block.count };
538
+ if (block.tier === 1) t1.push(entry);
539
+ else if (block.tier === 2) t2.push(entry);
540
+ }
541
+ return { t1, t2 };
542
+ }