Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0542737
chore(fuzzer): experiment — bias generation toward predicated mixed-l…
asterite Aug 5, 2026
ff4e3a0
feat(fuzzer): generate user induction variables in while/loop
asterite Aug 5, 2026
a796d09
feat(fuzzer): generate #[fold] functions
asterite Aug 5, 2026
5501096
feat(fuzzer): convert arrays to vectors with as_vector
asterite Aug 5, 2026
314c3dd
feat(fuzzer): generate bit and radix decomposition intrinsics
asterite Aug 5, 2026
15f889d
feat(fuzzer): convert strings to byte arrays with str_as_bytes
asterite Aug 5, 2026
29dc337
feat(fuzzer): print through format strings
asterite Aug 5, 2026
7e1e29c
chore(ci): raise the nightly fuzzing budget to 30 minutes per target
asterite Aug 5, 2026
322f2d8
feat(fuzzer): pass function values inside tuples and arrays
asterite Aug 5, 2026
bd980eb
feat(fuzzer): reborrow a field of a referenced tuple
asterite Aug 5, 2026
a7e5c73
fix(fuzzer): do not morph the operand of an immutable reference
asterite Aug 5, 2026
dc570ec
chore(fuzzer): satisfy clippy and rustfmt
asterite Aug 6, 2026
00613f6
chore(fuzzer): update the loop shape tests for user induction variables
asterite Aug 6, 2026
ab13f32
fix(fuzzer): print method builtins as methods, drop unprintable ones
asterite Aug 6, 2026
6cad6e1
Merge branch 'master' into ab/fuzzer-masking-discovery
TomAFrench Aug 10, 2026
311b8dd
fix(fuzzer): make the widened AST generation actually fire
AztecBot Aug 10, 2026
e8b6cc2
update PR #13498
AztecBot Aug 10, 2026
8ee6572
update PR #13498
AztecBot Aug 10, 2026
6bd5cc5
Merge branch 'master' into ab/fuzzer-masking-discovery
TomAFrench Aug 11, 2026
71b3e27
fix(fuzzer): make the widened AST generation actually fire
AztecBot Aug 10, 2026
722ad60
update PR #13498
AztecBot Aug 10, 2026
f096e28
update PR #13498
AztecBot Aug 10, 2026
cc69b56
fix(fuzzer): exclude #[no_predicates] from the equivalence targets
TomAFrench Aug 11, 2026
93fb265
Merge branch 'cb/ast-fuzzer-reachability-fixes' of origin
TomAFrench Aug 11, 2026
cde94d3
Merge remote-tracking branch 'origin/master' into cb/ast-fuzzer-reach…
TomAFrench Aug 11, 2026
d36a00d
Merge branch 'master' into cb/ast-fuzzer-reachability-fixes
TomAFrench Aug 11, 2026
9c77b3b
Merge branch 'master' into cb/ast-fuzzer-reachability-fixes
TomAFrench Aug 12, 2026
c9f4df9
Merge branch 'master' into cb/ast-fuzzer-reachability-fixes
TomAFrench Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion tooling/ast_fuzzer/fuzz/src/targets/acir_vs_brillig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ use crate::{compare_results_compiled, compile_into_circuit_or_die, default_ssa_o
use arbitrary::Arbitrary;
use arbitrary::Unstructured;
use color_eyre::eyre;
use noir_ast_fuzzer::Config;
use noir_ast_fuzzer::compare::{CompareOptions, ComparePipelines};
use noir_ast_fuzzer::rewrite::change_all_functions_into_unconstrained;

pub fn fuzz(u: &mut Unstructured) -> eyre::Result<()> {
let config = default_config(u)?;
// This target asserts that the ACIR and Brillig builds of a program agree, which
// `#[no_predicates]` breaks by design: see [`Config::avoid_no_predicates`].
let config = Config { avoid_no_predicates: true, ..default_config(u)? };

let inputs = ComparePipelines::arb(
u,
Expand Down
8 changes: 6 additions & 2 deletions tooling/ast_fuzzer/fuzz/src/targets/min_vs_full.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ use crate::{
use arbitrary::{Arbitrary, Unstructured};
use color_eyre::eyre;
use noir_ast_fuzzer::compare::{CompareOptions, ComparePipelines};
use noir_ast_fuzzer::{compare::CompareResult, rewrite::change_all_functions_into_unconstrained};
use noir_ast_fuzzer::{
Config, compare::CompareResult, rewrite::change_all_functions_into_unconstrained,
};
use noirc_evaluator::ssa::minimal_passes;

pub fn fuzz(u: &mut Unstructured) -> eyre::Result<()> {
let passes = minimal_passes();
let config = default_config(u)?;
// This target asserts that the minimally- and fully-compiled builds of a program agree,
// which `#[no_predicates]` breaks by design: see [`Config::avoid_no_predicates`].
let config = Config { avoid_no_predicates: true, ..default_config(u)? };

let inputs = ComparePipelines::arb(
u,
Expand Down
6 changes: 5 additions & 1 deletion tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::targets::default_config;
use crate::{compare_results_compiled, compile_into_circuit_or_die, default_ssa_options};
use arbitrary::{Arbitrary, Unstructured};
use color_eyre::eyre;
use noir_ast_fuzzer::Config;
use noir_ast_fuzzer::compare::{CompareMorph, CompareOptions};
use noir_ast_fuzzer::rewrite;
use noir_ast_fuzzer::scope::ScopeStack;
Expand All @@ -17,7 +18,10 @@ use noirc_frontend::monomorphization::ast::{
use noirc_frontend::monomorphization::visitor::{visit_expr, visit_expr_be_mut};

pub fn fuzz(u: &mut Unstructured) -> eyre::Result<()> {
let config = default_config(u)?;
// This target asserts that a program and its value-preserving morph agree, but a morph
// may change the predicate structure around a call, which changes what a
// `#[no_predicates]` callee executes by design: see [`Config::avoid_no_predicates`].
let config = Config { avoid_no_predicates: true, ..default_config(u)? };
let rules = rules::collect(&config);
let max_rewrites = 10;
let inputs = CompareMorph::arb(
Expand Down
29 changes: 29 additions & 0 deletions tooling/ast_fuzzer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,32 @@
pub avoid_match: bool,
/// Avoid using the vector type.
pub avoid_vectors: bool,
/// Avoid calling a constrained function from another constrained function.
///
/// `main` has the lowest function ID, and the rule that keeps the constrained call graph
/// acyclic only lets a function call lower IDs, so without an exception for `main` no
/// constrained function other than `main` is reachable and every one of them is deleted
/// as unreachable. Lifting that makes the whole constrained call graph — ACIR calling
/// ACIR, `#[fold]`, `#[no_predicates]` — reachable, which is a region of the compiler
/// nothing else exercises.
pub avoid_constrained_calls: bool,
/// Avoid marking functions with `#[fold]`.
///
/// A `#[fold]` function is compiled into its own ACIR circuit, which is a backend path
/// reached through `Opcode::Call` with its own argument marshalling and predicate
/// handling at the boundary. It only means anything for a constrained function that is
/// actually called, so it depends on [`Config::avoid_constrained_calls`] being off.
pub avoid_fold: bool,
/// Avoid marking functions with `#[no_predicates]`.
///
/// The attribute inlines the callee's body only after the flattening pass, so the body
/// runs unpredicated: a call sitting in an untaken branch really executes in ACIR while

Check warning on line 117 in tooling/ast_fuzzer/src/lib.rs

View workflow job for this annotation

GitHub Actions / Code

Unknown word (untaken)

Check warning on line 117 in tooling/ast_fuzzer/src/lib.rs

View workflow job for this annotation

GitHub Actions / Code

Unknown word (unpredicated)
/// in Brillig it does not. That is the attribute's documented behavior, but it means a
/// target that asserts two builds of the same program agree (ACIR vs Brillig, or two
/// predicate structures the morph is entitled to change) reports a false positive for
/// any program whose `#[no_predicates]` function is fallible or side-effecting. Such
/// targets set this flag; targets that compare a pass against its own input keep it off.
pub avoid_no_predicates: bool,
/// Only use comptime friendly expressions.
pub comptime_friendly: bool,
}
Expand Down Expand Up @@ -167,6 +193,9 @@
avoid_constrain: false,
avoid_match: false,
avoid_vectors: false,
avoid_constrained_calls: false,
avoid_fold: false,
avoid_no_predicates: false,
comptime_friendly: false,
}
}
Expand Down
77 changes: 56 additions & 21 deletions tooling/ast_fuzzer/src/program/func.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
caller_returns_ref: bool,
callee_id: FuncId,
callee_decl: &FunctionDeclaration,
allow_constrained_calls: bool,
) -> bool {
// Nobody should call `main`.
if callee_id == Program::main_id() {
Expand All @@ -87,8 +88,10 @@
// Since the `limit` module currently inserts an `if ctx_limit == 0`,
// returning a literal, it would violate this if the return has `&mut`,
// therefore we don't make recursive calls from such functions, so the
// limit strategy is not applied to them.
if caller_returns_ref && caller_unconstrained {
// limit strategy is not applied to them. This holds whichever runtime the
// caller is in: `limit` keys the rewrite off whether the body makes a call,
// not off `unconstrained`.
if caller_returns_ref {
return false;
}

Expand All @@ -108,6 +111,23 @@
// recursion by only calling functions with lower IDs,
// otherwise the inliner could get stuck.
if !callee_decl.unconstrained {
// Flattening cannot keep a reference that crosses a constrained call boundary inside
// the `enable_side_effects` region it belongs to, and the SSA fails to validate after
// the pass. Vectors are excluded for the same reason they are between ACIR and
// Brillig: they are not a shape a constrained call passes cleanly.
if callee_decl.has_refs() || callee_decl.returns_vectors() {
return false;
}

// `main` is the exception to the ordering rule below: it has the lowest ID, so
// the rule would forbid it from calling any ACIR function, and since nothing can
// call `main` and Brillig cannot call ACIR, no constrained function other than
// `main` would be reachable — `remove_unreachable_functions` would delete the
// whole constrained call graph. Nothing ever calls `main`, so letting it call any
// ACIR function cannot close a cycle.
if caller_id == Program::main_id() {
return allow_constrained_calls;
}
// Higher calls lower, so we can use this rule to pick function parameters
// as we create the declarations: we can pass functions already declared.
return callee_id < caller_id;
Expand Down Expand Up @@ -186,7 +206,7 @@
/// by locally defined variables. Block scopes add and remove layers.
locals: ScopeStack<LocalId>,
/// Indicate which local variables are derived from function inputs.
dynamics: Stack<imbl::HashMap<LocalId, bool>>,

Check warning on line 209 in tooling/ast_fuzzer/src/program/func.rs

View workflow job for this annotation

GitHub Actions / Code

Unknown word (imbl)
/// Indicator of being in a loop (and hence able to generate
/// break and continue statements)
in_loop: bool,
Expand Down Expand Up @@ -228,7 +248,14 @@

// Consider calling any allowed global function.
for (callee_id, callee_decl) in &ctx.function_declarations {
if !can_call(id, decl.unconstrained, decl.returns_refs(), *callee_id, callee_decl) {
if !can_call(
id,
decl.unconstrained,
decl.returns_refs(),
*callee_id,
callee_decl,
!ctx.config.avoid_constrained_calls,
) {
continue;
}
let produces = types::types_produced(&callee_decl.return_type);
Expand Down Expand Up @@ -731,10 +758,10 @@
let expr = expr::deref(src_expr, tgt_type.clone());
Ok(Some((expr, src_dyn)))
}
// Reborrow a field of a referenced tuple: `&mut (*r).i`.

Check warning on line 761 in tooling/ast_fuzzer/src/program/func.rs

View workflow job for this annotation

GitHub Actions / Code

Unknown word (Reborrow)
//
// The reborrow has to alias the field in place. Copying it into a fresh allocation

Check warning on line 763 in tooling/ast_fuzzer/src/program/func.rs

View workflow job for this annotation

GitHub Actions / Code

Unknown word (reborrow)
// instead silently detaches the two, so a write through the reborrow never reaches

Check warning on line 764 in tooling/ast_fuzzer/src/program/func.rs

View workflow job for this annotation

GitHub Actions / Code

Unknown word (reborrow)
// the original — which is what noir-claude#1099 was.
(Type::Reference(inner, true), Type::Reference(field_type, true))
if matches!(inner.as_ref(), Type::Tuple(fields)
Expand Down Expand Up @@ -773,22 +800,13 @@
//
// The conversion returns a value that shares the string's storage, so the ownership
// pass has to keep them apart; noir-claude#1201 was exactly a missing clone here.
(Type::String(len), Type::Array(tgt_len, item_type))
if *len == *tgt_len
&& matches!(
item_type.as_ref(),
Type::Integer(Signedness::Unsigned, IntegerBitSize::Eight)
) =>
{
let expr = self.call_str_as_bytes(src_expr, *len, tgt_type.clone());
Ok(Some((expr, src_dyn)))
}
// Reinterpret a string as its byte array with `str_as_bytes`.
//
// The conversion returns a value that shares the string's storage, so the ownership
// pass has to keep them apart; noir-claude#1201 was exactly a missing clone here.
// It prints as the method call `s.as_bytes()`, which only resolves against the
// standard library, so it is not available to the comptime targets: those
// elaborate the printed snippet on its own.
(Type::String(len), Type::Array(tgt_len, item_type))
if *len == *tgt_len
&& !self.config().comptime_friendly
&& matches!(
item_type.as_ref(),
Type::Integer(Signedness::Unsigned, IntegerBitSize::Eight)
Expand All @@ -801,8 +819,13 @@
// becomes dynamically sized in real Noir, and it is what promotes the value into
// the runtime memory-block representation that vector intrinsics and the
// reference-counting machinery operate on.
//
// Like `str_as_bytes` it prints as a method call, so it is unavailable to the
// comptime targets.
(Type::Array(_, item_type), Type::Vector(tgt_item))
if item_type == tgt_item && !self.in_no_dynamic =>
if item_type == tgt_item
&& !self.in_no_dynamic
&& !self.config().comptime_friendly =>
{
let expr = self.call_as_vector(src_expr, src_type.clone(), tgt_type.clone());
Ok(Some((expr, src_dyn)))
Expand Down Expand Up @@ -1557,9 +1580,18 @@
.current()
.variables()
.filter_map(|(id, (_, _, typ))| types::is_printable(typ).then_some((*id, typ.clone())))
// TODO(#10499): comptime function representations are at the moment just "(function)"
// (disable printing functions if comptime_friendly is on)
.filter(|(_, typ)| !types::is_function(typ) || !self.config().comptime_friendly)
.filter(|(_, typ)| {
!types::is_function(typ)
// TODO(#10499): comptime function representations are at the moment
// just "(function)".
|| (!self.config().comptime_friendly
// Only unconstrained code may call the print oracle directly. A
// constrained print is retargeted at a wrapper function by

Check warning on line 1589 in tooling/ast_fuzzer/src/program/func.rs

View workflow job for this annotation

GitHub Actions / Code

Unknown word (retargeted)
// `wrap_oracle_prints_in_functions`, which cannot wrap a function
// value: it is passed as a tuple that flattens into several SSA
// values, and those would not match the wrapper's one parameter.
&& self.unconstrained())
})
.collect::<Vec<(LocalId, Type)>>();

if opts.is_empty() {
Expand Down Expand Up @@ -2421,7 +2453,10 @@
.iter()
.skip(1) // Can't call main.
.filter_map(|(func_id, func)| {
let matches = func.return_type == *return_type.as_ref()
// `#[fold]` functions are not eligible as function values; see the candidate
// filter in `Context::gen_function_decl`.
let matches = func.inline_type != InlineType::Fold
&& func.return_type == *return_type.as_ref()
&& func.unconstrained == *unconstrained
&& func.params.len() == param_types.len()
&& func
Expand Down
40 changes: 31 additions & 9 deletions tooling/ast_fuzzer/src/program/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,18 @@ impl Context {
self.function_declarations
.iter()
.filter_map(|(callee_id, callee)| {
can_call(
id,
unconstrained,
types::contains_reference(&return_type),
*callee_id,
callee,
)
// A `#[fold]` function receives the recursion limit by value while every
// other function receives it by mutable reference, so its signature does
// not match the one a function pointer of that shape is rewritten to.
(callee.inline_type != InlineType::Fold
&& can_call(
id,
unconstrained,
types::contains_reference(&return_type),
*callee_id,
callee,
!self.config.avoid_constrained_calls,
))
.then_some(*callee_id)
})
.collect()
Expand Down Expand Up @@ -328,13 +333,25 @@ impl Context {
let inline_type = if is_main {
InlineType::default()
} else {
// A `#[fold]` function compiles into a separate ACIR circuit, so its signature
// has to cross a circuit boundary: `acir_gen` builds its parameters with
// `create_value_from_type`, which only understands numbers and arrays.
let can_be_folded = !self.config.avoid_fold
&& types::can_be_main(&return_type)
&& params.iter().all(|(_, _, _, typ, _)| types::can_be_main(typ));

// Automatically include any new inline type, except where the compiler does not
// support it: `#[fold]` compiles the function into a separate ACIR circuit, which
// has no meaning for an unconstrained function, and `#[no_predicates]` acts on the
// flattening pass that unconstrained code does not run.
let choices = InlineType::iter()
.filter(|it| {
!(unconstrained && matches!(it, InlineType::Fold | InlineType::NoPredicates))
if unconstrained {
!matches!(it, InlineType::Fold | InlineType::NoPredicates)
} else {
(*it != InlineType::Fold || can_be_folded)
&& (*it != InlineType::NoPredicates || !self.config.avoid_no_predicates)
}
})
.collect::<Vec<_>>();
*u.choose(&choices)?
Expand Down Expand Up @@ -392,7 +409,12 @@ impl Context {
return_visibility: decl.return_visibility,
unconstrained: decl.unconstrained,
inline_type: decl.inline_type,
is_entry_point: id == FuncId(0), // we only need main as an entry point
// `main`, plus every constrained function compiled into its own ACIR circuit.
// `acir_gen` emits one ACIR per entry point and `combine_artifacts` asserts that
// count against the entry points, so a `#[fold]` function has to be one.
// Mirrors `Monomorphizer::into_program`.
is_entry_point: id == Program::main_id()
|| (!decl.unconstrained && decl.inline_type.is_entry_point()),
allow_constant_return: false,
};
self.functions.insert(id, func);
Expand Down
Loading
Loading