Skip to main content

prism_q/qec/
bposd.rs

1//! Belief propagation with ordered-statistics post-processing (BP+OSD) over
2//! detector error models, hypergraph mechanisms included.
3
4use std::collections::HashMap;
5use std::collections::hash_map::Entry;
6
7use super::DetectorErrorModel;
8use super::decoder::{ShotDecoder, decode_batch, logical_error_rate};
9use super::dem::symptom_label;
10use crate::error::{PrismError, Result};
11use crate::sim::compiled::PackedShots;
12
13/// Largest exhaustive OSD order: `2^order` candidate patterns per shot.
14const MAX_EXHAUSTIVE_ORDER: usize = 24;
15/// Bound on `|tanh|` products before `atanh`, keeping product-sum messages finite.
16const TANH_CLAMP: f64 = 1.0 - 1e-15;
17
18/// Check-node update rule for belief propagation.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum BpMethod {
21    /// Exact sum-product update in the log-likelihood domain.
22    ProductSum,
23    /// Min-sum update with every check-to-variable message multiplied by
24    /// `scaling` in `(0, 1]`.
25    MinSum { scaling: f64 },
26}
27
28/// Ordered-statistics post-processing applied when belief propagation does not
29/// reach a solution of the syndrome.
30///
31/// Columns are ranked by posterior error likelihood; Gaussian elimination in
32/// that order picks the most reliable information set, and the non-pivot
33/// ("free") columns are the search space. Every candidate fixes the free bits
34/// and solves the pivots, and the candidate of least total weight
35/// `sum ln((1-p)/p)` wins.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum OsdMethod {
38    /// The single candidate with every free bit zero.
39    Zero,
40    /// OSD-0 plus every single free bit and every pair among the first
41    /// `order` free bits.
42    CombinationSweep { order: usize },
43    /// Every pattern over the first `order` free bits, at most 24. With
44    /// `order` at least the free-column count this is exact maximum-likelihood
45    /// error decoding.
46    Exhaustive { order: usize },
47}
48
49/// Configuration for [`BpOsdDecoder`].
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct BpOsdOptions {
52    /// Flooding iterations before falling back to OSD; zero runs OSD on the
53    /// priors alone.
54    pub max_iterations: usize,
55    pub bp_method: BpMethod,
56    pub osd_method: OsdMethod,
57}
58
59impl Default for BpOsdOptions {
60    /// 30 iterations of min-sum at scaling 0.625, then OSD-CS of order 7.
61    fn default() -> Self {
62        Self {
63            max_iterations: 30,
64            bp_method: BpMethod::MinSum { scaling: 0.625 },
65            osd_method: OsdMethod::CombinationSweep { order: 7 },
66        }
67    }
68}
69
70/// BP+OSD decoder compiled from a detector error model.
71///
72/// Each mechanism flipping at least one detector becomes a column of the
73/// check matrix (detectors are rows) with prior log-likelihood ratio
74/// `ln((1-p)/p)`; mechanisms sharing one detector set collapse to the most
75/// probable of them, and mechanisms flipping no detector cannot be inferred.
76/// No graphlike restriction applies, so color-code and qLDPC models decode
77/// directly. Belief propagation runs on the Tanner graph and stops as soon as
78/// its hard decision reproduces the syndrome; otherwise ordered-statistics
79/// decoding ([`OsdMethod`]) returns the least-weight solution among its
80/// candidates. Decoding uses no randomness.
81#[derive(Debug, Clone)]
82pub struct BpOsdDecoder {
83    num_detectors: usize,
84    num_observables: usize,
85    obs_words: usize,
86    num_columns: usize,
87    rank: usize,
88    check_offsets: Vec<u32>,
89    slot_column: Vec<u32>,
90    column_offsets: Vec<u32>,
91    column_slots: Vec<u32>,
92    slot_check: Vec<u32>,
93    prior: Vec<f64>,
94    column_obs: Vec<u64>,
95    nonnegative_priors: bool,
96    max_check_degree: usize,
97    options: BpOsdOptions,
98}
99
100impl BpOsdDecoder {
101    /// Compile a decoder with [`BpOsdOptions::default`].
102    ///
103    /// # Errors
104    ///
105    /// As [`Self::with_options`].
106    pub fn from_model(model: &DetectorErrorModel) -> Result<Self> {
107        Self::with_options(model, BpOsdOptions::default())
108    }
109
110    /// Compile a decoder from any detector error model.
111    ///
112    /// # Errors
113    ///
114    /// Mechanism probabilities must lie in `[0, 1)`; a zero-probability
115    /// mechanism is skipped. A min-sum scaling outside `(0, 1]` or an
116    /// exhaustive order above 24 is rejected.
117    pub fn with_options(model: &DetectorErrorModel, options: BpOsdOptions) -> Result<Self> {
118        if let BpMethod::MinSum { scaling } = options.bp_method
119            && !(scaling > 0.0 && scaling <= 1.0)
120        {
121            return Err(PrismError::InvalidParameter {
122                message: format!("min-sum scaling {scaling} lies outside (0, 1]"),
123            });
124        }
125        if let OsdMethod::Exhaustive { order } = options.osd_method
126            && order > MAX_EXHAUSTIVE_ORDER
127        {
128            return Err(PrismError::InvalidParameter {
129                message: format!(
130                    "exhaustive OSD order {order} exceeds {MAX_EXHAUSTIVE_ORDER}; use the \
131                         combination sweep for larger searches"
132                ),
133            });
134        }
135
136        let mut columns: Vec<(&[usize], f64, &[usize])> = Vec::new();
137        let mut slots: HashMap<&[usize], usize> = HashMap::new();
138        for mechanism in model.mechanisms() {
139            let p = mechanism.probability();
140            if !(0.0..1.0).contains(&p) {
141                return Err(PrismError::InvalidParameter {
142                    message: format!(
143                        "mechanism `{}` has probability {p}, outside [0, 1)",
144                        symptom_label(mechanism)
145                    ),
146                });
147            }
148            if p == 0.0 || mechanism.detectors().is_empty() {
149                continue;
150            }
151            match slots.entry(mechanism.detectors()) {
152                Entry::Occupied(slot) => {
153                    let column = &mut columns[*slot.get()];
154                    if p > column.1 {
155                        column.1 = p;
156                        column.2 = mechanism.observables();
157                    }
158                }
159                Entry::Vacant(slot) => {
160                    slot.insert(columns.len());
161                    columns.push((mechanism.detectors(), p, mechanism.observables()));
162                }
163            }
164        }
165        Ok(Self::from_columns(
166            model.num_detectors(),
167            model.num_observables(),
168            &columns,
169            options,
170        ))
171    }
172
173    /// Build from `(detectors, probability, observables)` columns with
174    /// ascending, in-range detector indices and probabilities in `(0, 1)`.
175    fn from_columns(
176        num_detectors: usize,
177        num_observables: usize,
178        columns: &[(&[usize], f64, &[usize])],
179        options: BpOsdOptions,
180    ) -> Self {
181        let obs_words = num_observables.div_ceil(64);
182        let num_columns = columns.len();
183        let mut check_offsets = vec![0u32; num_detectors + 1];
184        for (detectors, ..) in columns {
185            for &d in *detectors {
186                check_offsets[d + 1] += 1;
187            }
188        }
189        for d in 0..num_detectors {
190            check_offsets[d + 1] += check_offsets[d];
191        }
192        let nnz = check_offsets[num_detectors] as usize;
193        let mut cursor = check_offsets.clone();
194        let mut slot_column = vec![0u32; nnz];
195        let mut slot_check = vec![0u32; nnz];
196        let mut column_offsets = vec![0u32; num_columns + 1];
197        let mut column_slots = Vec::with_capacity(nnz);
198        let mut prior = Vec::with_capacity(num_columns);
199        let mut column_obs = vec![0u64; num_columns * obs_words];
200        for (column, (detectors, p, observables)) in columns.iter().enumerate() {
201            for &d in *detectors {
202                let slot = cursor[d];
203                cursor[d] += 1;
204                slot_column[slot as usize] = column as u32;
205                slot_check[slot as usize] = d as u32;
206                column_slots.push(slot);
207            }
208            column_offsets[column + 1] = column_slots.len() as u32;
209            prior.push(((1.0 - p) / p).ln());
210            for &o in *observables {
211                column_obs[column * obs_words + o / 64] |= 1u64 << (o % 64);
212            }
213        }
214        let max_check_degree = (0..num_detectors)
215            .map(|d| (check_offsets[d + 1] - check_offsets[d]) as usize)
216            .max()
217            .unwrap_or(0);
218        let nonnegative_priors = prior.iter().all(|&llr| llr >= 0.0);
219
220        let mut decoder = Self {
221            num_detectors,
222            num_observables,
223            obs_words,
224            num_columns,
225            rank: 0,
226            check_offsets,
227            slot_column,
228            column_offsets,
229            column_slots,
230            slot_check,
231            prior,
232            column_obs,
233            nonnegative_priors,
234            max_check_degree,
235            options,
236        };
237        let mut scratch = decoder.scratch();
238        for (position, column) in scratch.order.iter_mut().enumerate() {
239            *column = position as u32;
240        }
241        scratch.use_all_rows();
242        decoder.rank = decoder.eliminate(&mut scratch, num_columns, num_detectors);
243        decoder
244    }
245
246    pub fn num_detectors(&self) -> usize {
247        self.num_detectors
248    }
249
250    pub fn num_observables(&self) -> usize {
251        self.num_observables
252    }
253
254    pub fn options(&self) -> BpOsdOptions {
255        self.options
256    }
257
258    /// Decode packed detector samples into predicted observable flips.
259    ///
260    /// Layouts and bit order as [`super::UnionFindDecoder::decode_packed`].
261    ///
262    /// # Errors
263    ///
264    /// The input measurement count must equal the model's detector count, and
265    /// a syndrome outside the column space of the check matrix rejects the
266    /// batch, naming the first such shot.
267    pub fn decode_packed(&self, detectors: &PackedShots) -> Result<PackedShots> {
268        decode_batch(self, detectors)
269    }
270
271    /// Decode `detectors` and return the fraction of shots whose predicted
272    /// flips differ from `observables` in any observable; `0.0` for no shots.
273    ///
274    /// # Errors
275    ///
276    /// As [`Self::decode_packed`], and `observables` must hold one bit per
277    /// observable for the same shot count.
278    pub fn logical_error_rate(
279        &self,
280        detectors: &PackedShots,
281        observables: &PackedShots,
282    ) -> Result<f64> {
283        logical_error_rate(&self.decode_packed(detectors)?, observables)
284    }
285
286    /// Decode one shot into `s.solution` (one byte per column) and XOR its
287    /// observable flips into `out_row`. Returns the solution's prior weight.
288    fn solve_shot(
289        &self,
290        row: &[u64],
291        out_row: &mut [u64],
292        s: &mut BpOsdScratch,
293    ) -> std::result::Result<f64, Unexplained> {
294        let mut any = false;
295        for (d, bit) in s.syndrome.iter_mut().enumerate() {
296            *bit = (row[d / 64] >> (d % 64)) as u8 & 1;
297            any |= *bit != 0;
298        }
299        s.solution.fill(0);
300        if !any && self.nonnegative_priors {
301            return Ok(0.0);
302        }
303
304        if self.belief_propagation(s) {
305            self.refine_support(s);
306        } else {
307            for (position, column) in s.order.iter_mut().enumerate() {
308                *column = position as u32;
309            }
310            let posterior = &s.posterior;
311            s.order.sort_unstable_by(|&a, &b| {
312                posterior[a as usize]
313                    .total_cmp(&posterior[b as usize])
314                    .then(a.cmp(&b))
315            });
316            s.use_all_rows();
317            self.ordered_statistics(s, self.num_columns, self.rank)?;
318        }
319        let mut weight = 0.0;
320        for column in 0..self.num_columns {
321            if s.solution[column] != 0 {
322                weight += self.prior[column];
323                let base = column * self.obs_words;
324                for (word, mask) in out_row
325                    .iter_mut()
326                    .zip(&self.column_obs[base..base + self.obs_words])
327                {
328                    *word ^= mask;
329                }
330            }
331        }
332        Ok(weight)
333    }
334
335    /// Flooding belief propagation. Returns whether the hard decision, left in
336    /// `s.solution`, satisfies the syndrome; `s.posterior` holds the final
337    /// log-likelihood ratios either way.
338    fn belief_propagation(&self, s: &mut BpOsdScratch) -> bool {
339        s.posterior.copy_from_slice(&self.prior);
340        for (column, &llr) in self.prior.iter().enumerate() {
341            s.solution[column] = u8::from(llr < 0.0);
342        }
343        if self.satisfies_syndrome(s) {
344            return true;
345        }
346        for (slot, &column) in self.slot_column.iter().enumerate() {
347            s.v2c[slot] = self.prior[column as usize];
348        }
349        for _ in 0..self.options.max_iterations {
350            for check in 0..self.num_detectors {
351                let begin = self.check_offsets[check] as usize;
352                let end = self.check_offsets[check + 1] as usize;
353                let flip = s.syndrome[check] != 0;
354                match self.options.bp_method {
355                    BpMethod::MinSum { scaling } => {
356                        let mut negative = flip;
357                        let mut min1 = f64::INFINITY;
358                        let mut min2 = f64::INFINITY;
359                        let mut arg = begin;
360                        for slot in begin..end {
361                            let q = s.v2c[slot];
362                            negative ^= q < 0.0;
363                            let magnitude = q.abs();
364                            if magnitude < min1 {
365                                min2 = min1;
366                                min1 = magnitude;
367                                arg = slot;
368                            } else if magnitude < min2 {
369                                min2 = magnitude;
370                            }
371                        }
372                        for slot in begin..end {
373                            let magnitude = if slot == arg { min2 } else { min1 };
374                            let sign_negative = negative ^ (s.v2c[slot] < 0.0);
375                            s.c2v[slot] = if sign_negative {
376                                -scaling * magnitude
377                            } else {
378                                scaling * magnitude
379                            };
380                        }
381                    }
382                    BpMethod::ProductSum => {
383                        let degree = end - begin;
384                        let mut forward = 1.0;
385                        for at in 0..degree {
386                            s.partial[at] = forward;
387                            forward *= (0.5 * s.v2c[begin + at]).tanh();
388                        }
389                        let mut backward = 1.0;
390                        for at in (0..degree).rev() {
391                            let product = (s.partial[at] * backward).clamp(-TANH_CLAMP, TANH_CLAMP);
392                            let message = 2.0 * product.atanh();
393                            s.c2v[begin + at] = if flip { -message } else { message };
394                            backward *= (0.5 * s.v2c[begin + at]).tanh();
395                        }
396                    }
397                }
398            }
399            for column in 0..self.num_columns {
400                let begin = self.column_offsets[column] as usize;
401                let end = self.column_offsets[column + 1] as usize;
402                let mut total = self.prior[column];
403                for &slot in &self.column_slots[begin..end] {
404                    total += s.c2v[slot as usize];
405                }
406                s.posterior[column] = total;
407                s.solution[column] = u8::from(total < 0.0);
408                for &slot in &self.column_slots[begin..end] {
409                    s.v2c[slot as usize] = total - s.c2v[slot as usize];
410                }
411            }
412            if self.satisfies_syndrome(s) {
413                return true;
414            }
415        }
416        false
417    }
418
419    fn satisfies_syndrome(&self, s: &BpOsdScratch) -> bool {
420        (0..self.num_detectors).all(|check| {
421            let begin = self.check_offsets[check] as usize;
422            let end = self.check_offsets[check + 1] as usize;
423            let parity = self.slot_column[begin..end]
424                .iter()
425                .fold(0u8, |acc, &column| acc ^ s.solution[column as usize]);
426            parity == s.syndrome[check]
427        })
428    }
429
430    /// A converged BP decision satisfies the syndrome but can carry a
431    /// zero-syndrome cycle (a stabilizer or a logical) on top of a lighter
432    /// solution. Rerun the OSD search restricted to the decision's support and
433    /// the checks it touches, which keeps the cost proportional to its size.
434    fn refine_support(&self, s: &mut BpOsdScratch) {
435        let mut count = 0;
436        for column in 0..self.num_columns {
437            if s.solution[column] != 0 {
438                s.order[count] = column as u32;
439                count += 1;
440            }
441        }
442        if count < 2 {
443            return;
444        }
445        let posterior = &s.posterior;
446        s.order[..count].sort_unstable_by(|&a, &b| {
447            posterior[a as usize]
448                .total_cmp(&posterior[b as usize])
449                .then(a.cmp(&b))
450        });
451        s.row_stamp += 1;
452        s.rows.clear();
453        for &column in &s.order[..count] {
454            let begin = self.column_offsets[column as usize] as usize;
455            let end = self.column_offsets[column as usize + 1] as usize;
456            for &slot in &self.column_slots[begin..end] {
457                let check = self.slot_check[slot as usize] as usize;
458                if s.row_mark[check] != s.row_stamp {
459                    s.row_mark[check] = s.row_stamp;
460                    s.row_local[check] = s.rows.len() as u32;
461                    s.rows.push(check as u32);
462                }
463            }
464        }
465        let support_weight = |s: &BpOsdScratch| -> f64 {
466            s.order[..count]
467                .iter()
468                .filter(|&&column| s.solution[column as usize] != 0)
469                .map(|&column| self.prior[column as usize])
470                .sum()
471        };
472        let decision_weight = support_weight(s);
473        let max_rank = count.min(s.rows.len());
474        let refined = self.ordered_statistics(s, count, max_rank);
475        debug_assert!(refined.is_ok(), "the BP decision explains the syndrome");
476        if support_weight(s) > decision_weight {
477            for &column in &s.order[..count] {
478                s.solution[column as usize] = 1;
479            }
480        }
481    }
482
483    /// Reduce the submatrix on rows `s.rows` and columns `s.order[..count]`,
484    /// with the syndrome as right-hand side, to reduced row-echelon form over
485    /// GF(2), stopping after `max_rank` pivots. Returns the rank; pivot
486    /// positions land in `s.pivot_position` and the reduced rows in `s.matrix`
487    /// at a stride of `s.stride` words.
488    fn eliminate(&self, s: &mut BpOsdScratch, count: usize, max_rank: usize) -> usize {
489        let words = count.div_ceil(64).max(1);
490        s.stride = words;
491        let rows = s.rows.len();
492        s.matrix[..rows * words].fill(0);
493        for (position, &column) in s.order[..count].iter().enumerate() {
494            let begin = self.column_offsets[column as usize] as usize;
495            let end = self.column_offsets[column as usize + 1] as usize;
496            for &slot in &self.column_slots[begin..end] {
497                let local = s.row_local[self.slot_check[slot as usize] as usize] as usize;
498                s.matrix[local * words + position / 64] |= 1u64 << (position % 64);
499            }
500        }
501        for (local, &check) in s.rows.iter().enumerate() {
502            s.rhs[local] = s.syndrome[check as usize];
503        }
504
505        let mut rank = 0;
506        for position in 0..count {
507            if rank == max_rank || rank == rows {
508                break;
509            }
510            let word = position / 64;
511            let bit = 1u64 << (position % 64);
512            let Some(pivot) = (rank..rows).find(|&r| s.matrix[r * words + word] & bit != 0) else {
513                continue;
514            };
515            if pivot != rank {
516                for w in 0..words {
517                    s.matrix.swap(pivot * words + w, rank * words + w);
518                }
519                s.rhs.swap(pivot, rank);
520            }
521            let (head, tail) = s.matrix.split_at_mut(rank * words);
522            let (pivot_row, tail) = tail.split_at_mut(words);
523            for (r, target) in head.chunks_exact_mut(words).enumerate() {
524                if target[word] & bit != 0 {
525                    for (t, p) in target.iter_mut().zip(pivot_row.iter()) {
526                        *t ^= p;
527                    }
528                    s.rhs[r] ^= s.rhs[rank];
529                }
530            }
531            for (offset, target) in tail[..(rows - rank - 1) * words]
532                .chunks_exact_mut(words)
533                .enumerate()
534            {
535                if target[word] & bit != 0 {
536                    for (t, p) in target.iter_mut().zip(pivot_row.iter()) {
537                        *t ^= p;
538                    }
539                    s.rhs[rank + 1 + offset] ^= s.rhs[rank];
540                }
541            }
542            s.pivot_position[rank] = position as u32;
543            rank += 1;
544        }
545        rank
546    }
547
548    /// Search the OSD candidates over columns `s.order[..count]` (sorted most
549    /// likely error first) and rows `s.rows`, and write the least-weight
550    /// solution over those columns into `s.solution`.
551    fn ordered_statistics(
552        &self,
553        s: &mut BpOsdScratch,
554        count: usize,
555        max_rank: usize,
556    ) -> std::result::Result<(), Unexplained> {
557        let rank = self.eliminate(s, count, max_rank);
558        if s.rhs[rank..s.rows.len()].iter().any(|&bit| bit != 0) {
559            return Err(Unexplained);
560        }
561
562        let words = s.stride;
563        let rank_words = rank.div_ceil(64);
564        s.is_pivot[..count].fill(false);
565        for &position in &s.pivot_position[..rank] {
566            s.is_pivot[position as usize] = true;
567        }
568        s.free.clear();
569        for position in 0..count {
570            if !s.is_pivot[position] {
571                s.free.push(position as u32);
572            }
573        }
574        let candidates = match self.options.osd_method {
575            OsdMethod::Zero => 0,
576            OsdMethod::CombinationSweep { .. } => s.free.len(),
577            OsdMethod::Exhaustive { order } => order.min(s.free.len()),
578        };
579
580        // Reduced pivot-row column of each candidate free position, packed over
581        // pivot rows, and the right-hand side packed the same way.
582        s.base[..rank_words].fill(0);
583        for r in 0..rank {
584            s.base[r / 64] |= u64::from(s.rhs[r]) << (r % 64);
585        }
586        s.columns[..candidates * rank_words].fill(0);
587        for (candidate, &position) in s.free[..candidates].iter().enumerate() {
588            let word = position as usize / 64;
589            let shift = position as usize % 64;
590            for r in 0..rank {
591                let bit = (s.matrix[r * words + word] >> shift) & 1;
592                s.columns[candidate * rank_words + r / 64] |= bit << (r % 64);
593            }
594        }
595
596        let column_weight =
597            |s: &BpOsdScratch, position: u32| self.prior[s.order[position as usize] as usize];
598        let pivot_weight = |s: &BpOsdScratch, x: &[u64]| {
599            let mut total = 0.0;
600            for (w, &bits) in x.iter().enumerate() {
601                let mut bits = bits;
602                while bits != 0 {
603                    let r = w * 64 + bits.trailing_zeros() as usize;
604                    bits &= bits - 1;
605                    total += self.prior[s.order[s.pivot_position[r] as usize] as usize];
606                }
607            }
608            total
609        };
610
611        s.trial[..rank_words].copy_from_slice(&s.base[..rank_words]);
612        let mut best_weight = pivot_weight(s, &s.trial[..rank_words]);
613        let mut best = Flips::None;
614        match self.options.osd_method {
615            OsdMethod::Zero => {}
616            OsdMethod::CombinationSweep { order } => {
617                for f in 0..candidates {
618                    for w in 0..rank_words {
619                        s.trial[w] = s.base[w] ^ s.columns[f * rank_words + w];
620                    }
621                    let weight =
622                        column_weight(s, s.free[f]) + pivot_weight(s, &s.trial[..rank_words]);
623                    if weight < best_weight {
624                        best_weight = weight;
625                        best = Flips::One(f);
626                    }
627                }
628                let pairs = order.min(candidates);
629                for f1 in 0..pairs {
630                    for f2 in f1 + 1..pairs {
631                        for w in 0..rank_words {
632                            s.trial[w] = s.base[w]
633                                ^ s.columns[f1 * rank_words + w]
634                                ^ s.columns[f2 * rank_words + w];
635                        }
636                        let weight = column_weight(s, s.free[f1])
637                            + column_weight(s, s.free[f2])
638                            + pivot_weight(s, &s.trial[..rank_words]);
639                        if weight < best_weight {
640                            best_weight = weight;
641                            best = Flips::Two(f1, f2);
642                        }
643                    }
644                }
645            }
646            OsdMethod::Exhaustive { .. } => {
647                // Gray-code walk: each step toggles one free column.
648                let mut pattern = 0u32;
649                for step in 1..1u32 << candidates {
650                    let f = step.trailing_zeros() as usize;
651                    pattern ^= 1 << f;
652                    for w in 0..rank_words {
653                        s.trial[w] ^= s.columns[f * rank_words + w];
654                    }
655                    let mut free_weight = 0.0;
656                    let mut bits = pattern;
657                    while bits != 0 {
658                        free_weight += column_weight(s, s.free[bits.trailing_zeros() as usize]);
659                        bits &= bits - 1;
660                    }
661                    let weight = free_weight + pivot_weight(s, &s.trial[..rank_words]);
662                    if weight < best_weight {
663                        best_weight = weight;
664                        best = Flips::Pattern(pattern);
665                    }
666                }
667            }
668        }
669
670        for &column in &s.order[..count] {
671            s.solution[column as usize] = 0;
672        }
673        s.trial[..rank_words].copy_from_slice(&s.base[..rank_words]);
674        let flip = |s: &mut BpOsdScratch, f: usize| {
675            s.solution[s.order[s.free[f] as usize] as usize] = 1;
676            for w in 0..rank_words {
677                s.trial[w] ^= s.columns[f * rank_words + w];
678            }
679        };
680        match best {
681            Flips::None => {}
682            Flips::One(f) => flip(s, f),
683            Flips::Two(f1, f2) => {
684                flip(s, f1);
685                flip(s, f2);
686            }
687            Flips::Pattern(pattern) => {
688                for f in 0..candidates {
689                    if pattern >> f & 1 == 1 {
690                        flip(s, f);
691                    }
692                }
693            }
694        }
695        for r in 0..rank {
696            let column = s.order[s.pivot_position[r] as usize] as usize;
697            s.solution[column] = (s.trial[r / 64] >> (r % 64)) as u8 & 1;
698        }
699        Ok(())
700    }
701}
702
703/// Free-column flips of the best OSD candidate.
704#[derive(Debug, Clone, Copy)]
705enum Flips {
706    None,
707    One(usize),
708    Two(usize, usize),
709    Pattern(u32),
710}
711
712/// A syndrome outside the check matrix's column space.
713pub(super) struct Unexplained;
714
715impl ShotDecoder for BpOsdDecoder {
716    type Scratch = BpOsdScratch;
717    type Failure = Unexplained;
718    #[cfg(feature = "parallel")]
719    const PARALLEL_SHOT_THRESHOLD: usize = 64;
720    #[cfg(feature = "parallel")]
721    const SHOT_CHUNK: usize = 16;
722
723    fn num_detectors(&self) -> usize {
724        self.num_detectors
725    }
726
727    fn num_observables(&self) -> usize {
728        self.num_observables
729    }
730
731    fn scratch(&self) -> BpOsdScratch {
732        BpOsdScratch::new(self)
733    }
734
735    fn decode_shot(
736        &self,
737        row: &[u64],
738        out_row: &mut [u64],
739        scratch: &mut BpOsdScratch,
740    ) -> std::result::Result<(), Unexplained> {
741        self.solve_shot(row, out_row, scratch).map(|_| ())
742    }
743
744    fn failure_error(_: Unexplained, shot: usize) -> PrismError {
745        PrismError::InvalidParameter {
746            message: format!(
747                "shot {shot}: the syndrome lies outside the column space of the model's \
748                 check matrix, so it is impossible under the model"
749            ),
750        }
751    }
752}
753
754/// Reusable per-shot BP and OSD buffers, sized once from the decoder.
755pub(super) struct BpOsdScratch {
756    syndrome: Vec<u8>,
757    v2c: Vec<f64>,
758    c2v: Vec<f64>,
759    partial: Vec<f64>,
760    posterior: Vec<f64>,
761    solution: Vec<u8>,
762    order: Vec<u32>,
763    rows: Vec<u32>,
764    row_local: Vec<u32>,
765    row_mark: Vec<u64>,
766    row_stamp: u64,
767    stride: usize,
768    matrix: Vec<u64>,
769    rhs: Vec<u8>,
770    pivot_position: Vec<u32>,
771    is_pivot: Vec<bool>,
772    free: Vec<u32>,
773    base: Vec<u64>,
774    trial: Vec<u64>,
775    columns: Vec<u64>,
776}
777
778impl BpOsdScratch {
779    fn use_all_rows(&mut self) {
780        self.rows.clear();
781        for (check, local) in self.row_local.iter_mut().enumerate() {
782            *local = check as u32;
783            self.rows.push(check as u32);
784        }
785    }
786
787    fn new(decoder: &BpOsdDecoder) -> Self {
788        let rows = decoder.num_detectors;
789        let columns = decoder.num_columns;
790        let nnz = decoder.slot_column.len();
791        let row_words = columns.div_ceil(64);
792        let rank_words = rows.min(columns).div_ceil(64);
793        Self {
794            syndrome: vec![0; rows],
795            v2c: vec![0.0; nnz],
796            c2v: vec![0.0; nnz],
797            partial: vec![0.0; decoder.max_check_degree],
798            posterior: vec![0.0; columns],
799            solution: vec![0; columns],
800            order: vec![0; columns],
801            rows: Vec::with_capacity(rows),
802            row_local: vec![0; rows],
803            row_mark: vec![0; rows],
804            row_stamp: 0,
805            stride: row_words,
806            matrix: vec![0; rows * row_words.max(1)],
807            rhs: vec![0; rows],
808            pivot_position: vec![0; rows.min(columns)],
809            is_pivot: vec![false; columns],
810            free: Vec::with_capacity(columns),
811            base: vec![0; rank_words],
812            trial: vec![0; rank_words],
813            columns: vec![0; columns * rank_words],
814        }
815    }
816}
817
818#[cfg(test)]
819#[path = "bposd_tests.rs"]
820mod tests;