Skip to main content

bloq_graph/zx/
output_correction.rs

1//! Symbolic GF(2) solve of the joint output-correction system.
2//!
3//! The terminal Pauli-frame correction is an affine GF(2) system: the LHS
4//! (which output frame bits each correction surface constrains) is fixed by
5//! the stabilizer basis, while the RHS (whether each surface's measurement
6//! parity demands a flip) is only known at runtime. Running the elimination
7//! once over the static LHS — tracking, per solved bit, *which constraints'*
8//! RHS bits XOR into it — yields a [`SymbolicOutputCorrection`] used by both
9//! internal-measurement verification and compiler `Compute` lowering. One
10//! elimination implementation keeps the consumers aligned.
11
12use std::collections::BTreeSet;
13
14use bloq_utils::PauliString;
15use glam::IVec3;
16use thiserror::Error;
17
18use super::modular::toggle;
19use super::stabilizer::{CoeffVec, axis_pivot_constraints, gaussian_elimination_with_tracking};
20use crate::{FxHashMap, Pauli};
21
22/// One equation in the terminal output-correction system.
23///
24/// `output_support` is the equation's left-hand side. Its runtime right-hand
25/// side includes the XOR of the named generators' readable values. A row with
26/// no readable generators is still a real equation: physical lowering must
27/// retain any private measurement parity carried by its full surface.
28/// The materialized surface's Pauli-product sign is intentionally absent:
29/// consumers already realize it in those generator values, rather than adding
30/// another affine term in this solve.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct OutputCorrectionRow {
33    /// Requested outputs on which this row has non-identity Pauli support.
34    pub output_support: Vec<(IVec3, Pauli)>,
35    /// Original public generator ordinals whose decoded values form the RHS.
36    pub readout_ordinals: Vec<usize>,
37}
38
39/// An error from solving or evaluating the output-correction system.
40// Deliberately not `#[non_exhaustive]`: the verifier maps every variant and
41// should fail to compile when one is added.
42#[derive(Debug, Clone, Error, PartialEq, Eq)]
43pub enum OutputCorrectionError {
44    /// Runtime right-hand side has the wrong length.
45    #[error("output-correction RHS length mismatch: expected {expected}, got {actual}")]
46    RhsLengthMismatch {
47        /// Required bit count.
48        expected: usize,
49        /// Supplied bit count.
50        actual: usize,
51    },
52    /// A correction row names an unrequested output.
53    #[error("output {output} named by a correction surface is not a requested output")]
54    UnknownOutput {
55        /// Unknown output position.
56        output: IVec3,
57    },
58    /// The correction equations are inconsistent.
59    #[error("output corrections are inconsistent")]
60    Inconsistent,
61}
62
63/// One output's frame bits, each expressed as the constraint-index set whose
64/// runtime RHS bits XOR into it. An empty set is the constant `false`.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct SymbolicFramePair {
67    /// The output port these frame bits belong to.
68    pub output: IVec3,
69    /// Constraint indices (into the `constraints` slice given to
70    /// [`solve_output_correction_symbolic`]) whose RHS XOR gives the X-frame bit.
71    pub x: Vec<usize>,
72    /// Same, for the Z-frame bit.
73    pub z: Vec<usize>,
74}
75
76/// The eliminated system: per-output frame-bit assignments plus the dependent
77/// rows whose RHS must XOR to zero for the system to be consistent at runtime.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SymbolicOutputCorrection {
80    frames: Vec<SymbolicFramePair>,
81    inconsistencies: Vec<Vec<usize>>,
82    num_constraints: usize,
83}
84
85impl SymbolicOutputCorrection {
86    /// Returns the per-output symbolic frame-bit assignments.
87    pub fn frames(&self) -> &[SymbolicFramePair] {
88        &self.frames
89    }
90
91    /// Substitute concrete RHS bits (one per constraint, in the order given to
92    /// [`solve_output_correction_symbolic`]) into the solved system.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error for a mismatched RHS length or an inconsistent system.
97    pub fn evaluate(
98        &self,
99        needs_flip: &[bool],
100    ) -> Result<Vec<(IVec3, Pauli)>, OutputCorrectionError> {
101        if needs_flip.len() != self.num_constraints {
102            return Err(OutputCorrectionError::RhsLengthMismatch {
103                expected: self.num_constraints,
104                actual: needs_flip.len(),
105            });
106        }
107        let xor = |set: &[usize]| {
108            set.iter()
109                .fold(false, |acc, &index| acc ^ needs_flip[index])
110        };
111        if self.inconsistencies.iter().any(|set| xor(set)) {
112            return Err(OutputCorrectionError::Inconsistent);
113        }
114        Ok(self
115            .frames
116            .iter()
117            .map(|pair| (pair.output, pauli_from_xz(xor(&pair.x), xor(&pair.z))))
118            .collect())
119    }
120}
121
122/// Output-pivot dense stabilizer rows and retain only the compact correction
123/// equation plus its source row. Runtime derivation materializes the dense row
124/// only for the verifier; static and compiler paths consume the compact half.
125pub(super) fn pivot_output_correction_rows(
126    mut rows: Vec<PauliString>,
127    mut coeffs: Vec<CoeffVec>,
128    outputs: &[IVec3],
129    output_cols: &[usize],
130    input_cols: &[usize],
131    protected_cols: &[usize],
132) -> Vec<(OutputCorrectionRow, PauliString)> {
133    debug_assert_eq!(outputs.len(), output_cols.len());
134    let num_pivoted = gaussian_elimination_with_tracking(
135        &mut rows,
136        &mut coeffs,
137        axis_pivot_constraints(output_cols.iter().copied()),
138        |row, &(col, basis)| row.get(col) & basis,
139        None,
140    );
141
142    // Zero-output relations can label erased input/readout sectors. Reduce
143    // the output witness's input operator against that kernel so its reference
144    // map does not depend on which dense row happened to pivot first. Only
145    // relations that preserve prepared resources and other live outputs may
146    // participate: a row like X_input Z_T changes the resource map.
147    let mut output_start = 0;
148    if !input_cols.is_empty() {
149        let protected_rank = gaussian_elimination_with_tracking(
150            &mut rows[num_pivoted..],
151            &mut coeffs[num_pivoted..],
152            axis_pivot_constraints(protected_cols.iter().copied()),
153            |row, &(col, basis)| row.get(col) & basis,
154            None,
155        );
156        let kernel_start = num_pivoted + protected_rank;
157        let kernel_len = rows.len() - kernel_start;
158        if kernel_len != 0 {
159            rows.rotate_left(kernel_start);
160            coeffs.rotate_left(kernel_start);
161            gaussian_elimination_with_tracking(
162                &mut rows,
163                &mut coeffs,
164                axis_pivot_constraints(input_cols.iter().copied()),
165                |row, &(col, basis)| row.get(col) & basis,
166                Some(kernel_len),
167            );
168            output_start = kernel_len;
169        }
170    }
171
172    rows.into_iter()
173        .zip(coeffs)
174        .skip(output_start)
175        .take(num_pivoted)
176        .filter_map(|(row, coeff)| {
177            let output_support = outputs
178                .iter()
179                .zip(output_cols)
180                .filter_map(|(&output, &col)| {
181                    let pauli = row.get(col);
182                    (pauli != Pauli::I).then_some((output, pauli))
183                })
184                .collect::<Vec<_>>();
185            (!output_support.is_empty()).then(|| {
186                (
187                    OutputCorrectionRow {
188                        output_support,
189                        readout_ordinals: coeff.to_indices(),
190                    },
191                    row,
192                )
193            })
194        })
195        .collect()
196}
197
198/// Solve the joint output-correction system once, symbolically.
199///
200/// Variables are two frame bits per output: the X bit at column `2*i`
201/// (constrained by the Z part of a surface's Pauli at output `i`) and the Z
202/// bit at column `2*i + 1` (constrained by the X part). Each constraint row's
203/// RHS is tracked as a singleton constraint-index set and XOR-combined through
204/// the elimination; free variables solve to the constant `false` (empty set).
205///
206/// # Errors
207///
208/// Returns [`OutputCorrectionError::UnknownOutput`] if a row names an absent output.
209pub fn solve_output_correction_symbolic(
210    outputs: &[IVec3],
211    constraints: &[OutputCorrectionRow],
212) -> Result<SymbolicOutputCorrection, OutputCorrectionError> {
213    solve_output_correction_symbolic_prefix(outputs, constraints, outputs.len())
214}
215
216/// Solve the full joint system, materializing at most its first `output_count`
217/// outputs. All constraints and runtime consistency checks remain active.
218///
219/// Pivot order and the free-variable policy are identical to
220/// [`solve_output_correction_symbolic`]. A module linker can put public outputs
221/// first and retain internal seam variables without expanding their frames.
222///
223/// # Errors
224///
225/// Returns [`OutputCorrectionError::UnknownOutput`] if a row names an absent output.
226///
227/// # Panics
228///
229/// Panics if `output_count` exceeds `outputs.len()`.
230pub fn solve_output_correction_symbolic_prefix(
231    outputs: &[IVec3],
232    constraints: &[OutputCorrectionRow],
233    output_count: usize,
234) -> Result<SymbolicOutputCorrection, OutputCorrectionError> {
235    let num_vars = outputs.len() * 2;
236    let num_constraints = constraints.len();
237    let mut output_indices = FxHashMap::default();
238    for (index, &output) in outputs.iter().enumerate() {
239        // Preserve the first occurrence, as the original linear lookup did.
240        output_indices.entry(output).or_insert(index);
241    }
242    let mut parities = ConstraintParities {
243        sources: num_constraints,
244        xors: Vec::new(),
245    };
246    let mut rows = constraints
247        .iter()
248        .enumerate()
249        .map(|(constraint_index, constraint)| {
250            let mut lhs = BTreeSet::new();
251            for &(output, surface_pauli) in &constraint.output_support {
252                let &output_index = output_indices
253                    .get(&output)
254                    .ok_or(OutputCorrectionError::UnknownOutput { output })?;
255                if surface_pauli & Pauli::Z {
256                    toggle(&mut lhs, 2 * output_index);
257                }
258                if surface_pauli & Pauli::X {
259                    toggle(&mut lhs, 2 * output_index + 1);
260                }
261            }
262            Ok((lhs, Some(constraint_index)))
263        })
264        .collect::<Result<Vec<_>, _>>()?;
265
266    // Index only unpivoted rows by their first live column. Forward elimination
267    // touches a column's incident rows, not every row in the joint system.
268    let mut by_first = vec![BTreeSet::new(); num_vars];
269    for (index, (lhs, _)) in rows.iter().enumerate() {
270        if let Some(&col) = lhs.first() {
271            by_first[col].insert(index);
272        }
273    }
274    let mut pivot_row = 0;
275    for col in 0..num_vars {
276        let Some(found) = by_first[col].pop_first() else {
277            continue;
278        };
279        // Preserve the original row-swap policy: it determines the chosen RHS
280        // representative when constraints are dependent.
281        if found != pivot_row {
282            if let Some(&displaced_col) = rows[pivot_row].0.first() {
283                by_first[displaced_col].remove(&pivot_row);
284                by_first[displaced_col].insert(found);
285            }
286            rows.swap(pivot_row, found);
287        }
288        let pivot = rows[pivot_row].clone();
289        for index in std::mem::take(&mut by_first[col]) {
290            let (lhs, rhs) = &mut rows[index];
291            for &entry in &pivot.0 {
292                toggle(lhs, entry);
293            }
294            *rhs = parities.xor(*rhs, pivot.1);
295            if let Some(&next_col) = lhs.first() {
296                by_first[next_col].insert(index);
297            }
298        }
299        pivot_row += 1;
300    }
301
302    let inconsistencies = rows[pivot_row..]
303        .iter()
304        .map(|(lhs, rhs)| {
305            debug_assert!(lhs.is_empty());
306            parities.indices(*rhs)
307        })
308        .filter(|set| !set.is_empty())
309        .collect();
310
311    // Back-substitution shares XOR expressions instead of eagerly copying a
312    // growing constraint-index vector into every intermediate frame.
313    let mut solution = vec![None; num_vars];
314    for (lhs, rhs) in rows[..pivot_row].iter().rev() {
315        let mut columns = lhs.iter();
316        let &col = columns.next().expect("pivot row is nonzero");
317        let mut value = *rhs;
318        for &other in columns {
319            value = parities.xor(value, solution[other]);
320        }
321        solution[col] = value;
322    }
323    let frames = outputs
324        .iter()
325        .take(output_count)
326        .enumerate()
327        .map(|(index, &output)| SymbolicFramePair {
328            output,
329            x: parities.indices(solution[2 * index]),
330            z: parities.indices(solution[2 * index + 1]),
331        })
332        .collect();
333
334    Ok(SymbolicOutputCorrection {
335        frames,
336        inconsistencies,
337        num_constraints,
338    })
339}
340
341/// Original constraint indices are leaves; later indices name shared XORs.
342/// `None` is zero. Children always precede their parent, allowing cancellation
343/// before expansion without recursion or revisiting a shared subexpression.
344struct ConstraintParities {
345    sources: usize,
346    xors: Vec<(usize, usize)>,
347}
348
349impl ConstraintParities {
350    fn xor(&mut self, left: Option<usize>, right: Option<usize>) -> Option<usize> {
351        match (left, right) {
352            (None, other) | (other, None) => other,
353            (Some(left), Some(right)) if left == right => None,
354            (Some(left), Some(right)) => {
355                self.xors.push((left, right));
356                Some(self.sources + self.xors.len() - 1)
357            }
358        }
359    }
360
361    fn indices(&self, expression: Option<usize>) -> Vec<usize> {
362        let mut pending = expression.into_iter().collect::<BTreeSet<_>>();
363        while let Some(&node) = pending.last() {
364            if node < self.sources {
365                break;
366            }
367            pending.pop_last();
368            let (left, right) = self.xors[node - self.sources];
369            toggle(&mut pending, left);
370            toggle(&mut pending, right);
371        }
372        pending.into_iter().collect()
373    }
374}
375
376fn pauli_from_xz(x: bool, z: bool) -> Pauli {
377    match (x, z) {
378        (false, false) => Pauli::I,
379        (true, false) => Pauli::X,
380        (false, true) => Pauli::Z,
381        (true, true) => Pauli::Y,
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use glam::ivec3;
388    use rand::rngs::StdRng;
389    use rand::{RngExt, SeedableRng};
390
391    use super::*;
392    fn constraint(support: Vec<(IVec3, Pauli)>) -> OutputCorrectionRow {
393        OutputCorrectionRow {
394            output_support: support,
395            readout_ordinals: Vec::new(),
396        }
397    }
398
399    #[test]
400    fn input_kernel_normalization_preserves_resources_and_readout_provenance() {
401        // A, B are arbitrary inputs; T is a prepared resource; O is requested.
402        // These rows commute. K erases an input sector, while J also acts on T.
403        let r = PauliString::try_from("X__X").unwrap();
404        let k = PauliString::try_from("XX__").unwrap();
405        let j = PauliString::try_from("X_X_").unwrap();
406        let coeffs = (0..3)
407            .map(|index| CoeffVec::singleton(index, 3))
408            .collect::<Vec<_>>();
409        let solve = |rows, coeffs| {
410            pivot_output_correction_rows(rows, coeffs, &[ivec3(0, 0, 1)], &[3], &[0, 1], &[2])
411        };
412        let expected = solve(vec![r.clone(), k.clone(), j.clone()], coeffs.clone());
413        assert_eq!(expected.len(), 1);
414        assert_eq!(expected[0].1, PauliString::try_from("_X_X").unwrap());
415        assert_eq!(expected[0].0.readout_ordinals, vec![0, 1]);
416
417        let mut combined = coeffs[0].clone();
418        combined.xor_assign(&coeffs[1]);
419        assert_eq!(
420            solve(
421                vec![&r ^ &k, j.clone(), k],
422                vec![combined, coeffs[2].clone(), coeffs[1].clone()]
423            ),
424            expected,
425            "replacing a witness by the same input-kernel coset preserves its map and provenance"
426        );
427        let protected = solve(
428            vec![r.clone(), j],
429            vec![coeffs[0].clone(), coeffs[2].clone()],
430        );
431        assert_eq!(
432            protected[0].1, r,
433            "a resource-bearing relation cannot change the witness"
434        );
435        assert_eq!(protected[0].0.readout_ordinals, vec![0]);
436    }
437
438    /// Reference concrete elimination used to check the symbolic solver.
439    fn reference_solve(
440        outputs: &[IVec3],
441        constraints: &[(Vec<(usize, Pauli)>, bool)],
442    ) -> Result<Vec<(IVec3, Pauli)>, ()> {
443        let num_vars = outputs.len() * 2;
444        let mut rows = constraints
445            .iter()
446            .map(|(support, needs_flip)| {
447                let mut row = vec![false; num_vars + 1];
448                for &(output_index, surface_pauli) in support {
449                    if surface_pauli & Pauli::Z {
450                        row[2 * output_index] ^= true;
451                    }
452                    if surface_pauli & Pauli::X {
453                        row[2 * output_index + 1] ^= true;
454                    }
455                }
456                row[num_vars] = *needs_flip;
457                row
458            })
459            .collect::<Vec<_>>();
460
461        let mut pivot_row = 0;
462        for col in 0..num_vars {
463            let Some(found) = (pivot_row..rows.len()).find(|&row| rows[row][col]) else {
464                continue;
465            };
466            rows.swap(pivot_row, found);
467            let pivot = rows[pivot_row].clone();
468            for (row_index, row) in rows.iter_mut().enumerate() {
469                if row_index == pivot_row || !row[col] {
470                    continue;
471                }
472                for (entry, pivot_entry) in row.iter_mut().zip(&pivot).skip(col) {
473                    *entry ^= *pivot_entry;
474                }
475            }
476            pivot_row += 1;
477        }
478
479        if rows
480            .iter()
481            .any(|row| row[..num_vars].iter().all(|bit| !*bit) && row[num_vars])
482        {
483            return Err(());
484        }
485
486        let mut solution = vec![false; num_vars];
487        for row in rows.iter().take(pivot_row) {
488            if let Some(col) = row[..num_vars].iter().position(|bit| *bit) {
489                solution[col] = row[num_vars];
490            }
491        }
492
493        Ok(outputs
494            .iter()
495            .copied()
496            .enumerate()
497            .map(|(index, output)| {
498                (
499                    output,
500                    pauli_from_xz(solution[2 * index], solution[2 * index + 1]),
501                )
502            })
503            .collect())
504    }
505
506    const PAULIS: [Pauli; 4] = [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z];
507
508    #[test]
509    fn symbolic_solve_matches_reference_on_random_systems() {
510        let mut rng = StdRng::seed_from_u64(0x0510);
511        for case in 0..600 {
512            let num_outputs = rng.random_range(1..=if case < 500 { 3 } else { 70 });
513            let outputs: Vec<IVec3> = (0..num_outputs).map(|i| ivec3(i, 0, i)).collect();
514            let num_constraints =
515                rng.random_range(0..=if case < 500 { 6 } else { 2 * outputs.len() + 3 });
516            let mut constraints: Vec<OutputCorrectionRow> = (0..num_constraints)
517                .map(|_| {
518                    constraint(
519                        outputs
520                            .iter()
521                            .filter_map(|&output| {
522                                let pauli = if case >= 500 && case % 2 == 0 {
523                                    if rng.random_range(0..8) == 0 {
524                                        PAULIS[rng.random_range(1..4)]
525                                    } else {
526                                        Pauli::I
527                                    }
528                                } else {
529                                    PAULIS[rng.random_range(0..4)]
530                                };
531                                (pauli != Pauli::I).then_some((output, pauli))
532                            })
533                            .collect(),
534                    )
535                })
536                .collect();
537            if case % 3 == 0 && constraints.len() > 1 {
538                constraints[1] = constraints[0].clone();
539            }
540            let symbolic = solve_output_correction_symbolic(&outputs, &constraints)
541                .expect("all supports name requested outputs");
542            let projected = [0, outputs.len() / 2, outputs.len() + 1].map(|count| {
543                let solve =
544                    solve_output_correction_symbolic_prefix(&outputs, &constraints, count).unwrap();
545                assert_eq!(
546                    solve.frames(),
547                    &symbolic.frames()[..count.min(outputs.len())]
548                );
549                solve
550            });
551
552            // Exercise both arbitrary RHSs and guaranteed-consistent RHSs so
553            // larger overdetermined systems also check concrete frame values.
554            for trial in 0..8 {
555                let assigned = (0..outputs.len())
556                    .map(|_| (rng.random::<bool>(), rng.random::<bool>()))
557                    .collect::<Vec<_>>();
558                let reference_constraints = constraints
559                    .iter()
560                    .map(|constraint| {
561                        let indexed = constraint
562                            .output_support
563                            .iter()
564                            .map(|&(output, pauli)| {
565                                let index = outputs
566                                    .iter()
567                                    .position(|candidate| *candidate == output)
568                                    .expect("support built from outputs");
569                                (index, pauli)
570                            })
571                            .collect::<Vec<_>>();
572                        let flip = if trial < 4 {
573                            indexed.iter().fold(false, |value, &(index, pauli)| {
574                                value
575                                    ^ (pauli & Pauli::Z && assigned[index].0)
576                                    ^ (pauli & Pauli::X && assigned[index].1)
577                            })
578                        } else {
579                            rng.random()
580                        };
581                        (indexed, flip)
582                    })
583                    .collect::<Vec<_>>();
584                let needs_flip = reference_constraints
585                    .iter()
586                    .map(|(_, flip)| *flip)
587                    .collect::<Vec<_>>();
588                let expected = reference_solve(&outputs, &reference_constraints);
589                for solve in std::iter::once(&symbolic).chain(&projected) {
590                    match (solve.evaluate(&needs_flip), &expected) {
591                        (Ok(actual), Ok(expected)) => {
592                            assert_eq!(actual, expected[..solve.frames().len()])
593                        }
594                        (Err(OutputCorrectionError::Inconsistent), Err(())) => {}
595                        (actual, expected) => {
596                            panic!("solver drift: symbolic={actual:?} reference={expected:?}")
597                        }
598                    }
599                }
600            }
601        }
602    }
603
604    #[test]
605    fn public_frame_through_long_seam_chain_keeps_all_constraint_parities() {
606        let outputs = (0..4096).map(|i| ivec3(i, 0, 0)).collect::<Vec<_>>();
607        let mut constraints = outputs
608            .windows(2)
609            .map(|pair| constraint(vec![(pair[0], Pauli::Z), (pair[1], Pauli::Z)]))
610            .collect::<Vec<_>>();
611        constraints[0]
612            .output_support
613            .extend([(outputs[0], Pauli::X), (outputs[0], Pauli::X)]);
614        constraints.push(constraint(vec![(*outputs.last().unwrap(), Pauli::Z)]));
615        let solve = solve_output_correction_symbolic_prefix(&outputs, &constraints, 1).unwrap();
616        assert_eq!(
617            solve.frames(),
618            &[SymbolicFramePair {
619                output: outputs[0],
620                x: (0..constraints.len()).collect(),
621                z: Vec::new(),
622            }]
623        );
624    }
625
626    #[test]
627    fn unknown_output_in_support_is_rejected() {
628        let outputs = [ivec3(0, 0, 1)];
629        let constraints = [constraint(vec![(ivec3(9, 9, 9), Pauli::Z)])];
630        assert_eq!(
631            solve_output_correction_symbolic(&outputs, &constraints),
632            Err(OutputCorrectionError::UnknownOutput {
633                output: ivec3(9, 9, 9)
634            })
635        );
636        assert!(matches!(
637            solve_output_correction_symbolic_prefix(&outputs, &constraints, 0),
638            Err(OutputCorrectionError::UnknownOutput { .. })
639        ));
640    }
641
642    #[test]
643    fn dependent_rows_become_runtime_consistency_checks() {
644        // Two identical constraints: the second eliminates to a zero LHS whose
645        // RHS set {0, 1} must XOR to zero at runtime.
646        let outputs = [ivec3(0, 0, 1)];
647        let constraints = [
648            constraint(vec![(ivec3(0, 0, 1), Pauli::Z)]),
649            constraint(vec![(ivec3(0, 0, 1), Pauli::Z)]),
650        ];
651        let symbolic = solve_output_correction_symbolic(&outputs, &constraints).unwrap();
652
653        assert_eq!(
654            symbolic.evaluate(&[true, true]).unwrap(),
655            vec![(ivec3(0, 0, 1), Pauli::X)]
656        );
657        assert_eq!(symbolic.frames()[0].x, vec![0]);
658        let error = symbolic
659            .evaluate(&[true, false])
660            .expect_err("unequal dependent parities are inconsistent");
661        assert_eq!(error, OutputCorrectionError::Inconsistent);
662        assert_eq!(error.to_string(), "output corrections are inconsistent");
663    }
664
665    #[test]
666    fn evaluate_rejects_rhs_length_mismatch() {
667        let outputs = [ivec3(0, 0, 1)];
668        let constraints = [constraint(vec![(ivec3(0, 0, 1), Pauli::Z)])];
669        let symbolic = solve_output_correction_symbolic(&outputs, &constraints).unwrap();
670
671        assert_eq!(
672            symbolic.evaluate(&[]),
673            Err(OutputCorrectionError::RhsLengthMismatch {
674                expected: 1,
675                actual: 0,
676            })
677        );
678        assert_eq!(
679            symbolic.evaluate(&[false, true]),
680            Err(OutputCorrectionError::RhsLengthMismatch {
681                expected: 1,
682                actual: 2,
683            })
684        );
685    }
686}