From e31faa8f4fca35fa9ed73cbb81c05dfd4f4253f1 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 15 Jun 2026 13:26:06 +0000 Subject: [PATCH] test(cfg): retain dense reaching-defs as differential oracle + fuzz harness (#2201 U1) --- .../src/core/ingestion/cfg/reaching-defs.ts | 24 + .../cfg/reaching-defs-equivalence.test.ts | 487 ++++++++++++++++++ 2 files changed, 511 insertions(+) create mode 100644 gitnexus/test/unit/cfg/reaching-defs-equivalence.test.ts diff --git a/gitnexus/src/core/ingestion/cfg/reaching-defs.ts b/gitnexus/src/core/ingestion/cfg/reaching-defs.ts index ff192e802..492d6b2db 100644 --- a/gitnexus/src/core/ingestion/cfg/reaching-defs.ts +++ b/gitnexus/src/core/ingestion/cfg/reaching-defs.ts @@ -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 }; } diff --git a/gitnexus/test/unit/cfg/reaching-defs-equivalence.test.ts b/gitnexus/test/unit/cfg/reaching-defs-equivalence.test.ts new file mode 100644 index 000000000..7c697a619 --- /dev/null +++ b/gitnexus/test/unit/cfg/reaching-defs-equivalence.test.ts @@ -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 };