skalpel 4.0.24 → 4.0.26
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/eval-harness.mjs +1125 -0
- package/insights.mjs +12 -0
- package/install.mjs +1 -0
- package/package.json +1 -1
- package/skalpel-hook.mjs +8 -2
- package/skalpel-statusline.mjs +42 -2
- package/verify-shadow.mjs +105 -3
package/eval-harness.mjs
ADDED
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// eval-harness.mjs — Skalpel intervention eval harness (pure Node, local-only, NO network).
|
|
3
|
+
//
|
|
4
|
+
// TRUE NORTH: KNOW whether a steering intervention actually helps, MEASURED from real
|
|
5
|
+
// Claude Code transcripts — before building or scaling it. This harness FILTERS + RANKS
|
|
6
|
+
// candidate intervention variants fast on real data. It does NOT prove live lift.
|
|
7
|
+
//
|
|
8
|
+
// ┌───────────────────────────────────────────────────────────────────────────────────┐
|
|
9
|
+
// │ HONESTY BANNER (printed on every report, and true of every number this emits): │
|
|
10
|
+
// │ • TRIGGER metrics + COUNTERFACTUAL PROXY are OFFLINE FILTER/RANK signals. │
|
|
11
|
+
// │ • They are NOT lift. Firing the intervention changes the future; the recorded │
|
|
12
|
+
// │ transcript is off-policy. True lift REQUIRES a live A/B. needs_live_ab = true. │
|
|
13
|
+
// │ • Auto-labels are NOT ground truth. Without a human gold set the report is flagged │
|
|
14
|
+
// │ UNCALIBRATED and downstream metrics are marked untrustworthy. │
|
|
15
|
+
// │ • Every number below traces to a real transcript event. No fabricated/estimated │
|
|
16
|
+
// │ values. Proxy results are labelled "proxy (needs live A/B)". │
|
|
17
|
+
// └───────────────────────────────────────────────────────────────────────────────────┘
|
|
18
|
+
//
|
|
19
|
+
// Exports:
|
|
20
|
+
// labelThrashEpisodes(entries) -> { prompts, episodes, transitions }
|
|
21
|
+
// scoreVariant({trigger, mechanism}, transcriptsDir) -> aggregate scores {needs_live_ab:true, ...}
|
|
22
|
+
// loadSession(filePath) / listSessions(dir) -> helpers
|
|
23
|
+
// CLI:
|
|
24
|
+
// node eval-harness.mjs <transcriptsDir> [--max-sessions N] [--gold gold.json]
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
// ───────────────────────────── tunables (UNCALIBRATED defaults) ─────────────────────────────
|
|
30
|
+
// These thresholds are NOT tuned against a human gold set unless one is supplied via --gold.
|
|
31
|
+
// Reported alongside every run so the reader knows what produced the labels.
|
|
32
|
+
export const DEFAULTS = {
|
|
33
|
+
tau_sim: 0.5, // TF-IDF cosine similarity threshold for "same target"
|
|
34
|
+
tau_tgt: 0.3, // Jaccard threshold on target-tuple (paths ∪ rare identifiers)
|
|
35
|
+
tau_dup: 0.8, // near-duplicate restatement threshold
|
|
36
|
+
kappa_min: 0.6, // below this vs gold => UNCALIBRATED + suppress
|
|
37
|
+
winsor_gap_s: 15 * 60, // cap inter-turn gaps at 15 min so overnight AFK can't pollute durations
|
|
38
|
+
bootstrap_B: 2000,
|
|
39
|
+
proxy_cover: 0.5, // IDF-weighted coverage of resolving-delta R required for a supply-match
|
|
40
|
+
proxy_min_rare: 2, // require >= this many rare (IDF>median) tokens matched
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const NOISE_PREFIXES = [
|
|
44
|
+
"<local-command",
|
|
45
|
+
"<command-name>",
|
|
46
|
+
"<command-message>",
|
|
47
|
+
"<command-args>",
|
|
48
|
+
"<local-command-stdout>",
|
|
49
|
+
"<task-notification>",
|
|
50
|
+
"<system-reminder",
|
|
51
|
+
"[skalpel", // the injected behavioral-graph hook block — NEVER a genuine user re-ask
|
|
52
|
+
"[Image:", // pasted-image placeholder, not a typed prompt
|
|
53
|
+
"[Request interrupted",
|
|
54
|
+
"caveat:",
|
|
55
|
+
];
|
|
56
|
+
const DROP_TYPES = new Set([
|
|
57
|
+
"last-prompt",
|
|
58
|
+
"mode",
|
|
59
|
+
"permission-mode",
|
|
60
|
+
"attachment",
|
|
61
|
+
"file-history-snapshot",
|
|
62
|
+
"ai-title",
|
|
63
|
+
"queue-operation",
|
|
64
|
+
"system",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
// dissatisfaction / corrective / repeat cues (secondary signal — semantic sim is primary)
|
|
68
|
+
const CORRECTIVE_CUES = [
|
|
69
|
+
/\bno\b/i,
|
|
70
|
+
/\bstill\b/i,
|
|
71
|
+
/\bagain\b/i,
|
|
72
|
+
/\bwrong\b/i,
|
|
73
|
+
/\bdidn'?t work\b/i,
|
|
74
|
+
/\bdoesn'?t work\b/i,
|
|
75
|
+
/\bnot working\b/i,
|
|
76
|
+
/\bsame\b/i,
|
|
77
|
+
/\bas i (said|asked)\b/i,
|
|
78
|
+
/\brevert\b/i,
|
|
79
|
+
/\bundo\b/i,
|
|
80
|
+
/\bnot what i (asked|wanted)\b/i,
|
|
81
|
+
/\bverify\b/i,
|
|
82
|
+
/\bbroke(n)?\b/i,
|
|
83
|
+
/\bfails?\b/i,
|
|
84
|
+
/\berror\b/i,
|
|
85
|
+
/\bthat'?s not\b/i,
|
|
86
|
+
/\bfix (it|this|that)\b/i,
|
|
87
|
+
];
|
|
88
|
+
// acceptance / expansion => collaboration, NOT thrash (excluded)
|
|
89
|
+
const ACCEPT_CUES = [
|
|
90
|
+
/\bthanks\b/i,
|
|
91
|
+
/\bthank you\b/i,
|
|
92
|
+
/\bgreat\b/i,
|
|
93
|
+
/\bperfect\b/i,
|
|
94
|
+
/\bnice\b/i,
|
|
95
|
+
/\blooks good\b/i,
|
|
96
|
+
/\blgtm\b/i,
|
|
97
|
+
/\bnow also\b/i,
|
|
98
|
+
/\bnext(,| step)\b/i,
|
|
99
|
+
/\bnow let'?s\b/i,
|
|
100
|
+
/\balso add\b/i,
|
|
101
|
+
];
|
|
102
|
+
const ITERATE_CUES = [/\bkeep (iterating|going)\b/i, /\bcontinue\b/i, /\bkeep at it\b/i];
|
|
103
|
+
|
|
104
|
+
const STOPWORDS = new Set(
|
|
105
|
+
(
|
|
106
|
+
"the a an and or but if then else for to of in on at by with without from into over under this that these " +
|
|
107
|
+
"those it its is are was were be been being do does did done have has had you your i me my we our they them " +
|
|
108
|
+
"as so not no yes can could would should will just now also please make sure use using need want get got let " +
|
|
109
|
+
"up down out about all any some more most less can't cant dont don't didnt it's im i'm ok okay thanks"
|
|
110
|
+
)
|
|
111
|
+
.split(/\s+/)
|
|
112
|
+
.filter(Boolean),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// ───────────────────────────── parsing ─────────────────────────────
|
|
116
|
+
export function loadSession(filePath) {
|
|
117
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
118
|
+
const entries = [];
|
|
119
|
+
for (const line of raw.split("\n")) {
|
|
120
|
+
if (!line) continue;
|
|
121
|
+
try {
|
|
122
|
+
entries.push(JSON.parse(line));
|
|
123
|
+
} catch {
|
|
124
|
+
/* skip malformed line */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return entries;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function listSessions(dir) {
|
|
131
|
+
const out = [];
|
|
132
|
+
const walk = (d) => {
|
|
133
|
+
let items;
|
|
134
|
+
try {
|
|
135
|
+
items = fs.readdirSync(d, { withFileTypes: true });
|
|
136
|
+
} catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const it of items) {
|
|
140
|
+
const p = path.join(d, it.name);
|
|
141
|
+
if (it.isDirectory()) walk(p);
|
|
142
|
+
else if (it.isFile() && it.name.endsWith(".jsonl")) out.push(p);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
walk(dir);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function contentToText(content) {
|
|
150
|
+
if (typeof content === "string") return content;
|
|
151
|
+
if (Array.isArray(content)) {
|
|
152
|
+
return content
|
|
153
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
154
|
+
.map((b) => b.text)
|
|
155
|
+
.join("\n");
|
|
156
|
+
}
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assistantActivity(entry) {
|
|
161
|
+
// returns {tool:bool, text:string} describing what the assistant did in this record
|
|
162
|
+
if (entry.type !== "assistant") return null;
|
|
163
|
+
const c = entry.message && entry.message.content;
|
|
164
|
+
if (!Array.isArray(c)) return { tool: false, text: contentToText(c) };
|
|
165
|
+
const tool = c.some((b) => b && b.type === "tool_use");
|
|
166
|
+
const text = c
|
|
167
|
+
.filter((b) => b && b.type === "text")
|
|
168
|
+
.map((b) => b.text)
|
|
169
|
+
.join("\n");
|
|
170
|
+
return { tool, text };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ───────────────────────────── STEP 1: real user-prompt stream + interleaved attempts ─────
|
|
174
|
+
export function extractStream(entries) {
|
|
175
|
+
const prompts = [];
|
|
176
|
+
let pendingAttempt = { tool: false, text: "" }; // assistant activity since previous kept prompt
|
|
177
|
+
for (let idx = 0; idx < entries.length; idx++) {
|
|
178
|
+
const e = entries[idx];
|
|
179
|
+
if (!e || !e.type) continue;
|
|
180
|
+
if (e.type === "assistant") {
|
|
181
|
+
const a = assistantActivity(e);
|
|
182
|
+
if (a) {
|
|
183
|
+
if (a.tool) pendingAttempt.tool = true;
|
|
184
|
+
if (a.text) pendingAttempt.text += (pendingAttempt.text ? "\n" : "") + a.text;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (e.type !== "user") continue;
|
|
189
|
+
if (DROP_TYPES.has(e.type)) continue;
|
|
190
|
+
if (e.isMeta) continue;
|
|
191
|
+
const c = e.message && e.message.content;
|
|
192
|
+
if (typeof c !== "string") continue; // DROP array content (tool_result) — never a typed re-ask
|
|
193
|
+
const text = c.trim();
|
|
194
|
+
if (!text) continue;
|
|
195
|
+
const trimmedStart = c.trimStart();
|
|
196
|
+
if (NOISE_PREFIXES.some((n) => trimmedStart.startsWith(n))) continue;
|
|
197
|
+
prompts.push({
|
|
198
|
+
index: prompts.length,
|
|
199
|
+
entryIndex: idx,
|
|
200
|
+
ts: e.timestamp ? Date.parse(e.timestamp) : null,
|
|
201
|
+
text,
|
|
202
|
+
uuid: e.uuid,
|
|
203
|
+
parentUuid: e.parentUuid,
|
|
204
|
+
// attempt that the assistant made BEFORE this prompt (since the previous kept prompt)
|
|
205
|
+
attemptBefore: { tool: pendingAttempt.tool, hadText: pendingAttempt.text.length > 0 },
|
|
206
|
+
});
|
|
207
|
+
pendingAttempt = { tool: false, text: "" };
|
|
208
|
+
}
|
|
209
|
+
return prompts;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ───────────────────────────── STEP 2: target signature + TF-IDF ─────────────────────────
|
|
213
|
+
const PATH_RE =
|
|
214
|
+
/(?:[\w.-]*\/)+[\w.-]+|\b[\w-]+\.(?:mjs|js|ts|tsx|rs|go|py|json|toml|sql|md|sh|yml|yaml|css|html)\b/g;
|
|
215
|
+
const IDENT_RE = /\b[A-Za-z_][A-Za-z0-9_]{2,}\b/g;
|
|
216
|
+
|
|
217
|
+
function tokenize(text) {
|
|
218
|
+
const toks = [];
|
|
219
|
+
const lower = text.toLowerCase();
|
|
220
|
+
for (const m of lower.matchAll(IDENT_RE)) {
|
|
221
|
+
const t = m[0];
|
|
222
|
+
if (!STOPWORDS.has(t)) toks.push(t);
|
|
223
|
+
}
|
|
224
|
+
return toks;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function extractPaths(text) {
|
|
228
|
+
const set = new Set();
|
|
229
|
+
for (const m of text.matchAll(PATH_RE)) set.add(m[0].toLowerCase());
|
|
230
|
+
return set;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildIdf(prompts) {
|
|
234
|
+
// document frequency across the prompts in THIS session (episodes are within-session)
|
|
235
|
+
const df = new Map();
|
|
236
|
+
const N = prompts.length || 1;
|
|
237
|
+
for (const p of prompts) {
|
|
238
|
+
const seen = new Set(tokenize(p.text));
|
|
239
|
+
for (const t of seen) df.set(t, (df.get(t) || 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
const idf = new Map();
|
|
242
|
+
for (const [t, d] of df) idf.set(t, Math.log((N + 1) / (d + 1)) + 1);
|
|
243
|
+
return { idf, N };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function tfidfVec(text, idf) {
|
|
247
|
+
const tf = new Map();
|
|
248
|
+
const toks = tokenize(text);
|
|
249
|
+
for (const t of toks) tf.set(t, (tf.get(t) || 0) + 1);
|
|
250
|
+
const vec = new Map();
|
|
251
|
+
let norm = 0;
|
|
252
|
+
for (const [t, f] of tf) {
|
|
253
|
+
const w = (f / toks.length) * (idf.get(t) || Math.log(2) + 1);
|
|
254
|
+
vec.set(t, w);
|
|
255
|
+
norm += w * w;
|
|
256
|
+
}
|
|
257
|
+
norm = Math.sqrt(norm) || 1;
|
|
258
|
+
for (const [t, w] of vec) vec.set(t, w / norm);
|
|
259
|
+
return vec;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cosine(a, b) {
|
|
263
|
+
let dot = 0;
|
|
264
|
+
const [small, big] = a.size < b.size ? [a, b] : [b, a];
|
|
265
|
+
for (const [t, w] of small) {
|
|
266
|
+
const w2 = big.get(t);
|
|
267
|
+
if (w2) dot += w * w2;
|
|
268
|
+
}
|
|
269
|
+
return dot;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function targetTuple(text, idf) {
|
|
273
|
+
// paths ∪ rare (high-IDF) identifier tokens
|
|
274
|
+
const set = new Set(extractPaths(text));
|
|
275
|
+
const scored = tokenize(text)
|
|
276
|
+
.map((t) => [t, idf.get(t) || 0])
|
|
277
|
+
.sort((x, y) => y[1] - x[1])
|
|
278
|
+
.slice(0, 8);
|
|
279
|
+
for (const [t] of scored) set.add(t);
|
|
280
|
+
return set;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function jaccard(a, b) {
|
|
284
|
+
if (!a.size && !b.size) return 0;
|
|
285
|
+
let inter = 0;
|
|
286
|
+
const [small, big] = a.size < b.size ? [a, b] : [b, a];
|
|
287
|
+
for (const t of small) if (big.has(t)) inter++;
|
|
288
|
+
return inter / (a.size + b.size - inter);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const hasCue = (text, cues) => cues.some((re) => re.test(text));
|
|
292
|
+
|
|
293
|
+
// ───────────────────────────── STEP 3+4: episodes ─────────────────────────
|
|
294
|
+
export function labelThrashEpisodes(entries, opts = {}) {
|
|
295
|
+
const cfg = { ...DEFAULTS, ...opts };
|
|
296
|
+
const prompts = extractStream(entries);
|
|
297
|
+
const { idf } = buildIdf(prompts);
|
|
298
|
+
// precompute vectors + target tuples (cache => seconds target)
|
|
299
|
+
const vecs = prompts.map((p) => tfidfVec(p.text, idf));
|
|
300
|
+
const tgts = prompts.map((p) => targetTuple(p.text, idf));
|
|
301
|
+
|
|
302
|
+
const transitions = []; // per adjacent pair (i,i+1)
|
|
303
|
+
for (let i = 0; i + 1 < prompts.length; i++) {
|
|
304
|
+
const a = prompts[i];
|
|
305
|
+
const b = prompts[i + 1];
|
|
306
|
+
const attempted = b.attemptBefore.tool || b.attemptBefore.hadText;
|
|
307
|
+
const sim = cosine(vecs[i], vecs[i + 1]);
|
|
308
|
+
const jac = jaccard(tgts[i], tgts[i + 1]);
|
|
309
|
+
const accept = hasCue(b.text, ACCEPT_CUES) && !hasCue(b.text, CORRECTIVE_CUES);
|
|
310
|
+
const iterate = hasCue(a.text, ITERATE_CUES) || hasCue(b.text, ITERATE_CUES);
|
|
311
|
+
const sameTarget = sim >= cfg.tau_sim || jac >= cfg.tau_tgt;
|
|
312
|
+
const corrective = hasCue(b.text, CORRECTIVE_CUES) || sim >= cfg.tau_dup || jac >= cfg.tau_tgt;
|
|
313
|
+
const reengaged = attempted && sameTarget && corrective && !accept && !iterate;
|
|
314
|
+
transitions.push({ i, attempted, sim, jac, sameTarget, corrective, accept, reengaged });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const episodes = [];
|
|
318
|
+
let start = -1;
|
|
319
|
+
for (let i = 0; i < transitions.length; i++) {
|
|
320
|
+
if (transitions[i].reengaged) {
|
|
321
|
+
if (start === -1) start = i;
|
|
322
|
+
} else if (start !== -1) {
|
|
323
|
+
episodes.push(buildEpisode(start, i, prompts, tgts, idf, cfg));
|
|
324
|
+
start = -1;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (start !== -1) episodes.push(buildEpisode(start, transitions.length, prompts, tgts, idf, cfg));
|
|
328
|
+
|
|
329
|
+
return { prompts, vecs, tgts, idf, transitions, episodes };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function winsorGap(a, b, cap) {
|
|
333
|
+
if (a == null || b == null) return 0;
|
|
334
|
+
return Math.min(Math.max(0, b - a), cap * 1000);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildEpisode(startTrans, endTrans, prompts, tgts, idf, cfg) {
|
|
338
|
+
// members are prompt indices [startTrans .. endTrans] (endTrans transition was the last reengaged)
|
|
339
|
+
const t0 = startTrans;
|
|
340
|
+
const tEnd = endTrans; // last member prompt index
|
|
341
|
+
const members = [];
|
|
342
|
+
for (let k = t0; k <= tEnd; k++) members.push(k);
|
|
343
|
+
// duration winsorized over member span
|
|
344
|
+
let dur = 0;
|
|
345
|
+
for (let k = t0; k < tEnd; k++)
|
|
346
|
+
dur += winsorGap(prompts[k].ts, prompts[k + 1].ts, cfg.winsor_gap_s);
|
|
347
|
+
// target signature = union of member target tuples
|
|
348
|
+
const target = new Set();
|
|
349
|
+
for (const k of members) for (const t of tgts[k]) target.add(t);
|
|
350
|
+
// terminal state: if a prompt follows the last member (session continued past loop) => RESOLVED
|
|
351
|
+
const hasNext = tEnd + 1 < prompts.length;
|
|
352
|
+
return {
|
|
353
|
+
onset_index: t0,
|
|
354
|
+
onset_entryIndex: prompts[t0].entryIndex,
|
|
355
|
+
onset_ts: prompts[t0].ts,
|
|
356
|
+
member_indices: members,
|
|
357
|
+
n_reengagements: members.length - 1,
|
|
358
|
+
duration_s: Math.round(dur / 1000),
|
|
359
|
+
terminal_state: hasNext ? "RESOLVED" : "ABANDONED",
|
|
360
|
+
esc_index: hasNext ? tEnd + 1 : null, // escape turn = first prompt AFTER the loop (the switch/resolution)
|
|
361
|
+
target_signature: [...target].slice(0, 20),
|
|
362
|
+
_targetSet: target,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ───────────────────────────── trigger evaluation (strict causal truncation) ─────────────
|
|
367
|
+
// A decision point f = "after prompt index f, before prompt f+1". The trigger sees ONLY
|
|
368
|
+
// prompts[0..f] (ts <= that point). It NEVER sees prompt f+1 — enforced here by slicing.
|
|
369
|
+
function evalTriggerOnSession(variant, labeled) {
|
|
370
|
+
const { prompts, vecs, tgts, transitions, episodes } = labeled;
|
|
371
|
+
const fires = [];
|
|
372
|
+
for (let f = 0; f < prompts.length - 1; f++) {
|
|
373
|
+
// causal prefix: prompts up to and including f, with their vecs/tgts/transitions[< f]
|
|
374
|
+
const ctx = {
|
|
375
|
+
prompts: prompts.slice(0, f + 1),
|
|
376
|
+
vecs: vecs.slice(0, f + 1),
|
|
377
|
+
tgts: tgts.slice(0, f + 1),
|
|
378
|
+
transitions: transitions.slice(0, f), // transition j links j->j+1; j<f are fully in-prefix
|
|
379
|
+
f,
|
|
380
|
+
};
|
|
381
|
+
let fire = false;
|
|
382
|
+
try {
|
|
383
|
+
fire = !!variant.trigger(ctx);
|
|
384
|
+
} catch {
|
|
385
|
+
fire = false;
|
|
386
|
+
}
|
|
387
|
+
if (fire) fires.push(f);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// classify fires against episodes
|
|
391
|
+
const episodeSpans = episodes.map((ep) => ({
|
|
392
|
+
t0: ep.onset_index,
|
|
393
|
+
tEnd: ep.member_indices[ep.member_indices.length - 1],
|
|
394
|
+
ep,
|
|
395
|
+
}));
|
|
396
|
+
let firesInside = 0;
|
|
397
|
+
const perEpisode = episodeSpans.map(() => ({ onset: false, any: false, firstFire: null }));
|
|
398
|
+
const nonThrashPoints = prompts.length - 1; // total decision points
|
|
399
|
+
let insideAnyEpisodePoints = 0;
|
|
400
|
+
const pointInEpisode = new Array(prompts.length - 1).fill(false);
|
|
401
|
+
episodeSpans.forEach((s) => {
|
|
402
|
+
for (let f = s.t0; f <= s.tEnd && f < pointInEpisode.length; f++) pointInEpisode[f] = true;
|
|
403
|
+
});
|
|
404
|
+
for (let f = 0; f < pointInEpisode.length; f++) if (pointInEpisode[f]) insideAnyEpisodePoints++;
|
|
405
|
+
|
|
406
|
+
for (const f of fires) {
|
|
407
|
+
let inside = false;
|
|
408
|
+
episodeSpans.forEach((s, ei) => {
|
|
409
|
+
if (f >= s.t0 && f <= s.tEnd) {
|
|
410
|
+
inside = true;
|
|
411
|
+
perEpisode[ei].any = true;
|
|
412
|
+
if (perEpisode[ei].firstFire == null) perEpisode[ei].firstFire = f;
|
|
413
|
+
if (f <= s.t0 + 1) perEpisode[ei].onset = true;
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
if (inside) firesInside++;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const falseFires = fires.length - firesInside;
|
|
420
|
+
const nonEpisodePoints = nonThrashPoints - insideAnyEpisodePoints;
|
|
421
|
+
|
|
422
|
+
// fires-by-offset-from-onset histogram
|
|
423
|
+
const hist = {};
|
|
424
|
+
episodeSpans.forEach((s, ei) => {
|
|
425
|
+
if (perEpisode[ei].firstFire != null) {
|
|
426
|
+
const off = perEpisode[ei].firstFire - s.t0;
|
|
427
|
+
const key = off <= -1 ? "<0" : off >= 5 ? ">=5" : String(off);
|
|
428
|
+
hist[key] = (hist[key] || 0) + 1;
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
total_fires: fires.length,
|
|
434
|
+
fires_inside: firesInside,
|
|
435
|
+
false_fires: falseFires,
|
|
436
|
+
non_episode_points: nonEpisodePoints,
|
|
437
|
+
episodes: episodeSpans.length,
|
|
438
|
+
perEpisode,
|
|
439
|
+
hist,
|
|
440
|
+
fires,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ───────────────────────────── counterfactual proxy (would-have-short-circuited) ─────────
|
|
445
|
+
// PROXY — RANKING signal, explicitly NOT lift. Only RESOLVED episodes (known escape).
|
|
446
|
+
function proxyOnSession(variant, labeled, cfg) {
|
|
447
|
+
const { prompts, tgts, idf, episodes } = labeled;
|
|
448
|
+
const results = [];
|
|
449
|
+
for (const ep of episodes) {
|
|
450
|
+
if (ep.terminal_state !== "RESOLVED" || ep.esc_index == null) continue;
|
|
451
|
+
const escIdx = ep.esc_index;
|
|
452
|
+
if (escIdx >= prompts.length) continue;
|
|
453
|
+
const members = ep.member_indices;
|
|
454
|
+
const earlier = members.filter((m) => m < escIdx); // all members (escIdx is the post-loop turn)
|
|
455
|
+
if (!earlier.length) continue;
|
|
456
|
+
|
|
457
|
+
// RESOLVING DELTA R = high-IDF tokens in the ESCAPE turn (the prompt where the loop resolved /
|
|
458
|
+
// the user switched) that are ABSENT from the thrash members. The honesty firewall: the mechanism
|
|
459
|
+
// is drawn ONLY from pre-f prompts, so a token can match R only if it demonstrably existed earlier.
|
|
460
|
+
const escTokens = new Map();
|
|
461
|
+
for (const t of tokenize(prompts[escIdx].text)) escTokens.set(t, idf.get(t) || 0);
|
|
462
|
+
const earlierTokens = new Set();
|
|
463
|
+
for (const m of earlier) for (const t of tokenize(prompts[m].text)) earlierTokens.add(t);
|
|
464
|
+
const idfVals = [...escTokens.values()].sort((a, b) => a - b);
|
|
465
|
+
const medIdf = idfVals.length ? idfVals[Math.floor(idfVals.length / 2)] : 0;
|
|
466
|
+
const R = new Map(); // token -> idf, the delta
|
|
467
|
+
for (const [t, w] of escTokens) if (!earlierTokens.has(t)) R.set(t, w);
|
|
468
|
+
if (!R.size) continue;
|
|
469
|
+
|
|
470
|
+
// where would the variant have fired inside this episode?
|
|
471
|
+
const trig = evalTriggerOnSession(variant, labeled);
|
|
472
|
+
const spanFires = trig.fires.filter((f) => f >= ep.onset_index && f < escIdx);
|
|
473
|
+
if (!spanFires.length) continue;
|
|
474
|
+
const f = spanFires[0];
|
|
475
|
+
|
|
476
|
+
// EXECUTE mechanism against context truncated at f (pre-f state ONLY). Never hand-supplied.
|
|
477
|
+
const preCtx = {
|
|
478
|
+
prompts: prompts.slice(0, f + 1),
|
|
479
|
+
tgts: tgts.slice(0, f + 1),
|
|
480
|
+
idf,
|
|
481
|
+
target: ep._targetSet,
|
|
482
|
+
};
|
|
483
|
+
let injected = new Set();
|
|
484
|
+
try {
|
|
485
|
+
injected = variant.mechanism ? variant.mechanism(preCtx) : new Set();
|
|
486
|
+
} catch {
|
|
487
|
+
injected = new Set();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// SUPPLY match: injected (drawn from pre-f) covers R by IDF-weighted mass, with >= min rare tokens.
|
|
491
|
+
let coveredMass = 0;
|
|
492
|
+
let totalMass = 0;
|
|
493
|
+
let rareHits = 0;
|
|
494
|
+
for (const [t, w] of R) {
|
|
495
|
+
totalMass += w;
|
|
496
|
+
if (injected.has(t)) {
|
|
497
|
+
coveredMass += w;
|
|
498
|
+
if (w >= medIdf) rareHits++;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const coverage = totalMass ? coveredMass / totalMass : 0;
|
|
502
|
+
const supplyMatch = coverage >= cfg.proxy_cover && rareHits >= cfg.proxy_min_rare;
|
|
503
|
+
|
|
504
|
+
results.push({
|
|
505
|
+
onset: ep.onset_index,
|
|
506
|
+
fire: f,
|
|
507
|
+
esc: escIdx,
|
|
508
|
+
turns_short_circuited: escIdx - f,
|
|
509
|
+
coverage: +coverage.toFixed(3),
|
|
510
|
+
rareHits,
|
|
511
|
+
supplyMatch,
|
|
512
|
+
removeMatch: false, // these example mechanisms are supply-type
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return results;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ───────────────────────────── recurrence baseline (steered vs silent analog) ────────────
|
|
519
|
+
// Reproduce the cycle-12 reference (~50% steered vs ~45% silent) on THIS folder, SAME labeler.
|
|
520
|
+
// steered-analog = session contained a [skalpel injection block; silent-analog = it did not.
|
|
521
|
+
function sessionHadInjection(entries) {
|
|
522
|
+
for (const e of entries) {
|
|
523
|
+
if (e && e.type === "user" && typeof (e.message && e.message.content) === "string") {
|
|
524
|
+
if (e.message.content.trimStart().startsWith("[skalpel")) return true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function recurrenceRate(labeled) {
|
|
531
|
+
// fraction of eligible transitions (assistant attempted between) that are same-target re-engagements
|
|
532
|
+
let eligible = 0;
|
|
533
|
+
let reeng = 0;
|
|
534
|
+
for (const t of labeled.transitions) {
|
|
535
|
+
if (t.attempted) {
|
|
536
|
+
eligible++;
|
|
537
|
+
if (t.reengaged) reeng++;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return { eligible, reeng };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ───────────────────────────── bootstrap CI ─────────────────────────
|
|
544
|
+
function bootstrapCI(values, B, agg) {
|
|
545
|
+
if (!values.length) return [0, 0];
|
|
546
|
+
const n = values.length;
|
|
547
|
+
const stats = [];
|
|
548
|
+
for (let b = 0; b < B; b++) {
|
|
549
|
+
const sample = new Array(n);
|
|
550
|
+
for (let i = 0; i < n; i++) sample[i] = values[(Math.random() * n) | 0];
|
|
551
|
+
stats.push(agg(sample));
|
|
552
|
+
}
|
|
553
|
+
stats.sort((a, b) => a - b);
|
|
554
|
+
return [stats[Math.floor(0.025 * B)], stats[Math.floor(0.975 * B)]];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ───────────────────────────── corpus loading (one disk pass, cached) ─────────────────────
|
|
558
|
+
// Load + label every session ONCE. Variants are then scored against this cache so a full
|
|
559
|
+
// multi-variant CLI run is a single pass over disk (hits the seconds target).
|
|
560
|
+
export function labelCorpus(transcriptsDir, opts = {}) {
|
|
561
|
+
const cfg = { ...DEFAULTS, ...opts };
|
|
562
|
+
const files = listSessions(transcriptsDir);
|
|
563
|
+
const cap = opts.maxSessions || files.length;
|
|
564
|
+
const chosen = files.slice(0, cap);
|
|
565
|
+
const sessions = [];
|
|
566
|
+
for (const f of chosen) {
|
|
567
|
+
let entries;
|
|
568
|
+
try {
|
|
569
|
+
entries = loadSession(f);
|
|
570
|
+
} catch {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (!entries.length) continue;
|
|
574
|
+
const labeled = labelThrashEpisodes(entries, cfg);
|
|
575
|
+
if (labeled.prompts.length < 2) continue;
|
|
576
|
+
sessions.push({
|
|
577
|
+
file: f,
|
|
578
|
+
labeled,
|
|
579
|
+
steered: sessionHadInjection(entries),
|
|
580
|
+
recurrence: recurrenceRate(labeled),
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
return sessions;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ───────────────────────────── public: scoreVariant ─────────────────────────
|
|
587
|
+
export function scoreVariant(variant, transcriptsDir, opts = {}) {
|
|
588
|
+
const corpus = labelCorpus(transcriptsDir, opts);
|
|
589
|
+
return scoreVariantCached(variant, corpus, opts);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function scoreVariantCached(variant, corpus, opts = {}) {
|
|
593
|
+
const cfg = { ...DEFAULTS, ...opts };
|
|
594
|
+
|
|
595
|
+
let n_sessions = 0,
|
|
596
|
+
n_prompts = 0,
|
|
597
|
+
n_episodes = 0,
|
|
598
|
+
n_resolved = 0,
|
|
599
|
+
n_abandoned = 0;
|
|
600
|
+
let total_fires = 0,
|
|
601
|
+
fires_inside = 0,
|
|
602
|
+
false_fires = 0,
|
|
603
|
+
non_episode_points = 0;
|
|
604
|
+
let ep_with_onset = 0,
|
|
605
|
+
ep_with_any = 0;
|
|
606
|
+
const leadTurns = [];
|
|
607
|
+
const leadSecs = [];
|
|
608
|
+
const proxyPerEp = []; // 1/0 supplyMatch over resolved
|
|
609
|
+
const proxyTurns = [];
|
|
610
|
+
let proxySupply = 0,
|
|
611
|
+
proxyRemove = 0,
|
|
612
|
+
proxyResolvedScored = 0;
|
|
613
|
+
const hist = {};
|
|
614
|
+
const episodeOnsetFlags = []; // for bootstrap of onset-recall
|
|
615
|
+
// recurrence arms
|
|
616
|
+
const arm = {
|
|
617
|
+
steered: { elig: 0, re: 0, perSession: [] },
|
|
618
|
+
silent: { elig: 0, re: 0, perSession: [] },
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
for (const sess of corpus) {
|
|
622
|
+
const labeled = sess.labeled;
|
|
623
|
+
n_sessions++;
|
|
624
|
+
n_prompts += labeled.prompts.length;
|
|
625
|
+
n_episodes += labeled.episodes.length;
|
|
626
|
+
for (const ep of labeled.episodes) {
|
|
627
|
+
if (ep.terminal_state === "RESOLVED") n_resolved++;
|
|
628
|
+
else n_abandoned++;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// recurrence arm
|
|
632
|
+
const rr = sess.recurrence;
|
|
633
|
+
const steered = sess.steered;
|
|
634
|
+
const a = steered ? arm.steered : arm.silent;
|
|
635
|
+
a.elig += rr.eligible;
|
|
636
|
+
a.re += rr.reeng;
|
|
637
|
+
if (rr.eligible > 0) a.perSession.push(rr.reeng / rr.eligible);
|
|
638
|
+
|
|
639
|
+
// trigger
|
|
640
|
+
const t = evalTriggerOnSession(variant, labeled);
|
|
641
|
+
total_fires += t.total_fires;
|
|
642
|
+
fires_inside += t.fires_inside;
|
|
643
|
+
false_fires += t.false_fires;
|
|
644
|
+
non_episode_points += t.non_episode_points;
|
|
645
|
+
t.perEpisode.forEach((pe, ei) => {
|
|
646
|
+
episodeOnsetFlags.push(pe.onset ? 1 : 0);
|
|
647
|
+
if (pe.onset) ep_with_onset++;
|
|
648
|
+
if (pe.any) ep_with_any++;
|
|
649
|
+
if (pe.firstFire != null) {
|
|
650
|
+
const ep = labeled.episodes[ei];
|
|
651
|
+
const tEnd = ep.member_indices[ep.member_indices.length - 1];
|
|
652
|
+
const lt = tEnd - pe.firstFire; // remaining member turns after fire
|
|
653
|
+
leadTurns.push(lt);
|
|
654
|
+
const ftS = labeled.prompts[pe.firstFire].ts;
|
|
655
|
+
const teS = labeled.prompts[tEnd].ts;
|
|
656
|
+
if (ftS != null && teS != null)
|
|
657
|
+
leadSecs.push(Math.min(teS - ftS, cfg.winsor_gap_s * 1000 * 4) / 1000);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
for (const [k, v] of Object.entries(t.hist)) hist[k] = (hist[k] || 0) + v;
|
|
661
|
+
|
|
662
|
+
// proxy
|
|
663
|
+
const pr = proxyOnSession(variant, labeled, cfg);
|
|
664
|
+
for (const r of pr) {
|
|
665
|
+
proxyResolvedScored++;
|
|
666
|
+
proxyPerEp.push(r.supplyMatch || r.removeMatch ? 1 : 0);
|
|
667
|
+
if (r.supplyMatch || r.removeMatch) {
|
|
668
|
+
proxyTurns.push(r.turns_short_circuited);
|
|
669
|
+
if (r.supplyMatch) proxySupply++;
|
|
670
|
+
if (r.removeMatch) proxyRemove++;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const median = (arr) => {
|
|
676
|
+
if (!arr.length) return null;
|
|
677
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
678
|
+
return s[Math.floor(s.length / 2)];
|
|
679
|
+
};
|
|
680
|
+
const rate = (a, b) => (b ? a / b : 0);
|
|
681
|
+
|
|
682
|
+
const onsetRecall = rate(ep_with_onset, n_episodes);
|
|
683
|
+
const anyRecall = rate(ep_with_any, n_episodes);
|
|
684
|
+
const precision = rate(fires_inside, total_fires);
|
|
685
|
+
const falseFireRate = rate(false_fires, non_episode_points);
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
needs_live_ab: true,
|
|
689
|
+
thresholds: { tau_sim: cfg.tau_sim, tau_tgt: cfg.tau_tgt, tau_dup: cfg.tau_dup },
|
|
690
|
+
corpus: {
|
|
691
|
+
n_sessions,
|
|
692
|
+
n_real_user_turns: n_prompts,
|
|
693
|
+
n_thrash_episodes: n_episodes,
|
|
694
|
+
n_resolved,
|
|
695
|
+
n_abandoned,
|
|
696
|
+
},
|
|
697
|
+
trigger: {
|
|
698
|
+
total_fires,
|
|
699
|
+
trigger_precision: +precision.toFixed(4),
|
|
700
|
+
trigger_recall_any: +anyRecall.toFixed(4),
|
|
701
|
+
timing_at_onset_rate: +onsetRecall.toFixed(4), // HEADLINE
|
|
702
|
+
median_lead_turns: median(leadTurns),
|
|
703
|
+
median_lead_seconds: median(leadSecs) == null ? null : Math.round(median(leadSecs)),
|
|
704
|
+
false_fire_rate: +falseFireRate.toFixed(5),
|
|
705
|
+
fires_by_offset_from_onset: hist,
|
|
706
|
+
onset_recall_ci95: bootstrapCI(
|
|
707
|
+
episodeOnsetFlags,
|
|
708
|
+
cfg.bootstrap_B,
|
|
709
|
+
(s) => s.reduce((x, y) => x + y, 0) / s.length,
|
|
710
|
+
).map((x) => +x.toFixed(4)),
|
|
711
|
+
},
|
|
712
|
+
counterfactual_proxy: {
|
|
713
|
+
_label: "proxy (needs live A/B) — RANKING signal, NOT lift",
|
|
714
|
+
resolved_episodes_scored: proxyResolvedScored,
|
|
715
|
+
would_short_circuit_rate_over_resolved: +rate(
|
|
716
|
+
proxySupply + proxyRemove,
|
|
717
|
+
proxyResolvedScored,
|
|
718
|
+
).toFixed(4),
|
|
719
|
+
would_short_circuit_rate_over_all_episodes: +rate(
|
|
720
|
+
proxySupply + proxyRemove,
|
|
721
|
+
n_episodes,
|
|
722
|
+
).toFixed(4),
|
|
723
|
+
median_turns_short_circuited: median(proxyTurns),
|
|
724
|
+
supply_matches: proxySupply,
|
|
725
|
+
remove_matches: proxyRemove,
|
|
726
|
+
abandoned_episodes_unscorable: n_abandoned,
|
|
727
|
+
proxy_ci95_over_resolved: bootstrapCI(proxyPerEp, cfg.bootstrap_B, (s) =>
|
|
728
|
+
s.length ? s.reduce((x, y) => x + y, 0) / s.length : 0,
|
|
729
|
+
).map((x) => +x.toFixed(4)),
|
|
730
|
+
},
|
|
731
|
+
recurrence_baseline: buildRecurrence(arm, cfg),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function buildRecurrence(arm, cfg) {
|
|
736
|
+
const rate = (a, b) => (b ? a / b : 0);
|
|
737
|
+
const steeredN = arm.steered.perSession.length;
|
|
738
|
+
const silentN = arm.silent.perSession.length;
|
|
739
|
+
const splitOk = steeredN > 0 && silentN > 0;
|
|
740
|
+
return {
|
|
741
|
+
_label:
|
|
742
|
+
"reference to beat — SAME labeler, THIS folder (cycle-12 ref was ~50% steered vs ~45% silent)",
|
|
743
|
+
_caveat:
|
|
744
|
+
"steered-analog = session carried a [skalpel injection; silent-analog = none. Pooled recurrence, NOT a controlled A/B.",
|
|
745
|
+
_reproducible_split: splitOk,
|
|
746
|
+
_reconciliation: splitOk
|
|
747
|
+
? "both arms present — compare recurrence_rate across arms (still NOT a controlled A/B)."
|
|
748
|
+
: "SPLIT NOT REPRODUCIBLE on this folder: one arm has 0 sessions (no genuine hook-injected transcripts " +
|
|
749
|
+
"detected). FLAGGING per anti-misleading policy — a rosier baseline is NOT substituted. The single-arm " +
|
|
750
|
+
"recurrence below is the honest number-to-beat under THIS (stricter, per-transition, conservative) " +
|
|
751
|
+
"labeler; it is NOT expected to equal the cycle-12 ~45% figure, which used a different definition.",
|
|
752
|
+
steered_analog: {
|
|
753
|
+
sessions: arm.steered.perSession.length,
|
|
754
|
+
eligible_transitions: arm.steered.elig,
|
|
755
|
+
recurrence_rate: +rate(arm.steered.re, arm.steered.elig).toFixed(4),
|
|
756
|
+
ci95: bootstrapCI(
|
|
757
|
+
arm.steered.perSession,
|
|
758
|
+
cfg.bootstrap_B,
|
|
759
|
+
(s) => s.reduce((x, y) => x + y, 0) / s.length,
|
|
760
|
+
).map((x) => +x.toFixed(4)),
|
|
761
|
+
},
|
|
762
|
+
silent_analog: {
|
|
763
|
+
sessions: arm.silent.perSession.length,
|
|
764
|
+
eligible_transitions: arm.silent.elig,
|
|
765
|
+
recurrence_rate: +rate(arm.silent.re, arm.silent.elig).toFixed(4),
|
|
766
|
+
ci95: bootstrapCI(
|
|
767
|
+
arm.silent.perSession,
|
|
768
|
+
cfg.bootstrap_B,
|
|
769
|
+
(s) => s.reduce((x, y) => x + y, 0) / s.length,
|
|
770
|
+
).map((x) => +x.toFixed(4)),
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ───────────────────────────── calibration vs human gold ─────────────────────────
|
|
776
|
+
// gold.json format: [{ file: "<basename>.jsonl", onset_indices: [<prompt-index>, ...] }, ...]
|
|
777
|
+
function calibrate(goldPath, transcriptsDir, cfg) {
|
|
778
|
+
let gold;
|
|
779
|
+
try {
|
|
780
|
+
gold = JSON.parse(fs.readFileSync(goldPath, "utf8"));
|
|
781
|
+
} catch {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
const files = listSessions(transcriptsDir);
|
|
785
|
+
const byName = new Map(files.map((f) => [path.basename(f), f]));
|
|
786
|
+
let tp = 0,
|
|
787
|
+
fp = 0,
|
|
788
|
+
fn = 0,
|
|
789
|
+
goldN = 0;
|
|
790
|
+
// for kappa we treat each prompt as a unit: is it a thrash onset?
|
|
791
|
+
let a00 = 0,
|
|
792
|
+
a01 = 0,
|
|
793
|
+
a10 = 0,
|
|
794
|
+
a11 = 0;
|
|
795
|
+
for (const g of gold) {
|
|
796
|
+
const f = byName.get(g.file);
|
|
797
|
+
if (!f) continue;
|
|
798
|
+
const labeled = labelThrashEpisodes(loadSession(f), cfg);
|
|
799
|
+
const autoOnsets = new Set(labeled.episodes.map((e) => e.onset_index));
|
|
800
|
+
const goldOnsets = new Set(g.onset_indices || []);
|
|
801
|
+
goldN += goldOnsets.size;
|
|
802
|
+
const nP = labeled.prompts.length;
|
|
803
|
+
for (let i = 0; i < nP; i++) {
|
|
804
|
+
const auto = autoOnsets.has(i);
|
|
805
|
+
const human = goldOnsets.has(i);
|
|
806
|
+
if (auto && human) {
|
|
807
|
+
tp++;
|
|
808
|
+
a11++;
|
|
809
|
+
} else if (auto && !human) {
|
|
810
|
+
fp++;
|
|
811
|
+
a10++;
|
|
812
|
+
} else if (!auto && human) {
|
|
813
|
+
fn++;
|
|
814
|
+
a01++;
|
|
815
|
+
} else a00++;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const precision = tp + fp ? tp / (tp + fp) : 0;
|
|
819
|
+
const recall = tp + fn ? tp / (tp + fn) : 0;
|
|
820
|
+
const f1 = precision + recall ? (2 * precision * recall) / (precision + recall) : 0;
|
|
821
|
+
const total = a00 + a01 + a10 + a11 || 1;
|
|
822
|
+
const po = (a00 + a11) / total;
|
|
823
|
+
const pAuto = (a10 + a11) / total,
|
|
824
|
+
pHuman = (a01 + a11) / total;
|
|
825
|
+
const pe = pAuto * pHuman + (1 - pAuto) * (1 - pHuman);
|
|
826
|
+
const kappa = pe < 1 ? (po - pe) / (1 - pe) : 1;
|
|
827
|
+
return {
|
|
828
|
+
gold_n: goldN,
|
|
829
|
+
precision: +precision.toFixed(3),
|
|
830
|
+
recall: +recall.toFixed(3),
|
|
831
|
+
f1: +f1.toFixed(3),
|
|
832
|
+
cohens_kappa: +kappa.toFixed(3),
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ───────────────────────────── example variants ─────────────────────────
|
|
837
|
+
// each mechanism is EXECUTED against pre-f context; it may ONLY return tokens retrievable
|
|
838
|
+
// from the truncated transcript state — it can never hand-supply the resolving delta.
|
|
839
|
+
|
|
840
|
+
// V1: fire at thrash ONSET — the moment the last two prompts form a same-target re-engagement.
|
|
841
|
+
const V1_onset = {
|
|
842
|
+
name: "V1 fire-at-onset (last-2 re-engagement)",
|
|
843
|
+
trigger: (ctx) => {
|
|
844
|
+
const n = ctx.prompts.length;
|
|
845
|
+
if (n < 2) return false;
|
|
846
|
+
const a = ctx.vecs[n - 2],
|
|
847
|
+
b = ctx.vecs[n - 1];
|
|
848
|
+
const ta = ctx.tgts[n - 2],
|
|
849
|
+
tb = ctx.tgts[n - 1];
|
|
850
|
+
const sim = cosine(a, b);
|
|
851
|
+
const jac = jaccard(ta, tb);
|
|
852
|
+
const attempted =
|
|
853
|
+
ctx.prompts[n - 1].attemptBefore.tool || ctx.prompts[n - 1].attemptBefore.hadText;
|
|
854
|
+
const corrective =
|
|
855
|
+
hasCue(ctx.prompts[n - 1].text, CORRECTIVE_CUES) ||
|
|
856
|
+
sim >= DEFAULTS.tau_dup ||
|
|
857
|
+
jac >= DEFAULTS.tau_tgt;
|
|
858
|
+
const accept =
|
|
859
|
+
hasCue(ctx.prompts[n - 1].text, ACCEPT_CUES) &&
|
|
860
|
+
!hasCue(ctx.prompts[n - 1].text, CORRECTIVE_CUES);
|
|
861
|
+
return (
|
|
862
|
+
attempted && (sim >= DEFAULTS.tau_sim || jac >= DEFAULTS.tau_tgt) && corrective && !accept
|
|
863
|
+
);
|
|
864
|
+
},
|
|
865
|
+
// mechanism: re-surface the high-IDF context of the ORIGINAL ask + all prior on-target messages
|
|
866
|
+
mechanism: (ctx) => {
|
|
867
|
+
const out = new Set();
|
|
868
|
+
for (const p of ctx.prompts) {
|
|
869
|
+
const tg = targetTuple(p.text, ctx.idf);
|
|
870
|
+
if (jaccard(tg, ctx.target) > 0 || cosineTargets(tg, ctx.target)) {
|
|
871
|
+
for (const t of tokenize(p.text)) out.add(t);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return out;
|
|
875
|
+
},
|
|
876
|
+
};
|
|
877
|
+
function cosineTargets(a, b) {
|
|
878
|
+
for (const t of a) if (b.has(t)) return true;
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// V2: fire on ANY dissatisfaction cue in the latest prompt (looser — more false fires expected).
|
|
883
|
+
const V2_cue = {
|
|
884
|
+
name: "V2 fire-on-any-dissatisfaction-cue",
|
|
885
|
+
trigger: (ctx) => {
|
|
886
|
+
const n = ctx.prompts.length;
|
|
887
|
+
if (n < 1) return false;
|
|
888
|
+
return hasCue(ctx.prompts[n - 1].text, CORRECTIVE_CUES);
|
|
889
|
+
},
|
|
890
|
+
mechanism: V1_onset.mechanism,
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
// V3: fire on repeated target regardless of cue (target Jaccard high vs previous).
|
|
894
|
+
const V3_repeat = {
|
|
895
|
+
name: "V3 fire-on-repeated-target (no cue required)",
|
|
896
|
+
trigger: (ctx) => {
|
|
897
|
+
const n = ctx.prompts.length;
|
|
898
|
+
if (n < 2) return false;
|
|
899
|
+
return jaccard(ctx.tgts[n - 2], ctx.tgts[n - 1]) >= DEFAULTS.tau_tgt;
|
|
900
|
+
},
|
|
901
|
+
mechanism: V1_onset.mechanism,
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
// reference posts
|
|
905
|
+
const FIRE_ALWAYS = { name: "REF fire-always", trigger: () => true, mechanism: V1_onset.mechanism };
|
|
906
|
+
const FIRE_NEVER = { name: "REF fire-never", trigger: () => false, mechanism: () => new Set() };
|
|
907
|
+
|
|
908
|
+
// ───────────────────────────── self-tests (synthetic, prove the machinery) ───────────────
|
|
909
|
+
function mkUser(text, ts) {
|
|
910
|
+
return {
|
|
911
|
+
type: "user",
|
|
912
|
+
message: { content: text },
|
|
913
|
+
timestamp: new Date(ts).toISOString(),
|
|
914
|
+
uuid: "u" + ts,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
function mkTool(ts) {
|
|
918
|
+
return {
|
|
919
|
+
type: "assistant",
|
|
920
|
+
message: { content: [{ type: "tool_use", name: "Bash", input: {} }] },
|
|
921
|
+
timestamp: new Date(ts).toISOString(),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function mkToolResult(ts) {
|
|
925
|
+
return {
|
|
926
|
+
type: "user",
|
|
927
|
+
message: { content: [{ type: "tool_result", content: "ok" }] },
|
|
928
|
+
timestamp: new Date(ts).toISOString(),
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export function runSelfTests() {
|
|
933
|
+
const assert = (cond, msg) => {
|
|
934
|
+
if (!cond) throw new Error("SELFTEST FAIL: " + msg);
|
|
935
|
+
console.log(" ok:", msg);
|
|
936
|
+
};
|
|
937
|
+
let t = Date.now();
|
|
938
|
+
const step = () => (t += 60000);
|
|
939
|
+
|
|
940
|
+
// TEST 1 — noise filter: tool_result, slash-command, task-notification, skalpel block all dropped.
|
|
941
|
+
const noisy = [
|
|
942
|
+
mkUser("<local-command-stdout>build output</local-command-stdout>", step()),
|
|
943
|
+
mkToolResult(step()),
|
|
944
|
+
mkUser("[skalpel — signals for THIS turn ...] you are stuck", step()),
|
|
945
|
+
mkUser("<task-notification>done</task-notification>", step()),
|
|
946
|
+
mkUser("fix the tokenizer in parser.rs please", step()),
|
|
947
|
+
];
|
|
948
|
+
const stream = extractStream(noisy);
|
|
949
|
+
assert(
|
|
950
|
+
stream.length === 1,
|
|
951
|
+
"noise filter keeps only the 1 genuine typed prompt (got " + stream.length + ")",
|
|
952
|
+
);
|
|
953
|
+
assert(stream[0].text.startsWith("fix the tokenizer"), "the kept prompt is the real one");
|
|
954
|
+
|
|
955
|
+
// TEST 2 — episode detection: two same-target prompts with an assistant attempt between => 1 episode.
|
|
956
|
+
const thrash = [
|
|
957
|
+
mkUser("the auth middleware in server.rs rejects valid tokens, fix it", step()),
|
|
958
|
+
mkTool(step()),
|
|
959
|
+
mkUser(
|
|
960
|
+
"no that is still wrong, the auth middleware in server.rs still rejects valid tokens",
|
|
961
|
+
step(),
|
|
962
|
+
),
|
|
963
|
+
];
|
|
964
|
+
const lab2 = labelThrashEpisodes(thrash);
|
|
965
|
+
assert(
|
|
966
|
+
lab2.episodes.length === 1,
|
|
967
|
+
"detects exactly 1 thrash episode (got " + lab2.episodes.length + ")",
|
|
968
|
+
);
|
|
969
|
+
assert(lab2.episodes[0].n_reengagements === 1, "episode has 1 re-engagement");
|
|
970
|
+
|
|
971
|
+
// TEST 3 — proxy SUPPLY-MATCH fires when the resolving delta existed in pre-fire state.
|
|
972
|
+
// u0 (on-target, NOT a member) establishes rare tokens `zephyrflag quasarpath`. The thrash
|
|
973
|
+
// members omit them; the escape prompt restates them. A pre-f mechanism resurfaces them => match.
|
|
974
|
+
const proxyEntries = [
|
|
975
|
+
mkUser("configure the deploy with zephyrflag quasarpath for the widget renderer", step()),
|
|
976
|
+
mkTool(step()),
|
|
977
|
+
mkUser("something unrelated about billing invoices entirely different topic here", step()),
|
|
978
|
+
mkTool(step()),
|
|
979
|
+
mkUser("the widget renderer is broken again, fix the widget renderer now", step()),
|
|
980
|
+
mkTool(step()),
|
|
981
|
+
mkUser("still broken, the widget renderer widget renderer is wrong again", step()),
|
|
982
|
+
mkTool(step()),
|
|
983
|
+
mkUser("oh the fix was zephyrflag quasarpath on the renderer", step()),
|
|
984
|
+
mkTool(step()),
|
|
985
|
+
mkUser("great, now onto a totally separate authentication concern", step()),
|
|
986
|
+
];
|
|
987
|
+
const labP = labelThrashEpisodes(proxyEntries);
|
|
988
|
+
const corpus = [
|
|
989
|
+
{ file: "synthetic", labeled: labP, steered: false, recurrence: recurrenceRate(labP) },
|
|
990
|
+
];
|
|
991
|
+
const scored = scoreVariantCached(V3_repeat, corpus, {});
|
|
992
|
+
assert(
|
|
993
|
+
labP.episodes.length >= 1,
|
|
994
|
+
"proxy test builds >=1 episode (got " + labP.episodes.length + ")",
|
|
995
|
+
);
|
|
996
|
+
assert(
|
|
997
|
+
scored.counterfactual_proxy.supply_matches >= 1,
|
|
998
|
+
"proxy SUPPLY-MATCH fires on resurfacable resolving delta (got " +
|
|
999
|
+
scored.counterfactual_proxy.supply_matches +
|
|
1000
|
+
") — proves proxy is not dead-always-zero",
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
// TEST 4 — causal firewall: trigger context never contains the future prompt.
|
|
1004
|
+
let leaked = false;
|
|
1005
|
+
const spyVariant = {
|
|
1006
|
+
name: "spy",
|
|
1007
|
+
trigger: (ctx) => {
|
|
1008
|
+
// ctx.prompts must be exactly prefix through f; length == f+1, last index == f
|
|
1009
|
+
if (ctx.prompts.length !== ctx.f + 1) leaked = true;
|
|
1010
|
+
return false;
|
|
1011
|
+
},
|
|
1012
|
+
mechanism: () => new Set(),
|
|
1013
|
+
};
|
|
1014
|
+
evalTriggerOnSession(spyVariant, lab2);
|
|
1015
|
+
assert(!leaked, "strict causal truncation: trigger only ever sees prompts[0..f], never u_{f+1}");
|
|
1016
|
+
|
|
1017
|
+
console.log(
|
|
1018
|
+
"\nALL SELF-TESTS PASSED — filter, episode detection, proxy supply-match, causal firewall verified.",
|
|
1019
|
+
);
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ───────────────────────────── CLI ─────────────────────────
|
|
1024
|
+
function fmt(obj) {
|
|
1025
|
+
return JSON.stringify(obj, null, 2);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function main() {
|
|
1029
|
+
const args = process.argv.slice(2);
|
|
1030
|
+
if (!args.length) {
|
|
1031
|
+
console.error(
|
|
1032
|
+
"usage: node eval-harness.mjs <transcriptsDir> [--max-sessions N] [--gold gold.json]",
|
|
1033
|
+
);
|
|
1034
|
+
console.error(" node eval-harness.mjs --selftest (run synthetic machinery checks)");
|
|
1035
|
+
process.exit(1);
|
|
1036
|
+
}
|
|
1037
|
+
if (args[0] === "--selftest") {
|
|
1038
|
+
runSelfTests();
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const dir = args[0];
|
|
1042
|
+
const maxIdx = args.indexOf("--max-sessions");
|
|
1043
|
+
const goldIdx = args.indexOf("--gold");
|
|
1044
|
+
const maxSessions = maxIdx >= 0 ? parseInt(args[maxIdx + 1], 10) : undefined;
|
|
1045
|
+
const goldPath = goldIdx >= 0 ? args[goldIdx + 1] : null;
|
|
1046
|
+
const cfg = { ...DEFAULTS, maxSessions };
|
|
1047
|
+
|
|
1048
|
+
const t0 = Date.now();
|
|
1049
|
+
console.log("═".repeat(90));
|
|
1050
|
+
console.log("SKALPEL INTERVENTION EVAL HARNESS — offline FILTER/RANK, NOT lift");
|
|
1051
|
+
console.log("═".repeat(90));
|
|
1052
|
+
console.log(
|
|
1053
|
+
"HONESTY BANNER: TRIGGER metrics + COUNTERFACTUAL PROXY are OFFLINE filter/rank signals.\n" +
|
|
1054
|
+
" They are NOT lift — firing changes the future; the transcript is off-policy. needs_live_ab = TRUE.\n" +
|
|
1055
|
+
" Every number below traces to a real transcript event. Proxy is labelled 'proxy (needs live A/B)'.",
|
|
1056
|
+
);
|
|
1057
|
+
console.log(
|
|
1058
|
+
"Transcripts dir:",
|
|
1059
|
+
dir,
|
|
1060
|
+
maxSessions ? `(capped to ${maxSessions} sessions)` : "(all sessions)",
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
// calibration
|
|
1064
|
+
console.log("\n" + "─".repeat(90));
|
|
1065
|
+
console.log("AUTO-LABELER CALIBRATION vs HUMAN GOLD");
|
|
1066
|
+
console.log("─".repeat(90));
|
|
1067
|
+
let cal = null;
|
|
1068
|
+
if (goldPath) cal = calibrate(goldPath, dir, cfg);
|
|
1069
|
+
if (!cal) {
|
|
1070
|
+
console.log(
|
|
1071
|
+
"UNCALIBRATED: no human gold set supplied (--gold). Auto-labels are NOT ground truth.\n" +
|
|
1072
|
+
" Downstream trigger/proxy numbers are UNTRUSTWORTHY as absolutes — use for RELATIVE variant\n" +
|
|
1073
|
+
" ranking only, and calibrate before any decision. (gold format: [{file, onset_indices:[...]}, ...])",
|
|
1074
|
+
);
|
|
1075
|
+
} else {
|
|
1076
|
+
console.log(fmt(cal));
|
|
1077
|
+
if (cal.cohens_kappa < DEFAULTS.kappa_min)
|
|
1078
|
+
console.log(
|
|
1079
|
+
`\n*** UNCALIBRATED: kappa ${cal.cohens_kappa} < ${DEFAULTS.kappa_min} — labels untrustworthy. ***`,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ONE disk pass: load + label the whole corpus, then score every variant from cache.
|
|
1084
|
+
const corpus = labelCorpus(dir, cfg);
|
|
1085
|
+
|
|
1086
|
+
// baseline labeling + reference recurrence
|
|
1087
|
+
console.log("\n" + "─".repeat(90));
|
|
1088
|
+
console.log(
|
|
1089
|
+
"BASELINE: thrash episodes labelled across the REAL transcripts + reference recurrence",
|
|
1090
|
+
);
|
|
1091
|
+
console.log("─".repeat(90));
|
|
1092
|
+
const baseline = scoreVariantCached(FIRE_NEVER, corpus, cfg);
|
|
1093
|
+
console.log("Corpus counts:", fmt(baseline.corpus));
|
|
1094
|
+
console.log("Reference recurrence (number to beat):", fmt(baseline.recurrence_baseline));
|
|
1095
|
+
console.log("Labeler thresholds (UNCALIBRATED defaults):", fmt(baseline.thresholds));
|
|
1096
|
+
|
|
1097
|
+
// variants
|
|
1098
|
+
const variants = [V1_onset, V2_cue, V3_repeat, FIRE_ALWAYS, FIRE_NEVER];
|
|
1099
|
+
console.log("\n" + "─".repeat(90));
|
|
1100
|
+
console.log(
|
|
1101
|
+
"VARIANT SCORES (relative ranking — reference posts fire-always/fire-never included)",
|
|
1102
|
+
);
|
|
1103
|
+
console.log("─".repeat(90));
|
|
1104
|
+
for (const v of variants) {
|
|
1105
|
+
const s = scoreVariantCached(v, corpus, cfg);
|
|
1106
|
+
console.log("\n### " + v.name);
|
|
1107
|
+
console.log(" trigger:", fmt(s.trigger).replace(/\n/g, "\n "));
|
|
1108
|
+
console.log(" counterfactual_proxy:", fmt(s.counterfactual_proxy).replace(/\n/g, "\n "));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
console.log("\n" + "═".repeat(90));
|
|
1112
|
+
console.log(`Runtime: ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
1113
|
+
console.log(
|
|
1114
|
+
"VERDICT POLICY: no 'winner' is declared here. A proxy/trigger lead only FILTERS+RANKS variants;\n" +
|
|
1115
|
+
" a variant must clear a LIVE A/B before any lift claim. needs_live_ab = TRUE for every variant.",
|
|
1116
|
+
);
|
|
1117
|
+
console.log("═".repeat(90));
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const isMain =
|
|
1121
|
+
process.argv[1] &&
|
|
1122
|
+
path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
|
|
1123
|
+
if (isMain) main();
|
|
1124
|
+
|
|
1125
|
+
export { V1_onset, V2_cue, V3_repeat, FIRE_ALWAYS, FIRE_NEVER, calibrate };
|