voyage/scripts/bakeoff-fidelity.mjs
Kjell Tore Guttormsen 29b4ac91b3
fix: round 7 of the release review of 6.3.0, for 6.3.1
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>
2026-10-11 09:11:57 +02:00

124 lines
5.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}