Skip to main content

ppvm_tableau/
measure.rs

1// SPDX-FileCopyrightText: 2026 The PPVM Authors
2// SPDX-License-Identifier: Apache-2.0
3
4use super::data::{compute_phase_with_mask_static, symplectic_inner};
5use crate::prelude::*;
6use bitvec::view::BitView;
7use fxhash::FxHashMap as HashMap;
8use num::PrimInt;
9use num::complex::{Complex, Complex64, ComplexFloat};
10use num::traits::{One, ToPrimitive, Zero};
11use rand::RngExt;
12use std::fmt::Debug;
13
14impl<T: Config> Measure for Tableau<T>
15where
16    <<T as Config>::Storage as BitView>::Store: PrimInt,
17{
18    /// Measure qubit `addr0` in Z basis
19    fn measure(&mut self, addr0: usize) -> bool {
20        let q = self.find_z_anticommuting_stabilizer(addr0);
21        match q {
22            Some(q_idx) => {
23                // Case a: random measurement outcome
24                // At least one stabilizer anticommutes with Z_addr0
25
26                // Generate random measurement outcome (50/50)
27                let outcome = self.rng.random::<bool>();
28
29                self.update_tableau_according_to_outcome(addr0, q_idx, outcome);
30
31                outcome
32            }
33            None => {
34                // Case b: deterministic measurement outcome
35
36                self.get_deterministic_outcome(addr0)
37            }
38        }
39    }
40}
41
42const COMPLEX_PHASE_CONVERSION: [Complex64; 4] = [
43    Complex64::new(1.0, 0.0),  // +1
44    Complex64::new(0.0, 1.0),  // +i
45    Complex64::new(-1.0, 0.0), // -1
46    Complex64::new(0.0, -1.0), // -i
47];
48
49/// Per-measurement scratch buffers, reused across qubits within a single
50/// `measure_all` invocation — and, when threaded through
51/// [`measure_all_with_scratch`](GeneralizedTableau::measure_all_with_scratch),
52/// across many shots of a sampler too. Reusing one scratch keeps the case-a
53/// HashMap and the b-entries Vec out of the per-shot allocator churn.
54///
55/// - `odd_phase_mask` is lazily computed and cached until the destabilizers
56///   change (i.e. until a case-a measurement runs `update_tableau_according_to_outcome`).
57/// - `coeff_map` is the case-a HashMap holding `(idx → amplitude)` between
58///   the overlap, partition, and merge passes.
59/// - `b_entries` is the case-a partition's "k-bit = 1" scratch Vec.
60///
61/// Construct one per active sampling thread; the type is not meant to be
62/// shared across threads concurrently.
63#[derive(Clone)]
64pub struct MeasureScratch<I, R> {
65    pub odd_phase_mask: Option<I>,
66    pub coeff_map: HashMap<I, Complex<R>>,
67    pub b_entries: Vec<(I, Complex<R>)>,
68}
69
70impl<I, R> MeasureScratch<I, R> {
71    pub fn new() -> Self {
72        Self {
73            odd_phase_mask: None,
74            coeff_map: HashMap::default(),
75            b_entries: Vec::new(),
76        }
77    }
78}
79
80impl<I, R> Default for MeasureScratch<I, R> {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl<T: Config, I, C: SparseVector<Complex<T::Coeff>, I>> LossyMeasure
87    for GeneralizedTableau<T, I, C>
88where
89    T: Config,
90    <<T as Config>::Storage as BitView>::Store: PrimInt,
91    C: SparseVector<Complex<T::Coeff>, I> + std::fmt::Debug,
92    T::Coeff: One
93        + Zero
94        + Clone
95        + num::Num
96        + ToPrimitive
97        + std::fmt::Debug
98        + std::ops::Mul<f64>
99        + PartialOrd<f64>,
100    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
101        + From<Complex64>
102        + std::ops::MulAssign
103        + std::ops::AddAssign
104        + One
105        + ComplexFloat
106        + Copy,
107    I: TableauIndex + Debug,
108{
109    fn measure(&mut self, addr0: usize) -> Option<bool> {
110        if self.qubit_status[addr0] == QubitStatus::Lost {
111            self.measurement_record.push(None);
112            return None;
113        }
114
115        let (phase_decomp, stab_anticomm_bits, destab_anticomm_bits) =
116            self.compute_decomposition(addr0, Pauli::Z);
117
118        // Standalone callers don't get cross-call cache benefits; `measure_all`
119        // threads through a long-lived scratch.
120        let mut scratch = MeasureScratch::new();
121        self.measure_with_scratch(
122            addr0,
123            &mut scratch,
124            phase_decomp,
125            stab_anticomm_bits,
126            destab_anticomm_bits,
127        )
128    }
129
130    /// Override the trait default (a per-target `measure` loop, which allocates
131    /// a fresh `MeasureScratch` on every call) with a single scratch reused
132    /// across the whole batch, amortizing the case-a HashMap / `b_entries`
133    /// allocations and the cached odd-phase-destabilizer mask. Outcomes, the
134    /// measurement record, and the RNG-draw order are identical to measuring
135    /// each target individually — only the internal allocation pattern changes.
136    fn measure_many(&mut self, targets: &[usize]) -> Vec<Option<bool>> {
137        let mut scratch = MeasureScratch::new();
138        self.measure_many_with_scratch(targets, &mut scratch)
139    }
140}
141
142impl<T: Config, I, C: SparseVector<Complex<T::Coeff>, I>> GeneralizedTableau<T, I, C>
143where
144    T: Config,
145    <<T as Config>::Storage as BitView>::Store: PrimInt,
146    C: SparseVector<Complex<T::Coeff>, I> + std::fmt::Debug,
147    T::Coeff: One
148        + Zero
149        + Clone
150        + num::Num
151        + ToPrimitive
152        + std::fmt::Debug
153        + std::ops::Mul<f64>
154        + PartialOrd<f64>,
155    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
156        + From<Complex64>
157        + std::ops::MulAssign
158        + std::ops::AddAssign
159        + One
160        + ComplexFloat
161        + Copy,
162    I: TableauIndex + Debug,
163{
164    pub(crate) fn measure_with_scratch(
165        &mut self,
166        addr0: usize,
167        scratch: &mut MeasureScratch<I, T::Coeff>,
168        phase_decomp: u8,
169        stab_anticomm_bits: I,
170        destab_anticomm_bits: I,
171    ) -> Option<bool> {
172        if stab_anticomm_bits == I::zero() {
173            // Case b (fast path): Z is already a stabilizer. Overlap + filter in place.
174
175            // Compute overlap directly on self.coefficients without draining.
176            let mut z_overlap_re = 0.0f64;
177            let mut norm_sqr = 0.0f64;
178            for &(coeff, idx) in self.coefficients.iter() {
179                let norm_sq = coeff.norm_sqr().to_f64().unwrap_or(0.0);
180                norm_sqr += norm_sq;
181                let phase = (phase_decomp
182                    + (2 * symplectic_inner(destab_anticomm_bits, idx) as u8) % 4)
183                    % 4;
184                if !phase.is_multiple_of(2) {
185                    continue;
186                }
187                if phase == 0 {
188                    z_overlap_re += norm_sq;
189                } else {
190                    z_overlap_re -= norm_sq;
191                }
192            }
193
194            // Gate-time truncation drops branches without renormalizing, so
195            // divide by ⟨ψ|ψ⟩ rather than assuming it is 1.
196            let prob_1 = 0.5 - 0.5 * z_overlap_re / norm_sqr;
197            let outcome = self.tableau.rng.random::<f64>() < prob_1;
198
199            debug_assert!(
200                phase_decomp == 0 || phase_decomp == 2,
201                "Measurement result cannot be imaginary!"
202            );
203
204            let old_len = self.coefficients.len();
205            let z_sign = phase_decomp == 2;
206            self.coefficients.retain(|(_, alpha)| {
207                let parity = symplectic_inner(*alpha, destab_anticomm_bits) % 2 != 0;
208                (parity ^ outcome) == z_sign
209            });
210            if self.coefficients.len() < old_len {
211                self.coefficients.normalize();
212            }
213
214            // Case-b doesn't mutate destabilizers, so the cached mask remains valid.
215            self.measurement_record.push(Some(outcome));
216            Some(outcome)
217        } else {
218            // Case a: Z is not a stabilizer — sort-merge instead of HashMap.
219            let mut by_idx: Vec<(I, Complex<T::Coeff>)> =
220                std::mem::replace(&mut self.coefficients, C::new())
221                    .into_iter()
222                    .map(|(c, i)| (i, c))
223                    .collect();
224            {
225                let mut sorted = true;
226                let mut prev_k: Option<I> = None;
227                for &(k, _) in &by_idx {
228                    if let Some(p) = prev_k
229                        && k < p
230                    {
231                        sorted = false;
232                        break;
233                    }
234                    prev_k = Some(k);
235                }
236                if !sorted {
237                    by_idx.sort_unstable_by_key(|a| a.0);
238                }
239            }
240
241            let odd_phase_mask = *scratch
242                .odd_phase_mask
243                .get_or_insert_with(|| self.odd_phase_destabilizer_mask());
244
245            // OVERLAP: 2-way merge of by_idx and shifted (each entry XOR'd by stab_anticomm_bits).
246            // At equal key k: by_idx has (k, a_k) and shifted has (k, a_{k^s}), matching
247            // the HashMap overlap's coeff / coeff_branch pair — counted once per key from
248            // each side, exactly as the HashMap iterates every (idx, branch_index) pair.
249            let mut shifted: Vec<(I, Complex<T::Coeff>)> = by_idx
250                .iter()
251                .map(|&(i, c)| (i ^ stab_anticomm_bits, c))
252                .collect();
253            shifted.sort_unstable_by_key(|a| a.0);
254
255            let mut z_overlap_re = 0.0f64;
256            {
257                let mut ii = 0usize;
258                let mut jj = 0usize;
259                while ii < by_idx.len() && jj < shifted.len() {
260                    match by_idx[ii].0.cmp(&shifted[jj].0) {
261                        std::cmp::Ordering::Less => {
262                            ii += 1;
263                        }
264                        std::cmp::Ordering::Greater => {
265                            jj += 1;
266                        }
267                        std::cmp::Ordering::Equal => {
268                            let (idx, a) = by_idx[ii];
269                            let (_, b) = shifted[jj];
270                            let phase = (phase_decomp
271                                + compute_phase_with_mask_static(
272                                    destab_anticomm_bits,
273                                    idx,
274                                    stab_anticomm_bits,
275                                    odd_phase_mask,
276                                ))
277                                % 4;
278                            let a_re = a.re.to_f64().unwrap_or(0.0);
279                            let a_im = a.im.to_f64().unwrap_or(0.0);
280                            let b_re = b.re.to_f64().unwrap_or(0.0);
281                            let b_im = b.im.to_f64().unwrap_or(0.0);
282                            let re_w = a_re * b_re + a_im * b_im;
283                            let im_w = a_re * b_im - a_im * b_re;
284                            match phase {
285                                0 => z_overlap_re += re_w,
286                                1 => z_overlap_re += im_w,
287                                2 => z_overlap_re -= re_w,
288                                3 => z_overlap_re -= im_w,
289                                _ => unreachable!(),
290                            }
291                            ii += 1;
292                            jj += 1;
293                        }
294                    }
295                }
296            }
297
298            // See case b: the state is not necessarily normalized.
299            let norm_sqr: f64 = by_idx
300                .iter()
301                .map(|(_, c)| c.norm_sqr().to_f64().unwrap_or(0.0))
302                .sum();
303            let prob_1 = 0.5 - 0.5 * z_overlap_re / norm_sqr;
304            let outcome = self.tableau.rng.random::<f64>() < prob_1;
305
306            // PROJECTION: partition A (k-bit=0) and B (k-bit=1), transform B, merge.
307            let q_idx = stab_anticomm_bits.trailing_zeros() as usize;
308            let k = I::one() << q_idx;
309            let alpha = if outcome {
310                (phase_decomp + 2) % 4
311            } else {
312                phase_decomp
313            };
314
315            let mut a: Vec<(I, Complex<T::Coeff>)> = Vec::new();
316            let mut bt: Vec<(I, Complex<T::Coeff>)> = Vec::new();
317            for (idx, coeff) in by_idx {
318                if (idx & k) == I::zero() {
319                    a.push((idx, coeff));
320                } else {
321                    let symp = symplectic_inner(idx, destab_anticomm_bits);
322                    let phase_idx =
323                        ((alpha as i32 + if symp % 2 == 1 { 2 } else { 0 }) % 4) as usize;
324                    let q: Complex<T::Coeff> = COMPLEX_PHASE_CONVERSION[phase_idx].into();
325                    bt.push((idx ^ stab_anticomm_bits, q * coeff));
326                }
327            }
328            // `a` is already sorted (subset of sorted by_idx); bt needs sorting.
329            bt.sort_unstable_by_key(|e| e.0);
330
331            // 2-way merge summing equal keys → sorted merged output.
332            let mut merged: Vec<(I, Complex<T::Coeff>)> = Vec::with_capacity(a.len() + bt.len());
333            {
334                let mut i = 0usize;
335                let mut j = 0usize;
336                while i < a.len() && j < bt.len() {
337                    match a[i].0.cmp(&bt[j].0) {
338                        std::cmp::Ordering::Less => {
339                            merged.push(a[i]);
340                            i += 1;
341                        }
342                        std::cmp::Ordering::Greater => {
343                            merged.push(bt[j]);
344                            j += 1;
345                        }
346                        std::cmp::Ordering::Equal => {
347                            let mut sv = a[i].1;
348                            sv += bt[j].1;
349                            merged.push((a[i].0, sv));
350                            i += 1;
351                            j += 1;
352                        }
353                    }
354                }
355                while i < a.len() {
356                    merged.push(a[i]);
357                    i += 1;
358                }
359                while j < bt.len() {
360                    merged.push(bt[j]);
361                    j += 1;
362                }
363            }
364
365            let norm_sqr = merged
366                .iter()
367                .fold(T::Coeff::zero(), |acc, (_, c)| acc + c.norm_sqr());
368            let cutoff_sq = self.coefficient_threshold.clone() * self.coefficient_threshold.clone();
369            let threshold = cutoff_sq.to_f64().unwrap_or(0.0) * norm_sqr.to_f64().unwrap_or(0.0);
370            self.coefficients.reserve(merged.len());
371            for (idx, coeff) in merged {
372                if coeff.norm_sqr() > threshold {
373                    self.coefficients.unsafe_insert(idx, coeff);
374                }
375            }
376
377            self.coefficients.normalize();
378            self.tableau
379                .update_tableau_according_to_outcome(addr0, q_idx, outcome);
380            scratch.odd_phase_mask = None;
381            self.measurement_record.push(Some(outcome));
382            Some(outcome)
383        }
384    }
385
386    pub fn project_case_a(
387        &mut self,
388        outcome: bool,
389        scratch: &mut MeasureScratch<I, T::Coeff>,
390        phase_decomp: u8,
391        stab_anticomm_bits: I,
392        destab_anticomm_bits: I,
393        addr0: usize,
394    ) {
395        // Case a: Z is not a stabilizer — need HashMap for cross-index lookups.
396        // Drain self.coefficients into scratch.coeff_map via `retain` so the
397        // Vec's capacity survives and we can refill it at the end without a
398        // fresh allocation.
399
400        let q_idx = stab_anticomm_bits.trailing_zeros() as usize;
401
402        let one = I::one();
403        let zero = I::zero();
404        let k = one << q_idx;
405
406        let alpha = if outcome {
407            (phase_decomp + 2) % 4
408        } else {
409            phase_decomp
410        };
411
412        // Partition into A (k-bit=0) and B (k-bit=1) via retain, then merge.
413        // Split the borrow so `retain` can mutate coeff_map while the closure
414        // pushes into b_entries.
415        scratch.b_entries.clear();
416        let MeasureScratch {
417            coeff_map,
418            b_entries,
419            ..
420        } = scratch;
421        b_entries.reserve(coeff_map.len() / 2 + 1);
422        coeff_map.retain(|idx, coeff| {
423            if (*idx & k) != zero {
424                b_entries.push((*idx, *coeff));
425                false // remove B entry
426            } else {
427                true // keep A entry
428            }
429        });
430        // Merge B entries into their A partners with phase adjustment.
431        Self::merge_b_into_a(
432            coeff_map,
433            b_entries,
434            alpha,
435            destab_anticomm_bits,
436            stab_anticomm_bits,
437        );
438
439        // Keep entries where |c|/norm > threshold.
440        let norm_sqr = coeff_map
441            .values()
442            .fold(T::Coeff::zero(), |acc, c: &Complex<T::Coeff>| {
443                acc + c.norm_sqr()
444            });
445
446        let cutoff_sq = self.coefficient_threshold.clone() * self.coefficient_threshold.clone();
447        let threshold = cutoff_sq.to_f64().unwrap_or(0.0) * norm_sqr.to_f64().unwrap_or(0.0);
448        // self.coefficients is already empty here (drained via retain above);
449        // reserve is mostly a no-op since the prior capacity is still there.
450        self.coefficients.reserve(coeff_map.len());
451        for (idx, coeff) in coeff_map.drain() {
452            if coeff.norm_sqr() > threshold {
453                self.coefficients.unsafe_insert(idx, coeff);
454            }
455        }
456
457        self.coefficients.normalize();
458
459        self.tableau
460            .update_tableau_according_to_outcome(addr0, q_idx, outcome);
461        // Destabilizer phases just changed, invalidate the cached mask.
462        scratch.odd_phase_mask = None;
463    }
464
465    /// project state in case b (Z is a stabilizer) according to sampled outcome
466    pub fn project_case_b(
467        &mut self,
468        entries: &[(Complex<T::Coeff>, I)],
469        outcome: bool,
470        phase_decomp: u8,
471        destab_anticomm_bits: I,
472    ) {
473        let old_len = entries.len();
474
475        let z_sign = phase_decomp == 2;
476
477        // Pass 2: filter directly into self.coefficients (no retain needed)
478        self.coefficients.reserve(entries.len());
479        for &(coeff, alpha) in entries {
480            let parity = symplectic_inner(alpha, destab_anticomm_bits) % 2 != 0;
481            if (parity ^ outcome) == z_sign {
482                self.coefficients.unsafe_insert(alpha, coeff);
483            }
484        }
485
486        if self.coefficients.len() < old_len {
487            self.coefficients.normalize();
488        }
489    }
490}
491
492impl<T, I, C> GeneralizedTableau<T, I, C>
493where
494    T: Config,
495    <<T as Config>::Storage as BitView>::Store: PrimInt,
496    C: SparseVector<Complex<T::Coeff>, I> + std::fmt::Debug,
497    T::Coeff: One
498        + Zero
499        + Clone
500        + num::Num
501        + ToPrimitive
502        + std::fmt::Debug
503        + std::ops::Mul<f64>
504        + PartialOrd<f64>,
505    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
506        + From<Complex64>
507        + std::ops::MulAssign
508        + std::ops::AddAssign
509        + One
510        + ComplexFloat
511        + Copy,
512    I: TableauIndex + Debug,
513{
514    /// Measure qubit `addr0` in Z basis with optional readout noise.
515    ///
516    /// Behaves like [`measure`](LossyMeasure::measure), then with probability
517    /// `flip_prob` flips the *recorded* bit. The qubit's quantum state stays
518    /// consistent with the true outcome — only the returned value flips.
519    /// `flip_prob = 0.0` is equivalent to `measure`.
520    ///
521    /// If the qubit is lost, returns `None` regardless of `flip_prob`.
522    pub fn measure_noisy(&mut self, addr0: usize, flip_prob: f64) -> Option<bool> {
523        debug_assert!(
524            (0.0..=1.0).contains(&flip_prob),
525            "flip_prob must be in [0, 1], got {flip_prob}"
526        );
527        // `measure` already pushed the (un-flipped) outcome onto the record.
528        // Overwrite that last entry with the post-noise value so exactly one
529        // push occurs per logical measurement and the record matches what we
530        // return.
531        let outcome = self.measure(addr0)?;
532        let noisy = self.flip_with_prob(outcome, flip_prob);
533        self.overwrite_last_measurement_record(Some(noisy));
534        Some(noisy)
535    }
536
537    /// Sample a Bernoulli(`p`) outcome using the tableau's internal RNG.
538    /// Used by Stim measurement-noise dispatch in `ppvm-stim`.
539    pub fn bernoulli(&mut self, p: f64) -> bool {
540        debug_assert!((0.0..=1.0).contains(&p), "p must be in [0, 1], got {p}");
541        self.tableau.rng.random::<f64>() < p
542    }
543
544    /// Flip `bit` with probability `p`. Used by Stim MR/MPad readout-noise
545    /// dispatch in `ppvm-stim`. Returns `bit` unchanged when `p <= 0.0`.
546    pub fn flip_with_prob(&mut self, bit: bool, p: f64) -> bool {
547        debug_assert!((0.0..=1.0).contains(&p), "p must be in [0, 1], got {p}");
548        if p > 0.0 && self.bernoulli(p) {
549            !bit
550        } else {
551            bit
552        }
553    }
554}
555
556/// Measurement overlap helper functions, with optional rayon parallelism.
557impl<T: Config, I, C: SparseVector<Complex<T::Coeff>, I>> GeneralizedTableau<T, I, C>
558where
559    T: Config,
560    <<T as Config>::Storage as BitView>::Store: PrimInt,
561    C: SparseVector<Complex<T::Coeff>, I> + std::fmt::Debug,
562    T::Coeff: One
563        + Zero
564        + Clone
565        + num::Num
566        + ToPrimitive
567        + std::fmt::Debug
568        + std::ops::Mul<f64>
569        + PartialOrd<f64>,
570    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
571        + From<Complex64>
572        + std::ops::MulAssign
573        + std::ops::AddAssign
574        + One
575        + ComplexFloat
576        + Copy,
577    I: TableauIndex + Debug,
578{
579    /// `⟨Z⟩` on qubit `addr0`, computed non-destructively (the state is not
580    /// collapsed). Reuses the measurement overlap machinery; cost scales with
581    /// the number of coefficients (and n²).
582    pub fn z_expectation(&self, addr0: usize) -> f64 {
583        let (phase_decomp, stab_anticomm_bits, destab_anticomm_bits) =
584            self.compute_decomposition(addr0, Pauli::Z);
585
586        if stab_anticomm_bits == I::zero() {
587            // Case b: Z is a stabilizer — self-pairing overlap.
588            let entries: Vec<(Complex<T::Coeff>, I)> = self.coefficients.iter().copied().collect();
589            Self::compute_overlap_case_b(&entries, phase_decomp, destab_anticomm_bits)
590        } else {
591            // Case a: cross-index pairing — clone coefficients into a map (read-only,
592            // so unlike `measure` we don't drain `self.coefficients`).
593            let coeff_map: HashMap<I, Complex<T::Coeff>> =
594                self.coefficients.iter().map(|&(c, i)| (i, c)).collect();
595            let odd_phase_mask = self.odd_phase_destabilizer_mask();
596            Self::compute_overlap_case_a(
597                &coeff_map,
598                phase_decomp,
599                destab_anticomm_bits,
600                stab_anticomm_bits,
601                odd_phase_mask,
602            )
603        }
604    }
605
606    /// Case_b overlap: self-pairing (branch_index = idx), so overlap = ±|c|^2.
607    /// Only even phases contribute to the real part. Divided by ⟨ψ|ψ⟩, since
608    /// gate-time truncation leaves the state unnormalized.
609    pub fn compute_overlap_case_b(
610        entries: &[(Complex<T::Coeff>, I)],
611        phase_decomp: u8,
612        destab_anticomm_bits: I,
613    ) -> f64 {
614        let mut z_overlap_re = 0.0f64;
615        let mut norm_sqr = 0.0f64;
616        for &(coeff, idx) in entries {
617            let norm_sq = coeff.norm_sqr().to_f64().unwrap_or(0.0);
618            norm_sqr += norm_sq;
619            let phase =
620                (phase_decomp + (2 * symplectic_inner(destab_anticomm_bits, idx) as u8) % 4) % 4;
621            if !phase.is_multiple_of(2) {
622                continue;
623            }
624            if phase == 0 {
625                z_overlap_re += norm_sq;
626            } else {
627                z_overlap_re -= norm_sq;
628            }
629        }
630        z_overlap_re / norm_sqr
631    }
632
633    /// Case_a overlap: cross-index pairing via HashMap lookup.
634    /// Accumulates only the real part of z_overlap. Divided by ⟨ψ|ψ⟩, since
635    /// gate-time truncation leaves the state unnormalized.
636    pub fn compute_overlap_case_a(
637        coeff_map: &HashMap<I, Complex<T::Coeff>>,
638        phase_decomp: u8,
639        destab_anticomm_bits: I,
640        stab_anticomm_bits: I,
641        odd_phase_mask: I,
642    ) -> f64 {
643        let mut z_overlap_re = 0.0f64;
644        let mut norm_sqr = 0.0f64;
645        for (&idx, coeff) in coeff_map {
646            norm_sqr += coeff.norm_sqr().to_f64().unwrap_or(0.0);
647            let branch_index = idx ^ stab_anticomm_bits;
648            let phase = (phase_decomp
649                + compute_phase_with_mask_static(
650                    destab_anticomm_bits,
651                    idx,
652                    stab_anticomm_bits,
653                    odd_phase_mask,
654                ))
655                % 4;
656            let Some(coeff_branch) = coeff_map.get(&branch_index).copied() else {
657                continue;
658            };
659            let a_re = coeff.re.to_f64().unwrap_or(0.0);
660            let a_im = coeff.im.to_f64().unwrap_or(0.0);
661            let b_re = coeff_branch.re.to_f64().unwrap_or(0.0);
662            let b_im = coeff_branch.im.to_f64().unwrap_or(0.0);
663            let re_w = a_re * b_re + a_im * b_im;
664            let im_w = a_re * b_im - a_im * b_re;
665            match phase {
666                0 => z_overlap_re += re_w,
667                1 => z_overlap_re += im_w,
668                2 => z_overlap_re -= re_w,
669                3 => z_overlap_re -= im_w,
670                _ => unreachable!(),
671            }
672        }
673        z_overlap_re / norm_sqr
674    }
675
676    /// Merge B entries (k-bit=1) into their A counterparts in coeff_map.
677    /// With rayon: parallel phase computation, sequential HashMap accumulation.
678    fn merge_b_into_a(
679        coeff_map: &mut HashMap<I, Complex<T::Coeff>>,
680        b_entries: &[(I, Complex<T::Coeff>)],
681        alpha: u8,
682        destab_anticomm_bits: I,
683        stab_anticomm_bits: I,
684    ) {
685        for &(idx, coeff) in b_entries {
686            let symp_inner = symplectic_inner(idx, destab_anticomm_bits);
687            let phase_idx = ((alpha as i32 + if symp_inner % 2 == 1 { 2 } else { 0 }) % 4) as usize;
688            let q: Complex<T::Coeff> = COMPLEX_PHASE_CONVERSION[phase_idx].into();
689            *coeff_map
690                .entry(idx ^ stab_anticomm_bits)
691                .or_insert(Complex::zero()) += q * coeff;
692        }
693    }
694}