Skip to main content

prism_q/qec/
dem.rs

1//! Detector error model derivation and text export for native QEC programs.
2//!
3//! Expands each Pauli-noise annotation into its fault branches, propagates
4//! every branch to the detectors and observables it flips, and merges branches
5//! with identical symptoms into weighted error mechanisms.
6
7use std::collections::HashMap;
8
9use super::noise::{
10    QecDeferredNoiseEvent, append_qec_pauli_noise_effect, lower_qec_program_to_deferred_circuit,
11    walk_qec_noise_sensitivity,
12};
13use super::{QecNoise, QecOp, QecProgram};
14use crate::error::{PrismError, Result};
15use crate::sim::compiled::xor_words;
16
17/// One error mechanism: an independent fault process that flips a fixed set of
18/// detectors and observables with the given probability.
19#[derive(Debug, Clone, PartialEq)]
20pub struct ErrorMechanism {
21    probability: f64,
22    detectors: Vec<usize>,
23    observables: Vec<usize>,
24}
25
26impl ErrorMechanism {
27    pub fn probability(&self) -> f64 {
28        self.probability
29    }
30
31    /// Detector indices this mechanism flips, ascending.
32    pub fn detectors(&self) -> &[usize] {
33        &self.detectors
34    }
35
36    /// Observable indices this mechanism flips, ascending.
37    pub fn observables(&self) -> &[usize] {
38        &self.observables
39    }
40}
41
42/// Detector error model: independent error mechanisms over a program's
43/// detectors and observables.
44///
45/// Derived by [`QecProgram::detector_error_model`]. Mechanisms are ordered by
46/// the program position of the noise annotation that first produced each
47/// symptom. Detector coordinates are carried verbatim from the program's
48/// detector ops, one entry per detector, empty when the op carried none.
49#[derive(Debug, Clone, PartialEq)]
50pub struct DetectorErrorModel {
51    mechanisms: Vec<ErrorMechanism>,
52    detector_coords: Vec<Vec<f64>>,
53    num_detectors: usize,
54    num_observables: usize,
55}
56
57impl DetectorErrorModel {
58    pub fn mechanisms(&self) -> &[ErrorMechanism] {
59        &self.mechanisms
60    }
61
62    pub fn num_mechanisms(&self) -> usize {
63        self.mechanisms.len()
64    }
65
66    pub fn num_detectors(&self) -> usize {
67        self.num_detectors
68    }
69
70    pub fn num_observables(&self) -> usize {
71        self.num_observables
72    }
73
74    /// Coordinates per detector, in detector order. Empty for detectors whose
75    /// op carried no coordinates.
76    pub fn detector_coords(&self) -> &[Vec<f64>] {
77        &self.detector_coords
78    }
79
80    /// Decompose hypergraph mechanisms into graphlike components.
81    ///
82    /// Returns a model in which every mechanism flips at most two detectors.
83    /// A mechanism with more is replaced by existing graphlike mechanisms
84    /// whose non-empty detector sets partition its detectors and whose
85    /// observable XOR matches; its probability composes into every component
86    /// as `p = p1(1-p2) + p2(1-p1)`. Cross-component correlations are lost;
87    /// single-detector marginals are unchanged. Deterministic: retained
88    /// mechanisms keep their order, the first cover in mechanism order wins.
89    ///
90    /// # Errors
91    ///
92    /// A hypergraph mechanism with no cover; the error names its symptom.
93    pub fn decompose_graphlike(&self) -> Result<DetectorErrorModel> {
94        let mut graphlike: Vec<ErrorMechanism> = Vec::new();
95        for mechanism in &self.mechanisms {
96            if mechanism.detectors.len() <= 2 {
97                graphlike.push(mechanism.clone());
98            }
99        }
100
101        for mechanism in &self.mechanisms {
102            if mechanism.detectors.len() <= 2 {
103                continue;
104            }
105            let Some(components) = partition_cover(mechanism, &graphlike) else {
106                return Err(PrismError::InvalidParameter {
107                    message: format!(
108                        "graphlike decomposition failed: mechanism `{}` has no \
109                         cover by graphlike mechanisms",
110                        symptom_label(mechanism)
111                    ),
112                });
113            };
114            let p = mechanism.probability;
115            for at in components {
116                let prior = graphlike[at].probability;
117                graphlike[at].probability = prior * (1.0 - p) + p * (1.0 - prior);
118            }
119        }
120
121        Ok(DetectorErrorModel {
122            mechanisms: graphlike,
123            detector_coords: self.detector_coords.clone(),
124            num_detectors: self.num_detectors,
125            num_observables: self.num_observables,
126        })
127    }
128
129    /// Render the model in the common detector error model text format.
130    ///
131    /// One `error(p) D.. L..` line per mechanism in mechanism order, then one
132    /// `detector` line per detector (with its coordinates when present), then
133    /// one `logical_observable` line per observable slot. The grammar is
134    /// documented in `docs/architecture/qec-programs.md`.
135    pub fn to_text(&self) -> String {
136        let mut out = String::new();
137        for mechanism in &self.mechanisms {
138            out.push_str(&format!("error({})", mechanism.probability));
139            for detector in &mechanism.detectors {
140                out.push_str(&format!(" D{detector}"));
141            }
142            for observable in &mechanism.observables {
143                out.push_str(&format!(" L{observable}"));
144            }
145            out.push('\n');
146        }
147        for (detector, coords) in self.detector_coords.iter().enumerate() {
148            if coords.is_empty() {
149                out.push_str(&format!("detector D{detector}\n"));
150            } else {
151                let coords = coords
152                    .iter()
153                    .map(f64::to_string)
154                    .collect::<Vec<_>>()
155                    .join(", ");
156                out.push_str(&format!("detector({coords}) D{detector}\n"));
157            }
158        }
159        for observable in 0..self.num_observables {
160            out.push_str(&format!("logical_observable L{observable}\n"));
161        }
162        out
163    }
164}
165
166impl QecProgram {
167    /// Derive the detector error model implied by the program's Pauli-noise
168    /// annotations, detectors, and observables.
169    ///
170    /// Every annotation expands into its Pauli fault branches per fault site
171    /// (one branch per target for `X_ERROR` / `Z_ERROR`, three per target for
172    /// `DEPOLARIZE1`, fifteen per target pair for `DEPOLARIZE2`). Each branch
173    /// is propagated through the circuit to the set of detectors and
174    /// observables it flips. Mutually exclusive branches at one fault site
175    /// with the same symptom sum; independent fault sites (distinct targets
176    /// of one annotation included) with the same symptom compose as
177    /// `p = p1(1-p2) + p2(1-p1)`. Faults that flip no detector and no
178    /// observable are omitted. Mechanisms are independent in the model, so
179    /// its joint statistics agree with the sampler to second order in the
180    /// branch probabilities.
181    ///
182    /// # Errors
183    ///
184    /// Requires the compiled Clifford path: non-Clifford gates and reuse of a
185    /// measured qubit without reset are rejected.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use prism_q::QecProgram;
191    ///
192    /// let program = QecProgram::from_text(
193    ///     "X_ERROR(0.05) 0 1 2
194    ///      CX 0 3 1 3 1 4 2 4
195    ///      M 3 4
196    ///      DETECTOR rec[-2]
197    ///      DETECTOR rec[-1]",
198    /// )?;
199    /// let model = program.detector_error_model()?;
200    /// assert_eq!(model.num_detectors(), 2);
201    /// assert_eq!(model.num_mechanisms(), 3);
202    /// # Ok::<(), prism_q::PrismError>(())
203    /// ```
204    pub fn detector_error_model(&self) -> Result<DetectorErrorModel> {
205        derive_detector_error_model(self)
206    }
207}
208
209/// One independent random draw in the sampler: the mutually exclusive Pauli
210/// branches of a single noise annotation on a single target (or target pair),
211/// each with its packed measurement-record flip mask.
212struct FaultUnit {
213    position: usize,
214    branches: Vec<(f64, Vec<u64>)>,
215}
216
217/// Flipped (detector indices, observable indices), both ascending.
218type Symptom = (Vec<usize>, Vec<usize>);
219
220fn derive_detector_error_model(program: &QecProgram) -> Result<DetectorErrorModel> {
221    let detector_rows = program.detector_rows()?;
222    let observable_rows = program.observable_rows()?;
223    let num_detectors = detector_rows.len();
224    let num_observables = observable_rows.len();
225    let m_words = program.num_measurements().div_ceil(64);
226    let detector_masks = pack_record_rows(&detector_rows, m_words);
227    let observable_masks = pack_record_rows(&observable_rows, m_words);
228
229    let deferred = lower_qec_program_to_deferred_circuit(program)?;
230    let mut units: Vec<FaultUnit> = Vec::new();
231    walk_qec_noise_sensitivity(&deferred, |event, x_packed, z_packed| {
232        collect_fault_units(event, x_packed, z_packed, &mut units);
233    })?;
234    units.sort_by_key(|unit| unit.position);
235
236    let mut index: HashMap<Symptom, usize> = HashMap::new();
237    let mut mechanisms: Vec<ErrorMechanism> = Vec::new();
238    for unit in units {
239        for (symptom, probability) in unit_symptoms(&unit, &detector_masks, &observable_masks) {
240            match index.get(&symptom) {
241                Some(&at) => {
242                    let prior = mechanisms[at].probability;
243                    mechanisms[at].probability =
244                        prior * (1.0 - probability) + probability * (1.0 - prior);
245                }
246                None => {
247                    index.insert(symptom.clone(), mechanisms.len());
248                    let (detectors, observables) = symptom;
249                    mechanisms.push(ErrorMechanism {
250                        probability,
251                        detectors,
252                        observables,
253                    });
254                }
255            }
256        }
257    }
258
259    Ok(DetectorErrorModel {
260        mechanisms,
261        detector_coords: detector_coordinates(program),
262        num_detectors,
263        num_observables,
264    })
265}
266
267fn collect_fault_units(
268    event: &QecDeferredNoiseEvent,
269    x_packed: &[Vec<u64>],
270    z_packed: &[Vec<u64>],
271    units: &mut Vec<FaultUnit>,
272) {
273    match event.channel {
274        QecNoise::XError(p) => {
275            for &target in &event.targets {
276                units.push(FaultUnit {
277                    position: event.position,
278                    branches: vec![(p, z_packed[target].clone())],
279                });
280            }
281        }
282        QecNoise::ZError(p) => {
283            for &target in &event.targets {
284                units.push(FaultUnit {
285                    position: event.position,
286                    branches: vec![(p, x_packed[target].clone())],
287                });
288            }
289        }
290        QecNoise::Depolarize1(p) => {
291            let branch_p = p / 3.0;
292            for &target in &event.targets {
293                let mut y_mask = x_packed[target].clone();
294                xor_words(&mut y_mask, &z_packed[target]);
295                units.push(FaultUnit {
296                    position: event.position,
297                    branches: vec![
298                        (branch_p, z_packed[target].clone()),
299                        (branch_p, y_mask),
300                        (branch_p, x_packed[target].clone()),
301                    ],
302                });
303            }
304        }
305        QecNoise::Depolarize2(p) => {
306            let branch_p = p / 15.0;
307            for pair in event.targets.chunks_exact(2) {
308                let m_words = z_packed[pair[0]].len();
309                let mut branches = Vec::with_capacity(15);
310                for sample in 1..=15 {
311                    let mut mask = vec![0u64; m_words];
312                    append_qec_pauli_noise_effect(
313                        &mut mask,
314                        sample / 4,
315                        &x_packed[pair[0]],
316                        &z_packed[pair[0]],
317                    );
318                    append_qec_pauli_noise_effect(
319                        &mut mask,
320                        sample % 4,
321                        &x_packed[pair[1]],
322                        &z_packed[pair[1]],
323                    );
324                    branches.push((branch_p, mask));
325                }
326                units.push(FaultUnit {
327                    position: event.position,
328                    branches,
329                });
330            }
331        }
332    }
333}
334
335/// Project a unit's branches onto (detectors, observables) symptoms, summing
336/// exclusive branches that share a symptom and dropping branches that flip
337/// nothing.
338fn unit_symptoms(
339    unit: &FaultUnit,
340    detector_masks: &[Vec<u64>],
341    observable_masks: &[Vec<u64>],
342) -> Vec<(Symptom, f64)> {
343    let mut local: Vec<(Symptom, f64)> = Vec::new();
344    for (probability, mask) in &unit.branches {
345        let detectors = flipped_rows(mask, detector_masks);
346        let observables = flipped_rows(mask, observable_masks);
347        if detectors.is_empty() && observables.is_empty() {
348            continue;
349        }
350        let symptom = (detectors, observables);
351        match local.iter_mut().find(|(existing, _)| *existing == symptom) {
352            Some((_, total)) => *total += probability,
353            None => local.push((symptom, *probability)),
354        }
355    }
356    local
357}
358
359fn flipped_rows(mask: &[u64], rows: &[Vec<u64>]) -> Vec<usize> {
360    rows.iter()
361        .enumerate()
362        .filter(|(_, row)| odd_overlap(mask, row))
363        .map(|(row_index, _)| row_index)
364        .collect()
365}
366
367fn odd_overlap(a: &[u64], b: &[u64]) -> bool {
368    a.iter()
369        .zip(b)
370        .map(|(x, y)| (x & y).count_ones())
371        .sum::<u32>()
372        % 2
373        == 1
374}
375
376/// Pack record-index rows into bit masks. XOR rather than OR: a record listed
377/// twice in a row cancels in the parity, and the mask must agree.
378fn pack_record_rows(rows: &[Vec<usize>], m_words: usize) -> Vec<Vec<u64>> {
379    rows.iter()
380        .map(|row| {
381            let mut mask = vec![0u64; m_words];
382            for &record in row {
383                mask[record / 64] ^= 1u64 << (record % 64);
384            }
385            mask
386        })
387        .collect()
388}
389
390/// Depth-first over candidates in mechanism order; the first cover found is
391/// the result.
392fn partition_cover(mechanism: &ErrorMechanism, graphlike: &[ErrorMechanism]) -> Option<Vec<usize>> {
393    fn search(
394        remaining: &[usize],
395        observables: &[usize],
396        start: usize,
397        graphlike: &[ErrorMechanism],
398        chosen: &mut Vec<usize>,
399    ) -> bool {
400        if remaining.is_empty() {
401            return observables.is_empty();
402        }
403        for at in start..graphlike.len() {
404            let candidate = &graphlike[at];
405            if candidate.detectors.is_empty() || !is_subset(&candidate.detectors, remaining) {
406                continue;
407            }
408            let next_remaining = symmetric_difference(remaining, &candidate.detectors);
409            let next_observables = symmetric_difference(observables, &candidate.observables);
410            chosen.push(at);
411            if search(
412                &next_remaining,
413                &next_observables,
414                at + 1,
415                graphlike,
416                chosen,
417            ) {
418                return true;
419            }
420            chosen.pop();
421        }
422        false
423    }
424
425    let mut chosen = Vec::new();
426    search(
427        &mechanism.detectors,
428        &mechanism.observables,
429        0,
430        graphlike,
431        &mut chosen,
432    )
433    .then_some(chosen)
434}
435
436/// True when every element of ascending `a` appears in ascending `b`.
437fn is_subset(a: &[usize], b: &[usize]) -> bool {
438    let mut j = 0;
439    'outer: for &x in a {
440        while j < b.len() {
441            match b[j].cmp(&x) {
442                std::cmp::Ordering::Less => j += 1,
443                std::cmp::Ordering::Equal => {
444                    j += 1;
445                    continue 'outer;
446                }
447                std::cmp::Ordering::Greater => return false,
448            }
449        }
450        return false;
451    }
452    true
453}
454
455/// Symmetric difference of two ascending index lists, ascending.
456fn symmetric_difference(a: &[usize], b: &[usize]) -> Vec<usize> {
457    let mut out = Vec::with_capacity(a.len() + b.len());
458    let (mut i, mut j) = (0, 0);
459    while i < a.len() && j < b.len() {
460        match a[i].cmp(&b[j]) {
461            std::cmp::Ordering::Less => {
462                out.push(a[i]);
463                i += 1;
464            }
465            std::cmp::Ordering::Greater => {
466                out.push(b[j]);
467                j += 1;
468            }
469            std::cmp::Ordering::Equal => {
470                i += 1;
471                j += 1;
472            }
473        }
474    }
475    out.extend_from_slice(&a[i..]);
476    out.extend_from_slice(&b[j..]);
477    out
478}
479
480pub(super) fn symptom_label(mechanism: &ErrorMechanism) -> String {
481    let mut label = String::new();
482    for detector in &mechanism.detectors {
483        if !label.is_empty() {
484            label.push(' ');
485        }
486        label.push_str(&format!("D{detector}"));
487    }
488    for observable in &mechanism.observables {
489        if !label.is_empty() {
490            label.push(' ');
491        }
492        label.push_str(&format!("L{observable}"));
493    }
494    label
495}
496
497fn detector_coordinates(program: &QecProgram) -> Vec<Vec<f64>> {
498    program
499        .ops()
500        .iter()
501        .filter_map(|op| match op {
502            QecOp::Detector { coords, .. } => Some(coords.clone()),
503            _ => None,
504        })
505        .collect()
506}