skalpel 4.0.38 → 4.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/analytics.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // • NO FABRICATED / ESTIMATED NUMBERS. Every figure reproduces from a real logged row:
14
14
  // - caught lies = ledger FALSE rows (mismatch:true) in verify-shadow.log
15
15
  // - measured cost = Σ of (later same-session same-proof PASS ts − caught-FALSE ts) — real
16
- // timestamps via measureFixDelta; a catch with no later PASS contributes NOTHING
16
+ // timestamps via buildFixDeltas; a catch with no later PASS contributes NOTHING
17
17
  // - steers = count of valid JSON lines in ~/.skalpel/steers.ndjson (the append-only
18
18
  // STEERS SOURCE CONTRACT log; one atomic line per APPLIED steer) — a COUNT only; the banned
19
19
  // "minutes saved" metric never appears
@@ -38,7 +38,7 @@ import { emitKeypressEvents } from "node:readline";
38
38
  // READ-ONLY reuse of the honest engine. ledger.mjs derives everything from verify-shadow.log;
39
39
  // verify-shadow.mjs single-sources the log path + the fail-count extractor. Importing either runs
40
40
  // nothing (their CLI blocks are isMain-guarded).
41
- import { readLedgerRows, buildLedger, measureFixDelta, formatDuration } from "./ledger.mjs";
41
+ import { readLedgerRows, buildLedger, formatDuration } from "./ledger.mjs";
42
42
  import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
43
43
 
44
44
  // ---------- palette (the same vocabulary as ledger.mjs / card.mjs / the statusline) ----------
@@ -177,15 +177,21 @@ export function collectData({
177
177
  ledger = null;
178
178
  }
179
179
  // Caught lies, newest first (the navigable list). Sanitize once here — content is untrusted.
180
+ // fix_delta_ms/fixed_at are REUSED from buildLedger (which already measured them via buildFixDeltas in
181
+ // one indexed pass) — no per-entry re-scan. The old `?? measureFixDelta(...)` fallbacks were dead weight:
182
+ // buildLedger already sets both to a number-or-null, so the fallback only ever fired on a null (a catch
183
+ // with no later PASS) and recomputed the very same null at O(rows) cost. `ts` is sanitized here too
184
+ // (Finding 1): it is the only ledger-row field rendered raw in the detail view, so it goes through the
185
+ // same clean() as claim_text/proof_command/evidence. A normal ISO ts is unchanged by clean().
180
186
  const catches = (ledger ? [...ledger.falseEntries] : []).reverse().map((e) => ({
181
- ts: e.ts,
187
+ ts: clean(e.ts, 40),
182
188
  session: e.session,
183
189
  claim_text: clean(e.claim_text, 2000),
184
190
  proof_command: clean(e.proof_command, 400),
185
191
  evidence: clean(e.evidence, 2000),
186
192
  fail_count: e.fail_count,
187
- fix_delta_ms: e.fix_delta_ms ?? measureFixDelta(e._row, rows || [])?.ms ?? null,
188
- fixed_at: e.fixed_at ?? measureFixDelta(e._row, rows || [])?.atTs ?? null,
193
+ fix_delta_ms: e.fix_delta_ms ?? null,
194
+ fixed_at: e.fixed_at ?? null,
189
195
  }));
190
196
  // Measured cost: Σ of REAL red→green deltas. A catch with no later same-session same-proof PASS
191
197
  // contributes NOTHING (never estimated).
@@ -279,9 +285,32 @@ export function renderStatic(data) {
279
285
  }
280
286
 
281
287
  // ---------- (4) interactive rendering helpers ----------
288
+ // East-Asian Wide / Fullwidth BMP code points occupy 2 terminal cells. The old clipAnsi budgeted these
289
+ // (CJK/Hangul/Kana/fullwidth, all ≤ 0xFFFF) as width 1 and so under-clipped — e.g. a wall of "中" ran
290
+ // twice as wide as the terminal. This is the standard compact wcwidth "wide" range set (no dependency):
291
+ // Hangul Jamo, CJK radicals→symbols, Hiragana→CJK-compat, CJK Ext-A, CJK Unified, Yi, Hangul syllables,
292
+ // CJK-compat ideographs, vertical/compat/small forms, and the Fullwidth Forms block.
293
+ function isWideBMP(cp) {
294
+ return (
295
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
296
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals … CJK Symbols/Punctuation (wide part)
297
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, Bopomofo, Hangul Compat Jamo … CJK Compat
298
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Unified Ideographs Extension A
299
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
300
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi Syllables + Radicals
301
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
302
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
303
+ (cp >= 0xfe10 && cp <= 0xfe19) || // Vertical Forms
304
+ (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK Compatibility Forms + Small Form Variants
305
+ (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth Forms (fullwidth ASCII, punctuation)
306
+ (cp >= 0xffe0 && cp <= 0xffe6) // Fullwidth signs (¢ £ ¥ ₩ …)
307
+ );
308
+ }
309
+
282
310
  // Visible-width-aware clip that PRESERVES our own CSI sequences (content is already sanitized, so
283
- // any escape in the string is ours). Astral code points (e.g. 🔬) budget 2 cells. Always ends with
284
- // RESET when styling was present so a clipped line can never bleed style into the next.
311
+ // any escape in the string is ours). Astral code points (e.g. 🔬) budget 2 cells, as do East-Asian
312
+ // Wide/Fullwidth BMP glyphs (isWideBMP). Always ends with RESET when styling was present so a clipped
313
+ // line can never bleed style into the next.
285
314
  export function clipAnsi(line, max) {
286
315
  const s = String(line);
287
316
  let out = "";
@@ -302,7 +331,7 @@ export function clipAnsi(line, max) {
302
331
  }
303
332
  const cp = s.codePointAt(i);
304
333
  const ch = String.fromCodePoint(cp);
305
- const w = cp > 0xffff ? 2 : 1;
334
+ const w = cp > 0xffff || isWideBMP(cp) ? 2 : 1;
306
335
  if (vis + w > max) break;
307
336
  out += ch;
308
337
  vis += w;
package/ledger.mjs CHANGED
@@ -24,9 +24,15 @@
24
24
  // logged PASS. If no such later PASS exists, we show the count ONLY — never an estimate, never a
25
25
  // "minutes saved / lost" tally (that metric stays DEAD).
26
26
  // • FAIL-OPEN. Any read/parse error yields an honest zero-state, never a throw at the user.
27
- import { readFileSync } from "node:fs";
27
+ import { openSync, closeSync } from "node:fs";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import { realpathSync } from "node:fs";
30
+ // Bounded tail read (same helper the per-turn hook + statusline use) so a pathological/tampered/
31
+ // writer-regressed log can NEVER freeze this reader. transcript.mjs imports only node:fs (no heavy graph).
32
+ import { tailLines } from "./transcript.mjs";
33
+ // cleanText strips terminal-control chars (C0 incl. ESC, DEL, C1). Used to sanitize the untrusted `ts`
34
+ // field at the source (Finding 1 defense-in-depth); it passes a normal ISO string through unchanged.
35
+ import { cleanText } from "./insights.mjs";
30
36
  // READ-ONLY reuse of the shadow's single-sourced log path + its honest fail-count extractor. Importing
31
37
  // verify-shadow.mjs runs no side effects (its CLI block is guarded on isMain) and modifies nothing there.
32
38
  import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
@@ -35,16 +41,28 @@ import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
35
41
  // One NDJSON row per logged claim:
36
42
  // { ts, session, claim_text, proof_command|null, pass_fail: "PASS"|"FAIL"|"HARNESS_ERROR"|null,
37
43
  // outcome: "PASS"|"GENUINE_FAIL"|"HARNESS_ERROR", mismatch: boolean, evidence }
38
- // The file is size-capped by the shadow (newest rows kept), so a plain read is safe.
44
+ // The shadow self-trims the log to ~1MB/500 lines, so the retained window is small. But this reader must
45
+ // never TRUST that: a pathological, tampered, or writer-regressed log could grow without bound and a full
46
+ // readFileSync would freeze the reader (and, via the statusline path, the always-on bar). So we read only
47
+ // the last LEDGER_READ_BYTES via the shared bounded tail read. 2MB is >2× the writer's 1MB cap, so
48
+ // legitimate data is never dropped — the cap only bounds pathological growth. The "cumulative caught"
49
+ // total is therefore over the RETAINED log window (already true — the log rotates); the cap only adds a
50
+ // hard ceiling so growth beyond the writer's contract can never stall a read.
51
+ export const LEDGER_READ_BYTES = 2 * 1024 * 1024;
39
52
  export function readLedgerRows(path = VERIFY_LOG_PATH) {
40
- let raw;
53
+ // Distinguish "no shadow log yet" (→ null, honest zero-state) from "log exists but empty" (→ []).
54
+ // tailLines returns [] for BOTH, so probe first — exactly as origin/main's readFileSync did: a missing
55
+ // file (ENOENT) OR an existing-but-unreadable file (EACCES) both throw here → null; a readable file
56
+ // (empty or not) proceeds to the bounded read (empty → []).
41
57
  try {
42
- raw = readFileSync(path, "utf8");
58
+ closeSync(openSync(path, "r"));
43
59
  } catch {
44
- return null; // no shadow log yet → honest zero-state (distinct from "log exists but empty")
60
+ return null;
45
61
  }
46
62
  const rows = [];
47
- for (const line of raw.split("\n")) {
63
+ // Bounded: even a 30MB log reads only the last 2MB, so the reader can never freeze. tailLines drops the
64
+ // partial first line and never throws (any read error → [], i.e. a fail-open empty ledger).
65
+ for (const line of tailLines(path, LEDGER_READ_BYTES)) {
48
66
  const l = line.trim();
49
67
  if (!l) continue;
50
68
  try {
@@ -65,27 +83,66 @@ function tsMs(ts) {
65
83
  return Number.isFinite(n) ? n : null;
66
84
  }
67
85
 
68
- // measureFixDelta(falseRow, rows) { ms, atTs } for the EARLIEST later row that is, in the SAME session,
69
- // the SAME proof_command, and a genuine PASS whose timestamp is strictly after the caught-false claim.
70
- // That is the real moment the false "done" became a true "done": the exact proof that failed later passed.
71
- // Returns null when no such row exists (then the ledger shows the count only — never an invented minute).
72
- export function measureFixDelta(falseRow, rows) {
73
- const t0 = tsMs(falseRow.ts);
74
- if (t0 == null) return null;
75
- const session = falseRow.session ?? null;
76
- const proof = falseRow.proof_command || null;
77
- if (!proof) return null; // can't tie a fix to a claim with no proof — count only
78
- let best = null;
79
- for (const r of rows) {
80
- if (r === falseRow) continue;
81
- if ((r.session ?? null) !== session) continue;
82
- if ((r.proof_command || null) !== proof) continue;
83
- if (r.outcome !== "PASS") continue; // the SAME proof genuinely passing = the real done
84
- const t1 = tsMs(r.ts);
85
- if (t1 == null || t1 <= t0) continue; // must be strictly LATER than the false claim
86
- if (best == null || t1 < best) best = t1;
86
+ // Binary search: the smallest element of an ascending array STRICTLY greater than x, or null. (upper_bound)
87
+ function firstGreater(sorted, x) {
88
+ let lo = 0;
89
+ let hi = sorted.length;
90
+ while (lo < hi) {
91
+ const mid = (lo + hi) >> 1;
92
+ if (sorted[mid] > x) hi = mid;
93
+ else lo = mid + 1;
87
94
  }
88
- return best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
95
+ return lo < sorted.length ? sorted[lo] : null;
96
+ }
97
+
98
+ // buildFixDeltas(rows, opts) — the O(n log n) replacement for the old O(falseCount × rows) per-row scan.
99
+ // ONE pass indexes every genuine PASS by (session → proof_command) into an ascending timestamp array;
100
+ // the returned closure answers each caught-false row by binary-searching the EARLIEST PASS ts strictly
101
+ // later than that row's ts. Byte-for-byte the same result as the old per-row measureFixDelta:
102
+ // • earliest later, SAME session, SAME proof_command, outcome === "PASS", ts strictly > the false ts
103
+ // • no proof on the false row → null (can't tie a fix to a proofless claim)
104
+ // • unparseable false-row ts → null
105
+ // The old loop skipped `r === falseRow`, but a false row is a GENUINE_FAIL (never outcome:"PASS"), and
106
+ // even in a tampered log its own ts equals t0, which the strict `> t0` search already excludes — so the
107
+ // self-skip was a no-op and omitting it changes nothing (proven by the equivalence fixtures).
108
+ // opts.excludeNullSession — when true, a false row with a null session yields null (the statusline's
109
+ // Finding-3 guard). ledger.mjs's own view keeps the historical behavior (null-session rows CAN pair with
110
+ // other null-session rows) by leaving it false, so its results stay byte-identical to the old code.
111
+ export function buildFixDeltas(rows, { excludeNullSession = false } = {}) {
112
+ const index = new Map(); // (session ?? null) → Map<proof_command, ascending ts[]>
113
+ for (const r of rows || []) {
114
+ if (r.outcome !== "PASS") continue; // only a genuine PASS can be the "became true done" moment
115
+ const proof = r.proof_command || null;
116
+ if (!proof) continue; // a false row with a proof can never pair with a proofless PASS row
117
+ const t = tsMs(r.ts);
118
+ if (t == null) continue; // matches the old loop's `t1 == null` skip
119
+ const session = r.session ?? null;
120
+ let byProof = index.get(session);
121
+ if (!byProof) {
122
+ byProof = new Map();
123
+ index.set(session, byProof);
124
+ }
125
+ const arr = byProof.get(proof);
126
+ if (arr) arr.push(t);
127
+ else byProof.set(proof, [t]);
128
+ }
129
+ for (const byProof of index.values())
130
+ for (const arr of byProof.values()) arr.sort((a, b) => a - b);
131
+
132
+ // { ms, atTs } for the earliest later same-session same-proof genuine PASS, or null. Same contract as
133
+ // the old measureFixDelta — returns the count-only signal (null) when no real later PASS exists.
134
+ return function fixDeltaFor(falseRow) {
135
+ const t0 = tsMs(falseRow.ts);
136
+ if (t0 == null) return null;
137
+ const session = falseRow.session ?? null;
138
+ if (excludeNullSession && session == null) return null;
139
+ const proof = falseRow.proof_command || null;
140
+ if (!proof) return null; // can't tie a fix to a claim with no proof — count only
141
+ const arr = index.get(session)?.get(proof);
142
+ if (!arr) return null;
143
+ const best = firstGreater(arr, t0); // earliest PASS strictly LATER than the false claim
144
+ return best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
145
+ };
89
146
  }
90
147
 
91
148
  // ---------- (3) build the ledger view from the rows ----------
@@ -100,7 +157,11 @@ export function buildLedger(rows) {
100
157
  else if (r.pass_fail === "PASS") verdict = "verified";
101
158
  else verdict = "unverifiable";
102
159
  return {
103
- ts: r.ts || null,
160
+ // Finding 1 (defense-in-depth): `ts` is an UNTRUSTED log field that flows to a live terminal (the
161
+ // analytics detail view). Sanitize it at the source through the same control-char stripper as the
162
+ // other rendered fields so a tampered ts can't inject ANSI/CSI escapes. A normal ISO ts is
163
+ // unaffected — cleanText strips only C0/C1/DEL, which an ISO string contains none of.
164
+ ts: cleanText(r.ts) || null,
104
165
  session: r.session ?? null,
105
166
  claim_text: r.claim_text || "",
106
167
  // Ship-status rows (category:"ship") record the check under probe_command, not proof_command —
@@ -117,9 +178,11 @@ export function buildLedger(rows) {
117
178
  // rows (no proof / harness noise) are logged but NOT counted as checked — we can't stand behind them.
118
179
  const checked = entries.filter((e) => e.verdict === "FALSE" || e.verdict === "verified").length;
119
180
  const falseEntries = entries.filter((e) => e.verdict === "FALSE");
120
- // Attach the MEASURED fix delta where — and only where — a real later same-proof PASS exists.
181
+ // Attach the MEASURED fix delta where — and only where — a real later same-proof PASS exists. ONE index
182
+ // pass (buildFixDeltas) then a binary-search per false row: O(n log n), not the old O(falseCount × rows).
183
+ const fixDeltaFor = buildFixDeltas(rows || []);
121
184
  for (const e of falseEntries) {
122
- const d = measureFixDelta(e._row, rows || []);
185
+ const d = fixDeltaFor(e._row);
123
186
  e.fix_delta_ms = d ? d.ms : null;
124
187
  e.fixed_at = d ? d.atTs : null;
125
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.38",
3
+ "version": "4.0.39",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  // TIME clause is chosen by BASELINE_TIME_MODE (Ryan flips one constant):
16
16
  // · <Xm> caught — the CUMULATIVE LIFETIME sum of every real MEASURED red→green delta (each caught
17
17
  // false "done" and the moment that SAME proof, that SAME session, was later logged PASS — real
18
- // timestamps; reuses ledger.mjs's measureFixDelta logic). ALWAYS-ON once real catches exist (it is
18
+ // timestamps; reuses ledger.mjs's buildFixDeltas logic). ALWAYS-ON once real catches exist (it is
19
19
  // skalpel's terminal presence — never gated/hidden). A measured total, NEVER an estimate; a catch
20
20
  // with no later PASS adds 0. Cumulative framing (not present-tense) keeps it honest for old data.
21
21
  // · ~Xm saved — Raahil's #609 estimate: (applied steers) × (the user's OWN median loop cost from
@@ -190,6 +190,13 @@ function verdictNote() {
190
190
  // garbage/partial lines are skipped; the sum only ever adds ≥0 deltas, so it is honest and never negative.
191
191
  function readState() {
192
192
  try {
193
+ // Read the WHOLE file — the count MUST stay exact. Unlike verify-shadow.log (writer-trimmed to
194
+ // ~1MB/500 lines), steers.ndjson is APPEND-ONLY with NO writer-side rotation (see the STEERS SOURCE
195
+ // CONTRACT in skalpel-hook.mjs). So a reader byte-cap has no safety margin: past the cap it would
196
+ // FREEZE the "N steers" count while the true count keeps growing — a false, dishonest number on the
197
+ // always-on bar. An exact O(n) line count is cheap (KB in practice; even a 20MB file reads in ~135ms,
198
+ // no freeze), and the O(n²) hot-path cost that actually mattered lives in the verify-shadow.log delta
199
+ // scan, already fixed by buildFixDeltas. So: exact whole-file count here, matching origin/main.
193
200
  const raw = readFileSync(join(homedir(), ".skalpel", "steers.ndjson"), "utf8");
194
201
  let steers = 0;
195
202
  let savedMin = 0;
@@ -227,7 +234,7 @@ function savedPhrase(min) {
227
234
  }
228
235
 
229
236
  // ── MEASURED red→green delta (real timestamps only — the honest replacement for the banned "~Xh saved").
230
- // tsMs + measureFixDelta are the EXACT logic from ledger.mjs, replicated here (not imported) so the hot
237
+ // tsMs + buildFixDeltas are the EXACT logic from ledger.mjs, replicated here (not imported) so the hot
231
238
  // statusline path never pulls verify-shadow.mjs's heavy graph. Parse an ISO ts to epoch ms, or null.
232
239
  function tsMs(ts) {
233
240
  if (typeof ts !== "string") return null;
@@ -235,31 +242,62 @@ function tsMs(ts) {
235
242
  return Number.isFinite(n) ? n : null;
236
243
  }
237
244
 
238
- // measureFixDelta(falseRow, rows) { ms, atTs } for the EARLIEST later row that is, in the SAME session,
239
- // the SAME proof_command, and a genuine PASS whose timestamp is strictly after the caught-false claim.
240
- // That is the real moment the false "done" became a true "done": the exact proof that failed later passed.
241
- // Returns null when no such row exists (then we show NO time — never an invented minute). [ledger.mjs]
242
- function measureFixDelta(falseRow, rows) {
243
- const t0 = tsMs(falseRow.ts);
244
- if (t0 == null) return null;
245
- const session = falseRow.session ?? null;
246
- // Finding 3: a REAL session id is required to tie a fix to a claim. With a null session, the
247
- // `(r.session ?? null) !== session` guard below would treat every OTHER null-session row as
248
- // "same session" and cross-attribute a bogus delta — so refuse to measure when session is null.
249
- if (session == null) return null;
250
- const proof = falseRow.proof_command || null;
251
- if (!proof) return null; // can't tie a fix to a claim with no proof count only
252
- let best = null;
245
+ // Binary search: the smallest element of an ascending array STRICTLY greater than x, or null. (upper_bound)
246
+ function firstGreater(sorted, x) {
247
+ let lo = 0;
248
+ let hi = sorted.length;
249
+ while (lo < hi) {
250
+ const mid = (lo + hi) >> 1;
251
+ if (sorted[mid] > x) hi = mid;
252
+ else lo = mid + 1;
253
+ }
254
+ return lo < sorted.length ? sorted[lo] : null;
255
+ }
256
+
257
+ // buildFixDeltas(rows) the O(n log n) replacement for the old O(falseCount × rows) per-row scan, which
258
+ // ran on EVERY bar render (the always-on hot path). ONE pass indexes every genuine PASS by
259
+ // (session proof_command) into an ascending timestamp array; the returned closure answers each
260
+ // caught-false row by binary-searching the EARLIEST PASS ts strictly later than that row's ts. Byte-for-
261
+ // byte the same result as the old per-row measureFixDelta (earliest later, SAME session, SAME
262
+ // proof_command, outcome === "PASS", ts strictly >; no proof → null; unparseable ts → null).
263
+ // Finding 3 (statusline-only): a REAL session id is required — a null-session false row yields null, so a
264
+ // caught false with no session can't cross-attribute a bogus delta against other null-session rows. This
265
+ // mirrors ledger.mjs buildFixDeltas({ excludeNullSession: true }); replicated inline to keep the hot path
266
+ // import-free. The old loop's `r === falseRow` self-skip was a no-op (a false row is a GENUINE_FAIL, never
267
+ // outcome:"PASS", and its own ts equals t0, which the strict `> t0` search already excludes). [ledger.mjs]
268
+ function buildFixDeltas(rows) {
269
+ const index = new Map(); // (session ?? null) → Map<proof_command, ascending ts[]>
253
270
  for (const r of rows) {
254
- if (r === falseRow) continue;
255
- if ((r.session ?? null) !== session) continue;
256
- if ((r.proof_command || null) !== proof) continue;
257
- if (r.outcome !== "PASS") continue; // the SAME proof genuinely passing = the real done
258
- const t1 = tsMs(r.ts);
259
- if (t1 == null || t1 <= t0) continue; // must be strictly LATER than the false claim
260
- if (best == null || t1 < best) best = t1;
271
+ if (r.outcome !== "PASS") continue;
272
+ const proof = r.proof_command || null;
273
+ if (!proof) continue;
274
+ const t = tsMs(r.ts);
275
+ if (t == null) continue;
276
+ const session = r.session ?? null;
277
+ let byProof = index.get(session);
278
+ if (!byProof) {
279
+ byProof = new Map();
280
+ index.set(session, byProof);
281
+ }
282
+ const arr = byProof.get(proof);
283
+ if (arr) arr.push(t);
284
+ else byProof.set(proof, [t]);
261
285
  }
262
- return best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
286
+ for (const byProof of index.values())
287
+ for (const arr of byProof.values()) arr.sort((a, b) => a - b);
288
+
289
+ return function fixDeltaFor(falseRow) {
290
+ const t0 = tsMs(falseRow.ts);
291
+ if (t0 == null) return null;
292
+ const session = falseRow.session ?? null;
293
+ if (session == null) return null; // Finding 3: no real session id → refuse to measure (count only)
294
+ const proof = falseRow.proof_command || null;
295
+ if (!proof) return null; // can't tie a fix to a claim with no proof — count only
296
+ const arr = index.get(session)?.get(proof);
297
+ if (!arr) return null;
298
+ const best = firstGreater(arr, t0); // earliest PASS strictly LATER than the false claim
299
+ return best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
300
+ };
263
301
  }
264
302
 
265
303
  // Compact one-line duration for the bar. Input is a real (t_pass − t_false) MEASURED delta; this only
@@ -300,10 +338,13 @@ function measuredCaughtTotalMs() {
300
338
  /* partial / garbage line — skip it, keep the rest (fail-open) */
301
339
  }
302
340
  }
341
+ // ONE index pass over all rows, then a binary-search per caught-false row — O(n log n), not the old
342
+ // O(falseCount × rows) that re-scanned every row for each catch on EVERY bar render.
343
+ const fixDeltaFor = buildFixDeltas(rows);
303
344
  let total = 0;
304
345
  for (const r of rows) {
305
346
  if (r.mismatch !== true) continue; // only a caught false "done" (verify-shadow's money metric)
306
- const d = measureFixDelta(r, rows); // exact ledger.mjs logic; null-session rows excluded (Finding 3)
347
+ const d = fixDeltaFor(r); // exact ledger.mjs logic; null-session rows excluded (Finding 3)
307
348
  if (d && Number.isFinite(d.ms) && d.ms > 0) total += d.ms; // real measured delta only; else +0
308
349
  }
309
350
  return total;