From 9bab13bd2a8aca63c5aefc2e797bf57af40a2a68 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 14:20:15 -0600 Subject: [PATCH 01/14] Add locality-driven MinSpan deferred projection order to Mast with per-projection diagnostics --- .../examples/mast_projection_order.rs | 115 +++++++ exp/pecos-stab-tn/src/stab_mps/mast.rs | 320 +++++++++++++++--- exp/pecos-stab-tn/src/stab_mps/measure.rs | 28 ++ exp/pecos-stab-tn/tests/verification.rs | 142 +++++++- 4 files changed, 549 insertions(+), 56 deletions(-) create mode 100644 exp/pecos-stab-tn/examples/mast_projection_order.rs diff --git a/exp/pecos-stab-tn/examples/mast_projection_order.rs b/exp/pecos-stab-tn/examples/mast_projection_order.rs new file mode 100644 index 000000000..22840d300 --- /dev/null +++ b/exp/pecos-stab-tn/examples/mast_projection_order.rs @@ -0,0 +1,115 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the +// License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing permissions and +// limitations under the License. + +//! Compare deferred-projection ordering for seeded random Clifford+T circuits. +//! +//! Circuit generation follows the `next_rng` xorshift helper and random +//! Clifford-layer pattern in `disent_firing_rate.rs`. + +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::mast::{Mast, ProjectionOrder}; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy)] +enum CircuitGate { + H(usize), + Sz(usize), + Cx(usize, usize), + T(usize), +} + +/// Same xorshift generator as `examples/disent_firing_rate.rs`. +fn next_rng(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state +} + +fn random_clifford_t_circuit(num_qubits: usize, t_count: usize, seed: u64) -> Vec { + let mut gates = (0..num_qubits).map(CircuitGate::H).collect::>(); + let mut rng_state = seed.wrapping_add(1); + + for _ in 0..t_count { + for _ in 0..3 { + let gate_type = next_rng(&mut rng_state) % 3; + let q0 = (next_rng(&mut rng_state) % num_qubits as u64) as usize; + match gate_type { + 0 => gates.push(CircuitGate::H(q0)), + 1 => gates.push(CircuitGate::Sz(q0)), + _ => { + let q1 = loop { + let candidate = (next_rng(&mut rng_state) % num_qubits as u64) as usize; + if candidate != q0 { + break candidate; + } + }; + gates.push(CircuitGate::Cx(q0, q1)); + } + } + } + let target = (next_rng(&mut rng_state) % num_qubits as u64) as usize; + gates.push(CircuitGate::T(target)); + } + gates +} + +fn run_circuit( + num_qubits: usize, + t_count: usize, + simulator_seed: u64, + gates: &[CircuitGate], + order: ProjectionOrder, +) -> (usize, usize, Duration) { + let start = Instant::now(); + let mut mast = Mast::with_seed(num_qubits, t_count, simulator_seed).projection_order(order); + let t = Angle64::QUARTER_TURN / 2u64; + for &gate in gates { + match gate { + CircuitGate::H(q) => mast.h(&[QubitId(q)]), + CircuitGate::Sz(q) => mast.sz(&[QubitId(q)]), + CircuitGate::Cx(control, target) => mast.cx(&[(QubitId(control), QubitId(target))]), + CircuitGate::T(q) => mast.rz(t, &[QubitId(q)]), + }; + } + mast.project_all(); + let elapsed = start.elapsed(); + (mast.projection_peak_bond(), mast.max_bond_dim(), elapsed) +} + +fn main() { + let circuit_seed = 0x5eed_c1ff_07d0_2026; + let simulator_seed = 0x5eed_5a1f_2026_0814; + + println!("MAST deferred-projection order comparison"); + println!( + "{:<6} {:<8} {:<10} {:>18} {:>14} {:>14}", + "data", "T-count", "order", "projection peak", "final bond", "wall time (s)" + ); + println!("{:-<76}", ""); + + for num_qubits in [8usize, 16, 32] { + for t_count in [num_qubits, 2 * num_qubits] { + let gates = random_clifford_t_circuit(num_qubits, t_count, circuit_seed); + for order in [ProjectionOrder::Input, ProjectionOrder::MinSpan] { + let (projection_peak, final_bond, elapsed) = + run_circuit(num_qubits, t_count, simulator_seed, &gates, order); + println!( + "{num_qubits:<6} {t_count:<8} {order:<10?} {projection_peak:>18} \ + {final_bond:>14} {:>14.6}", + elapsed.as_secs_f64() + ); + } + } + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/mast.rs b/exp/pecos-stab-tn/src/stab_mps/mast.rs index 32536d84e..156022ff6 100644 --- a/exp/pecos-stab-tn/src/stab_mps/mast.rs +++ b/exp/pecos-stab-tn/src/stab_mps/mast.rs @@ -38,7 +38,34 @@ use pecos_simulators::{ use super::non_clifford; +/// Order used to collapse deferred magic-state ancillas. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ProjectionOrder { + /// Collapse in reverse injection order, preserving the original MAST behavior. + #[default] + Input, + /// Recompute MPS-frame locality before every collapse and choose the + /// smallest `(span, support size, injection index)` tuple. + MinSpan, +} + +/// Diagnostics for one deferred magic-state ancilla projection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProjectionRecord { + /// Ancilla qubit index in the expanded MAST system. + pub ancilla: usize, + /// Number of MPS sites in the conjugated observable's support. + pub support_size: usize, + /// Distance between the first and last supported MPS sites. + pub mps_span: usize, + /// Maximum MPS bond dimension immediately before projection. + pub bond_before: usize, + /// Maximum MPS bond dimension after projection and any correction. + pub bond_after: usize, +} + /// A deferred ancilla measurement. +#[derive(Clone, Copy)] struct DeferredMeasurement { /// The ancilla qubit index (in the expanded system). ancilla: usize, @@ -47,6 +74,8 @@ struct DeferredMeasurement { /// The correction angle: RZ(2*theta) applied to target if ancilla = 1. /// For T gates: correction = RZ(pi/2) = S (Clifford). correction_angle: Angle64, + /// Zero-based position in the original injection sequence. + injection_index: usize, } /// MAST simulator: Magic state injection Augmented STN. @@ -69,8 +98,15 @@ pub struct Mast { next_ancilla: usize, /// Deferred measurements to perform at the end. deferred: Vec, + /// Policy for selecting the next deferred ancilla to project. + projection_order: ProjectionOrder, + /// Per-projection locality and bond-dimension diagnostics since reset. + projection_records: Vec, + /// Peak bond dimension observed before or after a deferred projection. + projection_peak_bond: usize, global_phase: Complex64, disent_flags: Vec>, + numerical_flag_redetection: bool, gf2_matrix: super::ofd::Gf2FlipMatrix, rng: PecosRng, pub stats: super::StabMpsStats, @@ -106,8 +142,12 @@ impl Mast { config: MpsConfig::default(), next_ancilla: num_qubits, deferred: Vec::new(), + projection_order: ProjectionOrder::default(), + projection_records: Vec::new(), + projection_peak_bond: 0, global_phase: Complex64::new(1.0, 0.0), disent_flags: vec![Some(super::SiteEigenstate::Z(false)); total], + numerical_flag_redetection: false, gf2_matrix: super::ofd::Gf2FlipMatrix::new(total), rng: PecosRng::seed_from_u64(0), stats: super::StabMpsStats::default(), @@ -131,8 +171,12 @@ impl Mast { config: MpsConfig::default(), next_ancilla: num_qubits, deferred: Vec::new(), + projection_order: ProjectionOrder::default(), + projection_records: Vec::new(), + projection_peak_bond: 0, global_phase: Complex64::new(1.0, 0.0), disent_flags: vec![Some(super::SiteEigenstate::Z(false)); total], + numerical_flag_redetection: false, gf2_matrix: super::ofd::Gf2FlipMatrix::new(total), rng: PecosRng::seed_from_u64(seed), stats: super::StabMpsStats::default(), @@ -160,6 +204,24 @@ impl Mast { self } + /// Numerically recover missing exact-disentangling |0> flags at product sites. + /// Default: false. + #[must_use] + pub fn with_numerical_flag_redetection(mut self, enable: bool) -> Self { + self.numerical_flag_redetection = enable; + self + } + + /// Select the ordering policy for deferred ancilla projections. + /// + /// The default is [`ProjectionOrder::Input`], which preserves reverse + /// injection order and its RNG-consumption sequence. + #[must_use] + pub fn projection_order(mut self, projection_order: ProjectionOrder) -> Self { + self.projection_order = projection_order; + self + } + /// Flush any pending merged RZ on qubit `q` via magic-state injection. /// No-op when `merge_rz` is off or the slot is empty. fn flush_pending_rz(&mut self, q: usize) { @@ -229,6 +291,20 @@ impl Mast { &self.mps } + /// Return diagnostics for deferred projections performed since reset. + #[must_use] + pub fn projection_records(&self) -> &[ProjectionRecord] { + &self.projection_records + } + + /// Return the peak MPS bond dimension observed during deferred projection. + /// + /// Returns zero when no deferred projection has run since reset. + #[must_use] + pub fn projection_peak_bond(&self) -> usize { + self.projection_peak_bond + } + /// Inject a magic state for RZ(theta) on the target qubit. /// /// Magic state teleportation protocol: @@ -271,6 +347,7 @@ impl Mast { true, &mut non_clifford::RzContext { disent_flags: &mut self.disent_flags, + numerical_flag_redetection: self.numerical_flag_redetection, gf2_matrix: &mut self.gf2_matrix, stats: &mut self.stats, }, @@ -285,6 +362,7 @@ impl Mast { ancilla, target, correction_angle: theta + theta, // RZ(2*theta) correction if outcome=1 + injection_index: ancilla - self.num_data_qubits, }); } @@ -295,66 +373,120 @@ impl Mast { /// 2. If outcome = 1: apply RZ(2*theta) correction to the target data qubit /// (For T gates, this is S = RZ(pi/2), which is Clifford) pub fn project_all(&mut self) { - let deferred: Vec = self.deferred.drain(..).rev().collect(); - for dm in deferred { - // Measure the ancilla using the shared STN measurement protocol - let result = if self.lazy_measure { - super::measure::measure_qubit_stab_mps_lazy( - &mut self.tableau, - &mut self.mps, - &mut self.rng, - dm.ancilla, - &mut self.deferred_ops, - ) + match self.projection_order { + ProjectionOrder::Input => { + // Preserve the original drain and reverse-iteration path so + // projection order and RNG consumption remain unchanged. + let deferred: Vec = self.deferred.drain(..).rev().collect(); + for dm in deferred { + let (support_size, mps_span) = self.projection_locality(dm.ancilla); + self.project_deferred(dm, support_size, mps_span); + } + } + ProjectionOrder::MinSpan => { + while !self.deferred.is_empty() { + let mut selected = (0, usize::MAX, usize::MAX, usize::MAX); + for (position, dm) in self.deferred.iter().enumerate() { + let (support_size, mps_span) = self.projection_locality(dm.ancilla); + let candidate = (position, mps_span, support_size, dm.injection_index); + if (candidate.1, candidate.2, candidate.3) + < (selected.1, selected.2, selected.3) + { + selected = candidate; + } + } + let dm = self.deferred.remove(selected.0); + self.project_deferred(dm, selected.2, selected.1); + } + } + } + } + + fn projection_locality(&self, ancilla: usize) -> (usize, usize) { + let deferred_ops = if self.lazy_measure { + self.deferred_ops.as_slice() + } else { + &[] + }; + let support = super::measure::conjugated_z_support(&self.tableau, ancilla, deferred_ops); + let span = support + .first() + .zip(support.last()) + .map_or(0, |(first, last)| last - first); + (support.len(), span) + } + + fn project_deferred(&mut self, dm: DeferredMeasurement, support_size: usize, mps_span: usize) { + let bond_before = self.mps.max_bond_dim(); + self.projection_peak_bond = self.projection_peak_bond.max(bond_before); + + // Measure the ancilla using the shared STN measurement protocol. + let result = if self.lazy_measure { + super::measure::measure_qubit_stab_mps_lazy( + &mut self.tableau, + &mut self.mps, + &mut self.rng, + dm.ancilla, + &mut self.deferred_ops, + ) + } else { + super::measure::measure_qubit_stab_mps( + &mut self.tableau, + &mut self.mps, + &mut self.rng, + dm.ancilla, + ) + }; + + // If outcome = 1 (true in PECOS convention): apply correction. + if result.outcome { + let corr = dm.correction_angle; + let tgt = QubitId(dm.target); + + if corr == Angle64::ZERO { + // No correction needed. + } else if corr == Angle64::HALF_TURN { + // RZ(pi) = -iZ. + self.global_phase *= Complex64::new(0.0, -1.0); + self.tableau.z(&[tgt]); + } else if corr == Angle64::QUARTER_TURN { + // RZ(pi/2) = e^{-i*pi/4} S -- the T-gate correction. + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + self.global_phase *= Complex64::new(inv_sqrt2, -inv_sqrt2); + self.tableau.sz(&[tgt]); + } else if corr == Angle64::THREE_QUARTERS_TURN { + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + self.global_phase *= Complex64::new(inv_sqrt2, inv_sqrt2); + self.tableau.szdg(&[tgt]); } else { - super::measure::measure_qubit_stab_mps( + // Non-Clifford correction: apply via the STN protocol. + let (sin_half, cos_half) = corr.half_angle_sin_cos(); + non_clifford::apply_rz_stab_mps( &mut self.tableau, &mut self.mps, - &mut self.rng, - dm.ancilla, - ) - }; - - // If outcome = 1 (true in PECOS convention): apply correction - if result.outcome { - let corr = dm.correction_angle; - let tgt = QubitId(dm.target); - - // Check if correction is a Clifford angle - if corr == Angle64::ZERO { - // No correction needed - } else if corr == Angle64::HALF_TURN { - // RZ(pi) = -iZ - self.global_phase *= Complex64::new(0.0, -1.0); - self.tableau.z(&[tgt]); - } else if corr == Angle64::QUARTER_TURN { - // RZ(pi/2) = e^{-i*pi/4} S -- this is the T gate correction - let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; - self.global_phase *= Complex64::new(inv_sqrt2, -inv_sqrt2); - self.tableau.sz(&[tgt]); - } else if corr == Angle64::THREE_QUARTERS_TURN { - let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; - self.global_phase *= Complex64::new(inv_sqrt2, inv_sqrt2); - self.tableau.szdg(&[tgt]); - } else { - // Non-Clifford correction: apply via STN protocol - let (sin_half, cos_half) = corr.half_angle_sin_cos(); - non_clifford::apply_rz_stab_mps( - &mut self.tableau, - &mut self.mps, - cos_half, - sin_half, - dm.target, - true, - &mut non_clifford::RzContext { - disent_flags: &mut self.disent_flags, - gf2_matrix: &mut self.gf2_matrix, - stats: &mut self.stats, - }, - ); - } + cos_half, + sin_half, + dm.target, + true, + &mut non_clifford::RzContext { + disent_flags: &mut self.disent_flags, + numerical_flag_redetection: self.numerical_flag_redetection, + gf2_matrix: &mut self.gf2_matrix, + stats: &mut self.stats, + }, + ); } } + + let bond_after = self.mps.max_bond_dim(); + self.projection_peak_bond = self.projection_peak_bond.max(bond_after); + self.projection_records.push(ProjectionRecord { + ancilla: dm.ancilla, + support_size, + mps_span, + bond_before, + bond_after, + }); } } @@ -364,6 +496,8 @@ impl QuantumSimulator for Mast { self.mps = Mps::new(self.total_qubits, self.config.clone()); self.next_ancilla = self.num_data_qubits; self.deferred.clear(); + self.projection_records.clear(); + self.projection_peak_bond = 0; self.global_phase = Complex64::new(1.0, 0.0); self.disent_flags = vec![Some(super::SiteEigenstate::Z(false)); self.total_qubits]; self.gf2_matrix.reset(); @@ -534,6 +668,82 @@ mod tests { assert_relative_eq!(mast.mps().norm_squared(), 1.0, epsilon = 1e-8); } + fn apply_seeded_projection_regression_circuit(mast: &mut Mast) { + let t = Angle64::QUARTER_TURN / 2u64; + mast.h(&[QubitId(0), QubitId(2)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + mast.rz(t, &[QubitId(0)]); + mast.h(&[QubitId(1)]); + mast.rz(t, &[QubitId(2)]); + mast.cx(&[(QubitId(2), QubitId(0))]); + mast.rz(t, &[QubitId(1)]); + mast.rz(t, &[QubitId(0)]); + } + + #[test] + fn test_mast_explicit_input_preserves_default_projection_path() { + let mut default_path = Mast::with_seed(3, 4, 0x51_7a); + let mut explicit_input = + Mast::with_seed(3, 4, 0x51_7a).projection_order(ProjectionOrder::Input); + apply_seeded_projection_regression_circuit(&mut default_path); + apply_seeded_projection_regression_circuit(&mut explicit_input); + + default_path.project_all(); + explicit_input.project_all(); + + assert_eq!( + default_path + .projection_records() + .iter() + .map(|record| record.ancilla) + .collect::>(), + vec![6, 5, 4, 3] + ); + assert_eq!( + default_path.mps().state_vector(), + explicit_input.mps().state_vector() + ); + + let default_outcomes: Vec = default_path + .mz(&[QubitId(0), QubitId(1), QubitId(2)]) + .into_iter() + .map(|result| result.outcome) + .collect(); + let input_outcomes: Vec = explicit_input + .mz(&[QubitId(0), QubitId(1), QubitId(2)]) + .into_iter() + .map(|result| result.outcome) + .collect(); + assert_eq!(default_outcomes, input_outcomes); + } + + #[test] + fn test_mast_projection_diagnostics_populated_and_reset() { + for order in [ProjectionOrder::Input, ProjectionOrder::MinSpan] { + let mut mast = Mast::with_seed(3, 4, 91).projection_order(order); + apply_seeded_projection_regression_circuit(&mut mast); + mast.project_all(); + + assert_eq!(mast.projection_records().len(), 4); + assert!( + mast.projection_records() + .iter() + .all(|record| record.bond_before > 0 && record.bond_after > 0) + ); + let recorded_peak = mast + .projection_records() + .iter() + .map(|record| record.bond_before.max(record.bond_after)) + .max() + .expect("four projection records"); + assert_eq!(mast.projection_peak_bond(), recorded_peak); + + mast.reset(); + assert!(mast.projection_records().is_empty()); + assert_eq!(mast.projection_peak_bond(), 0); + } + } + #[test] fn test_mast_t_on_zero_deterministic() { // T|0> via MAST: data stays in |0>, measurement should be deterministic diff --git a/exp/pecos-stab-tn/src/stab_mps/measure.rs b/exp/pecos-stab-tn/src/stab_mps/measure.rs index 735a0e16a..ce2d36128 100644 --- a/exp/pecos-stab-tn/src/stab_mps/measure.rs +++ b/exp/pecos-stab-tn/src/stab_mps/measure.rs @@ -425,6 +425,34 @@ pub fn conjugate_pauli_by_deferred_ops( } } +/// Return the MPS-site support of the current conjugated observable `C† Z_q C`. +/// +/// The support is derived from the same `decompose_z` result used by the +/// measurement protocol. When lazy measurement has accumulated a virtual +/// Clifford frame, the decomposition is conjugated through that frame before +/// its X and Z sites are combined. +#[must_use] +pub(crate) fn conjugated_z_support( + tableau: &SparseStabY, + q_idx: usize, + deferred: &[DeferredOp], +) -> Vec { + let (mut flip_sites, mut sign_sites, mut phase) = + match decompose_z(tableau.stabs(), tableau.destabs(), q_idx) { + ZDecomposition::Stabilizer { phase, sign_sites } => (Vec::new(), sign_sites, phase), + ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } => (flip_sites, sign_sites, phase), + }; + conjugate_pauli_by_deferred_ops(&mut flip_sites, &mut sign_sites, &mut phase, deferred); + flip_sites.extend(sign_sites); + flip_sites.sort_unstable(); + flip_sites.dedup(); + flip_sites +} + /// Backwards-compatible CNOT-only conjugation wrapper. CNOT conjugation /// doesn't touch phase, so this discards the phase output. pub fn conjugate_pauli_by_deferred( diff --git a/exp/pecos-stab-tn/tests/verification.rs b/exp/pecos-stab-tn/tests/verification.rs index c69e51242..1242e02fd 100644 --- a/exp/pecos-stab-tn/tests/verification.rs +++ b/exp/pecos-stab-tn/tests/verification.rs @@ -16,7 +16,7 @@ use num_complex::Complex64; use pecos_core::{Angle64, QubitId}; use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, QuantumSimulator, StabVec}; use pecos_stab_tn::stab_mps::StabMps; -use pecos_stab_tn::stab_mps::mast::Mast; +use pecos_stab_tn::stab_mps::mast::{Mast, ProjectionOrder}; /// Check that two state vectors match up to global phase. fn assert_states_match(sv_a: &[Complex64], sv_b: &[Complex64], label: &str) { @@ -1944,6 +1944,146 @@ fn test_measure_apply_measure_3qubit() { // MAST vs STN measurement comparison // ============================================================================ +#[derive(Clone, Copy)] +enum SeededCliffordTGate { + H(usize), + Sz(usize), + Cx(usize, usize), + T(usize), +} + +fn next_seeded_gate_choice(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state +} + +fn seeded_clifford_t_circuit( + num_qubits: usize, + t_count: usize, + seed: u64, +) -> Vec { + let mut state = seed.wrapping_add(1); + let mut gates = (0..num_qubits) + .filter(|_| next_seeded_gate_choice(&mut state) & 1 != 0) + .map(SeededCliffordTGate::H) + .collect::>(); + if gates.is_empty() { + gates.push(SeededCliffordTGate::H(0)); + } + for _ in 0..t_count { + for _ in 0..3 { + let gate_type = next_seeded_gate_choice(&mut state) % 2; + let q0 = (next_seeded_gate_choice(&mut state) % num_qubits as u64) as usize; + if gate_type == 0 { + gates.push(SeededCliffordTGate::Sz(q0)); + } else { + let q1 = loop { + let candidate = + (next_seeded_gate_choice(&mut state) % num_qubits as u64) as usize; + if candidate != q0 { + break candidate; + } + }; + gates.push(SeededCliffordTGate::Cx(q0, q1)); + } + } + let target = (next_seeded_gate_choice(&mut state) % num_qubits as u64) as usize; + gates.push(SeededCliffordTGate::T(target)); + } + gates +} + +fn apply_seeded_clifford_t_to_stn(stn: &mut StabMps, gates: &[SeededCliffordTGate]) { + let t = Angle64::QUARTER_TURN / 2u64; + for &gate in gates { + match gate { + SeededCliffordTGate::H(q) => stn.h(&[QubitId(q)]), + SeededCliffordTGate::Sz(q) => stn.sz(&[QubitId(q)]), + SeededCliffordTGate::Cx(control, target) => { + stn.cx(&[(QubitId(control), QubitId(target))]) + } + SeededCliffordTGate::T(q) => stn.rz(t, &[QubitId(q)]), + }; + } +} + +fn apply_seeded_clifford_t_to_mast(mast: &mut Mast, gates: &[SeededCliffordTGate]) { + let t = Angle64::QUARTER_TURN / 2u64; + for &gate in gates { + match gate { + SeededCliffordTGate::H(q) => mast.h(&[QubitId(q)]), + SeededCliffordTGate::Sz(q) => mast.sz(&[QubitId(q)]), + SeededCliffordTGate::Cx(control, target) => { + mast.cx(&[(QubitId(control), QubitId(target))]) + } + SeededCliffordTGate::T(q) => mast.rz(t, &[QubitId(q)]), + }; + } +} + +#[test] +fn test_mast_min_span_matches_stn_exact_random_probabilities() { + // Mirrors `test_mast_matches_stn_exact_probabilities_2q`: compare each + // sampled outcome with `prob_bitstring` under the same five-sigma bound. + let num_trials = 5000usize; + for num_qubits in 3..=5 { + for t_count in 3..=6 { + let circuit_seed = 10_000 + (num_qubits * 100 + t_count) as u64; + let gates = seeded_clifford_t_circuit(num_qubits, t_count, circuit_seed); + let num_outcomes = 1usize << num_qubits; + + let mut stn = StabMps::with_seed(num_qubits, circuit_seed); + apply_seeded_clifford_t_to_stn(&mut stn, &gates); + stn.flush(); + let exact_probs = (0..num_outcomes) + .map(|outcome| { + let bits = (0..num_qubits) + .rev() + .map(|q| ((outcome >> q) & 1) != 0) + .collect::>(); + stn.prob_bitstring(&bits) + }) + .collect::>(); + let total: f64 = exact_probs.iter().sum(); + assert!( + (total - 1.0).abs() < 1e-8, + "n={num_qubits} t={t_count}: exact probabilities sum to {total}" + ); + + let mut counts = vec![0u32; num_outcomes]; + let measured_qubits = (0..num_qubits).map(QubitId).collect::>(); + for trial in 0..num_trials { + let simulator_seed = circuit_seed.wrapping_mul(10_000) + trial as u64; + let mut mast = Mast::with_seed(num_qubits, t_count, simulator_seed) + .projection_order(ProjectionOrder::MinSpan); + apply_seeded_clifford_t_to_mast(&mut mast, &gates); + let outcome = mast + .mz(&measured_qubits) + .iter() + .enumerate() + .fold(0usize, |value, (q, result)| { + value | (usize::from(result.outcome) << q) + }); + counts[outcome] += 1; + } + + for outcome in 0..num_outcomes { + let exact = exact_probs[outcome]; + let sampled = f64::from(counts[outcome]) / num_trials as f64; + let sigma = (exact * (1.0 - exact) / num_trials as f64).sqrt().max(1e-6); + let deviation = (exact - sampled).abs() / sigma; + assert!( + deviation < 5.0, + "n={num_qubits} t={t_count} outcome={outcome}: exact={exact:.4} \ + MinSpan={sampled:.4}, deviation={deviation:.1}σ" + ); + } + } + } +} + #[test] fn test_mast_matches_stn_exact_probabilities_2q() { // Compare MAST's sampled distribution to STN's EXACT probabilities From 7d4776e44308581e8a0ae4f89c6bcd0b34cc3417 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 14:36:52 -0600 Subject: [PATCH 02/14] Add opt-in numerical re-detection of |0> product sites for the STN exact-disentangling fast path --- .../examples/numerical_redetection.rs | 155 ++++++++++++++++++ exp/pecos-stab-tn/src/stab_mps.rs | 21 +++ .../src/stab_mps/non_clifford.rs | 31 ++++ exp/pecos-stab-tn/tests/verification.rs | 116 +++++++++++++ 4 files changed, 323 insertions(+) create mode 100644 exp/pecos-stab-tn/examples/numerical_redetection.rs diff --git a/exp/pecos-stab-tn/examples/numerical_redetection.rs b/exp/pecos-stab-tn/examples/numerical_redetection.rs new file mode 100644 index 000000000..c0c8eae8d --- /dev/null +++ b/exp/pecos-stab-tn/examples/numerical_redetection.rs @@ -0,0 +1,155 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the +// License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing permissions and +// limitations under the License. + +//! Falsifier benchmark for numerical |0> flag re-detection. + +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::StabMps; +use std::f64::consts::TAU; +use std::time::Instant; + +#[derive(Clone, Copy)] +enum GateMix { + Random, + CliffT, +} + +fn next_rng(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state +} + +fn run_circuit( + num_qubits: usize, + num_gates: usize, + seed: u64, + mix: GateMix, + numerical_redetection: bool, +) -> (StabMps, usize) { + let mut stn = StabMps::builder(num_qubits) + .seed(seed) + .numerical_flag_redetection(numerical_redetection) + .build(); + let mut peak_bond = stn.max_bond_dim(); + let mut rng_state = seed.wrapping_add(1); + + for _ in 0..num_gates { + let gate_type = next_rng(&mut rng_state) + % match mix { + GateMix::Random => 8, + GateMix::CliffT => 6, + }; + let q0 = (next_rng(&mut rng_state) % num_qubits as u64) as usize; + let q1 = loop { + let q = (next_rng(&mut rng_state) % num_qubits as u64) as usize; + if q != q0 { + break q; + } + }; + + match gate_type { + 0 => { + stn.h(&[QubitId(q0)]); + } + 1 => { + stn.sz(&[QubitId(q0)]); + } + 2 => { + stn.x(&[QubitId(q0)]); + } + 3 => { + stn.cx(&[(QubitId(q0), QubitId(q1))]); + } + 4 => { + stn.cz(&[(QubitId(q0), QubitId(q1))]); + } + 5 => { + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(q0)]); + } + 6 => { + let bits = next_rng(&mut rng_state); + let angle = Angle64::from_radians((bits % 1000) as f64 * 0.001 * TAU); + stn.rz(angle, &[QubitId(q0)]); + } + _ => { + let bits = next_rng(&mut rng_state); + let angle = Angle64::from_radians((bits % 1000) as f64 * 0.001 * TAU); + stn.rx(angle, &[QubitId(q0)]); + } + } + peak_bond = peak_bond.max(stn.max_bond_dim()); + } + + (stn, peak_bond) +} + +fn benchmark( + label: &str, + num_qubits: usize, + num_gates: usize, + num_seeds: u64, + mix: GateMix, + numerical_redetection: bool, +) { + let start = Instant::now(); + let mut total = 0u64; + let mut fast = 0u64; + let mut redetect = 0u64; + let mut standard = 0u64; + let mut final_bond_sum = 0u64; + let mut peak_bond_sum = 0u64; + + for seed in 0..num_seeds { + let (stn, peak_bond) = run_circuit(num_qubits, num_gates, seed, mix, numerical_redetection); + total += stn.stats.total_nonclifford; + fast += stn.stats.multi_disent; + redetect += stn.stats.numerical_redetect; + standard += stn.stats.multi_std; + final_bond_sum += stn.max_bond_dim() as u64; + peak_bond_sum += peak_bond as u64; + } + + let mode = if numerical_redetection { "ON" } else { "OFF" }; + let fast_rate = if total == 0 { + 0.0 + } else { + 100.0 * fast as f64 / total as f64 + }; + let average_final = final_bond_sum as f64 / num_seeds as f64; + let average_peak = peak_bond_sum as f64 / num_seeds as f64; + let wall_ms = start.elapsed().as_secs_f64() * 1000.0; + println!( + "{label:<13} | {mode:<3} | {total:>5} | {fast_rate:>7.2}% | {redetect:>8} | \ + {standard:>5} | {average_final:>9.2} | {average_peak:>8.2} | {wall_ms:>8.2}" + ); +} + +fn main() { + println!( + "scenario | opt | total | fast rate | redetect | std | avg final | avg peak | wall ms" + ); + println!( + "--------------|-----|-------|-----------|----------|-------|-----------|----------|---------" + ); + for (label, n, gates, seeds, mix) in [ + ("2q deep", 2, 50, 50, GateMix::Random), + ("3q T-heavy", 3, 30, 30, GateMix::CliffT), + ("10q T", 10, 40, 3, GateMix::CliffT), + ("15q T", 15, 50, 2, GateMix::CliffT), + ] { + benchmark(label, n, gates, seeds, mix, false); + benchmark(label, n, gates, seeds, mix, true); + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps.rs b/exp/pecos-stab-tn/src/stab_mps.rs index 4a115d751..ca396332e 100644 --- a/exp/pecos-stab-tn/src/stab_mps.rs +++ b/exp/pecos-stab-tn/src/stab_mps.rs @@ -96,6 +96,7 @@ impl StabMpsFlags { const LAZY_MEASURE: u8 = 1 << 1; const MERGE_RZ: u8 = 1 << 2; const PAULI_FRAME_TRACKING: u8 = 1 << 3; + const NUMERICAL_FLAG_REDETECTION: u8 = 1 << 4; /// Default flags: normalize enabled, everything else off. #[must_use] @@ -143,6 +144,13 @@ impl StabMpsFlags { pub fn set_pauli_frame_tracking(&mut self, v: bool) { self.set(Self::PAULI_FRAME_TRACKING, v); } + #[must_use] + pub fn numerical_flag_redetection(self) -> bool { + self.get(Self::NUMERICAL_FLAG_REDETECTION) + } + pub fn set_numerical_flag_redetection(&mut self, v: bool) { + self.set(Self::NUMERICAL_FLAG_REDETECTION, v); + } } impl Default for StabMpsFlags { @@ -325,6 +333,16 @@ impl StabMpsBuilder { self } + /// Numerically recover missing exact-disentangling |0> flags at product sites. + /// + /// When enabled, a failed symbolic flag search checks candidate bond-one MPS + /// tensors using a fixed tolerance of 1e-12. Default: false. + #[must_use] + pub fn numerical_flag_redetection(mut self, enable: bool) -> Self { + self.flags.set_numerical_flag_redetection(enable); + self + } + /// Preset for QEC-style workloads: stabilizer-code circuits with /// non-Clifford noise (T gates, small-angle RZ), syndrome extraction, /// magic-state distillation. @@ -453,6 +471,8 @@ pub struct StabMpsStats { pub single_site: u64, /// Non-Cliffords that fired multi-site disent (tableau right-compose). pub multi_disent: u64, + /// Missing |0> flags recovered numerically at product sites. + pub numerical_redetect: u64, /// Non-Cliffords that fell through to the std multi-site CNOT cascade path. pub multi_std: u64, /// Non-Cliffords that hit the Stabilizer branch (scalar or diagonal). @@ -1803,6 +1823,7 @@ impl StabMps { self.flags.normalize_after_gate(), &mut non_clifford::RzContext { disent_flags: &mut self.disent_flags, + numerical_flag_redetection: self.flags.numerical_flag_redetection(), gf2_matrix: &mut self.gf2_matrix, stats: &mut self.stats, }, diff --git a/exp/pecos-stab-tn/src/stab_mps/non_clifford.rs b/exp/pecos-stab-tn/src/stab_mps/non_clifford.rs index 25c1d7abf..181a7a953 100644 --- a/exp/pecos-stab-tn/src/stab_mps/non_clifford.rs +++ b/exp/pecos-stab-tn/src/stab_mps/non_clifford.rs @@ -104,6 +104,8 @@ enum PauliType { pub struct RzContext<'a> { /// Per-site disentangling eigenstate flags. pub disent_flags: &'a mut [Option], + /// Whether missing |0> flags may be recovered from product-site tensors. + pub numerical_flag_redetection: bool, /// GF(2) flip matrix for OFD diagnostics. pub gf2_matrix: &'a mut super::ofd::Gf2FlipMatrix, /// Running statistics for the STN simulator. @@ -130,6 +132,7 @@ pub fn apply_rz_stab_mps( ) { let RzContext { disent_flags, + numerical_flag_redetection, gf2_matrix, stats, } = ctx; @@ -232,6 +235,18 @@ pub fn apply_rz_stab_mps( break; } } + if disent_site.is_none() && *numerical_flag_redetection { + for &(site, pt) in &pauli_map { + if matches!(pt, PauliType::X | PauliType::Y) + && is_numerical_product_zero_site(mps, site) + { + disent_flags[site] = Some(super::SiteEigenstate::Z(false)); + stats.numerical_redetect += 1; + disent_site = Some(site); + break; + } + } + } } if let Some(rot_site) = disent_site { @@ -547,3 +562,19 @@ pub fn apply_rz_stab_mps( mps.normalize(); } } + +/// A bond-one `[c, 0]` tensor is |0> up to a global phase. The existing +/// fast path applies linear gates without replacing `c`, so the phase remains +/// represented in the MPS. +fn is_numerical_product_zero_site(mps: &Mps, site: usize) -> bool { + const TOLERANCE: f64 = 1e-12; + + if mps.bond_dim(site) != 1 || mps.bond_dim(site + 1) != 1 { + return false; + } + + let tensor = &mps.tensors()[site]; + let c = tensor[(0, 0)]; + let second = tensor[(0, 1)]; + second.norm() <= TOLERANCE && (c.norm() - 1.0).abs() <= TOLERANCE +} diff --git a/exp/pecos-stab-tn/tests/verification.rs b/exp/pecos-stab-tn/tests/verification.rs index 1242e02fd..3e93c9faf 100644 --- a/exp/pecos-stab-tn/tests/verification.rs +++ b/exp/pecos-stab-tn/tests/verification.rs @@ -2199,6 +2199,122 @@ fn test_mast_matches_stn_exact_probabilities_3q() { } } +#[test] +fn test_numerical_flag_redetection_random_probabilities() { + // Mirrors `stab_mps::tests::test_prob_bitstring_random_stress`, with + // numerical flag re-detection enabled and reconstructed state-vector + // probabilities as the oracle. + let t = Angle64::QUARTER_TURN / 2u64; + let eighth_turn = Angle64::QUARTER_TURN / 4u64; + + for seed in 0..30u64 { + let n = 4 + (seed % 3) as usize; + let mut stn = StabMps::builder(n) + .seed(seed) + .numerical_flag_redetection(true) + .build(); + let mut rng_state = 0xDEAD_BEEF ^ seed.wrapping_mul(37); + + for _ in 0..20 { + let op = next_seeded_gate_choice(&mut rng_state) % 5; + let q1 = (next_seeded_gate_choice(&mut rng_state) as usize) % n; + match op { + 0 => { + stn.h(&[QubitId(q1)]); + } + 1 => { + stn.sz(&[QubitId(q1)]); + } + 2 => { + let q2 = (next_seeded_gate_choice(&mut rng_state) as usize) % n; + if q1 != q2 { + stn.cx(&[(QubitId(q1), QubitId(q2))]); + } + } + 3 => { + stn.rz(t, &[QubitId(q1)]); + } + _ => { + stn.rz(eighth_turn, &[QubitId(q1)]); + } + } + } + + let state_vector = stn.state_vector(); + for (idx, amplitude) in state_vector.iter().enumerate() { + let bits = (0..n) + .rev() + .map(|q| ((idx >> q) & 1) != 0) + .collect::>(); + let actual = stn.prob_bitstring(&bits); + let expected = amplitude.norm_sqr(); + assert!( + (actual - expected).abs() <= 1e-12, + "seed={seed} n={n} idx={idx}: actual={actual:.16e} expected={expected:.16e}" + ); + } + } +} + +#[test] +fn test_numerical_flag_redetection_recovers_cancelled_rotation() { + let t = Angle64::QUARTER_TURN / 2u64; + let final_angle = Angle64::from_radians(0.37); + let mut stn = StabMps::builder(2).numerical_flag_redetection(true).build(); + let mut oracle = pecos_simulators::DenseStateVec::new(2); + + stn.h(&[QubitId(0)]); + oracle.h(&[QubitId(0)]); + stn.rz(t, &[QubitId(0)]); + oracle.rz(t, &[QubitId(0)]); + stn.rz(-t, &[QubitId(0)]); + oracle.rz(-t, &[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + oracle.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(final_angle, &[QubitId(1)]); + oracle.rz(final_angle, &[QubitId(1)]); + + assert_eq!(stn.stats.numerical_redetect, 1); + let expected = (0..4) + .map(|idx| oracle.get_amplitude(idx)) + .collect::>(); + assert_states_close( + &stn.state_vector(), + &expected, + 1e-12, + "cancelled rotation re-detection", + ); +} + +#[test] +fn test_numerical_flag_redetection_rejects_nonzero_product_site() { + let t = Angle64::QUARTER_TURN / 2u64; + let final_angle = Angle64::from_radians(0.37); + let mut stn = StabMps::builder(2).numerical_flag_redetection(true).build(); + let mut oracle = pecos_simulators::DenseStateVec::new(2); + + stn.h(&[QubitId(0)]); + oracle.h(&[QubitId(0)]); + stn.rz(t, &[QubitId(0)]); + oracle.rz(t, &[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + oracle.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(final_angle, &[QubitId(1)]); + oracle.rz(final_angle, &[QubitId(1)]); + + assert_eq!(stn.stats.numerical_redetect, 0); + assert_eq!(stn.stats.multi_std, 1); + let expected = (0..4) + .map(|idx| oracle.get_amplitude(idx)) + .collect::>(); + assert_states_close( + &stn.state_vector(), + &expected, + 1e-12, + "nonzero product site rejection", + ); +} + // ============================================================================ // Large bond dimension stress tests // ============================================================================ From 4caa9b6cca3fd374890ef9e46ece61c6f19de7d6 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 15:03:37 -0600 Subject: [PATCH 03/14] Guard numerical flag re-detection off while lazy deferred ops are pending --- exp/pecos-stab-tn/src/stab_mps.rs | 6 +++++- exp/pecos-stab-tn/src/stab_mps/mast.rs | 11 +++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/exp/pecos-stab-tn/src/stab_mps.rs b/exp/pecos-stab-tn/src/stab_mps.rs index ca396332e..36074d7f4 100644 --- a/exp/pecos-stab-tn/src/stab_mps.rs +++ b/exp/pecos-stab-tn/src/stab_mps.rs @@ -1823,7 +1823,11 @@ impl StabMps { self.flags.normalize_after_gate(), &mut non_clifford::RzContext { disent_flags: &mut self.disent_flags, - numerical_flag_redetection: self.flags.numerical_flag_redetection(), + // Redetection reads stored tensors; with pending lazy deferred + // ops the effective state is V * stored MPS, so stored |0> does + // not imply effective |0>. + numerical_flag_redetection: self.flags.numerical_flag_redetection() + && self.deferred_ops.is_empty(), gf2_matrix: &mut self.gf2_matrix, stats: &mut self.stats, }, diff --git a/exp/pecos-stab-tn/src/stab_mps/mast.rs b/exp/pecos-stab-tn/src/stab_mps/mast.rs index 156022ff6..15ff2542a 100644 --- a/exp/pecos-stab-tn/src/stab_mps/mast.rs +++ b/exp/pecos-stab-tn/src/stab_mps/mast.rs @@ -347,7 +347,11 @@ impl Mast { true, &mut non_clifford::RzContext { disent_flags: &mut self.disent_flags, - numerical_flag_redetection: self.numerical_flag_redetection, + // Redetection reads stored tensors; with pending lazy deferred + // ops the effective state is V * stored MPS, so stored |0> does + // not imply effective |0>. + numerical_flag_redetection: self.numerical_flag_redetection + && self.deferred_ops.is_empty(), gf2_matrix: &mut self.gf2_matrix, stats: &mut self.stats, }, @@ -470,7 +474,10 @@ impl Mast { true, &mut non_clifford::RzContext { disent_flags: &mut self.disent_flags, - numerical_flag_redetection: self.numerical_flag_redetection, + // Same stored-vs-effective constraint as the injection + // path above. + numerical_flag_redetection: self.numerical_flag_redetection + && self.deferred_ops.is_empty(), gf2_matrix: &mut self.gf2_matrix, stats: &mut self.stats, }, From 401110acae348465f14e17c8892a5e34ae143c01 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 15:27:22 -0600 Subject: [PATCH 04/14] Expose svd_cutoff in the StabMps Python binding --- python/pecos-rslib-exp/src/stab_mps_bindings.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/pecos-rslib-exp/src/stab_mps_bindings.rs b/python/pecos-rslib-exp/src/stab_mps_bindings.rs index feeb75fea..67ee5ad55 100644 --- a/python/pecos-rslib-exp/src/stab_mps_bindings.rs +++ b/python/pecos-rslib-exp/src/stab_mps_bindings.rs @@ -49,6 +49,7 @@ impl PyStabMps { auto_grow_bond_dim=None, auto_grow_max_bond_dim=None, max_truncation_error=None, + svd_cutoff=None, ))] #[allow(clippy::too_many_arguments)] fn new( @@ -62,6 +63,7 @@ impl PyStabMps { auto_grow_bond_dim: Option, auto_grow_max_bond_dim: Option, max_truncation_error: Option, + svd_cutoff: Option, ) -> Self { let mut b = StabMps::builder(num_qubits); if let Some(s) = seed { @@ -91,6 +93,9 @@ impl PyStabMps { if let Some(e) = max_truncation_error { b = b.max_truncation_error(e); } + if let Some(c) = svd_cutoff { + b = b.svd_cutoff(c); + } PyStabMps { inner: b.build() } } From 2f529ed50543976ec8becd59120be31fc221e17e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 15:52:25 -0600 Subject: [PATCH 05/14] Add prefix-sharing perfect sampling (sample_bitstrings) to StabMps --- .../examples/sampling_methods.rs | 102 ++++++++++++ exp/pecos-stab-tn/src/stab_mps.rs | 123 ++++++++++++++ exp/pecos-stab-tn/tests/verification.rs | 157 ++++++++++++++++++ 3 files changed, 382 insertions(+) create mode 100644 exp/pecos-stab-tn/examples/sampling_methods.rs diff --git a/exp/pecos-stab-tn/examples/sampling_methods.rs b/exp/pecos-stab-tn/examples/sampling_methods.rs new file mode 100644 index 000000000..3fea43dd1 --- /dev/null +++ b/exp/pecos-stab-tn/examples/sampling_methods.rs @@ -0,0 +1,102 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the +// License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing permissions and +// limitations under the License. + +//! Compare per-shot cloning with prefix-sharing perfect bitstring sampling. + +use std::collections::HashSet; +use std::hint::black_box; +use std::time::Instant; + +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::StabMps; + +fn next_seeded(state: &mut u64) -> u64 { + *state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + *state +} + +fn seeded_circuit(num_qubits: usize, t_count: usize, seed: u64) -> StabMps { + let mut order = (0..num_qubits).collect::>(); + let mut random_state = seed; + for i in (1..num_qubits).rev() { + let j = next_seeded(&mut random_state) as usize % (i + 1); + order.swap(i, j); + } + + let mut stn = StabMps::with_seed(num_qubits, seed); + stn.h(&[QubitId(order[0])]); + for pair in order.windows(2) { + stn.cx(&[(QubitId(pair[0]), QubitId(pair[1]))]); + } + + let t = Angle64::QUARTER_TURN / 2u64; + for _ in 0..t_count { + let q = next_seeded(&mut random_state) as usize % num_qubits; + stn.rz(t, &[QubitId(q)]); + let a = next_seeded(&mut random_state) as usize % num_qubits; + let mut b = next_seeded(&mut random_state) as usize % num_qubits; + if a == b { + b = (b + 1) % num_qubits; + } + stn.cz(&[(QubitId(a), QubitId(b))]); + } + stn +} + +fn distinct_internal_prefixes(shots: &[Vec]) -> usize { + let mut prefixes = HashSet::new(); + if let Some(first) = shots.first() { + for shot in shots { + for depth in 0..first.len() { + prefixes.insert(shot[..depth].to_vec()); + } + } + } + prefixes.len() +} + +fn main() { + println!( + "| n | T | shots | sample_bitstring | sample_bitstrings | speedup | distinct internal prefixes |" + ); + println!("|---:|---:|---:|---:|---:|---:|---:|"); + + for num_qubits in [8usize, 12, 16, 20] { + let t_count = num_qubits / 2; + let base = seeded_circuit(num_qubits, t_count, 0x5eed_u64 + num_qubits as u64); + for num_shots in [100usize, 1_000, 10_000] { + let mut legacy = base.clone(); + let start = Instant::now(); + let legacy_shots = legacy.sample_bitstring(num_shots); + let legacy_elapsed = start.elapsed(); + black_box(&legacy_shots); + + let mut prefix = base.clone(); + let start = Instant::now(); + let prefix_shots = prefix.sample_bitstrings(num_shots); + let prefix_elapsed = start.elapsed(); + let distinct_prefixes = distinct_internal_prefixes(&prefix_shots); + black_box(&prefix_shots); + + let legacy_ms = legacy_elapsed.as_secs_f64() * 1_000.0; + let prefix_ms = prefix_elapsed.as_secs_f64() * 1_000.0; + let speedup = legacy_ms / prefix_ms; + println!( + "| {num_qubits} | {t_count} | {num_shots} | {legacy_ms:.3} ms | \ + {prefix_ms:.3} ms | {speedup:.2}x | {distinct_prefixes} |" + ); + } + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps.rs b/exp/pecos-stab-tn/src/stab_mps.rs index 36074d7f4..1407057f5 100644 --- a/exp/pecos-stab-tn/src/stab_mps.rs +++ b/exp/pecos-stab-tn/src/stab_mps.rs @@ -493,6 +493,13 @@ pub struct StabMpsStats { pub ofd_in_span_disent: u64, } +struct PrefixSamplingContext<'a> { + rng: &'a mut PecosRng, + frame_x: &'a [bool], + num_qubits: usize, + output: &'a mut Vec>, +} + impl StabMps { /// Create a builder for configuring the simulator. #[must_use] @@ -1257,6 +1264,122 @@ impl StabMps { shots } + /// Sample `num_shots` bitstrings from the current Born distribution, + /// sharing each distinct measurement-prefix projection across all shots + /// that take that branch. + /// + /// The original simulator state is preserved; only its RNG advances. + /// A working clone first materializes any lazy-measurement frame and then + /// all pending merged RZ rotations. Tracked Pauli X bits remain classical: + /// as in [`Self::sample_bitstring`], they swap reported Z outcomes without + /// changing the stored-state collapse. Pauli Z bits and frame phase do not + /// affect computational-basis probabilities. + /// + /// At each prefix containing `k` shots, a candidate zero child is cloned and + /// passed once through [`measure::project_forced_z`]. Its returned probability + /// drives the split, and the already-projected candidate is reused if the + /// zero branch is inhabited. The one child similarly receives exactly one + /// forced projection on the untouched parent. Thus every descending path + /// follows the same atomic projection sequence as [`Self::prob_bitstring`]. + /// The clamped `p0` is tested against `k` uniforms in branch-local shot order, + /// with all node draws completed before visiting either child. Probabilities + /// below `1e-20`, the forced projector's tolerance, are zero; if either child + /// is zero-probability, the node consumes no RNG draws. + /// + /// Children are visited depth-first, outcome 0 before outcome 1, measuring + /// qubits `0..num_qubits`. Returned bitstrings therefore use the same + /// `bitstring[q] == qubit q` convention as [`Self::sample_bitstring`] and + /// are in lexicographic tree order, with copies of each leaf adjacent. + pub fn sample_bitstrings(&mut self, num_shots: usize) -> Vec> { + if num_shots == 0 { + return Vec::new(); + } + + let mut working = self.clone(); + measure::flush_deferred_ops(&mut working.mps, &mut working.deferred_ops); + working.flush_all_pending_rz(); + let frame_x = if working.flags.pauli_frame_tracking() { + working.pauli_frame_x.clone() + } else { + vec![false; self.num_qubits] + }; + + let mut shots = Vec::with_capacity(num_shots); + let mut prefix = Vec::with_capacity(self.num_qubits); + let mut context = PrefixSamplingContext { + rng: &mut self.rng, + frame_x: &frame_x, + num_qubits: self.num_qubits, + output: &mut shots, + }; + Self::sample_prefix_tree( + &mut working.tableau, + &mut working.mps, + num_shots, + &mut prefix, + &mut context, + ); + shots + } + + fn sample_prefix_tree( + tableau: &mut SparseStabY, + mps: &mut Mps, + num_shots: usize, + prefix: &mut Vec, + context: &mut PrefixSamplingContext<'_>, + ) { + const ZERO_PROBABILITY_TOLERANCE: f64 = 1e-20; + + let q = prefix.len(); + if q == context.num_qubits { + context + .output + .extend(std::iter::repeat_n(prefix.clone(), num_shots)); + return; + } + + // Probability and projection are deliberately atomic. In particular, + // do not pre-reduce the shared parent and then call `project_forced_z`: + // pre-reduction can produce a trivial MPS, and a second entry would take + // the trivial fast path instead of completing the in-progress general + // projection as `prob_bitstring` does. + let mut zero_tableau = tableau.clone(); + let mut zero_mps = mps.clone(); + let probability_zero = + measure::project_forced_z(&mut zero_tableau, &mut zero_mps, q, context.frame_x[q]) + .clamp(0.0, 1.0); + let probability_one = 1.0 - probability_zero; + let num_zero = if probability_zero < ZERO_PROBABILITY_TOLERANCE { + 0 + } else if probability_one < ZERO_PROBABILITY_TOLERANCE { + num_shots + } else { + (0..num_shots) + .filter(|_| context.rng.next_f64() < probability_zero) + .count() + }; + let num_one = num_shots - num_zero; + + if num_zero > 0 { + prefix.push(false); + Self::sample_prefix_tree(&mut zero_tableau, &mut zero_mps, num_zero, prefix, context); + prefix.pop(); + } + + if num_one > 0 { + let projected_probability = + measure::project_forced_z(tableau, mps, q, !context.frame_x[q]); + assert!( + projected_probability > 0.0, + "positive-probability one branch rejected by forced projection at qubit {q}" + ); + prefix.push(true); + Self::sample_prefix_tree(tableau, mps, num_one, prefix, context); + prefix.pop(); + } + } + /// Auto-grow check: if `auto_grow_bond_dim` is enabled and the MPS /// has accumulated truncation error past the threshold AND the cap /// is binding, double `max_bond_dim` (capped at diff --git a/exp/pecos-stab-tn/tests/verification.rs b/exp/pecos-stab-tn/tests/verification.rs index 3e93c9faf..9e802f49a 100644 --- a/exp/pecos-stab-tn/tests/verification.rs +++ b/exp/pecos-stab-tn/tests/verification.rs @@ -2023,6 +2023,163 @@ fn apply_seeded_clifford_t_to_mast(mast: &mut Mast, gates: &[SeededCliffordTGate } } +fn bitstring_counts(shots: &[Vec], num_qubits: usize) -> Vec { + let mut counts = vec![0usize; 1usize << num_qubits]; + for shot in shots { + let outcome = shot + .iter() + .enumerate() + .fold(0usize, |value, (q, &bit)| value | (usize::from(bit) << q)); + counts[outcome] += 1; + } + counts +} + +fn exact_stn_probabilities(stn: &StabMps, num_qubits: usize) -> Vec { + (0..1usize << num_qubits) + .map(|outcome| { + // `prob_bitstring` uses [q_(n-1), ..., q_0], whereas both + // samplers return [q_0, ..., q_(n-1)]. + let bits = (0..num_qubits) + .rev() + .map(|q| ((outcome >> q) & 1) != 0) + .collect::>(); + stn.prob_bitstring(&bits) + }) + .collect() +} + +#[test] +fn test_prefix_tree_sampler_random_clifford_t_distributions() { + // Mirrors the per-outcome five-sigma pattern in + // `test_mast_min_span_matches_stn_exact_random_probabilities`. + let num_shots = 5000usize; + for num_qubits in 3..=5 { + for t_count in 2..=5 { + let circuit_seed = 80_000 + (num_qubits * 100 + t_count) as u64; + let gates = seeded_clifford_t_circuit(num_qubits, t_count, circuit_seed); + let mut state = StabMps::with_seed(num_qubits, circuit_seed); + apply_seeded_clifford_t_to_stn(&mut state, &gates); + state.flush(); + + let exact_probabilities = exact_stn_probabilities(&state, num_qubits); + let total: f64 = exact_probabilities.iter().sum(); + assert!( + (total - 1.0).abs() < 1e-8, + "n={num_qubits} t={t_count}: exact probabilities sum to {total}" + ); + + let state_before = state.state_vector(); + let mut prefix_sampler = state.clone(); + let prefix_shots = prefix_sampler.sample_bitstrings(num_shots); + assert_eq!( + prefix_sampler.state_vector(), + state_before, + "n={num_qubits} t={t_count}: prefix sampler mutated state" + ); + let prefix_counts = bitstring_counts(&prefix_shots, num_qubits); + + let mut legacy_sampler = state.clone(); + let legacy_counts = + bitstring_counts(&legacy_sampler.sample_bitstring(num_shots), num_qubits); + + for (outcome, &exact) in exact_probabilities.iter().enumerate() { + let prefix_sampled = prefix_counts[outcome] as f64 / num_shots as f64; + let legacy_sampled = legacy_counts[outcome] as f64 / num_shots as f64; + let sigma = (exact * (1.0 - exact) / num_shots as f64).sqrt().max(1e-6); + let exact_deviation = (exact - prefix_sampled).abs() / sigma; + assert!( + exact_deviation < 5.0, + "n={num_qubits} t={t_count} outcome={outcome}: exact={exact:.4} \ + prefix={prefix_sampled:.4}, deviation={exact_deviation:.1}σ" + ); + + let agreement_sigma = std::f64::consts::SQRT_2 * sigma; + let agreement_deviation = (legacy_sampled - prefix_sampled).abs() / agreement_sigma; + assert!( + agreement_deviation < 5.0, + "n={num_qubits} t={t_count} outcome={outcome}: legacy={legacy_sampled:.4} \ + prefix={prefix_sampled:.4}, deviation={agreement_deviation:.1}σ" + ); + } + } + } +} + +#[test] +fn test_prefix_tree_sampler_is_seed_deterministic() { + let build = |seed| { + let mut stn = StabMps::with_seed(4, seed); + let gates = seeded_clifford_t_circuit(4, 5, 91_337); + apply_seeded_clifford_t_to_stn(&mut stn, &gates); + stn + }; + let mut first = build(1234); + let mut second = build(1234); + let mut different = build(1235); + let first_shots = first.sample_bitstrings(500); + assert_eq!(first_shots, second.sample_bitstrings(500)); + assert_ne!(first_shots, different.sample_bitstrings(500)); +} + +#[test] +fn test_prefix_tree_sampler_entangled_non_clifford_branches_and_order() { + // A CZ-entangled |++++> state with T phases has all sixteen Z-basis + // leaves populated, so both children are exercised at multiple depths. + let mut stn = StabMps::with_seed(4, 44_321); + stn.h(&[QubitId(0), QubitId(1), QubitId(2), QubitId(3)]); + stn.cz(&[ + (QubitId(0), QubitId(1)), + (QubitId(1), QubitId(2)), + (QubitId(2), QubitId(3)), + ]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0), QubitId(2)]); + let shots = stn.sample_bitstrings(5000); + assert!(shots.windows(2).all(|pair| pair[0] <= pair[1])); + let populated = bitstring_counts(&shots, 4) + .into_iter() + .filter(|&count| count > 0) + .count(); + assert_eq!(populated, 16, "every tree leaf should be populated"); +} + +#[test] +fn test_prefix_tree_sampler_flushes_supported_modes_on_working_clone() { + let mut frame = StabMps::builder(2) + .seed(501) + .pauli_frame_tracking(true) + .build(); + frame.inject_x_in_frame(QubitId(0)); + let frame_shots = frame.sample_bitstrings(32); + assert!(frame_shots.iter().all(|shot| shot == &[true, false])); + assert!(frame.frame_x_bit(QubitId(0)), "caller's frame was mutated"); + + let mut merged = StabMps::builder(2).seed(502).merge_rz(true).build(); + merged.h(&[QubitId(0)]); + merged.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let before = merged.state_vector(); + let merged_shots = merged.sample_bitstrings(200); + assert_eq!( + merged.state_vector(), + before, + "caller's pending RZ state was mutated" + ); + let q0_ones = merged_shots.iter().filter(|shot| shot[0]).count(); + assert!((70..=130).contains(&q0_ones)); + + let mut lazy = StabMps::builder(3).seed(503).lazy_measure(true).build(); + lazy.h(&[QubitId(0), QubitId(1)]); + lazy.cx(&[(QubitId(0), QubitId(2))]); + let _ = lazy.mz(&[QubitId(1)]); + let before = lazy.state_vector(); + let _ = lazy.sample_bitstrings(100); + assert_eq!( + lazy.state_vector(), + before, + "caller's lazy frame state was mutated" + ); +} + #[test] fn test_mast_min_span_matches_stn_exact_random_probabilities() { // Mirrors `test_mast_matches_stn_exact_probabilities_2q`: compare each From 1a4a0574f660637d2073d0d693cdf3e5124a707d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 16:00:05 -0600 Subject: [PATCH 06/14] Expose numerical_flag_redetection in the StabMps Python binding --- python/pecos-rslib-exp/src/stab_mps_bindings.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/pecos-rslib-exp/src/stab_mps_bindings.rs b/python/pecos-rslib-exp/src/stab_mps_bindings.rs index 67ee5ad55..9a662a943 100644 --- a/python/pecos-rslib-exp/src/stab_mps_bindings.rs +++ b/python/pecos-rslib-exp/src/stab_mps_bindings.rs @@ -50,6 +50,7 @@ impl PyStabMps { auto_grow_max_bond_dim=None, max_truncation_error=None, svd_cutoff=None, + numerical_flag_redetection=None, ))] #[allow(clippy::too_many_arguments)] fn new( @@ -64,6 +65,7 @@ impl PyStabMps { auto_grow_max_bond_dim: Option, max_truncation_error: Option, svd_cutoff: Option, + numerical_flag_redetection: Option, ) -> Self { let mut b = StabMps::builder(num_qubits); if let Some(s) = seed { @@ -96,6 +98,9 @@ impl PyStabMps { if let Some(c) = svd_cutoff { b = b.svd_cutoff(c); } + if numerical_flag_redetection == Some(true) { + b = b.numerical_flag_redetection(true); + } PyStabMps { inner: b.build() } } From 28a65a55b69cc425f93f98e06f1579c64c36d407 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 16:01:10 -0600 Subject: [PATCH 07/14] Add typed ExecutionAdvice with injection-mode and ancilla-budget recommendation to StabMpsCompile --- exp/pecos-stab-tn/src/stab_mps/compile.rs | 264 +++++++++++++++++++++- 1 file changed, 262 insertions(+), 2 deletions(-) diff --git a/exp/pecos-stab-tn/src/stab_mps/compile.rs b/exp/pecos-stab-tn/src/stab_mps/compile.rs index 81602231e..bcc0df007 100644 --- a/exp/pecos-stab-tn/src/stab_mps/compile.rs +++ b/exp/pecos-stab-tn/src/stab_mps/compile.rs @@ -43,6 +43,10 @@ pub struct StabMpsCompile { grown: u64, /// Number of non-Cliffords that hit the Stabilizer branch (no MPS site op). stabilizer: u64, + /// Number of non-Clifford RZ gates processed. + nonclifford_rz_total: u64, + /// Number whose deferred-injection correction RZ(2*theta) is Clifford. + injectable_clifford_correction: u64, } impl StabMpsCompile { @@ -56,6 +60,8 @@ impl StabMpsCompile { absorbed: 0, grown: 0, stabilizer: 0, + nonclifford_rz_total: 0, + injectable_clifford_correction: 0, } } @@ -88,6 +94,19 @@ impl StabMpsCompile { self.absorbed + self.grown + self.stabilizer } + /// Total non-Clifford RZ gates processed. + #[must_use] + pub fn nonclifford_rz_total(&self) -> u64 { + self.nonclifford_rz_total + } + + /// Number of non-Clifford RZ gates whose RZ(2*theta) injection + /// correction is Clifford. + #[must_use] + pub fn injectable_clifford_correction(&self) -> u64 { + self.injectable_clifford_correction + } + /// GF(2) nullity = number of flip patterns NOT in the rank. /// Bond dim bound from OFD is 2^nullity. #[must_use] @@ -175,9 +194,89 @@ impl StabMpsCompile { } } + /// Advise a simulator and magic-state injection mode for the accumulated + /// circuit, taking an optional ancilla budget into account. + #[must_use] + pub fn advise(&self, ancilla_budget: Option) -> ExecutionAdvice { + let base = self.recommend(); + let injectable_count = self.injectable_clifford_correction(); + let deferred_ancillas_required = injectable_count; + let deferred_feasible = ancilla_budget.map(|budget| { + usize::try_from(deferred_ancillas_required).is_ok_and(|required| budget >= required) + }); + let mut warnings = Vec::new(); + + let injection = if injectable_count == 0 { + InjectionMode::Direct + } else { + match ancilla_budget { + Some(0) => InjectionMode::Direct, + Some(_) if deferred_feasible == Some(true) => InjectionMode::Deferred, + None => { + warnings.push(format!( + "ancilla budget was unspecified; deferred injection requires \ + {deferred_ancillas_required} fresh ancilla(s)" + )); + InjectionMode::Deferred + } + Some(budget) => { + warnings.push(format!( + "deferred injection needs one fresh ancilla per injectable gate \ + ({deferred_ancillas_required} required); the given budget of {budget} is \ + insufficient" + )); + InjectionMode::Immediate + } + } + }; + + let noninjectable_count = self.nonclifford_rz_total().saturating_sub(injectable_count); + if noninjectable_count > 0 { + warnings.push(format!( + "{noninjectable_count} non-injectable non-Clifford RZ gate(s) have arbitrary \ + angles; deferred projection pays a non-Clifford correction per hit for those \ + gates" + )); + } + + let simulator = if base.kind == SimulatorKind::StabMps + && injectable_count > 0 + && deferred_feasible != Some(false) + { + SimulatorKind::Mast + } else { + base.kind + }; + + let mode_reason = match injection { + InjectionMode::Direct => "direct non-Clifford application advised", + InjectionMode::Immediate => { + "immediate magic-state injection advised with one reusable ancilla" + } + InjectionMode::Deferred => { + "deferred magic-state injection advised to keep the coefficient MPS near bond 1" + } + }; + + ExecutionAdvice { + simulator, + injection, + injectable_count, + deferred_ancillas_required, + deferred_feasible, + warnings, + reason: format!("{}; {mode_reason}", base.reason), + } + } + /// Process one non-Clifford Z-rotation on qubit q. Mirrors the decision /// logic of `non_clifford::apply_rz_stab_mps` but does not modify any MPS. - fn process_rz(&mut self, q: usize) { + fn process_rz(&mut self, theta: Angle64, q: usize) { + self.nonclifford_rz_total += 1; + if is_clifford_rz(theta + theta) { + self.injectable_clifford_correction += 1; + } + let decomp = decompose_z(self.tableau.stabs(), self.tableau.destabs(), q); match decomp { ZDecomposition::Stabilizer { .. } => { @@ -232,6 +331,13 @@ impl StabMpsCompile { } } +fn is_clifford_rz(theta: Angle64) -> bool { + theta == Angle64::ZERO + || theta == Angle64::HALF_TURN + || theta == Angle64::QUARTER_TURN + || theta == Angle64::THREE_QUARTERS_TURN +} + /// Classification of PECOS simulators for dispatch purposes. /// See `StabMpsCompile::recommend`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -251,6 +357,12 @@ pub enum SimulatorKind { /// low-rank (low OFD nullity) circuits and T-heavy circuits with /// adaptive bond-dim. StabMps, + /// Magic-state injection Augmented Stabilizer Tensor Network + /// (`pecos_stab_tn::stab_mps::mast::Mast`). Deferred magic-state + /// injection over the expanded data+ancilla register. Best for + /// Clifford+T-like circuits where deferral keeps the coefficient MPS + /// near bond 1. + Mast, } /// Simulator recommendation with a human-readable reason string. @@ -261,6 +373,36 @@ pub struct SimulatorRecommendation { pub reason: String, } +/// Strategy for applying injectable non-Clifford gates. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InjectionMode { + /// Apply non-Clifford gates directly without magic-state injection. + Direct, + /// Inject immediately using one reusable ancilla. + Immediate, + /// Defer magic-state projections using one fresh ancilla per gate. + Deferred, +} + +/// Typed simulator and injection recommendation from compile-only analysis. +#[derive(Clone, Debug)] +pub struct ExecutionAdvice { + /// Recommended simulator. + pub simulator: SimulatorKind, + /// Recommended non-Clifford injection strategy. + pub injection: InjectionMode, + /// Number of gates with Clifford deferred-injection corrections. + pub injectable_count: u64, + /// Fresh ancillas required for deferred injection. + pub deferred_ancillas_required: u64, + /// Whether the supplied budget supports deferral, or `None` if unspecified. + pub deferred_feasible: Option, + /// Non-fatal qualifications of the advice. + pub warnings: Vec, + /// Human-readable explanation of the recommendation. + pub reason: String, +} + impl QuantumSimulator for StabMpsCompile { fn reset(&mut self) -> &mut Self { self.tableau = SparseStabY::new(self.num_qubits).with_destab_sign_tracking(); @@ -269,6 +411,8 @@ impl QuantumSimulator for StabMpsCompile { self.absorbed = 0; self.grown = 0; self.stabilizer = 0; + self.nonclifford_rz_total = 0; + self.injectable_clifford_correction = 0; self } @@ -327,7 +471,7 @@ impl ArbitraryRotationGateable for StabMpsCompile { continue; } // Non-Clifford: process decomposition. - self.process_rz(q.index()); + self.process_rz(theta, q.index()); } self } @@ -432,4 +576,120 @@ mod tests { r.reason ); } + + #[test] + fn test_compile_counts_injectable_rz_corrections() { + let mut comp = StabMpsCompile::new(8); + let t = Angle64::QUARTER_TURN / 2u64; + let tdg = -t; + + comp.h(&[QubitId(0)]); + comp.rz(t, &[QubitId(0), QubitId(1), QubitId(2)]); + comp.rz(tdg, &[QubitId(3)]); + comp.rz(Angle64::from_radians(0.3), &[QubitId(4), QubitId(5)]); + comp.sz(&[QubitId(6)]); + + assert_eq!(comp.injectable_clifford_correction(), 4); + assert_eq!(comp.nonclifford_rz_total(), 6); + assert_eq!(comp.total_nonclifford(), 6); + + comp.reset(); + assert_eq!(comp.injectable_clifford_correction(), 0); + assert_eq!(comp.nonclifford_rz_total(), 0); + } + + fn compile_t_gates(count: usize) -> StabMpsCompile { + let mut comp = StabMpsCompile::new(20); + let t = Angle64::QUARTER_TURN / 2u64; + for _ in 0..count { + comp.rz(t, &[QubitId(0)]); + } + comp + } + + #[test] + fn test_advise_no_injectables_uses_direct_application() { + let mut comp = StabMpsCompile::new(20); + comp.rz(Angle64::from_radians(0.3), &[QubitId(0)]); + + let advice = comp.advise(Some(4)); + assert_eq!(advice.simulator, SimulatorKind::StabMps); + assert_eq!(advice.injection, InjectionMode::Direct); + assert_eq!(advice.injectable_count, 0); + assert_eq!(advice.deferred_ancillas_required, 0); + assert_eq!(advice.deferred_feasible, Some(true)); + } + + #[test] + fn test_advise_zero_budget_uses_direct_application() { + let advice = compile_t_gates(1).advise(Some(0)); + + assert_eq!(advice.simulator, SimulatorKind::StabMps); + assert_eq!(advice.injection, InjectionMode::Direct); + assert_eq!(advice.deferred_feasible, Some(false)); + } + + #[test] + fn test_advise_unspecified_budget_selects_mast_with_warning() { + let advice = compile_t_gates(2).advise(None); + + assert_eq!(advice.simulator, SimulatorKind::Mast); + assert_eq!(advice.injection, InjectionMode::Deferred); + assert_eq!(advice.injectable_count, 2); + assert_eq!(advice.deferred_ancillas_required, 2); + assert_eq!(advice.deferred_feasible, None); + assert!( + advice + .warnings + .iter() + .any(|warning| warning.contains("budget was unspecified")) + ); + assert!(advice.reason.contains("low OFD nullity")); + assert!(advice.reason.contains("STN bond dim bound")); + } + + #[test] + fn test_advise_sufficient_budget_selects_mast() { + let advice = compile_t_gates(2).advise(Some(2)); + + assert_eq!(advice.simulator, SimulatorKind::Mast); + assert_eq!(advice.injection, InjectionMode::Deferred); + assert_eq!(advice.deferred_feasible, Some(true)); + assert!(advice.warnings.is_empty()); + } + + #[test] + fn test_advise_insufficient_budget_uses_immediate_injection() { + let advice = compile_t_gates(2).advise(Some(1)); + + assert_eq!(advice.simulator, SimulatorKind::StabMps); + assert_eq!(advice.injection, InjectionMode::Immediate); + assert_eq!(advice.deferred_feasible, Some(false)); + assert!(advice.warnings.iter().any(|warning| { + warning.contains("one fresh ancilla per injectable gate") + && warning.contains("budget of 1 is insufficient") + })); + } + + #[test] + fn test_advise_warns_about_arbitrary_angle_corrections() { + let mut comp = compile_t_gates(1); + comp.rz(Angle64::from_radians(0.3), &[QubitId(1)]); + + let advice = comp.advise(Some(1)); + assert!(advice.warnings.iter().any(|warning| { + warning.contains("non-injectable non-Clifford RZ") + && warning.contains("non-Clifford correction per hit") + })); + } + + #[test] + fn test_advise_preserves_non_stn_base_simulator() { + let mut comp = StabMpsCompile::new(8); + comp.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + + let advice = comp.advise(Some(1)); + assert_eq!(advice.simulator, SimulatorKind::StateVector); + assert_eq!(advice.injection, InjectionMode::Deferred); + } } From dcaab1ecd763be5cb6144f198aa5ff2aaa4fa3a4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 16:36:08 -0600 Subject: [PATCH 08/14] Expose sample_bitstrings (prefix-sharing sampling) in the StabMps Python binding --- python/pecos-rslib-exp/src/stab_mps_bindings.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/python/pecos-rslib-exp/src/stab_mps_bindings.rs b/python/pecos-rslib-exp/src/stab_mps_bindings.rs index 9a662a943..8c8d566c9 100644 --- a/python/pecos-rslib-exp/src/stab_mps_bindings.rs +++ b/python/pecos-rslib-exp/src/stab_mps_bindings.rs @@ -305,6 +305,13 @@ impl PyStabMps { self.inner.sample_bitstring(num_shots) } + /// Prefix-sharing perfect sampling: shares each distinct measurement-prefix + /// projection across all shots taking that branch. Output is in + /// lexicographic tree order, not per-shot order. + fn sample_bitstrings(&mut self, num_shots: usize) -> Vec> { + self.inner.sample_bitstrings(num_shots) + } + // ---- Gate dispatch (matches pecos-rslib pattern) ---- #[pyo3(signature = (symbol, location, params=None))] From bf4228c7d395ce5b380b9393319e9294697aa3d5 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 17:01:43 -0600 Subject: [PATCH 09/14] Make the qec-grade configuration the general default; MinSpan default for Mast; tri-state Python binding --- exp/pecos-stab-tn/examples/qec_bench.rs | 8 +- exp/pecos-stab-tn/examples/qec_tutorial.rs | 6 +- exp/pecos-stab-tn/src/lib.rs | 9 ++ exp/pecos-stab-tn/src/mps.rs | 56 ++++++++- exp/pecos-stab-tn/src/stab_mps.rs | 110 ++++++++++-------- exp/pecos-stab-tn/src/stab_mps/mast.rs | 52 +++++++-- exp/pecos-stab-tn/tests/verification.rs | 96 ++++++++++----- python/pecos-rslib-exp/src/mast_bindings.rs | 32 ++++- .../pecos-rslib-exp/src/stab_mps_bindings.rs | 23 ++-- python/pecos-rslib-exp/src/stabmps_builder.rs | 16 +-- 10 files changed, 290 insertions(+), 118 deletions(-) diff --git a/exp/pecos-stab-tn/examples/qec_bench.rs b/exp/pecos-stab-tn/examples/qec_bench.rs index 2ece57421..b2509a254 100644 --- a/exp/pecos-stab-tn/examples/qec_bench.rs +++ b/exp/pecos-stab-tn/examples/qec_bench.rs @@ -207,8 +207,8 @@ fn main() { println!("{:-<90}", ""); let configs: &[(&str, bool, Option, bool)] = &[ - ("default", false, None, false), - ("lazy_measure", true, None, false), + ("legacy defaults", false, Some(0.0), false), + ("legacy + lazy_measure", true, Some(0.0), false), ("max_truncation_error=1e-8", false, Some(1e-8), false), ("merge_rz", false, None, true), ( @@ -217,7 +217,7 @@ fn main() { Some(1e-8), true, ), - ("for_qec()", false, Some(1e-8), true), + ("general defaults (cap pinned)", false, Some(1e-8), true), ]; for &(name, lazy, trunc, merge) in configs { @@ -277,7 +277,7 @@ fn main() { println!("{:<30} {:>12} {:>12}", "config", "time (s)", "max bond"); println!("{:-<70}", ""); let small_angle = Angle64::from_radians(0.01); - for (name, merge) in [("default", false), ("merge_rz", true)] { + for (name, merge) in [("merge_rz=off", false), ("merge_rz=on (default)", true)] { let (t, b) = ion_trap_memory_scenario(6, 10, 50, small_angle, merge, 42); println!("{name:<30} {t:>12.4} {b:>12}"); } diff --git a/exp/pecos-stab-tn/examples/qec_tutorial.rs b/exp/pecos-stab-tn/examples/qec_tutorial.rs index f0fd6dc37..57dfbd2f9 100644 --- a/exp/pecos-stab-tn/examples/qec_tutorial.rs +++ b/exp/pecos-stab-tn/examples/qec_tutorial.rs @@ -40,13 +40,13 @@ fn main() { // 1. Builder + preset // ------------------------------------------------------------------ // - // `StabMps::builder(n).for_qec().build()` sets: + // `StabMps::builder(n).build()` and the source-compatible `for_qec()` set: // - max_bond_dim = 128 (enough for syndrome rounds without truncation) // - max_truncation_error = 1e-8 (very tight) // - merge_rz = true (batch same-qubit RZ noise) // - // For ion-trap-memory-noise or T-heavy workloads this is the right - // default. You can layer `pauli_frame_tracking(true)` on top for + // For ion-trap-memory-noise or T-heavy workloads these are the general + // defaults. You can layer `pauli_frame_tracking(true)` on top for // fast Pauli-noise injection. // // 3 data + 2 ancillas = 5 qubits total. diff --git a/exp/pecos-stab-tn/src/lib.rs b/exp/pecos-stab-tn/src/lib.rs index 9e1b92d40..59c9cfc0f 100644 --- a/exp/pecos-stab-tn/src/lib.rs +++ b/exp/pecos-stab-tn/src/lib.rs @@ -19,6 +19,15 @@ //! - **STN**: Stabilizer Tensor Networks (tableau + MPS coefficients) //! - **MAST**: Magic state injection Augmented STN (deferred non-Clifford cost) //! +//! # Default configuration +//! +//! `StabMps` defaults to a maximum bond dimension of 128, a maximum relative +//! truncation error of `1e-8`, an SVD cutoff of `1e-12`, normalization after +//! non-Clifford gates, and merged same-qubit RZ rotations. Lazy measurement, +//! Pauli-frame tracking, and numerical flag redetection remain opt-in. To +//! recover the former behavior explicitly, use `max_bond_dim(64)`, +//! `max_truncation_error(0.0)`, and `merge_rz(false)` on the builder. +//! //! # References //! //! - Masot-Llima, Garcia-Saez. "Stabilizer Tensor Networks: Universal Quantum Simulator diff --git a/exp/pecos-stab-tn/src/mps.rs b/exp/pecos-stab-tn/src/mps.rs index f73ca4e20..df9feefdc 100644 --- a/exp/pecos-stab-tn/src/mps.rs +++ b/exp/pecos-stab-tn/src/mps.rs @@ -47,7 +47,8 @@ pub struct MpsConfig { /// (sum of discarded `s_i^2` / sum of all `s_i^2`) exceeds this threshold. /// This allows low-entanglement bonds to use small chi (fast) while /// high-entanglement bonds grow up to `max_bond_dim` (accurate). - /// None = disabled (fixed `max_bond_dim` only). + /// `None` or a value of zero disables adaptive truncation; only + /// `svd_cutoff` and `max_bond_dim` can then discard singular values. pub max_truncation_error: Option, /// Use rayon for parallelizing independent MPS operations. pub parallel: bool, @@ -56,9 +57,9 @@ pub struct MpsConfig { impl Default for MpsConfig { fn default() -> Self { Self { - max_bond_dim: 64, + max_bond_dim: 128, svd_cutoff: 1e-12, - max_truncation_error: None, + max_truncation_error: Some(1e-8), parallel: false, } } @@ -770,6 +771,55 @@ mod tests { use super::*; use approx::assert_relative_eq; + #[test] + fn test_default_config_values() { + let config = MpsConfig::default(); + assert_eq!(config.max_bond_dim, 128); + assert_relative_eq!(config.svd_cutoff, 1e-12); + assert_eq!(config.max_truncation_error, Some(1e-8)); + assert!(!config.parallel); + } + + #[test] + fn test_zero_max_truncation_error_disables_adaptive_compression() { + let mut zero = Mps::new(2, MpsConfig::default()); + let mut small_branch = Mps::new(2, MpsConfig::default()); + let x = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ); + small_branch.apply_one_site_gate(0, &x).unwrap(); + small_branch.apply_one_site_gate(1, &x).unwrap(); + small_branch.scale(Complex64::new(0.01, 0.0)); + zero = zero.add(&small_branch); + zero.config.max_bond_dim = 128; + zero.config.svd_cutoff = 0.0; + zero.config.max_truncation_error = Some(0.0); + + let mut adaptive = zero.clone(); + adaptive.config.max_truncation_error = Some(1e-3); + let mut cutoff_limited = zero.clone(); + cutoff_limited.config.svd_cutoff = 0.1; + let mut cap_limited = zero.clone(); + cap_limited.config.max_bond_dim = 1; + + zero.compress(); + adaptive.compress(); + cutoff_limited.compress(); + cap_limited.compress(); + + assert_eq!(zero.bond_dim(1), 2, "zero must retain positive weight"); + assert_eq!(adaptive.bond_dim(1), 1, "positive budget may discard it"); + assert_eq!(cutoff_limited.bond_dim(1), 1, "SVD cutoff remains active"); + assert_eq!(cap_limited.bond_dim(1), 1, "bond cap remains active"); + } + #[test] fn test_new_is_all_zeros_state() { let mps = Mps::new(3, MpsConfig::default()); diff --git a/exp/pecos-stab-tn/src/stab_mps.rs b/exp/pecos-stab-tn/src/stab_mps.rs index 1407057f5..7a1e1c891 100644 --- a/exp/pecos-stab-tn/src/stab_mps.rs +++ b/exp/pecos-stab-tn/src/stab_mps.rs @@ -98,10 +98,10 @@ impl StabMpsFlags { const PAULI_FRAME_TRACKING: u8 = 1 << 3; const NUMERICAL_FLAG_REDETECTION: u8 = 1 << 4; - /// Default flags: normalize enabled, everything else off. + /// Default flags: normalization and RZ merging enabled, everything else off. #[must_use] pub const fn new() -> Self { - Self(Self::NORMALIZE_AFTER_GATE) + Self(Self::NORMALIZE_AFTER_GATE | Self::MERGE_RZ) } fn get(self, bit: u8) -> bool { @@ -176,7 +176,7 @@ impl StabMpsBuilder { /// Maximum MPS bond dimension. Singular values beyond this are discarded /// during SVD truncation after two-site gates. /// - /// - Default: 64 + /// - Default: 128 /// - Higher values give more accuracy at the cost of memory and time /// - For n qubits, the exact max is 2^(n/2) #[must_use] @@ -219,8 +219,10 @@ impl StabMpsBuilder { /// while bonds with high entanglement grow up to `max_bond_dim` (accurate). /// The discarded weight at each SVD stays below this fraction. /// - /// - Default: None (disabled, use fixed `max_bond_dim` only) - /// - Typical values: 1e-6 to 1e-3 + /// - Default: 1e-8 + /// - Typical values: 1e-8 to 1e-3 + /// - `0.0` disables the adaptive bound: no positive discarded weight is + /// permitted, so only `svd_cutoff` and `max_bond_dim` truncate /// - `max_bond_dim` still acts as a hard cap #[must_use] pub fn max_truncation_error(mut self, error: f64) -> Self { @@ -270,7 +272,7 @@ impl StabMpsBuilder { /// `auto_grow_max_bond_dim`, default 4096). /// /// Removes the manual tuning step for deep T-heavy circuits where - /// the default cap of 64 is insufficient. Cost: rebuild bond + /// the default cap of 128 is insufficient. Cost: rebuild bond /// allocation on growth (rare). Benefit: avoids surprise truncation /// when entanglement spikes. /// @@ -321,7 +323,7 @@ impl StabMpsBuilder { /// models where every idle qubit receives a small RZ each time step: /// adjacent idle rounds merge into one non-Clifford op. /// - /// - Default: false. + /// - Default: true. /// - Semantics: strictly equivalent to applying each `rz` individually /// (tableau and MPS paths both reduce non-Clifford count). No /// accuracy trade-off. @@ -343,17 +345,10 @@ impl StabMpsBuilder { self } - /// Preset for QEC-style workloads: stabilizer-code circuits with - /// non-Clifford noise (T gates, small-angle RZ), syndrome extraction, - /// magic-state distillation. + /// QEC-style preset retained for source compatibility. /// - /// Sets: - /// - `max_truncation_error(1e-8)` — adaptive bond dim; bonds with low - /// entanglement shrink naturally, saving time on deep circuits. - /// - Keeps `lazy_measure = false` — benchmarks (see `examples/qec_bench.rs`) - /// show the default eager path is faster for typical QEC workloads. - /// - `max_bond_dim(128)` — 2× the library default, giving more headroom - /// for adversarial T-heavy subcircuits before truncation hits the cap. + /// This now matches the general defaults: `max_bond_dim(128)`, + /// `max_truncation_error(1e-8)`, `merge_rz(true)`, and eager measurement. /// /// Override any of these with subsequent builder calls: /// ``` @@ -367,7 +362,7 @@ impl StabMpsBuilder { self.for_qec_with_bond_dim(128) } - /// Like `for_qec()` but with a caller-chosen `max_bond_dim` cap. + /// Like the general defaults but with a caller-chosen `max_bond_dim` cap. /// Use when the default 128 is too tight (deep T-heavy circuits) /// or too loose (memory-constrained environments). #[must_use] @@ -507,9 +502,9 @@ impl StabMps { StabMpsBuilder { num_qubits, seed: None, - max_bond_dim: 64, + max_bond_dim: 128, svd_cutoff: 1e-12, - max_truncation_error: None, + max_truncation_error: Some(1e-8), parallel: false, auto_grow_bond_dim: None, auto_grow_max_bond_dim: 4096, @@ -2232,7 +2227,7 @@ mod tests { fn test_gf2_diagnostic_single_t() { // Single T gate: 1 non-Clifford gate, flip pattern has rank 1 // Theoretical min bond dim = 2^(1-1) = 1 - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); assert_eq!(stn.gf2_matrix().num_gates(), 1); @@ -2243,7 +2238,7 @@ mod tests { #[test] fn test_gf2_diagnostic_two_independent_t() { // Two T gates on independent qubits: rank 2, min bond dim = 1 - let mut stn = StabMps::new(4); + let mut stn = StabMps::builder(4).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.h(&[QubitId(2)]); stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); @@ -2256,7 +2251,7 @@ mod tests { #[test] fn test_gf2_diagnostic_entangled_t() { // Entangled state + T gates: check GF(2) tracking works - let mut stn = StabMps::new(3); + let mut stn = StabMps::builder(3).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.cx(&[(QubitId(0), QubitId(1))]); stn.cx(&[(QubitId(1), QubitId(2))]); @@ -2276,7 +2271,7 @@ mod tests { #[test] fn test_gf2_stabilizer_case_not_tracked() { // T on |0⟩: Z_0 is a stabilizer, no flip sites, not tracked in GF(2) matrix - let mut stn = StabMps::new(1); + let mut stn = StabMps::builder(1).merge_rz(false).build(); stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); assert_eq!(stn.gf2_matrix().num_gates(), 0); // Stabilizer case: no flip } @@ -2289,7 +2284,7 @@ mod tests { fn test_disentangle_single_site_case() { use pecos_simulators::DenseStateVec; let theta = Angle64::from_radians(0.7); - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); let mut ref_sim = DenseStateVec::new(2); stn.h(&[QubitId(0)]); @@ -2323,7 +2318,7 @@ mod tests { fn test_disentangle_multi_site_bell_plus_rz() { use pecos_simulators::DenseStateVec; let theta = Angle64::from_radians(0.7); - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); let mut ref_sim = DenseStateVec::new(2); stn.h(&[QubitId(0)]); @@ -2362,7 +2357,7 @@ mod tests { fn test_disentangle_yy_rotation() { use pecos_simulators::DenseStateVec; let theta = Angle64::from_radians(0.3); - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); let mut ref_sim = DenseStateVec::new(2); // Construct a state where RZ decomposition has pauli_map=[(0,Y),(1,Y)] @@ -2425,7 +2420,7 @@ mod tests { fn test_737_step14_std_only() { use pecos_simulators::DenseStateVec; let q = |i: usize| QubitId(i); - let mut stn = StabMps::new(4); + let mut stn = StabMps::builder(4).merge_rz(false).build(); let mut ref_sim = DenseStateVec::new(4); let apply = |stn: &mut StabMps, r: &mut DenseStateVec, step: usize| match step { 0 => { @@ -2519,7 +2514,7 @@ mod tests { #[test] fn test_span_decomposition_on_real_simulation() { let q = |i: usize| QubitId(i); - let mut stn = StabMps::with_seed(3, 42); + let mut stn = StabMps::builder(3).seed(42).merge_rz(false).build(); // Bring qubits out of Z-eigenstate so T decomposes via DestabilizerFlip. stn.h(&[q(0), q(1), q(2)]); stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); @@ -4002,7 +3997,7 @@ mod tests { let q = |i: usize| QubitId(i); // Case 1: 5q, all T on distinct qubits after H -> nullity=0, bond=1. - let mut stn = StabMps::with_seed(5, 1); + let mut stn = StabMps::builder(5).seed(1).merge_rz(false).build(); stn.h(&[q(0), q(1), q(2), q(3), q(4)]); for i in 0..5 { stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(i)]); @@ -4012,7 +4007,7 @@ mod tests { // Case 2: 3q, same qubit T'd multiple times with Cliffords between // -> some dependencies, nullity > 0, bond > 1. - let mut stn2 = StabMps::with_seed(3, 2); + let mut stn2 = StabMps::builder(3).seed(2).merge_rz(false).build(); stn2.h(&[q(0), q(1), q(2)]); // Build dependencies: T on q0, CNOT(0,1), T on q1 (depends on q0's pattern?) // Force bond dim to grow by interleaving differently. @@ -4040,7 +4035,7 @@ mod tests { #[test] fn test_ofd_analysis_api() { let q = |i: usize| QubitId(i); - let mut stn = StabMps::with_seed(5, 42); + let mut stn = StabMps::builder(5).seed(42).merge_rz(false).build(); // H on all, then T's interspersed with CNOTs. stn.h(&[q(0), q(1), q(2), q(3), q(4)]); stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); @@ -4239,7 +4234,7 @@ mod tests { fn test_trace_seed_107_disent() { let q0 = QubitId(0); let q1 = QubitId(1); - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); stn.cx(&[(q0, q1)]); stn.sz(&[q1]); stn.rz(Angle64::from_radians(4.8946), &[q1]); @@ -4311,7 +4306,7 @@ mod tests { use pecos_simulators::DenseStateVec; let q0 = QubitId(0); let q1 = QubitId(1); - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); let mut ref_sim = DenseStateVec::new(2); let apply_both = |_stn: &mut StabMps, @@ -4696,7 +4691,7 @@ mod tests { // Each Rz has a single flip site (no entangling gate between them). // Disentangling fires on both, recording single-site patterns. let theta = Angle64::from_radians(0.3); - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.h(&[QubitId(1)]); @@ -4737,14 +4732,14 @@ mod tests { #[test] fn test_stn_t_gate_on_zero() { - let mut stn = StabMps::new(1); + let mut stn = StabMps::builder(1).merge_rz(false).build(); stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); // T = RZ(pi/4) assert_eq!(stn.max_bond_dim(), 1); } #[test] fn test_stn_t_gate_on_plus() { - let mut stn = StabMps::new(1); + let mut stn = StabMps::builder(1).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); // T gate assert_eq!(stn.max_bond_dim(), 1); @@ -4753,7 +4748,7 @@ mod tests { #[test] fn test_stn_multiple_t_gates() { - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.cx(&[(QubitId(0), QubitId(1))]); stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); @@ -4811,7 +4806,7 @@ mod tests { #[test] fn test_cross_validate_t_on_plus() { // H then T on single qubit - let mut stn = StabMps::new(1); + let mut stn = StabMps::builder(1).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); let stn_sv = stn.state_vector(); @@ -4827,7 +4822,7 @@ mod tests { #[test] fn test_cross_validate_t_on_zero() { // T on |0> - let mut stn = StabMps::new(1); + let mut stn = StabMps::builder(1).merge_rz(false).build(); stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); let stn_sv = stn.state_vector(); @@ -4841,7 +4836,7 @@ mod tests { #[test] fn test_cross_validate_bell_plus_t() { // Bell state then T on q0 - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.cx(&[(QubitId(0), QubitId(1))]); stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); @@ -4860,7 +4855,7 @@ mod tests { fn test_cross_validate_rz_arbitrary_angle() { // RZ at non-Clifford, non-T angle let theta = Angle64::from_radians(1.234); - let mut stn = StabMps::new(1); + let mut stn = StabMps::builder(1).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.rz(theta, &[QubitId(0)]); let stn_sv = stn.state_vector(); @@ -4877,7 +4872,7 @@ mod tests { fn test_cross_validate_multiple_rz() { // H, T, H, T on single qubit (two non-Clifford layers) let t_angle = Angle64::QUARTER_TURN / 2u64; - let mut stn = StabMps::new(1); + let mut stn = StabMps::builder(1).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.rz(t_angle, &[QubitId(0)]); stn.h(&[QubitId(0)]); @@ -4898,7 +4893,7 @@ mod tests { fn test_cross_validate_two_t_gates_2qubit() { // Two T gates on different qubits: H(0), H(1), T(0), T(1) let t_angle = Angle64::QUARTER_TURN / 2u64; - let mut stn = StabMps::new(2); + let mut stn = StabMps::builder(2).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.h(&[QubitId(1)]); stn.rz(t_angle, &[QubitId(0)]); @@ -4919,7 +4914,7 @@ mod tests { fn test_cross_validate_3qubit_circuit() { // 3-qubit circuit with Cliffords and T gates let t_angle = Angle64::QUARTER_TURN / 2u64; - let mut stn = StabMps::new(3); + let mut stn = StabMps::builder(3).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.cx(&[(QubitId(0), QubitId(1))]); stn.rz(t_angle, &[QubitId(0)]); @@ -6304,7 +6299,7 @@ mod tests { let sv_merged = merged.state_vector(); let nc_merged = merged.stats.total_nonclifford; - let mut eager = StabMps::with_seed(2, 7); + let mut eager = StabMps::builder(2).seed(7).merge_rz(false).build(); eager.h(&[QubitId(0)]); eager.rz(t, &[QubitId(0)]); eager.z(&[QubitId(0)]); @@ -6364,7 +6359,7 @@ mod tests { // applying RZ(t) twice. Compare state vectors. let t = Angle64::QUARTER_TURN / 2u64; // T - let mut eager = StabMps::with_seed(2, 7); + let mut eager = StabMps::builder(2).seed(7).merge_rz(false).build(); eager.h(&[QubitId(0)]); eager.cx(&[(QubitId(0), QubitId(1))]); eager.rz(t, &[QubitId(0)]); @@ -6397,7 +6392,7 @@ mod tests { merged.flush(); let sv_merged = merged.state_vector(); - let mut eager = StabMps::with_seed(2, 11); + let mut eager = StabMps::builder(2).seed(11).merge_rz(false).build(); eager.h(&[QubitId(0)]); eager.rz(t, &[QubitId(0)]); eager.h(&[QubitId(1)]); @@ -6425,7 +6420,7 @@ mod tests { merged.flush(); let sv_merged = merged.state_vector(); - let mut eager = StabMps::with_seed(2, 13); + let mut eager = StabMps::builder(2).seed(13).merge_rz(false).build(); eager.h(&[QubitId(0)]); eager.rz(t, &[QubitId(0)]); eager.h(&[QubitId(0)]); @@ -6456,7 +6451,7 @@ mod tests { "two T merging to S should hit Clifford fast path, not non-Clifford" ); - let mut eager = StabMps::with_seed(2, 17); + let mut eager = StabMps::builder(2).seed(17).merge_rz(false).build(); eager.h(&[QubitId(0)]); eager.rz(t, &[QubitId(0)]); eager.rz(t, &[QubitId(0)]); @@ -6471,6 +6466,21 @@ mod tests { } } + #[test] + fn test_builder_default_values() { + let stn = StabMps::builder(4).build(); + + assert_eq!(stn.config.max_bond_dim, 128); + assert_relative_eq!(stn.config.svd_cutoff, 1e-12); + assert_eq!(stn.config.max_truncation_error, Some(1e-8)); + assert!(!stn.config.parallel); + assert!(stn.flags.normalize_after_gate()); + assert!(!stn.flags.lazy_measure()); + assert!(stn.flags.merge_rz()); + assert!(!stn.flags.pauli_frame_tracking()); + assert!(!stn.flags.numerical_flag_redetection()); + } + #[test] fn test_builder_for_qec_preset() { // Smoke test: the preset should build a working StabMps and handle diff --git a/exp/pecos-stab-tn/src/stab_mps/mast.rs b/exp/pecos-stab-tn/src/stab_mps/mast.rs index 15ff2542a..d460982cf 100644 --- a/exp/pecos-stab-tn/src/stab_mps/mast.rs +++ b/exp/pecos-stab-tn/src/stab_mps/mast.rs @@ -42,10 +42,10 @@ use super::non_clifford; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ProjectionOrder { /// Collapse in reverse injection order, preserving the original MAST behavior. - #[default] Input, /// Recompute MPS-frame locality before every collapse and choose the /// smallest `(span, support size, injection index)` tuple. + #[default] MinSpan, } @@ -214,8 +214,9 @@ impl Mast { /// Select the ordering policy for deferred ancilla projections. /// - /// The default is [`ProjectionOrder::Input`], which preserves reverse - /// injection order and its RNG-consumption sequence. + /// The default is [`ProjectionOrder::MinSpan`]. Select + /// [`ProjectionOrder::Input`] to preserve the legacy reverse-injection + /// order and its RNG-consumption sequence. #[must_use] pub fn projection_order(mut self, projection_order: ProjectionOrder) -> Self { self.projection_order = projection_order; @@ -687,19 +688,37 @@ mod tests { mast.rz(t, &[QubitId(0)]); } + fn project_all_legacy(mast: &mut Mast) { + let deferred: Vec = mast.deferred.drain(..).rev().collect(); + for dm in deferred { + let (support_size, mps_span) = mast.projection_locality(dm.ancilla); + mast.project_deferred(dm, support_size, mps_span); + } + } + #[test] - fn test_mast_explicit_input_preserves_default_projection_path() { + fn test_mast_default_is_min_span_and_input_preserves_legacy_path() { let mut default_path = Mast::with_seed(3, 4, 0x51_7a); + assert_eq!(default_path.projection_order, ProjectionOrder::MinSpan); + assert_eq!(default_path.mps().config().max_bond_dim, 128); + assert_eq!(default_path.mps().config().max_truncation_error, Some(1e-8)); + let mut explicit_min_span = + Mast::with_seed(3, 4, 0x51_7a).projection_order(ProjectionOrder::MinSpan); let mut explicit_input = Mast::with_seed(3, 4, 0x51_7a).projection_order(ProjectionOrder::Input); + let mut legacy_path = Mast::with_seed(3, 4, 0x51_7a); apply_seeded_projection_regression_circuit(&mut default_path); + apply_seeded_projection_regression_circuit(&mut explicit_min_span); apply_seeded_projection_regression_circuit(&mut explicit_input); + apply_seeded_projection_regression_circuit(&mut legacy_path); default_path.project_all(); + explicit_min_span.project_all(); explicit_input.project_all(); + project_all_legacy(&mut legacy_path); assert_eq!( - default_path + explicit_input .projection_records() .iter() .map(|record| record.ancilla) @@ -708,7 +727,15 @@ mod tests { ); assert_eq!( default_path.mps().state_vector(), - explicit_input.mps().state_vector() + explicit_min_span.mps().state_vector() + ); + assert_eq!( + explicit_input.projection_records(), + legacy_path.projection_records() + ); + assert_eq!( + explicit_input.mps().state_vector(), + legacy_path.mps().state_vector() ); let default_outcomes: Vec = default_path @@ -716,12 +743,23 @@ mod tests { .into_iter() .map(|result| result.outcome) .collect(); + let min_span_outcomes: Vec = explicit_min_span + .mz(&[QubitId(0), QubitId(1), QubitId(2)]) + .into_iter() + .map(|result| result.outcome) + .collect(); let input_outcomes: Vec = explicit_input .mz(&[QubitId(0), QubitId(1), QubitId(2)]) .into_iter() .map(|result| result.outcome) .collect(); - assert_eq!(default_outcomes, input_outcomes); + let legacy_outcomes: Vec = legacy_path + .mz(&[QubitId(0), QubitId(1), QubitId(2)]) + .into_iter() + .map(|result| result.outcome) + .collect(); + assert_eq!(default_outcomes, min_span_outcomes); + assert_eq!(input_outcomes, legacy_outcomes); } #[test] diff --git a/exp/pecos-stab-tn/tests/verification.rs b/exp/pecos-stab-tn/tests/verification.rs index 9e802f49a..f32c0bbc9 100644 --- a/exp/pecos-stab-tn/tests/verification.rs +++ b/exp/pecos-stab-tn/tests/verification.rs @@ -16,7 +16,7 @@ use num_complex::Complex64; use pecos_core::{Angle64, QubitId}; use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, QuantumSimulator, StabVec}; use pecos_stab_tn::stab_mps::StabMps; -use pecos_stab_tn::stab_mps::mast::{Mast, ProjectionOrder}; +use pecos_stab_tn::stab_mps::mast::Mast; /// Check that two state vectors match up to global phase. fn assert_states_match(sv_a: &[Complex64], sv_b: &[Complex64], label: &str) { @@ -53,7 +53,7 @@ fn run_circuit_on_both( gates: &[(&str, Vec, Option)], seed: u64, ) -> (Vec, Vec) { - let mut stn = StabMps::with_seed(n, seed); + let mut stn = StabMps::builder(n).seed(seed).merge_rz(false).build(); let mut crz = StabVec::builder(n).seed(seed).build(); for (gate, qubits, angle) in gates { @@ -332,7 +332,7 @@ fn test_mast_vs_stn_measurement_statistics() { #[test] fn test_bond_dim_growth_with_t_gates() { // Track bond dimension as T gates accumulate - let mut stn = StabMps::new(6); + let mut stn = StabMps::builder(6).max_bond_dim(64).merge_rz(false).build(); for q in 0..6 { stn.h(&[QubitId(q)]); } @@ -365,7 +365,10 @@ fn fuzz_circuit(num_qubits: usize, num_gates: usize, seed: u64) { } fn fuzz_circuit_with_tol(num_qubits: usize, num_gates: usize, seed: u64, tol: f64) { - let mut stn = StabMps::with_seed(num_qubits, seed); + let mut stn = StabMps::builder(num_qubits) + .seed(seed) + .merge_rz(false) + .build(); // Use DenseStateVec as reference (not CRZ, which has frame optimization issues with CZ) let mut crz = pecos_simulators::DenseStateVec::new(num_qubits); @@ -448,7 +451,10 @@ fn fuzz_circuit_with_tol(num_qubits: usize, num_gates: usize, seed: u64, tol: f6 .sum(); if (overlap.norm_sqr() - 1.0).abs() > tol { // Re-run step-by-step to find the divergence point - let mut stn2 = StabMps::with_seed(num_qubits, seed); + let mut stn2 = StabMps::builder(num_qubits) + .seed(seed) + .merge_rz(false) + .build(); let mut dsv2 = pecos_simulators::DenseStateVec::new(num_qubits); let mut rng2 = seed; let next2 = |state: &mut u64| -> u64 { @@ -555,7 +561,7 @@ fn test_fuzz_2qubit_circuits() { fn test_fuzz_seed_115_mps_check() { let q0 = QubitId(0); let q1 = QubitId(1); - let mut stn = StabMps::with_seed(2, 115); + let mut stn = StabMps::builder(2).seed(115).merge_rz(false).build(); stn.cx(&[(q0, q1)]); stn.cz(&[(q0, q1)]); stn.cx(&[(q1, q0)]); @@ -685,7 +691,7 @@ fn test_fuzz_debug_seed_101() { let rz_angle = Angle64::from_radians(4.0024); let rx_angle1 = Angle64::from_radians(5.6800); - let mut stn = StabMps::with_seed(2, 101); + let mut stn = StabMps::builder(2).seed(101).merge_rz(false).build(); let mut crz = StabVec::builder(2).seed(101).build(); // Step 0: T on q1 @@ -859,7 +865,10 @@ fn test_debug_seed_502() { let seed = 502u64; let dim = 1usize << num_qubits; - let mut stn = StabMps::with_seed(num_qubits, seed); + let mut stn = StabMps::builder(num_qubits) + .seed(seed) + .merge_rz(false) + .build(); let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); let mut rng_state = seed; @@ -1003,7 +1012,7 @@ fn test_fuzz_2qubit_deep() { #[test] fn test_rx_pi_after_nonclifford() { // RX(pi) = -i*X. Check it works after non-Clifford gates. - let mut stn = StabMps::with_seed(2, 42); + let mut stn = StabMps::builder(2).seed(42).merge_rz(false).build(); let mut dsv = pecos_simulators::DenseStateVec::new(2); let t = Angle64::QUARTER_TURN / 2u64; let pi = Angle64::from_radians(std::f64::consts::PI); @@ -1066,7 +1075,7 @@ fn test_rx_pi_after_nonclifford() { eprintln!(" D[{i}] minus={dm} i={di}"); } // Also check state before RX(pi) - let mut stn2 = StabMps::with_seed(2, 42); + let mut stn2 = StabMps::builder(2).seed(42).merge_rz(false).build(); let mut dsv2 = pecos_simulators::DenseStateVec::new(2); stn2.h(&[QubitId(0)]); dsv2.h(&[QubitId(0)]); @@ -1173,7 +1182,7 @@ fn test_seed502_prefix() { ("rx", vec![1], Some(rx_angle)), ("t", vec![1], None), ]; - let mut stn = StabMps::with_seed(2, 42); + let mut stn = StabMps::builder(2).seed(42).merge_rz(false).build(); let mut dsv = pecos_simulators::DenseStateVec::new(2); let dim = 1usize << 2; for (step, (gate, qubits, angle)) in gates.iter().enumerate() { @@ -1585,7 +1594,7 @@ fn test_disentangle_various_circuits() { ]; for (i, build) in circuits.iter().enumerate() { - let mut stn = StabMps::new(3); + let mut stn = StabMps::builder(3).merge_rz(false).build(); build(&mut stn); let sv_before = stn.state_vector(); let _gates = stn.disentangle(5); @@ -1759,7 +1768,10 @@ fn test_mast_t_then_measure_then_more() { /// Fuzz with RZZ gates included in the gate set. fn fuzz_with_rzz(num_qubits: usize, num_gates: usize, seed: u64) { - let mut stn = StabMps::with_seed(num_qubits, seed); + let mut stn = StabMps::builder(num_qubits) + .seed(seed) + .merge_rz(false) + .build(); let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); let mut rng_state = seed; @@ -2184,6 +2196,7 @@ fn test_prefix_tree_sampler_flushes_supported_modes_on_working_clone() { fn test_mast_min_span_matches_stn_exact_random_probabilities() { // Mirrors `test_mast_matches_stn_exact_probabilities_2q`: compare each // sampled outcome with `prob_bitstring` under the same five-sigma bound. + // MinSpan is the default, so this also guards the default MAST path. let num_trials = 5000usize; for num_qubits in 3..=5 { for t_count in 3..=6 { @@ -2213,8 +2226,7 @@ fn test_mast_min_span_matches_stn_exact_random_probabilities() { let measured_qubits = (0..num_qubits).map(QubitId).collect::>(); for trial in 0..num_trials { let simulator_seed = circuit_seed.wrapping_mul(10_000) + trial as u64; - let mut mast = Mast::with_seed(num_qubits, t_count, simulator_seed) - .projection_order(ProjectionOrder::MinSpan); + let mut mast = Mast::with_seed(num_qubits, t_count, simulator_seed); apply_seeded_clifford_t_to_mast(&mut mast, &gates); let outcome = mast .mz(&measured_qubits) @@ -2368,6 +2380,7 @@ fn test_numerical_flag_redetection_random_probabilities() { let n = 4 + (seed % 3) as usize; let mut stn = StabMps::builder(n) .seed(seed) + .merge_rz(false) .numerical_flag_redetection(true) .build(); let mut rng_state = 0xDEAD_BEEF ^ seed.wrapping_mul(37); @@ -2417,7 +2430,10 @@ fn test_numerical_flag_redetection_random_probabilities() { fn test_numerical_flag_redetection_recovers_cancelled_rotation() { let t = Angle64::QUARTER_TURN / 2u64; let final_angle = Angle64::from_radians(0.37); - let mut stn = StabMps::builder(2).numerical_flag_redetection(true).build(); + let mut stn = StabMps::builder(2) + .merge_rz(false) + .numerical_flag_redetection(true) + .build(); let mut oracle = pecos_simulators::DenseStateVec::new(2); stn.h(&[QubitId(0)]); @@ -2447,7 +2463,10 @@ fn test_numerical_flag_redetection_recovers_cancelled_rotation() { fn test_numerical_flag_redetection_rejects_nonzero_product_site() { let t = Angle64::QUARTER_TURN / 2u64; let final_angle = Angle64::from_radians(0.37); - let mut stn = StabMps::builder(2).numerical_flag_redetection(true).build(); + let mut stn = StabMps::builder(2) + .merge_rz(false) + .numerical_flag_redetection(true) + .build(); let mut oracle = pecos_simulators::DenseStateVec::new(2); stn.h(&[QubitId(0)]); @@ -2483,7 +2502,10 @@ fn test_many_t_gates_bond_dim_growth() { let num_qubits = 6; let t = Angle64::QUARTER_TURN / 2u64; - let mut stn = StabMps::builder(num_qubits).max_bond_dim(32).build(); + let mut stn = StabMps::builder(num_qubits) + .max_bond_dim(32) + .merge_rz(false) + .build(); let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); // Create full entanglement: H on all, then CX chain @@ -2520,7 +2542,10 @@ fn test_ghz_plus_t_ladder() { let num_qubits = 5; let t = Angle64::QUARTER_TURN / 2u64; - let mut stn = StabMps::builder(num_qubits).max_bond_dim(64).build(); + let mut stn = StabMps::builder(num_qubits) + .max_bond_dim(64) + .merge_rz(false) + .build(); let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); // GHZ: H(0), CX chain @@ -2562,7 +2587,10 @@ fn test_repeated_t_layers_4qubit() { let num_qubits = 4; let t = Angle64::QUARTER_TURN / 2u64; - let mut stn = StabMps::with_seed(num_qubits, 42); + let mut stn = StabMps::builder(num_qubits) + .seed(42) + .merge_rz(false) + .build(); let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); for _layer in 0..3 { @@ -2596,7 +2624,10 @@ fn test_bond_dim_respects_config() { let t = Angle64::QUARTER_TURN / 2u64; let max_chi = 8; - let mut stn = StabMps::builder(num_qubits).max_bond_dim(max_chi).build(); + let mut stn = StabMps::builder(num_qubits) + .max_bond_dim(max_chi) + .merge_rz(false) + .build(); stn.h(&[QubitId(0)]); stn.cx(&[(QubitId(0), QubitId(1))]); @@ -2629,7 +2660,10 @@ fn test_bond_dim_respects_config() { /// Fuzz with Tdg and negative-angle RZ gates. fn fuzz_with_tdg(num_qubits: usize, num_gates: usize, seed: u64) { - let mut stn = StabMps::with_seed(num_qubits, seed); + let mut stn = StabMps::builder(num_qubits) + .seed(seed) + .merge_rz(false) + .build(); let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); let mut rng_state = seed; @@ -2728,7 +2762,7 @@ fn test_fuzz_tdg_2qubit() { fn test_fuzz_szdg_circuits() { // Include szdg in the gate set to test the default sz.sz.sz path for seed in 3200..3250 { - let mut stn = StabMps::with_seed(2, seed); + let mut stn = StabMps::builder(2).seed(seed).merge_rz(false).build(); let mut dsv = pecos_simulators::DenseStateVec::new(2); let mut rng_state = seed; let next_rng = |state: &mut u64| -> u64 { @@ -2898,7 +2932,7 @@ fn test_post_measurement_multisite_collapse() { for trial in 0..50u64 { let seed = 9200 + trial; - let mut stn = StabMps::with_seed(3, seed); + let mut stn = StabMps::builder(3).seed(seed).merge_rz(false).build(); // Build state where Z_0 decomposes with both flip and sign sites stn.h(&[QubitId(0)]); @@ -2987,7 +3021,7 @@ fn test_post_measurement_state_3qubit() { fn test_fuzz_single_qubit() { // Single-qubit circuits: always Stabilizer decomposition path. for seed in 4000..4200 { - let mut stn = StabMps::with_seed(1, seed); + let mut stn = StabMps::builder(1).seed(seed).merge_rz(false).build(); let mut dsv = pecos_simulators::DenseStateVec::new(1); let mut rng_state = seed; @@ -3091,7 +3125,7 @@ fn test_rzz_then_non_clifford() { let angle = Angle64::from_radians(0.7); let t = Angle64::QUARTER_TURN / 2u64; - let mut stn = StabMps::with_seed(3, 42); + let mut stn = StabMps::builder(3).seed(42).merge_rz(false).build(); let mut dsv = pecos_simulators::DenseStateVec::new(3); stn.h(&[QubitId(0)]); @@ -3313,7 +3347,7 @@ fn test_property_cliffords_dont_grow_bond_dim() { fn test_property_stn_bond_dim_grows_with_nonclifford() { // Paper claim: each non-Clifford gate on an entangled state can increase bond dim. let t = Angle64::QUARTER_TURN / 2u64; - let mut stn = StabMps::with_seed(4, 42); + let mut stn = StabMps::builder(4).seed(42).merge_rz(false).build(); // Create entangled state for q in 0..4 { @@ -3401,7 +3435,10 @@ fn test_property_mast_vs_stn_bond_dim() { let t = Angle64::QUARTER_TURN / 2u64; let num_qubits = 6; - let mut stn = StabMps::with_seed(num_qubits, 42); + let mut stn = StabMps::builder(num_qubits) + .seed(42) + .merge_rz(false) + .build(); let mut mast = Mast::with_seed(num_qubits, 4, 42); // Same circuit on both @@ -3434,7 +3471,7 @@ fn test_property_mast_vs_stn_bond_dim() { fn test_property_disentangle_reduces_bond_dim() { // Paper claim: Clifford disentangling can reduce MPS bond dimension. let t = Angle64::QUARTER_TURN / 2u64; - let mut stn = StabMps::with_seed(3, 42); + let mut stn = StabMps::builder(3).seed(42).merge_rz(false).build(); stn.h(&[QubitId(0)]); stn.cx(&[(QubitId(0), QubitId(1))]); @@ -3480,6 +3517,7 @@ fn large_scale_bond_dim_check( let mut stn = StabMps::builder(num_qubits) .max_bond_dim(256) .seed(seed) + .merge_rz(false) .build(); let mut rng = seed; diff --git a/python/pecos-rslib-exp/src/mast_bindings.rs b/python/pecos-rslib-exp/src/mast_bindings.rs index 6f8c32e54..6abaaed24 100644 --- a/python/pecos-rslib-exp/src/mast_bindings.rs +++ b/python/pecos-rslib-exp/src/mast_bindings.rs @@ -12,7 +12,7 @@ use pecos_core::{Angle64, QubitId}; use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, QuantumSimulator}; -use pecos_stab_tn::stab_mps::mast::Mast; +use pecos_stab_tn::stab_mps::mast::{Mast, ProjectionOrder}; use pyo3::prelude::*; use pyo3::types::{PyDict, PySet, PyTuple}; @@ -36,14 +36,26 @@ impl PyMast { #[pymethods] impl PyMast { #[new] - #[pyo3(signature = (num_qubits, max_non_clifford, seed=None, lazy_measure=false, merge_rz=false))] + /// Create a MAST simulator. + /// + /// `projection_order` accepts `"min_span"` or `"input"`; `None` uses + /// the Rust default (`"min_span"`). + #[pyo3(signature = ( + num_qubits, + max_non_clifford, + seed=None, + lazy_measure=false, + merge_rz=false, + projection_order=None, + ))] fn new( num_qubits: usize, max_non_clifford: usize, seed: Option, lazy_measure: bool, merge_rz: bool, - ) -> Self { + projection_order: Option<&str>, + ) -> PyResult { let mut mast = if let Some(s) = seed { Mast::with_seed(num_qubits, max_non_clifford, s) } else { @@ -55,7 +67,19 @@ impl PyMast { if merge_rz { mast = mast.with_merge_rz(true); } - PyMast { inner: mast } + if let Some(order) = projection_order { + let order = match order { + "min_span" => ProjectionOrder::MinSpan, + "input" => ProjectionOrder::Input, + _ => { + return Err(PyErr::new::( + "projection_order must be 'min_span' or 'input'", + )); + } + }; + mast = mast.projection_order(order); + } + Ok(PyMast { inner: mast }) } fn reset(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { diff --git a/python/pecos-rslib-exp/src/stab_mps_bindings.rs b/python/pecos-rslib-exp/src/stab_mps_bindings.rs index 8c8d566c9..fedc8a5f0 100644 --- a/python/pecos-rslib-exp/src/stab_mps_bindings.rs +++ b/python/pecos-rslib-exp/src/stab_mps_bindings.rs @@ -37,6 +37,13 @@ impl PyStabMps { #[pymethods] impl PyStabMps { + /// Create a stabilizer-MPS simulator. + /// + /// Boolean options are tri-state: `None` preserves the Rust builder + /// default, while `True` or `False` explicitly enables or disables the + /// option. `max_truncation_error=None` preserves the builder default of + /// `1e-8`; a float overrides it, and `0.0` disables adaptive truncation + /// while retaining the SVD cutoff and bond cap. #[new] #[pyo3(signature = ( num_qubits, @@ -77,14 +84,14 @@ impl PyStabMps { if let Some(bd) = max_bond_dim { b = b.max_bond_dim(bd); } - if merge_rz == Some(true) { - b = b.merge_rz(true); + if let Some(v) = merge_rz { + b = b.merge_rz(v); } - if pauli_frame_tracking == Some(true) { - b = b.pauli_frame_tracking(true); + if let Some(v) = pauli_frame_tracking { + b = b.pauli_frame_tracking(v); } - if lazy_measure == Some(true) { - b = b.lazy_measure(true); + if let Some(v) = lazy_measure { + b = b.lazy_measure(v); } if let Some(t) = auto_grow_bond_dim { b = b.auto_grow_bond_dim(t); @@ -98,8 +105,8 @@ impl PyStabMps { if let Some(c) = svd_cutoff { b = b.svd_cutoff(c); } - if numerical_flag_redetection == Some(true) { - b = b.numerical_flag_redetection(true); + if let Some(v) = numerical_flag_redetection { + b = b.numerical_flag_redetection(v); } PyStabMps { inner: b.build() } } diff --git a/python/pecos-rslib-exp/src/stabmps_builder.rs b/python/pecos-rslib-exp/src/stabmps_builder.rs index b4cf0a044..af84d68be 100644 --- a/python/pecos-rslib-exp/src/stabmps_builder.rs +++ b/python/pecos-rslib-exp/src/stabmps_builder.rs @@ -31,7 +31,7 @@ pub struct StabMpsBuilder { /// Maximum MPS bond dimension. pub max_bond_dim: usize, /// Maximum truncation error for MPS compression. - /// None = disabled (library default, use fixed bond dim cap only). + /// Zero disables adaptive truncation while preserving cutoff and cap truncation. pub max_truncation_error: Option, /// Merge consecutive RZ on same qubit before decomposition. pub merge_rz: bool, @@ -41,9 +41,9 @@ impl Default for StabMpsBuilder { fn default() -> Self { Self { lazy_measure: false, - max_bond_dim: 64, - max_truncation_error: None, - merge_rz: false, + max_bond_dim: 128, + max_truncation_error: Some(1e-8), + merge_rz: true, } } } @@ -92,16 +92,12 @@ impl SimulatorFactory for StabMpsBuilder { seed: Option, ) -> Box { let mut builder = StabMps::builder(num_qubits); - if self.lazy_measure { - builder = builder.lazy_measure(true); - } + builder = builder.lazy_measure(self.lazy_measure); builder = builder.max_bond_dim(self.max_bond_dim); if let Some(err) = self.max_truncation_error { builder = builder.max_truncation_error(err); } - if self.merge_rz { - builder = builder.merge_rz(true); - } + builder = builder.merge_rz(self.merge_rz); if let Some(s) = seed { builder = builder.seed(s); } From 099219275e7cd84376a18aa90828745c0ca31318 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 17:15:27 -0600 Subject: [PATCH 10/14] Expose analysis, telemetry, and compile-advice surfaces in the Python bindings --- .../pecos-rslib-exp/src/compile_bindings.rs | 363 +++++++++++++++++ python/pecos-rslib-exp/src/lib.rs | 24 +- python/pecos-rslib-exp/src/mast_bindings.rs | 52 ++- .../pecos-rslib-exp/src/stab_mps_bindings.rs | 369 +++++++++++++++++- python/pecos-rslib-exp/tests/test_exposure.py | 160 ++++++++ 5 files changed, 955 insertions(+), 13 deletions(-) create mode 100644 python/pecos-rslib-exp/src/compile_bindings.rs create mode 100644 python/pecos-rslib-exp/tests/test_exposure.py diff --git a/python/pecos-rslib-exp/src/compile_bindings.rs b/python/pecos-rslib-exp/src/compile_bindings.rs new file mode 100644 index 000000000..c7437905e --- /dev/null +++ b/python/pecos-rslib-exp/src/compile_bindings.rs @@ -0,0 +1,363 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the +// License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing permissions and +// limitations under the License. + +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, QuantumSimulator}; +use pecos_stab_tn::stab_mps::compile::{InjectionMode, SimulatorKind, StabMpsCompile}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PySet, PyTuple}; + +#[pyclass(name = "StabMpsCompile", module = "pecos_rslib_exp")] +pub struct PyStabMpsCompile { + inner: StabMpsCompile, +} + +impl PyStabMpsCompile { + fn check_qubit(&self, q: usize, method: &str) -> PyResult<()> { + if q >= self.inner.num_qubits() { + return Err(PyErr::new::(format!( + "{method}: qubit {q} out of bounds (num_qubits={})", + self.inner.num_qubits() + ))); + } + Ok(()) + } +} + +fn simulator_name(kind: SimulatorKind) -> &'static str { + match kind { + SimulatorKind::StateVector => "state_vector", + SimulatorKind::CHForm => "ch_form", + SimulatorKind::StabVec => "stab_vec", + SimulatorKind::StabMps => "stab_mps", + SimulatorKind::Mast => "mast", + } +} + +fn injection_name(mode: InjectionMode) -> &'static str { + match mode { + InjectionMode::Direct => "direct", + InjectionMode::Immediate => "immediate", + InjectionMode::Deferred => "deferred", + } +} + +#[pymethods] +impl PyStabMpsCompile { + /// Create a compile-only stabilizer-MPS tractability analyzer. + #[new] + fn new(num_qubits: usize) -> Self { + Self { + inner: StabMpsCompile::new(num_qubits), + } + } + + fn reset(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { + slf.inner.reset(); + slf + } + + #[getter] + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + #[getter] + fn absorbed(&self) -> u64 { + self.inner.absorbed() + } + + #[getter] + fn grown(&self) -> u64 { + self.inner.grown() + } + + #[getter] + fn stabilizer(&self) -> u64 { + self.inner.stabilizer() + } + + #[getter] + fn total_nonclifford(&self) -> u64 { + self.inner.total_nonclifford() + } + + #[getter] + fn nonclifford_rz_total(&self) -> u64 { + self.inner.nonclifford_rz_total() + } + + #[getter] + fn injectable_clifford_correction(&self) -> u64 { + self.inner.injectable_clifford_correction() + } + + #[getter] + fn nullity(&self) -> usize { + self.inner.nullity() + } + + #[getter] + fn rank(&self) -> usize { + self.inner.rank() + } + + #[getter] + fn bond_dim_bound(&self) -> usize { + self.inner.bond_dim_bound() + } + + /// Recommend a simulator for the analyzed circuit. + fn recommend(&self, py: Python<'_>) -> PyResult> { + let recommendation = self.inner.recommend(); + let result = PyDict::new(py); + result.set_item("simulator", simulator_name(recommendation.kind))?; + result.set_item("reason", recommendation.reason)?; + Ok(result.unbind()) + } + + /// Recommend a simulator and non-Clifford injection mode. + #[pyo3(signature = (ancilla_budget=None))] + fn advise(&self, py: Python<'_>, ancilla_budget: Option) -> PyResult> { + let advice = self.inner.advise(ancilla_budget); + let result = PyDict::new(py); + result.set_item("simulator", simulator_name(advice.simulator))?; + result.set_item("injection", injection_name(advice.injection))?; + result.set_item("injectable_count", advice.injectable_count)?; + result.set_item( + "deferred_ancillas_required", + advice.deferred_ancillas_required, + )?; + result.set_item("deferred_feasible", advice.deferred_feasible)?; + result.set_item("warnings", advice.warnings)?; + result.set_item("reason", advice.reason)?; + Ok(result.unbind()) + } + + // ---- Gate dispatch (matches StabMps and Mast) ---- + + #[pyo3(signature = (symbol, location, params=None))] + fn run_1q_gate( + &mut self, + symbol: &str, + location: usize, + params: Option<&Bound<'_, PyDict>>, + ) -> PyResult> { + self.check_qubit(location, symbol)?; + let q = &[QubitId(location)]; + match symbol { + "I" => Ok(None), + "X" => { + self.inner.x(q); + Ok(None) + } + "Y" => { + self.inner.y(q); + Ok(None) + } + "Z" => { + self.inner.z(q); + Ok(None) + } + "H" | "H1" | "H+z+x" => { + self.inner.h(q); + Ok(None) + } + "F" | "F1" => { + self.inner.f(q); + Ok(None) + } + "Fdg" | "F1d" | "F1dg" => { + self.inner.fdg(q); + Ok(None) + } + "SX" | "SqrtX" | "Q" => { + self.inner.sx(q); + Ok(None) + } + "SXdg" | "SqrtXdg" | "SqrtXd" | "Qd" => { + self.inner.sxdg(q); + Ok(None) + } + "SY" | "SqrtY" | "R" => { + self.inner.sy(q); + Ok(None) + } + "SYdg" | "SqrtYdg" | "SqrtYd" | "Rd" => { + self.inner.sydg(q); + Ok(None) + } + "S" | "SZ" | "SqrtZ" => { + self.inner.sz(q); + Ok(None) + } + "Sd" | "SZdg" | "SqrtZdg" | "SqrtZd" => { + self.inner.szdg(q); + Ok(None) + } + "RX" => { + let angle = crate::extract_angle(params, "RX")?; + self.inner.rx(angle, q); + Ok(None) + } + "RY" => { + let angle = crate::extract_angle(params, "RY")?; + self.inner.ry(angle, q); + Ok(None) + } + "RZ" => { + let angle = crate::extract_angle(params, "RZ")?; + self.inner.rz(angle, q); + Ok(None) + } + "T" => { + self.inner.rz(Angle64::QUARTER_TURN / 2u64, q); + Ok(None) + } + "Tdg" => { + self.inner.rz(-(Angle64::QUARTER_TURN / 2u64), q); + Ok(None) + } + "PZ" | "Init" | "init |0>" => { + let results = self.inner.mz(q); + if results[0].outcome { + self.inner.x(q); + } + Ok(None) + } + "PX" | "Init +X" | "init |+>" => { + let results = self.inner.mz(q); + if results[0].outcome { + self.inner.x(q); + } + self.inner.h(q); + Ok(None) + } + "MZ" | "Measure" | "measure Z" => { + let result = self + .inner + .mz(q) + .into_iter() + .next() + .expect("measurement returned no results"); + Ok(Some(u8::from(result.outcome))) + } + _ => Err(PyErr::new::(format!( + "Unsupported single-qubit gate: {symbol}" + ))), + } + } + + #[pyo3(signature = (symbol, location, params=None))] + fn run_2q_gate( + &mut self, + symbol: &str, + location: &Bound<'_, PyTuple>, + params: Option<&Bound<'_, PyDict>>, + ) -> PyResult> { + if location.len() != 2 { + return Err(PyErr::new::( + "Two-qubit gate requires exactly 2 qubit locations", + )); + } + let q1: usize = location.get_item(0)?.extract()?; + let q2: usize = location.get_item(1)?.extract()?; + self.check_qubit(q1, symbol)?; + self.check_qubit(q2, symbol)?; + let pair = &[(QubitId(q1), QubitId(q2))]; + match symbol { + "CX" | "CNOT" => { + self.inner.cx(pair); + Ok(None) + } + "CY" => { + self.inner.cy(pair); + Ok(None) + } + "CZ" => { + self.inner.cz(pair); + Ok(None) + } + "SXX" => { + self.inner.sxx(pair); + Ok(None) + } + "SXXdg" => { + self.inner.sxxdg(pair); + Ok(None) + } + "SYY" => { + self.inner.syy(pair); + Ok(None) + } + "SYYdg" => { + self.inner.syydg(pair); + Ok(None) + } + "SZZ" => { + self.inner.szz(pair); + Ok(None) + } + "SZZdg" => { + self.inner.szzdg(pair); + Ok(None) + } + "SWAP" => { + self.inner.swap(pair); + Ok(None) + } + "RZZ" => { + let angle = crate::extract_angle(params, "RZZ")?; + self.inner.rzz(angle, pair); + Ok(None) + } + _ => Err(PyErr::new::(format!( + "Unsupported two-qubit gate: {symbol}" + ))), + } + } + + #[pyo3(signature = (symbol, locations, **params))] + fn run_gate( + &mut self, + symbol: &str, + locations: &Bound<'_, PyAny>, + params: Option<&Bound<'_, PyDict>>, + py: Python<'_>, + ) -> PyResult> { + let output = PyDict::new(py); + let locations_set: Bound = locations.clone().cast_into()?; + for location in locations_set.iter() { + let loc_tuple: Bound<'_, PyTuple> = if location.is_instance_of::() { + location.clone().cast_into()? + } else { + PyTuple::new(py, std::slice::from_ref(&location))? + }; + let result = match loc_tuple.len() { + 1 => { + let qubit: usize = loc_tuple.get_item(0)?.extract()?; + self.run_1q_gate(symbol, qubit, params)? + } + 2 => self.run_2q_gate(symbol, &loc_tuple, params)?, + _ => { + return Err(PyErr::new::( + "Gate location must be 1 or 2 qubits", + )); + } + }; + if let Some(value) = result { + output.set_item(location, value)?; + } + } + Ok(output.unbind()) + } +} diff --git a/python/pecos-rslib-exp/src/lib.rs b/python/pecos-rslib-exp/src/lib.rs index e3ea3b0e5..a7968a9cb 100644 --- a/python/pecos-rslib-exp/src/lib.rs +++ b/python/pecos-rslib-exp/src/lib.rs @@ -26,10 +26,12 @@ //! Python bindings for experimental PECOS simulators. //! -//! Exposes `StabMps` (stabilizer + MPS hybrid) and `Mast` (magic state -//! injection) from `pecos-stab-tn` via `PyO3`. +//! Exposes `StabMps` (stabilizer + MPS hybrid), `Mast` (magic state +//! injection), and `StabMpsCompile` (compile-only tractability analysis) +//! from `pecos-stab-tn` via `PyO3`. mod coherent_idle_channel; +mod compile_bindings; mod eeg_bindings; mod mast_bindings; mod sim_neo_bindings; @@ -37,9 +39,26 @@ mod stab_mps_bindings; pub mod stabmps_builder; use pecos_core::Angle64; +use pecos_stab_tn::stab_mps::StabMpsStats; use pyo3::prelude::*; use pyo3::types::PyDict; +pub(crate) fn stab_mps_stats_to_dict(py: Python<'_>, stats: &StabMpsStats) -> PyResult> { + let result = PyDict::new(py); + result.set_item("total_nonclifford", stats.total_nonclifford)?; + result.set_item("single_site", stats.single_site)?; + result.set_item("multi_disent", stats.multi_disent)?; + result.set_item("numerical_redetect", stats.numerical_redetect)?; + result.set_item("multi_std", stats.multi_std)?; + result.set_item("stabilizer", stats.stabilizer)?; + result.set_item("ofd_in_span", stats.ofd_in_span)?; + result.set_item("ofd_new_dim", stats.ofd_new_dim)?; + result.set_item("ofd_in_span_std", stats.ofd_in_span_std)?; + result.set_item("ofd_in_span_single", stats.ofd_in_span_single)?; + result.set_item("ofd_in_span_disent", stats.ofd_in_span_disent)?; + Ok(result.unbind()) +} + pub(crate) fn extract_angle( params: Option<&Bound<'_, PyDict>>, gate_name: &str, @@ -66,6 +85,7 @@ pub(crate) fn extract_angle( fn pecos_rslib_exp(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/python/pecos-rslib-exp/src/mast_bindings.rs b/python/pecos-rslib-exp/src/mast_bindings.rs index 6abaaed24..ef4044ed3 100644 --- a/python/pecos-rslib-exp/src/mast_bindings.rs +++ b/python/pecos-rslib-exp/src/mast_bindings.rs @@ -39,33 +39,40 @@ impl PyMast { /// Create a MAST simulator. /// /// `projection_order` accepts `"min_span"` or `"input"`; `None` uses - /// the Rust default (`"min_span"`). + /// the Rust default (`"min_span"`). Boolean options are tri-state: + /// `None` preserves the Rust default, while an explicit bool calls the + /// corresponding Rust setter. #[pyo3(signature = ( num_qubits, max_non_clifford, seed=None, - lazy_measure=false, - merge_rz=false, + lazy_measure=None, + merge_rz=None, projection_order=None, + numerical_flag_redetection=None, ))] fn new( num_qubits: usize, max_non_clifford: usize, seed: Option, - lazy_measure: bool, - merge_rz: bool, + lazy_measure: Option, + merge_rz: Option, projection_order: Option<&str>, + numerical_flag_redetection: Option, ) -> PyResult { let mut mast = if let Some(s) = seed { Mast::with_seed(num_qubits, max_non_clifford, s) } else { Mast::new(num_qubits, max_non_clifford) }; - if lazy_measure { - mast = mast.with_lazy_measure(true); + if let Some(value) = lazy_measure { + mast = mast.with_lazy_measure(value); } - if merge_rz { - mast = mast.with_merge_rz(true); + if let Some(value) = merge_rz { + mast = mast.with_merge_rz(value); + } + if let Some(value) = numerical_flag_redetection { + mast = mast.with_numerical_flag_redetection(value); } if let Some(order) = projection_order { let order = match order { @@ -107,6 +114,33 @@ impl PyMast { self.inner.max_bond_dim() } + /// Diagnostics for deferred magic-state projections since reset. + fn projection_records(&self, py: Python<'_>) -> PyResult>> { + self.inner + .projection_records() + .iter() + .map(|record| { + let result = PyDict::new(py); + result.set_item("ancilla", record.ancilla)?; + result.set_item("support_size", record.support_size)?; + result.set_item("mps_span", record.mps_span)?; + result.set_item("bond_before", record.bond_before)?; + result.set_item("bond_after", record.bond_after)?; + Ok(result.unbind()) + }) + .collect() + } + + #[getter] + fn projection_peak_bond(&self) -> usize { + self.inner.projection_peak_bond() + } + + /// Runtime non-Clifford path counters. + fn stats(&self, py: Python<'_>) -> PyResult> { + crate::stab_mps_stats_to_dict(py, &self.inner.stats) + } + fn flush(&mut self) { self.inner.flush(); } diff --git a/python/pecos-rslib-exp/src/stab_mps_bindings.rs b/python/pecos-rslib-exp/src/stab_mps_bindings.rs index fedc8a5f0..d9e5e6cc9 100644 --- a/python/pecos-rslib-exp/src/stab_mps_bindings.rs +++ b/python/pecos-rslib-exp/src/stab_mps_bindings.rs @@ -12,8 +12,9 @@ #![allow(clippy::needless_pass_by_value)] // PyO3 requires passing extracted types by value -use pecos_core::{Angle64, QubitId}; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, QuantumSimulator}; +use pecos_core::clifford_rep::CliffordRep; +use pecos_core::{Angle64, Pauli, PauliOperator, PauliString, QuarterPhase, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CHForm, CliffordGateable, QuantumSimulator}; use pecos_stab_tn::stab_mps::{PauliKind, StabMps}; use pyo3::prelude::*; use pyo3::types::{PyDict, PyList, PySet, PyTuple}; @@ -23,6 +24,241 @@ pub struct PyStabMps { inner: StabMps, } +#[derive(Clone, Copy)] +enum StabilizerPrepGate { + H(usize), + Sdg(usize), + X(usize), + Cx(usize, usize), + Cz(usize, usize), + Swap(usize, usize), +} + +impl StabilizerPrepGate { + fn clifford(self, num_qubits: usize) -> CliffordRep { + let gate = match self { + Self::H(q) => CliffordRep::h(q), + Self::Sdg(q) => CliffordRep::szdg(q), + Self::X(q) => CliffordRep::x(q), + Self::Cx(control, target) => CliffordRep::cx(control, target), + Self::Cz(q0, q1) => CliffordRep::cz(q0, q1), + Self::Swap(q0, q1) => CliffordRep::swap(q0, q1), + }; + gate.extended_to(num_qubits) + } + + fn apply_inverse(self, simulator: &mut CHForm) { + match self { + Self::H(q) => { + simulator.h(&[QubitId(q)]); + } + Self::Sdg(q) => { + simulator.sz(&[QubitId(q)]); + } + Self::X(q) => { + simulator.x(&[QubitId(q)]); + } + Self::Cx(control, target) => { + simulator.cx(&[(QubitId(control), QubitId(target))]); + } + Self::Cz(q0, q1) => { + simulator.cz(&[(QubitId(q0), QubitId(q1))]); + } + Self::Swap(q0, q1) => { + simulator.swap(&[(QubitId(q0), QubitId(q1))]); + } + } + } +} + +fn apply_stabilizer_prep_gate( + rows: &mut [PauliString], + gates: &mut Vec, + gate: StabilizerPrepGate, + num_qubits: usize, +) { + let clifford = gate.clifford(num_qubits); + for row in rows { + *row = clifford.apply(row); + } + gates.push(gate); +} + +fn stabilizer_state_from_generators( + num_qubits: usize, + generators: Vec>, + seed: u64, +) -> PyResult { + if generators.len() != num_qubits { + return Err(PyErr::new::(format!( + "expected {num_qubits} independent stabilizer generators, got {}", + generators.len() + ))); + } + + let mut rows = Vec::with_capacity(num_qubits); + for (generator_index, generator) in generators.into_iter().enumerate() { + let mut seen = vec![false; num_qubits]; + let mut paulis = Vec::with_capacity(generator.len()); + for (q, value) in generator { + if q >= num_qubits { + return Err(PyErr::new::(format!( + "stabilizer generator {generator_index}: qubit {q} out of bounds (num_qubits={num_qubits})" + ))); + } + if std::mem::replace(&mut seen[q], true) { + return Err(PyErr::new::(format!( + "stabilizer generator {generator_index}: duplicate qubit {q}" + ))); + } + let pauli = match value.as_str() { + "X" => Pauli::X, + "Y" => Pauli::Y, + "Z" => Pauli::Z, + _ => { + return Err(PyErr::new::(format!( + "Unknown Pauli: {value}" + ))); + } + }; + paulis.push((pauli, QubitId(q))); + } + let row = PauliString::with_phase_and_paulis(QuarterPhase::PlusOne, paulis); + if row.is_identity() { + return Err(PyErr::new::(format!( + "stabilizer generator {generator_index} must not be identity" + ))); + } + rows.push(row); + } + + for left in 0..num_qubits { + for right in left + 1..num_qubits { + if !rows[left].commutes_with(&rows[right]) { + return Err(PyErr::new::(format!( + "stabilizer generators {left} and {right} do not commute" + ))); + } + } + } + + // Symplectic Gaussian elimination maps the supplied generators to +Z_i. + // Reversing the recorded Clifford gates then prepares their unique common + // +1 eigenstate in CH form. + let mut gates = Vec::new(); + for pivot in 0..num_qubits { + let mut candidate = (pivot..num_qubits).find_map(|row| { + (pivot..num_qubits) + .find(|&q| matches!(rows[row].get(q), Pauli::X | Pauli::Y)) + .map(|q| (row, q)) + }); + if candidate.is_none() { + candidate = (pivot..num_qubits).find_map(|row| { + (pivot..num_qubits) + .find(|&q| rows[row].get(q) == Pauli::Z) + .map(|q| (row, q)) + }); + if let Some((_, q)) = candidate { + apply_stabilizer_prep_gate( + &mut rows, + &mut gates, + StabilizerPrepGate::H(q), + num_qubits, + ); + } + } + let Some((candidate_row, candidate_qubit)) = candidate else { + return Err(PyErr::new::( + "stabilizer generators are not independent", + )); + }; + + rows.swap(pivot, candidate_row); + if candidate_qubit != pivot { + apply_stabilizer_prep_gate( + &mut rows, + &mut gates, + StabilizerPrepGate::Swap(pivot, candidate_qubit), + num_qubits, + ); + } + if rows[pivot].get(pivot) == Pauli::Y { + apply_stabilizer_prep_gate( + &mut rows, + &mut gates, + StabilizerPrepGate::Sdg(pivot), + num_qubits, + ); + } + + for q in pivot + 1..num_qubits { + match rows[pivot].get(q) { + Pauli::I => {} + Pauli::X => apply_stabilizer_prep_gate( + &mut rows, + &mut gates, + StabilizerPrepGate::Cx(pivot, q), + num_qubits, + ), + Pauli::Y => { + apply_stabilizer_prep_gate( + &mut rows, + &mut gates, + StabilizerPrepGate::Sdg(q), + num_qubits, + ); + apply_stabilizer_prep_gate( + &mut rows, + &mut gates, + StabilizerPrepGate::Cx(pivot, q), + num_qubits, + ); + } + Pauli::Z => apply_stabilizer_prep_gate( + &mut rows, + &mut gates, + StabilizerPrepGate::Cz(pivot, q), + num_qubits, + ), + } + } + + for row in pivot + 1..num_qubits { + if rows[row].get(pivot) == Pauli::X { + rows[row] = rows[pivot].multiply(&rows[row]); + } + } + apply_stabilizer_prep_gate( + &mut rows, + &mut gates, + StabilizerPrepGate::H(pivot), + num_qubits, + ); + if rows[pivot].phase() == QuarterPhase::MinusOne { + apply_stabilizer_prep_gate( + &mut rows, + &mut gates, + StabilizerPrepGate::X(pivot), + num_qubits, + ); + } + if rows[pivot].phase() != QuarterPhase::PlusOne + || rows[pivot].get(pivot) != Pauli::Z + || rows[pivot].paulis().len() != 1 + { + return Err(PyErr::new::( + "stabilizer generators do not define a valid stabilizer state", + )); + } + } + + let mut state = CHForm::new_with_seed(num_qubits, seed); + for gate in gates.into_iter().rev() { + gate.apply_inverse(&mut state); + } + Ok(state) +} + impl PyStabMps { fn check_qubit(&self, q: usize, method: &str) -> PyResult<()> { if q >= self.inner.num_qubits() { @@ -154,10 +390,129 @@ impl PyStabMps { Ok(PyList::new(py, &list)?.unbind()) } + /// Wavefunction amplitude for a computational-basis bitstring. + fn amplitude(&self, bitstring: Vec) -> PyResult<(f64, f64)> { + if bitstring.len() != self.inner.num_qubits() { + return Err(PyErr::new::( + "bitstring length mismatch", + )); + } + if self.inner.num_qubits() > 14 { + return Err(PyErr::new::( + "amplitude requires n <= 14", + )); + } + let amplitude = self.inner.amplitude(&bitstring); + Ok((amplitude.re, amplitude.im)) + } + + /// CAMPS-native iterative wavefunction amplitude. + fn amplitude_iterative(&self, bitstring: Vec) -> PyResult<(f64, f64)> { + if bitstring.len() != self.inner.num_qubits() { + return Err(PyErr::new::( + "bitstring length mismatch", + )); + } + let amplitude = self.inner.amplitude_iterative(&bitstring); + Ok((amplitude.re, amplitude.im)) + } + + /// Monte Carlo estimate of the overlap with a stabilizer state specified + /// by a complete set of independent +1 Pauli generators. + #[pyo3(signature = (stabilizers, *, num_samples, rng_seed=None))] + fn overlap_with_stabilizer( + &self, + stabilizers: Vec>, + num_samples: usize, + rng_seed: Option, + ) -> PyResult<(f64, f64)> { + if self.inner.num_qubits() > 64 { + return Err(PyErr::new::( + "overlap_with_stabilizer requires n <= 64", + )); + } + if num_samples == 0 { + return Err(PyErr::new::( + "num_samples must be greater than zero", + )); + } + let state = stabilizer_state_from_generators( + self.inner.num_qubits(), + stabilizers, + rng_seed.unwrap_or(42), + )?; + let overlap = self + .inner + .overlap_with_stabilizer(&state, num_samples, rng_seed); + Ok((overlap.re, overlap.im)) + } + fn prob_bitstring(&self, bitstring: Vec) -> f64 { self.inner.prob_bitstring(&bitstring) } + /// Second Renyi entropy from the full state vector. + fn renyi_s2(&self, cut: usize) -> PyResult { + let num_qubits = self.inner.num_qubits(); + if cut == 0 || cut >= num_qubits { + return Err(PyErr::new::( + "cut must be in (0, num_qubits)", + )); + } + if num_qubits > 14 { + return Err(PyErr::new::( + "renyi_s2 requires n <= 14 (uses full state vector)", + )); + } + Ok(self.inner.renyi_s2(cut)) + } + + /// Second Renyi entropy via Pauli coefficient enumeration. + fn s2_pce(&self, cut: usize) -> PyResult { + self.inner + .s2_pce(cut) + .map_err(PyErr::new::) + } + + /// Second Renyi entropy via the PCMPS hierarchy. + fn s2_pcmps(&self, cut: usize) -> PyResult { + self.inner + .s2_pcmps(cut) + .map_err(PyErr::new::) + } + + /// Run Clifford disentangling sweeps and return the gates applied. + fn disentangle(&mut self, max_sweeps: usize) -> usize { + self.inner.disentangle(max_sweeps) + } + + #[getter] + fn bond_cap_hits(&self) -> u64 { + self.inner.bond_cap_hits() + } + + fn ofd_nullity(&self) -> usize { + self.inner.ofd_nullity() + } + + fn theoretical_min_bond_dim(&self) -> usize { + self.inner.theoretical_min_bond_dim() + } + + fn ofd_disentangled_count(&self) -> usize { + self.inner.ofd_disentangled_count() + } + + fn ofd_total_absorbed(&self) -> u64 { + u64::try_from(self.inner.ofd_total_absorbed()) + .expect("usize fits in u64 on supported Python targets") + } + + /// Runtime non-Clifford path counters. + fn stats(&self, py: Python<'_>) -> PyResult> { + crate::stab_mps_stats_to_dict(py, &self.inner.stats) + } + // ---- QEC helpers ---- fn reset_qubit(&mut self, q: usize) -> PyResult { @@ -230,6 +585,16 @@ impl PyStabMps { .map(|k| format!("{k:?}")) } + fn apply_bit_flip(&mut self, q: usize, p: f64) -> PyResult { + self.check_qubit(q, "apply_bit_flip")?; + Ok(self.inner.apply_bit_flip(QubitId(q), p)) + } + + fn apply_phase_flip(&mut self, q: usize, p: f64) -> PyResult { + self.check_qubit(q, "apply_phase_flip")?; + Ok(self.inner.apply_phase_flip(QubitId(q), p)) + } + fn apply_depolarizing_all(&mut self, qubits: Vec, p: f64) { let qs: Vec = qubits.into_iter().map(QubitId).collect(); self.inner.apply_depolarizing_all(&qs, p); diff --git a/python/pecos-rslib-exp/tests/test_exposure.py b/python/pecos-rslib-exp/tests/test_exposure.py new file mode 100644 index 000000000..48503fb2b --- /dev/null +++ b/python/pecos-rslib-exp/tests/test_exposure.py @@ -0,0 +1,160 @@ +"""Smoke tests for the approved experimental Python exposure set. + +REQUIRES a built ``pecos_rslib_exp`` extension. The downstream harness venv +builds the extension before running this file; this test is not wired into Cargo. +""" + +import math + +import pecos_rslib_exp as exp + + +STATS_KEYS = { + "total_nonclifford", + "single_site", + "multi_disent", + "numerical_redetect", + "multi_std", + "stabilizer", + "ofd_in_span", + "ofd_new_dim", + "ofd_in_span_std", + "ofd_in_span_single", + "ofd_in_span_disent", +} + + +def assert_complex_tuple(value): + assert isinstance(value, tuple) + assert len(value) == 2 + assert all(isinstance(component, float) for component in value) + + +def test_stab_mps_analysis_and_noise_exposure(): + bell = exp.StabMps(2, seed=7) + bell.run_1q_gate("H", 0) + bell.run_2q_gate("CX", (0, 1)) + + amplitude = bell.amplitude([False, False]) + assert_complex_tuple(amplitude) + assert math.isclose(amplitude[0], 1 / math.sqrt(2), abs_tol=1e-12) + assert math.isclose(amplitude[1], 0.0, abs_tol=1e-12) + + iterative = bell.amplitude_iterative([False, False]) + assert_complex_tuple(iterative) + assert math.isclose(iterative[0], amplitude[0], abs_tol=1e-12) + assert math.isclose(iterative[1], amplitude[1], abs_tol=1e-12) + + overlap = bell.overlap_with_stabilizer( + [[(0, "X"), (1, "X")], [(0, "Z"), (1, "Z")]], + num_samples=32, + rng_seed=11, + ) + assert_complex_tuple(overlap) + assert math.isclose(overlap[0], 1.0, abs_tol=1e-12) + assert math.isclose(overlap[1], 0.0, abs_tol=1e-12) + + product = exp.StabMps(2) + assert math.isclose(product.renyi_s2(1), 0.0, abs_tol=1e-12) + assert math.isclose(product.s2_pce(1), 0.0, abs_tol=1e-12) + assert math.isclose(product.s2_pcmps(1), 0.0, abs_tol=1e-12) + assert isinstance(product.disentangle(1), int) + assert isinstance(product.bond_cap_hits, int) + assert isinstance(product.ofd_nullity(), int) + assert isinstance(product.theoretical_min_bond_dim(), int) + assert isinstance(product.ofd_disentangled_count(), int) + assert isinstance(product.ofd_total_absorbed(), int) + + product.run_1q_gate("T", 0) + stats = product.stats() + assert isinstance(stats, dict) + assert set(stats) == STATS_KEYS + assert all(isinstance(value, int) for value in stats.values()) + + bit_flip = exp.StabMps(1, seed=1) + assert bit_flip.apply_bit_flip(0, 1.0) is True + assert math.isclose(bit_flip.prob_bitstring([True]), 1.0, abs_tol=1e-12) + + phase_flip = exp.StabMps(1, seed=1) + phase_flip.run_1q_gate("H", 0) + assert phase_flip.apply_phase_flip(0, 1.0) is True + assert math.isclose( + phase_flip.pauli_expectation([(0, "X")]), -1.0, abs_tol=1e-12 + ) + + +def test_mast_configuration_projection_diagnostics_and_stats(): + mast = exp.Mast( + 1, + 1, + seed=5, + lazy_measure=False, + merge_rz=False, + numerical_flag_redetection=True, + projection_order="input", + ) + mast.run_1q_gate("H", 0) + mast.run_1q_gate("T", 0) + mast.project_all() + + records = mast.projection_records() + assert isinstance(records, list) + assert len(records) == 1 + assert set(records[0]) == { + "ancilla", + "support_size", + "mps_span", + "bond_before", + "bond_after", + } + assert all(isinstance(value, int) for value in records[0].values()) + assert isinstance(mast.projection_peak_bond, int) + + stats = mast.stats() + assert isinstance(stats, dict) + assert set(stats) == STATS_KEYS + assert all(isinstance(value, int) for value in stats.values()) + + +def test_stab_mps_compile_dispatch_accessors_and_advice(): + compile_only = exp.StabMpsCompile(2) + assert compile_only.num_qubits == 2 + + assert compile_only.run_1q_gate("H", 0) is None + assert compile_only.run_2q_gate("CX", (0, 1)) is None + assert compile_only.run_gate("Z", {1}) == {} + + recommendation = compile_only.recommend() + assert recommendation["simulator"] == "ch_form" + assert isinstance(recommendation["reason"], str) + + advice = compile_only.advise() + assert set(advice) == { + "simulator", + "injection", + "injectable_count", + "deferred_ancillas_required", + "deferred_feasible", + "warnings", + "reason", + } + assert advice["injection"] == "direct" + assert advice["deferred_feasible"] is None + assert isinstance(advice["warnings"], list) + + compile_only.run_1q_gate("T", 0) + for name in ( + "absorbed", + "grown", + "stabilizer", + "total_nonclifford", + "nonclifford_rz_total", + "injectable_clifford_correction", + "nullity", + "rank", + "bond_dim_bound", + ): + assert isinstance(getattr(compile_only, name), int) + + assert compile_only.reset() is compile_only + assert compile_only.total_nonclifford == 0 From 6e862730dbbaf7c450912de6339d33abd0d2282e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 14 Aug 2026 23:56:03 -0600 Subject: [PATCH 11/14] Count every non-Clifford RZ in the deferred ancilla budget, not only injectable gates --- exp/pecos-stab-tn/src/stab_mps/compile.rs | 36 +++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/exp/pecos-stab-tn/src/stab_mps/compile.rs b/exp/pecos-stab-tn/src/stab_mps/compile.rs index bcc0df007..499aef15c 100644 --- a/exp/pecos-stab-tn/src/stab_mps/compile.rs +++ b/exp/pecos-stab-tn/src/stab_mps/compile.rs @@ -200,7 +200,11 @@ impl StabMpsCompile { pub fn advise(&self, ancilla_budget: Option) -> ExecutionAdvice { let base = self.recommend(); let injectable_count = self.injectable_clifford_correction(); - let deferred_ancillas_required = injectable_count; + // Mast consumes one fresh ancilla per non-Clifford RZ regardless of + // angle (mast.rs inject_magic_state); only the correction's + // Cliffordness depends on injectability. Budget feasibility must + // therefore count every non-Clifford RZ, not only T-like ones. + let deferred_ancillas_required = self.nonclifford_rz_total(); let deferred_feasible = ancilla_budget.map(|budget| { usize::try_from(deferred_ancillas_required).is_ok_and(|required| budget >= required) }); @@ -221,7 +225,7 @@ impl StabMpsCompile { } Some(budget) => { warnings.push(format!( - "deferred injection needs one fresh ancilla per injectable gate \ + "deferred injection needs one fresh ancilla per non-Clifford RZ \ ({deferred_ancillas_required} required); the given budget of {budget} is \ insufficient" )); @@ -393,7 +397,8 @@ pub struct ExecutionAdvice { pub injection: InjectionMode, /// Number of gates with Clifford deferred-injection corrections. pub injectable_count: u64, - /// Fresh ancillas required for deferred injection. + /// Fresh ancillas required for deferred injection: one per non-Clifford + /// RZ (injectable or not — Mast injects for every non-Clifford gate). pub deferred_ancillas_required: u64, /// Whether the supplied budget supports deferral, or `None` if unspecified. pub deferred_feasible: Option, @@ -616,10 +621,31 @@ mod tests { assert_eq!(advice.simulator, SimulatorKind::StabMps); assert_eq!(advice.injection, InjectionMode::Direct); assert_eq!(advice.injectable_count, 0); - assert_eq!(advice.deferred_ancillas_required, 0); + // Every non-Clifford RZ consumes a fresh ancilla under deferral, + // injectable or not. + assert_eq!(advice.deferred_ancillas_required, 1); assert_eq!(advice.deferred_feasible, Some(true)); } + #[test] + fn test_advise_mixed_angles_count_all_nonclifford_rz_for_deferral() { + // One T (injectable) plus one arbitrary-angle RZ: deferral consumes + // two ancillas. A budget covering only the injectable gate must not + // be reported feasible for deferred execution. + let mut comp = compile_t_gates(1); + comp.rz(Angle64::from_radians(0.3), &[QubitId(0)]); + + let advice = comp.advise(Some(1)); + assert_eq!(advice.injectable_count, 1); + assert_eq!(advice.deferred_ancillas_required, 2); + assert_eq!(advice.deferred_feasible, Some(false)); + assert_ne!(advice.injection, InjectionMode::Deferred); + + let advice = comp.advise(Some(2)); + assert_eq!(advice.deferred_feasible, Some(true)); + assert_eq!(advice.injection, InjectionMode::Deferred); + } + #[test] fn test_advise_zero_budget_uses_direct_application() { let advice = compile_t_gates(1).advise(Some(0)); @@ -666,7 +692,7 @@ mod tests { assert_eq!(advice.injection, InjectionMode::Immediate); assert_eq!(advice.deferred_feasible, Some(false)); assert!(advice.warnings.iter().any(|warning| { - warning.contains("one fresh ancilla per injectable gate") + warning.contains("one fresh ancilla per non-Clifford RZ") && warning.contains("budget of 1 is insufficient") })); } From 645d46befe9966dc94f2f8b448e68e51f36206ad Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 15 Aug 2026 00:19:05 -0600 Subject: [PATCH 12/14] Standardize bitstring convention to bits[q] == qubit q; auto-flush Python reads; uniform binding validation --- exp/pecos-stab-tn/src/lib.rs | 9 + exp/pecos-stab-tn/src/stab_mps.rs | 97 +++-- exp/pecos-stab-tn/src/stab_mps/mast.rs | 34 +- exp/pecos-stab-tn/tests/verification.rs | 50 ++- .../pecos-rslib-exp/src/compile_bindings.rs | 27 +- python/pecos-rslib-exp/src/mast_bindings.rs | 66 +++- .../pecos-rslib-exp/src/stab_mps_bindings.rs | 357 +++++++++++------- python/pecos-rslib-exp/tests/test_exposure.py | 55 +++ 8 files changed, 474 insertions(+), 221 deletions(-) diff --git a/exp/pecos-stab-tn/src/lib.rs b/exp/pecos-stab-tn/src/lib.rs index 59c9cfc0f..5d7dbdbff 100644 --- a/exp/pecos-stab-tn/src/lib.rs +++ b/exp/pecos-stab-tn/src/lib.rs @@ -28,6 +28,15 @@ //! recover the former behavior explicitly, use `max_bond_dim(64)`, //! `max_truncation_error(0.0)`, and `merge_rz(false)` on the builder. //! +//! # Bitstring convention +//! +//! Every public bitstring API uses qubit-index order: `bits[q]` is the bit +//! for qubit `q`. Consequently, converting a bitstring to the little-endian +//! integer index used by [`stab_mps::StabMps::state_vector`] gives +//! `index = sum(usize::from(bits[q]) << q)`. This convention applies equally +//! to bitstrings accepted by probability and amplitude reads and to rows +//! returned by the samplers. +//! //! # References //! //! - Masot-Llima, Garcia-Saez. "Stabilizer Tensor Networks: Universal Quantum Simulator diff --git a/exp/pecos-stab-tn/src/stab_mps.rs b/exp/pecos-stab-tn/src/stab_mps.rs index 7a1e1c891..4a8a2a097 100644 --- a/exp/pecos-stab-tn/src/stab_mps.rs +++ b/exp/pecos-stab-tn/src/stab_mps.rs @@ -584,6 +584,7 @@ impl StabMps { /// /// `bitstring` has length `num_qubits`; bit k corresponds to qubit k. /// Returns the unnormalized amplitude coefficient. + /// See the crate-level **Bitstring convention** section. /// /// For n ≤ 14 uses `state_vector()` directly. Paper Liu-Clark 2412.17209 /// Section VI.B gives an iterative CAMPS-native algorithm for larger n. @@ -599,12 +600,12 @@ impl StabMps { ); assert!(self.num_qubits <= 14, "amplitude requires n <= 14"); let sv = self.state_vector(); - // Convert bitstring to index per state_vector convention: - // x = Σ_k σ_k * 2^{n-1-k} where σ_0 is MSB. + // Convert bitstring to the state_vector's little-endian index: + // x = Σ_q bitstring[q] * 2^q. let mut idx = 0usize; - for (k, &b) in bitstring.iter().enumerate() { + for (q, &b) in bitstring.iter().enumerate() { if b { - idx |= 1 << (self.num_qubits - 1 - k); + idx |= 1 << q; } } sv[idx] @@ -747,11 +748,8 @@ impl StabMps { // from the correct Born distribution. Skip defensively. continue; } - // Compute via amplitude_iterative. - // Convert bitstring to amplitude_iterative's convention: - // amplitude(bs) treats bs[k] as qubit (n-1-k), so we reverse. - let bs_rev: Vec = bitstring.iter().rev().copied().collect(); - let amp_xpsi = self.amplitude_iterative(&bs_rev); + // Compute ; both APIs use bitstring[q] for qubit q. + let amp_xpsi = self.amplitude_iterative(&bitstring); acc += amp_xpsi / amp_xs; samples_used += 1; } @@ -826,6 +824,8 @@ impl StabMps { /// Complex amplitude ⟨s|Ψ⟩ via iterative forced projection without /// renormalization (Liu-Clark 2412.17209 Section VI.B). + /// `bitstring[q]` specifies qubit `q`; see the crate-level + /// **Bitstring convention** section. /// /// Scales beyond `amplitude`'s n ≤ 14 limit by working directly on the /// MPS + tableau. After forcing all N outcomes, the tableau encodes |s⟩ @@ -850,10 +850,7 @@ impl StabMps { let mut tab = self.tableau.clone(); let mut mps = self.mps.clone(); let n = self.num_qubits; - // Convention: `amplitude(bs)` treats `bs[k]` as qubit (n-1-k), so - // project qubit q with bitstring[n-1-q]. - for q in 0..n { - let s_q = bitstring[n - 1 - q]; + for (q, &s_q) in bitstring.iter().enumerate() { if !measure::project_forced_z_unnormalized(&mut tab, &mut mps, q, s_q) { return Complex64::new(0.0, 0.0); } @@ -863,6 +860,8 @@ impl StabMps { } /// Probability of measuring `bitstring` in the computational basis. + /// `bitstring[q]` specifies qubit `q`; see the crate-level + /// **Bitstring convention** section. /// /// Implements Liu-Clark 2412.17209 Algorithm 3 (Section VI.A): iterative /// forced projection of the CAMPS state. For each qubit k: @@ -885,11 +884,8 @@ impl StabMps { ); let mut tab = self.tableau.clone(); let mut mps = self.mps.clone(); - let n = self.num_qubits; let mut total_prob: f64 = 1.0; - // Convention: bitstring[k] is qubit (n-1-k) (matches `amplitude`). - for q in 0..n { - let s_q = bitstring[n - 1 - q]; + for (q, &s_q) in bitstring.iter().enumerate() { let pi_q = measure::project_forced_z(&mut tab, &mut mps, q, s_q); total_prob *= pi_q; if total_prob < 1e-30 { @@ -1061,8 +1057,10 @@ impl StabMps { /// `state_vector`/`amplitude` reads can drift. If exact state is /// needed, use `StabMpsBuilder::lazy_measure(true)`. /// - **Merged-RZ pending buffer** (`merge_rz = true`): any pending - /// merged-RZ angle has not been applied yet. Call `StabMps::flush()` - /// first. + /// merged-RZ angle has not been applied yet. + /// - **Lazy-measurement deferred operations** (`lazy_measure = true`): + /// queued virtual-frame operations have not been applied to the stored + /// MPS yet. Call `StabMps::flush()` before either kind of read. /// - **Pauli-frame tracking** (`pauli_frame_tracking = true`): the /// frame's Pauli bits are not in the returned state vector. Call /// `StabMps::flush_pauli_frame_to_state()` first for frame-applied @@ -1231,7 +1229,8 @@ impl StabMps { /// (only the internal RNG advances, to ensure each shot uses a /// distinct RNG seed). /// - /// `bitstring[k]` corresponds to qubit `k`'s outcome. + /// `bitstring[k]` corresponds to qubit `k`'s outcome. See the crate-level + /// **Bitstring convention** section. /// /// Useful for shot-based experiments (logical error rate estimation, /// outcome distribution histograms, etc.). @@ -1285,6 +1284,7 @@ impl StabMps { /// qubits `0..num_qubits`. Returned bitstrings therefore use the same /// `bitstring[q] == qubit q` convention as [`Self::sample_bitstring`] and /// are in lexicographic tree order, with copies of each leaf adjacent. + /// See the crate-level **Bitstring convention** section. pub fn sample_bitstrings(&mut self, num_shots: usize) -> Vec> { if num_shots == 0 { return Vec::new(); @@ -1536,9 +1536,9 @@ impl StabMps { /// /// Panics if any MPS gate application fails on a valid site. pub fn flush_pauli_frame_to_state(&mut self) { - // Flush pending RZ first so the tableau C reflects the true Clifford - // the frame will be composed with. - self.flush_all_pending_rz(); + // Materialize the lazy virtual frame and pending RZs first so the + // tableau C and stored MPS are aligned before composing the Pauli frame. + self.flush(); // Collect frame Paulis as a Pauli string. let mut paulis: Vec<(usize, pauli_decomp::PauliKindForDecomp)> = Vec::new(); @@ -1761,12 +1761,13 @@ impl StabMps { self.pragmatic_drift_count } - /// Apply any pending merged-RZ angles to the simulator state. - /// No-op when `merge_rz` is off. Call before `&self` read methods - /// (`state_vector`, `amplitude`, `prob_bitstring`, etc.) if `merge_rz` - /// is on and you want the read to reflect the most recent `rz` calls. - /// Measurements (`mz`) and `reset` flush automatically. + /// Materialize deferred lazy-measurement operations and any pending + /// merged-RZ angles into the simulator state. Call before `&self` read + /// methods (`state_vector`, `amplitude`, `prob_bitstring`, etc.) when + /// either feature is enabled. Measurements (`mz`) and `reset` flush the + /// state needed for their own operation automatically. pub fn flush(&mut self) { + measure::flush_deferred_ops(&mut self.mps, &mut self.deferred_ops); self.flush_all_pending_rz(); } @@ -2911,7 +2912,7 @@ mod tests { stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); let mut max_diff: f64 = 0.0; for idx in 0..16 { - let bs: Vec = (0..4).map(|k| (idx >> (3 - k)) & 1 == 1).collect(); + let bs: Vec = (0..4).map(|q| (idx >> q) & 1 == 1).collect(); let p = stn.prob_bitstring(&bs); let a = stn.amplitude(&bs); let diff = (p - a.norm_sqr()).abs(); @@ -3572,7 +3573,7 @@ mod tests { } } for idx in 0..(1usize << n) { - let bs: Vec = (0..n).map(|k| (idx >> (n - 1 - k)) & 1 == 1).collect(); + let bs: Vec = (0..n).map(|q| (idx >> q) & 1 == 1).collect(); let a_sv = stn.amplitude(&bs); // Probability must match exactly (primary correctness check). let p = stn.prob_bitstring(&bs); @@ -3599,7 +3600,7 @@ mod tests { let mut max_diff: f64 = 0.0; for idx in 0..16 { - let bs: Vec = (0..4).map(|k| (idx >> (3 - k)) & 1 == 1).collect(); + let bs: Vec = (0..4).map(|q| (idx >> q) & 1 == 1).collect(); let a_iter = stn.amplitude_iterative(&bs); let a_sv = stn.amplitude(&bs); let diff = (a_iter - a_sv).norm(); @@ -3627,10 +3628,10 @@ mod tests { stn.cx(&[(q(0), q(15))]); let bs0 = vec![false; n]; let a00 = stn.amplitude_iterative(&bs0); - // bs[k] corresponds to qubit (n-1-k); flip q0 and q15. + // bs[q] corresponds to qubit q; flip q0 and q15. let mut bs1 = vec![false; n]; - bs1[n - 1] = true; - bs1[n - 1 - 15] = true; + bs1[0] = true; + bs1[15] = true; let a11 = stn.amplitude_iterative(&bs1); eprintln!("n=30 Bell: a(0)={a00:.4}, a(q0,q15=1)={a11:.4}"); assert!((a00.norm_sqr() - 0.5).abs() < 1e-9); @@ -3651,7 +3652,7 @@ mod tests { // Check every bitstring. let mut max_diff = 0f64; for idx in 0..16 { - let bs: Vec = (0..4).map(|k| (idx >> (3 - k)) & 1 == 1).collect(); + let bs: Vec = (0..4).map(|q| (idx >> q) & 1 == 1).collect(); let p = stn.prob_bitstring(&bs); let a = stn.amplitude(&bs); let diff = (p - a.norm_sqr()).abs(); @@ -3671,11 +3672,11 @@ mod tests { let mut stn = StabMps::with_seed(n, 5); stn.h(&[q(0)]); stn.cx(&[(q(0), q(15))]); - // bs[k] corresponds to qubit (n-1-k). Bell correlator: q0, q15 same. + // bs[q] corresponds to qubit q. Bell correlator: q0, q15 same. let bs0 = vec![false; n]; let mut bs1 = vec![false; n]; - bs1[n - 1] = true; - bs1[n - 1 - 15] = true; + bs1[0] = true; + bs1[15] = true; let p00 = stn.prob_bitstring(&bs0); let p11 = stn.prob_bitstring(&bs1); eprintln!("n=30 Bell: P(all0)={p00:.3} P(q0,q15=1)={p11:.3}"); @@ -3683,7 +3684,7 @@ mod tests { assert!((p11 - 0.5).abs() < 1e-9); // Disallowed: q0=1, q15=0. let mut bs_bad = vec![false; n]; - bs_bad[n - 1] = true; + bs_bad[0] = true; assert!(stn.prob_bitstring(&bs_bad).abs() < 1e-9); } @@ -5361,6 +5362,24 @@ mod tests { assert!(stn.is_state_exact(), "after frame flush → exact"); } + #[test] + fn test_flush_materializes_lazy_deferred_operations() { + let mut stn = StabMps::builder(2).seed(19).lazy_measure(true).build(); + stn.h(&[QubitId(1)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(1)]); + stn.sz(&[QubitId(0)]); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let _ = stn.mz(&[QubitId(0)]); + + assert!(!stn.is_state_exact(), "lazy operations should be pending"); + stn.flush(); + assert!( + stn.is_state_exact(), + "flush should materialize lazy operations" + ); + } + #[test] fn test_pragmatic_drift_count_tracks_non_lazy_pre_reduce() { // Build a state where col_x for the measured qubit has multiple diff --git a/exp/pecos-stab-tn/src/stab_mps/mast.rs b/exp/pecos-stab-tn/src/stab_mps/mast.rs index d460982cf..5e7725f56 100644 --- a/exp/pecos-stab-tn/src/stab_mps/mast.rs +++ b/exp/pecos-stab-tn/src/stab_mps/mast.rs @@ -86,7 +86,7 @@ pub struct Mast { /// Number of data qubits. num_data_qubits: usize, /// Maximum number of non-Clifford gates (= number of ancilla slots). - _max_non_clifford: usize, + max_non_clifford: usize, /// Total qubits = data + ancillas. total_qubits: usize, /// The underlying stabilizer tableau for all qubits. @@ -130,12 +130,19 @@ pub struct Mast { impl Mast { /// Create a MAST simulator with `num_qubits` data qubits and room for /// `max_non_clifford` non-Clifford gates. + /// + /// # Panics + /// + /// Applying more injections than `max_non_clifford` panics. Use + /// [`Self::remaining_injections`] to inspect capacity; compile-only + /// [`super::compile::StabMpsCompile::advise`] reports the required deferred + /// ancilla capacity for an analyzed circuit. #[must_use] pub fn new(num_qubits: usize, max_non_clifford: usize) -> Self { let total = num_qubits + max_non_clifford; Self { num_data_qubits: num_qubits, - _max_non_clifford: max_non_clifford, + max_non_clifford, total_qubits: total, tableau: SparseStabY::new(total).with_destab_sign_tracking(), mps: Mps::new(total, MpsConfig::default()), @@ -159,12 +166,17 @@ impl Mast { } /// Create with a specific seed. + /// + /// # Panics + /// + /// Applying more injections than `max_non_clifford` panics. See + /// [`Self::new`] for capacity-planning details. #[must_use] pub fn with_seed(num_qubits: usize, max_non_clifford: usize, seed: u64) -> Self { let total = num_qubits + max_non_clifford; Self { num_data_qubits: num_qubits, - _max_non_clifford: max_non_clifford, + max_non_clifford, total_qubits: total, tableau: SparseStabY::with_seed(total, seed).with_destab_sign_tracking(), mps: Mps::new(total, MpsConfig::default()), @@ -261,9 +273,10 @@ impl Mast { self.inject_magic_state(theta, q); } - /// Flush all pending merged RZ. Public; useful before read operations - /// when `merge_rz` is on. + /// Materialize lazy-measurement deferred operations and all pending merged + /// RZ rotations. Public; useful before read operations. pub fn flush(&mut self) { + super::measure::flush_deferred_ops(&mut self.mps, &mut self.deferred_ops); if !self.merge_rz { return; } @@ -282,6 +295,13 @@ impl Mast { self.next_ancilla - self.num_data_qubits } + /// Number of additional magic-state injections available before the + /// configured `max_non_clifford` capacity is exhausted. + #[must_use] + pub fn remaining_injections(&self) -> usize { + self.max_non_clifford - self.num_ancillas_used() + } + #[must_use] pub fn max_bond_dim(&self) -> usize { self.mps.max_bond_dim() @@ -654,15 +674,19 @@ mod tests { fn test_mast_single_t_gate() { // T gate uses magic state injection let mut mast = Mast::new(1, 4); + assert_eq!(mast.remaining_injections(), 4); mast.h(&[QubitId(0)]); mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); assert_eq!(mast.num_ancillas_used(), 1); + assert_eq!(mast.remaining_injections(), 3); // Bond dim should be low -- the RZ on the ancilla is a single-site gate assert!( mast.max_bond_dim() <= 2, "bond dim should be low, got {}", mast.max_bond_dim() ); + mast.reset(); + assert_eq!(mast.remaining_injections(), 4); } #[test] diff --git a/exp/pecos-stab-tn/tests/verification.rs b/exp/pecos-stab-tn/tests/verification.rs index f32c0bbc9..c55b3c711 100644 --- a/exp/pecos-stab-tn/tests/verification.rs +++ b/exp/pecos-stab-tn/tests/verification.rs @@ -2050,10 +2050,7 @@ fn bitstring_counts(shots: &[Vec], num_qubits: usize) -> Vec { fn exact_stn_probabilities(stn: &StabMps, num_qubits: usize) -> Vec { (0..1usize << num_qubits) .map(|outcome| { - // `prob_bitstring` uses [q_(n-1), ..., q_0], whereas both - // samplers return [q_0, ..., q_(n-1)]. let bits = (0..num_qubits) - .rev() .map(|q| ((outcome >> q) & 1) != 0) .collect::>(); stn.prob_bitstring(&bits) @@ -2061,6 +2058,37 @@ fn exact_stn_probabilities(stn: &StabMps, num_qubits: usize) -> Vec { .collect() } +#[test] +fn test_sampled_bitstring_round_trips_through_probability_and_amplitude() { + let mut stn = StabMps::with_seed(3, 0xB17_0ADE); + stn.h(&[QubitId(0)]); + stn.x(&[QubitId(1)]); + + let rows = stn.sample_bitstrings(64); + let state_vector = stn.state_vector(); + assert!(rows.iter().any(|row| row[0])); + assert!(rows.iter().any(|row| !row[0])); + + for bits in rows { + let index = bits + .iter() + .enumerate() + .fold(0usize, |index, (q, &bit)| index | (usize::from(bit) << q)); + let expected_amplitude = state_vector[index]; + let actual_probability = stn.prob_bitstring(&bits); + assert!( + (actual_probability - expected_amplitude.norm_sqr()).abs() <= 1e-12, + "bits={bits:?}: probability={actual_probability}, expected={}", + expected_amplitude.norm_sqr() + ); + let actual_amplitude = stn.amplitude(&bits); + assert!( + (actual_amplitude - expected_amplitude).norm() <= 1e-12, + "bits={bits:?}: amplitude={actual_amplitude}, expected={expected_amplitude}" + ); + } +} + #[test] fn test_prefix_tree_sampler_random_clifford_t_distributions() { // Mirrors the per-outcome five-sigma pattern in @@ -2210,7 +2238,6 @@ fn test_mast_min_span_matches_stn_exact_random_probabilities() { let exact_probs = (0..num_outcomes) .map(|outcome| { let bits = (0..num_qubits) - .rev() .map(|q| ((outcome >> q) & 1) != 0) .collect::>(); stn.prob_bitstring(&bits) @@ -2269,11 +2296,9 @@ fn test_mast_matches_stn_exact_probabilities_2q() { stn_for_probs.h(&[QubitId(1)]); stn_for_probs.rz(t, &[QubitId(1)]); stn_for_probs.flush(); - // prob_bitstring is MSB-first: bitstring[k] is qubit (n-1-k). For a - // LSB-first integer index `i` (q_k = (i >> k) & 1), bitstring = - // [q_{n-1}, q_{n-2}, ..., q_0]. + // bitstring[q] is qubit q, matching the LSB-first integer index. for (i, ep) in exact_probs.iter_mut().enumerate().take(4) { - let bits = [(i & 2) != 0, (i & 1) != 0]; + let bits = [(i & 1) != 0, (i & 2) != 0]; *ep = stn_for_probs.prob_bitstring(&bits); } let total: f64 = exact_probs.iter().sum(); @@ -2326,9 +2351,9 @@ fn test_mast_matches_stn_exact_probabilities_3q() { stn.rz(t, &[QubitId(2)]); stn.cx(&[(QubitId(1), QubitId(2))]); stn.flush(); - // prob_bitstring is MSB-first: bitstring = [q_{n-1}, ..., q_0]. + // bitstring[q] is qubit q, matching the LSB-first integer index. for (i, ep) in exact_probs.iter_mut().enumerate().take(8) { - let bits = [(i & 4) != 0, (i & 2) != 0, (i & 1) != 0]; + let bits = [(i & 1) != 0, (i & 2) != 0, (i & 4) != 0]; *ep = stn.prob_bitstring(&bits); } let total: f64 = exact_probs.iter().sum(); @@ -2412,10 +2437,7 @@ fn test_numerical_flag_redetection_random_probabilities() { let state_vector = stn.state_vector(); for (idx, amplitude) in state_vector.iter().enumerate() { - let bits = (0..n) - .rev() - .map(|q| ((idx >> q) & 1) != 0) - .collect::>(); + let bits = (0..n).map(|q| ((idx >> q) & 1) != 0).collect::>(); let actual = stn.prob_bitstring(&bits); let expected = amplitude.norm_sqr(); assert!( diff --git a/python/pecos-rslib-exp/src/compile_bindings.rs b/python/pecos-rslib-exp/src/compile_bindings.rs index c7437905e..d3f270dc7 100644 --- a/python/pecos-rslib-exp/src/compile_bindings.rs +++ b/python/pecos-rslib-exp/src/compile_bindings.rs @@ -22,14 +22,20 @@ pub struct PyStabMpsCompile { } impl PyStabMpsCompile { - fn check_qubit(&self, q: usize, method: &str) -> PyResult<()> { + fn check_qubit(&self, q: isize, method: &str) -> PyResult { + let Ok(q) = usize::try_from(q) else { + return Err(PyErr::new::(format!( + "{method}: qubit {q} out of bounds (num_qubits={})", + self.inner.num_qubits() + ))); + }; if q >= self.inner.num_qubits() { return Err(PyErr::new::(format!( "{method}: qubit {q} out of bounds (num_qubits={})", self.inner.num_qubits() ))); } - Ok(()) + Ok(q) } } @@ -149,10 +155,10 @@ impl PyStabMpsCompile { fn run_1q_gate( &mut self, symbol: &str, - location: usize, + location: isize, params: Option<&Bound<'_, PyDict>>, ) -> PyResult> { - self.check_qubit(location, symbol)?; + let location = self.check_qubit(location, symbol)?; let q = &[QubitId(location)]; match symbol { "I" => Ok(None), @@ -269,10 +275,13 @@ impl PyStabMpsCompile { "Two-qubit gate requires exactly 2 qubit locations", )); } - let q1: usize = location.get_item(0)?.extract()?; - let q2: usize = location.get_item(1)?.extract()?; - self.check_qubit(q1, symbol)?; - self.check_qubit(q2, symbol)?; + let q1 = self.check_qubit(location.get_item(0)?.extract::()?, symbol)?; + let q2 = self.check_qubit(location.get_item(1)?.extract::()?, symbol)?; + if q1 == q2 { + return Err(PyErr::new::( + "Two-qubit gate requires distinct qubit locations", + )); + } let pair = &[(QubitId(q1), QubitId(q2))]; match symbol { "CX" | "CNOT" => { @@ -344,7 +353,7 @@ impl PyStabMpsCompile { }; let result = match loc_tuple.len() { 1 => { - let qubit: usize = loc_tuple.get_item(0)?.extract()?; + let qubit: isize = loc_tuple.get_item(0)?.extract()?; self.run_1q_gate(symbol, qubit, params)? } 2 => self.run_2q_gate(symbol, &loc_tuple, params)?, diff --git a/python/pecos-rslib-exp/src/mast_bindings.rs b/python/pecos-rslib-exp/src/mast_bindings.rs index ef4044ed3..bd2cd519b 100644 --- a/python/pecos-rslib-exp/src/mast_bindings.rs +++ b/python/pecos-rslib-exp/src/mast_bindings.rs @@ -16,20 +16,34 @@ use pecos_stab_tn::stab_mps::mast::{Mast, ProjectionOrder}; use pyo3::prelude::*; use pyo3::types::{PyDict, PySet, PyTuple}; +/// Python MAST simulator. +/// +/// Telemetry and capacity reads materialize pending lazy-measurement operations +/// and merged RZ rotations before returning. Exceeding the constructor's +/// `max_non_clifford` capacity raises a `PanicException`; +/// `remaining_injections` exposes available capacity, and +/// `StabMpsCompile.advise()` reports the required capacity for an analyzed +/// circuit. #[pyclass(name = "Mast", module = "pecos_rslib_exp")] pub struct PyMast { inner: Mast, } impl PyMast { - fn check_qubit(&self, q: usize, method: &str) -> PyResult<()> { + fn check_qubit(&self, q: isize, method: &str) -> PyResult { + let Ok(q) = usize::try_from(q) else { + return Err(PyErr::new::(format!( + "{method}: qubit {q} out of bounds (num_qubits={})", + self.inner.num_qubits() + ))); + }; if q >= self.inner.num_qubits() { return Err(PyErr::new::(format!( "{method}: qubit {q} out of bounds (num_qubits={})", self.inner.num_qubits() ))); } - Ok(()) + Ok(q) } } @@ -41,7 +55,10 @@ impl PyMast { /// `projection_order` accepts `"min_span"` or `"input"`; `None` uses /// the Rust default (`"min_span"`). Boolean options are tri-state: /// `None` preserves the Rust default, while an explicit bool calls the - /// corresponding Rust setter. + /// corresponding Rust setter. Exceeding `max_non_clifford` raises a + /// `PanicException`; inspect `remaining_injections` before adding work. + /// `StabMpsCompile.advise()` reports the required deferred capacity for an + /// analyzed circuit. #[pyo3(signature = ( num_qubits, max_non_clifford, @@ -60,6 +77,11 @@ impl PyMast { projection_order: Option<&str>, numerical_flag_redetection: Option, ) -> PyResult { + if num_qubits.checked_add(max_non_clifford).is_none() { + return Err(PyErr::new::( + "num_qubits + max_non_clifford exceeds platform capacity", + )); + } let mut mast = if let Some(s) = seed { Mast::with_seed(num_qubits, max_non_clifford, s) } else { @@ -105,17 +127,26 @@ impl PyMast { } #[getter] - fn num_ancillas_used(&self) -> usize { + fn num_ancillas_used(&mut self) -> usize { + self.inner.flush(); self.inner.num_ancillas_used() } #[getter] - fn max_bond_dim(&self) -> usize { + fn remaining_injections(&mut self) -> usize { + self.inner.flush(); + self.inner.remaining_injections() + } + + #[getter] + fn max_bond_dim(&mut self) -> usize { + self.inner.flush(); self.inner.max_bond_dim() } /// Diagnostics for deferred magic-state projections since reset. - fn projection_records(&self, py: Python<'_>) -> PyResult>> { + fn projection_records(&mut self, py: Python<'_>) -> PyResult>> { + self.inner.flush(); self.inner .projection_records() .iter() @@ -132,12 +163,14 @@ impl PyMast { } #[getter] - fn projection_peak_bond(&self) -> usize { + fn projection_peak_bond(&mut self) -> usize { + self.inner.flush(); self.inner.projection_peak_bond() } /// Runtime non-Clifford path counters. - fn stats(&self, py: Python<'_>) -> PyResult> { + fn stats(&mut self, py: Python<'_>) -> PyResult> { + self.inner.flush(); crate::stab_mps_stats_to_dict(py, &self.inner.stats) } @@ -155,10 +188,10 @@ impl PyMast { fn run_1q_gate( &mut self, symbol: &str, - location: usize, + location: isize, params: Option<&Bound<'_, PyDict>>, ) -> PyResult> { - self.check_qubit(location, symbol)?; + let location = self.check_qubit(location, symbol)?; let q = &[QubitId(location)]; match symbol { "I" => Ok(None), @@ -275,10 +308,13 @@ impl PyMast { "Two-qubit gate requires exactly 2 qubit locations", )); } - let q1: usize = location.get_item(0)?.extract()?; - let q2: usize = location.get_item(1)?.extract()?; - self.check_qubit(q1, symbol)?; - self.check_qubit(q2, symbol)?; + let q1 = self.check_qubit(location.get_item(0)?.extract::()?, symbol)?; + let q2 = self.check_qubit(location.get_item(1)?.extract::()?, symbol)?; + if q1 == q2 { + return Err(PyErr::new::( + "Two-qubit gate requires distinct qubit locations", + )); + } let pair = &[(QubitId(q1), QubitId(q2))]; match symbol { "CX" | "CNOT" => { @@ -350,7 +386,7 @@ impl PyMast { }; let result = match loc_tuple.len() { 1 => { - let qubit: usize = loc_tuple.get_item(0)?.extract()?; + let qubit: isize = loc_tuple.get_item(0)?.extract()?; self.run_1q_gate(symbol, qubit, params)? } 2 => self.run_2q_gate(symbol, &loc_tuple, params)?, diff --git a/python/pecos-rslib-exp/src/stab_mps_bindings.rs b/python/pecos-rslib-exp/src/stab_mps_bindings.rs index d9e5e6cc9..daf8ab2b4 100644 --- a/python/pecos-rslib-exp/src/stab_mps_bindings.rs +++ b/python/pecos-rslib-exp/src/stab_mps_bindings.rs @@ -17,8 +17,15 @@ use pecos_core::{Angle64, Pauli, PauliOperator, PauliString, QuarterPhase, Qubit use pecos_simulators::{ArbitraryRotationGateable, CHForm, CliffordGateable, QuantumSimulator}; use pecos_stab_tn::stab_mps::{PauliKind, StabMps}; use pyo3::prelude::*; -use pyo3::types::{PyDict, PyList, PySet, PyTuple}; - +use pyo3::types::{PyBool, PyDict, PyList, PySet, PyTuple}; + +/// Python stabilizer-MPS simulator. +/// +/// Read methods materialize pending lazy-measurement operations and merged RZ +/// rotations before returning. Bitstrings use qubit-index order: `bits[q]` is +/// the bit for qubit `q`. The `for_qec` constructor keyword is an enable-only +/// preset switch: `True` applies it, while `False` and `None` are identical +/// no-ops. #[pyclass(name = "StabMps", module = "pecos_rslib_exp")] pub struct PyStabMps { inner: StabMps, @@ -86,7 +93,7 @@ fn apply_stabilizer_prep_gate( fn stabilizer_state_from_generators( num_qubits: usize, - generators: Vec>, + generators: Vec>, seed: u64, ) -> PyResult { if generators.len() != num_qubits { @@ -101,8 +108,13 @@ fn stabilizer_state_from_generators( let mut seen = vec![false; num_qubits]; let mut paulis = Vec::with_capacity(generator.len()); for (q, value) in generator { + let Ok(q) = usize::try_from(q) else { + return Err(PyErr::new::(format!( + "stabilizer generator {generator_index}: qubit {q} out of bounds (num_qubits={num_qubits})" + ))); + }; if q >= num_qubits { - return Err(PyErr::new::(format!( + return Err(PyErr::new::(format!( "stabilizer generator {generator_index}: qubit {q} out of bounds (num_qubits={num_qubits})" ))); } @@ -260,24 +272,92 @@ fn stabilizer_state_from_generators( } impl PyStabMps { - fn check_qubit(&self, q: usize, method: &str) -> PyResult<()> { + fn check_qubit(&self, q: isize, method: &str) -> PyResult { + let Ok(q) = usize::try_from(q) else { + return Err(PyErr::new::(format!( + "{method}: qubit {q} out of bounds (num_qubits={})", + self.inner.num_qubits() + ))); + }; if q >= self.inner.num_qubits() { return Err(PyErr::new::(format!( "{method}: qubit {q} out of bounds (num_qubits={})", self.inner.num_qubits() ))); } + Ok(q) + } + + fn check_probability(p: f64, method: &str) -> PyResult<()> { + if !(0.0..=1.0).contains(&p) { + return Err(PyErr::new::(format!( + "{method}: probability must be finite and in [0, 1]" + ))); + } Ok(()) } + + fn bitstring(&self, value: &Bound<'_, PyAny>, method: &str) -> PyResult> { + let iterator = value.try_iter().map_err(|_| { + PyErr::new::(format!( + "{method}: bitstring must be an iterable of bool values" + )) + })?; + let mut bits = Vec::new(); + for (index, item) in iterator.enumerate() { + let item = item?; + if !item.is_instance_of::() { + return Err(PyErr::new::(format!( + "{method}: bitstring item {index} must be bool" + ))); + } + bits.push(item.extract::()?); + } + if bits.len() != self.inner.num_qubits() { + return Err(PyErr::new::(format!( + "{method}: bitstring length {}, expected {}", + bits.len(), + self.inner.num_qubits() + ))); + } + Ok(bits) + } + + fn pauli_kind(value: &str) -> PyResult { + match value { + "X" => Ok(PauliKind::X), + "Y" => Ok(PauliKind::Y), + "Z" => Ok(PauliKind::Z), + _ => Err(PyErr::new::(format!( + "Unknown Pauli: {value}. Use 'X', 'Y', or 'Z'." + ))), + } + } + + fn pauli_string( + &self, + values: Vec<(isize, String)>, + method: &str, + ) -> PyResult> { + values + .into_iter() + .map(|(q, value)| { + let q = self.check_qubit(q, method)?; + Ok((q, Self::pauli_kind(&value)?)) + }) + .collect() + } } #[pymethods] impl PyStabMps { /// Create a stabilizer-MPS simulator. /// - /// Boolean options are tri-state: `None` preserves the Rust builder - /// default, while `True` or `False` explicitly enables or disables the - /// option. `max_truncation_error=None` preserves the builder default of + /// Boolean options other than `for_qec` are tri-state: `None` preserves + /// the Rust builder default, while `True` or `False` explicitly enables or + /// disables the option. `for_qec` is enable-only: `True` applies the + /// preset, while `False` and `None` are identical no-ops. + /// `max_truncation_error=None` preserves the builder default of /// `1e-8`; a float overrides it, and `0.0` disables adaptive truncation /// while retaining the SVD cutoff and bond cap. #[new] @@ -358,12 +438,14 @@ impl PyStabMps { } #[getter] - fn max_bond_dim(&self) -> usize { + fn max_bond_dim(&mut self) -> usize { + self.inner.flush(); self.inner.max_bond_dim() } #[getter] - fn truncation_error(&self) -> f64 { + fn truncation_error(&mut self) -> f64 { + self.inner.flush(); self.inner.truncation_error() } @@ -384,35 +466,37 @@ impl PyStabMps { self.inner.flush_pauli_frame_to_state(); } - fn state_vector(&self, py: Python<'_>) -> PyResult> { + fn state_vector(&mut self, py: Python<'_>) -> PyResult> { + if self.inner.num_qubits() > 14 { + return Err(PyErr::new::( + "state_vector requires n <= 14", + )); + } + self.inner.flush(); let sv = self.inner.state_vector(); let list: Vec<(f64, f64)> = sv.iter().map(|c| (c.re, c.im)).collect(); Ok(PyList::new(py, &list)?.unbind()) } - /// Wavefunction amplitude for a computational-basis bitstring. - fn amplitude(&self, bitstring: Vec) -> PyResult<(f64, f64)> { - if bitstring.len() != self.inner.num_qubits() { - return Err(PyErr::new::( - "bitstring length mismatch", - )); - } + /// Wavefunction amplitude for a computational-basis bitstring, with + /// `bitstring[q]` specifying qubit `q`. + fn amplitude(&mut self, bitstring: &Bound<'_, PyAny>) -> PyResult<(f64, f64)> { + let bitstring = self.bitstring(bitstring, "amplitude")?; if self.inner.num_qubits() > 14 { return Err(PyErr::new::( "amplitude requires n <= 14", )); } + self.inner.flush(); let amplitude = self.inner.amplitude(&bitstring); Ok((amplitude.re, amplitude.im)) } - /// CAMPS-native iterative wavefunction amplitude. - fn amplitude_iterative(&self, bitstring: Vec) -> PyResult<(f64, f64)> { - if bitstring.len() != self.inner.num_qubits() { - return Err(PyErr::new::( - "bitstring length mismatch", - )); - } + /// CAMPS-native iterative wavefunction amplitude, with `bitstring[q]` + /// specifying qubit `q`. + fn amplitude_iterative(&mut self, bitstring: &Bound<'_, PyAny>) -> PyResult<(f64, f64)> { + let bitstring = self.bitstring(bitstring, "amplitude_iterative")?; + self.inner.flush(); let amplitude = self.inner.amplitude_iterative(&bitstring); Ok((amplitude.re, amplitude.im)) } @@ -421,8 +505,8 @@ impl PyStabMps { /// by a complete set of independent +1 Pauli generators. #[pyo3(signature = (stabilizers, *, num_samples, rng_seed=None))] fn overlap_with_stabilizer( - &self, - stabilizers: Vec>, + &mut self, + stabilizers: Vec>, num_samples: usize, rng_seed: Option, ) -> PyResult<(f64, f64)> { @@ -441,18 +525,23 @@ impl PyStabMps { stabilizers, rng_seed.unwrap_or(42), )?; + self.inner.flush(); let overlap = self .inner .overlap_with_stabilizer(&state, num_samples, rng_seed); Ok((overlap.re, overlap.im)) } - fn prob_bitstring(&self, bitstring: Vec) -> f64 { - self.inner.prob_bitstring(&bitstring) + /// Probability of a computational-basis bitstring, with `bitstring[q]` + /// specifying qubit `q`. + fn prob_bitstring(&mut self, bitstring: &Bound<'_, PyAny>) -> PyResult { + let bitstring = self.bitstring(bitstring, "prob_bitstring")?; + self.inner.flush(); + Ok(self.inner.prob_bitstring(&bitstring)) } /// Second Renyi entropy from the full state vector. - fn renyi_s2(&self, cut: usize) -> PyResult { + fn renyi_s2(&mut self, cut: usize) -> PyResult { let num_qubits = self.inner.num_qubits(); if cut == 0 || cut >= num_qubits { return Err(PyErr::new::( @@ -464,18 +553,21 @@ impl PyStabMps { "renyi_s2 requires n <= 14 (uses full state vector)", )); } + self.inner.flush(); Ok(self.inner.renyi_s2(cut)) } /// Second Renyi entropy via Pauli coefficient enumeration. - fn s2_pce(&self, cut: usize) -> PyResult { + fn s2_pce(&mut self, cut: usize) -> PyResult { + self.inner.flush(); self.inner .s2_pce(cut) .map_err(PyErr::new::) } /// Second Renyi entropy via the PCMPS hierarchy. - fn s2_pcmps(&self, cut: usize) -> PyResult { + fn s2_pcmps(&mut self, cut: usize) -> PyResult { + self.inner.flush(); self.inner .s2_pcmps(cut) .map_err(PyErr::new::) @@ -487,189 +579,173 @@ impl PyStabMps { } #[getter] - fn bond_cap_hits(&self) -> u64 { + fn bond_cap_hits(&mut self) -> u64 { + self.inner.flush(); self.inner.bond_cap_hits() } - fn ofd_nullity(&self) -> usize { + fn ofd_nullity(&mut self) -> usize { + self.inner.flush(); self.inner.ofd_nullity() } - fn theoretical_min_bond_dim(&self) -> usize { + fn theoretical_min_bond_dim(&mut self) -> usize { + self.inner.flush(); self.inner.theoretical_min_bond_dim() } - fn ofd_disentangled_count(&self) -> usize { + fn ofd_disentangled_count(&mut self) -> usize { + self.inner.flush(); self.inner.ofd_disentangled_count() } - fn ofd_total_absorbed(&self) -> u64 { + fn ofd_total_absorbed(&mut self) -> u64 { + self.inner.flush(); u64::try_from(self.inner.ofd_total_absorbed()) .expect("usize fits in u64 on supported Python targets") } /// Runtime non-Clifford path counters. - fn stats(&self, py: Python<'_>) -> PyResult> { + fn stats(&mut self, py: Python<'_>) -> PyResult> { + self.inner.flush(); crate::stab_mps_stats_to_dict(py, &self.inner.stats) } // ---- QEC helpers ---- - fn reset_qubit(&mut self, q: usize) -> PyResult { - self.check_qubit(q, "reset_qubit")?; + fn reset_qubit(&mut self, q: isize) -> PyResult { + let q = self.check_qubit(q, "reset_qubit")?; Ok(self.inner.reset_qubit(QubitId(q))) } - fn pz(&mut self, q: usize) -> PyResult<()> { - self.check_qubit(q, "pz")?; + fn pz(&mut self, q: isize) -> PyResult<()> { + let q = self.check_qubit(q, "pz")?; self.inner.pz(QubitId(q)); Ok(()) } - fn px(&mut self, q: usize) -> PyResult<()> { - self.check_qubit(q, "px")?; + fn px(&mut self, q: isize) -> PyResult<()> { + let q = self.check_qubit(q, "px")?; self.inner.px(QubitId(q)); Ok(()) } - fn inject_x_in_frame(&mut self, q: usize) -> PyResult<()> { - self.check_qubit(q, "inject_x_in_frame")?; + fn inject_x_in_frame(&mut self, q: isize) -> PyResult<()> { + let q = self.check_qubit(q, "inject_x_in_frame")?; self.inner.inject_x_in_frame(QubitId(q)); Ok(()) } - fn inject_y_in_frame(&mut self, q: usize) -> PyResult<()> { - self.check_qubit(q, "inject_y_in_frame")?; + fn inject_y_in_frame(&mut self, q: isize) -> PyResult<()> { + let q = self.check_qubit(q, "inject_y_in_frame")?; self.inner.inject_y_in_frame(QubitId(q)); Ok(()) } - fn inject_z_in_frame(&mut self, q: usize) -> PyResult<()> { - self.check_qubit(q, "inject_z_in_frame")?; + fn inject_z_in_frame(&mut self, q: isize) -> PyResult<()> { + let q = self.check_qubit(q, "inject_z_in_frame")?; self.inner.inject_z_in_frame(QubitId(q)); Ok(()) } - fn inject_paulis_in_frame(&mut self, paulis: Vec<(usize, String)>) -> PyResult<()> { - let converted: Vec<(QubitId, PauliKind)> = paulis + fn inject_paulis_in_frame(&mut self, paulis: Vec<(isize, String)>) -> PyResult<()> { + let converted: Vec<(QubitId, PauliKind)> = self + .pauli_string(paulis, "inject_paulis_in_frame")? .into_iter() - .map(|(q, s)| { - let kind = match s.as_str() { - "X" => PauliKind::X, - "Y" => PauliKind::Y, - "Z" => PauliKind::Z, - _ => { - return Err(PyErr::new::(format!( - "Unknown Pauli kind: {s}. Use 'X', 'Y', or 'Z'." - ))); - } - }; - Ok((QubitId(q), kind)) - }) - .collect::>>()?; + .map(|(q, kind)| (QubitId(q), kind)) + .collect(); self.inner.inject_paulis_in_frame(&converted); Ok(()) } - fn frame_x_bit(&self, q: usize) -> bool { - self.inner.frame_x_bit(QubitId(q)) + fn frame_x_bit(&self, q: isize) -> PyResult { + let q = self.check_qubit(q, "frame_x_bit")?; + Ok(self.inner.frame_x_bit(QubitId(q))) } - fn frame_z_bit(&self, q: usize) -> bool { - self.inner.frame_z_bit(QubitId(q)) + fn frame_z_bit(&self, q: isize) -> PyResult { + let q = self.check_qubit(q, "frame_z_bit")?; + Ok(self.inner.frame_z_bit(QubitId(q))) } - fn apply_depolarizing(&mut self, q: usize, p: f64) -> Option { - self.inner + fn apply_depolarizing(&mut self, q: isize, p: f64) -> PyResult> { + let q = self.check_qubit(q, "apply_depolarizing")?; + Self::check_probability(p, "apply_depolarizing")?; + Ok(self + .inner .apply_depolarizing(QubitId(q), p) - .map(|k| format!("{k:?}")) + .map(|k| format!("{k:?}"))) } - fn apply_bit_flip(&mut self, q: usize, p: f64) -> PyResult { - self.check_qubit(q, "apply_bit_flip")?; + fn apply_bit_flip(&mut self, q: isize, p: f64) -> PyResult { + let q = self.check_qubit(q, "apply_bit_flip")?; + Self::check_probability(p, "apply_bit_flip")?; Ok(self.inner.apply_bit_flip(QubitId(q), p)) } - fn apply_phase_flip(&mut self, q: usize, p: f64) -> PyResult { - self.check_qubit(q, "apply_phase_flip")?; + fn apply_phase_flip(&mut self, q: isize, p: f64) -> PyResult { + let q = self.check_qubit(q, "apply_phase_flip")?; + Self::check_probability(p, "apply_phase_flip")?; Ok(self.inner.apply_phase_flip(QubitId(q), p)) } - fn apply_depolarizing_all(&mut self, qubits: Vec, p: f64) { + fn apply_depolarizing_all(&mut self, qubits: Vec, p: f64) -> PyResult<()> { + let qubits = qubits + .into_iter() + .map(|q| self.check_qubit(q, "apply_depolarizing_all")) + .collect::>>()?; + Self::check_probability(p, "apply_depolarizing_all")?; let qs: Vec = qubits.into_iter().map(QubitId).collect(); self.inner.apply_depolarizing_all(&qs, p); + Ok(()) } fn extract_syndromes( &mut self, - generators: Vec>, - ancilla_qubits: Vec, + generators: Vec>, + ancilla_qubits: Vec, ) -> PyResult> { + if generators.len() != ancilla_qubits.len() { + return Err(PyErr::new::( + "extract_syndromes: one ancilla per generator required", + )); + } + let ancilla_qubits = ancilla_qubits + .into_iter() + .map(|q| self.check_qubit(q, "extract_syndromes")) + .collect::>>()?; let gens: Vec> = generators .into_iter() - .map(|g| { - g.into_iter() - .map(|(q, s)| { - let kind = match s.as_str() { - "X" => PauliKind::X, - "Y" => PauliKind::Y, - "Z" => PauliKind::Z, - _ => { - return Err(PyErr::new::( - format!("Unknown Pauli: {s}"), - )); - } - }; - Ok((q, kind)) - }) - .collect::>>() - }) + .map(|generator| self.pauli_string(generator, "extract_syndromes")) .collect::>>()?; + for (generator, &ancilla) in gens.iter().zip(&ancilla_qubits) { + if generator.iter().any(|&(q, _)| q == ancilla) { + return Err(PyErr::new::(format!( + "extract_syndromes: ancilla {ancilla} overlaps with generator data qubit" + ))); + } + } let ancs: Vec = ancilla_qubits.into_iter().map(QubitId).collect(); Ok(self.inner.extract_syndromes(&gens, &ancs)) } - fn pauli_expectation(&self, pauli_string: Vec<(usize, String)>) -> PyResult { - let ps: Vec<(usize, PauliKind)> = pauli_string - .into_iter() - .map(|(q, s)| { - let kind = match s.as_str() { - "X" => PauliKind::X, - "Y" => PauliKind::Y, - "Z" => PauliKind::Z, - _ => { - return Err(PyErr::new::(format!( - "Unknown Pauli: {s}" - ))); - } - }; - Ok((q, kind)) - }) - .collect::>>()?; + fn pauli_expectation(&mut self, pauli_string: Vec<(isize, String)>) -> PyResult { + let ps = self.pauli_string(pauli_string, "pauli_expectation")?; + self.inner.flush(); Ok(self.inner.pauli_expectation(&ps)) } - fn code_state_fidelity(&self, stabilizers: Vec>) -> PyResult { + fn code_state_fidelity(&mut self, stabilizers: Vec>) -> PyResult { + if stabilizers.len() > 30 { + return Err(PyErr::new::( + "code_state_fidelity supports at most 30 stabilizer generators", + )); + } let stabs: Vec> = stabilizers .into_iter() - .map(|g| { - g.into_iter() - .map(|(q, s)| { - let kind = match s.as_str() { - "X" => PauliKind::X, - "Y" => PauliKind::Y, - "Z" => PauliKind::Z, - _ => { - return Err(PyErr::new::( - format!("Unknown Pauli: {s}"), - )); - } - }; - Ok((q, kind)) - }) - .collect::>>() - }) + .map(|generator| self.pauli_string(generator, "code_state_fidelity")) .collect::>>()?; + self.inner.flush(); Ok(self.inner.code_state_fidelity(&stabs)) } @@ -690,10 +766,10 @@ impl PyStabMps { fn run_1q_gate( &mut self, symbol: &str, - location: usize, + location: isize, params: Option<&Bound<'_, PyDict>>, ) -> PyResult> { - self.check_qubit(location, symbol)?; + let location = self.check_qubit(location, symbol)?; let q = &[QubitId(location)]; match symbol { "I" => Ok(None), @@ -803,10 +879,13 @@ impl PyStabMps { "Two-qubit gate requires exactly 2 qubit locations", )); } - let q1: usize = location.get_item(0)?.extract()?; - let q2: usize = location.get_item(1)?.extract()?; - self.check_qubit(q1, symbol)?; - self.check_qubit(q2, symbol)?; + let q1 = self.check_qubit(location.get_item(0)?.extract::()?, symbol)?; + let q2 = self.check_qubit(location.get_item(1)?.extract::()?, symbol)?; + if q1 == q2 { + return Err(PyErr::new::( + "Two-qubit gate requires distinct qubit locations", + )); + } let pair = &[(QubitId(q1), QubitId(q2))]; match symbol { "CX" | "CNOT" => { @@ -878,7 +957,7 @@ impl PyStabMps { }; let result = match loc_tuple.len() { 1 => { - let qubit: usize = loc_tuple.get_item(0)?.extract()?; + let qubit: isize = loc_tuple.get_item(0)?.extract()?; self.run_1q_gate(symbol, qubit, params)? } 2 => self.run_2q_gate(symbol, &loc_tuple, params)?, diff --git a/python/pecos-rslib-exp/tests/test_exposure.py b/python/pecos-rslib-exp/tests/test_exposure.py index 48503fb2b..ad2132913 100644 --- a/python/pecos-rslib-exp/tests/test_exposure.py +++ b/python/pecos-rslib-exp/tests/test_exposure.py @@ -7,6 +7,7 @@ import math import pecos_rslib_exp as exp +import pytest STATS_KEYS = { @@ -83,6 +84,55 @@ def test_stab_mps_analysis_and_noise_exposure(): ) +def test_stab_mps_bitstring_convention_auto_flush_and_validation(): + q0_one = exp.StabMps(2, seed=13) + q0_one.run_1q_gate("X", 0) + assert q0_one.sample_bitstrings(4) == [[True, False]] * 4 + assert q0_one.state_vector() == [ + (0.0, 0.0), + (1.0, 0.0), + (0.0, 0.0), + (0.0, 0.0), + ] + assert q0_one.amplitude([True, False]) == (1.0, 0.0) + assert q0_one.amplitude_iterative([True, False]) == (1.0, 0.0) + assert math.isclose(q0_one.prob_bitstring([True, False]), 1.0) + + merged = exp.StabMps(2, seed=17, merge_rz=True) + merged.run_1q_gate("H", 0) + merged.run_1q_gate("T", 0) + assert merged.is_state_exact() is False + merged_amplitude = merged.amplitude([True, False]) + assert_complex_tuple(merged_amplitude) + assert merged.is_state_exact() is True + + lazy = exp.StabMps(2, seed=19, lazy_measure=True) + lazy.run_1q_gate("H", 1) + lazy.run_1q_gate("T", 1) + lazy.run_1q_gate("S", 0) + lazy.run_1q_gate("H", 0) + lazy.run_2q_gate("CX", (0, 1)) + lazy.run_1q_gate("MZ", 0) + assert lazy.is_state_exact() is False + assert len(lazy.state_vector()) == 4 + assert lazy.is_state_exact() is True + + with pytest.raises(ValueError): + q0_one.amplitude([True]) + with pytest.raises(ValueError): + q0_one.prob_bitstring([1, False]) + with pytest.raises(IndexError): + q0_one.frame_x_bit(2) + with pytest.raises(IndexError): + q0_one.frame_x_bit(-1) + with pytest.raises(IndexError): + q0_one.pauli_expectation([(2, "Z")]) + with pytest.raises(ValueError): + q0_one.pauli_expectation([(0, "A")]) + with pytest.raises(ValueError): + q0_one.apply_depolarizing(0, math.nan) + + def test_mast_configuration_projection_diagnostics_and_stats(): mast = exp.Mast( 1, @@ -95,6 +145,9 @@ def test_mast_configuration_projection_diagnostics_and_stats(): ) mast.run_1q_gate("H", 0) mast.run_1q_gate("T", 0) + assert mast.remaining_injections == 0 + with pytest.raises(IndexError): + mast.run_1q_gate("H", -1) mast.project_all() records = mast.projection_records() @@ -123,6 +176,8 @@ def test_stab_mps_compile_dispatch_accessors_and_advice(): assert compile_only.run_1q_gate("H", 0) is None assert compile_only.run_2q_gate("CX", (0, 1)) is None assert compile_only.run_gate("Z", {1}) == {} + with pytest.raises(IndexError): + compile_only.run_1q_gate("H", -1) recommendation = compile_only.recommend() assert recommendation["simulator"] == "ch_form" From b1a6699085fce0d32d76a7854020b4ba27276716 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 15 Aug 2026 00:52:41 -0600 Subject: [PATCH 13/14] Document the STN simulators: full docstring coverage, user-guide page with tested examples, doc-test wiring --- docs/user-guide/stabilizer-tensor-networks.md | 145 +++++++++ exp/pecos-stab-tn/examples/advice.rs | 57 ++++ exp/pecos-stab-tn/src/errors.rs | 22 +- exp/pecos-stab-tn/src/lib.rs | 1 + exp/pecos-stab-tn/src/mps.rs | 11 +- exp/pecos-stab-tn/src/mps/canon.rs | 6 +- exp/pecos-stab-tn/src/stab_mps.rs | 176 +++++++++-- exp/pecos-stab-tn/src/stab_mps/compile.rs | 64 +++- exp/pecos-stab-tn/src/stab_mps/mast.rs | 43 ++- exp/pecos-stab-tn/src/stab_mps/ofd.rs | 2 +- .../src/stab_mps/pauli_decomp.rs | 3 + .../src/stab_mps/tableau_compose.rs | 22 +- mkdocs.yml | 1 + .../pecos-rslib-exp/src/compile_bindings.rs | 98 +++++- python/pecos-rslib-exp/src/mast_bindings.rs | 109 ++++++- .../pecos-rslib-exp/src/stab_mps_bindings.rs | 290 +++++++++++++++++- .../tests/docs/rust_crate/Cargo.toml | 1 + .../user_guide_stabilizer_tensor_networks.rs | 23 ++ 18 files changed, 1000 insertions(+), 74 deletions(-) create mode 100644 docs/user-guide/stabilizer-tensor-networks.md create mode 100644 exp/pecos-stab-tn/examples/advice.rs create mode 100644 python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_stabilizer_tensor_networks.rs diff --git a/docs/user-guide/stabilizer-tensor-networks.md b/docs/user-guide/stabilizer-tensor-networks.md new file mode 100644 index 000000000..8f078cd37 --- /dev/null +++ b/docs/user-guide/stabilizer-tensor-networks.md @@ -0,0 +1,145 @@ +# Stabilizer Tensor-Network Simulators + +The experimental `pecos_rslib_exp` package exposes three related tools: + +- `StabMps` executes Clifford and arbitrary-rotation circuits using a stabilizer tableau plus an MPS of coefficients. +- `Mast` defers non-Clifford work through preallocated magic-state ancillas. +- `StabMpsCompile` replays a circuit without an MPS and estimates which execution strategy is practical. + +All public bitstrings use qubit-index order: `bits[q]` is the bit for qubit `q`. Dense state-vector indices are little-endian, so a row maps to `sum(int(bits[q]) << q for q in range(len(bits)))`. Gate rotation angles are in radians. + +## `StabMps` quickstart + +Use `sample_bitstrings`, plural, for shot workloads. It shares each distinct measurement-prefix projection between shots; `sample_bitstring` clones and collapses the entire simulator once per shot. + +```python +import math + +import pecos_rslib_exp as exp + +sim = exp.StabMps(2, seed=7, lazy_measure=True) +sim.run_gate("H", {0}) +sim.run_gate("CX", {(0, 1)}) +sim.run_gate("RZ", {1}, angle=math.pi / 4) + +shots = sim.sample_bitstrings(32) +assert len(shots) == 32 +assert all(len(bits) == 2 for bits in shots) +assert all(bits in ([False, False], [True, True]) for bits in shots) + +# bits[q] is qubit q, so these are |q1 q0> = |00> and |11>. +p00 = sim.prob_bitstring([False, False]) +p11 = sim.prob_bitstring([True, True]) +assert math.isclose(p00, 0.5, abs_tol=1e-12) +assert math.isclose(p11, 0.5, abs_tol=1e-12) + +# Python reads auto-flush lazy operations and merged RZ rotations. +accuracy = { + "state_exact": sim.is_state_exact(), + "pragmatic_drift_count": sim.pragmatic_drift_count, + "truncation_error": sim.truncation_error, + "bond_cap_hits": sim.bond_cap_hits, +} +assert accuracy == { + "state_exact": True, + "pragmatic_drift_count": 0, + "truncation_error": 0.0, + "bond_cap_hits": 0, +} +``` + +The accuracy fields answer different questions: + +- `is_state_exact()` detects pending work, an unmaterialized Pauli frame, and stored-state drift from eager measurement. It does not include MPS truncation in its definition. +- `pragmatic_drift_count` should remain zero when exact amplitudes are required after random measurements. Construct with `lazy_measure=True` to avoid that eager-measurement drift. +- `truncation_error` estimates accumulated discarded singular-value weight. +- `bond_cap_hits` counts SVDs at which `max_bond_dim` was binding. + +Python state reads automatically flush lazy operations and merged rotations. When Pauli-frame tracking is enabled, call `flush_pauli_frame_to_state()` before a read that must include the physical frame. + +## `Mast` quickstart + +`max_non_clifford` reserves one fresh ancilla for each deferred non-Clifford RZ. Exceeding it raises `PanicException`, so use compile advice to size the simulator and inspect `remaining_injections` while building a circuit. Prefer MAST for T-like gates whose injection corrections are Clifford and when the extra ancillas fit; prefer `StabMps` for direct arbitrary rotations, limited ancillary memory, or amplitude, probability, and bulk-sampling reads. + +```python +import pecos_rslib_exp as exp + +mast = exp.Mast(2, max_non_clifford=2, seed=11) +mast.run_gate("H", {0}) +mast.run_gate("CX", {(0, 1)}) +mast.run_gate("T", {1}) + +assert mast.num_ancillas_used == 1 +assert mast.remaining_injections == 1 + +# Complete all deferred injections and apply their corrections. +mast.project_all() +assert len(mast.projection_records()) == 1 + +# MZ would call project_all() automatically if work were still deferred. +outcome = mast.run_1q_gate("MZ", 0) +assert outcome in (0, 1) +``` + +`flush()` is not the MAST completion operation: it materializes lazy-measurement operations and pending merged rotations, but leaves already deferred injections alone. Finish with `project_all()` or measure a data qubit with MZ. + +## Analyze first with `StabMpsCompile` + +Replay the same gates through `StabMpsCompile`, then call `recommend()` or `advise()`. Advice is heuristic. Deferred capacity counts every non-Clifford RZ, even an arbitrary-angle rotation whose eventual correction is also non-Clifford. + +```python +import pecos_rslib_exp as exp + +analysis = exp.StabMpsCompile(20) +analysis.run_gate("H", {0, 1}) +analysis.run_gate("CX", {(0, 2), (1, 3)}) +analysis.run_gate("T", {0, 1}) + +recommendation = analysis.recommend() +assert recommendation["simulator"] == "stab_mps" + +required = analysis.nonclifford_rz_total +assert required == 2 + +sufficient = analysis.advise(ancilla_budget=required) +assert sufficient["injection"] == "deferred" +assert sufficient["deferred_feasible"] is True +assert sufficient["simulator"] == "mast" + +insufficient = analysis.advise(ancilla_budget=required - 1) +assert insufficient["injection"] == "immediate" +assert insufficient["deferred_feasible"] is False +assert insufficient["warnings"] + +unspecified = analysis.advise() +assert unspecified["injection"] == "deferred" +assert unspecified["deferred_feasible"] is None +assert unspecified["warnings"] +``` + +`recommend()` uses ordered thresholds: pure Clifford circuits select CH form; otherwise `n <= 14` selects a dense state vector; otherwise nullity `<= 6` selects `StabMps`; otherwise non-Clifford count `<= 40` selects `StabVec`; remaining circuits select `StabMps` with adaptive bond growth suggested. `bond_dim_bound` returns `2**nullity` and saturates at the platform maximum integer if that power overflows. + +## Rust quickstart + +Rust reads do not auto-flush. Call `flush()` before state reads when lazy measurement or merged RZ is enabled, and materialize a tracked Pauli frame separately when required. + +```rust +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::StabMps; + +fn main() { + let mut sim = StabMps::builder(2) + .seed(7) + .lazy_measure(true) + .build(); + sim.h(&[QubitId(0)]); + sim.cx(&[(QubitId(0), QubitId(1))]); + sim.rz(Angle64::QUARTER_TURN / 2_u64, &[QubitId(1)]); + + let outcome = sim.mz(&[QubitId(0)])[0].outcome; + sim.flush(); + let bits = [outcome, outcome]; + assert!((sim.prob_bitstring(&bits) - 1.0).abs() < 1e-12); +} +``` diff --git a/exp/pecos-stab-tn/examples/advice.rs b/exp/pecos-stab-tn/examples/advice.rs new file mode 100644 index 000000000..41bc7d4ab --- /dev/null +++ b/exp/pecos-stab-tn/examples/advice.rs @@ -0,0 +1,57 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the +// License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing permissions and +// limitations under the License. + +//! Replay a circuit, recommend a simulator, and compare three ancilla budgets. + +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::compile::{ExecutionAdvice, StabMpsCompile}; + +fn analyzed_circuit() -> StabMpsCompile { + let mut analysis = StabMpsCompile::new(20); + analysis.h(&[QubitId(0), QubitId(1)]); + analysis.cx(&[(QubitId(0), QubitId(2)), (QubitId(1), QubitId(3))]); + analysis.rz(Angle64::QUARTER_TURN / 2_u64, &[QubitId(0), QubitId(1)]); + analysis +} + +fn print_advice(label: &str, advice: &ExecutionAdvice) { + println!("{label}:"); + println!(" simulator: {:?}", advice.simulator); + println!(" injection: {:?}", advice.injection); + println!(" injectable_count: {}", advice.injectable_count); + println!( + " deferred_ancillas_required: {}", + advice.deferred_ancillas_required + ); + println!(" deferred_feasible: {:?}", advice.deferred_feasible); + println!(" warnings: {:?}", advice.warnings); + println!(" reason: {}", advice.reason); +} + +fn main() { + let analysis = analyzed_circuit(); + let recommendation = analysis.recommend(); + println!( + "recommendation: {:?}: {}", + recommendation.kind, recommendation.reason + ); + + let required = usize::try_from(analysis.nonclifford_rz_total()) + .expect("the example's gate count fits usize"); + print_advice("sufficient budget", &analysis.advise(Some(required))); + print_advice( + "insufficient budget", + &analysis.advise(Some(required.saturating_sub(1))), + ); + print_advice("unspecified budget", &analysis.advise(None)); +} diff --git a/exp/pecos-stab-tn/src/errors.rs b/exp/pecos-stab-tn/src/errors.rs index 56ed05d6c..c01588ff4 100644 --- a/exp/pecos-stab-tn/src/errors.rs +++ b/exp/pecos-stab-tn/src/errors.rs @@ -12,21 +12,39 @@ use thiserror::Error; +/// Failures reported by checked matrix-product-state operations. #[derive(Error, Debug)] pub enum MpsError { + /// A requested site does not exist in the MPS chain. #[error("site index {index} out of bounds (num_sites = {num_sites})")] - SiteOutOfBounds { index: usize, num_sites: usize }, + SiteOutOfBounds { + /// Requested zero-based site index. + index: usize, + /// Number of sites in the MPS chain. + num_sites: usize, + }, + /// A gate matrix does not have the dimension required by the target site or sites. #[error("gate dimension mismatch: expected {expected}x{expected}, got {rows}x{cols}")] GateDimMismatch { + /// Required number of rows and columns. expected: usize, + /// Actual row count. rows: usize, + /// Actual column count. cols: usize, }, + /// The singular-value decomposition failed to converge. #[error("SVD failed to converge")] SvdFailed, + /// An adjacent-site operation received sites that are not ordered neighbors. #[error("sites {q0} and {q1} are not adjacent")] - NonAdjacentSites { q0: usize, q1: usize }, + NonAdjacentSites { + /// First requested site. + q0: usize, + /// Second requested site. + q1: usize, + }, } diff --git a/exp/pecos-stab-tn/src/lib.rs b/exp/pecos-stab-tn/src/lib.rs index 5d7dbdbff..2da52ac69 100644 --- a/exp/pecos-stab-tn/src/lib.rs +++ b/exp/pecos-stab-tn/src/lib.rs @@ -45,6 +45,7 @@ //! with Magic State Injection." PRL 134, 190602 (2025). arXiv:2411.12482. //! - Reference implementation: +/// Errors returned by matrix-product-state operations. pub mod errors; pub mod mps; pub mod stab_mps; diff --git a/exp/pecos-stab-tn/src/mps.rs b/exp/pecos-stab-tn/src/mps.rs index df9feefdc..c1d96713c 100644 --- a/exp/pecos-stab-tn/src/mps.rs +++ b/exp/pecos-stab-tn/src/mps.rs @@ -143,11 +143,13 @@ impl Mps { } #[must_use] + /// Return the number of physical sites in the MPS chain. pub fn num_sites(&self) -> usize { self.num_sites } #[must_use] + /// Return the local physical dimension (two for qubit MPS instances). pub fn phys_dim(&self) -> usize { self.phys_dim } @@ -159,11 +161,13 @@ impl Mps { } #[must_use] + /// Return the largest bond dimension currently present in the chain. pub fn max_bond_dim(&self) -> usize { *self.bond_dims.iter().max().unwrap_or(&1) } #[must_use] + /// Return the truncation and parallelism configuration used by this MPS. pub fn config(&self) -> &MpsConfig { &self.config } @@ -565,9 +569,12 @@ impl Mps { result[(0, 0)] } - /// Compute the full state vector (2^N complex amplitudes). + /// Compute the full state vector (`2^N` complex amplitudes). /// - /// Only for testing on small systems. + /// This performs `2^N` MPS contractions and allocates `2^N` complex + /// values, so it is only suitable for testing and other small-system + /// reads. Prefer [`Self::amplitude`] for selected basis states and + /// [`Self::expectation_product`] for product-observable expectations. /// When `parallel` is enabled in the config, amplitude computations run on /// rayon's thread pool. /// diff --git a/exp/pecos-stab-tn/src/mps/canon.rs b/exp/pecos-stab-tn/src/mps/canon.rs index 19df73c41..67b6e6396 100644 --- a/exp/pecos-stab-tn/src/mps/canon.rs +++ b/exp/pecos-stab-tn/src/mps/canon.rs @@ -12,8 +12,10 @@ //! MPS canonicalization via QR decomposition. //! -//! Left-canonical form: each site tensor A[i] satisfies `sum_sigma` A[sigma]^dagger A[sigma] = I. -//! Right-canonical form: each site tensor B[i] satisfies `sum_sigma` B[sigma] B[sigma]^dagger = I. +//! Left-canonical form: each site tensor `A[i]` satisfies +//! `sum_sigma A[sigma]^dagger A[sigma] = I`. +//! Right-canonical form: each site tensor `B[i]` satisfies +//! `sum_sigma B[sigma] B[sigma]^dagger = I`. use super::tensor::{reshape_left_group, reshape_left_ungroup}; use nalgebra::DMatrix; diff --git a/exp/pecos-stab-tn/src/stab_mps.rs b/exp/pecos-stab-tn/src/stab_mps.rs index 4a8a2a097..90f6024cc 100644 --- a/exp/pecos-stab-tn/src/stab_mps.rs +++ b/exp/pecos-stab-tn/src/stab_mps.rs @@ -48,15 +48,19 @@ use pecos_simulators::{ ArbitraryRotationGateable, CliffordGateable, MeasurementResult, QuantumSimulator, SparseStabY, }; -/// Known eigenstate at an MPS site, for exact disentangling. -/// Tracks which Pauli basis the site is a definite eigenstate of. +/// Known Pauli eigenstate at an MPS site, used for exact disentangling. +/// +/// For every variant, `false` denotes the `+1` eigenstate and `true` the +/// `-1` eigenstate. Thus `Z(false)` is `|0>`, `Z(true)` is `|1>`, +/// `X(false)` is `|+>`, `X(true)` is `|->`, `Y(false)` is `|+i>`, and +/// `Y(true)` is `|-i>`. #[derive(Clone, Copy, Debug, PartialEq)] pub enum SiteEigenstate { - /// |0⟩ or |1⟩ (Z eigenstate). Compatible with X or Y Pauli rotations. + /// A Z eigenstate: `false` is `|0>` and `true` is `|1>`. Z(bool), - /// |+⟩ or |−⟩ (X eigenstate). Compatible with Z or Y Pauli rotations. + /// An X eigenstate: `false` is `|+>` and `true` is `|->`. X(bool), - /// |+i⟩ or |−i⟩ (Y eigenstate). Compatible with X or Z Pauli rotations. + /// A Y eigenstate: `false` is `|+i>` and `true` is `|-i>`. Y(bool), } @@ -71,8 +75,11 @@ pub(crate) struct MpsIndexGate { /// (e.g., stabilizer generators of QEC codes). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PauliKind { + /// Pauli X. X, + /// Pauli Y. Y, + /// Pauli Z. Z, } @@ -87,7 +94,10 @@ enum SingleQubitCliffordKind { Z, } -/// Runtime feature flags for `StabMps`, stored as a bitfield. +/// Runtime feature flags for [`StabMps`], stored as a bitfield. +/// +/// Each accessor and setter mirrors the equivalently named option on +/// [`StabMpsBuilder`]. #[derive(Clone, Copy, Debug)] pub struct StabMpsFlags(u8); @@ -117,37 +127,47 @@ impl StabMpsFlags { } #[must_use] + /// Return whether non-Clifford gates are followed by MPS normalization. pub fn normalize_after_gate(self) -> bool { self.get(Self::NORMALIZE_AFTER_GATE) } + /// Set whether non-Clifford gates are followed by MPS normalization. pub fn set_normalize_after_gate(&mut self, v: bool) { self.set(Self::NORMALIZE_AFTER_GATE, v); } #[must_use] + /// Return whether measurement uses the deferred virtual-frame path. pub fn lazy_measure(self) -> bool { self.get(Self::LAZY_MEASURE) } + /// Set whether measurement uses the deferred virtual-frame path. pub fn set_lazy_measure(&mut self, v: bool) { self.set(Self::LAZY_MEASURE, v); } #[must_use] + /// Return whether consecutive same-qubit RZ rotations are merged. pub fn merge_rz(self) -> bool { self.get(Self::MERGE_RZ) } + /// Set whether consecutive same-qubit RZ rotations are merged. pub fn set_merge_rz(&mut self, v: bool) { self.set(Self::MERGE_RZ, v); } #[must_use] + /// Return whether Pauli errors are tracked in a classical frame. pub fn pauli_frame_tracking(self) -> bool { self.get(Self::PAULI_FRAME_TRACKING) } + /// Set whether Pauli errors are tracked in a classical frame. pub fn set_pauli_frame_tracking(&mut self, v: bool) { self.set(Self::PAULI_FRAME_TRACKING, v); } #[must_use] + /// Return whether product-site eigenstate flags are recovered numerically. pub fn numerical_flag_redetection(self) -> bool { self.get(Self::NUMERICAL_FLAG_REDETECTION) } + /// Set whether product-site eigenstate flags are recovered numerically. pub fn set_numerical_flag_redetection(&mut self, v: bool) { self.set(Self::NUMERICAL_FLAG_REDETECTION, v); } @@ -206,7 +226,13 @@ impl StabMpsBuilder { self } - /// Set the RNG seed for reproducible measurements. + /// Seed the simulator's [`pecos_random::PecosRng`] and stabilizer-tableau RNG. + /// + /// Fresh simulators built with the same seed, configuration, gates, noise + /// calls, measurements, and sampling calls consume the same random stream. + /// Reproducibility covers those stochastic results, not floating-point + /// equivalence across different PECOS versions or platforms. `reset()` does + /// not rewind the RNG; construct a fresh seeded simulator to replay a stream. #[must_use] pub fn seed(mut self, seed: u64) -> Self { self.seed = Some(seed); @@ -417,7 +443,55 @@ impl StabMpsBuilder { } } -/// Stabilizer Tensor Network simulator. +/// Stabilizer tensor-network simulator for Clifford circuits with non-Clifford rotations. +/// +/// Clifford gates update a stabilizer tableau, while non-Clifford rotations +/// update an MPS of coefficients. This is usually preferable to a dense state +/// vector when the OFD nullity and resulting MPS bond dimensions remain small. +/// +/// # Quick start +/// +/// ``` +/// use pecos_core::{Angle64, QubitId}; +/// use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +/// use pecos_stab_tn::stab_mps::StabMps; +/// +/// let mut sim = StabMps::builder(2) +/// .seed(7) +/// .lazy_measure(true) +/// .build(); +/// sim.h(&[QubitId(0)]); +/// sim.cx(&[(QubitId(0), QubitId(1))]); +/// sim.rz(Angle64::QUARTER_TURN / 2_u64, &[QubitId(1)]); +/// +/// let outcome = sim.mz(&[QubitId(0)])[0].outcome; +/// sim.flush(); +/// let bits = [outcome, outcome]; +/// assert!((sim.prob_bitstring(&bits) - 1.0).abs() < 1e-12); +/// ``` +/// +/// Bitstrings always use qubit-index order: `bits[q]` is qubit `q`. See the +/// crate-level **Bitstring convention** section for conversion to a state-vector +/// index. +/// +/// # Read validity and accuracy checklist +/// +/// Rust callers must call [`Self::flush`] before state and diagnostic reads when +/// lazy measurement or merged RZ is enabled. If Pauli-frame tracking is enabled, +/// call [`Self::flush_pauli_frame_to_state`] as well before reads that must include +/// the physical Pauli frame. The Python bindings automatically perform +/// `flush()`, but do not implicitly materialize a Pauli frame. +/// +/// Before relying on a state read, check all four diagnostics: +/// +/// 1. [`Self::is_state_exact`] is `true` after the required flushes. It excludes +/// MPS truncation from its definition of exactness. +/// 2. [`Self::pragmatic_drift_count`] is zero. Nonzero drift from eager random +/// measurement is irreversible; use `lazy_measure(true)` when later exact +/// amplitudes are required. +/// 3. [`Self::truncation_error`] is acceptable for the application. +/// 4. [`Self::bond_cap_hits`] is zero, or the configured bond cap is known to be +/// adequate despite having bound an SVD. #[derive(Clone)] pub struct StabMps { num_qubits: usize, @@ -518,13 +592,18 @@ impl StabMps { Self::builder(num_qubits).build() } - /// Create with a specific seed for reproducibility. + /// Create with a specific seed for reproducible stochastic operations. + /// + /// This seeds both the simulator's [`pecos_random::PecosRng`] and the + /// stabilizer tableau. Identically configured fresh instances reproduce an + /// identical call sequence; `reset()` does not rewind the random stream. #[must_use] pub fn with_seed(num_qubits: usize, seed: u64) -> Self { Self::builder(num_qubits).seed(seed).build() } #[must_use] + /// Return the number of simulated qubits. pub fn num_qubits(&self) -> usize { self.num_qubits } @@ -580,14 +659,19 @@ impl StabMps { &self.gf2_matrix } - /// Wavefunction amplitude ⟨s|C|ψ⟩ for a given bitstring `s`. + /// Wavefunction amplitude `⟨s|C|psi⟩` for a given bitstring `s`. /// /// `bitstring` has length `num_qubits`; bit k corresponds to qubit k. /// Returns the unnormalized amplitude coefficient. /// See the crate-level **Bitstring convention** section. /// - /// For n ≤ 14 uses `state_vector()` directly. Paper Liu-Clark 2412.17209 - /// Section VI.B gives an iterative CAMPS-native algorithm for larger n. + /// This materializes the full dense state using [`Self::state_vector`], with + /// `O(2^n)` memory and greater construction cost than a single amplitude + /// needs; it is limited to `n <= 14`. Prefer [`Self::amplitude_iterative`] + /// for scalable selected-amplitude reads. + /// + /// Call [`Self::flush`] first when lazy measurement or RZ merging is enabled, + /// and materialize a tracked Pauli frame when it must be included. /// /// # Panics /// Panics if bitstring length doesn't match `num_qubits`, or n > 14. @@ -621,6 +705,9 @@ impl StabMps { /// stabilizer group of `⟨Ψ|g|Ψ⟩`), variational energy estimation, /// and arbitrary-observable readout. /// + /// Call [`Self::flush`] first when lazy measurement or RZ merging is enabled, + /// and materialize a tracked Pauli frame when it must be included. + /// /// # Method /// Tableau-based decomposition: writes `P` as /// `phase · X_{flip} · Z_{sign}` (in stored-MPS frame, after @@ -665,6 +752,9 @@ impl StabMps { /// CH-form `amplitude` + sequential measurement for `⟨x|s⟩` and /// stabilizer Born sampling). /// + /// Call [`Self::flush`] first when lazy measurement or RZ merging is enabled, + /// and materialize a tracked Pauli frame when it must be included. + /// /// Note: requires `n ≤ 64` due to CH-form's `usize`-indexed amplitude /// API; that's already a much higher limit than the SV path's `n ≤ 14`. /// @@ -788,6 +878,9 @@ impl StabMps { /// `StabMps::overlap_with_stabilizer` (CD Loschmidt MC) targeting one /// specific code state at a time. /// + /// Call [`Self::flush`] first when lazy measurement or RZ merging is enabled, + /// and materialize a tracked Pauli frame when it must be included. + /// /// # Panics /// Panics if any qubit index in a generator is ≥ `num_qubits`, or if /// `2^k` overflows `usize` (e.g., k > 62 on 64-bit). @@ -827,6 +920,9 @@ impl StabMps { /// `bitstring[q]` specifies qubit `q`; see the crate-level /// **Bitstring convention** section. /// + /// Call [`Self::flush`] first when lazy measurement or RZ merging is enabled, + /// and materialize a tracked Pauli frame when it must be included. + /// /// Scales beyond `amplitude`'s n ≤ 14 limit by working directly on the /// MPS + tableau. After forcing all N outcomes, the tableau encodes |s⟩ /// as a computational basis state and the MPS (left unnormalized) @@ -863,6 +959,9 @@ impl StabMps { /// `bitstring[q]` specifies qubit `q`; see the crate-level /// **Bitstring convention** section. /// + /// Call [`Self::flush`] first when lazy measurement or RZ merging is enabled, + /// and materialize a tracked Pauli frame when it must be included. + /// /// Implements Liu-Clark 2412.17209 Algorithm 3 (Section VI.A): iterative /// forced projection of the CAMPS state. For each qubit k: /// `π_k` = ⟨`ψ_k` | (I + (-`1)^{s_k`} `Z̃_k)/2` | `ψ_k`⟩ @@ -898,6 +997,9 @@ impl StabMps { /// Second Rényi entropy `S_2` = -`ln(Tr_A(ρ_A²))` at a bipartition /// (qubits 0..cut vs qubits cut..N). /// + /// Call [`Self::flush`] first when lazy measurement or RZ merging is enabled, + /// and materialize a tracked Pauli frame when it must be included. + /// /// Uses the full `state_vector` for computation — works only for n <= 14. /// Paper Liu-Clark 2412.17209 Section VI.C gives an MPS-based algorithm /// that scales better but requires careful implementation of the Pauli @@ -959,6 +1061,8 @@ impl StabMps { /// Complexity: ∏_j (1 + `non_zero_bloch_components(j)`) combinations. For /// Clifford+T with sparse T gates most sites give count=1 → 2^N fallback. /// Full-magic sites give count=3 -> 4^N worst case. Error if > 2^22. + /// Call [`Self::flush`] before this read when lazy measurement or RZ + /// merging is enabled, and materialize a tracked Pauli frame if required. /// /// # Errors /// @@ -981,6 +1085,8 @@ impl StabMps { /// Scales to much larger n than PCE when applicable: `2^null_dim` /// enumerations vs 2^N. For pure-Clifford Bell on n=100, `null_dim` is /// typically 0-2. + /// Call [`Self::flush`] before this read when lazy measurement or RZ + /// merging is enabled, and materialize a tracked Pauli frame if required. /// /// # Errors /// @@ -1006,7 +1112,11 @@ impl StabMps { renyi::compute_s2_pce(&self.mps, &self.tableau, &mask) } - /// Access the MPS (for testing). + /// Access the stored coefficient MPS. + /// + /// Call [`Self::flush`] first if the returned tensors must include pending + /// lazy-measurement operations or merged RZ rotations. A tracked Pauli + /// frame is stored separately until explicitly materialized. #[must_use] pub fn mps(&self) -> &Mps { &self.mps @@ -1014,6 +1124,7 @@ impl StabMps { /// Accumulated truncation error so far (approximate `1 - |⟨ψ_exact|ψ⟩|²`). /// Zero if no SVD has dropped any singular values above `svd_cutoff`. + /// Call [`Self::flush`] first to include SVDs triggered by pending work. #[must_use] pub fn truncation_error(&self) -> f64 { self.mps.truncation_error() @@ -1022,12 +1133,17 @@ impl StabMps { /// Number of SVDs where `max_bond_dim` was the binding cap. If > 0 the /// state is under-resolved; consider raising `max_bond_dim` or loosening /// `max_truncation_error`. + /// Call [`Self::flush`] first to include SVDs triggered by pending work. #[must_use] pub fn bond_cap_hits(&self) -> u64 { self.mps.bond_cap_hits() } - /// Access the tableau (for testing). + /// Access the stored stabilizer tableau. + /// + /// Call [`Self::flush`] first when the read must include pending merged RZ + /// rotations. Lazy virtual-frame operations and a tracked Pauli frame can + /// remain represented outside this tableau. #[must_use] pub fn tableau(&self) -> &SparseStabY { &self.tableau @@ -1035,17 +1151,29 @@ impl StabMps { /// Run Clifford disentangling sweeps to reduce MPS bond dimension. /// - /// Tries two-qubit Clifford gates at each bond. If one reduces entanglement, - /// it's applied to the MPS and the inverse to the tableau. - /// Returns the number of gates applied. + /// Each sweep examines every internal bond and tries 20 inequivalent + /// entangling two-qubit Clifford candidates, performing SVD-based entropy + /// estimates for them. If a candidate reduces entanglement, it is applied + /// exactly to the coefficient MPS and its inverse is recorded for later + /// physical-state reconstruction. The operation adds no approximation + /// beyond the MPS configuration's normal SVD truncation. + /// + /// This can be expensive compared with a gate update; use it after a batch + /// of non-Clifford gates or when observed bond growth justifies a sweep, not + /// after every gate. `max_sweeps` bounds full-chain passes. Returns the + /// number of accepted Clifford gates. pub fn disentangle(&mut self, max_sweeps: usize) -> usize { disentangle::disentangle(&mut self.mps, &mut self.mps_corrections, max_sweeps) } - /// Compute the full state vector (for testing on small systems). + /// Compute the full state vector for a small system. /// /// Directly computes |psi> = `Σ_x` `ν_x` * D^x * |stab> from the MPS /// coefficients and the current stabilizer/destabilizer generators. + /// It allocates `2^n` complex amplitudes and constructs dense `2^n` by + /// `2^n` operators, and is therefore restricted to `n <= 14`. For scalable + /// reads, use [`Self::amplitude_iterative`], [`Self::prob_bitstring`], + /// [`Self::pauli_expectation`], or [`Self::sample_bitstrings`]. /// /// # Accuracy caveats (read if you have outstanding measurements) /// @@ -1234,6 +1362,12 @@ impl StabMps { /// /// Useful for shot-based experiments (logical error rate estimation, /// outcome distribution histograms, etc.). + /// + /// Prefer [`Self::sample_bitstrings`] for multiple shots: this method pays + /// for a full simulator clone and all-qubit collapse per shot, whereas the + /// plural method shares each distinct measurement prefix. The repository's + /// `sampling_methods` release example measures tens-to-hundreds-fold speedups + /// for its 1,000-shot workloads (hardware and circuit dependent). pub fn sample_bitstring(&mut self, num_shots: usize) -> Vec> { use pecos_core::RngManageable; let mut shots = Vec::with_capacity(num_shots); @@ -1285,6 +1419,12 @@ impl StabMps { /// `bitstring[q] == qubit q` convention as [`Self::sample_bitstring`] and /// are in lexicographic tree order, with copies of each leaf adjacent. /// See the crate-level **Bitstring convention** section. + /// + /// Prefer this method over [`Self::sample_bitstring`] for multiple shots: + /// it shares projections for common prefixes instead of cloning and + /// collapsing the whole simulator once per shot. The repository's + /// `sampling_methods` release example measures tens-to-hundreds-fold speedups + /// for its 1,000-shot workloads (hardware and circuit dependent). pub fn sample_bitstrings(&mut self, num_shots: usize) -> Vec> { if num_shots == 0 { return Vec::new(); diff --git a/exp/pecos-stab-tn/src/stab_mps/compile.rs b/exp/pecos-stab-tn/src/stab_mps/compile.rs index 499aef15c..da3f181d2 100644 --- a/exp/pecos-stab-tn/src/stab_mps/compile.rs +++ b/exp/pecos-stab-tn/src/stab_mps/compile.rs @@ -28,8 +28,36 @@ use pecos_simulators::{ ArbitraryRotationGateable, CliffordGateable, MeasurementResult, QuantumSimulator, SparseStabY, }; -/// Compile-only STN analyzer: runs Clifford tableau and tracks OFD-relevant -/// GF(2) flip patterns, without any MPS representation. +/// Compile-only STN analyzer for replaying a circuit before choosing a simulator. +/// +/// Replay the same gates that an execution simulator would receive, then call +/// [`Self::recommend`] or [`Self::advise`]. The analysis tracks the Clifford +/// tableau and OFD-relevant GF(2) flip patterns without allocating an MPS. +/// Recommendations are heuristic dispatch guidance, not resource or runtime +/// guarantees. +/// +/// ``` +/// use pecos_core::{Angle64, QubitId}; +/// use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +/// use pecos_stab_tn::stab_mps::compile::{InjectionMode, StabMpsCompile}; +/// +/// let mut analysis = StabMpsCompile::new(20); +/// analysis.h(&[QubitId(0)]); +/// analysis.cx(&[(QubitId(0), QubitId(1))]); +/// analysis.rz(Angle64::QUARTER_TURN / 2_u64, &[QubitId(0)]); +/// +/// let recommendation = analysis.recommend(); +/// let advice = analysis.advise(Some(analysis.nonclifford_rz_total() as usize)); +/// assert_eq!(advice.injection, InjectionMode::Deferred); +/// println!("{:?}: {}", recommendation.kind, recommendation.reason); +/// ``` +/// +/// [`Self::recommend`] applies these rules in order: pure Clifford selects +/// `CHForm`; otherwise `n <= 14` selects a dense state vector; otherwise OFD +/// nullity `<= 6` selects `StabMps`; otherwise non-Clifford count `<= 40` +/// selects `StabVec`; all remaining circuits select `StabMps` with adaptive +/// bond growth suggested. [`Self::bond_dim_bound`] returns `2^nullity` when +/// representable and saturates at [`usize::MAX`] if that power overflows. pub struct StabMpsCompile { num_qubits: usize, tableau: SparseStabY, @@ -50,6 +78,7 @@ pub struct StabMpsCompile { } impl StabMpsCompile { + /// Create an empty compile-only analysis for `num_qubits` qubits. #[must_use] pub fn new(num_qubits: usize) -> Self { Self { @@ -66,6 +95,7 @@ impl StabMpsCompile { } #[must_use] + /// Return the number of qubits being analyzed. pub fn num_qubits(&self) -> usize { self.num_qubits } @@ -121,7 +151,10 @@ impl StabMpsCompile { self.gf2_matrix.gf2_rank() } - /// Theoretical bond dim upper bound: 2^nullity. + /// Theoretical bond-dimension upper bound, `2^nullity`. + /// + /// Returns one at zero nullity and saturates at [`usize::MAX`] when the + /// power of two cannot be represented by `usize` on the target platform. #[must_use] pub fn bond_dim_bound(&self) -> usize { let n = self.nullity(); @@ -140,9 +173,12 @@ impl StabMpsCompile { &self.gf2_matrix } - /// Recommend which PECOS simulator best fits the accumulated circuit - /// characteristics. Based on a heuristic cost model — see the - /// `SimulatorRecommendation` docstring for exact decision rules. + /// Heuristically recommend a PECOS simulator for the accumulated circuit. + /// + /// Rules are evaluated in order: pure Clifford selects `CHForm`; otherwise + /// `n <= 14` selects `StateVector`; otherwise nullity `<= 6` selects + /// `StabMps`; otherwise total non-Clifford count `<= 40` selects `StabVec`; + /// all remaining cases select `StabMps` and suggest adaptive bond growth. /// /// Use case: after running a circuit through `StabMpsCompile` (which does /// O(t·n²) pre-analysis without any MPS overhead), dispatch to the @@ -194,8 +230,15 @@ impl StabMpsCompile { } } - /// Advise a simulator and magic-state injection mode for the accumulated - /// circuit, taking an optional ancilla budget into account. + /// Advise a simulator and magic-state injection mode for the accumulated circuit. + /// + /// `ancilla_budget` is the number of fresh ancillas available for deferred + /// injection. `None` leaves feasibility unspecified and emits a warning; + /// a sufficient budget selects deferred injection for injectable gates; + /// an insufficient nonzero budget selects immediate injection; zero selects + /// direct application. Required deferred capacity is one ancilla for every + /// non-Clifford RZ, including arbitrary-angle rotations whose correction is + /// itself non-Clifford. #[must_use] pub fn advise(&self, ancilla_budget: Option) -> ExecutionAdvice { let base = self.recommend(); @@ -369,11 +412,12 @@ pub enum SimulatorKind { Mast, } -/// Simulator recommendation with a human-readable reason string. -/// Returned by `StabMpsCompile::recommend`. +/// Heuristic simulator recommendation returned by [`StabMpsCompile::recommend`]. #[derive(Clone, Debug)] pub struct SimulatorRecommendation { + /// Simulator selected by the ordered recommendation thresholds. pub kind: SimulatorKind, + /// Human-readable rule and observed metric that led to the selection. pub reason: String, } diff --git a/exp/pecos-stab-tn/src/stab_mps/mast.rs b/exp/pecos-stab-tn/src/stab_mps/mast.rs index 5e7725f56..f63e06826 100644 --- a/exp/pecos-stab-tn/src/stab_mps/mast.rs +++ b/exp/pecos-stab-tn/src/stab_mps/mast.rs @@ -78,10 +78,27 @@ struct DeferredMeasurement { injection_index: usize, } -/// MAST simulator: Magic state injection Augmented STN. +/// Magic-state-injection augmented stabilizer tensor-network simulator. /// -/// Wraps the STN approach with magic state injection for non-Clifford gates. -/// Pre-allocates ancilla qubits for up to `max_non_clifford` T/RZ gates. +/// `max_non_clifford` preallocates one fresh ancilla slot per deferred +/// non-Clifford RZ. Exceeding that capacity panics. Replay the circuit through +/// [`super::compile::StabMpsCompile`] and call +/// [`super::compile::StabMpsCompile::advise`] to compute +/// `deferred_ancillas_required`; during execution, +/// [`Self::remaining_injections`] reports unused slots. +/// +/// Non-Clifford injections remain deferred until completion. Call +/// [`Self::project_all`] explicitly to project every magic-state ancilla and +/// apply its correction. Alternatively, measuring data through +/// [`CliffordGateable::mz`] calls `project_all()` first and then performs the +/// requested Z measurements. [`Self::flush`] only materializes lazy operations +/// and pending merged RZ rotations; it does not project already deferred +/// injections. +/// +/// Prefer `Mast` for T-like, Clifford-correction workloads when sufficient +/// ancilla capacity is available and deferred projection keeps the coefficient +/// MPS small. Prefer [`super::StabMps`] for direct arbitrary rotations, limited +/// ancillary memory, or its amplitude, probability, and sampling read APIs. pub struct Mast { /// Number of data qubits. num_data_qubits: usize, @@ -109,6 +126,7 @@ pub struct Mast { numerical_flag_redetection: bool, gf2_matrix: super::ofd::Gf2FlipMatrix, rng: PecosRng, + /// Runtime counters for non-Clifford decomposition paths. pub stats: super::StabMpsStats, /// Deferred virtual-frame Clifford V for lazy measurement /// (see `super::measure::DeferredOp`). @@ -165,7 +183,12 @@ impl Mast { } } - /// Create with a specific seed. + /// Create with a specific seed for reproducible stochastic operations. + /// + /// Seeds both the simulator's [`pecos_random::PecosRng`] and its tableau. + /// Identically configured fresh instances reproduce an identical sequence + /// of injections, projections, and measurements. `reset()` does not rewind + /// the random stream. /// /// # Panics /// @@ -286,11 +309,13 @@ impl Mast { } #[must_use] + /// Return the number of data qubits, excluding preallocated ancillas. pub fn num_data_qubits(&self) -> usize { self.num_data_qubits } #[must_use] + /// Return the number of ancilla slots consumed by injections so far. pub fn num_ancillas_used(&self) -> usize { self.next_ancilla - self.num_data_qubits } @@ -303,11 +328,17 @@ impl Mast { } #[must_use] + /// Return the largest bond dimension currently present in the coefficient MPS. pub fn max_bond_dim(&self) -> usize { self.mps.max_bond_dim() } #[must_use] + /// Borrow the coefficient MPS over data qubits and preallocated ancillas. + /// + /// Call [`Self::flush`] first if pending merged rotations or lazy operations + /// must be included, and [`Self::project_all`] first if deferred injections + /// must be completed. pub fn mps(&self) -> &Mps { &self.mps } @@ -391,12 +422,14 @@ impl Mast { }); } - /// Project all deferred ancilla measurements. + /// Project all deferred ancilla measurements and apply their corrections. /// /// For each deferred ancilla: /// 1. Measure ancilla in Z basis (using shared STN measurement protocol) /// 2. If outcome = 1: apply RZ(2*theta) correction to the target data qubit /// (For T gates, this is S = RZ(pi/2), which is Clifford) + /// + /// Calling `mz` on data qubits performs this completion step automatically. pub fn project_all(&mut self) { match self.projection_order { ProjectionOrder::Input => { diff --git a/exp/pecos-stab-tn/src/stab_mps/ofd.rs b/exp/pecos-stab-tn/src/stab_mps/ofd.rs index fb480fdd5..8d942141b 100644 --- a/exp/pecos-stab-tn/src/stab_mps/ofd.rs +++ b/exp/pecos-stab-tn/src/stab_mps/ofd.rs @@ -40,7 +40,7 @@ pub struct RowMetadata { #[derive(Clone, Debug)] pub struct Gf2FlipMatrix { num_sites: usize, - /// Rows stored as bit vectors (Vec for clarity; could use bitvec for perf). + /// Rows stored as bit vectors (`Vec` for clarity; could use bitvec for perf). rows: Vec>, /// Metadata per row (parallel to `rows`). Populated by callers that want /// OFD fix-up info; left as default when only tracking rank. diff --git a/exp/pecos-stab-tn/src/stab_mps/pauli_decomp.rs b/exp/pecos-stab-tn/src/stab_mps/pauli_decomp.rs index 6b9d97e1e..48931b441 100644 --- a/exp/pecos-stab-tn/src/stab_mps/pauli_decomp.rs +++ b/exp/pecos-stab-tn/src/stab_mps/pauli_decomp.rs @@ -36,8 +36,11 @@ use pecos_simulators::GensGeneric; /// Single-qubit Pauli kind for `decompose_pauli_string`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PauliKindForDecomp { + /// Pauli X. X, + /// Pauli Y. Y, + /// Pauli Z. Z, } diff --git a/exp/pecos-stab-tn/src/stab_mps/tableau_compose.rs b/exp/pecos-stab-tn/src/stab_mps/tableau_compose.rs index 77572337d..7f965e021 100644 --- a/exp/pecos-stab-tn/src/stab_mps/tableau_compose.rs +++ b/exp/pecos-stab-tn/src/stab_mps/tableau_compose.rs @@ -28,8 +28,8 @@ //! //! Implementation for each gate (right-compose): //! - `H_q`: swap stabs row q with destabs row q (and their signs) -//! - `S_q`: destabs[q] *= stabs[q], with phase +i correction -//! - CX(c,t): stabs[t] *= stabs[c], destabs[c] *= destabs[t] +//! - `S_q`: `destabs[q] *= stabs[q]`, with phase +i correction +//! - CX(c,t): `stabs[t] *= stabs[c]`, `destabs[c] *= destabs[t]` //! //! Reference: Aaronson & Gottesman, "Improved Simulation of Stabilizer Circuits" //! (PRA 70, 052328 (2004)); stabilizer-TN reference compose(..., front=True). @@ -348,10 +348,10 @@ pub fn right_compose_sz stabs[c] unchanged -/// - `Z_t` -> `Z_c` `Z_t` -> stabs[t] *= stabs[c] -/// - `X_c` -> `X_c` `X_t` -> destabs[c] *= destabs[t] -/// - `X_t` unchanged -> destabs[t] unchanged +/// - `Z_c` unchanged -> `stabs[c]` unchanged +/// - `Z_t` -> `Z_c` `Z_t` -> `stabs[t] *= stabs[c]` +/// - `X_c` -> `X_c` `X_t` -> `destabs[c] *= destabs[t]` +/// - `X_t` unchanged -> `destabs[t]` unchanged pub fn right_compose_cx( tableau: &mut SparseStabY, control: usize, @@ -376,7 +376,7 @@ pub fn right_compose_cx( tableau: &mut SparseStabY, q: usize, @@ -431,7 +431,7 @@ pub fn right_compose_szdg( tableau: &mut SparseStabY, q: usize, @@ -444,7 +444,7 @@ pub fn right_compose_x( tableau: &mut SparseStabY, q: usize, @@ -484,8 +484,8 @@ pub fn right_compose_cy `Z_1`, `Z_2` -> `Z_2` (stabs unchanged) -/// - `X_1` -> `X_1` `Z_2` (destabs[1] *= stabs[2]) -/// - `X_2` -> `Z_1` `X_2` (destabs[2] *= stabs[1]) +/// - `X_1` -> `X_1` `Z_2` (`destabs[1] *= stabs[2]`) +/// - `X_2` -> `Z_1` `X_2` (`destabs[2] *= stabs[1]`) pub fn right_compose_cz( tableau: &mut SparseStabY, q1: usize, diff --git a/mkdocs.yml b/mkdocs.yml index cad2211f5..9253acb40 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,6 +57,7 @@ nav: - HUGR & Guppy Simulation: user-guide/hugr-simulation.md - Runtime QIS Tracing: user-guide/runtime-qis-tracing.md - Simulators: user-guide/simulators.md + - Stabilizer Tensor Networks: user-guide/stabilizer-tensor-networks.md - Engine Selection: user-guide/engine-selection.md - Gate Reference: user-guide/gates.md - Gate Naming Conventions: user-guide/gate-naming-conventions.md diff --git a/python/pecos-rslib-exp/src/compile_bindings.rs b/python/pecos-rslib-exp/src/compile_bindings.rs index d3f270dc7..3284c4b45 100644 --- a/python/pecos-rslib-exp/src/compile_bindings.rs +++ b/python/pecos-rslib-exp/src/compile_bindings.rs @@ -16,6 +16,30 @@ use pecos_stab_tn::stab_mps::compile::{InjectionMode, SimulatorKind, StabMpsComp use pyo3::prelude::*; use pyo3::types::{PyDict, PySet, PyTuple}; +/// Compile-only STN tractability analyzer. +/// +/// Replay the same gate stream that will be executed, then call `recommend()` +/// or `advise()`. The analyzer updates a stabilizer tableau and GF(2) flip +/// matrix without allocating an MPS. Recommendations are heuristic. Qubits are +/// zero-based; any bit rows elsewhere in the STN API use `bits[q] == qubit q`. +/// +/// # Gate symbols +/// +/// The three STN classes accept the same dispatch symbols: +/// +/// | Arity | Accepted symbols | Parameters | +/// | --- | --- | --- | +/// | 1 | `I`; `X`; `Y`; `Z` | none | +/// | 1 | `H`, `H1`, `H+z+x`; `F`, `F1`; `Fdg`, `F1d`, `F1dg` | none | +/// | 1 | `SX`, `SqrtX`, `Q`; `SXdg`, `SqrtXdg`, `SqrtXd`, `Qd` | none | +/// | 1 | `SY`, `SqrtY`, `R`; `SYdg`, `SqrtYdg`, `SqrtYd`, `Rd` | none | +/// | 1 | `S`, `SZ`, `SqrtZ`; `Sd`, `SZdg`, `SqrtZdg`, `SqrtZd` | none | +/// | 1 | `RX`; `RY`; `RZ` | `angle` in radians | +/// | 1 | `T`; `Tdg` | none | +/// | 1 | `PZ`, `Init`, `init \|0>`; `PX`, `Init +X`, `init \|+>` | none | +/// | 1 | `MZ`, `Measure`, `measure Z` | none; returns 0 or 1 | +/// | 2 | `CX`, `CNOT`; `CY`; `CZ`; `SXX`; `SXXdg`; `SYY`; `SYYdg`; `SZZ`; `SZZdg`; `SWAP` | none | +/// | 2 | `RZZ` | `angle` in radians | #[pyclass(name = "StabMpsCompile", module = "pecos_rslib_exp")] pub struct PyStabMpsCompile { inner: StabMpsCompile, @@ -59,7 +83,10 @@ fn injection_name(mode: InjectionMode) -> &'static str { #[pymethods] impl PyStabMpsCompile { - /// Create a compile-only stabilizer-MPS tractability analyzer. + /// Create an empty compile-only analyzer for `num_qubits` qubits. + /// + /// Gate replay costs approximately `O(t*n**2)` for `t` non-Clifford gates + /// and `n` qubits, without coefficient-MPS allocation. #[new] fn new(num_qubits: usize) -> Self { Self { @@ -67,62 +94,80 @@ impl PyStabMpsCompile { } } + /// Clear the replayed circuit and all counters, returning `self`. fn reset(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { slf.inner.reset(); slf } #[getter] + /// Number of qubits being analyzed. fn num_qubits(&self) -> usize { self.inner.num_qubits() } #[getter] + /// Non-Clifford gates that OFD would absorb by consuming a free qubit. fn absorbed(&self) -> u64 { self.inner.absorbed() } #[getter] + /// Non-Clifford gates predicted to grow the coefficient-MPS bond dimension. fn grown(&self) -> u64 { self.inner.grown() } #[getter] + /// Non-Clifford RZ gates reduced to the stabilizer-only branch. fn stabilizer(&self) -> u64 { self.inner.stabilizer() } #[getter] + /// Total analyzed non-Clifford gates: absorbed plus grown plus stabilizer. fn total_nonclifford(&self) -> u64 { self.inner.total_nonclifford() } #[getter] + /// Total analyzed non-Clifford RZ gates. fn nonclifford_rz_total(&self) -> u64 { self.inner.nonclifford_rz_total() } #[getter] + /// RZ gates whose deferred-injection `RZ(2*angle)` correction is Clifford. fn injectable_clifford_correction(&self) -> u64 { self.inner.injectable_clifford_correction() } #[getter] + /// OFD nullity: tracked flip-pattern count minus GF(2) rank. fn nullity(&self) -> usize { self.inner.nullity() } #[getter] + /// GF(2) rank of accumulated flip patterns. fn rank(&self) -> usize { self.inner.rank() } #[getter] + /// Theoretical STN bond upper bound, `2**nullity`. + /// + /// Saturates at the platform's maximum unsigned integer on overflow. fn bond_dim_bound(&self) -> usize { self.inner.bond_dim_bound() } - /// Recommend a simulator for the analyzed circuit. + /// Return a heuristic simulator recommendation dictionary. + /// + /// The `simulator` and `reason` fields use these ordered thresholds: pure + /// Clifford -> `ch_form`; otherwise `n <= 14` -> `state_vector`; otherwise + /// nullity `<= 6` -> `stab_mps`; otherwise non-Clifford count `<= 40` -> + /// `stab_vec`; otherwise -> `stab_mps` with adaptive growth suggested. fn recommend(&self, py: Python<'_>) -> PyResult> { let recommendation = self.inner.recommend(); let result = PyDict::new(py); @@ -131,7 +176,14 @@ impl PyStabMpsCompile { Ok(result.unbind()) } - /// Recommend a simulator and non-Clifford injection mode. + /// Return simulator, injection, capacity, warning, and reason fields. + /// + /// `ancilla_budget` counts fresh ancillas available for deferral. `None` + /// yields `deferred_feasible=None` and a warning; a sufficient budget yields + /// deferred injection; an insufficient nonzero budget yields immediate + /// injection; zero yields direct application. `deferred_ancillas_required` + /// counts every non-Clifford RZ, while `injectable_count` counts only gates + /// with Clifford corrections. Recommendations are heuristic. #[pyo3(signature = (ancilla_budget=None))] fn advise(&self, py: Python<'_>, ancilla_budget: Option) -> PyResult> { let advice = self.inner.advise(ancilla_budget); @@ -151,6 +203,13 @@ impl PyStabMpsCompile { // ---- Gate dispatch (matches StabMps and Mast) ---- + /// Replay one accepted gate symbol on one qubit. + /// + /// `location` is zero-based. RX, RY, and RZ require + /// `params={"angle": radians}`. Measurement symbols return `0` or `1`; + /// other gates return `None`. Raises `IndexError` for an invalid qubit and + /// `ValueError` for an unknown symbol or invalid angle. See `run_gate` for + /// the complete symbol table. #[pyo3(signature = (symbol, location, params=None))] fn run_1q_gate( &mut self, @@ -263,6 +322,12 @@ impl PyStabMpsCompile { } } + /// Replay one accepted two-qubit gate on an ordered qubit pair. + /// + /// `location` must be a two-element tuple of distinct zero-based indices; + /// the first is the control for controlled gates. RZZ requires + /// `params={"angle": radians}`. Raises `IndexError` or `ValueError` for + /// invalid arguments. See `run_gate` for the complete symbol table. #[pyo3(signature = (symbol, location, params=None))] fn run_2q_gate( &mut self, @@ -335,6 +400,33 @@ impl PyStabMpsCompile { } } + /// Replay a gate on a set of one- or two-qubit locations. + /// + /// `locations` must be a `set`; each item is either a zero-based qubit + /// integer or an ordered two-integer tuple. Rotation keyword `angle` is in + /// radians. The returned dictionary contains entries only for measurement + /// locations, mapped to integer outcomes. Bit-bearing results use the + /// shared convention `bits[q] == qubit q` wherever represented as rows. + /// + /// # Gate symbols + /// + /// | Arity | Accepted symbols | Parameters | + /// | --- | --- | --- | + /// | 1 | `I`; `X`; `Y`; `Z` | none | + /// | 1 | `H`, `H1`, `H+z+x`; `F`, `F1`; `Fdg`, `F1d`, `F1dg` | none | + /// | 1 | `SX`, `SqrtX`, `Q`; `SXdg`, `SqrtXdg`, `SqrtXd`, `Qd` | none | + /// | 1 | `SY`, `SqrtY`, `R`; `SYdg`, `SqrtYdg`, `SqrtYd`, `Rd` | none | + /// | 1 | `S`, `SZ`, `SqrtZ`; `Sd`, `SZdg`, `SqrtZdg`, `SqrtZd` | none | + /// | 1 | `RX`; `RY`; `RZ` | `angle` in radians | + /// | 1 | `T`; `Tdg` | none | + /// | 1 | `PZ`, `Init`, `init \|0>`; `PX`, `Init +X`, `init \|+>` | none | + /// | 1 | `MZ`, `Measure`, `measure Z` | none; returns 0 or 1 | + /// | 2 | `CX`, `CNOT`; `CY`; `CZ`; `SXX`; `SXXdg`; `SYY`; `SYYdg`; `SZZ`; `SZZdg`; `SWAP` | none | + /// | 2 | `RZZ` | `angle` in radians | + /// + /// Raises `TypeError` when `locations` or its items have the wrong Python + /// shape, `IndexError` for an out-of-range qubit, and `ValueError` for bad + /// arity, repeated pair members, an unsupported symbol, or an invalid angle. #[pyo3(signature = (symbol, locations, **params))] fn run_gate( &mut self, diff --git a/python/pecos-rslib-exp/src/mast_bindings.rs b/python/pecos-rslib-exp/src/mast_bindings.rs index bd2cd519b..518cbc258 100644 --- a/python/pecos-rslib-exp/src/mast_bindings.rs +++ b/python/pecos-rslib-exp/src/mast_bindings.rs @@ -23,7 +23,27 @@ use pyo3::types::{PyDict, PySet, PyTuple}; /// `max_non_clifford` capacity raises a `PanicException`; /// `remaining_injections` exposes available capacity, and /// `StabMpsCompile.advise()` reports the required capacity for an analyzed -/// circuit. +/// circuit. `project_all()` completes deferred injections explicitly; any MZ +/// gate does so automatically before measuring data. Qubit indices are +/// zero-based, and any bit rows elsewhere in the STN API use `bits[q] == qubit q`. +/// +/// # Gate symbols +/// +/// The three STN classes accept the same dispatch symbols: +/// +/// | Arity | Accepted symbols | Parameters | +/// | --- | --- | --- | +/// | 1 | `I`; `X`; `Y`; `Z` | none | +/// | 1 | `H`, `H1`, `H+z+x`; `F`, `F1`; `Fdg`, `F1d`, `F1dg` | none | +/// | 1 | `SX`, `SqrtX`, `Q`; `SXdg`, `SqrtXdg`, `SqrtXd`, `Qd` | none | +/// | 1 | `SY`, `SqrtY`, `R`; `SYdg`, `SqrtYdg`, `SqrtYd`, `Rd` | none | +/// | 1 | `S`, `SZ`, `SqrtZ`; `Sd`, `SZdg`, `SqrtZdg`, `SqrtZd` | none | +/// | 1 | `RX`; `RY`; `RZ` | `angle` in radians | +/// | 1 | `T`; `Tdg` | none | +/// | 1 | `PZ`, `Init`, `init \|0>`; `PX`, `Init +X`, `init \|+>` | none | +/// | 1 | `MZ`, `Measure`, `measure Z` | none; returns 0 or 1 | +/// | 2 | `CX`, `CNOT`; `CY`; `CZ`; `SXX`; `SXXdg`; `SYY`; `SYYdg`; `SZZ`; `SZZdg`; `SWAP` | none | +/// | 2 | `RZZ` | `angle` in radians | #[pyclass(name = "Mast", module = "pecos_rslib_exp")] pub struct PyMast { inner: Mast, @@ -59,6 +79,10 @@ impl PyMast { /// `PanicException`; inspect `remaining_injections` before adding work. /// `StabMpsCompile.advise()` reports the required deferred capacity for an /// analyzed circuit. + /// + /// `seed` initializes PECOS's buffered RapidHash RNG and the tableau. Fresh + /// instances with the same configuration and call sequence reproduce + /// stochastic results; `reset()` does not rewind the stream. #[pyo3(signature = ( num_qubits, max_non_clifford, @@ -111,40 +135,62 @@ impl PyMast { Ok(PyMast { inner: mast }) } + /// Reset data, ancillas, capacity use, and diagnostics, returning `self`. + /// + /// Configuration is retained. The random stream is not rewound. fn reset(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { slf.inner.reset(); slf } #[getter] + /// Number of addressable data qubits. + /// + /// Preallocated injection ancillas are internal and excluded. fn num_qubits(&self) -> usize { self.inner.num_qubits() } #[getter] + /// Number of data qubits, equal to `num_qubits` for this binding. fn num_data_qubits(&self) -> usize { self.inner.num_data_qubits() } #[getter] + /// Number of fresh ancilla slots consumed by injections. + /// + /// Pending merged rotations are flushed first and can consume capacity. fn num_ancillas_used(&mut self) -> usize { self.inner.flush(); self.inner.num_ancillas_used() } #[getter] + /// Number of additional injections available before capacity is exhausted. + /// + /// Pending merged rotations are flushed first and can reduce this value. + /// Exceeding capacity through a later gate raises `PanicException`. fn remaining_injections(&mut self) -> usize { self.inner.flush(); self.inner.remaining_injections() } #[getter] + /// Largest bond dimension currently present in the coefficient MPS. + /// + /// Pending lazy operations and merged rotations are flushed first. Deferred + /// injections remain unprojected until `project_all()` or an MZ gate. fn max_bond_dim(&mut self) -> usize { self.inner.flush(); self.inner.max_bond_dim() } - /// Diagnostics for deferred magic-state projections since reset. + /// Return diagnostics for deferred magic-state projections since reset. + /// + /// Pending lazy operations and merged rotations are flushed first, but + /// deferred injections are not projected. Each dictionary reports ancilla, + /// support size, MPS span, and bond dimensions before and after projection. fn projection_records(&mut self, py: Python<'_>) -> PyResult>> { self.inner.flush(); self.inner @@ -163,27 +209,49 @@ impl PyMast { } #[getter] + /// Peak MPS bond dimension observed during deferred projections. + /// + /// Returns zero until `project_all()` or an MZ gate projects an injection. + /// Pending ordinary work is flushed before the read. fn projection_peak_bond(&mut self) -> usize { self.inner.flush(); self.inner.projection_peak_bond() } - /// Runtime non-Clifford path counters. + /// Return runtime non-Clifford path counters as a dictionary. + /// + /// Pending ordinary work is flushed; deferred injections remain unprojected. fn stats(&mut self, py: Python<'_>) -> PyResult> { self.inner.flush(); crate::stab_mps_stats_to_dict(py, &self.inner.stats) } + /// Materialize lazy-measurement operations and pending merged RZ rotations. + /// + /// Flushing a non-Clifford merged rotation can consume injection capacity. + /// This does not project already deferred injections. fn flush(&mut self) { self.inner.flush(); } + /// Project all deferred magic-state ancillas and apply their corrections. + /// + /// This is the explicit MAST completion step. An MZ gate calls it + /// automatically before measuring the requested data qubit. fn project_all(&mut self) { self.inner.project_all(); } // ---- Gate dispatch ---- + /// Apply one accepted gate symbol to one data qubit. + /// + /// `location` is zero-based. RX, RY, and RZ require + /// `params={"angle": radians}`. MZ returns `0` or `1` and first completes + /// every deferred injection; other gates return `None`. Raises `IndexError` + /// for an invalid qubit, `ValueError` for an unknown symbol or invalid angle, + /// and `PanicException` if injection capacity is exceeded. See `run_gate` + /// for the complete symbol table. #[pyo3(signature = (symbol, location, params=None))] fn run_1q_gate( &mut self, @@ -296,6 +364,13 @@ impl PyMast { } } + /// Apply one accepted two-qubit gate to an ordered data-qubit pair. + /// + /// `location` must be a two-element tuple of distinct zero-based indices; + /// the first is the control for controlled gates. RZZ requires + /// `params={"angle": radians}`. Raises `IndexError`, `ValueError`, or + /// `PanicException` for invalid indices/arguments or exhausted injection + /// capacity. See `run_gate` for the complete symbol table. #[pyo3(signature = (symbol, location, params=None))] fn run_2q_gate( &mut self, @@ -368,6 +443,34 @@ impl PyMast { } } + /// Apply a gate to a set of one- or two-qubit locations. + /// + /// `locations` must be a `set`; each item is either a zero-based qubit + /// integer or an ordered two-integer tuple. Rotation keyword `angle` is in + /// radians. The returned dictionary contains entries only for measurement + /// locations, mapped to integer outcomes. Bit-bearing results use the + /// shared convention `bits[q] == qubit q` wherever represented as rows. + /// + /// # Gate symbols + /// + /// | Arity | Accepted symbols | Parameters | + /// | --- | --- | --- | + /// | 1 | `I`; `X`; `Y`; `Z` | none | + /// | 1 | `H`, `H1`, `H+z+x`; `F`, `F1`; `Fdg`, `F1d`, `F1dg` | none | + /// | 1 | `SX`, `SqrtX`, `Q`; `SXdg`, `SqrtXdg`, `SqrtXd`, `Qd` | none | + /// | 1 | `SY`, `SqrtY`, `R`; `SYdg`, `SqrtYdg`, `SqrtYd`, `Rd` | none | + /// | 1 | `S`, `SZ`, `SqrtZ`; `Sd`, `SZdg`, `SqrtZdg`, `SqrtZd` | none | + /// | 1 | `RX`; `RY`; `RZ` | `angle` in radians | + /// | 1 | `T`; `Tdg` | none | + /// | 1 | `PZ`, `Init`, `init \|0>`; `PX`, `Init +X`, `init \|+>` | none | + /// | 1 | `MZ`, `Measure`, `measure Z` | none; returns 0 or 1 | + /// | 2 | `CX`, `CNOT`; `CY`; `CZ`; `SXX`; `SXXdg`; `SYY`; `SYYdg`; `SZZ`; `SZZdg`; `SWAP` | none | + /// | 2 | `RZZ` | `angle` in radians | + /// + /// Raises `TypeError` when `locations` or its items have the wrong Python + /// shape, `IndexError` for an out-of-range qubit, `ValueError` for bad arity, + /// repeated pair members, an unsupported symbol, or an invalid angle, and + /// `PanicException` if injection capacity is exceeded. #[pyo3(signature = (symbol, locations, **params))] fn run_gate( &mut self, diff --git a/python/pecos-rslib-exp/src/stab_mps_bindings.rs b/python/pecos-rslib-exp/src/stab_mps_bindings.rs index daf8ab2b4..3264d0744 100644 --- a/python/pecos-rslib-exp/src/stab_mps_bindings.rs +++ b/python/pecos-rslib-exp/src/stab_mps_bindings.rs @@ -23,9 +23,28 @@ use pyo3::types::{PyBool, PyDict, PyList, PySet, PyTuple}; /// /// Read methods materialize pending lazy-measurement operations and merged RZ /// rotations before returning. Bitstrings use qubit-index order: `bits[q]` is -/// the bit for qubit `q`. The `for_qec` constructor keyword is an enable-only +/// the bit for qubit `q`. A tracked Pauli frame remains separate until +/// `flush_pauli_frame_to_state()` is called. The `for_qec` constructor keyword is an enable-only /// preset switch: `True` applies it, while `False` and `None` are identical /// no-ops. +/// +/// # Gate symbols +/// +/// The three STN classes accept the same dispatch symbols: +/// +/// | Arity | Accepted symbols | Parameters | +/// | --- | --- | --- | +/// | 1 | `I`; `X`; `Y`; `Z` | none | +/// | 1 | `H`, `H1`, `H+z+x`; `F`, `F1`; `Fdg`, `F1d`, `F1dg` | none | +/// | 1 | `SX`, `SqrtX`, `Q`; `SXdg`, `SqrtXdg`, `SqrtXd`, `Qd` | none | +/// | 1 | `SY`, `SqrtY`, `R`; `SYdg`, `SqrtYdg`, `SqrtYd`, `Rd` | none | +/// | 1 | `S`, `SZ`, `SqrtZ`; `Sd`, `SZdg`, `SqrtZdg`, `SqrtZd` | none | +/// | 1 | `RX`; `RY`; `RZ` | `angle` in radians | +/// | 1 | `T`; `Tdg` | none | +/// | 1 | `PZ`, `Init`, `init \|0>`; `PX`, `Init +X`, `init \|+>` | none | +/// | 1 | `MZ`, `Measure`, `measure Z` | none; returns 0 or 1 | +/// | 2 | `CX`, `CNOT`; `CY`; `CZ`; `SXX`; `SXXdg`; `SYY`; `SYYdg`; `SZZ`; `SZZdg`; `SWAP` | none | +/// | 2 | `RZZ` | `angle` in radians | #[pyclass(name = "StabMps", module = "pecos_rslib_exp")] pub struct PyStabMps { inner: StabMps, @@ -360,6 +379,10 @@ impl PyStabMps { /// `max_truncation_error=None` preserves the builder default of /// `1e-8`; a float overrides it, and `0.0` disables adaptive truncation /// while retaining the SVD cutoff and bond cap. + /// + /// `seed` seeds PECOS's buffered RapidHash RNG and the stabilizer tableau. + /// Fresh instances with the same configuration and call sequence reproduce + /// stochastic results; `reset()` does not rewind the stream. #[new] #[pyo3(signature = ( num_qubits, @@ -427,45 +450,85 @@ impl PyStabMps { PyStabMps { inner: b.build() } } + /// Reset the quantum state and diagnostics to `|0...0>` and return `self`. + /// + /// Configuration is retained. The random stream is not rewound; construct + /// a fresh simulator with the same seed to replay it. fn reset(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { slf.inner.reset(); slf } #[getter] + /// Number of simulated qubits. fn num_qubits(&self) -> usize { self.inner.num_qubits() } #[getter] + /// Largest bond dimension currently present. + /// + /// Pending lazy operations and merged rotations are flushed first. This + /// may perform MPS work and mutate diagnostics. fn max_bond_dim(&mut self) -> usize { self.inner.flush(); self.inner.max_bond_dim() } #[getter] + /// Accumulated approximate infidelity from SVD truncation. + /// + /// Pending work is flushed first. Zero means no tracked singular-value + /// weight above the configured cutoff has been discarded. fn truncation_error(&mut self) -> f64 { self.inner.flush(); self.inner.truncation_error() } #[getter] + /// Number of eager measurements that introduced pragmatic stored-state drift. + /// + /// A nonzero value means amplitude-like reads can be approximate even after + /// flushing. Use `lazy_measure=True` when later exact state reads are needed. fn pragmatic_drift_count(&self) -> u64 { self.inner.pragmatic_drift_count() } + /// Whether the stored tableau/MPS exactly represents the physical state. + /// + /// This is false for pending merged rotations, lazy operations, an + /// unmaterialized Pauli frame, or pragmatic measurement drift. MPS + /// truncation is reported separately by `truncation_error`. fn is_state_exact(&self) -> bool { self.inner.is_state_exact() } + /// Materialize pending lazy-measurement operations and merged RZ rotations. + /// + /// Read methods call this automatically. It does not materialize a tracked + /// Pauli frame; use `flush_pauli_frame_to_state()` for that. fn flush(&mut self) { self.inner.flush(); } + /// Materialize tracked Pauli-frame bits into the quantum state. + /// + /// This also flushes pending lazy operations and merged rotations. Call it + /// before physical-state reads when `pauli_frame_tracking=True`. fn flush_pauli_frame_to_state(&mut self) { self.inner.flush_pauli_frame_to_state(); } + /// Return the dense state vector as `(real, imag)` pairs. + /// + /// Indexing is little-endian: the entry for `bits` is at + /// `sum(int(bits[q]) << q)`. This allocates `2**num_qubits` amplitudes and + /// constructs dense operators, so it is restricted to `num_qubits <= 14`. + /// Prefer `amplitude_iterative`, `prob_bitstring`, `pauli_expectation`, or + /// `sample_bitstrings` for scalable reads. Pending work is auto-flushed; + /// a tracked Pauli frame must be materialized explicitly. + /// + /// Raises `ValueError` when more than 14 qubits are present. fn state_vector(&mut self, py: Python<'_>) -> PyResult> { if self.inner.num_qubits() > 14 { return Err(PyErr::new::( @@ -478,8 +541,15 @@ impl PyStabMps { Ok(PyList::new(py, &list)?.unbind()) } - /// Wavefunction amplitude for a computational-basis bitstring, with - /// `bitstring[q]` specifying qubit `q`. + /// Return a dense-state wavefunction amplitude as `(real, imag)`. + /// + /// `bitstring` must contain exactly `num_qubits` Python `bool` values and + /// `bitstring[q]` specifies qubit `q`. This materializes the full `2**n` + /// state and is restricted to `n <= 14`; prefer `amplitude_iterative` for + /// larger systems. Pending work is auto-flushed; materialize a tracked + /// Pauli frame explicitly. + /// + /// Raises `ValueError` for a malformed bitstring or `n > 14`. fn amplitude(&mut self, bitstring: &Bound<'_, PyAny>) -> PyResult<(f64, f64)> { let bitstring = self.bitstring(bitstring, "amplitude")?; if self.inner.num_qubits() > 14 { @@ -492,8 +562,14 @@ impl PyStabMps { Ok((amplitude.re, amplitude.im)) } - /// CAMPS-native iterative wavefunction amplitude, with `bitstring[q]` - /// specifying qubit `q`. + /// Return a CAMPS-native iterative amplitude as `(real, imag)`. + /// + /// `bitstring` must contain exactly `num_qubits` Python `bool` values and + /// `bitstring[q]` specifies qubit `q`. Forced projections avoid dense-state + /// materialization and scale with MPS contractions. Pending work is + /// auto-flushed; materialize a tracked Pauli frame explicitly. + /// + /// Raises `ValueError` for a malformed bitstring. fn amplitude_iterative(&mut self, bitstring: &Bound<'_, PyAny>) -> PyResult<(f64, f64)> { let bitstring = self.bitstring(bitstring, "amplitude_iterative")?; self.inner.flush(); @@ -501,8 +577,17 @@ impl PyStabMps { Ok((amplitude.re, amplitude.im)) } - /// Monte Carlo estimate of the overlap with a stabilizer state specified - /// by a complete set of independent +1 Pauli generators. + /// Estimate overlap with a stabilizer state by Monte Carlo sampling. + /// + /// `stabilizers` is a complete list of `num_qubits` independent, commuting + /// +1 generators; each generator is a list of `(qubit, "X"|"Y"|"Z")` + /// factors. `num_samples` controls statistical error, and `rng_seed` + /// controls the estimator stream (default 42). Returns `(real, imag)`. + /// Cost is linear in `num_samples` times sequential stabilizer sampling and + /// iterative-amplitude work. Pending simulator work is auto-flushed. + /// + /// Raises `IndexError` for an out-of-range qubit and `ValueError` for zero + /// samples, invalid/incomplete/noncommuting generators, or more than 64 qubits. #[pyo3(signature = (stabilizers, *, num_samples, rng_seed=None))] fn overlap_with_stabilizer( &mut self, @@ -532,15 +617,25 @@ impl PyStabMps { Ok((overlap.re, overlap.im)) } - /// Probability of a computational-basis bitstring, with `bitstring[q]` - /// specifying qubit `q`. + /// Return the computational-basis probability of `bitstring`. + /// + /// The iterable must contain exactly `num_qubits` Python `bool` values; + /// `bitstring[q]` specifies qubit `q`. The method uses iterative forced + /// MPS projections rather than a dense state. Pending work is auto-flushed; + /// materialize a tracked Pauli frame explicitly. + /// + /// Raises `ValueError` for a malformed bitstring. fn prob_bitstring(&mut self, bitstring: &Bound<'_, PyAny>) -> PyResult { let bitstring = self.bitstring(bitstring, "prob_bitstring")?; self.inner.flush(); Ok(self.inner.prob_bitstring(&bitstring)) } - /// Second Renyi entropy from the full state vector. + /// Return second Renyi entropy across the cut after qubits `[0, cut)`. + /// + /// Entropy uses the natural logarithm. This method materializes the full + /// state and is limited to `num_qubits <= 14`; pending work is auto-flushed. + /// Raises `ValueError` unless `0 < cut < num_qubits`, or when `n > 14`. fn renyi_s2(&mut self, cut: usize) -> PyResult { let num_qubits = self.inner.num_qubits(); if cut == 0 || cut >= num_qubits { @@ -557,7 +652,12 @@ impl PyStabMps { Ok(self.inner.renyi_s2(cut)) } - /// Second Renyi entropy via Pauli coefficient enumeration. + /// Return second Renyi entropy via Pauli-coefficient enumeration. + /// + /// `cut` divides qubits `[0, cut)` from `[cut, num_qubits)`. Cost is + /// exponential in the nonzero local Bloch components and is capped by the + /// implementation. Pending work is auto-flushed. Raises `ValueError` for an + /// invalid cut or an enumeration above the safety limit. fn s2_pce(&mut self, cut: usize) -> PyResult { self.inner.flush(); self.inner @@ -565,7 +665,12 @@ impl PyStabMps { .map_err(PyErr::new::) } - /// Second Renyi entropy via the PCMPS hierarchy. + /// Return second Renyi entropy via the PCMPS fallback hierarchy. + /// + /// `cut` divides qubits `[0, cut)` from `[cut, num_qubits)`. This tries the + /// GF(2) null-space methods before Pauli enumeration. Pending work is + /// auto-flushed. Raises `ValueError` for an invalid cut or excessive + /// enumeration size. fn s2_pcmps(&mut self, cut: usize) -> PyResult { self.inner.flush(); self.inner @@ -573,39 +678,65 @@ impl PyStabMps { .map_err(PyErr::new::) } - /// Run Clifford disentangling sweeps and return the gates applied. + /// Run up to `max_sweeps` full-chain Clifford disentangling sweeps. + /// + /// Each bond tries 20 two-qubit Clifford candidates using SVD-based entropy + /// estimates, so this is substantially costlier than a gate update. Use it + /// after batches that grew bonds. Returns the number of accepted exact + /// Clifford transformations; ordinary configured SVD truncation still applies. fn disentangle(&mut self, max_sweeps: usize) -> usize { self.inner.disentangle(max_sweeps) } #[getter] + /// Number of SVDs for which the configured maximum bond dimension was binding. + /// + /// Pending work is auto-flushed. A nonzero value is a warning to inspect + /// `truncation_error` and consider a larger cap. fn bond_cap_hits(&mut self) -> u64 { self.inner.flush(); self.inner.bond_cap_hits() } + /// Return OFD nullity, the number of tracked flip patterns beyond GF(2) rank. + /// + /// Pending work is auto-flushed. The associated ideal bond bound is + /// `2**ofd_nullity()`. fn ofd_nullity(&mut self) -> usize { self.inner.flush(); self.inner.ofd_nullity() } + /// Return the OFD theoretical bond dimension, `2**nullity`. + /// + /// Pending work is auto-flushed. The Rust calculation saturates at the + /// platform's maximum unsigned integer if the power overflows. fn theoretical_min_bond_dim(&mut self) -> usize { self.inner.flush(); self.inner.theoretical_min_bond_dim() } + /// Return the GF(2) rank of non-Clifford flip patterns absorbed by OFD. + /// + /// Pending work is auto-flushed. fn ofd_disentangled_count(&mut self) -> usize { self.inner.flush(); self.inner.ofd_disentangled_count() } + /// Return the total non-Clifford flip patterns recorded in the OFD basis. + /// + /// Pending work is auto-flushed. fn ofd_total_absorbed(&mut self) -> u64 { self.inner.flush(); u64::try_from(self.inner.ofd_total_absorbed()) .expect("usize fits in u64 on supported Python targets") } - /// Runtime non-Clifford path counters. + /// Return runtime non-Clifford path counters as a dictionary. + /// + /// Pending work is auto-flushed. Values count dispatch paths since + /// construction or the last reset. fn stats(&mut self, py: Python<'_>) -> PyResult> { self.inner.flush(); crate::stab_mps_stats_to_dict(py, &self.inner.stats) @@ -613,41 +744,68 @@ impl PyStabMps { // ---- QEC helpers ---- + /// Measure qubit `q`, reset it to `|0>`, and return the observed bit. + /// + /// Raises `IndexError` when `q` is outside `[0, num_qubits)`. fn reset_qubit(&mut self, q: isize) -> PyResult { let q = self.check_qubit(q, "reset_qubit")?; Ok(self.inner.reset_qubit(QubitId(q))) } + /// Prepare qubit `q` in `|0>` by measurement and conditional X. + /// + /// The discarded measurement makes this a state-preparation operation. + /// Raises `IndexError` for an out-of-range qubit. fn pz(&mut self, q: isize) -> PyResult<()> { let q = self.check_qubit(q, "pz")?; self.inner.pz(QubitId(q)); Ok(()) } + /// Prepare qubit `q` in `|+>` by Z reset followed by H. + /// + /// Raises `IndexError` for an out-of-range qubit. fn px(&mut self, q: isize) -> PyResult<()> { let q = self.check_qubit(q, "px")?; self.inner.px(QubitId(q)); Ok(()) } + /// Toggle a Pauli X error in the classical frame at qubit `q`. + /// + /// Intended when `pauli_frame_tracking=True`. Raises `IndexError` for an + /// out-of-range qubit. This is O(1) and does not update the MPS immediately. fn inject_x_in_frame(&mut self, q: isize) -> PyResult<()> { let q = self.check_qubit(q, "inject_x_in_frame")?; self.inner.inject_x_in_frame(QubitId(q)); Ok(()) } + /// Toggle a Pauli Y error in the classical frame at qubit `q`. + /// + /// Intended when `pauli_frame_tracking=True`. Raises `IndexError` for an + /// out-of-range qubit. This is O(1) and does not update the MPS immediately. fn inject_y_in_frame(&mut self, q: isize) -> PyResult<()> { let q = self.check_qubit(q, "inject_y_in_frame")?; self.inner.inject_y_in_frame(QubitId(q)); Ok(()) } + /// Toggle a Pauli Z error in the classical frame at qubit `q`. + /// + /// Intended when `pauli_frame_tracking=True`. Raises `IndexError` for an + /// out-of-range qubit. This is O(1) and does not update the MPS immediately. fn inject_z_in_frame(&mut self, q: isize) -> PyResult<()> { let q = self.check_qubit(q, "inject_z_in_frame")?; self.inner.inject_z_in_frame(QubitId(q)); Ok(()) } + /// Toggle several classical-frame Pauli factors. + /// + /// `paulis` contains `(qubit, "X"|"Y"|"Z")` pairs. Raises `IndexError` + /// for an out-of-range qubit and `ValueError` for any other Pauli name. + /// Cost is linear in the number of factors and does not update the MPS. fn inject_paulis_in_frame(&mut self, paulis: Vec<(isize, String)>) -> PyResult<()> { let converted: Vec<(QubitId, PauliKind)> = self .pauli_string(paulis, "inject_paulis_in_frame")? @@ -658,16 +816,27 @@ impl PyStabMps { Ok(()) } + /// Return the X component of the tracked Pauli frame at qubit `q`. + /// + /// Raises `IndexError` for an out-of-range qubit. fn frame_x_bit(&self, q: isize) -> PyResult { let q = self.check_qubit(q, "frame_x_bit")?; Ok(self.inner.frame_x_bit(QubitId(q))) } + /// Return the Z component of the tracked Pauli frame at qubit `q`. + /// + /// Raises `IndexError` for an out-of-range qubit. fn frame_z_bit(&self, q: isize) -> PyResult { let q = self.check_qubit(q, "frame_z_bit")?; Ok(self.inner.frame_z_bit(QubitId(q))) } + /// Apply single-qubit depolarizing noise with total probability `p`. + /// + /// Returns `None` or the sampled Pauli name `"X"`, `"Y"`, or `"Z"`. + /// `p` is dimensionless and must be finite in `[0, 1]`. Raises `IndexError` + /// for an invalid qubit and `ValueError` for an invalid probability. fn apply_depolarizing(&mut self, q: isize, p: f64) -> PyResult> { let q = self.check_qubit(q, "apply_depolarizing")?; Self::check_probability(p, "apply_depolarizing")?; @@ -677,18 +846,31 @@ impl PyStabMps { .map(|k| format!("{k:?}"))) } + /// Apply X with probability `p` and return whether it was applied. + /// + /// `p` is dimensionless and must be finite in `[0, 1]`. Raises `IndexError` + /// for an invalid qubit and `ValueError` for an invalid probability. fn apply_bit_flip(&mut self, q: isize, p: f64) -> PyResult { let q = self.check_qubit(q, "apply_bit_flip")?; Self::check_probability(p, "apply_bit_flip")?; Ok(self.inner.apply_bit_flip(QubitId(q), p)) } + /// Apply Z with probability `p` and return whether it was applied. + /// + /// `p` is dimensionless and must be finite in `[0, 1]`. Raises `IndexError` + /// for an invalid qubit and `ValueError` for an invalid probability. fn apply_phase_flip(&mut self, q: isize, p: f64) -> PyResult { let q = self.check_qubit(q, "apply_phase_flip")?; Self::check_probability(p, "apply_phase_flip")?; Ok(self.inner.apply_phase_flip(QubitId(q), p)) } + /// Apply independent single-qubit depolarizing noise to `qubits`. + /// + /// Each qubit receives a non-identity Pauli with total dimensionless + /// probability `p`. Raises `IndexError` for any invalid qubit and + /// `ValueError` unless `p` is finite and in `[0, 1]`. fn apply_depolarizing_all(&mut self, qubits: Vec, p: f64) -> PyResult<()> { let qubits = qubits .into_iter() @@ -700,6 +882,13 @@ impl PyStabMps { Ok(()) } + /// Extract one syndrome bit per Pauli generator using supplied ancillas. + /// + /// Each generator is a list of `(data_qubit, "X"|"Y"|"Z")` factors; + /// `ancilla_qubits` must contain one distinct non-overlapping ancilla per + /// generator. Returns bits in generator order. Raises `IndexError` for an + /// invalid qubit and `ValueError` for invalid Paulis, length mismatch, or + /// an ancilla that overlaps its generator. fn extract_syndromes( &mut self, generators: Vec>, @@ -729,12 +918,24 @@ impl PyStabMps { Ok(self.inner.extract_syndromes(&gens, &ancs)) } + /// Return the expectation of a Hermitian Pauli string. + /// + /// `pauli_string` lists non-identity `(qubit, "X"|"Y"|"Z")` factors. + /// Pending work is auto-flushed; materialize a tracked Pauli frame + /// explicitly. Raises `IndexError` for an invalid qubit and `ValueError` + /// for an invalid Pauli name. fn pauli_expectation(&mut self, pauli_string: Vec<(isize, String)>) -> PyResult { let ps = self.pauli_string(pauli_string, "pauli_expectation")?; self.inner.flush(); Ok(self.inner.pauli_expectation(&ps)) } + /// Return fidelity with the subspace stabilized by `stabilizers`. + /// + /// Each generator lists `(qubit, "X"|"Y"|"Z")` factors. Cost is + /// exponential in generator count (`2**k` expectations), so `k <= 30` is + /// enforced. Pending work is auto-flushed. Raises `IndexError` for an + /// invalid qubit and `ValueError` for an invalid Pauli or too many generators. fn code_state_fidelity(&mut self, stabilizers: Vec>) -> PyResult { if stabilizers.len() > 30 { return Err(PyErr::new::( @@ -749,19 +950,40 @@ impl PyStabMps { Ok(self.inner.code_state_fidelity(&stabs)) } + /// Sample `num_shots` computational-basis rows by cloning once per shot. + /// + /// Every returned row uses `row[q] == qubit q`; the original state is + /// preserved and its RNG advances. Prefer `sample_bitstrings`: this method + /// pays for a full clone and collapse per shot, while prefix sharing has + /// measured tens-to-hundreds-fold speedups on the repository's 1,000-shot + /// example workloads. A negative or oversized count raises `OverflowError`. fn sample_bitstring(&mut self, num_shots: usize) -> Vec> { self.inner.sample_bitstring(num_shots) } - /// Prefix-sharing perfect sampling: shares each distinct measurement-prefix - /// projection across all shots taking that branch. Output is in - /// lexicographic tree order, not per-shot order. + /// Sample `num_shots` computational-basis rows with shared prefixes. + /// + /// Every returned row uses `row[q] == qubit q`. The original state is + /// preserved and its RNG advances. Distinct measurement-prefix projections + /// are shared across all shots taking that branch, avoiding the per-shot + /// cloning cost of `sample_bitstring`; the repository's 1,000-shot example + /// measures hardware-dependent tens-to-hundreds-fold speedups. Output is in + /// lexicographic tree order, not input shot order. Pending merged rotations + /// and lazy operations are handled internally. A negative or oversized + /// count raises `OverflowError`. fn sample_bitstrings(&mut self, num_shots: usize) -> Vec> { self.inner.sample_bitstrings(num_shots) } // ---- Gate dispatch (matches pecos-rslib pattern) ---- + /// Apply one accepted gate symbol to one qubit. + /// + /// `location` is a zero-based qubit index. RX, RY, and RZ require + /// `params={"angle": radians}`; angles are floating-point radians. + /// Measurement symbols return `0` or `1`; other gates return `None`. + /// Raises `IndexError` for an invalid qubit and `ValueError` for an unknown + /// symbol or missing/non-numeric angle. See `run_gate` for the symbol table. #[pyo3(signature = (symbol, location, params=None))] fn run_1q_gate( &mut self, @@ -867,6 +1089,13 @@ impl PyStabMps { } } + /// Apply one accepted two-qubit gate symbol to an ordered qubit pair. + /// + /// `location` must be a two-element tuple of distinct zero-based indices; + /// for controlled gates the first qubit is the control. RZZ requires + /// `params={"angle": radians}`. Raises `IndexError` for an out-of-range + /// index and `ValueError` for bad arity, repeated qubits, an unknown symbol, + /// or a missing/non-numeric angle. See `run_gate` for the symbol table. #[pyo3(signature = (symbol, location, params=None))] fn run_2q_gate( &mut self, @@ -939,6 +1168,33 @@ impl PyStabMps { } } + /// Apply a gate to a set of one- or two-qubit locations. + /// + /// `locations` must be a `set`; each item is either a zero-based qubit + /// integer or an ordered two-integer tuple. Rotation keyword `angle` is in + /// radians. The returned dictionary contains entries only for measurement + /// locations, mapped to integer outcomes. Bit-bearing results use the + /// shared convention `bits[q] == qubit q` wherever represented as rows. + /// + /// # Gate symbols + /// + /// | Arity | Accepted symbols | Parameters | + /// | --- | --- | --- | + /// | 1 | `I`; `X`; `Y`; `Z` | none | + /// | 1 | `H`, `H1`, `H+z+x`; `F`, `F1`; `Fdg`, `F1d`, `F1dg` | none | + /// | 1 | `SX`, `SqrtX`, `Q`; `SXdg`, `SqrtXdg`, `SqrtXd`, `Qd` | none | + /// | 1 | `SY`, `SqrtY`, `R`; `SYdg`, `SqrtYdg`, `SqrtYd`, `Rd` | none | + /// | 1 | `S`, `SZ`, `SqrtZ`; `Sd`, `SZdg`, `SqrtZdg`, `SqrtZd` | none | + /// | 1 | `RX`; `RY`; `RZ` | `angle` in radians | + /// | 1 | `T`; `Tdg` | none | + /// | 1 | `PZ`, `Init`, `init \|0>`; `PX`, `Init +X`, `init \|+>` | none | + /// | 1 | `MZ`, `Measure`, `measure Z` | none; returns 0 or 1 | + /// | 2 | `CX`, `CNOT`; `CY`; `CZ`; `SXX`; `SXXdg`; `SYY`; `SYYdg`; `SZZ`; `SZZdg`; `SWAP` | none | + /// | 2 | `RZZ` | `angle` in radians | + /// + /// Raises `TypeError` when `locations` or its items have the wrong Python + /// shape, `IndexError` for an out-of-range qubit, and `ValueError` for bad + /// arity, repeated pair members, an unsupported symbol, or an invalid angle. #[pyo3(signature = (symbol, locations, **params))] fn run_gate( &mut self, diff --git a/python/quantum-pecos/tests/docs/rust_crate/Cargo.toml b/python/quantum-pecos/tests/docs/rust_crate/Cargo.toml index 847ca2bb2..85fff8bb5 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/Cargo.toml +++ b/python/quantum-pecos/tests/docs/rust_crate/Cargo.toml @@ -22,6 +22,7 @@ pecos-random = { path = "../../../../../crates/pecos-random" } pecos-qasm = { path = "../../../../../crates/pecos-qasm" } pecos-programs = { path = "../../../../../crates/pecos-programs" } pecos-neo = { path = "../../../../../exp/pecos-neo" } +pecos-stab-tn = { path = "../../../../../exp/pecos-stab-tn" } # Common external crates used in documentation examples serde_json = "1.0" tempfile = "3" diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_stabilizer_tensor_networks.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_stabilizer_tensor_networks.rs new file mode 100644 index 000000000..4889ed17b --- /dev/null +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_stabilizer_tensor_networks.rs @@ -0,0 +1,23 @@ +//! Auto-generated Rust tests from user-guide/stabilizer-tensor-networks.md +//! DO NOT EDIT - Generated by scripts/docs/generate_doc_tests.py +#![allow(unused_imports, unused_variables, unused_mut, unused_assignments, dead_code, non_snake_case)] + + +#[test] +fn test_user_guide_stabilizer_tensor_networks_rust_1() { + use pecos_core::{Angle64, QubitId}; + use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; + use pecos_stab_tn::stab_mps::StabMps; + let mut sim = StabMps::builder(2) + .seed(7) + .lazy_measure(true) + .build(); + sim.h(&[QubitId(0)]); + sim.cx(&[(QubitId(0), QubitId(1))]); + sim.rz(Angle64::QUARTER_TURN / 2_u64, &[QubitId(1)]); + + let outcome = sim.mz(&[QubitId(0)])[0].outcome; + sim.flush(); + let bits = [outcome, outcome]; + assert!((sim.prob_bitstring(&bits) - 1.0).abs() < 1e-12); +} From 58c413a3223bec1a17dcf0861e1dfc60d507583b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 15 Aug 2026 01:27:13 -0600 Subject: [PATCH 14/14] Pin exception match patterns in exposure tests and apply formatter fixes --- python/pecos-rslib-exp/tests/test_exposure.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/python/pecos-rslib-exp/tests/test_exposure.py b/python/pecos-rslib-exp/tests/test_exposure.py index ad2132913..7be1a8f23 100644 --- a/python/pecos-rslib-exp/tests/test_exposure.py +++ b/python/pecos-rslib-exp/tests/test_exposure.py @@ -9,7 +9,6 @@ import pecos_rslib_exp as exp import pytest - STATS_KEYS = { "total_nonclifford", "single_site", @@ -80,7 +79,9 @@ def test_stab_mps_analysis_and_noise_exposure(): phase_flip.run_1q_gate("H", 0) assert phase_flip.apply_phase_flip(0, 1.0) is True assert math.isclose( - phase_flip.pauli_expectation([(0, "X")]), -1.0, abs_tol=1e-12 + phase_flip.pauli_expectation([(0, "X")]), + -1.0, + abs_tol=1e-12, ) @@ -117,9 +118,9 @@ def test_stab_mps_bitstring_convention_auto_flush_and_validation(): assert len(lazy.state_vector()) == 4 assert lazy.is_state_exact() is True - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="bitstring length 1, expected 2"): q0_one.amplitude([True]) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="bitstring item 0 must be bool"): q0_one.prob_bitstring([1, False]) with pytest.raises(IndexError): q0_one.frame_x_bit(2) @@ -127,9 +128,12 @@ def test_stab_mps_bitstring_convention_auto_flush_and_validation(): q0_one.frame_x_bit(-1) with pytest.raises(IndexError): q0_one.pauli_expectation([(2, "Z")]) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Unknown Pauli: A"): q0_one.pauli_expectation([(0, "A")]) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=r"probability must be finite and in \[0, 1\]", + ): q0_one.apply_depolarizing(0, math.nan)