Skip to main content

bloq_vm/
circuit.rs

1//! Translate a flattened [`CoordCircuit`] op stream into engine instructions,
2//! and replay it.
3//!
4//! The IR addresses qubits by [`IVec2`] coordinate and measurements by circuit-
5//! local id; the engine addresses both by dense index. Nothing about that
6//! translation depends on the shot, so it happens once — in [`prepare_ops`] —
7//! and every shot replays the result through
8//! [`Simulator::apply_batch`](crate::backend::Simulator::apply_batch).
9
10use bloq_ir::circuit::{
11    ConditionalCorrection, CoordCircuit, Op, Pauli as BloqPauli, PauliBasis, PauliMap,
12};
13use bloq_ir::lowering::validate_template_circuit;
14use glam::IVec2;
15use rustc_hash::FxHashMap;
16
17use super::ExecError;
18use super::gate_table::single_qubit_instruction;
19use crate::backend::{Instruction, Pauli, PauliBasis as EnginePauliBasis, PauliString, Simulator};
20
21/// The measurement outcomes of one shot, indexed by (global) measurement id.
22///
23/// Outcome `true` is the `−1` eigenvalue (a stim "1" record bit).
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct ShotRecord {
26    records: Vec<Option<bool>>,
27    aliases: FxHashMap<u32, u32>,
28}
29
30impl ShotRecord {
31    /// The outcome of measurement `id`, or `None` if it was not produced.
32    #[must_use]
33    pub fn get(&self, id: u32) -> Option<bool> {
34        let id = self.aliases.get(&id).copied().unwrap_or(id);
35        self.records.get(id as usize).copied().flatten()
36    }
37
38    /// The outcome of measurement `id`, erroring if it has not been produced.
39    ///
40    /// # Errors
41    /// [`ExecError::MissingRecord`] if `id` is absent.
42    pub fn require(&self, id: u32) -> Result<bool, ExecError> {
43        self.get(id).ok_or(ExecError::MissingRecord(id))
44    }
45
46    pub(crate) fn set(&mut self, id: u32, value: bool) {
47        let id = id as usize;
48        if id >= self.records.len() {
49            self.records.resize(id + 1, None);
50        }
51        self.records[id] = Some(value);
52    }
53
54    pub(crate) fn with_len(len: u32) -> Self {
55        Self {
56            records: vec![None; len as usize],
57            aliases: FxHashMap::default(),
58        }
59    }
60
61    /// Each selected plan maps virtual instance records directly to one
62    /// physical record. RUS snapshots retain this map with the outcomes.
63    pub(crate) fn select_aliases(&mut self, aliases: &[(u32, u32)]) {
64        self.aliases.extend(aliases.iter().copied());
65    }
66}
67
68/// How to resolve a circuit's qubit coordinates to engine indices: a coordinate
69/// layout plus a translation `offset` (a template instance's placement).
70pub(crate) struct LayoutCtx<'a> {
71    pub(crate) coord_to_index: &'a FxHashMap<IVec2, u32>,
72    pub(crate) offset: IVec2,
73    pub(crate) qubit_count: usize,
74}
75
76impl LayoutCtx<'_> {
77    /// Engine index of `coord + offset`.
78    pub(crate) fn index(&self, coord: IVec2) -> Result<usize, ExecError> {
79        let global = coord + self.offset;
80        self.coord_to_index
81            .get(&global)
82            .map(|&i| i as usize)
83            .ok_or(ExecError::UnknownCoord(global))
84    }
85}
86
87// ==============================================================================
88// Translation
89// ==============================================================================
90
91/// One op stream in engine form: the instructions, plus the global record id
92/// each of its measurements produces.
93///
94/// The two vectors are parallel by construction — `record_ids[i]` names the
95/// `i`-th [`Instruction::Measure`] of `instructions`, which is exactly the
96/// indexing [`crate::backend::BatchOutcome::records`] uses.
97#[derive(Debug, Clone, Default, PartialEq)]
98pub(crate) struct PreparedOps {
99    instructions: Vec<Instruction>,
100    record_ids: Vec<u32>,
101}
102
103impl PreparedOps {
104    /// Replay the stream on `sim`, binding each outcome to its global record
105    /// id. Returns the peak stabilizer rank the batch passed through.
106    ///
107    /// # Errors
108    /// Propagates engine errors. A failure aborts the batch where it happened
109    /// and no outcome is recorded, which is fine because an engine error ends
110    /// the run: nothing downstream ever reads a half-populated record set.
111    pub(crate) fn run(
112        &self,
113        sim: &mut Simulator,
114        record: &mut ShotRecord,
115    ) -> Result<usize, ExecError> {
116        let outcome = sim.apply_batch(&self.instructions)?;
117        debug_assert_eq!(
118            outcome.records.len(),
119            self.record_ids.len(),
120            "preparation counted the batch's measurements"
121        );
122        for (&id, result) in self.record_ids.iter().zip(&outcome.records) {
123            record.set(id, result.outcome);
124        }
125        Ok(outcome.max_rank)
126    }
127
128    /// The global record ids this stream produces, in batch order.
129    #[cfg(test)]
130    pub(crate) fn record_ids(&self) -> &[u32] {
131        &self.record_ids
132    }
133}
134
135/// Translate `ops` into engine instructions. `remap` maps a circuit-local
136/// measurement id to the id its outcome is recorded under (identity for a
137/// standalone circuit).
138///
139/// # Errors
140/// [`ExecError::UnflattenedRepeat`] if any repeat remains,
141/// [`ExecError::UnknownCoord`] if an operand is outside `layout`,
142/// [`ExecError::EmptyMppProduct`] for an empty MPP product, or
143/// [`ExecError::MissingRecord`] if a conditional correction reads a
144/// measurement the same stream has not produced yet.
145pub(crate) fn prepare_ops(
146    ops: &[Op],
147    layout: &LayoutCtx<'_>,
148    remap: &impl Fn(u32) -> u32,
149) -> Result<PreparedOps, ExecError> {
150    let mut prepared = PreparedOps::default();
151    // Circuit-local measurement id to its position in the batch's outcome
152    // vector, which is how a conditional correction names its control. Only
153    // ids this stream has already measured are in here, so a correction that
154    // reads ahead of itself is caught during preparation rather than once per
155    // shot.
156    let mut produced: FxHashMap<u32, usize> = FxHashMap::default();
157
158    for op in ops {
159        match op {
160            Op::Gate { gate, qubits } => {
161                if gate.is_two_qubit_gate() {
162                    let (control, target) =
163                        gate.two_qubit_bases().expect("two-qubit gate has bases");
164                    debug_assert!(qubits.len().is_multiple_of(2), "two-qubit gate arg count");
165                    for pair in qubits.as_chunks::<2>().0 {
166                        prepared.instructions.push(Instruction::Gate2 {
167                            control: sim_basis(control),
168                            target: sim_basis(target),
169                            control_qubit: layout.index(pair[0])?,
170                            target_qubit: layout.index(pair[1])?,
171                        });
172                    }
173                } else {
174                    for &coord in qubits {
175                        let index = layout.index(coord)?;
176                        prepared
177                            .instructions
178                            .extend(single_qubit_instruction(*gate, index));
179                    }
180                }
181            }
182            Op::Measure {
183                basis,
184                qubits,
185                measurements,
186                flip_probability,
187            } => {
188                for (coord, &id) in qubits.iter().zip(measurements) {
189                    let obs = basis_pauli(*basis, layout.index(*coord)?, layout.qubit_count);
190                    push_measurement(
191                        &mut prepared,
192                        &mut produced,
193                        id,
194                        remap(id),
195                        obs,
196                        *flip_probability,
197                    );
198                }
199            }
200            Op::MPP {
201                products,
202                measurements,
203            } => {
204                for (product, &id) in products.iter().zip(measurements) {
205                    let obs = product_pauli(product, layout)?;
206                    push_measurement(&mut prepared, &mut produced, id, remap(id), obs, 0.0);
207                }
208            }
209            Op::ConditionalPauli(corrections) => {
210                for correction in corrections {
211                    prepared
212                        .instructions
213                        .push(conditional(correction, layout, remap, &produced)?);
214                }
215            }
216            Op::Depolarize1 {
217                probability,
218                qubits,
219            } => {
220                let probabilities = vec![probability / 3.0; 3];
221                for &qubit in qubits {
222                    let qubit = layout.index(qubit)?;
223                    push_random_pauli(
224                        &mut prepared,
225                        probabilities.clone(),
226                        [Pauli::X, Pauli::Y, Pauli::Z]
227                            .map(|pauli| PauliString::single(layout.qubit_count, qubit, pauli))
228                            .into(),
229                    );
230                }
231            }
232            Op::Depolarize2 {
233                probability,
234                qubits,
235            } => {
236                debug_assert!(qubits.len().is_multiple_of(2), "two-qubit noise arg count");
237                for pair in qubits.as_chunks::<2>().0 {
238                    let first = layout.index(pair[0])?;
239                    let second = layout.index(pair[1])?;
240                    let mut alternatives = Vec::with_capacity(15);
241                    for a in [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z] {
242                        for b in [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z] {
243                            if a != Pauli::I || b != Pauli::I {
244                                alternatives.push(PauliString::from_terms(
245                                    layout.qubit_count,
246                                    [(first, a), (second, b)],
247                                ));
248                            }
249                        }
250                    }
251                    push_random_pauli(&mut prepared, vec![probability / 15.0; 15], alternatives);
252                }
253            }
254            Op::PauliError {
255                probability,
256                pauli,
257                qubits,
258            } => {
259                let pauli: Pauli = sim_basis(*pauli).into();
260                for &qubit in qubits {
261                    let alternative =
262                        PauliString::single(layout.qubit_count, layout.index(qubit)?, pauli);
263                    push_random_pauli(&mut prepared, vec![*probability], vec![alternative]);
264                }
265            }
266            Op::Tick => {}
267            Op::Repeat { .. } => return Err(ExecError::UnflattenedRepeat),
268        }
269    }
270    Ok(prepared)
271}
272
273fn push_measurement(
274    prepared: &mut PreparedOps,
275    produced: &mut FxHashMap<u32, usize>,
276    local: u32,
277    global: u32,
278    observable: PauliString,
279    flip_probability: f64,
280) {
281    produced.insert(local, prepared.record_ids.len());
282    prepared.record_ids.push(global);
283    if flip_probability == 0.0 {
284        prepared.instructions.push(Instruction::Measure(observable));
285    } else {
286        prepared
287            .instructions
288            .push(Instruction::MeasureWithReadoutError {
289                observable,
290                probability: flip_probability,
291            });
292    }
293}
294
295fn push_random_pauli(
296    prepared: &mut PreparedOps,
297    probabilities: Vec<f64>,
298    alternatives: Vec<PauliString>,
299) {
300    prepared.instructions.push(Instruction::RandomPauli {
301        probabilities,
302        alternatives,
303        heralded: false,
304    });
305}
306
307fn conditional(
308    correction: &ConditionalCorrection,
309    layout: &LayoutCtx<'_>,
310    remap: &impl Fn(u32) -> u32,
311    produced: &FxHashMap<u32, usize>,
312) -> Result<Instruction, ExecError> {
313    let control = *produced
314        .get(&correction.control)
315        .ok_or_else(|| ExecError::MissingRecord(remap(correction.control)))?;
316    Ok(Instruction::ConditionalPauli {
317        basis: sim_basis(correction.pauli),
318        qubit: layout.index(correction.target)?,
319        control,
320    })
321}
322
323/// Require a global identity for every record produced or consumed by `ops`.
324pub(crate) fn validate_record_remap(
325    ops: &[Op],
326    remap: &FxHashMap<u32, u32>,
327) -> Result<(), ExecError> {
328    for op in ops {
329        let records: &[u32] = match op {
330            Op::Measure { measurements, .. } | Op::MPP { measurements, .. } => measurements,
331            _ => &[],
332        };
333        for &record in records {
334            if !remap.contains_key(&record) {
335                return Err(ExecError::MissingRecordRemap(record));
336            }
337        }
338        if let Op::ConditionalPauli(corrections) = op {
339            for correction in corrections {
340                if !remap.contains_key(&correction.control) {
341                    return Err(ExecError::MissingRecordRemap(correction.control));
342                }
343            }
344        }
345    }
346    Ok(())
347}
348
349/// Build the joint Pauli observable of an MPP product.
350///
351/// The terms sit on distinct coordinates, so this is a set of sites, not a
352/// Pauli product: no phase bookkeeping, and a `PauliMap` never carries
353/// identity.
354pub(super) fn product_pauli(
355    product: &PauliMap,
356    layout: &LayoutCtx<'_>,
357) -> Result<PauliString, ExecError> {
358    if product.is_empty() {
359        return Err(ExecError::EmptyMppProduct);
360    }
361    prepare_boundary_operator(product, layout)
362}
363
364/// Prepare a positive-Hermitian boundary map once for exact physical folds.
365pub(super) fn prepare_boundary_operator(
366    product: &PauliMap,
367    layout: &LayoutCtx<'_>,
368) -> Result<PauliString, ExecError> {
369    let mut pauli = PauliString::new(layout.qubit_count);
370    for (coord, bloq) in product.iter() {
371        pauli.set(layout.index(*coord)?, bloq_pauli(*bloq));
372    }
373    Ok(pauli)
374}
375
376/// Pad an engine operator with trailing identity sites during program
377/// preparation. The coefficient is preserved exactly.
378pub(super) fn widen_pauli(pauli: &PauliString, nqubits: usize) -> PauliString {
379    debug_assert!(nqubits >= pauli.nqubits);
380    let mut widened = PauliString::from_terms(
381        nqubits,
382        (0..pauli.nqubits).map(|qubit| (qubit, pauli.get(qubit))),
383    );
384    widened.set_phase(pauli.phase_exponent());
385    widened
386}
387
388// ==============================================================================
389// Flat single-circuit executor
390// ==============================================================================
391
392/// Replays a flattened [`CoordCircuit`] against a [`Simulator`].
393///
394/// Qubit coordinates are mapped to dense engine indices once (sorted by
395/// `(x, y)`, matching [`CoordCircuit::build_coord_to_index`]); measurement ids
396/// are used as-is. For whole-program execution (global layout, per-instance
397/// offsets and id remaps) see [`run_bloq`](super::run_bloq).
398#[derive(Debug)]
399pub struct CircuitExecutor {
400    qubit_count: usize,
401    prepared: PreparedOps,
402}
403
404impl CircuitExecutor {
405    /// Build an executor over a **flattened** circuit (no [`Op::Repeat`]),
406    /// translating it to engine instructions up front.
407    ///
408    /// # Errors
409    ///
410    /// [`ExecError::UnflattenedRepeat`] if any repeat remains,
411    /// [`ExecError::InvalidCircuit`] if the circuit fails the same structural
412    /// preflight used by Bloq IR template emission, or any
413    /// circuit-preparation error.
414    ///
415    /// # Panics
416    ///
417    /// Panics only if a validated [`CoordCircuit`] lacks its required entry body.
418    pub fn new(circuit: &CoordCircuit) -> Result<Self, ExecError> {
419        if circuit.has_repeats() {
420            return Err(ExecError::UnflattenedRepeat);
421        }
422        validate_template_circuit(circuit).map_err(ExecError::InvalidCircuit)?;
423        let coord_to_index = circuit.build_coord_to_index();
424        let qubit_count = coord_to_index.len();
425        let layout = LayoutCtx {
426            coord_to_index: &coord_to_index,
427            offset: IVec2::ZERO,
428            qubit_count,
429        };
430        let body = circuit
431            .body(circuit.entry_body())
432            .expect("entry body always exists on a CoordCircuit");
433        let prepared = prepare_ops(body.ops(), &layout, &|id| id)?;
434        Ok(Self {
435            qubit_count,
436            prepared,
437        })
438    }
439
440    /// Number of distinct qubits (engine width needed).
441    #[must_use]
442    pub fn qubit_count(&self) -> usize {
443        self.qubit_count
444    }
445
446    /// Run one shot on `sim` (which must have at least [`qubit_count`] qubits),
447    /// consuming classical inputs for `Value`-slot conditional corrections.
448    ///
449    /// # Errors
450    /// See [`ExecError`]; propagates engine errors (e.g. rank overflow).
451    ///
452    /// [`qubit_count`]: Self::qubit_count
453    pub fn run_shot(&self, sim: &mut Simulator) -> Result<ShotRecord, ExecError> {
454        let mut record = ShotRecord::default();
455        self.prepared.run(sim, &mut record)?;
456        Ok(record)
457    }
458}
459
460/// A single-qubit basis observable at engine index `idx`.
461fn basis_pauli(basis: PauliBasis, idx: usize, n: usize) -> PauliString {
462    PauliString::single(n, idx, sim_basis(basis).into())
463}
464
465/// The engine's spelling of a Bloq IR Pauli (never identity in a `PauliMap`).
466pub(crate) fn bloq_pauli(pauli: BloqPauli) -> Pauli {
467    match pauli {
468        BloqPauli::X => Pauli::X,
469        BloqPauli::Y => Pauli::Y,
470        BloqPauli::Z => Pauli::Z,
471        BloqPauli::I => unreachable!("PauliMap omits identity terms"),
472    }
473}
474
475/// The engine's spelling of a layout basis. Both enums name the same three
476/// axes; only the workspace's copy carries the serde derives the IR needs.
477pub(super) fn sim_basis(basis: PauliBasis) -> EnginePauliBasis {
478    match basis {
479        PauliBasis::X => EnginePauliBasis::X,
480        PauliBasis::Y => EnginePauliBasis::Y,
481        PauliBasis::Z => EnginePauliBasis::Z,
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use bloq_ir::circuit::{GateType, Pauli};
489    use bloq_ir::lowering::NodeTemplateInstanceMergeError;
490
491    /// Push a raw op onto the entry body (for ops without a `CoordCircuit`
492    /// helper).
493    fn push_op(circuit: &mut CoordCircuit, op: Op) {
494        let entry = circuit.entry_body();
495        circuit
496            .body_mut(entry)
497            .expect("entry body")
498            .ops_mut()
499            .push(op);
500    }
501
502    fn invalid_executor(op: Op) -> ExecError {
503        let mut circuit = CoordCircuit::new();
504        push_op(&mut circuit, op);
505        match CircuitExecutor::new(&circuit) {
506            Err(error) => error,
507            Ok(_) => panic!("malformed circuit was accepted"),
508        }
509    }
510
511    #[test]
512    fn rejects_malformed_operation_arities() {
513        let q = IVec2::ZERO;
514        assert!(matches!(
515            invalid_executor(Op::Gate {
516                gate: GateType::CX,
517                qubits: vec![q],
518            }),
519            ExecError::InvalidCircuit(NodeTemplateInstanceMergeError::OddTwoQubitTargetCount {
520                gate: GateType::CX,
521                targets: 1,
522            })
523        ));
524        assert!(matches!(
525            invalid_executor(Op::Measure {
526                basis: PauliBasis::Z,
527                qubits: vec![q],
528                measurements: vec![],
529                flip_probability: 0.0,
530            }),
531            ExecError::InvalidCircuit(NodeTemplateInstanceMergeError::MeasureOutputCountMismatch {
532                qubits: 1,
533                measurements: 0,
534            })
535        ));
536        assert!(matches!(
537            invalid_executor(Op::MPP {
538                products: vec![[(q, Pauli::Z)].into_iter().collect()],
539                measurements: vec![],
540            }),
541            ExecError::InvalidCircuit(
542                NodeTemplateInstanceMergeError::MppMeasurementCountMismatch {
543                    products: 1,
544                    measurements: 0,
545                },
546            )
547        ));
548        assert!(matches!(
549            invalid_executor(Op::Gate {
550                gate: GateType::CX,
551                qubits: vec![q, q],
552            }),
553            ExecError::InvalidCircuit(
554                NodeTemplateInstanceMergeError::RepeatedTwoQubitGateTarget {
555                    gate: GateType::CX,
556                    qubit,
557                }
558            ) if qubit == q
559        ));
560        assert!(matches!(
561            invalid_executor(Op::Depolarize2 {
562                probability: 0.1,
563                qubits: vec![q, q],
564            }),
565            ExecError::InvalidCircuit(
566                NodeTemplateInstanceMergeError::RepeatedDepolarize2Target(qubit)
567            ) if qubit == q
568        ));
569    }
570
571    #[test]
572    fn missing_record_remap_is_typed() {
573        let ops = [Op::Measure {
574            basis: PauliBasis::Z,
575            qubits: vec![IVec2::ZERO],
576            measurements: vec![7],
577            flip_probability: 0.0,
578        }];
579
580        assert!(matches!(
581            validate_record_remap(&ops, &FxHashMap::default()),
582            Err(ExecError::MissingRecordRemap(7))
583        ));
584    }
585
586    /// A correction whose control has not been measured yet is an out-of-order
587    /// stream, and preparation is where that is caught — once, rather than on
588    /// every shot that reaches the correction. The reported id is the *global*
589    /// one, after applying the circuit-local record remap.
590    #[test]
591    fn conditional_pauli_reading_ahead_is_rejected_at_preparation() {
592        let q = IVec2::ZERO;
593        let coord_to_index: FxHashMap<IVec2, u32> = [(q, 0)].into_iter().collect();
594        let layout = LayoutCtx {
595            coord_to_index: &coord_to_index,
596            offset: IVec2::ZERO,
597            qubit_count: 1,
598        };
599        let correction = Op::ConditionalPauli(vec![ConditionalCorrection {
600            pauli: PauliBasis::X,
601            control: 4,
602            target: q,
603        }]);
604        let measure = Op::Measure {
605            basis: PauliBasis::Z,
606            qubits: vec![q],
607            measurements: vec![4],
608            flip_probability: 0.0,
609        };
610
611        assert!(matches!(
612            prepare_ops(&[correction.clone(), measure.clone()], &layout, &|id| id
613                + 10),
614            Err(ExecError::MissingRecord(14))
615        ));
616        // The same two ops in emit order translate cleanly.
617        let prepared = prepare_ops(&[measure, correction], &layout, &|id| id + 10)
618            .expect("a correction after its control resolves");
619        assert_eq!(prepared.record_ids(), [14]);
620    }
621
622    /// A measurement-controlled Pauli really fires mid-stream: `H`, `MZ`, then
623    /// an `X` conditioned on that record puts the qubit back in `|0⟩`, so the
624    /// second `MZ` reads `0` whichever way the first one landed.
625    #[test]
626    fn conditional_pauli_corrects_the_measured_qubit() {
627        let data = IVec2::ZERO;
628        let mut circuit = CoordCircuit::new();
629        circuit.do_gate(GateType::H, [data]).expect("H is valid");
630        let m0 = circuit.measure(PauliBasis::Z, [data])[0];
631        push_op(
632            &mut circuit,
633            Op::ConditionalPauli(vec![ConditionalCorrection {
634                pauli: PauliBasis::X,
635                control: m0,
636                target: data,
637            }]),
638        );
639        let m1 = circuit.measure(PauliBasis::Z, [data])[0];
640
641        let executor = CircuitExecutor::new(&circuit).expect("valid circuit");
642        let mut corrected_something = false;
643        for seed in 0..16 {
644            let mut sim = Simulator::with_seed(executor.qubit_count(), seed);
645            let record = executor.run_shot(&mut sim).expect("shot runs");
646            assert_eq!(record.get(m1), Some(false), "the correction restores |0>");
647            corrected_something |= record.get(m0) == Some(true);
648        }
649        assert!(
650            corrected_something,
651            "the uncorrected outcome should vary across seeds"
652        );
653    }
654
655    #[test]
656    fn explicit_noise_ops_lower_to_backend_channels() {
657        let q0 = IVec2::ZERO;
658        let q1 = IVec2::X;
659        let coord_to_index = [(q0, 0), (q1, 1)].into_iter().collect();
660        let layout = LayoutCtx {
661            coord_to_index: &coord_to_index,
662            offset: IVec2::ZERO,
663            qubit_count: 2,
664        };
665        let prepared = prepare_ops(
666            &[
667                Op::Depolarize1 {
668                    probability: 0.3,
669                    qubits: vec![q0],
670                },
671                Op::Depolarize2 {
672                    probability: 0.15,
673                    qubits: vec![q0, q1],
674                },
675                Op::PauliError {
676                    probability: 0.4,
677                    pauli: PauliBasis::Y,
678                    qubits: vec![q1],
679                },
680                Op::Measure {
681                    basis: PauliBasis::Z,
682                    qubits: vec![q0],
683                    measurements: vec![0],
684                    flip_probability: 0.25,
685                },
686            ],
687            &layout,
688            &|id| id,
689        )
690        .unwrap();
691
692        let channel_sizes: Vec<_> = prepared
693            .instructions
694            .iter()
695            .filter_map(|instruction| match instruction {
696                Instruction::RandomPauli {
697                    probabilities,
698                    alternatives,
699                    heralded,
700                } => Some((probabilities.len(), alternatives.len(), *heralded)),
701                _ => None,
702            })
703            .collect();
704        assert_eq!(
705            channel_sizes,
706            [(3, 3, false), (15, 15, false), (1, 1, false)]
707        );
708        assert!(matches!(
709            prepared.instructions.last(),
710            Some(Instruction::MeasureWithReadoutError {
711                probability,
712                ..
713            }) if *probability == 0.25
714        ));
715        assert_eq!(prepared.record_ids(), [0]);
716    }
717
718    #[test]
719    fn pauli_error_changes_state_while_readout_error_only_changes_record() {
720        let qubit = IVec2::ZERO;
721        let mut circuit = CoordCircuit::new();
722        push_op(
723            &mut circuit,
724            Op::PauliError {
725                probability: 1.0,
726                pauli: PauliBasis::X,
727                qubits: vec![qubit],
728            },
729        );
730        let measurement = circuit.measure(PauliBasis::Z, [qubit])[0];
731        let entry = circuit.entry_body();
732        let Op::Measure {
733            flip_probability, ..
734        } = circuit
735            .body_mut(entry)
736            .expect("entry body")
737            .ops_mut()
738            .last_mut()
739            .expect("measurement")
740        else {
741            panic!("last op is the measurement just appended");
742        };
743        *flip_probability = 1.0;
744
745        let executor = CircuitExecutor::new(&circuit).expect("valid noisy circuit");
746        let mut simulator = Simulator::with_seed(executor.qubit_count(), 7);
747        let record = executor.run_shot(&mut simulator).unwrap();
748        assert_eq!(record.get(measurement), Some(false));
749        assert_eq!(simulator.peek_z(0).unwrap(), -1.0);
750    }
751
752    /// `MPP` measures a genuine joint observable rather than its factors: on a
753    /// Bell pair `X⊗X` is a stabilizer, so it reads `+1` and agrees with a
754    /// repeat, even though each single-qubit `X` is random.
755    #[test]
756    fn mpp_reads_a_bell_stabilizer() {
757        let (d0, d1) = (IVec2::ZERO, IVec2::new(1, 0));
758        let mut circuit = CoordCircuit::new();
759        circuit.do_gate(GateType::H, [d0]).expect("H is valid");
760        circuit
761            .do_gate(GateType::CX, [d0, d1])
762            .expect("CX is valid");
763        let xx = || PauliMap::from_iter([(d0, Pauli::X), (d1, Pauli::X)]);
764        let first = circuit.measure_pauli_products([xx()]).expect("XX product")[0];
765        let again = circuit.measure_pauli_products([xx()]).expect("XX product")[0];
766
767        let executor = CircuitExecutor::new(&circuit).expect("valid circuit");
768        let mut sim = Simulator::with_seed(executor.qubit_count(), 7);
769        let record = executor.run_shot(&mut sim).expect("shot runs");
770        assert_eq!(record.get(first), Some(false), "XX stabilizes a Bell pair");
771        assert_eq!(record.get(again), record.get(first));
772    }
773}