Skip to main content

bloq_graph/verify/
feedback.rs

1//! Bounded synthesis of missing Pauli feedback from QASM and a realized graph.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use glam::IVec3;
6use quizx::fscalar::{FScalar, Zero};
7use quizx::graph::{GraphLike, VType};
8use quizx::tensor::ToTensor;
9
10use crate::zx::{CoeffVec, solve_coeff_combination};
11use crate::{Action, BlockGraph, Expr, FeedbackTarget, Pauli, PauliBasis};
12
13use super::diagram::eval_expr;
14use super::qasm::QasmCircuit;
15use super::{
16    BoundaryOrder, BranchAssignment, BranchStatus, LogicalVerifier, QasmError, QuizxGraph,
17};
18
19/// Correspondence and resource bounds for feedback inference.
20///
21/// QASM is interpreted as a component: unreset wires are arbitrary inputs,
22/// not implicitly initialized to zero. By default its surviving wires map in
23/// declaration order to the verifier's boundary order. Single-bit classical
24/// registers match same-named BLOG measurements (`m[0]` matches `m`).
25#[derive(Debug, Clone)]
26pub struct FeedbackOptions {
27    boundaries: Option<BoundaryOrder>,
28    measurements: BTreeMap<String, Expr>,
29    fixed_measurements: BTreeMap<String, bool>,
30    discarded_measurements: BTreeSet<String>,
31    preparation: Option<String>,
32    discard_if: Option<Expr>,
33    max_branches: usize,
34    max_classes: usize,
35    max_tensor_qubits: usize,
36    max_trials: usize,
37    internal_sites: Option<Vec<IVec3>>,
38}
39
40impl Default for FeedbackOptions {
41    fn default() -> Self {
42        Self {
43            boundaries: None,
44            measurements: BTreeMap::new(),
45            fixed_measurements: BTreeMap::new(),
46            discarded_measurements: BTreeSet::new(),
47            preparation: None,
48            discard_if: None,
49            max_branches: 256,
50            max_classes: 256,
51            max_tensor_qubits: 20,
52            max_trials: 4096,
53            internal_sites: None,
54        }
55    }
56}
57
58impl FeedbackOptions {
59    /// Bounds the fallback search over internal Pauli actions. Zero requests
60    /// output feedback only. Each trial checks all named/anonymous branches.
61    #[must_use]
62    pub fn with_search_limit(mut self, trials: usize) -> Self {
63        self.max_trials = trials;
64        self
65    }
66
67    /// Restricts the internal search to these authored block positions.
68    /// By default all unconditional, unambiguous wire anchors are considered.
69    #[must_use]
70    pub fn with_internal_sites(mut self, sites: Vec<IVec3>) -> Self {
71        self.internal_sites = Some(sites);
72        self
73    }
74
75    /// Sets graph boundaries in the corresponding QASM input/output order.
76    #[must_use]
77    pub fn with_boundaries(mut self, boundaries: BoundaryOrder) -> Self {
78        self.boundaries = Some(boundaries);
79        self
80    }
81
82    /// Binds an indexed QASM result to an expression over named graph records.
83    /// This explicitly supplies any parity or polarity convention.
84    #[must_use]
85    pub fn with_measurement(mut self, bit: impl Into<String>, value: Expr) -> Self {
86        self.measurements.insert(bit.into(), value);
87        self
88    }
89
90    /// Explicitly postselects a source result instead of mapping it to a graph
91    /// record. Useful for gallery kernels with projected boundary effects.
92    #[must_use]
93    pub fn with_fixed_measurement(mut self, bit: impl Into<String>, value: bool) -> Self {
94        self.fixed_measurements.insert(bit.into(), value);
95        self
96    }
97
98    /// Forgets a source result after executing its consumers. Its Kraus
99    /// branches are summed, preserving the measurement's quantum state update.
100    #[must_use]
101    pub fn with_discarded_measurement(mut self, bit: impl Into<String>) -> Self {
102        self.discarded_measurements.insert(bit.into());
103        self
104    }
105
106    /// Supplies an unconditional preparation component whose ordered outputs
107    /// feed all graph inputs. Its unreset inputs must match the source inputs.
108    /// Resource states are never inferred from port tags.
109    #[must_use]
110    pub fn with_input_preparation(mut self, qasm: impl Into<String>) -> Self {
111        self.preparation = Some(qasm.into());
112        self
113    }
114
115    /// Declares the desired rejection predicate over graph records. Graph
116    /// `DiscardIf` behavior must agree; inference never silently drops a shot.
117    #[must_use]
118    pub fn with_discard_if(mut self, condition: Expr) -> Self {
119        self.discard_if = Some(condition);
120        self
121    }
122
123    /// Bounds named assignments, anonymous boundary classes per assignment,
124    /// and the largest intermediate tensor's number of binary axes.
125    #[must_use]
126    pub fn with_limits(mut self, branches: usize, classes: usize, tensor_qubits: usize) -> Self {
127        self.max_branches = branches;
128        self.max_classes = classes;
129        self.max_tensor_qubits = tensor_qubits;
130        self
131    }
132}
133
134/// A source/realization mismatch, unsupported contract, or inference limit.
135#[derive(Debug, thiserror::Error)]
136#[non_exhaustive]
137pub enum FeedbackInferenceError {
138    /// Neutral QASM parsing or validation failed.
139    #[error("{0}")]
140    Qasm(#[from] QasmError),
141    /// Block-graph construction or validation failed.
142    #[error("{0}")]
143    Graph(#[from] crate::BlockGraphError),
144    /// Logical map verification failed.
145    #[error("{0}")]
146    Verify(#[from] super::VerifyLogicalError),
147    /// Runtime-basis construction or update failed.
148    #[error("{0}")]
149    RuntimeBasis(#[from] crate::RuntimeBasisError),
150    /// Output correction surfaces could not be solved.
151    #[error("{0}")]
152    OutputCorrection(#[from] crate::OutputCorrectionError),
153    /// Caller-supplied source/graph correspondence is invalid.
154    #[error("invalid feedback correspondence: {0}")]
155    Binding(String),
156    /// Inference exceeded an explicit resource limit.
157    #[error("feedback inference exceeded the {phase} bound (size {observed}, limit {limit})")]
158    Limit {
159        /// Bounded inference phase or resource name.
160        phase: &'static str,
161        /// Resource amount required or observed.
162        observed: usize,
163        /// Configured maximum.
164        limit: usize,
165    },
166    /// Anonymous outcomes do not span the expected boundary space.
167    #[error(
168        "anonymous outcome certificate is incomplete: boundary rank {rank}, expected {expected}"
169    )]
170    IncompleteCertificate {
171        /// Observed independent boundary rank.
172        rank: usize,
173        /// Required boundary rank.
174        expected: usize,
175    },
176    /// Source and realization disagree on whether a branch is rejected.
177    #[error("source and graph reject different branches: {assignment:?}")]
178    RejectionMismatch {
179        /// Named outcome assignment selecting the branch.
180        assignment: BranchAssignment,
181    },
182    /// Source and realization disagree on whether a branch map is nonzero.
183    #[error("source and graph have different nonzero branch support: {assignment:?}")]
184    SupportMismatch {
185        /// Named outcome assignment selecting the branch.
186        assignment: BranchAssignment,
187    },
188    /// No output Pauli relates one realization branch to its source branch.
189    #[error("no output Pauli makes this branch equal to the source: {assignment:?}")]
190    NoOutputPauli {
191        /// Named outcome assignment selecting the branch.
192        assignment: BranchAssignment,
193    },
194    /// Internal Pauli trials found no correction satisfying all branches.
195    #[error("no valid Pauli feedback found in {trials} internal trials")]
196    NoPauliFeedback {
197        /// Number of internal candidate trials performed.
198        trials: usize,
199    },
200    /// Rechecking the emitted feedback disagreed with the inferred correction.
201    #[error("emitted feedback does not implement the inferred output correction")]
202    EmissionMismatch,
203    /// The comparison has no admitted nonzero branch.
204    #[error("the comparison contains no admitted nonzero branch")]
205    EmptyDomain,
206    /// QuiZX tensor arithmetic produced a NaN or infinity.
207    #[error("QuiZX tensor arithmetic produced a non-finite scalar")]
208    NonFiniteTensor,
209    /// Checked dyadic arithmetic overflowed or lost exactness.
210    #[error("dyadic comparison exceeded its checked arithmetic range or lost precision")]
211    ArithmeticLimit,
212    /// Existing readouts do not establish a signed C0 convention.
213    #[error("cannot establish the existing signed C0 readout convention for {0:?}")]
214    ReadoutConventionUnavailable(String),
215}
216
217/// Derives additional Pauli feedback for an existing realization.
218///
219/// Existing actions remain in force. The returned actions are checked on a
220/// clone before return; the input graph is unchanged. Use
221/// [`infer_feedback_with`] for explicit wire/record/resource correspondence.
222///
223/// This checks every bounded named branch and every certified anonymous
224/// boundary class in the logical ZX model. Discarded outcomes are summed as
225/// CP maps, compared up to a branch scalar using QuiZX's dyadic arithmetic.
226/// Choi arithmetic and projective cross-products are checked using `i128`
227/// dyadic coefficients. Earlier QuiZX simplification/contraction still uses
228/// floating-point coefficients: this is numerical logical verification, not
229/// an unrestricted exact-arithmetic certificate.
230/// Relative probabilities between named branches and noisy physical execution
231/// require separate verification. It does not infer missing `Resolve`
232/// choices or promise a polynomial algorithm for arbitrary non-Clifford maps.
233///
234/// ```
235/// use bloq_graph::{Action, GalleryItem, infer_feedback};
236/// let mut graph = GalleryItem::S.build().materialize_root_graph().expect("gallery flat projection");
237/// graph.set_actions(graph.actions().into_iter()
238///     .filter(|action| !matches!(action, Action::Feedback { .. })).collect())?;
239/// let mut actions = graph.actions();
240/// actions.extend(infer_feedback("OPENQASM 2.0; qreg q[1]; s q;", &graph)?);
241/// graph.set_actions(actions)?;
242/// # Ok::<(), Box<dyn std::error::Error>>(())
243/// ```
244///
245/// # Errors
246///
247/// Returns parsing, binding, support, resource-limit, or verification errors.
248pub fn infer_feedback(
249    qasm: &str,
250    graph: &BlockGraph,
251) -> Result<Vec<Action>, FeedbackInferenceError> {
252    infer_feedback_with(qasm, graph, &FeedbackOptions::default())
253}
254
255/// Derives missing feedback under an explicit component contract.
256///
257/// Unmatched source measurement results are errors, never silently fixed to
258/// zero. Graph-only measurement names are implementation records and are all
259/// checked. Only leading resets and measurements last on their own wires are
260/// supported; later conditional Paulis on other live wires are allowed.
261///
262/// # Errors
263///
264/// Rejects missing bindings, mismatched support/rejection, incomplete anonymous
265/// certificates, resource limits, non-Pauli differences, or actions that fail
266/// graph/readout validation or re-verification after insertion.
267pub fn infer_feedback_with(
268    qasm: &str,
269    graph: &BlockGraph,
270    options: &FeedbackOptions,
271) -> Result<Vec<Action>, FeedbackInferenceError> {
272    let source = QasmCircuit::parse(qasm)?;
273    let names = graph
274        .action_graph()
275        .inputs()
276        .map(str::to_owned)
277        .chain(
278            graph
279                .actions()
280                .into_iter()
281                .filter_map(|action| match action {
282                    Action::Measure { name, .. } => Some(name),
283                    _ => None,
284                }),
285        )
286        .collect::<BTreeSet<_>>()
287        .into_iter()
288        .collect::<Vec<_>>();
289    let count = checked_count(names.len(), options.max_branches, "named branches")?;
290    validate_records(graph, options.max_branches)?;
291    let verifier = make_verifier(graph, options)?;
292    let preparation = options
293        .preparation
294        .as_deref()
295        .map(QasmCircuit::parse)
296        .transpose()?;
297    if let Some(preparation) = &preparation {
298        if preparation.measurements.iter().any(Option::is_some) {
299            return Err(FeedbackInferenceError::Binding(
300                "input preparation must have no measurements".into(),
301            ));
302        }
303        if preparation.output_count() != verifier.boundaries().inputs().len()
304            || preparation.input_count() != source.input_count()
305        {
306            return Err(FeedbackInferenceError::Binding(
307                "input preparation does not match source/graph input widths".into(),
308            ));
309        }
310    } else if source.input_count() != verifier.boundaries().inputs().len() {
311        return Err(FeedbackInferenceError::Binding("source and graph input widths differ; supply ordered boundaries and any resource preparation".into()));
312    }
313    if source.output_count() != verifier.boundaries().outputs().len() {
314        return Err(FeedbackInferenceError::Binding(
315            "source and graph output widths differ".into(),
316        ));
317    }
318    // Reject oversized Choi matrices before contracting and retaining their
319    // Kraus tensors. The boundary correspondence gives both sides this width.
320    let channel_axes = 2 * (source.input_count() + source.output_count());
321    if channel_axes > options.max_tensor_qubits || channel_axes >= usize::BITS as usize {
322        return Err(FeedbackInferenceError::Limit {
323            phase: "channel tensor qubits",
324            observed: channel_axes,
325            limit: options.max_tensor_qubits,
326        });
327    }
328    let bindings = source_bindings(&source, &names, options)?;
329    if let Some(condition) = &options.discard_if {
330        validate_expr(condition, &names)?;
331    }
332    let preparation = preparation.map(|source| source.instantiate(&[]));
333    let discarded = bindings
334        .iter()
335        .enumerate()
336        .filter_map(|(bit, binding)| matches!(binding, BitBinding::Discarded).then_some(bit))
337        .collect::<Vec<_>>();
338    let source_count = checked_count(
339        discarded.len(),
340        options.max_classes,
341        "discarded source outcomes",
342    )?;
343    let expected = (0..count)
344        .map(|mask| {
345            let assignment = assignment(&names, mask);
346            let mut values = bindings
347                .iter()
348                .map(|binding| match binding {
349                    BitBinding::Fixed(value) => *value,
350                    BitBinding::Record(expr) => eval_expr(expr, &assignment),
351                    BitBinding::Discarded => false,
352                })
353                .collect::<Vec<_>>();
354            let maps = (0..source_count)
355                .map(|mask| {
356                    for (index, &bit) in discarded.iter().enumerate() {
357                        values[bit] = mask & (1 << index) != 0;
358                    }
359                    contract(source.instantiate(&values), options.max_tensor_qubits)
360                })
361                .collect::<Result<Vec<_>, _>>()?;
362            channel(&maps)
363        })
364        .collect::<Result<Vec<_>, _>>()?;
365    let problem = FeedbackProblem {
366        names: &names,
367        expected: &expected,
368        preparation: preparation.as_ref(),
369        options,
370    };
371    match problem.complete(graph, &verifier, Vec::new()) {
372        Err(
373            FeedbackInferenceError::NoOutputPauli { .. }
374            | FeedbackInferenceError::SupportMismatch { .. }
375            | FeedbackInferenceError::EmissionMismatch,
376        ) if options.max_trials != 0 => problem.search(graph),
377        result => result,
378    }
379}
380
381struct FeedbackProblem<'a> {
382    names: &'a [String],
383    expected: &'a [Vec<FScalar>],
384    preparation: Option<&'a QuizxGraph>,
385    options: &'a FeedbackOptions,
386}
387
388impl FeedbackProblem<'_> {
389    fn frames(
390        &self,
391        verifier: &LogicalVerifier,
392    ) -> Result<Vec<Vec<Pauli>>, FeedbackInferenceError> {
393        compare_branches(
394            verifier,
395            self.names,
396            self.expected,
397            self.preparation,
398            self.options,
399        )
400    }
401
402    fn complete(
403        &self,
404        graph: &BlockGraph,
405        verifier: &LogicalVerifier,
406        mut actions: Vec<Action>,
407    ) -> Result<Vec<Action>, FeedbackInferenceError> {
408        let frames = self.frames(verifier)?;
409        let output = frame_actions(self.names, verifier.boundaries().outputs(), &frames);
410        if output.is_empty() {
411            return Ok(actions);
412        }
413        let mut candidate = graph.clone();
414        let mut combined = candidate.actions();
415        combined.extend(output.iter().cloned());
416        candidate.set_actions(combined)?;
417        validate_records(&candidate, self.options.max_branches)?;
418        let remaining = self.frames(&make_verifier(&candidate, self.options)?)?;
419        if remaining.iter().flatten().any(|pauli| *pauli != Pauli::I) {
420            return Err(FeedbackInferenceError::EmissionMismatch);
421        }
422        actions.extend(output);
423        Ok(actions)
424    }
425
426    fn search(&self, graph: &BlockGraph) -> Result<Vec<Action>, FeedbackInferenceError> {
427        let graphs = if graph.branch_regions()?.is_empty() {
428            vec![graph.to_zx_graph()?]
429        } else {
430            graph
431                .branch_projections_up_to(self.options.max_branches)?
432                .iter()
433                .map(|projection| projection.graph().to_zx_graph())
434                .collect::<Result<Vec<_>, _>>()?
435        };
436        let zx = &graphs[0];
437        let is_wire = |position| {
438            graphs.iter().all(|zx| {
439                zx.feedback_edge(&FeedbackTarget {
440                    target: position,
441                    pauli: PauliBasis::X,
442                    direction: None,
443                })
444                .is_some()
445            })
446        };
447        let mut sites = match &self.options.internal_sites {
448            Some(sites) => {
449                for &site in sites {
450                    if !is_wire(site) {
451                        return Err(FeedbackInferenceError::Binding(format!(
452                            "{site} is not an unambiguous feedback wire in every structural arm"
453                        )));
454                    }
455                }
456                sites.clone()
457            }
458            None => zx
459                .nodes()
460                .iter()
461                .filter(|node| graph.get_block(node.pos).is_some() && is_wire(node.pos))
462                .map(|node| node.pos)
463                .collect(),
464        };
465        // Resource sign mistakes have a particularly small fix at their cap.
466        // Otherwise prefer late sites; the action DAG decides record causality.
467        sites.sort_by_key(|position| {
468            (
469                zx.node_at(*position)
470                    .expect("feedback sites are checked against this ZX graph")
471                    .kind
472                    != crate::NodeKind::Y,
473                std::cmp::Reverse(position.z),
474                position.to_array(),
475            )
476        });
477        sites.dedup();
478        let mut monomials = (0..self.expected.len()).collect::<Vec<_>>();
479        monomials.sort_by_key(|mask| (mask.count_ones(), *mask));
480        let atoms = monomials
481            .into_iter()
482            .flat_map(|mask| {
483                let condition = join(
484                    crate::BinaryOp::And,
485                    self.names
486                        .iter()
487                        .enumerate()
488                        .filter(|(bit, _)| mask & (1 << bit) != 0)
489                        .map(|(_, name)| Expr::Var(name.clone()))
490                        .collect(),
491                );
492                sites.iter().flat_map(move |&target| {
493                    [PauliBasis::Z, PauliBasis::X, PauliBasis::Y].map(|pauli| Action::Feedback {
494                        targets: vec![FeedbackTarget {
495                            pauli,
496                            target,
497                            direction: None,
498                        }],
499                        condition: condition.clone(),
500                    })
501                })
502            })
503            .take(self.options.max_trials.saturating_add(1))
504            .collect::<Vec<_>>();
505        let mut trials = 0;
506        // ponytail: bounded exhaustive action search; add Clifford-sign
507        // deduplication only if authoring examples exhaust this budget.
508        for weight in 1..=atoms.len() {
509            let mut indices = (0..weight).collect::<Vec<_>>();
510            loop {
511                trials += 1;
512                if trials > self.options.max_trials {
513                    return Err(FeedbackInferenceError::Limit {
514                        phase: "internal feedback trials",
515                        observed: trials,
516                        limit: self.options.max_trials,
517                    });
518                }
519                let additions = indices
520                    .iter()
521                    .map(|&index| atoms[index].clone())
522                    .collect::<Vec<_>>();
523                let mut candidate = graph.clone();
524                let mut actions = candidate.actions();
525                actions.extend(additions.iter().cloned());
526                let result = candidate
527                    .set_actions(actions)
528                    .map_err(FeedbackInferenceError::from)
529                    .and_then(|()| validate_records(&candidate, self.options.max_branches))
530                    .and_then(|()| make_verifier(&candidate, self.options))
531                    .and_then(|verifier| self.complete(&candidate, &verifier, additions));
532                match result {
533                    Ok(actions) => return Ok(actions),
534                    Err(
535                        error @ (FeedbackInferenceError::Limit { .. }
536                        | FeedbackInferenceError::NonFiniteTensor
537                        | FeedbackInferenceError::ArithmeticLimit),
538                    ) => return Err(error),
539                    Err(_) => {}
540                }
541                let Some(pivot) = (0..weight)
542                    .rev()
543                    .find(|&index| indices[index] < atoms.len() - weight + index)
544                else {
545                    break;
546                };
547                indices[pivot] += 1;
548                for index in pivot + 1..weight {
549                    indices[index] = indices[index - 1] + 1;
550                }
551            }
552        }
553        Err(FeedbackInferenceError::NoPauliFeedback { trials })
554    }
555}
556
557fn make_verifier(
558    graph: &BlockGraph,
559    options: &FeedbackOptions,
560) -> Result<LogicalVerifier, FeedbackInferenceError> {
561    Ok(match &options.boundaries {
562        Some(boundaries) => {
563            LogicalVerifier::with_boundaries_and_internal_measurements(graph, boundaries.clone())?
564        }
565        None => LogicalVerifier::with_internal_measurements(graph)?,
566    })
567}
568
569fn validate_records(graph: &BlockGraph, limit: usize) -> Result<(), FeedbackInferenceError> {
570    graph.validate()?;
571    if graph.branch_regions()?.is_empty() {
572        graph
573            .stabilizers()?
574            .validate_measurements_close_before_outputs_with_limit(limit)?;
575    } else {
576        let projections = graph.branch_projections_up_to(limit.saturating_add(1))?;
577        if projections.len() > limit {
578            return Err(FeedbackInferenceError::Limit {
579                phase: "structural branches",
580                observed: projections.len(),
581                limit,
582            });
583        }
584        for projection in projections {
585            projection
586                .graph()
587                .stabilizers()?
588                .validate_measurements_close_before_outputs_with_limit(limit)?;
589        }
590    }
591    Ok(())
592}
593
594enum BitBinding {
595    Fixed(bool),
596    Record(Expr),
597    Discarded,
598}
599
600fn source_bindings(
601    source: &QasmCircuit,
602    names: &[String],
603    options: &FeedbackOptions,
604) -> Result<Vec<BitBinding>, FeedbackInferenceError> {
605    let bits = source.program.bits();
606    for name in options
607        .measurements
608        .keys()
609        .chain(options.fixed_measurements.keys())
610        .chain(options.discarded_measurements.iter())
611    {
612        if !bits.contains(name) {
613            return Err(FeedbackInferenceError::Binding(format!(
614                "unknown QASM bit {name:?}"
615            )));
616        }
617        if usize::from(options.measurements.contains_key(name))
618            + usize::from(options.fixed_measurements.contains_key(name))
619            + usize::from(options.discarded_measurements.contains(name))
620            > 1
621        {
622            return Err(FeedbackInferenceError::Binding(format!(
623                "QASM bit {name:?} has conflicting bindings"
624            )));
625        }
626    }
627    bits.iter()
628        .enumerate()
629        .map(|(bit, name)| {
630            if options.discarded_measurements.contains(name) {
631                return Ok(BitBinding::Discarded);
632            }
633            if let Some(value) = options.fixed_measurements.get(name) {
634                return Ok(BitBinding::Fixed(*value));
635            }
636            if let Some(expr) = options.measurements.get(name) {
637                validate_expr(expr, names)?;
638                return Ok(BitBinding::Record(expr.clone()));
639            }
640            if !source.measurements.contains(&Some(bit)) {
641                return Ok(BitBinding::Fixed(false));
642            }
643            let matched = names
644                .iter()
645                .find(|candidate| *candidate == name)
646                .or_else(|| {
647                    let bare = name.strip_suffix("[0]")?;
648                    if bits.contains(&format!("{bare}[1]")) {
649                        return None;
650                    }
651                    names.iter().find(|candidate| candidate.as_str() == bare)
652                });
653            matched
654                .map(|name| BitBinding::Record(Expr::Var(name.clone())))
655                .ok_or_else(|| FeedbackInferenceError::Binding(format!(
656                    "QASM result {name:?} needs a graph binding, fixed outcome, or explicit discard"
657                )))
658        })
659        .collect()
660}
661
662fn validate_expr(expr: &Expr, names: &[String]) -> Result<(), FeedbackInferenceError> {
663    match expr {
664        Expr::Var(name) if !names.contains(name) => Err(FeedbackInferenceError::Binding(format!(
665            "unknown graph record {name:?}"
666        ))),
667        Expr::Var(_) => Ok(()),
668        Expr::Not(inner) => validate_expr(inner, names),
669        Expr::Binary(_, left, right) => {
670            validate_expr(left, names)?;
671            validate_expr(right, names)
672        }
673    }
674}
675
676fn assignment(names: &[String], mask: usize) -> BranchAssignment {
677    names
678        .iter()
679        .enumerate()
680        .map(|(bit, name)| (name.clone(), mask & (1 << bit) != 0))
681        .collect()
682}
683
684fn compare_branches(
685    verifier: &LogicalVerifier,
686    names: &[String],
687    expected: &[Vec<FScalar>],
688    preparation: Option<&QuizxGraph>,
689    options: &FeedbackOptions,
690) -> Result<Vec<Vec<Pauli>>, FeedbackInferenceError> {
691    let mut frames = Vec::with_capacity(expected.len());
692    let mut nonzero = false;
693    for (mask, expected) in expected.iter().enumerate() {
694        let assignment = assignment(names, mask);
695        let (branch, diagrams) = verifier.branch_family(&assignment, options.max_classes)?;
696        let rejected = options
697            .discard_if
698            .as_ref()
699            .is_some_and(|condition| eval_expr(condition, &assignment));
700        if rejected != (branch.status() == BranchStatus::Rejected) {
701            return Err(FeedbackInferenceError::RejectionMismatch { assignment });
702        }
703        let mut frame = vec![Pauli::I; verifier.boundaries().outputs().len()];
704        if rejected {
705            frames.push(frame);
706            continue;
707        }
708        let expected_nonzero = expected.iter().any(|value| !value.is_zero());
709        let mut maps = Vec::new();
710        for diagram in diagrams {
711            let prepared = match preparation {
712                Some(preparation) => {
713                    let mut prepared = preparation.clone();
714                    prepared.plug(&diagram);
715                    prepared
716                }
717                None => diagram,
718            };
719            let actual = contract(prepared, options.max_tensor_qubits)?;
720            if actual.iter().all(Zero::is_zero) {
721                continue;
722            }
723            if !expected_nonzero {
724                return Err(FeedbackInferenceError::SupportMismatch { assignment });
725            }
726            maps.push(actual);
727        }
728        if expected_nonzero == maps.is_empty() {
729            return Err(FeedbackInferenceError::SupportMismatch { assignment });
730        }
731        if !maps.is_empty() {
732            let actual = channel(&maps)?;
733            frame = output_pauli(&actual, expected, frame.len())?.ok_or_else(|| {
734                FeedbackInferenceError::NoOutputPauli {
735                    assignment: assignment.clone(),
736                }
737            })?;
738            nonzero = true;
739        }
740        frames.push(frame);
741    }
742    if !nonzero {
743        return Err(FeedbackInferenceError::EmptyDomain);
744    }
745    Ok(frames)
746}
747
748pub(super) fn checked_count(
749    bits: usize,
750    limit: usize,
751    phase: &'static str,
752) -> Result<usize, FeedbackInferenceError> {
753    let count = u32::try_from(bits)
754        .ok()
755        .and_then(|bits| 1usize.checked_shl(bits));
756    let observed = count.unwrap_or(usize::MAX);
757    if count.is_none() || observed > limit {
758        Err(FeedbackInferenceError::Limit {
759            phase,
760            observed,
761            limit,
762        })
763    } else {
764        Ok(observed)
765    }
766}
767
768fn contract(mut graph: QuizxGraph, limit: usize) -> Result<Vec<FScalar>, FeedbackInferenceError> {
769    let boundary = graph.inputs().len() + graph.outputs().len();
770    if boundary > limit || boundary >= usize::BITS as usize {
771        return Err(FeedbackInferenceError::Limit {
772            phase: "tensor qubits",
773            observed: boundary,
774            limit,
775        });
776    }
777    quizx::simplify::full_simp(&mut graph);
778    if graph.scalar().is_zero() {
779        return Ok(vec![FScalar::zero(); 1 << boundary]);
780    }
781    graph.x_to_z();
782    // Match QuiZX's contraction order and bound the actual intermediate axes,
783    // not just the small external tensor. No alternative contraction engine.
784    let order = graph
785        .inputs()
786        .iter()
787        .copied()
788        .chain(
789            graph
790                .vertices()
791                .filter(|&vertex| graph.vertex_type(vertex) != VType::B),
792        )
793        .chain(graph.outputs().iter().copied())
794        .collect::<Vec<_>>();
795    let mut seen = BTreeMap::new();
796    let mut live = BTreeSet::new();
797    for vertex in order.into_iter().rev() {
798        let width = live.len() + 1;
799        if width > limit {
800            return Err(FeedbackInferenceError::Limit {
801                phase: "intermediate tensor qubits",
802                observed: width,
803                limit,
804            });
805        }
806        live.insert(vertex);
807        let mut degree = 0;
808        for neighbor in graph.neighbors(vertex) {
809            if let Some(previous) = seen.get_mut(&neighbor) {
810                degree += 1;
811                *previous += 1;
812                if graph.vertex_type(neighbor) != VType::B && *previous == graph.degree(neighbor) {
813                    live.remove(&neighbor);
814                }
815            }
816        }
817        if graph.vertex_type(vertex) != VType::B && degree == graph.degree(vertex) {
818            live.remove(&vertex);
819        }
820        seen.insert(vertex, degree);
821    }
822    let values = graph.to_tensorf().iter().copied().collect::<Vec<_>>();
823    if values.iter().any(|value| {
824        let value = value.complex_value();
825        !value.re.is_finite() || !value.im.is_finite()
826    }) {
827        return Err(FeedbackInferenceError::NonFiniteTensor);
828    }
829    Ok(values)
830}
831
832fn channel(maps: &[Vec<FScalar>]) -> Result<Vec<FScalar>, FeedbackInferenceError> {
833    let width = maps[0].len();
834    let mut choi = vec![FScalar::zero(); width * width];
835    // Every feasible anonymous coordinate class has the same fiber size.
836    // Its common multiplicity cancels in this projective named-branch check.
837    for map in maps {
838        for (i, a) in map.iter().enumerate().filter(|(_, value)| !value.is_zero()) {
839            for (j, b) in map.iter().enumerate().filter(|(_, value)| !value.is_zero()) {
840                let product = *a * b.conj();
841                if exact_scalar(product)? != exact_product(*a, b.conj())? {
842                    return Err(FeedbackInferenceError::ArithmeticLimit);
843                }
844                let entry = &mut choi[i * width + j];
845                let sum = *entry + product;
846                if exact_scalar(sum)?
847                    != exact_terms(scalar_terms(*entry).chain(scalar_terms(product)))?
848                {
849                    return Err(FeedbackInferenceError::ArithmeticLimit);
850                }
851                *entry = sum;
852            }
853        }
854    }
855    Ok(choi)
856}
857
858/// Enumerate only X masks. Relative amplitude signs form a GF(2) solve for Z.
859fn output_pauli(
860    actual: &[FScalar],
861    expected: &[FScalar],
862    outputs: usize,
863) -> Result<Option<Vec<Pauli>>, FeedbackInferenceError> {
864    if actual.len() != expected.len() {
865        return Ok(None);
866    }
867    let Some(pivot) = expected.iter().position(|value| !value.is_zero()) else {
868        return Ok(None);
869    };
870    let dimension = 1usize << outputs;
871    let half = actual.len().ilog2() as usize / 2;
872    let feature = |index: usize| ((index >> half) ^ index) & (dimension - 1);
873    for x in 0..dimension {
874        let permutation = (x << half) | x;
875        let reference = actual[pivot ^ permutation];
876        if reference.is_zero() {
877            continue;
878        }
879        let mut signs = vec![None; dimension];
880        let mut valid = true;
881        for (index, target) in expected.iter().enumerate() {
882            let value = actual[index ^ permutation];
883            if value.is_zero() || target.is_zero() {
884                if value.is_zero() != target.is_zero() {
885                    valid = false;
886                    break;
887                }
888                continue;
889            }
890            let lhs = exact_product(*target, reference)?;
891            let rhs = exact_product(value, expected[pivot])?;
892            let flip = if lhs == rhs {
893                false
894            } else if lhs.0 == rhs.0
895                && lhs
896                    .1
897                    .iter()
898                    .zip(rhs.1)
899                    .all(|(a, b)| a.checked_add(b) == Some(0))
900            {
901                true
902            } else {
903                valid = false;
904                break;
905            };
906            let delta = feature(index) ^ feature(pivot);
907            if signs[delta]
908                .replace(flip)
909                .is_some_and(|previous| previous != flip)
910            {
911                valid = false;
912                break;
913            }
914        }
915        if !valid {
916            continue;
917        }
918        let equations = signs
919            .into_iter()
920            .enumerate()
921            .filter_map(|(delta, sign)| sign.map(|sign| (delta, sign)))
922            .collect::<Vec<_>>();
923        let mut rhs = CoeffVec::zeros(equations.len());
924        let mut columns = vec![CoeffVec::zeros(equations.len()); outputs];
925        for (row, &(delta, flip)) in equations.iter().enumerate() {
926            rhs.set_bit(row, flip);
927            for (output, column) in columns.iter_mut().enumerate() {
928                column.set_bit(row, delta & (1 << (outputs - 1 - output)) != 0);
929            }
930        }
931        if let Some(z) = solve_coeff_combination(&columns, &rhs) {
932            return Ok(Some(
933                (0..outputs)
934                    .map(
935                        |output| match (x & (1 << (outputs - 1 - output)) != 0, z.bit(output)) {
936                            (false, false) => Pauli::I,
937                            (true, false) => Pauli::X,
938                            (false, true) => Pauli::Z,
939                            (true, true) => Pauli::Y,
940                        },
941                    )
942                    .collect(),
943            ));
944        }
945    }
946    Ok(None)
947}
948
949fn scalar_terms(value: FScalar) -> impl Iterator<Item = (usize, i128, i32)> {
950    value
951        .exact_dyadic_form()
952        .into_iter()
953        .enumerate()
954        .filter(|(_, (coefficient, _))| *coefficient != 0)
955        .map(|(index, (coefficient, power))| (index, i128::from(coefficient), i32::from(power)))
956}
957
958fn exact_scalar(value: FScalar) -> Result<(i32, [i128; 4]), FeedbackInferenceError> {
959    let complex = value.complex_value();
960    if !complex.re.is_finite() || !complex.im.is_finite() {
961        return Err(FeedbackInferenceError::ArithmeticLimit);
962    }
963    exact_terms(scalar_terms(value))
964}
965
966fn exact_product(
967    left: FScalar,
968    right: FScalar,
969) -> Result<(i32, [i128; 4]), FeedbackInferenceError> {
970    exact_scalar(left)?;
971    exact_scalar(right)?;
972    exact_terms(scalar_terms(left).flat_map(|(i, a, power_a)| {
973        scalar_terms(right).map(move |(j, b, power_b)| {
974            // Each source mantissa fits in 53 bits, so this product fits i128.
975            (
976                (i + j) % 4,
977                a * b * if i + j >= 4 { -1 } else { 1 },
978                power_a + power_b,
979            )
980        })
981    }))
982}
983
984/// Exact arithmetic on the returned f64 dyadic coefficients, with a separate
985/// exponent. Reject wide coefficients instead of underflowing cross-products.
986/// This does not retroactively certify QuiZX's preceding simplification.
987fn exact_terms(
988    terms: impl Iterator<Item = (usize, i128, i32)>,
989) -> Result<(i32, [i128; 4]), FeedbackInferenceError> {
990    let terms = terms.collect::<Vec<_>>();
991    let Some(mut power) = terms.iter().map(|&(_, _, power)| power).min() else {
992        return Ok((0, [0; 4]));
993    };
994    let mut coefficients = [0_i128; 4];
995    for (index, coefficient, exponent) in terms {
996        let shift =
997            u32::try_from(exponent - power).map_err(|_| FeedbackInferenceError::ArithmeticLimit)?;
998        let factor = 1_i128
999            .checked_shl(shift)
1000            .filter(|factor| *factor > 0)
1001            .ok_or(FeedbackInferenceError::ArithmeticLimit)?;
1002        coefficients[index] = coefficient
1003            .checked_mul(factor)
1004            .and_then(|value| coefficients[index].checked_add(value))
1005            .ok_or(FeedbackInferenceError::ArithmeticLimit)?;
1006    }
1007    let Some(shift) = coefficients
1008        .iter()
1009        .filter(|coefficient| **coefficient != 0)
1010        .map(|coefficient| coefficient.trailing_zeros())
1011        .min()
1012    else {
1013        return Ok((0, [0; 4]));
1014    };
1015    power += shift as i32;
1016    for coefficient in &mut coefficients {
1017        *coefficient >>= shift;
1018    }
1019    Ok((power, coefficients))
1020}
1021
1022fn frame_actions(names: &[String], outputs: &[IVec3], frames: &[Vec<Pauli>]) -> Vec<Action> {
1023    let mut actions = Vec::new();
1024    for (output, &target) in outputs.iter().enumerate() {
1025        for (axis, pauli) in [(Pauli::X, PauliBasis::X), (Pauli::Z, PauliBasis::Z)] {
1026            let mut coefficients = frames
1027                .iter()
1028                .map(|frame| frame[output] & axis)
1029                .collect::<Vec<_>>();
1030            for bit in 0..names.len() {
1031                for mask in 0..coefficients.len() {
1032                    if mask & (1 << bit) != 0 {
1033                        coefficients[mask] ^= coefficients[mask ^ (1 << bit)];
1034                    }
1035                }
1036            }
1037            let constant = coefficients[0];
1038            let terms = coefficients
1039                .iter()
1040                .enumerate()
1041                .skip(1)
1042                .filter(|(_, bit)| **bit)
1043                .map(|(mask, _)| {
1044                    join(
1045                        crate::BinaryOp::And,
1046                        names
1047                            .iter()
1048                            .enumerate()
1049                            .filter(|(bit, _)| mask & (1 << bit) != 0)
1050                            .map(|(_, name)| Expr::Var(name.clone()))
1051                            .collect(),
1052                    )
1053                    .expect("nonconstant monomial has variables")
1054                })
1055                .collect();
1056            let expression = join(crate::BinaryOp::Xor, terms);
1057            let condition = match (constant, expression) {
1058                (false, None) => continue,
1059                (true, None) => None,
1060                (false, Some(expr)) => Some(expr),
1061                (true, Some(expr)) => Some(Expr::Not(Box::new(expr))),
1062            };
1063            actions.push(Action::Feedback {
1064                targets: vec![FeedbackTarget {
1065                    pauli,
1066                    target,
1067                    direction: None,
1068                }],
1069                condition,
1070            });
1071        }
1072    }
1073    actions
1074}
1075
1076fn join(op: crate::BinaryOp, mut expressions: Vec<Expr>) -> Option<Expr> {
1077    // Balanced trees avoid a stack proportional to the truth-table length.
1078    while expressions.len() > 1 {
1079        let mut iter = expressions.into_iter();
1080        expressions = std::iter::from_fn(|| {
1081            let left = iter.next()?;
1082            Some(match iter.next() {
1083                Some(right) => Expr::Binary(op, Box::new(left), Box::new(right)),
1084                None => left,
1085            })
1086        })
1087        .collect();
1088    }
1089    expressions.pop()
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095    use quizx::circuit::Circuit;
1096    use quizx::fscalar::One;
1097
1098    #[test]
1099    fn dyadic_scaling_never_turns_distinct_channels_into_a_match() {
1100        let zero = FScalar::zero();
1101        for exponent in [-600, 0, 600] {
1102            let epsilon = FScalar::dyadic(exponent, [1, 0, 0, 0]);
1103            let actual = [epsilon, zero, zero, epsilon * FScalar::real(3.0)];
1104            let different = [epsilon, zero, zero, epsilon * FScalar::real(2.0)];
1105            assert_eq!(output_pauli(&actual, &different, 1).unwrap(), None);
1106            assert_eq!(
1107                output_pauli(&actual, &actual, 1).unwrap(),
1108                Some(vec![Pauli::I])
1109            );
1110        }
1111        let tiny = FScalar::dyadic(-600, [1, 0, 0, 0]);
1112        assert!(matches!(
1113            channel(&[vec![tiny, tiny]]),
1114            Err(FeedbackInferenceError::ArithmeticLimit)
1115        ));
1116        let wide = FScalar::dyadic(500, [1, 0, 0, 0]) + FScalar::dyadic(-500, [0, 1, 0, 0]);
1117        assert!(matches!(
1118            exact_product(wide, wide),
1119            Err(FeedbackInferenceError::ArithmeticLimit)
1120        ));
1121    }
1122
1123    #[test]
1124    fn output_matching_checks_inputs_and_solves_entangled_paulis() {
1125        for outputs in 1..=3 {
1126            let mut circuit = Circuit::new(outputs);
1127            circuit.add_gate("h", vec![0]);
1128            if outputs > 1 {
1129                circuit.add_gate("cx", vec![0, 1]);
1130            }
1131            circuit.add_gate("t", vec![outputs - 1]);
1132            let actual = circuit.to_tensorf().iter().copied().collect::<Vec<_>>();
1133            for mask in 0..1usize << (2 * outputs) {
1134                let mut corrected = circuit.clone();
1135                let mut expected_paulis = Vec::new();
1136                for output in 0..outputs {
1137                    let x = mask & (1 << (2 * output)) != 0;
1138                    let z = mask & (1 << (2 * output + 1)) != 0;
1139                    if x {
1140                        corrected.add_gate("x", vec![output]);
1141                    }
1142                    if z {
1143                        corrected.add_gate("z", vec![output]);
1144                    }
1145                    expected_paulis.push(match (x, z) {
1146                        (false, false) => Pauli::I,
1147                        (true, false) => Pauli::X,
1148                        (false, true) => Pauli::Z,
1149                        (true, true) => Pauli::Y,
1150                    });
1151                }
1152                let expected = corrected.to_tensorf().iter().copied().collect::<Vec<_>>();
1153                assert_eq!(
1154                    output_pauli(
1155                        &channel(std::slice::from_ref(&actual)).unwrap(),
1156                        &channel(&[expected]).unwrap(),
1157                        outputs,
1158                    )
1159                    .unwrap(),
1160                    Some(expected_paulis)
1161                );
1162            }
1163        }
1164        let one = FScalar::one();
1165        let zero = FScalar::zero();
1166        for (actual, expected, outputs, paulis) in [
1167            (
1168                vec![one, zero, zero, zero],
1169                vec![zero, zero, zero, one],
1170                1,
1171                None,
1172            ),
1173            (vec![one, zero], vec![zero, one], 1, Some(vec![Pauli::X])),
1174            (vec![one, one], vec![one, zero - one], 0, None),
1175        ] {
1176            assert_eq!(
1177                output_pauli(
1178                    &channel(&[actual]).unwrap(),
1179                    &channel(&[expected]).unwrap(),
1180                    outputs,
1181                )
1182                .unwrap(),
1183                paulis
1184            );
1185        }
1186    }
1187}