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//! - [`compile_qec_program_rows`] lowers basis measurements and `MPP` records
20//!   into the packed X/Z Pauli row representation used by sampler internals.
21//!   It does not execute gates, resets, or active noise.
22//!
23//! [`QecSampleResult`] carries packed measurement, detector, and observable
24//! shots, plus accepted and discarded shot counts after postselection and
25//! per-observable logical-error counts.
26
27mod noise;
28mod parse;
29mod result;
30mod runner;
31
32pub use parse::parse_qec_program;
33pub use result::QecSampleResult;
34#[cfg(feature = "bench-internal")]
35pub use runner::{compile_qec_profiled_sampler, QecProfiledCounts, QecProfiledSampler};
36pub use runner::{run_qec_program, run_qec_program_reference};
37
38use crate::circuit::Circuit;
39use crate::error::{PrismError, Result};
40use crate::gates::Gate;
41use crate::sim::compiled::{get_bit, set_bit, PackedShots, PauliVec};
42
43/// Pauli basis used by QEC measurements and Pauli products.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum QecBasis {
46    /// X basis.
47    X,
48    /// Y basis.
49    Y,
50    /// Z basis.
51    Z,
52}
53
54/// One Pauli term in an MPP-style measurement.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub struct QecPauli {
57    /// Pauli basis for this term.
58    pub basis: QecBasis,
59    /// Qubit acted on by this term.
60    pub qubit: usize,
61}
62
63impl QecPauli {
64    /// Create a Pauli term.
65    pub fn new(basis: QecBasis, qubit: usize) -> Self {
66        Self { basis, qubit }
67    }
68
69    /// Create an X term.
70    pub fn x(qubit: usize) -> Self {
71        Self::new(QecBasis::X, qubit)
72    }
73
74    /// Create a Y term.
75    pub fn y(qubit: usize) -> Self {
76        Self::new(QecBasis::Y, qubit)
77    }
78
79    /// Create a Z term.
80    pub fn z(qubit: usize) -> Self {
81        Self::new(QecBasis::Z, qubit)
82    }
83}
84
85/// Reference to a previous measurement record.
86///
87/// Lookbacks are resolved against the count of measurement records that exist
88/// at the moment the referencing operation is appended (or, for queries like
89/// [`QecProgram::detector_rows`], at the moment that operation is reached
90/// during the walk). `Lookback(1)` is the most recent measurement.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum QecRecordRef {
93    /// Absolute measurement record index.
94    Absolute(usize),
95    /// Relative lookback into prior records, where `Lookback(1)` is the most recent.
96    Lookback(usize),
97}
98
99impl QecRecordRef {
100    /// Create an absolute measurement reference.
101    pub fn absolute(index: usize) -> Self {
102        Self::Absolute(index)
103    }
104
105    /// Create a relative measurement reference.
106    pub fn lookback(distance: usize) -> Result<Self> {
107        if distance == 0 {
108            return Err(PrismError::InvalidParameter {
109                message: "measurement lookback distance must be at least 1".to_string(),
110            });
111        }
112        Ok(Self::Lookback(distance))
113    }
114
115    fn resolve(self, next_measurement: usize) -> Result<usize> {
116        match self {
117            Self::Absolute(index) if index < next_measurement => Ok(index),
118            Self::Absolute(index) => Err(PrismError::InvalidParameter {
119                message: format!(
120                    "measurement record {index} out of bounds for {next_measurement} existing records"
121                ),
122            }),
123            Self::Lookback(distance) if distance > 0 && distance <= next_measurement => {
124                Ok(next_measurement - distance)
125            }
126            Self::Lookback(distance) => Err(PrismError::InvalidParameter {
127                message: format!(
128                    "measurement lookback {distance} out of bounds for {next_measurement} existing records"
129                ),
130            }),
131        }
132    }
133}
134
135/// Pauli-noise annotation for native QEC programs.
136///
137/// Probabilities are validated when the annotation is appended to a
138/// [`QecProgram`]. Probability zero is treated as an inactive annotation by
139/// runner APIs.
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub enum QecNoise {
142    /// With probability `p`, apply X to each target.
143    XError(f64),
144    /// With probability `p`, apply Z to each target.
145    ZError(f64),
146    /// For each target, with total probability `p`, apply a uniformly random
147    /// non-identity single-qubit Pauli. Each of X, Y, Z fires with probability
148    /// `p / 3`.
149    Depolarize1(f64),
150    /// For each target pair, with total probability `p`, apply a uniformly
151    /// random non-identity two-qubit Pauli. Each of the 15 non-identity
152    /// two-qubit Paulis fires with probability `p / 15`. The target list is
153    /// consumed in pairs and must have even length.
154    Depolarize2(f64),
155}
156
157impl QecNoise {
158    /// Channel probability.
159    pub fn probability(self) -> f64 {
160        match self {
161            Self::XError(p) | Self::ZError(p) | Self::Depolarize1(p) | Self::Depolarize2(p) => p,
162        }
163    }
164
165    /// Native text instruction name for this channel.
166    pub fn name(self) -> &'static str {
167        match self {
168            Self::XError(_) => "X_ERROR",
169            Self::ZError(_) => "Z_ERROR",
170            Self::Depolarize1(_) => "DEPOLARIZE1",
171            Self::Depolarize2(_) => "DEPOLARIZE2",
172        }
173    }
174}
175
176/// One operation in a native QEC program.
177#[derive(Debug, Clone, PartialEq)]
178pub enum QecOp {
179    /// Standard PRISM-Q gate operation. The compiled runner requires Clifford
180    /// gates; the reference runner accepts any gate the statevector backend
181    /// supports.
182    Gate { gate: Gate, targets: Vec<usize> },
183    /// Single-qubit measurement in the requested basis. Produces one
184    /// measurement record.
185    Measure { basis: QecBasis, qubit: usize },
186    /// Pauli-product (`MPP`) measurement. Produces one measurement record
187    /// equal to the parity of the listed Pauli terms.
188    MeasurePauliProduct { terms: Vec<QecPauli> },
189    /// Reset a qubit to the +1 eigenstate of the requested basis.
190    Reset { basis: QecBasis, qubit: usize },
191    /// Detector: parity over the listed measurement records. `coords` is
192    /// arbitrary passthrough metadata for visualization and downstream
193    /// decoders; it does not affect sampling.
194    Detector {
195        records: Vec<QecRecordRef>,
196        coords: Vec<f64>,
197    },
198    /// Logical observable parity contribution. Multiple includes for the same
199    /// `observable` index XOR into a single observable row.
200    ObservableInclude {
201        observable: usize,
202        records: Vec<QecRecordRef>,
203    },
204    /// Expectation-value metadata. Both runners reject programs containing
205    /// this op until estimator semantics land.
206    ExpectationValue {
207        terms: Vec<QecPauli>,
208        coefficient: f64,
209    },
210    /// Postselection predicate. The shot is accepted only when the parity over
211    /// `records` matches `expected`.
212    Postselect {
213        records: Vec<QecRecordRef>,
214        expected: bool,
215    },
216    /// Pauli-noise annotation applied at this point in the program. Zero
217    /// probability is treated as inactive.
218    Noise {
219        channel: QecNoise,
220        targets: Vec<usize>,
221    },
222    /// Scheduling separator. No semantic effect; carried forward for parity
223    /// with native QEC text formats.
224    Tick,
225}
226
227/// Packed Pauli row for one QEC measurement record.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct QecMeasurementRow {
230    num_qubits: usize,
231    pauli: PauliVec,
232    weight: usize,
233}
234
235impl QecMeasurementRow {
236    /// Create a row from Pauli-product terms.
237    pub fn from_terms(num_qubits: usize, terms: &[QecPauli]) -> Result<Self> {
238        if terms.is_empty() {
239            return Err(PrismError::InvalidParameter {
240                message: "QEC measurement row requires at least one Pauli term".to_string(),
241            });
242        }
243        validate_pauli_terms(terms, num_qubits)?;
244
245        let row_words = num_qubits.div_ceil(64);
246        let mut pauli = PauliVec::new(row_words);
247
248        for term in terms {
249            match term.basis {
250                QecBasis::X => set_bit(&mut pauli.x, term.qubit, true),
251                QecBasis::Y => {
252                    set_bit(&mut pauli.x, term.qubit, true);
253                    set_bit(&mut pauli.z, term.qubit, true);
254                }
255                QecBasis::Z => set_bit(&mut pauli.z, term.qubit, true),
256            }
257        }
258
259        Ok(Self {
260            num_qubits,
261            pauli,
262            weight: terms.len(),
263        })
264    }
265
266    /// Create a single-qubit measurement row.
267    pub fn single(num_qubits: usize, basis: QecBasis, qubit: usize) -> Result<Self> {
268        Self::from_terms(num_qubits, &[QecPauli::new(basis, qubit)])
269    }
270
271    /// Number of qubits covered by this row.
272    pub fn num_qubits(&self) -> usize {
273        self.num_qubits
274    }
275
276    /// Number of non-identity Pauli terms.
277    pub fn weight(&self) -> usize {
278        self.weight
279    }
280
281    /// Packed X mask.
282    pub fn x_mask(&self) -> &[u64] {
283        &self.pauli.x
284    }
285
286    /// Packed Z mask.
287    pub fn z_mask(&self) -> &[u64] {
288        &self.pauli.z
289    }
290
291    /// Pauli term on one qubit, or `None` for identity (or when `qubit` is
292    /// outside this row's qubit range).
293    pub fn pauli_at(&self, qubit: usize) -> Option<QecBasis> {
294        if qubit >= self.num_qubits {
295            return None;
296        }
297        match (get_bit(&self.pauli.x, qubit), get_bit(&self.pauli.z, qubit)) {
298            (true, false) => Some(QecBasis::X),
299            (true, true) => Some(QecBasis::Y),
300            (false, true) => Some(QecBasis::Z),
301            (false, false) => None,
302        }
303    }
304
305    /// Return non-identity Pauli terms in ascending qubit order.
306    pub fn terms(&self) -> Vec<QecPauli> {
307        let mut terms = Vec::with_capacity(self.weight);
308        for qubit in 0..self.num_qubits {
309            if let Some(basis) = self.pauli_at(qubit) {
310                terms.push(QecPauli::new(basis, qubit));
311            }
312        }
313        terms
314    }
315
316    /// Packed row storage in bytes.
317    pub fn packed_bytes(&self) -> usize {
318        (self.pauli.x.len() + self.pauli.z.len()) * std::mem::size_of::<u64>()
319    }
320}
321
322/// Compiled QEC record rows ready for sampler lowering.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct QecCompiledRows {
325    num_qubits: usize,
326    measurement_rows: Vec<QecMeasurementRow>,
327    detector_rows: Vec<Vec<usize>>,
328    observable_rows: Vec<Vec<usize>>,
329    postselection_rows: Vec<Vec<usize>>,
330    postselection_expected: Vec<bool>,
331}
332
333impl QecCompiledRows {
334    /// Number of qubits in the source program.
335    pub fn num_qubits(&self) -> usize {
336        self.num_qubits
337    }
338
339    /// Measurement rows in record order.
340    pub fn measurement_rows(&self) -> &[QecMeasurementRow] {
341        &self.measurement_rows
342    }
343
344    /// Detector parity rows over measurement records.
345    pub fn detector_rows(&self) -> &[Vec<usize>] {
346        &self.detector_rows
347    }
348
349    /// Observable parity rows over measurement records.
350    pub fn observable_rows(&self) -> &[Vec<usize>] {
351        &self.observable_rows
352    }
353
354    /// Postselection parity rows.
355    pub fn postselection_rows(&self) -> &[Vec<usize>] {
356        &self.postselection_rows
357    }
358
359    /// Expected parity for each postselection row.
360    pub fn postselection_expected(&self) -> &[bool] {
361        &self.postselection_expected
362    }
363
364    /// Postselection parity rows paired with expected values.
365    pub fn postselection_predicates(&self) -> impl ExactSizeIterator<Item = (&[usize], bool)> + '_ {
366        self.postselection_rows
367            .iter()
368            .map(Vec::as_slice)
369            .zip(self.postselection_expected.iter().copied())
370    }
371
372    /// Number of measurement records.
373    pub fn num_measurements(&self) -> usize {
374        self.measurement_rows.len()
375    }
376
377    /// Number of detector rows.
378    pub fn num_detectors(&self) -> usize {
379        self.detector_rows.len()
380    }
381
382    /// Number of observable rows.
383    pub fn num_observables(&self) -> usize {
384        self.observable_rows.len()
385    }
386
387    /// Number of postselection predicates.
388    pub fn num_postselections(&self) -> usize {
389        self.postselection_rows.len()
390    }
391
392    /// Packed words per X or Z mask.
393    pub fn packed_row_words(&self) -> usize {
394        self.num_qubits.div_ceil(64)
395    }
396
397    /// Packed measurement row storage in bytes.
398    pub fn measurement_mask_bytes(&self) -> usize {
399        self.measurement_rows
400            .len()
401            .saturating_mul(self.packed_row_words())
402            .saturating_mul(2)
403            .saturating_mul(std::mem::size_of::<u64>())
404    }
405
406    /// Compute detector records from packed measurement records.
407    pub fn detector_parities(&self, measurements: &PackedShots) -> Result<PackedShots> {
408        measurements.parity_rows(&self.detector_rows)
409    }
410
411    /// Compute logical observable records from packed measurement records.
412    pub fn observable_parities(&self, measurements: &PackedShots) -> Result<PackedShots> {
413        measurements.parity_rows(&self.observable_rows)
414    }
415
416    /// Compute postselection predicate parities from packed measurement records.
417    pub fn postselection_parities(&self, measurements: &PackedShots) -> Result<PackedShots> {
418        measurements.parity_rows(&self.postselection_rows)
419    }
420}
421
422/// Options for running a native QEC program.
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424pub struct QecOptions {
425    /// Number of shots requested by runner APIs.
426    pub shots: usize,
427    /// RNG seed used by stochastic samplers and Pauli-noise dispatch.
428    pub seed: u64,
429    /// Optional chunk size for the compiled runner. When `Some(n)`, sampling
430    /// proceeds in batches of at most `n` shots and intermediate measurement
431    /// matrices are not held in memory together. `None` is equivalent to
432    /// `Some(shots)`. `Some(0)` is rejected. Has no effect on the reference
433    /// runner.
434    pub chunk_size: Option<usize>,
435    /// When `false`, [`QecSampleResult::measurements`] is returned with zero
436    /// shots (only the column count is preserved). Detector and observable
437    /// records are always populated. Set to `false` to avoid materializing
438    /// large measurement-record matrices when only detectors and observables
439    /// are needed.
440    pub keep_measurements: bool,
441}
442
443impl Default for QecOptions {
444    fn default() -> Self {
445        Self {
446            shots: 1024,
447            seed: 42,
448            chunk_size: None,
449            keep_measurements: true,
450        }
451    }
452}
453
454/// Native QEC program expressed as measurement-record operations.
455#[derive(Debug, Clone, PartialEq)]
456pub struct QecProgram {
457    num_qubits: usize,
458    ops: Vec<QecOp>,
459    options: QecOptions,
460}
461
462impl QecProgram {
463    /// Create an empty program.
464    pub fn new(num_qubits: usize) -> Self {
465        Self::with_options(num_qubits, QecOptions::default())
466    }
467
468    /// Create an empty program with explicit options.
469    pub fn with_options(num_qubits: usize, options: QecOptions) -> Self {
470        Self {
471            num_qubits,
472            ops: Vec::new(),
473            options,
474        }
475    }
476
477    /// Create a program from operations, validating record references as
478    /// operations are appended.
479    pub fn from_ops(num_qubits: usize, options: QecOptions, ops: Vec<QecOp>) -> Result<Self> {
480        let mut program = Self::with_options(num_qubits, options);
481        let mut next_measurement = 0usize;
482        for op in ops {
483            program.validate_op(&op, next_measurement)?;
484            if matches!(
485                op,
486                QecOp::Measure { .. } | QecOp::MeasurePauliProduct { .. }
487            ) {
488                next_measurement += 1;
489            }
490            program.ops.push(op);
491        }
492        Ok(program)
493    }
494
495    /// Parse a native measurement-record QEC program.
496    pub fn from_text(input: &str) -> Result<Self> {
497        parse_qec_program(input)
498    }
499
500    /// Number of qubits in the program.
501    pub fn num_qubits(&self) -> usize {
502        self.num_qubits
503    }
504
505    /// Runner options.
506    pub fn options(&self) -> QecOptions {
507        self.options
508    }
509
510    /// Set runner options.
511    pub fn set_options(&mut self, options: QecOptions) {
512        self.options = options;
513    }
514
515    /// Operation stream.
516    pub fn ops(&self) -> &[QecOp] {
517        &self.ops
518    }
519
520    /// Number of measurement records produced by the operation stream.
521    pub fn num_measurements(&self) -> usize {
522        self.ops
523            .iter()
524            .filter(|op| {
525                matches!(
526                    op,
527                    QecOp::Measure { .. } | QecOp::MeasurePauliProduct { .. }
528                )
529            })
530            .count()
531    }
532
533    /// Number of detector rows.
534    pub fn num_detectors(&self) -> usize {
535        self.ops
536            .iter()
537            .filter(|op| matches!(op, QecOp::Detector { .. }))
538            .count()
539    }
540
541    /// Number of logical observable slots.
542    pub fn num_observables(&self) -> usize {
543        self.ops
544            .iter()
545            .filter_map(|op| match op {
546                QecOp::ObservableInclude { observable, .. } => Some(*observable),
547                _ => None,
548            })
549            .max()
550            .map_or(0, |max_idx| max_idx + 1)
551    }
552
553    /// Append a validated operation.
554    pub fn push_op(&mut self, op: QecOp) -> Result<()> {
555        self.validate_op(&op, self.num_measurements())?;
556        self.ops.push(op);
557        Ok(())
558    }
559
560    /// Append a gate operation.
561    pub fn push_gate(&mut self, gate: Gate, targets: &[usize]) -> Result<()> {
562        self.push_op(QecOp::Gate {
563            gate,
564            targets: targets.to_vec(),
565        })
566    }
567
568    /// Append a reset operation.
569    pub fn reset(&mut self, basis: QecBasis, qubit: usize) -> Result<()> {
570        self.push_op(QecOp::Reset { basis, qubit })
571    }
572
573    /// Append a single-qubit measurement and return its record index.
574    pub fn measure(&mut self, basis: QecBasis, qubit: usize) -> Result<usize> {
575        let record = self.num_measurements();
576        self.push_op(QecOp::Measure { basis, qubit })?;
577        Ok(record)
578    }
579
580    /// Append a Z-basis measurement and return its record index.
581    pub fn measure_z(&mut self, qubit: usize) -> Result<usize> {
582        self.measure(QecBasis::Z, qubit)
583    }
584
585    /// Append an X-basis measurement and return its record index.
586    pub fn measure_x(&mut self, qubit: usize) -> Result<usize> {
587        self.measure(QecBasis::X, qubit)
588    }
589
590    /// Append a Pauli-product measurement and return its record index.
591    pub fn measure_pauli_product(&mut self, terms: &[QecPauli]) -> Result<usize> {
592        let record = self.num_measurements();
593        self.push_op(QecOp::MeasurePauliProduct {
594            terms: terms.to_vec(),
595        })?;
596        Ok(record)
597    }
598
599    /// Append a detector and return its detector index.
600    pub fn detector(&mut self, records: &[QecRecordRef]) -> Result<usize> {
601        self.detector_with_coords(records, &[])
602    }
603
604    /// Append a detector with coordinates and return its detector index.
605    pub fn detector_with_coords(
606        &mut self,
607        records: &[QecRecordRef],
608        coords: &[f64],
609    ) -> Result<usize> {
610        let detector = self.num_detectors();
611        self.push_op(QecOp::Detector {
612            records: records.to_vec(),
613            coords: coords.to_vec(),
614        })?;
615        Ok(detector)
616    }
617
618    /// Append a logical observable parity contribution.
619    pub fn observable_include(
620        &mut self,
621        observable: usize,
622        records: &[QecRecordRef],
623    ) -> Result<()> {
624        self.push_op(QecOp::ObservableInclude {
625            observable,
626            records: records.to_vec(),
627        })
628    }
629
630    /// Append expectation-value metadata.
631    pub fn expectation_value(&mut self, terms: &[QecPauli], coefficient: f64) -> Result<()> {
632        self.push_op(QecOp::ExpectationValue {
633            terms: terms.to_vec(),
634            coefficient,
635        })
636    }
637
638    /// Append a postselection predicate.
639    pub fn postselect(&mut self, records: &[QecRecordRef], expected: bool) -> Result<()> {
640        self.push_op(QecOp::Postselect {
641            records: records.to_vec(),
642            expected,
643        })
644    }
645
646    /// Append a Pauli-noise annotation.
647    pub fn noise(&mut self, channel: QecNoise, targets: &[usize]) -> Result<()> {
648        self.push_op(QecOp::Noise {
649            channel,
650            targets: targets.to_vec(),
651        })
652    }
653
654    /// Resolve detector rows to absolute measurement record indices.
655    pub fn detector_rows(&self) -> Result<Vec<Vec<usize>>> {
656        let mut rows = Vec::new();
657        let mut next_measurement = 0;
658        for op in &self.ops {
659            match op {
660                QecOp::Measure { .. } | QecOp::MeasurePauliProduct { .. } => {
661                    next_measurement += 1;
662                }
663                QecOp::Detector { records, .. } => {
664                    rows.push(resolve_records(records, next_measurement)?);
665                }
666                _ => {}
667            }
668        }
669        Ok(rows)
670    }
671
672    /// Resolve observable rows to absolute measurement record indices.
673    pub fn observable_rows(&self) -> Result<Vec<Vec<usize>>> {
674        let mut rows = Vec::new();
675        let mut next_measurement = 0;
676        for op in &self.ops {
677            match op {
678                QecOp::Measure { .. } | QecOp::MeasurePauliProduct { .. } => {
679                    next_measurement += 1;
680                }
681                QecOp::ObservableInclude {
682                    observable,
683                    records,
684                } => {
685                    if rows.len() <= *observable {
686                        rows.resize_with(*observable + 1, Vec::new);
687                    }
688                    rows[*observable].extend(resolve_records(records, next_measurement)?);
689                }
690                _ => {}
691            }
692        }
693        Ok(rows)
694    }
695
696    /// Resolve postselection rows to absolute measurement record indices.
697    pub fn postselection_rows(&self) -> Result<Vec<(Vec<usize>, bool)>> {
698        let mut rows = Vec::new();
699        let mut next_measurement = 0;
700        for op in &self.ops {
701            match op {
702                QecOp::Measure { .. } | QecOp::MeasurePauliProduct { .. } => {
703                    next_measurement += 1;
704                }
705                QecOp::Postselect { records, expected } => {
706                    rows.push((resolve_records(records, next_measurement)?, *expected));
707                }
708                _ => {}
709            }
710        }
711        Ok(rows)
712    }
713
714    /// Create an empty result with the program's current record shape.
715    pub fn empty_result(&self) -> QecSampleResult {
716        QecSampleResult::empty(
717            self.num_measurements(),
718            self.num_detectors(),
719            self.num_observables(),
720        )
721    }
722
723    fn validate_op(&self, op: &QecOp, next_measurement: usize) -> Result<()> {
724        match op {
725            QecOp::Gate { gate, targets } => {
726                if gate.num_qubits() != targets.len() {
727                    return Err(PrismError::GateArity {
728                        gate: gate.name().to_string(),
729                        expected: gate.num_qubits(),
730                        got: targets.len(),
731                    });
732                }
733                validate_qubits(targets.iter().copied(), self.num_qubits)?;
734            }
735            QecOp::Measure { qubit, .. } | QecOp::Reset { qubit, .. } => {
736                validate_qubit(*qubit, self.num_qubits)?;
737            }
738            QecOp::MeasurePauliProduct { terms } => {
739                if terms.is_empty() {
740                    return Err(PrismError::InvalidParameter {
741                        message: "Pauli-product measurement requires at least one term".to_string(),
742                    });
743                }
744                validate_pauli_terms(terms, self.num_qubits)?;
745            }
746            QecOp::Detector { records, coords } => {
747                resolve_records(records, next_measurement)?;
748                validate_finite_values(coords, "detector coordinate")?;
749            }
750            QecOp::ObservableInclude { records, .. } | QecOp::Postselect { records, .. } => {
751                resolve_records(records, next_measurement)?;
752            }
753            QecOp::ExpectationValue { terms, coefficient } => {
754                if terms.is_empty() {
755                    return Err(PrismError::InvalidParameter {
756                        message: "expectation value requires at least one Pauli term".to_string(),
757                    });
758                }
759                validate_pauli_terms(terms, self.num_qubits)?;
760                if !coefficient.is_finite() {
761                    return Err(PrismError::InvalidParameter {
762                        message: "expectation-value coefficient must be finite".to_string(),
763                    });
764                }
765            }
766            QecOp::Noise { channel, targets } => {
767                validate_noise(*channel, targets, self.num_qubits)?;
768            }
769            QecOp::Tick => {}
770        }
771        Ok(())
772    }
773}
774
775/// Compile measurement-record operations into packed QEC row metadata.
776///
777/// Lowers `Measure` and `MeasurePauliProduct` ops into the same packed X/Z
778/// Pauli row representation used by the compiled sampler internals, and
779/// resolves detector, observable, and postselection record references to
780/// absolute indices. Useful when consumers want the row-level representation
781/// for custom sampler integration.
782///
783/// This is a sampler-row primitive, not an execution path: it rejects
784/// programs containing gates, resets, active Pauli noise, or `EXP_VAL`.
785/// Zero-probability noise annotations are skipped. To execute a full program
786/// (including gates, resets, and noise) use [`run_qec_program`].
787pub fn compile_qec_program_rows(program: &QecProgram) -> Result<QecCompiledRows> {
788    let mut measurement_rows = Vec::with_capacity(program.num_measurements());
789
790    for op in program.ops() {
791        match op {
792            QecOp::Gate { gate, .. } => {
793                return Err(PrismError::IncompatibleBackend {
794                    backend: "QEC row compiler".to_string(),
795                    reason: format!(
796                        "QEC row compilation does not lower gates yet, got `{}`",
797                        gate.name()
798                    ),
799                });
800            }
801            QecOp::Measure { basis, qubit } => {
802                measurement_rows.push(QecMeasurementRow::single(
803                    program.num_qubits(),
804                    *basis,
805                    *qubit,
806                )?);
807            }
808            QecOp::MeasurePauliProduct { terms } => {
809                measurement_rows.push(QecMeasurementRow::from_terms(program.num_qubits(), terms)?);
810            }
811            QecOp::Reset { .. } => {
812                return Err(PrismError::IncompatibleBackend {
813                    backend: "QEC row compiler".to_string(),
814                    reason: "QEC row compilation does not lower resets yet".to_string(),
815                });
816            }
817            QecOp::ExpectationValue { .. } => {
818                return Err(PrismError::IncompatibleBackend {
819                    backend: "QEC row compiler".to_string(),
820                    reason: "QEC row compilation does not evaluate EXP_VAL yet".to_string(),
821                });
822            }
823            QecOp::Detector { .. }
824            | QecOp::ObservableInclude { .. }
825            | QecOp::Postselect { .. }
826            | QecOp::Tick => {}
827            QecOp::Noise { channel, .. } if channel.probability() == 0.0 => {}
828            QecOp::Noise { .. } => {
829                return Err(PrismError::IncompatibleBackend {
830                    backend: "QEC row compiler".to_string(),
831                    reason: "QEC row compilation does not support active noise annotations yet"
832                        .to_string(),
833                });
834            }
835        }
836    }
837
838    let postselection_predicates = program.postselection_rows()?;
839    let mut postselection_rows = Vec::with_capacity(postselection_predicates.len());
840    let mut postselection_expected = Vec::with_capacity(postselection_predicates.len());
841    for (row, expected) in postselection_predicates {
842        postselection_rows.push(row);
843        postselection_expected.push(expected);
844    }
845
846    Ok(QecCompiledRows {
847        num_qubits: program.num_qubits(),
848        measurement_rows,
849        detector_rows: program.detector_rows()?,
850        observable_rows: program.observable_rows()?,
851        postselection_rows,
852        postselection_expected,
853    })
854}
855
856pub(super) fn append_basis_to_z_rotation(circuit: &mut Circuit, basis: QecBasis, qubit: usize) {
857    match basis {
858        QecBasis::X => circuit.add_gate(Gate::H, &[qubit]),
859        QecBasis::Y => {
860            circuit.add_gate(Gate::Sdg, &[qubit]);
861            circuit.add_gate(Gate::H, &[qubit]);
862        }
863        QecBasis::Z => {}
864    }
865}
866
867pub(super) fn append_z_to_basis_rotation(circuit: &mut Circuit, basis: QecBasis, qubit: usize) {
868    match basis {
869        QecBasis::X => circuit.add_gate(Gate::H, &[qubit]),
870        QecBasis::Y => {
871            circuit.add_gate(Gate::H, &[qubit]);
872            circuit.add_gate(Gate::S, &[qubit]);
873        }
874        QecBasis::Z => {}
875    }
876}
877
878fn resolve_records(records: &[QecRecordRef], next_measurement: usize) -> Result<Vec<usize>> {
879    records
880        .iter()
881        .map(|record| record.resolve(next_measurement))
882        .collect()
883}
884
885fn validate_qubit(qubit: usize, num_qubits: usize) -> Result<()> {
886    if qubit >= num_qubits {
887        return Err(PrismError::InvalidQubit {
888            index: qubit,
889            register_size: num_qubits,
890        });
891    }
892    Ok(())
893}
894
895fn validate_qubits<I>(qubits: I, num_qubits: usize) -> Result<()>
896where
897    I: IntoIterator<Item = usize>,
898{
899    for qubit in qubits {
900        validate_qubit(qubit, num_qubits)?;
901    }
902    Ok(())
903}
904
905fn validate_pauli_terms(terms: &[QecPauli], num_qubits: usize) -> Result<()> {
906    for (idx, term) in terms.iter().enumerate() {
907        validate_qubit(term.qubit, num_qubits)?;
908        if terms[..idx].iter().any(|prior| prior.qubit == term.qubit) {
909            return Err(PrismError::InvalidParameter {
910                message: format!(
911                    "Pauli-product measurement contains duplicate qubit {}",
912                    term.qubit
913                ),
914            });
915        }
916    }
917    Ok(())
918}
919
920fn validate_finite_values(values: &[f64], label: &str) -> Result<()> {
921    for value in values {
922        if !value.is_finite() {
923            return Err(PrismError::InvalidParameter {
924                message: format!("{label} must be finite"),
925            });
926        }
927    }
928    Ok(())
929}
930
931fn validate_noise(channel: QecNoise, targets: &[usize], num_qubits: usize) -> Result<()> {
932    let p = channel.probability();
933    if !(0.0..=1.0).contains(&p) || !p.is_finite() {
934        return Err(PrismError::InvalidParameter {
935            message: format!(
936                "{} probability must be finite and in [0, 1]",
937                channel.name()
938            ),
939        });
940    }
941
942    if targets.is_empty() {
943        return Err(PrismError::InvalidParameter {
944            message: format!("{} requires at least one target", channel.name()),
945        });
946    }
947
948    if matches!(channel, QecNoise::Depolarize2(_)) && targets.len() % 2 != 0 {
949        return Err(PrismError::InvalidParameter {
950            message: "DEPOLARIZE2 requires an even number of targets".to_string(),
951        });
952    }
953
954    if matches!(channel, QecNoise::Depolarize2(_)) {
955        for pair in targets.chunks_exact(2) {
956            if pair[0] == pair[1] {
957                return Err(PrismError::InvalidParameter {
958                    message: "DEPOLARIZE2 target pairs must use distinct qubits".to_string(),
959                });
960            }
961        }
962    }
963
964    validate_qubits(targets.iter().copied(), num_qubits)
965}