Skip to main content

bloq_graph/zx/
symbolic_basis.rs

1//! Branch-parametric (symbolic) stabilizer basis.
2//!
3//! Filling a selective node at site `c` acts on the basis through
4//! [`StabilizerBasis::apply_tagged_selective_fill`](super::stabilizer::StabilizerBasis::apply_tagged_selective_fill),
5//! which is the pointwise ground truth this module models. That transition uses
6//! the site's diagonalized fixing row `F_c` as the pivot: it XORs `F_c` into
7//! every row whose support at the site column *anticommutes* with the chosen
8//! fill axis, then removes `F_c` from the basis.
9//!
10//! # The affine model
11//!
12//! Two properties of the canonical basis make the whole evolution under *any*
13//! fill combination affine in the fill choices, with per-`(row, site)` behavior
14//! fixed at compile time:
15//!
16//! * **Fixing rows are pairwise diagonalized** — `F_c` carries the forbidden
17//!   Pauli on its own selective column and identity on every *other* selective
18//!   column. So filling site `d` never changes any row's support at column `c`,
19//!   and never disturbs `F_c` itself (which is consumed only when site `c` is
20//!   filled). A row's trigger status at site `c` therefore depends only on its
21//!   *initial* support at column `c`, independent of the fill order.
22//!
23//! * Consequently every surviving (non-fixing) row evolves as
24//!
25//!   ```text
26//!   concrete(row) = base ⊕ ⊕ { F_c : site c's chosen axis triggers this row }
27//!   ```
28//!
29//!   an XOR of a compile-time-fixed `base` and a subset of the fixing rows
30//!   selected by the fill assignment.
31//!
32//! # Arm polarity
33//!
34//! The tagged fill XORs `F_c` into a row iff the row's support `p` at the site
35//! column satisfies `p != I && p != chosen_axis` (see
36//! `apply_tagged_selective_fill`). Because fixing rows carry the forbidden Pauli
37//! and tracking rows are pivoted free of it during canonicalization, a surviving
38//! row's support at a site is normally one of `{I, axis0, axis1}` (the two
39//! allowed axes of the site's [`SelectiveKind`]):
40//!
41//! * support `I` → never triggered;
42//! * support `axis0` → triggered only by choosing `axis1`;
43//! * support `axis1` → triggered only by choosing `axis0`.
44//!
45//! We store the support Pauli itself, so evaluation mirrors the engine's
46//! condition verbatim (`support != chosen`). This also handles the degenerate
47//! case where a row carries the forbidden Pauli (`support == forbidden`): it then
48//! triggers for *both* axes — still affine, just unconditional.
49//!
50//! # Eligibility
51//!
52//! The model covers exactly the tagged-fill path. A graph is eligible iff every
53//! selective site owns a fixing row that (a) carries the forbidden Pauli on its
54//! own column and (b) is diagonalized against every other selective column, with
55//! a one-to-one correspondence between fixing rows and sites. Any violation
56//! yields a [`SymbolicBasisError`] naming the offending site — the
57//! representational-fallback signal, not a panic. At runtime such a graph takes
58//! the generic Gaussian path
59//! ([`apply_generic_selective_fill`](super::stabilizer::StabilizerBasis::apply_generic_selective_fill)),
60//! which this module deliberately does not represent.
61//!
62//! Partial evaluation (holding some sites symbolic while fixing others) is a
63//! trivial extension of the same structure but is intentionally not built here:
64//! no current consumer needs it.
65
66use std::collections::BTreeSet;
67#[cfg(test)]
68use std::collections::HashMap;
69
70use glam::IVec3;
71
72#[cfg(test)]
73use bloq_utils::PauliBasis;
74use bloq_utils::boolean::{BooleanLimits, BooleanResourceError};
75use bloq_utils::{Pauli, PauliString};
76
77#[cfg(test)]
78use super::stabilizer::StabilizerBasis;
79use super::stabilizer::{StabilizerRowKind, collect_selective_constraints};
80use super::{StabilizerGenerators, ZXGraph};
81use crate::SelectiveKind;
82
83/// A selective site's raw descriptor: where it is, its kind, and the column and
84/// forbidden Pauli its fixing row must own. The construction input, before a
85/// pivot is resolved.
86struct SiteSpec {
87    pos: IVec3,
88    kind: SelectiveKind,
89    col: usize,
90    forbidden: Pauli,
91}
92
93/// One selective site and the fixing row that resolves it.
94#[derive(Debug, Clone)]
95pub struct SymbolicSite {
96    /// Position of the selective node.
97    pub pos: IVec3,
98    /// The node's kind, defining its two allowed fill axes.
99    pub kind: SelectiveKind,
100    /// Column (node id) the site occupies in the Pauli strings.
101    pub(crate) col: usize,
102    /// The fixing row `F_c`, XORed into a row when this site triggers it.
103    pub(crate) fixing_row: PauliString,
104    /// Ordinal of `F_c` in the initial basis, for provenance composition.
105    #[cfg(test)]
106    pub(crate) fixing_ordinal: usize,
107}
108
109/// A gated contribution of one site's fixing row to one surviving row.
110#[derive(Debug, Clone)]
111pub(crate) struct SymbolicDelta {
112    /// Index of the triggering site in [`SymbolicStabilizerBasis::sites`].
113    pub(crate) site: usize,
114    /// The surviving row's support Pauli at the site column (never `I`). The
115    /// delta triggers for a chosen axis `a` iff `support != Pauli::from(a)`.
116    pub(crate) support: Pauli,
117}
118
119/// One surviving (non-fixing) basis row as an affine function of the fills.
120#[derive(Debug, Clone)]
121pub(crate) struct SymbolicRow {
122    /// The row's value when no site triggers it.
123    pub(crate) base: PauliString,
124    /// The role the row plays, preserved from the initial basis.
125    pub(crate) kind: StabilizerRowKind,
126    /// Ordinal of this row in the initial basis, seeding its provenance.
127    #[cfg(test)]
128    pub(crate) base_ordinal: usize,
129    /// Per-site gated fixing-row contributions (only sites the row touches).
130    pub(crate) deltas: Vec<SymbolicDelta>,
131}
132
133/// The surviving basis represented as an affine function of the fill assignment.
134#[derive(Debug, Clone)]
135pub struct SymbolicStabilizerBasis {
136    sites: Vec<SymbolicSite>,
137    rows: Vec<SymbolicRow>,
138}
139
140/// The concrete basis for one fill assignment, plus per-row provenance.
141#[cfg(test)]
142#[derive(Debug, Clone)]
143pub(crate) struct SymbolicEvaluation {
144    /// The surviving rows and their kinds, in survivor order.
145    pub(crate) basis: StabilizerBasis,
146    /// For each surviving row, the initial-basis ordinals whose product it is.
147    pub(crate) combinations: Vec<BTreeSet<usize>>,
148}
149
150/// A symbolic basis cannot represent a selective site's fixing structure.
151/// This reports an unsupported symbolic model, not an invalid source graph.
152/// Callers can fall back to runtime-basis replay.
153#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
154#[non_exhaustive]
155pub enum SymbolicBasisError {
156    /// A selective site has no symbolic fixing representation.
157    #[error("selective site {0} is unsupported by the symbolic basis model")]
158    UnsupportedSite(IVec3),
159}
160
161impl SymbolicStabilizerBasis {
162    /// The selective sites, in the order fills are keyed against.
163    pub fn sites(&self) -> &[SymbolicSite] {
164        &self.sites
165    }
166
167    pub(crate) fn measurements_close_before_outputs(
168        &self,
169        zx: &ZXGraph,
170        limits: BooleanLimits,
171    ) -> Result<bool, BooleanResourceError> {
172        let components = zx
173            .nodes()
174            .iter()
175            .filter(|node| node.is_output_port(zx))
176            .flat_map(|node| {
177                let axes: &[_] = if node.role == crate::PortRole::Multiplex {
178                    &[Pauli::X]
179                } else {
180                    &[Pauli::X, Pauli::Z]
181                };
182                axes.iter().map(move |&axis| (node.id, axis))
183            });
184        let components = components.collect::<Vec<_>>();
185
186        for row in self
187            .rows
188            .iter()
189            .filter(|row| matches!(row.kind, StabilizerRowKind::Measurement { .. }))
190        {
191            for &(col, axis) in &components {
192                if !self.component_closes(row, zx.action_graph(), col, axis, limits)? {
193                    return Ok(false);
194                }
195            }
196        }
197        Ok(true)
198    }
199
200    fn component_closes(
201        &self,
202        row: &SymbolicRow,
203        actions: &crate::ActionDag,
204        col: usize,
205        axis: Pauli,
206        limits: BooleanLimits,
207    ) -> Result<bool, BooleanResourceError> {
208        let mut expected = row.base.get(col) & axis;
209        let mut targets = Vec::new();
210        for delta in &row.deltas {
211            let site = &self.sites[delta.site];
212            if !(site.fixing_row.get(col) & axis) {
213                continue;
214            }
215            if delta.support == Pauli::from(site.kind.pauli_if_true()) {
216                expected ^= true;
217                targets.push(site.pos);
218            } else if delta.support == Pauli::from(site.kind.pauli_if_false()) {
219                targets.push(site.pos);
220            } else {
221                expected ^= true;
222            }
223        }
224        actions.resolve_xor_is_with_limits(&targets, expected, limits)
225    }
226
227    /// Builds the symbolic basis from already-canonicalized generators, reusing
228    /// their rows and kinds verbatim so ordinals stay identity-stable with a
229    /// [`RuntimeStabilizerBasis::from_generators`](super::runtime_basis::RuntimeStabilizerBasis::from_generators)
230    /// replay.
231    ///
232    /// # Errors
233    ///
234    /// Returns a [`SymbolicBasisError`] naming the first site whose fixing
235    /// structure does not fit the affine model (see the module docs).
236    pub fn from_generators(stabilizers: &StabilizerGenerators) -> Result<Self, SymbolicBasisError> {
237        let rows: Vec<PauliString> = stabilizers
238            .generators
239            .iter()
240            .map(|generator| generator.stabilizer.paulis.clone())
241            .collect();
242        let kinds: Vec<StabilizerRowKind> = stabilizers
243            .generators
244            .iter()
245            .map(|generator| generator.kind.clone())
246            .collect();
247
248        // Site descriptors carry the column and forbidden Pauli the fixing row
249        // must own; `collect_selective_constraints` orders them by node scan
250        // order, which we adopt as the canonical site order.
251        let specs: Vec<SiteSpec> = collect_selective_constraints(&stabilizers.zx_graph)
252            .into_iter()
253            .map(|constraint| SiteSpec {
254                pos: constraint.pos,
255                kind: constraint.kind,
256                col: constraint.col,
257                forbidden: constraint.forbidden,
258            })
259            .collect();
260
261        Self::from_raw(rows, kinds, &specs)
262    }
263
264    /// Core construction from raw rows, kinds, and site descriptors. Split out
265    /// of [`from_generators`](Self::from_generators) so the eligibility rules can
266    /// be exercised on hand-built bases without a backing [`ZXGraph`].
267    fn from_raw(
268        rows: Vec<PauliString>,
269        kinds: Vec<StabilizerRowKind>,
270        specs: &[SiteSpec],
271    ) -> Result<Self, SymbolicBasisError> {
272        // Resolve each site to its pivot the same way the engine does — the
273        // first row that fixes the site — and validate the fixing structure.
274        let mut sites = Vec::with_capacity(specs.len());
275        let mut pivot_indices = Vec::with_capacity(specs.len());
276        for spec in specs {
277            let pivot = kinds
278                .iter()
279                .position(|kind| kind.fixes_selective(spec.pos))
280                .ok_or(SymbolicBasisError::UnsupportedSite(spec.pos))?;
281
282            // The pivot must carry the forbidden Pauli on its own column;
283            // otherwise the tagged fill rejects the axis equal to that support.
284            if rows[pivot].get(spec.col) != spec.forbidden {
285                return Err(SymbolicBasisError::UnsupportedSite(spec.pos));
286            }
287
288            // Diagonalization: identity on every other selective column, so
289            // filling this site cannot alter another site's trigger status.
290            for other in specs {
291                if other.pos != spec.pos && rows[pivot].get(other.col) != Pauli::I {
292                    return Err(SymbolicBasisError::UnsupportedSite(spec.pos));
293                }
294            }
295
296            sites.push(SymbolicSite {
297                pos: spec.pos,
298                kind: spec.kind,
299                col: spec.col,
300                fixing_row: rows[pivot].clone(),
301                #[cfg(test)]
302                fixing_ordinal: pivot,
303            });
304            pivot_indices.push(pivot);
305        }
306
307        // Every fixing row must be exactly one site's pivot: distinct pivots and
308        // no leftover fixing rows. A leftover fixing row would survive our
309        // filter but never be removed by a replay, diverging the two.
310        let pivot_set: BTreeSet<usize> = pivot_indices.iter().copied().collect();
311        if pivot_set.len() != pivot_indices.len() {
312            // Two sites share a pivot; blame the second occurrence's site.
313            let mut seen = BTreeSet::new();
314            let doubled = pivot_indices
315                .iter()
316                .position(|pivot| !seen.insert(*pivot))
317                .expect("a duplicate pivot exists when the set is smaller");
318            return Err(SymbolicBasisError::UnsupportedSite(sites[doubled].pos));
319        }
320        for (index, kind) in kinds.iter().enumerate() {
321            if kind.is_selective_fixing() && !pivot_set.contains(&index) {
322                // A fixing row nobody pivots on; attribute it to its first target.
323                let pos = kind
324                    .selective_fixing_targets()
325                    .first()
326                    .map(|target| target.pos)
327                    .unwrap_or_default();
328                return Err(SymbolicBasisError::UnsupportedSite(pos));
329            }
330        }
331
332        // Survivors are the non-fixing rows in their original relative order,
333        // matching the in-place removals of a sequential replay.
334        let col_by_site: Vec<usize> = sites.iter().map(|site| site.col).collect();
335        let symbolic_rows = kinds
336            .iter()
337            .enumerate()
338            .filter(|(_, kind)| kind.is_readout())
339            .map(|(ordinal, kind)| {
340                let row = &rows[ordinal];
341                let deltas = col_by_site
342                    .iter()
343                    .enumerate()
344                    .filter_map(|(site, &col)| {
345                        let support = row.get(col);
346                        (support != Pauli::I).then_some(SymbolicDelta { site, support })
347                    })
348                    .collect();
349                SymbolicRow {
350                    base: row.clone(),
351                    kind: kind.clone(),
352                    #[cfg(test)]
353                    base_ordinal: ordinal,
354                    deltas,
355                }
356            })
357            .collect();
358
359        Ok(Self {
360            sites,
361            rows: symbolic_rows,
362        })
363    }
364
365    /// Evaluates the concrete basis for a full fill assignment (one chosen axis
366    /// per site), plus each surviving row's initial-ordinal provenance.
367    ///
368    /// # Panics
369    ///
370    /// Panics if `fills` omits a site of this basis: the model represents full
371    /// assignments only.
372    #[cfg(test)]
373    pub(crate) fn evaluate(&self, fills: &[(IVec3, PauliBasis)]) -> SymbolicEvaluation {
374        let chosen: HashMap<IVec3, PauliBasis> = fills.iter().copied().collect();
375        let axes = self
376            .sites
377            .iter()
378            .map(|site| {
379                *chosen
380                    .get(&site.pos)
381                    .expect("fill assignment covers every selective site")
382            })
383            .collect::<Vec<_>>();
384
385        let mut out_rows = Vec::with_capacity(self.rows.len());
386        let mut out_kinds = Vec::with_capacity(self.rows.len());
387        let mut combinations = Vec::with_capacity(self.rows.len());
388
389        for row in &self.rows {
390            let mut value = row.base.clone();
391            let mut combination = BTreeSet::from([row.base_ordinal]);
392            for delta in &row.deltas {
393                let site = &self.sites[delta.site];
394                let axis = axes[delta.site];
395                // Mirror the engine's trigger: XOR the fixing row iff the row's
396                // support anticommutes with the chosen axis (support is non-I).
397                if delta.support != Pauli::from(axis) {
398                    value ^= &site.fixing_row;
399                    xor_insert(&mut combination, site.fixing_ordinal);
400                }
401            }
402            out_rows.push(value);
403            out_kinds.push(row.kind.clone());
404            combinations.push(combination);
405        }
406
407        SymbolicEvaluation {
408            basis: StabilizerBasis::new(out_rows, out_kinds),
409            combinations,
410        }
411    }
412}
413
414/// XOR-toggle `value` into `set`: present ⇒ remove, absent ⇒ insert.
415#[cfg(test)]
416fn xor_insert(set: &mut BTreeSet<usize>, value: usize) {
417    if !set.remove(&value) {
418        set.insert(value);
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    use super::super::stabilizer::{SelectiveFixingTarget, coeffs_to_sparse};
427    use crate::GalleryItem;
428
429    /// Site count below which we enumerate every fill assignment (`2^n`). Above
430    /// it, exhaustive enumeration is infeasible — the gallery's `PhaseGradientK4`
431    /// has 22 selective sites — so we fall back to a structured pairwise sample.
432    const EXHAUSTIVE_SITE_LIMIT: usize = 12;
433
434    /// Rows, kinds, and `(pos, kind, col)` site descriptors of a canonical basis.
435    type BasisParts = (
436        Vec<PauliString>,
437        Vec<StabilizerRowKind>,
438        Vec<(IVec3, SelectiveKind, usize)>,
439    );
440
441    /// One fill assignment: a chosen axis per selective site.
442    type Assignment = Vec<(IVec3, PauliBasis)>;
443
444    /// The result of an engine replay: surviving rows, their kinds, and each
445    /// row's composed initial-ordinal provenance.
446    type ReplayResult = (
447        Vec<PauliString>,
448        Vec<StabilizerRowKind>,
449        Vec<BTreeSet<usize>>,
450    );
451
452    /// Extracts the raw basis parts from canonicalized generators, mirroring what
453    /// [`SymbolicStabilizerBasis::from_generators`] and the engine replay consume.
454    fn basis_parts(stabilizers: &crate::StabilizerGenerators) -> BasisParts {
455        let rows = stabilizers
456            .generators
457            .iter()
458            .map(|generator| generator.stabilizer.paulis.clone())
459            .collect();
460        let kinds = stabilizers
461            .generators
462            .iter()
463            .map(|generator| generator.kind.clone())
464            .collect();
465        let sites = collect_selective_constraints(&stabilizers.zx_graph)
466            .into_iter()
467            .map(|constraint| (constraint.pos, constraint.kind, constraint.col))
468            .collect();
469        (rows, kinds, sites)
470    }
471
472    /// The assignment for a bitmask: bit `i` set ⇒ site `i` takes its `if_true`
473    /// axis, else `if_false`.
474    fn assignment_from_mask(sites: &[(IVec3, SelectiveKind, usize)], mask: u64) -> Assignment {
475        sites
476            .iter()
477            .enumerate()
478            .map(|(index, &(pos, kind, _))| {
479                let axis = if (mask >> index) & 1 == 1 {
480                    kind.pauli_if_true()
481                } else {
482                    kind.pauli_if_false()
483                };
484                (pos, axis)
485            })
486            .collect()
487    }
488
489    /// Fill assignments exercising `sites`. Up to [`EXHAUSTIVE_SITE_LIMIT`] sites
490    /// we enumerate the full `2^n` product. Beyond it we sample structurally:
491    /// all-false, all-true, every single site toggled from each pole, and every
492    /// pair. Pairwise coverage is sufficient here because the model is affine
493    /// (each site contributes an independent XOR), so any cross-site coupling
494    /// would already surface in some two-site assignment.
495    fn sampled_assignments(sites: &[(IVec3, SelectiveKind, usize)]) -> Vec<Assignment> {
496        let n = sites.len();
497        if n <= EXHAUSTIVE_SITE_LIMIT {
498            return (0..(1u64 << n))
499                .map(|mask| assignment_from_mask(sites, mask))
500                .collect();
501        }
502
503        let full = (1u64 << n) - 1;
504        let mut masks = BTreeSet::from([0, full]);
505        for i in 0..n {
506            masks.insert(1u64 << i);
507            masks.insert(full ^ (1u64 << i));
508            for j in (i + 1)..n {
509                masks.insert((1u64 << i) | (1u64 << j));
510            }
511        }
512        masks
513            .into_iter()
514            .map(|mask| assignment_from_mask(sites, mask))
515            .collect()
516    }
517
518    /// Replays one full assignment through the engine's tagged fill — the
519    /// pointwise ground truth — composing each step's combinations down to
520    /// initial ordinals. Returns `None` if any site rejects the tagged path.
521    fn replay_tagged(
522        rows: &[PauliString],
523        kinds: &[StabilizerRowKind],
524        sites: &[(IVec3, SelectiveKind, usize)],
525        assignment: &[(IVec3, PauliBasis)],
526    ) -> Option<ReplayResult> {
527        let chosen: HashMap<IVec3, PauliBasis> = assignment.iter().copied().collect();
528        let mut basis = StabilizerBasis::new(rows.to_vec(), kinds.to_vec());
529        let mut composed: Vec<BTreeSet<usize>> = (0..rows.len())
530            .map(|index| BTreeSet::from([index]))
531            .collect();
532
533        for &(pos, kind, col) in sites {
534            let axis = chosen[&pos];
535            let target = Pauli::from(axis);
536            let (next, coeffs) = basis
537                .apply_tagged_selective_fill(&[(col, target)], pos, kind)
538                .ok()?;
539            composed = coeffs_to_sparse(&coeffs)
540                .iter()
541                .map(|combination| {
542                    let mut set = BTreeSet::new();
543                    for &index in combination {
544                        for &ordinal in &composed[index] {
545                            xor_insert(&mut set, ordinal);
546                        }
547                    }
548                    set
549                })
550                .collect();
551            basis = next;
552        }
553        Some((basis.rows, basis.kinds, composed))
554    }
555
556    fn fixing(pos: IVec3, forbidden: Pauli) -> StabilizerRowKind {
557        StabilizerRowKind::SelectiveFixing {
558            targets: vec![SelectiveFixingTarget { pos, forbidden }],
559        }
560    }
561
562    /// Full-combo equivalence across the gallery: for every eligible graph with
563    /// selective sites, every fill assignment must reproduce the engine replay's
564    /// rows, kinds, and composed provenance exactly.
565    #[test]
566    fn eligibility_and_fill_order_match_tagged_replay_across_gallery() {
567        let mut eligible_graphs = 0;
568        let mut total_assignments = 0;
569        let mut multi_site_exercised = false;
570
571        for entry in GalleryItem::iter().filter(|entry| {
572            !entry.in_category(crate::GalleryCategory::AnalysisOnly)
573                && entry.build().root().instances.is_empty()
574        }) {
575            let graph = entry
576                .build()
577                .materialize_root_graph()
578                .expect("gallery flat projection");
579            // This root replays selective fills on fixed topology. Structural
580            // choices require explicit projections, checked by guarded suites.
581            if !graph.branch_definitions().is_empty() {
582                continue;
583            }
584            let zx = crate::ZXGraph::try_from(&graph)
585                .unwrap_or_else(|error| panic!("gallery {entry:?}: ZX conversion failed: {error}"));
586            let stabilizers = zx
587                .stabilizers()
588                .unwrap_or_else(|error| panic!("gallery {entry:?}: stabilizers failed: {error}"));
589            let (rows, kinds, sites) = basis_parts(&stabilizers);
590            if sites.is_empty() {
591                continue;
592            }
593            let symbolic = match SymbolicStabilizerBasis::from_generators(&stabilizers) {
594                Ok(symbolic) => symbolic,
595                Err(SymbolicBasisError::UnsupportedSite(_)) => {
596                    assert!(
597                        sampled_assignments(&sites).iter().any(|assignment| {
598                            replay_tagged(&rows, &kinds, &sites, assignment).is_none()
599                        }),
600                        "gallery {entry:?}: symbolic model rejected a fully tagged basis"
601                    );
602                    continue;
603                }
604            };
605            eligible_graphs += 1;
606
607            for assignment in sampled_assignments(&sites) {
608                let evaluated = symbolic.evaluate(&assignment);
609                let (replay_rows, replay_kinds, replay_combos) =
610                    replay_tagged(&rows, &kinds, &sites, &assignment)
611                        .expect("eligible graph takes the tagged path for every assignment");
612                if sites.len() >= 2 {
613                    multi_site_exercised = true;
614                    let mut reversed = sites.clone();
615                    reversed.reverse();
616                    let backward = replay_tagged(&rows, &kinds, &reversed, &assignment)
617                        .expect("eligible graph takes the reversed tagged path");
618                    assert_eq!(
619                        (&replay_rows, &replay_kinds, &replay_combos),
620                        (&backward.0, &backward.1, &backward.2),
621                        "gallery {entry:?}: fill order matters"
622                    );
623                }
624
625                assert_eq!(
626                    evaluated.basis.rows, replay_rows,
627                    "gallery {entry:?}: rows differ for {assignment:?}"
628                );
629                assert_eq!(
630                    evaluated.basis.kinds, replay_kinds,
631                    "gallery {entry:?}: kinds differ for {assignment:?}"
632                );
633                assert_eq!(
634                    evaluated.combinations, replay_combos,
635                    "gallery {entry:?}: combinations differ for {assignment:?}"
636                );
637                total_assignments += 1;
638            }
639        }
640
641        assert!(eligible_graphs > 0, "no eligible gallery graph exercised");
642        assert!(total_assignments > 1, "too few assignments exercised");
643        assert!(multi_site_exercised, "no multi-site fill order exercised");
644    }
645
646    /// Coupled detection: a fixing row that is not diagonalized against another
647    /// site's column is ineligible, blaming the coupled site pair. The gallery
648    /// has no such graph, so this pins the rule on a hand-built basis.
649    #[test]
650    fn non_diagonalized_fixing_rows_are_ineligible() {
651        let site_a = IVec3::new(0, 0, 0);
652        let site_b = IVec3::new(1, 0, 0);
653        // Fixing row for A carries Z at both columns: forbidden on its own (col 0)
654        // but non-identity on B's column (col 1), so the two fills are coupled.
655        let rows = vec![
656            PauliString::try_from("ZZ").unwrap(),
657            PauliString::try_from("_Z").unwrap(),
658        ];
659        let kinds = vec![fixing(site_a, Pauli::Z), fixing(site_b, Pauli::Z)];
660        let specs = [
661            SiteSpec {
662                pos: site_a,
663                kind: SelectiveKind::XY,
664                col: 0,
665                forbidden: Pauli::Z,
666            },
667            SiteSpec {
668                pos: site_b,
669                kind: SelectiveKind::XY,
670                col: 1,
671                forbidden: Pauli::Z,
672            },
673        ];
674
675        let error = SymbolicStabilizerBasis::from_raw(rows, kinds, &specs)
676            .expect_err("non-diagonalized fixing rows must be ineligible");
677        assert_eq!(error, SymbolicBasisError::UnsupportedSite(site_a));
678    }
679
680    /// A fixing row that does not carry the forbidden Pauli on its own column is
681    /// ineligible: the tagged fill would reject the axis equal to that support.
682    #[test]
683    fn fixing_row_without_forbidden_support_is_ineligible() {
684        let site = IVec3::new(0, 0, 0);
685        let rows = vec![PauliString::try_from("X").unwrap()];
686        let kinds = vec![fixing(site, Pauli::Z)];
687        let specs = [SiteSpec {
688            pos: site,
689            kind: SelectiveKind::XY,
690            col: 0,
691            forbidden: Pauli::Z,
692        }];
693
694        let error = SymbolicStabilizerBasis::from_raw(rows, kinds, &specs)
695            .expect_err("a fixing row lacking forbidden support must be ineligible");
696        assert_eq!(error, SymbolicBasisError::UnsupportedSite(site));
697    }
698}