acvm/compiler/optimizers/common_subexpression/
csat.rs

1use std::{
2    cmp::Ordering,
3    collections::{HashMap, HashSet},
4};
5
6use acir::{
7    AcirField,
8    native_types::{Expression, Witness},
9};
10use indexmap::IndexMap;
11
12use crate::compiler::simulator::unresolved_witnesses;
13
14/// Minimum width accepted by the `CSatTransformer`.
15pub(crate) const MIN_EXPRESSION_WIDTH: usize = 3;
16
17/// A transformer which slices [`Expression`]s towards the backend's preferred `width`, creating
18/// intermediate variables to hold partial calculations and then combining them to recover the
19/// original expression.
20///
21/// This is a sub-pass of the [`CommonSubexpressionOptimizer`](super); see the module documentation
22/// for how the slices it produces become common-subexpression candidates.
23///
24/// `width` is a best-effort target, not a guarantee. ACIR places no upper bound on the width of an
25/// opcode, so the transformer never splits an expression in a way that would harm solvability. In
26/// particular, an emitted opcode may exceed `width` when it cannot be sliced further without becoming
27/// unsolvable. The two cases are:
28/// - The expression has more than one multiplication term whose operands are not all solvable: each
29///   such term must stay in the opcode (an intermediate bound to it would be unsolvable), so several
30///   may remain.
31/// - The sole unknown of the expression sits inside a multiplication term (e.g. `w_known * y = a + b + c + d`,
32///   solving for `y`). The term cannot be hoisted into an intermediate without leaving the opcode with two
33///   unknowns, so the whole expression — multiplication term and all linear terms — is emitted as one opcode.
34///
35/// The property the transformer preserves is solvability (see [`CircuitSimulator`](crate::compiler::CircuitSimulator)),
36/// not width.
37///
38/// Pre-Condition:
39/// - General Optimizer must run before this pass
40pub(crate) struct CSatTransformer<F: AcirField> {
41    width: usize,
42    /// Track the witness that can be solved
43    solvable_witness: HashSet<Witness>,
44    /// Cache of field-element inverses, used to normalize intermediate expressions.
45    /// Coefficients repeat heavily across a circuit (e.g. constant matrices, powers of two),
46    /// so memoizing avoids recomputing the same expensive modular inversion many times.
47    inverse_cache: HashMap<F, F>,
48}
49
50impl<F: AcirField> CSatTransformer<F> {
51    /// Create an optimizer with a given width.
52    ///
53    /// Panics if `width` is less than `MIN_EXPRESSION_WIDTH`.
54    pub(crate) fn new(width: usize) -> CSatTransformer<F> {
55        assert!(width >= MIN_EXPRESSION_WIDTH, "width has to be at least {MIN_EXPRESSION_WIDTH}");
56
57        CSatTransformer { width, solvable_witness: HashSet::new(), inverse_cache: HashMap::new() }
58    }
59
60    /// Check if the equation 'expression=0' can be solved, and if yes, add the solved witness to set of solvable witness
61    fn try_solve(&mut self, opcode: &Expression<F>) {
62        if let Some(unresolved) = unresolved_witnesses(opcode, &self.solvable_witness)
63            && unresolved.len() == 1
64        {
65            self.mark_solvable(*unresolved.iter().next().expect("len == 1"));
66        }
67    }
68
69    /// Adds the witness to set of solvable witness
70    pub(crate) fn mark_solvable(&mut self, witness: Witness) {
71        self.solvable_witness.insert(witness);
72    }
73
74    /// Transform the input arithmetic expression by slicing it towards `self.width` via intermediate
75    /// variables, marking the witness it solves for so later opcodes can reuse it.
76    ///
77    /// The intended shape of the returned opcode is:
78    /// - at most one multiplication term, and
79    /// - a fan-in whose [`Expression::width`] is at most `self.width`.
80    ///
81    /// This is best-effort, not guaranteed: when an unsolvable multiplication term cannot be hoisted
82    /// into an intermediate variable, it (and the linear terms it sits alongside) remain in the opcode,
83    /// which may then have more than one multiplication term and/or a width exceeding `self.width`. This
84    /// is intentional — slicing such a term out would make the circuit unsolvable, and ACIR imposes no
85    /// hard width limit. See the [`CSatTransformer`] documentation for details.
86    ///
87    /// The `width - 1` budget mentioned in the helper functions refers to each *intermediate* opcode:
88    /// an intermediate reserves one wire for the variable it defines, so it can absorb at most `width - 1`
89    /// source terms. The final opcode itself targets the full `self.width`.
90    pub(crate) fn transform(
91        &mut self,
92        opcode: Expression<F>,
93        intermediate_variables: &mut IndexMap<Expression<F>, (F, Witness)>,
94        num_witness: &mut u32,
95    ) -> Expression<F> {
96        // Here we create intermediate variables and constrain them to be equal to any subset of the polynomial that can be represented as a full opcode
97        let opcode =
98            self.full_opcode_scan_optimization(opcode, intermediate_variables, num_witness);
99        // The last optimization to do is to create intermediate variables in order to flatten the fan-in and the amount of mul terms
100        // If a opcode has more than one mul term. We may need an intermediate variable for each one. Since not every variable will need to link to
101        // the mul term, we could possibly do it that way.
102        // We wil call this a partial opcode scan optimization which will result in the opcodes being able to fit into the correct width
103        let mut opcode =
104            self.partial_opcode_scan_optimization(opcode, intermediate_variables, num_witness);
105        opcode.sort();
106        self.try_solve(&opcode);
107        opcode
108    }
109
110    // This optimization will search for combinations of terms which can be represented in a single assert-zero opcode
111    // Case 1 : qM * wL * wR + qL * wL + qR * wR + qO * wO + qC
112    // This polynomial does not require any further optimizations, it can be safely represented in one opcode
113    // ie a polynomial with 1 mul(bi-variate) term and 3 (univariate) terms where 2 of those terms match the bivariate term
114    // wL and wR, we can represent it in one opcode
115    // GENERALIZED for WIDTH: instead of the number 3, we use `WIDTH`
116    //
117    //
118    // Case 2: qM * wL * wR + qL * wL + qR * wR + qO * wO + qC + qM2 * wL2 * wR2 + qL * wL2 + qR * wR2 + qO * wO2 + qC2
119    // This polynomial cannot be represented using one assert-zero opcode.
120    //
121    // This algorithm will first extract the first full opcode(if possible):
122    // t = qM * wL * wR + qL * wL + qR * wR + qO * wO + qC
123    //
124    // The polynomial now looks like so t + qM2 * wL2 * wR2 + qL * wL2 + qR * wR2 + qO * wO2 + qC2
125    // This polynomial cannot be represented using one assert-zero opcode.
126    //
127    // This algorithm will then extract the second full opcode(if possible):
128    // t2 = qM2 * wL2 * wR2 + qL * wL2 + qR * wR2 + qO * wO2 + qC2
129    //
130    // The polynomial now looks like so t + t2
131    // We can no longer extract another full opcode, hence the algorithm terminates. Creating two intermediate variables t and t2.
132    // This stage of preprocessing does not guarantee that all polynomials can fit into a opcode. It only guarantees that all full opcodes have been extracted from each polynomial
133    fn full_opcode_scan_optimization(
134        &mut self,
135        mut opcode: Expression<F>,
136        intermediate_variables: &mut IndexMap<Expression<F>, (F, Witness)>,
137        num_witness: &mut u32,
138    ) -> Expression<F> {
139        // We pass around this intermediate variable IndexMap, so that we do not create intermediate variables that we have created before
140        // One instance where this might happen is t1 = wL * wR and t2 = wR * wL
141
142        // First check that this is not a simple opcode which does not need optimization
143        //
144        // If the opcode only has one mul term, then this algorithm cannot optimize it any further
145        // Either it can be represented in a single arithmetic equation or its fan-in is too large and we need intermediate variables for those
146        // Large-fan-in optimization is not this algorithm's purpose.
147        // If the opcode has 0 mul terms, then it is an add opcode and similarly it can either fit into a single assert-zero opcode or it has a large fan-in
148        if opcode.mul_terms.len() <= 1 {
149            return opcode;
150        }
151
152        // We now know that this opcode has multiple mul terms and can possibly be simplified into multiple full opcodes
153        // We need to create a (w_l, w_r) IndexMap and then check the simplified fan-in to verify if we have terms both with w_l and w_r
154        // In general, we can then push more terms into the opcode until we are at width-1 then the last variable will be the intermediate variable
155        //
156
157        // This will be our new opcode which will be equal to `self` except we will have intermediate variables that will be constrained to any
158        // subset of the terms that can be represented as full opcodes
159        let mut new_opcode = Expression::default();
160        let mut remaining_mul_terms = Vec::with_capacity(opcode.mul_terms.len());
161        for (scale, w_l, w_r) in opcode.mul_terms {
162            // We want to layout solvable intermediate variables, if we cannot solve one of the witnesses
163            // that means the intermediate opcode will not be immediately solvable
164            if !self.solvable_witness.contains(&w_l) || !self.solvable_witness.contains(&w_r) {
165                remaining_mul_terms.push((scale, w_l, w_r));
166                continue;
167            }
168
169            // Check if this (scale, w_l, w_r) triple is present in the simplified fan-in
170            // We are assuming that the fan-in/fan-out has been simplified.
171            // Note this function is not public, and can only be called within the optimize method, so this guarantee will always hold
172            let index_wl =
173                opcode.linear_combinations.iter().position(|(_scale, witness)| *witness == w_l);
174            let index_wr =
175                opcode.linear_combinations.iter().position(|(_scale, witness)| *witness == w_r);
176
177            match (index_wl, index_wr) {
178                (None, _) | (_, None) => {
179                    // This means that the polynomial does not contain both terms
180                    // Just push the Qm term as it cannot form a full opcode
181                    new_opcode.mul_terms.push((scale, w_l, w_r));
182                }
183                (Some(x), Some(y)) => {
184                    // This means that we can form a full opcode with this Qm term
185
186                    // First fetch the left and right wires which match the mul term
187                    let left_wire_term = opcode.linear_combinations[x];
188                    let right_wire_term = opcode.linear_combinations[y];
189
190                    // Lets create an intermediate opcode to store this full opcode
191                    //
192                    let mut intermediate_opcode = Expression::default();
193                    intermediate_opcode.mul_terms.push((scale, w_l, w_r));
194
195                    // Add the left and right wires
196                    intermediate_opcode.linear_combinations.push(left_wire_term);
197                    intermediate_opcode.linear_combinations.push(right_wire_term);
198                    // Remove the left and right wires so we do not re-add them
199                    match x.cmp(&y) {
200                        Ordering::Greater => {
201                            opcode.linear_combinations.remove(x);
202                            opcode.linear_combinations.remove(y);
203                        }
204                        Ordering::Less => {
205                            opcode.linear_combinations.remove(y);
206                            opcode.linear_combinations.remove(x);
207                        }
208                        Ordering::Equal => {
209                            opcode.linear_combinations.remove(x);
210                            intermediate_opcode.linear_combinations.pop();
211                        }
212                    }
213
214                    let used_space = intermediate_opcode.linear_combinations.len();
215                    assert!(used_space < self.width);
216
217                    // Now we have used up "used_space" spaces in our assert-zero opcode. The width now dictates how many more we can add
218                    let mut remaining_space = self.width - used_space - 1; // We minus 1 because we need an extra space to contain the intermediate variable
219                    // Keep adding terms until we have no more left, or we reach the width
220                    let mut remaining_linear_terms =
221                        Vec::with_capacity(opcode.linear_combinations.len());
222                    while remaining_space > 0 {
223                        if let Some(wire_term) = opcode.linear_combinations.pop() {
224                            // Add this element into the new opcode
225                            if self.solvable_witness.contains(&wire_term.1) {
226                                intermediate_opcode.linear_combinations.push(wire_term);
227                                remaining_space -= 1;
228                            } else {
229                                remaining_linear_terms.push(wire_term);
230                            }
231                        } else {
232                            // No more usable elements left in the old opcode
233                            break;
234                        }
235                    }
236                    opcode.linear_combinations.extend(remaining_linear_terms);
237
238                    // Constrain this intermediate_opcode to be equal to the temp variable by adding it into the IndexMap
239                    // We need a unique name for our intermediate variable
240                    // TODO(https://github.com/noir-lang/noir/issues/10192): Another optimization, which could be applied in another algorithm
241                    // If two opcodes have a large fan-in/out and they share a few common terms, then we should create intermediate variables for them
242                    // Do some sort of subset matching algorithm for this on the terms of the polynomial
243                    let intermediate_var = self.get_or_create_intermediate_var(
244                        intermediate_variables,
245                        intermediate_opcode,
246                        num_witness,
247                    );
248
249                    // Add intermediate variable to the new opcode instead of the full opcode
250                    self.mark_solvable(intermediate_var.1);
251                    new_opcode.linear_combinations.push(intermediate_var);
252                }
253            }
254        }
255
256        // Add the rest of the elements back into the new_opcode
257        new_opcode.mul_terms.extend(remaining_mul_terms);
258        new_opcode.linear_combinations.extend(opcode.linear_combinations);
259        new_opcode.q_c = opcode.q_c;
260        new_opcode.sort();
261        new_opcode
262    }
263
264    /// Normalize an expression by dividing it by its first coefficient
265    /// The first coefficient here means coefficient of the first linear term, or of the first quadratic term if no linear terms exist.
266    /// This function panics if the input expression is constant or if the first coefficient's inverse is `F::zero()`
267    fn normalize(&mut self, mut expr: Expression<F>) -> (F, Expression<F>) {
268        expr.sort();
269        let a = if !expr.linear_combinations.is_empty() {
270            expr.linear_combinations[0].0
271        } else {
272            expr.mul_terms[0].0
273        };
274        // The expression is already normalized when its leading coefficient is 1,
275        // so we can skip the field inversion and the scaled copy entirely.
276        if a == F::one() {
277            return (a, expr);
278        }
279        // A leading coefficient of -1 normalizes to a plain negation.
280        if a == -F::one() {
281            return (a, -expr);
282        }
283        // Coefficients repeat heavily, so memoize the inverse to avoid recomputing it.
284        let a_inverse = *self.inverse_cache.entry(a).or_insert_with(|| a.inverse());
285        assert!(a_inverse != F::zero(), "normalize: the first coefficient is non-invertible");
286        (a, expr * a_inverse)
287    }
288
289    /// Get or generate a scaled intermediate witness which is equal to the provided expression
290    /// The sets of previously generated witness and their (normalized) expression is cached in the `intermediate_variables` map
291    /// If there is no cache hit, we generate a new witness (and add the expression to the cache)
292    /// else, we return the cached witness along with the scaling factor so it is equal to the provided expression
293    fn get_or_create_intermediate_var(
294        &mut self,
295        intermediate_variables: &mut IndexMap<Expression<F>, (F, Witness)>,
296        expr: Expression<F>,
297        num_witness: &mut u32,
298    ) -> (F, Witness) {
299        let (k, normalized_expr) = self.normalize(expr);
300
301        if intermediate_variables.contains_key(&normalized_expr) {
302            let (l, iv) = intermediate_variables[&normalized_expr];
303            assert!(
304                l != F::zero(),
305                "get_or_create_intermediate_var: attempting to divide l by F::zero()"
306            );
307            (k / l, iv)
308        } else {
309            let inter_var = Witness::new(*num_witness);
310            *num_witness += 1;
311            // Add intermediate opcode and variable to map
312            intermediate_variables.insert(normalized_expr, (k, inter_var));
313            (F::one(), inter_var)
314        }
315    }
316
317    // A partial opcode scan optimization aim to create intermediate variables in order to compress the polynomial
318    // So that it fits within the given width
319    // Note that this opcode follows the full opcode scan optimization.
320    // We define the partial width as equal to the full width - 2.
321    // This is because two of our variables cannot be used as they are linked to the multiplication terms
322    // Example: qM1 * wL1 * wR2 + qL1 * wL3 + qR1 * wR4+ qR2 * wR5 + qO1 * wO5 + qC
323    // One thing to note is that the multiplication wires do not match any of the fan-in/out wires. This is guaranteed as we have
324    // just completed the full opcode optimization algorithm.
325    //
326    // Actually we can optimize in two ways here: We can create an intermediate variable which is equal to the fan-in terms
327    // t = qL1 * wL3 + qR1 * wR4 -> width = 3
328    // This `t` value can only use width - 1 terms
329    // The opcode now looks like: qM1 * wL1 * wR2 + t + qR2 * wR5+ qO1 * wO5 + qC
330    // But this is still not acceptable since wR5 is not wR2, so we need another intermediate variable
331    // t2 = t + qR2 * wR5
332    //
333    // The opcode now looks like: qM1 * wL1 * wR2 + t2 + qO1 * wO5 + qC
334    // This is still not good, so we do it one more time:
335    // t3 = t2 + qO1 * wO5
336    // The opcode now looks like: qM1 * wL1 * wR2 + t3 + qC
337    //
338    // Another strategy is to create a temporary variable for the multiplier term and then we can see it as a term in the fan-in
339    //
340    // Same Example: qM1 * wL1 * wR2 + qL1 * wL3 + qR1 * wR4+ qR2 * wR5 + qO1 * wO5 + qC
341    // t = qM1 * wL1 * wR2
342    // The opcode now looks like: t + qL1 * wL3 + qR1 * wR4+ qR2 * wR5 + qO1 * wO5 + qC
343    // Still assuming width-3, we still need to use width-1 terms for the intermediate variables, however we can stop at an earlier stage because
344    // the opcode does not need the multiplier term to match with any of the fan-in terms
345    // t2 = t + qL1 * wL3
346    // The opcode now looks like: t2 + qR1 * wR4+ qR2 * wR5 + qO1 * wO5 + qC
347    // t3 = t2 + qR1 * wR4
348    // The opcode now looks like: t3 + qR2 * wR5 + qO1 * wO5 + qC
349    // This took the same amount of opcodes, but which one is better when the width increases? Compute this and maybe do both optimizations
350    // naming : partial_opcode_mul_first_opt and partial_opcode_fan_first_opt
351    // Also remember that since we did full opcode scan, there is no way we can have a non-zero mul term along with the wL and wR terms being non-zero
352    //
353    // Cases, a lot of mul terms, a lot of fan-in terms, 50/50
354    fn partial_opcode_scan_optimization(
355        &mut self,
356        mut opcode: Expression<F>,
357        intermediate_variables: &mut IndexMap<Expression<F>, (F, Witness)>,
358        num_witness: &mut u32,
359    ) -> Expression<F> {
360        // We will go for the easiest route, which is to convert all multiplications into additions using intermediate variables
361        // Then use intermediate variables again to squash the fan-in, so that it can fit into the appropriate width
362
363        // First check if this polynomial actually needs a partial opcode optimization
364        // There is the chance that it fits perfectly within the assert-zero opcode
365        if fits_in_one_identity(&opcode, self.width) {
366            return opcode;
367        }
368
369        // Replace each multiplication term with an intermediate variable, so it becomes a single linear
370        // term in the fan-in. We can only do this for terms whose operands are both solvable: an intermediate
371        // bound to an unsolvable multiplication term would itself be unsolvable. Unsolvable terms are kept in
372        // the opcode unchanged.
373        let mut remaining_mul_terms = Vec::with_capacity(opcode.mul_terms.len());
374        for (scale, w_l, w_r) in opcode.mul_terms {
375            if self.solvable_witness.contains(&w_l) && self.solvable_witness.contains(&w_r) {
376                let mut intermediate_opcode = Expression::default();
377
378                // Push mul term into the opcode
379                intermediate_opcode.mul_terms.push((scale, w_l, w_r));
380                // Get an intermediate variable which squashes the multiplication term
381                let intermediate_var = self.get_or_create_intermediate_var(
382                    intermediate_variables,
383                    intermediate_opcode,
384                    num_witness,
385                );
386
387                // Add intermediate variable as a part of the fan-in for the original opcode
388                opcode.linear_combinations.push(intermediate_var);
389                self.mark_solvable(intermediate_var.1);
390            } else {
391                remaining_mul_terms.push((scale, w_l, w_r));
392            }
393        }
394
395        // Any multiplication terms left here are unsolvable and could not be hoisted into intermediates.
396        opcode.mul_terms = remaining_mul_terms;
397
398        // The remaining work is to squash the linear fan-in down to `self.width` terms. We can only stop once
399        // the linear fan-in fits; any residual unsolvable multiplication terms stay where they are, so the
400        // final opcode's `width()` may still exceed `self.width`. That is acceptable (ACIR has no width limit)
401        // and unavoidable — slicing those terms out would make the circuit unsolvable.
402        if opcode.linear_combinations.len() <= self.width {
403            return opcode;
404        }
405
406        // Stores the intermediate variables that are used to
407        // reduce the fan in.
408        let mut added_vars = Vec::new();
409
410        while opcode.linear_combinations.len() > self.width {
411            // Collect as many terms up to the given width-1 and constrain them to an intermediate variable
412            let mut intermediate_opcode = Expression::default();
413
414            let mut remaining_linear_terms = Vec::with_capacity(opcode.linear_combinations.len());
415
416            for term in opcode.linear_combinations {
417                if self.solvable_witness.contains(&term.1)
418                    && intermediate_opcode.linear_combinations.len() < self.width - 1
419                {
420                    intermediate_opcode.linear_combinations.push(term);
421                } else {
422                    remaining_linear_terms.push(term);
423                }
424            }
425            opcode.linear_combinations = remaining_linear_terms;
426            let not_full = intermediate_opcode.linear_combinations.len() < self.width - 1;
427            if intermediate_opcode.linear_combinations.len() > 1 {
428                let intermediate_var = self.get_or_create_intermediate_var(
429                    intermediate_variables,
430                    intermediate_opcode,
431                    num_witness,
432                );
433                self.mark_solvable(intermediate_var.1);
434                added_vars.push(intermediate_var);
435            } else {
436                // Put back the single term that couldn't form an intermediate variable
437                opcode.linear_combinations.extend(intermediate_opcode.linear_combinations);
438            }
439            // The intermediate opcode is not full, but the opcode still has too many terms
440            if not_full && opcode.linear_combinations.len() > self.width {
441                unreachable!("Could not reduce the expression");
442            }
443        }
444
445        // Add back the intermediate variables to
446        // keep consistency with the original equation.
447        opcode.linear_combinations.extend(added_vars);
448        self.partial_opcode_scan_optimization(opcode, intermediate_variables, num_witness)
449    }
450}
451
452/// Checks if this expression can fit into one arithmetic identity
453fn fits_in_one_identity<F: AcirField>(expr: &Expression<F>, width: usize) -> bool {
454    // A Polynomial with more than one mul term cannot fit into one opcode
455    if expr.mul_terms.len() > 1 {
456        return false;
457    }
458
459    expr.width() <= width
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use acir::FieldElement;
466
467    #[test]
468    fn simple_reduction_smoke_test() {
469        let a = Witness(0);
470        let b = Witness(1);
471        let c = Witness(2);
472        let d = Witness(3);
473
474        // a = b + c + d;
475        let opcode_a = Expression::from_str(&format!("{a} - {b} - {c} - {d}")).unwrap();
476
477        let mut intermediate_variables: IndexMap<
478            Expression<FieldElement>,
479            (FieldElement, Witness),
480        > = IndexMap::new();
481
482        let mut num_witness = 4;
483
484        let mut optimizer = CSatTransformer::new(MIN_EXPRESSION_WIDTH);
485        optimizer.mark_solvable(b);
486        optimizer.mark_solvable(c);
487        optimizer.mark_solvable(d);
488        let got_optimized_opcode_a =
489            optimizer.transform(opcode_a, &mut intermediate_variables, &mut num_witness);
490
491        // a = b + c + d => a - b - c - d = 0
492        // For width3, the result becomes:
493        // a - d + e = 0
494        // - c - b  - e = 0
495        //
496        // a - b + e = 0
497        let e = Witness(4);
498        let expected_optimized_opcode_a =
499            Expression::from_str(&format!("{a} - {d} + {e}")).unwrap();
500
501        assert_eq!(expected_optimized_opcode_a, got_optimized_opcode_a);
502
503        assert_eq!(intermediate_variables.len(), 1);
504
505        // e = - c - b
506        let expected_intermediate_opcode = Expression::from_str(&format!("-{c} - {b}")).unwrap();
507        let (_, normalized_opcode) =
508            CSatTransformer::new(MIN_EXPRESSION_WIDTH).normalize(expected_intermediate_opcode);
509        assert!(intermediate_variables.contains_key(&normalized_opcode));
510        assert_eq!(intermediate_variables[&normalized_opcode].1, e);
511    }
512
513    #[test]
514    fn stepwise_reduction_test() {
515        let a = Witness(0);
516        let b = Witness(1);
517        let c = Witness(2);
518        let d = Witness(3);
519        let e = Witness(4);
520
521        // a = b + c + d + e;
522        let opcode_a = Expression::from_str(&format!("-{a} + {b} + {c} + {d} + {e}")).unwrap();
523
524        let mut intermediate_variables: IndexMap<
525            Expression<FieldElement>,
526            (FieldElement, Witness),
527        > = IndexMap::new();
528
529        let mut num_witness = 4;
530
531        let mut optimizer = CSatTransformer::new(MIN_EXPRESSION_WIDTH);
532        optimizer.mark_solvable(a);
533        optimizer.mark_solvable(c);
534        optimizer.mark_solvable(d);
535        optimizer.mark_solvable(e);
536        let got_optimized_opcode_a =
537            optimizer.transform(opcode_a, &mut intermediate_variables, &mut num_witness);
538
539        // Since b is not known, it cannot be put inside intermediate opcodes, so it must belong to the transformed opcode.
540        let contains_b = got_optimized_opcode_a.linear_combinations.iter().any(|(_, w)| *w == b);
541        assert!(contains_b);
542    }
543
544    #[test]
545    fn recognize_expr_with_single_shared_witness_which_fits_in_single_identity() {
546        // Regression test for an expression which Zac found which should have been preserved but
547        // was being split into two expressions.
548        let expr = Expression::from_str("-555*w8*w10 + w10 + w11 - w13").unwrap();
549        assert!(fits_in_one_identity(&expr, 4));
550    }
551
552    #[test]
553    #[should_panic(expected = "normalize: the first coefficient is non-invertible")]
554    fn normalize_on_zero_linear_combination_panics() {
555        let expr = Expression {
556            mul_terms: vec![],
557            linear_combinations: vec![(FieldElement::zero(), Witness(0))],
558            q_c: FieldElement::zero(),
559        };
560        CSatTransformer::new(MIN_EXPRESSION_WIDTH).normalize(expr);
561    }
562
563    #[test]
564    #[should_panic(expected = "normalize: the first coefficient is non-invertible")]
565    fn normalize_on_zero_mul_term_scale_panics() {
566        let expr = Expression {
567            mul_terms: vec![(FieldElement::zero(), Witness(0), Witness(1))],
568            linear_combinations: vec![],
569            q_c: FieldElement::zero(),
570        };
571        CSatTransformer::new(MIN_EXPRESSION_WIDTH).normalize(expr);
572    }
573
574    #[test]
575    #[should_panic(
576        expected = "get_or_create_intermediate_var: attempting to divide l by F::zero()"
577    )]
578    fn get_or_create_intermediate_var_with_zero_panics() {
579        let expr = Expression {
580            mul_terms: vec![(FieldElement::one(), Witness(0), Witness(1))],
581            linear_combinations: vec![],
582            q_c: FieldElement::zero(),
583        };
584
585        let mut intermediate_variables = IndexMap::new();
586        intermediate_variables.insert(expr.clone(), (FieldElement::zero(), Witness(0)));
587
588        let mut num_witness = 2;
589
590        let mut optimizer = CSatTransformer::new(MIN_EXPRESSION_WIDTH);
591        optimizer.get_or_create_intermediate_var(
592            &mut intermediate_variables,
593            expr,
594            &mut num_witness,
595        );
596    }
597
598    #[test]
599    fn full_opcode_scan_optimization_extracts_full_opcodes() {
600        // Expression: x*x + a*b + x + a + b + c + d + e + f + g
601        //
602        // With width=3 and two mul terms, full_opcode_scan_optimization extracts each
603        // mul term together with 2 linear terms into an intermediate variable:
604        //   t0 = x*x + x + g      (Witness 8)
605        //   t1 = a*b + a + b      (Witness 9)
606        //
607        // The remaining expression becomes: t0 + t1 + c + d + e + f
608        let x = Witness(0);
609        let a = Witness(1);
610        let b = Witness(2);
611        let c = Witness(3);
612        let d = Witness(4);
613        let e = Witness(5);
614        let f = Witness(6);
615        let g = Witness(7);
616
617        let opcode = Expression {
618            mul_terms: vec![(FieldElement::one(), x, x), (FieldElement::one(), a, b)],
619            linear_combinations: vec![
620                (FieldElement::one(), x),
621                (FieldElement::one(), a),
622                (FieldElement::one(), b),
623                (FieldElement::one(), c),
624                (FieldElement::one(), d),
625                (FieldElement::one(), e),
626                (FieldElement::one(), f),
627                (FieldElement::one(), g),
628            ],
629            q_c: FieldElement::zero(),
630        };
631
632        let mut intermediate_variables: IndexMap<
633            Expression<FieldElement>,
634            (FieldElement, Witness),
635        > = IndexMap::new();
636        let mut num_witness = 8u32;
637
638        let mut optimizer = CSatTransformer::new(MIN_EXPRESSION_WIDTH);
639        for w in [x, a, b, c, d, e, f, g] {
640            optimizer.mark_solvable(w);
641        }
642
643        let result = optimizer.full_opcode_scan_optimization(
644            opcode,
645            &mut intermediate_variables,
646            &mut num_witness,
647        );
648
649        // Both mul terms were replaced by intermediate variables; no mul terms remain.
650        assert!(result.mul_terms.is_empty(), "all mul terms should be absorbed");
651
652        // Each intermediate variable is full: it has 2 linear terms.
653        for intermediate in intermediate_variables.keys() {
654            assert!(
655                intermediate.mul_terms.len() == 1,
656                "intermediate variables should be full opcodes"
657            );
658            assert!(
659                intermediate.linear_combinations.len() == 2,
660                "intermediate variables should be full opcodes"
661            );
662        }
663        // Remaining linear terms: c, d, e, f+ t0, t1 (intermediate vars).
664        assert_eq!(result.linear_combinations.len(), 6);
665    }
666
667    #[test]
668    #[should_panic(expected = "Could not reduce the expression")]
669    fn single_solvable_term_in_intermediate_opcode_is_preserved() {
670        // Test the case when len() is 1 in the line: 'if intermediate_opcode.linear_combinations.len() > 1 {'
671        // Setup: width is 3, 4 terms [a, b, c, d], only 'a' is solvable.
672        //
673        // Because only 'a' is solvable, the intermediate_opcode will be [a], so its len is 1.
674        // In this case, 'a' should be added back to the opcode which makes it larger than the width and trigger the panic.
675
676        let a = Witness(0); // solvable
677        let b = Witness(1); // unsolvable
678        let c = Witness(2); // unsolvable
679        let d = Witness(3); // unsolvable
680
681        let opcode = Expression {
682            mul_terms: vec![],
683            linear_combinations: vec![
684                (FieldElement::one(), a),
685                (FieldElement::one(), b),
686                (FieldElement::one(), c),
687                (FieldElement::one(), d),
688            ],
689            q_c: FieldElement::zero(),
690        };
691
692        let mut intermediate_variables: IndexMap<
693            Expression<FieldElement>,
694            (FieldElement, Witness),
695        > = IndexMap::new();
696
697        let mut num_witness = 4;
698
699        let mut optimizer = CSatTransformer::new(MIN_EXPRESSION_WIDTH);
700        optimizer.mark_solvable(a);
701
702        let _ = optimizer.transform(opcode, &mut intermediate_variables, &mut num_witness);
703    }
704}