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 +37 -8
- package/ledger.mjs +92 -29
- package/package.json +1 -1
- package/skalpel-statusline.mjs +67 -26
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
|
|
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,
|
|
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 ??
|
|
188
|
-
fixed_at: e.fixed_at ??
|
|
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
|
|
284
|
-
// RESET when styling was present so a clipped
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
closeSync(openSync(path, "r"));
|
|
43
59
|
} catch {
|
|
44
|
-
return null;
|
|
60
|
+
return null;
|
|
45
61
|
}
|
|
46
62
|
const rows = [];
|
|
47
|
-
|
|
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
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
package/skalpel-statusline.mjs
CHANGED
|
@@ -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
|
|
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 +
|
|
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
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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 =
|
|
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;
|