Skip to content

Commit 3f42811

Browse files
tools: verdict-ab — per-request classification verdicts for two trees, diffed
Graduates the throwaway A/B script of the unit-2b build (closing report 2026-07-30, "Corpus A/B — nothing else moved") because the reserved-entry identity build needed it a second time: a probe used twice graduates or dies (dev-loop). It answers a question replay.mjs is single-tree by construction and cannot ask — does CHANGING the code change any decision it takes on the committed corpus — by holding two extension modules resident at once and diffing the verdict line (action, reset reason, pinned, suppressed, moved, dropped, forwarded length) per request. Two modes, two different questions. Independent chains asks whether steady-state behaviour moved. `--seed-from-a` feeds tree B, at every request, the canonical tree A wrote for the preceding one — the OLD-CANON COMPATIBILITY probe, i.e. whether a restart is transparent for conversations already in flight, which is threat-matrix row 3's question and previously answerable only by argument. Three things carried over from the lessons that produced it. It exits 2 with COULD NOT VERIFY when no fixture yields a replayable request, because the first version of the unit-2b probe printed "IDENTICAL" over two empty dumps after crashing on both trees — demonstrated red here against an empty fixture directory, not asserted. It reads all THREE committed fixture shapes (`{requests}`, `{header, records}`, `.jsonl` capture records) and names every file it skipped, because the first draft of the reader silently saw 2 of the 6 message-array corpora and would have reported a 9-line "IDENTICAL" as a 44-line one. And it groups by the extension's OWN `resolveInsertionSessionKey` rather than chaining one canonical per file — the pinned fixture alone carries six conversations, and comparing across them is the hand-rolled-identity error this repo has paid for four times. Trees are given as git refs (checked out detached into a scratch worktree and removed afterwards) or as directories; the shared working tree is never used as a scratch checkout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit da8b837)
1 parent 5549d46 commit 3f42811

1 file changed

Lines changed: 256 additions & 0 deletions

File tree

tools/verdict-ab.mjs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
#!/usr/bin/env node
2+
// verdict-ab — per-request classification verdicts for TWO trees, diffed.
3+
//
4+
// Why a separate tool rather than a mode of replay.mjs (dev-loop: "extend an
5+
// existing tool before writing a new one"): replay.mjs is single-tree by
6+
// construction — it imports the extension it replays, and its whole gate
7+
// vocabulary is about one pipeline against recorded traffic. The question here
8+
// is different in kind: does CHANGING the code change any decision it takes on
9+
// the committed corpus? That needs two extension modules resident at once,
10+
// which is a harness concern, not a gate concern.
11+
//
12+
// It started as the throwaway A/B script of the unit-2b build (closing report
13+
// 2026-07-30, "Corpus A/B — nothing else moved") and is committed here because
14+
// it was needed a second time, by the reserved-entry-identity build — the
15+
// dev-loop rule that a probe used twice graduates or dies.
16+
//
17+
// THREE ANSWERS, not two (dev-loop). The first version of the unit-2b probe
18+
// printed "IDENTICAL" over two EMPTY dumps after crashing on both trees: an
19+
// absence of evidence wearing a verdict's clothes. So an empty corpus, or a
20+
// corpus in which no fixture yields a replayable request, exits 2 with
21+
// COULD-NOT-VERIFY and never 0.
22+
//
23+
// node tools/verdict-ab.mjs <treeA> <treeB> [options]
24+
//
25+
// <treeA> <treeB> a git ref (checked out DETACHED into a scratch
26+
// worktree, removed afterwards) or an existing directory
27+
// holding a tree. Never the shared working tree.
28+
// --seed-from-a feed tree B, at every request, the canonical tree A
29+
// wrote for the preceding request. This is the
30+
// OLD-CANON COMPATIBILITY probe: it asks whether the new
31+
// code takes the same decision the old code did when it
32+
// starts from state the old code produced — i.e. whether
33+
// a restart is transparent for conversations already in
34+
// flight. Without it, each tree runs its own chain, which
35+
// asks the different (and also useful) question of
36+
// whether steady-state behaviour moved.
37+
// --fixtures <dir> fixture corpus directory
38+
// (default: <this repo>/test/fixtures/harvested)
39+
// --scratch <dir> where scratch worktrees are created
40+
// (default: $TMPDIR/verdict-ab-<pid>)
41+
// --verbose print every verdict line, not only the differing ones
42+
//
43+
// exit 0 every verdict line identical
44+
// exit 1 at least one differs (the diff is printed)
45+
// exit 2 COULD NOT VERIFY — nothing replayable was found, or a tree failed
46+
// to load. Never reported as a pass.
47+
48+
import { execFileSync } from "node:child_process";
49+
import { existsSync, readdirSync, readFileSync, mkdirSync, rmSync } from "node:fs";
50+
import { dirname, join, resolve } from "node:path";
51+
import { fileURLToPath, pathToFileURL } from "node:url";
52+
import { tmpdir } from "node:os";
53+
54+
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
55+
const EXT = "proxy/extensions/insertion-normalization.mjs";
56+
57+
function parseArgs(argv) {
58+
const positional = [];
59+
const opts = { seedFromA: false, verbose: false, fixtures: null, scratch: null };
60+
for (let i = 0; i < argv.length; i++) {
61+
const a = argv[i];
62+
if (a === "--seed-from-a") opts.seedFromA = true;
63+
else if (a === "--verbose") opts.verbose = true;
64+
else if (a === "--fixtures") opts.fixtures = argv[++i];
65+
else if (a === "--scratch") opts.scratch = argv[++i];
66+
else if (a.startsWith("--")) fail(`unknown option ${a}`);
67+
else positional.push(a);
68+
}
69+
if (positional.length !== 2) fail("need exactly two trees: <treeA> <treeB>");
70+
return { a: positional[0], b: positional[1], ...opts };
71+
}
72+
73+
function fail(msg) {
74+
console.error(`verdict-ab: ${msg}`);
75+
process.exit(2);
76+
}
77+
78+
// A tree argument is either a directory that already holds the extension, or a
79+
// git ref to check out detached. The shared working tree is never used as a
80+
// scratch checkout: `git worktree add` refuses to reuse it, and a swap under a
81+
// live working copy is the mistake the unit-2b report called out by name.
82+
function resolveTree(spec, scratchRoot, created) {
83+
const asDir = resolve(spec);
84+
if (existsSync(join(asDir, EXT))) return { dir: asDir, label: spec };
85+
let sha;
86+
try {
87+
sha = execFileSync("git", ["-C", REPO, "rev-parse", "--verify", `${spec}^{commit}`], {
88+
encoding: "utf-8",
89+
}).trim();
90+
} catch {
91+
fail(`"${spec}" is neither a directory holding ${EXT} nor a git ref in ${REPO}`);
92+
}
93+
const dir = join(scratchRoot, sha.slice(0, 12));
94+
if (!existsSync(dir)) {
95+
execFileSync("git", ["-C", REPO, "worktree", "add", "--detach", dir, sha], { stdio: "pipe" });
96+
created.push(dir);
97+
}
98+
if (!existsSync(join(dir, EXT))) fail(`${spec} (${sha.slice(0, 12)}) has no ${EXT}`);
99+
return { dir, label: `${spec} (${sha.slice(0, 8)})` };
100+
}
101+
102+
// The committed corpus carries three shapes and all three are read, because a
103+
// corpus silently narrowed to the shape the reader happens to parse is the
104+
// blindness dev-loop names ("whatever a corpus is curated for, every other
105+
// property is where it is blind") — and the first version of this reader saw
106+
// 2 of the 6 message-array fixtures.
107+
//
108+
// { requests: [{ n, ts, messages }] } pre-grouped request-range fixtures
109+
// (flap, reset-move)
110+
// { header, records: [captureRecord] } pinned-range fixtures
111+
// *.jsonl of captureRecords harvested pair fixtures
112+
//
113+
// A capture record is { ts, sid, key, headers, body:{ messages, system } }.
114+
// A fixture that carries no message array at all (the growth snapshots, the
115+
// oscillation fixture) yields nothing and is REPORTED as skipped — never
116+
// silently counted as clean.
117+
function requestsFromRecords(records) {
118+
const out = [];
119+
for (let i = 0; i < records.length; i++) {
120+
const r = records[i];
121+
if (!Array.isArray(r?.body?.messages)) continue;
122+
out.push({ n: i, messages: r.body.messages, headers: r.headers, system: r.body.system });
123+
}
124+
return out;
125+
}
126+
127+
function loadCorpora(dir) {
128+
if (!existsSync(dir)) fail(`fixture directory ${dir} does not exist`);
129+
const corpora = [];
130+
const skipped = [];
131+
for (const name of readdirSync(dir).sort()) {
132+
if (name.startsWith("LEDGER-")) continue;
133+
const path = join(dir, name);
134+
let requests = [];
135+
try {
136+
if (name.endsWith(".jsonl")) {
137+
const records = readFileSync(path, "utf-8")
138+
.split("\n")
139+
.filter((l) => l.trim())
140+
.map((l) => JSON.parse(l));
141+
requests = requestsFromRecords(records);
142+
} else if (name.endsWith(".json")) {
143+
const doc = JSON.parse(readFileSync(path, "utf-8"));
144+
requests = Array.isArray(doc?.requests)
145+
? doc.requests.filter((r) => Array.isArray(r?.messages))
146+
: requestsFromRecords(doc?.records ?? []);
147+
} else {
148+
continue;
149+
}
150+
} catch (e) {
151+
skipped.push(`${name}: unreadable (${e.message})`);
152+
continue;
153+
}
154+
if (requests.length === 0) {
155+
skipped.push(`${name}: no request carries a messages array`);
156+
continue;
157+
}
158+
corpora.push({ name: name.replace(/\.(json|jsonl)$/, ""), requests });
159+
}
160+
return { corpora, skipped };
161+
}
162+
163+
// The verdict line. Deliberately the same seven fields the unit-2b A/B used —
164+
// action, reset reason, pinned, suppressed, moved, dropped, forwarded length —
165+
// because they are what every downstream gate reads off `stats`, plus the
166+
// forwarded length that says whether the wire changed shape.
167+
const verdictLine = (corpus, n, res, rawLen) =>
168+
`${corpus} n=${n} action=${res.action}` +
169+
` reset=${res.resetReason ?? "-"}` +
170+
` pinned=${res.pinned ?? 0}` +
171+
` suppressed=${res.suppressed ?? 0}` +
172+
` moved=${res.moved ?? 0}` +
173+
` dropped=${res.dropped ?? 0}` +
174+
` out=${(res.messages ?? { length: rawLen }).length}`;
175+
176+
async function main() {
177+
const opts = parseArgs(process.argv.slice(2));
178+
const scratchRoot = resolve(opts.scratch ?? join(tmpdir(), `verdict-ab-${process.pid}`));
179+
mkdirSync(scratchRoot, { recursive: true });
180+
const created = [];
181+
let exitCode = 0;
182+
try {
183+
const treeA = resolveTree(opts.a, scratchRoot, created);
184+
const treeB = resolveTree(opts.b, scratchRoot, created);
185+
let modA;
186+
let modB;
187+
try {
188+
modA = await import(pathToFileURL(join(treeA.dir, EXT)).href);
189+
modB = await import(pathToFileURL(join(treeB.dir, EXT)).href);
190+
} catch (e) {
191+
fail(`a tree failed to load: ${e.message}`);
192+
}
193+
if (typeof modA.classifyPinned !== "function" || typeof modB.classifyPinned !== "function") {
194+
fail("classifyPinned is not exported by both trees");
195+
}
196+
// Canonical state is PER CONVERSATION, and one capture key carries the main
197+
// thread, every subagent and CC's own sidecar calls. Chaining one canonical
198+
// across all of them would make tenant switches look like churn and every
199+
// verdict line downstream of the first switch meaningless. The grouping
200+
// identity is the extension's OWN — imported, never re-derived (dev-loop,
201+
// "never hand-roll identity in a probe").
202+
const groupOf = (r) => modA.resolveInsertionSessionKey(r.headers, r.messages, r.system);
203+
204+
const { corpora, skipped } = loadCorpora(resolve(opts.fixtures ?? join(REPO, "test/fixtures/harvested")));
205+
console.log(`A: ${treeA.label} ${treeA.dir}`);
206+
console.log(`B: ${treeB.label} ${treeB.dir}`);
207+
console.log(`mode: ${opts.seedFromA ? "seed-from-A (old-canon compatibility)" : "independent chains"}`);
208+
for (const s of skipped) console.log(` skipped ${s}`);
209+
210+
const diffs = [];
211+
let lines = 0;
212+
for (const { name, requests } of corpora) {
213+
const canonA = new Map();
214+
const canonB = new Map();
215+
const groups = new Set();
216+
for (const r of requests) {
217+
const g = groupOf(r);
218+
groups.add(g);
219+
const priorA = canonA.get(g) ?? null;
220+
const resA = modA.classifyPinned(r.messages, priorA);
221+
const resB = modB.classifyPinned(r.messages, opts.seedFromA ? priorA : (canonB.get(g) ?? null));
222+
const lineA = verdictLine(name, r.n, resA, r.messages.length);
223+
const lineB = verdictLine(name, r.n, resB, r.messages.length);
224+
lines++;
225+
if (opts.verbose) console.log(` A ${lineA}\n B ${lineB}`);
226+
if (lineA !== lineB) diffs.push({ a: lineA, b: lineB });
227+
canonA.set(g, resA.canonicalEntries);
228+
canonB.set(g, resB.canonicalEntries);
229+
}
230+
console.log(` ${name}: ${requests.length} request(s), ${groups.size} conversation(s)`);
231+
}
232+
233+
// The third answer. Zero lines is not "identical" — it is nothing checked.
234+
if (lines === 0) {
235+
console.log("COULD NOT VERIFY — no fixture yielded a replayable request");
236+
exitCode = 2;
237+
} else if (diffs.length === 0) {
238+
console.log(`IDENTICAL across ${lines} verdict lines, ${corpora.length} corpora`);
239+
} else {
240+
console.log(`DIFFERS on ${diffs.length} of ${lines} verdict lines:`);
241+
for (const d of diffs) console.log(` - A ${d.a}\n + B ${d.b}`);
242+
exitCode = 1;
243+
}
244+
} finally {
245+
for (const dir of created) {
246+
try {
247+
execFileSync("git", ["-C", REPO, "worktree", "remove", "--force", dir], { stdio: "pipe" });
248+
} catch {
249+
rmSync(dir, { recursive: true, force: true });
250+
}
251+
}
252+
}
253+
process.exit(exitCode);
254+
}
255+
256+
await main();

0 commit comments

Comments
 (0)