Skip to content

fix(fuzzer): make the widened AST generation actually fire - #13498

Draft
AztecBot wants to merge 27 commits into
masterfrom
cb/ast-fuzzer-reachability-fixes
Draft

fix(fuzzer): make the widened AST generation actually fire#13498
AztecBot wants to merge 27 commits into
masterfrom
cb/ast-fuzzer-reachability-fixes

Conversation

@AztecBot

@AztecBot AztecBot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #13476. Measured over freshly generated programs, three of that PR's four new generation features almost never fire, and one of them cannot fire at all. This makes them reachable and fixes the generator bugs that stand in the way.

Counts are AST-node tallies over N programs generated with Config::default().

as_vector and str_as_bytes were gated out by types_produced

Scope's producer index is built from types::types_produced, and choose_producer / gen_call only offer a variable when that index says it can produce the target type. #13476 adds the two conversion arms to gen_expr_from_source but does not touch types.rs, so the index never routes a string to [u8; N] or an array to [T]; the arms only fired on the ungated recursive path through a tuple field.

per 1500 programs #13476 here
str_as_bytes 0 52
as_vector 2 56

Both conversions print as method calls (s.as_bytes()), which only resolve against the standard library, so they are excluded under comptime_friendly: the comptime targets elaborate the printed snippet on its own and otherwise fail with UnresolvedMethodCall. That is why the counts above are not higher still — it is the same reason #13476 restricts format strings to unconstrained functions.

types_produced now carries a doc comment stating the invariant it has to hold: a type it lists that gen_expr_from_source cannot actually reach costs coverage, because the chosen producer yields nothing and the expression silently falls back to a literal.

#[fold] could not be generated at all

#13476 relaxes the InlineType filter so a constrained function may be #[fold], but no constrained non-main function survives into a generated program, so the filter never applies. can_call lets a Brillig caller call only Brillig callees, and an ACIR caller only an ACIR callee with callee_id < caller_id. main is FuncId(0), so it can never call an ACIR function, nothing else can either, and remove_unreachable_functions deletes the whole constrained subgraph. Measured over 3000 programs on #13476: every surviving non-main function is Brillig, and Fold and NoPredicates each appear zero times. NoPredicates has never been generated on master either, for the same reason.

main is the natural exception — nothing ever calls it, so letting it call any ACIR function cannot close a cycle. Turning that on makes the constrained call graph reachable for the first time, which in turn exposed five generator bugs, each fixed here:

  • gen_print could print a function value from constrained code. wrap_oracle_prints_in_functions deliberately refuses to wrap a function value, so the raw oracle call survived into ACIR: "Trying to call foreign function 'print' from ACIR function".
  • The caller_returns_ref guard tested the wrong runtime. Its comment describes an ACIR restriction but the condition required caller_unconstrained. limit keys its rewrite off whether the body makes a call, not off the runtime, so a constrained ref-returning function got an if ctx_limit == 0 wrapper and hit "Cannot return references from an if".
  • #[fold] signatures have to cross a circuit boundary. acir_gen builds a fold function's parameters with create_value_from_type, which understands only numbers and arrays.
  • The recursion limit itself violated that. add_recursion_limit appends ctx_limit: &mut u32 to every function, so a fold function got a reference parameter regardless of its declared signature — "ICE: Params to the program should only contains numbers and arrays" and "Load or Store instruction found". Fold functions now take the limit by value, reusing the existing by-value path; that cannot decrease the caller's budget, which is sound because the constrained call graph is acyclic and a constrained function can never recurse. They are excluded from function-pointer candidates, since their signature no longer matches what a pointer of that shape is rewritten to.
  • is_entry_point did not match the monomorphizer. It was id == FuncId(0), but acir_gen emits one ACIR per entry point and combine_artifacts asserts that count, so a #[fold] function has to be one. Now mirrors Monomorphizer::into_program, including the force_unconstrained arm in change_all_functions_into_unconstrained.

References and vectors are also excluded from constrained callees, for the reason they already are between ACIR and Brillig: flattening cannot keep a reference that crosses a constrained call inside its enable_side_effects region, and the SSA fails to validate after the pass.

Both features are on by default

avoid_constrained_calls and avoid_fold both default to false. Per 1500 programs that gives 289 non-main ACIR functions, of which 77 #[no_predicates] and 32 #[fold]. Compile failures over 800 programs went 20 → 0 as the generator bugs above were fixed.

cargo test -p noir_ast_fuzzer is green, including smoke, mono, parser and calibration under CI=1.

cargo test -p noir_ast_fuzzer_fuzz is not, and that is the point of turning these on. Five targets fail, and all five have been triaged to root cause:

  • pass_vs_prev, valid_after_passnoir-lang/noir-claude#1658. flatten_cfg resets enable_side_effects to 1 around a #[no_predicates] call without accounting for arguments computed under the enclosing predicate, so the post-flatten validator rejects the compiler's own SSA. Minimal repro is 12 lines of ordinary Noir; nargo compile aborts.
  • acir_vs_brillig, min_vs_full, orig_vs_morph — two causes:
    • noir-lang/noir-claude#1657. A #[fold] function whose only call site is in a statically-false branch panics nargo compile, in release as well as debug: create_program counts entry points from the AST while ssa_gen is demand-driven from main, so combine_artifacts asserts on mismatched counts. Minimal repro is 11 lines.
    • noir-lang/noir-claude#1659. These three targets assert that two builds agree, which is not a valid premise for a program containing a #[no_predicates] function: the attribute makes an untaken branch's body execute in ACIR but not in Brillig, by design. The fix belongs in the targets — they should skip such programs — not in the generator, since #[no_predicates] is what found chore(ssa refactor): simplify acir_variable using to_expression #1658. That opt-out now lands in this PR: Config::avoid_no_predicates (default off) excludes the attribute from generation, the three equivalence targets set it, and pass_vs_prev / valid_after_pass keep generating it. A regression test (red before wiring, green after) asserts the flag holds, and the two false-positive seeds 0x835b0f1700096545 and 0x927f3216000d5b92 now pass on all three targets.

With #1659's opt-out included here, the remaining red is #1657 for the three equivalence targets and #1658 for pass_vs_prev / valid_after_pass. Reviewers who want this PR green before those compiler fixes land can flip the two defaults back to true; everything else here stands on its own.

Also

The str_as_bytes arm in #13476 is duplicated verbatim; the second copy is dead. rustc does not warn because match guards defeat the unreachable-pattern lint.

Testing

  • cargo test -p noir_ast_fuzzer and CI=1 cargo test -p noir_ast_fuzzer — green; new tests cover can_call, the conversions in types_produced, and that constrained non-main functions are generated. The two can_call tests were confirmed red before the fix.
  • CI=1 cargo test -p noir_ast_fuzzer_fuzz — 6 passed, 6 failed; five are the triaged findings above and the sixth is fmt_line_comments (Expected token LeftParen, got: Greater in nargo_fmt), which reproduces on feat(fuzzer): widen AST fuzzer generation and stop first-failure shielding #13476 unchanged and comes from the target added by fix(fmt): terminate line comments before appending more output to the line #13480.
  • cargo fmt --all --check, cargo clippy -p noir_ast_fuzzer --all-targets, and RUSTDOCFLAGS="-Dwarnings -Drustdoc::unescaped_backticks" cargo doc --no-deps --document-private-items --workspace all clean.

Refs #13476.


Created by claudebox · group: slackbot · requested by Tom (@TomAFrench) · Slack thread

asterite and others added 16 commits August 6, 2026 11:25
…ayout array reads

Three changes that (measured) take acir_vs_brillig from never finding
the acir_gen masking bug family (#1601/#1615, noir-claude findings) to
finding it within ~100 core-minutes:

- half of generated tuples get a str field, so arrays/vectors of tuples
  routinely have fields with different flattened sizes — the layouts
  disabled-branch reads must handle (this was the decisive change; the
  only such layout previously possible was rare);
- max_depth 2 -> 3, widening element layouts beyond the str-only case;
- avoid_overflow/avoid_index_out_of_bounds forced on (experiment-only;
  productionize as a higher ratio), so runs are not consumed by
  overflow/OOB error-attribution noise.

Found seed (on master 65092c6): NOIR_AST_FUZZER_SEED=0xbf73fdf000100000
fails with 'first program failed: Cannot satisfy constraint' and passes
with the #13466 fix applied — plus three distinct shield ICEs that eat
the budget: acir/mod.rs:1019 (very hot at depth 3),
ssa/opt/constant_folding/mod.rs:218, ssa/opt/checks.rs:121.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every generated while/loop was stamped from one template: a synthetic
idx counter starting at 0, stepping by +1, compared with ==, and
deliberately withheld from the mutable locals so no generated statement
could touch it. That is the single loop shape the loop analyses get
right, so LICM bound inference, induction-variable simplification and
empty-loop detection were effectively unfuzzed.

Half of while/loop now also carry a user induction variable: a mutable
u32/i32 local with an arbitrary start (negative for signed), stepped by
an arbitrary delta in either direction, guarded by an arbitrary
comparison against an arbitrary bound. The synthetic counter stays as
the runaway backstop, so a guard that never fires still cannot hang.

This targets the shapes behind the source-reproducible loop findings in
noir-claude: decreasing induction against a < guard (#1303), an equality
guard false on entry (#1365), a step that skips the guard sentinel
(#102), and user break-on-equality with decrementing induction (#1397).

Loop statement weights are raised to keep the calibrated ~10% loop
density: each loop now emits two extra statements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
InlineType::Fold was filtered out of generated functions as deprecated,
which left a whole backend path unfuzzed: a fold function compiles into
a separate ACIR circuit reached through Opcode::Call, with its own
argument/return marshalling, predicate handling and optimizer behaviour
at the call boundary. noir-claude#916 (an explicit range constraint
dropped across an Opcode::Call boundary) was source-reachable precisely
through a fold function, and aztec-packages uses them.

Fold and no_predicates stay excluded for unconstrained functions, where
separate-circuit compilation and the flattening pass have no meaning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Vectors were only ever born as literals, so the array-to-vector
conversion never appeared in a generated program. In real Noir
`as_vector` is how a fixed-size array becomes dynamically sized, and it
is what promotes a value into the runtime memory-block representation
that the vector intrinsics, the reference-counting machinery and the
memory-block paths in ACIR gen all operate on — the same promotion that
appears in most of the load-store-forwarding findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
to_le_bits, to_be_bits, to_le_radix and to_be_radix are implemented four
separate times — comptime interpreter, SSA interpreter, ACIR gen and
Brillig gen — and all four have to agree, which makes them a natural
differential target that no generated program was reaching.

The source value is narrowed to an integer type whose every value the
requested output length can represent (len bits, or len bytes for
radix-256) before being widened back to Field, so a program can never
fail merely because the decomposition did not fit; only a genuine
disagreement between implementations shows up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A string was only ever produced or consumed as a whole value, so the
str-to-bytes conversion never appeared in a generated program. The
converted array shares the string's storage, which the ownership pass
has to account for: noir-claude#1201 was a missing clone on exactly this
conversion, where mutating the byte array corrupted the source string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Literal::FmtStr was the only literal form no generated program produced.
A format string carries its interpolated values in a tuple alongside the
fragment list, and that pairing has to survive monomorphization, the
printer/parser round trip and the comptime interpreter, which is where
its bugs have been (dropped duplicate interpolations, lost captures).

Restricted to unconstrained functions: a constrained println is routed
through a proxy generated once per signature in a later pass, and that
pass cannot key the per-interpolation metadata a format string carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
300 seconds per target is short enough that a single frequent shallow
failure consumes the whole run: measured over 7 x 300s runs against
master, three distinct ICEs accounted for every non-clean run and no run
reached anything deeper. With the harness now restarting after a failure
the extra budget is spent exploring rather than re-finding the same bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A function value could only ever appear as a bare parameter, so
defunctionalization only ever saw the trivial shape. Its dispatch table
is built by finding function values wherever they occur, and reaching
one through a tuple or an array exercises that discovery and the apply
dispatch it generates — the machinery behind noir-claude#1110, where a
dispatch site whose signature did not match any variant silently fell
through to a zeroing dummy.

A composite holding a function has no literal form, so gen_literal
cannot produce one; such values are now built element by element, with
each function element resolving to a function in scope or a global one.
References are handled the same way a bare function reference is: an
immutable global ident is bound to a variable before a reference is
taken over it. Printing excludes composites holding functions, since
only a bare function has an encoding the printable-type metadata can
describe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`&mut (*r).field` was not generated: a reference could only be taken
over a whole value, never over a field reached through a dereference.
The reborrow has to alias the field in place — copying it into a fresh
allocation detaches the two so a write through the reborrow never
reaches the original, which is what noir-claude#1099 was.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
orig_vs_morph guarded `&mut x` against its value-preserving rewrites but
not `&x`, and both alias what they point at. Rewriting `&a.4` into
`&(a.4 ^ (a.4 ^ a.4))` keeps the value but references a temporary, so a
later write to `a.4` is no longer observed through the reference and the
two programs legitimately disagree — a false positive reported as a
miscompilation.

Found by the target itself: a generated program took `&a.4`, wrote
`a.4 = !(*h)`, then asserted on `*h`; original and morph diverged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test_loop and test_while unit tests pin the generated loop harness;
they now also cover the user induction variable and its guard, with the
synthetic counter still bounding the iteration count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comptime targets compile the *printed* program, so every builtin the
generator emits has to print as something that parses back. The printer
renders a builtin as a method call only when its name starts with array
or vector, so `str_as_bytes` printed as `as_bytes(s)` — not a function in
scope — and the target failed with VariableNotDeclared. It is a method in
Noir, so the printer now recognises it and `as_vector` too.

Also:
- a format string now interpolates exactly one variable: the
  monomorphized print call takes the value, one piece of type metadata
  and the format marker, and the printer asserts that shape, so a second
  interpolation added an argument and tripped the assertion;
- the bit and radix decomposition intrinsics are dropped. Printed as
  methods they need the result length to be inferable at the call site,
  which is not guaranteed in every expression position;
- function values inside tuples and arrays are dropped. The
  recursion-limit rewrite has to understand composites for this to work
  — the types, the values, and which functions need proxies — and that
  is a bigger change than belongs in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@TomAFrench
TomAFrench force-pushed the cb/ast-fuzzer-reachability-fixes branch from 8ee6572 to f096e28 Compare August 11, 2026 12:14
The acir_vs_brillig, min_vs_full and orig_vs_morph targets assert that two
builds of the same program agree, which is not a valid premise for a program
containing a #[no_predicates] function: the attribute inlines the callee only
after flattening, so an untaken branch's body really executes in ACIR but not
in Brillig (noir-lang/noir-claude#1659). Add Config::avoid_no_predicates
(default off) and set it in those three targets; pass_vs_prev and
valid_after_pass keep generating the attribute, since it is what finds real
compiler bugs such as noir-lang/noir-claude#1658.

Seeds 0x835b0f1700096545 and 0x927f3216000d5b92 reproduced the false
positives and now pass on all three targets.
# Conflicts:
#	tooling/ast_fuzzer/src/lib.rs
#	tooling/ast_fuzzer/src/program/mod.rs
#	tooling/ast_fuzzer/src/program/tests.rs
Base automatically changed from ab/fuzzer-masking-discovery to master August 11, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants