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}