Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.
- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
text says so (red-proof, trekexecute Sub-step B, step-implementer,
HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
already in the tree when the step began no longer counts against it, and a
plan placed outside .claude/ reads the same. The round-6 test that had the
diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
(", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
not "could not start"; the release check tells a timeout from output past
its buffer and from another signal. One reading of a stop
(criteria-runner stoppedBy) for red proof, the criteria runner and the
release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
the realpath guard and the 20 modules still on the file:// guard, which
exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
passed at once; it fails when 127 is read as could-not-run); the prose now
names the git revision `@~1` as the one leading-@ exception.
Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
5.3 KiB
JavaScript
124 lines
5.3 KiB
JavaScript
// scripts/bakeoff-fidelity.mjs
|
||
// NW2 bake-off — fidelity analysis across ≥3 runs/arm (S10 part B).
|
||
//
|
||
// Consumes the per-run structured arm outputs ({verdict, findings:[...]}) and
|
||
// computes the T2 §5 fidelity picture:
|
||
//
|
||
// - CROSS-ARM (PRIMARY): every (Ai, Bj) pair via fidelityDiffStructured
|
||
// (lib/review/fidelity-diff.mjs) → median jaccard, verdict-match rate,
|
||
// equivalence rate, severity/rule_key mismatch tallies.
|
||
// - WITHIN-ARM (context): each arm's own run-to-run variance (Ai vs Aj),
|
||
// so cross-arm divergence can be read against each arm's intrinsic noise.
|
||
// - Distributions: per-arm verdicts + finding counts.
|
||
//
|
||
// Pure analysis over JSON the live runs produced — no LLM, deterministic.
|
||
//
|
||
// Usage:
|
||
// node scripts/bakeoff-fidelity.mjs --armA a1.json a2.json a3.json \
|
||
// --armB b1.json b2.json b3.json [--json]
|
||
// Each *.json is a structured arm result: {"verdict": "...", "findings": [...]}.
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import { fidelityDiffStructured } from '../lib/review/fidelity-diff.mjs';
|
||
import { isMain } from '../lib/util/is-main.mjs';
|
||
|
||
function median(nums) {
|
||
if (nums.length === 0) return null;
|
||
const s = [...nums].sort((a, b) => a - b);
|
||
const mid = Math.floor(s.length / 2);
|
||
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||
}
|
||
|
||
function round(n, d = 4) {
|
||
return n === null ? null : Number(n.toFixed(d));
|
||
}
|
||
|
||
function pairwise(arms, label) {
|
||
const out = [];
|
||
for (let i = 0; i < arms.length; i++) {
|
||
for (let j = i + 1; j < arms.length; j++) {
|
||
const d = fidelityDiffStructured(arms[i].result, arms[j].result);
|
||
out.push({ pair: `${label}${i + 1}×${label}${j + 1}`, jaccard: round(d.jaccard), verdictMatch: d.verdictMatch, equivalent: d.equivalent });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* @param {Array<{name, result:{verdict,findings}}>} armA
|
||
* @param {Array<{name, result:{verdict,findings}}>} armB
|
||
*/
|
||
export function analyze(armA, armB) {
|
||
// Cross-arm — the substrate comparison.
|
||
const cross = [];
|
||
for (let i = 0; i < armA.length; i++) {
|
||
for (let j = 0; j < armB.length; j++) {
|
||
const d = fidelityDiffStructured(armA[i].result, armB[j].result);
|
||
cross.push({
|
||
pair: `A${i + 1}×B${j + 1}`,
|
||
verdictA: d.verdictA, verdictB: d.verdictB, verdictMatch: d.verdictMatch,
|
||
jaccard: round(d.jaccard), countA: d.countA, countB: d.countB,
|
||
severityMismatches: d.severityMismatches.length,
|
||
ruleKeyMismatches: d.ruleKeyMismatches.length,
|
||
equivalent: d.equivalent,
|
||
});
|
||
}
|
||
}
|
||
const crossJ = cross.map((c) => c.jaccard);
|
||
const summary = {
|
||
runs: { armA: armA.length, armB: armB.length },
|
||
cross_arm: {
|
||
median_jaccard: round(median(crossJ)),
|
||
min_jaccard: round(Math.min(...crossJ)),
|
||
max_jaccard: round(Math.max(...crossJ)),
|
||
verdict_match_rate: round(cross.filter((c) => c.verdictMatch).length / cross.length, 3),
|
||
equivalent_rate: round(cross.filter((c) => c.equivalent).length / cross.length, 3),
|
||
severity_mismatch_pairs: cross.filter((c) => c.severityMismatches > 0).length,
|
||
rulekey_mismatch_pairs: cross.filter((c) => c.ruleKeyMismatches > 0).length,
|
||
},
|
||
within_arm_A: pairwise(armA, 'A'),
|
||
within_arm_B: pairwise(armB, 'B'),
|
||
distributions: {
|
||
armA_verdicts: armA.map((a) => a.result.verdict),
|
||
armB_verdicts: armB.map((b) => b.result.verdict),
|
||
armA_counts: armA.map((a) => (a.result.findings || []).length),
|
||
armB_counts: armB.map((b) => (b.result.findings || []).length),
|
||
},
|
||
};
|
||
return { summary, cross };
|
||
}
|
||
|
||
// ---- CLI shim ----------------------------------------------------------------
|
||
|
||
if (isMain(import.meta.url)) {
|
||
const argv = process.argv.slice(2);
|
||
const asJson = argv.includes('--json');
|
||
function collect(flag) {
|
||
const i = argv.indexOf(flag);
|
||
if (i === -1) return [];
|
||
const files = [];
|
||
for (let k = i + 1; k < argv.length && !argv[k].startsWith('--'); k++) files.push(argv[k]);
|
||
return files.map((f) => ({ name: f, result: JSON.parse(readFileSync(f, 'utf-8')) }));
|
||
}
|
||
const armA = collect('--armA');
|
||
const armB = collect('--armB');
|
||
if (armA.length === 0 || armB.length === 0) {
|
||
process.stderr.write('Usage: bakeoff-fidelity.mjs --armA a*.json --armB b*.json [--json]\n');
|
||
process.exit(2);
|
||
}
|
||
const { summary, cross } = analyze(armA, armB);
|
||
if (asJson) {
|
||
process.stdout.write(JSON.stringify({ summary, cross }, null, 2) + '\n');
|
||
} else {
|
||
const x = summary.cross_arm;
|
||
process.stdout.write('NW2 bake-off fidelity (cross-arm = PRIMARY)\n');
|
||
process.stdout.write(` runs: A=${summary.runs.armA} B=${summary.runs.armB}\n`);
|
||
process.stdout.write(` median jaccard: ${x.median_jaccard} (min ${x.min_jaccard}, max ${x.max_jaccard})\n`);
|
||
process.stdout.write(` verdict-match rate: ${x.verdict_match_rate}\n`);
|
||
process.stdout.write(` equivalent rate: ${x.equivalent_rate}\n`);
|
||
process.stdout.write(` severity-mismatch pairs: ${x.severity_mismatch_pairs}; rule_key-mismatch pairs: ${x.rulekey_mismatch_pairs}\n`);
|
||
process.stdout.write(` armA verdicts: ${summary.distributions.armA_verdicts.join(',')} | counts ${summary.distributions.armA_counts.join(',')}\n`);
|
||
process.stdout.write(` armB verdicts: ${summary.distributions.armB_verdicts.join(',')} | counts ${summary.distributions.armB_counts.join(',')}\n`);
|
||
}
|
||
process.exit(0);
|
||
}
|