GitNexus/gitnexus/bench/impact-pdg
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
..
fixtures feat(impact): opt-in PDG-backed impact mode - statement + inter-procedural slicing, resolved-callee-id soundness, mutation-oracle validated (#2227) 2026-06-20 12:04:32 +01:00
baselines.json feat(impact): opt-in PDG-backed impact mode - statement + inter-procedural slicing, resolved-callee-id soundness, mutation-oracle validated (#2227) 2026-06-20 12:04:32 +01:00
blast-radius.mjs feat(impact): opt-in PDG-backed impact mode - statement + inter-procedural slicing, resolved-callee-id soundness, mutation-oracle validated (#2227) 2026-06-20 12:04:32 +01:00
gate-mutation-recall.mjs fix(impact-pdg): make the Impact PDG Mutation Report workflow pass (3 latent oracle bugs) (#2258) 2026-06-20 20:40:28 +01:00
measure.mjs fix(impact-pdg): make the Impact PDG Mutation Report workflow pass (3 latent oracle bugs) (#2258) 2026-06-20 20:40:28 +01:00
metrics.mjs feat(impact): opt-in PDG-backed impact mode - statement + inter-procedural slicing, resolved-callee-id soundness, mutation-oracle validated (#2227) 2026-06-20 12:04:32 +01:00
mutation-oracle.mjs feat(impact): opt-in PDG-backed impact mode - statement + inter-procedural slicing, resolved-callee-id soundness, mutation-oracle validated (#2227) 2026-06-20 12:04:32 +01:00
name-collision.mjs feat(impact): opt-in PDG-backed impact mode - statement + inter-procedural slicing, resolved-callee-id soundness, mutation-oracle validated (#2227) 2026-06-20 12:04:32 +01:00
README.md fix(impact-pdg): make the Impact PDG Mutation Report workflow pass (3 latent oracle bugs) (#2258) 2026-06-20 20:40:28 +01:00
real-code.mjs feat(impact): opt-in PDG-backed impact mode - statement + inter-procedural slicing, resolved-callee-id soundness, mutation-oracle validated (#2227) 2026-06-20 12:04:32 +01:00

bench/impact-pdg — PDG-vs-call-graph impact accuracy harness

STATUS: LIVE (U7, statement-anchored rework). This directory holds the curated ground-truth fixture corpus and the measurement harness (measure.mjs, metrics.mjs, baselines.json). Run it with node --import tsx bench/impact-pdg/measure.mjs (build dist/ first — see How to run). The harness drives both impact engines over the fixtures — PDG seeded on the criterion's statement line so it returns the dependence slice — prints a stratified P/R/F1 table + a plain-language decision recommendation, and gates regressions with --check. It now also prints an additive unified impact axes table that keeps line-level and symbol-level truth separate while comparing callgraph, unified pdg, and the evaluation-only composed-current control baseline. The measured native result remains: PDG is precise at intra-procedural statement granularity (exact on the intra AND mixed fixtures — F1 = 1.000; FU-B-2 made the slice statement-granular, closing the block-coalescing recall caveat, and the U2 value-diff oracle now agrees on the intra stratum); call-graph remains the comparator for inter-procedural symbol granularity; unified PDG must match that composed baseline before any default-switch decision.

What this measures

impact has two engines that answer different questions at different granularities:

  • mode: 'callgraph' (the default) — inter-procedural BFS over symbol→symbol edges. It answers "what other symbols depend on / are called by this one?" at symbol granularity, scored against inter_AIS.
  • mode: 'pdg' (opt-in) — the unified PDG-facing result. Its local statement slice comes from the persisted CDG + REACHING_DEF Program Dependence Graph. Seeded with line: N (impact({mode:'pdg', line:N})), it returns affectedStatements: {line, filePath, text}[] — the dependent statements of the changed line N — and also attaches inter-procedural symbol reach in interproceduralByDepth/byDepth for the same target. The native PDG row is still scored against intra_AIS; the unified axes score its statement and symbol outputs together.

They measure different scopes, so the harness scores each at its native granularity against its native ground truth and reports both side by side. The "which is more accurate?" question gets an honest, per-scope answer rather than a single blended number — and the answer is they answer different questions; neither strictly dominates.

Unified impact axes

The harness also reports a separate unified comparison that is designed for the current architecture question: does unified mode:'pdg' match the composition of today's engines? This report is additive. It does not replace the native table above, and it does not change baselines.json gating.

Unified AIS has two namespaces:

  • statement:<filePath>:<line> for intra-procedural line truth from intra_AIS
  • symbol:<symbol>@<filePath> for inter-procedural symbol truth from inter_AIS

Each engine is adapted onto those axes without lossy projection:

  • callgraph contributes only the symbol axis.
  • pdg contributes the statement axis from affectedStatements and the symbol axis from its unified interproceduralByDepth/byDepth inter-procedural reach.
  • composed-current remains an evaluation-only control row that unions standalone callgraph symbols with PDG statements.

The report intentionally has no single blended unified F1. pdg is now judged axis-by-axis against composed-current so line precision cannot hide inter-symbol misses, and symbol recall cannot hide statement-level blindness. The control row is a recall baseline, not a perfection claim: PDG can still contribute intra-line noise on pure-inter fixtures, so default-switch decisions should require matching recall while reducing or bounding FPIS.

A note on line. A whole-symbol PDG slice (no line) is empty by design: intra-procedural dependence stays inside the function, so every reachable block is already part of the whole-symbol seed. The useful PDG mode is the statement-anchored one — seed the criterion's changed statement and read the dependent statements back. This is the central change the U7 rework measures; the earlier "PDG is empty / callgraph wins" verdict was an artifact of the whole-symbol seed, now replaced.

Runtime result contract

impact({mode:'pdg', line:N}) success results carry a target envelope (id, name, type, filePath), risk: 'UNKNOWN', affectedStatements, affectedStatementCount, and callgraph-compatible parity fields (byDepth, byDepthCounts, summary, affected_processes, affected_modules). affectedStatements is the statement-level PDG slice; interproceduralByDepth is the explicit cross-function reach; byDepth remains the compatibility symbol bucket attached by unified PDG mode.

Degraded PDG results are explicit, not empty successes. no-layer, sub-layer-missing, and unknown responses keep mode:'pdg', target metadata when the target resolves, risk:'UNKNOWN', a remediation note, and empty parity fields. Truncation is also explicit: when both depth and per-step limit bounds fire, truncatedByReasons reports both causes.

Deferred architecture remains out of scope for this harness: explicit Function|Method -> BasicBlock containment (CONTAINS_BLOCK), inter-procedural summary edges / realizable call-return paths, mutation-derived AIS, and a hybrid callgraph+PDG impact mode are follow-up features, not assumptions of the current statement-level benchmark.

The corpus

Each case is a tiny self-contained TypeScript source repo plus a ground-truth.json. TypeScript is used throughout because it has the most mature CFG/PDG support in this codebase.

line is the criterion.line — the statement the PDG slice seeds on.

Case Locus line Shape
intra-dataflow-accumulator intra 8 loop-carried accumulator def→use (downstream)
intra-dataflow-chain intra 7 straight-line def→use chain (downstream)
intra-dataflow-reassign intra 9 reaching defs of a use (upstream, RD-reverse)
intra-control-guard intra 7 guard-clause control dependence (downstream, CDG-forward)
intra-control-branch intra 7 if/else-if/else arm control dependence (downstream)
intra-control-loop intra 11 nested loop+if controllers of a stmt (upstream, CDG-reverse)
inter-dispatcher-thin inter 23 branch router → 3 handlers (intra slice = routing returns, empty intra_AIS)
inter-facade-delegate inter 21 guarded sequential delegation chain (empty intra_AIS)
inter-pipeline-stages inter 20 straight pipeline driver → 3 stages (empty intra_AIS)
mixed-validate-then-call mixed 13 guard-dominated intra dependence + 1 callee
mixed-compute-and-emit mixed 12 data-flow-dominated intra dependence + 1 callee
mixed-guarded-dispatch mixed 15 control+data intra dependence + 2 callees
nobody-interface-excluded n/a no-body symbols (KTD6); excluded from PDG scoring

Minimum corpus floor (KTD9/F3): ≥ 3 cases per locus stratum, ≥ 12 total measurable cases. Current: intra = 7, inter = 3, mixed = 3 → 13 measurable (+1 excluded no-body case). Below this floor the U7 harness must print "underpowered — directional only" instead of a verdict.

Annotation schema (ground-truth.json)

Field Type Meaning
schemaVersion int schema version (currently 1)
criterion { name, filePath, direction, line?, marker?, pdgEdgeKinds? } the changed symbol — the seed for "what is affected if I change this". directiondownstream | upstream. line is the 1-based source line of the statement being changed — the seed of the statement-anchored PDG slice (impact({mode:'pdg', line})). It is chosen from source semantics (the def/criterion whose change propagates to the intra_AIS lines), not by running the traversal (KTD9 annotation-circularity guard), then reconciled against the live traversal in the harness's Step 0. marker is a substring unique to the criterion function's body (appears in one of its BasicBlock.text fragments); the smoke test uses it to locate the criterion function's blocks deterministically. pdgEdgeKinds lists the PDG edge kinds (REACHING_DEF | CDG) the criterion function is expected to produce: a pure straight-line data-flow criterion declares only REACHING_DEF (no branches → no control dependence), a branching/guard criterion declares both. The smoke test asserts exactly the declared kinds are non-zero on the criterion (so the pure-dataflow archetype isn't forced to carry an artificial branch) and that the criterion produces ≥ 1 PDG edge overall (catching an accidental no-body/zero-edge criterion). line, marker, and pdgEdgeKinds are required for every measurable case; all three are omitted only on pdgScoring: "exclude" no-body cases.
intra_AIS AisEntry[] symbols/lines truly affected WITHIN the same function (the scope where PDG mode is defined). Annotated at symbol/line granularity, never block-id (block ids carry fragile fnLine:fnCol:idx).
inter_AIS AisEntry[] symbols truly affected ACROSS function boundaries (the scope where call-graph mode is defined and intra-procedural PDG is zero-by-design).
locus 'intra' | 'inter' | 'mixed' | 'n/a' the dominant impact locus; n/a only for excluded no-body cases.
pdgScoring 'exclude' (optional) present (= "exclude") only on no-body cases U7 must drop from PDG denominators.
provenance 'manual' | 'mutation' how the AIS was derived. v1 is manual only — the mutation track (perturb a statement, diff the changed outcomes) needs a fixture-runner + value-diff harness that does not exist yet, so it is deferred. The field stays for forward-compatibility.
analyzerVersion string pinned analyzer version marker (currently the package.json version) so ground truth versions against the analyzer.
rationale string prose — WHY each AIS element is in or out. This is what makes manual annotation defensible (SLICEBENCH generate-then-verify discipline).

AisEntry = { symbol, filePath, line?, note? }. line is 1-based and present for intra entries (which are statement-granular); inter entries name a whole symbol and omit line.

intra_AIS and inter_AIS are disjoint for every case (an intra entry is a line within the criterion function; an inter entry is a different symbol).

Validity threats (the two that dominate — KTD9)

  1. Ground-truth incompleteness. A hand-annotated handful of fixtures yields point estimates over a tiny, self-admittedly incomplete corpus. One mis-annotation can swing F1 by a large fraction, so U7 reports findings as a direction, not a headline decimal, until the corpus grows / the mutation track lands.
  2. Annotation circularity. PDG's intra_AIS risks being reconciled against the PDG traversal's own output. Mitigation (KTD9 annotation-circularity guard): these annotations are written from SOURCE SEMANTICS first — reading the source and reasoning about def→use / control dependence by hand — and reconciling against the live traversal is U7's job (its Step 0), not the annotation's. Call-graph gets no such home-field annotation, so the comparison is not rigged toward PDG.

Methodology — CIS / AIS, stratified (KTD9, ArnoldBohner)

For each fixture × mode the harness compares the mode's CIS (Computed Impact Set — what it reports as impacted) against the AIS (Actual Impact Set — the curated ground truth), at the mode's native granularity, stratified by impact locus:

  • precision = |AIS∩CIS| / |CIS| (over-approximation cost),
  • recall = |AIS∩CIS| / |AIS| (under-approximation; the dangerous miss for a safety tool),
  • F1 = harmonic mean,
  • FPIS = CIS AIS (noise), FNIS = AIS CIS (missed),
  • |CIS|/|AIS| size ratio.

Each engine is scored at its own granularity against its own ground truth:

  • PDG → line granularity vs intra_AIS. CIS_pdg is the set of affectedStatements line keys (<filePath>:<line>) returned by the line-seeded slice; AIS is the intra_AIS line set. This is the unit at which PDG is precise — the dependent statements of the changed line.
  • Call-graph → symbol granularity vs inter_AIS. CIS is the reported symbol keys (<symbol>@<filePath>); AIS is the inter_AIS symbol set. This is the unit at which the cross-function blast radius is meaningful.

Empty-denominator semantics are explicit, never silently 0/1. |CIS|=0 ⇒ precision is n/a (no predictions); |AIS|=0 ⇒ recall is n/a (no truth in that scope). A scope with an n/a metric is excluded from that metric's mean, never folded in as 0 (the apples-to-oranges trap, R1). The pure scorer lives in metrics.mjs; its arithmetic is pinned by the deterministic unit test test/unit/impact-pdg-metric-math.test.ts (synthetic sets only — no analyze, no DB, so it stays out of the flaky full-pipeline lane).

Stratification. Each fixture is scored in its own locus stratum (intra/inter/mixed). Within a stratum, the PDG row is line-vs-intra_AIS and the call-graph row is symbol-vs-inter_AIS:

  • On an intra fixture, inter_AIS is empty, so call-graph reports no other symbol → its row is n/a (no cross-function truth). PDG is scored against the real intra_AIS.
  • On an inter fixture, intra_AIS is empty by design, so the PDG line slice returns only the router's own control-dependent statements — FPIS against the empty truth (precision 0, recall n/a). Call-graph is scored against the real inter_AIS. This is the honest "PDG is intra-procedural; on a pure-inter fixture it has no meaningful intra ground truth" result — symmetric to call-graph's empty intra row.
  • On a mixed fixture, both rows are real: PDG resolves the intra statement set, call-graph reaches the callee(s).

The native rows still measure different units. The PDG native row scores statement reach, while the callgraph native row scores symbol reach. The unified axes table is where pdg is judged as the composed result: statement reach in affectedStatements, inter-symbol reach in byDepth.

Substrate (the load-bearing mechanism — R8)

runPipelineFromRepo is in-memory and never persists, but impact queries a persisted lbugPath + a meta.pdg stamp; there is no exported runAnalyze (the entrypoint analyzeCommand calls process.exit, unusable in a loop), and the test-suite vi.mock bridge is vitest-only. So the harness runs real analyze via a temp GITNEXUS_HOME, mock-free. Per fixture:

  1. Point process.env.GITNEXUS_HOME at a per-run temp dir (honored by repo-manager.getGlobalDir() — it roots the registry; the per-repo DB lands in <fixtureCopy>/.gitnexus/, so fixtures are copied to a temp working dir first, keeping the source tree clean).

  2. Shell out to the real CLI as a child process — child-process isolation sidesteps process.exit; real saveMeta + registerRepo land in the temp home. The harness prefers the built dist/ CLI (plain JS, no tsx; the parse workers it spawns also load from dist/), so it needs a built dist/; it falls back to tsx's own CLI over src/ for build-free local runs. (node --import tsx src/cli/index.ts is avoided: Node ≥22.18 native type-stripping breaks the .ts entry's ./lazy-action.js.ts import resolution.)

    node dist/cli/index.js analyze <fixtureCopy> --pdg --skip-git --index-only
    
  3. new LocalBackend(); await init() resolves the fixture via the real registry (the parent process sets GITNEXUS_HOME too, so init() reads the temp registry, not ~/.gitnexus).

  4. callTool('impact', …) ×2 (the absolute path is a tier-1 path match — no name collision): once mode:'callgraph' (symbol BFS), once mode:'pdg' with line: criterion.line so it returns the statement-anchored slice (affectedStatements). A whole-symbol PDG slice (no line) is empty by design, so the seed line is load-bearing.

  5. Teardown the temp home + copy.

Step 0 — fixture AIS validation (gated on the live traversal; circularity)

Before scoring, the harness reconciles each fixture against the live analyzer (metrics.mjs is annotation-only; Step 0 is the traversal reconciliation):

  • the criterion must produce ≥ 1 PDG edge (an accidental no-body / cap- truncated criterion has unmeasurable ground truth → excluded, logged);
  • the criterion symbol must not share (filePath, startLine) with another Function/Method (one count query) — same-line projection ambiguity (R4) would reconcile AIS against the wrong symbol's edges → excluded, logged.

Per the annotation-circularity guard, this reconciliation runs second: the criterion.line and the AIS were written from source semantics first (read the source, find the def/criterion whose change propagates), and Step 0 only confirms the fixture is measurable substrate — it never derives ground truth from the traversal. Where a source-derived belief disagreed with the live block-granular traversal, the annotation was corrected (documented in each ground-truth.json rationale), not the metric re-fit:

  • Direction. inter-pipeline-stages's AIS named callees while the criterion was tagged upstream; the annotation was corrected to downstream.
  • Block coalescing — RESOLVED (FU-B-2, statement-granular). The CFG coalesces consecutive straight-line statements into one BasicBlock. Before FU-B-2 the block-granular slice could not pinpoint a coalesced block's interior statements, so intra-dataflow-chain (8,9 → inside the line-7 seed block), intra-control-guard (12 → inside the line-11 body block), and intra-dataflow-reassign (8 → inside the line-7 def block) had their intra_AIS interior lines removed as block-granularity artifacts. FU-B-2 makes the intra slice statement-granular: each persisted REACHING_DEF edge now carries its def/use source lines (a compact versioned annotation on reason), and the projection walks the self-edge def→use line chain forward from the criterion (and through every reached coalesced block) to recover those interior statements. So the three fixtures were re-reconciled UP — chain {10}→{8,9,10}, guard {9,11,13}→{9,11,12,13}, reassign {6,7}→{6,7,8} — restoring the original source-derived belief the prior block-granularity reconciliation had under-counted. The annotation fingerprint moved deliberately; the U2 value-diff oracle had already proved chain's {8,9} independently, so this is a justified ground-truth correction, not a metric re-fit.
  • Under-counted dependencies. The combined CDG+REACHING_DEF slice reaches more than a control-only or single-step reading: intra-control-branch (+line 10, the nested else if predicate, control-dependent on the outer branch), intra-control-loop (+lines 6,7, the param block and count init reaching the increment), and intra-dataflow-reassign (+line 6, the param def of a) gained lines the original annotation missed.

After reconciliation, the line-seeded slice reproduces each corrected intra_AIS exactly (FPIS = FNIS = 0) on all 7 intra fixtures AND all 3 mixed fixtures (the FU-A intra-tag scopes the intra axis to the criterion's own function, so the U1 cross-function callee lines no longer count as intra FPIS). The U2 value-diff oracle now agrees with the static slice at statement granularity on the intra stratum (chain's {8,9} are in the slice). Call-graph gets no such home-field annotation, so the comparison is not rigged toward PDG.

Measured results (analyzer 1.6.7, 13 measurable + 2 excluded; post-U1 + U2 + FU-B-2)

Each engine scored at its native granularity against its native ground truth — PDG at line vs intra_AIS, call-graph at symbol vs inter_AIS:

Scope Mode Granularity P R F1 |CIS|/|AIS| FPIS FNIS n
intra callgraph symbol/inter n/a n/a n/a n/a 0 0 7
intra pdg line/intra 1.000 1.000 1.000 1.000 0 0 7
inter callgraph symbol/inter 1.000 1.000 1.000 1.000 0 0 3
inter pdg line/intra 0.000 n/a n/a n/a 10 0 3
mixed callgraph symbol/inter 1.000 1.000 1.000 1.000 0 0 3
mixed pdg line/intra 1.000 1.000 1.000 1.000 0 0 3

Post-FU-B-2 correction. FU-B-2 makes the intra slice statement-granular — the persisted REACHING_DEF edge carries its def/use source lines, and the projection walks the self-edge def→use chain (forward from the criterion, and through every reached coalesced block) to recover interior statements. With the three coalesced-block fixtures re-reconciled UP (chain {10}→{8,9,10}, guard {9,11,13}→{9,11,12,13}, reassign {6,7}→{6,7,8}), intra/pdg stays F1 = 1.000 (FPIS = FNIS = 0) and the U2 value-diff oracle now AGREES with the slice on the intra stratum (the old 0.333 statement-level recall on intra-dataflow-chain is now 1.000 — the block-coalescing blind spot is closed, not merely matched by a blind annotation). mixed/pdg is now F1 = 1.000 (was 0.468 post-U1): the FU-A intra-tag scopes the intra axis to the criterion's own function, so the U1 cross-function callee statements live on the inter symbol axis, not as intra FPIS. The remaining inter/pdg FPIS = 10 are the router's own control-dependent returns scored against an empty intra_AIS (by design — see the n/a/0 explanation below).

Read it honestly:

  • PDG mode is precise at intra-procedural statement granularity — exact on the 7 intra AND the 3 mixed fixtures. The line-seeded slice returns exactly the reconciled intra_AIS (F1 = 1.000, FPIS = FNIS = 0) on both strata. It precisely identifies the dependent statements of the changed line (def→use chains, control-dependent arms, reaching defs); the earlier "empty / no signal" result was the whole-symbol-seed artifact, and the post-U1 mixed precision dip (0.468) was closed by the FU-A intra-tag (cross-function callee lines score on the inter axis, not as intra FPIS). FU-B-2 closed the block-coalescing blind spot: the intra slice is now statement-granular (REACHING_DEF edges carry their def/use source lines; the projection walks the self-edge def→use chain through each coalesced block), so the U2 value-diff oracle that previously proved a statement-level recall of 0.333 on intra-dataflow-chain (lines chain.ts:8,9) now measures 1.000 — the slice and the dynamic oracle agree at statement granularity on the intra stratum.
  • Call-graph mode is exact on the cross-function questions. On all 3 inter fixtures and all 3 mixed fixtures it recovers every callee — F1 = 1.000. It is the engine for "what else calls/uses this?".
  • The two n/a / 0 cells are by design, not defects. intra/call-graph: a self-contained function calls no other symbol, so call-graph reports nothing and inter_AIS is empty → no cross-function truth to score (n/a). inter/pdg: a pure-inter router has an empty intra_AIS, and the line-seeded slice returns the router's own control-dependent routing returns — FPIS against the empty truth (precision 0, recall n/a). These are symmetric: each engine is blind to the other's native scope. The per-case lines surface each statement slice (pdg line/intra: …) and each callee set (cg symbol/inter: …), while the unified table verifies whether pdg now carries both axes.

Decision recommendation (the verdict — F2)

The two engines answer different questions at different granularities, and neither dominates.

  • mode:'callgraph' (the default) is the correct engine for the inter-procedural safety question — "what else depends on / calls this symbol?" It recovers the cross-function callees exactly (inter & mixed F1 = 1.0 on this corpus) and carries the cross-function reach the blast radius needs. Use it for cross-symbol impact.
  • mode:'pdg' (opt-in, seeded with line:N, where analyze --pdg persisted the layer) is precise at intra-procedural statement granularity"which statements inside this function does changing line N affect?" On the 7 intra fixtures AND the 3 mixed fixtures it reproduces the dependent-statement set exactly (intra & mixed PDG F1 = 1.0, FPIS = FNIS = 0): the FU-A intra-tag scopes the intra axis to the criterion's own function (cross-function reach goes on the inter symbol axis), and FU-B-2 made the slice statement-granular so the U2 value-diff oracle now agrees on the intra stratum (the old block-coalescing recall caveat — 0.333 on one chain fixture — is closed: recall 1.000). This is still a question call-graph cannot answer at all (it has no notion of a statement).

mode:'pdg' now composes those surfaces in one result: affectedStatements carries statement-level dependence and interproceduralByDepth/byDepth carries inter-procedural symbols. mode:'callgraph' remains the option-driven comparator/default. The unified axes table keeps composed-current as the control baseline that PDG must match or beat before any default-switch decision. match or exceed while reducing or bounding FPIS. Reach for the line-seeded PDG when you need statement-level dependence inside a function; reach for call-graph when you need cross-function reach. The earlier verdict ("PDG is empty / call-graph wins") was an artifact of the whole-symbol seed — a whole-symbol slice has nothing to report because intra-procedural dependence never leaves the function. Seeding the changed statement is what makes PDG's precision measurable, and it measures as exact.

Validity threats (the two that dominate — KTD9)

  1. Ground-truth incompleteness. A hand-annotated handful of fixtures yields point estimates over a tiny, self-admittedly incomplete corpus. One mis-annotation can swing F1 by a large fraction, so the harness reports findings as a direction, not a headline decimal, and prints an explicit "underpowered — directional only" banner when the corpus falls below the floor.
  2. Annotation circularity. PDG's intra_AIS risks being reconciled against the PDG traversal's own output. Mitigation: these annotations are written from SOURCE SEMANTICS first (U6) — reading the source and reasoning about def→use / control dependence by hand — and reconciling against the live traversal is the harness's Step 0, run second, only to confirm measurability. Call-graph gets no such home-field annotation, so the comparison is not rigged toward PDG.

Underpowered-corpus rule (F3)

Minimum corpus floor: ≥ 3 measurable cases per locus stratum, ≥ 12 total. Current corpus is above the floor (intra 7, inter 3, mixed 3 = 13 measurable; +1 excluded no-body) — so the harness prints headline decimals. When the measurable count after exclusions drops below the floor, it instead prints "underpowered — directional only" and reports the DIRECTION ("PDG exact at intra statement granularity; call-graph exact at inter symbol granularity") rather than headline decimals — decimal precision (F1 0.74 vs 0.68) implies a confidence a sub-floor corpus cannot support. Even at the floor the F1 = 1.0 results should be read as "exact on this small, deliberately-simple corpus", not "exact in general" — see the validity threats.

Annotation fingerprint + --check (two gates, KTD10)

--check runs two non-byte-identity gates (an exact-equality gate would go perpetually red on legitimate accuracy changes):

  1. One-sided F1 regression band per mode per scope: fail iff F1 < band ε; improvements pass freely. ε and the per-(scope,mode) bands are versioned in baselines.json. The four live bands are intra/pdg = 1.0, mixed/pdg = 1.0, inter/callgraph = 1.0, mixed/callgraph = 1.0. A null band means F1 is genuinely undefined for that cell on this corpus (intra/callgraph and inter/pdg — see Measured results) — the gate skips it.
  2. Order-independent annotation fingerprint over the curated ground-truth set (a SHA-256 over a sorted, line-collapsed canonicalization — mirrors the bench/cfg/measure.mjs technique, written here, not a literal import). Any unreviewed edit to a ground-truth.json (criterion including criterion.line, AIS membership, locus, direction, edge kinds) trips it; a pure reordering of AIS entries does not.

Substrate stability (F5). Real analyze is the repo's flaky lane, so --check applies median-of-K across GN_IMPACT_PDG_K runs before comparing F1 to the band, so substrate noise can't trip the metric gate. Default K = 1 (the fixtures are tiny and deterministic in practice); raise it (GN_IMPACT_PDG_K=3) in a flaky CI lane.

Runtime budget

Each fixture costs one full analyze --pdg child process (a fresh tree-sitter parse + CFG/PDG build + persist) plus two in-process impact calls (one call-graph, one line-seeded PDG). On these tiny fixtures that is ≈ 36 s/fixture, so the full 13-fixture corpus runs in roughly 4580 s wall-clock single-threaded (K = 1). A K-fold --check multiplies by K. For a fast substrate smoke, scope to a subset: --only=intra-dataflow-chain,inter-dispatcher-thin,mixed-guarded-dispatch (or GN_IMPACT_PDG_ONLY=…). Not wired into npm test (matches the other benches); the deterministic metric-math unit test is in npm test.

How to run

cd gitnexus
node scripts/build.js                                          # REQUIRED: workers spawn from dist/
node --import tsx bench/impact-pdg/measure.mjs                 # print the stratified report + verdict
node --import tsx bench/impact-pdg/measure.mjs --json          # machine report (for re-baselining)
node --import tsx bench/impact-pdg/measure.mjs --check          # gate against baselines.json (exit non-zero on regression)
node --import tsx bench/impact-pdg/measure.mjs --only=a,b,c     # fast subset (substrate smoke)
node --import tsx bench/impact-pdg/real-code.mjs                # latency + quality-proxy probe on indexed GitNexus
node --import tsx bench/impact-pdg/real-code.mjs --json --check # machine report + broad real-code gates
node --import tsx bench/impact-pdg/blast-radius.mjs             # real-code localization: PDG slice vs whole-function body
node --import tsx bench/impact-pdg/blast-radius.mjs --direction upstream

Real-code performance and quality proxy probe

real-code.mjs complements the AIS-backed fixture harness. It runs direct LocalBackend.callTool("impact", ...) calls against an already-indexed real repository (default --repo GitNexus) and measures:

  • callgraph vs PDG median/p95 latency over --repeat samples;
  • whether unified PDG's inter-procedural symbol reach preserves the callgraph symbol set for the same target/direction;
  • degraded, partial, no-block-at-line, and PDG bridge evidence counts.

This is a quality proxy, not an accuracy score: a real repo has no curated AIS, so the probe cannot prove correctness. Use it to catch performance regressions, degraded indexes, symbol-reach drift, and excessive unproven-bridge evidence on real code. Use measure.mjs for the ground-truth precision/recall/F1 gate.

The default cases are statement-anchored at a CFG block-start line. The CFG coalesces straight-line statements into one BasicBlock, so a mid-block anchor resolves to no block start and degrades to pdg-no-block-at-line — honest, but it then exercises only the symbol axis. The harness still detects and counts that degradation; the curated anchors avoid it so every case also exercises a real intra-procedural slice. (This is the same statement-anchoring discipline the fixture corpus uses, applied to real code.)

A representative run on the indexed GitNexus tree (~17.5k symbols, PDG layer persisted via analyze --pdg with ~171k BasicBlocks) — read it directionally, not as a baseline, since wall-clock latency is host- and noise-dependent:

  • Symbol reach is preserved exactly. Unified mode:'pdg' reproduces the mode:'callgraph' inter-procedural symbol set on every case — mean and min recall = precision = 1.000. This is the load-bearing check: the PDG-facing result must not silently drop or invent cross-function reach.
  • Each case carries a real statement slice (affectedStatements non-empty, 227 statements here), so the intra axis is genuinely exercised.
  • Latency overhead is modest — PDG median ≈ 1.21.4× the callgraph median (callgraph ≈ 90250 ms/case, PDG ≈ 150280 ms/case). The first call of a fresh backend carries a one-time DB-warmup spike the p95 reflects.
  • Bridge evidence is direction-shaped, by design. Downstream statement-anchored seeds label most inter-procedural reach unproven-bridge (the symbol's first-hop call site sits in a different statement than the seeded one, so the local slice does not prove the dependence); upstream and whole-symbol reach is callgraph-bridge. So unprovenBridgeRatio ≈ 0.7 is the expected shape for statement-anchored downstream seeds — a faithful proven-vs-reachable signal, not a regression.
  • No degraded / error / partial / no-block-at-line cases, and --check is green. Default gates: min symbol recall ≥ 0.95, PDG median ≤ 5000 ms (override via GN_REAL_CODE_PDG_MIN_SYMBOL_RECALL / GN_REAL_CODE_PDG_MAX_MEDIAN_MS).

Is PDG-mode impact actually better than callgraph-only? (four-axis verdict)

"Better" is not one thing, so each candidate claim is tested separately and reported honestly — including where PDG is not better. The evidence combines the AIS-backed fixture gate (measure.mjs, which proves correctness) with two real-code probes on the live GitNexus index (real-code.mjs and blast-radius.mjs, which measure magnitude at scale: 120 functions per direction, 240 total, plus the 5-case probe). blast-radius.mjs anchors each function on an early-interior block (floor(M/3)), a conservative slice-maximizing choice, and compares the PDG statement slice to the whole function body (M blocks).

Claim Verdict Evidence
Tighter / fewer false alarms confirmed for localization and correctness Correctness: the line-seeded slice equals the curated intra dependence exactly on the 7 intra fixtures AND the 3 mixed fixtures (F1 = 1.000, FPIS = FNIS = 0): the FU-A intra-tag keeps cross-function reach on the inter axis, and FU-B-2's statement-granular slice closed the block-coalescing blind spot — the U2 value-diff oracle now measures statement-level recall 1.000 on the chain fixture (was 0.333). Magnitude (RECORDED, not re-run this session): the slice is a median 0.26 (downstream) / 0.21 (upstream) of the function body; 240/240 functions localized below whole-body — a ~7479% cut in the intra-procedural inspection set, with no proven dropped dependency.
Catches impact callgraph misses confirmed (new axis) Callgraph emits no statement-level output (unified intra-line CIS = 0, recall 0 on every fixture); PDG recovers every true dependent statement (intra recall = 1.000). PDG answers a def→use / control-dependence question callgraph cannot represent at all.
Finds more callers/callees refuted (tie, by design) Full PDG inter-procedural reach is identical to callgraph on 240/240 real functions (0 pdg-only, 0 callgraph-only). PDG bridges inter-procedural reach through the call graph, so it never finds reach the call graph misses.
Tighter cross-function reach (statement-precise) confirmed (precision, additive) mode:'pdg' now also exposes statementPreciseByDepth — the callees actually invoked from the changed line's dependence slice (BasicBlock.callees), dropping symbols only reachable from independent statements. Strictly tighter than callgraph on 52/90 with-slice functions (median proven 1 vs callgraph 2 symbols, median statement-precision 0.67); the full reach stays available alongside it. statementPrecision reports the cut. Upstream seeds have no statement discriminator, so they stay all-proven (callgraph-equal) by design.
Faster / cheaper refuted PDG carries ~1.21.6× callgraph latency (the slice query + the slice-callees lookup). It buys precision, not speed.

Headline. PDG makes impact much better at the localization/precision question — "what exactly does changing this statement affect?" It narrows the intra-procedural blast radius to roughly a quarter-to-a-third of the function body with ground-truth-proven correctness, adds a statement-level dependence axis callgraph has no answer for, and — via the persisted BasicBlock.callees substrate — now also reports a statement-precise cross-function reach (only the callees the changed line actually reaches), strictly tighter than callgraph on roughly half of with-slice functions. It is deliberately not a wider or faster cross-function reach: the full callgraph reach is preserved alongside the precise view, and mode:'callgraph' remains the comparator for raw blast radius. The surfaces compose — that is the point of the unified result, not a default switch.

Reproduce the verdict:

node --import tsx bench/impact-pdg/measure.mjs                        # correctness (F1 / FPIS / FNIS vs AIS)
node --import tsx bench/impact-pdg/blast-radius.mjs                   # localization magnitude (downstream)
node --import tsx bench/impact-pdg/blast-radius.mjs --direction upstream
node --import tsx bench/impact-pdg/real-code.mjs                      # symbol-reach preservation + latency

Resolved-symbol-id soundness (the calleeIds upgrade)

The statement-precise cross-function bridge originally matched callgraph-reached callees to the slice by leaf name (BasicBlock.callees). That is a heuristic with two failure modes: same-leaf-name collision (two distinct gets both proven — a false positive) and import-alias/rename (call-site leaf ≠ resolved name — a false negative). The bridge now matches the resolved callee symbol-id (BasicBlock.calleeIds, the per-block union of resolved ids joined to each call site by exact position), which is sound by construction; the leaf-name match remains the graceful fallback for pre-v3 indexes, blocks with no captured ids, and truncation-capped blocks. name-collision.mjs diffs the two on the same real slices (fpEliminated = collision FPs the id bridge removes; fnRecovered = alias FNs it recovers).

Realized effect on a random single-statement sample (per language, exact seedreachable slice):

Language repo fpEliminated fnRecovered name-collision ambiguity
Java commons-lang 2.1% 0% 3.9%
PHP monolog 2.6% 1.8% 12.2%
C# commandline 0% 4.8% 0%
TS ky 0% 0% 0% (no regression)

Honest reading of these numbers:

  • The aggregate effect on a median edit is modest (≈03%). This matches the pre-build measurement: realized name-collision concentrates in the small tail of high-fan-out delegating functions, not the typical single-statement slice (the per-function reach is usually 12 callees, where a same-name collision is impossible). The win is soundness, gated cleanly by the intra-overloaded-callee fixture (id proves exactly the one called overload; name-match over-attributes both — measure.mjs --check Gate 3), not a large aggregate FP cut.
  • It is bidirectional. The id key also recovers alias/rename false negatives the name match can never prove (C# 4.8%, PHP 1.8%) — callees invoked under a name that differs from their resolved symbol name.
  • The id bridge is exactly as precise as GitNexus's call resolver — no more, no less. Where the resolver emits a multi-candidate set for one ambiguous call (e.g. printer.getX() on a typed field resolving to getX on both the field type and the enclosing class), the bridge faithfully proves the whole candidate set (sound — it never drops a real target). The residual "ambiguity" on the worst-case tail is therefore the resolver's receiver-type precision, not a name-matching artifact; improving it is a resolver-precision follow-up (sibling to the C++ overload under-resolution follow-up).

Language scope. The id bridge (and the name bridge) applies wherever the CFG harvests call sites. As of the call-site-harvesting extension this is all 12 supported languages — the original six (TS/JS, Java, C#, Go, C/C++, PHP) plus Kotlin, Swift, Dart, Ruby, Rust, and Python, which were migrated from the no-site def/use accumulator to the shared CallSiteFactAccumulator (each verified that its SiteRecord.at anchor matches that language's @reference.call resolution anchor byte-exact, so the resolved-id join lands). Their BasicBlocks now carry callees and calleeIds. Realized benefit still tracks each language's collision tail and its call-resolver precision (e.g. Python/Ruby route most calls to stdlib/builtins, which carry no in-repo id), but the substrate is uniform. The one remaining language-shaped gap is C++ overload under-resolution (a resolver issue, not a harvesting one — see the C++ caveat above).

Reproduce (needs a --pdg index of the target repo built under schema v3):

node --import tsx bench/impact-pdg/name-collision.mjs --repo commons-lang --src 'src/main/java/'
node --import tsx bench/impact-pdg/name-collision.mjs --repo monolog --src 'src/Monolog/'

Inter-procedural forward slice (U1 — calleeIds descent)

The mode:'pdg' slice was originally intra-procedural: the CDG + REACHING_DEF traversal stayed inside the seeded function, and cross-function reach was bolted on only through the call-graph bridge. U1 makes the statement slice itself cross function boundaries: after the intra slice completes (and before block→symbol projection), a bounded DOWNSTREAM-only descent gathers the slice blocks' resolved calleeIds, batch-resolves them to callee spans (one s.id IN $ids UNION-ALL over Function/Method/Constructor — keyed on the resolved id, so no same-line ambiguity), seeds each callee, and runs the SAME intra BFS within it, unioning the newly-reachable blocks into the slice. This is HRB context-insensitive forward closure — the approach Joern ships (no full SDG). Bounds: a default 3 inter-procedural function hops (maxDepth caps the per-hop intra step budget), a total node cap, and a shared visited set that guarantees termination over recursion/cycles. The cross-function reach deepens affectedStatements (the statement-level slice); the owning-symbol byDepth stays a single collapsed bucket (block-hops are not call-hops). A pre-namespace-v4 index (no calleeIds column) yields no callee ids, so the descent is a no-op and the result degrades cleanly to the prior intra-only behavior.

Soundness caveats (also stamped verbatim into the result note whenever the slice crosses a hop):

  1. Context-insensitive. A dependence may be attributed to a callee only reachable from a different call site of the same function (bounded over-inclusion — the same imprecision the call-graph mode already has).
  2. Return-value ascent IS captured (CALL_SUMMARY); out-param / exception ascent deferred. A caller statement that depends on a callee's RETURN value is now in the slice when the callee carries a persisted CALL_SUMMARY return-flow summary (FU-C): the descent re-seeds the caller's continuation from the call block, and FU-B-2 surfaces the dependent call/continuation statements at statement granularity (the self-edge def→use walk). What remains deferred: out-parameter / mutated-argument ascent, callee-written shared / captured variables, and exception ascent (a throw the callee raises that the caller catches) — these need an alias / try-catch model. A pre-FU-C (v3) --pdg index has no CALL_SUMMARY edges, so return-value ascent is absent there until a re-index (the result note steers to it).
  3. No cross-boundary alias model. Aliasing of arguments/heap across the call boundary is not modeled.
  4. Precision is bounded by the call RESOLVER's precision. Multi-candidate dispatch and C++ overload under-resolution flow through faithfully — the descent is sound (it never drops a real target), but it inherits exactly the resolver's precision, no more and no less.

U2 — dynamic mutation oracle (independent ground-truth cross-check)

The PR's #1 declared validity threat is annotation circularity (the manual intra_AIS risks being reconciled against the very traversal it scores). U2 adds an independent, CI-runnable check: a real dynamic forward slice computed by value-diff, not by reading the static slice. It lives in mutation-oracle.mjs (substrate) + the pure scorers in metrics.mjs, gated behind a new --mutation flag. It is bench-additive: no src/ change, no schema change; the default report run + its F1 --check gate + fingerprints are byte-identical without --mutation.

Why value-diff, not coverage. Per Voas's PIE model (TSE'92), an observable fault needs Propagation + Infection + Execution. Coverage is only E; dependence requires I+P = an actual VALUE CHANGE at a downstream point. So the oracle's behavioral_AIS is the set of statements whose observed value changed when the criterion line was mutated — a genuine Agrawal-Horgan PLDI'90 dynamic slice, not a coverage trace. The static⊇dynamic soundness relation (Tip'95) then says a sound static slicer must contain the dynamic slice on the executed paths, so B ⊆ slice is the recall expectation.

Per fixture, the oracle:

  1. Mutates the criterion line only (≤ 4 mutants, line-scoped regex operators: AOR + - * / %, ROR > < <= >= === !==, LCR || && !, CRP numeric-literal → k+1/0, UOI negate-RHS when no operator is flippable). EQUIVALENT mutants (empty behavioral_AIS) and syntactically-invalid mutants are discarded.
  2. Derives inputs with a tiny TYPE-DRIVEN generator from the criterion fn's params — number → [5,-3,0], number[] → [[1,2,3],[-1,-2],[]], boolean → [true,false], string → ['a','b','z'] (multi-input covers both branch arms). The tuples used are recorded in the sidecar.
  3. Instruments the ORIGINAL TS AST with Babel (@babel/parser + traverse + generator, retainLines:true so loc lines stay 1-based filePath:line — the SAME space as the slice, no source-map needed): a value-transparent __trace(EXPR, line, filePath, occ) wraps VariableDeclarator init / AssignmentExpression RHS / ReturnStatement arg / CallExpression and returns EXPR unchanged. Runs original + each mutant via tsx dynamic-import on the SAME inputs from the SAME temp working copy the analyze step used.
  4. behavioral_AIS = { filePath:line where serialize(orig) != serialize(mut) } for some input/occurrence (value changed / appeared / disappeared), EXCLUDING the criterion line, unioned over inputs then over non-equivalent mutants. A deterministic serializer handles undefined/NaN/±Infinity/stable object key order.

The metric (--mutation, report-only this landing). Let slice = pdgLineCis(results.pdg.affectedStatements) (the SAME live static slice the F1 metric scores), B = behavioral_AIS, M = intra_AIS:

  • mutation_recall = |B ∩ slice| / |B| (pure mutationRecall in metrics.mjs). recall < 1.0B slice is a statement the oracle PROVED depends on the criterion that the static slice MISSED. B slice is printed explicitly and every such line is classified as (a) known-U1-no-ascent-gap, (b) driver/model artifact (block-coalescing interior of a coalesced BasicBlock, or the upstream-fixture oracle-direction mismatch), or (c) novel recall hole — so a reader is never misled (see Caveats below).
  • Circularity cross-check: B M (pure circularityDiff). Non-empty on an intra fixture ⇒ the manual annotation missed a real dependence — the headline independent evidence U2 exists to produce. Reported as a WARN with the lines; it does not fail. (On inter/mixed fixtures intra_AIS is empty-by-design, so B M there is expected cross-function reach, labelled as such, not a miss.)
  • Precision is NOT gated. slice B is expected sound over-approximation (the static slice legitimately over-includes); |slice B| is reported informationally only.

Phasing — report-then-gate. measure.mjs --mutation --check prints a Gate 4 (mutation recall, REPORT-ONLY) line + the numbers but does NOT process.exit(1) on recall < 1.0 this landing. Flipping it to a hard gate is a one-flag change: --mutation-strict already fails the build on NOVEL holes only (block-coalescing artifacts and documented U1 gaps are excluded by the same classifier the report uses).

Caveats handled.

  • R1 — the two upstream fixtures (reassignSum, filterPositive) are a forward-oracle mismatch. The oracle runs in its native downstream sense and does the circularity cross-check, but the recall gate is NOT applied to them; this is printed as oracle-direction-excluded, never a silent skip.
  • R2 — U1's DOCUMENTED no-ascent gap. A caller statement depending on a callee RETURN/out-param/thrown-exception is not in the intra slice without CALL_SUMMARY. A recall < 1.0 from callee-effect ascent into the caller continuation is a KNOWN gap, not a novel bug — the report classifies it known-U1-no-ascent-gap with the lines. nobody-interface-excluded (no body) stays oracle-excluded; intra-overloaded-callee runs as id-discrimination corroboration (mutating the alpha arm changes Alpha.process's output, not Beta.process's), included as corroboration — not an AIS recall case.

Artifacts. All instrumented mutants are generated under an os.tmpdir() dir (gn-impact-pdg-mut-*), never inside fixtures/. The only persisted new file per fixture is mutation-ground-truth.json (a regenerated audit cache; provenance:'mutation'; separate from the manual ground-truth.json, never overwriting it). It is data, matched by no vitest project (the default glob is test/**/*.test.ts); the integration test carries a tripwire asserting no *.test.ts ever appears under bench/impact-pdg/.

Runtime. Each fixture costs one extra analyze --pdg child process (the same substrate the F1 loop uses) plus a handful of in-process tsx dynamic-imports (original + ≤ 4 mutants × the input tuples), all on tiny fixtures — roughly 47 s/fixture, so the full --mutation pass adds on the order of a minute over the base run. The oracle runs once (not median-of-K): the value-diff is the load-bearing signal, deterministic, not substrate-noise-prone like F1.

Cross-language sweep is a separate task. The fixtures are TypeScript (the maturest CFG/PDG support here) and the instrumenter is TS-AST-based. Extending the oracle to the other languages requires re-indexing per-language fixture corpora under --pdg and a per-language instrumenter — a separate re-indexing task, out of scope for this landing.

Reproduce:

node --import tsx bench/impact-pdg/measure.mjs --mutation          # per-fixture recall + circularity rows
node --import tsx bench/impact-pdg/measure.mjs --mutation --check   # + report-only Gate 4
node --import tsx bench/impact-pdg/measure.mjs --mutation --check --mutation-strict  # hard-fail on NOVEL holes

Re-baseline (after a reviewed accuracy or ground-truth change)

  1. node --import tsx bench/impact-pdg/measure.mjs --json and read annotationFingerprint + strata[scope][mode].f1.
  2. Copy those into baselines.json (annotationFingerprint, the f1Bands cells), bump analyzerVersion if the analyzer moved, adjust epsilon only deliberately.
  3. Confirm --check is green.

The fixtures are also validated by the integration test test/integration/impact-pdg-fixtures.test.ts (schema well-formedness + a smoke test that each fixture analyzes under --pdg and the criterion function produces its declared CDG / REACHING_DEF edges — a zero-edge criterion has unmeasurable ground truth).