|
| 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