Skip to main content

prism_q/sim/
homological.rs

1//! Homological noisy sampling for Clifford circuits: precomputed syndrome
2//! classes give O(1) noise work per shot, plus exact analytic noisy
3//! marginals.
4
5use crate::circuit::{Circuit, Instruction};
6use crate::error::Result;
7use crate::sim::ShotsResult;
8use crate::sim::compiled::batch_propagate_backward;
9use crate::sim::compiled::{PackedShots, ShotAccumulator, default_chunk_size, xor_words};
10use crate::sim::noise::NoiseModel;
11use rand::SeedableRng;
12use rand_chacha::ChaCha8Rng;
13
14/// Dense binary matrix over GF(2) stored as packed u64 words per row.
15/// Row-major: row i is stored in words[i * row_words .. (i+1) * row_words].
16struct F2DenseMatrix {
17    #[cfg(test)]
18    num_rows: usize,
19    #[cfg(test)]
20    num_cols: usize,
21    row_words: usize,
22    data: Vec<u64>,
23}
24
25impl F2DenseMatrix {
26    fn new(num_rows: usize, num_cols: usize) -> Self {
27        let row_words = num_cols.div_ceil(64);
28        Self {
29            #[cfg(test)]
30            num_rows,
31            #[cfg(test)]
32            num_cols,
33            row_words,
34            data: vec![0u64; num_rows * row_words],
35        }
36    }
37
38    #[inline(always)]
39    fn set(&mut self, row: usize, col: usize) {
40        self.data[row * self.row_words + col / 64] |= 1u64 << (col % 64);
41    }
42
43    #[inline(always)]
44    fn get(&self, row: usize, col: usize) -> bool {
45        (self.data[row * self.row_words + col / 64] >> (col % 64)) & 1 != 0
46    }
47
48    #[cfg(test)]
49    fn row(&self, row: usize) -> &[u64] {
50        let start = row * self.row_words;
51        &self.data[start..start + self.row_words]
52    }
53
54    #[cfg(test)]
55    fn xor_row(&mut self, dst_row: usize, src_row: usize) {
56        let rw = self.row_words;
57        let (dst_start, src_start) = (dst_row * rw, src_row * rw);
58        if dst_start < src_start {
59            let (left, right) = self.data.split_at_mut(src_start);
60            for w in 0..rw {
61                left[dst_start + w] ^= right[w];
62            }
63        } else {
64            let (left, right) = self.data.split_at_mut(dst_start);
65            for w in 0..rw {
66                right[w] ^= left[src_start + w];
67            }
68        }
69    }
70
71    #[cfg(test)]
72    fn swap_rows(&mut self, a: usize, b: usize) {
73        if a == b {
74            return;
75        }
76        let rw = self.row_words;
77        let (a_start, b_start) = (a * rw, b * rw);
78        for w in 0..rw {
79            self.data.swap(a_start + w, b_start + w);
80        }
81    }
82}
83
84/// Compute the kernel (null space) of a binary matrix over GF(2).
85///
86/// Given M ∈ F₂^{m×n}, returns a basis for ker(M) = {x ∈ F₂^n : Mx = 0}.
87/// Uses row reduction on the augmented matrix [M | I_n]^T approach:
88/// transpose M, row-reduce M^T, read off kernel vectors.
89///
90/// Returns: Vec of kernel basis vectors, each as a Vec<u64> packed bitvector of length n.
91#[cfg(test)]
92fn gf2_kernel(matrix: &F2DenseMatrix) -> Vec<Vec<u64>> {
93    let m = matrix.num_rows;
94    let n = matrix.num_cols;
95    let n_words = n.div_ceil(64);
96
97    let aug_cols = m + n;
98    let mut aug = F2DenseMatrix::new(n, aug_cols);
99
100    for r in 0..m {
101        for c in 0..n {
102            if matrix.get(r, c) {
103                aug.set(c, r);
104            }
105        }
106    }
107    for i in 0..n {
108        aug.set(i, m + i);
109    }
110
111    let mut pivot_row = 0;
112    for col in 0..m {
113        let mut found = None;
114        for r in pivot_row..n {
115            if aug.get(r, col) {
116                found = Some(r);
117                break;
118            }
119        }
120        let Some(pr) = found else { continue };
121
122        aug.swap_rows(pivot_row, pr);
123
124        for r in 0..n {
125            if r != pivot_row && aug.get(r, col) {
126                aug.xor_row(r, pivot_row);
127            }
128        }
129        pivot_row += 1;
130    }
131
132    let mut kernel = Vec::new();
133    let m_words = m.div_ceil(64);
134    for r in 0..n {
135        let row = aug.row(r);
136        let mt_zero = row[..m_words].iter().enumerate().all(|(w, &val)| {
137            if w == m_words - 1 && !m.is_multiple_of(64) {
138                val & ((1u64 << (m % 64)) - 1) == 0
139            } else {
140                val == 0
141            }
142        });
143        if mt_zero {
144            let mut kv = vec![0u64; n_words];
145            for c in 0..n {
146                if aug.get(r, m + c) {
147                    kv[c / 64] |= 1u64 << (c % 64);
148                }
149            }
150            kernel.push(kv);
151        }
152    }
153
154    kernel
155}
156
157/// Error chain complex for a Clifford circuit with noise.
158///
159/// Represents the chain complex C₂ →∂₂→ C₁ →∂₁→ C₀ where:
160/// - C₀ = F₂^m (measurement/detector space)
161/// - C₁ = F₂^p (error location space)
162/// - C₂ = F₂^s (stabilizer space)
163/// - ∂₁ = E (the m×p error propagation matrix)
164pub struct ErrorChainComplex {
165    /// E-matrix: m × p binary matrix. E[d][e] = 1 if error e flips measurement d.
166    e_matrix: F2DenseMatrix,
167    /// Error probabilities: p_total[e] = px + py + pz for error location e.
168    error_probs: Vec<f64>,
169    num_measurements: usize,
170    num_errors: usize,
171    /// dim(im(∂₂) ∩ ker(∂₁)): stabilizer generators undetectable by measurements
172    boundary_dim: usize,
173    /// dim(H₁) = dim(ker(∂₁)/im(∂₂)): independent logical error classes
174    homology_dim: usize,
175}
176
177/// Precomputed sampler for O(r + 1) per-shot noisy measurement sampling.
178///
179/// Combines a compiled sampler (quantum randomness, O(r) per shot) with
180/// precomputed syndrome class probabilities (noise randomness, O(1) per shot).
181/// The syndrome classes are elements of im(E) ⊆ F₂^m where E is the
182/// error-to-measurement propagation matrix.
183pub struct HomologicalSampler {
184    compiled: crate::sim::compiled::CompiledSampler,
185    /// Syndrome rank = dim(im(E))
186    syndrome_rank: usize,
187    /// 2^r class probabilities before normalization, kept for the sum-to-one test.
188    #[cfg(test)]
189    class_probs: Vec<f64>,
190    /// 2^r cumulative probabilities for sampling
191    class_cdf: Vec<f64>,
192    /// 2^r detection signatures: for class c, which measurements are flipped.
193    /// Stored as packed u64 vectors, each of length ceil(m/64).
194    class_detections: Vec<Vec<u64>>,
195    /// dim(im(∂₂) ∩ ker(∂₁)): undetectable stabilizer generators
196    boundary_dim: usize,
197    /// dim(H₁ = ker(∂₁)/im(∂₂)): independent logical error classes
198    homology_dim: usize,
199    rng: ChaCha8Rng,
200}
201
202impl ErrorChainComplex {
203    /// Build the error chain complex from a Clifford circuit and noise model.
204    ///
205    /// Uses backward Pauli propagation (same as the compiled noisy sampler)
206    /// to determine which measurements are sensitive to each error location.
207    pub fn build(circuit: &Circuit, noise: &NoiseModel, _seed: u64) -> Result<Self> {
208        let m = circuit
209            .instructions
210            .iter()
211            .filter(|i| matches!(i, Instruction::Measure { .. }))
212            .count();
213        if m == 0 {
214            return Ok(Self {
215                e_matrix: F2DenseMatrix::new(0, 0),
216                error_probs: Vec::new(),
217                num_measurements: 0,
218                num_errors: 0,
219                boundary_dim: circuit.num_qubits,
220                homology_dim: 0,
221            });
222        }
223
224        let m_words = m.div_ceil(64);
225        let n = circuit.num_qubits;
226
227        let mut x_packed: Vec<Vec<u64>> = vec![vec![0u64; m_words]; n];
228        let mut z_packed: Vec<Vec<u64>> = vec![vec![0u64; m_words]; n];
229        let mut sign_packed = vec![0u64; m_words];
230
231        let mut meas_idx = m;
232        for instr in circuit.instructions.iter().rev() {
233            if let Instruction::Measure { qubit, .. } = instr {
234                meas_idx -= 1;
235                let word = meas_idx / 64;
236                let bit = meas_idx % 64;
237                z_packed[*qubit][word] |= 1u64 << bit;
238            }
239        }
240
241        let mut error_probs = Vec::new();
242        let mut e_cols: Vec<Vec<u64>> = Vec::new();
243
244        for (instr_idx, instr) in circuit.instructions.iter().enumerate().rev() {
245            match instr {
246                Instruction::Gate { gate, targets } => {
247                    let noise_events = &noise.after_gate[instr_idx];
248                    for event in noise_events {
249                        // An inert channel contributes no column, and passes
250                        // `ensure_pauli_only` whatever its variant.
251                        if event.channel.is_inert() {
252                            continue;
253                        }
254                        let (px, py, pz) = event.pauli_probs();
255                        let q = event.qubit();
256                        let p_total = px + py + pz;
257                        if p_total < 1e-15 {
258                            continue;
259                        }
260
261                        let x_sens = &z_packed[q];
262                        let z_sens = &x_packed[q];
263
264                        if px > 1e-15 && x_sens.iter().any(|&w| w != 0) {
265                            error_probs.push(px);
266                            e_cols.push(x_sens.clone());
267                        }
268
269                        if pz > 1e-15 && z_sens.iter().any(|&w| w != 0) {
270                            error_probs.push(pz);
271                            e_cols.push(z_sens.clone());
272                        }
273
274                        if py > 1e-15 {
275                            let mut y_sens = vec![0u64; m_words];
276                            for w in 0..m_words {
277                                y_sens[w] = x_sens[w] ^ z_sens[w];
278                            }
279                            if y_sens.iter().any(|&w| w != 0) {
280                                error_probs.push(py);
281                                e_cols.push(y_sens);
282                            }
283                        }
284                    }
285
286                    batch_propagate_backward(
287                        &mut x_packed,
288                        &mut z_packed,
289                        &mut sign_packed,
290                        gate,
291                        targets.as_slice(),
292                        m_words,
293                    );
294                }
295                Instruction::Measure { .. }
296                | Instruction::Reset { .. }
297                | Instruction::Barrier { .. } => {}
298                Instruction::Conditional { gate, targets, .. } => {
299                    batch_propagate_backward(
300                        &mut x_packed,
301                        &mut z_packed,
302                        &mut sign_packed,
303                        gate,
304                        targets.as_slice(),
305                        m_words,
306                    );
307                }
308                Instruction::Region(_) => {
309                    return Err(crate::error::PrismError::IncompatibleBackend {
310                        backend: "HomologicalDetectorModel".to_string(),
311                        reason: "error propagation does not support guarded regions".to_string(),
312                    });
313                }
314            }
315        }
316
317        let p = error_probs.len();
318        let mut e_matrix = F2DenseMatrix::new(m, p);
319
320        for (col, col_data) in e_cols.iter().enumerate() {
321            for (w, &word) in col_data.iter().enumerate() {
322                if word == 0 {
323                    continue;
324                }
325                let base = w * 64;
326                let mut bits = word;
327                while bits != 0 {
328                    let bit = bits.trailing_zeros() as usize;
329                    let row = base + bit;
330                    if row < m {
331                        e_matrix.set(row, col);
332                    }
333                    bits &= bits - 1;
334                }
335            }
336        }
337
338        let (boundary_dim, homology_dim) = Self::compute_boundary_space(circuit, n);
339
340        Ok(Self {
341            e_matrix,
342            error_probs,
343            num_measurements: m,
344            num_errors: p,
345            boundary_dim,
346            homology_dim,
347        })
348    }
349
350    /// Forward-propagate stabilizer generators and compute ∂₂ boundary space.
351    ///
352    /// Returns (boundary_dim, homology_dim) where:
353    /// - boundary_dim = dim(im(∂₂) ∩ ker(∂₁)) = stabilizers undetectable by measurements
354    /// - homology_dim = dim(H₁) = independent logical error classes
355    ///
356    /// Algorithm: forward-propagate Z_0,...,Z_{n-1} through the circuit to get
357    /// output stabilizer generators. Build X-projection onto measured qubits.
358    /// rank(X_proj) counts stabilizers with detectable X components;
359    /// H₁ = ker(σ) / (S ∩ ker(σ)) has dim = n - num_measured + rank(X_proj).
360    fn compute_boundary_space(circuit: &Circuit, n: usize) -> (usize, usize) {
361        if n == 0 {
362            return (0, 0);
363        }
364
365        let n_words = n.div_ceil(64);
366        let mut stab_x: Vec<Vec<u64>> = vec![vec![0u64; n_words]; n];
367        let mut stab_z: Vec<Vec<u64>> = vec![vec![0u64; n_words]; n];
368        let mut stab_sign = vec![0u64; n_words];
369
370        for i in 0..n {
371            stab_z[i][i / 64] |= 1u64 << (i % 64);
372        }
373
374        for instr in circuit.instructions.iter() {
375            match instr {
376                Instruction::Gate { gate, targets } => {
377                    batch_propagate_backward(
378                        &mut stab_x,
379                        &mut stab_z,
380                        &mut stab_sign,
381                        gate,
382                        targets.as_slice(),
383                        n_words,
384                    );
385                }
386                Instruction::Conditional { gate, targets, .. } => {
387                    batch_propagate_backward(
388                        &mut stab_x,
389                        &mut stab_z,
390                        &mut stab_sign,
391                        gate,
392                        targets.as_slice(),
393                        n_words,
394                    );
395                }
396                _ => {}
397            }
398        }
399
400        let mut measured = vec![false; n];
401        for instr in &circuit.instructions {
402            if let Instruction::Measure { qubit, .. } = instr {
403                measured[*qubit] = true;
404            }
405        }
406        let num_measured = measured.iter().filter(|&&b| b).count();
407        let measured_indices: Vec<usize> = (0..n).filter(|&q| measured[q]).collect();
408
409        if num_measured == 0 {
410            return (n, 0);
411        }
412
413        let proj_words = num_measured.div_ceil(64);
414        let mut proj = vec![0u64; n * proj_words];
415
416        for stab_idx in 0..n {
417            for (proj_col, &q) in measured_indices.iter().enumerate() {
418                let x_bit = (stab_x[q][stab_idx / 64] >> (stab_idx % 64)) & 1;
419                if x_bit != 0 {
420                    proj[stab_idx * proj_words + proj_col / 64] |= 1u64 << (proj_col % 64);
421                }
422            }
423        }
424
425        let mut rank = 0;
426        let mut pivot_row = 0;
427        for col in 0..num_measured {
428            let mut found = None;
429            for r in pivot_row..n {
430                if (proj[r * proj_words + col / 64] >> (col % 64)) & 1 != 0 {
431                    found = Some(r);
432                    break;
433                }
434            }
435            let Some(pr) = found else { continue };
436
437            if pr != pivot_row {
438                for w in 0..proj_words {
439                    proj.swap(pivot_row * proj_words + w, pr * proj_words + w);
440                }
441            }
442
443            for r in 0..n {
444                if r != pivot_row && (proj[r * proj_words + col / 64] >> (col % 64)) & 1 != 0 {
445                    for w in 0..proj_words {
446                        proj[r * proj_words + w] ^= proj[pivot_row * proj_words + w];
447                    }
448                }
449            }
450
451            pivot_row += 1;
452            rank += 1;
453        }
454
455        let boundary_dim = n - rank;
456        let homology_dim = n - num_measured + rank;
457        (boundary_dim, homology_dim)
458    }
459
460    /// dim(im(∂₂) ∩ ker(∂₁)): stabilizer generators undetectable by the
461    /// measurements.
462    pub fn boundary_dim(&self) -> usize {
463        self.boundary_dim
464    }
465
466    /// dim(H₁): independent logical error classes.
467    pub fn homology_dim(&self) -> usize {
468        self.homology_dim
469    }
470
471    /// Compute exact noisy marginals analytically. No sampling, no rank limit.
472    ///
473    /// For each measurement j, the noisy probability is:
474    ///   p_j^noisy = p_j + (1 - 2·p_j) · (1 - f_j) / 2
475    /// where f_j = Π_{e: E(j,e)=1} (1 - 2·p_e) is the flip attenuation factor
476    /// and p_j is the noiseless marginal (0, 0.5, or 1).
477    ///
478    /// Cost: O(nnz(E)). Works for any qubit count.
479    pub fn noisy_marginals(&self, noiseless_marginals: &[f64]) -> Vec<f64> {
480        let m = self.num_measurements;
481        let p = self.num_errors;
482        if m == 0 || p == 0 {
483            return noiseless_marginals.to_vec();
484        }
485
486        let mut flip_factor = vec![1.0f64; m];
487        let rw = self.e_matrix.row_words;
488
489        for e in 0..p {
490            let factor = 1.0 - 2.0 * self.error_probs[e];
491            if (factor - 1.0).abs() < 1e-15 {
492                continue;
493            }
494
495            let col_word = e / 64;
496            let col_bit = 1u64 << (e % 64);
497
498            for (j, ff) in flip_factor.iter_mut().enumerate() {
499                if self.e_matrix.data[j * rw + col_word] & col_bit != 0 {
500                    *ff *= factor;
501                }
502            }
503        }
504
505        let mut result = Vec::with_capacity(m);
506        for j in 0..m {
507            let p_j = noiseless_marginals[j];
508            let p_flip = (1.0 - flip_factor[j]) / 2.0;
509            result.push(p_j + (1.0 - 2.0 * p_j) * p_flip);
510        }
511        result
512    }
513}
514
515const MAX_SYNDROME_RANK: usize = 20;
516
517impl HomologicalSampler {
518    /// Build a sampler from a circuit and noise model.
519    ///
520    /// Computes the E-matrix (error-to-measurement propagation), finds a basis
521    /// for im(E), and precomputes 2^r syndrome class probabilities where
522    /// r = rank(E), rejecting circuits with r above `MAX_SYNDROME_RANK`.
523    /// Also builds a compiled sampler for quantum randomness. Readout error is
524    /// rejected with the non-Pauli channels: it is drawn per shot against the
525    /// record and has no syndrome class to fold into.
526    ///
527    /// Total per-shot cost: O(r_quantum + 1) where r_quantum is the stabilizer
528    /// rank (number of random measurements), versus O(p) for brute-force
529    /// where p is the number of error locations.
530    pub fn compile(circuit: &Circuit, noise: &NoiseModel, seed: u64) -> Result<Self> {
531        noise.ensure_pauli_only()?;
532        let ecc = ErrorChainComplex::build(circuit, noise, seed)?;
533        let m = ecc.num_measurements;
534        let p = ecc.num_errors;
535        let compiled = crate::sim::compiled::compile_measurements(circuit, seed)?;
536
537        if m == 0 || p == 0 {
538            return Ok(Self {
539                compiled,
540                syndrome_rank: 0,
541                #[cfg(test)]
542                class_probs: vec![1.0],
543                class_cdf: vec![1.0],
544                class_detections: vec![vec![0u64; m.div_ceil(64)]],
545                boundary_dim: ecc.boundary_dim,
546                homology_dim: ecc.homology_dim,
547                rng: ChaCha8Rng::seed_from_u64(seed),
548            });
549        }
550
551        let m_words = m.div_ceil(64);
552
553        let mut work = ecc.e_matrix.data.clone();
554        let rw = ecc.e_matrix.row_words;
555        let mut pivot_cols = Vec::new();
556        let mut pivot_row = 0;
557
558        for col in 0..p {
559            let mut found = None;
560            for r in pivot_row..m {
561                if (work[r * rw + col / 64] >> (col % 64)) & 1 != 0 {
562                    found = Some(r);
563                    break;
564                }
565            }
566            let Some(pr) = found else { continue };
567
568            if pr != pivot_row {
569                for w in 0..rw {
570                    work.swap(pivot_row * rw + w, pr * rw + w);
571                }
572            }
573
574            for r in 0..m {
575                if r != pivot_row && (work[r * rw + col / 64] >> (col % 64)) & 1 != 0 {
576                    for w in 0..rw {
577                        work[r * rw + w] ^= work[pivot_row * rw + w];
578                    }
579                }
580            }
581
582            pivot_cols.push(col);
583            pivot_row += 1;
584        }
585
586        let r = pivot_cols.len();
587        if r > MAX_SYNDROME_RANK {
588            return Err(crate::error::PrismError::IncompatibleBackend {
589                backend: "HomologicalSampler".to_string(),
590                reason: format!("syndrome rank {r} too large (max {MAX_SYNDROME_RANK})"),
591            });
592        }
593
594        // Extract r-bit coordinates from RREF: col j's coordinate at basis i
595        // is work[i][j] in the reduced matrix.
596        let mut col_coords = vec![0usize; p];
597        for (basis_idx, &_pivot_col) in pivot_cols.iter().enumerate() {
598            for j in 0..p {
599                if (work[basis_idx * rw + j / 64] >> (j % 64)) & 1 != 0 {
600                    col_coords[j] |= 1 << basis_idx;
601                }
602            }
603        }
604
605        let num_classes = 1usize << r;
606        let mut class_detections = Vec::with_capacity(num_classes);
607        for c in 0..num_classes {
608            let mut det = vec![0u64; m_words];
609            for (basis_idx, &pivot_col) in pivot_cols.iter().enumerate() {
610                if (c >> basis_idx) & 1 != 0 {
611                    for row in 0..m {
612                        if ecc.e_matrix.get(row, pivot_col) {
613                            det[row / 64] ^= 1u64 << (row % 64);
614                        }
615                    }
616                }
617            }
618            class_detections.push(det);
619        }
620
621        // F₂^r probability convolution: P[c] = (1-p_j) P[c] + p_j P[c ⊕ coord_j]
622        let mut class_probs = vec![0.0_f64; num_classes];
623        class_probs[0] = 1.0;
624
625        for (j, &coord) in col_coords.iter().enumerate() {
626            let pj = ecc.error_probs[j];
627            if pj < 1e-15 {
628                continue;
629            }
630            if coord == 0 {
631                continue;
632            }
633            let mut new_probs = vec![0.0_f64; num_classes];
634            for c in 0..num_classes {
635                new_probs[c] = (1.0 - pj) * class_probs[c] + pj * class_probs[c ^ coord];
636            }
637            class_probs = new_probs;
638        }
639
640        let mut class_cdf = vec![0.0_f64; num_classes];
641        class_cdf[0] = class_probs[0];
642        for c in 1..num_classes {
643            class_cdf[c] = class_cdf[c - 1] + class_probs[c];
644        }
645        let total = class_cdf[num_classes - 1];
646        if total > 0.0 {
647            for v in &mut class_cdf {
648                *v /= total;
649            }
650        }
651
652        Ok(Self {
653            compiled,
654            syndrome_rank: r,
655            #[cfg(test)]
656            class_probs,
657            class_cdf,
658            class_detections,
659            boundary_dim: ecc.boundary_dim,
660            homology_dim: ecc.homology_dim,
661            rng: ChaCha8Rng::seed_from_u64(seed),
662        })
663    }
664
665    /// rank(E): number of independent syndrome classes is `2^rank`.
666    pub fn syndrome_rank(&self) -> usize {
667        self.syndrome_rank
668    }
669
670    /// dim(im(∂₂) ∩ ker(∂₁)): stabilizer generators undetectable by the
671    /// measurements.
672    pub fn boundary_dim(&self) -> usize {
673        self.boundary_dim
674    }
675
676    /// dim(H₁): independent logical error classes.
677    pub fn homology_dim(&self) -> usize {
678        self.homology_dim
679    }
680
681    /// Cost: O(r_quantum) for compiled sampler + O(1) for noise class lookup.
682    pub fn sample(&mut self) -> Vec<bool> {
683        let mut outcome = self.compiled.sample();
684
685        let u: f64 = rand::RngExt::random(&mut self.rng);
686        let class = match self
687            .class_cdf
688            .binary_search_by(|p| p.partial_cmp(&u).unwrap_or(std::cmp::Ordering::Equal))
689        {
690            Ok(i) => i,
691            Err(i) => i.min(self.class_cdf.len() - 1),
692        };
693
694        let det = &self.class_detections[class];
695        for (mi, bit) in outcome.iter_mut().enumerate() {
696            let det_bit = (det[mi / 64] >> (mi % 64)) & 1 != 0;
697            *bit ^= det_bit;
698        }
699        outcome
700    }
701
702    pub fn sample_bulk(&mut self, num_shots: usize) -> Vec<Vec<bool>> {
703        (0..num_shots).map(|_| self.sample()).collect()
704    }
705
706    /// Sample `num_shots` shots into a shot-major [`PackedShots`] buffer.
707    pub fn sample_packed(&mut self, num_shots: usize) -> PackedShots {
708        let m = self.compiled.num_measurements();
709        let m_words = m.div_ceil(64);
710        if num_shots == 0 || m == 0 {
711            return PackedShots::from_shot_major(Vec::new(), num_shots, m);
712        }
713
714        let mut accum = Vec::new();
715        let mut rand_buf = Vec::new();
716        self.compiled
717            .sample_bulk_words_shot_major_reuse(&mut accum, &mut rand_buf, num_shots);
718
719        let ref_bits = self.compiled.ref_bits_packed();
720        for s in 0..num_shots {
721            let base = s * m_words;
722            xor_words(&mut accum[base..base + m_words], ref_bits);
723        }
724
725        for s in 0..num_shots {
726            let u: f64 = rand::RngExt::random(&mut self.rng);
727            let class = match self
728                .class_cdf
729                .binary_search_by(|p| p.partial_cmp(&u).unwrap_or(std::cmp::Ordering::Equal))
730            {
731                Ok(i) => i,
732                Err(i) => i.min(self.class_cdf.len() - 1),
733            };
734
735            let det = &self.class_detections[class];
736            let base = s * m_words;
737            xor_words(&mut accum[base..base + m_words], det);
738        }
739
740        PackedShots::from_shot_major(accum, num_shots, m)
741    }
742
743    /// Stream shots into `acc` in default-sized chunks instead of one matrix.
744    pub fn sample_chunked<A: ShotAccumulator>(&mut self, total_shots: usize, acc: &mut A) {
745        let chunk_size = default_chunk_size(self.compiled.num_measurements());
746        crate::sim::compiled::for_each_chunk(total_shots, chunk_size, |batch| {
747            let packed = self.sample_packed(batch);
748            acc.accumulate(&packed);
749        });
750    }
751
752    /// Sample `total_shots` shots and return per-measurement `P(bit = 1)`.
753    pub fn sample_marginals(&mut self, total_shots: usize) -> Vec<f64> {
754        crate::sim::compiled::marginals_from_chunks(self.compiled.num_measurements(), |acc| {
755            self.sample_chunked(total_shots, acc)
756        })
757    }
758}
759
760/// Run noisy shot sampling using the homological sampler.
761///
762/// For Clifford circuits whose syndrome rank is at most `MAX_SYNDROME_RANK`
763/// (20), precomputes class probabilities and samples in O(1) per shot.
764pub fn run_shots_homological(
765    circuit: &Circuit,
766    noise: &NoiseModel,
767    num_shots: usize,
768    seed: u64,
769) -> Result<ShotsResult> {
770    noise.validate_for(circuit)?;
771    let sampler = HomologicalSampler::compile(circuit, noise, seed)?;
772    run_shots_homological_inner(sampler, circuit, num_shots)
773}
774
775pub(crate) fn run_shots_homological_inner(
776    mut sampler: HomologicalSampler,
777    circuit: &Circuit,
778    num_shots: usize,
779) -> Result<ShotsResult> {
780    let classical_bit_order = circuit.classical_bit_order();
781    let num_classical = circuit.num_classical_bits;
782
783    let raw_shots = sampler.sample_bulk(num_shots);
784
785    let mut shots = Vec::with_capacity(num_shots);
786    for raw in &raw_shots {
787        let mut out = vec![false; num_classical];
788        for (mi, &cbit) in classical_bit_order.iter().enumerate() {
789            if cbit < num_classical {
790                out[cbit] = raw[mi];
791            }
792        }
793        shots.push(out);
794    }
795
796    Ok(
797        ShotsResult::from_shots(shots, circuit.num_classical_bits).with_metadata(
798            crate::sim::RunMetadata::exact(crate::sim::ResolvedBackend::CompiledStabilizer)
799                .with_engine(crate::sim::Engine::HomologicalSampler),
800        ),
801    )
802}
803
804/// Compute exact noisy marginals analytically. No sampling, no rank limit.
805///
806/// Builds the error chain complex and compiled sampler, then computes
807/// exact per-measurement noisy probabilities in O(nnz(E)) time.
808/// Works for any qubit count, not limited by syndrome rank.
809pub fn noisy_marginals_analytical(
810    circuit: &Circuit,
811    noise: &NoiseModel,
812    seed: u64,
813) -> Result<Vec<f64>> {
814    noise.ensure_pauli_only()?;
815    let ecc = ErrorChainComplex::build(circuit, noise, seed)?;
816    let compiled = crate::sim::compiled::compile_measurements(circuit, seed)?;
817    let noiseless = compiled.marginal_probabilities();
818    let noisy = ecc.noisy_marginals(&noiseless);
819
820    let classical_bit_order: Vec<usize> = circuit
821        .instructions
822        .iter()
823        .filter_map(|inst| match inst {
824            Instruction::Measure { classical_bit, .. } => Some(*classical_bit),
825            _ => None,
826        })
827        .collect();
828    let num_classical = circuit.num_classical_bits;
829
830    let mut result = vec![0.5f64; num_classical];
831    for (mi, &cbit) in classical_bit_order.iter().enumerate() {
832        if cbit < num_classical && mi < noisy.len() {
833            result[cbit] = noisy[mi];
834        }
835    }
836    Ok(result)
837}
838
839#[cfg(test)]
840#[path = "homological_tests.rs"]
841mod tests;