Skip to main content

bloq_circuit/
detslice.rs

1//! Reverse region tracking: what stabilizer each detector or logical observable
2//! represents at every moment of a straight-line circuit.
3//!
4//! A native reimplementation of Stim's `SparseUnsignedRevFrameTracker`
5//! (`stim/src/stim/simulators/sparse_rev_frame_tracker.*`) driven the way
6//! `DetectorSliceSet` (`stim/src/stim/diagram/detector_slice/`) drives it. Each
7//! `undo_*` below mirrors its Stim counterpart; only the packaging differs —
8//! sparse per-coordinate maps instead of dense qubit-indexed vectors, side-table
9//! detectors instead of `DETECTOR` ops, and pre-segmented input instead of
10//! `TICK`-delimited moments. `bloq_ir::detslice` layers program-tape slicing
11//! over a whole [`Bloq`] on top of this.
12//!
13//! A tracked region is the Pauli operator that, propagated to a given moment,
14//! has the same value as its detector or observable. The walk runs **backward**,
15//! tracking per qubit which region ids hold `X` and/or `Z` sensitivity there
16//! (`Y` = both). Each id lives in a sparse [`DetSet`] (a sorted set with
17//! symmetric-difference merge — Stim's `SparseXorVec`), so a gate costs
18//! `O(region weight)` set XORs rather than a dense conjugation.
19//!
20//! Before the walk, every record-backed region xors its id into `rec_bits[m]`
21//! for each measurement `m` it sums; that entry discharges only once the walk
22//! reaches the measurement. Positioned Pauli seeds instead enter at their exact
23//! segment boundary.
24//!
25//! A gauge anticommutation records a [`RegionBreak`] and strips the region from
26//! every set so it stops propagating (Stim's `fail_on_anticommute = false`
27//! path). Snapshots are taken *before* undoing each segment, so `slices[k]` is
28//! the state at the **end of segment k**.
29
30use glam::IVec2;
31use rustc_hash::{FxHashMap, FxHashSet};
32use smallvec::SmallVec;
33use thiserror::Error;
34
35use crate::{GateType, Op, Pauli, PauliBasis};
36
37/// One qubit of a tracked region and the Pauli it carries there.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct RegionTerm {
40    /// Qubit coordinate.
41    pub qubit: IVec2,
42    /// Pauli carried on the qubit.
43    pub pauli: PauliBasis,
44}
45
46/// A detector to track: an opaque id and the measurement ids whose parity it
47/// sums. Ids live in the input op stream's id space.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct SliceDetector {
50    /// Opaque detector or observable id.
51    pub id: u32,
52    /// Measurement ids summed by the region.
53    pub measurements: Vec<u32>,
54}
55
56/// A Pauli contribution to a tracked region at an exact segment boundary.
57///
58/// Boundary `0` is before the first segment and boundary `segments.len()` is
59/// after the last. This models positioned Pauli-target
60/// `OBSERVABLE_INCLUDE`s; measurement-record contributions remain on
61/// [`SliceDetector::measurements`].
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct SliceRegionSeed {
64    /// Opaque detector or observable id.
65    pub id: u32,
66    /// Boundary where the contribution enters.
67    pub boundary: usize,
68    /// Pauli contribution.
69    pub terms: Vec<RegionTerm>,
70}
71
72/// Why a region stopped propagating backward.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum RegionBreakKind {
75    /// Support anticommuted with the basis a reset prepares.
76    AnticommutesWithReset,
77    /// Support anticommuted with the basis a measurement measured.
78    AnticommutesWithMeasurement,
79    /// `X`/`Y` support survived to the implicit `|0>` at the start of time.
80    AnticommutesWithStart,
81}
82
83/// A recorded anticommutation: the region could not continue past this point.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct RegionBreak {
86    /// Region that stopped propagating.
87    pub detector: u32,
88    /// Segment whose undo revealed the break.
89    pub segment: usize,
90    /// Qubit where anticommutation occurred.
91    pub qubit: IVec2,
92    /// Cause of the break.
93    pub kind: RegionBreakKind,
94}
95
96/// The region terms one detector's region covers at one moment.
97pub type DetectorRegion = (u32, Vec<RegionTerm>);
98
99/// The full slice table for a segmented straight-line circuit.
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct DetectorSlices {
102    /// `slices[k]` is the regions alive at the end of segment `k`, sorted by
103    /// detector id; each region's terms sorted by `(qubit.x, qubit.y)`.
104    /// Detectors with empty regions are omitted.
105    pub slices: Vec<Vec<DetectorRegion>>,
106    /// Every anticommutation, sorted by `(segment, detector, qubit)`.
107    pub breaks: Vec<RegionBreak>,
108}
109
110/// A failure preparing the op stream for reverse tracking.
111#[derive(Debug, Error, Clone, PartialEq, Eq)]
112pub enum DetsliceError {
113    /// The circuit still contains a repeat operation.
114    #[error(
115        "detector-slice tracking requires a straight-line circuit; flatten REPEAT blocks first"
116    )]
117    RepeatNotFlattened,
118    #[error(
119        "region seed boundary {boundary} is outside the {segments}-segment circuit (maximum {segments})"
120    )]
121    /// A positioned seed names a boundary outside the circuit.
122    SeedBoundaryOutOfRange {
123        /// Requested boundary.
124        boundary: usize,
125        /// Number of circuit segments.
126        segments: usize,
127    },
128}
129/// Track detecting and logical regions with positioned Pauli contributions.
130///
131/// `seeds` are XORed into the reverse frame at exact boundaries between
132/// segments. A seed at boundary `k + 1` is therefore visible in the snapshot at
133/// the end of segment `k`, then propagates backward through that segment.
134///
135/// # Errors
136///
137/// Returns [`DetsliceError::RepeatNotFlattened`] for nested repeats or
138/// [`DetsliceError::SeedBoundaryOutOfRange`] for a boundary past the circuit.
139pub fn detector_slices_with_seeds(
140    segments: &[&[Op]],
141    detectors: &[SliceDetector],
142    seeds: &[SliceRegionSeed],
143) -> Result<DetectorSlices, DetsliceError> {
144    if segments
145        .iter()
146        .flat_map(|segment| segment.iter())
147        .any(|op| matches!(op, Op::Repeat { .. }))
148    {
149        return Err(DetsliceError::RepeatNotFlattened);
150    }
151
152    let mut seeds_by_boundary = vec![Vec::new(); segments.len() + 1];
153    for seed in seeds {
154        let Some(slot) = seeds_by_boundary.get_mut(seed.boundary) else {
155            return Err(DetsliceError::SeedBoundaryOutOfRange {
156                boundary: seed.boundary,
157                segments: segments.len(),
158            });
159        };
160        slot.push(seed);
161    }
162
163    let mut tracker = Tracker::default();
164    for detector in detectors {
165        for &measurement in &detector.measurements {
166            tracker
167                .rec_bits
168                .entry(measurement)
169                .or_default()
170                .xor_id(detector.id);
171        }
172    }
173
174    let mut slices = vec![Vec::new(); segments.len()];
175    for k in (0..segments.len()).rev() {
176        for seed in &seeds_by_boundary[k + 1] {
177            tracker.xor_region(seed.id, &seed.terms);
178        }
179        slices[k] = tracker.snapshot();
180        for op in segments[k].iter().rev() {
181            tracker.undo_op(op);
182        }
183        tracker.process_anticommutations(k);
184    }
185    for seed in &seeds_by_boundary[0] {
186        tracker.xor_region(seed.id, &seed.terms);
187    }
188    tracker.undo_start_of_circuit();
189
190    let mut breaks = tracker.broken;
191    breaks.sort_by_key(|brk| (brk.segment, brk.detector, brk.qubit.x, brk.qubit.y));
192    Ok(DetectorSlices { slices, breaks })
193}
194
195/// A sorted set of detector ids with symmetric-difference merge — the sparse
196/// `X`/`Z` frame column, mirroring Stim's `SparseXorVec<DemTarget>`.
197///
198/// Inline capacity 4: a detecting region rarely touches more than a handful of
199/// detectors on any one qubit, so the common case stays off the heap.
200#[derive(Debug, Clone, Default, PartialEq, Eq)]
201struct DetSet(SmallVec<[u32; 4]>);
202
203impl DetSet {
204    fn is_empty(&self) -> bool {
205        self.0.is_empty()
206    }
207
208    fn contains(&self, id: u32) -> bool {
209        self.0.binary_search(&id).is_ok()
210    }
211
212    fn iter(&self) -> impl Iterator<Item = u32> + '_ {
213        self.0.iter().copied()
214    }
215
216    /// Toggle a single id (add if absent, remove if present).
217    fn xor_id(&mut self, id: u32) {
218        match self.0.binary_search(&id) {
219            Ok(index) => {
220                self.0.remove(index);
221            }
222            Err(index) => self.0.insert(index, id),
223        }
224    }
225
226    /// Remove `id` if present.
227    fn remove(&mut self, id: u32) {
228        if let Ok(index) = self.0.binary_search(&id) {
229            self.0.remove(index);
230        }
231    }
232
233    /// XOR `other` in: keep ids present in exactly one of the two sets.
234    fn xor_merge(&mut self, other: &DetSet) {
235        if other.is_empty() {
236            return;
237        }
238        let mut merged = SmallVec::with_capacity(self.0.len() + other.0.len());
239        let (mut i, mut j) = (0, 0);
240        let (a, b) = (&self.0, &other.0);
241        while i < a.len() && j < b.len() {
242            match a[i].cmp(&b[j]) {
243                std::cmp::Ordering::Less => {
244                    merged.push(a[i]);
245                    i += 1;
246                }
247                std::cmp::Ordering::Greater => {
248                    merged.push(b[j]);
249                    j += 1;
250                }
251                std::cmp::Ordering::Equal => {
252                    i += 1;
253                    j += 1;
254                }
255            }
256        }
257        merged.extend_from_slice(&a[i..]);
258        merged.extend_from_slice(&b[j..]);
259        self.0 = merged;
260    }
261}
262
263/// Ids in exactly one of two sorted sets — the gauge basis for `Y` measurements
264/// and resets (`handle_xor_gauge`).
265fn symmetric_difference(a: &DetSet, b: &DetSet) -> SmallVec<[u32; 4]> {
266    let mut out = a.clone();
267    out.xor_merge(b);
268    out.0
269}
270
271/// A pending gauge anticommutation, collected during a segment's undo and
272/// applied at the segment boundary (matching Stim, which strips only at ticks).
273struct Pending {
274    detector: u32,
275    qubit: IVec2,
276    kind: RegionBreakKind,
277}
278
279#[derive(Default)]
280struct Tracker {
281    /// Per qubit, the detectors with `X` support there.
282    xs: FxHashMap<IVec2, DetSet>,
283    /// Per qubit, the detectors with `Z` support there.
284    zs: FxHashMap<IVec2, DetSet>,
285    /// Per measurement id, the detectors whose parity still awaits it.
286    rec_bits: FxHashMap<u32, DetSet>,
287    /// Anticommutations pending strip at the current segment boundary.
288    pending: Vec<Pending>,
289    broken: Vec<RegionBreak>,
290}
291
292impl Tracker {
293    /// Take a qubit's `(X, Z)` columns out of the maps, so per-qubit gate rules
294    /// can rewrite them without cross-map borrow gymnastics.
295    fn take_frame(&mut self, qubit: IVec2) -> (DetSet, DetSet) {
296        (
297            self.xs.remove(&qubit).unwrap_or_default(),
298            self.zs.remove(&qubit).unwrap_or_default(),
299        )
300    }
301
302    /// Put a qubit's columns back, dropping empty ones to keep the maps sparse
303    /// (`contains` and snapshotting assume a present entry is non-empty).
304    fn put_frame(&mut self, qubit: IVec2, x: DetSet, z: DetSet) {
305        if x.is_empty() {
306            self.xs.remove(&qubit);
307        } else {
308            self.xs.insert(qubit, x);
309        }
310        if z.is_empty() {
311            self.zs.remove(&qubit);
312        } else {
313            self.zs.insert(qubit, z);
314        }
315    }
316
317    fn record_gauge(
318        &mut self,
319        detectors: impl IntoIterator<Item = u32>,
320        qubit: IVec2,
321        kind: RegionBreakKind,
322    ) {
323        for detector in detectors {
324            self.pending.push(Pending {
325                detector,
326                qubit,
327                kind,
328            });
329        }
330    }
331
332    /// XOR a positioned Pauli contribution into one tracked region.
333    fn xor_region(&mut self, id: u32, terms: &[RegionTerm]) {
334        for term in terms {
335            let (mut x, mut z) = self.take_frame(term.qubit);
336            match term.pauli {
337                PauliBasis::X => x.xor_id(id),
338                PauliBasis::Y => {
339                    x.xor_id(id);
340                    z.xor_id(id);
341                }
342                PauliBasis::Z => z.xor_id(id),
343            }
344            self.put_frame(term.qubit, x, z);
345        }
346    }
347
348    fn undo_op(&mut self, op: &Op) {
349        match op {
350            Op::Gate { gate, qubits } => self.undo_gate(*gate, qubits),
351            Op::Measure {
352                basis,
353                qubits,
354                measurements,
355                ..
356            } => self.undo_measure(*basis, qubits, measurements),
357            Op::MPP {
358                products,
359                measurements,
360            } => self.undo_mpp(products, measurements),
361            Op::ConditionalPauli(corrections) => {
362                for correction in corrections {
363                    self.undo_conditional_pauli(
364                        correction.target,
365                        correction.pauli,
366                        correction.control,
367                    );
368                }
369            }
370            // Ticks and noise carry no region; repeats were rejected up front.
371            Op::Tick
372            | Op::Depolarize1 { .. }
373            | Op::Depolarize2 { .. }
374            | Op::PauliError { .. }
375            | Op::Repeat { .. } => {}
376        }
377    }
378
379    /// A region anticommuting with the correction picks up its conditional
380    /// sign, so xor that region into the control's record dependency — an
381    /// explicit detector over the same record then cancels it.
382    fn undo_conditional_pauli(&mut self, target: IVec2, pauli: PauliBasis, control: u32) {
383        let x = self.xs.get(&target).cloned().unwrap_or_default();
384        let z = self.zs.get(&target).cloned().unwrap_or_default();
385        let anticommuting = match pauli {
386            PauliBasis::X => z,
387            PauliBasis::Z => x,
388            PauliBasis::Y => DetSet(symmetric_difference(&x, &z)),
389        };
390        if anticommuting.is_empty() {
391            return;
392        }
393        self.rec_bits
394            .entry(control)
395            .or_default()
396            .xor_merge(&anticommuting);
397    }
398
399    fn undo_gate(&mut self, gate: GateType, qubits: &[IVec2]) {
400        if gate.is_reset() {
401            for &q in qubits {
402                self.undo_reset(gate, q);
403            }
404        } else if gate.is_two_qubit_gate() {
405            for pair in qubits.as_chunks::<2>().0.iter().rev() {
406                self.undo_two_qubit(gate, pair[0], pair[1]);
407            }
408        } else {
409            // Cliffords proxy to themselves; the T-family tracks through
410            // [`GateType::clifford_proxy`] (`T`->`S`, `T_YZ`->`SQRT_X`,
411            // `T_XZ`->`SQRT_Y`), matching the workspace's
412            // `compile_clifford_proxy` convention. Deliberately diverges from
413            // Stim, which cannot track a region through a real `T`.
414            for &q in qubits {
415                self.undo_single_clifford(gate.clifford_proxy(), q);
416            }
417        }
418    }
419
420    /// `undo_RX`/`undo_RY`/`undo_RZ`: gauge-check the anticommuting basis, then
421    /// clear the qubit (the region's birth boundary).
422    fn undo_reset(&mut self, gate: GateType, q: IVec2) {
423        let (x, z) = self.take_frame(q);
424        match gate {
425            // RZ prepares Z; surviving X support anticommutes.
426            GateType::RZ => self.record_gauge(x.iter(), q, RegionBreakKind::AnticommutesWithReset),
427            // RX prepares X; surviving Z support anticommutes.
428            GateType::RX => self.record_gauge(z.iter(), q, RegionBreakKind::AnticommutesWithReset),
429            // RY prepares Y; support that is X-only or Z-only anticommutes.
430            GateType::RY => self.record_gauge(
431                symmetric_difference(&x, &z),
432                q,
433                RegionBreakKind::AnticommutesWithReset,
434            ),
435            _ => unreachable!("undo_reset only sees reset gates"),
436        }
437        // Frame cleared: both columns dropped (not put back).
438    }
439
440    /// `undo_MX`/`undo_MY`/`undo_MZ`: gauge-check the anticommuting basis, then
441    /// drain the measurement's pending detectors into the measured basis.
442    /// Measurement does not clear the qubit.
443    fn undo_measure(&mut self, basis: crate::PauliBasis, qubits: &[IVec2], measurements: &[u32]) {
444        use crate::PauliBasis::{X, Y, Z};
445        for (&q, &m) in qubits.iter().zip(measurements) {
446            let (mut x, mut z) = self.take_frame(q);
447            match basis {
448                Z => self.record_gauge(x.iter(), q, RegionBreakKind::AnticommutesWithMeasurement),
449                X => self.record_gauge(z.iter(), q, RegionBreakKind::AnticommutesWithMeasurement),
450                Y => self.record_gauge(
451                    symmetric_difference(&x, &z),
452                    q,
453                    RegionBreakKind::AnticommutesWithMeasurement,
454                ),
455            }
456            if let Some(dets) = self.rec_bits.remove(&m) {
457                match basis {
458                    Z => z.xor_merge(&dets),
459                    X => x.xor_merge(&dets),
460                    Y => {
461                        x.xor_merge(&dets);
462                        z.xor_merge(&dets);
463                    }
464                }
465            }
466            self.put_frame(q, x, z);
467        }
468    }
469
470    /// `undo_MPP`: gauge-check the product's anticommutation parity, then drain
471    /// the record into every `(qubit, pauli)` of the product.
472    fn undo_mpp(&mut self, products: &[crate::PauliMap], measurements: &[u32]) {
473        for (product, &m) in products.iter().zip(measurements).rev() {
474            self.gauge_check_product(product);
475            if let Some(dets) = self.rec_bits.remove(&m) {
476                for (&q, &pauli) in product {
477                    let (mut x, mut z) = self.take_frame(q);
478                    if pauli & Pauli::X {
479                        x.xor_merge(&dets);
480                    }
481                    if pauli & Pauli::Z {
482                        z.xor_merge(&dets);
483                    }
484                    self.put_frame(q, x, z);
485                }
486            }
487        }
488    }
489
490    /// A detector anticommutes with a joint product measurement iff it
491    /// anticommutes on an odd number of the product's qubits. Collect every
492    /// detector touching the product's support and break the odd ones.
493    fn gauge_check_product(&mut self, product: &crate::PauliMap) {
494        let mut candidates: FxHashSet<u32> = FxHashSet::default();
495        for (&q, _) in product {
496            if let Some(set) = self.xs.get(&q) {
497                candidates.extend(set.iter());
498            }
499            if let Some(set) = self.zs.get(&q) {
500                candidates.extend(set.iter());
501            }
502        }
503        let qubit = product.representative_coord().unwrap_or(IVec2::ZERO);
504        let mut breaks = Vec::new();
505        for detector in candidates {
506            let mut parity = false;
507            for (&q, &pauli) in product {
508                let has_x = self.xs.get(&q).is_some_and(|s| s.contains(detector));
509                let has_z = self.zs.get(&q).is_some_and(|s| s.contains(detector));
510                // anticommute((has_x,has_z), pauli) in the XZ symplectic basis.
511                parity ^= (has_x && pauli & Pauli::Z) ^ (has_z && pauli & Pauli::X);
512            }
513            if parity {
514                breaks.push(detector);
515            }
516        }
517        self.record_gauge(breaks, qubit, RegionBreakKind::AnticommutesWithMeasurement);
518    }
519
520    /// Undo one single-qubit Clifford by its inverse symplectic action. Signs
521    /// (`DAG`/`N` variants) do not matter for unsigned frames, so families
522    /// collapse: `S`/`S_DAG`/`H_XY`/`H_NXY` all fold `zs ^= xs`, etc. Mirrors
523    /// Stim's `undo_H_XZ`/`undo_H_XY`/`undo_H_YZ`/`undo_C_XYZ`/`undo_C_ZYX`.
524    fn undo_single_clifford(&mut self, gate: GateType, q: IVec2) {
525        use GateType::*;
526        let (mut x, mut z) = self.take_frame(q);
527        match gate {
528            // H_XZ: swap X and Z.
529            H | H_NXZ | SQRT_Y | SQRT_Y_DAG => std::mem::swap(&mut x, &mut z),
530            // H_YZ: xs ^= zs.
531            H_YZ | H_NYZ | SQRT_X | SQRT_X_DAG => x.xor_merge(&z),
532            // H_XY: zs ^= xs.
533            H_XY | H_NXY | S | S_DAG => z.xor_merge(&x),
534            // C_XYZ: zs ^= xs; xs ^= zs.
535            C_XYZ | C_NXYZ | C_XNYZ | C_XYNZ => {
536                z.xor_merge(&x);
537                x.xor_merge(&z);
538            }
539            // C_ZYX: xs ^= zs; zs ^= xs.
540            C_ZYX | C_NZYX | C_ZNYX | C_ZYNX => {
541                x.xor_merge(&z);
542                z.xor_merge(&x);
543            }
544            // Pauli gates commute with everything unsigned; I is a no-op.
545            I | X | Y | Z => {}
546            _ => unreachable!("undo_single_clifford only sees single-qubit Cliffords"),
547        }
548        self.put_frame(q, x, z);
549    }
550
551    /// Undo one two-qubit controlled Clifford on `(qa, qb)` (control basis then
552    /// target basis in the `ACB` name). Each rule is transcribed statement for
553    /// statement from Stim's matching `undo_*` / `undo_*_single`, applied to the
554    /// two qubits' four sparse columns.
555    fn undo_two_qubit(&mut self, gate: GateType, qa: IVec2, qb: IVec2) {
556        use GateType::*;
557        let (mut xa, mut za) = self.take_frame(qa);
558        let (mut xb, mut zb) = self.take_frame(qb);
559        match gate {
560            XCX => {
561                xa.xor_merge(&zb);
562                xb.xor_merge(&za);
563            }
564            XCY => {
565                xa.xor_merge(&xb);
566                xa.xor_merge(&zb);
567                xb.xor_merge(&za);
568                zb.xor_merge(&za);
569            }
570            XCZ => {
571                zb.xor_merge(&za);
572                xa.xor_merge(&xb);
573            }
574            YCX => {
575                xb.xor_merge(&xa);
576                xb.xor_merge(&za);
577                xa.xor_merge(&zb);
578                za.xor_merge(&zb);
579            }
580            YCY => {
581                za.xor_merge(&xb);
582                za.xor_merge(&zb);
583                xa.xor_merge(&xb);
584                xa.xor_merge(&zb);
585                zb.xor_merge(&xa);
586                zb.xor_merge(&za);
587                xb.xor_merge(&xa);
588                xb.xor_merge(&za);
589            }
590            YCZ => {
591                zb.xor_merge(&za);
592                zb.xor_merge(&xa);
593                xa.xor_merge(&xb);
594                za.xor_merge(&xb);
595            }
596            CX => {
597                za.xor_merge(&zb);
598                xb.xor_merge(&xa);
599            }
600            CY => {
601                za.xor_merge(&zb);
602                za.xor_merge(&xb);
603                xb.xor_merge(&xa);
604                zb.xor_merge(&xa);
605            }
606            CZ => {
607                za.xor_merge(&xb);
608                zb.xor_merge(&xa);
609            }
610            _ => unreachable!("undo_two_qubit only sees controlled gates"),
611        }
612        self.put_frame(qa, xa, za);
613        self.put_frame(qb, xb, zb);
614    }
615
616    /// `undo_implicit_RZs_at_start_of_circuit`: any surviving `X`/`Y` support
617    /// anticommutes with the implicit `|0>` preparation. Attributed to segment 0.
618    fn undo_start_of_circuit(&mut self) {
619        let breaks: Vec<(u32, IVec2)> = self
620            .xs
621            .iter()
622            .flat_map(|(&q, set)| set.iter().map(move |d| (d, q)))
623            .collect();
624        for (detector, qubit) in breaks {
625            self.pending.push(Pending {
626                detector,
627                qubit,
628                kind: RegionBreakKind::AnticommutesWithStart,
629            });
630        }
631        self.process_anticommutations(0);
632    }
633
634    /// Record the segment's pending anticommutations and strip each broken
635    /// detector from every column so it stops propagating backward. Mirrors
636    /// Stim's `process_anticommutations`; `rec_bits` is intentionally left
637    /// alone, matching Stim (a detector referencing an earlier measurement can
638    /// still discharge there — malformed detectors only).
639    fn process_anticommutations(&mut self, segment: usize) {
640        if self.pending.is_empty() {
641            return;
642        }
643        let mut pending = std::mem::take(&mut self.pending);
644        pending.sort_by_key(|p| (p.detector, p.qubit.x, p.qubit.y));
645
646        let mut stripped: FxHashSet<u32> = FxHashSet::default();
647        let mut last: Option<(u32, IVec2)> = None;
648        for p in pending {
649            // De-duplicate repeated (detector, qubit) events (one op can gauge
650            // the same detector on the same qubit more than once).
651            if last == Some((p.detector, p.qubit)) {
652                continue;
653            }
654            last = Some((p.detector, p.qubit));
655            self.broken.push(RegionBreak {
656                detector: p.detector,
657                segment,
658                qubit: p.qubit,
659                kind: p.kind,
660            });
661            if stripped.insert(p.detector) {
662                self.strip_detector(p.detector);
663            }
664        }
665    }
666
667    fn strip_detector(&mut self, detector: u32) {
668        self.xs.retain(|_, set| {
669            set.remove(detector);
670            !set.is_empty()
671        });
672        self.zs.retain(|_, set| {
673            set.remove(detector);
674            !set.is_empty()
675        });
676    }
677
678    /// The current frame as a sorted slice table entry: per detector, its region
679    /// terms. `Y` = present in both `X` and `Z` columns.
680    fn snapshot(&self) -> Vec<DetectorRegion> {
681        let mut per_detector: FxHashMap<u32, Vec<RegionTerm>> = FxHashMap::default();
682        for (&qubit, xset) in &self.xs {
683            let zset = self.zs.get(&qubit);
684            for detector in xset.iter() {
685                per_detector.entry(detector).or_default().push(RegionTerm {
686                    qubit,
687                    pauli: if zset.is_some_and(|set| set.contains(detector)) {
688                        PauliBasis::Y
689                    } else {
690                        PauliBasis::X
691                    },
692                });
693            }
694        }
695        for (&qubit, zset) in &self.zs {
696            let xset = self.xs.get(&qubit);
697            for detector in zset
698                .iter()
699                .filter(|&detector| xset.is_none_or(|set| !set.contains(detector)))
700            {
701                per_detector.entry(detector).or_default().push(RegionTerm {
702                    qubit,
703                    pauli: PauliBasis::Z,
704                });
705            }
706        }
707
708        let mut out: Vec<DetectorRegion> = per_detector.into_iter().collect();
709        for (_, terms) in &mut out {
710            terms.sort_by_key(|term| (term.qubit.x, term.qubit.y));
711        }
712        out.sort_by_key(|(detector, _)| *detector);
713        out
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720    use glam::ivec2;
721
722    fn gate(gate: GateType, qubits: &[IVec2]) -> Op {
723        Op::Gate {
724            gate,
725            qubits: qubits.to_vec(),
726        }
727    }
728
729    fn measure(basis: PauliBasis, qubit: IVec2, id: u32) -> Op {
730        Op::Measure {
731            basis,
732            qubits: vec![qubit],
733            measurements: vec![id],
734            flip_probability: 0.0,
735        }
736    }
737
738    fn det(id: u32, measurements: &[u32]) -> SliceDetector {
739        SliceDetector {
740            id,
741            measurements: measurements.to_vec(),
742        }
743    }
744
745    /// Render one segment's regions compactly: `d{id}:P(x,y)...`.
746    fn render(slice: &[DetectorRegion]) -> String {
747        slice
748            .iter()
749            .map(|(id, terms)| {
750                let body: String = terms
751                    .iter()
752                    .map(|t| format!("{:?}({},{})", t.pauli, t.qubit.x, t.qubit.y))
753                    .collect();
754                format!("d{id}:{body}")
755            })
756            .collect::<Vec<_>>()
757            .join(" ")
758    }
759
760    fn run(segments: &[Vec<Op>], detectors: &[SliceDetector]) -> DetectorSlices {
761        let refs: Vec<&[Op]> = segments.iter().map(Vec::as_slice).collect();
762        detector_slices_with_seeds(&refs, detectors, &[]).expect("straight-line input tracks")
763    }
764
765    #[test]
766    fn reversing_batched_operations_matches_separate_instructions() {
767        let [a, b, c] = [ivec2(0, 0), ivec2(1, 0), ivec2(2, 0)];
768        let batched = [gate(GateType::CX, &[a, b, b, c])];
769        let split = [gate(GateType::CX, &[a, b]), gate(GateType::CX, &[b, c])];
770        let seeds = [SliceRegionSeed {
771            id: 0,
772            boundary: 2,
773            terms: vec![RegionTerm {
774                qubit: c,
775                pauli: PauliBasis::Z,
776            }],
777        }];
778        let batched = detector_slices_with_seeds(&[&[], &batched], &[], &seeds).unwrap();
779        let split = detector_slices_with_seeds(&[&[], &split], &[], &seeds).unwrap();
780        assert_eq!(batched, split);
781        assert_eq!(render(&batched.slices[0]), "d0:Z(0,0)Z(1,0)Z(2,0)");
782
783        let products = [
784            crate::PauliMap::from_unique_entries([(a, Pauli::X)]),
785            crate::PauliMap::from_unique_entries([(a, Pauli::Z)]),
786        ];
787        let batched = vec![Op::MPP {
788            products: products.to_vec(),
789            measurements: vec![0, 1],
790        }];
791        let split = products
792            .into_iter()
793            .enumerate()
794            .map(|(m, product)| Op::MPP {
795                products: vec![product],
796                measurements: vec![m as u32],
797            })
798            .collect();
799        let batched = run(&[batched], &[det(0, &[1])]);
800        let split = run(&[split], &[det(0, &[1])]);
801        assert_eq!(batched, split);
802        assert!(
803            !batched.breaks.is_empty(),
804            "MPP X0 Z0 leaves the final Z outcome random"
805        );
806    }
807
808    #[test]
809    fn single_round_z_memory_holds_region_until_measurement() {
810        let q = ivec2(0, 0);
811        let segments = vec![
812            vec![gate(GateType::RZ, &[q])],
813            vec![Op::Tick],
814            vec![measure(PauliBasis::Z, q, 0)],
815        ];
816        let slices = run(&segments, &[det(9, &[0])]);
817        assert_eq!(render(&slices.slices[0]), "d9:Z(0,0)");
818        assert_eq!(render(&slices.slices[1]), "d9:Z(0,0)");
819        assert_eq!(render(&slices.slices[2]), "");
820        assert!(slices.breaks.is_empty());
821    }
822
823    #[test]
824    fn two_round_memory_region_lives_only_between_measurements() {
825        let q = ivec2(0, 0);
826        let segments = vec![
827            vec![gate(GateType::RZ, &[q])],
828            vec![measure(PauliBasis::Z, q, 0)],
829            vec![measure(PauliBasis::Z, q, 1)],
830        ];
831        // Parity of the two rounds: region cancels outside the interval.
832        let slices = run(&segments, &[det(1, &[0, 1])]);
833        assert_eq!(render(&slices.slices[0]), "");
834        assert_eq!(render(&slices.slices[1]), "d1:Z(0,0)");
835        assert_eq!(render(&slices.slices[2]), "");
836        assert!(slices.breaks.is_empty());
837    }
838
839    #[test]
840    fn hadamard_swaps_region_basis_across_the_gate() {
841        let q = ivec2(0, 0);
842        let segments = vec![
843            vec![gate(GateType::RZ, &[q])],
844            vec![gate(GateType::H, &[q])],
845            vec![measure(PauliBasis::X, q, 0)],
846        ];
847        let slices = run(&segments, &[det(0, &[0])]);
848        // Z before the H (propagated back), X after it (toward the MX).
849        assert_eq!(render(&slices.slices[0]), "d0:Z(0,0)");
850        assert_eq!(render(&slices.slices[1]), "d0:X(0,0)");
851        assert_eq!(render(&slices.slices[2]), "");
852        assert!(slices.breaks.is_empty());
853    }
854
855    #[test]
856    fn positioned_pauli_seed_enters_at_its_exact_boundary() {
857        let q = ivec2(0, 0);
858        let segments = [vec![gate(GateType::H, &[q])], vec![gate(GateType::I, &[q])]];
859        let refs: Vec<&[Op]> = segments.iter().map(Vec::as_slice).collect();
860        let seed = SliceRegionSeed {
861            id: 4,
862            boundary: 1,
863            terms: vec![RegionTerm {
864                qubit: q,
865                pauli: PauliBasis::Z,
866            }],
867        };
868
869        let slices = detector_slices_with_seeds(&refs, &[], &[seed]).unwrap();
870
871        assert_eq!(render(&slices.slices[0]), "d4:Z(0,0)");
872        assert_eq!(render(&slices.slices[1]), "");
873        assert_eq!(
874            slices.breaks,
875            vec![RegionBreak {
876                detector: 4,
877                segment: 0,
878                qubit: q,
879                kind: RegionBreakKind::AnticommutesWithStart,
880            }]
881        );
882    }
883
884    #[test]
885    fn cx_plaquette_region_grows_across_coupled_data_qubits() {
886        let a = ivec2(1, 0);
887        let d0 = ivec2(0, 0);
888        let d1 = ivec2(2, 0);
889        let segments = vec![
890            vec![gate(GateType::RZ, &[a])],
891            vec![gate(GateType::CX, &[d0, a])],
892            vec![gate(GateType::CX, &[d1, a])],
893            vec![measure(PauliBasis::Z, a, 0)],
894        ];
895        let slices = run(&segments, &[det(0, &[0])]);
896        assert_eq!(render(&slices.slices[3]), "");
897        assert_eq!(render(&slices.slices[2]), "d0:Z(1,0)");
898        assert_eq!(render(&slices.slices[1]), "d0:Z(1,0)Z(2,0)");
899        assert_eq!(render(&slices.slices[0]), "d0:Z(0,0)Z(1,0)Z(2,0)");
900        assert!(slices.breaks.is_empty());
901    }
902
903    #[test]
904    fn phase_gate_produces_a_y_region() {
905        let q = ivec2(0, 0);
906        // RY commutes with the Y the S gate builds, so no break: a clean Y.
907        let segments = vec![
908            vec![gate(GateType::RY, &[q])],
909            vec![gate(GateType::S, &[q])],
910            vec![measure(PauliBasis::X, q, 0)],
911        ];
912        let slices = run(&segments, &[det(0, &[0])]);
913        assert_eq!(render(&slices.slices[0]), "d0:Y(0,0)");
914        assert_eq!(render(&slices.slices[1]), "d0:X(0,0)");
915        assert_eq!(render(&slices.slices[2]), "");
916        assert!(slices.breaks.is_empty());
917    }
918
919    #[test]
920    fn segment_ops_are_undone_in_reverse_order() {
921        let q = ivec2(0, 0);
922        let segments = vec![
923            vec![gate(GateType::H, &[q]), gate(GateType::S, &[q])],
924            vec![measure(PauliBasis::X, q, 0)],
925        ];
926        let slices = run(&segments, &[det(0, &[0])]);
927
928        assert_eq!(
929            slices.breaks,
930            vec![RegionBreak {
931                detector: 0,
932                segment: 0,
933                qubit: q,
934                kind: RegionBreakKind::AnticommutesWithStart,
935            }]
936        );
937    }
938
939    #[test]
940    fn mpp_product_region_is_the_product_support() {
941        let q0 = ivec2(0, 0);
942        let q1 = ivec2(1, 0);
943        let product = crate::PauliMap::from_unique_entries([(q0, Pauli::X), (q1, Pauli::X)]);
944        let segments = vec![
945            vec![gate(GateType::RX, &[q0]), gate(GateType::RX, &[q1])],
946            vec![Op::MPP {
947                products: vec![product],
948                measurements: vec![0],
949            }],
950        ];
951        let slices = run(&segments, &[det(0, &[0])]);
952        assert_eq!(render(&slices.slices[0]), "d0:X(0,0)X(1,0)");
953        assert_eq!(render(&slices.slices[1]), "");
954        assert!(slices.breaks.is_empty());
955    }
956
957    #[test]
958    fn x_region_anticommuting_with_z_reset_breaks() {
959        let q = ivec2(0, 0);
960        let segments = vec![
961            vec![gate(GateType::I, &[q])],
962            vec![gate(GateType::RZ, &[q])],
963            vec![measure(PauliBasis::X, q, 0)],
964        ];
965        let slices = run(&segments, &[det(7, &[0])]);
966        // Present at end of the reset segment, stripped before the earlier one.
967        assert_eq!(render(&slices.slices[0]), "");
968        assert_eq!(render(&slices.slices[1]), "d7:X(0,0)");
969        assert_eq!(render(&slices.slices[2]), "");
970        assert_eq!(
971            slices.breaks,
972            vec![RegionBreak {
973                detector: 7,
974                segment: 1,
975                qubit: q,
976                kind: RegionBreakKind::AnticommutesWithReset,
977            }]
978        );
979    }
980
981    #[test]
982    fn t_gate_tracks_through_its_clifford_proxy_like_s() {
983        let q = ivec2(0, 0);
984        // T shares S's Z axis, so it must fold an X region into a Y exactly as S
985        // does and keep the region alive — unlike Stim, which breaks on a real
986        // T. Run the same circuit with T and with its S proxy; they must agree.
987        let build = |g: GateType| {
988            vec![
989                vec![gate(GateType::RY, &[q])],
990                vec![gate(g, &[q])],
991                vec![measure(PauliBasis::X, q, 0)],
992            ]
993        };
994        let via_t = run(&build(GateType::T), &[det(0, &[0])]);
995        let via_s = run(&build(GateType::S), &[det(0, &[0])]);
996        assert_eq!(via_t.slices, via_s.slices);
997        assert!(via_t.breaks.is_empty(), "T keeps the region alive");
998        // X toward the MX, folded to Y on the far side of the T.
999        assert_eq!(render(&via_t.slices[1]), "d0:X(0,0)");
1000        assert_eq!(render(&via_t.slices[0]), "d0:Y(0,0)");
1001    }
1002
1003    #[test]
1004    fn surviving_x_support_breaks_against_implicit_start() {
1005        let q = ivec2(0, 0);
1006        // No reset in X: the region reaches the implicit |0> and breaks.
1007        let segments = vec![
1008            vec![gate(GateType::I, &[q])],
1009            vec![measure(PauliBasis::X, q, 0)],
1010        ];
1011        let slices = run(&segments, &[det(5, &[0])]);
1012        assert_eq!(render(&slices.slices[0]), "d5:X(0,0)");
1013        assert_eq!(render(&slices.slices[1]), "");
1014        assert_eq!(
1015            slices.breaks,
1016            vec![RegionBreak {
1017                detector: 5,
1018                segment: 0,
1019                qubit: q,
1020                kind: RegionBreakKind::AnticommutesWithStart,
1021            }]
1022        );
1023    }
1024
1025    #[test]
1026    fn repeat_op_is_rejected() {
1027        let body = crate::BodyId(0);
1028        let segments = [vec![Op::Repeat {
1029            body,
1030            repetitions: 2,
1031        }]];
1032        let refs: Vec<&[Op]> = segments.iter().map(Vec::as_slice).collect();
1033        assert_eq!(
1034            detector_slices_with_seeds(&refs, &[], &[]),
1035            Err(DetsliceError::RepeatNotFlattened)
1036        );
1037    }
1038}