Repository: noir-lang/noir
Pull request #8118: feat(greybox_fuzzer): Should_fail and should_fail_with
Author: login: Rumata888
Labels: name: documentation
description: Improvements or additions to documentation
Created: 2025-04-17T12:33:26Z | Merged: 2025-04-23T15:40:01Z

- Updated error messages in the lexer to include new fuzz attribute forms.
- Modified parser to recognize and handle `should_fail` and `should_fail_with` attributes.
- Added new test programs to validate the behavior of these attributes.
- Enhanced the fuzzer to support expected failure scenarios based on the new attributes
- Added parsing of partial witness to detect triggered boolean conditions under "should_fail\*" scenarios

# Description

## Problem\*

Resolves <!-- Link to GitHub Issue -->

## Summary\*



## Additional Context



## Documentation\*

Check one:
- [ ] No documentation needed.
- [ ] Documentation included in this PR.
- [ ] **[For Experimental Features]** Documentation to be submitted in a separate PR.

# PR Checklist\*

- [ ] I have tested the changes locally.
- [ ] I have formatted the changes with [Prettier](https://prettier.io/) and/or `cargo fmt` on default settings.



Diff:
diff --git a/compiler/noirc_frontend/src/hir/def_map/mod.rs b/compiler/noirc_frontend/src/hir/def_map/mod.rs
index 6014ba8d2ee..2ab7e176387 100644
--- a/compiler/noirc_frontend/src/hir/def_map/mod.rs
+++ b/compiler/noirc_frontend/src/hir/def_map/mod.rs
@@ -438,6 +438,17 @@ impl FuzzingHarness {
         match self.scope {
             FuzzingScope::OnlyFailWith { .. } => true,
             FuzzingScope::None => false,
+            FuzzingScope::ShouldFailWith { .. } => false,
+        }
+    }
+    /// Returns true if the fuzzing harness has been specified to fail
+    /// This is done by annotating the function with `#[fuzz(should_fail)]`
+    /// or `#[fuzz(should_fail_with = "reason")]`
+    pub fn should_fail_enabled(&self) -> bool {
+        match self.scope {
+            FuzzingScope::OnlyFailWith { .. } => false,
+            FuzzingScope::None => false,
+            FuzzingScope::ShouldFailWith { .. } => true,
         }
     }
 
@@ -447,6 +458,7 @@ impl FuzzingHarness {
         match &self.scope {
             FuzzingScope::None => None,
             FuzzingScope::OnlyFailWith { reason } => Some(reason.clone()),
+            FuzzingScope::ShouldFailWith { reason } => reason.clone(),
         }
     }
 }
diff --git a/compiler/noirc_frontend/src/lexer/errors.rs b/compiler/noirc_frontend/src/lexer/errors.rs
index 004eea05439..6a39af6d47a 100644
--- a/compiler/noirc_frontend/src/lexer/errors.rs
+++ b/compiler/noirc_frontend/src/lexer/errors.rs
@@ -125,7 +125,7 @@ impl LexerErrorKind {
             ),
             LexerErrorKind::MalformedFuzzAttribute { location } => (
                 "Malformed fuzz attribute".to_string(),
-                "The fuzz attribute can be written in one of these forms: `#[fuzz]` or `#[fuzz(only_fail_with = \"message\")]`".to_string(),
+                "The fuzz attribute can be written in one of these forms: `#[fuzz]`, `#[fuzz(should_fail)]`, `#[fuzz(should_fail_with = \"message\")]` or `#[fuzz(only_fail_with = \"message\")]`".to_string(),
                 *location,
             ),
             LexerErrorKind::InvalidInnerAttribute { location, found } => (
diff --git a/compiler/noirc_frontend/src/lexer/token.rs b/compiler/noirc_frontend/src/lexer/token.rs
index dad9feaf31c..b9886e8638f 100644
--- a/compiler/noirc_frontend/src/lexer/token.rs
+++ b/compiler/noirc_frontend/src/lexer/token.rs
@@ -751,6 +751,12 @@ impl fmt::Display for TestScope {
 /// FuzzingScopr is used to specify additional annotations for fuzzing harnesses
 #[derive(PartialEq, Eq, Hash, Debug, Clone, PartialOrd, Ord)]
 pub enum FuzzingScope {
+    /// If the fuzzing harness has a scope of ShouldFailWith, then it should only pass
+    /// if it fails with the specified reason. If the reason is None, then
+    /// the harness must unconditionally fail
+    ShouldFailWith {
+        reason: Option<String>,
+    },
     /// If a fuzzing harness has a scope of OnlyFailWith, then it will only detect an assert
     /// if it fails with the specified reason.
     OnlyFailWith {
@@ -764,6 +770,10 @@ impl fmt::Display for FuzzingScope {
         match self {
             FuzzingScope::None => write!(f, ""),
             FuzzingScope::OnlyFailWith { reason } => write!(f, "(only_fail_with = {reason:?})"),
+            FuzzingScope::ShouldFailWith { reason } => match reason {
+                Some(failure_reason) => write!(f, "(should_fail_with = {failure_reason:?})"),
+                None => write!(f, "(should_fail)"),
+            },
         }
     }
 }
diff --git a/compiler/noirc_frontend/src/parser/parser/attributes.rs b/compiler/noirc_frontend/src/parser/parser/attributes.rs
index e86cec93497..1caf9a41a13 100644
--- a/compiler/noirc_frontend/src/parser/parser/attributes.rs
+++ b/compiler/noirc_frontend/src/parser/parser/attributes.rs
@@ -58,6 +58,8 @@ impl Parser<'_> {
     ///     | 'test' '(' 'should_fail_with' '=' string ')'
     ///     | 'fuzz'
     ///     | 'fuzz' '(' 'only_fail_with' '=' string ')'
+    ///     | 'fuzz' '(' 'should_fail' ')'
+    ///     | 'fuzz' '(' 'should_fail_with' '=' string ')'
     ///
     /// SecondaryAttribute
     ///     = 'abi' '(' AttributeValue ')'
@@ -147,7 +149,7 @@ impl Parser<'_> {
                     self.parse_test_attribute(start_location)
                 } else if ident.as_str() == "fuzz" {
                     // The fuzz attribute is a secondary attribute that has `a = b` in its syntax
-                    // (`only_fail_with = "..."``) so we parse it differently.
+                    // (`only_fail_with = "..."``) or (`should_fail_with = "..."``) so we parse it differently.
                     self.parse_fuzz_attribute(start_location)
                 } else {
                     // Every other attribute has the form `name(arg1, arg2, .., argN)`
@@ -366,6 +368,15 @@ impl Parser<'_> {
         let scope = if self.eat_left_paren() {
             let scope = if let Some(ident) = self.eat_ident() {
                 match ident.as_str() {
+                    "should_fail" => Some(FuzzingScope::ShouldFailWith { reason: None }),
+                    "should_fail_with" => {
+                        self.eat_or_error(Token::Assign);
+                        if let Some(reason) = self.eat_str() {
+                            Some(FuzzingScope::ShouldFailWith { reason: Some(reason) })
+                        } else {
+                            Some(FuzzingScope::ShouldFailWith { reason: None })
+                        }
+                    }
                     "only_fail_with" => {
                         self.eat_or_error(Token::Assign);
                         self.eat_str().map(|reason| FuzzingScope::OnlyFailWith { reason })
diff --git a/docs/docs/tooling/fuzzing.md b/docs/docs/tooling/fuzzing.md
index 518966ee8ab..c4865bf2a6d 100644
--- a/docs/docs/tooling/fuzzing.md
+++ b/docs/docs/tooling/fuzzing.md
@@ -84,6 +84,34 @@ It is recommended to use `--skip-underconstrained-check` to increase compilation
 
 
 ## Fuzzing more complex programs
+### Using `should_fail` and `should_fail_with`
+
+The fuzzer can be used to fuzz programs that are expected to fail. To do this, you can use the `should_fail` and `should_fail_with` attributes.
+
+The following example will fuzz the program with the `should_fail` attribute, and will only consider a test case as a failure if the program passes:
+```rust
+#[fuzz(should_fail)]
+fn fuzz_should_fail(a: [bool; 32]) {
+    let mut or_sum= false;
+    for i in 0..32 {
+        or_sum=or_sum|(a[i]==((i&1)as bool));
+    }
+    assert(!or_sum);
+}
+```
+
+The `should_fail_with` expects that the program will fail with a specific error message. The following example will fuzz the program with the `should_fail_with` attribute, and will only consider a test case as a failure if the program passes or fails with the message different from "This is the message that will be checked for":
+```rust
+#[fuzz(should_fail_with = "This is the message that will be checked for")]
+fn fuzz_should_fail_with(a: [bool; 32]) {
+    let mut or_sum= false;
+    for i in 0..32 {
+        or_sum=or_sum|(a[i]==((i&1)as bool));
+    }
+    assert(or_sum);
+    assert(false, "This is the message that will be checked for");
+}
+```
 
 ### Using `only_fail_with`
 A lot of the time, the program will already have many expected assertions that would lead to a failing test case, for example:
@@ -94,7 +122,7 @@ fn fuzz_add(a: u64, b: u64) {
     assert((a+b-15)!=(a-b+30));
 }
 ```
-Using integer arithmetic will often automatically lead to overflows and underflows, which will lead to a failing test case. To avoid this we can specify and "only_fail_with" attribute to the fuzzing harness, which will only mark a testcase as failing if the assertion contains a specific message:
+Using integer arithmetic will often automatically lead to overflows and underflows, which will lead to a failing test case. If we want to check that a specific property is broken, rather than detect all failures, we can specify an "only_fail_with" attribute to the fuzzing harness, which will only mark a testcase as failing if the assertion contains a specific message:
 
 ```rust
 #[fuzz(only_fail_with = "This is the message that will be checked for")]
diff --git a/test_programs/fuzzing_failure/partial_witness_should_fail/Nargo.toml b/test_programs/fuzzing_failure/partial_witness_should_fail/Nargo.toml
new file mode 100644
index 00000000000..b2d8a194742
--- /dev/null
+++ b/test_programs/fuzzing_failure/partial_witness_should_fail/Nargo.toml
@@ -0,0 +1,3 @@
+[package]
+name = "partial_witness_should_fail"
+type = "bin"
diff --git a/test_programs/fuzzing_failure/partial_witness_should_fail/src/main.nr b/test_programs/fuzzing_failure/partial_witness_should_fail/src/main.nr
new file mode 100644
index 00000000000..1f8de606a5e
--- /dev/null
+++ b/test_programs/fuzzing_failure/partial_witness_should_fail/src/main.nr
@@ -0,0 +1,107 @@
+#[fuzz(should_fail)]
+// This program is expected to fail, unless one of two combinations of inputs is provided (all true or all but 0th input is true)
+// The fuzzer should find the case where the program does not fail. It is important that the program has 2 conditions, otherwise the compiler will solve the single condition for brillig
+// and the fuzzer without partial witness parsing for ACIR will find the solution trivially.
+fn fuzz_partial_witness_fail(
+    a0: bool,
+    a1: bool,
+    a2: bool,
+    a3: bool,
+    a4: bool,
+    a5: bool,
+    a6: bool,
+    a7: bool,
+    a8: bool,
+    a9: bool,
+    a10: bool,
+    a11: bool,
+    a12: bool,
+    a13: bool,
+    a14: bool,
+    a15: bool,
+    a16: bool,
+    a17: bool,
+    a18: bool,
+    a19: bool,
+    a20: bool,
+    a21: bool,
+    a22: bool,
+    a23: bool,
+    a24: bool,
+    a25: bool,
+    a26: bool,
+    a27: bool,
+    a28: bool,
+    a29: bool,
+    a30: bool,
+    a31: bool,
+) {
+    assert(
+        a0
+            & a1
+            & a2
+            & a3
+            & a4
+            & a5
+            & a6
+            & a7
+            & a8
+            & a9
+            & a10
+            & a11
+            & a12
+            & a13
+            & a14
+            & a15
+            & a16
+            & a17
+            & a18
+            & a19
+            & a20
+            & a21
+            & a22
+            & a23
+            & a24
+            & a25
+            & a26
+            & a27
+            & a28
+            & a29
+            & a30
+            & a31
+            | (
+                !a0
+                    & a1
+                    & a2
+                    & a3
+                    & a4
+                    & a5
+                    & a6
+                    & a7
+                    & a8
+                    & a9
+                    & a10
+                    & a11
+                    & a12
+                    & a13
+                    & a14
+                    & a15
+                    & a16
+                    & a17
+                    & a18
+                    & a19
+                    & a20
+                    & a21
+                    & a22
+                    & a23
+                    & a24
+                    & a25
+                    & a26
+                    & a27
+                    & a28
+                    & a29
+                    & a30
+                    & a31
+            ),
+    );
+}
diff --git a/test_programs/fuzzing_failure/should_fail/Nargo.toml b/test_programs/fuzzing_failure/should_fail/Nargo.toml
new file mode 100644
index 00000000000..08e0fed4f43
--- /dev/null
+++ b/test_programs/fuzzing_failure/should_fail/Nargo.toml
@@ -0,0 +1,3 @@
+[package]
+name = "should_fail"
+type = "bin"
diff --git a/test_programs/fuzzing_failure/should_fail/src/main.nr b/test_programs/fuzzing_failure/should_fail/src/main.nr
new file mode 100644
index 00000000000..6ebf464187d
--- /dev/null
+++ b/test_programs/fuzzing_failure/should_fail/src/main.nr
@@ -0,0 +1,59 @@
+// This program is expected to fail unless all the inputs are true, which is the cast that the fuzzer should find
+#[fuzz(should_fail)]
+fn fuzz(
+    a_00: bool,
+    a_01: bool,
+    a_02: bool,
+    a_03: bool,
+    a_04: bool,
+    a_05: bool,
+    a_06: bool,
+    a_07: bool,
+    a_08: bool,
+    a_09: bool,
+    a_10: bool,
+    a_11: bool,
+    a_12: bool,
+    a_13: bool,
+    a_14: bool,
+    a_15: bool,
+    a_16: bool,
+    a_17: bool,
+    a_18: bool,
+    a_19: bool,
+    a_20: bool,
+    a_21: bool,
+    a_22: bool,
+    a_23: bool,
+    a_24: bool,
+) {
+    assert(
+        (
+            a_00
+                & a_01
+                & a_02
+                & a_03
+                & a_04
+                & a_05
+                & a_06
+                & a_07
+                & a_08
+                & a_09
+                & a_10
+                & a_11
+                & a_12
+                & a_13
+                & a_14
+                & a_15
+                & a_16
+                & a_17
+                & a_18
+                & a_19
+                & a_20
+                & a_21
+                & a_22
+                & a_23
+                & a_24
+        ),
+    );
+}
diff --git a/test_programs/fuzzing_failure/should_fail_with/Nargo.toml b/test_programs/fuzzing_failure/should_fail_with/Nargo.toml
new file mode 100644
index 00000000000..08e0fed4f43
--- /dev/null
+++ b/test_programs/fuzzing_failure/should_fail_with/Nargo.toml
@@ -0,0 +1,3 @@
+[package]
+name = "should_fail"
+type = "bin"
diff --git a/test_programs/fuzzing_failure/should_fail_with/src/main.nr b/test_programs/fuzzing_failure/should_fail_with/src/main.nr
new file mode 100644
index 00000000000..91fe40fa627
--- /dev/null
+++ b/test_programs/fuzzing_failure/should_fail_with/src/main.nr
@@ -0,0 +1,6 @@
+// This program expects the non-deadbeef failure message, so the deadbeef case is treated as an actual failure
+#[fuzz(should_fail_with = "input is not 0xdeadbeef")]
+fn fuzz(input: u64) {
+    assert(input == 0xdeadbeef, "input is not 0xdeadbeef");
+    assert(input != 0xdeadbeef, "input is 0xdeadbeef");
+}
diff --git a/tooling/greybox_fuzzer/src/coverage.rs b/tooling/greybox_fuzzer/src/coverage.rs
index 5b550116589..1af3e62ab5d 100644
--- a/tooling/greybox_fuzzer/src/coverage.rs
+++ b/tooling/greybox_fuzzer/src/coverage.rs
@@ -46,24 +46,25 @@ pub type UniqueFeatureIndex = usize;
 /// A map from a particular branch or comparison to its unique index in the raw vector used inside brillig vm
 pub type FeatureToIndexMap = HashMap<Feature, UniqueFeatureIndex>;
 
-/// Mechanism for automated detection of boolean witnesses in the ACIR witness map
-#[derive(Default)]
-pub struct PotentialBoolWitnessList {
+/// Mechanism for excluding non-boolean witnesses from the ACIR witness map
+/// This mechanism was introduced after we started to use partial witness maps from failed executions
+#[derive(Default, Clone)]
+pub struct NonBoolWitnessList {
     // Set of witnesses that could be boolean values
     witness: HashSet<Witness>,
 }
 
-impl From<&WitnessStack<FieldElement>> for PotentialBoolWitnessList {
-    /// Generate a bool witness list by parsing the witnesses in the program
+impl From<&WitnessStack<FieldElement>> for NonBoolWitnessList {
+    /// Generate a non-bool witness list by parsing the witnesses in the program
     fn from(witness_stack: &WitnessStack<FieldElement>) -> Self {
         let mut witness_set = HashSet::new();
         // Should be only one function
         assert!(witness_stack.length() == 1);
         let first_func_witnesses = witness_stack.peek().unwrap();
 
-        // Look for witnesses that are either 0 or 1
+        // Look for witnesses that are neither 0 nor 1
         for (witness_index, value) in first_func_witnesses.witness.clone().into_iter() {
-            if value.is_one() || value.is_zero() {
+            if !(value.is_one() || value.is_zero()) {
                 witness_set.insert(witness_index);
             }
         }
@@ -71,56 +72,31 @@ impl From<&WitnessStack<FieldElement>> for PotentialBoolWitnessList {
     }
 }
 
-impl PotentialBoolWitnessList {
+impl NonBoolWitnessList {
     pub fn new(given_set: HashSet<Witness>) -> Self {
         Self { witness: given_set }
     }
 
-    /// Given witnesses from a program, remove non-boolean witnesses from the list
-    pub fn update(&mut self, witness_stack: &WitnessStack<FieldElement>) {
+    /// Given witnesses from a program, add non-boolean witnesses to the list
+    pub fn update_with_witness_stack(&mut self, witness_stack: &WitnessStack<FieldElement>) {
         assert!(witness_stack.length() == 1);
         let first_func_witnesses = witness_stack.peek().unwrap();
-        let mut witnesses_for_removal = Vec::new();
 
         // Go through the list of perceived boolean witnesses
-        for witness_index in self.witness.iter().copied() {
-            let value = first_func_witnesses
-                .witness
-                .get(&witness_index)
-                .expect("There should be a witness in the witness map");
-
-            // Check that the values are zero or one
-            if !value.is_one() && !value.is_zero() {
-                witnesses_for_removal.push(witness_index);
+        for (witness_index, value) in first_func_witnesses.witness.clone().into_iter() {
+            if !(value.is_one() || value.is_zero()) {
+                self.witness.insert(witness_index);
             }
         }
-
-        // Remove values that are not boolean
-        for witness_index in witnesses_for_removal.into_iter() {
-            self.witness.remove(&witness_index);
-        }
     }
 
-    /// Create a new list by filtering the witness indices in this list using the witnesses produced from ACIR execution
+    /// Create a new list of non-boolean witnesses by merging the current list with the witnesses from a new execution
     pub fn merge_new(&self, witness_stack: &WitnessStack<FieldElement>) -> Self {
-        assert!(witness_stack.length() == 1);
-        let first_func_witnesses = witness_stack.peek().unwrap();
-        let mut new_set = HashSet::new();
-
-        // Keep only witnesses that are 0 or 1
-        for witness_index in self.witness.iter().copied() {
-            let value = first_func_witnesses
-                .witness
-                .get(&witness_index)
-                .expect("There should be a witness in the witness map");
-            if value.is_zero() || value.is_one() {
-                new_set.insert(witness_index);
-            }
-        }
-        Self::new(new_set)
+        let mut new_list = NonBoolWitnessList::new(self.witness.clone());
+        new_list.update_with_witness_stack(witness_stack);
+        new_list
     }
 }
-
 /// Represents a single encountered state of a boolean witness in the Acir program
 #[derive(Hash, PartialEq, Eq, Clone, Copy, Debug)]
 pub struct AcirBoolState {
@@ -168,7 +144,7 @@ impl SingleTestCaseCoverage {
         testcase_id: TestCaseId,
         acir_witness_stack: &Option<WitnessStack<FieldElement>>,
         brillig_coverage: RawBrilligCoverage,
-        potential_bool_witness_list: &PotentialBoolWitnessList,
+        non_bool_witness_list: &NonBoolWitnessList,
     ) -> Self {
         // Process all booleans
         let mut acir_bool_coverage = Vec::new();
@@ -177,19 +153,18 @@ impl SingleTestCaseCoverage {
         if let Some(acir_witnesses) = acir_witness_stack {
             let witness_map = &acir_witnesses.peek().unwrap().witness;
 
-            // Collect states of all boolean witnesses
-            for potential_bool_witness_index in potential_bool_witness_list.witness.iter() {
-                let value =
-                    witness_map.get(potential_bool_witness_index).expect("Witness should be there");
-                assert!(value.is_zero() || value.is_one());
-
-                acir_bool_coverage.push(AcirBoolState {
-                    witness_id: potential_bool_witness_index.witness_index(),
-                    state: value.is_one(),
-                });
+            // For each witness, if it is a boolean and not in the non-bool witness list, add it to the acir bool coverage
+            for (witness, value) in witness_map.clone().into_iter() {
+                if (value.is_zero() || value.is_one())
+                    && !non_bool_witness_list.witness.contains(&witness)
+                {
+                    acir_bool_coverage.push(AcirBoolState {
+                        witness_id: witness.witness_index(),
+                        state: value.is_one(),
+                    });
+                }
             }
         }
-
         Self { testcase_id, acir_bool_coverage, brillig_coverage }
     }
 }
@@ -240,8 +215,8 @@ pub struct AccumulatedFuzzerCoverage {
     brillig_branch_coverage: Vec<AccumulatedSingleBranchCoverage>,
     /// Comparison coverage in brillig that has been observed
     brillig_cmp_approach_coverage: Vec<AccumulatedCmpCoverage>,
-    /// The list of indices of all witnesses that are inferred to be boolean
-    pub potential_bool_witness_list: Option<PotentialBoolWitnessList>,
+    /// The list of indices of all witnesses that are inferred to be non-boolean
+    pub non_bool_witness_list: NonBoolWitnessList,
 }
 
 type UnusedTestcaseIdSet = HashSet<TestCaseId>;
@@ -297,7 +272,7 @@ impl AccumulatedFuzzerCoverage {
             bool_state_to_testcase_id: HashMap::new(),
             brillig_branch_coverage: single_branch_coverage,
             brillig_cmp_approach_coverage: cmp_coverage,
-            potential_bool_witness_list: None,
+            non_bool_witness_list: NonBoolWitnessList::new(HashSet::new()),
         }
     }
 
@@ -360,8 +335,16 @@ impl AccumulatedFuzzerCoverage {
         unused_testcases
     }
 
+    /// Update the non-bool witness list with a witness stack
+    pub fn update_non_bool_witness_list_with_witness_stack(
+        &mut self,
+        witness_stack: &WitnessStack<FieldElement>,
+    ) {
+        self.non_bool_witness_list.update_with_witness_stack(witness_stack);
+    }
     /// Merge the coverage of a testcase into accumulated coverage
     /// Returns (false, empty set) if there is no new coverage (true, set of no longer needed testcases' ids) if there is
+    /// We assume that the non-bool witness list is already updated and is the same in new_coverage
     pub fn merge(&mut self, new_coverage: &SingleTestCaseCoverage) -> (bool, UnusedTestcaseIdSet) {
         // Use quick detect first to see if we need to try and merge anything
         if !self.detect_new_coverage(new_coverage) {
@@ -378,8 +361,9 @@ impl AccumulatedFuzzerCoverage {
 
         self.merge_branch_coverage(new_coverage, &mut add_to_leavers);
         self.merge_comparison_coverage(new_coverage, &mut add_to_leavers);
+
         self.merge_acir_coverage(new_coverage, &mut add_to_leavers);
-        self.remove_boolean_witness_false_positives(new_coverage, &mut add_to_leavers);
+        self.remove_boolean_witness_false_positives(&mut add_to_leavers);
 
         // Filter testcase ids of testcases, whose feature ownership has been revoked by this new testcase
         (true, self.check_if_unused(&potential_leavers))
@@ -532,6 +516,11 @@ impl AccumulatedFuzzerCoverage {
     ) {
         // Insert all ACIR states and replace testcase association
         for acir_bool_state in new_coverage.acir_bool_coverage.iter() {
+            let witness = Witness::new(acir_bool_state.witness_id);
+            // If the witness is non-boolean according the merged non-bool witness list, skip it
+            if self.non_bool_witness_list.witness.contains(&witness) {
+                continue;
+            }
             add_to_leavers(
                 self.bool_state_to_testcase_id.insert(*acir_bool_state, new_coverage.testcase_id),
             );
@@ -543,20 +532,13 @@ impl AccumulatedFuzzerCoverage {
 
     fn remove_boolean_witness_false_positives(
         &mut self,
-        new_coverage: &SingleTestCaseCoverage,
         add_to_leavers: &mut impl FnMut(Option<TestCaseId>),
     ) {
-        // Get all boolean witnesses in the state
-        let all_witnesses_in_bool_coverage: HashSet<_> = new_coverage
-            .acir_bool_coverage
-            .iter()
-            .map(|acir_bool_state| acir_bool_state.witness_id)
-            .collect();
-
         let mut states_to_remove = Vec::new();
-        // Check that all boolean state witnesses observed in accumulated coverage are booleans here, too
+
+        // Check that all boolean state witnesses observed in accumulated coverage are not in the non-bool witness list
         for state in self.acir_bool_coverage.iter() {
-            if !all_witnesses_in_bool_coverage.contains(&state.witness_id) {
+            if self.non_bool_witness_list.witness.contains(&Witness::new(state.witness_id)) {
                 states_to_remove.push(*state);
             }
         }
diff --git a/tooling/greybox_fuzzer/src/lib.rs b/tooling/greybox_fuzzer/src/lib.rs
index a3804ad5c0f..966698fbd2d 100644
--- a/tooling/greybox_fuzzer/src/lib.rs
+++ b/tooling/greybox_fuzzer/src/lib.rs
@@ -13,8 +13,8 @@ use acvm::{
     },
 };
 use coverage::{
-    AccumulatedFuzzerCoverage, BrilligCoverageRanges, FeatureToIndexMap, PotentialBoolWitnessList,
-    RawBrilligCoverage, SingleTestCaseCoverage, analyze_brillig_program_before_fuzzing,
+    AccumulatedFuzzerCoverage, BrilligCoverageRanges, FeatureToIndexMap, RawBrilligCoverage,
+    SingleTestCaseCoverage, analyze_brillig_program_before_fuzzing,
 };
 use noir_fuzzer::dictionary::build_dictionary_from_program;
 
@@ -56,9 +56,12 @@ type SimpleXorShiftRNGSeed = <XorShiftRng as SeedableRng>::Seed;
 /// Information collected from testcase execution on success
 pub type WitnessAndCoverage = (WitnessStack<FieldElement>, Option<Vec<u32>>);
 
-/// Information collected from testcase execution on failure
+/// Information collected from testcase execution on failure in brillig
 pub type ErrorAndCoverage = (String, Option<Vec<u32>>);
 
+/// Information collected from testcase execution on failure in ACIR
+pub type ErrorAndWitness = (String, WitnessStack<FieldElement>);
+
 /// A structure with the values for a single mutation-fuzz iteration in the fuzzer
 struct FuzzTask {
     /// The id of the main testcase that is going to be mutated
@@ -270,12 +273,11 @@ pub struct FuzzedExecutorExecutionConfiguration {
     pub timeout: u64,
 }
 
-pub struct FuzzedExecutorFailureConfiguration {
-    /// Fail on specific asserts
-    pub fail_on_specific_asserts: bool,
-
-    /// Failure reason
-    pub failure_reason: Option<String>,
+pub enum FuzzedExecutorFailureConfiguration {
+    None,                   // Fail on any failing assertion
+    OnlyFailWith(String),   // Fail on failing assertions containing a specific message
+    ShouldFail,             // Pass if the program fails
+    ShouldFailWith(String), // Pass if the program fails with a specific message
 }
 
 pub struct FuzzedExecutorFolderConfiguration {
@@ -345,7 +347,7 @@ impl<
     E: Fn(
             &Program<FieldElement>,
             WitnessMap<FieldElement>,
-        ) -> Result<WitnessStack<FieldElement>, String>
+        ) -> Result<WitnessStack<FieldElement>, ErrorAndWitness>
         + Sync,
     F: Fn(
             &Program<FieldElement>,
@@ -410,19 +412,12 @@ impl<
         witness: &Option<WitnessStack<FieldElement>>,
         brillig_coverage: &Option<RawBrilligCoverage>,
     ) -> bool {
-        // Get a list of boolean witnesses (default or taken from accumulated coverage)
-        let mut default_list = PotentialBoolWitnessList::default();
-        let mut bool_witness_list =
-            accumulated_coverage.potential_bool_witness_list.as_ref().unwrap_or(&default_list);
+        // Get a list of non-boolean witnesses (default or taken from accumulated coverage)
+        let mut non_bool_witness_list = accumulated_coverage.non_bool_witness_list.clone();
 
         // If ACVM solved the witness, collect boolean states
-        if witness.is_some() {
-            default_list = accumulated_coverage
-                .potential_bool_witness_list
-                .as_ref()
-                .unwrap_or(&default_list)
-                .merge_new(witness.as_ref().unwrap());
-            bool_witness_list = &default_list;
+        if let Some(witness) = witness {
+            non_bool_witness_list = non_bool_witness_list.merge_new(witness);
         }
 
         // Form a coverage object with coverage from this run
@@ -431,7 +426,7 @@ impl<
             TestCaseId::default(),
             witness,
             brillig_coverage.clone().unwrap(),
-            bool_witness_list,
+            &non_bool_witness_list,
         );
         // Quickly detect if there is any new coverage so that later single-threaded check can quickly discard this testcase
         accumulated_coverage.detect_new_coverage(&new_coverage)
@@ -743,19 +738,11 @@ impl<
                         )
                     }
                 };
-                // If we ran ACIR and  managed to produce an ACIR witness
-                if acir_round && witness.is_some() {
-                    // Update the potential list
-                    if accumulated_coverage.potential_bool_witness_list.is_none() {
-                        // If it's the first time, we need to assign
-                        accumulated_coverage.potential_bool_witness_list =
-                            Some(PotentialBoolWitnessList::from(witness.as_ref().unwrap()));
-                    } else {
+                // If we ran ACIR and managed to produce an ACIR witness
+                if acir_round {
+                    if let Some(ref witness) = witness {
                         accumulated_coverage
-                            .potential_bool_witness_list
-                            .as_mut()
-                            .unwrap()
-                            .update(witness.as_ref().unwrap());
+                            .update_non_bool_witness_list_with_witness_stack(witness);
                     }
                 }
 
@@ -764,10 +751,7 @@ impl<
                     case_id,
                     &witness,
                     brillig_coverage.clone(),
-                    accumulated_coverage
-                        .potential_bool_witness_list
-                        .as_ref()
-                        .unwrap_or(&PotentialBoolWitnessList::default()),
+                    &accumulated_coverage.non_bool_witness_list,
                 );
 
                 // In case this is just a brillig round, we need to detect first, since a merge might skip some witnesses that we haven't added from acir
@@ -874,32 +858,16 @@ impl<
                     };
                 self.metrics.increase_total_acir_duration_micros(&acir_duration_micros);
 
-                // In case ACIR execution was successful
-                if witness.is_some() {
-                    // Update the potential list
-                    if accumulated_coverage.potential_bool_witness_list.is_none() {
-                        // If it's the first time, we need to assign
-                        accumulated_coverage.potential_bool_witness_list =
-                            Some(PotentialBoolWitnessList::from(witness.as_ref().unwrap()));
-                    } else {
-                        accumulated_coverage
-                            .potential_bool_witness_list
-                            .as_mut()
-                            .unwrap()
-                            .update(witness.as_ref().unwrap());
-                    }
+                // In case we got a witness from ACIR
+                if let Some(ref witness) = witness {
+                    accumulated_coverage.update_non_bool_witness_list_with_witness_stack(witness);
                 }
-                // If we ran just brillig at the start, we won't have a potential bool witness list, so we need a dummy
-                let mut dummy_witness_list_for_brillig = PotentialBoolWitnessList::default();
 
                 let new_coverage = SingleTestCaseCoverage::new(
                     case_id,
                     &witness,
                     brillig_coverage,
-                    accumulated_coverage
-                        .potential_bool_witness_list
-                        .as_mut()
-                        .unwrap_or(&mut dummy_witness_list_for_brillig),
+                    &accumulated_coverage.non_bool_witness_list,
                 );
                 let (new_coverage_discovered, testcases_to_remove) =
                     accumulated_coverage.merge(&new_coverage);
@@ -1023,18 +991,33 @@ impl<
         // Parse results
         match (result_acir, result_brillig) {
             (Ok(witnesses), Ok((_map, brillig_coverage))) => {
-                // If both were OK, collect coverage and ACIR witnesses along with timings and return
-                HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
-                    case_id: testcase.id(),
-                    case: testcase.value().clone(),
-                    witness: Some(witnesses),
-                    brillig_coverage: Some(brillig_coverage.unwrap()),
-                    acir_duration_micros: acir_elapsed.as_micros(),
-                    brillig_duration_micros: brillig_elapsed.as_micros(),
-                })
+                // If we expect the program to always fail, we need to return a counter example
+                match self.failure_configuration {
+                    // If we expect the program to always fail, we need to return a counter example, if it actually passes
+                    FuzzedExecutorFailureConfiguration::ShouldFail
+                    | FuzzedExecutorFailureConfiguration::ShouldFailWith(_) => {
+                        HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
+                            case_id: testcase.id(),
+                            exit_reason: "".to_string(),
+                            counterexample: testcase.value().clone(),
+                        })
+                    }
+                    FuzzedExecutorFailureConfiguration::OnlyFailWith(_)
+                    | FuzzedExecutorFailureConfiguration::None => {
+                        // If both were OK, collect coverage and ACIR witnesses along with timings and return
+                        HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
+                            case_id: testcase.id(),
+                            case: testcase.value().clone(),
+                            witness: Some(witnesses),
+                            brillig_coverage: Some(brillig_coverage.unwrap()),
+                            acir_duration_micros: acir_elapsed.as_micros(),
+                            brillig_duration_micros: brillig_elapsed.as_micros(),
+                        })
+                    }
+                }
             }
             // If results diverge, it's a discrepancy
-            (Err(err), Ok(_)) => HarnessExecutionOutcome::Discrepancy(DiscrepancyOutcome {
+            (Err((err, _)), Ok(_)) => HarnessExecutionOutcome::Discrepancy(DiscrepancyOutcome {
                 case_id: testcase.id(),
                 exit_reason: err,
                 acir_failed: true,
@@ -1047,42 +1030,97 @@ impl<
                 counterexample: testcase.value().clone(),
             }),
             // If both failed, then we need to check
-            (Err(..), Err((err, coverage))) => {
-                // If this is a foreign call failure, we need to inform the user
-                if err.contains(FOREIGN_CALL_FAILURE_SUBSTRING) {
-                    return HarnessExecutionOutcome::ForeignCallFailure(
-                        types::ForeignCallErrorInFuzzing { exit_reason: err },
-                    );
-                }
-                // If failures are expected and this is not the failure that we are looking for, then don't treat as failure
-                if self.failure_configuration.fail_on_specific_asserts
-                    && !err.contains(
-                        self.failure_configuration
-                            .failure_reason
-                            .as_ref()
-                            .expect("Failure reason should be provided"),
-                    )
-                {
-                    return HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
+            (Err((err, witness)), Err((_, coverage))) => self.handle_failed_case(
+                &err,
+                Some(witness),
+                coverage,
+                acir_elapsed,
+                brillig_elapsed,
+                testcase,
+            ),
+        }
+    }
+
+    /// Handle a failed execution case
+    /// The handling depends on the failure configuration
+    fn handle_failed_case(
+        &self,
+        err: &str,
+        witness: Option<WitnessStack<FieldElement>>,
+        coverage: Option<RawBrilligCoverage>,
+        acir_elapsed: Duration,
+        brillig_elapsed: Duration,
+        testcase: &TestCase,
+    ) -> HarnessExecutionOutcome {
+        // If this is a foreign call failure, we need to inform the user
+        if err.contains(FOREIGN_CALL_FAILURE_SUBSTRING) {
+            return HarnessExecutionOutcome::ForeignCallFailure(types::ForeignCallErrorInFuzzing {
+                exit_reason: err.to_string(),
+            });
+        }
+        match &self.failure_configuration {
+            // Failure is failure
+            FuzzedExecutorFailureConfiguration::None => {
+                // This is a bug, inform the user
+                HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
+                    case_id: testcase.id(),
+                    exit_reason: err.to_string(),
+                    counterexample: testcase.value().clone(),
+                })
+            }
+            // Failure is failure if the message in assertion matches the failure reason
+            FuzzedExecutorFailureConfiguration::OnlyFailWith(failure_reason) => {
+                // If we have triggered the failure that we are looking for, then it's a failure
+                if err.contains(failure_reason) {
+                    HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
+                        case_id: testcase.id(),
+                        exit_reason: err.to_string(),
+                        counterexample: testcase.value().clone(),
+                    })
+                } else {
+                    HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
                         case_id: testcase.id(),
                         case: testcase.value().clone(),
-                        witness: None,
+                        witness,
                         brillig_coverage: coverage,
                         acir_duration_micros: acir_elapsed.as_micros(),
                         brillig_duration_micros: brillig_elapsed.as_micros(),
-                    });
+                    })
                 }
-
-                // This is a bug, inform the user
-                HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
+            }
+            // Failure is a success
+            FuzzedExecutorFailureConfiguration::ShouldFail => {
+                HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
                     case_id: testcase.id(),
-                    exit_reason: err,
-                    counterexample: testcase.value().clone(),
+                    case: testcase.value().clone(),
+                    witness,
+                    brillig_coverage: coverage,
+                    acir_duration_micros: acir_elapsed.as_micros(),
+                    brillig_duration_micros: brillig_elapsed.as_micros(),
                 })
             }
+            // Failure is a success if the message in assertion matches the failure reason
+            FuzzedExecutorFailureConfiguration::ShouldFailWith(failure_reason) => {
+                // If the failure reason is in the error message
+                if err.contains(failure_reason) {
+                    HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
+                        case_id: testcase.id(),
+                        case: testcase.value().clone(),
+                        witness,
+                        brillig_coverage: coverage,
+                        acir_duration_micros: acir_elapsed.as_micros(),
+                        brillig_duration_micros: brillig_elapsed.as_micros(),
+                    })
+                } else {
+                    HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
+                        case_id: testcase.id(),
+                        exit_reason: err.to_string(),
+                        counterexample: testcase.value().clone(),
+                    })
+                }
+            }
         }
     }
-
     /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
     /// or a `CounterExampleOutcome`
     pub fn single_fuzz_acir(&self, testcase: &TestCase) -> HarnessExecutionOutcome {
@@ -1092,44 +1130,38 @@ impl<
         let acir_elapsed = acir_start.elapsed();
 
         match result_acir {
-            Ok(witnesses) => HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
-                case_id: testcase.id(),
-                case: testcase.value().clone(),
-                witness: Some(witnesses),
-                brillig_coverage: None,
-                acir_duration_micros: acir_elapsed.as_micros(),
-                brillig_duration_micros: 0,
-            }),
-            Err(err) => {
-                if err.contains(FOREIGN_CALL_FAILURE_SUBSTRING) {
-                    return HarnessExecutionOutcome::ForeignCallFailure(
-                        types::ForeignCallErrorInFuzzing { exit_reason: err },
-                    );
-                }
-                if self.failure_configuration.fail_on_specific_asserts
-                    && !err.contains(
-                        self.failure_configuration
-                            .failure_reason
-                            .as_ref()
-                            .expect("Failure reason should be provided"),
-                    )
-                {
-                    // TODO: in the future we can add partial witness propagation from ACIR
-                    return HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
-                        case_id: testcase.id(),
-                        case: testcase.value().clone(),
-                        witness: None,
-                        brillig_coverage: None,
-                        acir_duration_micros: acir_elapsed.as_micros(),
-                        brillig_duration_micros: 0,
-                    });
+            Ok(witnesses) => {
+                match &self.failure_configuration {
+                    // If we expect the program to always fail, we need to return a counter example
+                    FuzzedExecutorFailureConfiguration::ShouldFail
+                    | FuzzedExecutorFailureConfiguration::ShouldFailWith(_) => {
+                        HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
+                            case_id: testcase.id(),
+                            exit_reason: "".to_string(),
+                            counterexample: testcase.value().clone(),
+                        })
+                    }
+                    FuzzedExecutorFailureConfiguration::OnlyFailWith(_)
+                    | FuzzedExecutorFailureConfiguration::None => {
+                        HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
+                            case_id: testcase.id(),
+                            case: testcase.value().clone(),
+                            witness: Some(witnesses),
+                            brillig_coverage: None,
+                            acir_duration_micros: acir_elapsed.as_micros(),
+                            brillig_duration_micros: 0,
+                        })
+                    }
                 }
-                HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
-                    case_id: testcase.id(),
-                    exit_reason: err,
-                    counterexample: testcase.value().clone(),
-                })
             }
+            Err((err, witness)) => self.handle_failed_case(
+                &err,
+                Some(witness),
+                None,
+                acir_elapsed,
+                Duration::from_secs(0),
+                testcase,
+            ),
         }
     }
 
@@ -1146,43 +1178,38 @@ impl<
         let brillig_elapsed = brillig_start.elapsed();
 
         match result_brillig {
-            Ok((_, brillig_coverage)) => HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
-                case_id: testcase.id(),
-                case: testcase.value().clone(),
-                witness: None,
-                brillig_coverage: Some(brillig_coverage.unwrap()),
-                acir_duration_micros: 0,
-                brillig_duration_micros: brillig_elapsed.as_micros(),
-            }),
-            Err((err, coverage)) => {
-                if err.contains(FOREIGN_CALL_FAILURE_SUBSTRING) {
-                    return HarnessExecutionOutcome::ForeignCallFailure(
-                        types::ForeignCallErrorInFuzzing { exit_reason: err },
-                    );
-                }
-                if self.failure_configuration.fail_on_specific_asserts
-                    && !err.contains(
-                        self.failure_configuration
-                            .failure_reason
-                            .as_ref()
-                            .expect("Failure reason should be provided"),
-                    )
-                {
-                    return HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
-                        case_id: testcase.id(),
-                        case: testcase.value().clone(),
-                        witness: None,
-                        brillig_coverage: Some(coverage.unwrap()),
-                        acir_duration_micros: 0,
-                        brillig_duration_micros: brillig_elapsed.as_micros(),
-                    });
+            Ok((_, brillig_coverage)) => {
+                // If we expect the program to always fail, we need to return a counter example
+                match &self.failure_configuration {
+                    FuzzedExecutorFailureConfiguration::ShouldFail
+                    | FuzzedExecutorFailureConfiguration::ShouldFailWith(_) => {
+                        HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
+                            case_id: testcase.id(),
+                            exit_reason: "".to_string(),
+                            counterexample: testcase.value().clone(),
+                        })
+                    }
+                    FuzzedExecutorFailureConfiguration::OnlyFailWith(_)
+                    | FuzzedExecutorFailureConfiguration::None => {
+                        HarnessExecutionOutcome::Case(SuccessfulCaseOutcome {
+                            case_id: testcase.id(),
+                            case: testcase.value().clone(),
+                            witness: None,
+                            brillig_coverage: Some(brillig_coverage.unwrap()),
+                            acir_duration_micros: 0,
+                            brillig_duration_micros: brillig_elapsed.as_micros(),
+                        })
+                    }
                 }
-                HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
-                    case_id: testcase.id(),
-                    exit_reason: err,
-                    counterexample: testcase.value().clone(),
-                })
             }
+            Err((err, coverage)) => self.handle_failed_case(
+                &err,
+                None,
+                coverage,
+                Duration::from_secs(0),
+                brillig_elapsed,
+                testcase,
+            ),
         }
     }
 }
diff --git a/tooling/lsp/src/requests/completion/builtins.rs b/tooling/lsp/src/requests/completion/builtins.rs
index e966b4513b3..957c33f7040 100644
--- a/tooling/lsp/src/requests/completion/builtins.rs
+++ b/tooling/lsp/src/requests/completion/builtins.rs
@@ -153,6 +153,24 @@ impl NodeFinder<'_> {
                     ));
                 }
 
+                if name_matches("fuzz", prefix) || name_matches("should_fail", prefix) {
+                    self.completion_items.push(snippet_completion_item(
+                        "fuzz(should_fail)",
+                        CompletionItemKind::METHOD,
+                        "fuzz(should_fail)",
+                        None,
+                    ));
+                }
+
+                if name_matches("fuzz", prefix) || name_matches("should_fail_with", prefix) {
+                    self.completion_items.push(snippet_completion_item(
+                        "fuzz(should_fail_with = \"...\")",
+                        CompletionItemKind::METHOD,
+                        "fuzz(should_fail_with = \"${1:message}\")",
+                        None,
+                    ));
+                }
+
                 self.suggest_allow("dead_code", prefix);
             }
             AttributeTarget::Let => {
diff --git a/tooling/nargo/src/ops/execute.rs b/tooling/nargo/src/ops/execute.rs
index fafcde72bae..18d8b18b15e 100644
--- a/tooling/nargo/src/ops/execute.rs
+++ b/tooling/nargo/src/ops/execute.rs
@@ -7,6 +7,7 @@ use acvm::pwg::{
 };
 use acvm::{AcirField, BlackBoxFunctionSolver};
 type NargoErrorAndCoverage<F> = (NargoError<F>, Option<Vec<u32>>);
+type NargoErrorAndWitnessStack<F> = (NargoError<F>, WitnessStack<F>);
 type WitnessAndCoverage<F> = (WitnessStack<F>, Option<Vec<u32>>);
 use acvm::{acir::circuit::Circuit, acir::native_types::WitnessMap};
 
@@ -47,6 +48,12 @@ struct ProgramExecutor<'a, F: AcirField, B: BlackBoxFunctionSolver<F>, E: Foreig
 
     // Last recorded fuzzing trace
     last_fuzzing_trace: Option<Vec<u32>>,
+
+    // Flag that states whether we want to return the witness on failure (useful for fuzzing)
+    return_witness_on_failure: bool,
+
+    // Partial witness on failure
+    failing_partial_witness: Option<WitnessMap<F>>,
 }
 
 impl<'a, F: AcirField, B: BlackBoxFunctionSolver<F>, E: ForeignCallExecutor<F>>
@@ -71,6 +78,8 @@ impl<'a, F: AcirField, B: BlackBoxFunctionSolver<F>, E: ForeignCallExecutor<F>>
             brillig_fuzzing_active: false,
             brillig_branch_to_feature_map: None,
             last_fuzzing_trace: None,
+            return_witness_on_failure: false,
+            failing_partial_witness: None,
         }
     }
 
@@ -82,6 +91,10 @@ impl<'a, F: AcirField, B: BlackBoxFunctionSolver<F>, E: ForeignCallExecutor<F>>
         self.brillig_branch_to_feature_map = brillig_branch_to_feature_map;
     }
 
+    fn with_partial_witness_on_failure(&mut self, return_witness_on_failure: bool) {
+        self.return_witness_on_failure = return_witness_on_failure;
+    }
+
     fn finalize(self) -> WitnessStack<F> {
         self.witness_stack
     }
@@ -112,6 +125,9 @@ impl<'a, F: AcirField, B: BlackBoxFunctionSolver<F>, E: ForeignCallExecutor<F>>
                 }
                 ACVMStatus::Failure(error) => {
                     self.last_fuzzing_trace = acvm.get_brillig_fuzzing_trace();
+                    if self.return_witness_on_failure {
+                        self.failing_partial_witness = Some(acvm.witness_map().clone());
+                    }
                     match &error {
                         OpcodeResolutionError::UnsatisfiedConstrain {
                             opcode_location: ErrorLocation::Resolved(opcode_location),
@@ -268,7 +284,46 @@ pub(crate) fn execute_program_with_brillig_fuzzing<
         Err(err) => Err((err, executor.last_fuzzing_trace)),
     }
 }
+
+/// Execute the program with ACIR fuzzing enabled (returns the witness on failure, so that the fuzzer can detect boolean states)
+pub(crate) fn execute_program_with_acir_fuzzing<
+    F: AcirField,
+    B: BlackBoxFunctionSolver<F>,
+    E: ForeignCallExecutor<F>,
+>(
+    program: &Program<F>,
+    initial_witness: WitnessMap<F>,
+    blackbox_solver: &B,
+    foreign_call_executor: &mut E,
+) -> Result<WitnessStack<F>, NargoErrorAndWitnessStack<F>> {
+    let mut executor = ProgramExecutor::new(
+        &program.functions,
+        &program.unconstrained_functions,
+        blackbox_solver,
+        foreign_call_executor,
+        false,
+    );
+    executor.with_partial_witness_on_failure(true);
+    match executor.execute_circuit(initial_witness) {
+        Ok((main_witness, _)) => {
+            executor.witness_stack.push(0, main_witness);
+            Ok(executor.finalize())
+        }
+        Err(err) => {
+            executor.witness_stack.push(
+                0,
+                executor
+                    .failing_partial_witness
+                    .as_ref()
+                    .expect("No partial witness on failure")
+                    .clone(),
+            );
+            Err((err, executor.finalize()))
+        }
+    }
+}
 #[tracing::instrument(level = "trace", skip_all)]
+
 fn execute_program_inner<F: AcirField, B: BlackBoxFunctionSolver<F>, E: ForeignCallExecutor<F>>(
     program: &Program<F>,
     initial_witness: WitnessMap<F>,
diff --git a/tooling/nargo/src/ops/fuzz.rs b/tooling/nargo/src/ops/fuzz.rs
index 09cd71a42e3..59f9e4d7998 100644
--- a/tooling/nargo/src/ops/fuzz.rs
+++ b/tooling/nargo/src/ops/fuzz.rs
@@ -6,8 +6,9 @@ use acvm::{
     brillig_vm::BranchToFeatureMap,
 };
 use noir_greybox_fuzzer::{
-    AcirAndBrilligPrograms, ErrorAndCoverage, FuzzTestResult, FuzzedExecutorExecutionConfiguration,
-    FuzzedExecutorFailureConfiguration, FuzzedExecutorFolderConfiguration, WitnessAndCoverage,
+    AcirAndBrilligPrograms, ErrorAndCoverage, ErrorAndWitness, FuzzTestResult,
+    FuzzedExecutorExecutionConfiguration, FuzzedExecutorFailureConfiguration,
+    FuzzedExecutorFolderConfiguration, WitnessAndCoverage,
 };
 use noirc_abi::{Abi, InputMap};
 use noirc_driver::{CompileOptions, compile_no_check};
@@ -19,7 +20,10 @@ use crate::foreign_calls::ForeignCallExecutor;
 use crate::{
     errors::try_to_diagnose_runtime_error,
     foreign_calls::{DefaultForeignCallBuilder, layers},
-    ops::{execute::execute_program_with_brillig_fuzzing, test::TestForeignCallExecutor},
+    ops::{
+        execute::execute_program_with_acir_fuzzing, execute::execute_program_with_brillig_fuzzing,
+        test::TestForeignCallExecutor,
+    },
 };
 
 use super::execute_program;
@@ -139,6 +143,9 @@ where
 
     let acir_program =
         compile_no_check(context, &acir_config, fuzzing_harness.get_id(), None, false);
+
+    // We need to clone the acir program because it will be moved into the fuzzer
+    // and we need to keep the original program for the error message and callstack
     let acir_program_copy = if let Ok(acir_program_internal) = &acir_program {
         Some(acir_program_internal.clone())
     } else {
@@ -158,24 +165,33 @@ where
             use noir_greybox_fuzzer::FuzzedExecutor;
 
             let acir_error_types = acir_program.abi.error_types.clone();
-            let acir_executor = |program: &Program<FieldElement>,
-                                 initial_witness: WitnessMap<FieldElement>|
-             -> Result<WitnessStack<FieldElement>, String> {
-                let mut foreign_call_executor = build_foreign_call_executor(
-                    show_output,
-                    foreign_call_resolver_url,
-                    root_path.clone(),
-                    package_name.clone(),
-                );
-                execute_program(program, initial_witness, &B::default(), &mut foreign_call_executor)
-                    .map_err(|err| {
-                        err.to_string()
-                            + ": "
-                            + &err
-                                .user_defined_failure_message(&acir_error_types)
-                                .unwrap_or("<no message>".to_owned())
+            let acir_executor =
+                |program: &Program<FieldElement>,
+                 initial_witness: WitnessMap<FieldElement>|
+                 -> Result<WitnessStack<FieldElement>, ErrorAndWitness> {
+                    let mut foreign_call_executor = build_foreign_call_executor(
+                        show_output,
+                        foreign_call_resolver_url,
+                        root_path.clone(),
+                        package_name.clone(),
+                    );
+                    execute_program_with_acir_fuzzing(
+                        program,
+                        initial_witness,
+                        &B::default(),
+                        &mut foreign_call_executor,
+                    )
+                    .map_err(|(nargo_err, witness)| {
+                        (
+                            nargo_err.to_string()
+                                + ": "
+                                + &nargo_err
+                                    .user_defined_failure_message(&acir_error_types)
+                                    .unwrap_or("<no message>".to_owned()),
+                            witness,
+                        )
                     })
-            };
+                };
 
             let brillig_error_types = brillig_program.abi.error_types.clone();
             let brillig_executor = |program: &Program<FieldElement>,
@@ -212,6 +228,24 @@ where
                 acir_program: acir_program.into(),
                 brillig_program: brillig_program.into(),
             };
+            let failure_configuration = match fuzzing_harness.failure_reason() {
+                Some(failure_reason) => {
+                    if fuzzing_harness.should_fail_enabled() {
+                        FuzzedExecutorFailureConfiguration::ShouldFailWith(failure_reason)
+                    } else {
+                        assert!(fuzzing_harness.only_fail_enabled());
+                        FuzzedExecutorFailureConfiguration::OnlyFailWith(failure_reason)
+                    }
+                }
+
+                None => {
+                    if fuzzing_harness.should_fail_enabled() {
+                        FuzzedExecutorFailureConfiguration::ShouldFail
+                    } else {
+                        FuzzedExecutorFailureConfiguration::None
+                    }
+                }
+            };
             let mut fuzzer = FuzzedExecutor::new(
                 acir_and_brillig_programs,
                 acir_executor,
@@ -222,10 +256,7 @@ where
                     num_threads: fuzz_execution_config.num_threads,
                     timeout: fuzz_execution_config.timeout,
                 },
-                FuzzedExecutorFailureConfiguration {
-                    fail_on_specific_asserts: fuzzing_harness.only_fail_enabled(),
-                    failure_reason: fuzzing_harness.failure_reason(),
-                },
+                failure_configuration,
                 FuzzedExecutorFolderConfiguration {
                     corpus_dir: fuzz_folder_config.corpus_dir.clone(),
                     minimized_corpus_dir: fuzz_folder_config.minimized_corpus_dir.clone(),
@@ -256,12 +287,23 @@ where
                         &B::default(),
                         &mut foreign_call_executor,
                     );
-                    let error_diagnostic = match execution_failure {
-                        Err(err) => try_to_diagnose_runtime_error(
-                            &err,
-                            &unwrapped_acir_program.abi,
-                            &unwrapped_acir_program.debug,
-                        ),
+                    match execution_failure {
+                        Err(err) => FuzzingRunStatus::ExecutionFailure {
+                            message: if fuzzing_harness.should_fail_enabled() {
+                                format!(
+                                                "Expected failure message \"{}\", but got a different failing assertion",
+                                                fuzzing_harness.failure_reason().expect("There should be a failure reason if we detected a different failure reason during fuzzing")
+                                            )
+                            } else {
+                                program_failure_result.failure_reason
+                            },
+                            counterexample: Some((program_failure_result.counterexample, abi)),
+                            error_diagnostic: try_to_diagnose_runtime_error(
+                                &err,
+                                &unwrapped_acir_program.abi,
+                                &unwrapped_acir_program.debug,
+                            ),
+                        },
                         // Maybe it was the brillig version that failed and we hade a discrepancy?
                         Ok(..) => {
                             // Collect failing callstack from brillig
@@ -279,21 +321,44 @@ where
                                 &mut foreign_call_executor,
                             );
                             match execution_failure {
-                                Err(err) => try_to_diagnose_runtime_error(
-                                    &err,
-                                    &unwrapped_brillig_program.abi,
-                                    &unwrapped_brillig_program.debug,
-                                ),
-                                Ok(..) => panic!(
-                                    "The program being executed or the system is flakey. Found a failing testcase that didn't fail on reexecution"
-                                ),
+                                Err(err) => FuzzingRunStatus::ExecutionFailure {
+                                    message: if fuzzing_harness.should_fail_enabled() {
+                                        format!(
+                                                "Expected failure message \"{}\", but got a different failing assertion",
+                                                fuzzing_harness.failure_reason().expect("There should be a failure reason if we detected a different failure reason during fuzzing")
+                                            )
+                                    } else {
+                                        program_failure_result.failure_reason
+                                    },
+                                    counterexample: Some((
+                                        program_failure_result.counterexample,
+                                        abi,
+                                    )),
+                                    error_diagnostic: try_to_diagnose_runtime_error(
+                                        &err,
+                                        &unwrapped_brillig_program.abi,
+                                        &unwrapped_brillig_program.debug,
+                                    ),
+                                },
+                                Ok(..) => {
+                                    if fuzzing_harness.should_fail_enabled() {
+                                        return FuzzingRunStatus::ExecutionFailure {
+                                            message:
+                                                "Discovered a testcase that should fail but didn't"
+                                                    .to_owned(),
+                                            counterexample: Some((
+                                                program_failure_result.counterexample,
+                                                abi,
+                                            )),
+                                            error_diagnostic: None,
+                                        };
+                                    }
+                                    panic!(
+                                        "The program being executed or the system is flakey. Found a failing testcase that didn't fail on reexecution"
+                                    )
+                                }
                             }
                         }
-                    };
-                    FuzzingRunStatus::ExecutionFailure {
-                        message: program_failure_result.failure_reason,
-                        counterexample: Some((program_failure_result.counterexample, abi)),
-                        error_diagnostic,
                     }
                 }
                 FuzzTestResult::CorpusFailure(error) => {
diff --git a/tooling/nargo_fmt/src/formatter/attribute.rs b/tooling/nargo_fmt/src/formatter/attribute.rs
index 3a8c0d616c4..02c29211de9 100644
--- a/tooling/nargo_fmt/src/formatter/attribute.rs
+++ b/tooling/nargo_fmt/src/formatter/attribute.rs
@@ -165,6 +165,23 @@ impl Formatter<'_> {
                 self.write_current_token_and_bump(); // "reason"
                 self.write_right_paren(); // )
             }
+            FuzzingScope::ShouldFailWith { reason: None } => {
+                self.write_left_paren(); // (
+                self.skip_comments_and_whitespace();
+                self.write_current_token_and_bump(); // should_fail
+                self.write_right_paren(); // )
+            }
+            FuzzingScope::ShouldFailWith { reason: Some(..) } => {
+                self.write_left_paren(); // (
+                self.skip_comments_and_whitespace();
+                self.write_current_token_and_bump(); // should_fail_with
+                self.write_space();
+                self.write_token(Token::Assign);
+                self.write_space();
+                self.skip_comments_and_whitespace();
+                self.write_current_token_and_bump(); // "reason"
+                self.write_right_paren(); // )
+            }
         }
 
         self.write_right_bracket(); // ]
@@ -386,6 +403,20 @@ mod tests {
         assert_format_attribute(src, expected);
     }
 
+    #[test]
+    fn format_fuzz_should_fail_attribute() {
+        let src = "  #[ fuzz ( should_fail )] ";
+        let expected = "#[fuzz(should_fail)]";
+        assert_format_attribute(src, expected);
+    }
+
+    #[test]
+    fn format_fuzz_should_fail_with_reason_attribute() {
+        let src = "  #[ fuzz ( should_fail_with=\"reason\" )] ";
+        let expected = "#[fuzz(should_fail_with = \"reason\")]";
+        assert_format_attribute(src, expected);
+    }
+
     #[test]
     fn format_multiple_function_attributes() {
         let src = " #[foo] #[test] #[bar]  ";


Reviews:
user: login: asterite
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T17:06:35Z
user: login: asterite
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T17:10:00Z
user: login: asterite
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T17:12:17Z
user: login: Rumata888
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T14:47:24Z
user: login: Rumata888
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T14:47:34Z
user: login: Rumata888
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T14:48:28Z
user: login: asterite
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T14:56:33Z
user: login: asterite
body: Looks great! I left a final comment.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-23T14:58:39Z
user: login: Rumata888
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T15:17:42Z

Review comments:
diff_hunk: @@ -532,6 +516,14 @@ impl AccumulatedFuzzerCoverage {
     ) {
         // Insert all ACIR states and replace testcase association
         for acir_bool_state in new_coverage.acir_bool_coverage.iter() {
+            // If the witness is non-boolean according the merged non-bool witness list, skip it
+            if self
+                .non_bool_witness_list
+                .witness
+                .contains(&Witness::new(acir_bool_state.witness_id))
path: tooling/greybox_fuzzer/src/coverage.rs
user: login: asterite
body: Extracting this argument to a local variable reduces the number of lines a bit.
created_at: 2025-04-22T17:06:35Z
author_association: COLLABORATOR
diff_hunk: @@ -271,10 +274,13 @@ pub struct FuzzedExecutorExecutionConfiguration {
 }
 
 pub struct FuzzedExecutorFailureConfiguration {
-    /// Fail on specific asserts
+    /// Fail on specific asserts (when you use `fuzz(only_fail_with = "...")`)
     pub fail_on_specific_asserts: bool,
 
-    /// Failure reason
+    /// We expect the fuzzed program to always fail (when you use `fuzz(should_fail)` or `fuzz(should_fail_with = "...")` )
+    pub should_always_fail: bool,
+
+    /// Failure reason (when you use `fuzz(should_fail_with = "...")` or `fuzz(only_fail_with = "...")`)
     pub failure_reason: Option<String>,
 }
path: tooling/greybox_fuzzer/src/lib.rs
user: login: asterite
body: I wonder if it wouldn't be better to have this configuration be an enum, similar to `FuzzingScope`, but maybe with four variants: `None`, `OnlyFailWith`, `ShouldFail` and `ShouldFailWith`. Then in `single_fuzz_...` we could pattern match each of these and handle them separately. Mainly because right now we get the failure reason and then assume one of the other booleans is true, adding `unreachable!` otherwise. I think with pattern-matching we wouldn't have an unreachable state and it might also lead to clearer code (right now we are checking these booleans many times).
created_at: 2025-04-22T17:10:00Z
author_association: COLLABORATOR
diff_hunk: @@ -1023,6 +995,14 @@ impl<
         // Parse results
         match (result_acir, result_brillig) {
             (Ok(witnesses), Ok((_map, brillig_coverage))) => {
+                // If we expect the program to always fail, we need to return a counter example
path: tooling/greybox_fuzzer/src/lib.rs
user: login: asterite
body: I just noticed that this block is duplicated in two more places. Well, not exactly duplicated but it's almost exactly like the other two cases. I wonder if a function couldn't be extracted where `coverage` can be passed as an `Option`, together with the durations (passing 0 when we don't have one, like you are doing now).
created_at: 2025-04-22T17:12:17Z
author_association: COLLABORATOR
diff_hunk: @@ -532,6 +516,14 @@ impl AccumulatedFuzzerCoverage {
     ) {
         // Insert all ACIR states and replace testcase association
         for acir_bool_state in new_coverage.acir_bool_coverage.iter() {
+            // If the witness is non-boolean according the merged non-bool witness list, skip it
+            if self
+                .non_bool_witness_list
+                .witness
+                .contains(&Witness::new(acir_bool_state.witness_id))
path: tooling/greybox_fuzzer/src/coverage.rs
user: login: Rumata888
body: Done
created_at: 2025-04-23T14:47:24Z
author_association: COLLABORATOR
diff_hunk: @@ -271,10 +274,13 @@ pub struct FuzzedExecutorExecutionConfiguration {
 }
 
 pub struct FuzzedExecutorFailureConfiguration {
-    /// Fail on specific asserts
+    /// Fail on specific asserts (when you use `fuzz(only_fail_with = "...")`)
     pub fail_on_specific_asserts: bool,
 
-    /// Failure reason
+    /// We expect the fuzzed program to always fail (when you use `fuzz(should_fail)` or `fuzz(should_fail_with = "...")` )
+    pub should_always_fail: bool,
+
+    /// Failure reason (when you use `fuzz(should_fail_with = "...")` or `fuzz(only_fail_with = "...")`)
     pub failure_reason: Option<String>,
 }
path: tooling/greybox_fuzzer/src/lib.rs
user: login: Rumata888
body: Changed
created_at: 2025-04-23T14:47:34Z
author_association: COLLABORATOR
diff_hunk: @@ -1023,6 +995,14 @@ impl<
         // Parse results
         match (result_acir, result_brillig) {
             (Ok(witnesses), Ok((_map, brillig_coverage))) => {
+                // If we expect the program to always fail, we need to return a counter example
path: tooling/greybox_fuzzer/src/lib.rs
user: login: Rumata888
body: Moved it out
created_at: 2025-04-23T14:48:28Z
author_association: COLLABORATOR
diff_hunk: @@ -996,22 +992,28 @@ impl<
         match (result_acir, result_brillig) {
             (Ok(witnesses), Ok((_map, brillig_coverage))) => {
                 // If we expect the program to always fail, we need to return a counter example
-                if self.failure_configuration.should_always_fail {
-                    return HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
-                        case_id: testcase.id(),
-                        exit_reason: "".to_string(),
-                        counterexample: testcase.value().clone(),
-                    });
+                match self.failure_configuration {
+                    // If we expect the program to always fail, we need to return a counter example, if it actually passes
+                    FuzzedExecutorFailureConfiguration::ShouldFail
+                    | FuzzedExecutorFailureConfiguration::ShouldFailWith(_) => {
+                        HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
+                            case_id: testcase.id(),
+                            exit_reason: "".to_string(),
+                            counterexample: testcase.value().clone(),
+                        })
+                    }
+                    _ => {
path: tooling/greybox_fuzzer/src/lib.rs
user: login: asterite
body: Just in case, I would explicitly list the other two cases here in case we add more in the future.
created_at: 2025-04-23T14:56:33Z
author_association: COLLABORATOR
diff_hunk: @@ -996,22 +992,28 @@ impl<
         match (result_acir, result_brillig) {
             (Ok(witnesses), Ok((_map, brillig_coverage))) => {
                 // If we expect the program to always fail, we need to return a counter example
-                if self.failure_configuration.should_always_fail {
-                    return HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
-                        case_id: testcase.id(),
-                        exit_reason: "".to_string(),
-                        counterexample: testcase.value().clone(),
-                    });
+                match self.failure_configuration {
+                    // If we expect the program to always fail, we need to return a counter example, if it actually passes
+                    FuzzedExecutorFailureConfiguration::ShouldFail
+                    | FuzzedExecutorFailureConfiguration::ShouldFailWith(_) => {
+                        HarnessExecutionOutcome::CounterExample(CounterExampleOutcome {
+                            case_id: testcase.id(),
+                            exit_reason: "".to_string(),
+                            counterexample: testcase.value().clone(),
+                        })
+                    }
+                    _ => {
path: tooling/greybox_fuzzer/src/lib.rs
user: login: Rumata888
body: Yes, thanks for noticing. Changed everywhere
created_at: 2025-04-23T15:17:40Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-23T11:33:47Z
body: <!-- NETLIFY DEPLOY COMMENT GENERATED BY ACTIONS_NETLIFY - APP ID SHA256: 7b20257f23e04a75debb931fa289c68c4e3d27ef7727029e6bf5dfb1cf190cf4 -->
🚀 Deployed on https://6809058038097c185ebe0272--noir-docs.netlify.app
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-23T15:21:52Z
body: FYI @noir-lang/developerrelations on Noir doc changes.
<!-- Sticky Pull Request Comment -->
author_association: CONTRIBUTOR