Skip to main content

prism_q/qec/
mod.rs

1//! Native measurement-record QEC program IR, parser, and runners.
2//!
3//! Models QEC workloads that need measurement records, detectors, observables,
4//! postselection, expectation metadata, and Pauli-noise annotations. The IR is
5//! separate from `Circuit` so measurement records do not have to fit
6//! final-measurement OpenQASM semantics.
7//!
8//! # Public surface
9//!
10//! - [`QecProgram`] is the IR. Construct via [`QecProgram::new`] /
11//!   [`QecProgram::with_options`] and the typed `push_*` methods, or load
12//!   from text via [`parse_qec_program`] / [`QecProgram::from_text`].
13//! - [`run_qec_program`] is the scalable Clifford execution path. Lowers
14//!   programs into the packed compiled sampler and supports Pauli noise by
15//!   XORing sensitivity rows onto packed measurement records.
16//! - [`run_qec_program_reference`] is the correctness oracle. One state-vector
17//!   simulation per shot. Use it for small semantic cross-checks, not bulk
18//!   sampling.
19//! - [`run_qec_program_with_strategy`] dispatches non-Clifford observable
20//!   programs through exact light-cone SPD, CAMPS, then a private exact
21//!   tensor-network scalar fallback.
22//! - [`compile_qec_program_rows`] lowers basis measurements and `MPP` records
23//!   into the packed X/Z Pauli row representation used by sampler internals.
24//!   It does not execute gates, resets, or active noise.
25//! - [`QecProgram::detector_error_model`] derives the [`DetectorErrorModel`]
26//!   implied by the program's noise annotations, detectors, and observables,
27//!   for export to matching and belief-propagation decoders.
28//! - [`UnionFindDecoder`] decodes packed detector samples against a graphlike
29//!   detector error model, predicting observable flips per shot.
30//!
31//! [`QecSampleResult`] carries packed measurement, detector, and observable
32//! shots, plus accepted and discarded shot counts after postselection and
33//! per-observable logical-error counts.
34
35mod camps_prefix;
36/// Treewidth-aware cut-selection heuristics for the QEC T-strategy ladder.
37///
38/// Not yet wired into the production dispatcher (which follows a fixed
39/// SPD -> CAMPS -> tensor-network ladder); exposed only under the
40/// `bench-internal` feature so the heuristics can be benchmarked without
41/// committing them to the stable public API.
42#[cfg(feature = "bench-internal")]
43pub mod cut_selection;
44mod decoder;
45mod dem;
46mod noise;
47pub mod observable_reroute;
48mod parse;
49mod result;
50mod runner;
51mod t_sampler;
52
53pub use decoder::UnionFindDecoder;
54pub use dem::{DetectorErrorModel, ErrorMechanism};
55pub use parse::parse_qec_program;
56pub use result::{QecObservableEstimate, QecSampleResult};
57#[cfg(feature = "bench-internal")]
58pub use runner::{QecProfiledCounts, QecProfiledSampler, compile_qec_profiled_sampler};
59pub use runner::{run_qec_program, run_qec_program_reference};
60pub use t_sampler::{
61    QecObservableReroute, QecTStrategy, run_qec_program_spd_rerouted, run_qec_program_with_strategy,
62};
63
64use crate::circuit::{
65    Circuit, append_axis_to_z_rotation, append_parity_rotations, append_z_to_axis_rotation,
66};
67use crate::error::{PrismError, Result};
68use crate::gates::Gate;
69use crate::sim::compiled::{PackedShots, PauliVec, get_bit, set_bit};
70use crate::sim::unified_pauli::{PauliAxis, PauliTerm};
71
72/// Pauli basis used by QEC measurements and Pauli products.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum QecBasis {
75    X,
76    Y,
77    Z,
78}
79
80impl From<QecBasis> for PauliAxis {
81    fn from(basis: QecBasis) -> Self {
82        match basis {
83            QecBasis::X => PauliAxis::X,
84            QecBasis::Y => PauliAxis::Y,
85            QecBasis::Z => PauliAxis::Z,
86        }
87    }
88}
89
90/// One Pauli term in an MPP-style measurement.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub struct QecPauli {
93    pub basis: QecBasis,
94    pub qubit: usize,
95}
96
97impl QecPauli {
98    pub fn new(basis: QecBasis, qubit: usize) -> Self {
99        Self { basis, qubit }
100    }
101
102    pub fn x(qubit: usize) -> Self {
103        Self::new(QecBasis::X, qubit)
104    }
105
106    pub fn y(qubit: usize) -> Self {
107        Self::new(QecBasis::Y, qubit)
108    }
109
110    pub fn z(qubit: usize) -> Self {
111        Self::new(QecBasis::Z, qubit)
112    }
113}
114
115/// Reference to a previous measurement record.
116///
117/// Lookbacks are resolved against the count of measurement records that exist
118/// at the moment the referencing operation is appended (or, for queries like
119/// [`QecProgram::detector_rows`], at the moment that operation is reached
120/// during the walk). `Lookback(1)` is the most recent measurement.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122pub enum QecRecordRef {
123    Absolute(usize),
124    Lookback(usize),
125}
126
127impl QecRecordRef {
128    pub fn absolute(index: usize) -> Self {
129        Self::Absolute(index)
130    }
131
132    pub fn lookback(distance: usize) -> Result<Self> {
133        if distance == 0 {
134            return Err(PrismError::InvalidParameter {
135                message: "measurement lookback distance must be at least 1".to_string(),
136            });
137        }
138        Ok(Self::Lookback(distance))
139    }
140
141    fn resolve(self, next_measurement: usize) -> Result<usize> {
142        match self {
143            Self::Absolute(index) if index < next_measurement => Ok(index),
144            Self::Absolute(index) => Err(PrismError::InvalidParameter {
145                message: format!(
146                    "measurement record {index} out of bounds for {next_measurement} existing records"
147                ),
148            }),
149            Self::Lookback(distance) if distance > 0 && distance <= next_measurement => {
150                Ok(next_measurement - distance)
151            }
152            Self::Lookback(distance) => Err(PrismError::InvalidParameter {
153                message: format!(
154                    "measurement lookback {distance} out of bounds for {next_measurement} existing records"
155                ),
156            }),
157        }
158    }
159}
160
161/// Pauli-noise annotation for native QEC programs.
162///
163/// Probabilities are validated when the annotation is appended to a
164/// [`QecProgram`]. Probability zero is treated as an inactive annotation by
165/// runner APIs.
166#[derive(Debug, Clone, Copy, PartialEq)]
167pub enum QecNoise {
168    /// With probability `p`, apply X to each target.
169    XError(f64),
170    /// With probability `p`, apply Z to each target.
171    ZError(f64),
172    /// For each target, with total probability `p`, apply a uniformly random
173    /// non-identity single-qubit Pauli. Each of X, Y, Z fires with probability
174    /// `p / 3`.
175    Depolarize1(f64),
176    /// For each target pair, with total probability `p`, apply a uniformly
177    /// random non-identity two-qubit Pauli. Each of the 15 non-identity
178    /// two-qubit Paulis fires with probability `p / 15`. The target list is
179    /// consumed in pairs and must have even length.
180    Depolarize2(f64),
181}
182
183impl QecNoise {
184    pub fn probability(self) -> f64 {
185        match self {
186            Self::XError(p) | Self::ZError(p) | Self::Depolarize1(p) | Self::Depolarize2(p) => p,
187        }
188    }
189
190    /// Native text instruction name for this channel.
191    pub fn name(self) -> &'static str {
192        match self {
193            Self::XError(_) => "X_ERROR",
194            Self::ZError(_) => "Z_ERROR",
195            Self::Depolarize1(_) => "DEPOLARIZE1",
196            Self::Depolarize2(_) => "DEPOLARIZE2",
197        }
198    }
199}
200
201/// One operation in a native QEC program.
202#[derive(Debug, Clone, PartialEq)]
203pub enum QecOp {
204    /// Standard PRISM-Q gate operation. The compiled runner requires Clifford
205    /// gates; the reference runner accepts any gate the statevector backend
206    /// supports.
207    Gate { gate: Gate, targets: Vec<usize> },
208    /// Single-qubit measurement in the requested basis. Produces one
209    /// measurement record.
210    Measure { basis: QecBasis, qubit: usize },
211    /// Pauli-product (`MPP`) measurement. Produces one measurement record
212    /// equal to the parity of the listed Pauli terms.
213    MeasurePauliProduct { terms: Vec<QecPauli> },
214    /// Reset a qubit to the +1 eigenstate of the requested basis.
215    Reset { basis: QecBasis, qubit: usize },
216    /// Detector: parity over the listed measurement records. `coords` is
217    /// arbitrary passthrough metadata for visualization and downstream
218    /// decoders; it does not affect sampling.
219    Detector {
220        records: Vec<QecRecordRef>,
221        coords: Vec<f64>,
222    },
223    /// Logical observable parity contribution. Multiple includes for the same
224    /// `observable` index XOR into a single observable row.
225    ObservableInclude {
226        observable: usize,
227        records: Vec<QecRecordRef>,
228    },
229    /// Final-state expectation-value estimator: `coefficient * <P>` where
230    /// `P` is the Pauli product over `terms`, evaluated in the program's
231    /// final state. Must be terminal (no gate, measurement, reset, or
232    /// active noise may follow) and may only reference live qubits (not
233    /// single-qubit-measured since their last reset). Estimates are
234    /// returned in [`QecSampleResult::expectation_values`], one per op in
235    /// op order.
236    ExpectationValue {
237        terms: Vec<QecPauli>,
238        coefficient: f64,
239    },
240    /// Postselection predicate. The shot is accepted only when the parity over
241    /// `records` matches `expected`.
242    Postselect {
243        records: Vec<QecRecordRef>,
244        expected: bool,
245    },
246    /// Feed-forward: `body` executes iff the parity over `records` equals
247    /// `expected`.
248    ///
249    /// The predicate has the shape a detector has, because that is what
250    /// adaptive correction reads. `body` admits gates and resets only: the
251    /// record space is a static address space that detectors and observables
252    /// index, so a measurement whose execution depends on a record would make
253    /// those indices depend on the shot.
254    Feedforward {
255        records: Vec<QecRecordRef>,
256        expected: bool,
257        body: Vec<QecOp>,
258    },
259    /// Pauli-noise annotation applied at this point in the program. Zero
260    /// probability is treated as inactive.
261    Noise {
262        channel: QecNoise,
263        targets: Vec<usize>,
264    },
265    /// Scheduling separator. No semantic effect; carried forward for parity
266    /// with native QEC text formats.
267    Tick,
268}
269
270/// Packed Pauli row for one QEC measurement record.
271///
272/// The row names the Hermitian operator measured, with `Y` carried as both the
273/// `x` and `z` bit of its qubit. A consumer that rebuilds the operator as a
274/// per-qubit product of `X` and `Z` recovers `(-i)^k` times it, for `k` the
275/// number of `Y` letters, since `XZ = -iY`. The row carries no sign of its own;
276/// the measured eigenvalue comes from the state.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct QecMeasurementRow {
279    num_qubits: usize,
280    pauli: PauliVec,
281    weight: usize,
282}
283
284impl QecMeasurementRow {
285    /// Create a row from Pauli-product terms.
286    pub fn from_terms(num_qubits: usize, terms: &[QecPauli]) -> Result<Self> {
287        if terms.is_empty() {
288            return Err(PrismError::InvalidParameter {
289                message: "QEC measurement row requires at least one Pauli term".to_string(),
290            });
291        }
292        validate_pauli_terms(terms, num_qubits)?;
293
294        let row_words = num_qubits.div_ceil(64);
295        let mut pauli = PauliVec::new(row_words);
296
297        for term in terms {
298            match term.basis {
299                QecBasis::X => set_bit(&mut pauli.x, term.qubit, true),
300                QecBasis::Y => {
301                    set_bit(&mut pauli.x, term.qubit, true);
302                    set_bit(&mut pauli.z, term.qubit, true);
303                }
304                QecBasis::Z => set_bit(&mut pauli.z, term.qubit, true),
305            }
306        }
307
308        Ok(Self {
309            num_qubits,
310            pauli,
311            weight: terms.len(),
312        })
313    }
314
315    /// Create a single-qubit measurement row.
316    pub fn single(num_qubits: usize, basis: QecBasis, qubit: usize) -> Result<Self> {
317        Self::from_terms(num_qubits, &[QecPauli::new(basis, qubit)])
318    }
319
320    /// Number of qubits covered by this row.
321    pub fn num_qubits(&self) -> usize {
322        self.num_qubits
323    }
324
325    /// Number of non-identity Pauli terms.
326    pub fn weight(&self) -> usize {
327        self.weight
328    }
329
330    /// Packed X mask.
331    pub fn x_mask(&self) -> &[u64] {
332        &self.pauli.x
333    }
334
335    /// Packed Z mask.
336    pub fn z_mask(&self) -> &[u64] {
337        &self.pauli.z
338    }
339
340    /// Pauli term on one qubit, or `None` for identity (or when `qubit` is
341    /// outside this row's qubit range).
342    pub fn pauli_at(&self, qubit: usize) -> Option<QecBasis> {
343        if qubit >= self.num_qubits {
344            return None;
345        }
346        match (get_bit(&self.pauli.x, qubit), get_bit(&self.pauli.z, qubit)) {
347            (true, false) => Some(QecBasis::X),
348            (true, true) => Some(QecBasis::Y),
349            (false, true) => Some(QecBasis::Z),
350            (false, false) => None,
351        }
352    }
353
354    /// Return non-identity Pauli terms in ascending qubit order.
355    pub fn terms(&self) -> Vec<QecPauli> {
356        let mut terms = Vec::with_capacity(self.weight);
357        for qubit in 0..self.num_qubits {
358            if let Some(basis) = self.pauli_at(qubit) {
359                terms.push(QecPauli::new(basis, qubit));
360            }
361        }
362        terms
363    }
364}
365
366/// Compiled QEC record rows ready for sampler lowering.
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct QecCompiledRows {
369    num_qubits: usize,
370    measurement_rows: Vec<QecMeasurementRow>,
371    detector_rows: Vec<Vec<usize>>,
372    observable_rows: Vec<Vec<usize>>,
373    postselection_rows: Vec<Vec<usize>>,
374    postselection_expected: Vec<bool>,
375}
376
377impl QecCompiledRows {
378    pub fn num_qubits(&self) -> usize {
379        self.num_qubits
380    }
381
382    /// Measurement rows in record order.
383    pub fn measurement_rows(&self) -> &[QecMeasurementRow] {
384        &self.measurement_rows
385    }
386
387    /// Detector parity rows over measurement records.
388    pub fn detector_rows(&self) -> &[Vec<usize>] {
389        &self.detector_rows
390    }
391
392    /// Observable parity rows over measurement records.
393    pub fn observable_rows(&self) -> &[Vec<usize>] {
394        &self.observable_rows
395    }
396
397    pub fn postselection_rows(&self) -> &[Vec<usize>] {
398        &self.postselection_rows
399    }
400
401    /// Expected parity for each postselection row.
402    pub fn postselection_expected(&self) -> &[bool] {
403        &self.postselection_expected
404    }
405
406    /// Postselection parity rows paired with expected values.
407    pub fn postselection_predicates(&self) -> impl ExactSizeIterator<Item = (&[usize], bool)> + '_ {
408        self.postselection_rows
409            .iter()
410            .map(Vec::as_slice)
411            .zip(self.postselection_expected.iter().copied())
412    }
413
414    pub fn num_measurements(&self) -> usize {
415        self.measurement_rows.len()
416    }
417
418    pub fn num_detectors(&self) -> usize {
419        self.detector_rows.len()
420    }
421
422    pub fn num_observables(&self) -> usize {
423        self.observable_rows.len()
424    }
425
426    pub fn num_postselections(&self) -> usize {
427        self.postselection_rows.len()
428    }
429
430    /// Packed words per X or Z mask.
431    pub fn packed_row_words(&self) -> usize {
432        self.num_qubits.div_ceil(64)
433    }
434
435    /// Packed measurement row storage in bytes.
436    pub fn measurement_mask_bytes(&self) -> usize {
437        self.measurement_rows
438            .len()
439            .saturating_mul(self.packed_row_words())
440            .saturating_mul(2)
441            .saturating_mul(std::mem::size_of::<u64>())
442    }
443
444    pub fn detector_parities(&self, measurements: &PackedShots) -> Result<PackedShots> {
445        measurements.parity_rows(&self.detector_rows)
446    }
447
448    pub fn observable_parities(&self, measurements: &PackedShots) -> Result<PackedShots> {
449        measurements.parity_rows(&self.observable_rows)
450    }
451
452    pub fn postselection_parities(&self, measurements: &PackedShots) -> Result<PackedShots> {
453        measurements.parity_rows(&self.postselection_rows)
454    }
455}
456
457/// Options for running a native QEC program.
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub struct QecOptions {
460    pub shots: usize,
461    /// RNG seed used by stochastic samplers and Pauli-noise dispatch.
462    pub seed: u64,
463    /// Optional chunk size for the compiled runner. When `Some(n)`, sampling
464    /// proceeds in batches of at most `n` shots and intermediate measurement
465    /// matrices are not held in memory together. `None` is equivalent to
466    /// `Some(shots)`. `Some(0)` is rejected. Has no effect on the reference
467    /// runner.
468    pub chunk_size: Option<usize>,
469    /// When `false`, [`QecSampleResult::measurements`] is returned with zero
470    /// shots (only the column count is preserved). Detector and observable
471    /// records are always populated. Set to `false` to avoid materializing
472    /// large measurement-record matrices when only detectors and observables
473    /// are needed.
474    pub keep_measurements: bool,
475}
476
477impl Default for QecOptions {
478    fn default() -> Self {
479        Self {
480            shots: 1024,
481            seed: 42,
482            chunk_size: None,
483            keep_measurements: true,
484        }
485    }
486}
487
488/// Native QEC program expressed as measurement-record operations.
489#[derive(Debug, Clone, PartialEq)]
490pub struct QecProgram {
491    num_qubits: usize,
492    ops: Vec<QecOp>,
493    options: QecOptions,
494}
495
496impl QecProgram {
497    pub fn new(num_qubits: usize) -> Self {
498        Self::with_options(num_qubits, QecOptions::default())
499    }
500
501    pub fn with_options(num_qubits: usize, options: QecOptions) -> Self {
502        Self {
503            num_qubits,
504            ops: Vec::new(),
505            options,
506        }
507    }
508
509    /// Create a program from operations, validating record references as
510    /// operations are appended.
511    pub fn from_ops(num_qubits: usize, options: QecOptions, ops: Vec<QecOp>) -> Result<Self> {
512        let mut program = Self::with_options(num_qubits, options);
513        let mut next_measurement = 0usize;
514        for op in ops {
515            program.validate_op(&op, next_measurement)?;
516            if matches!(
517                op,
518                QecOp::Measure { .. } | QecOp::MeasurePauliProduct { .. }
519            ) {
520                next_measurement += 1;
521            }
522            program.ops.push(op);
523        }
524        Ok(program)
525    }
526
527    /// Parse a native measurement-record QEC program.
528    pub fn from_text(input: &str) -> Result<Self> {
529        parse_qec_program(input)
530    }
531
532    pub fn num_qubits(&self) -> usize {
533        self.num_qubits
534    }
535
536    pub fn options(&self) -> QecOptions {
537        self.options
538    }
539
540    pub fn set_options(&mut self, options: QecOptions) {
541        self.options = options;
542    }
543
544    pub fn ops(&self) -> &[QecOp] {
545        &self.ops
546    }
547
548    /// Number of measurement records produced by the operation stream.
549    pub fn num_measurements(&self) -> usize {
550        self.ops
551            .iter()
552            .filter(|op| {
553                matches!(
554                    op,
555                    QecOp::Measure { .. } | QecOp::MeasurePauliProduct { .. }
556                )
557            })
558            .count()
559    }
560
561    pub fn num_detectors(&self) -> usize {
562        self.ops
563            .iter()
564            .filter(|op| matches!(op, QecOp::Detector { .. }))
565            .count()
566    }
567
568    /// Observable slot count, `max included index + 1`.
569    pub fn num_observables(&self) -> usize {
570        self.ops
571            .iter()
572            .filter_map(|op| match op {
573                QecOp::ObservableInclude { observable, .. } => Some(*observable),
574                _ => None,
575            })
576            .max()
577            .map_or(0, |max_idx| max_idx + 1)
578    }
579
580    /// Number of `EXP_VAL` ops.
581    pub fn num_expectation_values(&self) -> usize {
582        self.ops
583            .iter()
584            .filter(|op| matches!(op, QecOp::ExpectationValue { .. }))
585            .count()
586    }
587
588    /// The `EXP_VAL` ops in op order as `(terms, coefficient)`.
589    pub fn expectation_value_ops(&self) -> Vec<(&[QecPauli], f64)> {
590        self.ops
591            .iter()
592            .filter_map(|op| match op {
593                QecOp::ExpectationValue { terms, coefficient } => {
594                    Some((terms.as_slice(), *coefficient))
595                }
596                _ => None,
597            })
598            .collect()
599    }
600
601    pub fn push_op(&mut self, op: QecOp) -> Result<()> {
602        self.validate_op(&op, self.num_measurements())?;
603        self.ops.push(op);
604        Ok(())
605    }
606
607    pub fn push_gate(&mut self, gate: Gate, targets: &[usize]) -> Result<()> {
608        self.push_op(QecOp::Gate {
609            gate,
610            targets: targets.to_vec(),
611        })
612    }
613
614    pub fn reset(&mut self, basis: QecBasis, qubit: usize) -> Result<()> {
615        self.push_op(QecOp::Reset { basis, qubit })
616    }
617
618    /// Append a single-qubit measurement and return its record index.
619    pub fn measure(&mut self, basis: QecBasis, qubit: usize) -> Result<usize> {
620        let record = self.num_measurements();
621        self.push_op(QecOp::Measure { basis, qubit })?;
622        Ok(record)
623    }
624
625    /// Append a Z-basis measurement and return its record index.
626    pub fn measure_z(&mut self, qubit: usize) -> Result<usize> {
627        self.measure(QecBasis::Z, qubit)
628    }
629
630    /// Append an X-basis measurement and return its record index.
631    pub fn measure_x(&mut self, qubit: usize) -> Result<usize> {
632        self.measure(QecBasis::X, qubit)
633    }
634
635    /// Append a Pauli-product measurement and return its record index.
636    pub fn measure_pauli_product(&mut self, terms: &[QecPauli]) -> Result<usize> {
637        let record = self.num_measurements();
638        self.push_op(QecOp::MeasurePauliProduct {
639            terms: terms.to_vec(),
640        })?;
641        Ok(record)
642    }
643
644    /// Append a detector and return its detector index.
645    pub fn detector(&mut self, records: &[QecRecordRef]) -> Result<usize> {
646        self.detector_with_coords(records, &[])
647    }
648
649    /// Append a detector with coordinates and return its detector index.
650    pub fn detector_with_coords(
651        &mut self,
652        records: &[QecRecordRef],
653        coords: &[f64],
654    ) -> Result<usize> {
655        let detector = self.num_detectors();
656        self.push_op(QecOp::Detector {
657            records: records.to_vec(),
658            coords: coords.to_vec(),
659        })?;
660        Ok(detector)
661    }
662
663    pub fn observable_include(
664        &mut self,
665        observable: usize,
666        records: &[QecRecordRef],
667    ) -> Result<()> {
668        self.push_op(QecOp::ObservableInclude {
669            observable,
670            records: records.to_vec(),
671        })
672    }
673
674    pub fn expectation_value(&mut self, terms: &[QecPauli], coefficient: f64) -> Result<()> {
675        self.push_op(QecOp::ExpectationValue {
676            terms: terms.to_vec(),
677            coefficient,
678        })
679    }
680
681    pub fn postselect(&mut self, records: &[QecRecordRef], expected: bool) -> Result<()> {
682        self.push_op(QecOp::Postselect {
683            records: records.to_vec(),
684            expected,
685        })
686    }
687
688    pub fn noise(&mut self, channel: QecNoise, targets: &[usize]) -> Result<()> {
689        self.push_op(QecOp::Noise {
690            channel,
691            targets: targets.to_vec(),
692        })
693    }
694
695    /// Append a feed-forward correction: `body` runs iff the parity over
696    /// `records` equals `expected`.
697    ///
698    /// See [`QecOp::Feedforward`] for what the body admits.
699    pub fn feedforward(
700        &mut self,
701        records: &[QecRecordRef],
702        expected: bool,
703        body: Vec<QecOp>,
704    ) -> Result<()> {
705        self.push_op(QecOp::Feedforward {
706            records: records.to_vec(),
707            expected,
708            body,
709        })
710    }
711
712    /// Walk the ops with a running count of measurement records emitted so
713    /// far. Record-referencing ops resolve relative offsets against that
714    /// count; the row-resolver methods below share this walk.
715    fn visit_ops_with_measurement_count(
716        &self,
717        mut visit: impl FnMut(&QecOp, usize) -> Result<()>,
718    ) -> Result<()> {
719        let mut next_measurement = 0;
720        for op in &self.ops {
721            if matches!(
722                op,
723                QecOp::Measure { .. } | QecOp::MeasurePauliProduct { .. }
724            ) {
725                next_measurement += 1;
726                continue;
727            }
728            visit(op, next_measurement)?;
729        }
730        Ok(())
731    }
732
733    /// Resolve detector rows to absolute measurement record indices.
734    pub fn detector_rows(&self) -> Result<Vec<Vec<usize>>> {
735        let mut rows = Vec::new();
736        self.visit_ops_with_measurement_count(|op, next_measurement| {
737            if let QecOp::Detector { records, .. } = op {
738                rows.push(resolve_records(records, next_measurement)?);
739            }
740            Ok(())
741        })?;
742        Ok(rows)
743    }
744
745    /// Resolve observable rows to absolute measurement record indices.
746    pub fn observable_rows(&self) -> Result<Vec<Vec<usize>>> {
747        let mut rows: Vec<Vec<usize>> = Vec::new();
748        self.visit_ops_with_measurement_count(|op, next_measurement| {
749            if let QecOp::ObservableInclude {
750                observable,
751                records,
752            } = op
753            {
754                if rows.len() <= *observable {
755                    rows.resize_with(*observable + 1, Vec::new);
756                }
757                rows[*observable].extend(resolve_records(records, next_measurement)?);
758            }
759            Ok(())
760        })?;
761        Ok(rows)
762    }
763
764    /// Resolve postselection rows to absolute measurement record indices.
765    pub fn postselection_rows(&self) -> Result<Vec<(Vec<usize>, bool)>> {
766        let mut rows = Vec::new();
767        self.visit_ops_with_measurement_count(|op, next_measurement| {
768            if let QecOp::Postselect { records, expected } = op {
769                rows.push((resolve_records(records, next_measurement)?, *expected));
770            }
771            Ok(())
772        })?;
773        Ok(rows)
774    }
775
776    /// Create an empty result with the program's current record shape.
777    pub fn empty_result(&self) -> QecSampleResult {
778        QecSampleResult::empty(
779            self.num_measurements(),
780            self.num_detectors(),
781            self.num_observables(),
782        )
783    }
784
785    fn validate_op(&self, op: &QecOp, next_measurement: usize) -> Result<()> {
786        match op {
787            QecOp::Gate { gate, targets } => {
788                if gate.num_qubits() != targets.len() {
789                    return Err(PrismError::GateArity {
790                        gate: gate.name().to_string(),
791                        expected: gate.num_qubits(),
792                        got: targets.len(),
793                    });
794                }
795                validate_qubits(targets.iter().copied(), self.num_qubits)?;
796            }
797            QecOp::Measure { qubit, .. } | QecOp::Reset { qubit, .. } => {
798                validate_qubit(*qubit, self.num_qubits)?;
799            }
800            QecOp::MeasurePauliProduct { terms } => {
801                if terms.is_empty() {
802                    return Err(PrismError::InvalidParameter {
803                        message: "Pauli-product measurement requires at least one term".to_string(),
804                    });
805                }
806                validate_pauli_terms(terms, self.num_qubits)?;
807            }
808            QecOp::Detector { records, coords } => {
809                resolve_records(records, next_measurement)?;
810                validate_finite_values(coords, "detector coordinate")?;
811            }
812            QecOp::ObservableInclude { records, .. } | QecOp::Postselect { records, .. } => {
813                resolve_records(records, next_measurement)?;
814            }
815            QecOp::ExpectationValue { terms, coefficient } => {
816                if terms.is_empty() {
817                    return Err(PrismError::InvalidParameter {
818                        message: "expectation value requires at least one Pauli term".to_string(),
819                    });
820                }
821                validate_pauli_terms(terms, self.num_qubits)?;
822                if !coefficient.is_finite() {
823                    return Err(PrismError::InvalidParameter {
824                        message: "expectation-value coefficient must be finite".to_string(),
825                    });
826                }
827            }
828            QecOp::Feedforward {
829                records,
830                body,
831                expected: _,
832            } => {
833                if records.is_empty() {
834                    return Err(PrismError::InvalidParameter {
835                        message: "feed-forward predicate requires at least one record".to_string(),
836                    });
837                }
838                if body.is_empty() {
839                    return Err(PrismError::InvalidParameter {
840                        message: "feed-forward body requires at least one operation".to_string(),
841                    });
842                }
843                resolve_records(records, next_measurement)?;
844                for inner in body {
845                    if !matches!(inner, QecOp::Gate { .. } | QecOp::Reset { .. }) {
846                        return Err(PrismError::InvalidParameter {
847                            message: format!(
848                                "feed-forward body admits gates and resets only, got `{}`",
849                                qec_op_name(inner)
850                            ),
851                        });
852                    }
853                    self.validate_op(inner, next_measurement)?;
854                }
855            }
856            QecOp::Noise { channel, targets } => {
857                validate_noise(*channel, targets, self.num_qubits)?;
858            }
859            QecOp::Tick => {}
860        }
861        Ok(())
862    }
863}
864
865/// Short name for an op, used when an error must say which one it found.
866fn qec_op_name(op: &QecOp) -> &'static str {
867    match op {
868        QecOp::Gate { .. } => "gate",
869        QecOp::Measure { .. } => "M",
870        QecOp::MeasurePauliProduct { .. } => "MPP",
871        QecOp::Reset { .. } => "R",
872        QecOp::Detector { .. } => "DETECTOR",
873        QecOp::ObservableInclude { .. } => "OBSERVABLE_INCLUDE",
874        QecOp::ExpectationValue { .. } => "EXP_VAL",
875        QecOp::Postselect { .. } => "POSTSELECT",
876        QecOp::Feedforward { .. } => "FEEDFORWARD",
877        QecOp::Noise { .. } => "noise",
878        QecOp::Tick => "TICK",
879    }
880}
881
882/// Compile measurement-record operations into packed QEC row metadata.
883///
884/// Lowers `Measure` and `MeasurePauliProduct` ops into the same packed X/Z
885/// Pauli row representation used by the compiled sampler internals, and
886/// resolves detector, observable, and postselection record references to
887/// absolute indices. Useful when consumers want the row-level representation
888/// for custom sampler integration.
889///
890/// This is a sampler-row primitive, not an execution path: it rejects
891/// programs containing gates, resets, active Pauli noise, or `EXP_VAL`.
892/// Zero-probability noise annotations are skipped. To execute a full program
893/// (including gates, resets, and noise) use [`run_qec_program`].
894pub fn compile_qec_program_rows(program: &QecProgram) -> Result<QecCompiledRows> {
895    let mut measurement_rows = Vec::with_capacity(program.num_measurements());
896
897    for op in program.ops() {
898        match op {
899            QecOp::Gate { gate, .. } => {
900                return Err(PrismError::IncompatibleBackend {
901                    backend: "QEC row compiler".to_string(),
902                    reason: format!(
903                        "QEC row compilation does not lower gates yet, got `{}`",
904                        gate.name()
905                    ),
906                });
907            }
908            QecOp::Measure { basis, qubit } => {
909                measurement_rows.push(QecMeasurementRow::single(
910                    program.num_qubits(),
911                    *basis,
912                    *qubit,
913                )?);
914            }
915            QecOp::MeasurePauliProduct { terms } => {
916                measurement_rows.push(QecMeasurementRow::from_terms(program.num_qubits(), terms)?);
917            }
918            QecOp::Reset { .. } => {
919                return Err(PrismError::IncompatibleBackend {
920                    backend: "QEC row compiler".to_string(),
921                    reason: "QEC row compilation does not lower resets yet".to_string(),
922                });
923            }
924            QecOp::ExpectationValue { .. } => {
925                return Err(PrismError::IncompatibleBackend {
926                    backend: "QEC row compiler".to_string(),
927                    reason: "QEC row compilation has no row representation for `EXP_VAL`; \
928                             use `run_qec_program`"
929                        .to_string(),
930                });
931            }
932            QecOp::Feedforward { .. } => {
933                return Err(PrismError::IncompatibleBackend {
934                    backend: "QEC row compiler".to_string(),
935                    reason: "QEC row compilation has no row representation for `FEEDFORWARD`; \
936                             use `run_qec_program_reference`"
937                        .to_string(),
938                });
939            }
940            QecOp::Detector { .. }
941            | QecOp::ObservableInclude { .. }
942            | QecOp::Postselect { .. }
943            | QecOp::Tick => {}
944            QecOp::Noise { channel, .. } if channel.probability() == 0.0 => {}
945            QecOp::Noise { .. } => {
946                return Err(PrismError::IncompatibleBackend {
947                    backend: "QEC row compiler".to_string(),
948                    reason: "QEC row compilation does not support active noise annotations yet"
949                        .to_string(),
950                });
951            }
952        }
953    }
954
955    let postselection_predicates = program.postselection_rows()?;
956    let mut postselection_rows = Vec::with_capacity(postselection_predicates.len());
957    let mut postselection_expected = Vec::with_capacity(postselection_predicates.len());
958    for (row, expected) in postselection_predicates {
959        postselection_rows.push(row);
960        postselection_expected.push(expected);
961    }
962
963    Ok(QecCompiledRows {
964        num_qubits: program.num_qubits(),
965        measurement_rows,
966        detector_rows: program.detector_rows()?,
967        observable_rows: program.observable_rows()?,
968        postselection_rows,
969        postselection_expected,
970    })
971}
972
973pub(crate) fn qec_terms_to_pauli(terms: &[QecPauli]) -> Vec<PauliTerm> {
974    terms
975        .iter()
976        .map(|t| PauliTerm::new(t.qubit, t.basis.into()))
977        .collect()
978}
979
980/// Reject reuse of a qubit measured in a non-Z basis before its next reset.
981///
982/// A basis measurement leaves the qubit in the Z frame rather than in the basis
983/// it named, so a later operation on that qubit reads a state the program did
984/// not ask for. Both lowerings take that convention, and it is only unobservable
985/// while the qubit is reset before reuse, which is the contract
986/// [`run_qec_program`] already documents. This makes it an error rather than a
987/// silent difference.
988///
989/// Z-basis measurements are unaffected: they rotate nothing, so nothing is left
990/// behind to observe. `MPP` is unaffected for the same reason, since it undoes
991/// each term's rotation before taking the record.
992pub(crate) fn validate_measured_qubit_reuse(program: &QecProgram) -> Result<()> {
993    let reuse = |qubit: usize| PrismError::InvalidParameter {
994        message: format!(
995            "qubit {qubit} was measured in a non-Z basis and must be reset before it is used \
996             again: a basis measurement leaves the qubit in the Z frame, not in the basis it \
997             named"
998        ),
999    };
1000    let mut rotated = vec![false; program.num_qubits()];
1001    for op in program.ops() {
1002        match op {
1003            QecOp::Gate { targets, .. } => {
1004                if let Some(&qubit) = targets.iter().find(|&&q| rotated[q]) {
1005                    return Err(reuse(qubit));
1006                }
1007            }
1008            QecOp::Measure { basis, qubit } => {
1009                if rotated[*qubit] {
1010                    return Err(reuse(*qubit));
1011                }
1012                rotated[*qubit] = *basis != QecBasis::Z;
1013            }
1014            QecOp::MeasurePauliProduct { terms } => {
1015                if let Some(term) = terms.iter().find(|t| rotated[t.qubit]) {
1016                    return Err(reuse(term.qubit));
1017                }
1018            }
1019            QecOp::Reset { qubit, .. } => rotated[*qubit] = false,
1020            _ => {}
1021        }
1022    }
1023    Ok(())
1024}
1025
1026/// Validate `EXP_VAL` placement for execution.
1027///
1028/// Terminality: no gate, measurement, reset, or active noise may follow an
1029/// `EXP_VAL` op, so "final state" is well defined on every path.
1030/// Liveness: an `EXP_VAL` term may not reference a qubit that was
1031/// single-qubit-measured after its last reset. Liveness keeps the sampled
1032/// post-measurement expectation equal to the measurement-stripped
1033/// pure-state expectation the analytical strategies evaluate (the Pauli
1034/// commutes with every measurement projector when their supports are
1035/// disjoint). Pauli-product measurements do not affect liveness: the
1036/// deferred lowering measures a scratch alias and the cross terms of the
1037/// projected state cancel exactly.
1038///
1039/// No-op for programs without `EXP_VAL` ops.
1040pub(crate) fn validate_qec_exp_val_placement(program: &QecProgram) -> Result<()> {
1041    let terminal_violation = |op_name: &str| PrismError::InvalidParameter {
1042        message: format!("`EXP_VAL` must be terminal: `{op_name}` appears after an `EXP_VAL` op"),
1043    };
1044    let mut seen_exp_val = false;
1045    let mut measured_since_reset = vec![false; program.num_qubits()];
1046    for op in program.ops() {
1047        match op {
1048            QecOp::ExpectationValue { terms, .. } => {
1049                seen_exp_val = true;
1050                if let Some(term) = terms.iter().find(|t| measured_since_reset[t.qubit]) {
1051                    return Err(PrismError::InvalidParameter {
1052                        message: format!(
1053                            "`EXP_VAL` term on qubit {}: qubit was measured after its last \
1054                             reset; expectation values are defined only on live qubits",
1055                            term.qubit
1056                        ),
1057                    });
1058                }
1059            }
1060            QecOp::Gate { gate, .. } => {
1061                if seen_exp_val {
1062                    return Err(terminal_violation(gate.name()));
1063                }
1064            }
1065            QecOp::Measure { qubit, .. } => {
1066                if seen_exp_val {
1067                    return Err(terminal_violation("M"));
1068                }
1069                measured_since_reset[*qubit] = true;
1070            }
1071            QecOp::MeasurePauliProduct { .. } => {
1072                if seen_exp_val {
1073                    return Err(terminal_violation("MPP"));
1074                }
1075            }
1076            QecOp::Reset { qubit, .. } => {
1077                if seen_exp_val {
1078                    return Err(terminal_violation("R"));
1079                }
1080                measured_since_reset[*qubit] = false;
1081            }
1082            QecOp::Noise { channel, .. } if channel.probability() > 0.0 => {
1083                if seen_exp_val {
1084                    return Err(terminal_violation(channel.name()));
1085                }
1086            }
1087            QecOp::Feedforward { .. } => {
1088                if seen_exp_val {
1089                    return Err(terminal_violation("FEEDFORWARD"));
1090                }
1091                // A conditional reset does not restore liveness: the shots where
1092                // the predicate is false leave the qubit collapsed.
1093            }
1094            QecOp::Detector { .. }
1095            | QecOp::ObservableInclude { .. }
1096            | QecOp::Postselect { .. }
1097            | QecOp::Noise { .. }
1098            | QecOp::Tick => {}
1099        }
1100    }
1101    Ok(())
1102}
1103
1104pub(super) fn append_basis_to_z_rotation(circuit: &mut Circuit, basis: QecBasis, qubit: usize) {
1105    append_axis_to_z_rotation(circuit, basis.into(), qubit);
1106}
1107
1108pub(super) fn append_z_to_basis_rotation(circuit: &mut Circuit, basis: QecBasis, qubit: usize) {
1109    append_z_to_axis_rotation(circuit, basis.into(), qubit);
1110}
1111
1112/// Lower a Pauli-product measurement onto a scratch qubit holding |0>; the
1113/// caller measures the scratch afterward. See [`append_parity_rotations`].
1114pub(super) fn append_mpp_parity_rotations(
1115    circuit: &mut Circuit,
1116    terms: &[QecPauli],
1117    scratch: usize,
1118) {
1119    append_parity_rotations(circuit, &qec_terms_to_pauli(terms), scratch);
1120}
1121
1122pub(super) fn qec_non_clifford_error(gate: &Gate) -> PrismError {
1123    PrismError::IncompatibleBackend {
1124        backend: "QEC compiled runner".to_string(),
1125        reason: format!(
1126            "compiled QEC runner requires Clifford gates, got `{}`",
1127            gate.name()
1128        ),
1129    }
1130}
1131
1132pub(super) fn ensure_lowered_record_count(
1133    program: &QecProgram,
1134    produced: usize,
1135    stage: &str,
1136) -> Result<()> {
1137    if produced != program.num_measurements() {
1138        return Err(PrismError::InvalidParameter {
1139            message: format!(
1140                "QEC {stage} lowering produced {produced} records, expected {}",
1141                program.num_measurements()
1142            ),
1143        });
1144    }
1145    Ok(())
1146}
1147
1148fn resolve_records(records: &[QecRecordRef], next_measurement: usize) -> Result<Vec<usize>> {
1149    records
1150        .iter()
1151        .map(|record| record.resolve(next_measurement))
1152        .collect()
1153}
1154
1155fn validate_qubit(qubit: usize, num_qubits: usize) -> Result<()> {
1156    if qubit >= num_qubits {
1157        return Err(PrismError::InvalidQubit {
1158            index: qubit,
1159            register_size: num_qubits,
1160        });
1161    }
1162    Ok(())
1163}
1164
1165fn validate_qubits<I>(qubits: I, num_qubits: usize) -> Result<()>
1166where
1167    I: IntoIterator<Item = usize>,
1168{
1169    for qubit in qubits {
1170        validate_qubit(qubit, num_qubits)?;
1171    }
1172    Ok(())
1173}
1174
1175fn validate_pauli_terms(terms: &[QecPauli], num_qubits: usize) -> Result<()> {
1176    for (idx, term) in terms.iter().enumerate() {
1177        validate_qubit(term.qubit, num_qubits)?;
1178        if terms[..idx].iter().any(|prior| prior.qubit == term.qubit) {
1179            return Err(PrismError::InvalidParameter {
1180                message: format!("Pauli product contains duplicate qubit {}", term.qubit),
1181            });
1182        }
1183    }
1184    Ok(())
1185}
1186
1187fn validate_finite_values(values: &[f64], label: &str) -> Result<()> {
1188    for value in values {
1189        if !value.is_finite() {
1190            return Err(PrismError::InvalidParameter {
1191                message: format!("{label} must be finite"),
1192            });
1193        }
1194    }
1195    Ok(())
1196}
1197
1198fn validate_noise(channel: QecNoise, targets: &[usize], num_qubits: usize) -> Result<()> {
1199    let p = channel.probability();
1200    if !(0.0..=1.0).contains(&p) || !p.is_finite() {
1201        return Err(PrismError::InvalidParameter {
1202            message: format!(
1203                "{} probability must be finite and in [0, 1]",
1204                channel.name()
1205            ),
1206        });
1207    }
1208
1209    if targets.is_empty() {
1210        return Err(PrismError::InvalidParameter {
1211            message: format!("{} requires at least one target", channel.name()),
1212        });
1213    }
1214
1215    if matches!(channel, QecNoise::Depolarize2(_)) && !targets.len().is_multiple_of(2) {
1216        return Err(PrismError::InvalidParameter {
1217            message: "DEPOLARIZE2 requires an even number of targets".to_string(),
1218        });
1219    }
1220
1221    if matches!(channel, QecNoise::Depolarize2(_)) {
1222        for pair in targets.chunks_exact(2) {
1223            if pair[0] == pair[1] {
1224                return Err(PrismError::InvalidParameter {
1225                    message: "DEPOLARIZE2 target pairs must use distinct qubits".to_string(),
1226                });
1227            }
1228        }
1229    }
1230
1231    validate_qubits(targets.iter().copied(), num_qubits)
1232}