Skip to main content

bloq_circuit/
noise.rs

1//! Circuit noise models and their materialization as explicit operations.
2//!
3//! Applying a [`NoiseModel`] produces an ordinary [`CoordCircuit`] containing
4//! depolarizing, Pauli-error, and measurement-flip instructions. Backends,
5//! simulators, and decoders can therefore consume the same operation stream.
6
7use glam::IVec2;
8use rustc_hash::{FxHashMap, FxHashSet};
9
10use crate::{BodyId, CircuitError, CoordCircuit, GateType, Op, PauliBasis};
11
12/// Circuit-level Pauli noise probabilities materialized by
13/// [`NoiseModel::noisy_circuit`].
14#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
15pub struct NoiseModel {
16    /// Single-qubit depolarizing probability after each noisy 1q gate.
17    pub p1: f64,
18    /// Two-qubit depolarizing probability after each 2q gate.
19    pub p2: f64,
20    /// Measurement result-flip probability.
21    pub p_meas: f64,
22    /// Orthogonal-state preparation probability after each reset.
23    pub p_reset: f64,
24    /// Per-tick single-qubit depolarizing probability on idle footprint qubits.
25    pub p_idle: f64,
26}
27
28impl NoiseModel {
29    /// Sets every channel to `probability`.
30    #[must_use]
31    pub const fn uniform_depolarizing(probability: f64) -> Self {
32        Self {
33            p1: probability,
34            p2: probability,
35            p_meas: probability,
36            p_reset: probability,
37            p_idle: probability,
38        }
39    }
40
41    /// Whether idle noise needs repeat expansion to account for moments that
42    /// cross a repeat boundary. Annotation owners must flatten their side tables
43    /// together with the circuit before applying noise in this case.
44    pub fn requires_repeat_flattening(&self, circuit: &CoordCircuit) -> bool {
45        self.p_idle != 0.0 && idle_body_shape(circuit, circuit.entry_body()).is_none()
46    }
47
48    /// Clone `circuit` and materialize this model as explicit noise operations.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`CircuitError`] if repeat expansion fails.
53    pub fn noisy_circuit(&self, circuit: &CoordCircuit) -> Result<CoordCircuit, CircuitError> {
54        self.noisy_circuit_excluding(circuit, &[])
55    }
56
57    /// [`Self::noisy_circuit`] while keeping `ideal_qubits` free of
58    /// generated or pre-existing error channels. A two-qubit channel is
59    /// removed when either member of its pair is ideal. Repeats sharing a moment
60    /// with their surroundings are flattened; final measurement ids are retained.
61    ///
62    /// # Errors
63    ///
64    /// Returns the circuit's flatten error if a repeat needing expansion is
65    /// invalid, exhausts measurement ids, or exceeds the expansion allowance.
66    /// Returns [`CircuitError::BodyIdOutOfRange`] if a body index exceeds `u32::MAX`.
67    pub fn noisy_circuit_excluding(
68        &self,
69        circuit: &CoordCircuit,
70        ideal_qubits: &[IVec2],
71    ) -> Result<CoordCircuit, CircuitError> {
72        let mut noisy = circuit.clone();
73        if self.requires_repeat_flattening(circuit) {
74            // ponytail: partial moments expand; use loop peeling if large partial-moment loops matter.
75            noisy.flatten()?;
76        }
77        let footprint = noisy.qubits();
78        let ideal_qubits = ideal_qubits.iter().copied().collect::<FxHashSet<_>>();
79        for index in 0..noisy.body_count() {
80            let body = crate::circuit::checked_body_id(index)?;
81            annotate_body(&mut noisy, body, &footprint, &ideal_qubits, self);
82        }
83        Ok(noisy)
84    }
85}
86
87// `Some(active)` means body-local noising is sound and reports whether the
88// body's last moment is still active. A repeat entered or left inside a moment
89// needs expansion: its first iteration may have different idle qubits from later
90// iterations, so one shared body cannot hold the right channels for both.
91fn idle_body_shape(circuit: &CoordCircuit, entry: BodyId) -> Option<bool> {
92    let mut cache = FxHashMap::default();
93    let mut visiting = FxHashSet::default();
94    visiting.insert(entry);
95    let mut pending = vec![(entry, 0usize, false)];
96    while let Some((body, next, active)) = pending.last_mut() {
97        let Some(op) = circuit.body(*body)?.ops().get(*next) else {
98            cache.insert(*body, *active);
99            visiting.remove(body);
100            pending.pop();
101            continue;
102        };
103        *next += 1;
104        match op {
105            Op::Repeat { body, repetitions } if *repetitions != 0 => {
106                if *active {
107                    return None;
108                }
109                if let Some(&unfinished) = cache.get(body) {
110                    if unfinished {
111                        return None;
112                    }
113                } else {
114                    if !visiting.insert(*body) {
115                        return None;
116                    }
117                    // Revisit this repeat once its body shape is known.
118                    *next -= 1;
119                    pending.push((*body, 0, false));
120                }
121            }
122            Op::Tick => *active = false,
123            Op::Gate { qubits, .. } | Op::Measure { qubits, .. } => *active |= !qubits.is_empty(),
124            Op::MPP { products, .. } => {
125                *active |= products.iter().any(|product| !product.is_empty())
126            }
127            Op::ConditionalPauli(corrections) => *active |= !corrections.is_empty(),
128            Op::Repeat { .. }
129            | Op::Depolarize1 { .. }
130            | Op::Depolarize2 { .. }
131            | Op::PauliError { .. } => {}
132        }
133    }
134    cache.get(&entry).copied()
135}
136
137fn annotate_body(
138    circuit: &mut CoordCircuit,
139    body: BodyId,
140    footprint: &FxHashSet<IVec2>,
141    ideal_qubits: &FxHashSet<IVec2>,
142    noise: &NoiseModel,
143) {
144    let body = circuit
145        .body_mut(body)
146        .expect("body id comes from this circuit's dense body range");
147    let source = std::mem::take(body.ops_mut());
148    let mut ops = Vec::with_capacity(source.len() * 2);
149    let mut active = FxHashSet::default();
150
151    for mut op in source {
152        match &mut op {
153            Op::Gate { gate, qubits } => {
154                // Idling qubits count as active either way, so the tick closing
155                // the moment cannot stack `p_idle` on top of whichever channel
156                // this gate earned.
157                active.extend(qubits.iter().copied());
158                let error = gate_noise(*gate, qubits, ideal_qubits, noise);
159                ops.push(op);
160                if let Some(error) = error {
161                    ops.push(error);
162                }
163            }
164            Op::Measure {
165                basis,
166                qubits,
167                measurements,
168                flip_probability,
169            } => {
170                active.extend(qubits.iter().copied());
171                if qubits.iter().any(|qubit| ideal_qubits.contains(qubit)) {
172                    for (&qubit, &measurement) in qubits.iter().zip(measurements.iter()) {
173                        ops.push(Op::Measure {
174                            basis: *basis,
175                            qubits: vec![qubit],
176                            measurements: vec![measurement],
177                            flip_probability: if ideal_qubits.contains(&qubit) {
178                                0.0
179                            } else {
180                                compose_flip_probabilities(*flip_probability, noise.p_meas)
181                            },
182                        });
183                    }
184                } else {
185                    *flip_probability = compose_flip_probabilities(*flip_probability, noise.p_meas);
186                    ops.push(op);
187                }
188            }
189            Op::MPP { products, .. } => {
190                active.extend(
191                    products
192                        .iter()
193                        .flat_map(|product| product.iter().map(|(coord, _)| *coord)),
194                );
195                ops.push(op);
196            }
197            Op::ConditionalPauli(corrections) => {
198                active.extend(corrections.iter().map(|correction| correction.target));
199                ops.push(op);
200            }
201            Op::Tick => {
202                ops.push(op);
203                if noise.p_idle != 0.0 {
204                    let mut idle: Vec<_> = footprint
205                        .difference(&active)
206                        .filter(|qubit| !ideal_qubits.contains(*qubit))
207                        .copied()
208                        .collect();
209                    idle.sort_unstable_by_key(|coord| (coord.x, coord.y));
210                    if !idle.is_empty() {
211                        ops.push(Op::Depolarize1 {
212                            probability: noise.p_idle,
213                            qubits: idle,
214                        });
215                    }
216                }
217                active.clear();
218            }
219            Op::Depolarize1 { qubits, .. } | Op::PauliError { qubits, .. } => {
220                *qubits = nonideal_qubits(qubits, ideal_qubits);
221                if !qubits.is_empty() {
222                    ops.push(op);
223                }
224            }
225            Op::Depolarize2 { qubits, .. } => {
226                *qubits = nonideal_pairs(qubits, ideal_qubits);
227                if !qubits.is_empty() {
228                    ops.push(op);
229                }
230            }
231            Op::Repeat { .. } => ops.push(op),
232        }
233    }
234
235    *body.ops_mut() = ops;
236}
237
238fn gate_noise(
239    gate: GateType,
240    qubits: &[IVec2],
241    ideal_qubits: &FxHashSet<IVec2>,
242    noise: &NoiseModel,
243) -> Option<Op> {
244    if gate.is_reset() {
245        let qubits = nonideal_qubits(qubits, ideal_qubits);
246        return (noise.p_reset != 0.0 && !qubits.is_empty()).then(|| Op::PauliError {
247            probability: noise.p_reset,
248            pauli: match gate {
249                GateType::RX => PauliBasis::Z,
250                GateType::RY | GateType::RZ => PauliBasis::X,
251                _ => unreachable!("is_reset is exactly RX/RY/RZ"),
252            },
253            qubits,
254        });
255    }
256    if gate.is_two_qubit_gate() {
257        let qubits = nonideal_pairs(qubits, ideal_qubits);
258        return (noise.p2 != 0.0 && !qubits.is_empty()).then(|| Op::Depolarize2 {
259            probability: noise.p2,
260            qubits,
261        });
262    }
263    let qubits = nonideal_qubits(qubits, ideal_qubits);
264    (noise.p1 != 0.0 && !qubits.is_empty()).then(|| Op::Depolarize1 {
265        probability: noise.p1,
266        qubits,
267    })
268}
269
270fn nonideal_qubits(qubits: &[IVec2], ideal: &FxHashSet<IVec2>) -> Vec<IVec2> {
271    qubits
272        .iter()
273        .filter(|qubit| !ideal.contains(qubit))
274        .copied()
275        .collect()
276}
277
278fn nonideal_pairs(qubits: &[IVec2], ideal: &FxHashSet<IVec2>) -> Vec<IVec2> {
279    qubits
280        .as_chunks::<2>()
281        .0
282        .iter()
283        .filter(|pair| pair.iter().all(|qubit| !ideal.contains(qubit)))
284        .flatten()
285        .copied()
286        .collect()
287}
288
289fn compose_flip_probabilities(left: f64, right: f64) -> f64 {
290    left + right - 2.0 * left * right
291}
292
293#[cfg(test)]
294mod tests {
295    use glam::ivec2;
296
297    use super::*;
298
299    #[test]
300    fn idle_noise_propagates_expansion_limits_without_panicking() {
301        let mut circuit = CoordCircuit::new();
302        let body = circuit.add_body(crate::CircuitBody::from_ops(vec![Op::Gate {
303            gate: GateType::H,
304            qubits: vec![IVec2::ZERO],
305        }]));
306        circuit.push_repeat(body, u32::MAX);
307        let noise = NoiseModel::uniform_depolarizing(0.1);
308        assert!(matches!(
309            noise.noisy_circuit(&circuit),
310            Err(CircuitError::FlattenResourceLimit { .. })
311        ));
312    }
313
314    #[test]
315    fn idle_noise_is_unchanged_by_repeat_expansion() {
316        let a = ivec2(0, 0);
317        let b = ivec2(1, 0);
318        let noise = NoiseModel {
319            p_idle: 0.125,
320            ..NoiseModel::uniform_depolarizing(0.0)
321        };
322        for close_body in [false, true] {
323            for repetitions in [1, 3] {
324                let mut circuit = CoordCircuit::new();
325                circuit.do_gate(GateType::H, [a]).unwrap();
326                let mut ops = vec![Op::Gate {
327                    gate: GateType::H,
328                    qubits: vec![b],
329                }];
330                if close_body {
331                    ops.push(Op::Tick);
332                }
333                let body = circuit.add_body(crate::CircuitBody::from_ops(ops));
334                circuit.push_repeat(body, repetitions);
335                circuit.tick();
336                let mut flat = circuit.clone();
337                flat.flatten().unwrap();
338                let expected = noise.noisy_circuit(&flat).unwrap();
339                let mut actual = noise.noisy_circuit(&circuit).unwrap();
340                actual.flatten().unwrap();
341                assert_eq!(
342                    actual.body(actual.entry_body()).unwrap().ops(),
343                    expected.body(expected.entry_body()).unwrap().ops()
344                );
345            }
346        }
347
348        let mut closed = CoordCircuit::new();
349        closed.do_gate(GateType::H, [a]).unwrap();
350        closed.tick();
351        let body = closed.add_body(crate::CircuitBody::from_ops(vec![
352            Op::Gate {
353                gate: GateType::H,
354                qubits: vec![b],
355            },
356            Op::Tick,
357        ]));
358        closed.push_repeat(body, 100);
359        assert!(!noise.requires_repeat_flattening(&closed));
360        assert_eq!(
361            noise
362                .noisy_circuit(&closed)
363                .unwrap()
364                .entry_top_level_repeats(),
365            [100]
366        );
367    }
368
369    #[test]
370    fn model_materializes_gate_reset_measurement_and_idle_noise() {
371        let q0 = ivec2(0, 0);
372        let q1 = ivec2(1, 0);
373        let mut circuit = CoordCircuit::new();
374        circuit.do_gate(GateType::RZ, [q0]).unwrap();
375        circuit.tick();
376        circuit.do_gate(GateType::H, [q0]).unwrap();
377        circuit.do_gate(GateType::CX, [q0, q1]).unwrap();
378        circuit.measure(PauliBasis::Z, [q0]);
379
380        let noisy = NoiseModel {
381            p1: 0.1,
382            p2: 0.2,
383            p_meas: 0.3,
384            p_reset: 0.4,
385            p_idle: 0.5,
386        }
387        .noisy_circuit(&circuit)
388        .unwrap();
389        let ops = noisy.body(noisy.entry_body()).unwrap().ops();
390
391        assert!(matches!(
392            &ops[1],
393            Op::PauliError { probability, pauli: PauliBasis::X, qubits }
394                if *probability == 0.4 && qubits == &[q0]
395        ));
396        assert!(matches!(
397            &ops[3],
398            Op::Depolarize1 { probability, qubits }
399                if *probability == 0.5 && qubits == &[q1]
400        ));
401        assert!(ops.iter().any(|op| matches!(
402            op,
403            Op::Depolarize1 { probability, qubits }
404                if *probability == 0.1 && qubits == &[q0]
405        )));
406        assert!(ops.iter().any(|op| matches!(
407            op,
408            Op::Depolarize2 { probability, qubits }
409                if *probability == 0.2 && qubits == &[q0, q1]
410        )));
411        assert!(ops.iter().any(|op| matches!(
412            op,
413            Op::Measure { flip_probability, .. } if *flip_probability == 0.3
414        )));
415    }
416
417    #[test]
418    fn applying_zero_noise_keeps_operation_stream_unchanged() {
419        let mut circuit = CoordCircuit::new();
420        circuit.do_gate(GateType::H, [IVec2::ZERO]).unwrap();
421        circuit.measure(PauliBasis::Z, [IVec2::ZERO]);
422        let expected = circuit.body(circuit.entry_body()).unwrap().ops().to_vec();
423
424        let noisy = NoiseModel::uniform_depolarizing(0.0)
425            .noisy_circuit(&circuit)
426            .unwrap();
427
428        assert_eq!(noisy.body(noisy.entry_body()).unwrap().ops(), expected);
429    }
430
431    #[test]
432    fn excluded_qubits_receive_no_error_channel() {
433        let ideal = ivec2(0, 0);
434        let real = ivec2(1, 0);
435        let other = ivec2(2, 0);
436        let mut circuit = CoordCircuit::new();
437        circuit.do_gate(GateType::RZ, [ideal, real]).unwrap();
438        circuit.do_gate(GateType::H, [ideal, real]).unwrap();
439        circuit.do_gate(GateType::CX, [ideal, real]).unwrap();
440        circuit.do_gate(GateType::CX, [real, other]).unwrap();
441        circuit.measure(PauliBasis::Z, [ideal, real]);
442
443        let noisy = NoiseModel::uniform_depolarizing(0.1)
444            .noisy_circuit_excluding(&circuit, &[ideal])
445            .unwrap();
446        let ops = noisy.body(noisy.entry_body()).unwrap().ops();
447
448        for op in ops {
449            match op {
450                Op::Depolarize1 { qubits, .. } | Op::PauliError { qubits, .. } => {
451                    assert!(!qubits.contains(&ideal), "{op:?}");
452                }
453                Op::Depolarize2 { qubits, .. } => {
454                    assert!(
455                        qubits
456                            .as_chunks::<2>()
457                            .0
458                            .iter()
459                            .all(|pair| !pair.contains(&ideal)),
460                        "{op:?}"
461                    );
462                }
463                Op::Measure {
464                    qubits,
465                    flip_probability,
466                    ..
467                } if qubits == &[ideal] => assert_eq!(*flip_probability, 0.0),
468                _ => {}
469            }
470        }
471        assert!(ops.iter().any(|op| matches!(
472            op,
473            Op::Depolarize2 { qubits, .. } if qubits == &[real, other]
474        )));
475        assert!(ops.iter().any(|op| matches!(
476            op,
477            Op::Measure { qubits, flip_probability, .. }
478                if qubits == &[real] && *flip_probability > 0.0
479        )));
480    }
481}