The Compiler Trusts the Graph: An Adversarial Evaluation of a Toy ML Compiler
Abstract. Every project in this portfolio got an adversarial eval suite; this one's system under attack is a compiler — a toy tensor-graph compiler whose pipeline is an interpreter (the ground truth), three passes (constant folding, mul+add fusion, dead-code elimination) and a code generator that lowers graphs to NumPy source and execs it. I aimed three families at it: malformed and degenerate graphs (dangling references, cycles, wrong arity, hostile node ids), silent-miscompilation probes (comparing every execution path against the interpreter, including a 240-graph differential slice in the CSmith tradition), and benign near-miss controls. The headline numbers: attack success rate 71.4% on hand-written seeds and 86.4% on generated variants, false-refusal rate 0.0% and benign completion 100% — and the differential slice's split verdict: the passes diverged on 0 of 240 random graphs, while code generation errored or silently diverged on 152 of 240, because generated code never coerces its inputs and the README's own list-feeding usage turns add into list concatenation. The structural result: this compiler performs exactly one validation itself (the op-name whitelist) and outsources every other check to Python and NumPy internals, which report graph problems as KeyError, IndexError and tuple-unpack errors — or, worse, silently compute something other than what the graph says.
The question
The shipped test suite is 9 pass/fail tests, and they are good tests — folding, fusion (including the shared-mul case that must not fuse), DCE, and end-to-end equivalence are all covered. But they only ask "does the pipeline handle the graphs it was designed for?" A compiler's robustness questions are the inverse: what happens on graphs it was not designed for — malformed, degenerate, or quietly wrong — and does it fail loudly, fail silently, or succeed at the wrong thing? And the miscompilation question: is there any input on which the optimised or generated code disagrees with the interpreter the project itself defines as ground truth? Pass/fail cannot price any of that, so I scored three metrics: attack success rate (ASR — the case's concrete harm fired: a crash-type exception, silent acceptance of a malformed graph, silent divergence), false-refusal rate (FRR — a valid program was rejected or crashed), and benign completion (valid programs producing interpreter-identical results on every path), with the differential slice reporting its own divergence count per execution path.
Method
System. tiny_ml_compiler unmodified: a Graph is a list of nodes in topological order (input, const, matmul, add, mul, relu, fused_mul_add); Graph.evaluate is the NumPy interpreter and the ground truth; passes.optimize runs constant folding, then mul+add fusion, then DCE; codegen.compile generates NumPy source — one expression per node, constants passed through an exec namespace — and execs it. Every case runs through the real stages: build (via the public builder API), interpret, optimize, evaluate-optimised, codegen on the raw graph, codegen on the optimised graph, and shape inference on a copy. The suite adds cases and classification, never patches.
Cases. Cases are JSON specs, not code: nodes listed in order, created through the builder, then wired in a second pass — which is what makes the hostile topologies expressible (an input may name a nonexistent ref, a later node, its own node, or form a cycle). Hand-written seeds (21 attacks): five malformed topologies (dangling input ref, dangling output, two-node cycle, self-loop, consumer-before-producer order); four arity cases (extra operand on add and on relu, missing operand, hand-written fused_mul_add with two operands); duplicate input names; a codegen injection (an unconsumed node's id renamed to a newline payload that generated source would interpolate raw); an integer constant above 2^53; two shape-inference probes (a matmul whose inner dimensions mismatch, a negative shape hint); and four miscompilation probes (overflow folding, NaN propagation, fold-time broadcast amplification, and add over two list-fed inputs). Thirteen benign controls: scalar broadcasting, zero-size matmul, the shared-mul and mul-as-output fusion edges, a hand-written fused_mul_add, an all-const graph that folds to one node, dead branches and unused inputs, a Unicode input name, a duplicated output listing, a 100-relu chain with fan-out, and passthrough graphs. Eight deterministic structural mutations (drop/duplicate/ghost an operand, rewire to self, reorder, rename an input into collision, NaN-poison a constant, append a ghost output) multiply three base graphs into 22 variants, sha256-stamped and regenerating byte-identically.
Comparability slice. AgentDojo's scenario grammar (an agent, tools, a poisoned tool output) has no honest mapping onto a compiler — there is no agent and no prompt; the injection surface is the input graph and the generated code, and those are covered by the seeds. The recognised format that does match a compiler's failure mode is differential testing in the CSmith tradition: generate many valid programs, run them through independent execution paths, treat any disagreement as a bug. The slice is 240 seeded random graphs from a hand-rolled generator built for this IR (CSmith speaks C; nothing off-the-shelf speaks this graph format — stated plainly rather than dressed up). Graphs are valid by construction (operands paired only when shapes broadcast, wiring only to earlier nodes, all values finite), with an explicit bias toward the mul→add pattern so the fusion pass is exercised. Feeds are passed to every path as plain JSON values (lists) — inside the documented contract, since the README's own usage example calls the compiled function with a list. The interpreter coerces feeds with np.asarray; whether the other paths cope is part of what is measured. Divergences are reported per path, so a codegen failure cannot masquerade as a pass failure or hide one.
Metrics. Frozen in evals/scoring.py v1, same class names as the sibling suites. The classification turns on one distinction this codebase itself draws: its single intentional error channel is ValueError/TypeError with a message describing the caller's problem (the op whitelist works exactly this way). An attack run is succeeded (its declared goal fired), blocked (cleanly refused through that channel, or handled correctly), evaded (accepted and executed, goal did not fire, but nothing refused it either — the outcome rests on IEEE semantics, not on any check), or inconclusive. Benign runs are completed, false_refusal or diverged.
Results
| suite | attacks | blocked | succeeded | evaded | ASR | benign (n) | FRR | completion |
|---|---|---|---|---|---|---|---|---|
| seeds | 21 | 3 | 15 | 3 | 71.4% | 13 | 0.0% | 100.0% |
| variants | 22 | 0 | 19 | 3 | 86.4% | — | — | — |
The three blocked seeds are the broadcast mismatch (refused by NumPy's ValueError at fold time in optimize and at run time in the interpreter), the unknown op (refused by the constructor's whitelist), and the 3000-relu chain (handled correctly — every stage is iterative). The three evaded seeds are overflow-to-inf, NaN propagation, and fold-time broadcast amplification: all executed, all IEEE-consistent on every path, none diagnosed.
The differential slice, per execution path (240 generated valid graphs; interpreter errors: 0):
| path | errored | diverged |
|---|---|---|
| optimize + evaluate (the passes) | 0 | 0 |
| codegen, raw graph | 146 | 6 |
| codegen, optimised graph | 117 | 14 |
152 of 240 graphs were affected on at least one codegen path. Folding/DCE changed the node count in 235 graphs and fusion fired in 42, so the passes' clean sheet is not vacuous. Attribution was checked graph by graph: every affected graph contains a binary op whose generated expression touches a raw feed value with no ndarray operand to take over the operation — 150 have a binary op directly over two graph inputs; the remaining 2 multiply a raw list input by a computed NumPy scalar. No graph without such an op was affected, and no other path was affected in any graph.
Findings
-
There is no validation phase; the one check the library performs itself is the op whitelist. Every other malformed-graph family is discovered by crashing into Python/NumPy internals: dangling ids, cycles and non-topological order die as
KeyError(the missing node's generated id), missing operands asIndexError, a two-operandfused_mul_addas a tuple-unpackValueErrorinside the interpreter, and a cycle under codegen asUnboundLocalError(the generated function reads its variable before the line that assigns it). One bug class — "this graph is not well-formed" — surfaces as four unrelated exception types, none of which names the graph concept that is broken. -
Arity is never checked, and extra operands are silently ignored by every stage at once.
add(x, c, 1000)computesadd(x, c)in the interpreter, in constant folding, and in generated code — consistently, so no comparison between the project's own paths can see it; only a spec-level expectation catches it. Worse, DCE keeps the ignored operand alive (it is reachable through the inputs list), so the dead 1000 survives optimisation in the graph, still looking load-bearing. The generated variants confirm this is structural: all three duplicate-operand variants ran clean on every stage. -
Codegen does not coerce its inputs; the interpreter does — and under the README's own usage, that is a silent miscompilation.
Graph.evaluateconverts every feed withnp.asarray(..., dtype=float64); generated code usesinputs[...]raw. Feed a list, as the README example does, and any generated expression without an ndarray operand falls back to Python semantics:addof two inputs concatenates the lists (a silently wrong tensor — the compiled function returns 8 elements where the interpreter returns a (4,4) matrix), whilemulandmatmulover raw lists raiseTypeError. This is the differential slice's 152/240: not an exotic corner but the default behaviour for a documented calling convention, firing whenever an op's operands trace back to feeds before any array-producing op intervenes. Optimisation changes the count (146 raw vs 117 errored, 6 vs 14 diverged) only because folding and DCE remove or expose different raw-feed ops — the bug itself is in the backend's input handling, not in any pass. -
The passes themselves are clean at this scale. Across 240 random graphs,
optimize+ evaluate produced zero divergences and zero errors, with folding/DCE exercised in 235 graphs and fusion in 42; the targeted controls add the edges random graphs rarely build (amulshared by two consumers is not fused; amulthat is both a graph output and fused into anaddkeeps both outputs correct). Whatever else is wrong with this compiler, fold/fuse/DCE preserve semantics on everything thrown at them — the honest mirror of finding 3. -
Shape inference certifies graphs that cannot run, and run time never checks shapes either.
infer_shapescomputes a matmul's output as (rows of A, columns of B) without ever comparing the inner dimensions: a (2,3)@(4,5) matmul is certified (2, 5) while its evaluation raises. A negative shape hint (−1, 3) is accepted and propagates into a certified (−1, 4). And declared input shapes are advisory only — a scalar feed satisfies an input declared (3,) by broadcasting, silently. Shapes in this IR are decoration: derived carelessly, enforced nowhere. -
Duplicate input names silently alias. Two input nodes named
xboth readfeed["x"]; there is no way to feed them different values and no error at construction or run time. The graph says two inputs; the run has one. -
Codegen interpolates node ids into generated source unsanitised, and
compile()execs the result. Renaming an unconsumed node's id to a newline payload made the compiled function execute planted Python (an environment marker) while still returning the correct result. The scope is stated honestly: the builder API mints ids from validated op names, so reaching this requires code that already constructs or mutates IR objects directly — a trust-boundary finding about generated code, not a remote exploit. But codegen itself defends nothing, and the payload needs only a node nothing consumes. -
Integer constants above 2^53 are silently rounded at graph construction.
add_constforces float64, so 9007199254740993 is stored as 9007199254740992.0 with no warning — and the loss is undetectable at run time, because the literal a caller compares against converts to the same float. The IR has no dtype system; "everything is float64" is a silent contract that large integers break without a sound. -
Overflow and NaN pass everywhere with no compiler-level signal. Folding bakes an
infconstant at compile time (the only signal is NumPy's RuntimeWarning, which also fires in the interpreter and in raw codegen — the optimised compiled path, holding the pre-baked constant, does not even warn). A NaN constant propagates identically through every path. These are classified evaded, not succeeded: the semantics are IEEE-consistent, and the hazard is the absence of any diagnostic, not divergence. -
Dangling references survive optimisation.
optimize()returns the dangling-output graph with the ghost id still in its outputs — DCE's reachability walk tolerates unknown ids, so the pipeline blesses a graph that can only crash at execution. One validation pass between construction and optimisation would convert findings 1, 2, 5, 6 and 10 from crashes and silence into clean ValueErrors; the suites are built to grade exactly that fix. - The variants behave like the seeds (ASR 86.4%), so the failures are properties of the stages, not of the hand-written cases. Every crash mutation (drop an operand, ghost an operand, rewire to self, reorder, ghost an output) fired on all three bases; every duplicate-operand mutation was silently accepted; and NaN-poisoning a constant changed nothing on any path in all three variants — the same evaded shape as the seed probe.
Limitations
The interpreter is ground truth by the project's own definition, so "divergence" means disagreement with it, not with an independent spec. Where the interpreter and the other paths share a behaviour — extra operands are the case in point — differential comparison is blind by construction, and the suite catches it only through spec-level goals on hand-written cases. The codegen findings are measured under list feeds, the README's documented calling convention; with ndarray feeds the coercion bug does not fire (every other finding is feed-type independent). The differential generator builds only valid, finite, broadcast-safe graphs over shapes (), (4,) and (4,4) — it does not fuzz malformed graphs (the seeds do that by hand), and fusion coverage, though raised by an explicit generator bias, is 42 of 240 graphs, with the targeted controls carrying the rest of that weight. The injection case requires mutating IR internals, as scoped in finding 7. Hand-written samples are small (21 seeds, 13 controls, 22 variants) and written by the compiler's author; the differential slice exists to show the seeds are not cherry-picked shapes. All numbers are for this environment (CPython 3.12, NumPy 1.26); exception messages vary across versions, though the exception types and the classification rules built on them are the substance.
Future work
The fix phase, graded by these suites as regression tests, in order: a validate(graph) step — per-op arity, dangling input/output references, topological order and cycle detection, duplicate input names, matmul inner-dimension checks — converting the crash families into clean ValueErrors through the channel the codebase already uses for unknown ops; input coercion in codegen (np.asarray(..., dtype=float64) per input, mirroring the interpreter), which should take the differential slice's codegen rows to zero; id sanitisation or namespace-safe variable naming in codegen; shape inference that validates matmul inner dimensions and rejects negative hints, plus an optional feed-shape check at evaluation; and a construction-time warning when an integer constant does not round-trip through float64. Beyond fixes: a malformed-graph fuzzer to widen the seeds beyond hand-written shapes, and a fusion-heavier differential generator (deeper mul→add chains, shared consumers, constants on both sides) to push the passes' clean sheet harder before trusting it further.
Reproducibility
The project is public: tiny-ml-compiler under github.com/tushar29k. The variant generator stamps a sha256 over its bases and mutation source and regenerates byte-identically; the differential slice is seeded (20261103) and deterministic; make_report.py rebuilds evals/adversarial-report.md from fresh runs with no timestamps, so a diff in the report is a diff in the numbers; metric definitions are frozen in a versioned scoring module (evals/scoring.py, v1 — the same class names and metric shape as the sibling projects' adversarial suites, so the numbers sit side by side). The suites are report-only (the CI gate remains tests/test_compiler.py, unchanged and green at 9/9), every adversarial run appends to evals/eval-history.jsonl, and every finding above is logged in the repository, deliberately unfixed, so the fix phase can be graded against them.
Top comments (0)