test(cfg): retain dense reaching-defs as differential oracle + fuzz harness (#2201 U1)

This commit is contained in:
Gergo Magyar 2026-06-15 13:26:06 +00:00
parent 6932e7a9fd
commit e31faa8f4f
2 changed files with 511 additions and 0 deletions

View file

@ -127,8 +127,32 @@ const EMPTY_LATTICE: Lattice = new Map();
/**
* Compute reaching definitions for one function. See the module doc for the
* purity/determinism/sharing contract.
*
* This is the production entry point. As of #2201 it delegates to the
* SSA-sparse solver ({@link computeReachingDefsSparse}); the dense GEN/KILL
* worklist ({@link computeReachingDefsDense}) is retained as the differential
* equivalence oracle the fuzz suite checks the sparse path against — the two
* MUST be byte-identical (status, bindings, sorted facts, def/use telemetry).
*/
export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimits): FunctionDefUse {
// #2201 U1: production still runs the dense solver; the swap to sparse lands
// in U5 once the differential fuzz is byte-identical green.
return computeReachingDefsDense(cfg, limits);
}
/**
* Dense GEN/KILL monotone worklist — the original (#2082 M2) reaching-defs
* solver. As of #2201 this is RETAINED AS A TEST/BENCH-ONLY DIFFERENTIAL
* ORACLE, not a production code path: {@link computeReachingDefs} runs the
* SSA-sparse solver, and the equivalence fuzz asserts the two are byte-identical
* across a random-CFG corpus. Keep it behavior-frozen — it is the ground truth.
*
* @internal exported only for the equivalence fuzz harness and the cfg bench.
*/
export function computeReachingDefsDense(
cfg: FunctionCfg,
limits?: ReachingDefsLimits,
): FunctionDefUse {
if (!cfg.bindings) {
return { status: 'no-facts', bindings: [], facts: [], defCount: 0, useCount: 0 };
}

View file

@ -0,0 +1,487 @@
/**
* #2201 — differential equivalence harness for the reaching-defs solvers.
*
* The SSA-sparse rewrite must be BYTE-IDENTICAL to the retained dense GEN/KILL
* oracle ({@link computeReachingDefsDense}). This file is the permanent gate:
* a seeded random-CFG generator drives both solvers and a structural comparator
* asserts identical status / bindings / sorted facts / def-use telemetry.
*
* In U1 both sides run the dense oracle (self-equivalence + corpus-coverage
* sanity); U5 flips the second solver to {@link computeReachingDefs} (sparse)
* — the single change that turns this into the real equivalence gate.
*
* The corpus deliberately covers the shapes where a may-reaching-defs rewrite
* is most likely to diverge: loops + irreducible (goto) topology, throw edges
* (IN∪allDefs handler semantics), may-defs (gen-without-kill), shadowed
* bindings, unreachable blocks, multi-predecessor joins, and the maxFacts /
* maxBlockVisits truncation postures (KTD6 — the truncated SUBSET depends on
* pre-sort emission order, so it must match too).
*
* Default corpus is CI-fast; GITNEXUS_RD_FUZZ_N raises it (the ≥1M run the
* plan calls for) for a deep local/CI-shard pass.
*/
import { describe, it, expect } from 'vitest';
import {
computeReachingDefs,
computeReachingDefsDense,
type FunctionDefUse,
type ReachingDefsLimits,
} from '../../../src/core/ingestion/cfg/reaching-defs.js';
import type {
BindingEntry,
BasicBlockData,
CfgEdgeData,
CfgEdgeKind,
FunctionCfg,
StatementFacts,
} from '../../../src/core/ingestion/cfg/types.js';
type Solver = (cfg: FunctionCfg, limits?: ReachingDefsLimits) => FunctionDefUse;
// ── deterministic PRNG (mulberry32) ───────────────────────────────────────
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const NON_THROW_KINDS: CfgEdgeKind[] = [
'seq',
'cond-true',
'cond-false',
'loop-back',
'break',
'continue',
'return',
'switch-case',
'fallthrough',
];
// ── random CFG generator ───────────────────────────────────────────────────
interface GenOpts {
maxBlocks: number;
maxBindings: number;
maxStmtsPerBlock: number;
pNoBindings: number; // chance the whole CFG has bindings:undefined (→ no-facts)
pThrowEdge: number;
pMayDef: number;
pExtraEdge: number; // per-block chance of an extra random edge
pShadowName: number;
}
const DEFAULT_GEN: GenOpts = {
maxBlocks: 14,
maxBindings: 8,
maxStmtsPerBlock: 4,
pNoBindings: 0.03,
pThrowEdge: 0.12,
pMayDef: 0.18,
pExtraEdge: 0.9,
pShadowName: 0.4,
};
function genCfg(seed: number, opts: GenOpts = DEFAULT_GEN): FunctionCfg {
const rnd = mulberry32(seed);
const int = (n: number) => Math.floor(rnd() * n);
const n = 1 + int(opts.maxBlocks); // ≥1 block (entry)
// bindings — small name pool so shadowing collisions happen; distinct
// declLine/declColumn keep non-synthetic bindings' keys distinct.
const noBindings = rnd() < opts.pNoBindings;
const nBindings = noBindings ? 0 : int(opts.maxBindings + 1);
const namePool = ['a', 'b', 'c', 'd', 'e'];
const kinds: BindingEntry['kind'][] = ['var', 'let', 'const', 'param', 'catch'];
const bindings: BindingEntry[] = [];
for (let i = 0; i < nBindings; i++) {
const shadow = rnd() < opts.pShadowName;
bindings.push({
name: shadow ? namePool[int(namePool.length)] : `v${i}`,
declLine: 100 + i,
declColumn: i,
kind: kinds[int(kinds.length)],
...(rnd() < 0.08 ? { synthetic: true } : {}),
});
}
const pickBindings = (max: number): number[] => {
if (nBindings === 0) return [];
const out: number[] = [];
const count = int(max + 1);
for (let k = 0; k < count; k++) out.push(int(nBindings));
return out;
};
// blocks (block 0 = entry; some blocks get no statements like synthetic
// ENTRY/EXIT to exercise the skip paths).
const blocks: BasicBlockData[] = [];
for (let b = 0; b < n; b++) {
const stmtCount = b === 0 && rnd() < 0.5 ? int(2) : int(opts.maxStmtsPerBlock + 1);
const statements: StatementFacts[] = [];
for (let i = 0; i < stmtCount; i++) {
const defs = pickBindings(2);
const uses = pickBindings(3);
const mayDefs = rnd() < opts.pMayDef ? pickBindings(1) : [];
statements.push({
line: b * 100 + i + 1,
defs,
uses,
...(mayDefs.length ? { mayDefs } : {}),
});
}
blocks.push({
index: b,
startLine: b * 100,
endLine: b * 100 + stmtCount,
text: `B${b}`,
kind: b === 0 ? 'entry' : b === n - 1 ? 'exit' : 'normal',
// bindings:undefined ⇒ no-facts: drop statements entirely so it mirrors a
// pre-M2 CFG (the solver keys no-facts off cfg.bindings, but a realistic
// no-facts CFG also lacks statements).
...(noBindings ? {} : { statements }),
});
}
// edges — a probabilistic spine (entry chain) for reachability + random
// extra edges that produce loops, irreducible topology, and unreachable
// blocks. Throw edges target a random handler block.
const edges: CfgEdgeData[] = [];
const addEdge = (from: number, to: number, kind: CfgEdgeKind) => {
if (from >= 0 && from < n && to >= 0 && to < n) edges.push({ from, to, kind });
};
for (let b = 0; b < n - 1; b++) {
if (rnd() < 0.75) addEdge(b, b + 1, 'seq');
}
for (let b = 0; b < n; b++) {
if (rnd() < opts.pExtraEdge) {
const to = int(n); // any target → forward / back / self / cross edges
const throwIt = rnd() < opts.pThrowEdge;
addEdge(b, to, throwIt ? 'throw' : NON_THROW_KINDS[int(NON_THROW_KINDS.length)]);
}
}
return {
filePath: 'fuzz.ts',
functionStartLine: 1,
functionEndLine: n * 100,
functionStartColumn: 0,
entryIndex: 0,
exitIndex: n - 1,
blocks,
edges,
...(noBindings ? {} : { bindings }),
};
}
// ── hand-built canonical hard CFGs (guaranteed shape coverage) ─────────────
// These pin the gnarly shapes the random generator hits only probabilistically.
function canonicalHardCfgs(): FunctionCfg[] {
const mk = (
blocks: BasicBlockData[],
edges: CfgEdgeData[],
bindings: BindingEntry[],
): FunctionCfg => ({
filePath: 'canon.ts',
functionStartLine: 1,
functionEndLine: 999,
functionStartColumn: 0,
entryIndex: 0,
exitIndex: blocks.length - 1,
blocks,
edges,
bindings,
});
const bind = (name: string, line: number): BindingEntry => ({
name,
declLine: line,
declColumn: 0,
kind: 'let',
});
const blk = (index: number, statements: StatementFacts[]): BasicBlockData => ({
index,
startLine: index * 10,
endLine: index * 10 + statements.length,
text: `B${index}`,
kind: index === 0 ? 'entry' : 'normal',
statements,
});
const st = (
line: number,
defs: number[],
uses: number[],
mayDefs?: number[],
): StatementFacts => ({
line,
defs,
uses,
...(mayDefs ? { mayDefs } : {}),
});
const out: FunctionCfg[] = [];
// (1) Irreducible two-entry loop: 0→1, 0→2, 1→2, 2→1. binding x def in 1, use in 2 & 1.
out.push(
mk(
[blk(0, [st(1, [0], [])]), blk(1, [st(2, [0], [0])]), blk(2, [st(3, [], [0])])],
[
{ from: 0, to: 1, kind: 'cond-true' },
{ from: 0, to: 2, kind: 'cond-false' },
{ from: 1, to: 2, kind: 'seq' },
{ from: 2, to: 1, kind: 'loop-back' },
],
[bind('x', 1)],
),
);
// (2) Self-loop with may-def: block 1 loops to itself; x may-def + use.
out.push(
mk(
[blk(0, [st(1, [0], [])]), blk(1, [st(2, [], [0], [0])])],
[
{ from: 0, to: 1, kind: 'seq' },
{ from: 1, to: 1, kind: 'loop-back' },
],
[bind('x', 1)],
),
);
// (3) try/catch throw edge: 0 (x=1), 1 (x=parse; x=normalize) -throw-> 2 (use x).
out.push(
mk(
[
blk(0, [st(1, [0], [])]),
blk(1, [st(2, [0], []), st(3, [0], [0])]),
blk(2, [st(4, [], [0])]),
],
[
{ from: 0, to: 1, kind: 'seq' },
{ from: 1, to: 2, kind: 'seq' },
{ from: 1, to: 2, kind: 'throw' },
],
[bind('x', 1)],
),
);
// (4) Diamond merge: both arm defs reach the join use.
out.push(
mk(
[
blk(0, [st(1, [], [])]),
blk(1, [st(2, [0], [])]),
blk(2, [st(3, [0], [])]),
blk(3, [st(4, [], [0])]),
],
[
{ from: 0, to: 1, kind: 'cond-true' },
{ from: 0, to: 2, kind: 'cond-false' },
{ from: 1, to: 3, kind: 'seq' },
{ from: 2, to: 3, kind: 'seq' },
],
[bind('x', 1)],
),
);
// (5) Unreachable block carrying a def (block 2 not reachable from entry).
out.push(
mk(
[blk(0, [st(1, [0], [0])]), blk(1, [st(2, [], [0])]), blk(2, [st(3, [0], [0])])],
[{ from: 0, to: 1, kind: 'seq' }],
[bind('x', 1)],
),
);
return out;
}
// ── structural comparator ──────────────────────────────────────────────────
function serializeFact(f: FunctionDefUse['facts'][number]): string {
return (
`${f.def.blockIndex}:${f.def.stmtIndex}@${f.def.line}` +
`->${f.use.blockIndex}:${f.use.stmtIndex}@${f.use.line}#${f.bindingIdx}`
);
}
/** Returns null when byte-identical, else a human-readable first divergence. */
function diffDefUse(a: FunctionDefUse, b: FunctionDefUse): string | null {
if (a.status !== b.status) return `status: ${a.status} vs ${b.status}`;
if (a.defCount !== b.defCount) return `defCount: ${a.defCount} vs ${b.defCount}`;
if (a.useCount !== b.useCount) return `useCount: ${a.useCount} vs ${b.useCount}`;
if (a.bindings.length !== b.bindings.length) {
return `bindings.length: ${a.bindings.length} vs ${b.bindings.length}`;
}
if (a.facts.length !== b.facts.length) {
return `facts.length: ${a.facts.length} vs ${b.facts.length}`;
}
for (let i = 0; i < a.facts.length; i++) {
const fa = serializeFact(a.facts[i]);
const fb = serializeFact(b.facts[i]);
if (fa !== fb) return `fact[${i}]: ${fa} vs ${fb}`;
}
return null;
}
// ── corpus shape classifier (coverage guard) ───────────────────────────────
interface ShapeFlags {
hasLoop: boolean;
hasThrow: boolean;
hasMayDef: boolean;
hasShadow: boolean;
hasMultiPred: boolean;
hasUnreachable: boolean;
hadComputed: boolean;
hadTruncated: boolean;
hadNoFacts: boolean;
}
function classify(cfg: FunctionCfg, flags: ShapeFlags): void {
const n = cfg.blocks.length;
if (cfg.edges.some((e) => e.kind === 'throw')) flags.hasThrow = true;
if (cfg.blocks.some((b) => b.statements?.some((s) => s.mayDefs?.length))) flags.hasMayDef = true;
if (cfg.bindings) {
const names = cfg.bindings.map((b) => b.name);
if (new Set(names).size < names.length) flags.hasShadow = true;
}
const predCount = new Array(n).fill(0);
for (const e of cfg.edges) if (e.to >= 0 && e.to < n) predCount[e.to]++;
if (predCount.some((c) => c >= 2)) flags.hasMultiPred = true;
// cycle detection (DFS rec-stack) over the whole graph
const succ: number[][] = Array.from({ length: n }, () => []);
for (const e of cfg.edges)
if (e.from >= 0 && e.from < n && e.to >= 0 && e.to < n) succ[e.from].push(e.to);
const color = new Array(n).fill(0); // 0=white 1=gray 2=black
const hasCycleFrom = (start: number): boolean => {
const stack: { node: number; idx: number }[] = [{ node: start, idx: 0 }];
color[start] = 1;
while (stack.length) {
const top = stack[stack.length - 1];
if (top.idx < succ[top.node].length) {
const nx = succ[top.node][top.idx++];
if (color[nx] === 1) return true;
if (color[nx] === 0) {
color[nx] = 1;
stack.push({ node: nx, idx: 0 });
}
} else {
color[top.node] = 2;
stack.pop();
}
}
return false;
};
for (let s = 0; s < n; s++) if (color[s] === 0 && hasCycleFrom(s)) flags.hasLoop = true;
// reachability from entry
const seen = new Array(n).fill(false);
const q = [cfg.entryIndex];
seen[cfg.entryIndex] = true;
while (q.length) {
const x = q.pop()!;
for (const y of succ[x]) if (!seen[y]) ((seen[y] = true), q.push(y));
}
if (seen.some((v, i) => !v && i < n)) flags.hasUnreachable = true;
}
// ── corpus runner ──────────────────────────────────────────────────────────
interface CorpusResult {
checked: number;
flags: ShapeFlags;
firstFailure: string | null;
}
function runCorpus(left: Solver, right: Solver, count: number, baseSeed: number): CorpusResult {
const flags: ShapeFlags = {
hasLoop: false,
hasThrow: false,
hasMayDef: false,
hasShadow: false,
hasMultiPred: false,
hasUnreachable: false,
hadComputed: false,
hadTruncated: false,
hadNoFacts: false,
};
let firstFailure: string | null = null;
let checked = 0;
const check = (cfg: FunctionCfg, limits: ReachingDefsLimits | undefined, label: string): void => {
const a = left(cfg, limits);
const b = right(cfg, limits);
const d = diffDefUse(a, b);
checked++;
if (a.status === 'computed') flags.hadComputed = true;
if (a.status === 'truncated') flags.hadTruncated = true;
if (a.status === 'no-facts') flags.hadNoFacts = true;
if (d && !firstFailure) firstFailure = `${label}: ${d}`;
};
// canonical hard CFGs first (under several limit postures)
for (const [i, cfg] of canonicalHardCfgs().entries()) {
classify(cfg, flags);
check(cfg, undefined, `canon[${i}]`);
check(cfg, { maxFacts: 1 }, `canon[${i}]/maxFacts=1`);
check(cfg, { maxFacts: 2 }, `canon[${i}]/maxFacts=2`);
check(cfg, { maxBlockVisits: 2 }, `canon[${i}]/maxBlockVisits=2`);
}
// random corpus
for (let i = 0; i < count; i++) {
const seed = baseSeed + i;
const cfg = genCfg(seed);
classify(cfg, flags);
check(cfg, undefined, `seed=${seed}`);
// exercise truncation on ~1/4 of cases (small maxFacts) and the block-visit
// ceiling on ~1/8 — both must match byte-for-byte (KTD6).
if (i % 4 === 0) check(cfg, { maxFacts: 1 + (i % 3) }, `seed=${seed}/maxFacts`);
if (i % 8 === 0) check(cfg, { maxBlockVisits: 1 + (i % 4) }, `seed=${seed}/maxBlockVisits`);
}
return { checked, flags, firstFailure };
}
const CORPUS_N = Number(process.env.GITNEXUS_RD_FUZZ_N ?? 1500);
describe('#2201 reaching-defs differential equivalence', () => {
it('dense oracle is self-consistent and the comparator + generator are sound', () => {
// U1 baseline: dense-vs-dense MUST be byte-identical (proves the harness).
const r = runCorpus(computeReachingDefsDense, computeReachingDefsDense, CORPUS_N, 0x2201);
expect(r.firstFailure).toBeNull();
expect(r.checked).toBeGreaterThan(CORPUS_N);
});
it('the corpus exercises every divergence-prone shape (coverage guard)', () => {
const r = runCorpus(computeReachingDefsDense, computeReachingDefsDense, CORPUS_N, 0x2201);
const f = r.flags;
expect(f.hasLoop, 'loops').toBe(true);
expect(f.hasThrow, 'throw edges').toBe(true);
expect(f.hasMayDef, 'may-defs').toBe(true);
expect(f.hasShadow, 'shadowed bindings').toBe(true);
expect(f.hasMultiPred, 'multi-pred joins').toBe(true);
expect(f.hasUnreachable, 'unreachable blocks').toBe(true);
expect(f.hadComputed, 'computed results').toBe(true);
expect(f.hadTruncated, 'truncated results').toBe(true);
expect(f.hadNoFacts, 'no-facts results').toBe(true);
});
it('is deterministic — a fixed seed yields a byte-identical corpus across runs', () => {
const a = runCorpus(computeReachingDefsDense, computeReachingDefsDense, 200, 0xfeed);
const b = runCorpus(computeReachingDefsDense, computeReachingDefsDense, 200, 0xfeed);
expect(a.checked).toBe(b.checked);
expect(a.flags).toEqual(b.flags);
});
it('PRODUCTION computeReachingDefs is byte-identical to the dense oracle', () => {
// U1: computeReachingDefs still delegates to dense, so this is trivially
// green. U5 swaps it to the sparse solver — this becomes the real gate.
const r = runCorpus(computeReachingDefs, computeReachingDefsDense, CORPUS_N, 0x5eed);
expect(r.firstFailure).toBeNull();
});
});
// Re-exported for U5 and future harness reuse.
export { genCfg, canonicalHardCfgs, diffDefUse, runCorpus, classify };
export type { Solver, ShapeFlags, CorpusResult };