skalpel 4.0.50 → 4.0.51
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/efficacy-eval.mjs +304 -0
- package/package.json +1 -1
package/efficacy-eval.mjs
CHANGED
|
@@ -390,6 +390,276 @@ export function classifyReask(text) {
|
|
|
390
390
|
return "none";
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
// ---------- DRIFT-LOOP detector (a repeated SAME-target correction chain — the loop a dev FEELS) ----------
|
|
394
|
+
// PRECISION IS THE POINT: this must NEVER falsely accuse. Three stacked false-positive guards below —
|
|
395
|
+
// (1) the strong-tier + SOFT-subtraction + anaphora ANCHOR gate, (2) a concrete-noun target the USER
|
|
396
|
+
// themselves named AND that appears in the claim, (3) a per-session verdict gate that SUPPRESSES a run
|
|
397
|
+
// where every member was a genuine true-done (that is scope-growth / flakiness, not drift). Pure + offline.
|
|
398
|
+
//
|
|
399
|
+
// ANCHOR_RE — an ANAPHORA / PRIOR-CLAIM reference. A correction that points BACK at the last "done"
|
|
400
|
+
// ("still", "again", "you didn't…", "that's wrong", "same error", "doesn't work", "nothing changed",
|
|
401
|
+
// "i don't see…"). A bare domain-vocab turn ("handle the login-fail case in login.ts") has NONE of these
|
|
402
|
+
// and is therefore NOT drift-eligible — it is a fresh ask, not a push-back on a prior claim.
|
|
403
|
+
const ANCHOR_RE = new RegExp(
|
|
404
|
+
[
|
|
405
|
+
"\\bstill\\b",
|
|
406
|
+
"\\bagain\\b",
|
|
407
|
+
"\\b(?:re-?do|redo|undo|revert|roll\\s?back|rollback|re-?open|reopen)\\b",
|
|
408
|
+
"\\byou\\s+(?:did\\s?n'?t|didnt|did\\s+not|broke|missed|removed|deleted|forgot)\\b",
|
|
409
|
+
"\\b(?:that|this|it)(?:'?s|\\s+is|\\s+isn'?t|\\s+still|\\s+broke|\\s+breaks)\\b",
|
|
410
|
+
"\\bsame\\s+(?:error|issue|problem|thing|result|bug|failure)\\b",
|
|
411
|
+
"\\bnot\\s+(?:fixed|working|done|right|correct|quite|what)\\b",
|
|
412
|
+
"\\bdoes\\s?n'?t\\s+work\\b",
|
|
413
|
+
"\\bdo\\s?n'?t\\s+work\\b",
|
|
414
|
+
"\\bdid\\s?n'?t\\s+work\\b",
|
|
415
|
+
"\\bnot\\s+work(?:ing)?\\b",
|
|
416
|
+
"\\bthat'?s\\s+(?:wrong|not\\s+right|incorrect|not\\s+it)\\b",
|
|
417
|
+
"\\bthis\\s+is\\s+(?:wrong|broken|not\\s+right)\\b",
|
|
418
|
+
"\\bit'?s\\s+(?:still\\s+)?(?:broken|failing|wrong|not\\s+working)\\b",
|
|
419
|
+
"\\bnot\\s+what\\s+i\\s+(?:meant|wanted|asked|said|had\\s+in\\s+mind)\\b",
|
|
420
|
+
"\\bi\\s+hate\\b",
|
|
421
|
+
"\\bvibe[\\s-]?coded\\b",
|
|
422
|
+
"\\b(?:ai\\s+slop|slop)\\b",
|
|
423
|
+
"\\bnothing\\s+chang(?:ed|es)\\b",
|
|
424
|
+
"\\bdid\\s?n'?t\\s+chang(?:e|ed)\\b",
|
|
425
|
+
"\\bno\\s+chang(?:e|ed)\\b",
|
|
426
|
+
"\\bi\\s+(?:do\\s?n'?t|don'?t)\\s+see\\b",
|
|
427
|
+
].join("|"),
|
|
428
|
+
"i",
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
// isDriftEligibleStrong(text) — the single-sourced precision gate. TRUE iff ALL hold:
|
|
432
|
+
// (a) classifyReask(text) === 'strong' (necessary condition — reuse the offline detector)
|
|
433
|
+
// (b) SOFT_CONTINUATION_RE.test(text) === false (SOFT-SUBTRACTION: classifyReask tests STRONG
|
|
434
|
+
// before SOFT, so a scope-add that also carries a strong keyword — "also, the old test was wrong,
|
|
435
|
+
// add another" — leaks in as 'strong'. If SOFT also matches, it is a scope-add → EXCLUDE.)
|
|
436
|
+
// (c) an ANAPHORA / PRIOR-CLAIM anchor is present (ANCHOR_RE).
|
|
437
|
+
export function isDriftEligibleStrong(text) {
|
|
438
|
+
const s = String(text || "");
|
|
439
|
+
if (!s.trim()) return false;
|
|
440
|
+
if (classifyReask(s) !== "strong") return false; // (a)
|
|
441
|
+
if (SOFT_CONTINUATION_RE.test(s)) return false; // (b) SOFT-subtraction
|
|
442
|
+
if (!ANCHOR_RE.test(s)) return false; // (c) anaphora / prior-claim anchor
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Generic project-ubiquitous tokens that are NEVER a distinguishing target (case-insensitive drop).
|
|
447
|
+
const DRIFT_GENERIC_DROP = new Set([
|
|
448
|
+
"npm",
|
|
449
|
+
"run",
|
|
450
|
+
"src",
|
|
451
|
+
"index",
|
|
452
|
+
"main",
|
|
453
|
+
"app",
|
|
454
|
+
"test",
|
|
455
|
+
"tests",
|
|
456
|
+
"build",
|
|
457
|
+
"node",
|
|
458
|
+
"pnpm",
|
|
459
|
+
"yarn",
|
|
460
|
+
"dist",
|
|
461
|
+
"lib",
|
|
462
|
+
"the",
|
|
463
|
+
"fix",
|
|
464
|
+
"add",
|
|
465
|
+
"make",
|
|
466
|
+
"get",
|
|
467
|
+
"set",
|
|
468
|
+
"use",
|
|
469
|
+
"now",
|
|
470
|
+
"also",
|
|
471
|
+
]);
|
|
472
|
+
const DRIFT_CODE_EXT_RE =
|
|
473
|
+
/\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|css|scss|json|md|html|sql|sh|yml|yaml|toml)$/i;
|
|
474
|
+
const DRIFT_IDENT_CHARSET_RE = /^[A-Za-z0-9_.-]+$/;
|
|
475
|
+
|
|
476
|
+
// concreteNouns(text) → unique concrete targets, ORIGINAL CASE preserved (compared case-insensitively).
|
|
477
|
+
// A surviving token MUST be one of: a file path (contains "/" OR a code extension), a URL, a "#<digits>"
|
|
478
|
+
// PR/issue ref, a backtick-/quote-delimited identifier, or a camelCase/snake_case/kebab identifier of
|
|
479
|
+
// length>=3 — never a bare generic lowercase English word. Generic project tokens are then dropped.
|
|
480
|
+
function concreteNouns(text) {
|
|
481
|
+
const s = String(text || "");
|
|
482
|
+
const out = [];
|
|
483
|
+
const seen = new Set(); // lowercase dedup keys
|
|
484
|
+
const consider = (raw, delimited = false) => {
|
|
485
|
+
const tok = String(raw || "").trim();
|
|
486
|
+
if (!tok) return;
|
|
487
|
+
const key = tok.toLowerCase();
|
|
488
|
+
if (DRIFT_GENERIC_DROP.has(key)) return;
|
|
489
|
+
if (seen.has(key)) return;
|
|
490
|
+
const isUrl = /^https?:\/\//i.test(tok);
|
|
491
|
+
const isRef = /^#\d+$/.test(tok);
|
|
492
|
+
const isPath = tok.includes("/") || DRIFT_CODE_EXT_RE.test(tok);
|
|
493
|
+
const isIdent =
|
|
494
|
+
tok.length >= 3 &&
|
|
495
|
+
DRIFT_IDENT_CHARSET_RE.test(tok) &&
|
|
496
|
+
(/[a-z][A-Z]/.test(tok) || tok.includes("_") || tok.includes("-"));
|
|
497
|
+
// A delimited (backtick/quote) single token is an explicit user reference — it qualifies even as a
|
|
498
|
+
// plain lowercase word, but must still be an identifier-ish charset with a letter (never pure prose).
|
|
499
|
+
const isDelimited = delimited && /^[A-Za-z0-9_./#-]{2,}$/.test(tok) && /[A-Za-z]/.test(tok);
|
|
500
|
+
if (!(isUrl || isRef || isPath || isIdent || isDelimited)) return;
|
|
501
|
+
seen.add(key);
|
|
502
|
+
out.push(tok);
|
|
503
|
+
};
|
|
504
|
+
// (1) backtick / single- / double-quote delimited SINGLE tokens (multi-word spans are ignored — an
|
|
505
|
+
// identifier has no internal whitespace, and this avoids apostrophe-span false tokens).
|
|
506
|
+
const delimRe = /`([^`]+)`|'([^']+)'|"([^"]+)"/g;
|
|
507
|
+
let m;
|
|
508
|
+
while ((m = delimRe.exec(s)) !== null) {
|
|
509
|
+
const inner = (m[1] ?? m[2] ?? m[3] ?? "").trim();
|
|
510
|
+
if (!inner || /\s/.test(inner)) continue;
|
|
511
|
+
consider(inner, true);
|
|
512
|
+
}
|
|
513
|
+
// (2) general whitespace scan — strip surrounding punctuation (keep a leading #/ and inner .-_).
|
|
514
|
+
const stripEdges = (w) =>
|
|
515
|
+
String(w || "")
|
|
516
|
+
.replace(/^[^A-Za-z0-9#/]+/, "")
|
|
517
|
+
.replace(/[^A-Za-z0-9/]+$/, "");
|
|
518
|
+
for (const w0 of s.split(/\s+/)) {
|
|
519
|
+
const w = stripEdges(w0);
|
|
520
|
+
if (w) consider(w, false);
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// targetTokens(record) — the concrete things the USER named that ALSO appear in the claim (case-insensitive
|
|
526
|
+
// intersection). Display value is taken VERBATIM from the user's turn (next_user_text). If the user named no
|
|
527
|
+
// surviving concrete target, the record cannot anchor a loop → []. This guarantees every emitted target is a
|
|
528
|
+
// concrete noun the user themselves typed, never one invented from the agent's own claim text.
|
|
529
|
+
function targetTokens(record) {
|
|
530
|
+
const r = record || {};
|
|
531
|
+
const userNouns = concreteNouns(r.next_user_text);
|
|
532
|
+
if (!userNouns.length) return [];
|
|
533
|
+
const claimNouns = concreteNouns((r.claim_text || "") + " " + (r.proof_command || ""));
|
|
534
|
+
const claimKeys = new Set(claimNouns.map((t) => t.toLowerCase()));
|
|
535
|
+
const out = [];
|
|
536
|
+
for (const t of userNouns) {
|
|
537
|
+
if (claimKeys.has(t.toLowerCase())) out.push(t); // userNouns is already unique + user-case
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// detectDriftLoop(records, opts) — per-session, pure, no network. Walks the drift-eligible corrections in
|
|
543
|
+
// each session and links CONSECUTIVE ones that keep re-naming an overlapping concrete target within a time
|
|
544
|
+
// span, then SUPPRESSES any run whose every member was a genuine true-done. Returns loop objects.
|
|
545
|
+
export function detectDriftLoop(records, opts = {}) {
|
|
546
|
+
const minRepeat = Number.isFinite(opts.minRepeat) ? opts.minRepeat : 2;
|
|
547
|
+
const maxSpanMs = Number.isFinite(opts.maxSpanMs) ? opts.maxSpanMs : 30 * 60 * 1000;
|
|
548
|
+
const IDLE_MS = 10 * 60 * 1000;
|
|
549
|
+
if (!Array.isArray(records) || !records.length) return [];
|
|
550
|
+
|
|
551
|
+
// group by .session, stable input order (= session-transcript order)
|
|
552
|
+
const order = [];
|
|
553
|
+
const groups = new Map();
|
|
554
|
+
for (const r of records) {
|
|
555
|
+
const key = r && r.session != null ? String(r.session) : "?";
|
|
556
|
+
if (!groups.has(key)) {
|
|
557
|
+
groups.set(key, []);
|
|
558
|
+
order.push(key);
|
|
559
|
+
}
|
|
560
|
+
groups.get(key).push(r);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const toMap = (tokens) => {
|
|
564
|
+
const mp = new Map();
|
|
565
|
+
for (const t of tokens) {
|
|
566
|
+
const k = t.toLowerCase();
|
|
567
|
+
if (!mp.has(k)) mp.set(k, t); // keep first (earliest user-turn) display case
|
|
568
|
+
}
|
|
569
|
+
return mp;
|
|
570
|
+
};
|
|
571
|
+
const intersect = (runMap, tokens) => {
|
|
572
|
+
const keys = new Set(tokens.map((t) => t.toLowerCase()));
|
|
573
|
+
const mp = new Map();
|
|
574
|
+
for (const [k, disp] of runMap) if (keys.has(k)) mp.set(k, disp);
|
|
575
|
+
return mp;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const loops = [];
|
|
579
|
+
for (const key of order) {
|
|
580
|
+
// (i) eligible list, in order: evaluated + drift-eligible-strong + has a user-named concrete target
|
|
581
|
+
const eligible = [];
|
|
582
|
+
for (const r of groups.get(key)) {
|
|
583
|
+
if (r && r.reask_status === "evaluated" && isDriftEligibleStrong(r.next_user_text)) {
|
|
584
|
+
const tokens = targetTokens(r);
|
|
585
|
+
if (tokens.length > 0) eligible.push({ r, tokens });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (!eligible.length) continue;
|
|
589
|
+
|
|
590
|
+
// (ii) build runs — extend IFF target overlap AND (span + per-step gap) within window (time checks
|
|
591
|
+
// skipped only when a ts is missing; a run with any missing ts yields elapsed_ms=null downstream).
|
|
592
|
+
const runs = [];
|
|
593
|
+
let run = null;
|
|
594
|
+
for (const e of eligible) {
|
|
595
|
+
if (!run) {
|
|
596
|
+
run = { members: [e], targets: toMap(e.tokens) };
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const inter = intersect(run.targets, e.tokens);
|
|
600
|
+
let extend = inter.size > 0;
|
|
601
|
+
if (extend) {
|
|
602
|
+
const tFirst = Date.parse(run.members[0].r.claim_ts);
|
|
603
|
+
const tPrev = Date.parse(run.members[run.members.length - 1].r.claim_ts);
|
|
604
|
+
const tNext = Date.parse(e.r.claim_ts);
|
|
605
|
+
if (Number.isFinite(tFirst) && Number.isFinite(tPrev) && Number.isFinite(tNext)) {
|
|
606
|
+
if (!(tNext - tFirst <= maxSpanMs && tNext - tPrev <= maxSpanMs)) extend = false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (extend) {
|
|
610
|
+
run.members.push(e);
|
|
611
|
+
run.targets = inter;
|
|
612
|
+
} else {
|
|
613
|
+
runs.push(run);
|
|
614
|
+
run = { members: [e], targets: toMap(e.tokens) };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (run) runs.push(run);
|
|
618
|
+
|
|
619
|
+
// (iii) verdict gate + emit
|
|
620
|
+
for (const rn of runs) {
|
|
621
|
+
const repeat = rn.members.length;
|
|
622
|
+
if (repeat < minRepeat) continue;
|
|
623
|
+
const verdicts = rn.members.map((mm) => mm.r.verdict);
|
|
624
|
+
if (verdicts.every((v) => v === "true-done")) continue; // SUPPRESS: scope-growth/flakiness, not drift
|
|
625
|
+
|
|
626
|
+
const first_ts = rn.members[0].r.claim_ts ?? null;
|
|
627
|
+
const last_ts = rn.members[repeat - 1].r.claim_ts ?? null;
|
|
628
|
+
const fp = Date.parse(first_ts);
|
|
629
|
+
const lp = Date.parse(last_ts);
|
|
630
|
+
const elapsed_ms = Number.isFinite(fp) && Number.isFinite(lp) ? lp - fp : null;
|
|
631
|
+
|
|
632
|
+
// largest single inter-member claim_ts gap (only over pairs with both ts present)
|
|
633
|
+
let maxGap = -1;
|
|
634
|
+
for (let j = 1; j < repeat; j++) {
|
|
635
|
+
const a = Date.parse(rn.members[j - 1].r.claim_ts);
|
|
636
|
+
const b = Date.parse(rn.members[j].r.claim_ts);
|
|
637
|
+
if (Number.isFinite(a) && Number.isFinite(b)) maxGap = Math.max(maxGap, b - a);
|
|
638
|
+
}
|
|
639
|
+
const idle_suspect = maxGap > maxSpanMs || maxGap > IDLE_MS;
|
|
640
|
+
|
|
641
|
+
// stable target: longest, then lexicographically-first (verbatim user-turn case)
|
|
642
|
+
const targets = [...rn.targets.values()].sort(
|
|
643
|
+
(a, b) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0),
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
loops.push({
|
|
647
|
+
session: key === "?" ? null : key,
|
|
648
|
+
target: targets[0],
|
|
649
|
+
repeat,
|
|
650
|
+
first_ts,
|
|
651
|
+
last_ts,
|
|
652
|
+
elapsed_ms,
|
|
653
|
+
idle_suspect,
|
|
654
|
+
members_verdicts: verdicts,
|
|
655
|
+
next_user_texts: rn.members.map((mm) => String(mm.r.next_user_text || "").slice(0, 300)),
|
|
656
|
+
turn_targets: rn.members.map((mm) => mm.tokens.slice()),
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return loops;
|
|
661
|
+
}
|
|
662
|
+
|
|
393
663
|
// ---------- (3) scan ONE session → per-claim records + the session's PASS proof-runs ----------
|
|
394
664
|
// A record's verdict, deduped by proof tool_use id (identical to scanSession), is:
|
|
395
665
|
// 'confirmed-lie' — claim over a proof whose recorded result classifies GENUINE_FAIL (a real lie)
|
|
@@ -751,6 +1021,8 @@ function recId(r) {
|
|
|
751
1021
|
// reported). The strong rate is the conservative headline; the soft (scope-add) tier is reported apart.
|
|
752
1022
|
export function computeReaskReport(corpus) {
|
|
753
1023
|
const recs = corpus.records || [];
|
|
1024
|
+
// DRIFT LOOPS — repeated same-target STRONG corrections in a session (pure, from the same records).
|
|
1025
|
+
const driftLoops = detectDriftLoop(recs);
|
|
754
1026
|
const totalDone = recs.length;
|
|
755
1027
|
const evaluated = recs.filter((r) => r.reask_status === "evaluated");
|
|
756
1028
|
const superseded = recs.filter((r) => r.reask_status === "superseded").length;
|
|
@@ -837,6 +1109,9 @@ export function computeReaskReport(corpus) {
|
|
|
837
1109
|
passing_proof_reask_rate_strong: passingProof.reask_rate_strong,
|
|
838
1110
|
passing_proof_reask_rate_any: passingProof.reask_rate_any,
|
|
839
1111
|
by_verdict: byVerdict,
|
|
1112
|
+
// DRIFT LOOPS — repeated same-target correction chains (deterministic; identical to detectDriftLoop(recs)).
|
|
1113
|
+
drift_loops: driftLoops,
|
|
1114
|
+
drift_loop_count: driftLoops.length,
|
|
840
1115
|
samples,
|
|
841
1116
|
};
|
|
842
1117
|
}
|
|
@@ -1216,6 +1491,35 @@ export function renderReaskReport(reask, frequency = {}) {
|
|
|
1216
1491
|
});
|
|
1217
1492
|
}
|
|
1218
1493
|
|
|
1494
|
+
// ---- DRIFT LOOPS (repeated same-target corrections — the loop you FEEL) ----
|
|
1495
|
+
L.push("");
|
|
1496
|
+
L.push(" 5) DRIFT LOOPS — repeated STRONG corrections about the SAME concrete target you named");
|
|
1497
|
+
L.push(
|
|
1498
|
+
" (a same-file/-symbol re-ask chain; every noun below is re-derived from YOUR own turns)",
|
|
1499
|
+
);
|
|
1500
|
+
L.push(" " + "·".repeat(74));
|
|
1501
|
+
const loops = Array.isArray(R.drift_loops) ? R.drift_loops : [];
|
|
1502
|
+
if ((R.drift_loop_count || 0) === 0) {
|
|
1503
|
+
L.push(" no repeated same-target correction loops found.");
|
|
1504
|
+
} else {
|
|
1505
|
+
L.push(` ${R.drift_loop_count} drift loop(s) found (showing up to 5):`);
|
|
1506
|
+
loops.slice(0, 5).forEach((lp, i) => {
|
|
1507
|
+
// Neutral wall-clock duration ONLY when we have two real timestamps, the run is not idle-suspect,
|
|
1508
|
+
// and the span is at least a minute. NEVER "wasted/burned" — it is a plain elapsed delta.
|
|
1509
|
+
let dstr = "";
|
|
1510
|
+
if (lp.elapsed_ms != null && !lp.idle_suspect && lp.elapsed_ms >= 60000) {
|
|
1511
|
+
const d = formatDuration(lp.elapsed_ms);
|
|
1512
|
+
if (d) dstr = ` · ${d} wall-clock between first and last correction`;
|
|
1513
|
+
}
|
|
1514
|
+
L.push(
|
|
1515
|
+
` ${i + 1}. \`${clip(lp.target, 48)}\` — corrected ${lp.repeat}× in this session${dstr}`,
|
|
1516
|
+
);
|
|
1517
|
+
(lp.next_user_texts || []).forEach((t) => {
|
|
1518
|
+
L.push(` you: "${clip(t, 66)}"`);
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1219
1523
|
L.push(" " + "─".repeat(76));
|
|
1220
1524
|
L.push(" Read-only replay. --reask --json for the machine-readable re-ask numbers.");
|
|
1221
1525
|
L.push("");
|