fix(fuzzer): make the widened AST generation actually fire - #13498
Draft
AztecBot wants to merge 27 commits into
Draft
fix(fuzzer): make the widened AST generation actually fire#13498AztecBot wants to merge 27 commits into
AztecBot wants to merge 27 commits into
Conversation
…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
force-pushed
the
cb/ast-fuzzer-reachability-fixes
branch
from
August 11, 2026 12:14
8ee6572 to
f096e28
Compare
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
…ability-fixes # Conflicts: # tooling/ast_fuzzer/src/program/func.rs # tooling/ast_fuzzer/src/program/mod.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_vectorandstr_as_byteswere gated out bytypes_producedScope's producer index is built fromtypes::types_produced, andchoose_producer/gen_callonly offer a variable when that index says it can produce the target type. #13476 adds the two conversion arms togen_expr_from_sourcebut does not touchtypes.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.str_as_bytesas_vectorBoth conversions print as method calls (
s.as_bytes()), which only resolve against the standard library, so they are excluded undercomptime_friendly: the comptime targets elaborate the printed snippet on its own and otherwise fail withUnresolvedMethodCall. That is why the counts above are not higher still — it is the same reason #13476 restricts format strings to unconstrained functions.types_producednow carries a doc comment stating the invariant it has to hold: a type it lists thatgen_expr_from_sourcecannot 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
InlineTypefilter so a constrained function may be#[fold], but no constrained non-main function survives into a generated program, so the filter never applies.can_calllets a Brillig caller call only Brillig callees, and an ACIR caller only an ACIR callee withcallee_id < caller_id.mainisFuncId(0), so it can never call an ACIR function, nothing else can either, andremove_unreachable_functionsdeletes the whole constrained subgraph. Measured over 3000 programs on #13476: every surviving non-main function is Brillig, andFoldandNoPredicateseach appear zero times.NoPredicateshas never been generated on master either, for the same reason.mainis 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_printcould print a function value from constrained code.wrap_oracle_prints_in_functionsdeliberately refuses to wrap a function value, so the raw oracle call survived into ACIR: "Trying to call foreign function 'print' from ACIR function".caller_returns_refguard tested the wrong runtime. Its comment describes an ACIR restriction but the condition requiredcaller_unconstrained.limitkeys its rewrite off whether the body makes a call, not off the runtime, so a constrained ref-returning function got anif ctx_limit == 0wrapper and hit "Cannot return references from an if".#[fold]signatures have to cross a circuit boundary.acir_genbuilds a fold function's parameters withcreate_value_from_type, which understands only numbers and arrays.add_recursion_limitappendsctx_limit: &mut u32to 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_pointdid not match the monomorphizer. It wasid == FuncId(0), butacir_genemits one ACIR per entry point andcombine_artifactsasserts that count, so a#[fold]function has to be one. Now mirrorsMonomorphizer::into_program, including theforce_unconstrainedarm inchange_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_effectsregion, and the SSA fails to validate after the pass.Both features are on by default
avoid_constrained_callsandavoid_foldboth default tofalse. 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_fuzzeris green, includingsmoke,mono,parserandcalibrationunderCI=1.cargo test -p noir_ast_fuzzer_fuzzis 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_pass— noir-lang/noir-claude#1658.flatten_cfgresetsenable_side_effectsto1around 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 compileaborts.acir_vs_brillig,min_vs_full,orig_vs_morph— two causes:#[fold]function whose only call site is in a statically-false branch panicsnargo compile, in release as well as debug:create_programcounts entry points from the AST whilessa_genis demand-driven frommain, socombine_artifactsasserts on mismatched counts. Minimal repro is 11 lines.#[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, andpass_vs_prev/valid_after_passkeep generating it. A regression test (red before wiring, green after) asserts the flag holds, and the two false-positive seeds0x835b0f1700096545and0x927f3216000d5b92now 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 totrue; everything else here stands on its own.Also
The
str_as_bytesarm 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_fuzzerandCI=1 cargo test -p noir_ast_fuzzer— green; new tests covercan_call, the conversions intypes_produced, and that constrained non-main functions are generated. The twocan_calltests 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 isfmt_line_comments(Expected token LeftParen, got: Greaterinnargo_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, andRUSTDOCFLAGS="-Dwarnings -Drustdoc::unescaped_backticks" cargo doc --no-deps --document-private-items --workspaceall clean.Refs #13476.
Created by claudebox · group:
slackbot· requested by Tom (@TomAFrench) · Slack thread