GitNexus/gitnexus/bench/impact-pdg/gate-mutation-recall.mjs
Gergő Magyar 239967116f
fix(impact-pdg): make the Impact PDG Mutation Report workflow pass (3 latent oracle bugs) (#2258)
* fix(impact-pdg): run mutation oracle's analyze child from built dist, not tsx-over-src

The nightly Impact PDG Mutation Report workflow failed at the first fixture with
ERR_MODULE_NOT_FOUND for src/cli/lazy-action.js. The harness shelled the real CLI
out as `node --import tsx src/cli/index.ts analyze …`; on the CI runner's Node
22.22.3, native TypeScript type-stripping is enabled by default and handles the
.ts entry instead of tsx, and native stripping does NOT remap the `./lazy-action.js`
import specifier to lazy-action.ts the way tsx does — so CLI startup crashes
before analyze even runs.

The workflow already builds dist/ (build: 'true'). Prefer the shipped
dist/cli/index.js (plain compiled JS — no tsx, no strip-types, and the parse
workers it spawns also resolve from dist/) for the analyze child, falling back to
tsx's own CLI over src only for build-free local runs. Production-faithful and
version-agnostic across the engines range (node >=22.0).

Verified on a real Node 22.22.3: the dist child starts cleanly with no
lazy-action resolution error; the full `--mutation --only=inter-dispatcher-thin`
run scores realized recall 1.0 and gate-mutation-recall passes. Workers are
independently confirmed green on 22.22.3 in CI (run 27874383902).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(impact-pdg): declare the mutation oracle's @babel/* deps

`bench/impact-pdg/mutation-oracle.mjs` imports @babel/parser, @babel/traverse,
@babel/generator and @babel/types to instrument + value-diff the fixture AST,
but none were declared in package.json. @babel/parser and @babel/types happen to
be hoisted into gitnexus/node_modules transitively, but @babel/traverse and
@babel/generator are only present at the monorepo root — so a fresh `npm ci` in
gitnexus/ (CI) can't resolve them and the oracle dies at module load with
`Cannot find package '@babel/traverse'` right after analyze succeeds.

Declare all four as devDependencies (they're already lazily imported only on the
--mutation path, so they stay out of the unit-test module graph). Verified the
oracle resolves them from gitnexus/node_modules and scores recall 1.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(impact-pdg): gate only recall-gated mutation checks (honor recallGated)

The recall gate filtered checks by `typeof c.recall === 'number'`, which includes
the UPSTREAM fixtures. The mutation oracle is a FORWARD value-diff: it mutates the
criterion line and observes which downstream lines' values change, so its
behavioral AIS can never intersect a reverse (upstream) PDG slice — recall is 0
by construction. measure.mjs already marks these `recallGated: false` (alongside
id-discrimination corroboration cases) and excludes them from its own internal
gate; the standalone gate just didn't honor that flag, so `intra-control-loop`
(direction: upstream, recall 0) tripped the floor even though the oracle ran the
full suite cleanly (mean recall 0.923).

Filter on `c.recallGated === true` so the floor applies only to the downstream
cases the forward oracle can fairly validate. Verified locally: an
upstream+downstream report now scores 1 of 2 and the gate passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(impact-pdg): fail the mutation gate when it has no recall signal + fix README drift

Tri-review hardening of this PR's own changes:

- gate-mutation-recall.mjs: the floor check passed vacuously when `scored` was
  empty (`min === null` short-circuits `min !== null && min < floor`). Narrowing
  the filter to `recallGated === true` made an empty `scored` set reachable in
  more inputs (a degenerate corpus, or a harvest that silently emptied every
  behavioral AIS). Now fail loudly when checks exist but none are recall-gated,
  so a hollow gate is red rather than a green "scored cases: 0 of N". A genuinely
  empty report (0 checks) still passes — it's not a degenerate-corpus signal.

- README.md: the harness substrate section still documented the old
  `node --import tsx src/cli/index.ts …` child invocation this PR replaced;
  update it to the dist-preferred form to match `cliChildArgs`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:40:28 +01:00

63 lines
3.1 KiB
JavaScript

// CI gate for the nightly impact-PDG mutation oracle (#2227 tri-review, U11).
//
// The oracle (`measure.mjs --mutation --json`) uploads a machine report as a
// nightly artifact, but nothing read it back — a realized-recall regression
// would silently sit in an artifact nobody opens. This gate:
// 1. always writes a recall summary to the GitHub job summary (visible on the
// run without downloading the artifact), and
// 2. fails the job when the MINIMUM realized recall across scored mutation
// cases drops below MUTATION_RECALL_FLOOR (tunable env, conservative
// default) — so a mutant the slicer stops catching surfaces as a red run.
//
// Usage: node bench/impact-pdg/gate-mutation-recall.mjs [report.json]
import fs from 'node:fs';
const reportPath = process.argv[2] ?? 'mutation-report.json';
const floor = Number(process.env.MUTATION_RECALL_FLOOR ?? '0.5');
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const checks = Array.isArray(report?.mutation?.checks) ? report.mutation.checks : [];
// Gate only the checks the oracle marked recall-gated. measure.mjs sets
// `recallGated: false` for cases a forward value-diff oracle cannot fairly
// score against the PDG slice: UPSTREAM fixtures (the oracle runs in its native
// downstream sense, so its behavioral AIS can never intersect a reverse slice —
// recall is 0 by construction) and id-discrimination corroboration fixtures.
// Those still carry a numeric `recall` for the report, so the legacy
// `typeof c.recall === 'number'` filter wrongly tripped the floor on them.
const scored = checks.filter((c) => c.recallGated === true && typeof c.recall === 'number');
const recalls = scored.map((c) => c.recall);
const min = recalls.length ? Math.min(...recalls) : null;
const mean = recalls.length ? recalls.reduce((a, b) => a + b, 0) / recalls.length : null;
const below = scored.filter((c) => c.recall < floor);
const fmt = (x) => (x === null ? 'n/a' : x.toFixed(3));
const summary = [
'## impact-PDG mutation oracle',
'',
`- scored cases: ${scored.length} of ${checks.length}`,
`- min realized recall: ${fmt(min)} (floor ${floor})`,
`- mean realized recall: ${fmt(mean)}`,
`- cases below floor: ${below.length}${below.length ? ' — ' + below.map((c) => c.name).join(', ') : ''}`,
'',
].join('\n');
if (process.env.GITHUB_STEP_SUMMARY) {
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + '\n');
}
process.stdout.write(summary + '\n');
// A report that produced checks but gated NONE of them has no recall signal:
// the floor check below would pass vacuously (`min === null`). Fail loudly so a
// degenerate corpus, or a harvest that silently emptied every behavioral AIS,
// surfaces as a red run instead of a green "scored cases: 0 of N".
if (checks.length > 0 && scored.length === 0) {
console.error(
`Mutation gate has no signal: 0 of ${checks.length} checks were recall-gated — refusing to pass.`,
);
process.exit(1);
}
if (min !== null && min < floor) {
console.error(`Mutation recall regression: min realized recall ${fmt(min)} < floor ${floor}`);
process.exit(1);
}