From 054273788f06a4a6f8a26eab62a147bba0a00c3e Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 14:53:06 -0300 Subject: [PATCH 01/21] =?UTF-8?q?chore(fuzzer):=20experiment=20=E2=80=94?= =?UTF-8?q?=20bias=20generation=20toward=20predicated=20mixed-layout=20arr?= =?UTF-8?q?ay=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 65092c6b84d): 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 --- tooling/ast_fuzzer/fuzz/src/targets/mod.rs | 3 ++- tooling/ast_fuzzer/src/program/mod.rs | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tooling/ast_fuzzer/fuzz/src/targets/mod.rs b/tooling/ast_fuzzer/fuzz/src/targets/mod.rs index 2ffc512b271..869941c28e1 100644 --- a/tooling/ast_fuzzer/fuzz/src/targets/mod.rs +++ b/tooling/ast_fuzzer/fuzz/src/targets/mod.rs @@ -13,7 +13,8 @@ pub mod valid_after_pass; fn default_config(u: &mut Unstructured) -> arbitrary::Result { // Some errors such as overflows and OOB are easy to trigger, so in half // the cases we avoid all of them, to make sure they don't mask other errors. - let avoid_frequent_errors = u.arbitrary()?; + let avoid_frequent_errors = true; + let _ = u; let config = Config { avoid_overflow: avoid_frequent_errors, avoid_index_out_of_bounds: avoid_frequent_errors, diff --git a/tooling/ast_fuzzer/src/program/mod.rs b/tooling/ast_fuzzer/src/program/mod.rs index 3577a54c1c8..d7d23ecbc1c 100644 --- a/tooling/ast_fuzzer/src/program/mod.rs +++ b/tooling/ast_fuzzer/src/program/mod.rs @@ -499,9 +499,17 @@ impl Context { 4 | 5 => { // 1-size tuples look strange, so let's make it minimum 2 fields. let size = u.int_in_range(2..=self.config.max_tuple_size)?; - let types = (0..size) + let mut types = (0..size) .map(|_| gen_inner_type(self, u, is_vector_allowed)) .collect::, _>>()?; + // Bias: half the time force one field to be a string, so tuples + // (and arrays of tuples) frequently have fields whose flattened + // sizes differ - the layouts predicated array reads must handle. + if u.ratio(1, 2)? { + let i = u.choose_index(types.len())?; + types[i] = + Type::String(u.int_in_range(1..=self.config.max_array_size)? as u32); + } Type::Tuple(types) } 6 if is_vector_allowed && !self.config.avoid_vectors => { From ff4e3a0e77973bfa2c1d6104949555c96bd0b495 Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 16:36:37 -0300 Subject: [PATCH 02/21] feat(fuzzer): generate user induction variables in while/loop 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 --- tooling/ast_fuzzer/fuzz/src/targets/mod.rs | 67 +++++++++-- tooling/ast_fuzzer/src/lib.rs | 4 +- tooling/ast_fuzzer/src/program/func.rs | 126 ++++++++++++++++++++- 3 files changed, 182 insertions(+), 15 deletions(-) diff --git a/tooling/ast_fuzzer/fuzz/src/targets/mod.rs b/tooling/ast_fuzzer/fuzz/src/targets/mod.rs index 869941c28e1..f1dab7e6240 100644 --- a/tooling/ast_fuzzer/fuzz/src/targets/mod.rs +++ b/tooling/ast_fuzzer/fuzz/src/targets/mod.rs @@ -13,8 +13,7 @@ pub mod valid_after_pass; fn default_config(u: &mut Unstructured) -> arbitrary::Result { // Some errors such as overflows and OOB are easy to trigger, so in half // the cases we avoid all of them, to make sure they don't mask other errors. - let avoid_frequent_errors = true; - let _ = u; + let avoid_frequent_errors = u.ratio(3, 4)?; let config = Config { avoid_overflow: avoid_frequent_errors, avoid_index_out_of_bounds: avoid_frequent_errors, @@ -100,16 +99,62 @@ mod tests { /// Run the tests non-deterministically until the timeout. /// - /// This is the local behavior. + /// This is the local and nightly behavior. + /// + /// A failure does not stop the run: the panic (which carries the reproduction seed) is + /// printed and fuzzing continues with a fresh session until the budget is spent, so one + /// shallow, frequent bug cannot shield deeper ones from an entire run's budget. Distinct + /// failures are collected and re-raised together at the end. fn run_nondeterministic(f: impl Fn(&mut Unstructured) -> eyre::Result<()>) { - arbtest::arbtest(|u| { - f(u).unwrap(); - Ok(()) - }) - .size_min(MIN_SIZE) - .size_max(MAX_SIZE) - .budget(budget()) - .run(); + /// Cap on collected failures, so a bug that fails instantly on almost every input + /// cannot keep the loop spinning for the whole budget. + const MAX_FAILURES: usize = 5; + + let budget = budget(); + let start = std::time::Instant::now(); + let mut failures: Vec = Vec::new(); + + while failures.len() < MAX_FAILURES { + let remaining = budget.saturating_sub(start.elapsed()); + if remaining.is_zero() { + break; + } + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + arbtest::arbtest(|u| { + f(u).unwrap(); + Ok(()) + }) + .size_min(MIN_SIZE) + .size_max(MAX_SIZE) + .budget(remaining) + .run(); + })); + match result { + // The session ran out of budget without finding a failure. + Ok(()) => break, + Err(panic) => { + // The default panic hook has already printed the full message, which + // includes the `Seed: 0x…` line that `extract-fuzz-seeds.sh` scrapes. + let msg = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or("") + .to_string(); + if !failures.contains(&msg) { + failures.push(msg); + } + } + } + } + + if !failures.is_empty() { + panic!( + "arbtest found {} distinct failure(s) within the budget:\n\n{}", + failures.len(), + failures.join("\n---\n") + ); + } } /// Run multiple tests with a deterministic RNG. diff --git a/tooling/ast_fuzzer/src/lib.rs b/tooling/ast_fuzzer/src/lib.rs index ac95220915f..1724672347b 100644 --- a/tooling/ast_fuzzer/src/lib.rs +++ b/tooling/ast_fuzzer/src/lib.rs @@ -126,8 +126,8 @@ impl Default for Config { ("if", 10), ("match", 15), ("for", 45), - ("loop", 45), - ("while", 45), + ("loop", 75), + ("while", 75), ("let", 20), ("call", 5), ("print", 15), diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index 514d039e844..e395b2adbcd 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -1800,11 +1800,32 @@ impl<'a> FunctionContext<'a> { // Start building the loop harness, initialize index to 0 let let_idx = expr::let_var(idx_local_id, true, idx_name, types::U32, expr::u32_literal(0)); + // Half the time, give the loop a user induction variable and a user break guard, so the + // exit condition is not always the synthetic `idx == max` shape. + self.enter_scope(); + let induction = + if u.ratio(1, 2)? { Some(self.gen_user_induction(u)?) } else { None }; + // Get the randomized loop body let was_in_loop = std::mem::replace(&mut self.in_loop, true); let (mut loop_body, _) = self.gen_block(u, &Type::Unit)?; self.in_loop = was_in_loop; + if let Some((_, update, guard)) = &induction { + // Prepend in reverse order to get `if guard { break }; step; ` — a + // user-written break-on-condition loop rather than the synthetic `idx == max` one. + expr::prepend(&mut loop_body, update.clone()); + expr::prepend( + &mut loop_body, + expr::if_else( + guard.clone(), + Expression::Break, + Expression::Block(vec![]), + Type::Unit, + ), + ); + } + // Increment the index in the beginning of the body. expr::prepend( &mut loop_body, @@ -1823,7 +1844,89 @@ impl<'a> FunctionContext<'a> { Type::Unit, ); - Ok(Expression::Block(vec![let_idx, Expression::Loop(Box::new(loop_body))])) + let mut stmts = vec![let_idx]; + if let Some((decl, _, _)) = induction { + stmts.push(decl); + } + stmts.push(Expression::Loop(Box::new(loop_body))); + self.exit_scope(); + + Ok(Expression::Block(stmts)) + } + + + /// Declare a *user* induction variable for a `while`/`loop`: a mutable integer local the + /// body updates by an arbitrary (possibly negative) step, guarded by an arbitrary + /// comparison against an arbitrary bound. + /// + /// The synthetic `idx` counter that bounds every generated loop always starts at `0`, always + /// steps by `+1` and is always compared with `==`, which is the one shape loop analyses get + /// right. Loop-bound inference, induction-variable simplification and empty-loop detection + /// are instead wrong on decreasing induction, guards that oppose the update direction, + /// equality guards that are false on entry, and steps that skip the guard's sentinel — so + /// those are exactly what this generates. The synthetic counter still bounds the iteration + /// count, so a guard that never fires cannot hang the loop. + /// + /// Returns the statement declaring the variable, the update statement to place in the body, + /// and the guard expression. The variable is registered in the current scope, so the caller + /// must have entered one. + fn gen_user_induction( + &mut self, + u: &mut Unstructured, + ) -> arbitrary::Result<(Expression, Expression, Expression)> { + let signed = bool::arbitrary(u)?; + let typ = if signed { + Type::Integer(Signedness::Signed, IntegerBitSize::ThirtyTwo) + } else { + types::U32 + }; + + let local_id = self.next_local_id(); + let ident_id = self.next_ident_id(); + let name = format!("ind_{}", make_name(local_id.0 as usize, false)); + let ident = expr::ident_inner( + VariableId::Local(local_id), + ident_id, + true, + name.clone(), + Rc::new(typ.clone()), + ); + let ident_expr = Expression::Ident(ident.clone()); + + // Keep the values small so a checked update is unlikely to overflow before the + // synthetic counter stops the loop, and allow negative starts for signed types. + let start = u.int_in_range(if signed { -8 } else { 0 }..=8)?; + let step = u.int_in_range(1..=3)?; + let bound = u.int_in_range(if signed { -8 } else { 0 }..=8)?; + + let decl = expr::let_var( + local_id, + true, + name.clone(), + typ.clone(), + expr::int_literal(start, typ.clone()), + ); + self.locals.add(local_id, true, name, typ.clone()); + + // Half the time the update opposes the guard's direction, which is what makes bound + // inference and checked-to-unchecked rewrites interesting. + let op = if bool::arbitrary(u)? { BinaryOp::Add } else { BinaryOp::Subtract }; + let update = expr::assign_ident( + ident.clone(), + expr::binary(ident_expr.clone(), op, expr::int_literal(step, typ.clone())), + ); + + let cmp = u.choose(&[ + BinaryOp::Less, + BinaryOp::LessEqual, + BinaryOp::Greater, + BinaryOp::GreaterEqual, + BinaryOp::Equal, + BinaryOp::NotEqual, + ])?; + let guard = expr::binary(ident_expr, *cmp, expr::int_literal(bound, typ)); + + Ok((decl, update, guard)) } /// Generate a `while` loop. @@ -1846,11 +1949,23 @@ impl<'a> FunctionContext<'a> { typ: types::U32, })]; + // Half the time, drive the loop with a user induction variable rather than an arbitrary + // boolean condition, so the loop analyses see a guard tied to a variable the body steps. + self.enter_scope(); + let induction = + if u.ratio(1, 2)? { Some(self.gen_user_induction(u)?) } else { None }; + // Get the randomized loop body let was_in_loop = std::mem::replace(&mut self.in_loop, true); let (mut loop_body, _) = self.gen_block(u, &Type::Unit)?; self.in_loop = was_in_loop; + // Step the user induction variable at the top of the body. Appending it instead would + // have to survive a body whose last statement is `break`. + if let Some((_, update, _)) = &induction { + expr::prepend(&mut loop_body, update.clone()); + } + // Increment the index in the beginning of the body. expr::prepend( &mut loop_body, @@ -1870,7 +1985,14 @@ impl<'a> FunctionContext<'a> { )]); // Generate the `while` condition with depth 1 - let (condition, _) = self.gen_expr(u, &Type::Bool, 1, Flags::CONDITION)?; + let condition = match &induction { + Some((_, _, guard)) => guard.clone(), + None => self.gen_expr(u, &Type::Bool, 1, Flags::CONDITION)?.0, + }; + if let Some((decl, _, _)) = induction { + stmts.push(decl); + } + self.exit_scope(); stmts.push(Expression::While(While { condition: Box::new(condition), From a796d09ffc164e37867676f351d24583d69561cb Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 16:46:13 -0300 Subject: [PATCH 03/21] feat(fuzzer): generate #[fold] functions 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 --- tooling/ast_fuzzer/src/program/mod.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tooling/ast_fuzzer/src/program/mod.rs b/tooling/ast_fuzzer/src/program/mod.rs index d7d23ecbc1c..a87ddee03fb 100644 --- a/tooling/ast_fuzzer/src/program/mod.rs +++ b/tooling/ast_fuzzer/src/program/mod.rs @@ -328,10 +328,13 @@ impl Context { let inline_type = if is_main { InlineType::default() } else { - // Automatically include any new inline type, except the ones we don't want: #[fold] is deprecated + // 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| { - *it != InlineType::Fold && !(*it == InlineType::NoPredicates && unconstrained) + !(unconstrained && matches!(it, InlineType::Fold | InlineType::NoPredicates)) }) .collect::>(); *u.choose(&choices)? From 550109665e02eb32e7269d19949c8868d3703022 Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 16:49:39 -0300 Subject: [PATCH 04/21] feat(fuzzer): convert arrays to vectors with as_vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tooling/ast_fuzzer/src/program/func.rs | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index e395b2adbcd..c79711c1eed 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -744,6 +744,16 @@ impl<'a> FunctionContext<'a> { let expr = expr::ref_with_mut(src_expr, typ.as_ref().clone(), false); Ok(Some((expr, src_dyn))) } + // Convert an array into a vector with `as_vector`. This is how a fixed-size array + // 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. + (Type::Array(_, item_type), Type::Vector(tgt_item)) + if item_type == tgt_item && !self.in_no_dynamic => + { + let expr = self.call_as_vector(src_expr, src_type.clone(), tgt_type.clone()); + Ok(Some((expr, src_dyn))) + } // Index a non-empty array. (Type::Array(len, item_type), _) if *len > 0 => { // Indexing arrays that contains references with dynamic indexes was banned in #8888 @@ -2414,6 +2424,34 @@ impl<'a> FunctionContext<'a> { }) } + /// Construct a `Call` to the `as_vector` builtin, converting an array into a vector. + fn call_as_vector( + &mut self, + array: Expression, + array_type: Type, + vector_type: Type, + ) -> Expression { + let func_ident = Ident { + location: None, + definition: Definition::Builtin("as_vector".to_string()), + mutable: false, + name: "as_vector".to_string(), + typ: Rc::new(Type::Function( + vec![array_type], + Rc::new(vector_type.clone()), + Rc::new(Type::Unit), + false, + )), + id: self.next_ident_id(), + }; + Expression::Call(Call { + func: Box::new(Expression::Ident(func_ident)), + arguments: vec![array], + return_type: vector_type, + location: Location::dummy(), + }) + } + /// Construct a `Call` to one of the `vector_*` builtin functions. fn call_vector_builtin( &mut self, From 314c3dd6b5179e1468325dac4f15ee34dfb09cfd Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 16:53:29 -0300 Subject: [PATCH 05/21] feat(fuzzer): generate bit and radix decomposition intrinsics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tooling/ast_fuzzer/src/program/func.rs | 92 ++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index c79711c1eed..338482cdba2 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -744,6 +744,27 @@ impl<'a> FunctionContext<'a> { let expr = expr::ref_with_mut(src_expr, typ.as_ref().clone(), false); Ok(Some((expr, src_dyn))) } + // Decompose a numeric value into its bit or byte representation. + // + // `to_le_bits`/`to_be_bits`/`to_le_radix`/`to_be_radix` are implemented four times + // over — in the comptime interpreter, the SSA interpreter, ACIR gen and Brillig gen + // — and those implementations have to agree. The source value is first narrowed to + // an integer type whose width the output can always represent, so a program can + // never fail merely because the decomposition did not fit. + (Type::Integer(_, _) | Type::Field, Type::Array(len, item_type)) + if self.decomposition_target(*len, item_type).is_some() => + { + let (bits, is_radix) = self + .decomposition_target(*len, item_type) + .expect("checked by the guard above"); + let narrowed = expr::cast( + expr::cast(src_expr, Type::Integer(Signedness::Unsigned, bits)), + Type::Field, + ); + let expr = + self.call_decompose(u, narrowed, *len, item_type.as_ref().clone(), is_radix)?; + Ok(Some((expr, src_dyn))) + } // Convert an array into a vector with `as_vector`. This is how a fixed-size array // becomes dynamically sized in real Noir, and it is what promotes the value into // the runtime memory-block representation that vector intrinsics and the @@ -2424,6 +2445,77 @@ impl<'a> FunctionContext<'a> { }) } + /// If an array of `len` values of `item_type` is a valid target for a bit or radix + /// decomposition, return the integer width the input must be narrowed to and whether the + /// intrinsic is a radix (byte) rather than a bit decomposition. + /// + /// The width is chosen so every value of the narrowed type is representable in `len` + /// digits: `len` bits for a bit decomposition, `len` bytes for a radix-256 one. + fn decomposition_target( + &self, + len: u32, + item_type: &Type, + ) -> Option<(IntegerBitSize, bool)> { + let width = |bits: u32| { + IntegerBitSize::iter() + .filter(|bs| u32::from(bs.bit_size()) <= bits) + .max_by_key(|bs| bs.bit_size()) + }; + match item_type { + Type::Bool => width(len).map(|bits| (bits, false)), + Type::Integer(Signedness::Unsigned, IntegerBitSize::Eight) => { + width(len.saturating_mul(8)).map(|bits| (bits, true)) + } + _ => None, + } + } + + /// Construct a `Call` to one of the bit/radix decomposition builtins. + fn call_decompose( + &mut self, + u: &mut Unstructured, + value: Expression, + len: u32, + item_type: Type, + is_radix: bool, + ) -> arbitrary::Result { + let little_endian = bool::arbitrary(u)?; + let name = match (is_radix, little_endian) { + (false, true) => "to_le_bits", + (false, false) => "to_be_bits", + (true, true) => "to_le_radix", + (true, false) => "to_be_radix", + }; + let return_type = Type::Array(len, Rc::new(item_type)); + // The radix intrinsics take the radix as a second argument; 256 pairs with the `u8` + // element type the guard requires. + let mut arg_types = vec![Type::Field]; + let mut args = vec![value]; + if is_radix { + arg_types.push(types::U32); + args.push(expr::u32_literal(256)); + } + let func_ident = Ident { + location: None, + definition: Definition::Builtin(name.to_string()), + mutable: false, + name: name.to_string(), + typ: Rc::new(Type::Function( + arg_types, + Rc::new(return_type.clone()), + Rc::new(Type::Unit), + false, + )), + id: self.next_ident_id(), + }; + Ok(Expression::Call(Call { + func: Box::new(Expression::Ident(func_ident)), + arguments: args, + return_type, + location: Location::dummy(), + })) + } + /// Construct a `Call` to the `as_vector` builtin, converting an array into a vector. fn call_as_vector( &mut self, From 15f889daaaeeca48d75451bcb7df5163618f0e5c Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 16:59:05 -0300 Subject: [PATCH 06/21] feat(fuzzer): convert strings to byte arrays with str_as_bytes 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 --- tooling/ast_fuzzer/src/program/func.rs | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index 338482cdba2..440b3852b3a 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -744,6 +744,20 @@ impl<'a> FunctionContext<'a> { let expr = expr::ref_with_mut(src_expr, typ.as_ref().clone(), false); 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. + (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))) + } // Decompose a numeric value into its bit or byte representation. // // `to_le_bits`/`to_be_bits`/`to_le_radix`/`to_be_radix` are implemented four times @@ -2445,6 +2459,29 @@ impl<'a> FunctionContext<'a> { }) } + /// Construct a `Call` to the `str_as_bytes` builtin. + fn call_str_as_bytes(&mut self, value: Expression, len: u32, bytes_type: Type) -> Expression { + let func_ident = Ident { + location: None, + definition: Definition::Builtin("str_as_bytes".to_string()), + mutable: false, + name: "as_bytes".to_string(), + typ: Rc::new(Type::Function( + vec![Type::String(len)], + Rc::new(bytes_type.clone()), + Rc::new(Type::Unit), + false, + )), + id: self.next_ident_id(), + }; + Expression::Call(Call { + func: Box::new(Expression::Ident(func_ident)), + arguments: vec![value], + return_type: bytes_type, + location: Location::dummy(), + }) + } + /// If an array of `len` values of `item_type` is a valid target for a bit or radix /// decomposition, return the integer width the input must be narrowed to and whether the /// intrinsic is a radix (byte) rather than a bit decomposition. From 29dc3371116a0f49879f07334f9a9f70ff68ece6 Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 17:06:29 -0300 Subject: [PATCH 07/21] feat(fuzzer): print through format strings 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 --- tooling/ast_fuzzer/src/program/func.rs | 96 ++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 5 deletions(-) diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index 440b3852b3a..bc76e1f2f4a 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -22,6 +22,7 @@ use noirc_frontend::{ }, }, shared::{Signedness, Visibility}, + token::FmtStrFragment, }; use super::{ @@ -1538,25 +1539,44 @@ impl<'a> FunctionContext<'a> { .locals .current() .variables() - .filter_map(|(id, (_, _, typ))| types::is_printable(typ).then_some((id, typ))) + .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) - .collect::>(); + .collect::>(); if opts.is_empty() { return Ok(None); } + // Half the time, print through a format string rather than printing a value as-is. + // A `f"..."` literal carries its interpolated values in a separate tuple alongside the + // fragment list, and that pairing has to survive monomorphization and the comptime + // interpreter; it is the only literal form no generated program was producing. + let fmt_opts = opts + .iter() + .filter(|(_, typ)| !types::is_function(typ)) + .cloned() + .collect::>(); + // Only in unconstrained functions: a constrained `println` is routed through a proxy + // function generated once per signature in a later pass, which does not know how to key + // the per-interpolation metadata a format string carries. + if self.unconstrained() + && u.ratio(1, 2)? + && let Some(call) = self.gen_print_fmt_str(u, &fmt_opts)? + { + return Ok(Some(call)); + } + // Print one of the variables as-is. - let (id, typ) = u.choose_iter(opts)?; - let id = *id; + let (id, typ) = u.choose_iter(opts.iter())?; + let (id, typ) = (*id, typ.clone()); // The print oracle takes 2 parameters: the newline marker and the value, // but it takes 2 more arguments: the type descriptor and the format string marker, // which are inserted automatically by the monomorphizer. let param_types = vec![Type::Bool, typ.clone()]; - let hir_type = types::to_hir_type(typ); + let hir_type = types::to_hir_type(&typ); let ident = self.local_ident(id); // Functions need to be passed as a tuple. @@ -1600,6 +1620,72 @@ impl<'a> FunctionContext<'a> { Ok(Some(call)) } + /// Generate a `println` of a format string interpolating one or two printable locals. + /// + /// Function-typed locals are excluded by the caller: they would print as "(function)" and + /// the tuple element type could not be described by the printable-type metadata. + fn gen_print_fmt_str( + &mut self, + u: &mut Unstructured, + opts: &[(LocalId, Type)], + ) -> arbitrary::Result> { + if opts.is_empty() { + return Ok(None); + } + + let count = u.int_in_range(1..=opts.len().min(2))?; + let mut fragments = Vec::new(); + let mut values = Vec::new(); + let mut value_types = Vec::new(); + fragments.push(FmtStrFragment::String("v".to_string())); + for i in 0..count { + let (id, typ) = u.choose(&opts)?.clone(); + if i > 0 { + fragments.push(FmtStrFragment::String(" ".to_string())); + } + let ident = self.local_ident(id); + fragments.push(FmtStrFragment::Interpolation(ident.name.clone(), Location::dummy())); + values.push(Expression::Ident(ident)); + value_types.push(typ); + } + + // `Literal::FmtStr`'s second field is the number of interpolated variables, which is + // also what `Expression::return_type` uses as the `FmtString` size. + let count = count as u32; + let values_type = Type::Tuple(value_types); + let fmt_type = Type::FmtString(count, Rc::new(values_type)); + let fmt_literal = Expression::Literal(Literal::FmtStr( + fragments, + u64::from(count), + Box::new(Expression::Tuple(values)), + )); + + let param_types = vec![Type::Bool, fmt_type.clone()]; + let mut args = vec![expr::lit_bool(true), fmt_literal]; + append_printable_type_info_for_type(types::to_hir_type(&fmt_type), &mut args); + + let print_oracle_ident = Ident { + location: None, + definition: Definition::Oracle { name: "print".to_string(), pure: false }, + mutable: false, + name: "print_oracle".to_string(), + typ: Rc::new(Type::Function( + param_types, + Rc::new(Type::Unit), + Rc::new(Type::Unit), + true, + )), + id: self.next_ident_id(), + }; + + Ok(Some(Expression::Call(Call { + func: Box::new(Expression::Ident(print_oracle_ident)), + arguments: args, + return_type: Type::Unit, + location: Location::dummy(), + }))) + } + /// Generate a `constrain` statement, if there is some local variable we can do it on. /// /// Arbitrary constraints are very likely to fail, so we don't want too many of them, From 7e1e29ceb0cac48defe79e710e1d32aa8a110873 Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 17:10:03 -0300 Subject: [PATCH 08/21] chore(ci): raise the nightly fuzzing budget to 30 minutes per target 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 --- .github/workflows/nightly-fuzz-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly-fuzz-test.yml b/.github/workflows/nightly-fuzz-test.yml index c9bc06d3e71..2d058500e55 100644 --- a/.github/workflows/nightly-fuzz-test.yml +++ b/.github/workflows/nightly-fuzz-test.yml @@ -14,7 +14,7 @@ concurrency: env: # How long should we run the fuzzing for, in seconds. - NOIR_AST_FUZZER_BUDGET_SECS: 300 + NOIR_AST_FUZZER_BUDGET_SECS: 1800 jobs: ast-fuzz: From 322f2d8e7841b213a3bcf122f0d176bca260766b Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 17:21:15 -0300 Subject: [PATCH 09/21] feat(fuzzer): pass function values inside tuples and arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tooling/ast_fuzzer/src/program/func.rs | 71 +++++++++++++++++++++++++ tooling/ast_fuzzer/src/program/mod.rs | 9 ++++ tooling/ast_fuzzer/src/program/types.rs | 10 ++++ tooling/ast_fuzzer/tests/smoke.rs | 1 + 4 files changed, 91 insertions(+) diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index bc76e1f2f4a..94d06e18854 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -559,10 +559,77 @@ impl<'a> FunctionContext<'a> { return Ok(expr); } + // A composite that holds a function has no literal form — there is no way to write a + // function value as a literal — so build it out of its parts instead, letting each + // function element resolve to a function in scope or a global one. + if types::contains_function(typ) { + return self.gen_composite_with_function(u, typ, max_depth, flags); + } + // If nothing else worked out we can always produce a random literal. self.gen_literal(u, typ).map(|expr| (expr, false)) } + /// Build a value of a composite type that holds a function, element by element. + /// + /// [`expr::gen_literal`] cannot do this: a function value can only come from a function in + /// scope or a global one, which it has no access to. + fn gen_composite_with_function( + &mut self, + u: &mut Unstructured, + typ: &Type, + max_depth: usize, + flags: Flags, + ) -> arbitrary::Result { + match typ { + Type::Tuple(items) => { + let mut values = Vec::new(); + let mut is_dyn = false; + for item in items { + let (value, dyn_item) = self.gen_expr(u, item, max_depth, flags)?; + values.push(value); + is_dyn |= dyn_item; + } + Ok((Expression::Tuple(values), is_dyn)) + } + Type::Array(len, item) => { + let mut contents = Vec::new(); + let mut is_dyn = false; + for _ in 0..*len { + let (value, dyn_item) = self.gen_expr(u, item, max_depth, flags)?; + contents.push(value); + is_dyn |= dyn_item; + } + let arr = ArrayLiteral { contents, typ: typ.clone() }; + Ok((Expression::Literal(Literal::Array(arr)), is_dyn)) + } + Type::Vector(item) => { + let len = u.int_in_range(0..=self.config().max_array_size)?; + let mut contents = Vec::new(); + let mut is_dyn = false; + for _ in 0..len { + let (value, dyn_item) = self.gen_expr(u, item, max_depth, flags)?; + contents.push(value); + is_dyn |= dyn_item; + } + let arr = ArrayLiteral { contents, typ: typ.clone() }; + Ok((Expression::Literal(Literal::Vector(arr)), is_dyn)) + } + Type::Reference(inner, _) => { + // Mirror how a bare function reference is produced: an immutable global ident + // has to be bound to a variable before a reference can be taken over it. + let (expr, is_dyn) = self.gen_expr(u, inner, max_depth, flags)?; + let expr = if expr::is_immutable_ident(&expr) { + self.indirect_ref_mut((expr, is_dyn), inner.as_ref().clone()) + } else { + expr::ref_mut(expr, inner.as_ref().clone()) + }; + Ok((expr, is_dyn)) + } + other => unreachable!("not a composite holding a function: {other}"), + } + } + /// Try to generate an expression with a certain type out of the variables in scope. fn gen_expr_from_vars( &mut self, @@ -1540,6 +1607,10 @@ impl<'a> FunctionContext<'a> { .current() .variables() .filter_map(|(id, (_, _, typ))| types::is_printable(typ).then_some((*id, typ.clone()))) + // A bare function is printed by passing it as a pair of idents, but a function + // nested in a composite has no such encoding: the printable-type metadata would + // describe more values than the call supplies. + .filter(|(_, typ)| types::is_function(typ) || !types::contains_function(typ)) // 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) diff --git a/tooling/ast_fuzzer/src/program/mod.rs b/tooling/ast_fuzzer/src/program/mod.rs index a87ddee03fb..df7a01cbe67 100644 --- a/tooling/ast_fuzzer/src/program/mod.rs +++ b/tooling/ast_fuzzer/src/program/mod.rs @@ -297,6 +297,15 @@ impl Context { Rc::new(Type::Unit), callee.unconstrained, ); + // Sometimes bury the function inside a composite. Defunctionalization builds its + // dispatch table by finding function values, and reaching one through a tuple or + // an array exercises that discovery, and the `apply` dispatch it generates, + // rather than only the bare-parameter case. + let typ = match u.choose_index(4)? { + 0 => Type::Tuple(vec![typ, Type::Field]), + 1 => Type::Array(u.int_in_range(1..=2)?, Rc::new(typ)), + _ => typ, + }; if u.ratio(2, 5)? { types::ref_mut(typ) } else { typ } }; diff --git a/tooling/ast_fuzzer/src/program/types.rs b/tooling/ast_fuzzer/src/program/types.rs index 0a31b077e9e..376d0277d2d 100644 --- a/tooling/ast_fuzzer/src/program/types.rs +++ b/tooling/ast_fuzzer/src/program/types.rs @@ -153,6 +153,16 @@ pub fn is_reference(typ: &Type) -> bool { } /// Check if the type is a function. +/// True if the type is a function, or a composite that holds one. +pub(crate) fn contains_function(typ: &Type) -> bool { + match typ { + Type::Function(_, _, _, _) => true, + Type::Array(_, typ) | Type::Vector(typ) | Type::Reference(typ, _) => contains_function(typ), + Type::Tuple(types) => types.iter().any(contains_function), + _ => false, + } +} + pub(crate) fn is_function(typ: &Type) -> bool { matches!(typ, Type::Function(_, _, _, _)) } diff --git a/tooling/ast_fuzzer/tests/smoke.rs b/tooling/ast_fuzzer/tests/smoke.rs index 6a039908edc..f6bd700a0ee 100644 --- a/tooling/ast_fuzzer/tests/smoke.rs +++ b/tooling/ast_fuzzer/tests/smoke.rs @@ -88,3 +88,4 @@ fn arb_program_can_be_executed() { CI_CASES, ); } + From bd980eb79fa0108c96c5defa4b88c6243d98d9dc Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 17:24:43 -0300 Subject: [PATCH 10/21] feat(fuzzer): reborrow a field of a referenced tuple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `&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 --- tooling/ast_fuzzer/src/program/func.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index 94d06e18854..7f7ea5c0dce 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -798,6 +798,30 @@ impl<'a> FunctionContext<'a> { let expr = expr::deref(src_expr, tgt_type.clone()); Ok(Some((expr, src_dyn))) } + // Reborrow a field of a referenced tuple: `&mut (*r).i`. + // + // The reborrow has to alias the field in place. Copying it into a fresh allocation + // instead silently detaches the two, so a write through the reborrow never reaches + // 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) + if fields.iter().any(|field| field == field_type.as_ref())) => + { + let Type::Tuple(fields) = inner.as_ref() else { + unreachable!("checked by the guard above"); + }; + let candidates = fields + .iter() + .enumerate() + .filter(|(_, field)| *field == field_type.as_ref()) + .map(|(i, _)| i) + .collect::>(); + let field_index = *u.choose(&candidates)?; + let deref = expr::deref(src_expr, inner.as_ref().clone()); + let field = Expression::ExtractTupleField(Box::new(deref), field_index); + let expr = expr::ref_mut(field, field_type.as_ref().clone()); + Ok(Some((expr, src_dyn))) + } // Mutable reference over the source type. (_, Type::Reference(typ, true)) if typ.as_ref() == src_type => { let expr = if src_mutable { From a7e5c73955e5e03954501d8bfd0e89c361ebbb40 Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Wed, 5 Aug 2026 18:24:10 -0300 Subject: [PATCH 11/21] fix(fuzzer): do not morph the operand of an immutable reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs b/tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs index 92e008c6d7f..22a7e2ff6cf 100644 --- a/tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs +++ b/tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs @@ -199,9 +199,11 @@ impl MorphContext<'_> { // No need to visit children, we just visited them. false } - Expression::Unary( - unary @ Unary { operator: UnaryOp::Reference { mutable: true }, .. }, - ) => { + Expression::Unary(unary @ Unary { operator: UnaryOp::Reference { .. }, .. }) => { + // Both `&mut x` and `&x` alias what they point at, so rewriting the operand + // into an equal-valued expression changes the meaning: `&(x ^ (x ^ x))` + // references a temporary, and a later write to `x` is no longer observed + // through it. let ctx = rules::Context { is_in_ref_mut: true, ..*ctx }; self.rewrite_expr(&ctx, u, &mut unary.rhs); false @@ -319,7 +321,7 @@ mod rules { pub unconstrained: bool, /// Are we rewriting an expression which is a `start` or `end` of a `for` loop? pub is_in_range: bool, - /// Are we in an expression that we're just taking a mutable reference to? + /// Are we in an expression that we're taking a reference to, mutable or not? pub is_in_ref_mut: bool, /// Are we processing the arguments of an non-user function call, such as an oracle or built-in? pub is_in_special_call: bool, From dc570ec1d7e800cc66eba41d8f89f344442d35bd Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Thu, 6 Aug 2026 09:24:03 -0300 Subject: [PATCH 12/21] chore(fuzzer): satisfy clippy and rustfmt Co-Authored-By: Claude Fable 5 --- tooling/ast_fuzzer/src/program/func.rs | 29 ++++++++------------------ tooling/ast_fuzzer/tests/smoke.rs | 1 - 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index 7f7ea5c0dce..7e54bad7b43 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -860,9 +860,8 @@ impl<'a> FunctionContext<'a> { (Type::Integer(_, _) | Type::Field, Type::Array(len, item_type)) if self.decomposition_target(*len, item_type).is_some() => { - let (bits, is_radix) = self - .decomposition_target(*len, item_type) - .expect("checked by the guard above"); + let (bits, is_radix) = + self.decomposition_target(*len, item_type).expect("checked by the guard above"); let narrowed = expr::cast( expr::cast(src_expr, Type::Integer(Signedness::Unsigned, bits)), Type::Field, @@ -1648,11 +1647,8 @@ impl<'a> FunctionContext<'a> { // A `f"..."` literal carries its interpolated values in a separate tuple alongside the // fragment list, and that pairing has to survive monomorphization and the comptime // interpreter; it is the only literal form no generated program was producing. - let fmt_opts = opts - .iter() - .filter(|(_, typ)| !types::is_function(typ)) - .cloned() - .collect::>(); + let fmt_opts = + opts.iter().filter(|(_, typ)| !types::is_function(typ)).cloned().collect::>(); // Only in unconstrained functions: a constrained `println` is routed through a proxy // function generated once per signature in a later pass, which does not know how to key // the per-interpolation metadata a format string carries. @@ -1734,7 +1730,7 @@ impl<'a> FunctionContext<'a> { let mut value_types = Vec::new(); fragments.push(FmtStrFragment::String("v".to_string())); for i in 0..count { - let (id, typ) = u.choose(&opts)?.clone(); + let (id, typ) = u.choose(opts)?.clone(); if i > 0 { fragments.push(FmtStrFragment::String(" ".to_string())); } @@ -2029,8 +2025,7 @@ impl<'a> FunctionContext<'a> { // Half the time, give the loop a user induction variable and a user break guard, so the // exit condition is not always the synthetic `idx == max` shape. self.enter_scope(); - let induction = - if u.ratio(1, 2)? { Some(self.gen_user_induction(u)?) } else { None }; + let induction = if u.ratio(1, 2)? { Some(self.gen_user_induction(u)?) } else { None }; // Get the randomized loop body let was_in_loop = std::mem::replace(&mut self.in_loop, true); @@ -2080,7 +2075,6 @@ impl<'a> FunctionContext<'a> { Ok(Expression::Block(stmts)) } - /// Declare a *user* induction variable for a `while`/`loop`: a mutable integer local the /// body updates by an arbitrary (possibly negative) step, guarded by an arbitrary /// comparison against an arbitrary bound. @@ -2138,7 +2132,7 @@ impl<'a> FunctionContext<'a> { // inference and checked-to-unchecked rewrites interesting. let op = if bool::arbitrary(u)? { BinaryOp::Add } else { BinaryOp::Subtract }; let update = expr::assign_ident( - ident.clone(), + ident, expr::binary(ident_expr.clone(), op, expr::int_literal(step, typ.clone())), ); @@ -2178,8 +2172,7 @@ impl<'a> FunctionContext<'a> { // Half the time, drive the loop with a user induction variable rather than an arbitrary // boolean condition, so the loop analyses see a guard tied to a variable the body steps. self.enter_scope(); - let induction = - if u.ratio(1, 2)? { Some(self.gen_user_induction(u)?) } else { None }; + let induction = if u.ratio(1, 2)? { Some(self.gen_user_induction(u)?) } else { None }; // Get the randomized loop body let was_in_loop = std::mem::replace(&mut self.in_loop, true); @@ -2669,11 +2662,7 @@ impl<'a> FunctionContext<'a> { /// /// The width is chosen so every value of the narrowed type is representable in `len` /// digits: `len` bits for a bit decomposition, `len` bytes for a radix-256 one. - fn decomposition_target( - &self, - len: u32, - item_type: &Type, - ) -> Option<(IntegerBitSize, bool)> { + fn decomposition_target(&self, len: u32, item_type: &Type) -> Option<(IntegerBitSize, bool)> { let width = |bits: u32| { IntegerBitSize::iter() .filter(|bs| u32::from(bs.bit_size()) <= bits) diff --git a/tooling/ast_fuzzer/tests/smoke.rs b/tooling/ast_fuzzer/tests/smoke.rs index f6bd700a0ee..6a039908edc 100644 --- a/tooling/ast_fuzzer/tests/smoke.rs +++ b/tooling/ast_fuzzer/tests/smoke.rs @@ -88,4 +88,3 @@ fn arb_program_can_be_executed() { CI_CASES, ); } - From 00613f68e82638bf03c47ab71042441e9c5475a5 Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Thu, 6 Aug 2026 09:36:09 -0300 Subject: [PATCH 13/21] chore(fuzzer): update the loop shape tests for user induction variables 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 --- tooling/ast_fuzzer/src/program/func.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index 7e54bad7b43..bda26343871 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -2886,17 +2886,27 @@ mod tests { function_ctx.budget = 2; let loop_code = format!("{}", function_ctx.gen_loop(&mut u).unwrap()).replace(" ", ""); + // `ind_b` is the user induction variable: the loop is driven by a variable the body + // steps and a guard the generator chose, while `idx_a` remains the harness counter that + // bounds the iteration count no matter what the guard does. assert!( loop_code.starts_with( &r#"{ let mut idx_a$l0 = 0; + let mut ind_b$l1 = 0; loop { if (idx_a$l0 == 10) { break } else { - idx_a$l0 = (idx_a$l0 + 1);"# + idx_a$l0 = (idx_a$l0 + 1); + if (ind_b$l1 < 0) { + break + } else { + }; + ind_b$l1 = (ind_b$l1 - 1);"# .replace(" ", "") - ) + ), + "{loop_code}" ); } @@ -2911,17 +2921,22 @@ mod tests { function_ctx.budget = 2; let while_code = format!("{}", function_ctx.gen_while(&mut u).unwrap()).replace(" ", ""); + // The `while` guard is the user induction variable's comparison, and `ind_b` is stepped + // inside the body; `idx_a` is the harness counter that still bounds the iterations. assert!( while_code.starts_with( &r#"{ let mut idx_a$l0 = 0; - while (!false) { + let mut ind_b$l1 = 0; + while (ind_b$l1 < 0) { if (idx_a$l0 == 10) { break } else { - idx_a$l0 = (idx_a$l0 + 1)"# + idx_a$l0 = (idx_a$l0 + 1); + ind_b$l1 = (ind_b$l1 - 1)"# .replace(" ", "") - ) + ), + "{while_code}" ); } } From ab13f32c9a998129bf8ed35dda8c989dc1a93516 Mon Sep 17 00:00:00 2001 From: Ary Borenszweig Date: Thu, 6 Aug 2026 10:41:21 -0300 Subject: [PATCH 14/21] fix(fuzzer): print method builtins as methods, drop unprintable ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/monomorphization/printer.rs | 9 +- tooling/ast_fuzzer/src/program/func.rs | 191 ++---------------- tooling/ast_fuzzer/src/program/mod.rs | 9 - tooling/ast_fuzzer/src/program/types.rs | 10 - 4 files changed, 30 insertions(+), 189 deletions(-) diff --git a/compiler/noirc_frontend/src/monomorphization/printer.rs b/compiler/noirc_frontend/src/monomorphization/printer.rs index 331449b2002..18483be2d76 100644 --- a/compiler/noirc_frontend/src/monomorphization/printer.rs +++ b/compiler/noirc_frontend/src/monomorphization/printer.rs @@ -580,7 +580,14 @@ impl AstPrinter { let is_unsafe = unconstrained && !self.in_unconstrained; let special = match definition { Definition::Oracle { name: s, .. } if s == "print" => Some(SpecialCall::Print), - Definition::Builtin(s) if s.starts_with("array") || s.starts_with("vector") => { + // Builtins that are written as methods in Noir source have to be printed that + // way, or the printed program does not parse back: `as_bytes(s)` is not a + // function in scope, `s.as_bytes()` is. + Definition::Builtin(s) + if s.starts_with("array") + || s.starts_with("vector") + || matches!(s.as_str(), "as_vector" | "str_as_bytes") => + { Some(SpecialCall::Object(name.clone())) } _ => None, diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index bda26343871..facc8c94f31 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -559,77 +559,10 @@ impl<'a> FunctionContext<'a> { return Ok(expr); } - // A composite that holds a function has no literal form — there is no way to write a - // function value as a literal — so build it out of its parts instead, letting each - // function element resolve to a function in scope or a global one. - if types::contains_function(typ) { - return self.gen_composite_with_function(u, typ, max_depth, flags); - } - // If nothing else worked out we can always produce a random literal. self.gen_literal(u, typ).map(|expr| (expr, false)) } - /// Build a value of a composite type that holds a function, element by element. - /// - /// [`expr::gen_literal`] cannot do this: a function value can only come from a function in - /// scope or a global one, which it has no access to. - fn gen_composite_with_function( - &mut self, - u: &mut Unstructured, - typ: &Type, - max_depth: usize, - flags: Flags, - ) -> arbitrary::Result { - match typ { - Type::Tuple(items) => { - let mut values = Vec::new(); - let mut is_dyn = false; - for item in items { - let (value, dyn_item) = self.gen_expr(u, item, max_depth, flags)?; - values.push(value); - is_dyn |= dyn_item; - } - Ok((Expression::Tuple(values), is_dyn)) - } - Type::Array(len, item) => { - let mut contents = Vec::new(); - let mut is_dyn = false; - for _ in 0..*len { - let (value, dyn_item) = self.gen_expr(u, item, max_depth, flags)?; - contents.push(value); - is_dyn |= dyn_item; - } - let arr = ArrayLiteral { contents, typ: typ.clone() }; - Ok((Expression::Literal(Literal::Array(arr)), is_dyn)) - } - Type::Vector(item) => { - let len = u.int_in_range(0..=self.config().max_array_size)?; - let mut contents = Vec::new(); - let mut is_dyn = false; - for _ in 0..len { - let (value, dyn_item) = self.gen_expr(u, item, max_depth, flags)?; - contents.push(value); - is_dyn |= dyn_item; - } - let arr = ArrayLiteral { contents, typ: typ.clone() }; - Ok((Expression::Literal(Literal::Vector(arr)), is_dyn)) - } - Type::Reference(inner, _) => { - // Mirror how a bare function reference is produced: an immutable global ident - // has to be bound to a variable before a reference can be taken over it. - let (expr, is_dyn) = self.gen_expr(u, inner, max_depth, flags)?; - let expr = if expr::is_immutable_ident(&expr) { - self.indirect_ref_mut((expr, is_dyn), inner.as_ref().clone()) - } else { - expr::ref_mut(expr, inner.as_ref().clone()) - }; - Ok((expr, is_dyn)) - } - other => unreachable!("not a composite holding a function: {other}"), - } - } - /// Try to generate an expression with a certain type out of the variables in scope. fn gen_expr_from_vars( &mut self, @@ -850,24 +783,18 @@ impl<'a> FunctionContext<'a> { let expr = self.call_str_as_bytes(src_expr, *len, tgt_type.clone()); Ok(Some((expr, src_dyn))) } - // Decompose a numeric value into its bit or byte representation. + // Reinterpret a string as its byte array with `str_as_bytes`. // - // `to_le_bits`/`to_be_bits`/`to_le_radix`/`to_be_radix` are implemented four times - // over — in the comptime interpreter, the SSA interpreter, ACIR gen and Brillig gen - // — and those implementations have to agree. The source value is first narrowed to - // an integer type whose width the output can always represent, so a program can - // never fail merely because the decomposition did not fit. - (Type::Integer(_, _) | Type::Field, Type::Array(len, item_type)) - if self.decomposition_target(*len, item_type).is_some() => + // 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 (bits, is_radix) = - self.decomposition_target(*len, item_type).expect("checked by the guard above"); - let narrowed = expr::cast( - expr::cast(src_expr, Type::Integer(Signedness::Unsigned, bits)), - Type::Field, - ); - let expr = - self.call_decompose(u, narrowed, *len, item_type.as_ref().clone(), is_radix)?; + let expr = self.call_str_as_bytes(src_expr, *len, tgt_type.clone()); Ok(Some((expr, src_dyn))) } // Convert an array into a vector with `as_vector`. This is how a fixed-size array @@ -1630,10 +1557,6 @@ impl<'a> FunctionContext<'a> { .current() .variables() .filter_map(|(id, (_, _, typ))| types::is_printable(typ).then_some((*id, typ.clone()))) - // A bare function is printed by passing it as a pair of idents, but a function - // nested in a composite has no such encoding: the printable-type metadata would - // describe more values than the call supplies. - .filter(|(_, typ)| types::is_function(typ) || !types::contains_function(typ)) // 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) @@ -1724,21 +1647,18 @@ impl<'a> FunctionContext<'a> { return Ok(None); } - let count = u.int_in_range(1..=opts.len().min(2))?; - let mut fragments = Vec::new(); - let mut values = Vec::new(); - let mut value_types = Vec::new(); - fragments.push(FmtStrFragment::String("v".to_string())); - for i in 0..count { - let (id, typ) = u.choose(opts)?.clone(); - if i > 0 { - fragments.push(FmtStrFragment::String(" ".to_string())); - } - let ident = self.local_ident(id); - fragments.push(FmtStrFragment::Interpolation(ident.name.clone(), Location::dummy())); - values.push(Expression::Ident(ident)); - value_types.push(typ); - } + // Exactly one interpolation: the monomorphized `print` call takes the value, one piece + // of type metadata and the format-string marker, and the printer asserts that shape. + // A second interpolation would add another metadata argument and break it. + let count = 1; + let (id, typ) = u.choose(opts)?.clone(); + let ident = self.local_ident(id); + let fragments = vec![ + FmtStrFragment::String("v".to_string()), + FmtStrFragment::Interpolation(ident.name.clone(), Location::dummy()), + ]; + let values = vec![Expression::Ident(ident)]; + let value_types = vec![typ]; // `Literal::FmtStr`'s second field is the number of interpolated variables, which is // also what `Expression::return_type` uses as the `FmtString` size. @@ -2656,73 +2576,6 @@ impl<'a> FunctionContext<'a> { }) } - /// If an array of `len` values of `item_type` is a valid target for a bit or radix - /// decomposition, return the integer width the input must be narrowed to and whether the - /// intrinsic is a radix (byte) rather than a bit decomposition. - /// - /// The width is chosen so every value of the narrowed type is representable in `len` - /// digits: `len` bits for a bit decomposition, `len` bytes for a radix-256 one. - fn decomposition_target(&self, len: u32, item_type: &Type) -> Option<(IntegerBitSize, bool)> { - let width = |bits: u32| { - IntegerBitSize::iter() - .filter(|bs| u32::from(bs.bit_size()) <= bits) - .max_by_key(|bs| bs.bit_size()) - }; - match item_type { - Type::Bool => width(len).map(|bits| (bits, false)), - Type::Integer(Signedness::Unsigned, IntegerBitSize::Eight) => { - width(len.saturating_mul(8)).map(|bits| (bits, true)) - } - _ => None, - } - } - - /// Construct a `Call` to one of the bit/radix decomposition builtins. - fn call_decompose( - &mut self, - u: &mut Unstructured, - value: Expression, - len: u32, - item_type: Type, - is_radix: bool, - ) -> arbitrary::Result { - let little_endian = bool::arbitrary(u)?; - let name = match (is_radix, little_endian) { - (false, true) => "to_le_bits", - (false, false) => "to_be_bits", - (true, true) => "to_le_radix", - (true, false) => "to_be_radix", - }; - let return_type = Type::Array(len, Rc::new(item_type)); - // The radix intrinsics take the radix as a second argument; 256 pairs with the `u8` - // element type the guard requires. - let mut arg_types = vec![Type::Field]; - let mut args = vec![value]; - if is_radix { - arg_types.push(types::U32); - args.push(expr::u32_literal(256)); - } - let func_ident = Ident { - location: None, - definition: Definition::Builtin(name.to_string()), - mutable: false, - name: name.to_string(), - typ: Rc::new(Type::Function( - arg_types, - Rc::new(return_type.clone()), - Rc::new(Type::Unit), - false, - )), - id: self.next_ident_id(), - }; - Ok(Expression::Call(Call { - func: Box::new(Expression::Ident(func_ident)), - arguments: args, - return_type, - location: Location::dummy(), - })) - } - /// Construct a `Call` to the `as_vector` builtin, converting an array into a vector. fn call_as_vector( &mut self, diff --git a/tooling/ast_fuzzer/src/program/mod.rs b/tooling/ast_fuzzer/src/program/mod.rs index df7a01cbe67..a87ddee03fb 100644 --- a/tooling/ast_fuzzer/src/program/mod.rs +++ b/tooling/ast_fuzzer/src/program/mod.rs @@ -297,15 +297,6 @@ impl Context { Rc::new(Type::Unit), callee.unconstrained, ); - // Sometimes bury the function inside a composite. Defunctionalization builds its - // dispatch table by finding function values, and reaching one through a tuple or - // an array exercises that discovery, and the `apply` dispatch it generates, - // rather than only the bare-parameter case. - let typ = match u.choose_index(4)? { - 0 => Type::Tuple(vec![typ, Type::Field]), - 1 => Type::Array(u.int_in_range(1..=2)?, Rc::new(typ)), - _ => typ, - }; if u.ratio(2, 5)? { types::ref_mut(typ) } else { typ } }; diff --git a/tooling/ast_fuzzer/src/program/types.rs b/tooling/ast_fuzzer/src/program/types.rs index 376d0277d2d..0a31b077e9e 100644 --- a/tooling/ast_fuzzer/src/program/types.rs +++ b/tooling/ast_fuzzer/src/program/types.rs @@ -153,16 +153,6 @@ pub fn is_reference(typ: &Type) -> bool { } /// Check if the type is a function. -/// True if the type is a function, or a composite that holds one. -pub(crate) fn contains_function(typ: &Type) -> bool { - match typ { - Type::Function(_, _, _, _) => true, - Type::Array(_, typ) | Type::Vector(typ) | Type::Reference(typ, _) => contains_function(typ), - Type::Tuple(types) => types.iter().any(contains_function), - _ => false, - } -} - pub(crate) fn is_function(typ: &Type) -> bool { matches!(typ, Type::Function(_, _, _, _)) } From 311b8ddf4c2a75a5822e9ba28416485c5d91c438 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 10 Aug 2026 16:15:02 +0000 Subject: [PATCH 15/21] fix(fuzzer): make the widened AST generation actually fire --- tooling/ast_fuzzer/src/lib.rs | 20 ++++ tooling/ast_fuzzer/src/program/func.rs | 77 +++++++++---- tooling/ast_fuzzer/src/program/mod.rs | 39 +++++-- .../ast_fuzzer/src/program/rewrite/limit.rs | 93 ++++++++++++---- tooling/ast_fuzzer/src/program/rewrite/mod.rs | 4 + tooling/ast_fuzzer/src/program/scope.rs | 7 +- tooling/ast_fuzzer/src/program/tests.rs | 105 ++++++++++++++++++ tooling/ast_fuzzer/src/program/types.rs | 17 ++- 8 files changed, 307 insertions(+), 55 deletions(-) diff --git a/tooling/ast_fuzzer/src/lib.rs b/tooling/ast_fuzzer/src/lib.rs index e954aa9137d..d825a36b59a 100644 --- a/tooling/ast_fuzzer/src/lib.rs +++ b/tooling/ast_fuzzer/src/lib.rs @@ -95,6 +95,24 @@ pub struct Config { 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 for the first time, and the + /// comparison targets immediately disagree on programs that use it. Keep it off until + /// those are triaged; see the notes on [`Config::avoid_fold`] for one known cause. + pub avoid_constrained_calls: bool, + /// Avoid marking functions with `#[fold]`. + /// + /// A `#[fold]` function is compiled into its own ACIR circuit, and `create_program` + /// counts the entry points in the AST up front and asserts that count against the + /// number of ACIRs the backend produced. When every call to a fold function sits in a + /// branch the SSA folds away, the backend produces no ACIR for it and that assertion + /// fires. Until the count is derived from what is actually generated, keep them out. + pub avoid_fold: bool, /// Only use comptime friendly expressions. pub comptime_friendly: bool, } @@ -167,6 +185,8 @@ impl Default for Config { avoid_constrain: false, avoid_match: false, avoid_vectors: false, + avoid_constrained_calls: true, + avoid_fold: true, comptime_friendly: false, } } diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index 0d41b5cebba..63ace4ce367 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -77,6 +77,7 @@ pub(super) fn can_call( 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() { @@ -87,8 +88,10 @@ pub(super) fn can_call( // 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; } @@ -108,6 +111,23 @@ pub(super) fn can_call( // 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; @@ -228,7 +248,14 @@ impl<'a> FunctionContext<'a> { // 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); @@ -773,22 +800,13 @@ impl<'a> FunctionContext<'a> { // // 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) @@ -801,8 +819,13 @@ impl<'a> FunctionContext<'a> { // 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))) @@ -1557,9 +1580,18 @@ impl<'a> FunctionContext<'a> { .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 + // `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::>(); if opts.is_empty() { @@ -2421,7 +2453,10 @@ impl<'a> FunctionContext<'a> { .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 diff --git a/tooling/ast_fuzzer/src/program/mod.rs b/tooling/ast_fuzzer/src/program/mod.rs index a87ddee03fb..3af57b817c6 100644 --- a/tooling/ast_fuzzer/src/program/mod.rs +++ b/tooling/ast_fuzzer/src/program/mod.rs @@ -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() @@ -328,13 +333,24 @@ 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 + } }) .collect::>(); *u.choose(&choices)? @@ -392,7 +408,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); diff --git a/tooling/ast_fuzzer/src/program/rewrite/limit.rs b/tooling/ast_fuzzer/src/program/rewrite/limit.rs index eb22ee3b43a..253f966df0a 100644 --- a/tooling/ast_fuzzer/src/program/rewrite/limit.rs +++ b/tooling/ast_fuzzer/src/program/rewrite/limit.rs @@ -9,7 +9,8 @@ use noirc_frontend::{ ast::BinaryOpKind, monomorphization::{ ast::{ - Call, Definition, Expression, FuncId, Function, Ident, IdentId, LocalId, Program, Type, + Call, Definition, Expression, FuncId, Function, Ident, IdentId, InlineType, LocalId, + Program, Type, }, visitor::visit_expr_mut, }, @@ -40,6 +41,15 @@ pub(crate) fn add_recursion_limit( ctx: &mut Context, u: &mut Unstructured, ) -> arbitrary::Result<()> { + // A `#[fold]` function is compiled into its own ACIR circuit, so its signature has to + // cross a circuit boundary and cannot contain a reference; it takes the limit by value. + let fold_functions = ctx + .functions + .iter() + .filter(|(_, func)| func.inline_type == InlineType::Fold) + .map(|(id, _)| *id) + .collect::>(); + // Collect functions potentially called from ACIR; they will need proxy functions. let called_from_acir = ctx.functions.values().filter(|func| !func.unconstrained).fold( HashSet::::new(), @@ -70,7 +80,7 @@ pub(crate) fn add_recursion_limit( // Rewrite functions. for (func_id, func) in &mut ctx.functions { - let mut limit_ctx = LimitContext::new(*func_id, func, &ctx.config); + let mut limit_ctx = LimitContext::new(*func_id, func, &ctx.config, &fold_functions); limit_ctx.rewrite_functions(u, &mut proxy_functions)?; } @@ -103,11 +113,21 @@ struct LimitContext<'a, 'b> { is_recursive: bool, next_local_id: u32, next_ident_id: u32, + /// Whether this function holds the limit by value rather than by mutable reference. + is_fold: bool, + /// Functions which hold the limit by value, so calls to them pass it that way. + fold_functions: &'b HashSet, } impl<'a, 'b> LimitContext<'a, 'b> { - fn new(func_id: FuncId, func: &'a mut Function, config: &'b Config) -> Self { + fn new( + func_id: FuncId, + func: &'a mut Function, + config: &'b Config, + fold_functions: &'b HashSet, + ) -> Self { let is_main = func_id == Program::main_id(); + let is_fold = func.inline_type == InlineType::Fold; // Recursive functions are those that call another function. let is_recursive = expr::has_call(&func.body); @@ -121,7 +141,17 @@ impl<'a, 'b> LimitContext<'a, 'b> { // traverse the AST to figure out what the next ID to use is. let (next_local_id, next_ident_id) = next_local_and_ident_id(func); - Self { func_id, func, config, is_main, is_recursive, next_local_id, next_ident_id } + Self { + func_id, + func, + config, + is_main, + is_recursive, + next_local_id, + next_ident_id, + is_fold, + fold_functions, + } } /// Rewrite the function and its proxy (if it has one). @@ -186,10 +216,16 @@ impl<'a, 'b> LimitContext<'a, 'b> { ) -> arbitrary::Result<()> { let limit_var = VariableId::Local(limit_id); - let limit_type = Rc::new(types::ref_mut(types::U32)); + // A `#[fold]` function takes the limit by value, because it is compiled into its own + // ACIR circuit and a reference cannot cross that boundary. It therefore cannot + // decrease its caller's budget, which is sound because the constrained call graph is + // acyclic — see `can_call` — so a constrained function can never recurse. + let by_value = self.is_fold; + let limit_type = Rc::new(if by_value { types::U32 } else { types::ref_mut(types::U32) }); + self.func.parameters.push(( limit_id, - false, + by_value, LIMIT_NAME.to_string(), limit_type.clone(), Visibility::Private, @@ -201,26 +237,32 @@ impl<'a, 'b> LimitContext<'a, 'b> { let limit_ident = expr::ident_inner( limit_var, self.next_ident_id(), - false, + by_value, LIMIT_NAME.to_string(), limit_type, ); let limit_expr = Expression::Ident(limit_ident.clone()); + // Reading the limit goes through a dereference unless we hold it by value. + let read_limit = + |expr: Expression| if by_value { expr } else { expr::deref(expr, types::U32) }; + expr::replace(&mut self.func.body, |mut body| { + let decreased = expr::binary( + read_limit(limit_expr.clone()), + BinaryOpKind::Subtract, + expr::u32_literal(1), + ); expr::prepend( &mut body, - expr::assign_ref( - limit_ident, - expr::binary( - expr::deref(limit_expr.clone(), types::U32), - BinaryOpKind::Subtract, - expr::u32_literal(1), - ), - ), + if by_value { + expr::assign_ident(limit_ident, decreased) + } else { + expr::assign_ref(limit_ident, decreased) + }, ); expr::if_else( - expr::equal(expr::deref(limit_expr.clone(), types::U32), expr::u32_literal(0)), + expr::equal(read_limit(limit_expr.clone()), expr::u32_literal(0)), default_return, body, self.func.return_type.clone(), @@ -234,7 +276,8 @@ impl<'a, 'b> LimitContext<'a, 'b> { /// In non-main we look at the limit and return a random value if it's zero, /// otherwise decrease it by one and continue with the original body. fn modify_body_when_non_recursive(&mut self, limit_id: LocalId) { - let limit_type = types::ref_mut(types::U32); + // See `modify_body_when_recursive` for why a `#[fold]` function takes it by value. + let limit_type = if self.is_fold { types::U32 } else { types::ref_mut(types::U32) }; self.func.parameters.push(( limit_id, false, @@ -341,6 +384,14 @@ impl<'a, 'b> LimitContext<'a, 'b> { other => unreachable!("unexpected call target definition: {}", other), }; + let callee_is_fold = match &ident.definition { + Definition::Function(id) => self.fold_functions.contains(id), + _ => false, + }; + // `main` keeps the limit in a local, and a `#[fold]` function receives it as + // a by-value parameter; everyone else holds a mutable reference to it. + let holds_by_value = self.is_main || self.is_fold; + types::unref_mut_rc(&mut ident.typ, |unref_mut_typ| { let Type::Function(mut param_types, ret, env, callee_unconstrained) = unref_mut_typ @@ -348,14 +399,14 @@ impl<'a, 'b> LimitContext<'a, 'b> { unreachable!("function type expected"); }; - if callee_unconstrained && !self.func.unconstrained { + if (callee_unconstrained && !self.func.unconstrained) || callee_is_fold { // Calling Brillig from ACIR: call the proxy if it's global. if let Some(proxy) = proxy { ident.name = proxy.name.clone(); ident.definition = Definition::Function(proxy.id); } // Pass the limit by value. - let limit_expr = if self.is_main { + let limit_expr = if holds_by_value { expr::ident( limit_var, self.next_ident_id(), @@ -380,8 +431,8 @@ impl<'a, 'b> LimitContext<'a, 'b> { } else { // Pass the limit by reference. let limit_type = types::ref_mut(types::U32); - let limit_expr = if self.is_main { - // In main we take a mutable reference to the limit. + let limit_expr = if holds_by_value { + // When we hold the limit itself, take a mutable reference to it. expr::ref_mut( expr::ident( limit_var, diff --git a/tooling/ast_fuzzer/src/program/rewrite/mod.rs b/tooling/ast_fuzzer/src/program/rewrite/mod.rs index 1be9babb1ab..c2d58e157f3 100644 --- a/tooling/ast_fuzzer/src/program/rewrite/mod.rs +++ b/tooling/ast_fuzzer/src/program/rewrite/mod.rs @@ -68,6 +68,10 @@ pub fn change_all_functions_into_unconstrained(mut program: Program) -> Program } // Modify the function. f.unconstrained = true; + // Only a constrained function is compiled into its own ACIR circuit, so an + // unconstrained one is an entry point only if it is `main`. This mirrors the + // `force_unconstrained` arm of `Monomorphizer::into_program`. + f.is_entry_point = f.id == Program::main_id(); // Modify any function pointers it takes. for (_, _, _, typ, _) in &mut f.parameters { types::unref_mut_rc(typ, |unref_mut_typ| { diff --git a/tooling/ast_fuzzer/src/program/scope.rs b/tooling/ast_fuzzer/src/program/scope.rs index 9e1d8529ca6..3309ed4d337 100644 --- a/tooling/ast_fuzzer/src/program/scope.rs +++ b/tooling/ast_fuzzer/src/program/scope.rs @@ -230,9 +230,12 @@ mod tests { let scope1 = &stack.0[1]; assert_eq!(scope0.variable_ids().len(), 1); - assert_eq!(scope0.types_produced().len(), 5 + 2); // What we see plus upcasts from u32 to u64 and u128 + // What we see (the tuple, `Field`, `bool`, `[u32; 4]` and `u32`), plus upcasts from + // u32 to u64 and u128, plus the `[u32]` the array converts into. + assert_eq!(scope0.types_produced().len(), 5 + 2 + 1); assert_eq!(scope1.variable_ids().len(), 2); - assert_eq!(scope1.types_produced().len(), 5 + 2 + 1); + // The above plus `str<10>` and the `[u8; 10]` it converts into. + assert_eq!(scope1.types_produced().len(), 5 + 2 + 1 + 2); stack.exit(); assert_eq!(stack.0.len(), 1); diff --git a/tooling/ast_fuzzer/src/program/tests.rs b/tooling/ast_fuzzer/src/program/tests.rs index 3c8ac20d559..4066d99be31 100644 --- a/tooling/ast_fuzzer/src/program/tests.rs +++ b/tooling/ast_fuzzer/src/program/tests.rs @@ -454,3 +454,108 @@ fn test_generates_non_homogeneous_array_types() { ACIR's non-homogeneous array handling is unreachable from the fuzzer" ); } + +/// A `FunctionDeclaration` with no parameters, returning `Field`. +fn simple_decl(unconstrained: bool, inline_type: InlineType) -> FunctionDeclaration { + FunctionDeclaration { + name: "func".to_string(), + params: vec![], + return_type: Type::Field, + return_visibility: Visibility::Private, + inline_type, + unconstrained, + } +} + +#[test] +fn test_main_can_call_constrained_functions() { + use super::func::can_call; + + let acir = simple_decl(false, InlineType::Inline); + + // `main` has the lowest ID, so the "higher calls lower" rule alone would leave every + // constrained function unreachable and `remove_unreachable_functions` would delete them. + assert!( + can_call(Program::main_id(), false, false, FuncId(1), &acir, true), + "main should be able to call a constrained function" + ); + assert!( + !can_call(Program::main_id(), false, false, FuncId(1), &acir, false), + "the exception is off unless constrained calls are allowed" + ); + + // Between other constrained functions the ordering rule still keeps the graph acyclic. + assert!(can_call(FuncId(2), false, false, FuncId(1), &acir, true)); + assert!(!can_call(FuncId(1), false, false, FuncId(2), &acir, true)); + + // Nothing calls main, which is what makes the exception above safe. + assert!(!can_call(FuncId(1), false, false, Program::main_id(), &acir, true)); + + // Brillig still only calls Brillig. + assert!(!can_call(FuncId(1), true, false, FuncId(2), &acir, true)); +} + +#[test] +fn test_functions_returning_references_do_not_call() { + use super::func::can_call; + + let acir = simple_decl(false, InlineType::Inline); + let brillig = simple_decl(true, InlineType::Inline); + + // The recursion limit rewrite wraps the body of any function that makes a call in an + // `if ctx_limit == 0 { .. } else { .. }`, and an `if` cannot return a reference in ACIR. + // The rewrite keys off whether the body calls anything, not off the runtime. + assert!(!can_call(Program::main_id(), false, true, FuncId(1), &acir, true)); + assert!(!can_call(FuncId(2), true, true, FuncId(1), &brillig, true)); +} + +#[test] +fn test_generates_constrained_functions_other_than_main() { + let config = Config { avoid_constrained_calls: false, ..Config::default() }; + + // A deterministic byte source; the generator only needs entropy, not randomness. + let mut state = 0x2545_F491_4F6C_DD1Du64; + let mut data = vec![0u8; 1 << 20]; + for byte in &mut data { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + *byte = state as u8; + } + let mut u = Unstructured::new(&data); + + let mut found = 0; + for _ in 0..32 { + let Ok(program) = crate::arb_program(&mut u, config.clone()) else { break }; + found += program + .functions + .iter() + .filter(|f| !f.unconstrained && f.id != Program::main_id()) + .count(); + } + + assert!( + found > 0, + "every generated program consisted of `main` plus Brillig functions; \ + the whole constrained call graph is unreachable" + ); +} + +#[test] +fn test_types_produced_covers_the_builtin_conversions() { + use super::types::{U8, types_produced}; + + // `str_as_bytes` + let produced = types_produced(&Type::String(3)); + assert!( + produced.contains(&Type::Array(3, Rc::new(U8))), + "a string should offer the byte array it converts into: {produced:?}" + ); + + // `as_vector` + let produced = types_produced(&Type::Array(4, Rc::new(Type::Field))); + assert!( + produced.contains(&Type::Vector(Rc::new(Type::Field))), + "an array should offer the vector it converts into: {produced:?}" + ); +} diff --git a/tooling/ast_fuzzer/src/program/types.rs b/tooling/ast_fuzzer/src/program/types.rs index 0a31b077e9e..d211bc362d7 100644 --- a/tooling/ast_fuzzer/src/program/types.rs +++ b/tooling/ast_fuzzer/src/program/types.rs @@ -54,6 +54,12 @@ pub fn can_be_matched(typ: &Type) -> bool { /// Collect all the sub-types produced by a type. /// /// It's like a _power set_ of the type. +/// +/// This is what the producer index in [`Scope`](super::scope::Scope) is built from, so it +/// decides which variables are offered for a target type. It should stay in step with +/// [`FunctionContext::gen_expr_from_source`](super::func): a type listed here that the +/// generator cannot actually reach costs coverage, because the chosen producer yields +/// nothing and the expression falls back to a literal. pub fn types_produced(typ: &Type) -> HashSet { /// Recursively visit subtypes. fn visit(acc: &mut HashSet, typ: &Type) { @@ -66,6 +72,10 @@ pub fn types_produced(typ: &Type) -> HashSet { match typ { Type::Array(len, item_type) => { + // `as_vector` turns a fixed-size array into a dynamically sized one. + // Only the vector itself is produced, not what the vector in turn + // produces, because that is as far as a single conversion gets us. + acc.insert(Type::Vector(item_type.clone())); if *len > 0 { visit(acc, item_type); } @@ -82,8 +92,11 @@ pub fn types_produced(typ: &Type) -> HashSet { visit(acc, item_type); } } - Type::String(_) => { - // Maybe it could produce substrings, but it would be an overkill to enumerate. + Type::String(len) => { + // `str_as_bytes` reinterprets the string as the array of its bytes. + // Maybe it could also produce substrings, but it would be an overkill + // to enumerate. + acc.insert(Type::Array(*len, Rc::new(U8))); } Type::Field => { // There are `try_to_*` methods, but let's consider only what is safe. From e8b6cc2113622b5099fe6a8565b6201da1a39590 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 10 Aug 2026 16:24:01 +0000 Subject: [PATCH 16/21] update PR #13498 --- tooling/ast_fuzzer/src/program/types.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tooling/ast_fuzzer/src/program/types.rs b/tooling/ast_fuzzer/src/program/types.rs index d211bc362d7..a0e523818a3 100644 --- a/tooling/ast_fuzzer/src/program/types.rs +++ b/tooling/ast_fuzzer/src/program/types.rs @@ -57,9 +57,9 @@ pub fn can_be_matched(typ: &Type) -> bool { /// /// This is what the producer index in [`Scope`](super::scope::Scope) is built from, so it /// decides which variables are offered for a target type. It should stay in step with -/// [`FunctionContext::gen_expr_from_source`](super::func): a type listed here that the -/// generator cannot actually reach costs coverage, because the chosen producer yields -/// nothing and the expression falls back to a literal. +/// `FunctionContext::gen_expr_from_source`: a type listed here that the generator cannot +/// actually reach costs coverage, because the chosen producer yields nothing and the +/// expression falls back to a literal. pub fn types_produced(typ: &Type) -> HashSet { /// Recursively visit subtypes. fn visit(acc: &mut HashSet, typ: &Type) { From 8ee657204806f0c3ef154dd35e71aaeae1c3e713 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 10 Aug 2026 16:55:45 +0000 Subject: [PATCH 17/21] update PR #13498 --- tooling/ast_fuzzer/src/lib.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tooling/ast_fuzzer/src/lib.rs b/tooling/ast_fuzzer/src/lib.rs index d825a36b59a..c522236b92d 100644 --- a/tooling/ast_fuzzer/src/lib.rs +++ b/tooling/ast_fuzzer/src/lib.rs @@ -101,17 +101,15 @@ pub struct Config { /// 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 for the first time, and the - /// comparison targets immediately disagree on programs that use it. Keep it off until - /// those are triaged; see the notes on [`Config::avoid_fold`] for one known cause. + /// 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, and `create_program` - /// counts the entry points in the AST up front and asserts that count against the - /// number of ACIRs the backend produced. When every call to a fold function sits in a - /// branch the SSA folds away, the backend produces no ACIR for it and that assertion - /// fires. Until the count is derived from what is actually generated, keep them out. + /// 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, /// Only use comptime friendly expressions. pub comptime_friendly: bool, @@ -185,8 +183,8 @@ impl Default for Config { avoid_constrain: false, avoid_match: false, avoid_vectors: false, - avoid_constrained_calls: true, - avoid_fold: true, + avoid_constrained_calls: false, + avoid_fold: false, comptime_friendly: false, } } From 71b3e276b8cb9ecc0e215e976d976e9e49d47b97 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 10 Aug 2026 16:15:02 +0000 Subject: [PATCH 18/21] fix(fuzzer): make the widened AST generation actually fire --- tooling/ast_fuzzer/src/lib.rs | 20 ++++ tooling/ast_fuzzer/src/program/func.rs | 77 +++++++++---- tooling/ast_fuzzer/src/program/mod.rs | 39 +++++-- .../ast_fuzzer/src/program/rewrite/limit.rs | 93 ++++++++++++---- tooling/ast_fuzzer/src/program/rewrite/mod.rs | 4 + tooling/ast_fuzzer/src/program/scope.rs | 7 +- tooling/ast_fuzzer/src/program/tests.rs | 105 ++++++++++++++++++ tooling/ast_fuzzer/src/program/types.rs | 17 ++- 8 files changed, 307 insertions(+), 55 deletions(-) diff --git a/tooling/ast_fuzzer/src/lib.rs b/tooling/ast_fuzzer/src/lib.rs index e954aa9137d..d825a36b59a 100644 --- a/tooling/ast_fuzzer/src/lib.rs +++ b/tooling/ast_fuzzer/src/lib.rs @@ -95,6 +95,24 @@ pub struct Config { 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 for the first time, and the + /// comparison targets immediately disagree on programs that use it. Keep it off until + /// those are triaged; see the notes on [`Config::avoid_fold`] for one known cause. + pub avoid_constrained_calls: bool, + /// Avoid marking functions with `#[fold]`. + /// + /// A `#[fold]` function is compiled into its own ACIR circuit, and `create_program` + /// counts the entry points in the AST up front and asserts that count against the + /// number of ACIRs the backend produced. When every call to a fold function sits in a + /// branch the SSA folds away, the backend produces no ACIR for it and that assertion + /// fires. Until the count is derived from what is actually generated, keep them out. + pub avoid_fold: bool, /// Only use comptime friendly expressions. pub comptime_friendly: bool, } @@ -167,6 +185,8 @@ impl Default for Config { avoid_constrain: false, avoid_match: false, avoid_vectors: false, + avoid_constrained_calls: true, + avoid_fold: true, comptime_friendly: false, } } diff --git a/tooling/ast_fuzzer/src/program/func.rs b/tooling/ast_fuzzer/src/program/func.rs index 0d41b5cebba..63ace4ce367 100644 --- a/tooling/ast_fuzzer/src/program/func.rs +++ b/tooling/ast_fuzzer/src/program/func.rs @@ -77,6 +77,7 @@ pub(super) fn can_call( 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() { @@ -87,8 +88,10 @@ pub(super) fn can_call( // 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; } @@ -108,6 +111,23 @@ pub(super) fn can_call( // 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; @@ -228,7 +248,14 @@ impl<'a> FunctionContext<'a> { // 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); @@ -773,22 +800,13 @@ impl<'a> FunctionContext<'a> { // // 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) @@ -801,8 +819,13 @@ impl<'a> FunctionContext<'a> { // 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))) @@ -1557,9 +1580,18 @@ impl<'a> FunctionContext<'a> { .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 + // `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::>(); if opts.is_empty() { @@ -2421,7 +2453,10 @@ impl<'a> FunctionContext<'a> { .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 diff --git a/tooling/ast_fuzzer/src/program/mod.rs b/tooling/ast_fuzzer/src/program/mod.rs index a87ddee03fb..3af57b817c6 100644 --- a/tooling/ast_fuzzer/src/program/mod.rs +++ b/tooling/ast_fuzzer/src/program/mod.rs @@ -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() @@ -328,13 +333,24 @@ 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 + } }) .collect::>(); *u.choose(&choices)? @@ -392,7 +408,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); diff --git a/tooling/ast_fuzzer/src/program/rewrite/limit.rs b/tooling/ast_fuzzer/src/program/rewrite/limit.rs index eb22ee3b43a..253f966df0a 100644 --- a/tooling/ast_fuzzer/src/program/rewrite/limit.rs +++ b/tooling/ast_fuzzer/src/program/rewrite/limit.rs @@ -9,7 +9,8 @@ use noirc_frontend::{ ast::BinaryOpKind, monomorphization::{ ast::{ - Call, Definition, Expression, FuncId, Function, Ident, IdentId, LocalId, Program, Type, + Call, Definition, Expression, FuncId, Function, Ident, IdentId, InlineType, LocalId, + Program, Type, }, visitor::visit_expr_mut, }, @@ -40,6 +41,15 @@ pub(crate) fn add_recursion_limit( ctx: &mut Context, u: &mut Unstructured, ) -> arbitrary::Result<()> { + // A `#[fold]` function is compiled into its own ACIR circuit, so its signature has to + // cross a circuit boundary and cannot contain a reference; it takes the limit by value. + let fold_functions = ctx + .functions + .iter() + .filter(|(_, func)| func.inline_type == InlineType::Fold) + .map(|(id, _)| *id) + .collect::>(); + // Collect functions potentially called from ACIR; they will need proxy functions. let called_from_acir = ctx.functions.values().filter(|func| !func.unconstrained).fold( HashSet::::new(), @@ -70,7 +80,7 @@ pub(crate) fn add_recursion_limit( // Rewrite functions. for (func_id, func) in &mut ctx.functions { - let mut limit_ctx = LimitContext::new(*func_id, func, &ctx.config); + let mut limit_ctx = LimitContext::new(*func_id, func, &ctx.config, &fold_functions); limit_ctx.rewrite_functions(u, &mut proxy_functions)?; } @@ -103,11 +113,21 @@ struct LimitContext<'a, 'b> { is_recursive: bool, next_local_id: u32, next_ident_id: u32, + /// Whether this function holds the limit by value rather than by mutable reference. + is_fold: bool, + /// Functions which hold the limit by value, so calls to them pass it that way. + fold_functions: &'b HashSet, } impl<'a, 'b> LimitContext<'a, 'b> { - fn new(func_id: FuncId, func: &'a mut Function, config: &'b Config) -> Self { + fn new( + func_id: FuncId, + func: &'a mut Function, + config: &'b Config, + fold_functions: &'b HashSet, + ) -> Self { let is_main = func_id == Program::main_id(); + let is_fold = func.inline_type == InlineType::Fold; // Recursive functions are those that call another function. let is_recursive = expr::has_call(&func.body); @@ -121,7 +141,17 @@ impl<'a, 'b> LimitContext<'a, 'b> { // traverse the AST to figure out what the next ID to use is. let (next_local_id, next_ident_id) = next_local_and_ident_id(func); - Self { func_id, func, config, is_main, is_recursive, next_local_id, next_ident_id } + Self { + func_id, + func, + config, + is_main, + is_recursive, + next_local_id, + next_ident_id, + is_fold, + fold_functions, + } } /// Rewrite the function and its proxy (if it has one). @@ -186,10 +216,16 @@ impl<'a, 'b> LimitContext<'a, 'b> { ) -> arbitrary::Result<()> { let limit_var = VariableId::Local(limit_id); - let limit_type = Rc::new(types::ref_mut(types::U32)); + // A `#[fold]` function takes the limit by value, because it is compiled into its own + // ACIR circuit and a reference cannot cross that boundary. It therefore cannot + // decrease its caller's budget, which is sound because the constrained call graph is + // acyclic — see `can_call` — so a constrained function can never recurse. + let by_value = self.is_fold; + let limit_type = Rc::new(if by_value { types::U32 } else { types::ref_mut(types::U32) }); + self.func.parameters.push(( limit_id, - false, + by_value, LIMIT_NAME.to_string(), limit_type.clone(), Visibility::Private, @@ -201,26 +237,32 @@ impl<'a, 'b> LimitContext<'a, 'b> { let limit_ident = expr::ident_inner( limit_var, self.next_ident_id(), - false, + by_value, LIMIT_NAME.to_string(), limit_type, ); let limit_expr = Expression::Ident(limit_ident.clone()); + // Reading the limit goes through a dereference unless we hold it by value. + let read_limit = + |expr: Expression| if by_value { expr } else { expr::deref(expr, types::U32) }; + expr::replace(&mut self.func.body, |mut body| { + let decreased = expr::binary( + read_limit(limit_expr.clone()), + BinaryOpKind::Subtract, + expr::u32_literal(1), + ); expr::prepend( &mut body, - expr::assign_ref( - limit_ident, - expr::binary( - expr::deref(limit_expr.clone(), types::U32), - BinaryOpKind::Subtract, - expr::u32_literal(1), - ), - ), + if by_value { + expr::assign_ident(limit_ident, decreased) + } else { + expr::assign_ref(limit_ident, decreased) + }, ); expr::if_else( - expr::equal(expr::deref(limit_expr.clone(), types::U32), expr::u32_literal(0)), + expr::equal(read_limit(limit_expr.clone()), expr::u32_literal(0)), default_return, body, self.func.return_type.clone(), @@ -234,7 +276,8 @@ impl<'a, 'b> LimitContext<'a, 'b> { /// In non-main we look at the limit and return a random value if it's zero, /// otherwise decrease it by one and continue with the original body. fn modify_body_when_non_recursive(&mut self, limit_id: LocalId) { - let limit_type = types::ref_mut(types::U32); + // See `modify_body_when_recursive` for why a `#[fold]` function takes it by value. + let limit_type = if self.is_fold { types::U32 } else { types::ref_mut(types::U32) }; self.func.parameters.push(( limit_id, false, @@ -341,6 +384,14 @@ impl<'a, 'b> LimitContext<'a, 'b> { other => unreachable!("unexpected call target definition: {}", other), }; + let callee_is_fold = match &ident.definition { + Definition::Function(id) => self.fold_functions.contains(id), + _ => false, + }; + // `main` keeps the limit in a local, and a `#[fold]` function receives it as + // a by-value parameter; everyone else holds a mutable reference to it. + let holds_by_value = self.is_main || self.is_fold; + types::unref_mut_rc(&mut ident.typ, |unref_mut_typ| { let Type::Function(mut param_types, ret, env, callee_unconstrained) = unref_mut_typ @@ -348,14 +399,14 @@ impl<'a, 'b> LimitContext<'a, 'b> { unreachable!("function type expected"); }; - if callee_unconstrained && !self.func.unconstrained { + if (callee_unconstrained && !self.func.unconstrained) || callee_is_fold { // Calling Brillig from ACIR: call the proxy if it's global. if let Some(proxy) = proxy { ident.name = proxy.name.clone(); ident.definition = Definition::Function(proxy.id); } // Pass the limit by value. - let limit_expr = if self.is_main { + let limit_expr = if holds_by_value { expr::ident( limit_var, self.next_ident_id(), @@ -380,8 +431,8 @@ impl<'a, 'b> LimitContext<'a, 'b> { } else { // Pass the limit by reference. let limit_type = types::ref_mut(types::U32); - let limit_expr = if self.is_main { - // In main we take a mutable reference to the limit. + let limit_expr = if holds_by_value { + // When we hold the limit itself, take a mutable reference to it. expr::ref_mut( expr::ident( limit_var, diff --git a/tooling/ast_fuzzer/src/program/rewrite/mod.rs b/tooling/ast_fuzzer/src/program/rewrite/mod.rs index 1be9babb1ab..c2d58e157f3 100644 --- a/tooling/ast_fuzzer/src/program/rewrite/mod.rs +++ b/tooling/ast_fuzzer/src/program/rewrite/mod.rs @@ -68,6 +68,10 @@ pub fn change_all_functions_into_unconstrained(mut program: Program) -> Program } // Modify the function. f.unconstrained = true; + // Only a constrained function is compiled into its own ACIR circuit, so an + // unconstrained one is an entry point only if it is `main`. This mirrors the + // `force_unconstrained` arm of `Monomorphizer::into_program`. + f.is_entry_point = f.id == Program::main_id(); // Modify any function pointers it takes. for (_, _, _, typ, _) in &mut f.parameters { types::unref_mut_rc(typ, |unref_mut_typ| { diff --git a/tooling/ast_fuzzer/src/program/scope.rs b/tooling/ast_fuzzer/src/program/scope.rs index 9e1d8529ca6..3309ed4d337 100644 --- a/tooling/ast_fuzzer/src/program/scope.rs +++ b/tooling/ast_fuzzer/src/program/scope.rs @@ -230,9 +230,12 @@ mod tests { let scope1 = &stack.0[1]; assert_eq!(scope0.variable_ids().len(), 1); - assert_eq!(scope0.types_produced().len(), 5 + 2); // What we see plus upcasts from u32 to u64 and u128 + // What we see (the tuple, `Field`, `bool`, `[u32; 4]` and `u32`), plus upcasts from + // u32 to u64 and u128, plus the `[u32]` the array converts into. + assert_eq!(scope0.types_produced().len(), 5 + 2 + 1); assert_eq!(scope1.variable_ids().len(), 2); - assert_eq!(scope1.types_produced().len(), 5 + 2 + 1); + // The above plus `str<10>` and the `[u8; 10]` it converts into. + assert_eq!(scope1.types_produced().len(), 5 + 2 + 1 + 2); stack.exit(); assert_eq!(stack.0.len(), 1); diff --git a/tooling/ast_fuzzer/src/program/tests.rs b/tooling/ast_fuzzer/src/program/tests.rs index 3c8ac20d559..4066d99be31 100644 --- a/tooling/ast_fuzzer/src/program/tests.rs +++ b/tooling/ast_fuzzer/src/program/tests.rs @@ -454,3 +454,108 @@ fn test_generates_non_homogeneous_array_types() { ACIR's non-homogeneous array handling is unreachable from the fuzzer" ); } + +/// A `FunctionDeclaration` with no parameters, returning `Field`. +fn simple_decl(unconstrained: bool, inline_type: InlineType) -> FunctionDeclaration { + FunctionDeclaration { + name: "func".to_string(), + params: vec![], + return_type: Type::Field, + return_visibility: Visibility::Private, + inline_type, + unconstrained, + } +} + +#[test] +fn test_main_can_call_constrained_functions() { + use super::func::can_call; + + let acir = simple_decl(false, InlineType::Inline); + + // `main` has the lowest ID, so the "higher calls lower" rule alone would leave every + // constrained function unreachable and `remove_unreachable_functions` would delete them. + assert!( + can_call(Program::main_id(), false, false, FuncId(1), &acir, true), + "main should be able to call a constrained function" + ); + assert!( + !can_call(Program::main_id(), false, false, FuncId(1), &acir, false), + "the exception is off unless constrained calls are allowed" + ); + + // Between other constrained functions the ordering rule still keeps the graph acyclic. + assert!(can_call(FuncId(2), false, false, FuncId(1), &acir, true)); + assert!(!can_call(FuncId(1), false, false, FuncId(2), &acir, true)); + + // Nothing calls main, which is what makes the exception above safe. + assert!(!can_call(FuncId(1), false, false, Program::main_id(), &acir, true)); + + // Brillig still only calls Brillig. + assert!(!can_call(FuncId(1), true, false, FuncId(2), &acir, true)); +} + +#[test] +fn test_functions_returning_references_do_not_call() { + use super::func::can_call; + + let acir = simple_decl(false, InlineType::Inline); + let brillig = simple_decl(true, InlineType::Inline); + + // The recursion limit rewrite wraps the body of any function that makes a call in an + // `if ctx_limit == 0 { .. } else { .. }`, and an `if` cannot return a reference in ACIR. + // The rewrite keys off whether the body calls anything, not off the runtime. + assert!(!can_call(Program::main_id(), false, true, FuncId(1), &acir, true)); + assert!(!can_call(FuncId(2), true, true, FuncId(1), &brillig, true)); +} + +#[test] +fn test_generates_constrained_functions_other_than_main() { + let config = Config { avoid_constrained_calls: false, ..Config::default() }; + + // A deterministic byte source; the generator only needs entropy, not randomness. + let mut state = 0x2545_F491_4F6C_DD1Du64; + let mut data = vec![0u8; 1 << 20]; + for byte in &mut data { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + *byte = state as u8; + } + let mut u = Unstructured::new(&data); + + let mut found = 0; + for _ in 0..32 { + let Ok(program) = crate::arb_program(&mut u, config.clone()) else { break }; + found += program + .functions + .iter() + .filter(|f| !f.unconstrained && f.id != Program::main_id()) + .count(); + } + + assert!( + found > 0, + "every generated program consisted of `main` plus Brillig functions; \ + the whole constrained call graph is unreachable" + ); +} + +#[test] +fn test_types_produced_covers_the_builtin_conversions() { + use super::types::{U8, types_produced}; + + // `str_as_bytes` + let produced = types_produced(&Type::String(3)); + assert!( + produced.contains(&Type::Array(3, Rc::new(U8))), + "a string should offer the byte array it converts into: {produced:?}" + ); + + // `as_vector` + let produced = types_produced(&Type::Array(4, Rc::new(Type::Field))); + assert!( + produced.contains(&Type::Vector(Rc::new(Type::Field))), + "an array should offer the vector it converts into: {produced:?}" + ); +} diff --git a/tooling/ast_fuzzer/src/program/types.rs b/tooling/ast_fuzzer/src/program/types.rs index 0a31b077e9e..d211bc362d7 100644 --- a/tooling/ast_fuzzer/src/program/types.rs +++ b/tooling/ast_fuzzer/src/program/types.rs @@ -54,6 +54,12 @@ pub fn can_be_matched(typ: &Type) -> bool { /// Collect all the sub-types produced by a type. /// /// It's like a _power set_ of the type. +/// +/// This is what the producer index in [`Scope`](super::scope::Scope) is built from, so it +/// decides which variables are offered for a target type. It should stay in step with +/// [`FunctionContext::gen_expr_from_source`](super::func): a type listed here that the +/// generator cannot actually reach costs coverage, because the chosen producer yields +/// nothing and the expression falls back to a literal. pub fn types_produced(typ: &Type) -> HashSet { /// Recursively visit subtypes. fn visit(acc: &mut HashSet, typ: &Type) { @@ -66,6 +72,10 @@ pub fn types_produced(typ: &Type) -> HashSet { match typ { Type::Array(len, item_type) => { + // `as_vector` turns a fixed-size array into a dynamically sized one. + // Only the vector itself is produced, not what the vector in turn + // produces, because that is as far as a single conversion gets us. + acc.insert(Type::Vector(item_type.clone())); if *len > 0 { visit(acc, item_type); } @@ -82,8 +92,11 @@ pub fn types_produced(typ: &Type) -> HashSet { visit(acc, item_type); } } - Type::String(_) => { - // Maybe it could produce substrings, but it would be an overkill to enumerate. + Type::String(len) => { + // `str_as_bytes` reinterprets the string as the array of its bytes. + // Maybe it could also produce substrings, but it would be an overkill + // to enumerate. + acc.insert(Type::Array(*len, Rc::new(U8))); } Type::Field => { // There are `try_to_*` methods, but let's consider only what is safe. From 722ad60ce3cdeaacf0078ac3ce7261c8d2d8a5de Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 10 Aug 2026 16:24:01 +0000 Subject: [PATCH 19/21] update PR #13498 --- tooling/ast_fuzzer/src/program/types.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tooling/ast_fuzzer/src/program/types.rs b/tooling/ast_fuzzer/src/program/types.rs index d211bc362d7..a0e523818a3 100644 --- a/tooling/ast_fuzzer/src/program/types.rs +++ b/tooling/ast_fuzzer/src/program/types.rs @@ -57,9 +57,9 @@ pub fn can_be_matched(typ: &Type) -> bool { /// /// This is what the producer index in [`Scope`](super::scope::Scope) is built from, so it /// decides which variables are offered for a target type. It should stay in step with -/// [`FunctionContext::gen_expr_from_source`](super::func): a type listed here that the -/// generator cannot actually reach costs coverage, because the chosen producer yields -/// nothing and the expression falls back to a literal. +/// `FunctionContext::gen_expr_from_source`: a type listed here that the generator cannot +/// actually reach costs coverage, because the chosen producer yields nothing and the +/// expression falls back to a literal. pub fn types_produced(typ: &Type) -> HashSet { /// Recursively visit subtypes. fn visit(acc: &mut HashSet, typ: &Type) { From f096e28f574fdcc01ec3f65a505a61b01ff3fd66 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 10 Aug 2026 16:55:45 +0000 Subject: [PATCH 20/21] update PR #13498 --- tooling/ast_fuzzer/src/lib.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tooling/ast_fuzzer/src/lib.rs b/tooling/ast_fuzzer/src/lib.rs index d825a36b59a..c522236b92d 100644 --- a/tooling/ast_fuzzer/src/lib.rs +++ b/tooling/ast_fuzzer/src/lib.rs @@ -101,17 +101,15 @@ pub struct Config { /// 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 for the first time, and the - /// comparison targets immediately disagree on programs that use it. Keep it off until - /// those are triaged; see the notes on [`Config::avoid_fold`] for one known cause. + /// 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, and `create_program` - /// counts the entry points in the AST up front and asserts that count against the - /// number of ACIRs the backend produced. When every call to a fold function sits in a - /// branch the SSA folds away, the backend produces no ACIR for it and that assertion - /// fires. Until the count is derived from what is actually generated, keep them out. + /// 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, /// Only use comptime friendly expressions. pub comptime_friendly: bool, @@ -185,8 +183,8 @@ impl Default for Config { avoid_constrain: false, avoid_match: false, avoid_vectors: false, - avoid_constrained_calls: true, - avoid_fold: true, + avoid_constrained_calls: false, + avoid_fold: false, comptime_friendly: false, } } From cc69b5690e4c72cd393a6a554796ac79290e878d Mon Sep 17 00:00:00 2001 From: Tom French <15848336+TomAFrench@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:01:02 +0000 Subject: [PATCH 21/21] fix(fuzzer): exclude #[no_predicates] from the equivalence targets 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. --- .../fuzz/src/targets/acir_vs_brillig.rs | 5 +++- .../fuzz/src/targets/min_vs_full.rs | 8 ++++-- .../fuzz/src/targets/orig_vs_morph.rs | 6 ++++- tooling/ast_fuzzer/src/lib.rs | 11 ++++++++ tooling/ast_fuzzer/src/program/mod.rs | 3 ++- tooling/ast_fuzzer/src/program/tests.rs | 27 +++++++++++++++++++ 6 files changed, 55 insertions(+), 5 deletions(-) diff --git a/tooling/ast_fuzzer/fuzz/src/targets/acir_vs_brillig.rs b/tooling/ast_fuzzer/fuzz/src/targets/acir_vs_brillig.rs index 3720e6419ad..08b89a79a00 100644 --- a/tooling/ast_fuzzer/fuzz/src/targets/acir_vs_brillig.rs +++ b/tooling/ast_fuzzer/fuzz/src/targets/acir_vs_brillig.rs @@ -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, diff --git a/tooling/ast_fuzzer/fuzz/src/targets/min_vs_full.rs b/tooling/ast_fuzzer/fuzz/src/targets/min_vs_full.rs index 3fb2e16f726..309470d2ed0 100644 --- a/tooling/ast_fuzzer/fuzz/src/targets/min_vs_full.rs +++ b/tooling/ast_fuzzer/fuzz/src/targets/min_vs_full.rs @@ -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, diff --git a/tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs b/tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs index 22a7e2ff6cf..caf3b0428fd 100644 --- a/tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs +++ b/tooling/ast_fuzzer/fuzz/src/targets/orig_vs_morph.rs @@ -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; @@ -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( diff --git a/tooling/ast_fuzzer/src/lib.rs b/tooling/ast_fuzzer/src/lib.rs index c522236b92d..5fdf84fef81 100644 --- a/tooling/ast_fuzzer/src/lib.rs +++ b/tooling/ast_fuzzer/src/lib.rs @@ -111,6 +111,16 @@ pub struct Config { /// 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 + /// 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, } @@ -185,6 +195,7 @@ impl Default for Config { avoid_vectors: false, avoid_constrained_calls: false, avoid_fold: false, + avoid_no_predicates: false, comptime_friendly: false, } } diff --git a/tooling/ast_fuzzer/src/program/mod.rs b/tooling/ast_fuzzer/src/program/mod.rs index 3af57b817c6..483008f26aa 100644 --- a/tooling/ast_fuzzer/src/program/mod.rs +++ b/tooling/ast_fuzzer/src/program/mod.rs @@ -349,7 +349,8 @@ impl Context { if unconstrained { !matches!(it, InlineType::Fold | InlineType::NoPredicates) } else { - *it != InlineType::Fold || can_be_folded + (*it != InlineType::Fold || can_be_folded) + && (*it != InlineType::NoPredicates || !self.config.avoid_no_predicates) } }) .collect::>(); diff --git a/tooling/ast_fuzzer/src/program/tests.rs b/tooling/ast_fuzzer/src/program/tests.rs index 4066d99be31..b2eb4e6c31a 100644 --- a/tooling/ast_fuzzer/src/program/tests.rs +++ b/tooling/ast_fuzzer/src/program/tests.rs @@ -541,6 +541,33 @@ fn test_generates_constrained_functions_other_than_main() { ); } +#[test] +fn test_avoid_no_predicates_generates_none() { + let config = Config { avoid_no_predicates: true, ..Config::default() }; + + // A deterministic byte source; the generator only needs entropy, not randomness. + let mut state = 0x2545_F491_4F6C_DD1Du64; + let mut data = vec![0u8; 1 << 20]; + for byte in &mut data { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + *byte = state as u8; + } + let mut u = Unstructured::new(&data); + + for _ in 0..32 { + let Ok(program) = crate::arb_program(&mut u, config.clone()) else { break }; + for func in &program.functions { + assert!( + !matches!(func.inline_type, InlineType::NoPredicates), + "function {} is `#[no_predicates]` despite `avoid_no_predicates`", + func.name + ); + } + } +} + #[test] fn test_types_produced_covers_the_builtin_conversions() { use super::types::{U8, types_produced};