diff --git a/.github/workflows/selene-plugins.yml b/.github/workflows/selene-plugins.yml index 790a04d1b..8efabd9e1 100644 --- a/.github/workflows/selene-plugins.yml +++ b/.github/workflows/selene-plugins.yml @@ -11,6 +11,7 @@ on: - 'python/selene-plugins/**' - 'crates/pecos-simulators/**' - 'crates/pecos-core/**' + - 'exp/pecos-stab-tn/**' - '.github/workflows/selene-plugins.yml' pull_request: branches: [master, development, dev] @@ -18,6 +19,7 @@ on: - 'python/selene-plugins/**' - 'crates/pecos-simulators/**' - 'crates/pecos-core/**' + - 'exp/pecos-stab-tn/**' - '.github/workflows/selene-plugins.yml' workflow_dispatch: @@ -120,8 +122,12 @@ jobs: package: pecos_selene_stabilizer - name: pecos-selene-statevec package: pecos_selene_statevec - - name: pecos-selene-clifford-rz - package: pecos_selene_clifford_rz + - name: pecos-selene-stab-vec + package: pecos_selene_stab_vec + - name: pecos-selene-stab-mps + package: pecos_selene_stab_mps + - name: pecos-selene-mast + package: pecos_selene_mast steps: - uses: actions/checkout@v6 diff --git a/Cargo.lock b/Cargo.lock index 776518657..b895000c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,6 +336,7 @@ version = "0.2.0-dev.0" dependencies = [ "criterion", "cxx", + "nalgebra", "num", "num-complex 0.4.6", "pecos", @@ -349,6 +350,7 @@ dependencies = [ "pecos-quantum", "pecos-random", "pecos-simulators", + "pecos-stab-tn", "quizx", "rand 0.10.1", "rand_xoshiro 0.8.0", @@ -4179,6 +4181,17 @@ dependencies = [ "pyo3", ] +[[package]] +name = "pecos-rslib-exp" +version = "0.2.0-dev.0" +dependencies = [ + "num-complex 0.4.6", + "pecos-core", + "pecos-simulators", + "pecos-stab-tn", + "pyo3", +] + [[package]] name = "pecos-rslib-llvm" version = "0.2.0-dev.0" @@ -4192,21 +4205,43 @@ dependencies = [ ] [[package]] -name = "pecos-selene-clifford-rz" +name = "pecos-selene-core" +version = "0.2.0-dev.0" +dependencies = [ + "anyhow", + "clap", + "pecos-core", + "pecos-simulators", + "selene-core 0.2.1", +] + +[[package]] +name = "pecos-selene-mast" version = "0.2.0-dev.0" dependencies = [ "anyhow", "pecos-core", "pecos-simulators", + "pecos-stab-tn", "selene-core 0.2.1", ] [[package]] -name = "pecos-selene-core" +name = "pecos-selene-stab-mps" +version = "0.2.0-dev.0" +dependencies = [ + "anyhow", + "pecos-core", + "pecos-simulators", + "pecos-stab-tn", + "selene-core 0.2.1", +] + +[[package]] +name = "pecos-selene-stab-vec" version = "0.2.0-dev.0" dependencies = [ "anyhow", - "clap", "pecos-core", "pecos-simulators", "selene-core 0.2.1", @@ -4248,6 +4283,22 @@ dependencies = [ "wide 1.3.0", ] +[[package]] +name = "pecos-stab-tn" +version = "0.2.0-dev.0" +dependencies = [ + "approx 0.5.1", + "nalgebra", + "num-complex 0.4.6", + "paste", + "pecos-core", + "pecos-quantum", + "pecos-random", + "pecos-simulators", + "rayon", + "thiserror 2.0.18", +] + [[package]] name = "pecos-tesseract" version = "0.2.0-dev.0" diff --git a/Cargo.toml b/Cargo.toml index 7405a15ce..ce3e865cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,12 @@ resolver = "2" members = [ "python/pecos-rslib", + "python/pecos-rslib-exp", "python/pecos-rslib-cuda", "python/pecos-rslib-llvm", - "python/selene-plugins/pecos-selene-clifford-rz", + "python/selene-plugins/pecos-selene-mast", + "python/selene-plugins/pecos-selene-stab-mps", + "python/selene-plugins/pecos-selene-stab-vec", "python/selene-plugins/pecos-selene-stabilizer", "python/selene-plugins/pecos-selene-statevec", "julia/pecos-julia-ffi", diff --git a/Justfile b/Justfile index b05ab7b67..7033955a0 100644 --- a/Justfile +++ b/Justfile @@ -504,7 +504,7 @@ sync-deps: set -euo pipefail # Quick check: ensure the packages used by the default dev/test lane are importable. # This catches newly added workspace members that an older .venv may be missing. - if uv run --frozen python -c "import importlib.util, sys; required = ('pecos', 'pecos_rslib', 'pecos_selene_clifford_rz', 'pecos_selene_stabilizer', 'pecos_selene_statevec'); missing = [name for name in required if importlib.util.find_spec(name) is None]; sys.exit(1 if missing else 0)" 2>/dev/null; then + if uv run --frozen python -c "import importlib.util, sys; required = ('pecos', 'pecos_rslib', 'pecos_selene_stab_vec', 'pecos_selene_stabilizer', 'pecos_selene_statevec', 'pecos_selene_stab_mps', 'pecos_selene_mast'); missing = [name for name in required if importlib.util.find_spec(name) is None]; sys.exit(1 if missing else 0)" 2>/dev/null; then exit 0 fi echo "Python deps incomplete, running uv sync..." diff --git a/crates/benchmarks/Cargo.toml b/crates/benchmarks/Cargo.toml index 260bd9666..5de039a7f 100644 --- a/crates/benchmarks/Cargo.toml +++ b/crates/benchmarks/Cargo.toml @@ -18,6 +18,7 @@ parallel = ["pecos-simulators/parallel"] gpu-sims = ["dep:pecos-gpu-sims"] cuquantum = ["dep:pecos-cuquantum"] cppsparsestab = ["dep:pecos-cppsparsestab"] +stab-tn = ["dep:pecos-stab-tn"] all-sims = ["gpu-sims", "cuquantum", "cppsparsestab"] [dependencies] @@ -27,6 +28,9 @@ pecos-cuquantum = { workspace = true, optional = true } pecos-cppsparsestab = { workspace = true, optional = true } pecos-core.workspace = true pecos-simulators.workspace = true +pecos-stab-tn = { path = "../../exp/pecos-stab-tn", optional = true } +nalgebra.workspace = true +num-complex.workspace = true [dev-dependencies] criterion.workspace = true diff --git a/crates/benchmarks/benches/benchmarks.rs b/crates/benchmarks/benches/benchmarks.rs index 0c93a36e9..d32dad430 100644 --- a/crates/benchmarks/benches/benchmarks.rs +++ b/crates/benchmarks/benches/benchmarks.rs @@ -17,12 +17,12 @@ use criterion::{Criterion, criterion_group, criterion_main}; mod modules { pub mod allocation_overhead; - pub mod clifford_rz; pub mod cpu_stabilizer_comparison; pub mod dem_builder; pub mod dem_sampler; pub mod dod_statevec; pub mod quizx_eval; + pub mod stab_vec; // TODO: pub mod hadamard_ops; #[cfg(feature = "cuquantum")] pub mod cuquantum; @@ -34,6 +34,8 @@ mod modules { #[cfg(feature = "cppsparsestab")] pub mod sparse_stab_vs_cpp; pub mod sparse_stab_w_vs_y; + #[cfg(feature = "stab-tn")] + pub mod stab_mps_vs_stab_vec; // TODO: pub mod pauli_ops; pub mod pecos_neo_comparison; pub mod rng; @@ -51,16 +53,18 @@ use modules::cuquantum; use modules::gpu_influence_sampler; #[cfg(feature = "cppsparsestab")] use modules::sparse_stab_vs_cpp; +#[cfg(feature = "stab-tn")] +use modules::stab_mps_vs_stab_vec; use modules::{ - allocation_overhead, clifford_rz, cpu_stabilizer_comparison, dem_builder, dem_sampler, - dod_statevec, measurement_sampling, native_statevec_comparison, noise_models, - pecos_neo_comparison, quizx_eval, rng, set_ops, sparse_stab_w_vs_y, sparse_state_vec, - stabilizer_sims, state_vec_sims, surface_code, trig, + allocation_overhead, cpu_stabilizer_comparison, dem_builder, dem_sampler, dod_statevec, + measurement_sampling, native_statevec_comparison, noise_models, pecos_neo_comparison, + quizx_eval, rng, set_ops, sparse_stab_w_vs_y, sparse_state_vec, stab_vec, stabilizer_sims, + state_vec_sims, surface_code, trig, }; fn all_benchmarks(c: &mut Criterion) { allocation_overhead::benchmarks(c); - clifford_rz::benchmarks(c); + stab_vec::benchmarks(c); cpu_stabilizer_comparison::benchmarks(c); quizx_eval::benchmarks(c); #[cfg(feature = "cuquantum")] @@ -83,6 +87,8 @@ fn all_benchmarks(c: &mut Criterion) { sparse_stab_vs_cpp::benchmarks(c); sparse_stab_w_vs_y::benchmarks(c); surface_code::benchmarks(c); + #[cfg(feature = "stab-tn")] + stab_mps_vs_stab_vec::benchmarks(c); trig::benchmarks(c); // TODO: pauli_ops::benchmarks(c); // TODO: hadamard_ops::benchmarks(c); diff --git a/crates/benchmarks/benches/modules/quizx_eval.rs b/crates/benchmarks/benches/modules/quizx_eval.rs index f81595753..57e980048 100644 --- a/crates/benchmarks/benches/modules/quizx_eval.rs +++ b/crates/benchmarks/benches/modules/quizx_eval.rs @@ -13,7 +13,7 @@ //! Evaluate `QuiZX` circuit simplification for T-count reduction. //! //! Tests whether ZX-calculus simplification meaningfully reduces the number -//! of non-Clifford gates in circuits relevant to the `CliffordRz` simulator. +//! of non-Clifford gates in circuits relevant to the `StabVec` simulator. use criterion::{BenchmarkId, Criterion, measurement::Measurement}; use quizx::circuit::Circuit; diff --git a/crates/benchmarks/benches/modules/stab_mps_vs_stab_vec.rs b/crates/benchmarks/benches/modules/stab_mps_vs_stab_vec.rs new file mode 100644 index 000000000..93f35bce4 --- /dev/null +++ b/crates/benchmarks/benches/modules/stab_mps_vs_stab_vec.rs @@ -0,0 +1,404 @@ +// 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. + +//! Performance benchmarks: STN vs `StabVec`. +//! +//! Measures wall time for the same circuits on both simulators to find +//! the crossover point where STN becomes faster. +//! +//! Run: `cargo bench -p pecos-stab-tn` + +use criterion::{BenchmarkId, Criterion, measurement::Measurement}; +use nalgebra::DMatrix; +use num_complex::Complex64; +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StabVec}; +use pecos_stab_tn::mps::svd; +use pecos_stab_tn::stab_mps::StabMps; +use pecos_stab_tn::stab_mps::mast::Mast; +use std::hint::black_box; + +pub fn benchmarks(c: &mut Criterion) { + bench_vary_t_count(c); + bench_vary_qubits(c); + bench_measurement(c); + bench_stab_mps_at_scale(c); + bench_mast_vs_stn(c); + bench_disentangling(c); + bench_svd_comparison(c); + bench_adaptive_truncation(c); +} + +/// Build a random Clifford+T circuit and run it. +fn run_circuit(sim: &mut S, num_qubits: usize, num_t_gates: usize) { + let t = Angle64::QUARTER_TURN / 2u64; + + // Clifford entangling layer + for q in 0..num_qubits { + sim.h(&[QubitId(q)]); + } + for q in 0..num_qubits - 1 { + sim.cx(&[(QubitId(q), QubitId(q + 1))]); + } + + // T gates on rotating qubits + for i in 0..num_t_gates { + let q = i % num_qubits; + sim.rz(t, &[QubitId(q)]); + } + + // Another Clifford layer + for q in (0..num_qubits - 1).rev() { + sim.cx(&[(QubitId(q + 1), QubitId(q))]); + } + for q in 0..num_qubits { + sim.h(&[QubitId(q)]); + } +} + +/// Benchmark: vary T-gate count at fixed qubit count. +fn bench_vary_t_count(c: &mut Criterion) { + let mut group = c.benchmark_group("STN vs CRZ: vary T-count (10 qubits)"); + group.sample_size(10); + + let num_qubits = 10; + for &num_t in &[2, 4, 8, 12, 16, 20] { + group.bench_with_input(BenchmarkId::new("StabVec", num_t), &num_t, |b, &nt| { + b.iter(|| { + let mut sim = StabVec::builder(num_qubits).seed(42).build(); + run_circuit(&mut sim, num_qubits, nt); + black_box(&sim); + }); + }); + + group.bench_with_input(BenchmarkId::new("STN", num_t), &num_t, |b, &nt| { + b.iter(|| { + let mut sim = StabMps::builder(num_qubits).seed(42).build(); + run_circuit(&mut sim, num_qubits, nt); + black_box(&sim); + }); + }); + } + group.finish(); +} + +/// Benchmark: vary qubit count at fixed T-gate count. +fn bench_vary_qubits(c: &mut Criterion) { + let mut group = c.benchmark_group("STN vs CRZ: vary qubits (8 T gates)"); + group.sample_size(10); + + let num_t = 8; + for &num_qubits in &[4, 8, 16, 32, 64] { + group.bench_with_input( + BenchmarkId::new("StabVec", num_qubits), + &num_qubits, + |b, &nq| { + b.iter(|| { + let mut sim = StabVec::builder(nq).seed(42).build(); + run_circuit(&mut sim, nq, num_t); + black_box(&sim); + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("STN", num_qubits), + &num_qubits, + |b, &nq| { + b.iter(|| { + let mut sim = StabMps::builder(nq).seed(42).build(); + run_circuit(&mut sim, nq, num_t); + black_box(&sim); + }); + }, + ); + } + group.finish(); +} + +/// Benchmark: measurement cost comparison. +fn bench_measurement(c: &mut Criterion) { + let mut group = c.benchmark_group("STN vs CRZ: measurement (10 qubits, 8 T)"); + group.sample_size(10); + + let num_qubits = 10; + let num_t = 8; + + group.bench_function("StabVec", |b| { + b.iter(|| { + let mut sim = StabVec::builder(num_qubits).seed(42).build(); + run_circuit(&mut sim, num_qubits, num_t); + let results = sim.mz(&(0..num_qubits).map(QubitId).collect::>()); + black_box(&results); + }); + }); + + group.bench_function("STN", |b| { + b.iter(|| { + let mut sim = StabMps::builder(num_qubits).seed(42).build(); + run_circuit(&mut sim, num_qubits, num_t); + let results = sim.mz(&(0..num_qubits).map(QubitId).collect::>()); + black_box(&results); + }); + }); + + group.finish(); +} + +/// Benchmark: MAST vs STN at varying T-count. +fn bench_mast_vs_stn(c: &mut Criterion) { + let mut group = c.benchmark_group("MAST vs STN: vary T-count (20 qubits)"); + group.sample_size(10); + + let num_qubits = 20; + for &num_t in &[4, 8, 16, 20] { + group.bench_with_input(BenchmarkId::new("STN", num_t), &num_t, |b, &nt| { + b.iter(|| { + let mut sim = StabMps::builder(num_qubits).seed(42).build(); + run_circuit(&mut sim, num_qubits, nt); + black_box(&sim); + }); + }); + + group.bench_with_input(BenchmarkId::new("MAST", num_t), &num_t, |b, &nt| { + b.iter(|| { + let mut sim = Mast::with_seed(num_qubits, nt + 2, 42); + run_circuit(&mut sim, num_qubits, nt); + sim.mz(&[QubitId(0)]); // Force projection + black_box(&sim); + }); + }); + } + group.finish(); +} + +/// Benchmark: STN at scale (CRZ can't do this). +fn bench_stab_mps_at_scale(c: &mut Criterion) { + let mut group = c.benchmark_group("STN at scale"); + group.sample_size(10); + + for &num_qubits in &[50, 100, 200] { + let num_t = num_qubits / 2; + group.bench_with_input( + BenchmarkId::new("STN circuit", num_qubits), + &num_qubits, + |b, &nq| { + b.iter(|| { + let mut sim = StabMps::builder(nq).seed(42).build(); + run_circuit(&mut sim, nq, num_t); + black_box(&sim); + }); + }, + ); + } + group.finish(); +} + +/// Build a "hard" circuit: interleaved Clifford + T layers that create real MPS entanglement. +/// Each layer: CX chain + T on all qubits. Repeats `depth` times. +fn run_interleaved_circuit( + sim: &mut S, + num_qubits: usize, + depth: usize, +) { + let t = Angle64::QUARTER_TURN / 2u64; + + for layer in 0..depth { + // Clifford entangling: H + CX chain (alternating direction) + for q in 0..num_qubits { + sim.h(&[QubitId(q)]); + } + if layer % 2 == 0 { + for q in 0..num_qubits - 1 { + sim.cx(&[(QubitId(q), QubitId(q + 1))]); + } + } else { + for q in (0..num_qubits - 1).rev() { + sim.cx(&[(QubitId(q + 1), QubitId(q))]); + } + } + + // T gate on every qubit + for q in 0..num_qubits { + sim.rz(t, &[QubitId(q)]); + } + } +} + +/// Benchmark: disentangling cost and effectiveness. +/// +/// Measures wall time and bond dim reduction from heuristic disentangling. +/// This provides the baseline for comparing against OFD. +fn bench_disentangling(c: &mut Criterion) { + let mut group = c.benchmark_group("STN disentangling"); + group.sample_size(10); + + // Use the interleaved circuit which creates real MPS entanglement + let num_qubits = 10; + for &depth in &[1, 2, 3] { + let label = format!("{num_qubits}q/depth{depth}"); + + group.bench_with_input(BenchmarkId::new("no_disent", &label), &depth, |b, &d| { + b.iter(|| { + let mut sim = StabMps::builder(num_qubits).seed(42).build(); + run_interleaved_circuit(&mut sim, num_qubits, d); + black_box(sim.max_bond_dim()); + }); + }); + + group.bench_with_input( + BenchmarkId::new("heuristic_1sweep", &label), + &depth, + |b, &d| { + b.iter(|| { + let mut sim = StabMps::builder(num_qubits).seed(42).build(); + run_interleaved_circuit(&mut sim, num_qubits, d); + sim.disentangle(1); + black_box(sim.max_bond_dim()); + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("heuristic_3sweeps", &label), + &depth, + |b, &d| { + b.iter(|| { + let mut sim = StabMps::builder(num_qubits).seed(42).build(); + run_interleaved_circuit(&mut sim, num_qubits, d); + sim.disentangle(3); + black_box(sim.max_bond_dim()); + }); + }, + ); + } + group.finish(); + + // Report bond dims and GF(2) diagnostic for reference + eprintln!("\n=== Bond dimension report (interleaved circuits) ==="); + for &depth in &[1, 2, 3] { + let mut sim = StabMps::builder(num_qubits).seed(42).build(); + run_interleaved_circuit(&mut sim, num_qubits, depth); + let before = sim.max_bond_dim(); + let gf2_rank = sim.gf2_matrix().gf2_rank(); + let gf2_gates = sim.gf2_matrix().num_gates(); + let gf2_min = sim.theoretical_min_bond_dim(); + let applied = sim.disentangle(3); + let after = sim.max_bond_dim(); + eprintln!( + " {num_qubits}q/depth{depth}: bond_dim {before} -> {after} ({applied} gates), GF2: {gf2_gates} gates rank {gf2_rank} (theory min {gf2_min})" + ); + } +} + +/// Benchmark: full SVD vs randomized SVD at various matrix sizes. +/// +/// The randomized SVD (Halko-Martinsson-Tropp) gives O(mnr) cost vs +/// O(mn*min(m,n)) for full SVD. This benchmark measures the crossover. +fn bench_svd_comparison(c: &mut Criterion) { + let mut group = c.benchmark_group("SVD full vs randomized"); + group.sample_size(10); + + // Generate a test matrix of given size with known rank structure + let make_matrix = |rows: usize, cols: usize| -> DMatrix { + DMatrix::from_fn(rows, cols, |i, j| { + let r = (i * 7 + j * 13 + 5) & 0xFFFF; + let c = (i * 3 + j * 11 + 2) & 0xFFFF; + Complex64::new(f64::from(r as u16).sin(), f64::from(c as u16).cos()) + }) + }; + + for &(rows, cols, max_rank) in &[ + (64, 64, 8), // Small matrix, heavy truncation + (128, 128, 16), // Medium matrix + (256, 256, 32), // Large matrix -- rSVD should win here + (512, 512, 32), // Very large -- rSVD should win clearly + ] { + let label = format!("{rows}x{cols}/r{max_rank}"); + let m = make_matrix(rows, cols); + + group.bench_with_input(BenchmarkId::new("full_svd", &label), &m, |b, matrix| { + b.iter(|| { + let result = svd::truncated_svd(matrix, max_rank, 1e-12).unwrap(); + black_box(result.singular_values.len()); + }); + }); + + group.bench_with_input(BenchmarkId::new("auto_svd", &label), &m, |b, matrix| { + b.iter(|| { + let result = svd::truncated_svd_auto(matrix, max_rank, 1e-12).unwrap(); + black_box(result.singular_values.len()); + }); + }); + } + group.finish(); +} + +/// Benchmark: adaptive truncation (error-budget) vs fixed `max_bond_dim`. +fn bench_adaptive_truncation(c: &mut Criterion) { + let mut group = c.benchmark_group("STN adaptive truncation"); + group.sample_size(10); + + let num_qubits = 20; + let depth = 2; + + // Fixed max_bond_dim = 64 (default) + group.bench_function("fixed_chi64", |b| { + b.iter(|| { + let mut sim = StabMps::builder(num_qubits).seed(42).build(); + run_interleaved_circuit(&mut sim, num_qubits, depth); + black_box(sim.max_bond_dim()); + }); + }); + + // Adaptive with error budget 1e-6, cap 64 + group.bench_function("adaptive_1e-6_cap64", |b| { + b.iter(|| { + let mut sim = StabMps::builder(num_qubits) + .seed(42) + .max_truncation_error(1e-6) + .build(); + run_interleaved_circuit(&mut sim, num_qubits, depth); + black_box(sim.max_bond_dim()); + }); + }); + + // Adaptive with error budget 1e-3, cap 64 + group.bench_function("adaptive_1e-3_cap64", |b| { + b.iter(|| { + let mut sim = StabMps::builder(num_qubits) + .seed(42) + .max_truncation_error(1e-3) + .build(); + run_interleaved_circuit(&mut sim, num_qubits, depth); + black_box(sim.max_bond_dim()); + }); + }); + + group.finish(); + + // Report bond dims + eprintln!("\n=== Adaptive truncation report ({num_qubits}q, depth {depth}) ==="); + for &(label, err) in &[ + ("fixed", -1.0), + ("adaptive_1e-6", 1e-6), + ("adaptive_1e-3", 1e-3), + ] { + let mut builder = StabMps::builder(num_qubits).seed(42); + if err > 0.0 { + builder = builder.max_truncation_error(err); + } + let mut sim = builder.build(); + run_interleaved_circuit(&mut sim, num_qubits, depth); + eprintln!(" {label}: max_bond_dim = {}", sim.max_bond_dim()); + } +} diff --git a/crates/benchmarks/benches/modules/clifford_rz.rs b/crates/benchmarks/benches/modules/stab_vec.rs similarity index 92% rename from crates/benchmarks/benches/modules/clifford_rz.rs rename to crates/benchmarks/benches/modules/stab_vec.rs index 83a4706c6..04a69e83b 100644 --- a/crates/benchmarks/benches/modules/clifford_rz.rs +++ b/crates/benchmarks/benches/modules/stab_vec.rs @@ -21,7 +21,7 @@ use criterion::{BenchmarkId, Criterion, measurement::Measurement}; use pecos_core::{Angle64, QubitId}; use pecos_simulators::{ - ArbitraryRotationGateable, CHForm, CliffordGateable, CliffordRz, QuantumSimulator, SparseStab, + ArbitraryRotationGateable, CHForm, CliffordGateable, QuantumSimulator, SparseStab, StabVec, }; use std::hint::black_box; @@ -89,11 +89,11 @@ fn bench_rz_term_growth(c: &mut Criterion) { let num_qubits = 2; for &num_rz in &[1, 2, 4, 6, 8] { group.bench_with_input( - BenchmarkId::new("CliffordRz", format!("{num_rz}_rz")), + BenchmarkId::new("StabVec", format!("{num_rz}_rz")), &num_rz, |b, &nrz| { b.iter(|| { - let mut sim = CliffordRz::new_with_seed(num_qubits, 42); + let mut sim = StabVec::new_with_seed(num_qubits, 42); sim.h(&[QubitId(0)]).h(&[QubitId(1)]); for i in 0..nrz { let theta = Angle64::from_radians(0.3 + 0.1 * i as f64); @@ -119,7 +119,7 @@ fn bench_state_vector(c: &mut Criterion) { BenchmarkId::new("2_terms", format!("{num_qubits}q")), &num_qubits, |b, &nq| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); sim.h(&[QubitId(0)]); if nq > 1 { sim.cx(&[(QubitId(0), QubitId(1))]); @@ -140,7 +140,7 @@ fn bench_state_vector(c: &mut Criterion) { BenchmarkId::new(format!("{terms}_terms"), format!("{nq}q_vary_terms")), &num_rz, |b, &nrz| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -162,7 +162,7 @@ fn bench_state_vector(c: &mut Criterion) { /// Benchmark measurement cost. #[allow(clippy::cast_precision_loss)] // small loop index as f64 fn bench_measurement(c: &mut Criterion) { - let mut group = c.benchmark_group("CliffordRz Measurement"); + let mut group = c.benchmark_group("StabVec Measurement"); group.sample_size(20); for &num_rz in &[1, 2, 3, 4] { @@ -172,7 +172,7 @@ fn bench_measurement(c: &mut Criterion) { &num_rz, |b, &nrz| { b.iter(|| { - let mut sim = CliffordRz::new_with_seed(4, 42); + let mut sim = StabVec::new_with_seed(4, 42); for q in 0..4 { sim.h(&[QubitId(q)]); } @@ -244,7 +244,7 @@ fn bench_inner_product(c: &mut Criterion) { /// Benchmark ONLY the measurement call (circuit pre-built). fn bench_measurement_only(c: &mut Criterion) { - let mut group = c.benchmark_group("CliffordRz Measurement Only"); + let mut group = c.benchmark_group("StabVec Measurement Only"); group.sample_size(10); for &num_qubits in &[4, 8, 14, 18, 22] { @@ -253,7 +253,7 @@ fn bench_measurement_only(c: &mut Criterion) { &num_qubits, |b, &nq| { // Pre-build the circuit state - let mut template = CliffordRz::new_with_seed(nq, 42); + let mut template = StabVec::new_with_seed(nq, 42); for q in 0..nq { template.h(&[QubitId(q)]); } @@ -299,7 +299,7 @@ fn bench_clone_cost(c: &mut Criterion) { /// Benchmark measurement at higher qubit counts to show where O(2^n) becomes the bottleneck. fn bench_measurement_scaling(c: &mut Criterion) { - let mut group = c.benchmark_group("CliffordRz Measurement Scaling"); + let mut group = c.benchmark_group("StabVec Measurement Scaling"); group.sample_size(10); // Fixed 2 RZ gates (4 terms), vary qubit count @@ -309,7 +309,7 @@ fn bench_measurement_scaling(c: &mut Criterion) { &num_qubits, |b, &nq| { b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -344,7 +344,7 @@ fn bench_realistic_circuit(c: &mut Criterion) { BenchmarkId::new("20_H_gates", format!("{terms}_terms")), &num_rz, |b, &nrz| { - let mut template = CliffordRz::new_with_seed(num_qubits, 42); + let mut template = StabVec::new_with_seed(num_qubits, 42); for q in 0..num_qubits { template.h(&[QubitId(q)]); } @@ -365,7 +365,7 @@ fn bench_realistic_circuit(c: &mut Criterion) { // Benchmark 1: Clifford-only portion (no term doubling) group.bench_function("20q_clifford_only_4terms", |b| { // Pre-build a 4-term state - let mut template = CliffordRz::new_with_seed(num_qubits, 42); + let mut template = StabVec::new_with_seed(num_qubits, 42); for q in 0..num_qubits { template.h(&[QubitId(q)]); } @@ -385,7 +385,7 @@ fn bench_realistic_circuit(c: &mut Criterion) { // Benchmark 2: Single RZ (doubles terms from 4 to 8) group.bench_function("20q_single_rz_4to8terms", |b| { - let mut template = CliffordRz::new_with_seed(num_qubits, 42); + let mut template = StabVec::new_with_seed(num_qubits, 42); for q in 0..num_qubits { template.h(&[QubitId(q)]); } @@ -401,7 +401,7 @@ fn bench_realistic_circuit(c: &mut Criterion) { // Benchmark 3: Measurement on 4-term, 20-qubit state group.bench_function("20q_measurement_4terms", |b| { - let mut template = CliffordRz::new_with_seed(num_qubits, 42); + let mut template = StabVec::new_with_seed(num_qubits, 42); for q in 0..num_qubits { template.h(&[QubitId(q)]); } @@ -429,7 +429,7 @@ fn bench_rz_commutation_opportunity(c: &mut Criterion) { // Pattern: RZ(q) - S(q) - RZ(q). S commutes with RZ, so these could fuse. group.bench_function("rz_S_rz_same_qubit", |b| { b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -444,7 +444,7 @@ fn bench_rz_commutation_opportunity(c: &mut Criterion) { // Pattern: RZ(q) - CZ(q,r) - RZ(q). CZ is diagonal, commutes with RZ. group.bench_function("rz_CZ_rz_same_qubit", |b| { b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -460,7 +460,7 @@ fn bench_rz_commutation_opportunity(c: &mut Criterion) { group.bench_function("rz_fused_ideal", |b| { let fused = Angle64::from_radians(0.6); b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -486,7 +486,7 @@ fn bench_t_gate_patterns(c: &mut Criterion) { // Pattern: T gates on same qubit fuse (T*T = S = Clifford) group.bench_function("4T_same_qubit_fuses_to_Z", |b| { b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -503,7 +503,7 @@ fn bench_t_gate_patterns(c: &mut Criterion) { // Pattern: T on different qubits (no fusion possible) group.bench_function("4T_different_qubits", |b| { b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -519,7 +519,7 @@ fn bench_t_gate_patterns(c: &mut Criterion) { // Pattern: T-Clifford-T interleaved on same qubit group.bench_function("T_CZ_T_same_qubit", |b| { b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -545,7 +545,7 @@ fn bench_rz_fusion_opportunity(c: &mut Criterion) { // Without fusion: 4 separate RZ gates on same qubit = 16 terms group.bench_function("4_separate_rz_same_qubit", |b| { b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -562,7 +562,7 @@ fn bench_rz_fusion_opportunity(c: &mut Criterion) { group.bench_function("1_fused_rz_same_qubit", |b| { let fused_theta = Angle64::from_radians(0.3 * 4.0); b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -586,7 +586,7 @@ fn bench_small_angle_rz(c: &mut Criterion) { group.bench_function("10_rz_5deg_different_qubits", |b| { let theta = Angle64::from_radians(5.0f64.to_radians()); b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -602,7 +602,7 @@ fn bench_small_angle_rz(c: &mut Criterion) { group.bench_function("10_rz_1deg_different_qubits", |b| { let theta = Angle64::from_radians(1.0f64.to_radians()); b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } @@ -632,7 +632,7 @@ fn bench_end_to_end(c: &mut Criterion) { |b, &nrz| { let theta = Angle64::from_radians(0.3); b.iter(|| { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); // Initial Clifford layer for q in 0..nq { sim.h(&[QubitId(q)]); diff --git a/crates/pecos-cppsparsestab/src/lib.rs b/crates/pecos-cppsparsestab/src/lib.rs index 149d30029..fd9587866 100644 --- a/crates/pecos-cppsparsestab/src/lib.rs +++ b/crates/pecos-cppsparsestab/src/lib.rs @@ -172,6 +172,10 @@ impl CppSparseStab { } impl QuantumSimulator for CppSparseStab { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.state_mut().clear(); // Don't reset the RNG - just reset the quantum state @@ -512,8 +516,4 @@ impl StabilizerTableauSimulator for CppSparseStab { fn destab_tableau(&self) -> String { self.format_generators(false) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } diff --git a/crates/pecos-cuquantum/src/stabilizer.rs b/crates/pecos-cuquantum/src/stabilizer.rs index 9fdc3ee29..2f2799f65 100644 --- a/crates/pecos-cuquantum/src/stabilizer.rs +++ b/crates/pecos-cuquantum/src/stabilizer.rs @@ -524,6 +524,10 @@ impl QuantumSimulator for CuStabilizer { self.measurement_count = 0; self } + + fn num_qubits(&self) -> usize { + self.num_qubits + } } impl CliffordGateable for CuStabilizer { @@ -625,10 +629,6 @@ impl StabilizerTableauSimulator for CuStabilizer { fn destab_tableau(&self) -> String { unimplemented!("CuStabilizer does not support local tableau access") } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } impl ForcedMeasurement for CuStabilizer { diff --git a/crates/pecos-cuquantum/src/statevec.rs b/crates/pecos-cuquantum/src/statevec.rs index 57d5e1b64..3bf547935 100644 --- a/crates/pecos-cuquantum/src/statevec.rs +++ b/crates/pecos-cuquantum/src/statevec.rs @@ -539,6 +539,10 @@ impl QuantumSimulator for CuStateVec { .expect("Failed to reset state vector"); self } + + fn num_qubits(&self) -> usize { + self.num_qubits + } } impl CliffordGateable for CuStateVec { diff --git a/crates/pecos-engines/src/lib.rs b/crates/pecos-engines/src/lib.rs index 156e8fe83..e24ac46ce 100644 --- a/crates/pecos-engines/src/lib.rs +++ b/crates/pecos-engines/src/lib.rs @@ -30,14 +30,14 @@ pub use noise::{ }; pub use pecos_core::errors::PecosError; pub use quantum::{ - CliffordRzEngine, CoinTossEngine, DenseStateVecEngine, DensityMatrixEngine, QuantumEngine, + CoinTossEngine, DenseStateVecEngine, DensityMatrixEngine, QuantumEngine, StabVecEngine, StabilizerEngine, StateVecEngine, StateVectorEngine, StateVectorSimulator, }; pub use quantum_engine_builder::{ - CliffordRzEngineBuilder, CoinTossEngineBuilder, DensityMatrixEngineBuilder, - IntoQuantumEngineBuilder, QuantumEngineBuilder, SparseStabEngineBuilder, - StabilizerEngineBuilder, StateVectorEngineBuilder, clifford_rz, coin_toss, density_matrix, - sparse_stab, stabilizer, state_vector, + CoinTossEngineBuilder, DensityMatrixEngineBuilder, IntoQuantumEngineBuilder, + QuantumEngineBuilder, SparseStabEngineBuilder, StabVecEngineBuilder, StabilizerEngineBuilder, + StateVectorEngineBuilder, coin_toss, density_matrix, sparse_stab, stab_vec, stabilizer, + state_vector, }; pub use quantum_system::QuantumSystem; pub use shot_results::data_vec::DataVecType; diff --git a/crates/pecos-engines/src/prelude.rs b/crates/pecos-engines/src/prelude.rs index 1bae72a9c..f30bd3557 100644 --- a/crates/pecos-engines/src/prelude.rs +++ b/crates/pecos-engines/src/prelude.rs @@ -21,13 +21,13 @@ pub use crate::{ // Quantum engines and builders pub use crate::quantum::{ - CliffordRzEngine, CoinTossEngine, DensityMatrixEngine, QuantumEngine, SparseStabEngine, + CoinTossEngine, DensityMatrixEngine, QuantumEngine, SparseStabEngine, StabVecEngine, StabilizerEngine, StateVecEngine, new_quantum_engine_arbitrary_qgate, }; pub use crate::quantum_engine_builder::{ - CliffordRzEngineBuilder, CoinTossEngineBuilder, DensityMatrixEngineBuilder, - IntoQuantumEngineBuilder, SparseStabEngineBuilder, StabilizerEngineBuilder, - StateVectorEngineBuilder, clifford_rz, coin_toss, density_matrix, sparse_stab, stabilizer, + CoinTossEngineBuilder, DensityMatrixEngineBuilder, IntoQuantumEngineBuilder, + SparseStabEngineBuilder, StabVecEngineBuilder, StabilizerEngineBuilder, + StateVectorEngineBuilder, coin_toss, density_matrix, sparse_stab, stab_vec, stabilizer, state_vector, }; diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index 80f408935..7f10035d3 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -10,8 +10,8 @@ use pecos_core::errors::PecosError; use pecos_random::{PecosRng, SeedableRng}; use pecos_simulators::clifford_rotation::CliffordRotation; use pecos_simulators::{ - ArbitraryRotationGateable, CliffordGateable, CliffordRz, CoinToss, DensityMatrix, - QuantumSimulator, SparseStab, Stabilizer, StateVec, StateVecAoS, StateVecSoA, + ArbitraryRotationGateable, CliffordGateable, CoinToss, DensityMatrix, QuantumSimulator, + SparseStab, StabVec, Stabilizer, StateVec, StateVecAoS, StateVecSoA, }; use std::any::Any; use std::fmt::Debug; @@ -271,7 +271,7 @@ fn process_clifford_message( @@ -601,9 +601,6 @@ pub trait StateVectorSimulator: where ::Rng: Clone, { - /// Returns the number of qubits in the simulator. - fn num_qubits(&self) -> usize; - /// Create a new simulator with the specified number of qubits. fn create(num_qubits: usize) -> Self; @@ -615,10 +612,6 @@ where } impl StateVectorSimulator for StateVec { - fn num_qubits(&self) -> usize { - self.num_qubits() - } - fn create(num_qubits: usize) -> Self { StateVec::new(num_qubits) } @@ -633,10 +626,6 @@ impl StateVectorSimulator for StateVec { } impl StateVectorSimulator for StateVecAoS { - fn num_qubits(&self) -> usize { - self.num_qubits() - } - fn create(num_qubits: usize) -> Self { StateVecAoS::new(num_qubits) } @@ -651,10 +640,6 @@ impl StateVectorSimulator for StateVecAoS { } impl StateVectorSimulator for StateVecSoA { - fn num_qubits(&self) -> usize { - self.num_qubits() - } - fn create(num_qubits: usize) -> Self { StateVecSoA::new(num_qubits) } @@ -1434,25 +1419,25 @@ impl QuantumEngine for StabilizerEngine { } // ============================================================================ -// Clifford+RZ Engine +// StabVec Engine // ============================================================================ -/// A quantum engine that uses the Clifford+RZ simulator. +/// A quantum engine that uses the `StabVec` simulator. /// /// Supports all Clifford gates plus arbitrary rotation gates (RZ, RX, RY, RZZ, etc.) /// via sum-over-Cliffords decomposition. More efficient than state vector for /// circuits with many qubits and few non-Clifford gates. #[derive(Debug, Clone)] -pub struct CliffordRzEngine { - simulator: CliffordRz, +pub struct StabVecEngine { + simulator: StabVec, } -impl CliffordRzEngine { - /// Create a new Clifford+RZ engine with the specified number of qubits. +impl StabVecEngine { + /// Create a new `StabVec` engine with the specified number of qubits. #[must_use] pub fn new(num_qubits: usize) -> Self { Self { - simulator: CliffordRz::new(num_qubits), + simulator: StabVec::new(num_qubits), } } @@ -1460,12 +1445,12 @@ impl CliffordRzEngine { #[must_use] pub fn with_seed(num_qubits: usize, seed: u64) -> Self { Self { - simulator: CliffordRz::new_with_seed(num_qubits, seed), + simulator: StabVec::new_with_seed(num_qubits, seed), } } } -impl Engine for CliffordRzEngine { +impl Engine for StabVecEngine { type Input = ByteMessage; type Output = ByteMessage; @@ -1479,7 +1464,7 @@ impl Engine for CliffordRzEngine { } } -impl RngManageable for CliffordRzEngine { +impl RngManageable for StabVecEngine { type Rng = PecosRng; fn set_rng(&mut self, rng: Self::Rng) { @@ -1495,7 +1480,7 @@ impl RngManageable for CliffordRzEngine { } } -impl QuantumEngine for CliffordRzEngine { +impl QuantumEngine for StabVecEngine { fn set_seed(&mut self, seed: u64) { let rng = PecosRng::seed_from_u64(seed); self.simulator.set_rng(rng); diff --git a/crates/pecos-engines/src/quantum_engine_builder.rs b/crates/pecos-engines/src/quantum_engine_builder.rs index 1ae0e754a..c185acdff 100644 --- a/crates/pecos-engines/src/quantum_engine_builder.rs +++ b/crates/pecos-engines/src/quantum_engine_builder.rs @@ -339,11 +339,11 @@ pub fn stabilizer() -> StabilizerEngineBuilder { /// Builder for Clifford+RZ quantum engine #[derive(Debug, Clone, Default)] -pub struct CliffordRzEngineBuilder { +pub struct StabVecEngineBuilder { num_qubits: Option, } -impl CliffordRzEngineBuilder { +impl StabVecEngineBuilder { #[must_use] pub fn new() -> Self { Self::default() @@ -356,12 +356,12 @@ impl CliffordRzEngineBuilder { } } -impl QuantumEngineBuilder for CliffordRzEngineBuilder { +impl QuantumEngineBuilder for StabVecEngineBuilder { fn build(&mut self) -> Result, PecosError> { let num_qubits = self.num_qubits.ok_or_else(|| { PecosError::Input("Number of qubits not specified for Clifford+RZ engine".to_string()) })?; - Ok(Box::new(crate::quantum::CliffordRzEngine::new(num_qubits))) + Ok(Box::new(crate::quantum::StabVecEngine::new(num_qubits))) } fn set_qubits_if_needed(&mut self, num_qubits: usize) { @@ -371,7 +371,7 @@ impl QuantumEngineBuilder for CliffordRzEngineBuilder { } } -impl IntoQuantumEngineBuilder for CliffordRzEngineBuilder { +impl IntoQuantumEngineBuilder for StabVecEngineBuilder { type Builder = Self; fn into_quantum_engine_builder(self) -> Self::Builder { @@ -381,8 +381,8 @@ impl IntoQuantumEngineBuilder for CliffordRzEngineBuilder { /// Create a Clifford+RZ quantum engine builder #[must_use] -pub fn clifford_rz() -> CliffordRzEngineBuilder { - CliffordRzEngineBuilder::new() +pub fn stab_vec() -> StabVecEngineBuilder { + StabVecEngineBuilder::new() } /// Builder for density matrix quantum engine diff --git a/crates/pecos-engines/tests/clifford_rz_engine_test.rs b/crates/pecos-engines/tests/stab_vec_engine_test.rs similarity index 91% rename from crates/pecos-engines/tests/clifford_rz_engine_test.rs rename to crates/pecos-engines/tests/stab_vec_engine_test.rs index f5ec2645d..0e639823c 100644 --- a/crates/pecos-engines/tests/clifford_rz_engine_test.rs +++ b/crates/pecos-engines/tests/stab_vec_engine_test.rs @@ -10,18 +10,18 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! Integration tests for `CliffordRzEngine` via `ByteMessage`. +//! Integration tests for `StabVecEngine` via `ByteMessage`. //! -//! Tests the full `ByteMessage` -> `Engine::process()` -> `CliffordRz` path. +//! Tests the full `ByteMessage` -> `Engine::process()` -> `StabVec` path. use pecos_core::Angle64; use pecos_engines::Engine; use pecos_engines::byte_message::ByteMessageBuilder; -use pecos_engines::quantum::{CliffordRzEngine, StateVecEngine}; +use pecos_engines::quantum::{StabVecEngine, StateVecEngine}; /// Helper: build a circuit, process it, return measurement outcomes. fn run(num_qubits: usize, build: impl FnOnce(&mut ByteMessageBuilder)) -> Vec { - let mut engine = CliffordRzEngine::with_seed(num_qubits, 42); + let mut engine = StabVecEngine::with_seed(num_qubits, 42); let mut builder = ByteMessageBuilder::new(); let _ = builder.for_quantum_operations(); build(&mut builder); @@ -218,7 +218,7 @@ fn prep_resets_qubit() { #[test] fn engine_reset() { - let mut engine = CliffordRzEngine::with_seed(1, 42); + let mut engine = StabVecEngine::with_seed(1, 42); let mut b1 = ByteMessageBuilder::new(); let _ = b1.for_quantum_operations(); @@ -240,7 +240,7 @@ fn engine_reset() { fn deterministic_seed() { let theta = Angle64::from_radians(0.5); let make = |seed: u64| -> Vec { - let mut engine = CliffordRzEngine::with_seed(2, seed); + let mut engine = StabVecEngine::with_seed(2, seed); let mut b = ByteMessageBuilder::new(); let _ = b.for_quantum_operations(); b.h(&[0, 1]); @@ -254,10 +254,10 @@ fn deterministic_seed() { #[test] fn builder_pattern() { - use pecos_engines::clifford_rz; use pecos_engines::quantum_engine_builder::QuantumEngineBuilder; + use pecos_engines::stab_vec; - let mut b = clifford_rz().qubits(2); + let mut b = stab_vec().qubits(2); let mut engine = b.build().unwrap(); engine.set_seed(42); @@ -271,7 +271,7 @@ fn builder_pattern() { } // ============================================================================ -// Round-trip: CliffordRzEngine vs StateVecEngine via ByteMessage +// Round-trip: StabVecEngine vs StateVecEngine via ByteMessage // ============================================================================ /// Build a circuit as a `ByteMessage`, run it on both engines, compare. @@ -290,7 +290,7 @@ fn build_circuit(b: &mut ByteMessageBuilder) { #[test] fn round_trip_statistical_comparison() { - // Run the same circuit on CliffordRzEngine and StateVecEngine many times, + // Run the same circuit on StabVecEngine and StateVecEngine many times, // verify the measurement distributions match. let num_shots = 5000; let num_qubits = 3; @@ -301,9 +301,9 @@ fn round_trip_statistical_comparison() { #[allow(clippy::cast_sign_loss)] // num_shots is a positive literal for seed in 0..num_shots as u64 { - // CliffordRz engine + // StabVec engine { - let mut engine = CliffordRzEngine::with_seed(num_qubits, seed); + let mut engine = StabVecEngine::with_seed(num_qubits, seed); let mut b = ByteMessageBuilder::new(); let _ = b.for_quantum_operations(); build_circuit(&mut b); @@ -331,14 +331,14 @@ fn round_trip_statistical_comparison() { // Compare distributions. Both engines should produce similar statistics. // Allow some deviation due to: // 1. Different RNG consumption patterns between engines - // 2. Pruning in CliffordRz introduces tiny errors + // 2. Pruning in StabVec introduces tiny errors let tolerance = 5.0 / f64::from(num_shots).sqrt(); // ~5 sigma for i in 0..num_outcomes { let crz_prob = f64::from(crz_counts[i]) / f64::from(num_shots); let sv_prob = f64::from(sv_counts[i]) / f64::from(num_shots); assert!( (crz_prob - sv_prob).abs() < tolerance, - "Outcome {i:03b}: CliffordRz={crz_prob:.4}, StateVec={sv_prob:.4}, diff={:.4}, tol={tolerance:.4}", + "Outcome {i:03b}: StabVec={crz_prob:.4}, StateVec={sv_prob:.4}, diff={:.4}, tol={tolerance:.4}", (crz_prob - sv_prob).abs() ); } @@ -349,7 +349,7 @@ fn round_trip_deterministic_clifford_only() { // For a purely Clifford circuit, both engines should give IDENTICAL outcomes // with the same seed (no pruning involved). for seed in 0..100u64 { - let mut crz = CliffordRzEngine::with_seed(3, seed); + let mut crz = StabVecEngine::with_seed(3, seed); let mut sv = StateVecEngine::with_seed(3, seed); let build = |b: &mut ByteMessageBuilder| { diff --git a/crates/pecos-foreign/src/conformance.rs b/crates/pecos-foreign/src/conformance.rs index 593253091..103d80e45 100644 --- a/crates/pecos-foreign/src/conformance.rs +++ b/crates/pecos-foreign/src/conformance.rs @@ -200,12 +200,12 @@ fn test_batch_h(sim: &mut ForeignSimulator, report: &mut ConformanceReport) { pub unsafe extern "C" fn pecos_run_conformance_tests( handle: *mut (), vtable: *const ForeignSimulatorVTable, - _num_qubits: usize, + num_qubits: usize, report_out: *mut ConformanceReport, ) -> i32 { let vtable_copy = unsafe { *vtable }; - let Some(sim) = (unsafe { ForeignSimulator::new(handle, vtable_copy) }) else { + let Some(sim) = (unsafe { ForeignSimulator::new(handle, vtable_copy, num_qubits) }) else { // Version mismatch unsafe { *report_out = ConformanceReport::new() }; return 0; diff --git a/crates/pecos-foreign/src/discovery.rs b/crates/pecos-foreign/src/discovery.rs index 67979c29d..f91758ceb 100644 --- a/crates/pecos-foreign/src/discovery.rs +++ b/crates/pecos-foreign/src/discovery.rs @@ -45,6 +45,8 @@ pub struct PluginDescriptor { /// Opaque handle to the simulator, or null if the plugin does not provide one. pub simulator_handle: *mut (), + /// Number of qubits the simulator was created with. + pub simulator_num_qubits: usize, /// Simulator vtable, or null if the plugin does not provide a simulator. pub simulator_vtable: *const ForeignSimulatorVTable, } @@ -132,6 +134,7 @@ pub fn load_plugin(path: &Path) -> Result { decoder_handle: std::ptr::null_mut(), decoder_vtable: std::ptr::null(), simulator_handle: std::ptr::null_mut(), + simulator_num_qubits: 0, simulator_vtable: std::ptr::null(), }; @@ -163,7 +166,13 @@ pub fn load_plugin(path: &Path) -> Result { // Wrap simulator if provided. let simulator = if !desc.simulator_handle.is_null() && !desc.simulator_vtable.is_null() { let vtable_copy = unsafe { *desc.simulator_vtable }; - unsafe { ForeignSimulator::new(desc.simulator_handle, vtable_copy) } + unsafe { + ForeignSimulator::new( + desc.simulator_handle, + vtable_copy, + desc.simulator_num_qubits, + ) + } } else { None }; diff --git a/crates/pecos-foreign/src/engine.rs b/crates/pecos-foreign/src/engine.rs index bcf1a0fbe..05645b8e4 100644 --- a/crates/pecos-foreign/src/engine.rs +++ b/crates/pecos-foreign/src/engine.rs @@ -20,7 +20,7 @@ use pecos_engines::Engine; use pecos_engines::byte_message::builder::ByteMessageBuilder; use pecos_engines::byte_message::message::ByteMessage; use pecos_engines::quantum::{ - CliffordRzEngine, CoinTossEngine, DensityMatrixEngine, SparseStabEngine, StabilizerEngine, + CoinTossEngine, DensityMatrixEngine, SparseStabEngine, StabVecEngine, StabilizerEngine, StateVecEngine, }; use std::ffi::CStr; @@ -34,7 +34,7 @@ enum EngineInner { StateVec(StateVecEngine), SparseStab(SparseStabEngine), Stabilizer(StabilizerEngine), - CliffordRz(CliffordRzEngine), + StabVec(StabVecEngine), DensityMatrix(DensityMatrixEngine), CoinToss(CoinTossEngine), } @@ -45,7 +45,7 @@ impl EngineInner { Self::StateVec(e) => e.process(input), Self::SparseStab(e) => e.process(input), Self::Stabilizer(e) => e.process(input), - Self::CliffordRz(e) => e.process(input), + Self::StabVec(e) => e.process(input), Self::DensityMatrix(e) => e.process(input), Self::CoinToss(e) => e.process(input), } @@ -56,7 +56,7 @@ impl EngineInner { Self::StateVec(e) => e.reset(), Self::SparseStab(e) => e.reset(), Self::Stabilizer(e) => e.reset(), - Self::CliffordRz(e) => e.reset(), + Self::StabVec(e) => e.reset(), Self::DensityMatrix(e) => e.reset(), Self::CoinToss(e) => e.reset(), } @@ -81,7 +81,7 @@ pub struct PecosCircuitBuilder { /// /// # Arguments /// - `engine_type`: null-terminated C string, one of: -/// `"state_vec"`, `"sparse_stab"`, `"stabilizer"`, `"clifford_rz"`, +/// `"state_vec"`, `"sparse_stab"`, `"stabilizer"`, `"stab_vec"`, /// `"density_matrix"`, `"coin_toss"` /// - `num_qubits`: number of qubits /// - `seed`: RNG seed (0 means use default/random seed) @@ -107,7 +107,7 @@ pub unsafe extern "C" fn pecos_engine_create( "state_vec" => EngineInner::StateVec(StateVecEngine::new(num_qubits)), "sparse_stab" => EngineInner::SparseStab(SparseStabEngine::new(num_qubits)), "stabilizer" => EngineInner::Stabilizer(StabilizerEngine::new(num_qubits)), - "clifford_rz" => EngineInner::CliffordRz(CliffordRzEngine::new(num_qubits)), + "stab_vec" => EngineInner::StabVec(StabVecEngine::new(num_qubits)), "density_matrix" => EngineInner::DensityMatrix(DensityMatrixEngine::new(num_qubits)), "coin_toss" => EngineInner::CoinToss(CoinTossEngine::new(num_qubits)), _ => return std::ptr::null_mut(), @@ -117,7 +117,7 @@ pub unsafe extern "C" fn pecos_engine_create( "state_vec" => EngineInner::StateVec(StateVecEngine::with_seed(num_qubits, seed)), "sparse_stab" => EngineInner::SparseStab(SparseStabEngine::with_seed(num_qubits, seed)), "stabilizer" => EngineInner::Stabilizer(StabilizerEngine::with_seed(num_qubits, seed)), - "clifford_rz" => EngineInner::CliffordRz(CliffordRzEngine::with_seed(num_qubits, seed)), + "stab_vec" => EngineInner::StabVec(StabVecEngine::with_seed(num_qubits, seed)), "density_matrix" => { EngineInner::DensityMatrix(DensityMatrixEngine::with_seed(num_qubits, seed)) } diff --git a/crates/pecos-foreign/src/ffi.rs b/crates/pecos-foreign/src/ffi.rs index f01886b71..10b0b027c 100644 --- a/crates/pecos-foreign/src/ffi.rs +++ b/crates/pecos-foreign/src/ffi.rs @@ -183,9 +183,10 @@ pub unsafe extern "C" fn pecos_foreign_decoder_free(decoder: *mut ForeignDecoder pub unsafe extern "C" fn pecos_foreign_simulator_create( handle: *mut (), vtable: *const ForeignSimulatorVTable, + num_qubits: usize, ) -> *mut ForeignSimulator { let vtable_copy = unsafe { *vtable }; - let Some(sim) = (unsafe { ForeignSimulator::new(handle, vtable_copy) }) else { + let Some(sim) = (unsafe { ForeignSimulator::new(handle, vtable_copy, num_qubits) }) else { return std::ptr::null_mut(); }; Box::into_raw(Box::new(sim)) diff --git a/crates/pecos-foreign/src/simulator.rs b/crates/pecos-foreign/src/simulator.rs index 8d1fdf366..3468a639e 100644 --- a/crates/pecos-foreign/src/simulator.rs +++ b/crates/pecos-foreign/src/simulator.rs @@ -116,6 +116,7 @@ unsafe impl Send for ForeignSimulator {} pub struct ForeignSimulator { handle: *mut (), vtable: ForeignSimulatorVTable, + num_qubits: usize, /// RNG used by PECOS's noise system. The foreign simulator has its own /// internal RNG; this one is for the Rust framework (noise injection, etc.). rng: PecosRng, @@ -133,7 +134,11 @@ impl ForeignSimulator { /// - The foreign simulator is thread-safe (Send) /// /// Returns `None` if the vtable version does not match the expected ABI version. - pub unsafe fn new(handle: *mut (), vtable: ForeignSimulatorVTable) -> Option { + pub unsafe fn new( + handle: *mut (), + vtable: ForeignSimulatorVTable, + num_qubits: usize, + ) -> Option { if vtable.version != crate::version::SIMULATOR_VTABLE_VERSION { log::error!( "Foreign simulator ABI version mismatch: plugin has v{}, PECOS expects v{}", @@ -145,6 +150,7 @@ impl ForeignSimulator { Some(Self { handle, vtable, + num_qubits, rng: PecosRng::seed_from_u64(0), }) } @@ -186,6 +192,10 @@ impl QuantumSimulator for ForeignSimulator { } self } + + fn num_qubits(&self) -> usize { + self.num_qubits + } } impl CliffordGateable for ForeignSimulator { diff --git a/crates/pecos-foreign/tests/conformance_test.rs b/crates/pecos-foreign/tests/conformance_test.rs index 975d0e6ca..9856486de 100644 --- a/crates/pecos-foreign/tests/conformance_test.rs +++ b/crates/pecos-foreign/tests/conformance_test.rs @@ -112,7 +112,7 @@ fn make_real_sim(n: usize) -> ForeignSimulator { destroy: real_destroy, }; - unsafe { ForeignSimulator::new(handle, vtable) }.expect("vtable version should match") + unsafe { ForeignSimulator::new(handle, vtable, n) }.expect("vtable version should match") } #[test] @@ -162,7 +162,7 @@ fn make_broken_sim(n: usize) -> ForeignSimulator { destroy: real_destroy, }; - unsafe { ForeignSimulator::new(handle, vtable) }.expect("vtable version should match") + unsafe { ForeignSimulator::new(handle, vtable, n) }.expect("vtable version should match") } #[test] diff --git a/crates/pecos-foreign/tests/engine_test.rs b/crates/pecos-foreign/tests/engine_test.rs index 57763bb92..a2b79073c 100644 --- a/crates/pecos-foreign/tests/engine_test.rs +++ b/crates/pecos-foreign/tests/engine_test.rs @@ -76,7 +76,7 @@ fn test_engine_create_all_types() { "state_vec", "sparse_stab", "stabilizer", - "clifford_rz", + "stab_vec", "density_matrix", "coin_toss", ] { diff --git a/crates/pecos-foreign/tests/foreign_simulator_test.rs b/crates/pecos-foreign/tests/foreign_simulator_test.rs index 39ad45835..7beb67755 100644 --- a/crates/pecos-foreign/tests/foreign_simulator_test.rs +++ b/crates/pecos-foreign/tests/foreign_simulator_test.rs @@ -92,7 +92,8 @@ fn make_toy_sim(num_qubits: usize) -> ForeignSimulator { destroy: toy_destroy, }; - unsafe { ForeignSimulator::new(handle, vtable) }.expect("vtable version should match") + unsafe { ForeignSimulator::new(handle, vtable, num_qubits) } + .expect("vtable version should match") } #[test] @@ -224,7 +225,7 @@ fn test_foreign_simulator_version_mismatch() { }; // Should return None on version mismatch - let result = unsafe { ForeignSimulator::new(handle, vtable) }; + let result = unsafe { ForeignSimulator::new(handle, vtable, 3) }; assert!(result.is_none(), "wrong version should return None"); unsafe { diff --git a/crates/pecos-foreign/tests/neo_integration_test.rs b/crates/pecos-foreign/tests/neo_integration_test.rs index a6232d4c6..24f8ab997 100644 --- a/crates/pecos-foreign/tests/neo_integration_test.rs +++ b/crates/pecos-foreign/tests/neo_integration_test.rs @@ -89,7 +89,8 @@ fn make_toy_sim(num_qubits: usize) -> ForeignSimulator { destroy: toy_destroy, }; - unsafe { ForeignSimulator::new(handle, vtable) }.expect("vtable version should match") + unsafe { ForeignSimulator::new(handle, vtable, num_qubits) } + .expect("vtable version should match") } #[test] diff --git a/crates/pecos-gpu-sims/src/gpu.rs b/crates/pecos-gpu-sims/src/gpu.rs index b31327751..3e092b6ee 100644 --- a/crates/pecos-gpu-sims/src/gpu.rs +++ b/crates/pecos-gpu-sims/src/gpu.rs @@ -1552,6 +1552,10 @@ impl QuantumSimulator for GpuStateVec32 { .write_buffer(&self.state_buffer, 0, bytemuck::cast_slice(&initial_state)); self } + + fn num_qubits(&self) -> usize { + self.num_qubits as usize + } } // Trait implementations queue gates for batched dispatch. diff --git a/crates/pecos-gpu-sims/src/gpu64.rs b/crates/pecos-gpu-sims/src/gpu64.rs index 81387dd0d..ec9e887e4 100644 --- a/crates/pecos-gpu-sims/src/gpu64.rs +++ b/crates/pecos-gpu-sims/src/gpu64.rs @@ -1245,6 +1245,10 @@ impl QuantumSimulator for GpuStateVec64 { self.reset(); self } + + fn num_qubits(&self) -> usize { + self.num_qubits as usize + } } #[allow(clippy::cast_possible_truncation)] diff --git a/crates/pecos-gpu-sims/src/gpu_auto.rs b/crates/pecos-gpu-sims/src/gpu_auto.rs index 02ba8ae48..eba201c96 100644 --- a/crates/pecos-gpu-sims/src/gpu_auto.rs +++ b/crates/pecos-gpu-sims/src/gpu_auto.rs @@ -71,6 +71,13 @@ impl QuantumSimulator for GpuStateVecAuto { fn reset(&mut self) -> &mut Self { dispatch_mut!(self, reset()) } + + fn num_qubits(&self) -> usize { + match self { + Self::F64(s) => QuantumSimulator::num_qubits(s), + Self::F32(s) => QuantumSimulator::num_qubits(s), + } + } } impl CliffordGateable for GpuStateVecAuto { diff --git a/crates/pecos-gpu-sims/src/gpu_density_matrix.rs b/crates/pecos-gpu-sims/src/gpu_density_matrix.rs index a9d28593e..58bfa2472 100644 --- a/crates/pecos-gpu-sims/src/gpu_density_matrix.rs +++ b/crates/pecos-gpu-sims/src/gpu_density_matrix.rs @@ -539,6 +539,10 @@ impl QuantumSimulator for GpuDensityMatrix { self.state_vector.reset(); self } + + fn num_qubits(&self) -> usize { + self.num_physical_qubits + } } impl RngManageable for GpuDensityMatrix { diff --git a/crates/pecos-gpu-sims/src/gpu_stab.rs b/crates/pecos-gpu-sims/src/gpu_stab.rs index 6013210d2..945d64d24 100644 --- a/crates/pecos-gpu-sims/src/gpu_stab.rs +++ b/crates/pecos-gpu-sims/src/gpu_stab.rs @@ -2464,6 +2464,10 @@ impl QuantumSimulator for GpuStab { self.initialize_state(); self } + + fn num_qubits(&self) -> usize { + self.num_qubits as usize + } } impl Debug for GpuStab { diff --git a/crates/pecos-simulators/examples/profile_inner_product.rs b/crates/pecos-simulators/examples/profile_inner_product.rs index 0fbf55736..5cdeb95f1 100644 --- a/crates/pecos-simulators/examples/profile_inner_product.rs +++ b/crates/pecos-simulators/examples/profile_inner_product.rs @@ -1,5 +1,5 @@ use pecos_core::{Angle64, QubitId}; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, CliffordRz}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StabVec}; use std::time::Instant; fn main() { @@ -13,7 +13,7 @@ fn main() { .unwrap_or(12); let theta = Angle64::from_radians(0.3); - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); diff --git a/crates/pecos-simulators/examples/profile_meas_breakdown.rs b/crates/pecos-simulators/examples/profile_meas_breakdown.rs index 71e406c6e..3610be2d2 100644 --- a/crates/pecos-simulators/examples/profile_meas_breakdown.rs +++ b/crates/pecos-simulators/examples/profile_meas_breakdown.rs @@ -1,5 +1,5 @@ use pecos_core::{Angle64, QubitId}; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, CliffordRz}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StabVec}; use std::time::Instant; fn main() { @@ -13,7 +13,7 @@ fn main() { .unwrap_or(12); let theta = Angle64::from_radians(0.3); - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); // Create entangled state with many terms for q in 0..nq { diff --git a/crates/pecos-simulators/examples/profile_pruning.rs b/crates/pecos-simulators/examples/profile_pruning.rs index 29d6b89dd..db87cc869 100644 --- a/crates/pecos-simulators/examples/profile_pruning.rs +++ b/crates/pecos-simulators/examples/profile_pruning.rs @@ -1,12 +1,12 @@ use pecos_core::{Angle64, QubitId}; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, CliffordRz}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StabVec}; use std::time::Instant; fn run_test(nq: usize, nrz: usize, threshold: f64) { let theta = Angle64::from_radians(0.3); let mc = if threshold < 0.0 { None } else { Some(2048) }; let actual_threshold = threshold.abs(); - let mut sim = CliffordRz::builder(nq) + let mut sim = StabVec::builder(nq) .seed(42) .pruning_threshold(actual_threshold) .mc_threshold(mc) diff --git a/crates/pecos-simulators/examples/profile_qec_like.rs b/crates/pecos-simulators/examples/profile_qec_like.rs index ebba877fd..a4f56a2ec 100644 --- a/crates/pecos-simulators/examples/profile_qec_like.rs +++ b/crates/pecos-simulators/examples/profile_qec_like.rs @@ -1,5 +1,5 @@ use pecos_core::{Angle64, QubitId}; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, CliffordRz}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StabVec}; use std::time::Instant; /// QEC-like circuit: repeated rounds of gates + measurement on ancilla qubits. @@ -20,7 +20,7 @@ fn main() { let nq = data_q + ancilla_q; let theta = Angle64::from_radians(0.3); - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); // Initialize for q in 0..data_q { diff --git a/crates/pecos-simulators/examples/profile_clifford_rz.rs b/crates/pecos-simulators/examples/profile_stab_vec.rs similarity index 91% rename from crates/pecos-simulators/examples/profile_clifford_rz.rs rename to crates/pecos-simulators/examples/profile_stab_vec.rs index 0d2783cc5..649c2d346 100644 --- a/crates/pecos-simulators/examples/profile_clifford_rz.rs +++ b/crates/pecos-simulators/examples/profile_stab_vec.rs @@ -1,5 +1,5 @@ use pecos_core::{Angle64, QubitId}; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, CliffordRz}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StabVec}; fn main() { let nq: usize = std::env::args() @@ -16,7 +16,7 @@ fn main() { .unwrap_or(2); let theta = Angle64::from_radians(0.3); for _ in 0..iters { - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); for q in 0..nq { sim.h(&[QubitId(q)]); } diff --git a/crates/pecos-simulators/examples/profile_terms.rs b/crates/pecos-simulators/examples/profile_terms.rs index 99c2c09e1..321887478 100644 --- a/crates/pecos-simulators/examples/profile_terms.rs +++ b/crates/pecos-simulators/examples/profile_terms.rs @@ -1,5 +1,5 @@ use pecos_core::{Angle64, QubitId}; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, CliffordRz}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StabVec}; use std::time::Instant; /// Benchmark that actually creates many terms by interleaving H and RZ. @@ -14,7 +14,7 @@ fn main() { .unwrap_or(8); let theta = Angle64::from_radians(0.3); - let mut sim = CliffordRz::new_with_seed(nq, 42); + let mut sim = StabVec::new_with_seed(nq, 42); // Create entangled state for q in 0..nq { diff --git a/crates/pecos-simulators/examples/verify_mc.rs b/crates/pecos-simulators/examples/verify_mc.rs index 7c8784b93..b8eac988a 100644 --- a/crates/pecos-simulators/examples/verify_mc.rs +++ b/crates/pecos-simulators/examples/verify_mc.rs @@ -1,5 +1,5 @@ use pecos_core::{Angle64, QubitId}; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, CliffordRz, StateVec}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StabVec, StateVec}; /// Verify MC sampling gives similar statistics to exact state vector. fn main() { @@ -31,12 +31,12 @@ fn main() { exact_probs[x] = state[x].norm_sqr() / norm; } - // Sample from CliffordRz (which uses MC for T > 2048, exact otherwise) + // Sample from StabVec (which uses MC for T > 2048, exact otherwise) // Force the MC path by temporarily using it let mut mc_counts = vec![0u32; dim]; #[allow(clippy::cast_sign_loss)] // num_shots is a positive literal for seed in 0..num_shots as u64 { - let mut crz = CliffordRz::new_with_seed(nq, seed); + let mut crz = StabVec::new_with_seed(nq, seed); for q in 0..nq { crz.h(&[QubitId(q)]); } @@ -79,7 +79,7 @@ fn main() { } } eprintln!("n={nq}, nrz={nrz}, T={} (exact path, not MC)", { - let mut c = CliffordRz::new(nq); + let mut c = StabVec::new(nq); for q in 0..nq { c.h(&[QubitId(q)]); } diff --git a/crates/pecos-simulators/src/coin_toss.rs b/crates/pecos-simulators/src/coin_toss.rs index 42160377b..3b8442e7a 100644 --- a/crates/pecos-simulators/src/coin_toss.rs +++ b/crates/pecos-simulators/src/coin_toss.rs @@ -220,6 +220,10 @@ impl QuantumSimulator for CoinToss where R: Rng + SeedableRng + Debug, { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { // CoinToss is stateless, so reset is a no-op self diff --git a/crates/pecos-simulators/src/dense_stab.rs b/crates/pecos-simulators/src/dense_stab.rs index 230c3d69c..fc77da9fd 100644 --- a/crates/pecos-simulators/src/dense_stab.rs +++ b/crates/pecos-simulators/src/dense_stab.rs @@ -1030,6 +1030,10 @@ impl DenseStab { } impl QuantumSimulator for DenseStab { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.init_state(); self @@ -1341,10 +1345,6 @@ impl StabilizerTableauSimulator for DenseS &self.destab_signs_i, ) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } impl DenseStab { diff --git a/crates/pecos-simulators/src/dense_stab_variants.rs b/crates/pecos-simulators/src/dense_stab_variants.rs index 6ba6e6068..9daf77d7f 100644 --- a/crates/pecos-simulators/src/dense_stab_variants.rs +++ b/crates/pecos-simulators/src/dense_stab_variants.rs @@ -706,6 +706,10 @@ impl DenseStabColOnly { } impl QuantumSimulator for DenseStabColOnly { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.init_state(); self @@ -1236,6 +1240,10 @@ impl DenseStabRowOnly { } impl QuantumSimulator for DenseStabRowOnly { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.init_state(); self @@ -1680,6 +1688,10 @@ impl SparseColOnly { } impl QuantumSimulator for SparseColOnly { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { let n = self.num_qubits; for q in 0..n { @@ -2129,6 +2141,10 @@ impl SparseRowOnly { } impl QuantumSimulator for SparseRowOnly { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { let n = self.num_qubits; for g in 0..n { @@ -2293,10 +2309,6 @@ impl StabilizerTableauSimulator for DenseS &self.destab_signs_i, ) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } impl StabilizerTableauSimulator for DenseStabRowOnly { @@ -2321,10 +2333,6 @@ impl StabilizerTableauSimulator for DenseS &self.destab_signs_i, ) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } impl StabilizerTableauSimulator for SparseColOnly { @@ -2347,10 +2355,6 @@ impl StabilizerTableauSimulator for SparseColOnly { &self.destab_signs_i, ) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } /// Build a tableau string from sparse column storage (`SmallVec`<[u16; 8]>). @@ -2447,10 +2451,6 @@ impl StabilizerTableauSimulator for SparseRowOnly { &self.destab_signs_i, ) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } // ========== ForcedMeasurement implementations ========== diff --git a/crates/pecos-simulators/src/density_matrix.rs b/crates/pecos-simulators/src/density_matrix.rs index 1b0008f4b..dce74e0d2 100644 --- a/crates/pecos-simulators/src/density_matrix.rs +++ b/crates/pecos-simulators/src/density_matrix.rs @@ -867,6 +867,10 @@ impl QuantumSimulator for DensityMatrix where R: Rng + SeedableRng + Debug + Clone, { + fn num_qubits(&self) -> usize { + self.num_physical_qubits + } + /// Reset the quantum state to |0...0⟩⟨0...0| /// /// # Returns diff --git a/crates/pecos-simulators/src/gpu_stab.rs b/crates/pecos-simulators/src/gpu_stab.rs index b992488bc..d3be6fc06 100644 --- a/crates/pecos-simulators/src/gpu_stab.rs +++ b/crates/pecos-simulators/src/gpu_stab.rs @@ -624,6 +624,10 @@ impl GpuStab { // ========== Trait implementations ========== impl QuantumSimulator for GpuStab { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.init_tableau(); self @@ -745,10 +749,6 @@ impl StabilizerTableauSimulator for GpuStab { &self.destab_signs_i, ) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } impl GpuStab { diff --git a/crates/pecos-simulators/src/gpu_stab_opt.rs b/crates/pecos-simulators/src/gpu_stab_opt.rs index 1c89b734f..224ec171b 100644 --- a/crates/pecos-simulators/src/gpu_stab_opt.rs +++ b/crates/pecos-simulators/src/gpu_stab_opt.rs @@ -745,6 +745,10 @@ impl GpuStabOpt { // ========== Trait implementations ========== impl QuantumSimulator for GpuStabOpt { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.init_tableau(); self @@ -866,10 +870,6 @@ impl StabilizerTableauSimulator for GpuStabOpt { &self.destab_signs_i, ) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } impl GpuStabOpt { diff --git a/crates/pecos-simulators/src/gpu_stab_parallel.rs b/crates/pecos-simulators/src/gpu_stab_parallel.rs index 1b7542f35..570579b4b 100644 --- a/crates/pecos-simulators/src/gpu_stab_parallel.rs +++ b/crates/pecos-simulators/src/gpu_stab_parallel.rs @@ -659,6 +659,10 @@ impl GpuStabParallel { // ========== Trait implementations ========== impl QuantumSimulator for GpuStabParallel { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.init_tableau(); self @@ -780,10 +784,6 @@ impl StabilizerTableauSimulator for GpuStabParallel { &self.destab_signs_i, ) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } impl GpuStabParallel { diff --git a/crates/pecos-simulators/src/graph_state.rs b/crates/pecos-simulators/src/graph_state.rs index 85a493ff8..c620879f3 100644 --- a/crates/pecos-simulators/src/graph_state.rs +++ b/crates/pecos-simulators/src/graph_state.rs @@ -473,6 +473,10 @@ impl GraphStateSim { // ============================================================================ impl QuantumSimulator for GraphStateSim { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { // |0>^n = H^n |+>^n = H^n |G_empty> // So all VOPs are H, and the graph has no edges. @@ -652,10 +656,6 @@ impl crate::StabilizerTableauSimulator for GraphSt } result } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } /// Format a `PauliString` as a tableau line matching the `DenseStab` format. diff --git a/crates/pecos-simulators/src/lib.rs b/crates/pecos-simulators/src/lib.rs index 9bcd9d0b1..a67d3c4cc 100644 --- a/crates/pecos-simulators/src/lib.rs +++ b/crates/pecos-simulators/src/lib.rs @@ -16,7 +16,6 @@ pub mod circuit_executor; pub mod clifford_frame; pub mod clifford_gateable; pub mod clifford_rotation; -pub mod clifford_rz; pub mod clifford_test_utils; pub mod coin_toss; pub mod dense_stab; @@ -30,7 +29,9 @@ pub mod gpu_stab_parallel; pub mod graph_state; pub mod graph_state_repr; pub mod measurement_sampler; +pub mod measurement_stress_test_utils; pub mod pauli_prop; +pub mod stab_vec; // pub mod paulis; pub mod prelude; pub mod quantum_simulator; @@ -67,10 +68,6 @@ pub type GensData = ( Vec>, ); -pub use clifford_rz::ch_form::{CHForm, CHFormGeneric}; -pub use clifford_rz::exact_scalar::ExactScalar; -pub use clifford_rz::sparse_binary_matrix::SparseBinaryMatrix; -pub use clifford_rz::{CliffordRz, CliffordRzBuilder, CliffordRzGeneric}; pub use dense_stab::DenseStab; pub use dense_stab_variants::{DenseStabColOnly, DenseStabRowOnly, SparseColOnly, SparseRowOnly}; pub use density_matrix::DensityMatrix; @@ -80,6 +77,10 @@ pub use gpu_stab_opt::GpuStabOpt; pub use gpu_stab_parallel::GpuStabParallel; pub use graph_state::GraphStateSim; pub use graph_state_repr::{GraphState, GraphStateRenderer}; +pub use stab_vec::ch_form::{CHForm, CHFormGeneric}; +pub use stab_vec::exact_scalar::ExactScalar; +pub use stab_vec::sparse_binary_matrix::SparseBinaryMatrix; +pub use stab_vec::{StabVec, StabVecBuilder, StabVecGeneric}; // pub use paulis::Paulis; pub use measurement_sampler::{ MeasurementKind, MeasurementSampler, MeasurementValidationError, SampleResult, diff --git a/crates/pecos-simulators/src/measurement_stress_test_utils.rs b/crates/pecos-simulators/src/measurement_stress_test_utils.rs new file mode 100644 index 000000000..787c09a0d --- /dev/null +++ b/crates/pecos-simulators/src/measurement_stress_test_utils.rs @@ -0,0 +1,302 @@ +// 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. + +//! Measurement stress tests for any `ArbitraryRotationGateable` simulator. +//! +//! These tests exercise measurement-related edge cases discovered during +//! STN (Stabilizer Tensor Network) development. They use only measurement +//! outcomes (no state vector access), so any simulator can use them. +//! +//! Test categories: +//! - Re-measurement consistency: measure, then re-measure the same qubit +//! - Measure-gate-measure: measure, apply gates, measure again +//! - Clifford rotations after non-Clifford: RX(pi)/RZ(pi) after T gates +//! - Negative-angle rotations: Tdg, negative RZ/RX angles + +#![allow(clippy::missing_panics_doc)] + +use crate::ArbitraryRotationGateable; +use pecos_core::{Angle64, QubitId, qid}; + +// ============================================================================ +// Re-measurement consistency +// ============================================================================ + +/// After measuring a qubit, re-measuring it should give the same outcome. +/// This verifies that measurement collapse is implemented correctly. +pub fn verify_remeasurement_consistency(sim: &mut S) { + let t = Angle64::QUARTER_TURN / 2u64; + + // Case 1: T|+> then measure twice + sim.reset(); + sim.h(&qid(0)); + sim.rz(t, &qid(0)); + let r1 = sim.mz(&qid(0))[0].outcome; + let r2 = sim.mz(&qid(0))[0].outcome; + assert_eq!(r1, r2, "T|+>: re-measurement should give same outcome"); + + // Case 2: Bell+T then measure both, re-measure first + { + sim.reset(); + sim.h(&qid(0)); + sim.cx(&[(QubitId(0), QubitId(1))]); + sim.rz(t, &qid(0)); + let r0 = sim.mz(&qid(0))[0].outcome; + let _r1 = sim.mz(&qid(1))[0].outcome; + let r0_again = sim.mz(&qid(0))[0].outcome; + assert_eq!( + r0, r0_again, + "Bell+T: re-measurement of q0 should be stable" + ); + } +} + +// ============================================================================ +// Measure-gate-measure +// ============================================================================ + +/// Measure a qubit, apply more gates (including non-Clifford), then measure +/// again. Verifies the post-measurement state is usable. +pub fn verify_measure_gate_measure(sim: &mut S) { + let t = Angle64::QUARTER_TURN / 2u64; + + // Measure q0, then apply T+H on q1, then measure q1 + sim.reset(); + sim.h(&qid(0)); + sim.cx(&[(QubitId(0), QubitId(1))]); + sim.rz(t, &qid(0)); + + let _r0 = sim.mz(&qid(0))[0].outcome; + + // After measuring q0, apply gates on q1 + sim.h(&qid(1)); + sim.rz(t, &qid(1)); + let _r1 = sim.mz(&qid(1)); // Should not panic +} + +/// Multiple rounds of measure-gate-measure on a 3-qubit system. +pub fn verify_measure_gate_measure_3qubit(sim: &mut S) { + let t = Angle64::QUARTER_TURN / 2u64; + + sim.reset(); + sim.h(&qid(0)); + sim.cx(&[(QubitId(0), QubitId(1))]); + sim.cx(&[(QubitId(1), QubitId(2))]); + sim.rz(t, &qid(1)); + + // Measure q0 + let _r0 = sim.mz(&qid(0))[0].outcome; + + // Apply more gates after measurement + sim.h(&qid(1)); + sim.rz(t, &qid(1)); + + // Measure q1 and q2 + let _r1 = sim.mz(&qid(1))[0].outcome; + let _r2 = sim.mz(&qid(2))[0].outcome; +} + +// ============================================================================ +// Clifford rotations after non-Clifford gates +// ============================================================================ + +/// RX(pi) = -i*X after non-Clifford gates. The Clifford-angle detection +/// path in RZ must handle the case where the MPS already has non-Clifford +/// content. Tests the X/Y/Z gate destab sign tracking. +pub fn verify_rx_pi_after_nonclifford(sim: &mut S) { + let t = Angle64::QUARTER_TURN / 2u64; + let pi = Angle64::from_radians(std::f64::consts::PI); + + // H, T, RX(pi) on single qubit + sim.reset(); + sim.h(&qid(0)); + sim.rz(t, &qid(0)); + sim.rx(pi, &qid(0)); + // RX(pi)*T|+> should be measurable without panic + let _r = sim.mz(&qid(0)); + + // H, T, then RZ(pi) (= Z up to phase) + sim.reset(); + sim.h(&qid(0)); + sim.rz(t, &qid(0)); + sim.rz(Angle64::HALF_TURN, &qid(0)); + let _r = sim.mz(&qid(0)); + + // Entangled case: Bell + T + RX(pi) + { + sim.reset(); + sim.h(&qid(0)); + sim.cx(&[(QubitId(0), QubitId(1))]); + sim.rz(t, &qid(0)); + sim.rz(t, &qid(1)); + sim.rx(pi, &qid(0)); + + let r0 = sim.mz(&qid(0))[0].outcome; + let r1 = sim.mz(&qid(1))[0].outcome; + // RX(pi)=X flips one qubit of the Bell pair: outcomes are anti-correlated + assert_ne!(r0, r1, "Bell+T+RX(pi): outcomes should be anti-correlated"); + } +} + +/// RZ at all Clifford angles after non-Clifford gates. +pub fn verify_rz_clifford_angles_after_nonclifford(sim: &mut S) { + let t = Angle64::QUARTER_TURN / 2u64; + + let clifford_angles = [ + Angle64::ZERO, + Angle64::QUARTER_TURN, + Angle64::HALF_TURN, + Angle64::THREE_QUARTERS_TURN, + ]; + + for &angle in &clifford_angles { + sim.reset(); + sim.h(&qid(0)); + sim.rz(t, &qid(0)); // Non-Clifford first + sim.rz(angle, &qid(0)); // Clifford angle after + let _r = sim.mz(&qid(0)); // Should not panic + } +} + +// ============================================================================ +// Negative-angle rotations +// ============================================================================ + +/// Tdg = RZ(-pi/4). Verify T * Tdg = I and Tdg alone works. +pub fn verify_tdg_basic(sim: &mut S) { + let t = Angle64::QUARTER_TURN / 2u64; + let tdg = -t; + + // T * Tdg = I on |+> + sim.reset(); + sim.h(&qid(0)); + sim.rz(t, &qid(0)); + sim.rz(tdg, &qid(0)); + // Should be back in |+>, deterministic X measurement + let r = sim.mx(&qid(0)); + assert!( + r[0].is_deterministic, + "T*Tdg|+> should be deterministic in X" + ); + assert!(!r[0].outcome, "T*Tdg|+> should measure 0 in X"); + + // Tdg alone on |+>: p(0) = p(1) = 0.5. Just verify no panic. + sim.reset(); + sim.h(&qid(0)); + sim.rz(tdg, &qid(0)); + let _r = sim.mz(&qid(0)); +} + +/// Negative-angle rotations produce valid states. +pub fn verify_negative_angle_rotations(sim: &mut S) { + let t = Angle64::QUARTER_TURN / 2u64; + let tdg = -t; + + // Tdg on |0>: deterministic (Z is stabilizer), should always measure 0 + sim.reset(); + sim.rz(tdg, &qid(0)); + let r = sim.mz(&qid(0)); + assert!(!r[0].outcome, "Tdg|0> should measure 0"); + + // Tdg on |+> produces valid state (no panic, non-deterministic in Z) + sim.reset(); + sim.h(&qid(0)); + sim.rz(tdg, &qid(0)); + let _r = sim.mz(&qid(0)); + + // Negative-angle RX on |0> (no panic) + sim.reset(); + sim.rx(-t, &qid(0)); + let _r = sim.mz(&qid(0)); +} + +// ============================================================================ +// Measurement probability distribution check +// ============================================================================ + +/// Statistical check: RX(pi/3)|0> should give p(0) = cos^2(pi/6) = 3/4. +/// Runs many trials and checks the distribution. +pub fn verify_rx_measurement_probabilities( + sim: &mut S, + num_trials: usize, +) { + let theta = Angle64::from_radians(std::f64::consts::FRAC_PI_3); + let expected_p0 = 0.75; + let mut count_0 = 0u32; + + for _ in 0..num_trials { + sim.reset(); + sim.rx(theta, &qid(0)); + if !sim.mz(&qid(0))[0].outcome { + count_0 += 1; + } + } + + let p0 = f64::from(count_0) + / f64::from(u32::try_from(num_trials).expect("num_trials must fit in u32")); + assert!( + (p0 - expected_p0).abs() < 0.1, + "RX(pi/3)|0> p(0) = {p0:.3}, expected {expected_p0:.3}" + ); +} + +// ============================================================================ +// Main runner +// ============================================================================ + +/// Run the full measurement stress test suite. +pub fn run_measurement_stress_tests(sim: &mut S) { + verify_remeasurement_consistency(sim); + verify_measure_gate_measure(sim); + verify_measure_gate_measure_3qubit(sim); + verify_rx_pi_after_nonclifford(sim); + verify_rz_clifford_angles_after_nonclifford(sim); + verify_tdg_basic(sim); + verify_negative_angle_rotations(sim); + verify_rx_measurement_probabilities(sim, 200); +} + +/// Generate a test that runs the measurement stress suite on a simulator type. +/// +/// Usage: +/// ```ignore +/// use pecos_simulators::measurement_stress_test_suite; +/// measurement_stress_test_suite!(StabVec, 4); +/// ``` +#[macro_export] +macro_rules! measurement_stress_test_suite { + ($sim_type:ty) => { + $crate::measurement_stress_test_suite!($sim_type, 4); + }; + ($sim_type:ty, $num_qubits:expr) => { + paste::paste! { + #[test] + fn []() { + use $crate::measurement_stress_test_utils::run_measurement_stress_tests; + let mut sim = <$sim_type>::builder($num_qubits).seed(42).build(); + run_measurement_stress_tests(&mut sim); + } + } + }; + ($sim_type:ty, $num_qubits:expr, $constructor:expr) => { + paste::paste! { + #[test] + fn []() { + use $crate::measurement_stress_test_utils::run_measurement_stress_tests; + #[allow(unused_variables)] + let num_qubits: usize = $num_qubits; + let mut sim = $constructor; + run_measurement_stress_tests(&mut sim); + } + } + }; +} diff --git a/crates/pecos-simulators/src/pauli_prop.rs b/crates/pecos-simulators/src/pauli_prop.rs index b89de6a8b..5927b65db 100644 --- a/crates/pecos-simulators/src/pauli_prop.rs +++ b/crates/pecos-simulators/src/pauli_prop.rs @@ -111,6 +111,10 @@ impl PauliProp { } impl QuantumSimulator for PauliProp { + fn num_qubits(&self) -> usize { + self.num_qubits.unwrap_or(0) + } + /// Resets the state by clearing all Pauli all tracked X and Z operators. /// /// # Returns diff --git a/crates/pecos-simulators/src/quantum_simulator.rs b/crates/pecos-simulators/src/quantum_simulator.rs index 02973e3d5..4b747d0bb 100644 --- a/crates/pecos-simulators/src/quantum_simulator.rs +++ b/crates/pecos-simulators/src/quantum_simulator.rs @@ -37,4 +37,7 @@ pub trait QuantumSimulator { /// .z(&qid(1)); // Can continue chaining methods /// ``` fn reset(&mut self) -> &mut Self; + + /// Returns the number of qubits in the simulator. + fn num_qubits(&self) -> usize; } diff --git a/crates/pecos-simulators/src/sparse_stab.rs b/crates/pecos-simulators/src/sparse_stab.rs index 19c62894b..0432793aa 100644 --- a/crates/pecos-simulators/src/sparse_stab.rs +++ b/crates/pecos-simulators/src/sparse_stab.rs @@ -104,6 +104,10 @@ pub struct SparseStabGeneric, pub(crate) destabs: GensGeneric, pub(crate) rng: R, + /// When true, maintain destabilizer signs through Clifford gates. + /// Off by default (not needed for standard stabilizer simulation). + /// Required for STN-style decomposition that uses destabilizer phases. + track_destab_signs: bool, } /// Default sparse stabilizer simulator using `BitSet` for O(1) toggle operations. @@ -269,11 +273,27 @@ where stabs: GensGeneric::::new(num_qubits), destabs: GensGeneric::::new(num_qubits), rng, + track_destab_signs: false, }; stab.reset(); stab } + /// Enable tracking of destabilizer signs through Clifford gates. + /// Required for STN-style decomposition that uses destabilizer phases. + #[inline] + #[must_use] + pub fn with_destab_sign_tracking(mut self) -> Self { + self.track_destab_signs = true; + self + } + + /// Whether destabilizer sign tracking is enabled. + #[inline] + pub fn tracks_destab_signs(&self) -> bool { + self.track_destab_signs + } + #[inline] pub fn reset(&mut self) -> &mut Self { self.stabs.init_all_z(); @@ -572,6 +592,9 @@ where if result.outcome { // Inline X gate: X -> X, Z -> -Z self.stabs.signs_minus.xor_assign(&self.stabs.col_z[q]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[q]); + } } self } @@ -593,6 +616,10 @@ where S: IndexSet, R: SeedableRng + Rng + Debug, { + fn num_qubits(&self) -> usize { + self.num_qubits + } + #[inline] fn reset(&mut self) -> &mut Self { Self::reset(self) @@ -613,6 +640,9 @@ where for &q in qubits { let qu = q.index(); self.stabs.signs_minus.xor_assign(&self.stabs.col_z[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + } } self } @@ -625,6 +655,12 @@ where // Fused: XOR elements in (col_x[qu] ⊕ col_z[qu]) into signs_minus self.stabs.col_x[qu] .xor_symmetric_difference_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.col_x[qu].xor_symmetric_difference_into( + &self.destabs.col_z[qu], + &mut self.destabs.signs_minus, + ); + } } self } @@ -633,9 +669,11 @@ where #[inline] fn z(&mut self, qubits: &[QubitId]) -> &mut Self { for &q in qubits { - self.stabs - .signs_minus - .xor_assign(&self.stabs.col_x[q.index()]); + let qu = q.index(); + self.stabs.signs_minus.xor_assign(&self.stabs.col_x[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + } } self } @@ -661,6 +699,12 @@ where .signs_i .xor_intersection_into(&self.stabs.col_x[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_x[qu]); + if self.track_destab_signs { + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_x[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_x[qu]); + } for g in [&mut self.stabs, &mut self.destabs] { g.col_z[qu].xor_assign(&g.col_x[qu]); @@ -682,6 +726,10 @@ where // Fused: XOR elements in (col_x[qu] ∩ col_z[qu]) into signs_minus self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } for g in [&mut self.stabs, &mut self.destabs] { // Elements in col_x but not in col_z: X -> Z @@ -721,6 +769,13 @@ where .signs_i .xor_intersection_into(&self.stabs.col_x[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_x[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_x[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_x[qu]); + } // Data: col_z ^= col_x (same as SZ) for g in [&mut self.stabs, &mut self.destabs] { @@ -745,6 +800,13 @@ where .signs_i .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_z[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_z[qu]); + } // Data: col_x ^= col_z for g in [&mut self.stabs, &mut self.destabs] { @@ -768,6 +830,12 @@ where .signs_i .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_z[qu]); + if self.track_destab_signs { + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_z[qu]); + } // Data: col_x ^= col_z for g in [&mut self.stabs, &mut self.destabs] { @@ -791,6 +859,11 @@ where self.stabs.signs_minus.xor_assign(&self.stabs.col_x[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } // Data: swap col_x <-> col_z (same as H) for g in [&mut self.stabs, &mut self.destabs] { @@ -823,6 +896,11 @@ where self.stabs.signs_minus.xor_assign(&self.stabs.col_z[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } // Data: swap col_x <-> col_z (same as H) for g in [&mut self.stabs, &mut self.destabs] { @@ -857,6 +935,12 @@ where self.stabs.signs_minus.xor_assign(&self.stabs.col_z[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } // Data: swap col_x <-> col_z (same as H) for g in [&mut self.stabs, &mut self.destabs] { @@ -890,6 +974,13 @@ where .signs_i .xor_intersection_into(&self.stabs.col_x[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_x[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_x[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_x[qu]); + } // Data: col_z ^= col_x (same as SZ) for g in [&mut self.stabs, &mut self.destabs] { @@ -915,6 +1006,14 @@ where .signs_i .xor_intersection_into(&self.stabs.col_x[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_x[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_x[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_x[qu]); + } // Data: col_z ^= col_x (same as SZ) for g in [&mut self.stabs, &mut self.destabs] { @@ -939,6 +1038,13 @@ where .signs_i .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_z[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_z[qu]); + } // Data: col_x ^= col_z (same as SX) for g in [&mut self.stabs, &mut self.destabs] { @@ -964,6 +1070,14 @@ where .signs_i .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_z[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_z[qu]); + } // Data: col_x ^= col_z (same as SX) for g in [&mut self.stabs, &mut self.destabs] { @@ -989,6 +1103,14 @@ where self.stabs.signs_i.xor_assign(&self.stabs.col_x[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_x[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_x[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } // Data: col_z ^= col_x, then swap col_x <-> col_z // Row updates: (1,0)->(1,1): insert row_z; (0,1)->(1,0): move row_z->row_x; (1,1)->(0,1): remove row_x @@ -1029,6 +1151,14 @@ where self.stabs.signs_i.xor_assign(&self.stabs.col_z[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_z[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } // Data: col_x ^= col_z, then swap col_x <-> col_z // Row updates: (1,0)->(0,1): move row_x->row_z; (0,1)->(1,1): insert row_x; (1,1)->(1,0): remove row_z @@ -1070,6 +1200,15 @@ where .signs_i .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_z[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_z[qu]); + } // Data: col_x ^= col_z, then swap (same as Fdg) for g in [&mut self.stabs, &mut self.destabs] { @@ -1108,6 +1247,16 @@ where .signs_i .xor_intersection_into(&self.stabs.col_x[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_x[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_x[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_x[qu]); + } // Data: col_z ^= col_x, then swap (same as F) for g in [&mut self.stabs, &mut self.destabs] { @@ -1145,6 +1294,15 @@ where .signs_i .xor_intersection_into(&self.stabs.col_x[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_x[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_x[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_x[qu]); + } // Data: col_z ^= col_x, then swap (same as F) for g in [&mut self.stabs, &mut self.destabs] { @@ -1183,6 +1341,16 @@ where .signs_i .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); self.stabs.signs_i.xor_assign(&self.stabs.col_z[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_z[qu]); + } // Data: col_x ^= col_z, then swap (same as Fdg) for g in [&mut self.stabs, &mut self.destabs] { @@ -1220,6 +1388,15 @@ where self.stabs.signs_i.xor_assign(&self.stabs.col_z[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_z[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } // Data: col_x ^= col_z, then swap (same as Fdg) for g in [&mut self.stabs, &mut self.destabs] { @@ -1257,6 +1434,15 @@ where self.stabs.signs_i.xor_assign(&self.stabs.col_x[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs + .signs_i + .xor_intersection_into(&self.destabs.col_x[qu], &mut self.destabs.signs_minus); + self.destabs.signs_i.xor_assign(&self.destabs.col_x[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } // Data: col_z ^= col_x, then swap (same as F) for g in [&mut self.stabs, &mut self.destabs] { @@ -1371,6 +1557,30 @@ where } } } + if self.track_destab_signs { + for g in self.destabs.col_z[q1].iter() { + if !self.destabs.col_z[q2].contains(g) { + self.destabs.signs_minus.toggle(g); + if self.destabs.signs_i.contains(g) { + self.destabs.signs_minus.toggle(g); + self.destabs.signs_i.remove(g); + } else { + self.destabs.signs_i.insert(g); + } + } + } + for g in self.destabs.col_z[q2].iter() { + if !self.destabs.col_z[q1].contains(g) { + self.destabs.signs_minus.toggle(g); + if self.destabs.signs_i.contains(g) { + self.destabs.signs_minus.toggle(g); + self.destabs.signs_i.remove(g); + } else { + self.destabs.signs_i.insert(g); + } + } + } + } // Pauli update (both stabs and destabs): toggle X on q1,q2 for odd-Z generators. for tab in [&mut self.stabs, &mut self.destabs] { @@ -1462,6 +1672,28 @@ where } } } + if self.track_destab_signs { + for g in self.destabs.col_x[q1].iter() { + if !self.destabs.col_x[q2].contains(g) { + if self.destabs.signs_i.contains(g) { + self.destabs.signs_minus.toggle(g); + self.destabs.signs_i.remove(g); + } else { + self.destabs.signs_i.insert(g); + } + } + } + for g in self.destabs.col_x[q2].iter() { + if !self.destabs.col_x[q1].contains(g) { + if self.destabs.signs_i.contains(g) { + self.destabs.signs_minus.toggle(g); + self.destabs.signs_i.remove(g); + } else { + self.destabs.signs_i.insert(g); + } + } + } + } // Pauli update (both stabs and destabs): toggle Z on q1,q2 for odd-X generators. for tab in [&mut self.stabs, &mut self.destabs] { @@ -1605,6 +1837,71 @@ where apply_syy_sign!(g, false, false, false, true); } } + if self.track_destab_signs { + let signs_minus = &mut self.destabs.signs_minus; + let signs_i = &mut self.destabs.signs_i; + let col_x = &self.destabs.col_x; + let col_z = &self.destabs.col_z; + + macro_rules! mul_i { + (plus, $g:expr, $signs_i:expr, $signs_minus:expr) => { + if $signs_i.contains($g) { + $signs_minus.toggle($g); + $signs_i.remove($g); + } else { + $signs_i.insert($g); + } + }; + (minus, $g:expr, $signs_i:expr, $signs_minus:expr) => { + $signs_minus.toggle($g); + mul_i!(plus, $g, $signs_i, $signs_minus); + }; + } + + macro_rules! apply_syy_sign { + ($g:expr, $x1:expr, $z1:expr, $x2:expr, $z2:expr) => { + if ($x1 != $z1) != ($x2 != $z2) { + if $z1 == $z2 { + mul_i!(minus, $g, signs_i, signs_minus); + } else { + mul_i!(plus, $g, signs_i, signs_minus); + } + } + }; + } + + for g in col_x[q1].iter() { + let x1 = true; + let z1 = col_z[q1].contains(g); + let x2 = col_x[q2].contains(g); + let z2 = col_z[q2].contains(g); + apply_syy_sign!(g, x1, z1, x2, z2); + } + for g in col_z[q1].iter() { + if col_x[q1].contains(g) { + continue; + } + let x1 = false; + let z1 = true; + let x2 = col_x[q2].contains(g); + let z2 = col_z[q2].contains(g); + apply_syy_sign!(g, x1, z1, x2, z2); + } + for g in col_x[q2].iter() { + if col_x[q1].contains(g) || col_z[q1].contains(g) { + continue; + } + let x2 = true; + let z2 = col_z[q2].contains(g); + apply_syy_sign!(g, false, false, x2, z2); + } + for g in col_z[q2].iter() { + if col_x[q1].contains(g) || col_z[q1].contains(g) || col_x[q2].contains(g) { + continue; + } + apply_syy_sign!(g, false, false, false, true); + } + } // Pauli update (both stabs and destabs): toggle both X and Z on q1,q2 // for generators where (x1^z1) XOR (x2^z2) = 1. @@ -1767,10 +2064,6 @@ where fn destab_tableau(&self) -> String { Self::tableau_string(self.num_qubits, &self.destabs) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } // ============================================================================ @@ -2169,6 +2462,7 @@ where stabs, destabs, rng: self.rng, + track_destab_signs: false, } } } @@ -2177,6 +2471,10 @@ impl QuantumSimulator for SparseStabHybrid where R: SeedableRng + Rng + Debug, { + fn num_qubits(&self) -> usize { + self.num_qubits + } + #[inline] fn reset(&mut self) -> &mut Self { Self::reset(self) @@ -3336,10 +3634,6 @@ where fn destab_tableau(&self) -> String { Self::tableau_string(self.num_qubits, &self.destabs) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } // ============================================================================ diff --git a/crates/pecos-simulators/src/sparse_stab_y.rs b/crates/pecos-simulators/src/sparse_stab_y.rs index 333d791ae..52d4f3124 100644 --- a/crates/pecos-simulators/src/sparse_stab_y.rs +++ b/crates/pecos-simulators/src/sparse_stab_y.rs @@ -43,6 +43,10 @@ pub struct SparseStabYGeneric, pub(crate) destabs: GensGeneric, pub(crate) rng: R, + /// When true, maintain destabilizer signs through Clifford gates. + /// Off by default (not needed for standard stabilizer simulation). + /// Required for STN-style decomposition that uses destabilizer phases. + track_destab_signs: bool, } /// Default Y-convention sparse stabilizer simulator using `BitSet`. @@ -123,11 +127,27 @@ where stabs: GensGeneric::::new(num_qubits), destabs: GensGeneric::::new(num_qubits), rng, + track_destab_signs: false, }; stab.reset(); stab } + /// Enable tracking of destabilizer signs through Clifford gates. + /// Required for STN-style decomposition that uses destabilizer phases. + #[inline] + #[must_use] + pub fn with_destab_sign_tracking(mut self) -> Self { + self.track_destab_signs = true; + self + } + + /// Whether destabilizer sign tracking is enabled. + #[inline] + pub fn tracks_destab_signs(&self) -> bool { + self.track_destab_signs + } + #[inline] pub fn reset(&mut self) -> &mut Self { self.stabs.init_all_z(); @@ -463,6 +483,10 @@ where S: IndexSet, R: SeedableRng + Rng + Debug, { + fn num_qubits(&self) -> usize { + self.num_qubits + } + #[inline] fn reset(&mut self) -> &mut Self { Self::reset(self) @@ -481,6 +505,9 @@ where for &q in qubits { let qu = q.index(); self.stabs.signs_minus.xor_assign(&self.stabs.col_z[qu]); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + } } self } @@ -493,6 +520,12 @@ where let qu = q.index(); self.stabs.col_x[qu] .xor_symmetric_difference_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.col_x[qu].xor_symmetric_difference_into( + &self.destabs.col_z[qu], + &mut self.destabs.signs_minus, + ); + } } self } @@ -505,6 +538,11 @@ where self.stabs .signs_minus .xor_assign(&self.stabs.col_x[q.index()]); + if self.track_destab_signs { + self.destabs + .signs_minus + .xor_assign(&self.destabs.col_x[q.index()]); + } } self } @@ -525,6 +563,10 @@ where // (both X and Z bits set). Y -> -X means the sign flips. self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } // Data update: same as W-convention (X bit implies Z bit gets toggled) for g in [&mut self.stabs, &mut self.destabs] { @@ -547,6 +589,10 @@ where self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } for g in [&mut self.stabs, &mut self.destabs] { for i in g.col_x[qu].iter() { @@ -578,6 +624,11 @@ where self.stabs.signs_minus.xor_assign(&self.stabs.col_x[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } for g in [&mut self.stabs, &mut self.destabs] { g.col_z[qu].xor_assign(&g.col_x[qu]); for i in g.col_x[qu].iter() { @@ -597,6 +648,11 @@ where self.stabs.signs_minus.xor_assign(&self.stabs.col_z[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } for g in [&mut self.stabs, &mut self.destabs] { g.col_x[qu].xor_assign(&g.col_z[qu]); for i in g.col_z[qu].iter() { @@ -615,6 +671,10 @@ where let qu = q.index(); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } for g in [&mut self.stabs, &mut self.destabs] { g.col_x[qu].xor_assign(&g.col_z[qu]); for i in g.col_z[qu].iter() { @@ -634,6 +694,11 @@ where self.stabs.signs_minus.xor_assign(&self.stabs.col_x[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_x[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } for g in [&mut self.stabs, &mut self.destabs] { for i in g.col_x[qu].iter() { if !g.col_z[qu].contains(i) { @@ -662,6 +727,11 @@ where self.stabs.signs_minus.xor_assign(&self.stabs.col_z[qu]); self.stabs.col_x[qu] .xor_intersection_into(&self.stabs.col_z[qu], &mut self.stabs.signs_minus); + if self.track_destab_signs { + self.destabs.signs_minus.xor_assign(&self.destabs.col_z[qu]); + self.destabs.col_x[qu] + .xor_intersection_into(&self.destabs.col_z[qu], &mut self.destabs.signs_minus); + } for g in [&mut self.stabs, &mut self.destabs] { for i in g.col_x[qu].iter() { if !g.col_z[qu].contains(i) { @@ -1054,7 +1124,7 @@ where let q2 = target.index(); debug_assert_ne!(q1, q2, "CX requires distinct qubits"); - // Y-convention sign update (only on stabs, not destabs) + // Y-convention sign update // Toggle signs_minus where: col_x[q1] AND col_z[q2] AND NOT(col_z[q1] XOR col_x[q2]) // = col_x[q1] AND col_z[q2] AND ((col_z[q1] AND col_x[q2]) OR (NOT col_z[q1] AND NOT col_x[q2])) // = (col_x[q1] AND col_z[q2] AND col_z[q1] AND col_x[q2]) OR (col_x[q1] AND col_z[q2] AND NOT col_z[q1] AND NOT col_x[q2]) @@ -1068,10 +1138,21 @@ where let has_z1 = self.stabs.col_z[q1].contains(g); let has_x2 = self.stabs.col_x[q2].contains(g); if has_z1 == has_x2 { - // NOT(z1 XOR x2) is true self.stabs.signs_minus.toggle(g); } } + if self.track_destab_signs { + for g in self.destabs.col_x[q1].iter() { + if !self.destabs.col_z[q2].contains(g) { + continue; + } + let has_z1 = self.destabs.col_z[q1].contains(g); + let has_x2 = self.destabs.col_x[q2].contains(g); + if has_z1 == has_x2 { + self.destabs.signs_minus.toggle(g); + } + } + } // Data update: identical to W-convention for g in &mut [&mut self.stabs, &mut self.destabs] { @@ -1109,7 +1190,7 @@ where let q2 = qb.index(); debug_assert_ne!(q1, q2, "SXX requires distinct qubits"); - // Sign update (stabs only): Q -> i*Q*XX. Per-qubit phase from + // Sign update: Q -> i*Q*XX. Per-qubit phase from // right-multiplying by X: Z*X=iY (c=+i), Y*X=-iZ (c=-i). // For odd-Z generators (z=1 at one qubit), total = i*c_q: // z=1 qubit is Z (x=0): i*(+i) = -1 -> toggle signs_minus @@ -1124,6 +1205,18 @@ where self.stabs.signs_minus.toggle(g); } } + if self.track_destab_signs { + for g in self.destabs.col_z[q1].iter() { + if !self.destabs.col_z[q2].contains(g) && !self.destabs.col_x[q1].contains(g) { + self.destabs.signs_minus.toggle(g); + } + } + for g in self.destabs.col_z[q2].iter() { + if !self.destabs.col_z[q1].contains(g) && !self.destabs.col_x[q2].contains(g) { + self.destabs.signs_minus.toggle(g); + } + } + } // Pauli update (both stabs and destabs): toggle X on q1,q2 for odd-Z generators. for tab in [&mut self.stabs, &mut self.destabs] { @@ -1285,14 +1378,35 @@ where for g in self.stabs.col_x[q2].iter() { if self.stabs.col_z[q2].contains(g) { continue; - } // skip Y, need X (x=1,z=0) + } let x1 = self.stabs.col_x[q1].contains(g); let z1 = self.stabs.col_z[q1].contains(g); if x1 == z1 { - // q1 commutes with Y -> toggle self.stabs.signs_minus.toggle(g); } } + if self.track_destab_signs { + for g in self.destabs.col_x[q1].iter() { + if self.destabs.col_z[q1].contains(g) { + continue; + } + let x2 = self.destabs.col_x[q2].contains(g); + let z2 = self.destabs.col_z[q2].contains(g); + if x2 == z2 { + self.destabs.signs_minus.toggle(g); + } + } + for g in self.destabs.col_x[q2].iter() { + if self.destabs.col_z[q2].contains(g) { + continue; + } + let x1 = self.destabs.col_x[q1].contains(g); + let z1 = self.destabs.col_z[q1].contains(g); + if x1 == z1 { + self.destabs.signs_minus.toggle(g); + } + } + } for tab in [&mut self.stabs, &mut self.destabs] { unsafe { @@ -1429,10 +1543,6 @@ where fn destab_tableau(&self) -> String { Self::tableau_string(self.num_qubits, &self.destabs) } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } // ForcedMeasurement and StabilizerSimulator implementations diff --git a/crates/pecos-simulators/src/clifford_rz.rs b/crates/pecos-simulators/src/stab_vec.rs similarity index 95% rename from crates/pecos-simulators/src/clifford_rz.rs rename to crates/pecos-simulators/src/stab_vec.rs index bad187847..11ee8cbcb 100644 --- a/crates/pecos-simulators/src/clifford_rz.rs +++ b/crates/pecos-simulators/src/stab_vec.rs @@ -59,10 +59,10 @@ use pecos_random::{PecosRng, Rng, RngExt, SeedableRng}; /// The pruning threshold can be configured via the builder: /// /// ``` -/// use pecos_simulators::CliffordRz; +/// use pecos_simulators::StabVec; /// /// let num_qubits = 4; -/// let sim = CliffordRz::builder(num_qubits) +/// let sim = StabVec::builder(num_qubits) /// .pruning_threshold(1e-6) /// .seed(42) /// .build(); @@ -71,7 +71,7 @@ use pecos_random::{PecosRng, Rng, RngExt, SeedableRng}; use crate::clifford_frame::{CliffordFrame, GEN_LENS, GENERATORS, PHASE_COCYCLE}; #[derive(Clone, Debug)] -pub struct CliffordRzGeneric { +pub struct StabVecGeneric { num_qubits: usize, terms: Vec<(Complex64, CHFormGeneric)>, /// Pending RZ angles per qubit. @@ -93,17 +93,17 @@ pub struct CliffordRzGeneric = CliffordRzGeneric; +pub type StabVec = StabVecGeneric; -/// Builder for configuring a `CliffordRz` simulator. -pub struct CliffordRzBuilder { +/// Builder for configuring a `StabVec` simulator. +pub struct StabVecBuilder { num_qubits: usize, seed: Option, rel_pruning_threshold: f64, mc_threshold: Option, } -impl CliffordRzBuilder { +impl StabVecBuilder { /// Set the pruning threshold. Terms with |c|^2 < threshold * max(|c|^2) are pruned. /// /// - Default: 1e-8 (conservative, safe for precision work like QEC) @@ -135,14 +135,14 @@ impl CliffordRzBuilder { /// Build the simulator. #[must_use] - pub fn build(self) -> CliffordRz { + pub fn build(self) -> StabVec { let rng = if let Some(seed) = self.seed { PecosRng::seed_from_u64(seed) } else { rand::make_rng() }; let ch = CHFormGeneric::with_rng(self.num_qubits, rng.clone()); - CliffordRzGeneric { + StabVecGeneric { num_qubits: self.num_qubits, terms: vec![(Complex64::new(1.0, 0.0), ch)], pending_rz: vec![Angle64::default(); self.num_qubits], @@ -156,7 +156,7 @@ impl CliffordRzBuilder { } } -impl CliffordRzGeneric { +impl StabVecGeneric { /// Recompute `gamma_diff_qubits` from the actual surviving terms. /// Only keeps qubits where gamma genuinely differs across at least one pair. fn recompute_gamma_diff(&mut self) { @@ -941,11 +941,11 @@ impl CliffordRzGeneric // Constructors for default types // ============================================================================ -impl CliffordRzGeneric { +impl StabVecGeneric { /// Create a builder for configuring the simulator. #[must_use] - pub fn builder(num_qubits: usize) -> CliffordRzBuilder { - CliffordRzBuilder { + pub fn builder(num_qubits: usize) -> StabVecBuilder { + StabVecBuilder { num_qubits, seed: None, rel_pruning_threshold: 1e-8, @@ -971,9 +971,11 @@ impl CliffordRzGeneric { // Trait implementations // ============================================================================ -impl QuantumSimulator - for CliffordRzGeneric -{ +impl QuantumSimulator for StabVecGeneric { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { let rng = self.rng.clone(); let ch = CHFormGeneric::with_rng(self.num_qubits, rng); @@ -984,9 +986,7 @@ impl QuantumSimulator } } -impl CliffordGateable - for CliffordRzGeneric -{ +impl CliffordGateable for StabVecGeneric { // === Single-qubit Cliffords: all compose into the frame in O(1) === // Diagonal gates (Z, S, Sdg) commute with pending_rz. // Non-diagonal gates (H, X, Y, SX, etc.) negate pending_rz if they @@ -1356,7 +1356,7 @@ impl CliffordGateable } impl ArbitraryRotationGateable - for CliffordRzGeneric + for StabVecGeneric { fn rx(&mut self, theta: Angle64, qubits: &[QubitId]) -> &mut Self { // RX = H * RZ * H. Use frame-aware H and RZ. @@ -1430,7 +1430,7 @@ impl ArbitraryRotationGateabl } impl pecos_core::RngManageable - for CliffordRzGeneric + for StabVecGeneric { type Rng = R; @@ -1485,8 +1485,8 @@ mod tests { } #[test] - fn test_clifford_rz_initial_state() { - let mut sim = CliffordRz::new(2); + fn test_stab_vec_initial_state() { + let mut sim = StabVec::new(2); assert_eq!(sim.num_terms(), 1); let sv = sim.state_vector(); assert!((sv[0] - Complex64::new(1.0, 0.0)).norm() < EPS); @@ -1495,7 +1495,7 @@ mod tests { #[test] fn test_clifford_only_matches_statevec() { - let mut crz = CliffordRz::new(2); + let mut crz = StabVec::new(2); let mut sv = StateVec::new(2); // Apply Clifford circuit @@ -1508,7 +1508,7 @@ mod tests { #[test] fn test_single_rz_doubles_terms() { - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); crz.h(&qid(0)); assert_eq!(crz.num_terms(), 1); @@ -1522,7 +1522,7 @@ mod tests { #[test] fn test_rz_matches_statevec() { - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); let mut sv = StateVec::new(1); let theta = Angle64::from_radians(0.7); @@ -1534,7 +1534,7 @@ mod tests { #[test] fn test_t_gate_matches_statevec() { - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); let mut sv = StateVec::new(1); // T gate = RZ(pi/4) @@ -1547,7 +1547,7 @@ mod tests { #[test] fn test_multiple_rz_matches_statevec() { - let mut crz = CliffordRz::new(2); + let mut crz = StabVec::new(2); let mut sv = StateVec::new(2); let theta1 = Angle64::from_radians(0.5); @@ -1568,7 +1568,7 @@ mod tests { #[test] fn test_rx_matches_statevec() { - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); let mut sv = StateVec::new(1); let theta = Angle64::from_radians(0.9); @@ -1580,7 +1580,7 @@ mod tests { #[test] fn test_rzz_matches_statevec() { - let mut crz = CliffordRz::new(2); + let mut crz = StabVec::new(2); let mut sv = StateVec::new(2); let theta = Angle64::from_radians(0.6); @@ -1595,8 +1595,8 @@ mod tests { } #[test] - fn test_mixed_clifford_rz_circuit() { - let mut crz = CliffordRz::new(2); + fn test_mixed_stab_vec_circuit() { + let mut crz = StabVec::new(2); let mut sv = StateVec::new(2); let theta = Angle64::from_radians(0.4); @@ -1617,25 +1617,25 @@ mod tests { #[test] fn test_rz_clifford_angle_stays_one_term() { // RZ(0) = I: no term growth - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); crz.h(&qid(0)); crz.rz(Angle64::from_radians(0.0), &qid(0)); assert_eq!(crz.num_terms(), 1); // RZ(pi) = -iZ: no term growth - let mut crz2 = CliffordRz::new(1); + let mut crz2 = StabVec::new(1); crz2.h(&qid(0)); crz2.rz(Angle64::from_radians(std::f64::consts::PI), &qid(0)); assert_eq!(crz2.num_terms(), 1, "RZ(pi) should not add terms"); // RZ(pi/2) = e^{-i*pi/4} S: no term growth - let mut crz3 = CliffordRz::new(1); + let mut crz3 = StabVec::new(1); crz3.h(&qid(0)); crz3.rz(Angle64::from_radians(std::f64::consts::FRAC_PI_2), &qid(0)); assert_eq!(crz3.num_terms(), 1, "RZ(pi/2) should not add terms"); // RZ(-pi/2) = e^{i*pi/4} Sdg: no term growth - let mut crz4 = CliffordRz::new(1); + let mut crz4 = StabVec::new(1); crz4.h(&qid(0)); crz4.rz(Angle64::from_radians(-std::f64::consts::FRAC_PI_2), &qid(0)); assert_eq!(crz4.num_terms(), 1, "RZ(-pi/2) should not add terms"); @@ -1647,7 +1647,7 @@ mod tests { #[test] fn test_measurement_deterministic_zero_state() { - let mut crz = CliffordRz::new_with_seed(1, 42); + let mut crz = StabVec::new_with_seed(1, 42); let results = crz.mz(&qid(0)); assert!(results[0].is_deterministic); assert!(!results[0].outcome); // |0> @@ -1656,7 +1656,7 @@ mod tests { #[test] fn test_measurement_after_rz() { // RZ(theta) on |0> gives e^{-i*theta/2}|0> -- still deterministic |0> - let mut crz = CliffordRz::new_with_seed(1, 42); + let mut crz = StabVec::new_with_seed(1, 42); let theta = Angle64::from_radians(0.7); crz.rz(theta, &qid(0)); let results = crz.mz(&qid(0)); @@ -1667,7 +1667,7 @@ mod tests { #[test] fn test_measurement_after_h_rz() { // H|0> then RZ should give non-deterministic measurement - let mut crz = CliffordRz::new_with_seed(1, 42); + let mut crz = StabVec::new_with_seed(1, 42); let theta = Angle64::from_radians(0.5); crz.h(&qid(0)).rz(theta, &qid(0)); let results = crz.mz(&qid(0)); @@ -1693,7 +1693,7 @@ mod tests { let mut count0 = 0; for seed in 0..num_shots { - let mut crz = CliffordRz::new_with_seed(1, seed); + let mut crz = StabVec::new_with_seed(1, seed); crz.rx(theta, &qid(0)); let results = crz.mz(&qid(0)); if !results[0].outcome { @@ -1715,7 +1715,7 @@ mod tests { // Create Bell state, apply RZ on q0, measure both. // After measuring q0, q1 outcome should be correlated. let theta = Angle64::from_radians(0.6); - let mut crz = CliffordRz::new_with_seed(2, 42); + let mut crz = StabVec::new_with_seed(2, 42); crz.h(&qid(0)) .cx(&[(QubitId(0), QubitId(1))]) .rz(theta, &qid(0)); @@ -1752,7 +1752,7 @@ mod tests { // Second measurement should be non-deterministic (50/50). let theta = Angle64::from_radians(0.5); - let mut crz = CliffordRz::new_with_seed(1, 42); + let mut crz = StabVec::new_with_seed(1, 42); crz.h(&qid(0)).rz(theta, &qid(0)); // Force measurement outcome to 0 @@ -1781,7 +1781,7 @@ mod tests { #[test] fn test_three_qubit_circuit() { - let mut crz = CliffordRz::new(3); + let mut crz = StabVec::new(3); let mut sv = StateVec::new(3); let theta = Angle64::from_radians(0.8); @@ -1801,7 +1801,7 @@ mod tests { #[test] fn test_reset() { - let mut crz = CliffordRz::new(2); + let mut crz = StabVec::new(2); let theta = Angle64::from_radians(0.5); crz.h(&qid(0)).rz(theta, &qid(0)); crz.flush_all_pending_rz(); @@ -1816,7 +1816,7 @@ mod tests { #[test] fn test_rz_at_clifford_angles_vs_statevec() { // RZ(pi/2) should be equivalent to S (up to global phase) - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); let mut sv = StateVec::new(1); let half_pi = Angle64::from_radians(std::f64::consts::FRAC_PI_2); crz.h(&qid(0)).rz(half_pi, &qid(0)); @@ -1824,7 +1824,7 @@ mod tests { states_match_up_to_phase(&crz.state_vector(), &sv.state(), "rz_pi_2"); // RZ(pi) should be equivalent to Z (up to global phase) - let mut crz2 = CliffordRz::new(1); + let mut crz2 = StabVec::new(1); let mut sv2 = StateVec::new(1); let pi = Angle64::from_radians(std::f64::consts::PI); crz2.h(&qid(0)).rz(pi, &qid(0)); @@ -1835,7 +1835,7 @@ mod tests { #[test] fn test_many_rz_gates() { // 5 RZ gates -> 32 terms. Verify state still matches StateVec. - let mut crz = CliffordRz::new(2); + let mut crz = StabVec::new(2); let mut sv = StateVec::new(2); let angles: Vec = [0.3, 0.7, 1.1, 0.5, 0.9] @@ -1877,12 +1877,12 @@ mod tests { #[test] fn test_measurement_probability_matches_statevec() { - // Compare exact measurement probabilities between CliffordRz and StateVec. + // Compare exact measurement probabilities between StabVec and StateVec. // Circuit: H(0) - CX(0,1) - RZ(0.8, q0) - H(1) // Then compute Pr(q0=0) and Pr(q1=0) from both simulators. let theta = Angle64::from_radians(0.8); - let mut crz = CliffordRz::new(2); + let mut crz = StabVec::new(2); let mut sv = StateVec::new(2); crz.h(&qid(0)) @@ -1923,7 +1923,7 @@ mod tests { // After forced measurement, compare the projected state vectors. let theta = Angle64::from_radians(0.6); - let mut crz = CliffordRz::new(2); + let mut crz = StabVec::new(2); let mut sv = StateVec::new(2); crz.h(&qid(0)) @@ -1933,7 +1933,7 @@ mod tests { .cx(&[(QubitId(0), QubitId(1))]) .rz(theta, &qid(0)); - // Force q0 = 0 on CliffordRz + // Force q0 = 0 on StabVec crz.measure_qubit(0, Some(false)); // For StateVec, project manually: zero out amplitudes where q0=1, renormalize @@ -1958,7 +1958,7 @@ mod tests { // 3-qubit circuit: measure q0, verify q1 and q2 state is correct. let theta = Angle64::from_radians(0.5); - let mut crz = CliffordRz::new(3); + let mut crz = StabVec::new(3); let mut sv = StateVec::new(3); // Prepare: H(0) CX(0,1) RZ(q2) -- q2 is independent @@ -1971,7 +1971,7 @@ mod tests { .h(&qid(2)) .rz(theta, &qid(2)); - // Force q0 = 0 on CliffordRz + // Force q0 = 0 on StabVec crz.measure_qubit(0, Some(false)); // Project StateVec manually: zero amplitudes where q0=1, renormalize @@ -2010,10 +2010,10 @@ mod tests { .map(num_complex::Complex::norm_sqr) .collect(); - // Sample from CliffordRz + // Sample from StabVec let mut counts = [0u32; 4]; for seed in 0..num_shots { - let mut crz = CliffordRz::new_with_seed(2, seed); + let mut crz = StabVec::new_with_seed(2, seed); crz.h(&qid(0)) .rz(theta, &qid(0)) .cx(&[(QubitId(0), QubitId(1))]); @@ -2037,7 +2037,7 @@ mod tests { fn test_ry_gate() { // RY uses default decomposition: Sdg RX Sz. // RX uses our H RZ H. So this tests the full chain. - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); let mut sv = StateVec::new(1); let theta = Angle64::from_radians(1.2); @@ -2055,7 +2055,7 @@ mod tests { // State vector should have amp[0] = e^{-i*theta/2}, amp[1] = 0. let theta = Angle64::from_radians(0.8); - let mut crz = CliffordRz::new_with_seed(1, 42); + let mut crz = StabVec::new_with_seed(1, 42); crz.h(&qid(0)); crz.measure_qubit(0, Some(false)); crz.rz(theta, &qid(0)); @@ -2079,7 +2079,7 @@ mod tests { let theta1 = Angle64::from_radians(0.5); let theta2 = Angle64::from_radians(0.9); - let mut crz = CliffordRz::new_with_seed(1, 42); + let mut crz = StabVec::new_with_seed(1, 42); crz.h(&qid(0)).rz(theta1, &qid(0)); crz.measure_qubit(0, Some(false)); // After projecting to |0>, apply H -> RZ @@ -2114,8 +2114,8 @@ mod tests { .map(num_complex::Complex::norm_sqr) .collect(); - // Verify CliffordRz state matches before measurement - let mut crz = CliffordRz::new(2); + // Verify StabVec state matches before measurement + let mut crz = StabVec::new(2); crz.h(&qid(0)) .h(&qid(1)) .rzz(theta, &[(QubitId(0), QubitId(1))]); @@ -2125,7 +2125,7 @@ mod tests { let num_shots = 5000; let mut counts = [0u32; 4]; for seed in 0..num_shots { - let mut crz = CliffordRz::new_with_seed(2, seed); + let mut crz = StabVec::new_with_seed(2, seed); crz.h(&qid(0)) .h(&qid(1)) .rzz(theta, &[(QubitId(0), QubitId(1))]); @@ -2147,8 +2147,8 @@ mod tests { #[test] fn test_5_qubit_circuit() { - // Verify CliffordRz works at 5 qubits with entanglement and RZ gates. - let mut crz = CliffordRz::new(5); + // Verify StabVec works at 5 qubits with entanglement and RZ gates. + let mut crz = StabVec::new(5); let mut sv = StateVec::new(5); let theta1 = Angle64::from_radians(0.4); @@ -2176,7 +2176,7 @@ mod tests { // Measure all 5 qubits after Clifford+RZ circuit, verify normalization. let theta = Angle64::from_radians(0.6); - let mut crz = CliffordRz::new_with_seed(5, 42); + let mut crz = StabVec::new_with_seed(5, 42); crz.h(&[QubitId(0)]) .cx(&[(QubitId(0), QubitId(1))]) .cx(&[(QubitId(1), QubitId(2))]) @@ -2206,7 +2206,7 @@ mod tests { #[test] fn test_builder_default() { - let mut sim = CliffordRz::builder(2).build(); + let mut sim = StabVec::builder(2).build(); sim.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); let sv = sim.state_vector(); let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; @@ -2215,8 +2215,8 @@ mod tests { #[test] fn test_builder_with_seed() { - let mut sim1 = CliffordRz::builder(1).seed(42).build(); - let mut sim2 = CliffordRz::builder(1).seed(42).build(); + let mut sim1 = StabVec::builder(1).seed(42).build(); + let mut sim2 = StabVec::builder(1).seed(42).build(); sim1.h(&qid(0)); sim2.h(&qid(0)); let r1 = sim1.mz(&qid(0)); @@ -2231,10 +2231,7 @@ mod tests { fn test_builder_exact_mode() { // With threshold=0 (exact mode), no terms are pruned even for small angles. let theta = Angle64::from_radians(0.001); - let mut sim = CliffordRz::builder(1) - .pruning_threshold(0.0) - .seed(42) - .build(); + let mut sim = StabVec::builder(1).pruning_threshold(0.0).seed(42).build(); sim.h(&qid(0)); for _ in 0..8 { sim.rz(theta, &qid(0)); @@ -2249,10 +2246,7 @@ mod tests { fn test_builder_aggressive_pruning() { // With aggressive pruning, small-angle terms are removed faster. let theta = Angle64::from_radians(5.0f64.to_radians()); - let mut sim = CliffordRz::builder(4) - .pruning_threshold(1e-4) - .seed(42) - .build(); + let mut sim = StabVec::builder(4).pruning_threshold(1e-4).seed(42).build(); for q in 0..4 { sim.h(&[QubitId(q)]); } @@ -2271,7 +2265,7 @@ mod tests { #[test] fn test_pz_prep() { // X|0> = |1>, then PZ resets to |0> - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); crz.x(&qid(0)); crz.pz(&qid(0)); let results = crz.mz(&qid(0)); @@ -2281,7 +2275,7 @@ mod tests { #[test] fn test_rxx_matches_statevec() { - let mut crz = CliffordRz::new(2); + let mut crz = StabVec::new(2); let mut sv = StateVec::new(2); let theta = Angle64::from_radians(0.7); crz.h(&qid(0)) @@ -2295,7 +2289,7 @@ mod tests { #[test] fn test_ryy_matches_statevec() { - let mut crz = CliffordRz::new(2); + let mut crz = StabVec::new(2); let mut sv = StateVec::new(2); let theta = Angle64::from_radians(0.9); crz.h(&qid(0)) @@ -2310,10 +2304,7 @@ mod tests { #[test] fn test_exact_mode_matches_statevec() { // With pruning_threshold=0, results should match StateVec exactly (up to phase). - let mut crz = CliffordRz::builder(2) - .pruning_threshold(0.0) - .seed(42) - .build(); + let mut crz = StabVec::builder(2).pruning_threshold(0.0).seed(42).build(); let mut sv = StateVec::new(2); let theta = Angle64::from_radians(0.3); crz.h(&qid(0)) @@ -2333,13 +2324,13 @@ mod tests { // Qubit range coverage tests // ======================================================================== - /// Test `CliffordRz` at qubit counts that exercise the pairwise inner product + /// Test `StabVec` at qubit counts that exercise the pairwise inner product /// measurement path (n>6) and various `ExponentialSum` tiers. #[test] - fn test_clifford_rz_medium_qubit_counts() { + fn test_stab_vec_medium_qubit_counts() { // These exercise: n>6 pairwise measurement, ExponentialSum d>3 path for nq in [8, 10, 14, 20] { - let mut crz = CliffordRz::new_with_seed(nq, 42); + let mut crz = StabVec::new_with_seed(nq, 42); let mut sv = StateVec::new(nq); let theta = Angle64::from_radians(0.5); @@ -2369,7 +2360,7 @@ mod tests { let mut crz_p0_sum = 0.0; let nshots = 5000; for seed in 0..nshots { - let mut crz = CliffordRz::new_with_seed(nq, seed); + let mut crz = StabVec::new_with_seed(nq, seed); for q in 0..nq { crz.h(&[QubitId(q)]); } @@ -2405,7 +2396,7 @@ mod tests { let crz_p0 = crz_p0_sum / nshots as f64; assert!( (crz_p0 - sv_p0).abs() < 0.05, - "nrz={nrz}: Pr(q0=0) CliffordRz={crz_p0:.3} vs StateVec={sv_p0:.3}" + "nrz={nrz}: Pr(q0=0) StabVec={crz_p0:.3} vs StateVec={sv_p0:.3}" ); } } @@ -2417,7 +2408,7 @@ mod tests { let nq = 8; let theta = Angle64::from_radians(0.4); for nrz in [3, 4, 5] { - let mut crz = CliffordRz::new_with_seed(nq, 42); + let mut crz = StabVec::new_with_seed(nq, 42); for q in 0..nq { crz.h(&[QubitId(q)]); } @@ -2436,11 +2427,11 @@ mod tests { } #[test] - fn test_clifford_rz_measurement_at_pairwise_threshold() { + fn test_stab_vec_measurement_at_pairwise_threshold() { // n=7 (state vector path) and n=8 (pairwise path) should both work for nq in [6, 7, 8] { let theta = Angle64::from_radians(0.5); - let mut crz = CliffordRz::new_with_seed(nq, 42); + let mut crz = StabVec::new_with_seed(nq, 42); for q in 0..nq { crz.h(&[QubitId(q)]); } @@ -2451,10 +2442,10 @@ mod tests { } #[test] - fn test_clifford_rz_at_u64_boundary() { + fn test_stab_vec_at_u64_boundary() { // n=62 (last u64 ExponentialSum) -- verify measurement works let nq = 62; - let mut crz = CliffordRz::new_with_seed(nq, 42); + let mut crz = StabVec::new_with_seed(nq, 42); for q in 0..nq { crz.h(&[QubitId(q)]); } @@ -2464,10 +2455,10 @@ mod tests { } #[test] - fn test_clifford_rz_at_u128_boundary() { + fn test_stab_vec_at_u128_boundary() { // n=63 (first u128 ExponentialSum) -- verify measurement works let nq = 63; - let mut crz = CliffordRz::new_with_seed(nq, 42); + let mut crz = StabVec::new_with_seed(nq, 42); for q in 0..nq { crz.h(&[QubitId(q)]); } @@ -2479,7 +2470,7 @@ mod tests { #[test] fn test_ry_simple() { // Just H then RY on 1 qubit - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); let mut sv = StateVec::new(1); crz.h(&qid(0)); sv.h(&qid(0)); @@ -2491,7 +2482,7 @@ mod tests { #[test] fn test_ry_on_zero() { // RY on |0> should match statevec - let mut crz = CliffordRz::new(1); + let mut crz = StabVec::new(1); let mut sv = StateVec::new(1); crz.ry(Angle64::from_radians(0.3), &qid(0)); sv.ry(Angle64::from_radians(0.3), &qid(0)); @@ -2501,7 +2492,7 @@ mod tests { #[test] fn test_engine_circuit_statevec_match() { // Reproduce the engine round-trip circuit: H, CX, RZ, H, RY, CZ, RX - let mut crz = CliffordRz::new(3); + let mut crz = StabVec::new(3); let mut sv = StateVec::new(3); crz.h(&[QubitId(0), QubitId(1), QubitId(2)]); diff --git a/crates/pecos-simulators/src/clifford_rz/ch_form.rs b/crates/pecos-simulators/src/stab_vec/ch_form.rs similarity index 100% rename from crates/pecos-simulators/src/clifford_rz/ch_form.rs rename to crates/pecos-simulators/src/stab_vec/ch_form.rs index fd788a3f2..e96393dbe 100644 --- a/crates/pecos-simulators/src/clifford_rz/ch_form.rs +++ b/crates/pecos-simulators/src/stab_vec/ch_form.rs @@ -2074,6 +2074,10 @@ impl CHFormGeneric { // ============================================================================ impl QuantumSimulator for CHFormGeneric { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.init(); self @@ -2420,10 +2424,6 @@ impl StabilizerTableauSimulator for C } lines.join("\n") } - - fn num_qubits(&self) -> usize { - self.num_qubits - } } // ============================================================================ diff --git a/crates/pecos-simulators/src/clifford_rz/exact_scalar.rs b/crates/pecos-simulators/src/stab_vec/exact_scalar.rs similarity index 100% rename from crates/pecos-simulators/src/clifford_rz/exact_scalar.rs rename to crates/pecos-simulators/src/stab_vec/exact_scalar.rs diff --git a/crates/pecos-simulators/src/clifford_rz/quadratic_form.rs b/crates/pecos-simulators/src/stab_vec/quadratic_form.rs similarity index 100% rename from crates/pecos-simulators/src/clifford_rz/quadratic_form.rs rename to crates/pecos-simulators/src/stab_vec/quadratic_form.rs diff --git a/crates/pecos-simulators/src/clifford_rz/sparse_binary_matrix.rs b/crates/pecos-simulators/src/stab_vec/sparse_binary_matrix.rs similarity index 100% rename from crates/pecos-simulators/src/clifford_rz/sparse_binary_matrix.rs rename to crates/pecos-simulators/src/stab_vec/sparse_binary_matrix.rs diff --git a/crates/pecos-simulators/src/stabilizer.rs b/crates/pecos-simulators/src/stabilizer.rs index a492ea9ac..b1e1b5bc6 100644 --- a/crates/pecos-simulators/src/stabilizer.rs +++ b/crates/pecos-simulators/src/stabilizer.rs @@ -115,6 +115,10 @@ impl Stabilizer { } impl QuantumSimulator for Stabilizer { + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + #[inline] fn reset(&mut self) -> &mut Self { self.inner.reset(); @@ -220,10 +224,6 @@ impl StabilizerTableauSimulator for Stabilizer { fn destab_tableau(&self) -> String { self.inner.destab_tableau() } - - fn num_qubits(&self) -> usize { - self.inner.num_qubits() - } } // ============================================================================ diff --git a/crates/pecos-simulators/src/stabilizer_tableau.rs b/crates/pecos-simulators/src/stabilizer_tableau.rs index 78244050b..c55e95128 100644 --- a/crates/pecos-simulators/src/stabilizer_tableau.rs +++ b/crates/pecos-simulators/src/stabilizer_tableau.rs @@ -89,10 +89,4 @@ pub trait StabilizerTableauSimulator: QuantumSimulator { self.stab_tableau() ) } - - /// Returns the number of qubits in the simulator. - /// - /// This method should be implemented by each simulator type to return - /// the number of qubits being simulated. - fn num_qubits(&self) -> usize; } diff --git a/crates/pecos-simulators/src/state_vec_aos.rs b/crates/pecos-simulators/src/state_vec_aos.rs index 85b25e41a..9a69c1a3d 100644 --- a/crates/pecos-simulators/src/state_vec_aos.rs +++ b/crates/pecos-simulators/src/state_vec_aos.rs @@ -444,6 +444,10 @@ impl QuantumSimulator for StateVecAoS where R: Rng + SeedableRng + Debug, { + fn num_qubits(&self) -> usize { + self.num_qubits + } + /// # Examples /// ```rust /// use pecos_simulators::{QuantumSimulator, StateVecAoS}; diff --git a/crates/pecos-simulators/src/state_vec_soa.rs b/crates/pecos-simulators/src/state_vec_soa.rs index 8d284b329..600c744d6 100644 --- a/crates/pecos-simulators/src/state_vec_soa.rs +++ b/crates/pecos-simulators/src/state_vec_soa.rs @@ -2814,6 +2814,10 @@ impl QuantumSimulator for StateVecSoA where R: Rng, { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { // Clear pending gates (state is being reset anyway) for pg in &mut self.pending_gates { diff --git a/crates/pecos-simulators/src/state_vec_soa32.rs b/crates/pecos-simulators/src/state_vec_soa32.rs index 54b14bb83..7dad3a055 100644 --- a/crates/pecos-simulators/src/state_vec_soa32.rs +++ b/crates/pecos-simulators/src/state_vec_soa32.rs @@ -938,6 +938,10 @@ impl QuantumSimulator for StateVecSoA32 where R: Rng, { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.real.fill(0.0); self.imag.fill(0.0); diff --git a/crates/pecos-simulators/src/state_vec_sparse_aos.rs b/crates/pecos-simulators/src/state_vec_sparse_aos.rs index 38fe0e019..193c215dd 100644 --- a/crates/pecos-simulators/src/state_vec_sparse_aos.rs +++ b/crates/pecos-simulators/src/state_vec_sparse_aos.rs @@ -843,6 +843,10 @@ impl SparseStateVecAoS { // ============================================================================= impl QuantumSimulator for SparseStateVecAoS { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { self.amplitudes.clear(); self.amplitudes.push((0, Complex64::new(1.0, 0.0))); diff --git a/crates/pecos-simulators/src/state_vec_sparse_soa.rs b/crates/pecos-simulators/src/state_vec_sparse_soa.rs index 25206e191..6a75f429b 100644 --- a/crates/pecos-simulators/src/state_vec_sparse_soa.rs +++ b/crates/pecos-simulators/src/state_vec_sparse_soa.rs @@ -1915,6 +1915,10 @@ impl SparseStateVecSoA { // --- QuantumSimulator trait implementation --- impl QuantumSimulator for SparseStateVecSoA { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn reset(&mut self) -> &mut Self { // Reset to |0⟩ state in buffer A self.indices_a.clear(); diff --git a/crates/pecos-simulators/src/state_vector_test_utils.rs b/crates/pecos-simulators/src/state_vector_test_utils.rs index 6d45bf074..ba3009076 100644 --- a/crates/pecos-simulators/src/state_vector_test_utils.rs +++ b/crates/pecos-simulators/src/state_vector_test_utils.rs @@ -57,9 +57,6 @@ pub trait StateVectorSimulator: CliffordGateable + QuantumSimulator + Sized { /// For sparse implementations, returns `Complex64::new(0.0, 0.0)` for /// basis states not present in the state vector. fn get_amplitude(&mut self, basis_state: usize) -> Complex64; - - /// Get the number of qubits in the simulator. - fn num_qubits(&self) -> usize; } /// Generates a Clifford-only test suite for a state vector simulator. @@ -2656,10 +2653,6 @@ impl StateVectorSimulator for StateVecAoS { fn get_amplitude(&mut self, basis_state: usize) -> Complex64 { self.state()[basis_state] } - - fn num_qubits(&self) -> usize { - StateVecAoS::num_qubits(self) - } } impl StateVectorSimulator for StateVecSoA { @@ -2672,10 +2665,6 @@ impl StateVectorSimulator for StateVecSoA { fn get_amplitude(&mut self, basis_state: usize) -> Complex64 { StateVecSoA::get_amplitude(self, basis_state) } - - fn num_qubits(&self) -> usize { - StateVecSoA::num_qubits(self) - } } impl StateVectorSimulator for SparseStateVecAoS { @@ -2686,10 +2675,6 @@ impl StateVectorSimulator for SparseStateVecAoS { fn get_amplitude(&mut self, basis_state: usize) -> Complex64 { SparseStateVecAoS::get_amplitude(self, basis_state) } - - fn num_qubits(&self) -> usize { - SparseStateVecAoS::num_qubits(self) - } } impl StateVectorSimulator for SparseStateVecSoA { @@ -2700,10 +2685,6 @@ impl StateVectorSimulator for SparseStateVecSoA { fn get_amplitude(&mut self, basis_state: usize) -> Complex64 { SparseStateVecSoA::get_amplitude(self, basis_state) } - - fn num_qubits(&self) -> usize { - SparseStateVecSoA::num_qubits(self) - } } // --- Module Tests --- diff --git a/crates/pecos-simulators/src/symbolic_sparse_stab.rs b/crates/pecos-simulators/src/symbolic_sparse_stab.rs index cd573b075..9ca67d26c 100644 --- a/crates/pecos-simulators/src/symbolic_sparse_stab.rs +++ b/crates/pecos-simulators/src/symbolic_sparse_stab.rs @@ -790,6 +790,10 @@ impl SymbolicSparseStabVecSet { } impl QuantumSimulator for SymbolicSparseStabVecSet { + fn num_qubits(&self) -> usize { + self.num_qubits + } + #[inline] fn reset(&mut self) -> &mut Self { Self::reset(self) diff --git a/crates/pecos-simulators/src/symbolic_sparse_stab_bitset.rs b/crates/pecos-simulators/src/symbolic_sparse_stab_bitset.rs index 9e42cf8c8..76506cd45 100644 --- a/crates/pecos-simulators/src/symbolic_sparse_stab_bitset.rs +++ b/crates/pecos-simulators/src/symbolic_sparse_stab_bitset.rs @@ -503,6 +503,10 @@ impl SymbolicSparseStab { } impl QuantumSimulator for SymbolicSparseStab { + fn num_qubits(&self) -> usize { + self.num_qubits + } + #[inline] fn reset(&mut self) -> &mut Self { Self::reset(self) diff --git a/crates/pecos-simulators/tests/measurement_stress_tests.rs b/crates/pecos-simulators/tests/measurement_stress_tests.rs new file mode 100644 index 000000000..f31a295dd --- /dev/null +++ b/crates/pecos-simulators/tests/measurement_stress_tests.rs @@ -0,0 +1,43 @@ +// 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. + +//! Measurement stress tests for all rotation-capable simulators. + +use pecos_simulators::{ + DensityMatrix, SparseStateVecAoS, SparseStateVecSoA, StabVec, StateVecAoS, StateVecSoA, + StateVecSoA32, +}; + +// State vector simulators +pecos_simulators::measurement_stress_test_suite!(StateVecSoA, 4, StateVecSoA::with_seed(4, 42)); +pecos_simulators::measurement_stress_test_suite!(StateVecSoA32, 4, StateVecSoA32::with_seed(4, 42)); +pecos_simulators::measurement_stress_test_suite!(StateVecAoS, 4, StateVecAoS::with_seed(4, 42)); +pecos_simulators::measurement_stress_test_suite!( + SparseStateVecAoS, + 4, + SparseStateVecAoS::with_seed(4, 42) +); +pecos_simulators::measurement_stress_test_suite!( + SparseStateVecSoA, + 4, + SparseStateVecSoA::with_seed(4, 42) +); + +// Density matrix +pecos_simulators::measurement_stress_test_suite!(DensityMatrix, 4, DensityMatrix::with_seed(4, 42)); + +// Clifford+Rz (exact mode) +pecos_simulators::measurement_stress_test_suite!( + StabVec, + 4, + StabVec::builder(4).seed(42).pruning_threshold(0.0).build() +); diff --git a/crates/pecos/src/lib.rs b/crates/pecos/src/lib.rs index 8258552ff..44588d851 100644 --- a/crates/pecos/src/lib.rs +++ b/crates/pecos/src/lib.rs @@ -68,13 +68,13 @@ pub mod simulators { #[cfg(feature = "cppsparsestab")] pub use pecos_cppsparsestab::CppSparseStab; pub use pecos_engines::quantum::{ - CliffordRzEngine, DensityMatrixEngine, QuantumEngine, SparseStabEngine, StabilizerEngine, + DensityMatrixEngine, QuantumEngine, SparseStabEngine, StabVecEngine, StabilizerEngine, StateVecEngine, new_quantum_engine_arbitrary_qgate, }; pub use pecos_engines::quantum_engine_builder::{ - CliffordRzEngineBuilder, DensityMatrixEngineBuilder, IntoQuantumEngineBuilder, - SparseStabEngineBuilder, StabilizerEngineBuilder, StateVectorEngineBuilder, clifford_rz, - density_matrix, sparse_stab, stabilizer, state_vector, + DensityMatrixEngineBuilder, IntoQuantumEngineBuilder, SparseStabEngineBuilder, + StabVecEngineBuilder, StabilizerEngineBuilder, StateVectorEngineBuilder, density_matrix, + sparse_stab, stab_vec, stabilizer, state_vector, }; pub use pecos_simulators::*; } @@ -201,7 +201,7 @@ pub use pecos_engines::{ pub use pecos_engines::{SimInput, sim_builder}; #[cfg(feature = "sim")] pub use pecos_engines::{ - clifford_rz, coin_toss, density_matrix, sparse_stab, stabilizer, state_vector, + coin_toss, density_matrix, sparse_stab, stab_vec, stabilizer, state_vector, }; #[cfg(feature = "hugr")] pub use pecos_hugr::{HugrEngine, HugrEngineBuilder, hugr_engine, hugr_sim}; diff --git a/design/ENGINES_ARCHITECTURE.md b/design/ENGINES_ARCHITECTURE.md index 09e383866..72bfd9133 100644 --- a/design/ENGINES_ARCHITECTURE.md +++ b/design/ENGINES_ARCHITECTURE.md @@ -1,1005 +1,3 @@ -# Engines Architecture: Simulation Framework +# Moved to pecos-docs vault -This document describes the architecture of the `pecos-engines` crate, which provides the simulation framework for PECOS. It explains how quantum programs are executed, how classical and quantum components interact, and how the system enables mid-circuit measurements with classical feedback. - -## Design Philosophy - -PECOS serves two complementary roles: - -**As a Framework** - A complete, extendable environment for studying QEC and hybrid quantum-classical computation. Users can plug in custom components (error models, decoders, machines) and run full simulations with the `sim()` API or `HybridEngine`. - -**As a Library** - A collection of well-designed, independent components that users can pick and choose for their own projects. Need just a fast stabilizer simulator? Use `pecos-simulators::SparseStab`. Need deterministic seeding? Use `pecos-core::derive_seed()`. The crates are designed to be useful standalone. - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ PECOS as Framework │ -│ - sim(program).quantum(sparse_stab()).run(1000) │ -│ - HybridEngine with custom components │ -│ - Full QEC simulation pipelines │ -└─────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────┐ -│ PECOS as Library (pick what you need) │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ pecos-simulators │ │ pecos-core │ │ pecos-random │ │ -│ │ SparseStab │ │ QubitId │ │ PecosRng │ │ -│ │ StateVec │ │ derive_seed │ │ │ │ -│ │ Gateable │ │ GateType │ │ │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ pecos-gpu- │ │ pecos- │ │ pecos- │ │ -│ │ sims │ │ clifford- │ │ engines │ │ -│ │ GpuSampler │ │ gates │ │ ByteMessage │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -This dual nature means: -- Researchers can quickly prototype QEC experiments using the framework -- Library authors can integrate specific PECOS components into their own tools -- The same battle-tested code serves both use cases - -## Overview - -The `pecos-engines` crate orchestrates quantum simulation through a layered architecture: - -1. **User API Layer** - `sim()` function and `SimBuilder` for configuration -2. **Parallelization Layer** - `MonteCarloEngine` for multi-shot execution -3. **Execution Layer** - `HybridEngine` for single-shot orchestration -4. **Component Layer** - Classical engines, quantum systems, and noise models - -``` -┌─────────────────────────────────────────────────────────────┐ -│ User API (sim_builder) │ -│ sim(program).quantum(sparse_stab()).noise(...).run(1000) │ -└────────────────────┬────────────────────────────────────────┘ - │ - ▼ - ┌──────────────────────┐ - │ MonteCarloEngine │ (parallel orchestration) - │ (num_workers, seed) │ - └──────────┬───────────┘ - │ - ┌──────────┴───────────┐ - │ (parallel workers) │ - ▼ ▼ - ┌──────────────────────────────────────────┐ - │ HybridEngine (per worker) │ - │ - Cloned with derived seed │ - │ - Reset between shots │ - └────────┬─────────────────────────────────┘ - │ - ┌─────────┴──────────┐ - │ │ - ▼ ▼ -┌────────────────┐ ┌──────────────────────┐ -│ ClassicalEngine│ │ QuantumSystem │ -│ │ │ ┌────────────────┐ │ -│ - generate_ │ │ │ NoiseModel │ │ -│ commands() │ │ │ (transforms │ │ -│ - handle_ │ │ │ operations) │ │ -│ measurements │ │ └───────┬────────┘ │ -│ - get_results()│ │ ▼ │ -└────────┬───────┘ │ ┌────────────────┐ │ - │ │ │ QuantumEngine │ │ - │ │ │ (StateVec or │ │ - │ │ │ SparseStab) │ │ - │ │ └────────────────┘ │ - │ └──────────────────────┘ - │ │ - └────ByteMessage─────┘ - (binary protocol) -``` - -## Core Concepts - -### The Engine Trait - -All components in the system implement the base `Engine` trait: - -```rust -pub trait Engine { - fn process(&mut self, input: Input) -> Result; - fn reset(&mut self) -> Result<()>; -} -``` - -This simple interface enables composition - engines can delegate to other engines. - -### Control Flow with EngineStage - -The `EngineStage` enum enables feedback loops between components: - -```rust -pub enum EngineStage { - NeedsProcessing(I), // "Send this input to the controlled engine" - Complete(O), // "Processing finished, here's the result" -} -``` - -This is used by `ControlEngine` implementations (like `ClassicalEngine` and `NoiseModel`) to orchestrate execution with another engine. - -### ByteMessage Protocol - -Components communicate using `ByteMessage`, a binary protocol for quantum commands and measurement results: - -```rust -// Commands from classical to quantum -ByteMessage: [H(0), CX(0,1), MZ(0), MZ(1)] - -// Results from quantum to classical -ByteMessage: [MZ(0)=1, MZ(1)=1] -``` - -This allows efficient batching of operations and decouples the classical and quantum components. - -## The Classical-Quantum Feedback Loop - -The key architectural feature is the **feedback loop** between classical and quantum components. This enables: - -- Mid-circuit measurements -- Classical control based on measurement outcomes -- Repeat-until-success protocols -- QEC syndrome decoding and correction - -### Single Shot Execution Flow - -Inside `HybridEngine::run_shot()`: - -``` -┌─────────────────┐ ┌─────────────────┐ -│ ClassicalEngine │ │ QuantumSystem │ -└────────┬────────┘ └────────┬────────┘ - │ │ - │ 1. start() │ - │ ─────────────────────────────────► │ - │ ByteMessage: [H(0), CX(0,1), MZ(0)]│ - │ │ - │ 2. process() → execute gates │ - │ │ - │ 3. measurement results │ - │ ◄───────────────────────────────── │ - │ ByteMessage: [MZ(0) = 1] │ - │ │ - │ 4. continue_processing() │ - │ // Decide next action based on │ - │ // measurement result │ - │ │ - │ 5. More commands (if needed) │ - │ ─────────────────────────────────► │ - │ ByteMessage: [X(1), MZ(1)] │ - │ │ - │ 6. process() │ - │ │ - │ 7. final measurements │ - │ ◄───────────────────────────────── │ - │ │ - │ 8. Complete(Shot) │ - ▼ ▼ -``` - -### The Loop in Code - -```rust -// Simplified HybridEngine::run_shot() -fn run_shot(&mut self) -> Result { - // Reset both engines for fresh shot - self.classical_engine.reset()?; - self.quantum_system.reset()?; - - // Start execution - classical engine generates first batch of commands - let mut stage = self.classical_engine.start()?; - - loop { - match stage { - EngineStage::NeedsProcessing(commands) => { - // Send commands to quantum system - let measurements = self.quantum_system.process(commands)?; - - // Classical engine processes measurements and decides next action - stage = self.classical_engine.continue_processing(measurements)?; - } - EngineStage::Complete(shot) => { - // Done - return results - return Ok(shot); - } - } - } -} -``` - -### Concrete Example: QasmEngine - -The `QasmEngine` is a good example to understand how the feedback loop works in practice. Consider this QASM program with conditional logic: - -```qasm -h q[0]; -measure q[0] -> c[0]; -if (c==1) x q[0]; -measure q[0] -> c[1]; -``` - -Here's exactly what happens: - -**Round 1 - Start:** -``` -QasmEngine.start() - └─ process_program_impl() - ├─ Process: h q[0] → add H gate to batch - └─ Process: measure q[0] → BREAK! Must wait for result - Return NeedsProcessing([H(0), MZ(0)]) -``` - -**Round 1 - Quantum System:** -``` -QuantumSystem.process([H(0), MZ(0)]) - ├─ NoiseModel transforms operations (maybe adds errors) - ├─ QuantumEngine executes H, then measures - └─ Return ByteMessage([MZ(0) = 1]) // measured |1⟩ -``` - -**Round 2 - Continue:** -``` -QasmEngine.continue_processing([MZ(0) = 1]) - ├─ handle_measurements(): store c[0] = 1 - └─ process_program_impl() - ├─ Process: if (c==1) x q[0] - │ └─ c[0] is 1, so add X gate to batch - └─ Process: measure q[0] → BREAK! - Return NeedsProcessing([X(0), MZ(0)]) -``` - -**Round 2 - Quantum System:** -``` -QuantumSystem.process([X(0), MZ(0)]) - ├─ Execute X gate (flips |1⟩ back to |0⟩) - └─ Return ByteMessage([MZ(0) = 0]) -``` - -**Round 3 - Finish:** -``` -QasmEngine.continue_processing([MZ(0) = 0]) - ├─ handle_measurements(): store c[1] = 0 - └─ process_program_impl() - └─ No more operations → Return Complete(Shot { c: [1, 0] }) -``` - -**Key insight:** QasmEngine breaks the batch on every measurement because: -1. The measurement result might be needed by the next operation (`if` statement) -2. It can't know the result until the quantum system actually measures -3. So it must pause, get the result, store it in classical registers, then continue - -This is what makes mid-circuit measurement possible - the classical engine is in control, asking for quantum operations in batches and making decisions based on results. - -### Why This Matters - -This architecture enables **adaptive quantum circuits** where the program flow depends on measurement outcomes: - -``` -Example: Repeat-until-success - -Round 1: - Classical: "Apply H, measure" - Quantum: executes, returns measurement = 0 - Classical: "Wrong outcome, try again" - -Round 2: - Classical: "Reset, apply H, measure" - Quantum: executes, returns measurement = 1 - Classical: "Success! Done." -``` - -Without this feedback loop, you'd need to know all operations upfront, making adaptive protocols impossible. - -## Component Details - -### ClassicalEngine Trait - -The classical engine controls program flow: - -```rust -pub trait ClassicalEngine { - /// Compile/prepare the program - fn compile(&mut self) -> Result<()>; - - /// Generate quantum commands to execute - fn generate_commands(&mut self) -> ByteMessage; - - /// Process measurement results from quantum system - fn handle_measurements(&mut self, measurements: ByteMessage); - - /// Get final results after execution completes - fn get_results(&self) -> Shot; - - /// Number of qubits needed - fn num_qubits(&self) -> usize; - - /// Reset for next shot - fn reset(&mut self) -> Result<()>; -} -``` - -Different classical engines implement different program formats: -- `QasmEngine` - OpenQASM circuits -- `QisEngine` - QIS/LLVM IR programs (via Helios) -- `HugrEngine` - HUGR graphs (via Guppy) - -### QuantumEngine Trait - -The quantum engine executes gates: - -```rust -pub trait QuantumEngine { - /// Process a batch of quantum commands, return measurement results - fn process(&mut self, commands: ByteMessage) -> ByteMessage; - - /// Set RNG seed for reproducibility - fn set_seed(&mut self, seed: u64); - - /// Reset quantum state - fn reset(&mut self); -} -``` - -Built-in implementations: -- `StateVecEngine` - Full state vector simulation (universal) -- `SparseStabEngine` - Stabilizer simulation (Clifford circuits only) - -### NoiseModel Trait - -Noise models transform operations before execution: - -```rust -pub trait NoiseModel: ControlEngine { - // Inherits from ControlEngine: - // - start(commands) -> EngineStage - // - continue_processing(measurements) -> EngineStage -} -``` - -The noise model sits between classical and quantum engines: - -``` -Classical → NoiseModel → QuantumEngine - ↑ - May add noise gates - May flip measurement results -``` - -Built-in noise models: -- `PassThroughNoiseModel` - No noise (default) -- `DepolarizingNoiseModel` - Depolarizing noise on gates -- `BiasedDepolarizingNoiseModel` - Gate noise + measurement errors -- `GeneralNoiseModel` - Customizable per-gate noise - -### QuantumSystem - -`QuantumSystem` combines a noise model and quantum engine. The noise model "wraps" the quantum engine and can transform operations before they reach the simulator: - -```rust -pub struct QuantumSystem { - noise_model: Box, - quantum_engine: Box, -} -``` - -**The flow through QuantumSystem:** - -``` - QuantumSystem -┌─────────────────────────────────────────────────────┐ -│ │ -│ ByteMessage: [H(0), CX(0,1), MZ(0)] │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ NoiseModel │ │ -│ │ - May add depolarizing errors after gates │ │ -│ │ - May flip measurement outcomes │ │ -│ │ - Returns EngineStage::NeedsProcessing │ │ -│ │ with modified commands │ │ -│ └─────────────────┬───────────────────────────┘ │ -│ │ │ -│ [H(0), X(0), CX(0,1), Z(1), MZ(0)] │ -│ (original ops + injected errors) │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ QuantumEngine │ │ -│ │ (StateVec or SparseStab) │ │ -│ │ - Executes all gates on quantum state │ │ -│ │ - Performs measurements │ │ -│ │ - Returns raw measurement results │ │ -│ └─────────────────┬───────────────────────────┘ │ -│ │ │ -│ ByteMessage: [MZ(0) = 1] │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ NoiseModel (again) │ │ -│ │ - May flip measurement results │ │ -│ │ - Returns EngineStage::Complete │ │ -│ │ with final measurements │ │ -│ └─────────────────┬───────────────────────────┘ │ -│ │ │ -│ ByteMessage: [MZ(0) = 0] (flipped!) │ -│ │ │ -└────────────────────┼────────────────────────────────┘ - │ - ▼ - Back to ClassicalEngine -``` - -The NoiseModel is itself a `ControlEngine` - it can return `NeedsProcessing` to send modified commands to the quantum engine, and the loop continues until it returns `Complete`. This allows noise models to: -- Inject error gates before/after operations -- Transform gate parameters -- Flip measurement outcomes -- Add multiple rounds of noise injection if needed - -## Parallelization with MonteCarloEngine - -`MonteCarloEngine` distributes shots across worker threads: - -```rust -pub struct MonteCarloEngine { - /// Template engine (cloned for each worker) - template: HybridEngine, - - /// Number of parallel workers - num_workers: usize, - - /// Base seed for reproducibility - seed: u64, -} -``` - -### Execution Flow - -```rust -fn run(&mut self, num_shots: usize) -> Result { - // Distribute shots across workers - let shots_per_worker = num_shots / self.num_workers; - - // Parallel execution with rayon - let results: Vec = (0..self.num_workers) - .into_par_iter() - .flat_map(|worker_id| { - // Clone template with derived seed - let mut engine = self.template.clone(); - engine.set_seed(derive_seed(self.seed, worker_id)); - - // Run assigned shots - (0..shots_per_worker) - .map(|_| engine.run_shot()) - .collect::>() - }) - .collect(); - - Ok(ShotVec::from(results)) -} -``` - -### Seed Derivation for Reproducibility - -Seeds are derived deterministically to ensure reproducible results: - -``` -Base seed (42) - ├── Worker 0: derive_seed(42, 0) - │ ├── Classical: derive_seed(..., 0) - │ └── Quantum: derive_seed(..., 1) - │ ├── NoiseModel: derive_seed(..., 0) - │ └── QuantumEngine: derive_seed(..., 1) - ├── Worker 1: derive_seed(42, 1) - │ └── ... - ... -``` - -This ensures: -- Same seed always produces same results -- Different workers have uncorrelated random streams -- Different components have uncorrelated random streams - -## User API: sim() and SimBuilder - -The `sim()` function provides a fluent API for configuration: - -```rust -// Basic usage -let results = sim(my_program) - .quantum(sparse_stab()) - .run(1000)?; - -// With noise -let results = sim(my_program) - .quantum(state_vec()) - .noise(DepolarizingNoise { p: 0.01 }) - .seed(42) - .workers(4) - .run(10000)?; - -// Reusable engine -let mut engine = sim_builder() - .classical(qasm_engine().qasm("H q[0]; measure q[0] -> c[0];")) - .quantum(sparse_stab()) - .build()?; - -let batch1 = engine.run(1000)?; -let batch2 = engine.run(2000)?; // Reuse same engine -``` - -### SimBuilder Configuration - -| Method | Purpose | -|--------|---------| -| `.classical(builder)` | Set classical engine (program source) | -| `.quantum(builder)` | Set quantum simulator | -| `.noise(model)` | Set noise model | -| `.seed(u64)` | Set RNG seed for reproducibility | -| `.workers(n)` | Set number of parallel workers | -| `.build()` | Build reusable engine | -| `.run(shots)` | Build and run immediately | - -## Results: Shot and ShotVec - -### Shot - -A `Shot` represents results from a single execution: - -```rust -pub struct Shot { - /// Named results (e.g., "outcome" -> 1) - results: BTreeMap, -} - -pub enum Data { - U32(u32), - I64(i64), - F64(f64), - Bool(bool), - BitVec(BitVec), - Json(serde_json::Value), -} -``` - -### ShotVec - -A `ShotVec` aggregates results from multiple shots in columnar format: - -```rust -let results: ShotVec = engine.run(1000)?; - -// Access as columns -let outcomes: &[i64] = results.get_i64("outcome")?; -// outcomes = [0, 1, 1, 0, 1, ...] (1000 values) - -// Convert to HashMap -let map: HashMap> = results.to_map(); -``` - -## Crate Dependencies - -``` -pecos-engines (orchestration) - │ - ├── pecos-simulators - │ ├── StateVec (state vector simulator) - │ ├── SparseStab (stabilizer simulator) - │ └── CliffordGateable, ArbitraryRotationGateable traits - │ - ├── pecos-core - │ ├── QubitId (qubit identification) - │ ├── GateType, Gate (gate definitions) - │ ├── derive_seed() (deterministic seed derivation) - │ └── PecosError (error handling) - │ - ├── pecos-random - │ └── PecosRng (parallel-safe RNG) - │ - └── byte_message/ (internal module) - ├── message.rs (parsing/serialization) - ├── protocol.rs (binary format definitions) - └── builder.rs (message construction) - -pecos-qis-ffi (C ABI for external programs) - │ - ├── QIS-style exports (__quantum__qis__*) - ├── Runtime functions (__quantum__rt__*) - ├── Dynamic circuit support (___lazy_measure, etc.) - └── ExecutionContext (thread-local isolation) - -selene-plugins/ (simulator plugins) - │ - ├── pecos-selene-statevec - └── pecos-selene-stabilizer -``` - -## ByteMessage: Binary Protocol for FFI and Plugins - -The `ByteMessage` protocol is a cornerstone of PECOS's extensibility. Beyond decoupling internal components, it enables Foreign Function Interface (FFI) support and a plugin architecture. - -### Binary Format - -ByteMessage uses a 4-byte aligned binary format stored in `Vec`: - -```rust -pub struct ByteMessage { - data: Vec, // Binary format with 4-byte alignment - byte_len: usize, // Track actual byte length -} -``` - -**Message Structure:** -- **Batch Header (16 bytes):** Magic number (`0x50_45_43_53` = "PECS"), protocol version, flags, message count, total size -- **Per-Message:** Message header (8 bytes) + payload -- **Payload:** Gate operations with encoded qubit indices and floating-point parameters -- **Alignment:** All boundaries padded to 4-byte alignment for FFI safety using `bytemuck` - -### FFI Support (pecos-qis-ffi) - -The `pecos-qis-ffi` crate exports C ABI functions following QIS (Quantum Instruction Set) standards: - -```rust -// Gate operations exported with #[no_mangle] extern "C" -__quantum__qis__h__body(qubit: *mut Qubit) -__quantum__qis__cx__body(control: *mut Qubit, target: *mut Qubit) -__quantum__qis__rz__body(theta: f64, qubit: *mut Qubit) -__quantum__qis__mz__body(qubit: *mut Qubit, result: *mut Result) - -// Runtime functions -__quantum__rt__qubit_allocate() -> *mut Qubit -__quantum__rt__qubit_release(qubit: *mut Qubit) -``` - -**Dynamic Circuit Support:** - -For mid-circuit measurement with classical feedback across FFI: - -```rust -// Lazy measurement returns a future ID -___lazy_measure(qubit: i64) -> i64 - -// Blocks until measurement result is available -___read_future_bool(future_id: i64) -> bool - -// Control dynamic execution mode -pecos_enable_dynamic_mode() -pecos_disable_dynamic_mode() -``` - -Thread-local `ExecutionContext` enables per-execution isolation for parallel Monte Carlo simulations: - -```rust -pub struct ExecutionContext { - pub dynamic_mode_active: AtomicBool, - pub waiting_for_result: AtomicU64, - pub sync_state: Mutex, - pub measurement_results: Mutex>, -} -``` - -### Plugin Architecture (selene-plugins) - -Plugins implement the `SimulatorInterface` trait: - -```rust -pub trait SimulatorInterface { - fn shot_start(&mut self, shot_id: u64, seed: u64) -> Result<()>; - fn shot_end(&mut self) -> Result<()>; - fn rxy(&mut self, qubit: u64, theta: f64, phi: f64) -> Result<()>; - fn rz(&mut self, qubit: u64, theta: f64) -> Result<()>; - fn czz(&mut self, q1: u64, q2: u64) -> Result<()>; - fn measure(&mut self, qubit: u64) -> Result; - // ... additional gate methods -} -``` - -**Available Plugins:** -- `pecos-selene-statevec` - State vector simulator -- `pecos-selene-stabilizer` - Stabilizer simulator - -### Python Bindings - -ByteMessage is exposed to Python via `pecos-rslib`: - -```python -from pecos import ByteMessage - -# Build a message -builder = ByteMessage.quantum_operations_builder() -builder.h([0]) -builder.cx([(0, 1)]) -builder.mz([0]) -message = builder.build() - -# Parse operations -gates = message.parse_quantum_operations() # Returns list of dicts -raw = message.as_bytes() # Raw binary for network/storage -``` - -### Design Benefits - -The ByteMessage protocol provides: - -- **Decouples components** - Classical and quantum engines don't need to know about each other's internals -- **Enables batching** - Multiple operations sent in one message -- **FFI-safe** - 4-byte alignment and simple binary format works across language boundaries -- **Plugin extensibility** - New simulators can be added without modifying core code -- **Network-ready** - Messages can be serialized for distributed simulation -- **Python integration** - Full access to simulation infrastructure from Python - -## Key Design Decisions - -### Why ControlEngine Pattern? - -The `ControlEngine` pattern (start/continue_processing) enables: -- **Feedback loops** - Essential for mid-circuit measurements -- **Lazy evaluation** - Only generate commands as needed -- **State management** - Controller maintains state across rounds -- **Composability** - Controllers can wrap other engines (e.g., NoiseModel wraps QuantumEngine) - -### Why Clone-per-Worker? - -Each worker gets a clone of the `HybridEngine`: -- **Thread safety** - No shared mutable state between workers -- **Independence** - Workers can't interfere with each other -- **Simplicity** - No complex synchronization needed -- **Reproducibility** - Each worker has deterministic behavior - -## Example: QEC Simulation Flow - -Here's how the architecture supports a QEC simulation: - -``` -1. Classical engine: Generate data qubit initialization - → ByteMessage: [H(d0), H(d1), ...] - -2. Quantum system: Execute initialization - → ByteMessage: [] (no measurements) - -3. Classical engine: Generate syndrome extraction circuit - → ByteMessage: [CX(d0,a0), CX(d1,a0), ..., MZ(a0), MZ(a1), ...] - -4. Quantum system: Execute, return syndrome measurements - → ByteMessage: [MZ(a0)=1, MZ(a1)=0, ...] - -5. Classical engine: Decode syndrome, generate corrections - → ByteMessage: [X(d0), Z(d2)] // Corrections based on decoder - -6. Quantum system: Apply corrections - → ByteMessage: [] - -7. Classical engine: Generate next round or final measurements - → ... - -8. Complete: Return Shot with logical measurement results -``` - -The feedback loop is essential here - the corrections depend on the syndrome measurements. - -## Python Extensibility - -PECOS is designed for Python users to write custom components while leveraging Rust performance for the heavy lifting. - -### Protocol-Based Architecture - -Python components implement Protocol classes (structural typing): - -```python -from __future__ import annotations - -from typing import Any, Callable, Protocol - -BitArray = Any # placeholder for bit-array type -Correction = Any # placeholder for correction type - - -class MachineProtocol(Protocol): - """Interface for hardware models (connectivity, leakage, etc.).""" - - leaked_qubits: set[int] - lost_qubits: set[int] - - def process(self, op_buffer: list) -> list: ... - - -class ErrorModelProtocol(Protocol): - """Interface for custom error/noise models.""" - - error_params: dict - - def init(self, num_qubits: int, machine: MachineProtocol | None = None) -> None: ... - def process(self, qops: list, call_back: Callable | None = None) -> list | None: ... - def reset(self) -> None: ... - - -class Decoder(Protocol): - """Interface for QEC decoders.""" - - def decode(self, syndrome: BitArray) -> Correction: ... -``` - -### Writing Custom Components in Python - -Users can implement any protocol in pure Python: - -```python -import random - - -class MyCustomErrorModel: - """Custom error model - just implement the protocol methods.""" - - def __init__(self, error_rate: float): - self.error_params = {"p": error_rate} - self.num_qubits = None - - def init(self, num_qubits: int, machine=None) -> None: - self.num_qubits = num_qubits - - def process(self, qops: list, call_back=None) -> list: - noisy_ops = [] - for op in qops: - noisy_ops.append(op) - if random.random() < self.error_params["p"]: - noisy_ops.append(("X", op)) # simplified noise - return noisy_ops - - def reset(self) -> None: - pass - - -model = MyCustomErrorModel(0.01) -model.init(num_qubits=5) -``` - -Usage with the hybrid engine: - -```hidden-python -import random - -from pecos.engines import HybridEngine - - -class MyCustomErrorModel: - def __init__(self, error_rate): - self.error_params = {"p": error_rate} - self.num_qubits = None - - def init(self, num_qubits, machine=None): - self.num_qubits = num_qubits - - def process(self, qops, call_back=None): - noisy_ops = [] - for op in qops: - noisy_ops.append(op) - return noisy_ops - - def reset(self): - pass - - def shot_reinit(self): - pass - - -program = { - "format": "PHIR/JSON", - "version": "0.1.0", - "metadata": {"num_qubits": 2}, - "ops": [ - {"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 2}, - {"data": "cvar_define", "data_type": "i32", "variable": "m", "size": 2}, - {"qop": "H", "args": [["q", 0]]}, - {"qop": "CX", "args": [[["q", 0], ["q", 1]]]}, - {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, - {"qop": "Measure", "args": [["q", 1]], "returns": [["m", 1]]}, - ], -} -``` - -```python -from pecos.engines import HybridEngine - -# Use with HybridEngine -engine = HybridEngine( - error_model=MyCustomErrorModel(0.01), # Python error model (flexible) -) -results = engine.run(program, shots=100) -``` - -### Two-Layer Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Python Layer │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Python HybridEngine │ │ -│ │ - Orchestrates Python-defined components │ │ -│ │ - Custom ErrorModel, Machine, Decoder in Python │ │ -│ │ - Flexible experimentation │ │ -│ └──────────────────────┬──────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────┴──────────────────────────────┐ │ -│ │ PyO3 Bindings (pecos-rslib) │ │ -│ │ - SparseStab, StateVec exposed to Python │ │ -│ │ - WasmForeignObject for classical co-processors │ │ -│ │ - Engine builders for Rust-native pipelines │ │ -│ └──────────────────────┬──────────────────────────────┘ │ -└─────────────────────────┼───────────────────────────────────┘ - │ -┌─────────────────────────┼───────────────────────────────────┐ -│ Rust Layer │ -│ ┌──────────────────────┴──────────────────────────────┐ │ -│ │ Rust HybridEngine │ │ -│ │ - High-performance orchestration │ │ -│ │ - ByteMessage protocol │ │ -│ │ - Parallel Monte Carlo │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Rust Simulators │ │ -│ │ - SparseStab (stabilizer) │ │ -│ │ - StateVec (state vector) │ │ -│ │ - GPU backends │ │ -│ └─────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - -### Use Cases - -| Scenario | Approach | -|----------|----------| -| **Prototyping new error model** | Write in Python, use Rust simulator | -| **Custom QEC decoder** | Python decoder with Rust stabilizer sim | -| **Production simulation** | Full Rust pipeline via `sim()` API | -| **Research flexibility** | Mix Python and Rust components freely | -| **Classical co-processing** | WASM modules via WasmForeignObject | - -### Foreign Objects (Classical Co-Processors) - -For computationally intensive classical logic (decoders, lookup tables), PECOS supports WASM: - -```python -import tempfile -from pathlib import Path - -from pecos import WasmForeignObject - -# Create a minimal WASM module with a decode function -wat = """(module - (func $init) - (func $decode_syndrome (param i32) (result i32) (local.get 0)) - (memory (;0;) 1) - (export "init" (func $init)) - (export "decode_syndrome" (func $decode_syndrome)) - (export "memory" (memory 0)) -)""" -wat_path = Path(tempfile.mktemp(suffix=".wat")) -wat_path.write_text(wat) - -# Load and use -decoder_wasm = WasmForeignObject.from_file(str(wat_path)) -decoder_wasm.init() -result = decoder_wasm.exec("decode_syndrome", [42]) -``` - -This allows writing performance-critical classical code in Rust/C/C++ compiled to WASM, while keeping the orchestration in Python. - -## Summary - -The `pecos-engines` architecture provides: - -- **Modularity** - Swap simulators, noise models, or program formats independently -- **Composability** - Engines delegate to other engines via well-defined interfaces -- **Parallelism** - Automatic multi-threaded shot execution -- **Reproducibility** - Deterministic seed derivation -- **Flexibility** - Support for adaptive circuits via classical-quantum feedback -- **Extensibility** - FFI support and plugin architecture via ByteMessage protocol -- **Cross-language** - Python bindings and C ABI exports for external integration - -The key insights are: -1. The `EngineStage` pattern enables feedback loops between classical and quantum components, making mid-circuit measurements and classical control possible -2. The `ByteMessage` binary protocol provides a clean FFI boundary, enabling plugins, Python integration, and potential distributed simulation -3. The two-layer architecture (Python + Rust) allows users to prototype custom components in Python while leveraging Rust performance for simulation +This document has been moved to `~/Repos/pecos-docs/design/engines-architecture.md`. diff --git a/design/OPERATOR_TYPE_SYSTEM.md b/design/OPERATOR_TYPE_SYSTEM.md index aeab62541..532d2e099 100644 --- a/design/OPERATOR_TYPE_SYSTEM.md +++ b/design/OPERATOR_TYPE_SYSTEM.md @@ -1,275 +1,3 @@ -# Operator Type System Architecture +# Moved to pecos-docs vault -This document describes the quantum operator type system in PECOS: the four algebraic levels (Pauli, Clifford, Unitary, Channel), their representations, the unified `Op` type with automatic promotion, and the Pauli collection hierarchy. - -## Design Principles - -1. **Tightest possible representation**: Each operator is represented at the most specific level that can express it. A Hadamard gate is Clifford, not "just a unitary". This enables level-specific optimizations (e.g., fast Pauli conjugation via tableaux). - -2. **Automatic promotion**: When combining operators from different levels, the result is promoted to the tightest level that can represent the combination. Pauli & Clifford -> Clifford. - -3. **Lazy expression trees**: At the Unitary and Channel levels, operators are stored as symbolic expression trees, not eagerly evaluated matrices. Composition (`*`) and tensor (`&`) build tree nodes. - -4. **Sparse by default**: Pauli strings store only non-identity entries. Clifford tableaux store only the images of generators that change. This handles large qubit counts efficiently. - -## Operator Levels - -``` -Level 0: Pauli PauliString Exact, finite group, symplectic algebra -Level 1: Clifford CliffordRep Heisenberg picture, O(n) Pauli conjugation -Level 2: Unitary UnitaryRep Lazy expression tree, any unitary -Level 3: Channel ChannelExpr Non-unitary: measurement, noise, reset -``` - -### Level 0: Pauli (`PauliString`) - -**File:** `crates/pecos-core/src/pauli/pauli_string.rs` - -```rust -pub struct PauliString { - phase: QuarterPhase, // {+1, -1, +i, -i} - paulis: Vec<(Pauli, QubitId)>, // sparse: only non-identity entries -} -``` - -The n-qubit Pauli group has 4^(n+1) elements (4 single-qubit Paulis, 4 phases). `PauliString` represents them sparsely -- a Pauli acting non-trivially on 3 out of 1000 qubits uses O(3) storage. - -**Key properties:** -- Exact arithmetic (no floating point) -- O(w) commutation check where w = total weight of both operators -- Multiplication produces another `PauliString` with computed phase -- The `PauliOperator` trait unifies `PauliString`, `PauliBitmap`, and `PauliSparse` - -**Constructor modules:** -- `pecos_core::pauli::constructors` -- `X(q)`, `Y(q)`, `Z(q)`, `Xs(qs)`, `Ys(qs)`, `Zs(qs)` -- `pecos_core::pauli::algebra` -- operator overloading: `&` (tensor), `*` (multiply), `-` (negate), `i *` (phase) - -**Alternative representations:** -- `PauliBitmap` -- u64 bitmasks for X and Z positions. Limited to 64 qubits, but bitwise operations are very fast. -- `PauliSparse` -- generic over the set type `T` for X/Z positions. Allows custom set implementations. - -### Level 1: Clifford (`CliffordRep`) - -**File:** `crates/pecos-core/src/clifford_rep.rs` - -```rust -pub struct CliffordRep { - num_qubits: usize, - x_images: Vec, // X_i -> PauliString - z_images: Vec, // Z_i -> PauliString -} -``` - -Represents a Clifford gate via the Heisenberg picture: how it conjugates each single-qubit Pauli generator. A Clifford on n qubits is fully specified by 2n generator images. - -**Why this representation:** -- Conjugating a weight-w Pauli string through a Clifford is O(w * n), not O(4^n) -- Composition of two Cliffords is O(n^2) -- Natural for stabilizer simulation and code analysis - -**Key methods:** -- `identity(n)`, `h(q)`, `cx(c, t)`, `s(q)`, etc. -- standard gate constructors -- `compose(&other)` -- C1 * C2 -- `apply(&pauli)` -- C * P * C^dag -- `inverse()` -- C^dag -- `from(PauliString)` -- every Pauli is a Clifford - -**Relation to `Clifford` enum:** The `Clifford` enum (`crates/pecos-core/src/clifford.rs`) lists the 24 single-qubit and 14 two-qubit Clifford primitives by name. `CliffordRep` is the algebraic representation used for computation. - -### Level 2: Unitary (`UnitaryRep`) - -**File:** `crates/pecos-core/src/unitary_rep.rs` - -```rust -pub enum UnitaryRep { - Pauli(PauliString), - Rotation { rotation_type: RotationType, angle: Angle64, qubits: SmallVec<[usize; 2]> }, - Gate { gate_type: GateType, qubits: SmallVec<[usize; 3]> }, - Tensor(Vec), - Compose(Vec), - Adjoint(Box), - Phase { phase: Angle64, inner: Box }, -} -``` - -A lazy expression tree that can represent any unitary, including non-Clifford gates (T, Rz(theta), etc.). Operations build tree nodes rather than evaluating eagerly. - -**Why an expression tree:** -- Composition and tensor product are O(1) (just wrap in a new node) -- The tree can be analyzed symbolically (e.g., `is_clifford()` checks) -- Different backends can evaluate the tree differently (matrix, stabilizer sim, etc.) - -**Constructor functions:** Defined in `crates/pecos-core/src/unitary_rep.rs`: -- Single-qubit: `X(q)`, `Y(q)`, `Z(q)`, `H(q)`, `SX(q)`, `SZ(q)`, `T(q)`, etc. -- Rotation: `RX(angle, q)`, `RY(angle, q)`, `RZ(angle, q)`, `RXX(angle, q0, q1)`, etc. -- Two-qubit: `CX(c, t)`, `CZ(q0, q1)`, `SWAP(q0, q1)`, `ISWAP(q0, q1)`, etc. -- Pluralized: `Hs([q0, q1, ...])`, `CXs([(c0,t0), (c1,t1)])` -- tensor multiple gates - -### Level 3: Channel (`ChannelExpr`) - -**File:** `crates/pecos-core/src/op.rs` - -```rust -pub enum ChannelExpr { - Prep { basis: Basis, qubit: usize }, - Measure { basis: Basis, qubit: usize }, - Unitary(UnitaryRep), - MixedUnitary(Vec<(f64, UnitaryRep)>), - AmplitudeDamping { gamma: f64, qubit: usize }, - PhaseDamping { lambda: f64, qubit: usize }, - Erasure { prob: f64, qubit: usize }, - Reset { qubit: usize }, - Leakage { rate: f64, qubit: usize }, - Tensor(Vec), - Compose(Vec), -} -``` - -Non-unitary quantum operations. These compose and tensor like unitaries but are not invertible (no `dg()`). - -**Notable variants:** -- `MixedUnitary` -- covers Pauli channels, depolarizing, dephasing, bit-flip via weighted sums of unitaries -- `AmplitudeDamping` / `PhaseDamping` -- explicit Kraus-operator channels for T1/T2 processes -- `Erasure` -- heralded error channel -- `Leakage` -- transition to non-computational states - -## The Unified `Op` Type - -**File:** `crates/pecos-core/src/op.rs` - -```rust -pub enum Op { - Pauli(PauliString), - Clifford(CliffordRep, UnitaryRep), - Unitary(UnitaryRep), - Channel(ChannelExpr), -} -``` - -`Op` wraps all four levels and provides automatic promotion via the `&` (tensor) and `*` (composition) operators. When combining two `Op` values, the result is at the maximum level of the operands. - -### Dual Representation at Clifford Level - -The `Clifford` variant stores both a `CliffordRep` (for efficient Pauli conjugation) and a `UnitaryRep` (for promotion to the Unitary level). This avoids information loss when mixing Clifford and non-Clifford operations. - -### Promotion Rules - -``` -Pauli & Pauli -> Pauli -Pauli & Clifford -> Clifford -Pauli & Unitary -> Unitary -Clifford & Unitary -> Unitary -anything & Channel -> Channel -``` - -Same rules apply for composition (`*`). - -### Extraction Methods - -`Op` provides both borrowing and consuming extractors: - -- `as_pauli()` / `into_pauli()` -- returns `None` for non-Pauli -- `as_clifford()` / `into_clifford()` -- Pauli promotes to Clifford; Unitary/Channel return `None` -- `as_unitary()` / `into_unitary()` -- Pauli and Clifford promote; Channel returns `None` -- `into_channel()` -- always succeeds (lower levels wrap in `ChannelExpr::Unitary`) - -### Constructor Functions - -`Op`-level constructors live in `crates/pecos-core/src/op.rs` and mirror the `UnitaryRep` constructors but return `Op` at the tightest level: - -- `X(q)`, `Z(q)` -- return `Op::Pauli` -- `H(q)`, `CX(c,t)`, `SZ(q)` -- return `Op::Clifford` -- `T(q)`, `RZ(angle, q)` -- return `Op::Unitary` -- `MZ(q)`, `PZ(q)`, `Depolarizing(p, q)` -- return `Op::Channel` - -**Important:** The constructors in `pecos_core::op` and `pecos_core::unitary_rep` have the same names (`X`, `H`, `T`, etc.) but return different types (`Op` vs `UnitaryRep`). Use `use pecos_core::op::*` for the unified `Op` algebra, or `use pecos_core::unitary_rep::*` for the `UnitaryRep`-only algebra. Similarly, `use pecos_core::pauli::constructors::*` gives `PauliString`-level constructors. - -## Pauli Collection Types - -**Crate:** `pecos-quantum` - -Four collection types with increasing algebraic constraints: - -``` -PauliSequence ──(validate commutativity)──> PauliGroup ──(validate real phases)──> PauliStabilizerGroup - -PauliSet (separate: unordered, deduplicated) -``` - -### PauliSequence - -**File:** `crates/pecos-quantum/src/pauli_sequence.rs` - -Ordered list of `PauliString`s with no constraints. Provides GF(2) symplectic analysis: - -- `rank()` -- number of linearly independent generators -- `row_reduce()` -- independent generator subset -- `contains(&pauli)` -- membership in GF(2) span (ignoring phase) -- `is_abelian()` -- check mutual commutativity -- `to_symplectic_matrix()` -- binary representation for linear algebra - -### PauliSet - -**File:** `crates/pecos-quantum/src/pauli_set.rs` - -Unordered set of unique `PauliString`s backed by `BTreeSet`. Phase-sensitive equality (+XZ and -XZ are distinct). Standard set operations (union, intersection, difference). - -### PauliGroup - -**File:** `crates/pecos-quantum/src/pauli_group.rs` - -Wraps `PauliSequence` with validated commutativity. Generators may have any `QuarterPhase`. When a generator has phase +i or -i, it has order 4 and the group contains -I (so it cannot stabilize any quantum state). - -### PauliStabilizerGroup - -**File:** `crates/pecos-quantum/src/stabilizer_group.rs` - -Wraps `PauliGroup` with the additional constraint that all generators have `Sign` phases (+1 or -1). This is the standard stabilizer group for QEC: every element squares to +I, and the group defines a valid code space. - -### Conversion Safety - -Widening conversions (dropping constraints) always succeed via `From`: -``` -PauliStabilizerGroup -> PauliGroup -> PauliSequence -``` - -Narrowing conversions (adding constraints) are fallible via `TryFrom`: -``` -PauliSequence -> PauliGroup (validates commutativity) -PauliGroup -> PauliStabilizerGroup (validates real phases) -PauliSequence -> PauliStabilizerGroup (validates both) -``` - -All types also have `from_generators_unchecked()` for internal use when constraints are known to hold. - -## File Organization - -``` -crates/pecos-core/src/ - pauli.rs -- Pauli enum, PauliOperator trait - pauli/ - pauli_string.rs -- PauliString (primary Pauli type) - pauli_bitmap.rs -- PauliBitmap (<=64 qubits, fast) - pauli_sparse.rs -- PauliSparse (generic) - constructors.rs -- X(), Y(), Z(), Xs(), Ys(), Zs() - algebra.rs -- operator overloading (&, *, -, i*) - clifford.rs -- Clifford enum (named gate primitives) - clifford_rep.rs -- CliffordRep (Heisenberg picture) - unitary_rep.rs -- UnitaryRep (expression tree) - op.rs -- Op (unified type), ChannelExpr - -crates/pecos-quantum/src/ - pauli_sequence.rs -- PauliSequence, F2Matrix - pauli_set.rs -- PauliSet - pauli_group.rs -- PauliGroup - stabilizer_group.rs -- PauliStabilizerGroup -``` - -## Relation to QEC Types - -The Pauli collection types feed into the QEC layer in `pecos-qec`: - -- `PauliStabilizerGroup` + `num_qubits` -> `StabilizerCode` (mathematical definition, on-demand analysis) -- `StabilizerCode` -> `StabilizerCodeSpec` (verified, with paired logicals, for fault tolerance) - -See [Stabilizer Code Architecture](STABILIZER_CODE_ARCHITECTURE.md) for details. +This document has been moved to `~/Repos/pecos-docs/design/operator-type-system.md`. diff --git a/design/QIS_ARCHITECTURE.md b/design/QIS_ARCHITECTURE.md index 7434b2f79..03331b6a2 100644 --- a/design/QIS_ARCHITECTURE.md +++ b/design/QIS_ARCHITECTURE.md @@ -1,646 +1,3 @@ -# QIS Architecture: Interface, Runtime, and Engine +# Moved to pecos-docs vault - - -This document describes the architecture of the Quantum Instruction Set (QIS) system in PECOS, focusing on how quantum programs are compiled, executed, and simulated. - -## Overview - -The QIS architecture consists of three main components: - -1. **Interface Layer** - Compiles quantum programs and collects operations -2. **Runtime Layer** - Executes collected quantum operations -3. **Engine Layer** - Orchestrates interface and runtime - -``` -┌─────────────────────────────────────────────────────────────┐ -│ QisEngine │ -│ (pecos-qis) │ -│ │ -│ ┌─────────────────────┐ ┌──────────────────────┐ │ -│ │ QisInterface │ │ QisRuntime │ │ -│ │ (Interface Impl) │──────│ (Runtime Impl) │ │ -│ └─────────────────────┘ └──────────────────────┘ │ -│ │ │ │ -└───────────┼──────────────────────────────┼──────────────────┘ - │ │ - ▼ ▼ - Compile & Collect Execute Operations - Operations (Quantum Simulation) -``` - -## 1. Interface Architecture - -The **Interface Layer** is responsible for taking a quantum program (in various formats) and extracting the quantum operations from it. - -### Interface Trait - -Defined in `pecos-qis/src/qis_interface.rs`: - -```rust -pub trait QisInterface { - /// Load a quantum program - fn load_program(&mut self, program_bytes: &[u8], format: ProgramFormat) - -> Result<(), InterfaceError>; - - /// Collect operations from the loaded program - fn collect_operations(&mut self) -> Result; - - /// Execute with pre-set measurement results (for conditional operations) - fn execute_with_measurements(&mut self, measurements: HashMap) - -> Result; - - /// Get interface metadata - fn metadata(&self) -> HashMap; - - /// Interface name - fn name(&self) -> &'static str; - - /// Reset the interface state - fn reset(&mut self) -> Result<(), InterfaceError>; -} -``` - -### Helios Interface Implementation - -The **Helios Interface** (`QisHeliosInterface` in `pecos-qis`) is the primary interface implementation. It works by: - -1. **Compilation**: Linking quantum program bitcode with Selene's Helios library -2. **Dynamic Execution**: Loading and executing the compiled program in-process -3. **Operation Collection**: Capturing quantum operations via FFI interception - -#### Helios Interface Flow - -``` -User provides QIS bitcode/LLVM IR - ↓ -QisHeliosInterface.load_program() - ↓ - Compile with clang: - program.bc + libhelios.a → program.so - ↓ -QisHeliosInterface.collect_operations() - ↓ - Load libraries with RTLD_GLOBAL: - 1. libpecos_qis_ffi.so (provides __quantum__rt__*) - 2. libpecos_selene.so (provides selene_*) - 3. program.so (calls selene_*) - ↓ - Execute: qmain() or main() - ↓ - Collect operations from thread-local storage - ↓ - Return OperationCollector -``` - -### Symbol Resolution Chain - -When a quantum program executes, function calls are resolved through multiple layers: - -``` -program.so: qmain() - ↓ calls ___qalloc() - -libhelios.a (linked into program.so) - ↓ calls selene_qalloc() - -libpecos_selene.so (C shim, loaded with RTLD_GLOBAL) - │ File: pecos-qis/src/c/selene_shim.c - │ Purpose: Adapts Selene interface to PECOS FFI - ↓ calls __quantum__rt__qubit_allocate() - -libpecos_qis_ffi.so (Rust cdylib, loaded with RTLD_GLOBAL) - │ Crate: pecos-qis-ffi - │ Purpose: Provides QIS FFI functions - ↓ records operation - -OperationCollector (thread-local storage) - │ Records: AllocateQubit, H, CX, Measure, etc. - ↓ retrieved by - -QisHeliosInterface - │ Returns operations to QisEngine -``` - -### The Shim Layer (libpecos_selene.so) - -**Purpose**: Bridges Selene's C interface to PECOS Rust FFI - -**Location**: Built by `pecos-qis/build_selene.rs` from `src/c/selene_shim.c` - -**Example** (from `selene_shim.c`): -```c -selene_u64_result_t selene_qalloc(SeleneInstance *instance) { - (void)instance; // Unused - we use thread-local storage - int64_t qubit_id = __quantum__rt__qubit_allocate(); - return SUCCESS_VAL(selene_u64_result_t, (uint64_t)qubit_id); -} - -selene_void_result_t selene_rxy(SeleneInstance *instance, - uint64_t q, double theta, double phi) { - (void)instance; - __quantum__qis__r1xy__body(theta, phi, (int64_t)q); - return SUCCESS(selene_void_result_t); -} -``` - -**Why it exists**: Selene's Helios compiler expects functions with specific signatures (e.g., `selene_qalloc`). The shim provides these functions and forwards calls to our Rust FFI layer. - -### The FFI Layer (libpecos_qis_ffi.so) - -**Purpose**: Provides `__quantum__rt__*` and `__quantum__qis__*` symbols that record operations - -**Crate**: `pecos-qis-ffi` - -**Example** (from `pecos-qis-ffi/src/ffi.rs`): -```rust -#[unsafe(no_mangle)] -pub unsafe extern "C" fn __quantum__rt__qubit_allocate() -> i64 { - with_interface(|interface| { - let id = interface.allocate_qubit(); - interface.queue_operation(Operation::AllocateQubit { id }); - i64::try_from(id).expect("Qubit ID too large for i64") - }) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn __quantum__qis__h__body(qubit: i64) { - let qubit_id = i64_to_usize(qubit); - with_interface(|interface| { - interface.queue_operation(QuantumOp::H(qubit_id).into()); - }); -} -``` - -**Thread-local storage**: Operations are collected in thread-local `OperationCollector` that can be retrieved after execution. - -### Operation Collector - -The `OperationCollector` (in `pecos-qis-ffi`) stores: - -```rust -pub struct OperationCollector { - /// Allocated qubit IDs - pub allocated_qubits: Vec, - - /// Allocated result IDs - pub allocated_results: Vec, - - /// Sequence of quantum operations - pub operations: Vec, - - /// Measurement results (for conditional execution) - measurement_results: HashMap, -} -``` - -Operations include: -- `AllocateQubit`, `ReleaseQubit` -- `AllocateResult` -- Quantum gates: `H`, `X`, `Y`, `Z`, `S`, `T`, `CX`, `CY`, `CZ`, etc. -- Rotations: `RX`, `RY`, `RZ`, `RXY`, `RZZ`, etc. -- Measurements: `Measure`, `Reset` - -## 2. Runtime Architecture - -The **Runtime Layer** takes collected quantum operations and executes them using a quantum simulator. - -### Runtime Trait - -Defined in `pecos-qis/src/runtime.rs`: - -```rust -pub trait QisRuntime: Send + Sync + DynClone { - /// Execute quantum operations and return results - fn execute(&mut self, operations: &OperationCollector) - -> Result; - - /// Runtime name - fn name(&self) -> &'static str; - - /// Clone the runtime - fn clone_box(&self) -> Box; -} -``` - -### Selene Runtime Implementation - -The **Selene Runtime** wraps Selene's quantum simulator library (.so files). - -**Location**: `pecos-qis/src/selene_runtime.rs` - -#### Selene Runtime Types - -Selene provides multiple runtime variants (all are .so files): - -1. **Simple Runtime** (`libselene_simple_runtime.so`): - - State vector simulation - - Full quantum state tracking - - Function: `selene_simple_runtime()?` - -2. **Soft-Rz Runtime** (`libselene_soft_rz_runtime.so`): - - Optimized for Rz-heavy circuits - - Function: `selene_soft_rz_runtime()?` - -#### Runtime Wrapper Structure - -```rust -pub struct QisSeleneRuntime { - /// Path to the Selene runtime .so file - runtime_lib_path: PathBuf, - - /// Loaded runtime library - runtime_lib: Option, - - /// Runtime metadata - metadata: HashMap, -} -``` - -#### Runtime Execution Flow - -``` -QisEngine calls runtime.execute(operations) - ↓ -QisSeleneRuntime.execute() - ↓ - Load libselene_*_runtime.so - ↓ - Initialize Selene instance - ↓ - For each operation in OperationCollector: - - Translate to Selene API call - - Call runtime function via FFI - - Track quantum state in Selene - ↓ - Perform measurements (if any) - ↓ - Extract results from Selene - ↓ - Return RuntimeResult -``` - -#### Selene Runtime Functions - -Selene runtimes expose functions like: - -```c -// State management -SeleneInstance* selene_new_instance(void); -void selene_free_instance(SeleneInstance*); - -// Qubit operations -selene_u64_result_t selene_qalloc(SeleneInstance*); -selene_void_result_t selene_qfree(SeleneInstance*, uint64_t qubit); - -// Quantum gates -selene_void_result_t selene_rxy(SeleneInstance*, uint64_t q, double theta, double phi); -selene_void_result_t selene_rz(SeleneInstance*, uint64_t q, double theta); - -// Measurements -selene_bool_result_t selene_qubit_measure(SeleneInstance*, uint64_t qubit); -``` - -The `QisSeleneRuntime` wrapper calls these functions via `libloading` FFI. - -### Runtime Results - -The `RuntimeResult` contains: - -```rust -pub struct RuntimeResult { - /// Measurement outcomes (result_id → bool) - pub measurements: HashMap, - - /// Runtime-specific metadata - pub metadata: HashMap, -} -``` - -## 3. Engine Architecture (QisEngine) - -The **QisEngine** orchestrates the interface and runtime to provide a complete quantum program execution pipeline. - -**Location**: `pecos-qis/src/ccengine.rs` - -### QisEngine Structure - -```rust -pub struct QisEngine { - /// Interface implementation (e.g., QisHeliosInterface) - interface: Box, - - /// Runtime implementation (e.g., QisSeleneRuntime) - runtime: Box, - - /// Number of qubits in the current program - num_qubits: usize, - - /// Number of classical results - num_results: usize, -} -``` - -### Engine Builder Pattern - -Users construct a `QisEngine` using the builder pattern: - -```rust -use pecos_qis::{qis_engine, helios_interface_builder, selene_simple_runtime}; - -let engine = qis_engine() - .interface(helios_interface_builder()) // Set interface - .runtime(selene_simple_runtime()?) // Set runtime - .program(qis_program) // Load program - .build()?; // Build engine -``` - -**Builder location**: `pecos-qis/src/engine_builder.rs` - -### QisEngine Execution Flow - -#### 1. Initialization (build time) - -```rust -QisEngineBuilder::build() - ↓ -Interface: load_program(program_bytes) - ↓ (compiles program) -Interface: collect_operations() - ↓ (executes program, collects ops) -Store operations and metadata - ↓ -Return QisEngine -``` - -#### 2. Execution (run time) - -```rust -engine.run(options) - ↓ -For each shot: - ↓ - Runtime: execute(operations) - ↓ (simulates quantum circuit) - ↓ (performs measurements) - ↓ - Return RuntimeResult - ↓ -Aggregate results across shots - ↓ -Return SimulationResult -``` - -### Engine Responsibilities - -The `QisEngine` mediates between interface and runtime: - -1. **Initialization**: - - Uses interface to compile and collect operations - - Stores program metadata (num_qubits, num_results) - -2. **Execution**: - - Passes operations to runtime for each shot - - Handles multi-shot simulations - - Aggregates measurement results - -3. **Classical Control** (implements `ClassicalEngine` trait): - - Supports conditional operations based on measurements - - Manages measurement result storage - - Enables dynamic circuit execution - -## 4. Complete Example Flow - -Let's trace a complete example: executing a Bell state program. - -### Step 1: User Code - -```rust -use pecos_qis::{qis_engine, helios_interface_builder, selene_simple_runtime}; -use pecos_programs::Qis; -use pecos_engines::{ClassicalControlEngineBuilder, ClassicalEngine}; - -// Load Bell state program -let qis_program = Qis::from_file("bell.ll")?; - -// Build engine -let mut engine = qis_engine() - .interface(helios_interface_builder()) - .runtime(selene_simple_runtime()?) - .program(qis_program) - .build()?; - -// Run simulation -let result = engine.run(&sim_options)?; -``` - -### Step 2: Interface Processing (during build) - -``` -QisEngineBuilder::build() - ↓ -QisHeliosInterface::load_program(bell.ll) - ↓ - Compile: clang bell.ll + libhelios.a → bell.so - Store: temp file bell.so - ↓ -QisHeliosInterface::collect_operations() - ↓ - Load: libpecos_qis_ffi.so (RTLD_GLOBAL) - Load: libpecos_selene.so (RTLD_GLOBAL) - Load: bell.so - ↓ - Execute: qmain(0) - ↓ calls ___qalloc() [twice] - ↓ calls ___h() [once on qubit 0] - ↓ calls ___cx() [once: control=0, target=1] - ↓ - Operations recorded in thread-local: - - AllocateQubit { id: 0 } - - AllocateQubit { id: 1 } - - H(0) - - CX(0, 1) - ↓ - Return OperationCollector - ↓ -QisEngine stores: - - operations: [AllocateQubit(0), AllocateQubit(1), H(0), CX(0,1)] - - num_qubits: 2 -``` - -### Step 3: Runtime Execution (during run) - -``` -engine.run(sim_options) - ↓ -For shot in 0..num_shots: - ↓ - QisSeleneRuntime::execute(operations) - ↓ - Load: libselene_simple_runtime.so - Init: instance = selene_new_instance() - ↓ - Process operations: - AllocateQubit(0) → q0 = selene_qalloc(instance) - AllocateQubit(1) → q1 = selene_qalloc(instance) - H(0) → selene_rxy(instance, q0, π, 0) - CX(0, 1) → (implemented via Rxy+Rz+Rxy+Rz) - ↓ - Measurements (if any): - Measure(0, 0) → result = selene_qubit_measure(instance, q0) - Measure(1, 1) → result = selene_qubit_measure(instance, q1) - ↓ - Cleanup: selene_free_instance(instance) - ↓ - Return RuntimeResult { - measurements: {0: false, 1: false} (or {0: true, 1: true}) - } - ↓ -Aggregate across shots: - - Count: |00⟩ and |11⟩ states - - Expected: ~50% each for Bell state - ↓ -Return SimulationResult -``` - -## 5. Architecture Benefits - -This three-layer architecture provides: - -### Separation of Concerns - -- **Interface**: Handles program compilation and operation extraction -- **Runtime**: Handles quantum simulation -- **Engine**: Orchestrates and provides unified API - -### Flexibility - -- **Multiple Interfaces**: Can implement JIT, AOT, or other compilation strategies -- **Multiple Runtimes**: Can swap Selene for other simulators (QuEst, Qulacs, etc.) -- **Mix and Match**: Any interface can work with any runtime - -### Extensibility - -Adding a new interface: -```rust -pub struct MyCustomInterface { /* ... */ } - -impl QisInterface for MyCustomInterface { - fn load_program(&mut self, program: &[u8], format: ProgramFormat) - -> Result<(), InterfaceError> { - // Custom compilation logic - } - - fn collect_operations(&mut self) -> Result { - // Custom operation collection - } - // ... other methods -} -``` - -Adding a new runtime: -```rust -pub struct MyCustomRuntime { /* ... */ } - -impl QisRuntime for MyCustomRuntime { - fn execute(&mut self, operations: &OperationCollector) - -> Result { - // Custom simulation logic - } - // ... other methods -} -``` - -### Testability - -- Interface and runtime can be tested independently -- Mock implementations for unit testing -- Real implementations for integration testing - -## 6. Key Design Decisions - -### Why Dynamic Loading? - -The Helios interface uses dynamic loading (`dlopen`/`libloading`) because: - -1. **Symbol Resolution**: LLVM-compiled programs need `__quantum__rt__*` symbols available globally -2. **Flexibility**: Programs are compiled at runtime, not build time -3. **Interception**: We can intercept operations before they reach the simulator - -### Why Thread-Local Storage? - -Operation collection uses thread-local storage because: - -1. **Simplicity**: No need to pass context through C FFI calls -2. **Safety**: Each thread has independent operation collector -3. **Performance**: Thread-local access is fast - -### Why Separate Shim and FFI? - -We have both `libpecos_selene.so` (C shim) and `libpecos_qis_ffi.so` (Rust FFI) because: - -1. **Compatibility**: Helios expects specific C function signatures (`selene_*`) -2. **Type Safety**: Rust FFI provides safe operation collection -3. **Reusability**: FFI layer can be used by other interfaces, not just Helios - -## 7. Crate Organization - -``` -pecos-qis/ # Main QIS crate (with optional selene feature) -├── src/ -│ ├── lib.rs # Re-exports, prelude -│ ├── ccengine.rs # QisEngine -│ ├── engine_builder.rs # QisEngineBuilder -│ ├── qis_interface.rs # QisInterface trait -│ ├── runtime.rs # QisRuntime trait -│ ├── executor.rs # QisHeliosInterface (selene feature) -│ ├── selene_runtime.rs # SeleneRuntime (selene feature) -│ ├── selene_runtimes.rs # Runtime discovery (selene feature) -│ ├── shim.rs # Path to libpecos_selene.so (selene feature) -│ └── c/ -│ └── selene_shim.c # C shim implementation (selene feature) -├── build.rs # Main build script -├── build_selene.rs # Selene build logic (selene feature) -└── Cargo.toml -│ -pecos-qis-ffi/ # FFI layer (cdylib) -├── src/ -│ ├── lib.rs # OperationCollector, thread-local -│ ├── ffi.rs # __quantum__rt__* and __quantum__qis__* exports -│ └── operations.rs # Operation types -└── Cargo.toml # crate-type = ["rlib", "cdylib"] -``` - -## 8. Future Directions - -Potential extensions to this architecture: - -1. **Additional Interfaces**: - - JIT interface using LLVM Orc - - Ahead-of-time (AOT) compiled interface - - Direct QASM→operations interface - -2. **Additional Runtimes**: - - Native PECOS runtime (no Selene dependency) - - GPU-accelerated runtime (QuEst, Qulacs) - - Distributed runtime for large-scale simulation - -3. **Optimizations**: - - Operation fusion (combine multiple gates) - - Circuit optimization passes - - Lazy evaluation of operations - -4. **Features**: - - Noise models in runtime layer - - State vector inspection - - Intermediate measurements with classical control - -## Summary - -The QIS architecture provides a clean separation between: - -- **Interface** (compilation & operation collection) -- **Runtime** (quantum simulation) -- **Engine** (orchestration & API) - -This design enables flexibility, extensibility, and maintainability while supporting complex quantum program execution with features like conditional operations and multi-shot simulations. +This document has been moved to `~/Repos/pecos-docs/design/qis-architecture.md`. diff --git a/design/STABILIZER_CODE_ARCHITECTURE.md b/design/STABILIZER_CODE_ARCHITECTURE.md index 174a790d3..b121b1859 100644 --- a/design/STABILIZER_CODE_ARCHITECTURE.md +++ b/design/STABILIZER_CODE_ARCHITECTURE.md @@ -1,166 +1,3 @@ -# Stabilizer Code Architecture +# Moved to pecos-docs vault -This document describes the architecture of the stabilizer code types in `pecos-qec` and how they relate to the Pauli algebra types in `pecos-core` and `pecos-quantum`. For the broader operator type system (Cliffords, unitaries, channels, `Op`), see [Operator Type System Architecture](OPERATOR_TYPE_SYSTEM.md). - -## Type Hierarchy - -The stabilizer code system is built in layers, from low-level Pauli algebra up to fault tolerance analysis: - -``` -pecos-core pecos-quantum pecos-qec -┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────────────┐ -│ PauliString │ │ PauliStabilizerGroup │ │ StabilizerCode │ -│ Pauli (X,Y,Z,I) │────────▶│ PauliCollection │───▶│ (mathematical definition)│ -│ QuarterPhase │ │ F2Matrix │ ├──────────────────────────┤ -│ CliffordRep │ └─────────────────────┘ │ StabilizerCodeSpec │ -└──────────────────┘ │ (operational spec) │ - ├──────────────────────────┤ - │ StabilizerFlipChecker │ - │ PauliPropChecker │ - │ (fault tolerance) │ - └──────────────────────────┘ -``` - -## Two Stabilizer Code Types - -There are two distinct stabilizer code types in `pecos-qec`, serving different roles: - -### `StabilizerCode` -- The Mathematical Definition - -**File:** `crates/pecos-qec/src/stabilizer_code.rs` - -A lightweight type that wraps a `PauliStabilizerGroup` together with an explicit `num_qubits`. This is the mathematical definition of a stabilizer code: a subgroup of the Pauli group that defines a code space. - -```rust -pub struct StabilizerCode { - group: PauliStabilizerGroup, - num_qubits: usize, -} -``` - -**Purpose:** On-demand QEC analysis. Given just the stabilizer generators, it can compute: - -- `num_logical_qubits()` -- `n - rank` via GF(2) linear algebra -- `logical_operators()` -- centralizer computation over GF(2) -- `distance()` -- coset enumeration (exponential, small codes only) -- `syndrome(error)` -- commutation check against each generator -- `apply_clifford(C)` -- conjugate all generators by a Clifford - -**Key design decisions:** - -- **Explicit `num_qubits`**: Stabilizer generators may not touch all physical qubits. For example, `ZZ` on a 4-qubit system defines a `[[4, 3]]` code, not a `[[2, 1]]` code. The explicit qubit count determines the code parameters. -- **Computed on demand**: Nothing is precomputed or cached. Each call to `logical_operators()` or `distance()` redoes the computation. This keeps the type simple and stateless. -- **Standard constructors**: `repetition(n)`, `steane()`, `five_qubit()`, `shor()`, `four_two_two()`, `toric(l)` provide well-known codes. - -### `StabilizerCodeSpec` -- The Operational Specification - -**File:** `crates/pecos-qec/src/stabilizer_code_spec.rs` - -A heavier type that stores explicit stabilizers, destabilizers, paired logical operators, and optional distance. Used by the fault tolerance analysis stack. - -```rust -pub struct StabilizerCodeSpec { - num_qubits: usize, - stabilizers: Vec, - destabilizers: Vec, - logical_zs: Vec, - logical_xs: Vec, - distance: Option, -} -``` - -**Purpose:** Verification and fault tolerance analysis. Provides: - -- **Verification methods**: `verify()` checks all commutation relations (stabilizers commute, logicals commute with stabilizers, X/Z pairs anticommute, cross-logical commutation). -- **Column-indexed lookups**: `build_stabilizer_index()` creates an O(weight) anticommutation index for efficient syndrome computation. -- **Builder pattern**: Fluent API for constructing codes with explicit logical operators. -- **Logical pairing**: Stores matched (X_i, Z_i) pairs, unlike `StabilizerCode` which returns an unpaired basis. - -**Key design decisions:** - -- **Paired logicals**: Fault tolerance analysis needs to know which logical X goes with which logical Z. `StabilizerCodeSpec` enforces this pairing. -- **Destabilizers**: Stored explicitly for use in stabilizer simulation and error correction. -- **Verification-first**: The `verify()` method checks all algebraic constraints before the code is used for analysis. This catches bugs in code definitions early. - -### Why Two Types? - -They serve different roles: - -| Feature | `StabilizerCode` | `StabilizerCodeSpec` | -|---|---|---| -| Input | Generators only | Generators + logicals + destabilizers | -| Computation | On-demand (centralizer, coset) | Pre-stored, verified | -| Logical operators | Unpaired basis | Paired (X_i, Z_i) | -| Verification | None (algebraic correctness assumed) | Full commutation verification | -| Column indexing | No | Yes (O(weight) lookups) | -| Used by | Exploratory analysis, code discovery | Fault tolerance stack | - -### Conversion - -`StabilizerCode` can be converted to `StabilizerCodeSpec` via: - -```rust -// Direct conversion (discovers logicals via stabilizer simulation) -let spec = StabilizerCodeSpec::from_stabilizer_code(&code)?; - -// Via builder (for adding manual logicals) -let spec = StabilizerCodeSpecBuilder::from_stabilizer_code(&code) - .logical_z(Zs([0, 1, 2])) - .logical_x(Xs([0])) - .build() - .unwrap(); -``` - -The `from_stabilizer_code` method uses `discover_logicals()` which runs a stabilizer simulation to find properly paired (X_i, Z_i) logical operators and their corresponding destabilizers. - -## Supporting Types - -### `PauliStabilizerGroup` (pecos-quantum) - -A purely algebraic type: a collection of commuting Pauli strings with real phases (+1 or -1). No QEC interpretation. Provides: - -- `rank()` -- GF(2) rank of the generator matrix -- `row_reduce()` -- reduced row echelon form over GF(2) -- `centralizer_in(n)` -- centralizer of the group in the n-qubit Pauli group -- `to_symplectic_matrix()` -- binary symplectic representation -- `apply_clifford(C)` -- conjugate all elements - -### `F2Matrix` (pecos-quantum) - -Matrix over GF(2) for symplectic linear algebra. Used internally for rank computation, row reduction, and centralizer calculation. - -### `CliffordRep` (pecos-core) - -Sparse representation of a Clifford unitary as conjugation rules on single-qubit Paulis. Used by `StabilizerCode::apply_clifford()` to transform code generators. - -## Fault Tolerance Integration - -The fault tolerance module in `pecos-qec` consumes `StabilizerCodeSpec`: - -- **`StabilizerFlipChecker`**: Code-level analysis. Takes a `StabilizerCodeSpec` and checks whether faults of a given weight can cause undetectable logical errors. Works without a circuit. - -- **`PauliPropChecker`**: Circuit-level analysis. Takes a syndrome extraction circuit, then propagates Pauli faults through it to verify fault tolerance of a specific implementation. - -- **`GadgetChecker`**: Gadget-level analysis. Extends `PauliPropChecker` with explicit input/output qubit tracking for analyzing gadgets in composed QEC protocols. Enforces the s + r <= t constraint (input fault weight + internal fault weight). - -`StabilizerFlipChecker` uses the column-indexed anticommutation structure provided by `StabilizerCodeSpec` for efficient syndrome computation. - -## Module Organization - -``` -crates/pecos-qec/src/ - lib.rs -- crate root, re-exports - stabilizer_code.rs -- StabilizerCode (mathematical definition) - stabilizer_code_spec.rs -- StabilizerCodeSpec (operational spec) - logical_discovery.rs -- discover_logicals() via stabilizer simulation - distance.rs -- distance calculation algorithms - geometry.rs -- physical layout types - surface.rs -- surface code geometry - fault_tolerance/ -- fault tolerance analysis - mod.rs - stabilizer_flip_checker.rs - pauli_prop_checker.rs - gadget_checker.rs -- gadget-level fault tolerance (input/output tracking) - dem_builder.rs -- detector error model construction - ... -``` +This document has been moved to `~/Repos/pecos-docs/design/stabilizer-code-architecture.md`. diff --git a/design/circuit-representations.md b/design/circuit-representations.md index a9187ae2b..746dd7601 100644 --- a/design/circuit-representations.md +++ b/design/circuit-representations.md @@ -1,346 +1,3 @@ -# Circuit Representations (Internal) +# Moved to pecos-docs vault - - -This document covers the internal circuit representations used in PECOS's Rust core. For user-facing documentation on building and manipulating circuits, see the [User Guide: Circuit Representation](../user-guide/circuit-representation.md). - -## Overview - -PECOS uses four internal circuit representations, each optimized for different stages of the compilation and simulation pipeline: - -| Representation | Level | Storage | Mutable | Primary Use | -|----------------|-------|---------|---------|-------------| -| `Hugr` | High-level IR | Hierarchical graph | External | Compilation input, interop | -| `SimpleHugr` | Validated wrapper | Pre-processed cache | No | Fast iteration over simple circuits | -| `DagCircuit` | DAG | Nodes + edges | Yes | Optimization, analysis, construction | -| `TickCircuit` | Time-sliced | Vec of ticks | Yes | Hardware scheduling, QEC | - -## Hugr (Higher-order Unified Graph Representation) - -HUGR is a standard intermediate representation developed alongside tket2 and guppylang. It represents the full semantics of hybrid quantum-classical programs. - -### Capabilities - -- **Control flow**: Conditionals, CFG nodes, function calls -- **Loops**: TailLoop nodes for iteration -- **Classical computation**: Arithmetic, logic, data structures -- **Hierarchical structure**: Nested regions, modules -- **Type system**: Linear types for qubits, classical types - -### When HUGR is Used - -1. **Input from Guppy**: Guppy compiles to HUGR bytecode -2. **Interoperability**: Exchange format with tket2 and other tools -3. **Dynamic programs**: Programs with runtime-dependent control flow - -### Limitations for Simulation - -HUGR's generality makes it complex to simulate directly. For simple quantum circuits (no control flow), we convert to `DagCircuit` or wrap in `SimpleHugr` for efficient access. - -### Key Types - -```rust -// From the `hugr` crate (external dependency) -use hugr::{Hugr, HugrView, Node, Wire}; - -// PECOS conversion functions -use pecos_quantum::hugr_convert::{ - hugr_to_dag_circuit, - dag_circuit_to_hugr, - SimpleHugr, -}; -``` - -## SimpleHugr - -A validated wrapper around HUGR that guarantees the circuit is "simple" (no control flow) and provides efficient access through the `Circuit` trait. - -### Validation - -Construction fails if the HUGR contains: -- `Conditional` nodes -- `TailLoop` nodes -- `CFG` nodes -- `Case` nodes - -```rust -use pecos_quantum::hugr_convert::{SimpleHugr, NotSimpleError}; - -match SimpleHugr::try_new(hugr) { - Ok(simple) => { - // Safe to iterate efficiently - for gate in simple.iter_gates_topo() { - // ... - } - } - Err(NotSimpleError::ContainsConditional) => { - // Fall back to full HUGR execution - } - // ... -} -``` - -### Pre-computed Structure - -On construction, `SimpleHugr` caches: -- Topological order of gates -- Predecessor/successor relationships -- Qubit-to-gate mappings -- Root and leaf gates -- Circuit depth - -This avoids repeated graph traversals during simulation. - -### When to Use - -- When you receive a HUGR but expect it to be a simple circuit -- When you need `Circuit` trait compatibility without conversion overhead -- For read-only circuit analysis - -## DagCircuit - -The primary internal representation for circuit manipulation. Gates are nodes, qubit wires are labeled edges. - -### Design - -Follows the design of Qiskit's `DAGCircuit` and HUGR's dataflow regions: -- Edges represent qubit wires (not just dependencies) -- Each edge is labeled with the `QubitId` it carries -- Two-qubit gates have two incoming and two outgoing edges - -### Capabilities - -- **Mutable**: Add/remove gates, rewire connections -- **Rich queries**: Predecessors, successors, layers, qubit timelines -- **Attributes**: Metadata on circuit, gates, and wires -- **Builder API**: Fluent methods with auto-wiring - -### Implementation Notes - -```rust -pub struct DagCircuit { - /// The underlying DAG structure (from pecos-num) - dag: DAG, - /// Gates stored by node index - gates: Vec>, - /// Qubit labels for each edge - edge_qubits: BTreeMap, - /// Tracks the most recent gate on each qubit (for builder mode) - qubit_heads: BTreeMap, - /// Last added node (for .meta() calls) - last_node: Option, -} -``` - -The `qubit_heads` map enables the builder API to automatically wire consecutive gates on the same qubit. - -## TickCircuit - -A time-sliced representation where each "tick" contains gates that execute in parallel. - -### Design - -```rust -pub struct TickCircuit { - ticks: Vec, - next_tick: usize, - circuit_attrs: BTreeMap, -} - -pub struct Tick { - gates: Vec, - gate_attrs: BTreeMap>, - attrs: BTreeMap, // Tick-level metadata -} -``` - -### Qubit Conflict Detection - -Each tick enforces that no qubit is used by multiple gates: - -```rust -impl Tick { - pub fn try_add_gate(&mut self, gate: Gate) -> Result { - let conflicts = self.find_conflicts(&gate.qubits); - if !conflicts.is_empty() { - return Err(QubitConflictError { conflicting_qubits: conflicts, tick_idx: None }); - } - Ok(self.add_gate(gate)) - } -} -``` - -### Use Cases - -- **QEC syndrome extraction**: Each tick is a round -- **Hardware scheduling**: Maps to clocked execution -- **Timing metadata**: Attach round numbers, durations to ticks - -## The Circuit Trait - -Both `DagCircuit` and `SimpleHugr` implement the `Circuit` trait, enabling generic algorithms: - -```rust -pub trait Circuit { - // Basic properties - fn gate_count(&self) -> usize; - fn wire_count(&self) -> usize; - fn qubits(&self) -> Vec; - fn depth(&self) -> usize; - - // Gate access - fn gate(&self, index: GateHandle) -> Option<&Gate>; - fn iter_gates(&self) -> Box> + '_>; - fn iter_gates_topo(&self) -> Box> + '_>; - - // Graph structure - fn predecessors(&self, gate: GateHandle) -> Vec; - fn successors(&self, gate: GateHandle) -> Vec; - fn roots(&self) -> Vec; - fn leaves(&self) -> Vec; - - // Qubit queries - fn gates_on_qubit(&self, qubit: QubitId) -> Vec; - fn qubit_timeline(&self, qubit: QubitId) -> Vec; - - // Attributes - fn circuit_attrs(&self) -> &BTreeMap; - fn gate_attrs(&self, gate: GateHandle) -> Option<&BTreeMap>; -} -``` - -### CircuitMut Trait - -For mutable operations (only `DagCircuit` implements this): - -```rust -pub trait CircuitMut: Circuit { - fn add_gate(&mut self, gate: Gate) -> GateHandle; - fn remove_gate(&mut self, gate: GateHandle) -> Option; - fn set_circuit_attr(&mut self, key: impl Into, value: Attribute); - fn set_gate_attr(&mut self, gate: GateHandle, key: impl Into, value: Attribute) -> bool; -} -``` - -## Conversions - -### Conversion Graph - -``` - hugr_to_dag_circuit() - Hugr ─────────────────────> DagCircuit <────> TickCircuit - │ ^ │ - │ try_new() │ │ - v │ │ - SimpleHugr ────────────────────────+ │ - (implements Circuit) │ - │ - From/Into traits ──────────+ -``` - -### HUGR <-> DagCircuit - -```rust -// HUGR to DagCircuit -let dag = hugr_to_dag_circuit(&hugr)?; - -// DagCircuit to HUGR -let hugr = dag_circuit_to_hugr(&dag)?; -``` - -**HUGR -> DagCircuit algorithm:** -1. Extract quantum operations from tket.quantum extension -2. Process in topological order (QAlloc nodes first) -3. Track qubit identity through wire connections -4. Build edges based on qubit flow - -**DagCircuit -> HUGR algorithm:** -1. Create DFG builder with qubit type signature -2. Process gates in topological order -3. Track wire mappings for each qubit -4. Handle rotation gates specially (add ConstRotation inputs) - -### DagCircuit <-> TickCircuit - -```rust -// DagCircuit to TickCircuit (layers become ticks) -let tick_circuit = TickCircuit::from(&dag_circuit); - -// TickCircuit to DagCircuit (auto-wire by qubit) -let dag_circuit = DagCircuit::from(&tick_circuit); -``` - -**DagCircuit -> TickCircuit:** -- Each layer of parallel gates becomes a tick -- Gate attributes are preserved -- Tick-level attributes stored with `tick[N].key` prefix in DAG - -**TickCircuit -> DagCircuit:** -- Gates added in tick order -- Consecutive gates on same qubit are wired -- Tick attributes restored from prefixed keys - -### HUGR -> SimpleHugr - -```rust -let simple = SimpleHugr::try_new(hugr)?; - -// Access underlying HUGR if needed -let hugr_ref = simple.as_hugr(); -let hugr_owned = simple.into_hugr(); -``` - -## Performance Considerations - -### When to Convert - -| Scenario | Recommendation | -|----------|----------------| -| Single pass over gates | Use `SimpleHugr` (avoids conversion) | -| Multiple optimization passes | Convert to `DagCircuit` once | -| Need to modify circuit | Must use `DagCircuit` | -| Hardware scheduling | Convert to `TickCircuit` | -| Interop with tket | Keep as `Hugr` | - -### Conversion Costs - -- **HUGR -> DagCircuit**: O(n) where n = nodes, requires graph traversal -- **DagCircuit -> TickCircuit**: O(n + d) where d = depth (layer computation) -- **HUGR -> SimpleHugr**: O(n) validation + structure caching - -### Memory - -- `DagCircuit`: ~3 allocations per gate (node, gate storage, edge labels) -- `TickCircuit`: 1 Vec per tick + 1 Vec per gate -- `SimpleHugr`: Original HUGR + cached vectors - -## Adding New Circuit Types - -To add a new circuit representation: - -1. **Implement `Circuit` trait** for read-only access -2. **Optionally implement `CircuitMut`** if mutable -3. **Add conversion functions** to/from `DagCircuit` -4. **Consider validation** (like `SimpleHugr::try_new`) - -Example skeleton: - -```rust -pub struct MyCircuit { - // Internal storage -} - -impl Circuit for MyCircuit { - fn gate_count(&self) -> usize { /* ... */ } - fn wire_count(&self) -> usize { /* ... */ } - // ... implement all required methods -} - -impl From<&DagCircuit> for MyCircuit { - fn from(dag: &DagCircuit) -> Self { /* ... */ } -} - -impl From<&MyCircuit> for DagCircuit { - fn from(my: &MyCircuit) -> Self { /* ... */ } -} -``` +This document has been moved to `~/Repos/pecos-docs/design/circuit-representations.md`. diff --git a/design/pecos-cuquantum-plan.md b/design/pecos-cuquantum-plan.md index fa6ed9606..0a15b3003 100644 --- a/design/pecos-cuquantum-plan.md +++ b/design/pecos-cuquantum-plan.md @@ -1,225 +1,3 @@ -# Plan: pecos-cuquantum Crate +# Moved to pecos-docs vault -## Overview - -Create Rust bindings for NVIDIA's [cuQuantum SDK](https://developer.nvidia.com/cuquantum-sdk) to provide CUDA-accelerated quantum simulation in PECOS. - -## cuQuantum SDK Components - -The SDK includes five libraries (as of v25.x): - -| Library | Purpose | PECOS Relevance | -|---------|---------|-----------------| -| **cuStateVec** | State vector simulation | Alternative to `GpuStateVec` (wgpu) | -| **cuStabilizer** | Stabilizer/Clifford simulation | Alternative to `GpuStab`/`GpuStabMulti` | -| **cuTensorNet** | Tensor network contraction | Advanced simulation methods | -| **cuDensityMat** | Density matrix simulation | Noise modeling | -| **cuPauliProp** | Pauli propagation | Efficient Pauli tracking | - -**Priority**: cuStateVec and cuStabilizer are most relevant initially. - -## Architecture - -``` -pecos-cuquantum-sys/ # Raw FFI bindings (generated by bindgen) - src/ - lib.rs # bindgen output + manual additions - build.rs # Find/download cuQuantum, run bindgen - -pecos-cuquantum/ # Safe Rust wrapper - src/ - lib.rs - statevec.rs # cuStateVec wrapper - stabilizer.rs # cuStabilizer wrapper - error.rs # Error handling - build.rs # Link configuration -``` - -## Dependency Management - -### Option 1: System Installation (Preferred for users with CUDA) -- Check `CUQUANTUM_ROOT` environment variable -- Check standard paths: `/usr/local/cuquantum`, `/opt/nvidia/cuquantum` -- Check if installed via apt/conda - -### Option 2: Auto-download to ~/.pecos/cuquantum/ -- Download tarball from NVIDIA (requires accepting license) -- Extract to `~/.pecos/cuquantum//` -- Cache for reuse across builds - -### Detection Order (similar to pecos-build cuda.rs) -```rust -pub fn find_cuquantum() -> Option { - // 1. ~/.pecos/cuquantum/ - // 2. CUQUANTUM_ROOT env var - // 3. Standard system paths - // 4. Derive from ldconfig/pkg-config -} -``` - -## C API Bindings - -### Header Files -```c -#include // State vector API -#include // Stabilizer API (if available) -#include // Complex number types -``` - -### Key cuStateVec Functions -```c -// Handle management -custatevecCreate(custatevecHandle_t* handle); -custatevecDestroy(custatevecHandle_t handle); - -// Gate application -custatevecApplyMatrix(handle, sv, dataType, nQubits, matrix, ...); -custatevecApplyMatrixGetWorkspaceSize(...); - -// Measurement -custatevecMeasure(handle, sv, dataType, nQubits, ...); -custatevecSample(handle, sv, dataType, nQubits, ...); - -// Expectation -custatevecComputeExpectation(...); -``` - -### bindgen Configuration -```rust -// build.rs -let bindings = bindgen::Builder::default() - .header("wrapper.h") - .clang_arg(format!("-I{}/include", cuquantum_path)) - .clang_arg(format!("-I{}/include", cuda_path)) - .allowlist_function("custatevec.*") - .allowlist_type("custatevec.*") - .allowlist_var("CUSTATEVEC_.*") - .generate() - .expect("Failed to generate bindings"); -``` - -## Safe Rust API Design - -### CuStateVec -```rust -pub struct CuStateVec { - handle: custatevecHandle_t, - state: DeviceBuffer, - num_qubits: usize, -} - -impl CuStateVec { - pub fn new(num_qubits: usize) -> Result; - - // Gate application - pub fn apply_matrix(&mut self, targets: &[usize], matrix: &[Complex64]) -> Result<(), _>; - pub fn h(&mut self, qubit: usize) -> Result<(), _>; - pub fn cx(&mut self, control: usize, target: usize) -> Result<(), _>; - // ... other gates - - // Measurement - pub fn measure(&mut self, qubit: usize) -> Result; - pub fn sample(&mut self, num_samples: usize) -> Result, _>; -} - -// Implement PECOS traits -impl CliffordGateable for CuStateVec { ... } -impl Measurable for CuStateVec { ... } -``` - -### CuStabilizer (if SDK includes it) -```rust -pub struct CuStabilizer { - // Wrapper for cuStabilizer -} - -impl CliffordGateable for CuStabilizer { ... } -``` - -## Build System Integration - -### pecos.toml Addition -```toml -[dependencies.cuquantum] -version = "25.11.0" -url = "https://developer.download.nvidia.com/..." # If redistributable -sha256 = "..." -description = "NVIDIA cuQuantum SDK" -requires_cuda = true - -[crates.pecos-cuquantum] -dependencies = ["cuquantum"] -requires_cuda = true -optional = true # Don't fail if CUDA unavailable -``` - -### Feature Flags -```toml -# pecos-cuquantum/Cargo.toml -[features] -default = [] -cuda-12 = [] # Link against CUDA 12 -cuda-11 = [] # Link against CUDA 11 -download = [] # Auto-download cuQuantum if not found -``` - -## Implementation Steps - -### Phase 1: Foundation -1. Add cuQuantum detection to `pecos-build/src/cuquantum.rs` -2. Create `pecos-cuquantum-sys` crate with bindgen -3. Verify bindings compile and link - -### Phase 2: Safe Wrapper -4. Create `pecos-cuquantum` crate -5. Implement `CuStateVec` wrapper -6. Add basic gates (H, X, Y, Z, S, T, CX, CZ) -7. Add measurement and sampling - -### Phase 3: PECOS Integration -8. Implement `CliffordGateable` trait -9. Add benchmarks comparing to `GpuStateVec` -10. Add cuStabilizer wrapper if available - -### Phase 4: Polish -11. Error handling improvements -12. Documentation -13. CI testing (requires CUDA runner) - -## Considerations - -### License -- cuQuantum SDK has its own license (not open source) -- Need to handle redistribution carefully -- May need to download at build time rather than bundle - -### CUDA Version Compatibility -- cuQuantum requires specific CUDA versions -- Need to match CUDA toolkit version -- pecos-build already handles CUDA detection - -### Multi-GPU Support -- cuStateVec supports multi-GPU via cuStateVecEx -- Consider exposing this for large simulations - -### Comparison with wgpu Backend -| Aspect | wgpu (GpuStateVec) | cuQuantum | -|--------|-------------------|-----------| -| Portability | Cross-platform | NVIDIA only | -| Performance | Good | Optimized for NVIDIA | -| Dependencies | Minimal | CUDA + cuQuantum | -| Maintenance | In-house | NVIDIA maintained | - -## Open Questions - -1. **cuStabilizer availability**: Is cuStabilizer a separate library or part of cuStateVec? -2. **License for auto-download**: Can we auto-download or must users accept license manually? -3. **Minimum CUDA version**: What CUDA versions should we support? -4. **Testing infrastructure**: Do we have CI runners with NVIDIA GPUs? - -## References - -- [cuQuantum Documentation](https://docs.nvidia.com/cuda/cuquantum/latest/index.html) -- [cuStateVec API Reference](https://docs.nvidia.com/cuda/cuquantum/latest/custatevec/index.html) -- [cuQuantum GitHub Samples](https://github.com/NVIDIA/cuQuantum/tree/main/samples) -- [Getting Started Guide](https://docs.nvidia.com/cuda/cuquantum/latest/getting-started/index.html) +This document has been moved to `~/Repos/pecos-docs/design/pecos-cuquantum-plan.md`. diff --git a/design/proposals/byte_message_api_cleanup.md b/design/proposals/byte_message_api_cleanup.md index a1c69daaf..4725b8e6a 100644 --- a/design/proposals/byte_message_api_cleanup.md +++ b/design/proposals/byte_message_api_cleanup.md @@ -1,36 +1,3 @@ -# ByteMessageBuilder API Cleanup +# Moved to pecos-docs vault -## Changes Made - -### Rust API - -Two-qubit gates now take `&[(usize, usize)]` (slice of pairs) instead of separate slices: - -```rust -// Before: -builder.cx(&[0], &[1]); -builder.rzz(theta, &[0], &[1]); - -// After: -builder.cx(&[(0, 1)]); -builder.rzz(theta, &[(0, 1)]); - -// Batch: -builder.cx(&[(0, 1), (2, 3)]); -``` - -Affected methods: `cx`, `cy`, `cz`, `szz`, `szzdg`, `rzz` - -### Rename: `mz` -> `mz` - -The method name now matches the gate name (MZ). - -### Python API - -Single-qubit gates take lists: `h([0, 1, 2])` -Two-qubit gates take lists of tuples: `cx([(0, 1), (2, 3)])` -Measurements renamed: `mz([0, 1])` - -## Status - -Done. Both Rust and Python APIs updated. +This document has been moved to `~/Repos/pecos-docs/design/proposals/byte_message_api_cleanup.md`. diff --git a/design/proposals/slr-ast.md b/design/proposals/slr-ast.md index 522168a4c..2b1cd8130 100644 --- a/design/proposals/slr-ast.md +++ b/design/proposals/slr-ast.md @@ -1,973 +1,3 @@ -# SLR Abstract Syntax Tree (AST) Proposal +# Moved to pecos-docs vault - - -## Status - -**Draft** - Ready for review - ---- - -## Motivation - -### Current State - -SLR currently uses Python classes directly as both the syntax and the runtime representation: - -```python -prog = Main( - q := QReg("q", 2), - c := CReg("c", 2), - qb.H(q[0]), - qb.CX(q[0], q[1]), - qb.Measure(q) > c, -) -``` - -This approach has drawbacks: - -1. **Mixed concerns**: Representation classes also contain execution logic -2. **Difficult analysis**: No clean separation for static analysis passes -3. **Inconsistent structure**: Different node types have different interfaces -4. **Hard to transform**: Modifying programs requires understanding implementation details -5. **Code generation complexity**: Generators work directly with heterogeneous objects - -### Benefits of a Formal AST - -1. **Clean separation**: Syntax representation separate from semantics -2. **Uniform interface**: All nodes share a common base with predictable structure -3. **Easy traversal**: Visitor pattern for analysis and transformation -4. **Better tooling**: Linting, formatting, refactoring tools -5. **Simpler code gen**: One AST → multiple targets (QASM, Guppy, HUGR, etc.) -6. **Integration with QAlloc**: Clean representation of allocator hierarchy and slot states - ---- - -## Design Principles - -### 1. Immutable Data Structures - -AST nodes should be immutable dataclasses. This enables: -- Safe sharing and caching -- Easy equality comparison -- Predictable behavior in analysis passes - -### 2. Type Safety - -Use Python's type system with generics and protocols: -- All nodes have precise types -- Analysis results are strongly typed -- IDE support for navigation and refactoring - -### 3. Location Tracking - -Every node can optionally track source location for error reporting: -```python -@dataclass(frozen=True) -class SourceLocation: - line: int - column: int - file: str | None = None -``` - -### 4. Visitor Pattern - -Support the visitor pattern for analysis and transformation: -```python -class ASTVisitor(Protocol[T]): - def visit_program(self, node: Program) -> T: ... - def visit_gate(self, node: GateOp) -> T: ... - - # etc. -``` - -### 5. Bidirectional Conversion - -The AST should support: -- Building from current SLR objects (`from_slr()`) -- Converting back for execution (`to_slr()`) -- Direct construction for new code - ---- - -## AST Node Hierarchy - -### Base Types - -```python -from __future__ import annotations -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from enum import Enum, auto -from typing import TypeVar, Generic, Protocol, Sequence - - -@dataclass(frozen=True) -class SourceLocation: - """Source location for error reporting.""" - - line: int - column: int - file: str | None = None - - -@dataclass(frozen=True) -class ASTNode(ABC): - """Base class for all AST nodes.""" - - location: SourceLocation | None = field(default=None, compare=False) - - @abstractmethod - def accept(self, visitor: ASTVisitor[T]) -> T: - """Accept a visitor for traversal.""" - ... - - def children(self) -> Sequence[ASTNode]: - """Return child nodes for traversal.""" - return () -``` - -### Program Structure - -```python -@dataclass(frozen=True) -class Program(ASTNode): - """Root node representing an SLR program.""" - - name: str - allocator: AllocatorDecl | None # Base allocator (required in strict mode) - declarations: tuple[Declaration, ...] - body: tuple[Statement, ...] - returns: tuple[TypeExpr, ...] = () - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_program(self) - - def children(self) -> Sequence[ASTNode]: - nodes = [] - if self.allocator: - nodes.append(self.allocator) - nodes.extend(self.declarations) - nodes.extend(self.body) - return nodes -``` - -### Declarations - -```python -class Declaration(ASTNode, ABC): - """Base for all declarations.""" - - pass - - -@dataclass(frozen=True) -class AllocatorDecl(Declaration): - """Qubit allocator declaration.""" - - name: str - capacity: int - parent: str | None = None # Name of parent allocator - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_allocator_decl(self) - - -@dataclass(frozen=True) -class RegisterDecl(Declaration): - """Classical register declaration.""" - - name: str - size: int - is_result: bool = True - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_register_decl(self) -``` - -### Statements - -```python -class Statement(ASTNode, ABC): - """Base for all statements.""" - - pass - - -@dataclass(frozen=True) -class GateOp(Statement): - """Quantum gate application.""" - - gate: GateKind - targets: tuple[SlotRef, ...] - params: tuple[Expression, ...] = () - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_gate(self) - - def children(self) -> Sequence[ASTNode]: - return (*self.targets, *self.params) - - -@dataclass(frozen=True) -class PrepareOp(Statement): - """Prepare qubit slots (unprepared -> prepared).""" - - allocator: str - slots: tuple[int, ...] | None = None # None means all slots - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_prepare(self) - - -@dataclass(frozen=True) -class MeasureOp(Statement): - """Measure qubit slots.""" - - targets: tuple[SlotRef, ...] - results: tuple[BitRef, ...] = () - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_measure(self) - - def children(self) -> Sequence[ASTNode]: - return (*self.targets, *self.results) - - -@dataclass(frozen=True) -class AssignOp(Statement): - """Classical assignment.""" - - target: BitRef | str # Variable or bit reference - value: Expression - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_assign(self) - - def children(self) -> Sequence[ASTNode]: - nodes = [self.value] - if isinstance(self.target, ASTNode): - nodes.insert(0, self.target) - return nodes - - -@dataclass(frozen=True) -class BarrierOp(Statement): - """Synchronization barrier.""" - - allocators: tuple[str, ...] = () - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_barrier(self) - - -@dataclass(frozen=True) -class CommentOp(Statement): - """Comment in generated code.""" - - text: str - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_comment(self) - - -@dataclass(frozen=True) -class ReturnOp(Statement): - """Return statement.""" - - values: tuple[Expression, ...] - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_return(self) - - def children(self) -> Sequence[ASTNode]: - return self.values -``` - -### Control Flow - -```python -@dataclass(frozen=True) -class IfStmt(Statement): - """Conditional execution.""" - - condition: Expression - then_body: tuple[Statement, ...] - else_body: tuple[Statement, ...] = () - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_if(self) - - def children(self) -> Sequence[ASTNode]: - return (self.condition, *self.then_body, *self.else_body) - - -@dataclass(frozen=True) -class WhileStmt(Statement): - """While loop.""" - - condition: Expression - body: tuple[Statement, ...] - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_while(self) - - def children(self) -> Sequence[ASTNode]: - return (self.condition, *self.body) - - -@dataclass(frozen=True) -class ForStmt(Statement): - """For loop with iteration variable.""" - - variable: str - start: Expression - stop: Expression - step: Expression | None = None - body: tuple[Statement, ...] - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_for(self) - - def children(self) -> Sequence[ASTNode]: - nodes = [self.start, self.stop] - if self.step: - nodes.append(self.step) - nodes.extend(self.body) - return nodes - - -@dataclass(frozen=True) -class RepeatStmt(Statement): - """Repeat N times.""" - - count: int - body: tuple[Statement, ...] - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_repeat(self) - - def children(self) -> Sequence[ASTNode]: - return self.body - - -@dataclass(frozen=True) -class ParallelBlock(Statement): - """Parallel execution hint.""" - - body: tuple[Statement, ...] - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_parallel(self) - - def children(self) -> Sequence[ASTNode]: - return self.body -``` - -### References - -```python -@dataclass(frozen=True) -class SlotRef(ASTNode): - """Reference to a qubit slot in an allocator.""" - - allocator: str - index: int - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_slot_ref(self) - - def __str__(self) -> str: - return f"{self.allocator}[{self.index}]" - - -@dataclass(frozen=True) -class BitRef(ASTNode): - """Reference to a classical bit in a register.""" - - register: str - index: int - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_bit_ref(self) - - def __str__(self) -> str: - return f"{self.register}[{self.index}]" -``` - -### Expressions - -```python -class Expression(ASTNode, ABC): - """Base for all expressions.""" - - pass - - -@dataclass(frozen=True) -class LiteralExpr(Expression): - """Literal value (int, float, bool).""" - - value: int | float | bool - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_literal(self) - - -@dataclass(frozen=True) -class VarExpr(Expression): - """Variable reference.""" - - name: str - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_var(self) - - -@dataclass(frozen=True) -class BitExpr(Expression): - """Bit reference as expression (for conditions).""" - - ref: BitRef - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_bit_expr(self) - - def children(self) -> Sequence[ASTNode]: - return (self.ref,) - - -@dataclass(frozen=True) -class BinaryExpr(Expression): - """Binary operation.""" - - op: BinaryOp - left: Expression - right: Expression - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_binary(self) - - def children(self) -> Sequence[ASTNode]: - return (self.left, self.right) - - -@dataclass(frozen=True) -class UnaryExpr(Expression): - """Unary operation.""" - - op: UnaryOp - operand: Expression - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_unary(self) - - def children(self) -> Sequence[ASTNode]: - return (self.operand,) - - -class BinaryOp(Enum): - """Binary operators.""" - - # Arithmetic - ADD = auto() - SUB = auto() - MUL = auto() - DIV = auto() - # Comparison - EQ = auto() - NE = auto() - LT = auto() - LE = auto() - GT = auto() - GE = auto() - # Logical - AND = auto() - OR = auto() - XOR = auto() - # Bitwise - LSHIFT = auto() - RSHIFT = auto() - - -class UnaryOp(Enum): - """Unary operators.""" - - NOT = auto() - NEG = auto() -``` - -### Gate Kinds - -```python -class GateKind(Enum): - """All supported gate types.""" - - # Single-qubit Paulis - X = auto() - Y = auto() - Z = auto() - # Hadamard - H = auto() - # Phase gates - S = auto() - Sdg = auto() - T = auto() - Tdg = auto() - # Square root gates - SX = auto() - SY = auto() - SZ = auto() - SXdg = auto() - SYdg = auto() - SZdg = auto() - # Rotation gates (parameterized) - RX = auto() - RY = auto() - RZ = auto() - # Two-qubit gates - CX = auto() - CY = auto() - CZ = auto() - CH = auto() - # Two-qubit rotations - SXX = auto() - SYY = auto() - SZZ = auto() - SXXdg = auto() - SYYdg = auto() - SZZdg = auto() - RZZ = auto() - # Face rotations - F = auto() - Fdg = auto() - F4 = auto() - F4dg = auto() - - @property - def arity(self) -> int: - """Number of qubit arguments.""" - two_qubit = { - GateKind.CX, - GateKind.CY, - GateKind.CZ, - GateKind.CH, - GateKind.SXX, - GateKind.SYY, - GateKind.SZZ, - GateKind.SXXdg, - GateKind.SYYdg, - GateKind.SZZdg, - GateKind.RZZ, - } - return 2 if self in two_qubit else 1 - - @property - def is_parameterized(self) -> bool: - """Whether this gate takes angle parameters.""" - return self in {GateKind.RX, GateKind.RY, GateKind.RZ, GateKind.RZZ} -``` - -### Type Expressions - -```python -@dataclass(frozen=True) -class TypeExpr(ASTNode): - """Type expression for return types and declarations.""" - - pass - - -@dataclass(frozen=True) -class QubitType(TypeExpr): - """Single qubit type.""" - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_qubit_type(self) - - -@dataclass(frozen=True) -class BitType(TypeExpr): - """Single classical bit type.""" - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_bit_type(self) - - -@dataclass(frozen=True) -class ArrayType(TypeExpr): - """Array type with element type and size.""" - - element: TypeExpr - size: int - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_array_type(self) - - def children(self) -> Sequence[ASTNode]: - return (self.element,) - - -@dataclass(frozen=True) -class AllocatorType(TypeExpr): - """Qubit allocator type with capacity.""" - - capacity: int - - def accept(self, visitor: ASTVisitor[T]) -> T: - return visitor.visit_allocator_type(self) -``` - ---- - -## Visitor Protocol - -```python -from typing import TypeVar, Protocol - -T = TypeVar("T") - - -class ASTVisitor(Protocol[T]): - """Protocol for AST visitors.""" - - # Program structure - def visit_program(self, node: Program) -> T: ... - def visit_allocator_decl(self, node: AllocatorDecl) -> T: ... - def visit_register_decl(self, node: RegisterDecl) -> T: ... - - # Statements - def visit_gate(self, node: GateOp) -> T: ... - def visit_prepare(self, node: PrepareOp) -> T: ... - def visit_measure(self, node: MeasureOp) -> T: ... - def visit_assign(self, node: AssignOp) -> T: ... - def visit_barrier(self, node: BarrierOp) -> T: ... - def visit_comment(self, node: CommentOp) -> T: ... - def visit_return(self, node: ReturnOp) -> T: ... - - # Control flow - def visit_if(self, node: IfStmt) -> T: ... - def visit_while(self, node: WhileStmt) -> T: ... - def visit_for(self, node: ForStmt) -> T: ... - def visit_repeat(self, node: RepeatStmt) -> T: ... - def visit_parallel(self, node: ParallelBlock) -> T: ... - - # References - def visit_slot_ref(self, node: SlotRef) -> T: ... - def visit_bit_ref(self, node: BitRef) -> T: ... - - # Expressions - def visit_literal(self, node: LiteralExpr) -> T: ... - def visit_var(self, node: VarExpr) -> T: ... - def visit_bit_expr(self, node: BitExpr) -> T: ... - def visit_binary(self, node: BinaryExpr) -> T: ... - def visit_unary(self, node: UnaryExpr) -> T: ... - - # Types - def visit_qubit_type(self, node: QubitType) -> T: ... - def visit_bit_type(self, node: BitType) -> T: ... - def visit_array_type(self, node: ArrayType) -> T: ... - def visit_allocator_type(self, node: AllocatorType) -> T: ... - - -class BaseVisitor(Generic[T]): - """Base visitor with default traversal behavior.""" - - def visit(self, node: ASTNode) -> T: - """Dispatch to appropriate visit method.""" - return node.accept(self) - - def visit_children(self, node: ASTNode) -> list[T]: - """Visit all children and collect results.""" - return [self.visit(child) for child in node.children()] - - # Default implementations that just visit children - def visit_program(self, node: Program) -> T: - self.visit_children(node) - return self.default_result() - - # ... etc for all node types ... - - def default_result(self) -> T: - """Default result when no specific handling.""" - return None # type: ignore -``` - ---- - -## Example: AST for QEC Program - -```python -# The SLR code: -# def main(): -# base = QAlloc(17) -# data = base.child(9) -# ancilla = base.child(8) -# data.prepare_all() -# ancilla.prepare_all() -# H(data[0]) -# CX(data[0], data[1]) -# Measure(ancilla) > syndrome - -# As AST: -program = Program( - name="main", - allocator=AllocatorDecl(name="base", capacity=17), - declarations=( - AllocatorDecl(name="data", capacity=9, parent="base"), - AllocatorDecl(name="ancilla", capacity=8, parent="base"), - RegisterDecl(name="syndrome", size=8), - ), - body=( - PrepareOp(allocator="data"), - PrepareOp(allocator="ancilla"), - GateOp( - gate=GateKind.H, - targets=(SlotRef("data", 0),), - ), - GateOp( - gate=GateKind.CX, - targets=(SlotRef("data", 0), SlotRef("data", 1)), - ), - MeasureOp( - targets=tuple(SlotRef("ancilla", i) for i in range(8)), - results=tuple(BitRef("syndrome", i) for i in range(8)), - ), - ), -) -``` - ---- - -## Integration with QAlloc - -The AST is designed to work seamlessly with the QAlloc system: - -### Allocator Hierarchy in AST - -```python -# Parent-child relationships are explicit -AllocatorDecl(name="base", capacity=100) -AllocatorDecl(name="data", capacity=7, parent="base") -AllocatorDecl(name="ancilla", capacity=6, parent="base") -``` - -### Slot State Validation - -The `QubitStateValidator` can work directly on the AST: - -```python -class ASTStateValidator(BaseVisitor[None]): - """Validate qubit states on AST.""" - - def __init__(self): - self.slot_states: dict[tuple[str, int], SlotState] = {} - self.violations: list[StateViolation] = [] - - def visit_prepare(self, node: PrepareOp) -> None: - # Mark slots as prepared - if node.slots is None: - # prepare_all - need allocator capacity - ... - else: - for slot in node.slots: - self.slot_states[(node.allocator, slot)] = SlotState.PREPARED - - def visit_gate(self, node: GateOp) -> None: - # Validate all targets are prepared - for target in node.targets: - state = self.slot_states.get((target.allocator, target.index), SlotState.UNPREPARED) - if state == SlotState.UNPREPARED: - self.violations.append(StateViolation(...)) - - def visit_measure(self, node: MeasureOp) -> None: - # Mark slots as unprepared - for target in node.targets: - self.slot_states[(target.allocator, target.index)] = SlotState.UNPREPARED -``` - ---- - -## Conversion Functions - -### From Current SLR to AST - -```python -def slr_to_ast(block: SLRBlock) -> Program: - """Convert current SLR block to AST.""" - converter = SLRToASTConverter() - return converter.convert(block) - - -class SLRToASTConverter: - """Converts SLR objects to AST nodes.""" - - def convert(self, block: SLRBlock) -> Program: - declarations = [] - body = [] - - # Convert variables - for var in block.vars: - declarations.append(self.convert_var(var)) - - # Convert operations - for op in block.ops: - body.append(self.convert_op(op)) - - return Program( - name=getattr(block, "block_name", "main"), - allocator=None, # Legacy mode - no base allocator - declarations=tuple(declarations), - body=tuple(body), - ) - - def convert_var(self, var) -> Declaration: - if isinstance(var, QReg): - # Convert to legacy allocator-like declaration - return AllocatorDecl(name=var.sym, capacity=var.size) - elif isinstance(var, CReg): - return RegisterDecl(name=var.sym, size=var.size, is_result=var.result) - elif isinstance(var, QAlloc): - return AllocatorDecl( - name=var.name, - capacity=var.capacity, - parent=var.parent.name if var.parent else None, - ) - # ... etc - - def convert_op(self, op) -> Statement: - op_name = type(op).__name__ - - if op_name in GATE_NAMES: - return self.convert_gate(op) - elif op_name == "Measure": - return self.convert_measure(op) - elif op_name in ("Prep", "Init", "Reset"): - return self.convert_prepare(op) - elif op_name == "If": - return self.convert_if(op) - # ... etc -``` - -### From AST to Current SLR - -```python -def ast_to_slr(program: Program) -> SLRBlock: - """Convert AST to current SLR objects for execution.""" - converter = ASTToSLRConverter() - return converter.convert(program) -``` - ---- - -## Code Generation from AST - -Each target gets a visitor: - -```python -class QASMGenerator(BaseVisitor[str]): - """Generate QASM from AST.""" - - def visit_program(self, node: Program) -> str: - lines = ["OPENQASM 2.0;", 'include "qelib1.inc";', ""] - - # Declarations - for decl in node.declarations: - lines.append(self.visit(decl)) - - lines.append("") - - # Body - for stmt in node.body: - lines.append(self.visit(stmt)) - - return "\n".join(lines) - - def visit_allocator_decl(self, node: AllocatorDecl) -> str: - return f"qreg {node.name}[{node.capacity}];" - - def visit_register_decl(self, node: RegisterDecl) -> str: - return f"creg {node.name}[{node.size}];" - - def visit_gate(self, node: GateOp) -> str: - gate_name = node.gate.name.lower() - targets = ", ".join(str(t) for t in node.targets) - if node.params: - params = ", ".join(str(p) for p in node.params) - return f"{gate_name}({params}) {targets};" - return f"{gate_name} {targets};" - - # ... etc - - -class GuppyGenerator(BaseVisitor[str]): - """Generate Guppy code from AST.""" - - # Similar structure, different output format - - -class HUGRGenerator(BaseVisitor[HUGRNode]): - """Generate HUGR from AST.""" - - # Returns HUGR IR nodes instead of strings -``` - ---- - -## Implementation Plan - -### Phase 1: Core AST Nodes - -1. Define all node dataclasses in `pecos/slr/ast/nodes.py` -2. Define visitor protocol in `pecos/slr/ast/visitor.py` -3. Implement `BaseVisitor` with default traversal - -### Phase 2: Conversion - -1. Implement `SLRToASTConverter` for current SLR → AST -2. Implement `ASTToSLRConverter` for AST → current SLR -3. Add tests for round-trip conversion - -### Phase 3: Analysis - -1. Port `QubitStateValidator` to work on AST -2. Add more analysis passes (unused variables, unreachable code, etc.) -3. Add pretty-printer for debugging - -### Phase 4: Code Generation - -1. Migrate QASM generator to use AST -2. Migrate Guppy generator to use AST -3. Add new generators as needed - -### Phase 5: DSL Integration - -1. Consider new DSL syntax that builds AST directly -2. Add builder pattern for programmatic AST construction -3. Integration with IDE tooling - ---- - -## Open Questions - -1. **Span tracking**: Should we track full spans (start + end) or just start locations? - -2. **Error recovery**: Should AST support partial/invalid trees for better error reporting? - -3. **Macro expansion**: How to handle QEC library blocks (Steane, etc.) that expand to multiple operations? - -4. **Metadata**: What additional metadata should nodes carry (e.g., optimization hints)? - -5. **Serialization**: Should AST be serializable to JSON/protobuf for tooling? - ---- - -## Summary - -The SLR AST provides: - -- **Clean structure**: Immutable, typed nodes with uniform interface -- **Easy analysis**: Visitor pattern for traversal and transformation -- **QAlloc integration**: First-class support for allocator hierarchy and slot states -- **Multi-target codegen**: One AST → QASM, Guppy, HUGR, etc. -- **Backward compatibility**: Conversion to/from current SLR objects +This document has been moved to `~/Repos/pecos-docs/design/proposals/slr-ast.md`. diff --git a/design/proposals/slr-qubit-allocators.md b/design/proposals/slr-qubit-allocators.md index 0923e05f7..771ec8d25 100644 --- a/design/proposals/slr-qubit-allocators.md +++ b/design/proposals/slr-qubit-allocators.md @@ -1,730 +1,3 @@ -# SLR Qubit Allocator Proposal +# Moved to pecos-docs vault - - -## Status - -**Design decisions finalized.** Ready for implementation. - ---- - -## Motivation - -SLR needs to bridge two different models of qubit management: - -1. **QASM model**: Static registers declared upfront (`qreg q[5];`), all qubits exist from program start -2. **Guppy model**: Dynamic allocation with linear types (`q = qubit()`), qubits appear on demand - -Both have drawbacks: -- QASM's static registers lack ownership semantics and lifecycle tracking -- Guppy's dynamic allocation feels disconnected from physical hardware constraints ("qubits from nowhere") - -### The Allocator Model - -Inspired by Zig's allocator pattern and NASA's Power of 10 rules (particularly Rule 3: no dynamic allocation after initialization), we propose a hierarchical qubit allocator model that: -- Grounds allocation in physical resource constraints (total qubit budget declared upfront) -- Provides explicit ownership and natural scoping -- Tracks qubit slot states (unprepared vs prepared) -- Maintains array/register-oriented access patterns (effectively implementing QRegs) -- Enables compiler optimizations through abstracted physical identity - -### NASA Power of 10 Alignment - -| Power of 10 Rule | Allocator Model | -|------------------|-----------------| -| Rule 3: No dynamic allocation after init | Base allocator declares total capacity in `main` | -| Rule 6: Smallest possible scope | Child allocators scoped to functions/blocks | -| Rule 2: Fixed loop bounds | Allocator capacity is bounded and known | -| Predictability | All resource usage visible from `main` | - ---- - -## Core Concepts - -### 1. Base Allocator - -Every program declares a base allocator in `main` representing the total physical qubit capacity: - -```python -def main(): - base = QAlloc(capacity=100) # "I have 100 physical qubits" - - # All other allocators derive from this - data = base.child(7) - ancilla = base.child(6) - - run_qec(data, ancilla) -``` - -This is the root of all qubit ownership. It represents the actual hardware constraint. Functions that need qubits receive allocators (or children thereof) as parameters. - -### 2. Child Allocators (Hierarchical Ownership) - -Any allocator can create child allocators that reserve slots from its capacity: - -```python -base = QAlloc(100) - -# First level partitioning -data = base.child(7) # Reserve 7 slots for data -ancilla = base.child(6) # Reserve 6 slots for ancilla -# base now has 87 available - -# Nested partitioning - any allocator can have children -workspace = ancilla.child(2) # Borrow 2 from ancilla -# ancilla now has 4 available -``` - -**Key properties:** -- Child allocators exclusively reserve slots from their parent -- Parent cannot use reserved slots until child releases them -- Children can create their own children (unlimited depth) -- Natural scoping: unreturned allocators automatically release to parent - -### 3. Slots and Qubit Association - -An allocator has N **slots**. Each slot is in one of two states: - -``` -┌─────────────┐ -│ unprepared │ ← Not ready for gates (initial state, or after measurement) -└──────┬──────┘ - │ prepare() ← Request qubit, associate with slot, initialize to |0⟩ - ▼ -┌─────────────┐ -│ prepared │ ← Ready for gates -└──────┬──────┘ - │ measure() - ▼ -┌─────────────┐ -│ unprepared │ ← Back to unprepared -└─────────────┘ -``` - -Two states, not three. Whether a slot has "never been used" or "was measured" doesn't matter - both are **unprepared** and require `prepare()` before gates. - -**Key insight**: `prepare()` means "request a qubit to be associated with this slot and prepared for use." The slot becomes usable. After measurement, the slot returns to unprepared. - -**Rules (enforced at compile time):** -- Gates can only be applied to **prepared** slots → compile error if unprepared -- Measurement transitions slots to **unprepared** -- Slots can be prepared individually or in batches at different times - -### 4. Slot-Based Access (Not Physical Identity) - -Qubits are accessed through their allocator via slot indices: - -```python -ancilla = base.child(4) -ancilla.prepare(0, 1) # Prepare slots 0 and 1 - -H(ancilla[0]) # Apply H to slot 0 -CNOT(ancilla[0], ancilla[1]) # CNOT between slots 0 and 1 -``` - -**Important**: `ancilla[0]` refers to "slot 0 in the ancilla allocator" - not a fixed physical qubit. After measure + prepare cycles, the physical qubit backing slot 0 may change. The compiler manages the mapping. - -```python -ancilla.prepare_all() -# ancilla[0] → physical qubit 42 - -Measure(ancilla) -ancilla.prepare_all() -# ancilla[0] → might now be physical qubit 37 (compiler's choice) -``` - -This abstraction enables: -- Qubit recycling and reuse optimizations -- Routing around defective qubits -- Connectivity-aware mapping - -The programmer thinks in logical slots; the compiler handles physical mapping. - -### 5. Ownership and Natural Scoping - -Allocators follow ownership rules similar to Zig: - -```python -def syndrome_round(ancilla: QAlloc[6]) -> Bits: - ancilla.prepare_all() - # ... syndrome extraction ... - return Measure(ancilla) - # ancilla NOT returned → consumed → released to parent - - -def apply_logical_gate(data: QAlloc[7]) -> QAlloc[7]: - # ... apply gate ... - return data # returned → caller retains ownership -``` - -**Scoping rules:** -- If an allocator is not returned from a function/block, it is automatically released -- Released resources flow back to the parent allocator -- No explicit `free()` or `release()` needed - scope handles it - ---- - -## API Design - -### QAlloc Class - -```python -class QAlloc[N]: - """ - A qubit allocator managing N qubit slots. - - Type parameter N is the capacity (known at compile time for type checking, - but can also be runtime-determined). - """ - - # --- Creation --- - - def __init__(self, capacity: int): - """Create a base allocator with given capacity.""" - ... - - def child(self, size: int) -> QAlloc: - """ - Create a child allocator with `size` slots. - - Reserves `size` qubits from this allocator's available pool. - Raises if insufficient capacity available. - """ - ... - - # --- Lifecycle Operations --- - - def prepare(self, *indices: int) -> None: - """Prepare specific slots (unprepared → prepared).""" - ... - - def prepare_all(self) -> None: - """Prepare all slots in this allocator.""" - ... - - # --- Access --- - - def __getitem__(self, index: int) -> QubitRef: - """ - Access slot `index` for use in gates. - - Returns a QubitRef that can be passed to gate operations. - The qubit must be in 'prepared' state. - """ - ... - - # --- Information --- - - @property - def capacity(self) -> int: - """Total number of slots in this allocator.""" - ... - - @property - def available(self) -> int: - """Number of slots not reserved by children.""" - ... - - def state(self, index: int) -> SlotState: - """Get the state of a specific slot (unprepared or prepared).""" - ... -``` - -### QubitRef (Reference to a Slot) - -```python -class QubitRef: - """ - A reference to a qubit slot in an allocator. - - Used as arguments to gate operations. Not a standalone qubit - - always tied to its parent allocator. - """ - - allocator: QAlloc - index: int -``` - -### SlotState Enum - -```python -class SlotState(Enum): - UNPREPARED = "unprepared" # Not ready for gates (initial or post-measurement) - PREPARED = "prepared" # Ready for gate operations -``` - -Two states only. Simple. - -### Gate Operations - -Gates accept `QubitRef` arguments: - -```python -# Single qubit gates -H(alloc[0]) -X(alloc[1]) -Rz(alloc[2], angle=0.5) - -# Two qubit gates -CNOT(alloc[0], alloc[1]) -CZ(data[0], ancilla[0]) # Can span different allocators - -# Measurement (transitions to unprepared) -result = Measure(alloc[0]) # Single qubit -results = Measure(alloc) # All qubits in allocator -results = Measure(alloc[0:3]) # Slice of allocator -``` - ---- - -## Edge Cases and Considerations - -### 1. Cross-Allocator Entanglement - -**Scenario**: Qubits from different allocators become entangled. - -```python -data = base.child(7) -ancilla = base.child(6) - -data.prepare_all() -ancilla.prepare_all() - -# Entangle across allocators -CNOT(data[0], ancilla[0]) -``` - -**Decision**: Allowed. Allocators manage ownership and slot lifecycle, not entanglement tracking. If `ancilla` is released while entangled with `data`, the slots return to the parent (unprepared). No compiler warning needed - we're not tracking entanglement. - -### 2. Partial Measurement - -**Scenario**: Only some slots in an allocator are measured. - -```python -ancilla = base.child(4) -ancilla.prepare_all() - -# Measure only slots 0 and 1 -result = Measure(ancilla[0], ancilla[1]) - -# ancilla[0], ancilla[1] are now unprepared -# ancilla[2], ancilla[3] are still prepared -``` - -**Resolution**: The allocator tracks per-slot state. This is fully supported. - -### 3. Capacity Exhaustion - -**Scenario**: Requesting more qubits than available. - -```python -base = QAlloc(10) -a = base.child(6) -b = base.child(6) # ERROR: only 4 available -``` - -**Resolution**: -- **Compile-time**: If sizes are known statically, this is a compile error -- **Runtime**: Raises an exception (e.g., `AllocationError`) - -The type system can help: `QAlloc[N]` carries capacity information. - -### 4. Returning Partial Allocators - -**Scenario**: Function receives an allocator, creates children, returns some. - -```python -def split_and_process(alloc: QAlloc[10]) -> QAlloc[3]: - a = alloc.child(3) - b = alloc.child(7) - - process(b) # b consumed (not returned) - - return a # Only a returned - COMPILE ERROR -``` - -**Decision**: Must return parent OR all children. Enforced at compile time. - -If you create children from a received allocator, you must either: -1. Return the parent allocator (children are released back to it) -2. Return ALL children (parent is consumed, all resources accounted for) - -This ensures clear resource contracts and prevents orphaned slots. - -```python -# Valid: return parent -def process_and_return(alloc: QAlloc[10]) -> QAlloc[10]: - child = alloc.child(5) - use(child) # child released back to alloc - return alloc - - -# Valid: return all children -def split_evenly(alloc: QAlloc[10]) -> tuple[QAlloc[5], QAlloc[5]]: - a = alloc.child(5) - b = alloc.child(5) - return a, b # alloc consumed, all slots accounted for -``` - -### 5. Conditional Allocation - -**Scenario**: Allocation inside conditional blocks. - -```python -base = QAlloc(10) - -if condition: - extra = base.child(5) - extra.prepare_all() - # ... use extra ... - # extra released at end of block -else: - pass # no allocation -``` - -**Resolution**: This is fine. The allocation is scoped to the if-block. After the block, resources are back in `base`. Both branches end with `base` having the same available capacity. - -### 6. Allocation in Loops - -**Scenario**: Creating allocators inside loops. - -```python -base = QAlloc(10) - -for round in range(1000): - ancilla = base.child(4) - ancilla.prepare_all() - syndrome = Measure(ancilla) - # ancilla released, back to base - - # Next iteration can allocate again -``` - -**Resolution**: This is a primary use case. Each iteration allocates, uses, and releases. The pool is recycled. - -### 7. Escaping References (Zig-inspired) - -**Scenario**: Storing a `QubitRef` beyond the allocator's lifetime. - -```python -stored_ref = None - - -def bad_function(alloc: QAlloc[5]): - global stored_ref - alloc.prepare_all() - stored_ref = alloc[0] # Store reference - # alloc released at end - - -bad_function(base.child(5)) -H(stored_ref) # ERROR: dangling reference -``` - -**Decision**: `QubitRef` is ephemeral, like Zig slices/pointers into allocator memory. - -In Zig, when you get memory from an allocator, you get a slice that's valid only while the allocator owns that memory. Similarly, `QubitRef` is valid only while its allocator is alive. - -- `alloc[i]` creates a `QubitRef` for immediate use in gate operations -- `QubitRef` should not be stored in data structures or globals -- The compile-time analysis detects when a `QubitRef` escapes its allocator's scope -- If used after allocator release: compile error (if detectable) or runtime error - -### 8. Allocator Merging - -**Scenario**: Combining two sibling allocators. - -```python -base = QAlloc(10) -a = base.child(3) -b = base.child(3) - -# Can we merge a and b into a single allocator of 6? -merged = merge(a, b) # ??? -``` - -**Resolution**: Not supported in initial design. If you need a combined view: -- Release both back to parent -- Allocate a new child of desired size - -Merging adds complexity (different qubit states, index remapping). YAGNI for now. - -### 9. Slicing Allocators - -**Scenario**: Creating a view into part of an allocator. - -```python -data = base.child(7) -first_three = data[0:3] # Is this a new allocator or just refs? -``` - -**Resolution**: Two options: - -**Option A**: Slicing returns a tuple of `QubitRef` -```python -first_three = (data[0], data[1], data[2]) # Just refs -``` - -**Option B**: Slicing creates a child allocator (view) -```python -first_three = data.slice(0, 3) # New child allocator -``` - -**Recommendation**: Option A for simplicity. Slicing is just syntactic sugar for multiple refs. Use explicit `child()` for ownership transfer. - -### 10. Classical Data - Do We Need Allocators? - -**Decision**: No. Keep `CReg` as-is. - -Classical data doesn't have: -- The same physical scarcity constraints -- Lifecycle states (bits don't need "preparation") -- The same ownership complexity - -Classical registers remain as simple `CReg` arrays. The allocator pattern is specifically for quantum resources. - -### 11. Interaction with Existing SLR Constructs - -The allocator model effectively implements `QReg` with additional semantics: - -| Current | New | -|---------|-----| -| `QReg("q", 5)` | `q = parent.child(5)` | -| `q[0]` (Qubit) | `q[0]` (QubitRef) | -| `Prep(q[0])` | `q.prepare(0)` | -| `Measure(q[0])` | `Measure(q[0])` (unchanged) | - -**Key difference**: Base allocator declared in main, passed to functions: - -```python -def main(): - base = QAlloc(100) # Declare capacity in main - - data = base.child(7) - ancilla = base.child(6) - - data.prepare_all() - ancilla.prepare_all() - - run_qec_rounds(data, ancilla) - - -def run_qec_rounds(data: QAlloc[7], ancilla: QAlloc[6]): - # Receives allocators as parameters - ... -``` - -This replaces the current pattern where `QReg` is declared inside `Block` classes. - ---- - -## Type System Integration - -### Static Capacity Tracking - -```python -QAlloc[N] # Allocator with capacity N - - -def syndrome_extraction(data: QAlloc[7], ancilla: QAlloc[6]) -> tuple[Bits, QAlloc[7]]: - # Type system knows: - # - data has 7 slots - # - ancilla has 6 slots - # - ancilla is consumed (not in return type) - # - data is returned (ownership transferred back) - ... -``` - -### Lowering to Target Formats - -| Target | Allocator Becomes | -|--------|-------------------| -| QASM 2.0 | `qreg` declarations with index mapping | -| QASM 3.0 | `qubit[N]` arrays | -| Guppy | `array[qubit, N]` with linear ownership | -| HUGR | Qubit allocation ops with region tracking | -| QIR | Qubit allocation intrinsics | - ---- - -## Implementation Plan - -### Phase 1: Core Data Structures - -1. Define `QAlloc` class with: - - Capacity tracking - - Child creation and management - - Per-slot state tracking (unprepared/prepared) - -2. Define `QubitRef` class as thin wrapper - -3. Define `SlotState` enum (two states: unprepared, prepared) - -### Phase 2: Integration with SLR Operations - -1. Modify gate classes to accept `QubitRef` -2. Add `prepare()` method/gate -3. Modify `Measure` to transition slots to unprepared -4. Update `Block` to require base allocator declaration - -### Phase 3: Code Generation Updates - -1. Update `SlrConverter` to handle allocator-based programs -2. Update QASM generator to map allocators to registers -3. Update Guppy generator to map allocators to arrays with linear semantics -4. Update resource planner to understand allocator hierarchy - -### Phase 4: Validation and Analysis - -1. Add compile-time checks for: - - Base allocator requirement - - Capacity overflow detection - - Lifecycle violations (gate on unprepared slot) - - Ownership violations (use after release) - -2. Add warnings for: - - Releasing entangled qubits - - Unused allocator capacity - -**Integration Points for State Checking:** - -The existing data flow analysis infrastructure can be leveraged: -- `DataFlowAnalyzer` (data_flow.py:37-354) - already tracks consumption and replacement -- `IRAnalyzer` (ir_analyzer.py:114) - integration point after `_integrate_data_flow()` -- `IRBuilder` (ir_builder.py:3078, 4137) - gate conversion with validation -- `ScopeManager` (scope_manager.py:27-145) - runtime state tracking - -A new `QubitStateValidator` module would: -1. Initialize from DataFlowAnalysis with all elements as "unprepared" -2. Mark elements as "prepared" when `Prep`/`Init`/`Reset` operations occur -3. Mark elements as "unprepared" when `Measure` operations occur -4. Validate that gates only operate on "prepared" elements - -### Phase 5: Documentation and Migration - -1. Document the allocator model -2. Provide migration guide from `QReg` to allocators -3. Update examples - ---- - -## Design Decisions Summary - -| Question | Decision | -|----------|----------| -| Slot states | Two: `unprepared` and `prepared` (no separate "dirty") | -| Prepare syntax | Method: `alloc.prepare(0, 1, 2)` or `alloc.prepare_all()` | -| Gate on unprepared slot | Compile-time error | -| Base allocator location | Declared in `main`, passed to functions | -| Returning allocators | Must return parent OR all children | -| Cross-allocator entanglement | Allowed, not tracked | -| Classical registers | Keep `CReg` as-is | -| QubitRef lifetime | Ephemeral, Zig-inspired (no escaping scope) | -| Philosophy | NASA Power of 10 inspired (no dynamic alloc after init) | - ---- - -## Implementation Decisions - -| Question | Decision | -|----------|----------| -| Migration | Dual support: `QReg` as alias/wrapper for `QAlloc` | -| Naming | `QAlloc` (follows `QReg`/`CReg` convention) | -| Prepare return | `void` - keeps refs ephemeral, allocator is source of truth | - ---- - -## Complete Example: QEC Round - -```python -def main(): - # Declare physical resource budget - base = QAlloc(capacity=17) - - # Partition into logical groupings - data = base.child(9) # 9 data qubits for surface code - ancilla = base.child(8) # 8 ancilla for syndrome extraction - - # Initialize data qubits - data.prepare_all() - encode_logical_zero(data) - - # Run QEC rounds - for round in range(100): - syndrome = extract_syndrome(data, ancilla) - if needs_correction(syndrome): - apply_correction(data, syndrome) - - # Final readout - result = decode_and_measure(data) - return result - - -def extract_syndrome(data: QAlloc[9], ancilla: QAlloc[8]) -> Bits: - """ - Extract syndrome without consuming data. - Ancilla is consumed (not returned). - """ - ancilla.prepare_all() - - # X stabilizers - for i in range(4): - H(ancilla[i]) - CNOT(ancilla[i], data[stabilizer_x_targets(i)]) - H(ancilla[i]) - - # Z stabilizers - for i in range(4): - CNOT(data[stabilizer_z_targets(i)], ancilla[4 + i]) - - # Measure all ancilla - slots become unprepared - syndrome = Measure(ancilla) - - # ancilla not returned → released back to caller's scope - # data returned implicitly via not being consumed - return syndrome - - -def encode_logical_zero(data: QAlloc[9]) -> QAlloc[9]: - """ - Encode logical |0⟩. Returns the data allocator. - """ - # data already prepared by caller - H(data[0]) - CNOT(data[0], data[1]) - # ... encoding circuit ... - return data # Ownership returned to caller - - -def decode_and_measure(data: QAlloc[9]) -> Bits: - """ - Decode and measure. Consumes the data allocator. - """ - # ... decoding circuit ... - result = Measure(data) - # data not returned → consumed - return result -``` - -### What This Demonstrates - -1. **Base in main**: `QAlloc(17)` declares total capacity upfront -2. **Child allocators as "registers"**: `data` and `ancilla` are like QRegs -3. **Slot preparation**: `prepare_all()` makes slots usable -4. **Natural consumption**: `ancilla` not returned from `extract_syndrome` → released -5. **Explicit return for ownership**: `encode_logical_zero` returns `data` to maintain ownership -6. **Loop reuse**: Each round re-prepares ancilla, reusing the same slots - ---- - -## Summary - -The qubit allocator model provides: - -- **Physical grounding**: Resources come from a declared capacity, not thin air -- **Hierarchical ownership**: Clear parent-child relationships with natural scoping -- **Lifecycle tracking**: Two states (unprepared/prepared) enforced at compile time -- **Slot abstraction**: Logical indices, not physical identity - enabling optimizations -- **Clean semantics**: Ownership rules similar to Rust/Zig for resource safety - -This bridges QASM's register model and Guppy's linear types while feeling more connected to physical hardware constraints. +This document has been moved to `~/Repos/pecos-docs/design/proposals/slr-qubit-allocators.md`. diff --git a/design/python-classical-interpreter-suspected-bugs.md b/design/python-classical-interpreter-suspected-bugs.md index ce8060449..4cc8cbbff 100644 --- a/design/python-classical-interpreter-suspected-bugs.md +++ b/design/python-classical-interpreter-suspected-bugs.md @@ -1,54 +1,3 @@ -# Python PhirClassicalInterpreter -- Suspected Bugs +# Moved to pecos-docs vault -Found during the Rust reimplementation and fuzz testing. - -Bugs #1 and #2 are the same class of issue as -[PECOS-packages/PECOS#213](https://github.com/PECOS-packages/PECOS/issues/213): -PECOS dtype constructors reject values outside the type range instead of -masking/wrapping. PR #214 fixed the specific operator overload case but the -underlying dtype overflow issue remains. - -## 1. Overflow rejected for values that fit the register but not the dtype - -**Status:** FIXED (dtype constructors now truncate instead of rejecting) - -**Description:** `assign_int` converts the value through the PECOS dtype constructor (`dtype(val)`) before masking to register size. If the value exceeded the dtype's range, Python threw `OverflowError`. Fixed by changing dtype constructors to accept `i64` and truncate via cast. - ---- - -## 2. Bitwise NOT overflows when assigning cross-type - -**Status:** FIXED (dtype constructors now truncate instead of rejecting) - -**Description:** `~m` where `m` is `u32 size=1` produced `u32(4294967295)`. Assigning to an `i32` variable did `i32(4294967295)` which threw `OverflowError`. Fixed by the same dtype constructor change. - ---- - -## 3. `PhirModel.model_validate` rejects valid PHIR programs with `Result` cop - -**Confidence:** High (but in the `phir` pydantic model, not the interpreter itself) -**File:** `python/quantum-pecos/src/pecos/classical_interpreters/phir_classical_interpreter.py` -**Line:** 101-102 - -**Description:** When `phir_validate=True` (default), the interpreter validates programs through `PhirModel.model_validate()` from the `phir` pydantic package. This validator rejects the `Result` classical operation, which is a valid PECOS-specific extension used in many test programs. - -**Example:** Programs with `{"cop": "Result", "args": ["m"], "returns": ["c"]}` fail pydantic validation even though they execute correctly. - -**Impact:** Users must set `phir_validate=False` to run programs with `Result` operations when using the Python interpreter. The Rust interpreter's serde parser handles these correctly. - ---- - -## Design questions (not clear-cut bugs) - -### Signed types not masked to register size - -**File:** `python/quantum-pecos/src/pecos/classical_interpreters/phir_classical_interpreter.py` -**Line:** 345-349 - -`assign_int()` only masks unsigned types to the register's declared `size`. Signed types are stored at the full dtype width. The code has a comment: `# (only valid for unsigned data types)` -- suggesting this was a deliberate choice. - -The PHIR spec says "assigning 5 to a 2-bit variable stores only the lower 2 bits" with no unsigned-only qualifier, but there may be good reasons for treating signed types differently (sign-extension from narrow widths is lossy). - -### Shift by type width is a no-op - -`u32(1) << 32` gives `u32(1)` instead of `u32(0)`. The PECOS dtype uses native hardware shift semantics (shift amount modulo type width). This matches x86/ARM behavior but not mathematical semantics. Whether the PHIR spec expects hardware or mathematical shift behavior is unclear. +This document has been moved to `~/Repos/pecos-docs/design/python-classical-interpreter-suspected-bugs.md`. diff --git a/design/rust-phir-classical-interpreter.md b/design/rust-phir-classical-interpreter.md index ebfd0c378..b48a2d571 100644 --- a/design/rust-phir-classical-interpreter.md +++ b/design/rust-phir-classical-interpreter.md @@ -1,298 +1,3 @@ -# Rust PhirClassicalInterpreter -- Drop-in Replacement Spec +# Moved to pecos-docs vault -## Goal - -Build a Rust `PhirClassicalInterpreter` that is a drop-in replacement for the Python -`PhirClassicalInterpreter` in `HybridEngine`. The Python PHIR code is the spec -- the -Rust version must have identical behavior. - -Eventually we move to the full Rust sim() system, but this is the first step: replace -the Python classical interpreter internals while keeping the Python `HybridEngine` -orchestration. - -## Architecture - -``` -crates/pecos-phir-json/ -- Rust logic lives here (reuse existing internals) -python/pecos-rslib/ -- PyO3 wrapper lives here (new pyclass) -``` - -The Rust interpreter reuses existing components from `pecos-phir-json/src/v0_1/`: -- `ast.rs` -- PHIR JSON parsing -- `environment.rs` -- variable storage, bit ops, types -- `expression.rs` -- expression evaluation -- `operations.rs` -- classical op handling logic (adapt) - -The PyO3 layer in `pecos-rslib` exposes it as a `#[pyclass]` implementing the -`ClassicalInterpreterProtocol`. - -## Python Protocol - -From `python/quantum-pecos/src/pecos/protocols.py`, the interpreter must satisfy: - -```python -class ClassicalInterpreterProtocol(Protocol): - program: Any - foreign_obj: Any - phir_validate: bool - - def reset(self) -> None: ... - def init(self, program: str | dict | QuantumCircuit, foreign_obj: object | None = None) -> int: ... - def shot_reinit(self) -> None: ... - def execute(self, sequence: Sequence | None) -> Generator[list[QOp | MOp], Any, None]: ... - def receive_results(self, qsim_results: list[dict]) -> None: ... - def results(self, *, return_int: bool = True) -> dict: ... -``` - -Additional methods called by HybridEngine but not in the protocol: -```python -def add_cvar(self, cvar: str, dtype: type, size: int) -> None: ... -def result_bits(self, bits: Iterable, *, filter_private: bool = True) -> dict: ... -``` - -## Call Ordering in HybridEngine - -``` -INITIALIZATION: - outer = PhirClassicalInterpreter() - inner = PhirClassicalInterpreter() - inner.phir_validate = outer.phir_validate - - num_qubits = outer.init(program, foreign_object) - inner.init(program, foreign_object) # same program - machine.init(num_qubits) - -PER-SHOT: - outer.shot_reinit() - inner.shot_reinit() - for i in range(num_qubits): - inner.add_cvar(f"__q{i}__", pc.dtypes.i64, 1) # private qubit vars - - EXECUTION LOOP: - for buffered_ops in outer.execute(outer.program.ops): - noisy_ops = op_processor.process(buffered_ops) - measurements.clear() - for noisy_qops in inner.execute(noisy_ops): - temp_meas = qsim.run(noisy_qops) - inner.receive_results(temp_meas) - measurements.extend(temp_meas) - transmit_meas = inner.result_bits(measurements) - outer.receive_results([transmit_meas]) - - RESULTS: - shot_results = outer.results(return_int=return_int) -``` - -## Data Flow - -``` -PHIR JSON str/dict - | - v -outer.init() --> parse JSON, validate, build internal AST, init env - | return num_qubits - v -outer.execute(program.ops) - | - | yields: list[QOp | MOp] (batches ending at measurements) - v -op_processor.process(buffered_ops) - | - | returns: list[QOp] (noisy operations) - v -inner.execute(noisy_ops) - | - | yields: list[QOp] - v -qsim.run(noisy_qops) - | - | returns: list[dict] e.g. [{("m", 0): 1, ("m", 1): 0}] - v -inner.receive_results(temp_meas) --> stores via assign_int - | -inner.result_bits(measurements) --> extracts bits, filters __private__ vars - | - | returns: dict[(str, int), int] - v -outer.receive_results([transmit_meas]) --> stores via assign_int - | -outer.results(return_int=...) - | - | returns: dict[str, int_or_bitstring] -``` - -## What `execute()` Yields - -`execute()` is a generator/iterator. It yields `list[QOp | MOp]`. - -Behavior: -- Walks the op list, recursively flattening SeqBlock and evaluating IfBlock conditions -- Buffers QOps and MOps -- Executes COps inline (never yielded) -- Yields the buffer when a measurement QOp is encountered (name in {"measure Z", "Measure", "Measure +Z"}) -- Yields remaining buffer at end of program -- Classical ops (assignment, Result mapping, FFCall) are handled during the walk - -### QOp Fields (what consumers read) - -``` -name: str # e.g. "H", "Measure", "RZ" -sim_name: str # resolved name for simulator -args: list[int] | list[tuple] # qubit IDs -returns: list | None # measurement targets, e.g. [["m", 0], ["m", 1]] -metadata: dict | None # includes "angle", "angles", "var_output" -angles: tuple[float, ...] | None # rotation angles in radians -``` - -Fields read by QuantumSimulator: `sim_name`, `args`, `metadata`, `returns` -Fields read by GenericOpProc: only `isinstance()` checks (QOp vs MOp routing) - -### MOp Fields - -``` -name: str -args: list | None -returns: list | None -metadata: dict | None # may contain "duration" -``` - -## The Generator Problem - -Python `execute()` is a coroutine -- yields batches, caller runs quantum sim, feeds -measurements back via `receive_results()`, then execution resumes. Conditional branches -may depend on those measurement results. - -**Solution: PyO3 iterator class.** A `#[pyclass]` that holds interpreter state and -advances to the next yield point on each `__next__()`. The Rust struct holds program -state, and each `__next__` call processes ops until the next measurement batch. - -## Dual Interpreter Pattern - -HybridEngine uses TWO interpreter instances: - -**Outer interpreter:** -- Drives the full program -- Has only program-declared variables -- Receives filtered measurement bits from inner via `receive_results()` - -**Inner interpreter:** -- Processes noisy ops from error model -- Gets extra `__q{i}__` private vars via `add_cvar()` (one per qubit, i64, size 1) -- `result_bits()` filters out `__`-prefixed vars when transmitting back - -Both use the same code, same class. The inner just handles a flat list of QOps -(no blocks to flatten) and has extra private vars. - -## Method Details - -### `init(program, foreign_obj) -> int` - -1. Parse program: JSON string -> dict, or accept dict directly -2. Validate format ("PHIR/JSON" or "PHIR") and version (< 0.2.0) -3. Optionally validate against PHIR schema (if `phir_validate` is True) -4. Build internal AST / operation list -5. Extract variable definitions, initialize environment (all vars to 0) -6. Check foreign function calls against foreign object -7. Return num_qubits - -### `shot_reinit()` - -Reset all variable values to 0. Keep variable definitions. - -### `execute(sequence) -> Iterator[list[QOp | MOp]]` - -Walk ops, flatten blocks, execute classical ops, yield quantum op batches. -See "What execute() Yields" above. - -### `receive_results(qsim_results: list[dict])` - -Each dict maps `cvar` or `(cvar, idx)` to a value. -For each key/value, calls `assign_int(key, value)`. - -### `result_bits(bits, filter_private=True) -> dict` - -`bits` is a list of measurement dicts from qsim.run(). -Iterates all (cvar, bit_idx) pairs, filters out `__`-prefixed vars, -returns `{(cvar, bit_idx): self.get_bit(cvar, bit_idx)}`. - -Important: reads from own env (after receive_results), not from input. - -### `results(return_int=True) -> dict` - -Returns ALL variables in csym2id. -- return_int=True: values are integers -- return_int=False: values are zero-padded binary strings ("{:0{width}b}") - -### `add_cvar(cvar, dtype, size)` - -Dynamically add a new classical variable after init. Used by HybridEngine -for the inner interpreter's private qubit vars. - -### `assign_int(cvar, val)` - -Assign integer value to variable or specific bit. -- `cvar` is a string: assign whole variable -- `cvar` is (string, int): assign to bit at index - -## Name Resolver - -`sim_name_resolver(qop)` translates PHIR gate names to simulator names: -- `RZZ(0.0)` -> `"I"` -- `RZZ(pi/2)` -> `"SZZ"` -- `RZZ(3pi/2)` -> `"SZZdg"` -- `RZ(angle)` -> tries clifford match -- `R1XY(angles)` -> tries clifford match -- Otherwise returns `qop.name` - -Applied during PHIR parsing when building QOp objects. The Rust side needs this -for yielded QOps to have correct `sim_name` values. - -## Foreign Function Calls - -FFCalls are COps handled during `execute()` -- never yielded. The foreign object -is a Python object implementing `ForeignObjectProtocol`: - - def exec(func_name: str, args: Sequence) -> tuple | int - -The Rust side must call back into this Python object for FFCalls. This requires -holding a `Py` reference. - -## Edge Cases - -- **Empty programs**: `execute([])` yields nothing. `results()` returns `{}` -- **No measurements**: buffer yielded at end (if non-empty). No receive_results() calls. -- **Only classical ops**: all handled inline. Nothing yielded. results() has computed values. -- **"Result" cop**: maps internal register to external name, copies value, creates dest var if needed. - -## What Exists vs What Needs Building - -### Reuse from `pecos-phir-json/src/v0_1/`: - -| Component | File | Notes | -|-----------|------|-------| -| PHIR JSON parsing | `ast.rs` | Complete | -| Variable storage | `environment.rs` | Complete -- DataType, TypedValue, Environment | -| Expression eval | `expression.rs` | Complete -- all operators | -| Classical ops | `operations.rs` | Adapt -- different interface needed | -| Block flattening | `block_iterative_executor.rs` | Adapt -- yield pattern differs | - -### New code needed: - -| Component | Location | Description | -|-----------|----------|-------------| -| Rust interpreter struct | `pecos-phir-json` | State machine wrapping existing internals | -| PyO3 wrapper class | `pecos-rslib` | `#[pyclass]` with protocol methods | -| PyO3 iterator | `pecos-rslib` | `__iter__`/`__next__` for execute() generator | -| QOp/MOp pyclass | `pecos-rslib` | Lightweight attribute bags for yielded ops | -| Name resolver | `pecos-phir-json` | Port of sim_name_resolver | -| result_bits() | `pecos-phir-json` | Bit extraction with private var filtering | -| receive_results() | `pecos-phir-json` | Handle list[dict] format | -| results() | `pecos-phir-json` | Return dict with return_int flag | - -## Validation Strategy - -The Rust interpreter must produce identical results to the Python one. Test by: -1. Running existing PHIR test programs through both interpreters -2. Comparing shot-by-shot results -3. Testing edge cases (empty programs, no measurements, only classical ops) -4. Testing the dual-interpreter pattern (outer + inner with private vars) +This document has been moved to `~/Repos/pecos-docs/design/rust-phir-classical-interpreter-spec.md`. diff --git a/design/stn_2d_geometry_backend.md b/design/stn_2d_geometry_backend.md new file mode 100644 index 000000000..26b9552f0 --- /dev/null +++ b/design/stn_2d_geometry_backend.md @@ -0,0 +1,3 @@ +# Moved to pecos-docs vault + +This document has been moved to `~/Repos/pecos-docs/design/stab-tn/2d-geometry-backend.md`. diff --git a/design/stn_orthogonal_directions.md b/design/stn_orthogonal_directions.md new file mode 100644 index 000000000..2c1ebef8c --- /dev/null +++ b/design/stn_orthogonal_directions.md @@ -0,0 +1,3 @@ +# Moved to pecos-docs vault + +This document has been moved to `~/Repos/pecos-docs/design/stab-tn/orthogonal-directions.md`. diff --git a/docs/concepts/clifford-rz-simulator.md b/docs/concepts/clifford-rz-simulator.md index b3e1a3320..d6e3f53c0 100644 --- a/docs/concepts/clifford-rz-simulator.md +++ b/docs/concepts/clifford-rz-simulator.md @@ -1,6 +1,6 @@ # Clifford+RZ Simulator -The `CliffordRz` simulator represents quantum states as weighted sums of stabilizer +The `StabVec` simulator represents quantum states as weighted sums of stabilizer states using the CH-form representation from Bravyi et al. ([arXiv:1808.00128](https://arxiv.org/abs/1808.00128)). This enables efficient simulation of circuits with many Clifford gates and a moderate number of RZ rotations. @@ -68,7 +68,7 @@ tableaux lack. ## Sum-over-Cliffords decomposition -The `CliffordRz` simulator represents the full quantum state as: +The `StabVec` simulator represents the full quantum state as: ``` |psi> = sum_k alpha_k |phi_k> @@ -186,14 +186,14 @@ Default threshold: 1e-8. This trades exactness for keeping T manageable. When T exceeds a threshold (default: 2048), measurement uses O(T) term sampling instead of exact O(T^2) pairwise inner products. -## When to use CliffordRz +## When to use StabVec | Scenario | Best approach | |----------------------------------------------|--------------------------------------------------------------------------------| | Pure Clifford, no branching, pure depolarizing noise | DEM sampling (detector error model -- fastest, but limited to this restricted case) | | Pure Clifford circuits (general) | SparseStab (single tableau, O(n^2) worst case, typically less due to sparsity) | | Few qubits, arbitrary gates | StateVec (full 2^n vector) | -| Many qubits, mostly Clifford, some rotations | CliffordRz | +| Many qubits, mostly Clifford, some rotations | StabVec | | Deep circuits with many rotations | StateVec or cuQuantum (term count explodes) | For pure Clifford QEC circuits without branching or conditional logic, and with only @@ -202,7 +202,7 @@ entirely and is significantly faster. However, stabilizer tableaux like SparseSt full state simulators that handle the general case -- branching, conditional operations, arbitrary Clifford circuits, and complex noise models. -The sweet spot for CliffordRz is large qubit counts with circuits dominated by Clifford +The sweet spot for StabVec is large qubit counts with circuits dominated by Clifford gates and a moderate number of non-Clifford rotations -- typical of error correction circuits with T gates or variational circuits with few parameterized layers. diff --git a/docs/development/foreign-plugins.md b/docs/development/foreign-plugins.md index dccab9882..3d4295ae5 100644 --- a/docs/development/foreign-plugins.md +++ b/docs/development/foreign-plugins.md @@ -276,7 +276,7 @@ Foreign code can create and run PECOS quantum engines via the C ABI: | State vector | `"state_vec"` | Full state vector simulation | | Sparse stabilizer | `"sparse_stab"` | Clifford-only, sparse tableau | | Stabilizer | `"stabilizer"` | Clifford-only, standard tableau | -| Clifford+RZ | `"clifford_rz"` | Sum-over-Cliffords for T/RZ gates | +| StabVec | `"stab_vec"` | Sum-over-Cliffords for T/RZ gates | | Density matrix | `"density_matrix"` | Mixed state simulation | | Coin toss | `"coin_toss"` | Random outcomes (testing) | diff --git a/docs/user-guide/simulators.md b/docs/user-guide/simulators.md index 5b1fc6980..5c0f29104 100644 --- a/docs/user-guide/simulators.md +++ b/docs/user-guide/simulators.md @@ -80,7 +80,7 @@ fn main() -> Result<(), Box> { | **SparseStab** | Stabilizer | QEC simulations, Clifford circuits | None (default) | | **Stabilizer** | Stabilizer | Dense Clifford circuits | None | | **StateVec** | State vector | Arbitrary circuits, small systems | None | -| **CliffordRz** | Clifford + Rz | Clifford circuits with Z rotations | None | +| **StabVec** | Clifford + Rz | Clifford circuits with Z rotations | None | | **PauliProp** | Fault tracking | Error propagation analysis | None | | **CuStateVec** | State vector (GPU) | Large circuits with GPU | CUDA, cuQuantum | | **MPS** | Tensor network | Low-entanglement circuits | CUDA, cuQuantum | @@ -104,7 +104,7 @@ fn main() -> Result<(), Box> { │ │ │ │ ▼ ▼ │ StateVec CuStateVec - │ CliffordRz MPS + │ StabVec MPS │ │ │ ▼ └── Need mixed states? ──→ density_matrix @@ -231,14 +231,14 @@ Pure Rust state vector implementation. - Supports arbitrary gates (including T, rotation gates) - Good baseline performance -### CliffordRz +### StabVec Rust backend specialized for Clifford circuits plus Z-axis rotations. ```python -from pecos.simulators import CliffordRz +from pecos.simulators import StabVec -results = sim(Qasm(circuit)).quantum(CliffordRz).run(100) +results = sim(Qasm(circuit)).quantum(StabVec).run(100) ``` **Strengths:** @@ -393,7 +393,7 @@ Approximate performance characteristics (relative, not absolute): | SparseStab | ★★★★★ | N/A | Low | 1000+ | | Stabilizer | ★★★★ | N/A | Medium | 1000+ | | StateVec | ★★★ | ★★★ | 2^n | ~25-30 | -| CliffordRz | ★★★★ | Limited to Clifford + Rz | Low | 1000+ | +| StabVec | ★★★★ | Limited to Clifford + Rz | Low | 1000+ | | CuStateVec | ★★★★ | ★★★★★ | 2^n (GPU) | ~30-35 | | MPS | ★★★ | ★★★ | ~n × chi² | Varies | | density_matrix | ★★ | ★★ | 4^n | ~15 | diff --git a/exp/pecos-neo/docs/design/extensible-gates-test-plan.md b/exp/pecos-neo/docs/design/extensible-gates-test-plan.md index 3644715d1..de8a5aca8 100644 --- a/exp/pecos-neo/docs/design/extensible-gates-test-plan.md +++ b/exp/pecos-neo/docs/design/extensible-gates-test-plan.md @@ -738,7 +738,7 @@ fn test_program_serialization_roundtrip() { ```rust #[test] fn test_standard_adaptor_decomposes_t() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); assert!(adaptor.can_adapt(gates::T)); @@ -752,7 +752,7 @@ fn test_standard_adaptor_decomposes_t() { #[test] fn test_standard_adaptor_decomposes_swap() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); let decomposed = adaptor.adapt(gates::SWAP, &[QubitId(0), QubitId(1)], &[], &[]); @@ -763,7 +763,7 @@ fn test_standard_adaptor_decomposes_swap() { #[test] fn test_adaptor_bitset_lookup() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); // Fast bit test assert!(adaptor.can_adapt(gates::T)); diff --git a/exp/pecos-neo/docs/design/extensible-gates.md b/exp/pecos-neo/docs/design/extensible-gates.md index 4524b6d2b..f2f42daf8 100644 --- a/exp/pecos-neo/docs/design/extensible-gates.md +++ b/exp/pecos-neo/docs/design/extensible-gates.md @@ -914,7 +914,7 @@ pub struct StandardAdaptor { impl StandardAdaptor { /// Create adaptor targeting Clifford+RZ gate set - pub fn clifford_rz() -> Self { + pub fn stab_vec() -> Self { let mut bits = BitVec::repeat(false, 256); // Mark gates we can decompose bits.set(gates::RX.0 as usize, true); diff --git a/exp/pecos-neo/src/extensible/adaptor.rs b/exp/pecos-neo/src/extensible/adaptor.rs index 42f02735a..150d48bd8 100644 --- a/exp/pecos-neo/src/extensible/adaptor.rs +++ b/exp/pecos-neo/src/extensible/adaptor.rs @@ -73,14 +73,14 @@ pub struct StandardAdaptor { impl Default for StandardAdaptor { fn default() -> Self { - Self::clifford_rz() + Self::stab_vec() } } impl StandardAdaptor { /// Create an adaptor targeting Clifford+RZ gate set. #[must_use] - pub fn clifford_rz() -> Self { + pub fn stab_vec() -> Self { let mut bits = GateSupportSet::new(); // Gates we can decompose into Clifford+RZ @@ -469,7 +469,7 @@ impl CompositeExtendedAdaptor { use super::stabilizer_adaptor::StabilizerAdaptor; Self::new() - .with_gate_adaptor(StandardAdaptor::clifford_rz()) + .with_gate_adaptor(StandardAdaptor::stab_vec()) .with(StabilizerAdaptor::new()) } } @@ -538,7 +538,7 @@ mod extended_tests { #[test] fn test_lifted_adaptor() { - let standard = StandardAdaptor::clifford_rz(); + let standard = StandardAdaptor::stab_vec(); let lifted = LiftedAdaptor::new(standard); assert!(lifted.can_adapt(gates::T)); @@ -583,7 +583,7 @@ mod extended_tests { #[test] fn test_swap_decomposition_via_lifted() { - let lifted = LiftedAdaptor::new(StandardAdaptor::clifford_rz()); + let lifted = LiftedAdaptor::new(StandardAdaptor::stab_vec()); let seq = lifted.adapt(gates::SWAP, &[QubitId(0), QubitId(1)], &[], &[]); diff --git a/exp/pecos-neo/src/extensible/tests.rs b/exp/pecos-neo/src/extensible/tests.rs index a65fef47b..1728ffa1d 100644 --- a/exp/pecos-neo/src/extensible/tests.rs +++ b/exp/pecos-neo/src/extensible/tests.rs @@ -1294,7 +1294,7 @@ fn test_validation_error_display() { #[test] fn test_standard_adaptor_can_adapt() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); assert!(adaptor.can_adapt(gates::T)); assert!(adaptor.can_adapt(gates::Tdg)); @@ -1312,7 +1312,7 @@ fn test_standard_adaptor_can_adapt() { #[test] fn test_standard_adaptor_t_gate() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); let result = adaptor.adapt(gates::T, &[QubitId(0)], &[]); @@ -1325,7 +1325,7 @@ fn test_standard_adaptor_t_gate() { #[test] fn test_standard_adaptor_tdg_gate() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); let result = adaptor.adapt(gates::Tdg, &[QubitId(0)], &[]); @@ -1338,7 +1338,7 @@ fn test_standard_adaptor_tdg_gate() { #[test] fn test_standard_adaptor_rx_gate() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); let theta = Angle64::QUARTER_TURN; let result = adaptor.adapt(gates::RX, &[QubitId(0)], &[theta]); @@ -1353,7 +1353,7 @@ fn test_standard_adaptor_rx_gate() { #[test] fn test_standard_adaptor_swap_gate() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); let result = adaptor.adapt(gates::SWAP, &[QubitId(0), QubitId(1)], &[]); @@ -1364,7 +1364,7 @@ fn test_standard_adaptor_swap_gate() { #[test] fn test_standard_adaptor_rzz_gate() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); let theta = Angle64::QUARTER_TURN; let result = adaptor.adapt(gates::RZZ, &[QubitId(0), QubitId(1)], &[theta]); @@ -1379,7 +1379,7 @@ fn test_standard_adaptor_rzz_gate() { #[test] fn test_standard_adaptor_ccx_gate() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); let result = adaptor.adapt(gates::CCX, &[QubitId(0), QubitId(1), QubitId(2)], &[]); @@ -1394,7 +1394,7 @@ fn test_standard_adaptor_ccx_gate() { #[test] fn test_composite_adaptor() { - let adaptor = CompositeAdaptor::new().with(StandardAdaptor::clifford_rz()); + let adaptor = CompositeAdaptor::new().with(StandardAdaptor::stab_vec()); assert!(adaptor.can_adapt(gates::T)); assert!(adaptor.can_adapt(gates::SWAP)); @@ -1432,7 +1432,7 @@ fn test_custom_adaptor() { #[test] fn test_adaptor_adaptable_gates() { - let adaptor = StandardAdaptor::clifford_rz(); + let adaptor = StandardAdaptor::stab_vec(); let adaptable = adaptor.adaptable_gates(); assert!(adaptable.contains(gates::T)); diff --git a/exp/pecos-stab-tn/Cargo.toml b/exp/pecos-stab-tn/Cargo.toml new file mode 100644 index 000000000..dad469731 --- /dev/null +++ b/exp/pecos-stab-tn/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "pecos-stab-tn" +version.workspace = true +edition.workspace = true +readme.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Experimental hybrid stabilizer + tensor network simulation methods (STN, MAST, CAMPS, etc.)." +publish = false + +[lib] +crate-type = ["rlib"] + +[dependencies] +pecos-core.workspace = true +pecos-quantum.workspace = true +pecos-random.workspace = true +pecos-simulators.workspace = true +nalgebra.workspace = true +num-complex.workspace = true +rayon = "1.10" +thiserror.workspace = true + +[dev-dependencies] +approx.workspace = true +paste.workspace = true + +[lints.clippy] +# Inherit workspace pedantic lint policy +suspicious = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } +multiple-crate-versions = "allow" +similar-names = "allow" +many-single-char-names = "allow" +too-many-lines = "allow" +# Numeric casts in test/example code use small values (qubits <= 64, trial +# counts <= 10000) where the casts are exact. Library code avoids lossy casts. +cast_precision_loss = "allow" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_sign_loss = "allow" diff --git a/exp/pecos-stab-tn/docs/approach.md b/exp/pecos-stab-tn/docs/approach.md new file mode 100644 index 000000000..e82b71940 --- /dev/null +++ b/exp/pecos-stab-tn/docs/approach.md @@ -0,0 +1,3 @@ +# Moved to pecos-docs vault + +This document has been moved to `~/Repos/pecos-docs/design/stab-tn/approach.md`. diff --git a/exp/pecos-stab-tn/docs/future_work.md b/exp/pecos-stab-tn/docs/future_work.md new file mode 100644 index 000000000..f1c47453b --- /dev/null +++ b/exp/pecos-stab-tn/docs/future_work.md @@ -0,0 +1,3 @@ +# Moved to pecos-docs vault + +This document has been moved to `~/Repos/pecos-docs/design/stab-tn/future-work.md`. diff --git a/exp/pecos-stab-tn/docs/landscape.md b/exp/pecos-stab-tn/docs/landscape.md new file mode 100644 index 000000000..8361359f2 --- /dev/null +++ b/exp/pecos-stab-tn/docs/landscape.md @@ -0,0 +1,3 @@ +# Moved to pecos-docs vault + +This document has been moved to `~/Repos/pecos-docs/design/stab-tn/landscape.md`. diff --git a/exp/pecos-stab-tn/docs/literature_status.md b/exp/pecos-stab-tn/docs/literature_status.md new file mode 100644 index 000000000..8968ce8fb --- /dev/null +++ b/exp/pecos-stab-tn/docs/literature_status.md @@ -0,0 +1,3 @@ +# Moved to pecos-docs vault + +This document has been moved to `~/Repos/pecos-docs/design/stab-tn/literature-status.md`. diff --git a/exp/pecos-stab-tn/docs/ofd_plan.md b/exp/pecos-stab-tn/docs/ofd_plan.md new file mode 100644 index 000000000..ff9606fdd --- /dev/null +++ b/exp/pecos-stab-tn/docs/ofd_plan.md @@ -0,0 +1,3 @@ +# Moved to pecos-docs vault + +This document has been moved to `~/Repos/pecos-docs/design/stab-tn/ofd-plan.md`. diff --git a/exp/pecos-stab-tn/docs/priorities.md b/exp/pecos-stab-tn/docs/priorities.md new file mode 100644 index 000000000..6c0b00b78 --- /dev/null +++ b/exp/pecos-stab-tn/docs/priorities.md @@ -0,0 +1,3 @@ +# Moved to pecos-docs vault + +This document has been moved to `~/Repos/pecos-docs/design/stab-tn/priorities.md`. diff --git a/exp/pecos-stab-tn/docs/references.md b/exp/pecos-stab-tn/docs/references.md new file mode 100644 index 000000000..a16b0eb15 --- /dev/null +++ b/exp/pecos-stab-tn/docs/references.md @@ -0,0 +1,3 @@ +# Moved to pecos-docs vault + +This document has been moved to `~/Repos/pecos-docs/design/stab-tn/references.md`. diff --git a/exp/pecos-stab-tn/examples/disent_firing_rate.rs b/exp/pecos-stab-tn/examples/disent_firing_rate.rs new file mode 100644 index 000000000..295636938 --- /dev/null +++ b/exp/pecos-stab-tn/examples/disent_firing_rate.rs @@ -0,0 +1,439 @@ +// 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. + +//! Benchmark disent firing rate across random fuzz circuits. +//! +//! Reports what fraction of non-Clifford RZ gates successfully avoid the +//! multi-site CNOT cascade path via: (a) single-site decomposition, +//! (b) Stabilizer branch (no bond-dim growth), or (c) multi-site disent +//! (one MPS op + tableau right-compose). +//! +//! The remaining fraction hits the std multi-site path which applies CNOTs +//! on the MPS -- the case OFD would replace. + +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::StabMps; +use pecos_stab_tn::stab_mps::compile::StabMpsCompile; +use pecos_stab_tn::stab_mps::mast::Mast; +use std::f64::consts::TAU; + +/// Same xorshift generator as fuzz tests. +fn next_rng(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state +} + +/// Distribution of gate types to sample. +#[derive(Clone, Copy)] +enum GateMix { + /// Full random: H/S/X/CX/CZ/T/RZ/RX with equal weights. + Random, + /// Clifford + T only (research target for MAST). + CliffT, +} + +fn fuzz_circuit(num_qubits: usize, num_gates: usize, seed: u64, mix: GateMix) -> StabMps { + let mut stn = StabMps::with_seed(num_qubits, seed); + // xorshift state 0 stays 0 forever — skip seed 0 by adding offset. + let mut rng_state = seed.wrapping_add(1); + + for _ in 0..num_gates { + let n_types: u64 = match mix { + GateMix::Random => 8, + GateMix::CliffT => 6, + }; + let gate_type = next_rng(&mut rng_state) % n_types; + 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 => { + let t = Angle64::QUARTER_TURN / 2u64; + stn.rz(t, &[QubitId(q0)]); + } + 6 => { + let ab = next_rng(&mut rng_state); + let a = Angle64::from_radians((ab % 1000) as f64 * 0.001 * TAU); + stn.rz(a, &[QubitId(q0)]); + } + _ => { + let ab = next_rng(&mut rng_state); + let a = Angle64::from_radians((ab % 1000) as f64 * 0.001 * TAU); + stn.rx(a, &[QubitId(q0)]); + } + } + } + stn +} + +/// Run scenario with optional auto-disentangle every N gates. +fn run_scenario_with_auto( + label: &str, + n_qubits: usize, + n_gates: usize, + n_seeds: u64, + mix: GateMix, + auto_disent_every: Option, +) { + let mut max_bond_sum = 0u64; + let mut gates_disent_total = 0u64; + for seed in 0..n_seeds { + let mut stn = pecos_stab_tn::stab_mps::StabMps::with_seed(n_qubits, seed); + let mut rng_state = seed.wrapping_add(1); + let mut gate_count = 0; + for _ in 0..n_gates { + let n_types: u64 = match mix { + GateMix::Random => 8, + GateMix::CliffT => 6, + }; + let gate_type = next_rng(&mut rng_state) % n_types; + let q0 = (next_rng(&mut rng_state) % n_qubits as u64) as usize; + let q1 = loop { + let q = (next_rng(&mut rng_state) % n_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 ab = next_rng(&mut rng_state); + stn.rz( + Angle64::from_radians((ab % 1000) as f64 * 0.001 * TAU), + &[QubitId(q0)], + ); + } + _ => { + let ab = next_rng(&mut rng_state); + stn.rx( + Angle64::from_radians((ab % 1000) as f64 * 0.001 * TAU), + &[QubitId(q0)], + ); + } + } + gate_count += 1; + if let Some(every) = auto_disent_every + && gate_count % every == 0 + { + gates_disent_total += stn.disentangle(2) as u64; + } + } + // Final disent sweep + if auto_disent_every.is_some() { + gates_disent_total += stn.disentangle(3) as u64; + } + max_bond_sum += stn.max_bond_dim() as u64; + } + let avg_bond = max_bond_sum as f64 / n_seeds as f64; + let avg_disent = gates_disent_total as f64 / n_seeds as f64; + println!( + "{label:<28} n={n_qubits} gates={n_gates} auto_every={auto_disent_every:?} | avg_bond={avg_bond:.2} avg_disent_gates={avg_disent:.1}", + ); +} + +fn run_scenario(label: &str, n_qubits: usize, n_gates: usize, n_seeds: u64, mix: GateMix) { + use std::io::Write; + let mut total = 0u64; + let mut single = 0u64; + let mut disent = 0u64; + let mut std_path = 0u64; + let mut stabilizer = 0u64; + let mut max_bond_sum = 0u64; + let mut theoretical_bond_sum = 0u64; + let mut ofd_in_span = 0u64; + let mut ofd_wins = 0u64; // in_span gates heuristic sent through std path + + for seed in 0..n_seeds { + let stn = fuzz_circuit(n_qubits, n_gates, seed, mix); + theoretical_bond_sum += stn.gf2_matrix().theoretical_min_bond_dim() as u64; + ofd_in_span += stn.stats.ofd_in_span; + ofd_wins += stn.stats.ofd_in_span_std; + let s = stn.stats; + total += s.total_nonclifford; + single += s.single_site; + disent += s.multi_disent; + std_path += s.multi_std; + stabilizer += s.stabilizer; + max_bond_sum += stn.max_bond_dim() as u64; + } + + let pct = |x: u64| { + if total == 0 { + 0.0 + } else { + 100.0 * x as f64 / total as f64 + } + }; + let avg_bond = max_bond_sum as f64 / n_seeds as f64; + let avg_theo = theoretical_bond_sum as f64 / n_seeds as f64; + println!( + "{label:<24} n={n_qubits} gates={n_gates} | total={total} \ + heur: stab={:.0}% single={:.0}% disent={:.0}% std={:.0}% | \ + OFD in_span={:.0}% | OFD wins (in_span but heur-std) ={ofd_wins}/{} ({:.0}%) | \ + bond={avg_bond:.2}/{avg_theo:.2}", + pct(stabilizer), + pct(single), + pct(disent), + pct(std_path), + pct(ofd_in_span), + std_path, + if std_path == 0 { + 0.0 + } else { + 100.0 * ofd_wins as f64 / std_path as f64 + }, + ); + let _ = std::io::stdout().flush(); +} + +fn main() { + println!("Disent firing rate benchmark. Runs random fuzz circuits and reports"); + println!("what fraction of non-Clifford RZs take each code path."); + println!(); + println!(" stab = Stabilizer branch (Z_q already a stabilizer product: no MPS site ops)"); + println!(" single = single-site decomposition (trivial 1-qubit gate on MPS)"); + println!(" disent = multi-site disent fires (1-qubit MPS op + tableau right-compose)"); + println!(" std = multi-site CNOT cascade on MPS (OFD target to replace)"); + println!(); + + // Random gate mix: Cliffords + rotations + T. + println!("=== Random gate mix (H/S/X/CX/CZ/T/RZ/RX) ==="); + run_scenario("2q shallow", 2, 10, 100, GateMix::Random); + run_scenario("2q medium", 2, 20, 100, GateMix::Random); + run_scenario("2q deep", 2, 50, 50, GateMix::Random); + run_scenario("3q shallow", 3, 10, 50, GateMix::Random); + run_scenario("3q medium", 3, 20, 30, GateMix::Random); + run_scenario("4q shallow", 4, 10, 30, GateMix::Random); + run_scenario("4q medium", 4, 20, 20, GateMix::Random); + + // T-heavy: research target for MAST / Clifford+T simulation. + println!("\n=== Clifford + T only (H/S/X/CX/CZ/T) ==="); + run_scenario("2q T 10g", 2, 10, 100, GateMix::CliffT); + run_scenario("2q T 30g", 2, 30, 50, GateMix::CliffT); + run_scenario("3q T 15g", 3, 15, 50, GateMix::CliffT); + run_scenario("3q T 30g", 3, 30, 30, GateMix::CliffT); + run_scenario("4q T 20g", 4, 20, 20, GateMix::CliffT); + run_scenario("5q T 20g", 5, 20, 10, GateMix::CliffT); + run_scenario("8q T 30g", 8, 30, 5, GateMix::CliffT); + run_scenario("10q T 40g", 10, 40, 3, GateMix::CliffT); + run_scenario("15q T 50g", 15, 50, 2, GateMix::CliffT); + + // Test auto-heuristic-disentangle: compare to baseline on 2q deep (bond 2) + // where std path fires heavily. + // Pre-analysis with StabMpsCompile: same circuit, no MPS cost. Verifies + // that the compile-only pass gives matching OFD predictions. + println!("\n=== StabMpsCompile pre-analysis (no MPS cost) ==="); + bench_compile("5q T 20g", 5, 20, 50, GateMix::CliffT); + bench_compile("10q T 30g", 10, 30, 20, GateMix::CliffT); + bench_compile("20q T 50g", 20, 50, 10, GateMix::CliffT); + bench_compile("50q T 100g", 50, 100, 5, GateMix::CliffT); + bench_compile("100q T 200g", 100, 200, 2, GateMix::CliffT); + + println!("\n=== Auto heuristic disentangle (on 2q deep) ==="); + run_scenario_with_auto("baseline (no auto)", 2, 50, 20, GateMix::Random, None); + run_scenario_with_auto("auto every 5 gates", 2, 50, 20, GateMix::Random, Some(5)); + run_scenario_with_auto("auto every 10 gates", 2, 50, 20, GateMix::Random, Some(10)); + run_scenario_with_auto("auto every 20 gates", 2, 50, 20, GateMix::Random, Some(20)); + + println!("\n=== Auto heuristic disentangle (on 3q T 30g) ==="); + run_scenario_with_auto("baseline (no auto)", 3, 30, 20, GateMix::CliffT, None); + run_scenario_with_auto("auto every 5 gates", 3, 30, 20, GateMix::CliffT, Some(5)); + run_scenario_with_auto("auto every 10 gates", 3, 30, 20, GateMix::CliffT, Some(10)); + + // MAST: magic-state injection scheme. Targets 20-200 qubits with bond ~1. + println!("\n=== MAST (Clifford+T, deferred ancilla projection) ==="); + run_mast_scenario("10q T 10", 10, 10, 10); + run_mast_scenario("10q T 50", 10, 50, 5); + run_mast_scenario("20q T 20", 20, 20, 5); + run_mast_scenario("20q T 100", 20, 100, 3); + run_mast_scenario("50q T 20", 50, 20, 3); + run_mast_scenario("50q T 100", 50, 100, 1); + run_mast_scenario("100q T 30", 100, 30, 2); + run_mast_scenario("100q T 100", 100, 100, 1); +} + +/// Benchmark `StabMpsCompile` on a fuzz circuit: reports timing and nullity. +fn bench_compile(label: &str, n_qubits: usize, n_gates: usize, n_seeds: u64, mix: GateMix) { + let mut total_nullity = 0usize; + let mut total_absorbed = 0u64; + let mut total_grown = 0u64; + let start = std::time::Instant::now(); + for seed in 0..n_seeds { + let mut c = StabMpsCompile::new(n_qubits); + let mut rng_state = seed.wrapping_add(1); + for _ in 0..n_gates { + let n_types: u64 = match mix { + GateMix::Random => 8, + GateMix::CliffT => 6, + }; + let gate_type = next_rng(&mut rng_state) % n_types; + let q0 = (next_rng(&mut rng_state) % n_qubits as u64) as usize; + let q1 = loop { + let q = (next_rng(&mut rng_state) % n_qubits as u64) as usize; + if q != q0 { + break q; + } + }; + match gate_type { + 0 => { + c.h(&[QubitId(q0)]); + } + 1 => { + c.sz(&[QubitId(q0)]); + } + 2 => { + c.x(&[QubitId(q0)]); + } + 3 => { + c.cx(&[(QubitId(q0), QubitId(q1))]); + } + 4 => { + c.cz(&[(QubitId(q0), QubitId(q1))]); + } + 5 => { + c.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(q0)]); + } + 6 => { + let ab = next_rng(&mut rng_state); + c.rz( + Angle64::from_radians((ab % 1000) as f64 * 0.001 * TAU), + &[QubitId(q0)], + ); + } + _ => { + let ab = next_rng(&mut rng_state); + c.rx( + Angle64::from_radians((ab % 1000) as f64 * 0.001 * TAU), + &[QubitId(q0)], + ); + } + } + } + total_nullity += c.nullity(); + total_absorbed += c.absorbed(); + total_grown += c.grown(); + } + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; + let avg_nullity = total_nullity as f64 / n_seeds as f64; + let avg_absorbed = total_absorbed as f64 / n_seeds as f64; + let avg_grown = total_grown as f64 / n_seeds as f64; + println!( + "{label:<20} n={n_qubits} g={n_gates} | absorbed={avg_absorbed:.1} grown={avg_grown:.1} nullity={avg_nullity:.1} bound=2^{avg_nullity:.1} | elapsed={elapsed_ms:.1}ms ({n_seeds} seeds)", + ); +} + +fn run_mast_scenario(label: &str, n_data: usize, n_t: usize, n_seeds: u64) { + let mut total = 0u64; + let mut single = 0u64; + let mut disent = 0u64; + let mut std_path = 0u64; + let mut stabilizer = 0u64; + let mut max_bond_sum = 0u64; + + for seed in 0..n_seeds { + let mut mast = Mast::with_seed(n_data, n_t, seed); + let mut rng_state = seed.wrapping_add(1); + // Scatter H/CX and T gates so ancillas get diverse inputs. + for _ in 0..n_t { + // Random Clifford layer + for _ in 0..3 { + let gt = next_rng(&mut rng_state) % 3; + let q0 = (next_rng(&mut rng_state) % n_data as u64) as usize; + let q1 = loop { + let q = (next_rng(&mut rng_state) % n_data as u64) as usize; + if q != q0 { + break q; + } + }; + match gt { + 0 => { + mast.h(&[QubitId(q0)]); + } + 1 => { + mast.sz(&[QubitId(q0)]); + } + _ => { + mast.cx(&[(QubitId(q0), QubitId(q1))]); + } + } + } + // T gate on random qubit + let tq = (next_rng(&mut rng_state) % n_data as u64) as usize; + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(tq)]); + } + mast.project_all(); + + let s = mast.stats; + total += s.total_nonclifford; + single += s.single_site; + disent += s.multi_disent; + std_path += s.multi_std; + stabilizer += s.stabilizer; + max_bond_sum += mast.max_bond_dim() as u64; + } + + let pct = |x: u64| { + if total == 0 { + 0.0 + } else { + 100.0 * x as f64 / total as f64 + } + }; + let avg_bond = max_bond_sum as f64 / n_seeds as f64; + println!( + "{label:<24} n={n_data} T={n_t} seeds={n_seeds} | \ + total={total} stab={stabilizer} ({:.1}%) single={single} ({:.1}%) disent={disent} ({:.1}%) std={std_path} ({:.1}%) | avg_max_bond={avg_bond:.2}", + pct(stabilizer), + pct(single), + pct(disent), + pct(std_path), + ); +} diff --git a/exp/pecos-stab-tn/examples/qec_bench.rs b/exp/pecos-stab-tn/examples/qec_bench.rs new file mode 100644 index 000000000..2ece57421 --- /dev/null +++ b/exp/pecos-stab-tn/examples/qec_bench.rs @@ -0,0 +1,285 @@ +// 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. + +//! QEC-like benchmark: syndrome-extraction rounds with small RZ noise. +//! +//! Structure per round: +//! 1. CX ladder entangles each data qubit with its ancilla (syndrome extraction). +//! 2. Small-angle RZ noise on every data qubit (decoherence model). +//! 3. CX ladder in reverse. +//! 4. Ancilla measurements (in Z basis). +//! 5. Ancilla resets (prep |0>). +//! +//! Compares wall time + max bond dim across builder knob combinations: +//! - default (eager measure, no adaptive truncation) +//! - `lazy_measure` +//! - `max_truncation_error` +//! - both +//! +//! Usage: `cargo run --release --example qec_bench`. + +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::StabMps; +use pecos_stab_tn::stab_mps::mast::Mast; +use std::time::Instant; + +struct BenchConfig { + num_data: usize, + num_rounds: usize, + noise_angle: Angle64, + lazy: bool, + max_trunc: Option, + merge_rz: bool, + max_bond: usize, + seed: u64, +} + +fn build_and_run(cfg: &BenchConfig) -> (f64, usize, u64) { + let BenchConfig { + num_data, + num_rounds, + noise_angle, + lazy, + max_trunc, + merge_rz, + max_bond, + seed, + } = *cfg; + // Layout: data qubits [0..num_data), ancilla qubits [num_data..2*num_data). + let n = num_data * 2; + let mut builder = StabMps::builder(n) + .seed(seed) + .max_bond_dim(max_bond) + .lazy_measure(lazy) + .merge_rz(merge_rz); + if let Some(e) = max_trunc { + builder = builder.max_truncation_error(e); + } + let mut stn = builder.build(); + + let start = Instant::now(); + let mut outcome_parity: u64 = 0; + + // Simple xorshift for reproducible pseudo-random gate choices. + let mut rng_state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1); + let next_u64 = |s: &mut u64| -> u64 { + *s ^= *s << 13; + *s ^= *s >> 7; + *s ^= *s << 17; + *s + }; + + // Initial H on all data to spread into superposition. + for i in 0..num_data { + stn.h(&[QubitId(i)]); + } + + for _round in 0..num_rounds { + // 1. Long-range CX cascade between random data pairs (mixes entanglement). + for _ in 0..num_data { + let a = (next_u64(&mut rng_state) as usize) % num_data; + let b = (next_u64(&mut rng_state) as usize) % num_data; + if a != b { + stn.cx(&[(QubitId(a), QubitId(b))]); + } + } + // 2. T gates (non-Clifford) on random data qubits. + for _ in 0..num_data { + let q = (next_u64(&mut rng_state) as usize) % num_data; + stn.rz(noise_angle, &[QubitId(q)]); + } + // 3. Entangle each data with its ancilla (syndrome extraction). + for i in 0..num_data { + stn.cx(&[(QubitId(i), QubitId(num_data + i))]); + } + // 4. Ancilla measurements (Z-basis). + for i in 0..num_data { + let outcome = stn.mz(&[QubitId(num_data + i)])[0].outcome; + if outcome { + outcome_parity ^= 1 << (i % 64); + } + } + } + let elapsed = start.elapsed().as_secs_f64(); + let max_bond_dim = stn.max_bond_dim(); + (elapsed, max_bond_dim, outcome_parity) +} + +/// Ion-trap-memory-noise scenario: many small-angle RZs per round on +/// every data qubit (modeling per-step dephasing). RZ batching should +/// merge these consecutive same-qubit RZs into one non-Clifford op. +fn ion_trap_memory_scenario( + num_data: usize, + num_rounds: usize, + noise_per_round: usize, + noise_angle: Angle64, + merge_rz: bool, + seed: u64, +) -> (f64, usize) { + let mut stn = StabMps::builder(num_data) + .seed(seed) + .merge_rz(merge_rz) + .build(); + + // Initial superposition. + for q in 0..num_data { + stn.h(&[QubitId(q)]); + } + + let start = Instant::now(); + for _round in 0..num_rounds { + // Many small-angle RZ noise per qubit (memory error each timestep). + for _ in 0..noise_per_round { + for q in 0..num_data { + stn.rz(noise_angle, &[QubitId(q)]); + } + } + // One Clifford layer per round (e.g., a syndrome-extraction-like CX). + for q in 0..num_data - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + } + stn.flush(); + let elapsed = start.elapsed().as_secs_f64(); + let bond = stn.max_bond_dim(); + (elapsed, bond) +} + +/// MAST-style: T-injection using ancilla pattern, final measurement. +fn mast_scenario(num_qubits: usize, num_t_gates: usize, lazy: bool, seed: u64) -> (f64, usize) { + let mut mast = Mast::with_seed(num_qubits, num_t_gates, seed).with_lazy_measure(lazy); + let t = Angle64::QUARTER_TURN / 2u64; + + let start = Instant::now(); + + // Random Clifford + T circuit + measurement. + for q in 0..num_qubits { + mast.h(&[QubitId(q)]); + } + for q in 0..num_qubits - 1 { + mast.cx(&[(QubitId(q), QubitId(q + 1))]); + } + let mut rng_state = 30000u64 + seed; + for _ in 0..num_t_gates { + rng_state ^= rng_state << 13; + rng_state ^= rng_state >> 7; + rng_state ^= rng_state << 17; + let q = (rng_state % num_qubits as u64) as usize; + mast.rz(t, &[QubitId(q)]); + } + for q in (0..num_qubits - 1).rev() { + mast.cx(&[(QubitId(q + 1), QubitId(q))]); + } + let _ = mast.mz(&[QubitId(0)]); + + let elapsed = start.elapsed().as_secs_f64(); + let bond = mast.mps().max_bond_dim(); + (elapsed, bond) +} + +fn main() { + // Magic-state-distillation-like: T gates per round (non-Clifford-heavy). + let t_angle = Angle64::QUARTER_TURN / 2u64; // T = RZ(π/4) + let num_data = 8; + let num_rounds = 20; + let max_bond = 64; + let seed = 42; + + println!( + "QEC-like bench: {num_data} data qubits, {num_rounds} rounds, T-gate per data per round" + ); + let _ = t_angle; + println!("{:-<90}", ""); + println!( + "{:<40} {:>12} {:>12} {:>20}", + "config", "time (s)", "max bond", "outcome parity" + ); + println!("{:-<90}", ""); + + let configs: &[(&str, bool, Option, bool)] = &[ + ("default", false, None, false), + ("lazy_measure", true, None, false), + ("max_truncation_error=1e-8", false, Some(1e-8), false), + ("merge_rz", false, None, true), + ( + "merge_rz + max_truncation_error=1e-8", + false, + Some(1e-8), + true, + ), + ("for_qec()", false, Some(1e-8), true), + ]; + + for &(name, lazy, trunc, merge) in configs { + let (t, bond, parity) = build_and_run(&BenchConfig { + num_data, + num_rounds, + noise_angle: t_angle, + lazy, + max_trunc: trunc, + merge_rz: merge, + max_bond, + seed, + }); + println!("{name:<40} {t:>12.4} {bond:>12} {parity:>20x}"); + } + + println!("{:-<90}", ""); + println!( + "\nNote: outcome parities differ between lazy/eager because the RNG is consumed in\n different sequences (not a correctness issue — both give the right distribution)." + ); + + // ------------------------------------------------------------------------- + // MAST-style scenario: where lazy_measure actually helps. + // ------------------------------------------------------------------------- + println!(); + println!("MAST-like scenario: deep random Clifford+T measured via ancilla injection"); + println!("{:-<70}", ""); + println!("{:<30} {:>12} {:>12}", "config", "time (s)", "max bond"); + println!("{:-<70}", ""); + + let n_q = 8; + let n_t = 8; + let num_trials = 20; + for (name, lazy) in [("eager", false), ("lazy", true)] { + let mut total_time = 0.0; + let mut total_bond = 0usize; + for trial in 0..num_trials { + let (t, b) = mast_scenario(n_q, n_t, lazy, 20000 + trial as u64); + total_time += t; + total_bond += b; + } + println!( + "{name:<30} {:>12.4} {:>12.1}", + total_time / f64::from(num_trials), + total_bond as f64 / f64::from(num_trials) + ); + } + println!("{:-<70}", ""); + + // ------------------------------------------------------------------------- + // Ion-trap memory noise scenario: where merge_rz actually helps. + // ------------------------------------------------------------------------- + println!(); + println!("Ion-trap memory noise: many small RZs per qubit each round"); + println!("(6 data qubits, 10 rounds, 50 noise RZs/qubit/round, θ=0.01 rad)"); + println!("{:-<70}", ""); + 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)] { + let (t, b) = ion_trap_memory_scenario(6, 10, 50, small_angle, merge, 42); + println!("{name:<30} {t:>12.4} {b:>12}"); + } + println!("{:-<70}", ""); +} diff --git a/exp/pecos-stab-tn/examples/qec_tutorial.rs b/exp/pecos-stab-tn/examples/qec_tutorial.rs new file mode 100644 index 000000000..f0fd6dc37 --- /dev/null +++ b/exp/pecos-stab-tn/examples/qec_tutorial.rs @@ -0,0 +1,231 @@ +// 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. + +//! # QEC tutorial — the STN workflow end-to-end +//! +//! Run with `cargo run --release --example qec_tutorial`. +//! +//! This example walks through a typical quantum-error-correction +//! simulation using `pecos-stab-tn`. Each section maps to one feature +//! of the API; read top-to-bottom to understand how the pieces fit. +//! +//! Contents: +//! 1. Choosing a builder preset for QEC workloads. +//! 2. Defining a stabilizer code (3-qubit bit-flip). +//! 3. Noiseless syndrome extraction and ancilla reuse. +//! 4. Pauli-noise sampling via the `pauli_frame`. +//! 5. Many-round simulation with mid-circuit resets. +//! 6. Interpreting the results. + +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::{PauliKind, StabMps}; +use std::time::Instant; + +fn main() { + println!("============================================================="); + println!(" STN QEC tutorial"); + println!("=============================================================\n"); + + // ------------------------------------------------------------------ + // 1. Builder + preset + // ------------------------------------------------------------------ + // + // `StabMps::builder(n).for_qec().build()` sets: + // - 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 + // fast Pauli-noise injection. + // + // 3 data + 2 ancillas = 5 qubits total. + + let num_data = 3; + let num_ancillas = 2; // one per stabilizer generator + let n = num_data + num_ancillas; + let ancilla_base = num_data; + + let mut stn = StabMps::builder(n) + .seed(42) + .for_qec() + .pauli_frame_tracking(true) + .build(); + + println!("Step 1: built StabMps with for_qec() preset + pauli_frame_tracking"); + println!(" data qubits : 0..{num_data}"); + println!(" ancillas : {num_data}..{n}"); + println!(); + + // ------------------------------------------------------------------ + // 2. Define the code + // ------------------------------------------------------------------ + // + // Each stabilizer generator is a Vec<(qubit_index, PauliKind)>. + // For the 3-qubit bit-flip code: Z_0 Z_1 and Z_1 Z_2. + // + // `extract_syndromes` handles any Pauli generator — mix X/Y/Z freely. + + let stabilizers: Vec> = vec![ + vec![(0, PauliKind::Z), (1, PauliKind::Z)], + vec![(1, PauliKind::Z), (2, PauliKind::Z)], + ]; + let ancilla_qubits: Vec = (ancilla_base..ancilla_base + num_ancillas) + .map(QubitId) + .collect(); + + println!("Step 2: defined 3-qubit bit-flip code stabilizers"); + for (i, s) in stabilizers.iter().enumerate() { + println!(" g{i}: {s:?}"); + } + println!(); + + // ------------------------------------------------------------------ + // 3. Noiseless syndrome extraction + // ------------------------------------------------------------------ + // + // `extract_syndromes(generators, ancilla_qubits)`: + // 1. For each generator, prep_plus (|+⟩) the ancilla. + // 2. Apply controlled-P with ancilla as control. + // 3. H + measure ancilla → syndrome bit. + // 4. reset_qubit(ancilla) so it's ready for the next round. + // + // On the codespace, syndrome must be all-zero. + + // Data state starts at |000⟩ — already in the codespace for bit-flip. + let syndrome = stn.extract_syndromes(&stabilizers, &ancilla_qubits); + println!("Step 3: noiseless syndrome extraction"); + println!(" syndrome = {syndrome:?} (expected all-false)"); + assert!(syndrome.iter().all(|&b| !b)); + println!(); + + // ------------------------------------------------------------------ + // 4. Inject a single X error via the Pauli frame + // ------------------------------------------------------------------ + // + // With pauli_frame_tracking(true), `inject_x_in_frame` is O(1) — + // it toggles a classical bit rather than applying X to the state. + // The bit propagates through Cliffords and flips measurement + // outcomes at read time. + // + // For bulk injection, use `inject_paulis_in_frame(&[(q, Pauli), ...])`. + + println!("Step 4: inject X_0 error and re-extract syndrome"); + stn.inject_x_in_frame(QubitId(0)); + let syndrome = stn.extract_syndromes(&stabilizers, &ancilla_qubits); + println!(" syndrome = {syndrome:?} (expected [true, false]: X_0 triggers Z_0Z_1 only)"); + println!(); + + // ------------------------------------------------------------------ + // 5. Many rounds with random noise + // ------------------------------------------------------------------ + // + // Apply depolarizing noise to each data qubit each round, then + // extract. Frame tracking + merge_rz makes this fast. We count + // how often the syndrome is non-zero as a function of noise rate. + + println!("Step 5: many-round detection rate vs depolarizing rate"); + let num_rounds = 5000; + for &p in &[0.001_f64, 0.005, 0.01, 0.02, 0.05] { + let mut non_zero_syndromes = 0u32; + let mut stn = StabMps::builder(n) + .seed(100 + (p * 1e6) as u64) + .for_qec() + .pauli_frame_tracking(true) + .build(); + + let start = Instant::now(); + for _round in 0..num_rounds { + // Per-round depolarizing on each data qubit. + for q in 0..num_data { + stn.apply_depolarizing(QubitId(q), p); + } + let s = stn.extract_syndromes(&stabilizers, &ancilla_qubits); + if s.iter().any(|&b| b) { + non_zero_syndromes += 1; + } + } + let elapsed = start.elapsed().as_secs_f64(); + let rate = f64::from(non_zero_syndromes) / f64::from(num_rounds); + println!( + " p={p:.3}: detection rate = {rate:.3} ({num_rounds} rounds in {elapsed:.2}s = {:.0} rounds/s)", + f64::from(num_rounds) / elapsed + ); + } + println!(); + + // ------------------------------------------------------------------ + // 6. Ion-trap-style RZ memory noise + // ------------------------------------------------------------------ + // + // Every "gate timestep" each idle qubit picks up a small rz(θ). + // merge_rz accumulates these until some gate or measurement forces + // a flush. Scales well — 25-42× speedup over the eager path. + + println!("Step 6: ion-trap memory noise scenario"); + let num_rounds_ion = 50; + let steps_per_round = 20; + let theta = Angle64::from_radians(0.005); + + let mut stn = StabMps::builder(n) + .seed(77) + .for_qec() + .pauli_frame_tracking(true) + .build(); + let start = Instant::now(); + for _round in 0..num_rounds_ion { + for _step in 0..steps_per_round { + // Pass all data qubits in a single slice call — the rz + // method accumulates into pending_rz[q] for each q at O(1). + let data: Vec = (0..num_data).map(QubitId).collect(); + stn.rz(theta, &data); + } + stn.extract_syndromes(&stabilizers, &ancilla_qubits); + } + let elapsed = start.elapsed().as_secs_f64(); + println!( + " {num_rounds_ion} rounds × {steps_per_round} idle steps each, θ={theta:?}: {elapsed:.3}s" + ); + println!(); + + // ------------------------------------------------------------------ + // 7. Flush the frame before reading exact state + // ------------------------------------------------------------------ + // + // If you want exact state_vector/amplitude readouts (including + // complex phase from Y injections), call `flush_pauli_frame_to_state` + // first. The decomposition-based flush gives EXACT amplitudes even + // on Clifford-evolved states — no ±1 residual. + + let mut stn = StabMps::builder(2) + .seed(1) + .pauli_frame_tracking(true) + .build(); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.inject_y_in_frame(QubitId(0)); + stn.flush_pauli_frame_to_state(); + let sv = stn.state_vector(); + println!("Step 7: Y_0 on a Bell state, exact amplitudes after flush"); + for (i, a) in sv.iter().enumerate() { + println!(" sv[{i}] = {a:.4}"); + } + + println!("\nTutorial done. Key API summary:"); + println!(" - StabMps::builder(n).for_qec().pauli_frame_tracking(true).build()"); + println!(" - extract_syndromes(generators, ancillas)"); + println!(" - reset_qubit(q), pz(q), px(q)"); + println!(" - inject_{{x,y,z}}_in_frame(q), inject_paulis_in_frame(&[...])"); + println!(" - apply_depolarizing(q, p), apply_depolarizing_all(&qs, p)"); + println!(" - flush_pauli_frame_to_state(): exact state_vector after Y frames"); +} diff --git a/exp/pecos-stab-tn/examples/rz_noise_scale.rs b/exp/pecos-stab-tn/examples/rz_noise_scale.rs new file mode 100644 index 000000000..329aea632 --- /dev/null +++ b/exp/pecos-stab-tn/examples/rz_noise_scale.rs @@ -0,0 +1,139 @@ +// 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. + +//! Probe whether there's value in a dedicated batched-RZ-round API beyond +//! the existing `merge_rz` path. Two comparisons: +//! +//! A. Per-qubit loop vs single `rz(theta, &all_qubits)` slice call. +//! Pure Rust-call overhead of the noise-injection loop. +//! B. `merge_rz` on/off at scale, plus the effect of frame tracking for +//! the measurement-phase dominated regime. + +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::StabMps; +use std::time::Instant; + +fn ion_trap_per_qubit_loop( + n: usize, + rounds: usize, + noise_per_round: usize, + theta: Angle64, + merge: bool, + seed: u64, +) -> (f64, usize) { + let mut stn = StabMps::builder(n).seed(seed).merge_rz(merge).build(); + for q in 0..n { + stn.h(&[QubitId(q)]); + } + let start = Instant::now(); + for _round in 0..rounds { + for _ in 0..noise_per_round { + for q in 0..n { + stn.rz(theta, &[QubitId(q)]); + } + } + for q in 0..n - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + } + stn.flush(); + (start.elapsed().as_secs_f64(), stn.max_bond_dim()) +} + +fn ion_trap_slice_call( + n: usize, + rounds: usize, + noise_per_round: usize, + theta: Angle64, + merge: bool, + seed: u64, +) -> (f64, usize) { + let mut stn = StabMps::builder(n).seed(seed).merge_rz(merge).build(); + for q in 0..n { + stn.h(&[QubitId(q)]); + } + let qubits: Vec = (0..n).map(QubitId).collect(); + let start = Instant::now(); + for _round in 0..rounds { + for _ in 0..noise_per_round { + stn.rz(theta, &qubits); + } + for q in 0..n - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + } + stn.flush(); + (start.elapsed().as_secs_f64(), stn.max_bond_dim()) +} + +fn main() { + let theta = Angle64::from_radians(0.01); + + println!("Ion-trap memory noise — scaling + per-qubit-loop vs slice-call"); + println!("{:-<80}", ""); + println!( + "{:<30} {:>10} {:>12} {:>12}", + "config", "time (s)", "max bond", "rz calls" + ); + + // small + medium: compare merge_rz on/off. + for &(n, rounds, noise) in &[(12, 10, 20), (12, 20, 30)] { + let total_rz = rounds * noise * n; + println!("\n-- n={n}, rounds={rounds}, noise/round={noise} ({total_rz} rz calls)"); + let (t_off_loop, b_off) = ion_trap_per_qubit_loop(n, rounds, noise, theta, false, 42); + println!( + " {:<28} {:>10.4} {:>12}", + "merge_rz=OFF (loop)", t_off_loop, b_off + ); + let (t_on_loop, b_on) = ion_trap_per_qubit_loop(n, rounds, noise, theta, true, 42); + println!( + " {:<28} {:>10.4} {:>12} speedup {:.1}x", + "merge_rz=ON (loop)", + t_on_loop, + b_on, + t_off_loop / t_on_loop + ); + let (t_on_slice, b_on2) = ion_trap_slice_call(n, rounds, noise, theta, true, 42); + println!( + " {:<28} {:>10.4} {:>12} vs loop {:.2}x", + "merge_rz=ON (slice)", + t_on_slice, + b_on2, + t_on_loop / t_on_slice + ); + } + + // Medium-scale merge_rz=ON only (OFF is hours at these sizes). + { + let &(n, rounds, noise) = &(16, 20, 30); + let total_rz = rounds * noise * n; + println!( + "\n-- n={n}, rounds={rounds}, noise/round={noise} ({total_rz} rz calls, merge_rz=ON)" + ); + let (t_on_loop, b_on) = ion_trap_per_qubit_loop(n, rounds, noise, theta, true, 42); + println!(" {:<28} {:>10.4} {:>12}", "loop", t_on_loop, b_on); + let (t_on_slice, b_on2) = ion_trap_slice_call(n, rounds, noise, theta, true, 42); + println!( + " {:<28} {:>10.4} {:>12} vs loop {:.2}x", + "slice", + t_on_slice, + b_on2, + t_on_loop / t_on_slice + ); + } + + println!("\n{:-<80}", ""); + println!("Conclusion: merge_rz gives the dominant speedup. Passing all qubits in one"); + println!("slice call vs looping per-qubit makes no significant difference — the"); + println!("pending_rz accumulator is the hot path and already O(1) per rz invocation."); +} diff --git a/exp/pecos-stab-tn/examples/steane_code_demo.rs b/exp/pecos-stab-tn/examples/steane_code_demo.rs new file mode 100644 index 000000000..ebf7a9e8c --- /dev/null +++ b/exp/pecos-stab-tn/examples/steane_code_demo.rs @@ -0,0 +1,365 @@ +// 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. + +//! End-to-end QEC code demo using STN. +//! +//! Demonstrates the toolchain on three increasingly complex stabilizer codes: +//! +//! - 3-qubit bit-flip code (2 stabilizers): protects against single X errors. +//! - 8-qubit Z-rep code (7 stabilizers): scales to larger generator counts. +//! - GHZ state with global parity (1 stabilizer of all Zs): tests +//! entanglement-aware fidelity. +//! +//! For each code: +//! - Prepare |`0_L`⟩ via Clifford circuit. +//! - Verify codespace fidelity = 1.0 using `StabMps::code_state_fidelity`. +//! - Inject per-qubit depolarizing noise via +//! `StabMps::apply_depolarizing_all`, observe the fidelity drop. +//! - Confirm `for_qec()` preset and `auto_grow_bond_dim` work. +//! +//! Now also includes a correct Steane [[7, 1, 3]] CSS code encoder + +//! codespace fidelity verification. + +use pecos_core::QubitId; +use pecos_simulators::CliffordGateable; +use pecos_stab_tn::stab_mps::{PauliKind, StabMps}; +use std::time::Instant; + +/// 3-qubit bit-flip code: |`0_L`⟩ = |000⟩, |`1_L`⟩ = |111⟩. +/// Stabilizers: `Z_0Z_1`, `Z_1Z_2`. +fn bit_flip_3q_stabilizers() -> Vec> { + vec![ + vec![(0, PauliKind::Z), (1, PauliKind::Z)], + vec![(1, PauliKind::Z), (2, PauliKind::Z)], + ] +} + +/// N-qubit Z-repetition code: stabilizers `Z_iZ`_{i+1} for i = 0..n-2. +fn z_rep_stabilizers(n: usize) -> Vec> { + (0..n - 1) + .map(|i| vec![(i, PauliKind::Z), (i + 1, PauliKind::Z)]) + .collect() +} + +/// Steane [[7, 1, 3]] CSS code stabilizers. Based on Hamming [7,4,3] +/// parity-check matrix +/// H = [[0,0,0,1,1,1,1], +/// [0,1,1,0,0,1,1], +/// [1,0,1,0,1,0,1]] +/// X-stabilizers = {g1, g2, g3} from each row's X-support; Z-stabilizers +/// = {g4, g5, g6} from each row's Z-support (self-dual CSS). +fn steane_stabilizers() -> Vec> { + vec![ + // X-type + vec![ + (3, PauliKind::X), + (4, PauliKind::X), + (5, PauliKind::X), + (6, PauliKind::X), + ], + vec![ + (1, PauliKind::X), + (2, PauliKind::X), + (5, PauliKind::X), + (6, PauliKind::X), + ], + vec![ + (0, PauliKind::X), + (2, PauliKind::X), + (4, PauliKind::X), + (6, PauliKind::X), + ], + // Z-type + vec![ + (3, PauliKind::Z), + (4, PauliKind::Z), + (5, PauliKind::Z), + (6, PauliKind::Z), + ], + vec![ + (1, PauliKind::Z), + (2, PauliKind::Z), + (5, PauliKind::Z), + (6, PauliKind::Z), + ], + vec![ + (0, PauliKind::Z), + (2, PauliKind::Z), + (4, PauliKind::Z), + (6, PauliKind::Z), + ], + ] +} + +/// Prepare the logical |`0_L`⟩ of the Steane [[7, 1, 3]] CSS code using a +/// standard CX-cascade encoder (no ancillas). Pivots are chosen as +/// qubits {0, 1, 3} — each belongs to exactly one X-stabilizer, so +/// they can Hadamard independently and CX outward without cross- +/// contamination. +fn prepare_steane_logical_zero(stn: &mut StabMps) { + // H on pivots. + stn.h(&[QubitId(0), QubitId(1), QubitId(3)]); + // g1 = X_3X_4X_5X_6: CX from pivot 3 to 4, 5, 6. + stn.cx(&[(QubitId(3), QubitId(4))]); + stn.cx(&[(QubitId(3), QubitId(5))]); + stn.cx(&[(QubitId(3), QubitId(6))]); + // g2 = X_1X_2X_5X_6: CX from pivot 1 to 2, 5, 6. + stn.cx(&[(QubitId(1), QubitId(2))]); + stn.cx(&[(QubitId(1), QubitId(5))]); + stn.cx(&[(QubitId(1), QubitId(6))]); + // g3 = X_0X_2X_4X_6: CX from pivot 0 to 2, 4, 6. + stn.cx(&[(QubitId(0), QubitId(2))]); + stn.cx(&[(QubitId(0), QubitId(4))]); + stn.cx(&[(QubitId(0), QubitId(6))]); +} + +/// Extract the Steane syndrome via one ancilla per stabilizer generator +/// (6 ancillas total for 7 data + 6 ancilla = 13 qubit layout). For each +/// X-stabilizer, prep ancilla in |+⟩ via H, apply CX from ancilla to +/// each qubit in the stabilizer support, then measure ancilla in X +/// basis (H + Z-measurement). For each Z-stabilizer, prep ancilla in +/// |0⟩, apply CX from each data qubit in support to ancilla, measure +/// ancilla in Z basis. +/// +/// Returns the 6-bit syndrome (MSB first: g1..g6 in order of +/// `steane_stabilizers()`). +fn steane_syndrome_extraction(stn: &mut StabMps, ancilla_base: usize) -> [bool; 6] { + let mut syndrome = [false; 6]; + let stabs = steane_stabilizers(); + for (i, generator) in stabs.iter().enumerate() { + let anc = QubitId(ancilla_base + i); + let is_x_type = generator.iter().all(|&(_, k)| k == PauliKind::X); + if is_x_type { + // Prep |+⟩ ancilla, CX(anc, data) for each data in support, + // measure in X basis (H + mz). + stn.h(&[anc]); + for &(q, _) in generator { + stn.cx(&[(anc, QubitId(q))]); + } + stn.h(&[anc]); + syndrome[i] = stn.mz(&[anc])[0].outcome; + } else { + // Z-type: prep |0⟩, CX(data, anc) for each data, measure Z. + for &(q, _) in generator { + stn.cx(&[(QubitId(q), anc)]); + } + syndrome[i] = stn.mz(&[anc])[0].outcome; + } + } + syndrome +} + +/// Run a Steane prep + noise + syndrome extraction cycle across many shots. +/// Returns (`detection_count`, `any_errors_count)`: +/// - `detection_count`: shots where the syndrome is non-zero. +/// - `any_errors_count`: shots where depolarizing injected at least one non-I error. +fn steane_syndrome_detection_rate(p_noise: f64, num_shots: u64) -> (usize, usize) { + let mut detections = 0; + let mut any_errors = 0; + for shot in 0..num_shots { + // 7 data + 6 ancillas = 13 qubits. + let mut stn = StabMps::builder(13).seed(shot).for_qec().build(); + prepare_steane_logical_zero(&mut stn); + // Inject per-data-qubit depolarizing. + let data_qubits: Vec = (0..7).map(QubitId).collect(); + let mut had_error = false; + for &q in &data_qubits { + if stn.apply_depolarizing(q, p_noise).is_some() { + had_error = true; + } + } + if had_error { + any_errors += 1; + } + let syndrome = steane_syndrome_extraction(&mut stn, 7); + let syndrome_nonzero = syndrome.iter().any(|&b| b); + if had_error && syndrome_nonzero { + detections += 1; + } + } + (detections, any_errors) +} + +/// GHZ state stabilizers: `X_0X_1...X`_{n-1}, `Z_iZ`_{i+1} for each pair. +fn ghz_stabilizers(n: usize) -> Vec> { + let mut stabs: Vec> = (0..n - 1) + .map(|i| vec![(i, PauliKind::Z), (i + 1, PauliKind::Z)]) + .collect(); + stabs.push((0..n).map(|i| (i, PauliKind::X)).collect()); + stabs +} + +fn run_code_scenario( + name: &str, + num_qubits: usize, + stabs: &[Vec<(usize, PauliKind)>], + prep: impl Fn(&mut StabMps), + p_noise: f64, + num_shots: usize, +) { + println!(); + println!( + "=== {name} ({num_qubits} qubits, {} stabilizer generators) ===", + stabs.len() + ); + + // Phase 1: noiseless prep + codespace fidelity check. + let start = Instant::now(); + let mut stn = StabMps::builder(num_qubits).seed(42).for_qec().build(); + prep(&mut stn); + let prep_time = start.elapsed().as_secs_f64(); + let f_clean = stn.code_state_fidelity(stabs); + println!("Phase 1: noiseless prep"); + println!(" prep + fidelity time: {prep_time:.4} s"); + println!(" fidelity: {f_clean:.6} (expected 1.0)"); + if (f_clean - 1.0).abs() > 1e-9 { + println!(" WARNING: prep circuit does not produce |0_L⟩"); + return; + } + + // Phase 2: prep + depolarizing noise, average across shots. + let start = Instant::now(); + let mut total_fidelity = 0.0; + let qubits: Vec = (0..num_qubits).map(QubitId).collect(); + for shot in 0..num_shots { + let mut stn_noisy = StabMps::builder(num_qubits) + .seed(100 + shot as u64) + .for_qec() + .build(); + prep(&mut stn_noisy); + stn_noisy.apply_depolarizing_all(&qubits, p_noise); + total_fidelity += stn_noisy.code_state_fidelity(stabs); + } + let avg_fidelity = total_fidelity / num_shots as f64; + let noisy_time = start.elapsed().as_secs_f64(); + println!("Phase 2: prep + per-qubit depolarizing (p = {p_noise:.3})"); + println!( + " total time: {noisy_time:.4} s ({:.2} ms/shot)", + noisy_time * 1000.0 / num_shots as f64 + ); + println!(" avg fidelity: {avg_fidelity:.6}"); + println!( + " drop: {:.4} (1.0 - avg_fidelity)", + 1.0 - avg_fidelity + ); +} + +fn main() { + println!("End-to-end QEC code demo using STN"); + println!("{:-<70}", ""); + println!( + "Toolchain: StabMps::builder().for_qec() + apply_depolarizing_all + code_state_fidelity" + ); + + // 3-qubit bit-flip code: trivial prep (|000⟩ already in code). + run_code_scenario( + "3-qubit bit-flip code (Z-rep, k=2)", + 3, + &bit_flip_3q_stabilizers(), + |_stn| { /* |000⟩ default state */ }, + 0.05, + 500, + ); + + // 8-qubit Z-repetition code: |0^N⟩ prep is trivial. + run_code_scenario( + "8-qubit Z-repetition code (k=7)", + 8, + &z_rep_stabilizers(8), + |_stn| {}, + 0.02, + 200, + ); + + // GHZ state on 6 qubits: H + CX cascade. + run_code_scenario( + "6-qubit GHZ state (k=6: 5 ZZ + 1 XX...X)", + 6, + &ghz_stabilizers(6), + |stn| { + stn.h(&[QubitId(0)]); + for q in 0..5 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + }, + 0.01, + 200, + ); + + // Steane [[7, 1, 3]] code: standard CSS encoder. + run_code_scenario( + "Steane [[7, 1, 3]] CSS code (k=6: 3 X-type + 3 Z-type)", + 7, + &steane_stabilizers(), + prepare_steane_logical_zero, + 0.01, + 200, + ); + + // --- Steane syndrome extraction with ancillas --- + println!(); + println!("{:-<70}", ""); + println!("Steane syndrome extraction cycle (prep + noise + ancilla syndrome):"); + println!(" Detection ratio (syndrome non-zero / noise injected):"); + let num_cycles = 500; + for &p in &[0.001_f64, 0.005, 0.01, 0.02, 0.05] { + let (detections, any_errors) = steane_syndrome_detection_rate(p, num_cycles); + let detection_ratio = if any_errors == 0 { + 0.0 + } else { + detections as f64 / any_errors as f64 + }; + println!( + " p={p:.3}: {detections}/{any_errors} noisy cycles triggered syndrome ({:.1}%)", + detection_ratio * 100.0 + ); + } + + // --- Pauli frame tracking: noise-injection speedup at scale --- + println!(); + println!("{:-<70}", ""); + println!("Pauli frame tracking: noise-injection scaling (n=32, 10k injections):"); + let n_large = 32; + let num_injects = 10_000; + + let start = Instant::now(); + let mut stn_eager = StabMps::builder(n_large).seed(42).build(); + for _ in 0..num_injects { + stn_eager.apply_depolarizing(QubitId(0), 1.0); + } + let t_eager = start.elapsed().as_secs_f64(); + + let start = Instant::now(); + let mut stn_frame = StabMps::builder(n_large) + .seed(42) + .pauli_frame_tracking(true) + .build(); + for _ in 0..num_injects { + stn_frame.apply_depolarizing(QubitId(0), 1.0); + } + let t_frame = start.elapsed().as_secs_f64(); + + println!(" eager (apply to tableau): {t_eager:.4} s"); + println!(" frame tracking (O(1) per inj): {t_frame:.4} s"); + println!( + " speedup: {:.2}×", + t_eager / t_frame + ); + + println!(); + println!("{:-<70}", ""); + println!("Demo complete. The StabMps::for_qec() preset plus apply_depolarizing_all"); + println!("+ code_state_fidelity gives a complete API for QEC code-state"); + println!("verification + noise impact studies. Adding pauli_frame_tracking"); + println!("eliminates per-injection tableau overhead — useful for noise-heavy"); + println!("shot sweeps."); +} diff --git a/exp/pecos-stab-tn/examples/tier3_profile.rs b/exp/pecos-stab-tn/examples/tier3_profile.rs new file mode 100644 index 000000000..edfaf2e14 --- /dev/null +++ b/exp/pecos-stab-tn/examples/tier3_profile.rs @@ -0,0 +1,152 @@ +// 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. + +//! Tier 3 profiling: measure absolute cost of the operations we'd +//! optimize with `cliff_frame` deferral, sub-MPO long-range, and CD +//! Loschmidt Method 2. Decides whether each Tier 3 item is worth the +//! implementation effort. +//! +//! Usage: `cargo run --release --example tier3_profile`. + +use pecos_core::QubitId; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::StabMps; +use std::time::Instant; + +fn bench(label: &str, ops: usize, f: impl FnOnce()) { + let start = Instant::now(); + f(); + let elapsed = start.elapsed().as_secs_f64(); + let per_op_us = elapsed * 1e6 / ops as f64; + println!(" {label:<48} {elapsed:>8.4} s ({per_op_us:>6.2} µs/op × {ops})"); +} + +fn main() { + use pecos_simulators::CHForm; + println!("Tier 3 profiling -- measure where cliff_frame / sub-MPO / CD2 would help"); + println!("{:-<80}", ""); + + // ---- 1. Single-qubit Clifford cost (cliff_frame target) ---- + println!(); + println!("1. Single-qubit Clifford batching candidate (cliff_frame target):"); + println!(" Question: how much of QEC time is spent on single-qubit Cliffords?"); + + let n = 32; + let num_ops = 100_000; + let mut stn = StabMps::builder(n).seed(42).build(); + bench("H × 100k on random qubits", num_ops, || { + let mut rng = 12345u64; + for _ in 0..num_ops { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + let q = (rng as usize) % n; + stn.h(&[QubitId(q)]); + } + }); + + let mut stn = StabMps::builder(n).seed(42).build(); + bench("SZ × 100k on random qubits", num_ops, || { + let mut rng = 54321u64; + for _ in 0..num_ops { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + let q = (rng as usize) % n; + stn.sz(&[QubitId(q)]); + } + }); + + // ---- 2. Long-range CX/CZ cost (sub-MPO target) ---- + // For Clifford gates, tableau handles long-range in O(n) regardless. + // Sub-MPO would help only for NON-CLIFFORD ops applied to an entangled + // MPS where the pre_reduce path needs long-range compensation — but + // we've already switched to the pragmatic-fix path that avoids this. + println!(); + println!("2. Long-range 2-qubit Clifford cost (sub-MPO target):"); + println!(" Question: does long-range CX hurt STN, given the tableau-only path?"); + + let n = 32; + let num_ops = 100_000; + let mut stn = StabMps::builder(n).seed(42).build(); + bench("CX(0, n/2) × 100k (max-distance)", num_ops, || { + for _ in 0..num_ops { + stn.cx(&[(QubitId(0), QubitId(n / 2))]); + } + }); + + let mut stn = StabMps::builder(n).seed(42).build(); + bench("CX(0, 1) × 100k (adjacent)", num_ops, || { + for _ in 0..num_ops { + stn.cx(&[(QubitId(0), QubitId(1))]); + } + }); + + // Long-range CX inside a non-Clifford path (where it could matter). + println!(); + println!(" Non-Clifford workload where MPS bond activity dominates:"); + + let n = 12; + let num_rounds = 50; + let mut stn = StabMps::builder(n).seed(42).for_qec().build(); + let t = pecos_core::Angle64::QUARTER_TURN / 2u64; + bench("T+longrange-CX round × 50 (n=12)", num_rounds, || { + for _ in 0..num_rounds { + for q in 0..n { + stn.rz(t, &[QubitId(q)]); + } + for q in 0..n / 2 { + stn.cx(&[(QubitId(q), QubitId(n - 1 - q))]); + } + } + }); + println!(" final bond dim: {}", stn.max_bond_dim()); + + // ---- 3. MC overlap_with_stabilizer cost (CD Loschmidt target) ---- + println!(); + println!("3. MC overlap_with_stabilizer (CD Loschmidt Method 1 we have):"); + println!(" Question: is the MC variance a bottleneck for code-state fidelity?"); + + let n = 20; + let mut s = CHForm::new_with_seed(n, 42); + s.h(&[QubitId(0)]); + for q in 0..n - 1 { + s.cx(&[(QubitId(q), QubitId(q + 1))]); + } + let mut stn = StabMps::with_seed(n, 7); + stn.h(&[QubitId(0)]); + for q in 0..n - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + + let num_samples_small = 100; + let num_samples_medium = 1000; + let num_samples_large = 10_000; + + bench("overlap n=20 GHZ (100 samples)", 1, || { + let _ = stn.overlap_with_stabilizer(&s, num_samples_small, None); + }); + bench("overlap n=20 GHZ (1k samples)", 1, || { + let _ = stn.overlap_with_stabilizer(&s, num_samples_medium, None); + }); + bench("overlap n=20 GHZ (10k samples)", 1, || { + let _ = stn.overlap_with_stabilizer(&s, num_samples_large, None); + }); + + println!(); + println!("{:-<80}", ""); + println!("Interpretation:"); + println!(" 1. If single-qubit Clifford time << total circuit time → cliff_frame skip."); + println!(" 2. If long-range CX time ≈ adjacent CX time → sub-MPO skip."); + println!(" 3. If overlap scales linearly with samples → CD Method 2 (deterministic)"); + println!(" is worthwhile only if we need << sampling error at fixed compute."); +} diff --git a/exp/pecos-stab-tn/src/errors.rs b/exp/pecos-stab-tn/src/errors.rs new file mode 100644 index 000000000..56ed05d6c --- /dev/null +++ b/exp/pecos-stab-tn/src/errors.rs @@ -0,0 +1,32 @@ +// 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 thiserror::Error; + +#[derive(Error, Debug)] +pub enum MpsError { + #[error("site index {index} out of bounds (num_sites = {num_sites})")] + SiteOutOfBounds { index: usize, num_sites: usize }, + + #[error("gate dimension mismatch: expected {expected}x{expected}, got {rows}x{cols}")] + GateDimMismatch { + expected: usize, + rows: usize, + cols: usize, + }, + + #[error("SVD failed to converge")] + SvdFailed, + + #[error("sites {q0} and {q1} are not adjacent")] + NonAdjacentSites { q0: usize, q1: usize }, +} diff --git a/exp/pecos-stab-tn/src/lib.rs b/exp/pecos-stab-tn/src/lib.rs new file mode 100644 index 000000000..9e1b92d40 --- /dev/null +++ b/exp/pecos-stab-tn/src/lib.rs @@ -0,0 +1,32 @@ +// 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. + +//! Hybrid stabilizer + tensor network simulation methods. +//! +//! This crate provides experimental implementations of methods that combine +//! Clifford/stabilizer tracking with tensor network (MPS) representations: +//! +//! - **MPS**: Matrix Product State engine (SVD truncation, gate application, contraction) +//! - **STN**: Stabilizer Tensor Networks (tableau + MPS coefficients) +//! - **MAST**: Magic state injection Augmented STN (deferred non-Clifford cost) +//! +//! # References +//! +//! - Masot-Llima, Garcia-Saez. "Stabilizer Tensor Networks: Universal Quantum Simulator +//! on a Basis of Stabilizer States." PRL 133, 230601 (2024). arXiv:2403.08724. +//! - Nakhl, Harper, West, Dowling, Sevior, Quella, Usman. "Stabilizer Tensor Networks +//! with Magic State Injection." PRL 134, 190602 (2025). arXiv:2411.12482. +//! - Reference implementation: + +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 new file mode 100644 index 000000000..f73ca4e20 --- /dev/null +++ b/exp/pecos-stab-tn/src/mps.rs @@ -0,0 +1,1385 @@ +// 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. + +//! Matrix Product State (MPS) engine. +//! +//! An MPS represents a quantum state as a chain of tensors: +//! +//! ```text +//! |psi> = sum_{s_0, ..., s_{N-1}} A[0]^{s_0} A[1]^{s_1} ... A[N-1]^{s_{N-1}} |s_0 s_1 ... s_{N-1}> +//! ``` +//! +//! Each site tensor `A[i]^{s_i}` is a matrix of shape `(chi_left, chi_right)`. +//! For all physical indices `s_i` together, site `i` is stored as a single +//! `DMatrix` of shape `(chi_left, d * chi_right)`, where columns +//! `[s * chi_right .. (s+1) * chi_right]` correspond to physical index `s`. + +pub mod canon; +pub mod svd; +pub mod tensor; + +use crate::errors::MpsError; +use nalgebra::DMatrix; +use num_complex::Complex64; +use rayon::prelude::*; +use tensor::{ + contract_two_sites, phys_block, reshape_left_ungroup, reshape_two_site_for_svd, set_phys_block, +}; + +/// Configuration for MPS truncation. +#[derive(Clone, Debug)] +pub struct MpsConfig { + /// Maximum bond dimension (hard cap). Singular values beyond this are discarded. + pub max_bond_dim: usize, + /// Minimum singular value to keep (absolute cutoff). + pub svd_cutoff: f64, + /// Maximum relative truncation error per SVD. + /// When set, singular values are kept until the discarded weight + /// (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). + pub max_truncation_error: Option, + /// Use rayon for parallelizing independent MPS operations. + pub parallel: bool, +} + +impl Default for MpsConfig { + fn default() -> Self { + Self { + max_bond_dim: 64, + svd_cutoff: 1e-12, + max_truncation_error: None, + parallel: false, + } + } +} + +/// Matrix Product State with open boundary conditions. +/// +/// Physical dimension is `d` (2 for qubits). Site tensor `i` has shape +/// `(bond_dims[i], d * bond_dims[i+1])`. +pub struct Mps { + num_sites: usize, + phys_dim: usize, + tensors: Vec>, + /// Bond dimensions: length `num_sites + 1`. + /// `bond_dims[0] = 1` (left boundary), `bond_dims[num_sites] = 1` (right boundary). + bond_dims: Vec, + config: MpsConfig, + /// Accumulated truncation error: `1 - ∏(1 - step_discarded_weight)`. + /// Approximates total 1-fidelity loss from SVD truncations over the lifetime + /// of this MPS. Each truncated SVD updates this via + /// `err = err + (1 - err) * step_discarded_weight`. + truncation_error: f64, + /// Number of SVDs that were capped by `max_bond_dim` (rank-limited rather + /// than cutoff-limited). If > 0 the caller may want to raise `max_bond_dim`. + bond_cap_hits: u64, +} + +impl Mps { + /// Create an MPS initialized to |00...0> with bond dimension 1 everywhere. + #[must_use] + pub fn new(num_sites: usize, config: MpsConfig) -> Self { + let d = 2; + let bond_dims = vec![1; num_sites + 1]; + let mut tensors = Vec::with_capacity(num_sites); + for _ in 0..num_sites { + // Each tensor is (1, d*1) = (1, 2), representing [1, 0] (amplitude 1 for |0>) + let mut t = DMatrix::zeros(1, d); + t[(0, 0)] = Complex64::new(1.0, 0.0); + tensors.push(t); + } + Self { + num_sites, + phys_dim: d, + tensors, + bond_dims, + config, + truncation_error: 0.0, + bond_cap_hits: 0, + } + } + + /// Accumulated truncation error: `1 - ∏(1 - step_discarded_weight)`. + /// Zero for exact simulations; bounded above by the sum of per-step + /// discarded weights. Approximates `1 - |⟨ψ_true|ψ_truncated⟩|²`. + #[must_use] + pub fn truncation_error(&self) -> f64 { + self.truncation_error + } + + /// Count of SVDs where the `max_bond_dim` cap was binding. If > 0 the + /// state is under-resolved and the user may want to increase the cap. + #[must_use] + pub fn bond_cap_hits(&self) -> u64 { + self.bond_cap_hits + } + + /// Reset truncation diagnostics (keep state). + pub fn reset_truncation_stats(&mut self) { + self.truncation_error = 0.0; + self.bond_cap_hits = 0; + } + + /// Record the outcome of one truncated SVD for telemetry. + pub(crate) fn record_truncation(&mut self, discarded_weight: f64, hit_cap: bool) { + if discarded_weight > 0.0 { + self.truncation_error += (1.0 - self.truncation_error) * discarded_weight; + } + if hit_cap { + self.bond_cap_hits += 1; + } + } + + #[must_use] + pub fn num_sites(&self) -> usize { + self.num_sites + } + + #[must_use] + pub fn phys_dim(&self) -> usize { + self.phys_dim + } + + /// Bond dimension at bond `i` (between sites `i-1` and `i`). + #[must_use] + pub fn bond_dim(&self, bond: usize) -> usize { + self.bond_dims[bond] + } + + #[must_use] + pub fn max_bond_dim(&self) -> usize { + *self.bond_dims.iter().max().unwrap_or(&1) + } + + #[must_use] + pub fn config(&self) -> &MpsConfig { + &self.config + } + + /// Update the max bond dimension cap. Used by adaptive bond-dim + /// auto-grow logic (e.g., `StabMps::auto_grow_bond_dim_if_needed`). + /// Does not retroactively change existing tensors; takes effect on + /// subsequent SVD truncations. + pub fn set_max_bond_dim(&mut self, new_cap: usize) { + self.config.max_bond_dim = new_cap; + } + + /// Multiply the entire MPS by a scalar (absorbed into the first tensor). + pub fn scale(&mut self, scalar: Complex64) { + if self.tensors.is_empty() { + return; + } + self.tensors[0] *= scalar; + } + + /// Apply a single-site gate (d x d unitary matrix) to site `q`. + /// + /// For each pair of physical indices (`sigma_out`, `sigma_in)`: + /// A'[`alpha_l`, `sigma_out`, `alpha_r`] = sum_{`sigma_in`} gate[`sigma_out`, `sigma_in`] * A[`alpha_l`, `sigma_in`, `alpha_r`] + /// + /// # Errors + /// + /// Returns [`MpsError::GateDimMismatch`] if the gate dimensions don't match the + /// physical dimension, or [`MpsError::SiteOutOfBounds`] if `q` is out of range. + pub fn apply_one_site_gate( + &mut self, + q: usize, + gate: &DMatrix, + ) -> Result<(), MpsError> { + let d = self.phys_dim; + if gate.nrows() != d || gate.ncols() != d { + return Err(MpsError::GateDimMismatch { + expected: d, + rows: gate.nrows(), + cols: gate.ncols(), + }); + } + if q >= self.num_sites { + return Err(MpsError::SiteOutOfBounds { + index: q, + num_sites: self.num_sites, + }); + } + + let chi_r = self.bond_dims[q + 1]; + + // Collect old blocks + let old_blocks: Vec> = (0..d) + .map(|s| phys_block(&self.tensors[q], s, chi_r)) + .collect(); + + // Compute new blocks: new_block[sigma_out] = sum_sigma_in gate[sigma_out, sigma_in] * old_block[sigma_in] + for sigma_out in 0..d { + let mut new_block = DMatrix::zeros(self.bond_dims[q], chi_r); + for (sigma_in, old_block) in old_blocks.iter().enumerate() { + let coeff = gate[(sigma_out, sigma_in)]; + if coeff != Complex64::new(0.0, 0.0) { + new_block += old_block * coeff; + } + } + set_phys_block(&mut self.tensors[q], sigma_out, chi_r, &new_block); + } + Ok(()) + } + + /// Apply a diagonal single-site gate: diag(c0, c1, ...) to site `q`. + /// + /// Just scales each physical block by the corresponding coefficient. + /// + /// # Errors + /// + /// Returns [`MpsError::GateDimMismatch`] if `coeffs.len()` differs from the + /// physical dimension, or [`MpsError::SiteOutOfBounds`] if `q` is out of range. + pub fn apply_diagonal_one_site( + &mut self, + q: usize, + coeffs: &[Complex64], + ) -> Result<(), MpsError> { + let d = self.phys_dim; + if coeffs.len() != d { + return Err(MpsError::GateDimMismatch { + expected: d, + rows: d, + cols: d, + }); + } + if q >= self.num_sites { + return Err(MpsError::SiteOutOfBounds { + index: q, + num_sites: self.num_sites, + }); + } + + let chi_r = self.bond_dims[q + 1]; + for (sigma, &c) in coeffs.iter().enumerate() { + let start_col = sigma * chi_r; + for j in 0..chi_r { + for i in 0..self.bond_dims[q] { + self.tensors[q][(i, start_col + j)] *= c; + } + } + } + Ok(()) + } + + /// Apply a two-site gate (d^2 x d^2 matrix) to adjacent sites (q, q+1). + /// + /// The gate acts on the combined physical space of both sites. + /// Row/column index = `sigma_l * d + sigma_r`. + /// + /// After applying the gate, the two-site tensor is split via SVD with truncation. + /// + /// # Errors + /// + /// Returns [`MpsError::GateDimMismatch`] if the gate isn't d^2 x d^2, + /// [`MpsError::SiteOutOfBounds`] if q+1 exceeds the chain, or + /// [`MpsError::SvdFailed`] if the SVD decomposition fails. + pub fn apply_two_site_gate( + &mut self, + q: usize, + gate: &DMatrix, + ) -> Result<(), MpsError> { + let d = self.phys_dim; + let d2 = d * d; + if gate.nrows() != d2 || gate.ncols() != d2 { + return Err(MpsError::GateDimMismatch { + expected: d2, + rows: gate.nrows(), + cols: gate.ncols(), + }); + } + if q + 1 >= self.num_sites { + return Err(MpsError::NonAdjacentSites { q0: q, q1: q + 1 }); + } + + let chi_l = self.bond_dims[q]; + let chi_mid = self.bond_dims[q + 1]; + let chi_r = self.bond_dims[q + 2]; + + // Contract the two site tensors into a two-site tensor + let two_site = contract_two_sites( + &self.tensors[q], + chi_l, + chi_mid, + &self.tensors[q + 1], + chi_r, + d, + ); + + // Apply the gate to the physical indices + // two_site: (chi_l, d * d * chi_r) + // We need to contract gate[sigma_l_out * d + sigma_r_out, sigma_l_in * d + sigma_r_in] + // with two_site[alpha_l, sigma_l_in * d * chi_r + sigma_r_in * chi_r + alpha_r] + let mut gated = DMatrix::zeros(chi_l, d * d * chi_r); + for alpha_l in 0..chi_l { + for alpha_r in 0..chi_r { + for sigma_l_out in 0..d { + for sigma_r_out in 0..d { + let mut val = Complex64::new(0.0, 0.0); + for sigma_l_in in 0..d { + for sigma_r_in in 0..d { + let gate_val = gate + [(sigma_l_out * d + sigma_r_out, sigma_l_in * d + sigma_r_in)]; + if gate_val != Complex64::new(0.0, 0.0) { + let in_col = (sigma_l_in * d + sigma_r_in) * chi_r + alpha_r; + val += gate_val * two_site[(alpha_l, in_col)]; + } + } + } + let out_col = (sigma_l_out * d + sigma_r_out) * chi_r + alpha_r; + gated[(alpha_l, out_col)] = val; + } + } + } + } + + // Reshape for SVD: (chi_l * d, d * chi_r) + let svd_matrix = reshape_two_site_for_svd(&gated, chi_l, chi_r, d); + + // SVD split with truncation + let (u_s, vt, disc, hit) = svd::truncated_svd_left_absorb_with_error( + &svd_matrix, + self.config.max_bond_dim, + self.config.svd_cutoff, + self.config.max_truncation_error, + )?; + self.record_truncation(disc, hit); + + let new_chi = u_s.ncols(); + + // U_S: (chi_l * d, new_chi) -> reshape to (chi_l, d * new_chi) + self.tensors[q] = reshape_left_ungroup(&u_s, chi_l, d, new_chi); + + // Vt: (new_chi, d * chi_r) -- already in site tensor format + self.tensors[q + 1] = vt; + + // Update bond dimension + self.bond_dims[q + 1] = new_chi; + + Ok(()) + } + + /// Apply a two-site gate between arbitrary (possibly non-adjacent) sites. + /// + /// Uses SWAP gates to bring site `q1` adjacent to `q0`, applies the gate, + /// then SWAPs back. `q0 < q1` required. + /// + /// SWAP gates are unitary permutations that preserve the Schmidt spectrum, + /// so SVD truncation after each SWAP introduces minimal numerical drift. + /// The dominant error comes only from the actual gate application. + /// + /// # Errors + /// + /// Returns [`MpsError::NonAdjacentSites`] if `q0 >= q1`, + /// [`MpsError::SiteOutOfBounds`] if `q1` exceeds the chain, or + /// [`MpsError::SvdFailed`] if any intermediate SVD fails. + pub fn apply_long_range_two_site_gate( + &mut self, + q0: usize, + q1: usize, + gate: &DMatrix, + ) -> Result<(), MpsError> { + if q0 >= q1 { + return Err(MpsError::NonAdjacentSites { q0, q1 }); + } + if q1 >= self.num_sites { + return Err(MpsError::SiteOutOfBounds { + index: q1, + num_sites: self.num_sites, + }); + } + + // Adjacent case: apply directly + if q1 == q0 + 1 { + return self.apply_two_site_gate(q0, gate); + } + + // Non-adjacent: SWAP chain to bring sites together, apply gate, SWAP back. + let swap = DMatrix::from_row_slice( + 4, + 4, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + ], + ); + + // SWAP q1 leftward until it's adjacent to q0 + for i in (q0 + 1..q1).rev() { + self.apply_two_site_gate(i, &swap)?; + } + + // Apply the gate on the now-adjacent pair + self.apply_two_site_gate(q0, gate)?; + + // SWAP back + for i in q0 + 1..q1 { + self.apply_two_site_gate(i, &swap)?; + } + + Ok(()) + } + + /// Compute the squared norm `` by contracting the MPS with itself. + #[must_use] + pub fn norm_squared(&self) -> f64 { + // Contract from left to right, building the transfer matrix product. + // E[alpha, beta] = sum_{sigma} A*[alpha, sigma] A[beta, sigma] + // Start with E = 1x1 identity. + let d = self.phys_dim; + let mut transfer = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + + for q in 0..self.num_sites { + let chi_r = self.bond_dims[q + 1]; + let t = &self.tensors[q]; + + // new_transfer[alpha_r, beta_r] = sum_{alpha_l, beta_l, sigma} + // transfer[alpha_l, beta_l] * conj(A[alpha_l, sigma, alpha_r]) * A[beta_l, sigma, beta_r] + let mut new_transfer = DMatrix::zeros(chi_r, chi_r); + for sigma in 0..d { + // block_sigma: (chi_l, chi_r) + let block = phys_block(t, sigma, chi_r); + // conj(block)^T * transfer * block + let conj_block_t = block.conjugate().transpose(); + let tmp = &conj_block_t * &transfer * █ + new_transfer += tmp; + } + transfer = new_transfer; + } + + // Final transfer is 1x1 + transfer[(0, 0)].re + } + + /// Compute `` where O is a product of per-site 2x2 operators. + /// + /// `ops` maps site index -> 2x2 matrix. Sites not in `ops` get identity. + /// Returns the complex expectation value. + #[must_use] + pub fn expectation_product(&self, ops: &[(usize, DMatrix)]) -> Complex64 { + let d = self.phys_dim; + let mut transfer = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + + // Build a lookup for which sites have operators + let mut site_ops: Vec>> = vec![None; self.num_sites]; + for (site, op) in ops { + site_ops[*site] = Some(op); + } + + for (q, site_op) in site_ops.iter().enumerate() { + let chi_r = self.bond_dims[q + 1]; + let t = &self.tensors[q]; + + let mut new_transfer = DMatrix::zeros(chi_r, chi_r); + + if let Some(op) = site_op { + // at this site + // new_transfer = sum_{sigma_bra, sigma_ket} conj(A[sigma_bra])^T * transfer * A[sigma_ket] * O[sigma_bra, sigma_ket] + for sigma_bra in 0..d { + let bra_block = phys_block(t, sigma_bra, chi_r); + let conj_bra_t = bra_block.conjugate().transpose(); + for sigma_ket in 0..d { + let o_val = op[(sigma_bra, sigma_ket)]; + if o_val.norm() < 1e-15 { + continue; + } + let ket_block = phys_block(t, sigma_ket, chi_r); + let tmp = &conj_bra_t * &transfer * &ket_block; + new_transfer += tmp * o_val; + } + } + } else { + // Identity at this site (same as norm_squared) + for sigma in 0..d { + let block = phys_block(t, sigma, chi_r); + let conj_block_t = block.conjugate().transpose(); + let tmp = &conj_block_t * &transfer * █ + new_transfer += tmp; + } + } + + transfer = new_transfer; + } + + transfer[(0, 0)] + } + + /// Normalize the MPS so that ` = 1`. + pub fn normalize(&mut self) { + if self.tensors.is_empty() { + return; + } + let norm_sq = self.norm_squared(); + if norm_sq > 0.0 { + let inv_norm = Complex64::new(1.0 / norm_sq.sqrt(), 0.0); + self.tensors[0] *= inv_norm; + } + } + + /// Extract the amplitude for a given computational basis state. + /// + /// `basis_state[i]` is the physical index (0 or 1) at site `i`. + /// + /// # Panics + /// + /// Panics if `basis_state.len() != self.num_sites`. + #[must_use] + pub fn amplitude(&self, basis_state: &[u8]) -> Complex64 { + assert_eq!(basis_state.len(), self.num_sites); + + // Contract: A[0]^{s_0} * A[1]^{s_1} * ... * A[N-1]^{s_{N-1}} + // Each A[i]^{s_i} is a (chi_l, chi_r) matrix. Product is a 1x1 scalar. + let mut result = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + for (q, &sigma) in basis_state.iter().enumerate() { + let sigma = sigma as usize; + let chi_r = self.bond_dims[q + 1]; + let block = phys_block(&self.tensors[q], sigma, chi_r); + result = &result * █ + } + result[(0, 0)] + } + + /// Compute the full state vector (2^N complex amplitudes). + /// + /// Only for testing on small systems. + /// When `parallel` is enabled in the config, amplitude computations run on + /// rayon's thread pool. + /// + /// # Panics + /// + /// Panics if `num_sites > 20`. + #[must_use] + pub fn state_vector(&self) -> Vec { + assert!( + self.num_sites <= 20, + "state_vector is only for small systems (N <= 20)" + ); + let dim = 1 << self.num_sites; + let n = self.num_sites; + + let to_basis = |idx: usize| -> Vec { + (0..n) + .map(|q| u8::try_from((idx >> (n - 1 - q)) & 1).unwrap()) + .collect() + }; + + if self.config.parallel { + (0..dim) + .into_par_iter() + .map(|idx| self.amplitude(&to_basis(idx))) + .collect() + } else { + (0..dim).map(|idx| self.amplitude(&to_basis(idx))).collect() + } + } + + /// Add two MPS of the same structure (direct sum of bond spaces). + /// + /// The result has bond dimension `chi_self + chi_other` at each internal bond. + /// Should be followed by SVD truncation (e.g. via `left_canonicalize` + truncate). + /// + /// # Panics + /// + /// Panics if `self` and `other` differ in `num_sites` or `phys_dim`. + #[must_use] + pub fn add(&self, other: &Self) -> Self { + assert_eq!(self.num_sites, other.num_sites); + assert_eq!(self.phys_dim, other.phys_dim); + let d = self.phys_dim; + let n = self.num_sites; + + let mut new_bond_dims = vec![1; n + 1]; + for (new_bd, (bd_s, bd_o)) in new_bond_dims[1..n].iter_mut().zip( + self.bond_dims[1..n] + .iter() + .zip(other.bond_dims[1..n].iter()), + ) { + *new_bd = bd_s + bd_o; + } + + let mut new_tensors = Vec::with_capacity(n); + for q in 0..n { + let chi_l_s = self.bond_dims[q]; + let chi_r_s = self.bond_dims[q + 1]; + let chi_l_o = other.bond_dims[q]; + let chi_r_o = other.bond_dims[q + 1]; + let chi_l_new = new_bond_dims[q]; + let chi_r_new = new_bond_dims[q + 1]; + + let mut t = DMatrix::zeros(chi_l_new, d * chi_r_new); + + for sigma in 0..d { + // Place self's block in top-left + let block_s = phys_block(&self.tensors[q], sigma, chi_r_s); + for i in 0..chi_l_s { + for j in 0..chi_r_s { + t[(i, sigma * chi_r_new + j)] = block_s[(i, j)]; + } + } + + // Place other's block in bottom-right (or add at boundaries) + let block_o = phys_block(&other.tensors[q], sigma, chi_r_o); + let row_offset = if q == 0 { 0 } else { chi_l_s }; + let col_offset = if q == n - 1 { 0 } else { chi_r_s }; + for i in 0..chi_l_o { + for j in 0..chi_r_o { + t[(row_offset + i, sigma * chi_r_new + col_offset + j)] += block_o[(i, j)]; + } + } + } + + new_tensors.push(t); + } + + Self { + num_sites: n, + phys_dim: d, + tensors: new_tensors, + bond_dims: new_bond_dims, + config: self.config.clone(), + truncation_error: self.truncation_error.max(other.truncation_error), + bond_cap_hits: self.bond_cap_hits + other.bond_cap_hits, + } + } + + /// Access the internal tensors. + #[must_use] + pub fn tensors(&self) -> &[DMatrix] { + &self.tensors + } + + /// Mutable access to the internal tensors. + pub fn tensors_mut(&mut self) -> &mut [DMatrix] { + &mut self.tensors + } + + /// Access the bond dimensions (for testing). + #[must_use] + pub fn bond_dims(&self) -> &[usize] { + &self.bond_dims + } + + /// Left-canonicalize the entire MPS. + pub fn left_canonicalize(&mut self) { + canon::left_canonicalize_all(&mut self.tensors, &mut self.bond_dims, self.phys_dim); + } + + /// Right-canonicalize the entire MPS. + pub fn right_canonicalize(&mut self) { + canon::right_canonicalize_all(&mut self.tensors, &mut self.bond_dims, self.phys_dim); + } + + /// Compress the MPS by SVD truncation at each bond. + /// + /// Left-canonicalizes first, then sweeps right-to-left performing SVD + /// truncation at each bond to enforce `max_bond_dim` and `svd_cutoff`. + pub fn compress(&mut self) { + if self.num_sites <= 1 { + return; + } + + // Left-canonicalize + self.left_canonicalize(); + + // Sweep right to left: at each bond, reshape the site tensor into + // (chi_l * d, chi_r), do truncated SVD, absorb U*S into left neighbor. + let d = self.phys_dim; + for q in (1..self.num_sites).rev() { + let chi_l = self.bond_dims[q]; + + // Reshape site q from (chi_l, d * chi_r) to (chi_l, d * chi_r) -- already in this form. + // But we want to split the left bond, so transpose the grouping: + // Reshape to (chi_l, d * chi_r) and do SVD to split as (chi_l, new_chi) * (new_chi, d * chi_r). + let matrix = &self.tensors[q]; + if let Ok((u, svt, disc, hit)) = svd::truncated_svd_right_absorb_with_error( + matrix, + self.config.max_bond_dim, + self.config.svd_cutoff, + self.config.max_truncation_error, + ) { + self.record_truncation(disc, hit); + let new_chi = u.ncols(); + if new_chi < chi_l { + // U: (chi_l, new_chi) -- absorb into left neighbor + // SVt: (new_chi, d * chi_r) -- new site q tensor + self.tensors[q] = svt; + self.bond_dims[q] = new_chi; + + // Absorb U into tensors[q-1]: multiply each physical block by U + let chi_l_prev = self.bond_dims[q - 1]; + let old_chi_r_prev = chi_l; // was bond_dims[q] before update + let mut new_prev = DMatrix::zeros(chi_l_prev, d * new_chi); + for sigma in 0..d { + let prev_block = + tensor::phys_block(&self.tensors[q - 1], sigma, old_chi_r_prev); + let absorbed = &prev_block * &u; + for i in 0..chi_l_prev { + for j in 0..new_chi { + new_prev[(i, sigma * new_chi + j)] = absorbed[(i, j)]; + } + } + } + self.tensors[q - 1] = new_prev; + } + } + } + } +} + +impl Clone for Mps { + fn clone(&self) -> Self { + Self { + num_sites: self.num_sites, + phys_dim: self.phys_dim, + tensors: self.tensors.clone(), + bond_dims: self.bond_dims.clone(), + config: self.config.clone(), + truncation_error: self.truncation_error, + bond_cap_hits: self.bond_cap_hits, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + #[test] + fn test_new_is_all_zeros_state() { + let mps = Mps::new(3, MpsConfig::default()); + assert_eq!(mps.num_sites(), 3); + assert_relative_eq!(mps.amplitude(&[0, 0, 0]).re, 1.0, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[0, 0, 1]).norm(), 0.0, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[1, 0, 0]).norm(), 0.0, epsilon = 1e-10); + } + + #[test] + fn test_norm_of_initial_state() { + let mps = Mps::new(4, MpsConfig::default()); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); + } + + #[test] + fn test_single_site_x_gate() { + let mut mps = Mps::new(2, MpsConfig::default()); + // X gate on site 0: |00> -> |10> + 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), + ], + ); + mps.apply_one_site_gate(0, &x).unwrap(); + assert_relative_eq!(mps.amplitude(&[1, 0]).re, 1.0, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[0, 0]).norm(), 0.0, epsilon = 1e-10); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); + } + + #[test] + fn test_hadamard_gate() { + let mut mps = Mps::new(1, MpsConfig::default()); + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(-inv_sqrt2, 0.0), + ], + ); + mps.apply_one_site_gate(0, &h).unwrap(); + // |+> = (|0> + |1>) / sqrt(2) + assert_relative_eq!(mps.amplitude(&[0]).re, inv_sqrt2, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[1]).re, inv_sqrt2, epsilon = 1e-10); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); + } + + #[test] + fn test_diagonal_gate() { + let mut mps = Mps::new(1, MpsConfig::default()); + // First apply H to get |+> + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(-inv_sqrt2, 0.0), + ], + ); + mps.apply_one_site_gate(0, &h).unwrap(); + // Apply Z = diag(1, -1) + mps.apply_diagonal_one_site(0, &[Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]) + .unwrap(); + // Should get |->: (|0> - |1>) / sqrt(2) + assert_relative_eq!(mps.amplitude(&[0]).re, inv_sqrt2, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[1]).re, -inv_sqrt2, epsilon = 1e-10); + } + + #[test] + fn test_cnot_gate() { + let mut mps = Mps::new(2, MpsConfig::default()); + // Apply X to site 0: |00> -> |10> + 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), + ], + ); + mps.apply_one_site_gate(0, &x).unwrap(); + + // Apply CNOT (control=0, target=1): |10> -> |11> + let mut cnot = DMatrix::zeros(4, 4); + cnot[(0, 0)] = Complex64::new(1.0, 0.0); // |00> -> |00> + cnot[(1, 1)] = Complex64::new(1.0, 0.0); // |01> -> |01> + cnot[(3, 2)] = Complex64::new(1.0, 0.0); // |10> -> |11> + cnot[(2, 3)] = Complex64::new(1.0, 0.0); // |11> -> |10> + mps.apply_two_site_gate(0, &cnot).unwrap(); + + assert_relative_eq!(mps.amplitude(&[1, 1]).re, 1.0, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[0, 0]).norm(), 0.0, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[1, 0]).norm(), 0.0, epsilon = 1e-10); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); + } + + #[test] + fn test_bell_state() { + let mut mps = Mps::new(2, MpsConfig::default()); + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + + // H on site 0 + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(-inv_sqrt2, 0.0), + ], + ); + mps.apply_one_site_gate(0, &h).unwrap(); + + // CNOT + let mut cnot = DMatrix::zeros(4, 4); + cnot[(0, 0)] = Complex64::new(1.0, 0.0); + cnot[(1, 1)] = Complex64::new(1.0, 0.0); + cnot[(3, 2)] = Complex64::new(1.0, 0.0); + cnot[(2, 3)] = Complex64::new(1.0, 0.0); + mps.apply_two_site_gate(0, &cnot).unwrap(); + + // Bell state: (|00> + |11>) / sqrt(2) + assert_relative_eq!(mps.amplitude(&[0, 0]).re, inv_sqrt2, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[1, 1]).re, inv_sqrt2, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[0, 1]).norm(), 0.0, epsilon = 1e-10); + assert_relative_eq!(mps.amplitude(&[1, 0]).norm(), 0.0, epsilon = 1e-10); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); + assert_eq!(mps.bond_dim(1), 2); // Bell state needs bond dim 2 + } + + #[test] + fn test_state_vector() { + let mut mps = Mps::new(2, MpsConfig::default()); + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(-inv_sqrt2, 0.0), + ], + ); + mps.apply_one_site_gate(0, &h).unwrap(); + let sv = mps.state_vector(); + // |+0> = (|00> + |10>) / sqrt(2) + assert_eq!(sv.len(), 4); + assert_relative_eq!(sv[0].re, inv_sqrt2, epsilon = 1e-10); // |00> + assert_relative_eq!(sv[1].norm(), 0.0, epsilon = 1e-10); // |01> + assert_relative_eq!(sv[2].re, inv_sqrt2, epsilon = 1e-10); // |10> + assert_relative_eq!(sv[3].norm(), 0.0, epsilon = 1e-10); // |11> + } + + #[test] + fn test_scale() { + let mut mps = Mps::new(2, MpsConfig::default()); + mps.scale(Complex64::new(0.0, 1.0)); // multiply by i + assert_relative_eq!(mps.amplitude(&[0, 0]).im, 1.0, epsilon = 1e-10); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); + } + + #[test] + fn test_mps_add() { + // |00> + |11> (unnormalized) + let mps0 = Mps::new(2, MpsConfig::default()); // |00> + + let mut mps1 = 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), + ], + ); + mps1.apply_one_site_gate(0, &x).unwrap(); + mps1.apply_one_site_gate(1, &x).unwrap(); + // mps1 = |11> + + let sum = mps0.add(&mps1); + // Should be |00> + |11> + assert_relative_eq!(sum.amplitude(&[0, 0]).re, 1.0, epsilon = 1e-10); + assert_relative_eq!(sum.amplitude(&[1, 1]).re, 1.0, epsilon = 1e-10); + assert_relative_eq!(sum.amplitude(&[0, 1]).norm(), 0.0, epsilon = 1e-10); + assert_relative_eq!(sum.amplitude(&[1, 0]).norm(), 0.0, epsilon = 1e-10); + assert_relative_eq!(sum.norm_squared(), 2.0, epsilon = 1e-10); + } + + #[test] + fn test_two_site_gate_preserves_norm() { + // Build an entangled 4-qubit MPS, then apply a two-site gate. + // The norm should be preserved. + let mut mps = Mps::new(4, MpsConfig::default()); + + // Create entanglement: H(0), CNOT(0,1), H(2), CNOT(2,3) + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(-std::f64::consts::FRAC_1_SQRT_2, 0.0), + ], + ); + let cnot = DMatrix::from_row_slice( + 4, + 4, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ); + let swap = DMatrix::from_row_slice( + 4, + 4, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + ], + ); + + mps.apply_one_site_gate(0, &h).unwrap(); + mps.apply_two_site_gate(0, &cnot).unwrap(); + mps.apply_one_site_gate(2, &h).unwrap(); + mps.apply_two_site_gate(2, &cnot).unwrap(); + + let norm_before = mps.norm_squared(); + assert_relative_eq!(norm_before, 1.0, epsilon = 1e-10); + + // Apply various two-site gates and check norm + mps.apply_two_site_gate(1, &cnot).unwrap(); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); // "CNOT on (1,2)"); + + mps.apply_two_site_gate(0, &swap).unwrap(); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); // "SWAP on (0,1)"); + + // Long-range CNOT via SWAP chain + mps.apply_long_range_two_site_gate(0, 3, &cnot).unwrap(); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); // "Long-range CNOT(0,3)"); + + mps.apply_long_range_two_site_gate(0, 2, &swap).unwrap(); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); + } + + #[test] + fn test_long_range_cnot_state_vector() { + // Apply CNOT(0, 2) to H(0)|000⟩ via the MPO approach + // and compare to building the exact state with adjacent gates. + let c0 = Complex64::new(0.0, 0.0); + let c1 = Complex64::new(1.0, 0.0); + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(-inv_sqrt2, 0.0), + ], + ); + let cnot = DMatrix::from_row_slice( + 4, + 4, + &[ + c1, c0, c0, c0, c0, c1, c0, c0, c0, c0, c0, c1, c0, c0, c1, c0, + ], + ); + + // Method 1: long-range CNOT(0, 2) via MPO + let mut mps1 = Mps::new(3, MpsConfig::default()); + mps1.apply_one_site_gate(0, &h).unwrap(); + mps1.apply_long_range_two_site_gate(0, 2, &cnot).unwrap(); + let sv1 = mps1.state_vector(); + + // Method 2: build exact state manually + // H(0)|000⟩ = (|000⟩ + |100⟩) / sqrt(2) + // CNOT(0,2)(|000⟩ + |100⟩)/sqrt(2) = (|000⟩ + |101⟩)/sqrt(2) + // State vector ordering: MSB-first, so |000⟩ = idx 0, |101⟩ = idx 5 + assert_relative_eq!(sv1[0].re, inv_sqrt2, epsilon = 1e-8); + assert_relative_eq!(sv1[5].re, inv_sqrt2, epsilon = 1e-8); + for (i, amp) in sv1.iter().enumerate().take(8) { + if i != 0 && i != 5 { + assert_relative_eq!(amp.norm(), 0.0, epsilon = 1e-8); + } + } + } + + #[test] + fn test_long_range_cnot_entangled() { + // Apply CNOT(0, 3) on a 4-qubit state that's already entangled. + // Compare MPO approach to building reference via adjacent gates only. + let c0 = Complex64::new(0.0, 0.0); + let c1 = Complex64::new(1.0, 0.0); + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(-inv_sqrt2, 0.0), + ], + ); + let cnot = DMatrix::from_row_slice( + 4, + 4, + &[ + c1, c0, c0, c0, c0, c1, c0, c0, c0, c0, c0, c1, c0, c0, c1, c0, + ], + ); + let swap = DMatrix::from_row_slice( + 4, + 4, + &[ + c1, c0, c0, c0, c0, c0, c1, c0, c0, c1, c0, c0, c0, c0, c0, c1, + ], + ); + + // Build entangled state: H(0), CNOT(0,1), H(2), CNOT(2,3) + // Then apply CNOT(0, 3) via MPO + let mut mps_mpo = Mps::new(4, MpsConfig::default()); + mps_mpo.apply_one_site_gate(0, &h).unwrap(); + mps_mpo.apply_two_site_gate(0, &cnot).unwrap(); + mps_mpo.apply_one_site_gate(2, &h).unwrap(); + mps_mpo.apply_two_site_gate(2, &cnot).unwrap(); + mps_mpo.apply_long_range_two_site_gate(0, 3, &cnot).unwrap(); + let sv_mpo = mps_mpo.state_vector(); + + // Reference: same state, CNOT(0, 3) via manual SWAP chain + let mut mps_ref = Mps::new(4, MpsConfig::default()); + mps_ref.apply_one_site_gate(0, &h).unwrap(); + mps_ref.apply_two_site_gate(0, &cnot).unwrap(); + mps_ref.apply_one_site_gate(2, &h).unwrap(); + mps_ref.apply_two_site_gate(2, &cnot).unwrap(); + // Manual SWAP chain for CNOT(0, 3) + mps_ref.apply_two_site_gate(2, &swap).unwrap(); // SWAP(2,3) + mps_ref.apply_two_site_gate(1, &swap).unwrap(); // SWAP(1,2) + mps_ref.apply_two_site_gate(0, &cnot).unwrap(); // CNOT(0,1) [was q3] + mps_ref.apply_two_site_gate(1, &swap).unwrap(); // SWAP back + mps_ref.apply_two_site_gate(2, &swap).unwrap(); // SWAP back + let sv_ref = mps_ref.state_vector(); + + // Check overlap + let overlap: Complex64 = sv_mpo + .iter() + .zip(sv_ref.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + assert_relative_eq!(overlap.norm_sqr(), 1.0, epsilon = 1e-6); + } + + #[test] + fn test_long_range_cnot_hi_ctrl() { + // Test with high-qubit control CNOT (target < control) + let c0 = Complex64::new(0.0, 0.0); + let c1 = Complex64::new(1.0, 0.0); + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(-inv_sqrt2, 0.0), + ], + ); + // CNOT with hi-index qubit as control + let cnot_hi = DMatrix::from_row_slice( + 4, + 4, + &[ + c1, c0, c0, c0, c0, c0, c0, c1, c0, c0, c1, c0, c0, c1, c0, c0, + ], + ); + let swap = DMatrix::from_row_slice( + 4, + 4, + &[ + c1, c0, c0, c0, c0, c0, c1, c0, c0, c1, c0, c0, c0, c0, c0, c1, + ], + ); + + // H(2), CNOT_hi(0, 2) on 3-qubit MPS + // CNOT_hi: control=qubit 2, target=qubit 0 + let mut mps_mpo = Mps::new(3, MpsConfig::default()); + mps_mpo.apply_one_site_gate(2, &h).unwrap(); + mps_mpo + .apply_long_range_two_site_gate(0, 2, &cnot_hi) + .unwrap(); + let sv_mpo = mps_mpo.state_vector(); + + // Reference via SWAP chain + let mut mps_ref = Mps::new(3, MpsConfig::default()); + mps_ref.apply_one_site_gate(2, &h).unwrap(); + mps_ref.apply_two_site_gate(1, &swap).unwrap(); + mps_ref.apply_two_site_gate(0, &cnot_hi).unwrap(); + mps_ref.apply_two_site_gate(1, &swap).unwrap(); + let sv_ref = mps_ref.state_vector(); + + let overlap: Complex64 = sv_mpo + .iter() + .zip(sv_ref.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + assert_relative_eq!(overlap.norm_sqr(), 1.0, epsilon = 1e-6); + } + + #[test] + fn test_long_range_cnot_cascade() { + // Test the pattern from non_clifford.rs: multiple long-range CNOTs + let c0 = Complex64::new(0.0, 0.0); + let c1 = Complex64::new(1.0, 0.0); + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(inv_sqrt2, 0.0), + Complex64::new(-inv_sqrt2, 0.0), + ], + ); + let cnot_lo = DMatrix::from_row_slice( + 4, + 4, + &[ + c1, c0, c0, c0, c0, c1, c0, c0, c0, c0, c0, c1, c0, c0, c1, c0, + ], + ); + let rx_gate = { + let theta = 0.5_f64; + let c = Complex64::new(theta.cos(), 0.0); + let s = Complex64::new(0.0, -theta.sin()); + DMatrix::from_row_slice(2, 2, &[c, s, s, c]) + }; + + // H on all, then CNOT cascade (0→1, 0→3), RX(0), reverse CNOT + let mut mps_mpo = Mps::new(4, MpsConfig::default()); + for q in 0..4 { + mps_mpo.apply_one_site_gate(q, &h).unwrap(); + } + mps_mpo.apply_two_site_gate(0, &cnot_lo).unwrap(); + mps_mpo + .apply_long_range_two_site_gate(0, 3, &cnot_lo) + .unwrap(); + mps_mpo.apply_one_site_gate(0, &rx_gate).unwrap(); + mps_mpo + .apply_long_range_two_site_gate(0, 3, &cnot_lo) + .unwrap(); + mps_mpo.apply_two_site_gate(0, &cnot_lo).unwrap(); + let sv_mpo = mps_mpo.state_vector(); + + // Reference: same but use SWAP chains for long-range + let swap = DMatrix::from_row_slice( + 4, + 4, + &[ + c1, c0, c0, c0, c0, c0, c1, c0, c0, c1, c0, c0, c0, c0, c0, c1, + ], + ); + let mut mps_ref = Mps::new(4, MpsConfig::default()); + for q in 0..4 { + mps_ref.apply_one_site_gate(q, &h).unwrap(); + } + mps_ref.apply_two_site_gate(0, &cnot_lo).unwrap(); + // SWAP chain for CNOT(0,3) + mps_ref.apply_two_site_gate(2, &swap).unwrap(); + mps_ref.apply_two_site_gate(1, &swap).unwrap(); + mps_ref.apply_two_site_gate(0, &cnot_lo).unwrap(); + mps_ref.apply_two_site_gate(1, &swap).unwrap(); + mps_ref.apply_two_site_gate(2, &swap).unwrap(); + mps_ref.apply_one_site_gate(0, &rx_gate).unwrap(); + // SWAP chain for CNOT(0,3) again + mps_ref.apply_two_site_gate(2, &swap).unwrap(); + mps_ref.apply_two_site_gate(1, &swap).unwrap(); + mps_ref.apply_two_site_gate(0, &cnot_lo).unwrap(); + mps_ref.apply_two_site_gate(1, &swap).unwrap(); + mps_ref.apply_two_site_gate(2, &swap).unwrap(); + mps_ref.apply_two_site_gate(0, &cnot_lo).unwrap(); + let sv_ref = mps_ref.state_vector(); + + let overlap: Complex64 = sv_mpo + .iter() + .zip(sv_ref.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + assert_relative_eq!(overlap.norm_sqr(), 1.0, epsilon = 1e-4); + } + + #[test] + fn test_multi_site_rotation_preserves_norm() { + // Reproduce the Stabilizer multi-site rotation: + // H(0), H(2), CNOT(0,2), RX(0), CNOT(0,2), H(0), H(2) + let mut mps = Mps::new(4, MpsConfig::default()); + + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(-std::f64::consts::FRAC_1_SQRT_2, 0.0), + ], + ); + let cnot = DMatrix::from_row_slice( + 4, + 4, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ); + let rx = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.9239, 0.0), + Complex64::new(0.0, -0.3827), + Complex64::new(0.0, -0.3827), + Complex64::new(0.9239, 0.0), + ], + ); + + // Build entangled state + mps.apply_one_site_gate(0, &h).unwrap(); + mps.apply_two_site_gate(0, &cnot).unwrap(); + mps.apply_one_site_gate(2, &h).unwrap(); + mps.apply_two_site_gate(2, &cnot).unwrap(); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-10); + + // Multi-site Z rotation on sites {0, 2} + mps.apply_one_site_gate(0, &h).unwrap(); + mps.apply_one_site_gate(2, &h).unwrap(); + mps.apply_long_range_two_site_gate(0, 2, &cnot).unwrap(); + let norm_mid = mps.norm_squared(); + mps.apply_one_site_gate(0, &rx).unwrap(); + mps.apply_long_range_two_site_gate(0, 2, &cnot).unwrap(); + mps.apply_one_site_gate(0, &h).unwrap(); + mps.apply_one_site_gate(2, &h).unwrap(); + + eprintln!( + "norm mid-cascade: {norm_mid:.10}, after: {:.10}", + mps.norm_squared() + ); + assert_relative_eq!(mps.norm_squared(), 1.0, epsilon = 1e-3); + } +} diff --git a/exp/pecos-stab-tn/src/mps/canon.rs b/exp/pecos-stab-tn/src/mps/canon.rs new file mode 100644 index 000000000..19df73c41 --- /dev/null +++ b/exp/pecos-stab-tn/src/mps/canon.rs @@ -0,0 +1,149 @@ +// 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. + +//! 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. + +use super::tensor::{reshape_left_group, reshape_left_ungroup}; +use nalgebra::DMatrix; +use num_complex::Complex64; + +/// Left-canonicalize a single site by QR decomposition. +/// +/// Takes the site tensor at position `q` in `(chi_l, d * chi_r)` format, +/// reshapes to `(chi_l * d, chi_r)`, performs thin QR, stores Q back as the +/// new site tensor, and absorbs R into the next site's tensor. +/// +/// Returns the new bond dimension between sites q and q+1. +/// +/// # Panics +/// +/// Panics if `q >= tensors.len() - 1` (cannot left-canonicalize the last site). +pub fn left_canonicalize_site( + tensors: &mut [DMatrix], + bond_dims: &mut [usize], + q: usize, + d: usize, +) -> usize { + let num_sites = tensors.len(); + assert!(q < num_sites - 1, "cannot left-canonicalize the last site"); + + let chi_l = bond_dims[q]; + let chi_r = bond_dims[q + 1]; + + // Reshape to (chi_l * d, chi_r) for QR + let grouped = reshape_left_group(&tensors[q], chi_l, d, chi_r); + let qr = grouped.qr(); + let q_mat = qr.q(); + let r_mat = qr.r(); + + // New bond dimension = min(chi_l * d, chi_r) -- rank of R + let new_chi = q_mat.ncols(); + bond_dims[q + 1] = new_chi; + + // Store Q as new site tensor: reshape (chi_l * d, new_chi) -> (chi_l, d * new_chi) + tensors[q] = reshape_left_ungroup(&q_mat, chi_l, d, new_chi); + + // Absorb R into next site: new_next = R * old_next + // R: (new_chi, chi_r), next tensor: (chi_r, d * chi_r_next) -> needs reshape + let next = &tensors[q + 1]; + // Reshape next to (chi_r, d * chi_r_next) -- it already is in this form + // but chi_r might have been the old bond dim. The matrix R has chi_r columns. + // next has chi_r rows, d * chi_r_next columns. + let absorbed = &r_mat * next; + tensors[q + 1] = absorbed; + + new_chi +} + +/// Right-canonicalize a single site by LQ decomposition. +/// +/// Takes the site tensor at position `q`, reshapes to `(chi_l, d * chi_r)`, +/// performs LQ (via QR of transpose), stores Q back as the site tensor, +/// and absorbs L into the previous site's tensor. +/// +/// Returns the new bond dimension between sites q-1 and q. +/// +/// # Panics +/// +/// Panics if `q == 0` (cannot right-canonicalize the first site). +pub fn right_canonicalize_site( + tensors: &mut [DMatrix], + bond_dims: &mut [usize], + q: usize, + d: usize, +) -> usize { + assert!(q > 0, "cannot right-canonicalize the first site"); + + let chi_l = bond_dims[q]; + + // LQ decomposition via QR of transpose: A^T = Q R -> A = R^T Q^T = L Q + let at = tensors[q].transpose(); + let qr = at.qr(); + // Q^T gives us the right factor, R^T gives us the left factor + let q_mat_t = qr.q().transpose(); // shape: (new_chi, d * chi_r) -- but need to verify + let l_mat = qr.r().transpose(); // shape: (chi_l, new_chi) + + let new_chi = q_mat_t.nrows(); + bond_dims[q] = new_chi; + + // Store Q^T as the new site tensor: (new_chi, d * chi_r) + // We need to reshape this back to proper site tensor format + tensors[q] = q_mat_t; + + // Absorb L into previous site + // Previous site: (chi_l_prev, d * chi_l) -- the last chi_l columns per physical block + // L: (chi_l, new_chi) + // We need: new_prev[alpha_l_prev, sigma * new_chi + alpha_new] = + // sum_{alpha_l} prev[alpha_l_prev, sigma * chi_l + alpha_l] * L[alpha_l, alpha_new] + let chi_l_prev = bond_dims[q - 1]; + let prev = &tensors[q - 1]; + let mut new_prev = DMatrix::zeros(chi_l_prev, d * new_chi); + for sigma in 0..d { + let prev_block = prev.columns(sigma * chi_l, chi_l).clone_owned(); + let absorbed_block = &prev_block * &l_mat; + for i in 0..chi_l_prev { + for j in 0..new_chi { + new_prev[(i, sigma * new_chi + j)] = absorbed_block[(i, j)]; + } + } + } + tensors[q - 1] = new_prev; + + new_chi +} + +/// Put the entire MPS in left-canonical form by sweeping left to right. +pub fn left_canonicalize_all( + tensors: &mut [DMatrix], + bond_dims: &mut [usize], + d: usize, +) { + let n = tensors.len(); + for q in 0..n - 1 { + left_canonicalize_site(tensors, bond_dims, q, d); + } +} + +/// Put the entire MPS in right-canonical form by sweeping right to left. +pub fn right_canonicalize_all( + tensors: &mut [DMatrix], + bond_dims: &mut [usize], + d: usize, +) { + let n = tensors.len(); + for q in (1..n).rev() { + right_canonicalize_site(tensors, bond_dims, q, d); + } +} diff --git a/exp/pecos-stab-tn/src/mps/svd.rs b/exp/pecos-stab-tn/src/mps/svd.rs new file mode 100644 index 000000000..9a1ac9c0b --- /dev/null +++ b/exp/pecos-stab-tn/src/mps/svd.rs @@ -0,0 +1,551 @@ +// 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. + +//! Truncated SVD for MPS bond compression. +//! +//! Provides both full SVD (via nalgebra) and randomized SVD for large matrices. +//! The randomized variant uses the Halko-Martinsson-Tropp algorithm (2011): +//! random projection -> QR -> small SVD, giving O(mnr) cost instead of +//! O(mn * min(m,n)) for the full SVD. + +use crate::errors::MpsError; +use nalgebra::{DMatrix, DVector, SVD}; +use num_complex::Complex64; + +/// Result of a truncated SVD. +pub struct TruncatedSvd { + /// Left singular vectors, shape (m, r). + pub u: DMatrix, + /// Singular values (r entries, descending order). + pub singular_values: Vec, + /// Right singular vectors (conjugate transpose), shape (r, n). + pub vt: DMatrix, + /// Relative weight of discarded singular values: + /// `sum(discarded_sv²) / sum(all_sv²)`. Zero if no truncation. + /// Approximates the 1-fidelity cost of this SVD step. + pub discarded_weight: f64, + /// True if the kept rank equals `max_rank` (i.e. the bond cap was binding). + /// Useful for detecting under-resolution in adaptive schemes. + pub hit_cap: bool, +} + +/// Perform truncated SVD on a complex matrix. +/// +/// Given matrix M of shape (m, n), computes M = U * diag(S) * V^dagger, +/// then keeps at most `max_rank` singular values that are above `cutoff`. +/// If `max_trunc_error` is Some, also stops when the relative discarded +/// weight (sum of discarded `s_i^2` / total) would exceed the budget. +/// +/// # Errors +/// +/// Returns [`MpsError::SvdFailed`] if nalgebra's SVD fails to produce U or V^T. +pub fn truncated_svd( + matrix: &DMatrix, + max_rank: usize, + cutoff: f64, +) -> Result { + truncated_svd_with_error(matrix, max_rank, cutoff, None) +} + +/// Perform truncated SVD with optional adaptive error budget. +/// +/// # Errors +/// +/// Returns [`MpsError::SvdFailed`] if nalgebra's SVD fails to produce U or V^T. +pub fn truncated_svd_with_error( + matrix: &DMatrix, + max_rank: usize, + cutoff: f64, + max_trunc_error: Option, +) -> Result { + let svd = SVD::new(matrix.clone(), true, true); + + let u_full = svd.u.ok_or(MpsError::SvdFailed)?; + let vt_full = svd.v_t.ok_or(MpsError::SvdFailed)?; + let svals: &DVector = &svd.singular_values; + + let rank = compute_rank(svals, max_rank, cutoff, max_trunc_error); + + let u_trunc = u_full.columns(0, rank).clone_owned(); + let vt_trunc = vt_full.rows(0, rank).clone_owned(); + let kept_svals: Vec = svals.iter().take(rank).copied().collect(); + let total_weight: f64 = svals.iter().map(|s| s * s).sum(); + let kept_weight: f64 = kept_svals.iter().map(|s| s * s).sum(); + let discarded_weight = if total_weight > 0.0 { + ((total_weight - kept_weight) / total_weight).max(0.0) + } else { + 0.0 + }; + + Ok(TruncatedSvd { + u: u_trunc, + singular_values: kept_svals, + vt: vt_trunc, + discarded_weight, + hit_cap: rank >= max_rank && svals.len() > max_rank, + }) +} + +/// Determine how many singular values to keep given truncation criteria. +fn compute_rank( + svals: &DVector, + max_rank: usize, + cutoff: f64, + max_trunc_error: Option, +) -> usize { + let n = svals.len(); + + // Start with all singular values that pass the hard criteria + let mut rank = 0; + for i in 0..n { + if i >= max_rank { + break; + } + if svals[i] < cutoff { + break; + } + rank += 1; + } + + // Apply adaptive error budget: reduce rank if discarded weight is within budget + if let Some(max_err) = max_trunc_error { + let total_weight: f64 = svals.iter().map(|s| s * s).sum(); + if total_weight > 0.0 { + // Walk backwards from rank, checking if we can drop more values + let mut discarded_weight = 0.0; + for i in (1..rank).rev() { + let candidate_discard = discarded_weight + svals[i] * svals[i]; + if candidate_discard / total_weight > max_err { + break; + } + discarded_weight = candidate_discard; + rank = i; + } + } + } + + // Keep at least 1 to avoid empty tensors + rank.max(1) +} + +/// Oversampling parameter for randomized SVD. +const RSVD_OVERSAMPLING: usize = 5; + +/// Minimum matrix dimension ratio (min(m,n) / `max_rank`) to trigger randomized SVD. +/// When the ratio exceeds this threshold, randomized SVD is used instead of full SVD. +const RSVD_THRESHOLD: usize = 4; + +/// Perform truncated SVD, automatically choosing between full and randomized. +/// +/// Uses randomized SVD when `max_rank * RSVD_THRESHOLD < min(m, n)`, +/// otherwise uses full SVD. +/// +/// # Errors +/// +/// Returns [`MpsError::SvdFailed`] if the underlying SVD fails to produce U or V^T. +pub fn truncated_svd_auto( + matrix: &DMatrix, + max_rank: usize, + cutoff: f64, +) -> Result { + truncated_svd_auto_with_error(matrix, max_rank, cutoff, None) +} + +/// Perform truncated SVD with error budget, auto-selecting algorithm. +/// +/// # Errors +/// +/// Returns [`MpsError::SvdFailed`] if the underlying SVD fails to produce U or V^T. +pub fn truncated_svd_auto_with_error( + matrix: &DMatrix, + max_rank: usize, + cutoff: f64, + max_trunc_error: Option, +) -> Result { + let m = matrix.nrows(); + let n = matrix.ncols(); + let min_dim = m.min(n); + + if max_rank * RSVD_THRESHOLD < min_dim && max_rank + RSVD_OVERSAMPLING < min_dim { + randomized_truncated_svd_with_error(matrix, max_rank, cutoff, max_trunc_error) + } else { + truncated_svd_with_error(matrix, max_rank, cutoff, max_trunc_error) + } +} + +/// Randomized truncated SVD using the Halko-Martinsson-Tropp algorithm. +/// +/// For an m×n matrix A with target rank r: +/// 1. Generate random sketch Ω (n × (r+p)) +/// 2. Y = A × Ω (m × (r+p)) +/// 3. Q, _ = QR(Y) (thin QR) +/// 4. B = Q^H × A ((r+p) × n) +/// 5. SVD(B) = Ũ Σ V^T +/// 6. U = Q × Ũ +/// +/// Cost: O(mn(r+p)) vs O(mn·min(m,n)) for full SVD. +fn randomized_truncated_svd_with_error( + matrix: &DMatrix, + max_rank: usize, + cutoff: f64, + max_trunc_error: Option, +) -> Result { + // f64 mantissa is 53 bits, so we extract top 53 bits and convert in two + // lossless u32->f64 steps to avoid clippy::cast_precision_loss. + const SCALE: f64 = 2.0 / 9_007_199_254_740_992.0; // 2 / 2^53 + + let m = matrix.nrows(); + let n = matrix.ncols(); + let sketch_cols = (max_rank + RSVD_OVERSAMPLING).min(m.min(n)); + + // Step 1: Generate random sketch matrix Ω (n × sketch_cols) + // Using a simple xorshift64 PRNG seeded deterministically from matrix dimensions. + // Deterministic seed means same matrix always gives same result. + let mut rng_state: u64 = 0x5DEE_CE66_D1A4_F87D ^ (m as u64 * 31 + n as u64 * 37); + let next_f64 = |state: &mut u64| -> f64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + // Map to uniform [-1, 1] (sub-Gaussian suffices for randomized SVD). + let top53 = *state >> 11; + let hi = (top53 >> 21) as u32; // upper 32 bits + let lo = (top53 & 0x1F_FFFF) as u32; // lower 21 bits + (f64::from(hi) * f64::from(1u32 << 21) + f64::from(lo)) * SCALE - 1.0 + }; + + let omega = DMatrix::from_fn(n, sketch_cols, |_i, _j| { + Complex64::new(next_f64(&mut rng_state), next_f64(&mut rng_state)) + }); + + // Step 2: Y = A × Ω (m × sketch_cols) + let y = matrix * ω + + // Step 3: Thin QR of Y + let qr = y.qr(); + let q = qr.q(); // m × min(m, sketch_cols) + let q_cols = q.ncols().min(sketch_cols); + let q_thin = q.columns(0, q_cols).clone_owned(); + + // Step 4: B = Q^H × A (q_cols × n) + let b = q_thin.adjoint() * matrix; + + // Step 5: Full SVD of the small matrix B + let svd_b = SVD::new(b, true, true); + let u_b = svd_b.u.ok_or(MpsError::SvdFailed)?; + let vt_b = svd_b.v_t.ok_or(MpsError::SvdFailed)?; + let svals: &DVector = &svd_b.singular_values; + + // Determine rank using same criteria as full SVD + let rank = compute_rank(svals, max_rank, cutoff, max_trunc_error); + + // Step 6: U = Q × Ũ_truncated + let u_b_trunc = u_b.columns(0, rank).clone_owned(); + let u = &q_thin * &u_b_trunc; + + let vt_trunc = vt_b.rows(0, rank).clone_owned(); + let kept_svals: Vec = svals.iter().take(rank).copied().collect(); + let total_weight: f64 = svals.iter().map(|s| s * s).sum(); + let kept_weight: f64 = kept_svals.iter().map(|s| s * s).sum(); + let discarded_weight = if total_weight > 0.0 { + ((total_weight - kept_weight) / total_weight).max(0.0) + } else { + 0.0 + }; + + Ok(TruncatedSvd { + u, + singular_values: kept_svals, + vt: vt_trunc, + discarded_weight, + hit_cap: rank >= max_rank && svals.len() > max_rank, + }) +} + +/// Perform truncated SVD and absorb singular values into the left matrix. +/// +/// Returns `(U * diag(S), V^dagger)` after truncation. +/// Automatically uses randomized SVD for large matrices with small target rank. +/// +/// # Errors +/// +/// Returns [`MpsError::SvdFailed`] if the underlying SVD fails to produce U or V^T. +pub fn truncated_svd_left_absorb( + matrix: &DMatrix, + max_rank: usize, + cutoff: f64, + max_trunc_error: Option, +) -> Result<(DMatrix, DMatrix), MpsError> { + let (us, vt, _, _) = + truncated_svd_left_absorb_with_error(matrix, max_rank, cutoff, max_trunc_error)?; + Ok((us, vt)) +} + +/// Like `truncated_svd_left_absorb` but also returns (`discarded_weight`, `hit_cap`). +/// +/// # Errors +/// +/// Returns [`MpsError::SvdFailed`] if the underlying SVD fails to produce U or V^T. +pub fn truncated_svd_left_absorb_with_error( + matrix: &DMatrix, + max_rank: usize, + cutoff: f64, + max_trunc_error: Option, +) -> Result<(DMatrix, DMatrix, f64, bool), MpsError> { + let result = truncated_svd_auto_with_error(matrix, max_rank, cutoff, max_trunc_error)?; + let mut u_scaled = result.u; + for (j, &sv) in result.singular_values.iter().enumerate() { + let scale = Complex64::new(sv, 0.0); + for i in 0..u_scaled.nrows() { + u_scaled[(i, j)] *= scale; + } + } + Ok((u_scaled, result.vt, result.discarded_weight, result.hit_cap)) +} + +/// Perform truncated SVD and absorb singular values into the right matrix. +/// +/// Returns `(U, diag(S) * V^dagger)` after truncation. +/// Automatically uses randomized SVD for large matrices with small target rank. +/// +/// # Errors +/// +/// Returns [`MpsError::SvdFailed`] if the underlying SVD fails to produce U or V^T. +pub fn truncated_svd_right_absorb( + matrix: &DMatrix, + max_rank: usize, + cutoff: f64, + max_trunc_error: Option, +) -> Result<(DMatrix, DMatrix), MpsError> { + let (u, svt, _, _) = + truncated_svd_right_absorb_with_error(matrix, max_rank, cutoff, max_trunc_error)?; + Ok((u, svt)) +} + +/// Like `truncated_svd_right_absorb` but also returns (`discarded_weight`, `hit_cap`). +/// +/// # Errors +/// +/// Returns [`MpsError::SvdFailed`] if the underlying SVD fails to produce U or V^T. +pub fn truncated_svd_right_absorb_with_error( + matrix: &DMatrix, + max_rank: usize, + cutoff: f64, + max_trunc_error: Option, +) -> Result<(DMatrix, DMatrix, f64, bool), MpsError> { + let result = truncated_svd_auto_with_error(matrix, max_rank, cutoff, max_trunc_error)?; + let mut svt = result.vt; + for (i, &sv) in result.singular_values.iter().enumerate() { + let scale = Complex64::new(sv, 0.0); + for j in 0..svt.ncols() { + svt[(i, j)] *= scale; + } + } + Ok((result.u, svt, result.discarded_weight, result.hit_cap)) +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + #[test] + fn test_truncated_svd_identity() { + let m = DMatrix::from_fn(3, 3, |i, j| { + if i == j { + Complex64::new(1.0, 0.0) + } else { + Complex64::new(0.0, 0.0) + } + }); + let result = truncated_svd(&m, 10, 1e-12).unwrap(); + assert_eq!(result.singular_values.len(), 3); + for sv in &result.singular_values { + assert_relative_eq!(*sv, 1.0, epsilon = 1e-10); + } + } + + #[test] + fn test_truncated_svd_rank_1() { + // Rank-1 matrix: outer product of [1, 0] and [1, 1] + let m = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ); + let result = truncated_svd(&m, 10, 1e-12).unwrap(); + // Should have rank 1 (second singular value ~ 0) + assert_eq!(result.singular_values.len(), 1); + assert_relative_eq!(result.singular_values[0], 2.0_f64.sqrt(), epsilon = 1e-10); + } + + #[test] + fn test_truncated_svd_max_rank() { + let m = DMatrix::from_fn(4, 4, |i, j| { + if i == j { + Complex64::new(f64::from(u32::try_from(4 - i).unwrap()), 0.0) + } else { + Complex64::new(0.0, 0.0) + } + }); + let result = truncated_svd(&m, 2, 1e-12).unwrap(); + assert_eq!(result.singular_values.len(), 2); + assert_relative_eq!(result.singular_values[0], 4.0, epsilon = 1e-10); + assert_relative_eq!(result.singular_values[1], 3.0, epsilon = 1e-10); + } + + #[test] + fn test_left_absorb_reconstructs() { + let m = DMatrix::from_row_slice( + 2, + 3, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(2.0, 0.0), + Complex64::new(3.0, 0.0), + Complex64::new(4.0, 0.0), + Complex64::new(5.0, 0.0), + Complex64::new(6.0, 0.0), + ], + ); + let (u_s, vt) = truncated_svd_left_absorb(&m, 10, 1e-12, None).unwrap(); + let reconstructed = &u_s * &vt; + for i in 0..2 { + for j in 0..3 { + assert_relative_eq!(reconstructed[(i, j)].re, m[(i, j)].re, epsilon = 1e-10); + } + } + } + + #[test] + fn test_adaptive_truncation() { + // Build a matrix with known singular value spectrum: 10, 5, 1, 0.1, 0.01 + // Total weight = 100 + 25 + 1 + 0.01 + 0.0001 = 126.0201 + let mut m = DMatrix::zeros(5, 5); + let spectrum = [10.0_f64, 5.0, 1.0, 0.1, 0.01]; + for (i, &s) in spectrum.iter().enumerate() { + m[(i, i)] = Complex64::new(s, 0.0); + } + + // With max_rank=5, cutoff=0, no error budget: keep all 5 + let r1 = truncated_svd_with_error(&m, 5, 0.0, None).unwrap(); + assert_eq!(r1.singular_values.len(), 5); + + // With error budget 1e-4: total=126.02, discarding 0.01^2=0.0001 costs 0.0001/126.02 ~ 8e-7 + // Discarding 0.1^2 + 0.01^2 = 0.0101 costs 0.0101/126.02 ~ 8e-5 + // So error budget 1e-3 should drop the last two (keep 3) + let r2 = truncated_svd_with_error(&m, 5, 0.0, Some(1e-3)).unwrap(); + assert!( + r2.singular_values.len() <= 4, + "should drop small values, got {}", + r2.singular_values.len() + ); + assert!( + r2.singular_values.len() >= 2, + "should keep large values, got {}", + r2.singular_values.len() + ); + + // With tight error budget 1e-6: should keep almost all + let r3 = truncated_svd_with_error(&m, 5, 0.0, Some(1e-6)).unwrap(); + assert!(r3.singular_values.len() >= 4); + } + + #[test] + fn test_randomized_svd_low_rank() { + // Build a rank-2 matrix of size 20x20 (forces randomized path when max_rank=2) + // A = u * v^T where u is 20x2 and v is 20x2 + let u_col = DMatrix::from_fn(20, 2, |i, j| { + Complex64::new( + f64::from(u32::try_from(i * 3 + j * 7 + 1).unwrap()).sin(), + 0.0, + ) + }); + let v_col = DMatrix::from_fn(20, 2, |i, j| { + Complex64::new( + f64::from(u32::try_from(i * 5 + j * 11 + 3).unwrap()).cos(), + 0.0, + ) + }); + let a = &u_col * &v_col.adjoint(); + + // Randomized SVD with max_rank=2 should recover the matrix + let result = randomized_truncated_svd_with_error(&a, 2, 1e-12, None).unwrap(); + assert!(result.singular_values.len() <= 2); + + // Reconstruct and check + let mut u_s = result.u.clone(); + for (j, &sv) in result.singular_values.iter().enumerate() { + for i in 0..u_s.nrows() { + u_s[(i, j)] *= Complex64::new(sv, 0.0); + } + } + let reconstructed = &u_s * &result.vt; + let error = (&a - &reconstructed).norm(); + assert!( + error < 1e-6, + "reconstruction error {error} should be < 1e-6" + ); + } + + #[test] + fn test_randomized_svd_truncation() { + // Full-rank 20x20 matrix, truncate to rank 3 + let a = DMatrix::from_fn(20, 20, |i, j| { + Complex64::new( + f64::from(u32::try_from(i * 7 + j * 13 + 5).unwrap()).sin(), + f64::from(u32::try_from(i + j).unwrap()).cos(), + ) + }); + + let result_full = truncated_svd(&a, 3, 1e-15).unwrap(); + let result_rand = randomized_truncated_svd_with_error(&a, 3, 1e-15, None).unwrap(); + + // Both should return rank 3 + assert_eq!(result_full.singular_values.len(), 3); + assert_eq!(result_rand.singular_values.len(), 3); + + // Singular values should be close (randomized is approximate) + for (sf, sr) in result_full + .singular_values + .iter() + .zip(result_rand.singular_values.iter()) + { + assert_relative_eq!(sf, sr, epsilon = 0.1 * sf); + } + } + + #[test] + fn test_auto_selects_full_for_small() { + // Small matrix: should use full SVD (same result as truncated_svd) + let m = DMatrix::from_fn(4, 4, |i, j| { + Complex64::new(f64::from(u32::try_from(i + j).unwrap()), 0.0) + }); + let result_auto = truncated_svd_auto(&m, 2, 1e-12).unwrap(); + let result_full = truncated_svd(&m, 2, 1e-12).unwrap(); + assert_eq!( + result_auto.singular_values.len(), + result_full.singular_values.len() + ); + for (sa, sf) in result_auto + .singular_values + .iter() + .zip(result_full.singular_values.iter()) + { + assert_relative_eq!(sa, sf, epsilon = 1e-10); + } + } +} diff --git a/exp/pecos-stab-tn/src/mps/tensor.rs b/exp/pecos-stab-tn/src/mps/tensor.rs new file mode 100644 index 000000000..ee8db45c7 --- /dev/null +++ b/exp/pecos-stab-tn/src/mps/tensor.rs @@ -0,0 +1,193 @@ +// 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. + +//! Tensor reshape and contraction utilities for MPS site tensors. +//! +//! Site tensors are stored as `DMatrix` with shape `(chi_l, d * chi_r)`. +//! The physical index `sigma in {0, ..., d-1}` selects a column block: +//! columns `[sigma * chi_r .. (sigma+1) * chi_r]`. + +use nalgebra::DMatrix; +use num_complex::Complex64; + +/// Extract the column block for physical index `sigma` from a site tensor. +/// +/// Site tensor has shape `(chi_l, d * chi_r)`. Returns a view of columns +/// `[sigma * chi_r .. (sigma+1) * chi_r]`, i.e. shape `(chi_l, chi_r)`. +#[must_use] +pub fn phys_block(tensor: &DMatrix, sigma: usize, chi_r: usize) -> DMatrix { + let start_col = sigma * chi_r; + tensor.columns(start_col, chi_r).clone_owned() +} + +/// Set the column block for physical index `sigma` in a site tensor. +pub fn set_phys_block( + tensor: &mut DMatrix, + sigma: usize, + chi_r: usize, + block: &DMatrix, +) { + let start_col = sigma * chi_r; + for j in 0..chi_r { + for i in 0..tensor.nrows() { + tensor[(i, start_col + j)] = block[(i, j)]; + } + } +} + +/// Reshape a site tensor from `(chi_l, d * chi_r)` to `(chi_l * d, chi_r)`. +/// +/// This puts the tensor in "left-grouped" form suitable for SVD when splitting +/// the bond to the right. +#[must_use] +pub fn reshape_left_group( + tensor: &DMatrix, + chi_l: usize, + d: usize, + chi_r: usize, +) -> DMatrix { + // Input: (chi_l, d * chi_r), stored as T[alpha_l, sigma * chi_r + alpha_r] + // Output: (chi_l * d, chi_r), stored as M[alpha_l * d + sigma, alpha_r] + let mut out = DMatrix::zeros(chi_l * d, chi_r); + for alpha_l in 0..chi_l { + for sigma in 0..d { + for alpha_r in 0..chi_r { + out[(alpha_l * d + sigma, alpha_r)] = tensor[(alpha_l, sigma * chi_r + alpha_r)]; + } + } + } + out +} + +/// Reshape from `(chi_l * d, chi_r)` back to `(chi_l, d * chi_r)`. +#[must_use] +pub fn reshape_left_ungroup( + matrix: &DMatrix, + chi_l: usize, + d: usize, + chi_r: usize, +) -> DMatrix { + let mut out = DMatrix::zeros(chi_l, d * chi_r); + for alpha_l in 0..chi_l { + for sigma in 0..d { + for alpha_r in 0..chi_r { + out[(alpha_l, sigma * chi_r + alpha_r)] = matrix[(alpha_l * d + sigma, alpha_r)]; + } + } + } + out +} + +/// Contract two adjacent site tensors into a combined two-site tensor. +/// +/// Left tensor: `(chi_l, d * chi_mid)`, right tensor: `(chi_mid, d * chi_r)`. +/// Result: `(chi_l, d * d * chi_r)` -- a "two-site" tensor with two physical indices. +/// +/// Layout of result: `T[alpha_l, sigma_l * d * chi_r + sigma_r * chi_r + alpha_r]` +#[must_use] +pub fn contract_two_sites( + left: &DMatrix, + chi_l: usize, + chi_mid: usize, + right: &DMatrix, + chi_r: usize, + d: usize, +) -> DMatrix { + let mut out = DMatrix::zeros(chi_l, d * d * chi_r); + for sigma_l in 0..d { + // left_block: (chi_l, chi_mid) for physical index sigma_l + let left_block = phys_block(left, sigma_l, chi_mid); + for sigma_r in 0..d { + // right_block: (chi_mid, chi_r) for physical index sigma_r + let right_block = phys_block(right, sigma_r, chi_r); + // contracted: (chi_l, chi_r) = left_block * right_block + let contracted = &left_block * &right_block; + // Place into output at combined physical index (sigma_l, sigma_r) + let out_col_start = (sigma_l * d + sigma_r) * chi_r; + for alpha_l in 0..chi_l { + for alpha_r in 0..chi_r { + out[(alpha_l, out_col_start + alpha_r)] = contracted[(alpha_l, alpha_r)]; + } + } + } + } + out +} + +/// Reshape a two-site tensor `(chi_l, d * d * chi_r)` into a matrix +/// `(chi_l * d, d * chi_r)` suitable for SVD splitting. +/// +/// Groups the left physical index with `chi_l` and right physical index with `chi_r`. +#[must_use] +pub fn reshape_two_site_for_svd( + tensor: &DMatrix, + chi_l: usize, + chi_r: usize, + d: usize, +) -> DMatrix { + let mut out = DMatrix::zeros(chi_l * d, d * chi_r); + for alpha_l in 0..chi_l { + for sigma_l in 0..d { + for sigma_r in 0..d { + for alpha_r in 0..chi_r { + let in_col = (sigma_l * d + sigma_r) * chi_r + alpha_r; + let out_row = alpha_l * d + sigma_l; + let out_col = sigma_r * chi_r + alpha_r; + out[(out_row, out_col)] = tensor[(alpha_l, in_col)]; + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_phys_block_roundtrip() { + // Create a 2x4 tensor (chi_l=2, d=2, chi_r=2) + let t = DMatrix::from_row_slice( + 2, + 4, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(2.0, 0.0), + Complex64::new(3.0, 0.0), + Complex64::new(4.0, 0.0), + Complex64::new(5.0, 0.0), + Complex64::new(6.0, 0.0), + Complex64::new(7.0, 0.0), + Complex64::new(8.0, 0.0), + ], + ); + let b0 = phys_block(&t, 0, 2); + let b1 = phys_block(&t, 1, 2); + assert_eq!(b0[(0, 0)], Complex64::new(1.0, 0.0)); + assert_eq!(b0[(0, 1)], Complex64::new(2.0, 0.0)); + assert_eq!(b1[(0, 0)], Complex64::new(3.0, 0.0)); + assert_eq!(b1[(1, 1)], Complex64::new(8.0, 0.0)); + } + + #[test] + fn test_reshape_roundtrip() { + let t = DMatrix::from_fn(3, 4, |i, j| { + Complex64::new(f64::from(u32::try_from(i * 4 + j).unwrap()), 0.0) + }); + let grouped = reshape_left_group(&t, 3, 2, 2); + assert_eq!(grouped.nrows(), 6); + assert_eq!(grouped.ncols(), 2); + let ungrouped = reshape_left_ungroup(&grouped, 3, 2, 2); + assert_eq!(ungrouped, t); + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps.rs b/exp/pecos-stab-tn/src/stab_mps.rs new file mode 100644 index 000000000..888c2b6f6 --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps.rs @@ -0,0 +1,6368 @@ +// 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. + +//! `StabMps` — hybrid stabilizer-tableau + MPS simulator. +//! +//! Represents a quantum state as: +//! +//! ```text +//! |psi> = sum_i nu_i D_i |phi> +//! ``` +//! +//! where |phi> is a stabilizer state tracked by a `SparseStabY` tableau, +//! `D_i` are destabilizer operators, and `nu_i` are complex coefficients +//! stored as an MPS. +//! +//! - Clifford gates: update only the tableau (O(n^2)), MPS untouched +//! - Non-Clifford gates (RZ): decompose `Z_q` in stabilizer basis, apply to MPS +//! +//! Based on: Masot-Llima, Garcia-Saez. "Stabilizer Tensor Networks: Universal +//! Quantum Simulator on a Basis of Stabilizer States." PRL 133, 230601 (2024). +//! arXiv:2403.08724. + +pub mod compile; +pub mod disentangle; +pub mod mast; +pub mod measure; +pub mod non_clifford; +pub mod ofd; +pub mod pauli_decomp; +pub mod renyi; +pub mod tableau_compose; + +use crate::mps::{Mps, MpsConfig}; +use nalgebra::DMatrix; +use num_complex::Complex64; +use pecos_core::{Angle64, QubitId}; +use pecos_random::PecosRng; +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. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SiteEigenstate { + /// |0⟩ or |1⟩ (Z eigenstate). Compatible with X or Y Pauli rotations. + Z(bool), + /// |+⟩ or |−⟩ (X eigenstate). Compatible with Z or Y Pauli rotations. + X(bool), + /// |+i⟩ or |−i⟩ (Y eigenstate). Compatible with X or Z Pauli rotations. + Y(bool), +} + +/// A gate applied in the MPS index space (for disentangling). +#[derive(Clone)] +pub(crate) struct MpsIndexGate { + site: usize, + inverse_matrix: DMatrix, +} + +/// Single-qubit Pauli kind for specifying multi-qubit Pauli strings +/// (e.g., stabilizer generators of QEC codes). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PauliKind { + X, + Y, + Z, +} + +/// Single-qubit Clifford kind used internally for Pauli frame propagation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SingleQubitCliffordKind { + H, + SZ, + SZdg, + X, + Y, + Z, +} + +/// Runtime feature flags for `StabMps`, stored as a bitfield. +#[derive(Clone, Copy, Debug)] +pub struct StabMpsFlags(u8); + +impl StabMpsFlags { + const NORMALIZE_AFTER_GATE: u8 = 1 << 0; + const LAZY_MEASURE: u8 = 1 << 1; + const MERGE_RZ: u8 = 1 << 2; + const PAULI_FRAME_TRACKING: u8 = 1 << 3; + + /// Default flags: normalize enabled, everything else off. + #[must_use] + pub const fn new() -> Self { + Self(Self::NORMALIZE_AFTER_GATE) + } + + fn get(self, bit: u8) -> bool { + self.0 & bit != 0 + } + + fn set(&mut self, bit: u8, val: bool) { + if val { + self.0 |= bit; + } else { + self.0 &= !bit; + } + } + + #[must_use] + pub fn normalize_after_gate(self) -> bool { + self.get(Self::NORMALIZE_AFTER_GATE) + } + pub fn set_normalize_after_gate(&mut self, v: bool) { + self.set(Self::NORMALIZE_AFTER_GATE, v); + } + #[must_use] + pub fn lazy_measure(self) -> bool { + self.get(Self::LAZY_MEASURE) + } + pub fn set_lazy_measure(&mut self, v: bool) { + self.set(Self::LAZY_MEASURE, v); + } + #[must_use] + pub fn merge_rz(self) -> bool { + self.get(Self::MERGE_RZ) + } + pub fn set_merge_rz(&mut self, v: bool) { + self.set(Self::MERGE_RZ, v); + } + #[must_use] + pub fn pauli_frame_tracking(self) -> bool { + self.get(Self::PAULI_FRAME_TRACKING) + } + pub fn set_pauli_frame_tracking(&mut self, v: bool) { + self.set(Self::PAULI_FRAME_TRACKING, v); + } +} + +impl Default for StabMpsFlags { + fn default() -> Self { + Self::new() + } +} + +/// Builder for configuring an `StabMps` simulator. +pub struct StabMpsBuilder { + num_qubits: usize, + seed: Option, + max_bond_dim: usize, + svd_cutoff: f64, + max_truncation_error: Option, + parallel: bool, + auto_grow_bond_dim: Option, + auto_grow_max_bond_dim: usize, + flags: StabMpsFlags, +} + +impl StabMpsBuilder { + /// Maximum MPS bond dimension. Singular values beyond this are discarded + /// during SVD truncation after two-site gates. + /// + /// - Default: 64 + /// - Higher values give more accuracy at the cost of memory and time + /// - For n qubits, the exact max is 2^(n/2) + #[must_use] + pub fn max_bond_dim(mut self, dim: usize) -> Self { + self.max_bond_dim = dim; + self + } + + /// Minimum singular value to keep (absolute cutoff). + /// + /// - Default: 1e-12 + /// - Lower values keep more precision + #[must_use] + pub fn svd_cutoff(mut self, cutoff: f64) -> Self { + self.svd_cutoff = cutoff; + self + } + + /// Normalize the MPS after each non-Clifford gate. + /// Prevents unbounded norm drift from accumulated SVD numerical noise. + /// + /// - Default: true + /// - Set to false if you need to track the unnormalized state + #[must_use] + pub fn normalize_after_gate(mut self, normalize: bool) -> Self { + self.flags.set_normalize_after_gate(normalize); + self + } + + /// Set the RNG seed for reproducible measurements. + #[must_use] + pub fn seed(mut self, seed: u64) -> Self { + self.seed = Some(seed); + self + } + + /// Maximum relative truncation error per SVD (adaptive bond dimension). + /// + /// When set, bonds with low entanglement get small bond dimension (fast) + /// 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 + /// - `max_bond_dim` still acts as a hard cap + #[must_use] + pub fn max_truncation_error(mut self, error: f64) -> Self { + self.max_truncation_error = Some(error); + self + } + + /// Enable parallel MPS operations via rayon. + /// + /// - Default: false + /// - Useful for large bond dimensions (chi > 16) + /// - Do not enable when parallelizing at the shot/circuit level + #[must_use] + pub fn parallel(mut self, parallel: bool) -> Self { + self.parallel = parallel; + self + } + + /// Use lazy virtual-frame measurement: accumulate `pre_reduce` CNOTs AND + /// post-projection basis-rotation Cliffords into a deferred `V` queue + /// instead of applying them eagerly to the MPS. Pauli strings from + /// `decompose_z` are conjugated by `V†` before application to the + /// stored MPS, so expectation/projection are exact. + /// + /// - Default: false (eager path) + /// - **Not a universal win.** Per `examples/qec_bench.rs`, eager is + /// faster for both QEC-like (syndrome extraction + T noise) and + /// MAST-style (T-injection + ancilla measurement) workloads. Lazy + /// uses MPS addition for projection (bond grows ~2× per measurement) + /// whereas eager uses an in-place single-site basis-swap trick that + /// avoids bond growth. Lazy's only advantage is exact stored-MPS + /// state for subsequent non-measurement operations; eager's stored + /// MPS drifts slightly but measurement statistics and tableau stay + /// correct. Enable only if you need exact MPS state after random + /// measurements (e.g., computing `state_vector` or `amplitude` and + /// requiring no drift across many measurements). + #[must_use] + pub fn lazy_measure(mut self, lazy: bool) -> Self { + self.flags.set_lazy_measure(lazy); + self + } + + /// Enable adaptive bond-dim auto-grow. When the running truncation + /// error exceeds `threshold` AND the bond-dim cap was binding (a + /// truncation step actually discarded singular values at the cap), + /// the simulator doubles `max_bond_dim` (capped at + /// `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 + /// allocation on growth (rare). Benefit: avoids surprise truncation + /// when entanglement spikes. + /// + /// - Default: `None` (disabled — fixed `max_bond_dim`). + /// - Typical thresholds: 1e-6 (conservative), 1e-4 (aggressive). + #[must_use] + pub fn auto_grow_bond_dim(mut self, threshold: f64) -> Self { + self.auto_grow_bond_dim = Some(threshold); + self + } + + /// Hard cap on `auto_grow_bond_dim`'s growth. Default: 4096. + #[must_use] + pub fn auto_grow_max_bond_dim(mut self, cap: usize) -> Self { + self.auto_grow_max_bond_dim = cap; + self + } + + /// Enable Pauli frame tracking: `inject_x_in_frame`, `inject_y_in_frame`, + /// `inject_z_in_frame`, and (when the flag is set) + /// `apply_depolarizing*` track Pauli errors as classical bits rather + /// than applying them to the quantum state. Clifford gates propagate + /// the frame via Heisenberg rules; measurements XOR the tracked + /// Z-bit into the outcome. + /// + /// **Big win** for Pauli-noise-heavy QEC simulation: each error is + /// a single bit flip (O(1)) instead of an O(n) tableau update. + /// + /// - Default: false. + /// - Sign tracking: `pauli_frame_phase` evolves through Clifford + /// propagation per Heisenberg sign-flip rules (H·Y·H = -Y, + /// SZ·Y·SZ† = -X, etc.) and folds into `global_phase` at flush. + /// - `State_vector` after flush: EXACT for all states. The frame is + /// applied to the MPS via `C† · P · C = phase · X_flip · Z_sign` + /// (decomposition in the MPS frame), not to the tableau. The + /// Clifford `C` is unchanged, the MPS absorbs the frame's full + /// content, and there is no state-dependent phase loss. + #[must_use] + pub fn pauli_frame_tracking(mut self, enable: bool) -> Self { + self.flags.set_pauli_frame_tracking(enable); + self + } + + /// Merge consecutive `rz(θ, q)` on the same qubit into a single + /// `rz(Σθ, q)` before invoking the non-Clifford path. Any gate + /// touching `q` (other than another `rz` on `q`) flushes the + /// accumulated angle first. Intended for ion-trap-style memory-error + /// models where every idle qubit receives a small RZ each time step: + /// adjacent idle rounds merge into one non-Clifford op. + /// + /// - Default: false. + /// - Semantics: strictly equivalent to applying each `rz` individually + /// (tableau and MPS paths both reduce non-Clifford count). No + /// accuracy trade-off. + /// - Clifford-angle RZ (0, π/2, π, 3π/2) is detected and applied + /// directly as before (no buffering). + #[must_use] + pub fn merge_rz(mut self, merge: bool) -> Self { + self.flags.set_merge_rz(merge); + self + } + + /// Preset for QEC-style workloads: stabilizer-code circuits with + /// non-Clifford noise (T gates, small-angle RZ), syndrome extraction, + /// magic-state distillation. + /// + /// 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. + /// + /// Override any of these with subsequent builder calls: + /// ```ignore + /// StabMps::builder(n).for_qec().max_bond_dim(64).build() + /// ``` + #[must_use] + pub fn for_qec(self) -> Self { + self.for_qec_with_bond_dim(128) + } + + /// Like `for_qec()` 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] + pub fn for_qec_with_bond_dim(self, bond_dim: usize) -> Self { + self.max_truncation_error(1e-8) + .max_bond_dim(bond_dim) + .merge_rz(true) + } + + /// Build the simulator. + #[must_use] + pub fn build(self) -> StabMps { + let config = MpsConfig { + max_bond_dim: self.max_bond_dim, + svd_cutoff: self.svd_cutoff, + max_truncation_error: self.max_truncation_error, + parallel: self.parallel, + }; + let (tableau, rng) = if let Some(seed) = self.seed { + ( + SparseStabY::with_seed(self.num_qubits, seed).with_destab_sign_tracking(), + PecosRng::seed_from_u64(seed), + ) + } else { + ( + SparseStabY::new(self.num_qubits).with_destab_sign_tracking(), + PecosRng::seed_from_u64(0), + ) + }; + StabMps { + num_qubits: self.num_qubits, + tableau, + mps: Mps::new(self.num_qubits, config.clone()), + config, + mps_corrections: Vec::new(), + global_phase: Complex64::new(1.0, 0.0), + disent_flags: vec![Some(SiteEigenstate::Z(false)); self.num_qubits], + gf2_matrix: ofd::Gf2FlipMatrix::new(self.num_qubits), + rng, + stats: StabMpsStats::default(), + deferred_ops: Vec::new(), + pragmatic_drift_count: 0, + pending_rz: vec![None; self.num_qubits], + auto_grow_bond_dim: self.auto_grow_bond_dim, + auto_grow_max_bond_dim: self.auto_grow_max_bond_dim, + last_truncation_error: 0.0, + pauli_frame_x: vec![false; self.num_qubits], + pauli_frame_z: vec![false; self.num_qubits], + pauli_frame_phase: Complex64::new(1.0, 0.0), + flags: self.flags, + } + } +} + +/// Stabilizer Tensor Network simulator. +#[derive(Clone)] +pub struct StabMps { + num_qubits: usize, + tableau: SparseStabY, + mps: Mps, + config: MpsConfig, + /// Inverse of disentangling gates applied to MPS (in index space). + mps_corrections: Vec, + /// Global phase accumulated from Clifford-angle RZ gates. + global_phase: Complex64, + /// Per-site eigenstate tracking for exact disentangling. + disent_flags: Vec>, + /// GF(2) flip matrix for OFD diagnostic. + gf2_matrix: ofd::Gf2FlipMatrix, + rng: PecosRng, + /// Diagnostic counters. Updated by `non_clifford::apply_rz_stab_mps`. + pub stats: StabMpsStats, + /// Deferred virtual-frame Clifford V (see `measure::DeferredOp`). + deferred_ops: Vec, + /// Count of pragmatic-path measurement drifts. + pragmatic_drift_count: u64, + /// Pending non-Clifford RZ angle per qubit when `merge_rz` is on. + pending_rz: Vec>, + /// Auto-grow bond-dim threshold; `None` disables. + auto_grow_bond_dim: Option, + /// Hard cap when auto-growing. + auto_grow_max_bond_dim: usize, + /// Snapshot of `mps.truncation_error()` at the last auto-grow check. + last_truncation_error: f64, + /// Pauli frame X bit per qubit. + pauli_frame_x: Vec, + /// Pauli frame Z bit per qubit. + pauli_frame_z: Vec, + /// Global scalar of the Pauli frame. + pauli_frame_phase: Complex64, + /// Runtime feature flags. + flags: StabMpsFlags, +} + +/// Runtime statistics for diagnostics. +#[derive(Clone, Copy, Debug, Default)] +pub struct StabMpsStats { + /// Total non-Clifford RZ calls (Clifford-angle RZs not counted). + pub total_nonclifford: u64, + /// Non-Cliffords that hit the single-site decomposition (cheap). + pub single_site: u64, + /// Non-Cliffords that fired multi-site disent (tableau right-compose). + pub multi_disent: 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). + pub stabilizer: u64, + /// OFD diagnostic: non-Cliffords whose flip pattern is in the span of + /// previously-added patterns (would not increase bond dim under OFD). + pub ofd_in_span: u64, + /// OFD diagnostic: non-Cliffords whose flip pattern is linearly independent + /// from previous (OFD would grow bond dim by factor 2). + pub ofd_new_dim: u64, + /// Cross-tab: OFD `in_span` gates that the heuristic routed through std path. + /// These are the "OFD wins" — OFD would avoid MPS CNOT cascade. + pub ofd_in_span_std: u64, + /// Cross-tab: OFD `in_span` gates that the heuristic routed through single-site. + /// Both paths are cheap; OFD doesn't improve here. + pub ofd_in_span_single: u64, + /// Cross-tab: OFD `in_span` gates that the heuristic routed through disent path. + pub ofd_in_span_disent: u64, +} + +impl StabMps { + /// Create a builder for configuring the simulator. + #[must_use] + pub fn builder(num_qubits: usize) -> StabMpsBuilder { + StabMpsBuilder { + num_qubits, + seed: None, + max_bond_dim: 64, + svd_cutoff: 1e-12, + max_truncation_error: None, + parallel: false, + auto_grow_bond_dim: None, + auto_grow_max_bond_dim: 4096, + flags: StabMpsFlags::new(), + } + } + + /// Create a new STN simulator with default configuration. + #[must_use] + pub fn new(num_qubits: usize) -> Self { + Self::builder(num_qubits).build() + } + + /// Create with a specific seed for reproducibility. + #[must_use] + pub fn with_seed(num_qubits: usize, seed: u64) -> Self { + Self::builder(num_qubits).seed(seed).build() + } + + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Current maximum bond dimension in the MPS. + #[must_use] + pub fn max_bond_dim(&self) -> usize { + self.mps.max_bond_dim() + } + + /// Theoretical minimum bond dimension from GF(2) OFD analysis. + /// + /// Returns `2^(t - rank)` where t is the number of non-Clifford gates + /// applied and rank is the GF(2) rank of the flip pattern matrix. + /// This is the best bond dimension achievable by Clifford disentangling. + #[must_use] + pub fn theoretical_min_bond_dim(&self) -> usize { + self.gf2_matrix.theoretical_min_bond_dim() + } + + /// OFD null space dimension (Liu-Clark 2412.17209 Section III.C). + /// + /// Returns `t - rank` where t is the number of absorbed non-Clifford gates + /// and rank is the GF(2) rank of their flip patterns. This is the number + /// of gates that could NOT be disentangled (required bond-dim growth). + /// + /// The bond dimension lower bound from OFD is `2^nullity`. + /// + /// For research circuits where nullity < log₂(N), the simulation is + /// efficient (polynomial in N). + #[must_use] + pub fn ofd_nullity(&self) -> usize { + let t = self.gf2_matrix.num_gates(); + let r = self.gf2_matrix.gf2_rank(); + t.saturating_sub(r) + } + + /// Number of non-Clifford gates that OFD disentangled (rank of GF(2) matrix). + #[must_use] + pub fn ofd_disentangled_count(&self) -> usize { + self.gf2_matrix.gf2_rank() + } + + /// Total non-Clifford gates recorded in the GF(2) basis. + #[must_use] + pub fn ofd_total_absorbed(&self) -> usize { + self.gf2_matrix.num_gates() + } + + /// Access the GF(2) flip matrix (for diagnostics). + #[must_use] + pub fn gf2_matrix(&self) -> &ofd::Gf2FlipMatrix { + &self.gf2_matrix + } + + /// Wavefunction amplitude ⟨s|C|ψ⟩ for a given bitstring `s`. + /// + /// `bitstring` has length `num_qubits`; bit k corresponds to qubit k. + /// Returns the unnormalized amplitude coefficient. + /// + /// For n ≤ 14 uses `state_vector()` directly. Paper Liu-Clark 2412.17209 + /// Section VI.B gives an iterative CAMPS-native algorithm for larger n. + /// + /// # Panics + /// Panics if bitstring length doesn't match `num_qubits`, or n > 14. + #[must_use] + pub fn amplitude(&self, bitstring: &[bool]) -> Complex64 { + assert_eq!( + bitstring.len(), + self.num_qubits, + "bitstring length mismatch" + ); + 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. + let mut idx = 0usize; + for (k, &b) in bitstring.iter().enumerate() { + if b { + idx |= 1 << (self.num_qubits - 1 - k); + } + } + sv[idx] + } + + /// Compute `⟨Ψ|P|Ψ⟩` for an arbitrary multi-qubit Pauli string `P`. + /// + /// `pauli_string` lists non-identity factors as `(qubit, PauliKind)` + /// pairs; qubits not listed get `I`. Returns the real expectation + /// value (the Hermitian Pauli always has real expectation). + /// + /// Building block for code-state fidelity at large `n` (sum over + /// stabilizer group of `⟨Ψ|g|Ψ⟩`), variational energy estimation, + /// and arbitrary-observable readout. + /// + /// # Method + /// Tableau-based decomposition: writes `P` as + /// `phase · X_{flip} · Z_{sign}` (in stored-MPS frame, after + /// conjugating by `C†`) + /// using `pauli_decomp::decompose_pauli_string`, then evaluates via + /// `measure::pauli_expectation` on the MPS. Scales to arbitrary `n`. + /// + /// # Panics + /// Panics if any qubit index exceeds `num_qubits`. + #[must_use] + pub fn pauli_expectation(&self, pauli_string: &[(usize, PauliKind)]) -> f64 { + // Translate public PauliKind into pauli_decomp's enum. + let decomp_input: Vec<(usize, pauli_decomp::PauliKindForDecomp)> = pauli_string + .iter() + .map(|&(q, k)| { + assert!(q < self.num_qubits, "pauli qubit index {q} >= num_qubits"); + let pk = match k { + PauliKind::X => pauli_decomp::PauliKindForDecomp::X, + PauliKind::Y => pauli_decomp::PauliKindForDecomp::Y, + PauliKind::Z => pauli_decomp::PauliKindForDecomp::Z, + }; + (q, pk) + }) + .collect(); + let (flip, sign, phase) = pauli_decomp::decompose_pauli_string( + self.tableau.stabs(), + self.tableau.destabs(), + &decomp_input, + ); + measure::pauli_expectation(&self.mps, &flip, &sign, phase).re + } + + /// Compute the overlap `⟨s|Ψ⟩` where `|s⟩` is a stabilizer state given + /// as a CH-form simulator. Uses the importance-sampling estimator from + /// CD-Loschmidt-echoes (Mello, Santini, Collura, arXiv:2502.01872 Eq. 1): + /// + /// `⟨s|Ψ⟩ = E_{x ~ |⟨x|s⟩|²}[ ⟨x|Ψ⟩ / ⟨x|s⟩ ]` + /// + /// Variance is `1 − |⟨s|Ψ⟩|²` (independent of N — Eq. 2 of the paper), + /// so a few hundred samples typically suffice for 1% statistical error. + /// Scales to arbitrary `n` (uses `amplitude_iterative` for `⟨x|Ψ⟩` and + /// CH-form `amplitude` + sequential measurement for `⟨x|s⟩` and + /// stabilizer Born sampling). + /// + /// 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`. + /// + /// # Arguments + /// - `s`: CH-form simulator representing the stabilizer state `|s⟩`. + /// Caller is responsible for setting up the desired Clifford circuit + /// on `s` before passing it in. **Mutated** as samples are drawn + /// (cloned internally per sample); a fresh CH-form is used per shot. + /// - `num_samples`: number of MC samples. ~100 gives ~10% error, + /// ~10000 gives ~1% error. + /// - `rng_seed`: optional seed for per-sample CH-form clones. When + /// `None`, uses a deterministic hash of the sample index (reproducible + /// but not caller-controllable). Pass `Some(seed)` to control the + /// MC stream for reproducibility across runs. + /// + /// # Returns + /// Complex MC estimate of `⟨s|Ψ⟩`. Take `.norm_sqr()` for a fidelity + /// estimate `|⟨s|Ψ⟩|²`. + /// + /// # Limitations + /// - Requires `n <= 64` (usize bitstring index in CH-form). + /// - Statistical estimator: not exact. Use `code_state_fidelity` for + /// exact answer at `n <= 14`. + /// - The CH-form `s` must be on the same number of qubits as `self`. + /// + /// # Panics + /// + /// Panics if `s.num_qubits() != self.num_qubits` or `num_qubits > 64`. + #[must_use] + pub fn overlap_with_stabilizer< + R: pecos_random::SeedableRng + pecos_random::Rng + std::fmt::Debug + Clone, + >( + &self, + s: &pecos_simulators::CHForm, + num_samples: usize, + rng_seed: Option, + ) -> Complex64 { + use pecos_core::RngManageable; + + assert_eq!( + s.num_qubits(), + self.num_qubits, + "stabilizer-state qubit count mismatch" + ); + assert!( + self.num_qubits <= 64, + "overlap_with_stabilizer requires n <= 64" + ); + + let n = self.num_qubits; + let mut acc = Complex64::new(0.0, 0.0); + let mut samples_used = 0usize; + + for sample_idx in 0..num_samples { + // Per-sample clone of |s⟩ with a fresh RNG seed so each sample + // produces an independent bitstring from the Born distribution. + // Use sample_idx-based seed (self is &self so we can't advance + // self.rng). The wrapping_mul mixes bits to avoid trivial overlap. + let mut s_sampler = s.clone(); + let base_seed = rng_seed.unwrap_or(42); + let sample_seed = (sample_idx as u64) + .wrapping_mul(2_654_435_761) + .wrapping_add(base_seed); + s_sampler.set_rng(R::seed_from_u64(sample_seed)); + let mut bitstring = vec![false; n]; + for (q, bit) in bitstring.iter_mut().enumerate() { + let outcome = s_sampler.mz(&[pecos_core::QubitId(q)])[0].outcome; + *bit = outcome; + } + // Compute x as usize index per CH-form's amplitude API: + // bit q of x corresponds to qubit q's outcome (LSB-first). + let mut x_idx = 0usize; + for (q, &bit) in bitstring.iter().enumerate() { + if bit { + x_idx |= 1usize << q; + } + } + let amp_xs = s.amplitude(x_idx); + if amp_xs.norm_sqr() < 1e-30 { + // Zero-amplitude sample: should be impossible if we sampled + // 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); + acc += amp_xpsi / amp_xs; + samples_used += 1; + } + if samples_used == 0 { + eprintln!( + "warning: overlap_with_stabilizer: all {num_samples} samples had zero amplitude — returning 0" + ); + return Complex64::new(0.0, 0.0); + } + acc / Complex64::new( + f64::from(u32::try_from(samples_used).expect("samples fit in u32")), + 0.0, + ) + } + + /// Compute `⟨Ψ|P_code|Ψ⟩` where `P_code` is the projector onto the + /// stabilizer code subspace defined by `stabilizer_generators`. + /// + /// Each generator is a Pauli string given as a `Vec<(usize, PauliKind)>` + /// listing non-identity factors. `P_code = Π_i (I + g_i)/2` for `k` + /// generators yields a fidelity in [0, 1]: 1 means `|Ψ⟩` is fully + /// inside the code subspace, 0 means fully outside. + /// + /// Useful for QEC verification: after running a code's preparation / + /// syndrome-extraction circuit, this returns how much of the state is + /// in the codespace. Compare against expected value (1.0 for noiseless, + /// less for noisy circuits). + /// + /// # Method + /// Expands `P_code = (1/2^k) Σ_{g ∈ stabilizer group} g` and computes + /// `(1/2^k) Σ ⟨Ψ|g|Ψ⟩` via `pauli_expectation` per group element. + /// Scales to arbitrary `n` (the bottleneck is `2^k` group enumeration + /// where `k = stabilizer_generators.len()`). + /// + /// For codes with many generators, prefer + /// `StabMps::overlap_with_stabilizer` (CD Loschmidt MC) targeting one + /// specific code state at a time. + /// + /// # 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). + #[must_use] + pub fn code_state_fidelity(&self, stabilizer_generators: &[Vec<(usize, PauliKind)>]) -> f64 { + let k = stabilizer_generators.len(); + assert!( + k <= 30, + "code_state_fidelity: 2^k group enumeration with k={k} would take too long" + ); + let n = self.num_qubits; + for gen_string in stabilizer_generators { + for &(q, _) in gen_string { + assert!(q < n, "generator qubit index {q} >= num_qubits {n}"); + } + } + let group_size = 1usize << k; + let mut acc = 0.0; + for mask in 0..group_size { + // Compose group element by multiplying generators selected by mask. + // Use Pauli aggregation via decompose_pauli_string's per-qubit logic + // — but we just need <Ψ|g|Ψ>, so flatten the selected generators + // into one Pauli-string list and let pauli_expectation aggregate. + let mut composed: Vec<(usize, PauliKind)> = Vec::new(); + for (i, generator) in stabilizer_generators.iter().enumerate() { + if (mask >> i) & 1 == 1 { + composed.extend_from_slice(generator); + } + } + acc += self.pauli_expectation(&composed); + } + acc / f64::from(u32::try_from(group_size).expect("group_size fits in u32")) + } + + /// Complex amplitude ⟨s|Ψ⟩ via iterative forced projection without + /// renormalization (Liu-Clark 2412.17209 Section VI.B). + /// + /// 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) + /// contains the amplitude at its |0^N⟩ coefficient: + /// amp(s) = `global_phase` · `ν_final(0^N)`. + /// + /// # Correctness + /// Exact match to `amplitude` (SV-based) at n ≤ 14 for Clifford+T + /// circuits. Scales to arbitrary n via MPS operations. Probabilities + /// via `prob_bitstring` are always correct. + /// + /// # Panics + /// Panics if bitstring length doesn't match `num_qubits`. + #[must_use] + pub fn amplitude_iterative(&self, bitstring: &[bool]) -> Complex64 { + assert_eq!( + bitstring.len(), + self.num_qubits, + "bitstring length mismatch" + ); + 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]; + if !measure::project_forced_z_unnormalized(&mut tab, &mut mps, q, s_q) { + return Complex64::new(0.0, 0.0); + } + } + let zero: Vec = vec![0u8; n]; + self.global_phase * mps.amplitude(&zero) + } + + /// Probability of measuring `bitstring` in the computational basis. + /// + /// 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`⟩ + /// |ψ_{k+1}⟩ ∝ (I + (-`1)^{s_k`} `Z̃_k)/2` |`ψ_k`⟩ + /// where `Z̃_k` is the tableau's Z-mapping on qubit k. Final probability is + /// the product of conditional probabilities `π_k`. + /// + /// Scales beyond n = 14 (unlike `amplitude`) by working directly on the + /// MPS + tableau instead of the full state vector. + /// + /// # Panics + /// Panics if bitstring length doesn't match `num_qubits`. + #[must_use] + pub fn prob_bitstring(&self, bitstring: &[bool]) -> f64 { + assert_eq!( + bitstring.len(), + self.num_qubits, + "bitstring length mismatch" + ); + 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]; + let pi_q = measure::project_forced_z(&mut tab, &mut mps, q, s_q); + total_prob *= pi_q; + if total_prob < 1e-30 { + return 0.0; + } + } + total_prob.clamp(0.0, 1.0) + } + + /// Second Rényi entropy `S_2` = -`ln(Tr_A(ρ_A²))` at a bipartition + /// (qubits 0..cut vs qubits cut..N). + /// + /// 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 + /// generator enumeration and CAMPS-specific Gaussian elimination. + /// + /// # Panics + /// Panics if cut == 0 or cut >= `num_qubits`, or if `num_qubits` > 14. + #[must_use] + pub fn renyi_s2(&self, cut: usize) -> f64 { + let n = self.num_qubits; + assert!(cut > 0 && cut < n, "cut must be in (0, num_qubits)"); + assert!( + n <= 14, + "renyi_s2 requires n <= 14 (uses full state vector)" + ); + + let sv = self.state_vector(); + let dim_a = 1usize << cut; + let dim_b = 1usize << (n - cut); + // state_vector is LSB-first: `sv[idx]` has qubit k at bit k of idx. + // Convention: A = first `cut` qubits (0..cut) → low bits. + // B = qubits cut..n → high bits. + // idx = a_bits | (b_bits << cut) + // + // Reduced density ρ_A: (ρ_A)_{a, a'} = Σ_b ψ(a, b) · ψ*(a', b) + let mut rho_a = vec![Complex64::new(0.0, 0.0); dim_a * dim_a]; + for a in 0..dim_a { + for a_prime in 0..dim_a { + let mut acc = Complex64::new(0.0, 0.0); + for b in 0..dim_b { + let idx1 = a | (b << cut); + let idx2 = a_prime | (b << cut); + acc += sv[idx1] * sv[idx2].conj(); + } + rho_a[a * dim_a + a_prime] = acc; + } + } + + // S_2 = -ln(Tr(ρ_A^2)) = -ln(Σ_{a,a'} |ρ_A[a, a']|^2). + let mut tr_sq = 0.0_f64; + for a in 0..dim_a { + for a_prime in 0..dim_a { + tr_sq += rho_a[a * dim_a + a_prime].norm_sqr(); + } + } + if tr_sq < 1e-30 { + f64::INFINITY + } else { + -tr_sq.ln() + } + } + + /// CAMPS-native `S_2` entropy via Pauli Coefficient Enumeration (Liu-Clark + /// Section VI.C). Does NOT require constructing the state vector, so scales + /// beyond n = 14 when the MPS has bond dim 1 and T-gate density is moderate. + /// + /// `cut` places qubits [0, cut) in region A, [cut, n) in region B. + /// + /// 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. + /// + /// # Errors + /// + /// Returns an error string if the cut is out of range or the number of + /// Pauli combinations exceeds the safety limit. + pub fn s2_pce(&self, cut: usize) -> Result { + let n = self.num_qubits; + if cut == 0 || cut >= n { + return Err(format!("cut {cut} must be in (0, {n})")); + } + let mask: Vec = (0..n).map(|q| q < cut).collect(); + renyi::compute_s2_pce(&self.mps, &self.tableau, &mask) + } + + /// Fast `S_2` via GF(2) null-space enumeration (PCMPS). Requires every MPS + /// site to have a single Pauli-axis Bloch vector (typical for STN + /// Clifford+T where T gets absorbed into the tableau). Falls back to + /// [`StabMps::s2_pce`] if multi-axis sites are present. + /// + /// 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. + /// + /// # Errors + /// + /// Returns an error string if the cut is out of range, or if the null-space + /// enumeration is too large. + pub fn s2_pcmps(&self, cut: usize) -> Result { + let n = self.num_qubits; + if cut == 0 || cut >= n { + return Err(format!("cut {cut} must be in (0, {n})")); + } + let mask: Vec = (0..n).map(|q| q < cut).collect(); + // Fast path: single-axis-per-site PCMPS (Clifford-state analytic + // short-circuit handles pure Clifford at any n). + if let Ok(s) = renyi::compute_s2_pcmps(&self.mps, &self.tableau, &mask) { + return Ok(s); + } + // General path: 2N-bit F_2 null-space TN enumeration. Handles + // multi-axis Bloch but null_dim capped at 22. + if let Ok(s) = renyi::compute_s2_pcmps_tn(&self.mps, &self.tableau, &mask) { + return Ok(s); + } + // Last resort: full PCE (4^N hard cap). + renyi::compute_s2_pce(&self.mps, &self.tableau, &mask) + } + + /// Access the MPS (for testing). + #[must_use] + pub fn mps(&self) -> &Mps { + &self.mps + } + + /// Accumulated truncation error so far (approximate `1 - |⟨ψ_exact|ψ⟩|²`). + /// Zero if no SVD has dropped any singular values above `svd_cutoff`. + #[must_use] + pub fn truncation_error(&self) -> f64 { + self.mps.truncation_error() + } + + /// 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`. + #[must_use] + pub fn bond_cap_hits(&self) -> u64 { + self.mps.bond_cap_hits() + } + + /// Access the tableau (for testing). + #[must_use] + pub fn tableau(&self) -> &SparseStabY { + &self.tableau + } + + /// 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. + 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). + /// + /// Directly computes |psi> = `Σ_x` `ν_x` * D^x * |stab> from the MPS + /// coefficients and the current stabilizer/destabilizer generators. + /// + /// # Accuracy caveats (read if you have outstanding measurements) + /// + /// - **Default (pragmatic-fix) measurement path**: `measure_qubit_stab_mps` + /// skips MPS compensation for `pre_reduce` row-ops. The stored + /// `(tableau, MPS)` pair may no longer represent the exact physical + /// state after a measurement that triggered multi-anticom + /// `pre_reduce`. Measurement outcome statistics stay correct, but + /// `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. + /// - **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 + /// output (modulo a global phase for Y contributions). + /// + /// # Panics + /// + /// Panics if `num_qubits > 14`. + #[must_use] + pub fn state_vector(&self) -> Vec { + assert!( + self.num_qubits <= 14, + "state_vector only for small systems (N <= 14)" + ); + + let n = self.num_qubits; + let dim = 1usize << n; + let mut mps_sv = self.mps.state_vector(); + + // Undo disentangling corrections (reverse order) so MPS SV matches the tableau. + // MPS uses MSB-first: bit (n-1-k) = destabilizer index k. + for correction in self.mps_corrections.iter().rev() { + let k = correction.site; + let bit_hi = n - 1 - k; + let bit_lo = n - 1 - (k + 1); + let mat = &correction.inverse_matrix; + let mut new_sv = vec![Complex64::new(0.0, 0.0); dim]; + for (idx, &sv_val) in mps_sv.iter().enumerate() { + let sigma_in = ((idx >> bit_hi) & 1) * 2 + ((idx >> bit_lo) & 1); + let base = idx & !(1 << bit_hi) & !(1 << bit_lo); + for sigma_out in 0..4usize { + let out_idx = base | ((sigma_out >> 1) << bit_hi) | ((sigma_out & 1) << bit_lo); + new_sv[out_idx] += mat[(sigma_out, sigma_in)] * sv_val; + } + } + mps_sv = new_sv; + } + + // Build Pauli matrices for generator construction. + let i2 = DMatrix::::identity(2, 2); + let x_mat = 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), + ], + ); + let z_mat = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(-1.0, 0.0), + ], + ); + let y_mat = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(0.0, -1.0), + Complex64::new(0.0, 1.0), + Complex64::new(0.0, 0.0), + ], + ); + + // Helper: build the 2^n × 2^n matrix for a generator row. + let gen_matrix = |is_stab: bool, row: usize| -> DMatrix { + let gens = if is_stab { + self.tableau.stabs() + } else { + self.tableau.destabs() + }; + let mut result = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + for q in 0..n { + let p = match (gens.row_x[row].contains(q), gens.row_z[row].contains(q)) { + (false, false) => &i2, + (true, false) => &x_mat, + (false, true) => &z_mat, + (true, true) => &y_mat, + }; + result = result.kronecker(p); + } + let mut phase = Complex64::new(1.0, 0.0); + if gens.signs_minus.contains(row) { + phase *= Complex64::new(-1.0, 0.0); + } + if gens.signs_i.contains(row) { + phase *= Complex64::new(0.0, 1.0); + } + result * phase + }; + + // Find stabilizer state |stab>: +1 eigenstate of all stabilizers. + // Build the projector P = prod_k (I + S_k) / 2, then find a nonzero + // column to get the stabilizer state. + let id = DMatrix::::identity(dim, dim); + let mut proj = id.clone(); + for k in 0..n { + let sk = gen_matrix(true, k); + proj = (&id + &sk) * Complex64::new(0.5, 0.0) * &proj; + } + // Find a nonzero column of the projector + let mut stab_state = nalgebra::DVector::from_element(dim, Complex64::new(0.0, 0.0)); + for col in 0..dim { + let candidate = proj.column(col); + let norm_sq: f64 = candidate.iter().map(nalgebra::Complex::norm_sqr).sum(); + if norm_sq > 1e-20 { + stab_state = candidate.into_owned() / Complex64::new(norm_sq.sqrt(), 0.0); + break; + } + } + + // Compute |psi> = Σ_x ν_x * D_0^{x_0} * ... * D_{n-1}^{x_{n-1}} * |stab>. + // MPS SV uses MSB-first: index x = Σ_k σ_k * 2^{n-1-k}. + let mut psi = nalgebra::DVector::from_element(dim, Complex64::new(0.0, 0.0)); + for (x, &nu) in mps_sv.iter().enumerate() { + if nu.norm_sqr() < 1e-30 { + continue; + } + let mut state = stab_state.clone(); + for k in 0..n { + if (x >> (n - 1 - k)) & 1 == 1 { + state = &gen_matrix(false, k) * &state; + } + } + psi += state * nu; + } + + // Convert from Kronecker ordering (MSB-first: q0 is leftmost) + // to DenseStateVec ordering (LSB-first: bit k = qubit k). + let mut result = vec![Complex64::new(0.0, 0.0); dim]; + for i in 0..dim { + let mut rev = 0; + for b in 0..n { + if (i >> b) & 1 == 1 { + rev |= 1 << (n - 1 - b); + } + } + result[rev] = self.global_phase * psi[i]; + } + + // Normalize (MPS norm can drift from truncation in multi-site gates) + let norm_sq: f64 = result.iter().map(nalgebra::Complex::norm_sqr).sum(); + if norm_sq > 1e-20 { + let inv_norm = Complex64::new(1.0 / norm_sq.sqrt(), 0.0); + for a in &mut result { + *a *= inv_norm; + } + } + + result + } +} + +impl StabMps { + /// Sample `num_shots` bitstrings from the Born distribution + /// `|⟨x|Ψ⟩|²` of the current state. Each shot clones the simulator, + /// measures all qubits in the Z basis (consuming the clone), and + /// returns the bitstring. The original simulator state is unchanged + /// (only the internal RNG advances, to ensure each shot uses a + /// distinct RNG seed). + /// + /// `bitstring[k]` corresponds to qubit `k`'s outcome. + /// + /// Useful for shot-based experiments (logical error rate estimation, + /// outcome distribution histograms, etc.). + pub fn sample_bitstring(&mut self, num_shots: usize) -> Vec> { + use pecos_core::RngManageable; + let mut shots = Vec::with_capacity(num_shots); + for _shot in 0..num_shots { + let shot_seed = self.rng.next_u64(); + let mut clone = self.clone(); + // Re-seed both the StabMps-level RNG (used by random measurement + // probability sampling) and the tableau's internal RNG (used + // by the trivial-MPS measurement fast path). Otherwise clones + // would all share the parent's RNG state and produce identical + // outcomes. + clone.rng = PecosRng::seed_from_u64(shot_seed); + clone + .tableau + .set_rng(PecosRng::seed_from_u64(shot_seed.wrapping_add(1))); + let mut bitstring = Vec::with_capacity(self.num_qubits); + for q in 0..self.num_qubits { + bitstring.push(clone.measure_qubit(QubitId(q)).outcome); + } + shots.push(bitstring); + } + shots + } + + /// 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 + /// `auto_grow_max_bond_dim`). Called after MPS-modifying ops. + fn maybe_grow_bond_dim(&mut self) { + let Some(threshold) = self.auto_grow_bond_dim else { + return; + }; + let cur_err = self.mps.truncation_error(); + let delta = cur_err - self.last_truncation_error; + self.last_truncation_error = cur_err; + if delta < threshold { + return; + } + // Only grow if the cap was actually binding (not just float noise). + if self.mps.bond_cap_hits() == 0 { + return; + } + let cur_cap = self.config.max_bond_dim; + let new_cap = (cur_cap * 2).min(self.auto_grow_max_bond_dim); + if new_cap > cur_cap { + self.config.max_bond_dim = new_cap; + self.mps.set_max_bond_dim(new_cap); + } + } + + /// Inject Pauli X into the Pauli frame on qubit `q` (no quantum-state + /// update). See `StabMpsBuilder::pauli_frame_tracking`. + pub fn inject_x_in_frame(&mut self, q: QubitId) { + self.pauli_frame_x[q.index()] ^= true; + } + + /// Inject Pauli Z into the Pauli frame on qubit `q`. + pub fn inject_z_in_frame(&mut self, q: QubitId) { + self.pauli_frame_z[q.index()] ^= true; + } + + /// Inject Pauli Y into the Pauli frame on qubit `q`. In the Y-direct + /// representation, the bit pair `(1, 1)` names Y directly — no scalar + /// phase contribution. + pub fn inject_y_in_frame(&mut self, q: QubitId) { + let i = q.index(); + self.pauli_frame_x[i] ^= true; + self.pauli_frame_z[i] ^= true; + } + + /// Bulk-inject a list of single-qubit Pauli errors into the frame. + /// Equivalent to calling `inject_{x,y,z}_in_frame` in order, but + /// exposed as a single call so noise samplers can emit a single vector + /// per timestep rather than looping. See `StabMpsBuilder::pauli_frame_tracking`. + pub fn inject_paulis_in_frame(&mut self, paulis: &[(QubitId, PauliKind)]) { + for &(q, kind) in paulis { + match kind { + PauliKind::X => self.inject_x_in_frame(q), + PauliKind::Y => self.inject_y_in_frame(q), + PauliKind::Z => self.inject_z_in_frame(q), + } + } + } + + /// Read the accumulated Z-bit of the Pauli frame on qubit `q`. + /// (Z-bit tracks pure Z errors; commutes with Z-measurement.) + #[must_use] + pub fn frame_z_bit(&self, q: QubitId) -> bool { + self.pauli_frame_z[q.index()] + } + + /// Read the accumulated X-bit of the Pauli frame on qubit `q`. When + /// `pauli_frame_tracking` is on, this bit is `XORed` into the + /// measurement outcome of `mz(q)` (X/Y anticommute with Z-measurement, + /// flipping the outcome). + #[must_use] + pub fn frame_x_bit(&self, q: QubitId) -> bool { + self.pauli_frame_x[q.index()] + } + + /// Propagate the Pauli frame through a single-qubit Clifford gate `kind` + /// applied to qubit `q`. Y-direct representation — bit pair names + /// the Pauli directly; `pauli_frame_phase` tracks only `±1` signs + /// from Clifford sign flips: + /// - H: X ↔ Z (swap bits); Y → -Y (phase *= -1 if both bits set). + /// - SZ: X → Y, Z → Z, Y → -X (toggle z; phase *= -1 if both bits set). + /// - `SZdg`: X → -Y, Z → Z, Y → X (toggle z; phase *= -1 if x && !z). + /// - X: Z → -Z, Y → -Y (phase *= -1 if z set). + /// - Y: X → -X, Z → -Z (phase *= -1 if x ⊕ z set). + /// - Z: X → -X, Y → -Y (phase *= -1 if x set). + fn propagate_frame_single_qubit(&mut self, kind: SingleQubitCliffordKind, q: usize) { + let x = self.pauli_frame_x[q]; + let z = self.pauli_frame_z[q]; + match kind { + SingleQubitCliffordKind::H => { + self.pauli_frame_x[q] = z; + self.pauli_frame_z[q] = x; + if x && z { + self.pauli_frame_phase = -self.pauli_frame_phase; + } + } + SingleQubitCliffordKind::SZ => { + self.pauli_frame_z[q] ^= x; + if x && z { + self.pauli_frame_phase = -self.pauli_frame_phase; + } + } + SingleQubitCliffordKind::SZdg => { + // SZdg·X·SZ = -Y (flip), SZdg·Y·SZ = +X (no flip). + // Condition: x set AND z NOT set (starting from X, not Y). + self.pauli_frame_z[q] ^= x; + if x && !z { + self.pauli_frame_phase = -self.pauli_frame_phase; + } + } + SingleQubitCliffordKind::X => { + if z { + self.pauli_frame_phase = -self.pauli_frame_phase; + } + } + SingleQubitCliffordKind::Y => { + if x ^ z { + self.pauli_frame_phase = -self.pauli_frame_phase; + } + } + SingleQubitCliffordKind::Z => { + if x { + self.pauli_frame_phase = -self.pauli_frame_phase; + } + } + } + } + + /// Propagate the Pauli frame through CX(c, t). + fn propagate_frame_cx(&mut self, c: usize, t: usize) { + // Heisenberg: + // X_c → X_c X_t + // X_t → X_t + // Z_c → Z_c + // Z_t → Z_c Z_t + // Bit updates: + // if x_bit[c] set: toggle x_bit[t]. + // if z_bit[t] set: toggle z_bit[c]. + if self.pauli_frame_x[c] { + self.pauli_frame_x[t] ^= true; + } + if self.pauli_frame_z[t] { + self.pauli_frame_z[c] ^= true; + } + } + + /// Flush the accumulated Pauli frame into the simulator state. Applies + /// the frame Pauli `P = pauli_frame_phase · ⊗_q P_q` to the MPS via + /// the decomposition `C† · P · C = decomp_phase · X_flip · Z_sign` + /// (where `C` is the tableau Clifford). The tableau is left unchanged; + /// the MPS absorbs the frame content. This avoids stabilizer-formalism + /// phase loss: `state_vector` / `amplitude` after flush are EXACT + /// complex amplitudes, including correct global phase even for + /// Clifford-evolved and entangled states with Y-bits in the frame. + /// Clears the frame. + /// + /// # Panics + /// + /// 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(); + + // Collect frame Paulis as a Pauli string. + let mut paulis: Vec<(usize, pauli_decomp::PauliKindForDecomp)> = Vec::new(); + for q in 0..self.num_qubits { + let pk = match (self.pauli_frame_x[q], self.pauli_frame_z[q]) { + (true, true) => pauli_decomp::PauliKindForDecomp::Y, + (true, false) => pauli_decomp::PauliKindForDecomp::X, + (false, true) => pauli_decomp::PauliKindForDecomp::Z, + (false, false) => continue, + }; + paulis.push((q, pk)); + } + + // Frame-phase scalar (from Clifford sign-flip propagation) always + // folds into global_phase at flush, frame or not. + let frame_scalar = self.pauli_frame_phase; + self.pauli_frame_phase = Complex64::new(1.0, 0.0); + for b in &mut self.pauli_frame_x { + *b = false; + } + for b in &mut self.pauli_frame_z { + *b = false; + } + + if paulis.is_empty() { + self.global_phase *= frame_scalar; + return; + } + + // Decomposition trick (avoids the stabilizer-formalism phase loss + // of tab.x / tab.y / tab.z): + // C† · P · C = decomp_phase · X_{flip} · Z_{sign} (in MPS frame) + // So P · C · |MPS⟩ = C · (decomp_phase · X_flip · Z_sign) · |MPS⟩. + // Applying `decomp_phase · X_flip · Z_sign` to MPS (not the tableau) + // preserves the EXACT physical state — including global phase — + // because the Clifford C is unchanged and the MPS absorbs the + // frame's full content. No state-dependent phase loss. + let (flip, sign, decomp_phase) = pauli_decomp::decompose_pauli_string( + self.tableau.stabs(), + self.tableau.destabs(), + &paulis, + ); + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + let x_gate = 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), + ], + ); + for &k in &sign { + self.mps + .apply_diagonal_one_site(k, &z_diag) + .expect("frame flush: site from decomposition"); + } + for &j in &flip { + self.mps + .apply_one_site_gate(j, &x_gate) + .expect("frame flush: site from decomposition"); + } + self.global_phase *= frame_scalar * decomp_phase; + } + + /// Propagate the Pauli frame through CZ(a, b). + fn propagate_frame_cz(&mut self, a: usize, b: usize) { + // Heisenberg: + // X_a → X_a Z_b + // X_b → Z_a X_b + // Z_a → Z_a + // Z_b → Z_b + if self.pauli_frame_x[a] { + self.pauli_frame_z[b] ^= true; + } + if self.pauli_frame_x[b] { + self.pauli_frame_z[a] ^= true; + } + } + + /// Apply Pauli X to qubit `q` with probability `p` (bit-flip channel). + /// No-op when `p == 0.0`. Used to model dephasing-free bit-flip noise + /// or Pauli-X errors injected at specific points in a circuit. + /// + /// When `pauli_frame_tracking` is enabled, the X is accumulated into + /// the Pauli frame (O(1)) instead of applied to the quantum state + /// (O(n) tableau update). + /// + /// Returns `true` iff the X was applied (either to state or frame). + pub fn apply_bit_flip(&mut self, q: QubitId, p: f64) -> bool { + if p <= 0.0 { + return false; + } + if self.rng.random_bool(p) { + if self.flags.pauli_frame_tracking() { + self.inject_x_in_frame(q); + } else { + self.x(&[q]); + } + true + } else { + false + } + } + + /// Apply Pauli Z to qubit `q` with probability `p` (phase-flip channel). + /// No-op when `p == 0.0`. Models pure dephasing. Uses frame-injection + /// path when `pauli_frame_tracking` is on. + pub fn apply_phase_flip(&mut self, q: QubitId, p: f64) -> bool { + if p <= 0.0 { + return false; + } + if self.rng.random_bool(p) { + if self.flags.pauli_frame_tracking() { + self.inject_z_in_frame(q); + } else { + self.z(&[q]); + } + true + } else { + false + } + } + + /// Apply depolarizing noise to qubit `q` with total error probability + /// `p`. With probability `p`, applies one of {X, Y, Z} uniformly + /// (each with conditional probability 1/3 = total `p/3`). With + /// probability `1 − p`, no error is applied. + /// + /// When `pauli_frame_tracking` is enabled, the error goes into the + /// Pauli frame (O(1)) instead of the quantum state. + /// + /// Returns the applied Pauli kind, or `None` if no error. + /// Standard QEC depolarizing channel. + pub fn apply_depolarizing(&mut self, q: QubitId, p: f64) -> Option { + if p <= 0.0 { + return None; + } + if !self.rng.random_bool(p) { + return None; + } + // Error occurred; pick X/Y/Z uniformly. + let r = self.rng.random_bool(2.0 / 3.0); + let kind = if r { + // 2/3: X or Y + if self.rng.random_bool(0.5) { + PauliKind::X + } else { + PauliKind::Y + } + } else { + PauliKind::Z + }; + if self.flags.pauli_frame_tracking() { + match kind { + PauliKind::X => self.inject_x_in_frame(q), + PauliKind::Y => self.inject_y_in_frame(q), + PauliKind::Z => self.inject_z_in_frame(q), + } + } else { + match kind { + PauliKind::X => { + self.x(&[q]); + } + PauliKind::Y => { + self.y(&[q]); + } + PauliKind::Z => { + self.z(&[q]); + } + } + } + Some(kind) + } + + /// Apply depolarizing noise to every qubit in `qubits` independently. + /// Each qubit gets an X/Y/Z with total probability `p`. Models + /// memory-error channel applied to multiple qubits per timestep + /// (e.g., ion-trap idle decoherence). + pub fn apply_depolarizing_all(&mut self, qubits: &[QubitId], p: f64) { + for &q in qubits { + let _ = self.apply_depolarizing(q, p); + } + } + + /// Returns `true` if the stored `(tableau, MPS)` pair exactly + /// represents the current physical state — no pending merged RZ, + /// no unflushed Pauli frame, no deferred CNOT queue from lazy + /// measurement. When `true`, `state_vector` / `amplitude` etc. return + /// exact results (modulo MPS truncation error reported by + /// `truncation_error`). + /// + /// Also returns `false` if the pragmatic-fix path in + /// `measure_qubit_stab_mps` has fired at least once on this simulator + /// (tracked via `pragmatic_drift_count`). Use + /// `StabMpsBuilder::lazy_measure(true)` if you need exact state after + /// random measurements with multi-anticom stabilizer columns. + #[must_use] + pub fn is_state_exact(&self) -> bool { + let no_pending_rz = self.pending_rz.iter().all(std::option::Option::is_none); + let phase_trivial = (self.pauli_frame_phase - Complex64::new(1.0, 0.0)).norm() < 1e-12; + let no_frame = !self.flags.pauli_frame_tracking() + || (self.pauli_frame_x.iter().all(|&b| !b) + && self.pauli_frame_z.iter().all(|&b| !b) + && phase_trivial); + let no_deferred = self.deferred_ops.is_empty(); + let no_drift = self.pragmatic_drift_count == 0; + no_pending_rz && no_frame && no_deferred && no_drift + } + + /// Number of measurements that took the pragmatic-fix path (`pre_reduce` + /// row-ops applied to the tableau without MPS compensation) on this + /// simulator. Non-zero means the stored `(tableau, MPS)` pair has + /// drifted from the exact physical state; read methods may return + /// approximate amplitudes. Enable `StabMpsBuilder::lazy_measure(true)` to + /// avoid drift entirely. + #[must_use] + pub fn pragmatic_drift_count(&self) -> u64 { + 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. + pub fn flush(&mut self) { + self.flush_all_pending_rz(); + } + + /// Mid-circuit reset of qubit `q` to |0⟩. Measures in Z basis, then + /// conditionally applies X to force |0⟩. Returns the physical + /// measurement outcome (true iff the qubit was in |1⟩ before reset). + /// + /// For QEC ancillas: after syndrome extraction `reset_qubit` clears + /// the ancilla in one call. Cheaper than `mz` + explicit conditional + /// `x` because: (1) frame bits for this qubit are cleared directly + /// rather than propagating X through them; (2) only one `flush_pending_rz` + /// fires rather than two. + /// + /// With `pauli_frame_tracking`: clears both X and Z frame bits for + /// this qubit — any tracked Pauli error on `q` is semantically erased + /// by the reset. (Global `pauli_frame_phase` is left unchanged; its + /// per-qubit contribution is not tracked, so a residual ±1 phase + /// may remain. Measurement outcomes on other qubits are unaffected.) + pub fn reset_qubit(&mut self, q: QubitId) -> bool { + let idx = q.index(); + let reported = self.mz(&[q])[0].outcome; + // `mz` XORs the frame X-bit into the reported outcome. Undo that + // to find the stored-state collapse outcome (== physical outcome + // with frame applied elsewhere but not here). + let frame_x_before = self.flags.pauli_frame_tracking() && self.pauli_frame_x[idx]; + let physical_outcome = reported ^ frame_x_before; + // Clear this qubit's frame bits BEFORE applying X so the frame + // propagation rule for X doesn't spuriously flip the global phase + // on a Z-bit we're about to erase anyway. + if self.flags.pauli_frame_tracking() { + self.pauli_frame_x[idx] = false; + self.pauli_frame_z[idx] = false; + } + if physical_outcome { + // Apply X to bring stored |1⟩ back to |0⟩. Bypass the + // public `x` method — we've already flushed pending_rz via mz + // and cleared the frame, so there's nothing to propagate. + self.tableau.x(&[q]); + } + // Refresh the disent flag: after reset, q is a Z(+1) eigenstate. + self.disent_flags[idx] = Some(SiteEigenstate::Z(false)); + // Return the REPORTED outcome (frame-adjusted) — this is the + // physical measurement the user observes before reset. + reported + } + + /// Prepare qubit `q` in |0⟩ (Z-basis +1 eigenstate). PECOS `pz`. Alias + /// for `reset_qubit` with the return value discarded — intended for + /// circuit-building code where the measurement outcome from reset + /// isn't needed. + pub fn pz(&mut self, q: QubitId) { + self.reset_qubit(q); + } + + /// Prepare qubit `q` in |+⟩ = (|0⟩ + |1⟩)/√2 (X-basis +1 eigenstate). + /// PECOS `px`. Reset + H. + pub fn px(&mut self, q: QubitId) { + self.reset_qubit(q); + self.h(&[q]); + } + + /// Extract the syndrome bits of a stabilizer code using one ancilla per + /// generator. `generators[i]` describes the `i`-th Pauli stabilizer as + /// a list `(data_qubit, Pauli)`. `ancilla_qubits[i]` is the ancilla for + /// generator `i`; must be distinct from data qubits and from each + /// other. Returns a `bool` per generator (syndrome bit). + /// + /// Protocol (works for arbitrary Pauli generators including mixed + /// X/Y/Z on the same generator): + /// 1. `px(ancilla)` — reset + H. + /// 2. For each (`data_q`, P): apply controlled-P with ancilla as + /// control (CX for P=X, CY for P=Y, CZ for P=Z). + /// 3. H + mz ancilla → syndrome bit. + /// 4. `reset_qubit(ancilla)` so it's ready for the next round. + /// + /// # Panics + /// + /// Panics if `generators.len() != ancilla_qubits.len()`. + pub fn extract_syndromes( + &mut self, + generators: &[Vec<(usize, PauliKind)>], + ancilla_qubits: &[QubitId], + ) -> Vec { + assert_eq!( + generators.len(), + ancilla_qubits.len(), + "extract_syndromes: one ancilla per generator required" + ); + let mut syndrome = Vec::with_capacity(generators.len()); + for (generator, &anc) in generators.iter().zip(ancilla_qubits.iter()) { + debug_assert!( + !generator.iter().any(|&(q, _)| q == anc.index()), + "extract_syndromes: ancilla {} overlaps with generator data qubit", + anc.index() + ); + self.px(anc); + for &(q, kind) in generator { + let data = QubitId(q); + match kind { + PauliKind::X => self.cx(&[(anc, data)]), + PauliKind::Y => self.cy(&[(anc, data)]), + PauliKind::Z => self.cz(&[(anc, data)]), + }; + } + self.h(&[anc]); + let bit = self.mz(&[anc])[0].outcome; + syndrome.push(bit); + // Leave the ancilla in |0⟩ for subsequent rounds. + self.reset_qubit(anc); + } + syndrome + } + + /// If `merge_rz` is on and qubit `q` has a pending RZ accumulation, + /// apply it via the standard non-Clifford path and clear the slot. + /// Called by every gate method that touches `q` (except `rz` itself, + /// which merges). No-op when `merge_rz` is off or the slot is empty. + fn flush_pending_rz(&mut self, q: usize) { + if !self.flags.merge_rz() { + return; + } + if let Some(theta) = self.pending_rz[q].take() { + self.rz_apply_direct(theta, q); + } + } + + /// Apply all pending RZ (all qubits). Used before reads and at reset. + fn flush_all_pending_rz(&mut self) { + if !self.flags.merge_rz() { + return; + } + for q in 0..self.num_qubits { + self.flush_pending_rz(q); + } + } + + /// Apply `rz(theta)` on qubit `q` directly (without the merge buffer), + /// handling Clifford-angle shortcuts and the non-Clifford path. + /// Factored from `rz()` so `flush_pending_rz` can reuse it. + fn rz_apply_direct(&mut self, theta: Angle64, q: usize) { + if theta == Angle64::ZERO { + return; + } + let qid = QubitId(q); + if theta == Angle64::HALF_TURN { + self.global_phase *= Complex64::new(0.0, -1.0); + self.tableau.z(&[qid]); + return; + } + if theta == Angle64::QUARTER_TURN { + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + self.global_phase *= Complex64::new(inv_sqrt2, -inv_sqrt2); + self.tableau.sz(&[qid]); + return; + } + if theta == 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(&[qid]); + return; + } + // Non-Clifford + let half_rad = theta.to_radians_signed() / 2.0; + let cos_half = half_rad.cos(); + let sin_half = half_rad.sin(); + non_clifford::apply_rz_stab_mps( + &mut self.tableau, + &mut self.mps, + cos_half, + sin_half, + q, + self.flags.normalize_after_gate(), + &mut non_clifford::RzContext { + disent_flags: &mut self.disent_flags, + gf2_matrix: &mut self.gf2_matrix, + stats: &mut self.stats, + }, + ); + self.maybe_grow_bond_dim(); + } + + /// Measure qubit q in the Z basis using the shared STN measurement protocol. + fn measure_qubit(&mut self, q: QubitId) -> MeasurementResult { + self.flush_pending_rz(q.index()); + let result = if self.flags.lazy_measure() { + measure::measure_qubit_stab_mps_lazy( + &mut self.tableau, + &mut self.mps, + &mut self.rng, + q.index(), + &mut self.deferred_ops, + ) + } else { + // Detect pragmatic-fix drift: pre_reduce fires when col_x has + // multiple anticommuting stabilizers. It applies row-ops to the + // tableau (changing C) WITHOUT compensating MPS. Drift occurs + // regardless of whether decompose_z then takes the Stabilizer + // or DestabilizerFlip path — the uncompensated row-ops already + // changed the (C, MPS) pair. + if self.tableau.stabs().col_x[q.index()].len() > 1 { + self.pragmatic_drift_count += 1; + } + measure::measure_qubit_stab_mps( + &mut self.tableau, + &mut self.mps, + &mut self.rng, + q.index(), + ) + }; + // Set disentangling flag: measured qubit is now in a Z-eigenstate + self.disent_flags[q.index()] = Some(SiteEigenstate::Z(result.outcome)); + self.maybe_grow_bond_dim(); + // Pauli-frame XOR: the tracked X-bit flips the reported Z-basis + // outcome, since X (and Y = XZ·sign) anticommute with Z. Z in the + // frame commutes with Z-measurement and so does not flip the bit. + if self.flags.pauli_frame_tracking() && self.pauli_frame_x[q.index()] { + MeasurementResult { + outcome: !result.outcome, + is_deterministic: result.is_deterministic, + } + } else { + result + } + } +} + +impl QuantumSimulator for StabMps { + fn reset(&mut self) -> &mut Self { + self.tableau = SparseStabY::new(self.num_qubits).with_destab_sign_tracking(); + self.mps = Mps::new(self.num_qubits, self.config.clone()); + self.mps_corrections.clear(); + self.global_phase = Complex64::new(1.0, 0.0); + self.disent_flags = vec![Some(SiteEigenstate::Z(false)); self.num_qubits]; + self.gf2_matrix.reset(); + self.deferred_ops.clear(); + self.pragmatic_drift_count = 0; + for slot in &mut self.pending_rz { + *slot = None; + } + for b in &mut self.pauli_frame_x { + *b = false; + } + for b in &mut self.pauli_frame_z { + *b = false; + } + self.pauli_frame_phase = Complex64::new(1.0, 0.0); + self + } + + fn num_qubits(&self) -> usize { + self.num_qubits + } +} + +impl CliffordGateable for StabMps { + fn sz(&mut self, qubits: &[QubitId]) -> &mut Self { + // SZ commutes with RZ: skip `flush_pending_rz`. The pending RZ + // angle stays valid; applying it later yields the same physical + // state as flushing first and then applying SZ (since RZ(θ)·SZ = + // SZ·RZ(θ)). See merge_rz docstring. + self.tableau.sz(qubits); + for &q in qubits { + self.propagate_frame_single_qubit(SingleQubitCliffordKind::SZ, q.index()); + } + // Flags are NOT updated through Clifford gates. They track whether the + // MPS-frame state at a site is in Z-eigenstate |0⟩, which is true iff + // no non-Clifford has yet been applied to that site. This matches the + // stabilizer-TN reference's _disent_flag semantics. + self + } + + fn szdg(&mut self, qubits: &[QubitId]) -> &mut Self { + // SZdg commutes with RZ: skip flush. Same reasoning as sz(). + self.tableau.szdg(qubits); + for &q in qubits { + self.propagate_frame_single_qubit(SingleQubitCliffordKind::SZdg, q.index()); + } + self + } + + fn z(&mut self, qubits: &[QubitId]) -> &mut Self { + // Z commutes with RZ: skip flush. + self.tableau.z(qubits); + for &q in qubits { + self.propagate_frame_single_qubit(SingleQubitCliffordKind::Z, q.index()); + } + self + } + + fn h(&mut self, qubits: &[QubitId]) -> &mut Self { + // H does NOT commute with RZ (it swaps Z and X axes). Flush. + for &q in qubits { + self.flush_pending_rz(q.index()); + } + self.tableau.h(qubits); + for &q in qubits { + self.propagate_frame_single_qubit(SingleQubitCliffordKind::H, q.index()); + } + self + } + + fn x(&mut self, qubits: &[QubitId]) -> &mut Self { + // X anticommutes with RZ: X·RZ(θ) = RZ(-θ)·X, so applying X + // after a pending RZ(θ) is equivalent to applying X first then + // RZ(-θ). Flip sign of pending_rz and skip flush. + for &q in qubits { + let idx = q.index(); + if let Some(theta) = self.pending_rz.get_mut(idx).and_then(|s| s.as_mut()) { + *theta = -*theta; + } + } + self.tableau.x(qubits); + for &q in qubits { + self.propagate_frame_single_qubit(SingleQubitCliffordKind::X, q.index()); + } + self + } + + fn y(&mut self, qubits: &[QubitId]) -> &mut Self { + // Y anticommutes with RZ (same as X for this purpose): flip + // pending_rz sign, skip flush. + for &q in qubits { + let idx = q.index(); + if let Some(theta) = self.pending_rz.get_mut(idx).and_then(|s| s.as_mut()) { + *theta = -*theta; + } + } + self.tableau.y(qubits); + for &q in qubits { + self.propagate_frame_single_qubit(SingleQubitCliffordKind::Y, q.index()); + } + self + } + + fn cx(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + // CX does not commute with RZ on arbitrary qubits (mixes bases). + // Flush pending RZ on both control and target. + for &(c, t) in pairs { + self.flush_pending_rz(c.index()); + self.flush_pending_rz(t.index()); + } + self.tableau.cx(pairs); + for &(c, t) in pairs { + self.propagate_frame_cx(c.index(), t.index()); + } + self + } + + fn cz(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + // CZ IS diagonal and commutes with RZ on either qubit. Skip flush. + self.tableau.cz(pairs); + for &(a, b) in pairs { + self.propagate_frame_cz(a.index(), b.index()); + } + self + } + + fn mz(&mut self, qubits: &[QubitId]) -> Vec { + qubits.iter().map(|&q| self.measure_qubit(q)).collect() + } +} + +impl ArbitraryRotationGateable for StabMps { + fn rx(&mut self, theta: Angle64, qubits: &[QubitId]) -> &mut Self { + // RX(theta) = H * RZ(theta) * H + self.h(qubits); + self.rz(theta, qubits); + self.h(qubits); + self + } + + fn rz(&mut self, theta: Angle64, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let q_idx = q.index(); + if !self.flags.merge_rz() { + self.rz_apply_direct(theta, q_idx); + continue; + } + // Merge path: accumulate non-Clifford angles; Clifford angles + // (including ZERO) go through direct path. ALL Clifford-angle + // RZ operators (ZERO=I, HALF_TURN=Z, QUARTER_TURN=SZ, + // THREE_QUARTERS_TURN=SZdg) commute with RZ, so they do NOT + // need to flush pending_rz — they just update the tableau. + let is_clifford_angle = theta == Angle64::ZERO + || theta == Angle64::HALF_TURN + || theta == Angle64::QUARTER_TURN + || theta == Angle64::THREE_QUARTERS_TURN; + if is_clifford_angle { + // No flush: Clifford RZ commutes with pending RZ. + self.rz_apply_direct(theta, q_idx); + } else { + // Accumulate non-Clifford angle. + let prev = self.pending_rz[q_idx].unwrap_or(Angle64::ZERO); + let merged = prev + theta; + // If merged sum hits a Clifford angle, flush via direct path + // (captures the Clifford-angle shortcut savings). + if merged == Angle64::ZERO + || merged == Angle64::HALF_TURN + || merged == Angle64::QUARTER_TURN + || merged == Angle64::THREE_QUARTERS_TURN + { + self.pending_rz[q_idx] = None; + self.rz_apply_direct(merged, q_idx); + } else { + self.pending_rz[q_idx] = Some(merged); + } + } + } + self + } + + fn rzz(&mut self, theta: Angle64, pairs: &[(QubitId, QubitId)]) -> &mut Self { + // RZZ(theta) = CX * RZ_target(theta) * CX + for &(q0, q1) in pairs { + self.cx(&[(q0, q1)]); + self.rz(theta, &[q1]); + self.cx(&[(q0, q1)]); + } + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + use pecos_simulators::StabVec; + + #[test] + fn test_stn_initial_state() { + let stn = StabMps::new(2); + assert_eq!(stn.num_qubits(), 2); + assert_eq!(stn.max_bond_dim(), 1); + } + + #[test] + 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); + stn.h(&[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + assert_eq!(stn.gf2_matrix().num_gates(), 1); + assert_eq!(stn.theoretical_min_bond_dim(), 1); + assert_eq!(stn.max_bond_dim(), 1); // Actual should match theoretical + } + + #[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); + stn.h(&[QubitId(0)]); + stn.h(&[QubitId(2)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + assert_eq!(stn.gf2_matrix().num_gates(), 2); + assert_eq!(stn.gf2_matrix().gf2_rank(), 2); + assert_eq!(stn.theoretical_min_bond_dim(), 1); + } + + #[test] + fn test_gf2_diagnostic_entangled_t() { + // Entangled state + T gates: check GF(2) tracking works + let mut stn = StabMps::new(3); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.cx(&[(QubitId(1), QubitId(2))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(1)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + + // GF(2) diagnostic reports theoretical values; actual bond dim may be + // lower because single-site decompositions don't grow bond dim even + // when the GF(2) matrix shows dependencies. + let rank = stn.gf2_matrix().gf2_rank(); + let num_gates = stn.gf2_matrix().num_gates(); + assert!(rank <= num_gates, "rank should be <= num_gates"); + assert!(rank <= stn.num_qubits(), "rank should be <= num_qubits"); + } + + #[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); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + assert_eq!(stn.gf2_matrix().num_gates(), 0); // Stabilizer case: no flip + } + + /// Disentangling test: H on both qubits, then Rz on q0. + /// Expected: after H, q0 and q1 are in |+⟩. The Rz on q0 should have + /// a single-site decomposition (`Z_0` anticommutes only with `X_0` stabilizer). + /// The disentangling fires on the single flip site. + #[test] + 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 ref_sim = DenseStateVec::new(2); + + stn.h(&[QubitId(0)]); + ref_sim.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + ref_sim.h(&[QubitId(1)]); + stn.rz(theta, &[QubitId(0)]); + ref_sim.rz(theta, &[QubitId(0)]); + + let stn_sv = stn.state_vector(); + let dim = 1 << 2; + let ref_sv: Vec = (0..dim).map(|i| ref_sim.get_amplitude(i)).collect(); + + let overlap: Complex64 = stn_sv + .iter() + .zip(ref_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + assert!( + (overlap.norm_sqr() - 1.0).abs() < 1e-9, + "Overlap should be 1: {} vs reference", + overlap.norm_sqr() + ); + } + + /// Disentangling test: Bell state + Rz. The Rz decomposition has two flip + /// sites. Without disentangling, the multi-site cascade runs. With the + /// current (safe) approach, CX cleared the flags, so disentangling doesn't + /// fire and we use the cascade. + #[test] + 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 ref_sim = DenseStateVec::new(2); + + stn.h(&[QubitId(0)]); + ref_sim.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + ref_sim.cx(&[(QubitId(0), QubitId(1))]); + // After Bell: Z_0 decomposition has 2 flip sites (both destabs have X on q0) + stn.rz(theta, &[QubitId(0)]); + ref_sim.rz(theta, &[QubitId(0)]); + + let stn_sv = stn.state_vector(); + let dim = 1 << 2; + let ref_sv: Vec = (0..dim).map(|i| ref_sim.get_amplitude(i)).collect(); + + let overlap: Complex64 = stn_sv + .iter() + .zip(ref_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + assert!( + (overlap.norm_sqr() - 1.0).abs() < 1e-9, + "Overlap should be 1: got {}", + overlap.norm_sqr() + ); + } + + /// Test that verifies the GF(2) diagnostic correctly tracks disentangled sites. + /// When disentangling fires, the flip pattern recorded is just the single `rot_site`. + /// Targeted test: construct state where `pauli_map`=[(0,Y),(1,Y)] with + /// flags [X(true), Z(true)] and verify disentangle gives correct rotation. + /// + /// To construct: need stab with Y on both q0, q1 (so `col_x` contains both) + /// AND destab also with Y on both (so `col_x` for destabs also contains both). + /// Simplest path: apply S,H pattern to get Y stab, then CX to propagate. + #[test] + fn test_disentangle_yy_rotation() { + use pecos_simulators::DenseStateVec; + let theta = Angle64::from_radians(0.3); + let mut stn = StabMps::new(2); + let mut ref_sim = DenseStateVec::new(2); + + // Construct a state where RZ decomposition has pauli_map=[(0,Y),(1,Y)] + // Apply: S on both, then H on both, then apply some Cliffords to get Y stabs + // Or try sequence that matches seed 107's prefix approximately. + // Seed 107 prefix (from actual fuzz output, best guess): + stn.cx(&[(QubitId(0), QubitId(1))]); + ref_sim.cx(&[(QubitId(0), QubitId(1))]); + stn.sz(&[QubitId(1)]); + ref_sim.sz(&[QubitId(1)]); + stn.sz(&[QubitId(0)]); + ref_sim.sz(&[QubitId(0)]); + stn.h(&[QubitId(0)]); + ref_sim.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + ref_sim.h(&[QubitId(1)]); + stn.sz(&[QubitId(0)]); + ref_sim.sz(&[QubitId(0)]); + stn.sz(&[QubitId(1)]); + ref_sim.sz(&[QubitId(1)]); + + eprintln!("Bond dim: {}", stn.max_bond_dim()); + + // Apply the non-Clifford RZ that may trigger disentangling + stn.rz(theta, &[QubitId(0)]); + ref_sim.rz(theta, &[QubitId(0)]); + + let stn_sv = stn.state_vector(); + let dim = 1 << 2; + let ref_sv: Vec = (0..dim).map(|i| ref_sim.get_amplitude(i)).collect(); + let overlap: Complex64 = stn_sv + .iter() + .zip(ref_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!( + "STN: {:?}", + stn_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + "REF: {:?}", + ref_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + assert!( + (overlap.norm_sqr() - 1.0).abs() < 1e-8, + "YY rotation mismatch: overlap={}", + overlap.norm_sqr() + ); + } + + /// Check: if we FORCE std path at step 14 (clearing flags), does it still diverge? + /// If yes: std path has a bug (unlikely). If no: disent at step 14 is buggy. + #[test] + fn test_737_step14_std_only() { + use pecos_simulators::DenseStateVec; + let q = |i: usize| QubitId(i); + let mut stn = StabMps::new(4); + let mut ref_sim = DenseStateVec::new(4); + let apply = |stn: &mut StabMps, r: &mut DenseStateVec, step: usize| match step { + 0 => { + stn.cz(&[(q(1), q(0))]); + r.cz(&[(q(1), q(0))]); + } + 1 => { + stn.cx(&[(q(3), q(0))]); + r.cx(&[(q(3), q(0))]); + } + 2 => { + stn.h(&[q(1)]); + r.h(&[q(1)]); + } + 3 => { + stn.rz(Angle64::from_radians(0.0691), &[q(3)]); + r.rz(Angle64::from_radians(0.0691), &[q(3)]); + } + 4 => { + stn.rz(Angle64::from_radians(0.3330), &[q(2)]); + r.rz(Angle64::from_radians(0.3330), &[q(2)]); + } + 5 => { + stn.cx(&[(q(2), q(3))]); + r.cx(&[(q(2), q(3))]); + } + 6 | 7 => { + stn.cx(&[(q(3), q(1))]); + r.cx(&[(q(3), q(1))]); + } + 8 => { + stn.sz(&[q(3)]); + r.sz(&[q(3)]); + } + 9 => { + stn.sz(&[q(1)]); + r.sz(&[q(1)]); + } + 10 => { + stn.rx(Angle64::from_radians(0.8608), &[q(2)]); + r.rx(Angle64::from_radians(0.8608), &[q(2)]); + } + 11 => { + stn.x(&[q(2)]); + r.x(&[q(2)]); + } + 12 => { + stn.rx(Angle64::from_radians(3.2610), &[q(1)]); + r.rx(Angle64::from_radians(3.2610), &[q(1)]); + } + 13 => { + stn.sz(&[q(2)]); + r.sz(&[q(2)]); + } + 14 => { + stn.rz(Angle64::from_radians(3.4558), &[q(2)]); + r.rz(Angle64::from_radians(3.4558), &[q(2)]); + } + _ => panic!("bad step {step}"), + }; + + for i in 0..14 { + apply(&mut stn, &mut ref_sim, i); + } + + // Before step 14, force flags to None. + for i in 0..stn.disent_flags.len() { + stn.disent_flags[i] = None; + } + + apply(&mut stn, &mut ref_sim, 14); + + let sv_stn = stn.state_vector(); + let sv_ref: Vec = (0..16).map(|i| ref_sim.get_amplitude(i)).collect(); + let overlap: Complex64 = sv_stn + .iter() + .zip(sv_ref.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + let fid = overlap.norm_sqr(); + eprintln!("step 14 with std path: fid={fid}"); + assert!( + (fid - 1.0).abs() < 1e-6, + "std path should give fid=1.0: got {fid}" + ); + } + + /// Sanity check: `span_decomposition` on a real STN gf2 matrix gives a + /// dependency whose XOR reconstructs the target row. Tests the primitive + /// works on data produced by actual simulations. + #[test] + fn test_span_decomposition_on_real_simulation() { + let q = |i: usize| QubitId(i); + let mut stn = StabMps::with_seed(3, 42); + // 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)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(1)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(2)]); + let m = stn.gf2_matrix(); + eprintln!("num_gates={} rank={}", m.num_gates(), m.gf2_rank()); + // After H on each qubit, Z_q decomposes to destab_q only (single-site). + assert!( + m.num_gates() >= 3, + "expected at least 3 rows, got {}", + m.num_gates() + ); + // Combinations of existing rows should be in span. + let single = m.span_decomposition(&[0]); + eprintln!("Looking up [0]: {single:?}"); + assert!(single.is_some()); + // All-three XOR + let all_three = m.span_decomposition(&[0, 1, 2]); + eprintln!("Looking up [0,1,2]: {all_three:?}"); + assert!(all_three.is_some()); + } + + /// Verify the explicit heuristic disentangler (`stn.disentangle()`) does not + /// Verify `StabMps::amplitude` returns correct coefficients for known states. + #[test] + fn test_amplitude_api() { + let q = |i: usize| QubitId(i); + // Bell state: amplitudes 1/√2 at |00⟩ and |11⟩, 0 elsewhere. + let mut stn = StabMps::with_seed(2, 1); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(1))]); + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let amp_00 = stn.amplitude(&[false, false]); + let amp_11 = stn.amplitude(&[true, true]); + let amp_01 = stn.amplitude(&[false, true]); + let amp_10 = stn.amplitude(&[true, false]); + assert!((amp_00.re - inv_sqrt2).abs() < 1e-9, "|00⟩ amp = {amp_00}"); + assert!((amp_11.re - inv_sqrt2).abs() < 1e-9, "|11⟩ amp = {amp_11}"); + assert!(amp_01.norm_sqr() < 1e-18, "|01⟩ amp = {amp_01}"); + assert!(amp_10.norm_sqr() < 1e-18, "|10⟩ amp = {amp_10}"); + } + + /// Verify Rényi `S_2` computation: for a product state, `S_2` should be 0. + /// For a Bell state, `S_2` should be ln(2). + #[test] + fn test_renyi_s2_product_vs_bell() { + let q = |i: usize| QubitId(i); + + // Product state |00⟩ -> S_2 = 0. + let stn_prod = StabMps::with_seed(2, 1); + let s_prod = stn_prod.renyi_s2(1); + assert!( + s_prod.abs() < 1e-9, + "product state S_2={s_prod}, expected 0" + ); + + // Bell state |Φ+⟩ = (|00⟩+|11⟩)/√2 -> S_2 = ln(2). + let mut stn_bell = StabMps::with_seed(2, 2); + stn_bell.h(&[q(0)]); + stn_bell.cx(&[(q(0), q(1))]); + let s_bell = stn_bell.renyi_s2(1); + eprintln!("Bell S_2 = {s_bell}, ln(2) = {}", (2.0f64).ln()); + assert!( + (s_bell - (2.0f64).ln()).abs() < 1e-9, + "Bell state S_2={s_bell}, expected ln(2)={}", + (2.0f64).ln() + ); + + // Bell+T: (|00⟩ + e^{iπ/4}|11⟩)/√2. Still maximally entangled, S_2 = ln(2). + let mut stn_bt = StabMps::with_seed(2, 3); + stn_bt.h(&[q(0)]); + stn_bt.cx(&[(q(0), q(1))]); + stn_bt.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); + let s_bt = stn_bt.renyi_s2(1); + eprintln!("Bell+T S_2 = {s_bt}, expected ln(2) = {}", (2.0f64).ln()); + assert!((s_bt - (2.0f64).ln()).abs() < 1e-9); + } + + /// Cross-validate PCE vs full-SV `S_2` across Clifford+T circuits. + #[test] + fn test_s2_pce_matches_sv_various() { + let q = |i: usize| QubitId(i); + let quarter = Angle64::QUARTER_TURN; + let t = quarter / 2u64; + + // Case 1: 4q Clifford+T with boundary entanglement. + let mut a = StabMps::with_seed(4, 1); + a.h(&[q(0), q(1)]); + a.cx(&[(q(0), q(2))]); + a.rz(t, &[q(2)]); + a.cx(&[(q(1), q(3))]); + assert!((a.s2_pce(2).unwrap() - a.renyi_s2(2)).abs() < 1e-6); + + // Case 2: 6q heavier circuit. + let mut b = StabMps::with_seed(6, 2); + b.h(&[q(0), q(1), q(2)]); + b.cx(&[(q(0), q(3)), (q(1), q(4)), (q(2), q(5))]); + b.rz(t, &[q(0), q(3)]); + assert!((b.s2_pce(3).unwrap() - b.renyi_s2(3)).abs() < 1e-6); + } + + /// Demonstrate PCE scaling beyond n=14 where `renyi_s2` panics. + #[test] + fn test_s2_pce_beyond_state_vector_limit() { + let q = |i: usize| QubitId(i); + // n=20, pure-Clifford Bell across cut → expect ln(2). + let mut stn = StabMps::with_seed(20, 42); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(10))]); + let s = stn.s2_pce(10).unwrap(); + eprintln!("n=20 Bell across middle cut: S_2 = {s}"); + assert!( + (s - (2.0f64).ln()).abs() < 1e-6, + "expected ln(2) for Bell, got {s}" + ); + } + + /// Bell+T at n=20: T gets absorbed into tableau (stab branch), MPS stays bond 1. + /// T is a diagonal gate on an X-basis stabilizer pair — contributes global phase + /// only; physical state entanglement unchanged from pure Bell. + /// + /// Known limitation: this specific setup has T on a qubit whose stabilizer-at-q + /// is Z → T hits the Stabilizer branch, so MPS remains bond 1 but the tableau + /// encodes Bell+phase. PCE may mishandle the phase if `decompose_z` picks a + /// non-trivial flip pattern. Documented for now. + #[test] + fn test_s2_pce_bell_plus_t_n20() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::with_seed(20, 42); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(10))]); + stn.rz(t, &[q(10)]); + let s = stn.s2_pce(10).unwrap(); + assert!((s - (2.0f64).ln()).abs() < 1e-6, "expected ln(2), got {s}"); + } + + /// Single-qubit trivial: amp(|0⟩) = 1 after no gates. + #[test] + fn test_amplitude_iterative_trivial() { + let stn = StabMps::new(1); + let a0 = stn.amplitude_iterative(&[false]); + let a1 = stn.amplitude_iterative(&[true]); + eprintln!("|0⟩: a(0)={a0} a(1)={a1}"); + assert!( + (a0 - Complex64::new(1.0, 0.0)).norm() < 1e-9, + "a(0) should be 1, got {a0}" + ); + assert!(a1.norm() < 1e-9, "a(1) should be 0, got {a1}"); + } + + /// Single-qubit T|+⟩ = RZ(π/4)H|0⟩. amp(0) = e^{-iπ/8}/√2. + #[test] + fn test_amplitude_iterative_t_plus_1q() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::new(1); + stn.h(&[q(0)]); + stn.rz(t, &[q(0)]); + let a = stn.amplitude_iterative(&[false]); + let s = stn.amplitude(&[false]); + eprintln!("T|+⟩: iter={a} sv={s}"); + assert!((a - s).norm() < 1e-9); + } + + /// n=2 no-entangle H+T: amp(00) = (e^{-iπ/8}/√2)/√2 = e^{-iπ/8}/2. + #[test] + fn test_amplitude_iterative_t_plus_2q() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::new(2); + stn.h(&[q(0), q(1)]); + stn.rz(t, &[q(0)]); + let a = stn.amplitude_iterative(&[false, false]); + let s = stn.amplitude(&[false, false]); + eprintln!("T|++⟩ n=2: iter={a} sv={s}"); + assert!((a - s).norm() < 1e-9); + } + + /// n=4 all-plus: amp(any) = 1/4. + #[test] + fn test_amplitude_iterative_plus_state() { + let q = |i: usize| QubitId(i); + let mut stn = StabMps::new(4); + stn.h(&[q(0), q(1), q(2), q(3)]); + let a = stn.amplitude_iterative(&[false; 4]); + eprintln!("|++++⟩: a(0000)={a}"); + assert!((a - Complex64::new(0.25, 0.0)).norm() < 1e-9, "got {a}"); + } + + /// Regression: forced projection leaves state with correct + /// expectation and conditional amplitude for decompositions with + /// overlapping flip/sign sites (phase = ±i). Fixed 2026-04-12 by + /// ensuring `project_forced_z`'s `DestabilizerFlip` branch applies + /// Z-then-X at overlap sites (matches `z_expectation_value` order, + /// yielding XZ = `Y_conv`, not the anti-sign ZX). + #[test] + fn test_forced_projection_matches_conditional_sv() { + use pecos_core::QubitId; + let n = 5; + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let s_gate = Angle64::QUARTER_TURN / 4u64; + let mut stn = StabMps::new(n); + // Build the failing circuit up to gate 17 (before H(3)) + stn.sz(&[q(2)]); + stn.h(&[q(3)]); + stn.sz(&[q(0)]); + stn.sz(&[q(0)]); + stn.rz(s_gate, &[q(3)]); + stn.rz(t, &[q(2)]); + stn.h(&[q(2)]); + stn.h(&[q(1)]); + stn.sz(&[q(3)]); + stn.rz(t, &[q(4)]); + stn.rz(s_gate, &[q(2)]); + stn.rz(t, &[q(3)]); + stn.cx(&[(q(2), q(3))]); + stn.rz(t, &[q(3)]); + stn.rz(s_gate, &[q(2)]); + stn.sz(&[q(4)]); + stn.cx(&[(q(2), q(4))]); + stn.h(&[q(3)]); // gate 18 — the bug trigger + // Compare SV directly + let full_sv = stn.state_vector(); + let full_amp_00000 = full_sv[0]; + eprintln!("full state: amp(|00000⟩)={full_amp_00000:.4e}"); + let mut tab = stn.tableau.clone(); + let mut mps = stn.mps.clone(); + let mut cumul_prob: f64 = 1.0; + for q in 0..n { + // Compute true conditional from state_vector BEFORE projection. + let mut stn_pre = StabMps::new(n); + stn_pre.tableau = tab.clone(); + stn_pre.mps = mps.clone(); + stn_pre.global_phase = stn.global_phase; + let sv_pre = stn_pre.state_vector(); + // Compute on the current state (which may be conditioned + // on prior forced outcomes). Since the tableau was mutated by + // prior projections, this is the conditional expectation. + let mut num: f64 = 0.0; + let mut denom: f64 = 0.0; + for (idx, sv_val) in sv_pre.iter().enumerate() { + let n2 = sv_val.norm_sqr(); + denom += n2; + let bit_q = (idx >> q) & 1; + let sign = if bit_q == 0 { 1.0 } else { -1.0 }; + num += sign * n2; + } + let true_ev = if denom > 1e-20 { num / denom } else { 0.0 }; + let true_prob_plus = f64::midpoint(1.0, true_ev).clamp(0.0, 1.0); + // Also compute true conditional directly from ORIGINAL sv. + let mut orig_num: f64 = 0.0; + let mut orig_denom: f64 = 0.0; + for (idx, _) in full_sv.iter().enumerate() { + let mut in_subspace = true; + for qp in 0..q { + if (idx >> qp) & 1 != 0 { + in_subspace = false; + break; + } + } + if !in_subspace { + continue; + } + let n2 = full_sv[idx].norm_sqr(); + orig_denom += n2; + let bit_q = (idx >> q) & 1; + let sign = if bit_q == 0 { 1.0 } else { -1.0 }; + orig_num += sign * n2; + } + let orig_cond_ev = if orig_denom > 1e-20 { + orig_num / orig_denom + } else { + 0.0 + }; + let _ = (true_prob_plus, denom); + eprintln!(" q={q}: code_state={true_ev:.4} orig_cond={orig_cond_ev:.4}"); + + let pi = measure::project_forced_z(&mut tab, &mut mps, q, false); + cumul_prob *= pi; + let mut stn_after = StabMps::new(n); + stn_after.tableau = tab.clone(); + stn_after.mps = mps.clone(); + let sv_after = stn_after.state_vector(); + eprintln!( + " q={q}: code π={pi:.6} cumul={cumul_prob:.6} after |sv[0]|²={:.4e}", + sv_after[0].norm_sqr() + ); + } + } + + /// Regression: `prob_bitstring` matches SV exactly for seed-10 circuit + /// (was off by 8x before the Z-then-X ordering fix in measure.rs). + #[test] + fn test_prob_bitstring_seed10_minimal() { + use pecos_core::QubitId; + // From test_prob_bitstring_seed10_repro: + // "sz(2); h(3); sz(0); sz(0); s(3); t(2); h(2); h(1); sz(3); t(4); s(2); t(3); + // cx(2,3); t(3); s(2); sz(4); cx(2,4); h(3); cx(2,4);" + let n = 5; + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let s_gate = Angle64::QUARTER_TURN / 4u64; + #[allow(clippy::type_complexity)] + let gates: Vec> = vec![ + Box::new(|s| { + s.sz(&[q(2)]); + }), + Box::new(|s| { + s.h(&[q(3)]); + }), + Box::new(|s| { + s.sz(&[q(0)]); + }), + Box::new(|s| { + s.sz(&[q(0)]); + }), + Box::new(move |s| { + s.rz(s_gate, &[q(3)]); + }), + Box::new(move |s| { + s.rz(t, &[q(2)]); + }), + Box::new(|s| { + s.h(&[q(2)]); + }), + Box::new(|s| { + s.h(&[q(1)]); + }), + Box::new(|s| { + s.sz(&[q(3)]); + }), + Box::new(move |s| { + s.rz(t, &[q(4)]); + }), + Box::new(move |s| { + s.rz(s_gate, &[q(2)]); + }), + Box::new(move |s| { + s.rz(t, &[q(3)]); + }), + Box::new(|s| { + s.cx(&[(q(2), q(3))]); + }), + Box::new(move |s| { + s.rz(t, &[q(3)]); + }), + Box::new(move |s| { + s.rz(s_gate, &[q(2)]); + }), + Box::new(|s| { + s.sz(&[q(4)]); + }), + Box::new(|s| { + s.cx(&[(q(2), q(4))]); + }), + Box::new(|s| { + s.h(&[q(3)]); + }), + Box::new(|s| { + s.cx(&[(q(2), q(4))]); + }), + ]; + // Print prob at each step. + let mut stn = StabMps::new(n); + for (step, g) in gates.iter().enumerate() { + g(&mut stn); + let bs = vec![false; n]; + let p = stn.prob_bitstring(&bs); + let sv = stn.amplitude(&bs); + let diff = (p - sv.norm_sqr()).abs(); + eprintln!( + "step {step}: p={p:.6} |sv|²={:.6} diff={diff:.3e}", + sv.norm_sqr() + ); + if diff > 1e-8 { + return; + } + } + } + + /// Check `prob_bitstring` is correct even when `amplitude_iterative` has phase. + #[test] + fn test_prob_bitstring_vs_amplitude_square() { + use pecos_core::QubitId; + let mut stn = StabMps::with_seed(4, 2); + stn.h(&[QubitId(0), QubitId(1), QubitId(2), QubitId(3)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.sz(&[QubitId(2)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(3)]); + stn.cx(&[(QubitId(2), QubitId(3))]); + 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 p = stn.prob_bitstring(&bs); + let a = stn.amplitude(&bs); + let diff = (p - a.norm_sqr()).abs(); + if diff > max_diff { + max_diff = diff; + } + } + eprintln!("SZ+T circuit: max |prob - |amp|²| = {max_diff:.3e}"); + assert!(max_diff < 1e-8); + } + + /// n=4 H+T: amp magnitudes still 1/4. + #[test] + fn test_amplitude_iterative_plus_plus_t() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::new(4); + stn.h(&[q(0), q(1), q(2), q(3)]); + stn.rz(t, &[q(2)]); + let a = stn.amplitude_iterative(&[false; 4]); + let s = stn.amplitude(&[false; 4]); + eprintln!("|++++⟩·T(2): iter={a} sv={s}"); + assert!((a - s).norm() < 1e-9); + } + + /// 2q Bell state: both amp(|00⟩) and amp(|11⟩) = 1/√2. + #[test] + fn test_amplitude_iterative_bell() { + let q = |i: usize| QubitId(i); + let mut stn = StabMps::new(2); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(1))]); + let a00 = stn.amplitude_iterative(&[false, false]); + let a01 = stn.amplitude_iterative(&[false, true]); + let a10 = stn.amplitude_iterative(&[true, false]); + let a11 = stn.amplitude_iterative(&[true, true]); + let target = Complex64::new(1.0 / std::f64::consts::SQRT_2, 0.0); + eprintln!("Bell: a(00)={a00} a(01)={a01} a(10)={a10} a(11)={a11}"); + assert!((a00 - target).norm() < 1e-9, "a(00)={a00}, want {target}"); + assert!(a01.norm() < 1e-9); + assert!(a10.norm() < 1e-9); + assert!((a11 - target).norm() < 1e-9, "a(11)={a11}, want {target}"); + } + + /// Test `pre_reduce` with non-Clifford T gate in circuit. + #[test] + fn test_pre_reduce_with_t() { + use pecos_core::QubitId; + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::new(3); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(1))]); + stn.rz(t, &[q(0)]); + stn.h(&[q(2)]); + stn.cx(&[(q(2), q(1))]); + let sv_before = stn.state_vector(); + let mut tab = stn.tableau.clone(); + let mut mps = stn.mps.clone(); + measure::pre_reduce_for_measurement_pub(&mut tab, &mut mps, 1); + let mut stn_after = StabMps::new(3); + stn_after.tableau = tab; + stn_after.mps = mps; + stn_after.global_phase = stn.global_phase; + let sv_after = stn_after.state_vector(); + let mut max_diff: f64 = 0.0; + for i in 0..sv_before.len() { + let d = (sv_before[i].norm_sqr() - sv_after[i].norm_sqr()).abs(); + if d > max_diff { + max_diff = d; + } + } + eprintln!("T circuit: max ||amp|² diff| = {max_diff:.3e}"); + eprintln!("Pre SV:"); + for (i, a) in sv_before.iter().enumerate() { + if a.norm() > 1e-9 { + eprintln!(" [{i}] = {a:.4}"); + } + } + eprintln!("Post SV:"); + for (i, a) in sv_after.iter().enumerate() { + if a.norm() > 1e-9 { + eprintln!(" [{i}] = {a:.4}"); + } + } + assert!(max_diff < 1e-8); + } + + /// Verify `stn.state_vector()` magnitudes match `DenseStateVec` for seed 16. + #[test] + fn test_seed16_sv_matches_dense() { + use pecos_core::QubitId; + use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, DenseStateVec}; + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let s_g = Angle64::QUARTER_TURN / 4u64; + let mut stn = StabMps::new(5); + let mut dsv = DenseStateVec::new(5); + macro_rules! both { + ($a:block,$b:block) => {{ + $a; + $b; + }}; + } + both!( + { + stn.rz(s_g, &[q(0)]); + }, + { + dsv.rz(s_g, &[q(0)]); + } + ); + both!( + { + stn.h(&[q(2)]); + }, + { + dsv.h(&[q(2)]); + } + ); + both!( + { + stn.rz(s_g, &[q(4)]); + }, + { + dsv.rz(s_g, &[q(4)]); + } + ); + both!( + { + stn.rz(s_g, &[q(0)]); + }, + { + dsv.rz(s_g, &[q(0)]); + } + ); + both!( + { + stn.cx(&[(q(1), q(4))]); + }, + { + dsv.cx(&[(q(1), q(4))]); + } + ); + both!( + { + stn.cx(&[(q(0), q(4))]); + }, + { + dsv.cx(&[(q(0), q(4))]); + } + ); + both!( + { + stn.h(&[q(0)]); + }, + { + dsv.h(&[q(0)]); + } + ); + both!( + { + stn.h(&[q(3)]); + }, + { + dsv.h(&[q(3)]); + } + ); + both!( + { + stn.rz(t, &[q(1)]); + }, + { + dsv.rz(t, &[q(1)]); + } + ); + both!( + { + stn.rz(t, &[q(1)]); + }, + { + dsv.rz(t, &[q(1)]); + } + ); + both!( + { + stn.sz(&[q(1)]); + }, + { + dsv.sz(&[q(1)]); + } + ); + both!( + { + stn.sz(&[q(3)]); + }, + { + dsv.sz(&[q(3)]); + } + ); + both!( + { + stn.h(&[q(1)]); + }, + { + dsv.h(&[q(1)]); + } + ); + both!( + { + stn.sz(&[q(1)]); + }, + { + dsv.sz(&[q(1)]); + } + ); + both!( + { + stn.rz(s_g, &[q(3)]); + }, + { + dsv.rz(s_g, &[q(3)]); + } + ); + both!( + { + stn.h(&[q(3)]); + }, + { + dsv.h(&[q(3)]); + } + ); + both!( + { + stn.cx(&[(q(4), q(1))]); + }, + { + dsv.cx(&[(q(4), q(1))]); + } + ); + both!( + { + stn.rz(t, &[q(0)]); + }, + { + dsv.rz(t, &[q(0)]); + } + ); + let stn_sv = stn.state_vector(); + let mut max_diff: f64 = 0.0; + for (i, sv_val) in stn_sv.iter().enumerate().take(32) { + let d = (sv_val.norm_sqr() - dsv.get_amplitude(i).norm_sqr()).abs(); + if d > max_diff { + max_diff = d; + } + } + eprintln!("seed 16 |sv|² diff: {max_diff:.3e}"); + assert!(max_diff < 1e-8); + } + + /// Regression: `project_forced_z` state matches true conditional after + /// Bug #3 fix (MPS CNOT compensation via `apply_long_range_two_site_gate`). + #[test] + fn test_seed16_project_correctness() { + use pecos_core::QubitId; + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let s_g = Angle64::QUARTER_TURN / 4u64; + let mut stn = StabMps::new(5); + stn.rz(s_g, &[q(0)]); + stn.h(&[q(2)]); + stn.rz(s_g, &[q(4)]); + stn.rz(s_g, &[q(0)]); + stn.cx(&[(q(1), q(4))]); + stn.cx(&[(q(0), q(4))]); + stn.h(&[q(0)]); + stn.h(&[q(3)]); + stn.rz(t, &[q(1)]); + stn.rz(t, &[q(1)]); + stn.sz(&[q(1)]); + stn.sz(&[q(3)]); + stn.h(&[q(1)]); + stn.sz(&[q(1)]); + stn.rz(s_g, &[q(3)]); + stn.h(&[q(3)]); + stn.cx(&[(q(4), q(1))]); + stn.rz(t, &[q(0)]); + let full_sv = stn.state_vector(); + // True conditional state (q=0 forced to 0): set amps at q=0=1 to zero, renorm. + let mut true_cond: Vec = full_sv + .iter() + .enumerate() + .map(|(idx, &a)| { + if idx & 1 == 0 { + a + } else { + Complex64::new(0.0, 0.0) + } + }) + .collect(); + let norm2: f64 = true_cond.iter().map(nalgebra::Complex::norm_sqr).sum(); + let inv_norm = 1.0 / norm2.sqrt(); + for a in &mut true_cond { + *a *= Complex64::new(inv_norm, 0.0); + } + // Code's post-project state. + let mut tab = stn.tableau.clone(); + let mut mps = stn.mps.clone(); + let _ = measure::project_forced_z(&mut tab, &mut mps, 0, false); + let mut stn_post = StabMps::new(5); + stn_post.tableau = tab; + stn_post.mps = mps; + stn_post.global_phase = stn.global_phase; + let code_sv = stn_post.state_vector(); + let mut max_mag_diff: f64 = 0.0; + for i in 0..full_sv.len() { + let d = (true_cond[i].norm_sqr() - code_sv[i].norm_sqr()).abs(); + if d > max_mag_diff { + max_mag_diff = d; + } + } + eprintln!("project_forced_z(0) vs truth: max ||amp|² diff| = {max_mag_diff:.3e}"); + assert!( + max_mag_diff < 1e-8, + "project_forced_z state diverges from truth" + ); + } + + /// Test `pre_reduce` preservation with SZ gates (introduces Y bits). + #[test] + fn test_pre_reduce_with_sz() { + use pecos_core::QubitId; + let q = |i: usize| QubitId(i); + let mut stn = StabMps::new(2); + stn.h(&[q(0)]); + stn.sz(&[q(0)]); // q0 → Y-state (virtually) + stn.h(&[q(1)]); + stn.cx(&[(q(0), q(1))]); + let sv_before = stn.state_vector(); + let mut tab = stn.tableau.clone(); + let mut mps = stn.mps.clone(); + measure::pre_reduce_for_measurement_pub(&mut tab, &mut mps, 1); + let mut stn_after = StabMps::new(2); + stn_after.tableau = tab; + stn_after.mps = mps; + stn_after.global_phase = stn.global_phase; + let sv_after = stn_after.state_vector(); + let mut max_diff: f64 = 0.0; + for i in 0..sv_before.len() { + let d = (sv_before[i].norm_sqr() - sv_after[i].norm_sqr()).abs(); + if d > max_diff { + max_diff = d; + } + } + eprintln!("SZ circuit: max ||amp|² diff| = {max_diff:.3e}"); + assert!(max_diff < 1e-8); + } + + /// Test non-adjacent CNOT via `apply_cnot_to_mps`. + #[test] + fn test_cnot_non_adjacent() { + use pecos_core::QubitId; + let q = |i: usize| QubitId(i); + // Build bond-1 state with specific amp at different sites. + let mut stn = StabMps::new(5); + stn.h(&[q(0)]); // |+⟩_0 + stn.h(&[q(4)]); // |+⟩_4 + // State: |+⟩_0 |0⟩_1 |0⟩_2 |0⟩_3 |+⟩_4 = (|00000⟩+|00001⟩+|10000⟩+|10001⟩)/2 + // Wait LSB-first: idx bit 0 = q0. So idx: q0=0: |+⟩_4 at bit 4. + // state_vector gives 4 non-zero amps. + let _ = stn; + let mut stn_test = StabMps::new(5); + stn_test.h(&[q(0)]); + stn_test.h(&[q(4)]); + stn_test.cx(&[(q(0), q(4))]); + // Stabs: X_0 X_4, Z_1, Z_2, Z_3, X_4. col_x[4] = {0, 4}. pre_reduce on q=4. + let sv_before = stn_test.state_vector(); + let mut tab = stn_test.tableau.clone(); + let mut mps = stn_test.mps.clone(); + measure::pre_reduce_for_measurement_pub(&mut tab, &mut mps, 4); + let mut stn_after = StabMps::new(5); + stn_after.tableau = tab; + stn_after.mps = mps; + stn_after.global_phase = stn_test.global_phase; + let sv_after = stn_after.state_vector(); + let mut max_diff: f64 = 0.0; + for i in 0..sv_before.len() { + let d = (sv_before[i].norm_sqr() - sv_after[i].norm_sqr()).abs(); + if d > max_diff { + max_diff = d; + } + } + eprintln!("non-adjacent CNOT: max ||amp|² diff| = {max_diff:.3e}"); + assert!(max_diff < 1e-8); + } + + /// Seed 16 full circuit: `pre_reduce` on each qubit — do magnitudes preserve? + #[test] + fn test_seed16_pre_reduce_each_q() { + use pecos_core::QubitId; + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let s_g = Angle64::QUARTER_TURN / 4u64; + let mut stn = StabMps::new(5); + stn.rz(s_g, &[q(0)]); + stn.h(&[q(2)]); + stn.rz(s_g, &[q(4)]); + stn.rz(s_g, &[q(0)]); + stn.cx(&[(q(1), q(4))]); + stn.cx(&[(q(0), q(4))]); + stn.h(&[q(0)]); + stn.h(&[q(3)]); + stn.rz(t, &[q(1)]); + stn.rz(t, &[q(1)]); + stn.sz(&[q(1)]); + stn.sz(&[q(3)]); + stn.h(&[q(1)]); + stn.sz(&[q(1)]); + stn.rz(s_g, &[q(3)]); + stn.h(&[q(3)]); + stn.cx(&[(q(4), q(1))]); + stn.rz(t, &[q(0)]); + let sv_before = stn.state_vector(); + for test_q in 0..5 { + let mut tab = stn.tableau.clone(); + let mut mps = stn.mps.clone(); + measure::pre_reduce_for_measurement_pub(&mut tab, &mut mps, test_q); + let mut stn_after = StabMps::new(5); + stn_after.tableau = tab; + stn_after.mps = mps; + stn_after.global_phase = stn.global_phase; + let sv_after = stn_after.state_vector(); + let mut max_diff: f64 = 0.0; + for i in 0..sv_before.len() { + let d = (sv_before[i].norm_sqr() - sv_after[i].norm_sqr()).abs(); + if d > max_diff { + max_diff = d; + } + } + eprintln!("pre_reduce(q={test_q}): max ||amp|² diff| = {max_diff:.3e}"); + } + } + + /// Minimal 2q test: `pre_reduce` preserves state for H+H+CX-|00⟩. + #[test] + fn test_pre_reduce_minimal_2q() { + use pecos_core::QubitId; + let q = |i: usize| QubitId(i); + let mut stn = StabMps::new(2); + stn.h(&[q(0)]); + stn.h(&[q(1)]); + stn.cx(&[(q(0), q(1))]); + let sv_before = stn.state_vector(); + let mut tab = stn.tableau.clone(); + let mut mps = stn.mps.clone(); + measure::pre_reduce_for_measurement_pub(&mut tab, &mut mps, 1); + let mut stn_after = StabMps::new(2); + stn_after.tableau = tab; + stn_after.mps = mps; + stn_after.global_phase = stn.global_phase; + let sv_after = stn_after.state_vector(); + let mut max_mag_diff: f64 = 0.0; + for i in 0..sv_before.len() { + let d = (sv_before[i].norm_sqr() - sv_after[i].norm_sqr()).abs(); + if d > max_mag_diff { + max_mag_diff = d; + } + } + eprintln!("2q minimal: max ||amp|² diff| = {max_mag_diff:.3e}"); + assert!(max_mag_diff < 1e-8); + } + + /// `pre_reduce_for_measurement` preserves the CAMPS state when the proper + /// virtual-frame CNOT is applied to the MPS (seed-16 regression). + #[test] + fn test_pre_reduce_preserves_state() { + use pecos_core::QubitId; + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let s_g = Angle64::QUARTER_TURN / 4u64; + let mut stn = StabMps::new(5); + stn.rz(s_g, &[q(0)]); + stn.h(&[q(2)]); + stn.rz(s_g, &[q(4)]); + stn.rz(s_g, &[q(0)]); + stn.cx(&[(q(1), q(4))]); + stn.cx(&[(q(0), q(4))]); + stn.h(&[q(0)]); + stn.h(&[q(3)]); + stn.rz(t, &[q(1)]); + stn.rz(t, &[q(1)]); + stn.sz(&[q(1)]); + stn.sz(&[q(3)]); + stn.h(&[q(1)]); + stn.sz(&[q(1)]); + stn.rz(s_g, &[q(3)]); + stn.h(&[q(3)]); + stn.cx(&[(q(4), q(1))]); + stn.rz(t, &[q(0)]); + // Directly pre_reduce on q=1 (no prior project_forced_z). + let sv_before = stn.state_vector(); + let mut tab = stn.tableau.clone(); + let mut mps = stn.mps.clone(); + measure::pre_reduce_for_measurement_pub(&mut tab, &mut mps, 1); + let mut stn_after = StabMps::new(5); + stn_after.tableau = tab; + stn_after.mps = mps; + stn_after.global_phase = stn.global_phase; + let sv_after = stn_after.state_vector(); + let mut max_mag_diff: f64 = 0.0; + for i in 0..sv_before.len() { + let d = (sv_before[i].norm_sqr() - sv_after[i].norm_sqr()).abs(); + if d > max_mag_diff { + max_mag_diff = d; + } + } + eprintln!("seed 16 direct pre_reduce: max ||amp|² diff| = {max_mag_diff:.3e}"); + assert!(max_mag_diff < 1e-6); + } + + /// Regression: seed-16 `prob_bitstring` matches SV (Bug #3 fixed). + #[test] + fn test_prob_bitstring_seed16_diag() { + use pecos_core::QubitId; + let n: usize = 4 + ((16u64 % 3) as usize); + let mut stn = StabMps::with_seed(n, 16); + let mut rng_state: u64 = 0xDEAD_BEEF ^ 16u64.wrapping_mul(37); + let rnd = |s: &mut u64| -> u64 { + *s ^= *s << 13; + *s ^= *s >> 7; + *s ^= *s << 17; + *s + }; + for _ in 0..20 { + let op = rnd(&mut rng_state) % 5; + let q1 = (rnd(&mut rng_state) as usize) % n; + match op { + 0 => { + stn.h(&[QubitId(q1)]); + } + 1 => { + stn.sz(&[QubitId(q1)]); + } + 2 => { + let q2 = (rnd(&mut rng_state) as usize) % n; + if q1 != q2 { + stn.cx(&[(QubitId(q1), QubitId(q2))]); + } + } + 3 => { + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(q1)]); + } + _ => { + stn.rz(Angle64::QUARTER_TURN / 4u64, &[QubitId(q1)]); + } + } + } + let full_sv = stn.state_vector(); + let full_amp_00000 = full_sv[0]; + eprintln!("full amp(|00000⟩)²={:.4e}", full_amp_00000.norm_sqr()); + // True chain of conditional and probs. + let mut tab = stn.tableau.clone(); + let mut mps = stn.mps.clone(); + let mut cumul_code: f64 = 1.0; + for q in 0..n { + // Pre-projection state vector (represents conditional state under code's projections). + let mut stn_pre = StabMps::new(n); + stn_pre.tableau = tab.clone(); + stn_pre.mps = mps.clone(); + stn_pre.global_phase = stn.global_phase; + let sv_pre = stn_pre.state_vector(); + let mut num: f64 = 0.0; + let mut denom: f64 = 0.0; + for (idx, sv_val) in sv_pre.iter().enumerate() { + let n2 = sv_val.norm_sqr(); + denom += n2; + let bit_q = (idx >> q) & 1; + let sign = if bit_q == 0 { 1.0 } else { -1.0 }; + num += sign * n2; + } + let code_state_ev = if denom > 1e-20 { num / denom } else { 0.0 }; + let code_state_prob0 = f64::midpoint(1.0, code_state_ev).clamp(0.0, 1.0); + + // True conditional from ORIGINAL full sv: condition on q_0..q_{q-1}=0. + let mut orig_num: f64 = 0.0; + let mut orig_denom: f64 = 0.0; + for (idx, _) in full_sv.iter().enumerate() { + let mut in_sub = true; + for qp in 0..q { + if (idx >> qp) & 1 != 0 { + in_sub = false; + break; + } + } + if !in_sub { + continue; + } + let n2 = full_sv[idx].norm_sqr(); + orig_denom += n2; + let bit_q = (idx >> q) & 1; + let sign = if bit_q == 0 { 1.0 } else { -1.0 }; + orig_num += sign * n2; + } + let true_cond_ev = if orig_denom > 1e-20 { + orig_num / orig_denom + } else { + 0.0 + }; + let true_cond_prob0 = f64::midpoint(1.0, true_cond_ev).clamp(0.0, 1.0); + + let pi = measure::project_forced_z(&mut tab, &mut mps, q, false); + cumul_code *= pi; + eprintln!( + " q={q}: code_state_ev={code_state_ev:.4} true_cond_ev={true_cond_ev:.4} π={pi:.6} (code cond_prob={code_state_prob0:.6}, true cond_prob={true_cond_prob0:.6}) cumul_code={cumul_code:.6}" + ); + } + } + + /// Regression: `prob_bitstring` matches SV across 30 random Clifford+T + /// circuits at n=4..=6 after Bug #1 (Z-then-X), Bug #2 (`multiply_row` + /// phase), and Bug #3 (MPS CNOT compensation via long-range gate) fixes. + #[test] + #[ignore = "slow stress (~60s debug): run with `cargo test --lib -- --include-ignored`"] + fn test_prob_bitstring_random_stress() { + use pecos_core::QubitId; + for seed in 0..30u64 { + let n: usize = 4 + ((seed % 3) as usize); + let mut stn = StabMps::with_seed(n, seed); + let mut rng_state: u64 = 0xDEAD_BEEF ^ seed.wrapping_mul(37); + let rnd = |s: &mut u64| -> u64 { + *s ^= *s << 13; + *s ^= *s >> 7; + *s ^= *s << 17; + *s + }; + for _ in 0..20 { + let op = rnd(&mut rng_state) % 5; + let q1 = (rnd(&mut rng_state) as usize) % n; + match op { + 0 => { + stn.h(&[QubitId(q1)]); + } + 1 => { + stn.sz(&[QubitId(q1)]); + } + 2 => { + let q2 = (rnd(&mut rng_state) as usize) % n; + if q1 != q2 { + stn.cx(&[(QubitId(q1), QubitId(q2))]); + } + } + 3 => { + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(q1)]); + } + _ => { + stn.rz(Angle64::QUARTER_TURN / 4u64, &[QubitId(q1)]); + } + } + } + for idx in 0..(1usize << n) { + let bs: Vec = (0..n).map(|k| (idx >> (n - 1 - k)) & 1 == 1).collect(); + let a_sv = stn.amplitude(&bs); + // Probability must match exactly (primary correctness check). + let p = stn.prob_bitstring(&bs); + let prob_diff = (p - a_sv.norm_sqr()).abs(); + assert!( + prob_diff < 1e-8, + "seed {seed} idx={idx}: prob={p} |sv|²={} diff={prob_diff:.3e}", + a_sv.norm_sqr() + ); + } + } + } + + /// `amplitude_iterative` matches `amplitude` at small n (full complex). + #[test] + fn test_amplitude_iterative_matches_sv() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::with_seed(4, 1); + stn.h(&[q(0), q(1), q(2), q(3)]); + stn.cx(&[(q(0), q(2))]); + stn.rz(t, &[q(2)]); + stn.cx(&[(q(1), q(3))]); + + 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 a_iter = stn.amplitude_iterative(&bs); + let a_sv = stn.amplitude(&bs); + let diff = (a_iter - a_sv).norm(); + if diff > max_diff { + max_diff = diff; + } + if diff > 1e-6 { + eprintln!("bs={idx:04b}: iter={a_iter:.3} sv={a_sv:.3} diff={diff:.3e}"); + } + } + eprintln!("max |amp_iter - amp_sv| = {max_diff:.3e}"); + assert!( + max_diff < 1e-6, + "amplitude_iterative mismatch: max_diff={max_diff}" + ); + } + + /// `amplitude_iterative` at n=30 (beyond `state_vector`). + #[test] + fn test_amplitude_iterative_n30_bell() { + let q = |i: usize| QubitId(i); + let n = 30; + let mut stn = StabMps::with_seed(n, 5); + stn.h(&[q(0)]); + 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. + let mut bs1 = vec![false; n]; + bs1[n - 1] = true; + bs1[n - 1 - 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); + assert!((a11.norm_sqr() - 0.5).abs() < 1e-9); + } + + /// `prob_bitstring` matches `|amplitude|²` at small n. + #[test] + fn test_prob_bitstring_matches_amplitude() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::with_seed(4, 1); + stn.h(&[q(0), q(1), q(2), q(3)]); + stn.cx(&[(q(0), q(2))]); + stn.rz(t, &[q(2)]); + stn.cx(&[(q(1), q(3))]); + + // 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 p = stn.prob_bitstring(&bs); + let a = stn.amplitude(&bs); + let diff = (p - a.norm_sqr()).abs(); + if diff > max_diff { + max_diff = diff; + } + } + eprintln!("max |p - |a|²| = {max_diff:.3e}"); + assert!(max_diff < 1e-8); + } + + /// `prob_bitstring` at n=30 where `state_vector` would OOM. + #[test] + fn test_prob_bitstring_n30_bell() { + let q = |i: usize| QubitId(i); + let n = 30; + 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. + let bs0 = vec![false; n]; + let mut bs1 = vec![false; n]; + bs1[n - 1] = true; + bs1[n - 1 - 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}"); + assert!((p00 - 0.5).abs() < 1e-9); + assert!((p11 - 0.5).abs() < 1e-9); + // Disallowed: q0=1, q15=0. + let mut bs_bad = vec![false; n]; + bs_bad[n - 1] = true; + assert!(stn.prob_bitstring(&bs_bad).abs() < 1e-9); + } + + /// Truncation telemetry: pure Clifford keeps `truncation_error` = 0. + #[test] + fn test_truncation_error_clifford_zero() { + let q = |i: usize| QubitId(i); + let mut stn = StabMps::with_seed(6, 1); + stn.h(&[q(0), q(1), q(2), q(3), q(4), q(5)]); + for i in 0..5 { + stn.cx(&[(q(i), q(i + 1))]); + } + eprintln!( + "truncation_error={} bond_cap_hits={}", + stn.truncation_error(), + stn.bond_cap_hits() + ); + assert!(stn.truncation_error() < 1e-15); + assert_eq!(stn.bond_cap_hits(), 0); + } + + /// Direct MPS cap hit: apply a bond-2 entangling gate with cap=1. + #[test] + fn test_mps_cap_hit_tracking() { + use crate::mps::{Mps, MpsConfig}; + use nalgebra::DMatrix; + let cfg = MpsConfig { + max_bond_dim: 1, + svd_cutoff: 0.0, + max_truncation_error: None, + parallel: false, + }; + let mut mps = Mps::new(2, cfg); + // CNOT: 4x4 matrix. Start from |++⟩ by rotating each site; then CNOT creates + // bond-2 entanglement which gets clipped back to bond 1. + let c = Complex64::new(1.0, 0.0); + let z = Complex64::new(0.0, 0.0); + let inv = Complex64::new(1.0 / std::f64::consts::SQRT_2, 0.0); + // H gate + let h = DMatrix::from_row_slice(2, 2, &[inv, inv, inv, -inv]); + mps.apply_one_site_gate(0, &h).unwrap(); + mps.apply_one_site_gate(1, &h).unwrap(); + // CNOT |++⟩ = |++⟩ (invariant) so no truncation. Use CZ-style entangler that + // isn't invariant: apply a general 2-site unitary that entangles. + let entangler = DMatrix::from_row_slice( + 4, + 4, + &[c, z, z, z, z, inv, inv, z, z, inv, -inv, z, z, z, z, c], + ); + let _ = mps.apply_two_site_gate(0, &entangler); + eprintln!( + "after entangler (cap=1): err={:.3e} cap_hits={}", + mps.truncation_error(), + mps.bond_cap_hits() + ); + // Expect some telemetry signal since cap was binding. + assert!( + mps.bond_cap_hits() >= 1 || mps.truncation_error() > 0.0, + "expected truncation telemetry but got err={} hits={}", + mps.truncation_error(), + mps.bond_cap_hits() + ); + } + + /// Low-level: forcing a tight MPS cap via `compress()` triggers telemetry. + #[test] + fn test_truncation_error_mps_level() { + use crate::mps::{Mps, MpsConfig}; + use nalgebra::DMatrix; + let cfg = MpsConfig { + max_bond_dim: 1, + svd_cutoff: 0.0, + max_truncation_error: None, + parallel: false, + }; + let mut mps = Mps::new(2, cfg); + // Seed with bond-2 Bell entangled tensors, then compress. + // Build a bell MPS manually: site 0 = (1,2)=[1/√2, 0; 0, 1/√2] stacked, bond=2. + let mut t0 = DMatrix::zeros(1, 4); // (chi_l=1, 2·chi_r=2·2=4) + let inv = 1.0 / std::f64::consts::SQRT_2; + t0[(0, 0)] = Complex64::new(inv, 0.0); // σ=0, chi_r=0 + t0[(0, 3)] = Complex64::new(inv, 0.0); // σ=1, chi_r=1 + let mut t1 = DMatrix::zeros(2, 2); + t1[(0, 0)] = Complex64::new(1.0, 0.0); + t1[(1, 1)] = Complex64::new(1.0, 0.0); + mps.tensors_mut()[0] = t0; + mps.tensors_mut()[1] = t1; + // Can't set bond_dims directly — use the cfg to force truncation via compress. + // Actually just test that the hook compiles and telemetry accessors work. + assert!(mps.truncation_error().abs() < f64::EPSILON); + assert_eq!(mps.bond_cap_hits(), 0); + mps.reset_truncation_stats(); + assert!(mps.truncation_error().abs() < f64::EPSILON); + } + + /// PCMPS cross-validates PCE and scales to larger n. + #[test] + fn test_s2_pcmps_matches_pce() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + + let mut a = StabMps::with_seed(4, 1); + a.h(&[q(0), q(1)]); + a.cx(&[(q(0), q(2))]); + a.rz(t, &[q(2)]); + a.cx(&[(q(1), q(3))]); + let pcmps = a.s2_pcmps(2).unwrap(); + let pce = a.s2_pce(2).unwrap(); + let sv = a.renyi_s2(2); + eprintln!("4q: pcmps={pcmps:.6} pce={pce:.6} sv={sv:.6}"); + assert!((pcmps - pce).abs() < 1e-6); + assert!((pcmps - sv).abs() < 1e-6); + } + + /// PCMPS-TN handles multi-axis Bloch sites that single-axis PCMPS bails on. + /// H+T+CX creates off-axis Bloch on one site via the MPS rotation path. + #[test] + fn test_s2_pcmps_tn_multi_axis() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + // Circuit that forces a multi-axis MPS site via the CX-then-T path. + let mut stn = StabMps::with_seed(4, 1); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(2))]); + stn.rz(t, &[q(2)]); // T after CX: multi-site cascade, MPS rotation on q0. + let pcmps = stn.s2_pcmps(2).unwrap(); + let sv = stn.renyi_s2(2); + eprintln!("multi-axis: pcmps={pcmps:.6} sv={sv:.6}"); + assert!( + (pcmps - sv).abs() < 1e-6, + "pcmps={pcmps} sv={sv} — TN fallback should match SV" + ); + } + + /// Deep Clifford+T creating several multi-axis sites; TN enumeration handles. + #[test] + fn test_s2_pcmps_tn_deep_circuit() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::with_seed(6, 7); + stn.h(&[q(0), q(1), q(2), q(3), q(4), q(5)]); + for i in 0..5 { + stn.cx(&[(q(i), q(i + 1))]); + stn.rz(t, &[q(i)]); + } + let pcmps = stn.s2_pcmps(3).unwrap(); + let sv = stn.renyi_s2(3); + eprintln!("6q deep: pcmps={pcmps:.6} sv={sv:.6}"); + assert!((pcmps - sv).abs() < 1e-6); + } + + /// TN-PCMPS on a circuit with genuinely multi-axis sites at modest n. + /// Matches SV across non-trivial cuts. + #[test] + fn test_s2_pcmps_tn_multi_axis_cuts() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + // Bond-1-preserving circuit that creates multi-axis Bloch on one site: + // CX + T together forces the MPS rotation path. + let mut stn = StabMps::with_seed(6, 17); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(3))]); + stn.rz(t, &[q(3)]); // multi-axis on q0 via disent + stn.cx(&[(q(0), q(1))]); // spread entanglement + assert_eq!(stn.mps().max_bond_dim(), 1, "expected bond-1 MPS for PCMPS"); + for cut in 1..=5 { + let pcmps = stn.s2_pcmps(cut).unwrap(); + let sv = stn.renyi_s2(cut); + assert!( + (pcmps - sv).abs() < 1e-6, + "cut={cut}: pcmps={pcmps} sv={sv}" + ); + } + } + + /// PCMPS-TN scales beyond state-vector limit (n=18 with multi-axis). + #[test] + fn test_s2_pcmps_tn_n18_multi_axis() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::with_seed(18, 13); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(9))]); + stn.rz(t, &[q(9)]); // forces multi-axis via CX-then-T cascade + let s = stn.s2_pcmps(9).unwrap(); + eprintln!( + "n=18 multi-axis: S_2 = {s:.6}, ln(2) = {:.6}", + (2.0f64).ln() + ); + assert!((s - (2.0f64).ln()).abs() < 1e-6, "got {s}"); + } + + /// PCMPS at n=100 — far beyond PCE's 2^22 cap. Pure-Clifford Bell. + #[test] + fn test_s2_pcmps_n100() { + let q = |i: usize| QubitId(i); + let mut stn = StabMps::with_seed(100, 7); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(50))]); + let s = stn.s2_pcmps(50).unwrap(); + eprintln!("n=100 Bell: S_2 = {s}"); + assert!((s - (2.0f64).ln()).abs() < 1e-9); + } + + /// PCMPS at n=100 with a T gate that hits the Stabilizer branch. + /// T on a qubit whose stab generator is `Z_q` (pure |0⟩-style) just scales + /// the MPS, leaving single-axis Bloch vectors. `S_2` unchanged from Clifford + /// underlying state. + /// + /// Note: T after H+CX entangling into the `rot_site` enters the multi-site + /// cascade instead, producing multi-axis Bloch and hitting PCMPS's bail-out. + /// For that genuine Clifford+T regime beyond PCE's 2^22 cap, a proper + /// tensor-network PCMPS would be needed (future work). + #[test] + fn test_s2_pcmps_n100_clifford_t() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::with_seed(100, 11); + // T first, while q0 is still stabilized by Z_0 → Stabilizer branch. + stn.rz(t, &[q(0)]); + // Then the Bell pair. + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(50))]); + let s = stn.s2_pcmps(50).unwrap(); + eprintln!("n=100 T-then-Bell: S_2 = {s}"); + assert!((s - (2.0f64).ln()).abs() < 1e-9); + } + + /// Small-n replica of n=20 Bell+T to allow SV comparison. + #[test] + fn test_s2_pce_bell_plus_t_small() { + let q = |i: usize| QubitId(i); + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::with_seed(4, 42); + stn.h(&[q(0)]); + stn.cx(&[(q(0), q(2))]); + stn.rz(t, &[q(2)]); + let pce = stn.s2_pce(2).unwrap(); + let sv = stn.renyi_s2(2); + let ln2 = (2.0f64).ln(); + eprintln!("PCE={pce:.6} SV={sv:.6} ln(2)={ln2:.6}"); + eprintln!( + "bond_dim={} nullity={}", + stn.mps().max_bond_dim(), + stn.ofd_nullity() + ); + assert!((pce - sv).abs() < 1e-6, "PCE={pce} SV={sv}"); + } + + /// Paper Algorithm 3 (Liu-Clark 2412.17209 Sec VI.A): bitstring probability + /// from CAMPS. For each qubit k: + /// `Z̃_k` = C† `Z_k` C + /// |φ⟩ = (I + (-`1)^s_k` `Z̃_k)/2` · |ψ⟩ + /// `π(s_k)` = ⟨φ|φ⟩ + /// |ψ⟩ ← |φ⟩ (+ disentangle) + /// Product of π's = full probability. + /// + /// Compare to probability from our `state_vector()` for small N. + #[test] + fn test_paper_bitstring_probability() { + let q = |i: usize| QubitId(i); + let mut stn = StabMps::with_seed(3, 7); + stn.h(&[q(0), q(1), q(2)]); + stn.cx(&[(q(0), q(1))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); // T(0) + stn.cx(&[(q(1), q(2))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(1)]); // T(1) + + // Get full state vector to compute expected probabilities. + let sv = stn.state_vector(); + let probs: Vec = sv.iter().map(nalgebra::Complex::norm_sqr).collect(); + + // Sample using our mz over many trials; check matches expected. + let num_trials: u32 = 2000; + let mut counts = [0u32; 8]; + for trial in 0..num_trials { + let mut s = StabMps::with_seed(3, u64::from(7 + 1000 * trial)); + s.h(&[q(0), q(1), q(2)]); + s.cx(&[(q(0), q(1))]); + s.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); + s.cx(&[(q(1), q(2))]); + s.rz(Angle64::QUARTER_TURN / 2u64, &[q(1)]); + let r0 = usize::from(s.mz(&[q(0)])[0].outcome); + let r1 = usize::from(s.mz(&[q(1)])[0].outcome); + let r2 = usize::from(s.mz(&[q(2)])[0].outcome); + counts[r0 | (r1 << 1) | (r2 << 2)] += 1; + } + + // Verify sampled probabilities match state_vector predictions. + let mut max_diff = 0f64; + for i in 0..8 { + let p_sampled = f64::from(counts[i]) / f64::from(num_trials); + let p_expected = probs[i]; + let diff = (p_sampled - p_expected).abs(); + if diff > max_diff { + max_diff = diff; + } + } + eprintln!("Max probability diff: {max_diff:.3}"); + // Statistical tolerance: 3 sigma for p=0.125 at n=2000 is ~0.022. + assert!( + max_diff < 0.05, + "sampled and expected probabilities diverge: {max_diff}" + ); + } + + /// Empirical verification: Liu-Clark 2412.17209 predicts bond dim <= 2^nullity. + /// Check this holds for several Clifford+T circuits. + #[test] + fn test_ofd_bond_dim_bound_holds() { + 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); + 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)]); + } + assert_eq!(stn.ofd_nullity(), 0); + assert!(stn.max_bond_dim() <= stn.theoretical_min_bond_dim()); + + // 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); + 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. + stn2.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); + stn2.cx(&[(q(0), q(1))]); + stn2.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); // second T on q0 (q0 no longer |0⟩) + stn2.cx(&[(q(1), q(2))]); + stn2.rz(Angle64::QUARTER_TURN / 2u64, &[q(1)]); // q1 was touched already (not |0⟩) + + // Theorem: actual bond dim <= 2^nullity (possibly << for small Hilbert spaces). + let nullity = stn2.ofd_nullity(); + let bound = stn2.theoretical_min_bond_dim(); + let actual = stn2.max_bond_dim(); + eprintln!( + "Case 2: nullity={nullity}, theoretical_bound=2^nullity={bound}, actual_bond={actual}" + ); + assert!( + actual <= bound.max(1 << 2), + "actual {actual} should be <= 2^nullity {bound} or Hilbert limit" + ); + } + + /// Demonstrate OFD pre-analysis API. After running a Clifford+T circuit + /// through `StabMps`, these accessors report OFD's predictions. + #[test] + fn test_ofd_analysis_api() { + let q = |i: usize| QubitId(i); + let mut stn = StabMps::with_seed(5, 42); + // 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)]); + stn.cx(&[(q(0), q(1))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(1)]); + stn.cx(&[(q(1), q(2))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(2)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(3)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(4)]); + + // All 5 T gates absorbed into single-site paths. + assert_eq!(stn.ofd_total_absorbed(), 5); + assert_eq!(stn.ofd_disentangled_count(), 5); + assert_eq!(stn.ofd_nullity(), 0); + assert_eq!(stn.theoretical_min_bond_dim(), 1); + assert_eq!(stn.max_bond_dim(), 1); // matches OFD prediction + } + + /// make bond dim worse. In current scheme, it typically does nothing because + /// the main scheme already achieves near-optimal bond dim. + #[test] + fn test_heuristic_disentangler_noop_on_optimized_state() { + let mut stn = StabMps::with_seed(4, 42); + let q = |i: usize| QubitId(i); + stn.h(&[q(0)]); + for _ in 0..5 { + stn.cx(&[(q(0), q(1))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); + stn.cx(&[(q(1), q(2))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(1)]); + stn.cx(&[(q(2), q(3))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(2)]); + } + let bond_before = stn.max_bond_dim(); + let gates_applied = stn.disentangle(5); + let bond_after = stn.max_bond_dim(); + // Heuristic should not make things worse. + assert!( + bond_after <= bond_before, + "heuristic disentangle should not increase bond dim: {bond_before} -> {bond_after}" + ); + // On this circuit (4q, Clifford+T, bond dim 1 already), heuristic + // finds nothing to do. + assert_eq!(gates_applied, 0); + } + + /// 4q seed 737 reproduction: step-by-step comparison with `DenseStateVec`. + /// Find exactly which step diverges. + #[test] + #[allow(clippy::type_complexity)] + fn test_fuzz_4q_seed_737_step_by_step() { + use pecos_simulators::DenseStateVec; + let mut stn = StabMps::new(4); + let mut ref_sim = DenseStateVec::new(4); + let q = |i: usize| QubitId(i); + + let check = |stn: &StabMps, ref_sim: &mut DenseStateVec, label: &str| -> f64 { + let sv_stn = stn.state_vector(); + let sv_ref: Vec = (0..16).map(|i| ref_sim.get_amplitude(i)).collect(); + let overlap: Complex64 = sv_stn + .iter() + .zip(sv_ref.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + let fid = overlap.norm_sqr(); + if (fid - 1.0).abs() > 1e-4 { + eprintln!("*** {label}: fid={fid:.4} ***"); + } + fid + }; + + let mut force_std = |stn: &mut StabMps| { + for i in 0..stn.disent_flags.len() { + stn.disent_flags[i] = None; + } + }; + let _ = &mut force_std; // allow unused + + let steps: Vec> = vec![ + Box::new(|s, r| { + s.cz(&[(q(1), q(0))]); + r.cz(&[(q(1), q(0))]); + }), + Box::new(|s, r| { + s.cx(&[(q(3), q(0))]); + r.cx(&[(q(3), q(0))]); + }), + Box::new(|s, r| { + s.h(&[q(1)]); + r.h(&[q(1)]); + }), + Box::new(|s, r| { + s.rz(Angle64::from_radians(0.0691), &[q(3)]); + r.rz(Angle64::from_radians(0.0691), &[q(3)]); + }), + Box::new(|s, r| { + s.rz(Angle64::from_radians(0.3330), &[q(2)]); + r.rz(Angle64::from_radians(0.3330), &[q(2)]); + }), + Box::new(|s, r| { + s.cx(&[(q(2), q(3))]); + r.cx(&[(q(2), q(3))]); + }), + Box::new(|s, r| { + s.cx(&[(q(3), q(1))]); + r.cx(&[(q(3), q(1))]); + }), + Box::new(|s, r| { + s.cx(&[(q(3), q(1))]); + r.cx(&[(q(3), q(1))]); + }), + Box::new(|s, r| { + s.sz(&[q(3)]); + r.sz(&[q(3)]); + }), + Box::new(|s, r| { + s.sz(&[q(1)]); + r.sz(&[q(1)]); + }), + Box::new(|s, r| { + s.rx(Angle64::from_radians(0.8608), &[q(2)]); + r.rx(Angle64::from_radians(0.8608), &[q(2)]); + }), + Box::new(|s, r| { + s.x(&[q(2)]); + r.x(&[q(2)]); + }), + Box::new(|s, r| { + s.rx(Angle64::from_radians(3.2610), &[q(1)]); + r.rx(Angle64::from_radians(3.2610), &[q(1)]); + }), + Box::new(|s, r| { + s.sz(&[q(2)]); + r.sz(&[q(2)]); + }), + Box::new(|s, r| { + s.rz(Angle64::from_radians(3.4558), &[q(2)]); + r.rz(Angle64::from_radians(3.4558), &[q(2)]); + }), + Box::new(|s, r| { + s.rx(Angle64::from_radians(1.3195), &[q(2)]); + r.rx(Angle64::from_radians(1.3195), &[q(2)]); + }), + Box::new(|s, r| { + s.x(&[q(1)]); + r.x(&[q(1)]); + }), + Box::new(|s, r| { + s.rz(Angle64::QUARTER_TURN / 2u64, &[q(3)]); + r.rz(Angle64::QUARTER_TURN / 2u64, &[q(3)]); + }), + Box::new(|s, r| { + s.sz(&[q(3)]); + r.sz(&[q(3)]); + }), + Box::new(|s, r| { + s.h(&[q(1)]); + r.h(&[q(1)]); + }), + Box::new(|s, r| { + s.rx(Angle64::from_radians(5.3596), &[q(0)]); + r.rx(Angle64::from_radians(5.3596), &[q(0)]); + }), + Box::new(|s, r| { + s.rz(Angle64::QUARTER_TURN / 2u64, &[q(2)]); + r.rz(Angle64::QUARTER_TURN / 2u64, &[q(2)]); + }), + Box::new(|s, r| { + s.rz(Angle64::QUARTER_TURN / 2u64, &[q(2)]); + r.rz(Angle64::QUARTER_TURN / 2u64, &[q(2)]); + }), + Box::new(|s, r| { + s.rz(Angle64::QUARTER_TURN / 2u64, &[q(3)]); + r.rz(Angle64::QUARTER_TURN / 2u64, &[q(3)]); + }), + Box::new(|s, r| { + s.h(&[q(3)]); + r.h(&[q(3)]); + }), + ]; + + for (i, step) in steps.iter().enumerate() { + step(&mut stn, &mut ref_sim); + let fid = check(&stn, &mut ref_sim, &format!("step {i}")); + if (fid - 1.0).abs() > 1e-4 { + // print diagnostic + eprintln!("diverged at step {i}, fid={fid}"); + return; // stop at first divergence + } + } + eprintln!("All 25 steps pass"); + } + + /// Trace seed 107 disent step: print tableau + xvec before and after. + #[test] + fn test_trace_seed_107_disent() { + let q0 = QubitId(0); + let q1 = QubitId(1); + let mut stn = StabMps::new(2); + stn.cx(&[(q0, q1)]); + stn.sz(&[q1]); + stn.rz(Angle64::from_radians(4.8946), &[q1]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q0]); + stn.cz(&[(q0, q1)]); + stn.x(&[q0]); + stn.rz(Angle64::from_radians(6.0633), &[q1]); + stn.sz(&[q1]); + stn.x(&[q1]); + stn.h(&[q0]); + + eprintln!("=== Before inner RZ(1.4326, 0) ==="); + for k in 0..2 { + let xs: Vec = stn.tableau.destabs().row_x[k].iter().collect(); + let zs: Vec = stn.tableau.destabs().row_z[k].iter().collect(); + let s_m = stn.tableau.destabs().signs_minus.contains(k); + let s_i = stn.tableau.destabs().signs_i.contains(k); + eprintln!("destab {k}: x={xs:?} z={zs:?} -={s_m} i={s_i}"); + } + for k in 0..2 { + let xs: Vec = stn.tableau.stabs().row_x[k].iter().collect(); + let zs: Vec = stn.tableau.stabs().row_z[k].iter().collect(); + let s_m = stn.tableau.stabs().signs_minus.contains(k); + let s_i = stn.tableau.stabs().signs_i.contains(k); + eprintln!("stab {k}: x={xs:?} z={zs:?} -={s_m} i={s_i}"); + } + let mps_sv = stn.mps.state_vector(); + eprintln!( + "mps: {:?}", + mps_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!("flags: {:?}", stn.disent_flags); + + stn.rz(Angle64::from_radians(1.4326), &[q0]); + + eprintln!("\n=== After inner RZ(1.4326, 0) ==="); + for k in 0..2 { + let xs: Vec = stn.tableau.destabs().row_x[k].iter().collect(); + let zs: Vec = stn.tableau.destabs().row_z[k].iter().collect(); + let s_m = stn.tableau.destabs().signs_minus.contains(k); + let s_i = stn.tableau.destabs().signs_i.contains(k); + eprintln!("destab {k}: x={xs:?} z={zs:?} -={s_m} i={s_i}"); + } + for k in 0..2 { + let xs: Vec = stn.tableau.stabs().row_x[k].iter().collect(); + let zs: Vec = stn.tableau.stabs().row_z[k].iter().collect(); + let s_m = stn.tableau.stabs().signs_minus.contains(k); + let s_i = stn.tableau.stabs().signs_i.contains(k); + eprintln!("stab {k}: x={xs:?} z={zs:?} -={s_m} i={s_i}"); + } + let mps_sv = stn.mps.state_vector(); + eprintln!( + "mps: {:?}", + mps_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + } + + /// Direct comparison: std path (flags cleared) vs `DenseStateVec` for seed 107 setup. + /// Does std implement `U_goal` correctly? + #[test] + #[allow(clippy::type_complexity)] + fn test_std_vs_ref_seed_107() { + use pecos_simulators::DenseStateVec; + let q0 = QubitId(0); + let q1 = QubitId(1); + let mut stn = StabMps::new(2); + let mut ref_sim = DenseStateVec::new(2); + + let apply_both = |_stn: &mut StabMps, + _ref_sim: &mut DenseStateVec, + gate: &dyn Fn( + &mut dyn FnMut(&mut StabMps), + &mut dyn FnMut(&mut DenseStateVec), + )| { + let mut s_closure = |s: &mut StabMps| { + let _ = s; + }; + let mut r_closure = |r: &mut DenseStateVec| { + let _ = r; + }; + gate(&mut s_closure, &mut r_closure); + }; + let _ = apply_both; + + stn.cx(&[(q0, q1)]); + ref_sim.cx(&[(q0, q1)]); + stn.sz(&[q1]); + ref_sim.sz(&[q1]); + stn.rz(Angle64::from_radians(4.8946), &[q1]); + ref_sim.rz(Angle64::from_radians(4.8946), &[q1]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q0]); + ref_sim.rz(Angle64::QUARTER_TURN / 2u64, &[q0]); + stn.cz(&[(q0, q1)]); + ref_sim.cz(&[(q0, q1)]); + stn.x(&[q0]); + ref_sim.x(&[q0]); + stn.rz(Angle64::from_radians(6.0633), &[q1]); + ref_sim.rz(Angle64::from_radians(6.0633), &[q1]); + stn.sz(&[q1]); + ref_sim.sz(&[q1]); + stn.x(&[q1]); + ref_sim.x(&[q1]); + stn.h(&[q0]); + ref_sim.h(&[q0]); + + // Force std path: clear flags. + for i in 0..stn.disent_flags.len() { + stn.disent_flags[i] = None; + } + stn.rz(Angle64::from_radians(1.4326), &[q0]); + ref_sim.rz(Angle64::from_radians(1.4326), &[q0]); + + let sv_stn = stn.state_vector(); + let sv_ref: Vec = (0..4).map(|i| ref_sim.get_amplitude(i)).collect(); + let overlap: Complex64 = sv_stn + .iter() + .zip(sv_ref.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!( + "STN std: {:?}", + sv_stn + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + "REF: {:?}", + sv_ref + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!("fid = {}", overlap.norm_sqr()); + assert!( + (overlap.norm_sqr() - 1.0).abs() < 1e-6, + "std path should match DenseStateVec reference: fid={}", + overlap.norm_sqr() + ); + } + + /// Compare YY test setup: disent path vs std path (flags cleared). + /// If they DIVERGE, then the forward right-compose is NOT equivalent to std + /// (even though `test_disentangle_YY_rotation` passes vs true reference — + /// meaning the disent path matches true reference by coincidence, not because + /// it equals std path). + #[test] + fn test_yy_setup_disent_vs_std() { + let theta = Angle64::from_radians(0.3); + let build = || -> StabMps { + let mut s = StabMps::new(2); + s.cx(&[(QubitId(0), QubitId(1))]); + s.sz(&[QubitId(1)]); + s.sz(&[QubitId(0)]); + s.h(&[QubitId(0)]); + s.h(&[QubitId(1)]); + s.sz(&[QubitId(0)]); + s.sz(&[QubitId(1)]); + s + }; + + let mut disent = build(); + disent.rz(theta, &[QubitId(0)]); + let sv_d = disent.state_vector(); + + let mut std = build(); + for i in 0..std.disent_flags.len() { + std.disent_flags[i] = None; + } + std.rz(theta, &[QubitId(0)]); + let sv_s = std.state_vector(); + + let overlap: Complex64 = sv_d + .iter() + .zip(sv_s.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!("YY disent vs std fid = {}", overlap.norm_sqr()); + } + + /// Simpler diagnostic: apply each `right_compose` op to tableau, and compare + /// virtual state to applying the same op to MPS directly. These should match + /// per the identity: (C·U)·xvec = C·(U·xvec). + #[test] + fn test_right_compose_equivalence_diagnostic() { + use crate::stab_mps::tableau_compose; + let q0 = QubitId(0); + let q1 = QubitId(1); + + // Build a state with some Clifford ops to get a non-trivial tableau & non-trivial MPS. + let build = || -> StabMps { + let mut s = StabMps::new(2); + s.cx(&[(q0, q1)]); + s.sz(&[q1]); + s.rz(Angle64::from_radians(4.8946), &[q1]); // non-Clifford to get MPS nontrivial + s.rz(Angle64::QUARTER_TURN / 2u64, &[q0]); + s.cz(&[(q0, q1)]); + s.x(&[q0]); + s.rz(Angle64::from_radians(6.0633), &[q1]); + s.sz(&[q1]); + s.x(&[q1]); + s.h(&[q0]); + s + }; + + let sdg_m = { + let mut m = DMatrix::identity(2, 2); + m[(1, 1)] = Complex64::new(0.0, -1.0); + m + }; + let s_m = { + let mut m = DMatrix::identity(2, 2); + m[(1, 1)] = Complex64::new(0.0, 1.0); + m + }; + let h_m = { + let r = Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0); + DMatrix::from_row_slice(2, 2, &[r, r, r, -r]) + }; + let cnot_lo_m = DMatrix::from_row_slice( + 4, + 4, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ); + + let check = |label: &str, + apply_tableau: &dyn Fn(&mut StabMps), + apply_mps: &dyn Fn(&mut StabMps)| { + let mut s_tab = build(); + apply_tableau(&mut s_tab); + let sv_tab = s_tab.state_vector(); + let mut s_mps = build(); + apply_mps(&mut s_mps); + let sv_mps = s_mps.state_vector(); + let overlap: Complex64 = sv_tab + .iter() + .zip(sv_mps.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + let fid = overlap.norm_sqr(); + eprintln!("{label}: fid(tab vs mps) = {fid:.6}"); + (fid - 1.0).abs() < 1e-6 + }; + + let r_szdg_0 = |s: &mut StabMps| tableau_compose::right_compose_szdg(&mut s.tableau, 0); + let r_szdg_1 = |s: &mut StabMps| tableau_compose::right_compose_szdg(&mut s.tableau, 1); + let r_sz_1 = |s: &mut StabMps| tableau_compose::right_compose_sz(&mut s.tableau, 1); + let r_cx_01 = |s: &mut StabMps| tableau_compose::right_compose_cx(&mut s.tableau, 0, 1); + let r_z_0 = |s: &mut StabMps| tableau_compose::right_compose_z(&mut s.tableau, 0); + let r_h_0 = |s: &mut StabMps| tableau_compose::right_compose_h(&mut s.tableau, 0); + + let sdg_on_0 = |s: &mut StabMps| { + s.mps.apply_one_site_gate(0, &sdg_m).unwrap(); + }; + let sdg_on_1 = |s: &mut StabMps| { + s.mps.apply_one_site_gate(1, &sdg_m).unwrap(); + }; + let s_on_1 = |s: &mut StabMps| { + s.mps.apply_one_site_gate(1, &s_m).unwrap(); + }; + let cnot_01_mps = |s: &mut StabMps| { + s.mps + .apply_long_range_two_site_gate(0, 1, &cnot_lo_m) + .unwrap(); + }; + let z_m = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(-1.0, 0.0), + ], + ); + let z_m_clone = z_m.clone(); + let z_on_0 = move |s: &mut StabMps| { + s.mps.apply_one_site_gate(0, &z_m_clone).unwrap(); + }; + let h_m_clone = h_m.clone(); + let h_on_0 = move |s: &mut StabMps| { + s.mps.apply_one_site_gate(0, &h_m_clone).unwrap(); + }; + + let mut ok = true; + ok &= check("right_compose_szdg(0)", &r_szdg_0, &sdg_on_0); + ok &= check("right_compose_szdg(1)", &r_szdg_1, &sdg_on_1); + ok &= check("right_compose_sz(1)", &r_sz_1, &s_on_1); + ok &= check("right_compose_cx(0,1)", &r_cx_01, &cnot_01_mps); + ok &= check("right_compose_z(0)", &r_z_0, &z_on_0); + ok &= check("right_compose_h(0)", &r_h_0, &h_on_0); + assert!(ok, "some right_compose op fails equivalence"); + } + + /// Compare the disentangle path to the standard (non-disentangle) path by + /// running the exact same setup twice: once with flags enabled, once with + /// flags forced to None. + #[test] + fn test_disentangle_vs_standard_seed_107() { + let q0 = QubitId(0); + let q1 = QubitId(1); + // Build the state at step 8 end (before the failing rx). + let build = || -> StabMps { + let mut s = StabMps::new(2); + s.cx(&[(q0, q1)]); + s.sz(&[q1]); + s.rz(Angle64::from_radians(4.8946), &[q1]); + s.rz(Angle64::QUARTER_TURN / 2u64, &[q0]); + s.cz(&[(q0, q1)]); + s.x(&[q0]); + s.rz(Angle64::from_radians(6.0633), &[q1]); + s.sz(&[q1]); + s.x(&[q1]); + // Start of rx(0, 1.4326): apply inner H(q0). + s.h(&[q0]); + s + }; + + // Run 1: standard flags -> disentangle fires. + let mut s_disent = build(); + s_disent.rz(Angle64::from_radians(1.4326), &[q0]); + let sv_disent = s_disent.state_vector(); + + // Run 2: flags cleared -> uses multi-site CNOT cascade path. + let mut s_std = build(); + for i in 0..s_std.disent_flags.len() { + s_std.disent_flags[i] = None; + } + s_std.rz(Angle64::from_radians(1.4326), &[q0]); + let sv_std = s_std.state_vector(); + + let overlap: Complex64 = sv_disent + .iter() + .zip(sv_std.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!( + "disent: {:?}", + sv_disent + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + "std: {:?}", + sv_std + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!("fid(disent vs std) = {}", overlap.norm_sqr()); + assert!( + (overlap.norm_sqr() - 1.0).abs() < 1e-6, + "disentangle path diverges from standard path: fid={}", + overlap.norm_sqr() + ); + } + + /// Exact replay of fuzz seed 107. + /// Gates: cx, sz(1), rz(1) 4.8946, t(0), cz, x(0), rz(1) 6.0633, sz(1), x(1), rx(0) 1.4326. + #[test] + fn test_fuzz_seed_107_exact_replay() { + use pecos_simulators::DenseStateVec; + let mut stn = StabMps::new(2); + let mut ref_sim = DenseStateVec::new(2); + let q0 = QubitId(0); + let q1 = QubitId(1); + + let check = |stn: &StabMps, ref_sim: &mut DenseStateVec, label: &str| { + let stn_sv = stn.state_vector(); + let ref_sv: Vec = (0..4).map(|i| ref_sim.get_amplitude(i)).collect(); + let overlap: Complex64 = stn_sv + .iter() + .zip(ref_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + let fid = overlap.norm_sqr(); + eprintln!("{label}: fid={fid:.6}"); + eprintln!( + " STN: {:?}", + stn_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + " REF: {:?}", + ref_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + (fid - 1.0).abs() < 1e-6 + }; + + stn.cx(&[(q0, q1)]); + ref_sim.cx(&[(q0, q1)]); + assert!(check(&stn, &mut ref_sim, "step 0 cx")); + stn.sz(&[q1]); + ref_sim.sz(&[q1]); + assert!(check(&stn, &mut ref_sim, "step 1 sz(1)")); + stn.rz(Angle64::from_radians(4.8946), &[q1]); + ref_sim.rz(Angle64::from_radians(4.8946), &[q1]); + assert!(check(&stn, &mut ref_sim, "step 2 rz(1) 4.89")); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q0]); + ref_sim.rz(Angle64::QUARTER_TURN / 2u64, &[q0]); + assert!(check(&stn, &mut ref_sim, "step 3 t(0)")); + stn.cz(&[(q0, q1)]); + ref_sim.cz(&[(q0, q1)]); + assert!(check(&stn, &mut ref_sim, "step 4 cz")); + stn.x(&[q0]); + ref_sim.x(&[q0]); + assert!(check(&stn, &mut ref_sim, "step 5 x(0)")); + stn.rz(Angle64::from_radians(6.0633), &[q1]); + ref_sim.rz(Angle64::from_radians(6.0633), &[q1]); + assert!(check(&stn, &mut ref_sim, "step 6 rz(1) 6.06")); + stn.sz(&[q1]); + ref_sim.sz(&[q1]); + assert!(check(&stn, &mut ref_sim, "step 7 sz(1)")); + stn.x(&[q1]); + ref_sim.x(&[q1]); + assert!(check(&stn, &mut ref_sim, "step 8 x(1)")); + stn.rx(Angle64::from_radians(1.4326), &[q0]); + ref_sim.rx(Angle64::from_radians(1.4326), &[q0]); + assert!(check(&stn, &mut ref_sim, "step 9 rx(0) 1.43")); + } + + #[test] + fn test_disentangle_gf2_recording() { + // H(0), H(1), Rz(theta, 0), Rz(theta, 1) + // 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); + + stn.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + stn.rz(theta, &[QubitId(0)]); + stn.rz(theta, &[QubitId(1)]); + + // GF(2) matrix should have 2 rows, each a single-site indicator + assert_eq!(stn.gf2_matrix().num_gates(), 2); + assert_eq!(stn.gf2_matrix().gf2_rank(), 2); // Independent sites + } + + #[test] + fn test_stn_clifford_circuit() { + let mut stn = StabMps::new(2); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + + let results0 = stn.mz(&[QubitId(0)]); + let outcome0 = results0[0].outcome; + let determ0 = results0[0].is_deterministic; + + let results1 = stn.mz(&[QubitId(1)]); + let outcome1 = results1[0].outcome; + let determ1 = results1[0].is_deterministic; + + assert!(!determ0); + assert!(determ1); + assert_eq!(outcome0, outcome1); + } + + #[test] + fn test_stn_rz_clifford_angles() { + let mut stn = StabMps::new(1); + stn.h(&[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN, &[QubitId(0)]); // S gate + assert_eq!(stn.max_bond_dim(), 1); + } + + #[test] + fn test_stn_t_gate_on_zero() { + let mut stn = StabMps::new(1); + 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); + stn.h(&[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); // T gate + assert_eq!(stn.max_bond_dim(), 1); + assert_relative_eq!(stn.mps().norm_squared(), 1.0, epsilon = 1e-10); + } + + #[test] + fn test_stn_multiple_t_gates() { + let mut stn = StabMps::new(2); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + assert_relative_eq!(stn.mps().norm_squared(), 1.0, epsilon = 1e-8); + } + + #[test] + fn test_stn_reset() { + let mut stn = StabMps::new(2); + stn.h(&[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + stn.reset(); + assert_eq!(stn.max_bond_dim(), 1); + } + + // --- Cross-validation tests against StabVec --- + + /// Helper: compare STN state vector against `StabVec` state vector. + /// Allows global phase difference: checks that ||^2 ≈ 1 + /// and that both are normalized. + fn assert_state_vectors_match(stn_sv: &[Complex64], crz_sv: &[Complex64], label: &str) { + assert_eq!(stn_sv.len(), crz_sv.len(), "{label}: dimension mismatch"); + + // Check both are normalized + let norm_stn: f64 = stn_sv.iter().map(nalgebra::Complex::norm_sqr).sum(); + let norm_crz: f64 = crz_sv.iter().map(nalgebra::Complex::norm_sqr).sum(); + assert_relative_eq!(norm_stn, 1.0, epsilon = 1e-6); + assert_relative_eq!(norm_crz, 1.0, epsilon = 1e-6); + + // Check overlap ||^2 == 1 (states are the same up to global phase) + let overlap: Complex64 = stn_sv + .iter() + .zip(crz_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + assert_relative_eq!(overlap.norm_sqr(), 1.0, epsilon = 1e-6); + } + + #[test] + fn test_cross_validate_pure_clifford() { + // H on q0, CX(q0, q1) -> Bell state + let mut stn = StabMps::new(2); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let stn_sv = stn.state_vector(); + + let mut crz = StabVec::builder(2).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.cx(&[(QubitId(0), QubitId(1))]); + let crz_sv = crz.state_vector(); + + assert_state_vectors_match(&stn_sv, &crz_sv, "Bell state"); + } + + #[test] + fn test_cross_validate_t_on_plus() { + // H then T on single qubit + let mut stn = StabMps::new(1); + stn.h(&[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let stn_sv = stn.state_vector(); + + let mut crz = StabVec::builder(1).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let crz_sv = crz.state_vector(); + + assert_state_vectors_match(&stn_sv, &crz_sv, "T|+>"); + } + + #[test] + fn test_cross_validate_t_on_zero() { + // T on |0> + let mut stn = StabMps::new(1); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let stn_sv = stn.state_vector(); + + let mut crz = StabVec::builder(1).seed(42).build(); + crz.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let crz_sv = crz.state_vector(); + + assert_state_vectors_match(&stn_sv, &crz_sv, "T|0>"); + } + + #[test] + fn test_cross_validate_bell_plus_t() { + // Bell state then T on q0 + let mut stn = StabMps::new(2); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let stn_sv = stn.state_vector(); + + let mut crz = StabVec::builder(2).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.cx(&[(QubitId(0), QubitId(1))]); + crz.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let crz_sv = crz.state_vector(); + + assert_state_vectors_match(&stn_sv, &crz_sv, "Bell + T"); + } + + #[test] + 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); + stn.h(&[QubitId(0)]); + stn.rz(theta, &[QubitId(0)]); + let stn_sv = stn.state_vector(); + + let mut crz = StabVec::builder(1).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.rz(theta, &[QubitId(0)]); + let crz_sv = crz.state_vector(); + + assert_state_vectors_match(&stn_sv, &crz_sv, "RZ(1.234)|+>"); + } + + #[test] + 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); + stn.h(&[QubitId(0)]); + stn.rz(t_angle, &[QubitId(0)]); + stn.h(&[QubitId(0)]); + stn.rz(t_angle, &[QubitId(0)]); + let stn_sv = stn.state_vector(); + + let mut crz = StabVec::builder(1).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.rz(t_angle, &[QubitId(0)]); + crz.h(&[QubitId(0)]); + crz.rz(t_angle, &[QubitId(0)]); + let crz_sv = crz.state_vector(); + + assert_state_vectors_match(&stn_sv, &crz_sv, "H T H T |0>"); + } + + #[test] + 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); + stn.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + stn.rz(t_angle, &[QubitId(0)]); + stn.rz(t_angle, &[QubitId(1)]); + let stn_sv = stn.state_vector(); + + let mut crz = StabVec::builder(2).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.h(&[QubitId(1)]); + crz.rz(t_angle, &[QubitId(0)]); + crz.rz(t_angle, &[QubitId(1)]); + let crz_sv = crz.state_vector(); + + assert_state_vectors_match(&stn_sv, &crz_sv, "H H T T (2 qubits, product state)"); + } + + #[test] + 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); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(t_angle, &[QubitId(0)]); + stn.h(&[QubitId(2)]); + stn.cx(&[(QubitId(1), QubitId(2))]); + + stn.rz(t_angle, &[QubitId(2)]); + let stn_sv = stn.state_vector(); + + let mut crz = StabVec::builder(3).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.cx(&[(QubitId(0), QubitId(1))]); + crz.rz(t_angle, &[QubitId(0)]); + crz.h(&[QubitId(2)]); + crz.cx(&[(QubitId(1), QubitId(2))]); + crz.rz(t_angle, &[QubitId(2)]); + let crz_sv = crz.state_vector(); + assert_state_vectors_match(&stn_sv, &crz_sv, "3-qubit circuit"); + } + + #[test] + fn test_cross_validate_s_gate_via_rz() { + // RZ(pi/2) should match S gate + let mut stn = StabMps::new(1); + stn.h(&[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN, &[QubitId(0)]); // S = RZ(pi/2) + let stn_sv = stn.state_vector(); + + let mut crz = StabVec::builder(1).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.rz(Angle64::QUARTER_TURN, &[QubitId(0)]); + let crz_sv = crz.state_vector(); + + assert_state_vectors_match(&stn_sv, &crz_sv, "S|+> via RZ"); + } + + // --- Measurement tests --- + + #[test] + fn test_measurement_after_t_gate() { + // H, T, measure in Z basis. + // T|+> = (e^{-i*pi/8}|0> + e^{i*pi/8}|1>)/sqrt(2) + // Both amplitudes have magnitude 1/sqrt(2), so prob(0) = prob(1) = 0.5 + let expected_p0 = 0.5; + + let n_trials: u32 = 2000; + let mut count_0 = 0; + for trial in 0..n_trials { + let mut stn = StabMps::with_seed(1, u64::from(1000 + trial)); + stn.h(&[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let result = stn.mz(&[QubitId(0)]); + if !result[0].outcome { + count_0 += 1; + } + } + let measured_p0 = f64::from(count_0) / f64::from(n_trials); + assert!( + (measured_p0 - expected_p0).abs() < 0.05, + "p(0) = {measured_p0:.3}, expected {expected_p0:.3}" + ); + } + + #[test] + fn test_measurement_rx_probabilities() { + // RX(pi/3)|0> has prob(0) = cos^2(pi/6) = 3/4 + let expected_p0 = 0.75; + let theta = Angle64::from_radians(std::f64::consts::FRAC_PI_3); + + let n_trials: u32 = 2000; + let mut count_0 = 0; + for trial in 0..n_trials { + let mut stn = StabMps::with_seed(1, u64::from(3000 + trial)); + stn.rx(theta, &[QubitId(0)]); + let result = stn.mz(&[QubitId(0)]); + if !result[0].outcome { + count_0 += 1; + } + } + let measured_p0 = f64::from(count_0) / f64::from(n_trials); + assert!( + (measured_p0 - expected_p0).abs() < 0.05, + "p(0) = {measured_p0:.3}, expected {expected_p0:.3}" + ); + } + + #[test] + fn test_measurement_deterministic_after_t_on_zero() { + // T|0> is still an eigenstate of Z (Z is a stabilizer of |0>) + let mut stn = StabMps::with_seed(1, 42); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let result = stn.mz(&[QubitId(0)]); + assert!(result[0].is_deterministic); + assert!(!result[0].outcome); // +1 eigenvalue -> outcome false + } + + #[test] + fn test_measurement_bell_state_correlation() { + // Bell state: measure q0, then q1 should give same outcome + for trial in 0..50 { + let mut stn = StabMps::with_seed(2, 2000 + trial); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + // Apply T to make MPS non-trivial + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + + let r0 = stn.mz(&[QubitId(0)])[0].outcome; + let r1 = stn.mz(&[QubitId(1)])[0].outcome; + assert_eq!( + r0, r1, + "trial {trial}: Bell state + T should have correlated measurements" + ); + } + } + + #[test] + fn test_disentangle_preserves_state() { + // Create a circuit, disentangle, verify state vector is unchanged. + let t_angle = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::new(3); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(t_angle, &[QubitId(0)]); + stn.h(&[QubitId(2)]); + stn.cx(&[(QubitId(1), QubitId(2))]); + stn.rz(t_angle, &[QubitId(2)]); + + // Get state vector before disentangling + let sv_before = stn.state_vector(); + let bond_before = stn.max_bond_dim(); + + // Disentangle + let gates_applied = stn.disentangle(3); + eprintln!( + "Disentangle: applied {gates_applied} gates, bond dim {} -> {}", + bond_before, + stn.max_bond_dim() + ); + + // State vector should be unchanged (up to global phase) + let sv_after = stn.state_vector(); + let overlap: Complex64 = sv_before + .iter() + .zip(sv_after.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!("overlap = {:.6}", overlap.norm_sqr()); + assert_state_vectors_match(&sv_before, &sv_after, "disentangle preserves state"); + + // Bond dimension should not have increased + assert!( + stn.max_bond_dim() <= bond_before, + "disentangle should not increase bond dim: {} > {}", + stn.max_bond_dim(), + bond_before + ); + + eprintln!( + "Disentangle: applied {gates_applied} gates, bond dim {} -> {}", + bond_before, + stn.max_bond_dim() + ); + } + + #[test] + fn test_compression_keeps_bond_dim_bounded() { + // Apply multiple T gates. Without compression, bond dim would grow + // exponentially. With compression, redundant components are removed. + let t_angle = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::builder(4).max_bond_dim(4).build(); + + // Create entangled state + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.h(&[QubitId(2)]); + stn.cx(&[(QubitId(2), QubitId(3))]); + + // Apply T gates -- each one could double bond dim without compression + stn.rz(t_angle, &[QubitId(0)]); + stn.rz(t_angle, &[QubitId(2)]); + stn.rz(t_angle, &[QubitId(1)]); + stn.rz(t_angle, &[QubitId(3)]); + + // Bond dimension should be bounded by max_bond_dim + assert!( + stn.max_bond_dim() <= 4, + "bond dim {} should be <= 4", + stn.max_bond_dim() + ); + + // State should still be approximately normalized + assert!( + (stn.mps().norm_squared() - 1.0).abs() < 0.1, + "norm should be close to 1, got {}", + stn.mps().norm_squared() + ); + } + + #[test] + fn test_pauli_expectation_z_on_zero_state() { + // ⟨0|Z|0⟩ = 1. + let stn = StabMps::new(2); + let v = stn.pauli_expectation(&[(0, PauliKind::Z)]); + assert!((v - 1.0).abs() < 1e-10); + } + + #[test] + fn test_pauli_expectation_z_on_one_state() { + // ⟨1|Z|1⟩ = -1. + let mut stn = StabMps::new(2); + stn.x(&[QubitId(0)]); + let v = stn.pauli_expectation(&[(0, PauliKind::Z)]); + assert!((v + 1.0).abs() < 1e-10); + } + + #[test] + fn test_pauli_expectation_x_on_plus_state() { + // ⟨+|X|+⟩ = 1. + let mut stn = StabMps::new(2); + stn.h(&[QubitId(0)]); + let v = stn.pauli_expectation(&[(0, PauliKind::X)]); + assert!((v - 1.0).abs() < 1e-10); + } + + #[test] + fn test_sample_bitstring_plus_state() { + // |+⟩ on q0, |0⟩ on q1: shots should be 50/50 for q0, always 0 for q1. + let mut stn = StabMps::with_seed(2, 99); + stn.h(&[QubitId(0)]); + let shots = stn.sample_bitstring(200); + let q0_one_count = shots.iter().filter(|bs| bs[0]).count(); + let q1_one_count = shots.iter().filter(|bs| bs[1]).count(); + assert_eq!(q1_one_count, 0, "q1 must always measure 0"); + assert!( + q0_one_count > 70 && q0_one_count < 130, + "q0 should be ~50/50, got {q0_one_count}/200" + ); + } + + #[test] + fn test_sample_bitstring_bell_correlation() { + // Bell state: each shot is either (0,0) or (1,1). Sample 200 + // shots, verify all are correlated. + let mut stn = StabMps::with_seed(2, 99); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let shots = stn.sample_bitstring(200); + for (i, bs) in shots.iter().enumerate() { + assert_eq!(bs[0], bs[1], "shot {i} not Bell-correlated: {bs:?}"); + } + let zero_count = shots.iter().filter(|bs| !bs[0]).count(); + assert!( + zero_count > 60 && zero_count < 140, + "zero_count {zero_count}/200 outside 60..140" + ); + } + + #[test] + fn test_sample_bitstring_does_not_mutate_state() { + // Verify the simulator state is unchanged after sampling. + let mut stn = StabMps::with_seed(3, 42); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let bond_before = stn.max_bond_dim(); + let _ = stn.sample_bitstring(10); + let bond_after = stn.max_bond_dim(); + // Self-state untouched. + assert_eq!( + bond_before, bond_after, + "sample_bitstring mutated simulator state" + ); + } + + #[test] + fn test_auto_grow_bond_dim_starts_low_grows_when_capped() { + // Build a small-cap STN and exercise it with a deep, adversarial + // T circuit (small angle that defeats disent flag) so the cap + // binds. Auto-grow should kick in. + let n = 6; + let mut stn = StabMps::builder(n) + .seed(42) + .max_bond_dim(2) + .auto_grow_bond_dim(1e-15) // any truncation triggers + .auto_grow_max_bond_dim(64) + .build(); + + // Spread + entangle. + for q in 0..n { + stn.h(&[QubitId(q)]); + } + for q in 0..n - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + // Deeper T-heavy circuit with rotating qubits + interleaved CXs + // so the disent flag mechanism can't absorb the T's into the + // tableau cheaply. Forces real MPS bond growth. + let small = Angle64::from_radians(0.37); + for layer in 0..6 { + for q in 0..n { + stn.rz(small, &[QubitId((q + layer) % n)]); + } + for q in 0..n - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + } + // After deep mixing, cap should have hit AND been raised. + assert!( + stn.config.max_bond_dim > 2, + "auto-grow should have raised cap from 2; current = {} (bond_cap_hits={}, trunc={:.2e})", + stn.config.max_bond_dim, + stn.bond_cap_hits(), + stn.truncation_error(), + ); + assert!(stn.config.max_bond_dim <= 64); + } + + #[test] + fn test_auto_grow_bond_dim_disabled_by_default() { + let n = 4; + let mut stn = StabMps::builder(n).seed(99).max_bond_dim(2).build(); + // No auto_grow_bond_dim builder call → disabled. + for q in 0..n { + stn.h(&[QubitId(q)]); + } + for q in 0..n - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + let t = Angle64::QUARTER_TURN / 2u64; + for q in 0..n { + stn.rz(t, &[QubitId(q)]); + } + // Cap stays at 2. + assert_eq!(stn.config.max_bond_dim, 2); + } + + #[test] + fn test_pauli_expectation_product_pauli_per_qubit() { + // Exercise decompose_pauli_string's per-qubit Pauli multiplication: + // (q, X), (q, Y) → X·Y = iZ at q. Phase contribution should come through. + // <0|X·Y|0> = <0|iZ|0> = i. Real part = 0. + let stn = StabMps::new(1); + let v = stn.pauli_expectation(&[(0, PauliKind::X), (0, PauliKind::Y)]); + // XY = iZ; <0|iZ|0> = i; pauli_expectation returns real part = 0. + assert!(v.abs() < 1e-10, "<0|XY|0> real part should be 0, got {v}"); + } + + #[test] + fn test_pauli_expectation_yy_per_qubit_is_identity() { + // (q, Y), (q, Y) → Y² = I. <0|I|0> = 1. + let stn = StabMps::new(1); + let v = stn.pauli_expectation(&[(0, PauliKind::Y), (0, PauliKind::Y)]); + assert!((v - 1.0).abs() < 1e-10, "<0|YY|0> = 1, got {v}"); + } + + #[test] + fn test_pauli_expectation_z_on_one_via_apply_x() { + // Apply X to a plain state, measure Z: should give -1. + let mut stn = StabMps::new(1); + stn.x(&[QubitId(0)]); + let v = stn.pauli_expectation(&[(0, PauliKind::Z)]); + assert!((v + 1.0).abs() < 1e-10, "<1|Z|1> = -1, got {v}"); + } + + #[test] + fn test_pauli_frame_with_lazy_measure() { + // Lazy measure + Pauli frame should compose: frame applies AFTER + // the measurement outcome, irrespective of lazy/eager internals. + // Init |0⟩, inject X in frame, measure: expect outcome=1 regardless + // of lazy_measure setting. + for lazy in [false, true] { + let mut stn = StabMps::builder(1) + .seed(42) + .lazy_measure(lazy) + .pauli_frame_tracking(true) + .build(); + stn.inject_x_in_frame(QubitId(0)); + let r = stn.mz(&[QubitId(0)])[0].outcome; + assert!( + r, + "lazy_measure={lazy}, frame X should give outcome=1, got {r}" + ); + } + } + + #[test] + fn test_pauli_frame_with_merge_rz() { + // Inject frame X on q0, apply rz(theta) with merge_rz on. + // X·RZ(θ) = RZ(-θ)·X. Since frame X is "virtual" (will be applied + // at measurement), the simulated state should evolve under RZ(θ) + // naturally — but the NET physical state differs from + // "simulated + frame" only by a global phase (e^{-iθ} on X|ψ>). + // + // Consequence: measurement outcome distributions are identical + // between frame-tracking and no-frame-tracking paths for + // Z-basis measurements. Verify. + let theta = Angle64::from_radians(0.7); + let mut stn_frame = StabMps::builder(1) + .seed(5) + .merge_rz(true) + .pauli_frame_tracking(true) + .build(); + stn_frame.h(&[QubitId(0)]); + stn_frame.inject_x_in_frame(QubitId(0)); + stn_frame.rz(theta, &[QubitId(0)]); + stn_frame.h(&[QubitId(0)]); + let results_frame = stn_frame.mz(&[QubitId(0)]); + let outcome_frame = results_frame[0].outcome; + + // Reference: apply X explicitly (no frame), same sequence. + let mut stn_ref = StabMps::builder(1).seed(5).build(); + stn_ref.h(&[QubitId(0)]); + stn_ref.x(&[QubitId(0)]); + stn_ref.rz(theta, &[QubitId(0)]); + stn_ref.h(&[QubitId(0)]); + let results_ref = stn_ref.mz(&[QubitId(0)]); + let outcome_ref = results_ref[0].outcome; + + assert_eq!( + outcome_frame, outcome_ref, + "frame-X vs applied-X should give same measurement outcome" + ); + } + + #[test] + fn test_is_state_exact_detects_all_sources_of_drift() { + let mut stn = StabMps::builder(2) + .seed(7) + .merge_rz(true) + .pauli_frame_tracking(true) + .build(); + assert!(stn.is_state_exact(), "fresh builder state should be exact"); + + // Pending merged RZ makes it non-exact. + stn.h(&[QubitId(0)]); + stn.rz(Angle64::from_radians(0.5), &[QubitId(0)]); + assert!(!stn.is_state_exact(), "pending merged RZ → not exact"); + stn.flush(); + assert!(stn.is_state_exact(), "after flush() → exact again"); + + // Frame injection makes it non-exact. + stn.inject_x_in_frame(QubitId(0)); + assert!(!stn.is_state_exact(), "frame X set → not exact"); + stn.flush_pauli_frame_to_state(); + assert!(stn.is_state_exact(), "after frame flush → exact"); + } + + #[test] + fn test_pragmatic_drift_count_tracks_non_lazy_pre_reduce() { + // Build a state where col_x for the measured qubit has multiple + // anticommuting stabilizers so pre_reduce fires. H(0), H(1), CX(0,1) + // gives stabs {X_0X_1, X_1}; measuring qubit 1 has col_x[1].len()=2. + let mut stn = StabMps::builder(2).seed(3).build(); + stn.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + assert_eq!(stn.pragmatic_drift_count(), 0, "no measurements yet"); + let _ = stn.mz(&[QubitId(1)]); + assert_eq!( + stn.pragmatic_drift_count(), + 1, + "non-lazy mz on multi-anticom col_x should bump drift counter" + ); + assert!( + !stn.is_state_exact(), + "pragmatic drift makes stored state non-exact" + ); + + // Lazy path: same setup but no drift (pre_reduce CNOTs go into V). + let mut stn = StabMps::builder(2).seed(3).lazy_measure(true).build(); + stn.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let _ = stn.mz(&[QubitId(1)]); + assert_eq!( + stn.pragmatic_drift_count(), + 0, + "lazy_measure path must not increment drift count" + ); + + // Reset clears the counter. + let mut stn = StabMps::builder(2).seed(3).build(); + stn.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let _ = stn.mz(&[QubitId(1)]); + assert!(stn.pragmatic_drift_count() > 0); + stn.reset(); + assert_eq!(stn.pragmatic_drift_count(), 0, "reset clears drift counter"); + } + + #[test] + fn test_lazy_measure_imaginary_sp_y_eigenstate() { + // To hit the imaginary-sp branch we need `id` (flip_site) to also + // appear in `sign_sites`, meaning both stab and destab have the X + // bit at the measured qubit. Circuit: SZ(0), H(0), CX(0,1) gives + // stab = X_0·X_1 (X-bit at 0) and destab = -Y_0·X_1 (X-bit at 0). + // CX(0,1) entangles → MPS non-trivial → decompose_z path fires. + // + // Expected: ~50/50 outcome on qubit 0, post-collapse re-measurement + // deterministic and matching. + let num_shots = 400; + let mut zero_count = 0; + let mut one_count = 0; + let t = Angle64::QUARTER_TURN / 2u64; + for shot in 0..num_shots { + let mut stn = StabMps::builder(2).seed(shot).lazy_measure(true).build(); + // Non-Clifford first to force MPS non-trivial (Cliffords alone + // keep MPS in its initial product form via tableau routing). + stn.h(&[QubitId(1)]); + stn.rz(t, &[QubitId(1)]); + stn.sz(&[QubitId(0)]); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let r1 = stn.mz(&[QubitId(0)])[0].outcome; + let r2 = stn.mz(&[QubitId(0)]); + assert_eq!( + r2[0].outcome, r1, + "after collapse, Z measurement must be stable (shot {shot})" + ); + assert!( + r2[0].is_deterministic, + "post-collapse measurement must be deterministic (shot {shot})" + ); + if r1 { + one_count += 1; + } else { + zero_count += 1; + } + } + assert!( + zero_count > 130 && zero_count < 270, + "qubit 0 should give ~50/50: got {zero_count} zeros, {one_count} ones" + ); + } + + #[test] + fn test_flush_pauli_frame_to_state_makes_read_correct() { + // Without flush: state_vector shows |0⟩ (sim state) even though + // frame has X (physical state is |1⟩). After flush: state_vector + // correctly shows |1⟩. + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_x_in_frame(QubitId(0)); + // State vector BEFORE flush: frame-bits aren't in the state. + let sv_before = stn.state_vector(); + // Stored state is still |0⟩ (index 0, real amplitude 1). + assert!( + (sv_before[0].re - 1.0).abs() < 1e-10, + "before flush: {sv_before:?}" + ); + assert!(sv_before[1].norm() < 1e-10); + + // Now flush. State should become |1⟩. + stn.flush_pauli_frame_to_state(); + assert!(!stn.frame_x_bit(QubitId(0)), "frame cleared after flush"); + let sv_after = stn.state_vector(); + assert!( + sv_after[0].norm() < 1e-10, + "post-flush q0 amp at |0⟩: {sv_after:?}" + ); + assert!( + (sv_after[1].re - 1.0).abs() < 1e-10, + "post-flush q0 amp at |1⟩: {sv_after:?}" + ); + } + + #[test] + fn test_pauli_frame_inject_x_flips_measurement() { + // Init |0⟩. Inject X in frame → measurement should give 1. + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_x_in_frame(QubitId(0)); + let r = stn.mz(&[QubitId(0)])[0].outcome; + assert!(r, "X in frame on |0⟩ should measure as 1, got {r}"); + } + + #[test] + fn test_pauli_frame_inject_z_no_effect_on_zero_state() { + // Z on |0⟩ gives |0⟩ (eigenstate). Measurement = 0 still. + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_z_in_frame(QubitId(0)); + let r = stn.mz(&[QubitId(0)])[0].outcome; + assert!(!r, "Z in frame on |0⟩ should measure 0 (Z|0⟩=|0⟩), got {r}"); + } + + #[test] + fn test_pauli_frame_h_swaps_x_z() { + // Inject Z in frame, apply H, measure. H·Z = X·H. So X bit set, + // Z bit cleared after H. Measurement of X on |0⟩... hmm, but state + // isn't an eigenstate of X. Actually we're tracking Paulis via frame. + // Before H: frame = Z. After H: frame = X (per propagation). + // Physical state |0⟩, measurement in Z basis: frame has Z=0 so + // outcome matches underlying quantum outcome (0). + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_z_in_frame(QubitId(0)); + stn.h(&[QubitId(0)]); // frame: Z → X after H + assert!(stn.frame_x_bit(QubitId(0))); + assert!(!stn.frame_z_bit(QubitId(0))); + } + + #[test] + fn test_pauli_frame_y_inject_flush_gives_correct_amplitude_phase() { + // Inject Y on qubit 0 (|0⟩). Y|0⟩ = i|1⟩. After flushing, the + // state vector should show amplitude i at index 1 (not -i or 1 or -1). + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_y_in_frame(QubitId(0)); + stn.flush_pauli_frame_to_state(); + let sv = stn.state_vector(); + assert!(sv[0].norm() < 1e-10, "post-Y at |0⟩ amp: {:?}", sv[0]); + assert!( + (sv[1] - Complex64::new(0.0, 1.0)).norm() < 1e-10, + "post-Y at |1⟩ amp should be +i, got {:?}", + sv[1] + ); + } + + #[test] + fn test_pauli_frame_h_on_y_exact_state_vector() { + // Inject Y on |0⟩, apply H → physical = H·Y·|0⟩ = H·(i|1⟩) = i·|-⟩. + // The decomposition-based flush (applies the frame Pauli to MPS via + // C†·P·C = phase·X_flip·Z_sign rather than to the tableau via tab.y) + // recovers the correct global phase — no ±1 residual. + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_y_in_frame(QubitId(0)); + stn.h(&[QubitId(0)]); + stn.flush_pauli_frame_to_state(); + let sv = stn.state_vector(); + // Expected i·|-⟩ = (i/√2)|0⟩ + (-i/√2)|1⟩. + let expect_0 = Complex64::new(0.0, inv_sqrt2); + let expect_1 = Complex64::new(0.0, -inv_sqrt2); + assert!( + (sv[0] - expect_0).norm() < 1e-10, + "amp |0⟩: expected {expect_0:?}, got {:?}", + sv[0] + ); + assert!( + (sv[1] - expect_1).norm() < 1e-10, + "amp |1⟩: expected {expect_1:?}, got {:?}", + sv[1] + ); + } + + #[test] + fn test_pauli_frame_y_inject_on_bell_state_exact_phase() { + // Φ+ = (|00⟩+|11⟩)/√2. Apply frame Y_0: + // Y_0|00⟩ = i|1⟩_{q0}|0⟩_{q1} = i·(q0=1,q1=0) → LSB index 1. + // Y_0|11⟩ = -i|0⟩_{q0}|1⟩_{q1} = -i·(q0=0,q1=1) → LSB index 2. + // So sv[1] = i/√2, sv[2] = -i/√2, others 0. (Equivalent to -i·Ψ-.) + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + let mut stn = StabMps::builder(2) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.inject_y_in_frame(QubitId(0)); + stn.flush_pauli_frame_to_state(); + let sv = stn.state_vector(); + let expect_1 = Complex64::new(0.0, inv_sqrt2); + let expect_2 = Complex64::new(0.0, -inv_sqrt2); + assert!(sv[0].norm() < 1e-10, "|00⟩: {:?}", sv[0]); + assert!((sv[1] - expect_1).norm() < 1e-10, "sv[1]: {:?}", sv[1]); + assert!((sv[2] - expect_2).norm() < 1e-10, "sv[2]: {:?}", sv[2]); + assert!(sv[3].norm() < 1e-10, "|11⟩: {:?}", sv[3]); + } + + #[test] + fn test_pauli_frame_propagation_preserves_signs() { + // Sanity: even though the state_vector is now exact via flush, the + // propagation signs are still correctly recorded in pauli_frame_phase. + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_y_in_frame(QubitId(0)); + stn.h(&[QubitId(0)]); + // H·Y·H = -Y: propagation should record -1 in pauli_frame_phase. + assert!( + (stn.pauli_frame_phase + Complex64::new(1.0, 0.0)).norm() < 1e-12, + "H-on-Y should record phase -1, got {:?}", + stn.pauli_frame_phase + ); + assert!(stn.frame_x_bit(QubitId(0))); + assert!(stn.frame_z_bit(QubitId(0))); + } + + #[test] + fn test_reset_qubit_forces_zero_from_one() { + // Prepare |1⟩ then reset → should land on |0⟩ and report outcome=1. + let mut stn = StabMps::builder(1).seed(42).build(); + stn.x(&[QubitId(0)]); + let phys = stn.reset_qubit(QubitId(0)); + assert!(phys, "reset from |1⟩ should report physical outcome true"); + // Post-reset measurement must give 0 (deterministic). + let r = stn.mz(&[QubitId(0)]); + assert!(!r[0].outcome, "after reset, mz must give 0"); + assert!(r[0].is_deterministic, "post-reset mz must be deterministic"); + } + + #[test] + fn test_reset_qubit_forces_zero_from_plus() { + // Prepare |+⟩ then reset. Outcome is random (50/50) but after + // reset, state is deterministically |0⟩. + let mut stn = StabMps::builder(1).seed(42).build(); + stn.h(&[QubitId(0)]); + stn.reset_qubit(QubitId(0)); + let r = stn.mz(&[QubitId(0)]); + assert!(!r[0].outcome, "after reset from |+⟩, mz must give 0"); + assert!(r[0].is_deterministic); + } + + #[test] + fn test_reset_qubit_clears_frame_bits() { + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_y_in_frame(QubitId(0)); + assert!(stn.frame_x_bit(QubitId(0))); + assert!(stn.frame_z_bit(QubitId(0))); + stn.reset_qubit(QubitId(0)); + assert!(!stn.frame_x_bit(QubitId(0)), "reset must clear frame X"); + assert!(!stn.frame_z_bit(QubitId(0)), "reset must clear frame Z"); + // And the physical state is |0⟩. + let r = stn.mz(&[QubitId(0)]); + assert!(!r[0].outcome, "post-reset mz must give 0"); + } + + #[test] + fn test_reset_qubit_preserves_other_qubits() { + // Entangle q0-q1, then reset q0. q1 should still have a definite + // outcome consistent with the GHZ-like correlation collapsed by + // the reset-measurement. + for shot in 0..20u64 { + let mut stn = StabMps::builder(2).seed(100 + shot).build(); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let phys = stn.reset_qubit(QubitId(0)); + // q1 should collapse to match phys (Bell correlation). + let r1 = stn.mz(&[QubitId(1)]); + assert!( + r1[0].is_deterministic, + "q1 post-Bell-reset must be deterministic" + ); + assert_eq!( + r1[0].outcome, phys, + "Bell correlation: q1 outcome must match reset's physical outcome" + ); + } + } + + #[test] + fn test_px_gives_plus_state() { + // px should land qubit in |+⟩: X measurement deterministic 0. + let mut stn = StabMps::builder(1).seed(42).build(); + stn.px(QubitId(0)); + // Measure in X basis via H + mz. + stn.h(&[QubitId(0)]); + let r = stn.mz(&[QubitId(0)]); + assert!(!r[0].outcome, "|+⟩ in X-basis should measure 0"); + assert!(r[0].is_deterministic); + } + + #[test] + fn test_extract_syndromes_steane_noiseless() { + // Steane [[7,1,3]]: prep |0_L⟩, extract syndrome, expect all-zero + // syndrome (codestate is a +1 eigenstate of every generator). + // Uses 7 data + 6 ancillas. + let stabs: Vec> = vec![ + vec![ + (3, PauliKind::X), + (4, PauliKind::X), + (5, PauliKind::X), + (6, PauliKind::X), + ], + vec![ + (1, PauliKind::X), + (2, PauliKind::X), + (5, PauliKind::X), + (6, PauliKind::X), + ], + vec![ + (0, PauliKind::X), + (2, PauliKind::X), + (4, PauliKind::X), + (6, PauliKind::X), + ], + vec![ + (3, PauliKind::Z), + (4, PauliKind::Z), + (5, PauliKind::Z), + (6, PauliKind::Z), + ], + vec![ + (1, PauliKind::Z), + (2, PauliKind::Z), + (5, PauliKind::Z), + (6, PauliKind::Z), + ], + vec![ + (0, PauliKind::Z), + (2, PauliKind::Z), + (4, PauliKind::Z), + (6, PauliKind::Z), + ], + ]; + let ancillas: Vec = (7..13).map(QubitId).collect(); + let mut stn = StabMps::builder(13).seed(42).for_qec().build(); + // Prep Steane |0_L⟩ (pivots 0, 1, 3). + stn.h(&[QubitId(0), QubitId(1), QubitId(3)]); + for (c, t) in [ + (3, 4), + (3, 5), + (3, 6), + (1, 2), + (1, 5), + (1, 6), + (0, 2), + (0, 4), + (0, 6), + ] { + stn.cx(&[(QubitId(c), QubitId(t))]); + } + let syndrome = stn.extract_syndromes(&stabs, &ancillas); + assert_eq!(syndrome.len(), 6); + for (i, &b) in syndrome.iter().enumerate() { + assert!(!b, "noiseless Steane syndrome must be zero, bit {i} = {b}"); + } + // Ancillas must be left in |0⟩ for the next round. + for &a in &ancillas { + let r = stn.mz(&[a]); + assert!( + !r[0].outcome && r[0].is_deterministic, + "ancilla {a:?} not reset after extract_syndromes" + ); + } + } + + #[test] + fn test_extract_syndromes_steane_single_x_error_detects() { + // Same setup, inject X_0 before extraction — expect NON-ZERO + // syndrome on at least one Z-stabilizer. + let stabs: Vec> = vec![ + vec![ + (3, PauliKind::X), + (4, PauliKind::X), + (5, PauliKind::X), + (6, PauliKind::X), + ], + vec![ + (1, PauliKind::X), + (2, PauliKind::X), + (5, PauliKind::X), + (6, PauliKind::X), + ], + vec![ + (0, PauliKind::X), + (2, PauliKind::X), + (4, PauliKind::X), + (6, PauliKind::X), + ], + vec![ + (3, PauliKind::Z), + (4, PauliKind::Z), + (5, PauliKind::Z), + (6, PauliKind::Z), + ], + vec![ + (1, PauliKind::Z), + (2, PauliKind::Z), + (5, PauliKind::Z), + (6, PauliKind::Z), + ], + vec![ + (0, PauliKind::Z), + (2, PauliKind::Z), + (4, PauliKind::Z), + (6, PauliKind::Z), + ], + ]; + let ancillas: Vec = (7..13).map(QubitId).collect(); + let mut stn = StabMps::builder(13).seed(42).for_qec().build(); + stn.h(&[QubitId(0), QubitId(1), QubitId(3)]); + for (c, t) in [ + (3, 4), + (3, 5), + (3, 6), + (1, 2), + (1, 5), + (1, 6), + (0, 2), + (0, 4), + (0, 6), + ] { + stn.cx(&[(QubitId(c), QubitId(t))]); + } + // Inject X_0. Z-stabilizer 3 (ZZZZ on 0,2,4,6) anticommutes → syndrome bit 5 set. + stn.x(&[QubitId(0)]); + let syndrome = stn.extract_syndromes(&stabs, &ancillas); + assert!( + syndrome.iter().skip(3).any(|&b| b), + "X_0 error must trigger at least one Z-stabilizer syndrome, got {syndrome:?}" + ); + } + + #[test] + fn test_extract_syndromes_repeated_rounds_stable() { + // Two noiseless rounds should both report zero syndrome. + let stabs: Vec> = vec![ + vec![(0, PauliKind::Z), (1, PauliKind::Z)], + vec![(1, PauliKind::Z), (2, PauliKind::Z)], + ]; + let ancillas = vec![QubitId(3), QubitId(4)]; + let mut stn = StabMps::builder(5).seed(42).for_qec().build(); + // Trivial |000⟩ data is already a +1 eigenstate of Z_iZ_j. + for _round in 0..2 { + let s = stn.extract_syndromes(&stabs, &ancillas); + assert_eq!(s, vec![false, false]); + } + } + + #[test] + fn test_inject_paulis_in_frame_bulk() { + let mut stn = StabMps::builder(3) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_paulis_in_frame(&[ + (QubitId(0), PauliKind::X), + (QubitId(1), PauliKind::Y), + (QubitId(2), PauliKind::Z), + ]); + assert!(stn.frame_x_bit(QubitId(0))); + assert!(!stn.frame_z_bit(QubitId(0))); + assert!(stn.frame_x_bit(QubitId(1))); + assert!(stn.frame_z_bit(QubitId(1))); + assert!(!stn.frame_x_bit(QubitId(2))); + assert!(stn.frame_z_bit(QubitId(2))); + } + + #[test] + fn test_pauli_frame_cx_propagates() { + // Inject X on q0, apply CX(0, 1). Frame X propagates: X_0 → X_0·X_1. + let mut stn = StabMps::builder(2) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_x_in_frame(QubitId(0)); + stn.cx(&[(QubitId(0), QubitId(1))]); + assert!(stn.frame_x_bit(QubitId(0))); + assert!( + stn.frame_x_bit(QubitId(1)), + "CX should propagate X to target" + ); + } + + #[test] + fn test_pauli_frame_sz_propagation_phase() { + // SZ · X · SZdg = Y (no sign flip). SZ · Y · SZdg = -X (sign flip). + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_x_in_frame(QubitId(0)); + stn.sz(&[QubitId(0)]); + // X → Y: bits (1,0) → (1,1), phase stays +1. + assert!(stn.frame_x_bit(QubitId(0))); + assert!(stn.frame_z_bit(QubitId(0))); + assert!( + (stn.pauli_frame_phase - Complex64::new(1.0, 0.0)).norm() < 1e-12, + "SZ on X should not flip phase, got {:?}", + stn.pauli_frame_phase + ); + + // Now apply SZ again: Y → -X. Phase should flip to -1. + stn.sz(&[QubitId(0)]); + assert!(stn.frame_x_bit(QubitId(0))); + assert!(!stn.frame_z_bit(QubitId(0))); + assert!( + (stn.pauli_frame_phase + Complex64::new(1.0, 0.0)).norm() < 1e-12, + "SZ on Y should flip phase to -1, got {:?}", + stn.pauli_frame_phase + ); + } + + #[test] + fn test_pauli_frame_szdg_propagation_phase() { + // SZdg · X · SZ = -Y (sign flip). SZdg · Y · SZ = +X (no sign flip). + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_x_in_frame(QubitId(0)); + stn.szdg(&[QubitId(0)]); + // X → -Y: bits (1,0) → (1,1), phase -1. + assert!(stn.frame_x_bit(QubitId(0))); + assert!(stn.frame_z_bit(QubitId(0))); + assert!( + (stn.pauli_frame_phase + Complex64::new(1.0, 0.0)).norm() < 1e-12, + "SZdg on X should flip phase to -1, got {:?}", + stn.pauli_frame_phase + ); + + // Apply SZdg again: -Y → +X → -(-X) = +X? No: frame is -Y, bits (1,1). + // SZdg on Y: Y → X, no sign flip. Phase stays -1. + stn.szdg(&[QubitId(0)]); + assert!(stn.frame_x_bit(QubitId(0))); + assert!(!stn.frame_z_bit(QubitId(0))); + assert!( + (stn.pauli_frame_phase + Complex64::new(1.0, 0.0)).norm() < 1e-12, + "SZdg on Y should NOT flip phase, got {:?}", + stn.pauli_frame_phase + ); + } + + #[test] + fn test_pauli_frame_sz_szdg_state_vector_exact() { + // End-to-end: inject X, apply SZ, flush, check state_vector matches + // eager application. Physical: SZ · X · |0⟩ = SZ · |1⟩ = i|1⟩. + // Frame path: state |0⟩, frame = Y (from SZ on X), flush via decomposition. + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn.inject_x_in_frame(QubitId(0)); + stn.sz(&[QubitId(0)]); + stn.flush_pauli_frame_to_state(); + let sv = stn.state_vector(); + // Expected: SZ·X|0⟩ = SZ|1⟩ = i|1⟩. + assert!(sv[0].norm() < 1e-10, "amp |0⟩: {:?}", sv[0]); + assert!( + (sv[1] - Complex64::new(0.0, 1.0)).norm() < 1e-10, + "amp |1⟩ should be +i, got {:?}", + sv[1] + ); + } + + #[test] + fn test_pauli_frame_cz_propagates() { + // CZ Heisenberg: X_a → X_a Z_b, X_b → Z_a X_b, Z unchanged. + let mut stn = StabMps::builder(2) + .seed(42) + .pauli_frame_tracking(true) + .build(); + // Inject X on q0 only. + stn.inject_x_in_frame(QubitId(0)); + stn.cz(&[(QubitId(0), QubitId(1))]); + // After CZ: X_0 → X_0 Z_1. So q0 still X, q1 gains Z. + assert!(stn.frame_x_bit(QubitId(0)), "CZ: X_0 stays"); + assert!(!stn.frame_x_bit(QubitId(1)), "CZ: q1 should not gain X"); + assert!( + stn.frame_z_bit(QubitId(1)), + "CZ: X_0 → X_0 Z_1, so q1 gains Z" + ); + assert!(!stn.frame_z_bit(QubitId(0)), "CZ: q0 should not gain Z"); + + // Now inject Z on q1 separately, apply CZ(0,1) again. + // Frame is now X_0, Z_1+Z_1=I on q1 (toggled off). So frame = X_0. + // After another CZ: X_0 → X_0 Z_1 again. + let mut stn2 = StabMps::builder(2) + .seed(42) + .pauli_frame_tracking(true) + .build(); + stn2.inject_z_in_frame(QubitId(0)); + stn2.cz(&[(QubitId(0), QubitId(1))]); + // Z_0 → Z_0 (Z commutes with CZ). No change. + assert!(!stn2.frame_x_bit(QubitId(0))); + assert!(stn2.frame_z_bit(QubitId(0)), "CZ: Z_0 unchanged"); + assert!(!stn2.frame_x_bit(QubitId(1))); + assert!( + !stn2.frame_z_bit(QubitId(1)), + "CZ: Z_0 doesn't propagate to q1" + ); + } + + #[test] + fn test_reset_qubit_with_frame_tracking_clears_and_measures_correctly() { + // With frame tracking enabled: inject X error, then reset_qubit. + // The reported outcome from reset should reflect the frame, + // and after reset the qubit should be |0⟩ with no frame bits. + let mut stn = StabMps::builder(1) + .seed(42) + .pauli_frame_tracking(true) + .build(); + // State = |0⟩, frame X = flip → physical is |1⟩. + stn.inject_x_in_frame(QubitId(0)); + let phys = stn.reset_qubit(QubitId(0)); + // Physical outcome should be true (qubit was in |1⟩ physically). + assert!(phys, "reset with X-frame on |0⟩ should report physical |1⟩"); + // Frame cleared. + assert!(!stn.frame_x_bit(QubitId(0)), "frame X must be cleared"); + assert!(!stn.frame_z_bit(QubitId(0)), "frame Z must be cleared"); + // Post-reset measurement should give 0. + let r = stn.mz(&[QubitId(0)]); + assert!(!r[0].outcome, "post-reset mz must be 0"); + + // Now test with Y frame (both X and Z bits). + let mut stn = StabMps::builder(1) + .seed(7) + .pauli_frame_tracking(true) + .build(); + stn.inject_y_in_frame(QubitId(0)); + let _phys = stn.reset_qubit(QubitId(0)); + assert!(!stn.frame_x_bit(QubitId(0))); + assert!(!stn.frame_z_bit(QubitId(0))); + let r = stn.mz(&[QubitId(0)]); + assert!(!r[0].outcome, "post-Y-reset mz must be 0"); + } + + #[test] + fn test_pauli_frame_faster_than_eager_for_many_noise_injects() { + // Timing sanity check: many Pauli injections into frame should + // be far faster than applying each to tableau. + use std::time::Instant; + let n = 32; + let num_injects = 10_000; + + let mut stn_frame = StabMps::builder(n) + .seed(1) + .pauli_frame_tracking(true) + .build(); + let start = Instant::now(); + for _ in 0..num_injects { + stn_frame.apply_depolarizing(QubitId(0), 1.0); + } + let t_frame = start.elapsed().as_secs_f64(); + + let mut stn_eager = StabMps::builder(n).seed(1).build(); + let start = Instant::now(); + for _ in 0..num_injects { + stn_eager.apply_depolarizing(QubitId(0), 1.0); + } + let t_eager = start.elapsed().as_secs_f64(); + + // Frame tracking should be at least 2x faster. + eprintln!( + "Pauli frame: {t_frame:.4}s; eager: {t_eager:.4}s → {:.1}x", + t_eager / t_frame + ); + assert!( + t_frame * 2.0 < t_eager, + "frame tracking should be >2x faster: frame={t_frame:.4}s eager={t_eager:.4}s" + ); + } + + #[test] + fn test_apply_bit_flip_zero_p_noop() { + let mut stn = StabMps::with_seed(2, 42); + // p = 0: no flip, deterministic. + for _ in 0..10 { + assert!(!stn.apply_bit_flip(QubitId(0), 0.0)); + } + let result = stn.mz(&[QubitId(0)])[0].outcome; + assert!(!result, "no-op noise: |0> should give 0"); + } + + #[test] + fn test_apply_bit_flip_p_one_always_flips() { + let mut stn = StabMps::with_seed(2, 42); + // p = 1: always flips. + for _ in 0..5 { + assert!(stn.apply_bit_flip(QubitId(0), 1.0)); + } + // 5 X's = X (odd count) → q0 = 1. + let result = stn.mz(&[QubitId(0)])[0].outcome; + assert!(result, "5x X(0) should leave q0 = 1"); + } + + #[test] + fn test_apply_depolarizing_p_zero_noop() { + let mut stn = StabMps::with_seed(2, 42); + for _ in 0..10 { + assert!(stn.apply_depolarizing(QubitId(0), 0.0).is_none()); + } + // Z-basis measurement of |0> is deterministic. + let results = stn.mz(&[QubitId(0)]); + assert!(results[0].is_deterministic && !results[0].outcome); + } + + #[test] + fn test_apply_depolarizing_distribution() { + // p = 0.9: error occurs ~90% of the time. Of those, X/Y/Z each ~30%. + // For 1000 trials, count outcomes. + let mut x_count = 0; + let mut y_count = 0; + let mut z_count = 0; + let mut none_count = 0; + let trials = 2000; + let mut stn = StabMps::with_seed(1, 42); + for _ in 0..trials { + stn.reset(); + match stn.apply_depolarizing(QubitId(0), 0.9) { + Some(PauliKind::X) => x_count += 1, + Some(PauliKind::Y) => y_count += 1, + Some(PauliKind::Z) => z_count += 1, + None => none_count += 1, + } + } + let frac_none = f64::from(none_count) / f64::from(trials); + let frac_each_pauli = f64::from(x_count + y_count + z_count) / f64::from(trials) / 3.0; + // None = 1 - p ≈ 0.10. Each Pauli ≈ p/3 ≈ 0.30. Tolerance ±5%. + assert!((frac_none - 0.10).abs() < 0.05, "P(no error) = {frac_none}"); + assert!( + (frac_each_pauli - 0.30).abs() < 0.05, + "P(each Pauli) = {frac_each_pauli}" + ); + } + + #[test] + fn test_apply_depolarizing_all_uses_each_qubit() { + // Apply depolarizing to all qubits with p = 1: every qubit gets some error. + let n = 4; + let mut stn = StabMps::with_seed(n, 99); + let qubits: Vec = (0..n).map(QubitId).collect(); + stn.apply_depolarizing_all(&qubits, 1.0); + // After error on each qubit (X, Y, or Z), the state is no longer |0^N>. + // For Z errors only, qubit stays in |0> (Z|0>=|0>). For X/Y, q flips to |1>. + // At least some qubits should have flipped (very high prob with 4 qubits). + let outcomes: Vec = stn.mz(&qubits).iter().map(|r| r.outcome).collect(); + let any_flipped = outcomes.iter().any(|&b| b); + assert!( + any_flipped, + "with p=1 on 4 qubits, at least one X/Y likely; got {outcomes:?}" + ); + } + + #[test] + fn test_pauli_expectation_n_30_zz_chain() { + // 30-qubit GHZ-like state, scales beyond the SV path's n<=14 limit. + // After H(0) + CX chain, ZZ on neighboring qubits = 1 (Bell-style). + let n = 30; + let mut stn = StabMps::new(n); + stn.h(&[QubitId(0)]); + for q in 0..n - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + // ⟨ZZ_{0,1}⟩ on GHZ = 1. + let zz01 = stn.pauli_expectation(&[(0, PauliKind::Z), (1, PauliKind::Z)]); + assert!((zz01 - 1.0).abs() < 1e-10, "n=30 ZZ_{{0,1}}: {zz01}"); + // ⟨ZZ_{15,29}⟩ on GHZ = 1 (long-range still correlated). + let zz_far = stn.pauli_expectation(&[(15, PauliKind::Z), (29, PauliKind::Z)]); + assert!((zz_far - 1.0).abs() < 1e-10, "n=30 ZZ_{{15,29}}: {zz_far}"); + } + + #[test] + fn test_pauli_expectation_zz_on_bell_state() { + // Bell state (|00⟩+|11⟩)/√2: ⟨ZZ⟩ = 1, ⟨XX⟩ = 1, ⟨YY⟩ = -1. + let mut stn = StabMps::new(2); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let zz = stn.pauli_expectation(&[(0, PauliKind::Z), (1, PauliKind::Z)]); + let xx = stn.pauli_expectation(&[(0, PauliKind::X), (1, PauliKind::X)]); + let yy = stn.pauli_expectation(&[(0, PauliKind::Y), (1, PauliKind::Y)]); + assert!((zz - 1.0).abs() < 1e-10, "ZZ on Bell = {zz}"); + assert!((xx - 1.0).abs() < 1e-10, "XX on Bell = {xx}"); + assert!((yy + 1.0).abs() < 1e-10, "YY on Bell = {yy}"); + } + + #[test] + fn test_overlap_with_stabilizer_matches_state_vector() { + // |s⟩ = |+⟩|0⟩|+⟩ via H on qubits 0 and 2. + // |Ψ⟩ = |+⟩|0⟩|+⟩ same → overlap = 1. + use pecos_simulators::CHForm; + let mut s = CHForm::new_with_seed(3, 42); + s.h(&[QubitId(0), QubitId(2)]); + + let mut stn = StabMps::with_seed(3, 99); + stn.h(&[QubitId(0), QubitId(2)]); + + let est = stn.overlap_with_stabilizer(&s, 200, None); + // Should be ~1 with some MC noise. For identical pure states, + // each sample contributes exactly 1, so accumulator = num_samples + // and average = 1 exactly (no variance for identical states). + assert!( + (est.norm_sqr() - 1.0).abs() < 0.01, + "identical states fidelity should be 1.0, got |est|² = {}", + est.norm_sqr() + ); + } + + #[test] + fn test_overlap_with_stabilizer_orthogonal_zero() { + // |s⟩ = |0⟩, |Ψ⟩ = |1⟩ → overlap = 0. + use pecos_simulators::CHForm; + let s = CHForm::new_with_seed(2, 7); + // |s⟩ = |00⟩. + + let mut stn = StabMps::with_seed(2, 99); + stn.x(&[QubitId(0)]); // |Ψ⟩ = |10⟩. + + // |s⟩ has support {|00⟩} only, so MC samples always give x=|00⟩. + // = <00|10> = 0. Estimator returns 0. + let est = stn.overlap_with_stabilizer(&s, 50, None); + assert!( + est.norm() < 1e-10, + "orthogonal states overlap should be 0, got {}", + est.norm() + ); + } + + #[test] + fn test_code_state_fidelity_three_qubit_bit_flip() { + // 3-qubit bit-flip code |0_L⟩ = |000⟩, |1_L⟩ = |111⟩. + // Stabilizers: Z_0·Z_1, Z_1·Z_2. + // Logical |0_L⟩ = |000⟩ is in the code → fidelity = 1. + let mut stn = StabMps::new(3); + // |000⟩ initially. + let stabs = vec![ + vec![(0, PauliKind::Z), (1, PauliKind::Z)], + vec![(1, PauliKind::Z), (2, PauliKind::Z)], + ]; + let f = stn.code_state_fidelity(&stabs); + assert!( + (f - 1.0).abs() < 1e-10, + "|000⟩ in bit-flip code, fidelity {f}" + ); + + // Apply X_0 → |100⟩, an error state, NOT in the code. + // Z_0·Z_1·|100⟩ = -|100⟩ (Z_0 gives -1), so it's a -1 eigenstate of stab → fidelity = 0. + stn.x(&[QubitId(0)]); + let f = stn.code_state_fidelity(&stabs); + assert!(f.abs() < 1e-10, "|100⟩ NOT in bit-flip code, fidelity {f}"); + + // Encode logical |1⟩: |111⟩ via X on all 3. + stn.reset(); + stn.x(&[QubitId(0), QubitId(1), QubitId(2)]); + let f = stn.code_state_fidelity(&stabs); + assert!( + (f - 1.0).abs() < 1e-10, + "|111⟩ in bit-flip code, fidelity {f}" + ); + } + + #[test] + fn test_code_state_fidelity_large_n_repetition_code() { + // 8-qubit repetition code (logical 0 = |00000000>): stabilizers are + // Z_iZ_{i+1} for i in 0..7. After preparing |0^N>, fidelity = 1. + let n = 8; + let stabs: Vec> = (0..n - 1) + .map(|i| vec![(i, PauliKind::Z), (i + 1, PauliKind::Z)]) + .collect(); + let stn = StabMps::new(n); + let f = stn.code_state_fidelity(&stabs); + assert!((f - 1.0).abs() < 1e-10, "n=8 |0..0> rep code fidelity {f}"); + } + + #[test] + fn test_code_state_fidelity_partial() { + // Superposition partly in / partly out of code. + // 50/50 mix of |000⟩ (in code) and |001⟩ (out of code) → fidelity = 0.5. + let mut stn = StabMps::new(3); + stn.h(&[QubitId(2)]); // |0⟩|0⟩(|0⟩+|1⟩)/√2 = (|000⟩ + |001⟩)/√2 + let stabs = vec![ + vec![(0, PauliKind::Z), (1, PauliKind::Z)], + vec![(1, PauliKind::Z), (2, PauliKind::Z)], + ]; + let f = stn.code_state_fidelity(&stabs); + assert!((f - 0.5).abs() < 1e-10, "half/half, fidelity {f}"); + } + + #[test] + fn test_merge_rz_commutes_through_z_and_s() { + // RZ(t, q); Z(q); RZ(t, q); S(q); RZ(t, q) should merge to one + // non-Clifford at flush time, because Z and S commute with RZ. + // Compare vs eager (no commute optimization, same physics). + let t = Angle64::from_radians(0.12345); + + let mut merged = StabMps::builder(2).seed(7).merge_rz(true).build(); + merged.h(&[QubitId(0)]); // make MPS non-trivial + merged.rz(t, &[QubitId(0)]); + merged.z(&[QubitId(0)]); + merged.rz(t, &[QubitId(0)]); + merged.sz(&[QubitId(0)]); + merged.rz(t, &[QubitId(0)]); + merged.flush(); + let sv_merged = merged.state_vector(); + let nc_merged = merged.stats.total_nonclifford; + + let mut eager = StabMps::with_seed(2, 7); + eager.h(&[QubitId(0)]); + eager.rz(t, &[QubitId(0)]); + eager.z(&[QubitId(0)]); + eager.rz(t, &[QubitId(0)]); + eager.sz(&[QubitId(0)]); + eager.rz(t, &[QubitId(0)]); + let sv_eager = eager.state_vector(); + + for (a, b) in sv_eager.iter().zip(sv_merged.iter()) { + assert!((a - b).norm() < 1e-10, "commute-merge state mismatch"); + } + // Merge saves non-Clifford applications: 3 → 1. + assert_eq!( + nc_merged, 1, + "merged should call non-Clifford path once; got {nc_merged}" + ); + assert_eq!( + eager.stats.total_nonclifford, 3, + "eager applies 3 non-Cliffords" + ); + } + + #[test] + fn test_merge_rz_x_flips_pending_sign() { + // X anticommutes with RZ. rz(t, q); x(q) should leave pending_rz + // = -t. Final state after x(q) + rz(t, q) + flush should equal + // applying x then rz(t) (since net = identity for +t−t... wait + // actually: X·RZ(θ) = RZ(-θ)·X. So rz(t); x; rz(t) = x; rz(-t); rz(t) = x. + // Verify equality with just x applied. + let t = Angle64::from_radians(0.7); + + let mut merged = StabMps::builder(2).seed(5).merge_rz(true).build(); + merged.h(&[QubitId(0)]); // non-trivial MPS + merged.rz(t, &[QubitId(0)]); + merged.x(&[QubitId(0)]); + merged.rz(t, &[QubitId(0)]); + merged.flush(); + let sv_merged = merged.state_vector(); + + // Reference: just H and X (since RZ effects cancel via X-flip). + let mut ref_sim = StabMps::with_seed(2, 5); + ref_sim.h(&[QubitId(0)]); + ref_sim.x(&[QubitId(0)]); + let sv_ref = ref_sim.state_vector(); + + for (a, b) in sv_ref.iter().zip(sv_merged.iter()) { + assert!( + (a - b).norm() < 1e-10, + "X-flip pending-rz sign mismatch: {a} vs {b}" + ); + } + } + + #[test] + fn test_merge_rz_two_t_gates_same_state_vector() { + // RZ(t) + RZ(t) merged should equal one RZ(2t) and equal eager + // applying RZ(t) twice. Compare state vectors. + let t = Angle64::QUARTER_TURN / 2u64; // T + + let mut eager = StabMps::with_seed(2, 7); + eager.h(&[QubitId(0)]); + eager.cx(&[(QubitId(0), QubitId(1))]); + eager.rz(t, &[QubitId(0)]); + eager.rz(t, &[QubitId(0)]); + let sv_eager = eager.state_vector(); + + let mut merged = StabMps::builder(2).seed(7).merge_rz(true).build(); + merged.h(&[QubitId(0)]); + merged.cx(&[(QubitId(0), QubitId(1))]); + merged.rz(t, &[QubitId(0)]); + merged.rz(t, &[QubitId(0)]); + merged.flush(); + let sv_merged = merged.state_vector(); + + for (a, b) in sv_eager.iter().zip(sv_merged.iter()) { + assert!((a - b).norm() < 1e-10, "merge_rz state mismatch"); + } + } + + #[test] + fn test_merge_rz_intervening_gate_on_other_qubit_still_merges() { + // rz(t, 0); h(1); rz(t, 0) — h(1) does not touch q0, so merge applies. + let t = Angle64::QUARTER_TURN / 2u64; + + let mut merged = StabMps::builder(2).seed(11).merge_rz(true).build(); + merged.h(&[QubitId(0)]); + merged.rz(t, &[QubitId(0)]); + merged.h(&[QubitId(1)]); + merged.rz(t, &[QubitId(0)]); + merged.flush(); + let sv_merged = merged.state_vector(); + + let mut eager = StabMps::with_seed(2, 11); + eager.h(&[QubitId(0)]); + eager.rz(t, &[QubitId(0)]); + eager.h(&[QubitId(1)]); + eager.rz(t, &[QubitId(0)]); + let sv_eager = eager.state_vector(); + + for (a, b) in sv_eager.iter().zip(sv_merged.iter()) { + assert!( + (a - b).norm() < 1e-10, + "intervening other-qubit gate merge mismatch" + ); + } + } + + #[test] + fn test_merge_rz_intervening_gate_on_same_qubit_flushes() { + // rz(t, 0); h(0); rz(t, 0) — h(0) flushes pending rz first. + let t = Angle64::QUARTER_TURN / 2u64; + + let mut merged = StabMps::builder(2).seed(13).merge_rz(true).build(); + merged.h(&[QubitId(0)]); + merged.rz(t, &[QubitId(0)]); + merged.h(&[QubitId(0)]); + merged.rz(t, &[QubitId(0)]); + merged.flush(); + let sv_merged = merged.state_vector(); + + let mut eager = StabMps::with_seed(2, 13); + eager.h(&[QubitId(0)]); + eager.rz(t, &[QubitId(0)]); + eager.h(&[QubitId(0)]); + eager.rz(t, &[QubitId(0)]); + let sv_eager = eager.state_vector(); + + for (a, b) in sv_eager.iter().zip(sv_merged.iter()) { + assert!( + (a - b).norm() < 1e-10, + "intervening same-qubit gate flush mismatch" + ); + } + } + + #[test] + fn test_merge_rz_to_clifford_angle_uses_fast_path() { + // Two T gates merge to S (RZ(π/2)) — Clifford angle, taken via tableau. + let t = Angle64::QUARTER_TURN / 2u64; + + let mut merged = StabMps::builder(2).seed(17).merge_rz(true).build(); + merged.h(&[QubitId(0)]); + merged.rz(t, &[QubitId(0)]); + merged.rz(t, &[QubitId(0)]); + merged.flush(); + // After merge, no pending RZ, no MPS non-Clifford gate count incremented. + assert_eq!( + merged.stats.total_nonclifford, 0, + "two T merging to S should hit Clifford fast path, not non-Clifford" + ); + + let mut eager = StabMps::with_seed(2, 17); + eager.h(&[QubitId(0)]); + eager.rz(t, &[QubitId(0)]); + eager.rz(t, &[QubitId(0)]); + // Eager applies T twice as non-Clifford. + assert_eq!(eager.stats.total_nonclifford, 2); + + // Both produce equivalent state. + let sv_merged = merged.state_vector(); + let sv_eager = eager.state_vector(); + for (a, b) in sv_eager.iter().zip(sv_merged.iter()) { + assert!((a - b).norm() < 1e-10, "T+T = S state mismatch"); + } + } + + #[test] + fn test_builder_for_qec_preset() { + // Smoke test: the preset should build a working StabMps and handle + // a Clifford + T + measurement sequence. + let mut stn = StabMps::builder(4).seed(99).for_qec().build(); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + let r0 = stn.mz(&[QubitId(0)])[0].outcome; + let r1 = stn.mz(&[QubitId(1)])[0].outcome; + assert_eq!(r0, r1, "for_qec preset: Bell+T correlation"); + } + + #[test] + fn test_builder_lazy_measure_bell_correlation() { + // Lazy-measure path must give same Bell-state correlation as eager. + for trial in 0..20 { + let mut stn = StabMps::builder(2) + .seed(3000 + trial) + .lazy_measure(true) + .build(); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let r0 = stn.mz(&[QubitId(0)])[0].outcome; + let r1 = stn.mz(&[QubitId(1)])[0].outcome; + assert_eq!(r0, r1, "lazy Bell+T trial {trial}"); + } + } + + #[test] + fn test_builder_lazy_measure_rx_statistics() { + // Lazy path must give correct RX(pi/3) measurement statistics. + let theta = Angle64::from_radians(std::f64::consts::FRAC_PI_3); + let num_trials: u32 = 400; + let mut count_0 = 0; + for trial in 0..num_trials { + let mut stn = StabMps::builder(1) + .seed(u64::from(4000 + trial)) + .lazy_measure(true) + .build(); + stn.rx(theta, &[QubitId(0)]); + if !stn.mz(&[QubitId(0)])[0].outcome { + count_0 += 1; + } + } + let p0 = f64::from(count_0) / f64::from(num_trials); + assert!((p0 - 0.75).abs() < 0.08, "lazy RX(pi/3) p(0) = {p0:.3}"); + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/compile.rs b/exp/pecos-stab-tn/src/stab_mps/compile.rs new file mode 100644 index 000000000..81602231e --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps/compile.rs @@ -0,0 +1,435 @@ +// 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. + +//! Compile-only pre-analysis for STN tractability. +//! +//! Runs through a circuit's Clifford tableau and non-Clifford gate decomposition +//! WITHOUT building an MPS. Reports the GF(2) nullity of the accumulated flip +//! patterns, which per Liu-Clark 2412.17209 bounds the CAMPS bond dimension: +//! `bond_dim` ≤ 2^nullity. +//! +//! Useful for deciding whether a circuit is tractable for full simulation +//! before committing resources. Complexity is O(t·n²) for t non-Cliffords +//! and n qubits (Clifford tableau ops dominate). + +use super::ofd::{Gf2FlipMatrix, RowMetadata}; +use super::pauli_decomp::{ZDecomposition, decompose_z}; +use pecos_core::{Angle64, QubitId}; +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. +pub struct StabMpsCompile { + num_qubits: usize, + tableau: SparseStabY, + gf2_matrix: Gf2FlipMatrix, + /// Per-site "free qubit" flag: true if this qubit has never been the + /// disent `rot_site`. Mirrors our `disent_flags` for OFD applicability. + free_qubit: Vec, + /// Number of non-Clifford gates that OFD would absorb (consume a free qubit). + absorbed: u64, + /// Number of non-Clifford gates that would grow bond dim. + grown: u64, + /// Number of non-Cliffords that hit the Stabilizer branch (no MPS site op). + stabilizer: u64, +} + +impl StabMpsCompile { + #[must_use] + pub fn new(num_qubits: usize) -> Self { + Self { + num_qubits, + tableau: SparseStabY::new(num_qubits).with_destab_sign_tracking(), + gf2_matrix: Gf2FlipMatrix::new(num_qubits), + free_qubit: vec![true; num_qubits], + absorbed: 0, + grown: 0, + stabilizer: 0, + } + } + + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Number of non-Clifford gates that consumed a free qubit (disentangled). + #[must_use] + pub fn absorbed(&self) -> u64 { + self.absorbed + } + + /// Number of non-Clifford gates that would grow bond dim. + #[must_use] + pub fn grown(&self) -> u64 { + self.grown + } + + /// Number of non-Cliffords that hit the Stabilizer branch. + #[must_use] + pub fn stabilizer(&self) -> u64 { + self.stabilizer + } + + /// Total non-Clifford gates processed. + #[must_use] + pub fn total_nonclifford(&self) -> u64 { + self.absorbed + self.grown + self.stabilizer + } + + /// GF(2) nullity = number of flip patterns NOT in the rank. + /// Bond dim bound from OFD is 2^nullity. + #[must_use] + pub fn nullity(&self) -> usize { + let t = self.gf2_matrix.num_gates(); + t.saturating_sub(self.gf2_matrix.gf2_rank()) + } + + /// Rank of accumulated GF(2) matrix. + #[must_use] + pub fn rank(&self) -> usize { + self.gf2_matrix.gf2_rank() + } + + /// Theoretical bond dim upper bound: 2^nullity. + #[must_use] + pub fn bond_dim_bound(&self) -> usize { + let n = self.nullity(); + if n == 0 { + 1 + } else { + 1usize + .checked_shl(u32::try_from(n).unwrap_or(u32::MAX)) + .unwrap_or(usize::MAX) + } + } + + /// Access the accumulated GF(2) matrix for inspection. + #[must_use] + pub fn gf2_matrix(&self) -> &Gf2FlipMatrix { + &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. + /// + /// Use case: after running a circuit through `StabMpsCompile` (which does + /// O(t·n²) pre-analysis without any MPS overhead), dispatch to the + /// best simulator for actual simulation. + #[must_use] + pub fn recommend(&self) -> SimulatorRecommendation { + let n = self.num_qubits(); + let t = self.total_nonclifford(); + let nullity = self.nullity(); + + // Pure Clifford: CHForm is exact and fastest. + if t == 0 { + return SimulatorRecommendation { + kind: SimulatorKind::CHForm, + reason: "pure Clifford circuit — CHForm is exact and O(n²) memory".to_string(), + }; + } + // Small n: dense state vector is straightforward and fastest. + if n <= 14 { + return SimulatorRecommendation { + kind: SimulatorKind::StateVector, + reason: format!("small system (n={n} ≤ 14) — dense state vector fits in memory"), + }; + } + // Low-rank: STN bond dim bound is 2^nullity; stays cheap at small nullity. + if nullity <= 6 { + return SimulatorRecommendation { + kind: SimulatorKind::StabMps, + reason: format!( + "low OFD nullity ({nullity}) — STN bond dim bound 2^{nullity} = {}", + 1usize << nullity + ), + }; + } + // Moderate T-count: StabVec stabilizer-sum with pruning. + if t <= 40 { + return SimulatorRecommendation { + kind: SimulatorKind::StabVec, + reason: format!("moderate T-count (t={t} ≤ 40) — StabVec with MC pruning"), + }; + } + // Fallback: STN with adaptive bond-dim cap. + SimulatorRecommendation { + kind: SimulatorKind::StabMps, + reason: format!( + "large nullity (nullity={nullity}) and high T-count (t={t}) — \ + STN with auto_grow_bond_dim recommended" + ), + } + } + + /// 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) { + let decomp = decompose_z(self.tableau.stabs(), self.tableau.destabs(), q); + match decomp { + ZDecomposition::Stabilizer { .. } => { + self.stabilizer += 1; + } + ZDecomposition::DestabilizerFlip { + ref flip_sites, + ref sign_sites, + .. + } => { + // Build list of affected sites (union of flip + sign). + let mut sites: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for s in flip_sites { + sites.insert(*s); + } + for s in sign_sites { + sites.insert(*s); + } + let affected: Vec = sites.into_iter().collect(); + + if affected.len() == 1 { + // Single-site path: always absorbable. + let site = affected[0]; + self.absorbed += 1; + let flip_vec: Vec = flip_sites.clone(); + self.gf2_matrix + .add_row_with_meta(&flip_vec, RowMetadata { rot_site: site }); + self.free_qubit[site] = false; + } else { + // Multi-site: OFD condition is "some site i has free_qubit[i] + // AND site i has X/Y pauli (i.e. i ∈ flip_sites)". + let mut rot = None; + for &s in &affected { + if self.free_qubit[s] && flip_sites.contains(&s) { + rot = Some(s); + break; + } + } + if let Some(site) = rot { + self.absorbed += 1; + let flip_vec: Vec = flip_sites.clone(); + self.gf2_matrix + .add_row_with_meta(&flip_vec, RowMetadata { rot_site: site }); + self.free_qubit[site] = false; + } else { + self.grown += 1; + } + } + } + } + } +} + +/// Classification of PECOS simulators for dispatch purposes. +/// See `StabMpsCompile::recommend`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SimulatorKind { + /// Dense state vector (e.g., `pecos_simulators::StateVec`). Exact; + /// O(2^n) memory. Best for small n. + StateVector, + /// CH-form stabilizer simulator + /// (`pecos_simulators::CHForm`). Exact for pure Clifford; O(n²) memory. + CHForm, + /// Clifford+Rz stabilizer-sum simulator + /// (`pecos_simulators::StabVec`). Stabilizer-rank method with + /// MC pruning. Best for moderate T-count. + StabVec, + /// Stabilizer Tensor Network + /// (`pecos_stab_tn::stab_mps::StabMps`). Hybrid tableau+MPS. Best for + /// low-rank (low OFD nullity) circuits and T-heavy circuits with + /// adaptive bond-dim. + StabMps, +} + +/// Simulator recommendation with a human-readable reason string. +/// Returned by `StabMpsCompile::recommend`. +#[derive(Clone, Debug)] +pub struct SimulatorRecommendation { + pub kind: SimulatorKind, + pub reason: String, +} + +impl QuantumSimulator for StabMpsCompile { + fn reset(&mut self) -> &mut Self { + self.tableau = SparseStabY::new(self.num_qubits).with_destab_sign_tracking(); + self.gf2_matrix.reset(); + self.free_qubit = vec![true; self.num_qubits]; + self.absorbed = 0; + self.grown = 0; + self.stabilizer = 0; + self + } + + fn num_qubits(&self) -> usize { + self.num_qubits + } +} + +impl CliffordGateable for StabMpsCompile { + fn sz(&mut self, qubits: &[QubitId]) -> &mut Self { + self.tableau.sz(qubits); + self + } + fn h(&mut self, qubits: &[QubitId]) -> &mut Self { + self.tableau.h(qubits); + self + } + fn cx(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + self.tableau.cx(pairs); + self + } + fn cz(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + self.tableau.cz(pairs); + self + } + fn mz(&mut self, qubits: &[QubitId]) -> Vec { + // Compile mode: delegate to tableau (no MPS needed for measurement). + self.tableau.mz(qubits) + } +} + +impl ArbitraryRotationGateable for StabMpsCompile { + fn rx(&mut self, theta: Angle64, qubits: &[QubitId]) -> &mut Self { + self.h(qubits); + self.rz(theta, qubits); + self.h(qubits); + self + } + + fn rz(&mut self, theta: Angle64, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + // Handle Clifford angles as Cliffords. + if theta == Angle64::ZERO { + continue; + } + if theta == Angle64::HALF_TURN { + self.tableau.z(&[q]); + continue; + } + if theta == Angle64::QUARTER_TURN { + self.tableau.sz(&[q]); + continue; + } + if theta == Angle64::THREE_QUARTERS_TURN { + self.tableau.szdg(&[q]); + continue; + } + // Non-Clifford: process decomposition. + self.process_rz(q.index()); + } + self + } + + fn rzz(&mut self, theta: Angle64, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q0, q1) in pairs { + self.cx(&[(q0, q1)]); + self.rz(theta, &[q1]); + self.cx(&[(q0, q1)]); + } + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compile_sizes() { + let s = StabMpsCompile::new(5); + assert_eq!(s.num_qubits(), 5); + assert_eq!(s.nullity(), 0); + assert_eq!(s.bond_dim_bound(), 1); + } + + #[test] + fn test_compile_all_independent_t_gates() { + let mut s = StabMpsCompile::new(5); + s.h(&[QubitId(0), QubitId(1), QubitId(2), QubitId(3), QubitId(4)]); + for i in 0..5 { + s.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(i)]); + } + assert_eq!(s.absorbed(), 5); + assert_eq!(s.grown(), 0); + assert_eq!(s.nullity(), 0); + assert_eq!(s.bond_dim_bound(), 1); + } + + #[test] + fn test_compile_vs_stn_nullity_matches() { + // Verify that StabMpsCompile and full StabMps agree on nullity for same circuit. + use crate::stab_mps::StabMps; + let q = |i: usize| QubitId(i); + let mut comp = StabMpsCompile::new(4); + comp.h(&[q(0), q(1), q(2), q(3)]); + comp.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); + comp.cx(&[(q(0), q(1))]); + comp.rz(Angle64::QUARTER_TURN / 2u64, &[q(1)]); + comp.rz(Angle64::QUARTER_TURN / 2u64, &[q(2)]); + + let mut stn = StabMps::with_seed(4, 1); + stn.h(&[q(0), q(1), q(2), q(3)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(0)]); + stn.cx(&[(q(0), q(1))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(1)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q(2)]); + + assert_eq!( + comp.nullity(), + stn.ofd_nullity(), + "StabMpsCompile and StabMps should report same OFD nullity" + ); + } + + #[test] + fn test_recommend_pure_clifford_prefers_chform() { + let mut comp = StabMpsCompile::new(4); + comp.h(&[QubitId(0), QubitId(1)]); + comp.cx(&[(QubitId(0), QubitId(1))]); + // t = 0. + let r = comp.recommend(); + assert_eq!(r.kind, SimulatorKind::CHForm); + } + + #[test] + fn test_recommend_small_n_prefers_state_vector() { + let mut comp = StabMpsCompile::new(8); + comp.h(&[QubitId(0)]); + comp.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); // one T + let r = comp.recommend(); + assert_eq!(r.kind, SimulatorKind::StateVector); + } + + #[test] + fn test_recommend_low_nullity_prefers_stn() { + let n = 20; + let mut comp = StabMpsCompile::new(n); + // Simple Clifford + independent T gates (nullity = 0 because + // same flip pattern on unique qubits each rank-1). + // H on qubit 0, T on qubit 0 gives one flip pattern of weight 1. + // Multiple independent Ts → independent flip patterns → all rank, + // zero nullity. + comp.h(&[QubitId(0)]); + comp.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let r = comp.recommend(); + assert_eq!( + r.kind, + SimulatorKind::StabMps, + "nullity={} should recommend STN for n={n} (reason: {})", + comp.nullity(), + r.reason + ); + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/disentangle.rs b/exp/pecos-stab-tn/src/stab_mps/disentangle.rs new file mode 100644 index 000000000..86f2acaa8 --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps/disentangle.rs @@ -0,0 +1,335 @@ +// 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. + +//! Heuristic Clifford disentangling for the STN simulator. +//! +//! After non-Clifford gates increase the MPS bond dimension, we can try to +//! reduce it by applying two-qubit Clifford gates that absorb entanglement +//! into the stabilizer tableau. +//! +//! The algorithm: for each internal bond, try the 20 inequivalent entangling +//! two-qubit Cliffords. If one reduces the entanglement entropy at the bond, +//! apply it to the MPS and store the inverse for `state_vector` reconstruction. +//! +//! References: +//! - Masot-Llima, Garcia-Saez. arXiv:2403.08724 (Clifford disentangling). +//! - Masot-Llima, Sierant, Stornati, Garcia-Saez. arXiv:2602.15942 +//! (limits of Clifford disentangling). + +use crate::mps::Mps; +use nalgebra::DMatrix; +use num_complex::Complex64; + +/// A two-qubit Clifford gate for disentangling. +struct DisentanglerGate { + /// 4x4 unitary matrix for the MPS + matrix: DMatrix, + /// 4x4 inverse matrix (for `state_vector` correction) + inverse_matrix: DMatrix, +} + +/// Build a 2x2 single-qubit Clifford gate. +fn single_qubit_clifford(idx: usize) -> DMatrix { + let one = Complex64::new(1.0, 0.0); + let zero = Complex64::new(0.0, 0.0); + let inv2 = Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0); + let i_val = Complex64::new(0.0, 1.0); + + let id = DMatrix::from_row_slice(2, 2, &[one, zero, zero, one]); + let h = DMatrix::from_row_slice(2, 2, &[inv2, inv2, inv2, -inv2]); + let s = DMatrix::from_row_slice(2, 2, &[one, zero, zero, i_val]); + + match idx { + 1 => h.clone(), // H + 2 => s.clone(), // S + 3 => &s * &h, // SH + 4 => &h * &s, // HS + 5 => &h * &s * &h, // HSH + _ => id, // I (idx == 0 or out of range) + } +} + +/// Build the set of candidate disentangling gates. +/// +/// Generates entangling 2-qubit Cliffords by dressing CX with single-qubit +/// Cliffords on each qubit: (A⊗B) * CX * (C⊗D) for various A,B,C,D. +fn build_disentangler_set() -> Vec { + let one = Complex64::new(1.0, 0.0); + let zero = Complex64::new(0.0, 0.0); + + // Base CX gate + let cx = DMatrix::from_row_slice( + 4, + 4, + &[ + one, zero, zero, zero, zero, one, zero, zero, zero, zero, zero, one, zero, zero, one, + zero, + ], + ); + + // Generate dressed CX gates: (A⊗B) * CX for single-qubit Cliffords A, B. + // This covers the 20 inequivalent entangling 2-qubit Cliffords. + // We use 6 single-qubit Cliffords: {I, H, S, SH, HS, HSH} + let mut gates = Vec::new(); + let mut seen = Vec::new(); + + // Dressings: (left_q0, left_q1) applied AFTER CX + let dressings: &[(usize, usize)] = &[ + (0, 0), // I⊗I * CX = CX + (0, 1), // I⊗H * CX + (1, 0), // H⊗I * CX + (1, 1), // H⊗H * CX + (0, 2), // I⊗S * CX + (2, 0), // S⊗I * CX + (3, 0), // SH⊗I * CX + (0, 3), // I⊗SH * CX + (4, 0), // HS⊗I * CX + (0, 4), // I⊗HS * CX + (1, 2), // H⊗S * CX + (2, 1), // S⊗H * CX + (5, 0), // HSH⊗I * CX + (0, 5), // I⊗HSH * CX + (3, 1), // SH⊗H * CX + (1, 3), // H⊗SH * CX + (4, 1), // HS⊗H * CX + (1, 4), // H⊗HS * CX + (2, 2), // S⊗S * CX + (3, 3), // SH⊗SH * CX + ]; + + for &(a_idx, b_idx) in dressings { + let a = single_qubit_clifford(a_idx); + let b = single_qubit_clifford(b_idx); + + // Build A⊗B + let ab = a.kronecker(&b); + let matrix = &ab * &cx; + + // Check for duplicates (up to global phase) + let is_dup = seen.iter().any(|existing: &DMatrix| { + // Two unitaries are equivalent if one is a scalar multiple of the other + if let Some(&first_nonzero) = matrix.iter().zip(existing.iter()).find_map(|(a, b)| { + if a.norm() > 0.1 && b.norm() > 0.1 { + Some(a) + } else { + None + } + }) { + let first_existing = existing + .iter() + .zip(matrix.iter()) + .find_map(|(b, a)| if a.norm() > 0.1 { Some(b) } else { None }); + if let Some(&fe) = first_existing { + let ratio = first_nonzero / fe; + // Check all elements have the same ratio + matrix + .iter() + .zip(existing.iter()) + .all(|(a, b)| (a - b * ratio).norm() < 1e-10) + } else { + false + } + } else { + false + } + }); + + if !is_dup { + seen.push(matrix.clone()); + gates.push(DisentanglerGate { + inverse_matrix: matrix.adjoint(), + matrix, + }); + } + } + + gates +} + +/// Compute the entanglement entropy at a given bond of the MPS. +/// +/// Uses singular values from the bond matrix after left-canonicalization up to that bond. +/// For efficiency, just compute the Frobenius norm ratio as a proxy. +fn bond_entropy(mps: &Mps, bond: usize) -> f64 { + if bond == 0 || bond >= mps.num_sites() { + return 0.0; + } + + let d = mps.phys_dim(); + let chi_l = mps.bond_dim(bond); + let chi_r = mps.bond_dim(bond + 1); + let tensor = &mps.tensors()[bond]; + + // Reshape to (chi_l * d, chi_r) and compute SVD + let matrix = crate::mps::tensor::reshape_left_group(tensor, chi_l, d, chi_r); + let svd = nalgebra::SVD::new(matrix, false, false); + + // Compute von Neumann entropy from singular values + let svals = &svd.singular_values; + let norm_sq: f64 = svals.iter().map(|s| s * s).sum(); + if norm_sq < 1e-30 { + return 0.0; + } + + let mut entropy = 0.0; + for &s in svals.iter() { + let p = (s * s) / norm_sq; + if p > 1e-15 { + entropy -= p * p.ln(); + } + } + entropy +} + +/// Run one sweep of heuristic disentangling on the MPS. +/// +/// For each internal bond, tries each candidate Clifford gate and keeps +/// the one that reduces the max bond dimension of the MPS. Uses max bond +/// dim as the criterion (not local entropy) because a gate that reduces +/// entropy at one bond can increase it at neighboring bonds. +/// +/// Records inverse operations in the gate log so `state_vector()` stays correct. +/// +/// Returns the number of gates applied (0 means no improvement found). +pub(crate) fn disentangle_sweep( + mps: &mut Mps, + corrections: &mut Vec, +) -> usize { + let n = mps.num_sites(); + if n < 2 { + return 0; + } + + let gates = build_disentangler_set(); + let mut num_applied = 0; + + // Forward sweep: bonds 0..n-2 (between sites q and q+1) + for q in 0..n - 1 { + let bond = q + 1; + let current_entropy = bond_entropy(mps, bond); + + if current_entropy < 1e-6 { + continue; // Already effectively disentangled at this bond + } + + let current_max_bond = mps.max_bond_dim(); + let mut best_entropy = current_entropy; + let mut best_gate_idx: Option = None; + + for (gate_idx, gate) in gates.iter().enumerate() { + let mut trial_mps = mps.clone(); + if trial_mps.apply_two_site_gate(q, &gate.matrix).is_ok() { + let trial_max_bond = trial_mps.max_bond_dim(); + let trial_entropy = bond_entropy(&trial_mps, bond); + // Accept gate only if it doesn't increase max bond dim + // AND reduces local entropy + if trial_max_bond <= current_max_bond && trial_entropy < best_entropy - 1e-2 { + best_entropy = trial_entropy; + best_gate_idx = Some(gate_idx); + } + } + } + + if let Some(idx) = best_gate_idx { + mps.apply_two_site_gate(q, &gates[idx].matrix) + .expect("gate should succeed"); + corrections.push(super::MpsIndexGate { + site: q, + inverse_matrix: gates[idx].inverse_matrix.clone(), + }); + num_applied += 1; + } + } + + // Backward sweep: bonds n-2..0 + for q in (0..n - 1).rev() { + let bond = q + 1; + let current_entropy = bond_entropy(mps, bond); + + if current_entropy < 1e-6 { + continue; + } + + let current_max_bond = mps.max_bond_dim(); + let mut best_entropy = current_entropy; + let mut best_gate_idx: Option = None; + + for (gate_idx, gate) in gates.iter().enumerate() { + let mut trial_mps = mps.clone(); + if trial_mps.apply_two_site_gate(q, &gate.matrix).is_ok() { + let trial_max_bond = trial_mps.max_bond_dim(); + let trial_entropy = bond_entropy(&trial_mps, bond); + if trial_max_bond <= current_max_bond && trial_entropy < best_entropy - 1e-2 { + best_entropy = trial_entropy; + best_gate_idx = Some(gate_idx); + } + } + } + + if let Some(idx) = best_gate_idx { + mps.apply_two_site_gate(q, &gates[idx].matrix) + .expect("gate should succeed"); + corrections.push(super::MpsIndexGate { + site: q, + inverse_matrix: gates[idx].inverse_matrix.clone(), + }); + num_applied += 1; + } + } + + num_applied +} + +/// Run multiple sweeps of disentangling until convergence or `max_sweeps` reached. +pub(crate) fn disentangle( + mps: &mut Mps, + corrections: &mut Vec, + max_sweeps: usize, +) -> usize { + let mut total_applied = 0; + for _ in 0..max_sweeps { + let applied = disentangle_sweep(mps, corrections); + total_applied += applied; + if applied == 0 { + break; + } + } + total_applied +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_disentangler_gate_count() { + let gates = build_disentangler_set(); + eprintln!("Disentangler gate set: {} unique gates", gates.len()); + assert!( + gates.len() >= 15, + "should have at least 15 unique gates, got {}", + gates.len() + ); + + // Verify all gates are unitary + let dim = 4; + let id = DMatrix::::identity(dim, dim); + for (i, gate) in gates.iter().enumerate() { + let product = &gate.matrix * &gate.inverse_matrix; + let diff = (&product - &id).norm(); + assert!( + diff < 1e-10, + "gate {i} is not unitary: ||U*Udg - I|| = {diff}" + ); + } + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/mast.rs b/exp/pecos-stab-tn/src/stab_mps/mast.rs new file mode 100644 index 000000000..32536d84e --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps/mast.rs @@ -0,0 +1,1066 @@ +// 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. + +//! MAST: Magic state injection Augmented Stabilizer Tensor network. +//! +//! Instead of applying non-Clifford gates directly (which increases MPS bond +//! dimension), each non-Clifford gate is replaced by: +//! +//! 1. Prepare a magic state |+_T> on a fresh ancilla +//! 2. CNOT between ancilla and target (Clifford -- only touches tableau) +//! 3. Defer the ancilla measurement until the end +//! +//! At the end of the circuit, all deferred measurements are performed. +//! For random circuits with t <= N, most projections are non-entangling, +//! keeping the MPS bond dimension bounded by ~3 on average. +//! +//! # References +//! +//! Nakhl et al., "Stabilizer Tensor Networks with Magic State Injection," +//! PRL 134, 190602 (2025). arXiv:2411.12482. + +use crate::mps::{Mps, MpsConfig}; +use num_complex::Complex64; +use pecos_core::{Angle64, QubitId}; +use pecos_random::PecosRng; +use pecos_simulators::{ + ArbitraryRotationGateable, CliffordGateable, MeasurementResult, QuantumSimulator, SparseStabY, +}; + +use super::non_clifford; + +/// A deferred ancilla measurement. +struct DeferredMeasurement { + /// The ancilla qubit index (in the expanded system). + ancilla: usize, + /// The target data qubit that needs correction if ancilla outcome = 1. + target: usize, + /// The correction angle: RZ(2*theta) applied to target if ancilla = 1. + /// For T gates: correction = RZ(pi/2) = S (Clifford). + correction_angle: Angle64, +} + +/// MAST simulator: Magic state injection Augmented STN. +/// +/// 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. +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, + /// Total qubits = data + ancillas. + total_qubits: usize, + /// The underlying stabilizer tableau for all qubits. + tableau: SparseStabY, + /// The MPS over all qubits. + mps: Mps, + config: MpsConfig, + /// Next available ancilla index. + next_ancilla: usize, + /// Deferred measurements to perform at the end. + deferred: Vec, + global_phase: Complex64, + disent_flags: Vec>, + gf2_matrix: super::ofd::Gf2FlipMatrix, + rng: PecosRng, + pub stats: super::StabMpsStats, + /// Deferred virtual-frame Clifford V for lazy measurement + /// (see `super::measure::DeferredOp`). + deferred_ops: Vec, + /// When `true`, measurement uses the lazy virtual-frame path: + /// accumulates `pre_reduce` CNOTs and post-projection basis-rotation + /// Cliffords into a deferred queue rather than applying them eagerly + /// to the MPS. Set via `with_lazy_measure(true)`. + lazy_measure: bool, + /// Pending non-Clifford RZ angle per qubit when `merge_rz` is on. + /// Flushed when any other gate touches the qubit (except RZ-same-qubit + /// merges, Z/S/Sdg/CZ commutes). Mirror of `StabMps`'s field. + pending_rz: Vec>, + /// When `true`, consecutive `rz(θ, q)` on same qubit merge before + /// invoking magic-state injection. Big win for ion-trap RZ noise. + merge_rz: bool, +} + +impl Mast { + /// Create a MAST simulator with `num_qubits` data qubits and room for + /// `max_non_clifford` non-Clifford gates. + #[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, + total_qubits: total, + tableau: SparseStabY::new(total).with_destab_sign_tracking(), + mps: Mps::new(total, MpsConfig::default()), + config: MpsConfig::default(), + next_ancilla: num_qubits, + deferred: Vec::new(), + global_phase: Complex64::new(1.0, 0.0), + disent_flags: vec![Some(super::SiteEigenstate::Z(false)); total], + gf2_matrix: super::ofd::Gf2FlipMatrix::new(total), + rng: PecosRng::seed_from_u64(0), + stats: super::StabMpsStats::default(), + deferred_ops: Vec::new(), + lazy_measure: false, + pending_rz: vec![None; total], + merge_rz: false, + } + } + + /// Create with a specific seed. + #[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, + total_qubits: total, + tableau: SparseStabY::with_seed(total, seed).with_destab_sign_tracking(), + mps: Mps::new(total, MpsConfig::default()), + config: MpsConfig::default(), + next_ancilla: num_qubits, + deferred: Vec::new(), + global_phase: Complex64::new(1.0, 0.0), + disent_flags: vec![Some(super::SiteEigenstate::Z(false)); total], + gf2_matrix: super::ofd::Gf2FlipMatrix::new(total), + rng: PecosRng::seed_from_u64(seed), + stats: super::StabMpsStats::default(), + deferred_ops: Vec::new(), + lazy_measure: false, + pending_rz: vec![None; total], + merge_rz: false, + } + } + + /// Enable lazy virtual-frame measurement. Fluent-style setter; returns + /// `self` for chaining after `new`/`with_seed`. See + /// `StabMpsBuilder::lazy_measure` for semantics. + #[must_use] + pub fn with_lazy_measure(mut self, lazy: bool) -> Self { + self.lazy_measure = lazy; + self + } + + /// Enable RZ batching on same qubit. See `StabMpsBuilder::merge_rz` for + /// semantics. Fluent-style setter on MAST. + #[must_use] + pub fn with_merge_rz(mut self, merge: bool) -> Self { + self.merge_rz = merge; + 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) { + if !self.merge_rz { + return; + } + if let Some(theta) = self.pending_rz[q].take() { + self.rz_apply_direct(theta, q); + } + } + + /// Apply `rz(theta)` on qubit `q` directly (without the merge buffer). + /// Handles Clifford-angle shortcuts and MAST magic-state injection. + fn rz_apply_direct(&mut self, theta: Angle64, q: usize) { + if theta == Angle64::ZERO { + return; + } + let qid = QubitId(q); + if theta == Angle64::HALF_TURN { + self.global_phase *= Complex64::new(0.0, -1.0); + self.tableau.z(&[qid]); + return; + } + if theta == Angle64::QUARTER_TURN { + let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2; + self.global_phase *= Complex64::new(inv_sqrt2, -inv_sqrt2); + self.tableau.sz(&[qid]); + return; + } + if theta == 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(&[qid]); + return; + } + self.inject_magic_state(theta, q); + } + + /// Flush all pending merged RZ. Public; useful before read operations + /// when `merge_rz` is on. + pub fn flush(&mut self) { + if !self.merge_rz { + return; + } + for q in 0..self.total_qubits { + self.flush_pending_rz(q); + } + } + + #[must_use] + pub fn num_data_qubits(&self) -> usize { + self.num_data_qubits + } + + #[must_use] + pub fn num_ancillas_used(&self) -> usize { + self.next_ancilla - self.num_data_qubits + } + + #[must_use] + pub fn max_bond_dim(&self) -> usize { + self.mps.max_bond_dim() + } + + #[must_use] + pub fn mps(&self) -> &Mps { + &self.mps + } + + /// Inject a magic state for RZ(theta) on the target qubit. + /// + /// Magic state teleportation protocol: + /// 1. Prepare ancilla in |+>: H on ancilla + /// 2. Apply RZ(theta) on ancilla (local, single-site MPS gate) + /// 3. CNOT(target, ancilla) -- **target controls, ancilla is CX target** + /// 4. Defer measurement of ancilla + /// + /// When the ancilla is later measured: + /// - Outcome 0: data qubit has RZ(theta) applied. Done. + /// - Outcome 1: data qubit has RZ(-theta). Correction: RZ(2*theta) on data. + /// For T gate (theta=pi/4): correction = S = RZ(pi/2), which is Clifford. + fn inject_magic_state(&mut self, theta: Angle64, target: usize) { + assert!( + self.next_ancilla < self.total_qubits, + "exceeded max_non_clifford ancilla slots" + ); + + let ancilla = self.next_ancilla; + self.next_ancilla += 1; + + let anc_qid = QubitId(ancilla); + let tgt_qid = QubitId(target); + + // Step 1: Prepare ancilla in |+> + self.tableau.h(&[anc_qid]); + + // Step 2: Apply RZ(theta) on the ancilla. + // Ancilla is in |+> (product state), so Z_anc is a destabilizer flip + // at the ancilla site -- single-site gate, no bond dim growth. + let half_rad = theta.to_radians_signed() / 2.0; + let cos_half = half_rad.cos(); + let sin_half = half_rad.sin(); + non_clifford::apply_rz_stab_mps( + &mut self.tableau, + &mut self.mps, + cos_half, + sin_half, + ancilla, + true, + &mut non_clifford::RzContext { + disent_flags: &mut self.disent_flags, + gf2_matrix: &mut self.gf2_matrix, + stats: &mut self.stats, + }, + ); + + // Step 3: CNOT(target, ancilla) -- target controls, ancilla is CX target + // This is the key: data qubit controls, ancilla flips. + self.tableau.cx(&[(tgt_qid, anc_qid)]); + + // Step 4: Record deferred measurement with correction angle + self.deferred.push(DeferredMeasurement { + ancilla, + target, + correction_angle: theta + theta, // RZ(2*theta) correction if outcome=1 + }); + } + + /// Project all deferred ancilla measurements. + /// + /// 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) + 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, + ) + } 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); + + // 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, + }, + ); + } + } + } + } +} + +impl QuantumSimulator for Mast { + fn reset(&mut self) -> &mut Self { + self.tableau = SparseStabY::new(self.total_qubits).with_destab_sign_tracking(); + self.mps = Mps::new(self.total_qubits, self.config.clone()); + self.next_ancilla = self.num_data_qubits; + self.deferred.clear(); + 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(); + self.deferred_ops.clear(); + for slot in &mut self.pending_rz { + *slot = None; + } + self + } + + fn num_qubits(&self) -> usize { + self.num_data_qubits + } +} + +impl CliffordGateable for Mast { + fn sz(&mut self, qubits: &[QubitId]) -> &mut Self { + self.tableau.sz(qubits); + self + } + + fn h(&mut self, qubits: &[QubitId]) -> &mut Self { + // H does not commute with RZ: flush pending merged RZ first. + for &q in qubits { + self.flush_pending_rz(q.index()); + } + self.tableau.h(qubits); + self + } + + fn cx(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + // CX doesn't commute with RZ on arbitrary qubits: flush both. + for &(c, t) in pairs { + self.flush_pending_rz(c.index()); + self.flush_pending_rz(t.index()); + } + self.tableau.cx(pairs); + self + } + + fn cz(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + // CZ is diagonal, commutes with RZ on either qubit — no flush needed. + self.tableau.cz(pairs); + self + } + + fn mz(&mut self, qubits: &[QubitId]) -> Vec { + // Flush any pending merged RZ on measured qubits before measuring. + for &q in qubits { + self.flush_pending_rz(q.index()); + } + // Project all deferred measurements first + self.project_all(); + // Then measure data qubits using the full STN measurement protocol + qubits + .iter() + .map(|&q| { + if self.lazy_measure { + super::measure::measure_qubit_stab_mps_lazy( + &mut self.tableau, + &mut self.mps, + &mut self.rng, + q.index(), + &mut self.deferred_ops, + ) + } else { + super::measure::measure_qubit_stab_mps( + &mut self.tableau, + &mut self.mps, + &mut self.rng, + q.index(), + ) + } + }) + .collect() + } +} + +impl ArbitraryRotationGateable for Mast { + fn rx(&mut self, theta: Angle64, qubits: &[QubitId]) -> &mut Self { + self.h(qubits); + self.rz(theta, qubits); + self.h(qubits); + self + } + + fn rz(&mut self, theta: Angle64, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let q_idx = q.index(); + if !self.merge_rz { + self.rz_apply_direct(theta, q_idx); + continue; + } + let is_clifford_angle = theta == Angle64::ZERO + || theta == Angle64::HALF_TURN + || theta == Angle64::QUARTER_TURN + || theta == Angle64::THREE_QUARTERS_TURN; + if is_clifford_angle { + // Clifford-angle RZ commutes with pending non-Clifford RZ; + // no flush needed, apply directly. + self.rz_apply_direct(theta, q_idx); + } else { + let prev = self.pending_rz[q_idx].unwrap_or(Angle64::ZERO); + let merged = prev + theta; + if merged == Angle64::ZERO + || merged == Angle64::HALF_TURN + || merged == Angle64::QUARTER_TURN + || merged == Angle64::THREE_QUARTERS_TURN + { + self.pending_rz[q_idx] = None; + self.rz_apply_direct(merged, q_idx); + } else { + self.pending_rz[q_idx] = Some(merged); + } + } + } + self + } + + fn rzz(&mut self, theta: Angle64, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q0, q1) in pairs { + self.cx(&[(q0, q1)]); + self.rz(theta, &[q1]); + self.cx(&[(q0, q1)]); + } + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + #[test] + fn test_mast_pure_clifford() { + // Pure Clifford circuit should work like STN + let mut mast = Mast::new(2, 4); + mast.h(&[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + assert_eq!(mast.num_ancillas_used(), 0); + assert_eq!(mast.max_bond_dim(), 1); + } + + #[test] + fn test_mast_single_t_gate() { + // T gate uses magic state injection + let mut mast = Mast::new(1, 4); + mast.h(&[QubitId(0)]); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + assert_eq!(mast.num_ancillas_used(), 1); + // 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() + ); + } + + #[test] + fn test_mast_norm_preserved() { + let mut mast = Mast::new(2, 4); + mast.h(&[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(1)]); + + assert_relative_eq!(mast.mps().norm_squared(), 1.0, epsilon = 1e-8); + } + + #[test] + fn test_mast_t_on_zero_deterministic() { + // T|0> via MAST: data stays in |0>, measurement should be deterministic + for trial in 0..20 { + let mut mast = Mast::with_seed(1, 4, 7000 + trial); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let r = mast.mz(&[QubitId(0)]); + assert!(!r[0].outcome, "trial {trial}: T|0> should measure as 0"); + } + } + + #[test] + fn test_mast_t_on_plus_statistics() { + // H then T via MAST, then measure: should get 50/50 (T only changes phase) + let num_trials = 200; + let mut count_0 = 0; + for trial in 0..num_trials { + let mut mast = Mast::with_seed(1, 4, 8000 + trial); + mast.h(&[QubitId(0)]); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let r = mast.mz(&[QubitId(0)]); + if !r[0].outcome { + count_0 += 1; + } + } + let p0 = f64::from(count_0) / num_trials as f64; + assert!((p0 - 0.5).abs() < 0.1, "p(0) = {p0:.2}, expected ~0.5"); + } + + /// Multi-qubit MAST vs STN: sample measurement distributions on a + /// Clifford+T circuit. Each of the 2^n outcomes should have matching + /// probabilities between MAST and STN. + #[test] + fn test_mast_vs_stn_multi_qubit() { + use crate::stab_mps::StabMps; + let num_trials = 1000; + let n = 4; + // Circuit: H on all, CX(0,1), T(0), CX(1,2), T(1), CX(2,3), T(2) + let apply = |s: &mut dyn FnMut(&[QubitId])| { + let _ = s; + }; + let _ = apply; + + let mut stn_counts = vec![0u32; 1 << n]; + let mut mast_counts = vec![0u32; 1 << n]; + for trial in 0..num_trials { + // STN + let mut s = StabMps::with_seed(n, 10_000 + trial); + s.h(&[QubitId(0), QubitId(1), QubitId(2), QubitId(3)]); + s.cx(&[(QubitId(0), QubitId(1))]); + s.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + s.cx(&[(QubitId(1), QubitId(2))]); + s.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(1)]); + s.cx(&[(QubitId(2), QubitId(3))]); + s.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + let mut idx = 0usize; + for q in 0..n { + if s.mz(&[QubitId(q)])[0].outcome { + idx |= 1 << q; + } + } + stn_counts[idx] += 1; + + // MAST + let mut m = Mast::with_seed(n, 10, 10_000 + trial); + m.h(&[QubitId(0), QubitId(1), QubitId(2), QubitId(3)]); + m.cx(&[(QubitId(0), QubitId(1))]); + m.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + m.cx(&[(QubitId(1), QubitId(2))]); + m.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(1)]); + m.cx(&[(QubitId(2), QubitId(3))]); + m.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + let mut idx = 0usize; + for q in 0..n { + if m.mz(&[QubitId(q)])[0].outcome { + idx |= 1 << q; + } + } + mast_counts[idx] += 1; + } + + // Chi-squared-like check: each outcome should have close probabilities. + let mut max_diff: f64 = 0.0; + for i in 0..(1 << n) { + let p_stn = f64::from(stn_counts[i]) / num_trials as f64; + let p_mast = f64::from(mast_counts[i]) / num_trials as f64; + let diff = (p_stn - p_mast).abs(); + if diff > max_diff { + max_diff = diff; + } + eprintln!("outcome {i:04b}: STN={p_stn:.3}, MAST={p_mast:.3}"); + } + eprintln!("max |p_STN - p_MAST| = {max_diff:.3}"); + // Statistical tolerance for 1000 trials ~= 3 sigma on p=0.5 is 0.047. + // Use 0.08 to allow for multiple-outcome max. + assert!( + max_diff < 0.08, + "MAST and STN distributions diverge: max diff {max_diff:.3}" + ); + } + + #[test] + fn test_mast_vs_stn_single_qubit() { + // Compare MAST and STN state vectors for H, T on single qubit + use crate::stab_mps::StabMps; + + let mut stn = StabMps::new(1); + stn.h(&[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let _stn_sv = stn.state_vector(); + + // MAST: the state vector includes ancilla qubits, so we can't + // directly compare. But the data qubit probabilities should match. + // Use measurement statistics instead. + let num_trials = 500; + let mut stn_count = 0; + let mut mast_count = 0; + for trial in 0..num_trials { + let mut s = StabMps::with_seed(1, 9000 + trial); + s.h(&[QubitId(0)]); + s.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + if !s.mz(&[QubitId(0)])[0].outcome { + stn_count += 1; + } + + let mut m = Mast::with_seed(1, 4, 9000 + trial); + m.h(&[QubitId(0)]); + m.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + if !m.mz(&[QubitId(0)])[0].outcome { + mast_count += 1; + } + } + let stn_p0 = f64::from(stn_count) / num_trials as f64; + let mast_p0 = f64::from(mast_count) / num_trials as f64; + eprintln!("STN p(0) = {stn_p0:.3}, MAST p(0) = {mast_p0:.3}"); + // Both should be ~0.5 (T only changes phase, not Z-basis probabilities) + assert!( + (stn_p0 - mast_p0).abs() < 0.1, + "STN p(0)={stn_p0:.3} vs MAST p(0)={mast_p0:.3} should be similar" + ); + } + + #[test] + fn test_stn_3qubit_measurement_correlation() { + // Test that STN gives same results as plain SparseStabY for pure Clifford. + use crate::stab_mps::StabMps; + + let mut stn_corr = 0; + let mut tab_corr = 0; + let num_trials = 50; + for trial in 0..num_trials { + // STN version + let mut stn = StabMps::with_seed(3, 6000 + trial); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.h(&[QubitId(2)]); + stn.cx(&[(QubitId(0), QubitId(2))]); + let r2_stn = stn.mz(&[QubitId(2)])[0].outcome; + let r0_stn = stn.mz(&[QubitId(0)])[0].outcome; + if r0_stn == r2_stn { + stn_corr += 1; + } + + // Plain SparseStabY version (same seed) + let mut tab = SparseStabY::with_seed(3, 6000 + trial); + tab.h(&[QubitId(0)]); + tab.cx(&[(QubitId(0), QubitId(1))]); + tab.h(&[QubitId(2)]); + tab.cx(&[(QubitId(0), QubitId(2))]); + let r2_tab = tab.mz(&[QubitId(2)])[0].outcome; + let r0_tab = tab.mz(&[QubitId(0)])[0].outcome; + if r0_tab == r2_tab { + tab_corr += 1; + } + } + let stn_rate = f64::from(stn_corr) / num_trials as f64; + let tab_rate = f64::from(tab_corr) / num_trials as f64; + eprintln!("STN correlation: {stn_rate:.2}, SparseStabY correlation: {tab_rate:.2}"); + // Both should match + assert!( + (stn_rate - tab_rate).abs() < 0.2, + "STN {stn_rate:.2} should match SparseStabY {tab_rate:.2}" + ); + } + + #[test] + fn test_manual_mast_with_sparse_stab() { + // Verify the magic state teleportation protocol using plain SparseStabY. + // This tests the PROTOCOL, not the STN implementation. + let mut correlated = 0; + let num_trials = 100; + for trial in 0..num_trials { + let mut tab = SparseStabY::with_seed(3, 7000 + trial); + // Bell state on q0, q1 + tab.h(&[QubitId(0)]); + tab.cx(&[(QubitId(0), QubitId(1))]); + // Magic state injection for T on q0: + tab.h(&[QubitId(2)]); // ancilla in |+> + tab.sz(&[QubitId(2)]); // S on ancilla (half of T = S*T^{1/2}... wait, we need T) + // Actually, SparseStabY can't do T. Let me use T = RZ(pi/4) via the Clifford S. + // T|+> via Clifford: not possible. T is non-Clifford. + // In the SparseStabY world, we can test the protocol with S instead of T. + // S|+> = (|0> + i|1>)/sqrt(2) + // Protocol: prepare S|+>, CNOT(data, anc), measure anc, correct. + // For S: correction if outcome=1 is RZ(2*pi/2)=RZ(pi)=-iZ (Clifford). + // S gate on q0 of Bell state: (|00> + i|11>)/sqrt(2) + // CNOT(q0, q2): + tab.cx(&[(QubitId(0), QubitId(2))]); + let anc_result = tab.mz(&[QubitId(2)])[0].outcome; + if anc_result { + // Correction: RZ(pi) = -iZ on q0 + tab.z(&[QubitId(0)]); + } + let r0 = tab.mz(&[QubitId(0)])[0].outcome; + let r1 = tab.mz(&[QubitId(1)])[0].outcome; + if r0 == r1 { + correlated += 1; + } + } + let rate = f64::from(correlated) / num_trials as f64; + eprintln!("SparseStabY manual S-injection correlation: {rate:.2}"); + assert!(rate > 0.90, "correlation {rate:.2} should be > 0.90"); + } + + #[test] + fn test_manual_mast_with_stn_clifford() { + // Manual MAST with S (Clifford) instead of T. + // This should work because the MPS stays trivial. + use crate::stab_mps::StabMps; + + let mut correlated = 0; + let num_trials = 100; + for trial in 0..num_trials { + let mut stn = StabMps::with_seed(3, 5000 + trial); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + + // S-injection (Clifford, MPS stays trivial): + stn.h(&[QubitId(2)]); + stn.sz(&[QubitId(2)]); // S instead of T + stn.cx(&[(QubitId(0), QubitId(2))]); + let anc_result = stn.mz(&[QubitId(2)])[0].outcome; + if anc_result { + stn.z(&[QubitId(0)]); // RZ(pi) correction for S + } + + let r0 = stn.mz(&[QubitId(0)])[0].outcome; + let r1 = stn.mz(&[QubitId(1)])[0].outcome; + if r0 == r1 { + correlated += 1; + } + } + let rate = f64::from(correlated) / num_trials as f64; + eprintln!("STN Clifford injection correlation: {rate:.2}"); + assert!(rate > 0.90, "correlation {rate:.2} should be > 0.90"); + } + + #[test] + fn test_z2_expectation_value() { + // Verify the Z_2 expectation value matches between STN and direct computation. + use crate::stab_mps::StabMps; + use nalgebra::DMatrix; + use pecos_simulators::StabVec; + + let mut stn = StabMps::new(3); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.h(&[QubitId(2)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + stn.cx(&[(QubitId(0), QubitId(2))]); + + // Compute from state vector + let mut crz = StabVec::builder(3).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.cx(&[(QubitId(0), QubitId(1))]); + crz.h(&[QubitId(2)]); + crz.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + crz.cx(&[(QubitId(0), QubitId(2))]); + let crz_sv = crz.state_vector(); + + // from state vector: sum |a_i|^2 * (-1)^{bit 2 of i} + let mut z2_ev_direct = 0.0; + for (i, a) in crz_sv.iter().enumerate() { + let bit2 = (i >> 2) & 1; // qubit 2 in LSB convention + let sign = if bit2 == 1 { -1.0 } else { 1.0 }; + z2_ev_direct += a.norm_sqr() * sign; + } + + // from STN decomposition + let decomp = crate::stab_mps::pauli_decomp::decompose_z( + stn.tableau().stabs(), + stn.tableau().destabs(), + 2, + ); + eprintln!("Z_2 decomp: {decomp:?}"); + + let z_gate = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(-1.0, 0.0), + ], + ); + let x_gate = 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), + ], + ); + + if let crate::stab_mps::pauli_decomp::ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } = decomp + { + let mut ops: Vec<(usize, DMatrix)> = Vec::new(); + for j in &flip_sites { + ops.push((*j, x_gate.clone())); + } + for k in &sign_sites { + ops.push((*k, z_gate.clone())); + } + let raw_ev = stn.mps().expectation_product(&ops); + let z2_ev_stn = (phase * raw_ev).re; + eprintln!("Z_2 EV: direct={z2_ev_direct:.6}, STN={z2_ev_stn:.6}, phase={phase:.4}"); + approx::assert_relative_eq!(z2_ev_stn, z2_ev_direct, epsilon = 1e-6); + } + } + + #[test] + fn test_stn_state_before_ancilla_measurement() { + // Check that the STN state vector before ancilla measurement is correct. + use crate::stab_mps::StabMps; + use pecos_simulators::StabVec; + + let mut stn = StabMps::new(3); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.h(&[QubitId(2)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + stn.cx(&[(QubitId(0), QubitId(2))]); + + let mut crz = StabVec::builder(3).seed(42).build(); + crz.h(&[QubitId(0)]); + crz.cx(&[(QubitId(0), QubitId(1))]); + crz.h(&[QubitId(2)]); + crz.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + crz.cx(&[(QubitId(0), QubitId(2))]); + + let stn_sv = stn.state_vector(); + let crz_sv = crz.state_vector(); + + // Check overlap + let norm_stn: f64 = stn_sv.iter().map(nalgebra::Complex::norm_sqr).sum(); + let norm_crz: f64 = crz_sv.iter().map(nalgebra::Complex::norm_sqr).sum(); + let overlap: Complex64 = stn_sv + .iter() + .zip(crz_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + + eprintln!( + "State before ancilla meas: norm_stn={norm_stn:.4}, norm_crz={norm_crz:.4}, overlap={:.4}", + overlap.norm_sqr() + ); + assert!( + (overlap.norm_sqr() - 1.0).abs() < 0.01, + "states should match (overlap = {:.4})", + overlap.norm_sqr() + ); + } + + #[test] + fn test_manual_mast_with_stn_nonclifford() { + // Manual MAST with T (non-Clifford). + // This tests whether the STN measurement handles the ancilla correctly. + use crate::stab_mps::StabMps; + + let mut correlated = 0; + let num_trials = 100; + for trial in 0..num_trials { + let mut stn = StabMps::with_seed(3, 5000 + trial); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + + // T-injection (non-Clifford): + stn.h(&[QubitId(2)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + stn.cx(&[(QubitId(0), QubitId(2))]); + let anc_result = stn.mz(&[QubitId(2)])[0].outcome; + if anc_result { + stn.sz(&[QubitId(0)]); // S correction + } + + let r0 = stn.mz(&[QubitId(0)])[0].outcome; + let r1 = stn.mz(&[QubitId(1)])[0].outcome; + if r0 == r1 { + correlated += 1; + } + } + let rate = f64::from(correlated) / num_trials as f64; + eprintln!("STN T-injection correlation: {rate:.2}"); + assert!(rate > 0.90, "correlation {rate:.2} should be > 0.90"); + } + + #[test] + fn test_mast_measurement() { + // Bell state + T via MAST: after ancilla projection, data qubits + // should be in Bell+T state with correlated measurements. + // + // Diagnose: check MPS norm and bond dims after each step. + let mut mast = Mast::with_seed(2, 4, 42); + mast.h(&[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + + eprintln!( + "After Bell: norm={:.4}, bonds={:?}", + mast.mps().norm_squared(), + mast.mps().bond_dims() + ); + + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + + eprintln!( + "After T inject: norm={:.4}, bonds={:?}, ancillas={}", + mast.mps().norm_squared(), + mast.mps().bond_dims(), + mast.num_ancillas_used() + ); + + // Project deferred measurements + mast.project_all(); + + eprintln!( + "After project: norm={:.4}, bonds={:?}", + mast.mps().norm_squared(), + mast.mps().bond_dims() + ); + + // Check MPS state + let mps_sv = mast.mps().state_vector(); + eprintln!("MPS SV after project:"); + for (i, a) in mps_sv.iter().enumerate() { + if a.norm() > 1e-12 { + eprintln!(" [{i:06b}] = {:.4} + {:.4}i", a.re, a.im); + } + } + + // Now measure both data qubits + let mut correlated = 0; + let num_trials = 100; + for trial in 0..num_trials { + let mut m = Mast::with_seed(2, 4, 5000 + trial); + m.h(&[QubitId(0)]); + m.cx(&[(QubitId(0), QubitId(1))]); + m.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + + let r0 = m.mz(&[QubitId(0)])[0].outcome; + let r1 = m.mz(&[QubitId(1)])[0].outcome; + if r0 == r1 { + correlated += 1; + } + } + let correlation_rate = f64::from(correlated) / num_trials as f64; + eprintln!("Correlation rate: {correlation_rate:.2}"); + assert!( + correlation_rate > 0.90, + "correlation rate {correlation_rate:.2} should be > 0.90" + ); + } + + #[test] + fn test_mast_merge_rz_two_t_gates_merge() { + // Two T on same qubit with merge_rz should produce a single + // non-Clifford (merged to S = Clifford fast-path). Eager path + // would do two MAST injections. + let t = Angle64::QUARTER_TURN / 2u64; + let mut m = Mast::with_seed(2, 4, 7).with_merge_rz(true); + m.h(&[QubitId(0)]); + m.rz(t, &[QubitId(0)]); + m.rz(t, &[QubitId(0)]); + m.flush(); + // T+T = S (Clifford). No ancillas used. + assert_eq!( + m.num_ancillas_used(), + 0, + "T+T should merge to S (Clifford), no MAST ancillas used" + ); + } + + #[test] + fn test_mast_merge_rz_intervening_cz_still_merges() { + // CZ on different qubits doesn't flush pending_rz on q0. Merge. + let t = Angle64::QUARTER_TURN / 2u64; + let mut m = Mast::with_seed(2, 4, 9).with_merge_rz(true); + m.h(&[QubitId(0), QubitId(1)]); + m.rz(t, &[QubitId(0)]); + m.cz(&[(QubitId(0), QubitId(1))]); // CZ commutes with RZ + m.rz(t, &[QubitId(0)]); + m.flush(); + // Merged T+T = S. No MAST ancilla used. + assert_eq!( + m.num_ancillas_used(), + 0, + "CZ should not flush pending_rz, merge persists" + ); + } + + #[test] + fn test_mast_with_lazy_measure_bell_correlation() { + // Fluent setter on MAST: measurements via lazy path. + for trial in 0..10 { + let mut m = Mast::with_seed(2, 4, 5000 + trial).with_lazy_measure(true); + m.h(&[QubitId(0)]); + m.cx(&[(QubitId(0), QubitId(1))]); + m.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let r0 = m.mz(&[QubitId(0)])[0].outcome; + let r1 = m.mz(&[QubitId(1)])[0].outcome; + assert_eq!(r0, r1, "lazy MAST Bell+T trial {trial}"); + } + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/measure.rs b/exp/pecos-stab-tn/src/stab_mps/measure.rs new file mode 100644 index 000000000..fcd5d7d92 --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps/measure.rs @@ -0,0 +1,1382 @@ +// 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. + +//! Shared measurement logic for STN and MAST simulators. +//! +//! Measures a qubit in the Z basis using the stabilizer tableau for structure +//! and the MPS for probability computation and projection. +//! +//! The measurement protocol decomposes `Z_q` in the stabilizer basis, computes +//! the expectation value from the MPS, samples an outcome, and projects the +//! MPS using the (I + sign * `Z_q)/2` projector. After projection, the measured +//! site collapses to sigma=0 (the stabilizer eigenstate). +//! +//! Reference: Masot-Llima, Garcia-Saez. arXiv:2403.08724, Section III. + +use super::pauli_decomp::{ZDecomposition, decompose_z}; +use crate::mps::Mps; +use nalgebra::DMatrix; +use num_complex::Complex64; +use pecos_random::PecosRng; +use pecos_simulators::{CliffordGateable, MeasurementResult, SparseStabY}; + +/// Check if the MPS is trivial (all sites in a computational basis state). +fn is_mps_trivial(mps: &Mps) -> bool { + mps.max_bond_dim() == 1 + && mps.tensors().iter().all(|t| { + let chi_r = t.ncols() / 2; + let b0_norm: f64 = (0..t.nrows()) + .flat_map(|i| (0..chi_r).map(move |j| t[(i, j)].norm_sqr())) + .sum(); + let b1_norm: f64 = (0..t.nrows()) + .flat_map(|i| (0..chi_r).map(move |j| t[(i, chi_r + j)].norm_sqr())) + .sum(); + b0_norm < 1e-12 || b1_norm < 1e-12 + }) +} + +/// Compute `` via clone + inner product. +/// +/// Returns the expectation value of the Pauli string. Z applied first, then +/// X (matches the measurement projection convention in this module). +/// +/// # Panics +/// +/// Panics if any MPS gate application fails on a valid site (should not happen +/// for in-range sites). +#[must_use] +pub fn pauli_expectation( + mps: &Mps, + flip_sites: &[usize], + sign_sites: &[usize], + phase: Complex64, +) -> Complex64 { + if flip_sites.is_empty() && sign_sites.is_empty() { + return phase; + } + let x_gate = 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), + ], + ); + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + let mut mps_op = mps.clone(); + for &k in sign_sites { + mps_op + .apply_diagonal_one_site(k, &z_diag) + .expect("MPS op on valid site"); + } + for &j in flip_sites { + mps_op + .apply_one_site_gate(j, &x_gate) + .expect("MPS op on valid site"); + } + let raw = mps_inner_product(mps, &mps_op); + phase * raw +} + +/// Compute `` by applying the decomposition to a clone and taking the inner product. +/// +/// Returns the raw expectation value (before multiplying by the decomposition phase). +/// The full expectation is: `phase * apply_z_to_clone_and_overlap(...)`. +#[must_use] +pub fn z_expectation_value(tableau: &SparseStabY, mps: &Mps, q: usize) -> Complex64 { + let decomp = decompose_z(tableau.stabs(), tableau.destabs(), q); + match decomp { + ZDecomposition::Stabilizer { phase, sign_sites } => { + pauli_expectation(mps, &[], &sign_sites, phase) + } + ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } => pauli_expectation(mps, &flip_sites, &sign_sites, phase), + } +} + +/// Compute the inner product <`mps_a|mps_b`> by contracting from left to right. +fn mps_inner_product(mps_a: &Mps, mps_b: &Mps) -> Complex64 { + let d = mps_a.phys_dim(); + let mut transfer = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + + for q in 0..mps_a.num_sites() { + let chi_r_a = mps_a.bond_dim(q + 1); + let chi_r_b = mps_b.bond_dim(q + 1); + let t_a = &mps_a.tensors()[q]; + let t_b = &mps_b.tensors()[q]; + + let mut new_transfer = DMatrix::zeros(chi_r_a, chi_r_b); + for sigma in 0..d { + let block_a = crate::mps::tensor::phys_block(t_a, sigma, chi_r_a); + let block_b = crate::mps::tensor::phys_block(t_b, sigma, chi_r_b); + let conj_a_t = block_a.conjugate().transpose(); + let tmp = &conj_a_t * &transfer * &block_b; + new_transfer += tmp; + } + transfer = new_transfer; + } + + transfer[(0, 0)] +} + +/// Find the stabilizer index that `mz_forced` will select for replacement. +/// +/// This is the minimum-weight stabilizer that anticommutes with `Z_q`, +/// matching the logic in `SparseStabY::nondeterministic_meas`. +fn find_replaced_stabilizer(tableau: &SparseStabY, q_idx: usize) -> usize { + let stabs = tableau.stabs(); + let col_x = &stabs.col_x[q_idx]; + + let mut best_id = None; + let mut best_weight = usize::MAX; + for stab_id in col_x { + let weight = stabs.row_x[stab_id].len() + stabs.row_z[stab_id].len(); + if weight < best_weight { + best_weight = weight; + best_id = Some(stab_id); + if weight == 1 { + break; + } + } + } + best_id.expect("col_x should be non-empty for DestabilizerFlip case") +} + +/// Test hook for `pre_reduce_for_measurement`. +pub fn pre_reduce_for_measurement_pub(tableau: &mut SparseStabY, mps: &mut Mps, q_idx: usize) { + pre_reduce_for_measurement(tableau, mps, q_idx, true); +} + +/// Pre-reduce the stabilizer tableau so that `Z_q` anticommutes with at most +/// one stabilizer. For each other anti-commuting stab: +/// - Tableau: `S[other] *= S[replaced]`, `D[replaced] *= D[other]` (via +/// full Y-convention `multiply_row`, including sign/phase tracking). +/// - MPS (when `apply_mps_compensation=true`): apply virtual-frame +/// `CNOT(c=replaced, t=other)` for CAMPS state preservation. The tableau +/// change transforms the Clifford as `C → C · CNOT` — applying the +/// same CNOT to the MPS (self-inverse) compensates so +/// `C'·MPS_new = C·MPS_old`. Non-adjacent CNOTs use +/// `apply_long_range_two_site_gate`. +/// +/// `apply_mps_compensation` is `true` for exact-state callers +/// (`project_forced_z`, `project_forced_z_unnormalized`) used by +/// `prob_bitstring` / `amplitude_iterative`. It is `false` for random +/// measurement (`measure_qubit_stab_mps`): the state representation becomes +/// inconsistent with the tableau after row ops, but measurement +/// statistics stay correct and subsequent measurements remain +/// self-consistent. Skipping compensation avoids SWAP-chain bond growth +/// during measurement-heavy circuits (MAST magic-state injection). +/// +/// Proper long-term fix: lazy virtual-frame tracking — accumulate a +/// deferred Clifford V such that effective MPS = V·stored MPS, conjugate +/// Pauli strings by V before applying to stored MPS, flush only when MPS +/// must be read directly. See `docs/future_work.md`. +fn pre_reduce_for_measurement( + tableau: &mut SparseStabY, + mps: &mut Mps, + q_idx: usize, + apply_mps_compensation: bool, +) { + let col_x = &tableau.stabs().col_x[q_idx]; + if col_x.len() <= 1 { + return; + } + + let replaced_idx = find_replaced_stabilizer(tableau, q_idx); + let n = tableau.num_qubits(); + + let anticom: Vec = tableau.stabs().col_x[q_idx] + .iter() + .filter(|&id| id != replaced_idx) + .collect(); + + // Clone stabs/destabs ONCE before the loop (not per iteration). + // For stabs: replaced_idx is the SOURCE row and never modified, so one + // clone suffices for all iterations. + // For destabs: replaced_idx IS modified (accumulated), but the SOURCE + // rows (other_id) are all distinct and untouched. One clone captures + // all of them before any mutation. + let stabs_snapshot = tableau.stabs().clone(); + let destabs_snapshot = tableau.destabs().clone(); + for other_id in anticom { + crate::stab_mps::tableau_compose::multiply_row( + tableau.stabs_mut(), + other_id, + &stabs_snapshot, + replaced_idx, + n, + ); + crate::stab_mps::tableau_compose::multiply_row( + tableau.destabs_mut(), + replaced_idx, + &destabs_snapshot, + other_id, + n, + ); + if apply_mps_compensation { + apply_cnot_to_mps(mps, replaced_idx, other_id); + } + } +} + +fn apply_cnot_to_mps(mps: &mut Mps, control: usize, target: usize) { + // Optimization: if the control site has no |1⟩_virt amplitude, CNOT is + // identity on this MPS — skip to avoid bond-dim blowup from SWAP chains. + // Mirror: if control has no |0⟩_virt amp, CNOT reduces to X on target. + if mps_site_block_is_zero(mps, control, 1) { + return; + } + if mps_site_block_is_zero(mps, control, 0) { + // Control is |1⟩ → CNOT unconditionally flips target = X on target. + let x_gate = 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), + ], + ); + mps.apply_one_site_gate(target, &x_gate) + .expect("MPS op on valid site"); + return; + } + + // General case: apply full CNOT. + let o = Complex64::new(0.0, 0.0); + let one = Complex64::new(1.0, 0.0); + let cnot_c_lo = DMatrix::from_row_slice( + 4, + 4, + &[one, o, o, o, o, one, o, o, o, o, o, one, o, o, one, o], + ); + let cnot_c_hi = DMatrix::from_row_slice( + 4, + 4, + &[one, o, o, o, o, o, o, one, o, o, one, o, o, one, o, o], + ); + let (q0, q1, gate) = if control < target { + (control, target, cnot_c_lo) + } else { + (target, control, cnot_c_hi) + }; + if q1 == q0 + 1 { + mps.apply_two_site_gate(q0, &gate) + .expect("MPS op on valid site"); + } else { + mps.apply_long_range_two_site_gate(q0, q1, &gate) + .expect("MPS op on valid site"); + } +} + +/// A deferred Clifford primitive in the virtual-frame queue. +/// +/// The queue represents a Clifford `V = ops[last] · ... · ops[0]` where +/// index 0 is the first pushed (earliest applied if flushed). Each primitive +/// has a cheap Heisenberg conjugation rule (bit XOR on flip/sign sets) and +/// a cheap MPS application (single-site for H, diagonal for CZ, SWAP-chain +/// for CNOT). +#[derive(Clone, Copy, Debug)] +pub enum DeferredOp { + /// CNOT(control, target). + Cnot(usize, usize), + /// Hadamard on qubit. + H(usize), + /// CZ(a, b) — symmetric. + Cz(usize, usize), + /// Pauli Z on qubit. Used for outcome-dependent W basis rotation: + /// for outcome=1, W includes a `Z_id` factor to flip `Z_id` → -`X_id`. + Z(usize), + /// Phase gate adjoint (SZ†) — needed for outcome-dependent W when + /// flip and sign overlap at id (Y-like Pauli). + SZdg(usize), + /// Phase gate SZ — needed for outcome-dependent W when the + /// decomposition phase `sp` is purely imaginary. Conjugation rule: + /// SZ†·P·SZ — if X at q, toggle Z at q and multiply phase by -i. + SZ(usize), +} + +fn toggle(v: &mut Vec, x: usize) { + if let Some(pos) = v.iter().position(|&y| y == x) { + v.swap_remove(pos); + } else { + v.push(x); + } +} + +/// Conjugate a Pauli `P = X_flip · Z_sign` by `V†` where +/// `V = ops[last] · ops[last-1] · ... · ops[0]`. Updates `flip_sites` and +/// `sign_sites` in place to represent `V† · P · V`. The scalar phase is +/// unchanged (CNOT/H/CZ conjugation preserves phase of the product). +/// +/// Heisenberg rules: +/// - CNOT(c, t): `X_c -> X_c · X_t`; `Z_t -> Z_c · Z_t`. +/// - H(q): swap `X_q` and `Z_q` (swap q between flip and sign). +/// - CZ(a, b): `X_a -> X_a · Z_b`; `X_b -> X_b · Z_a`. +/// +/// Order: `V† P V = op_0·...·op_last·P·op_last·...·op_0`, so iterate `ops` +/// in REVERSE (innermost conjugation by `op_last` first). +pub fn conjugate_pauli_by_deferred_ops( + flip_sites: &mut Vec, + sign_sites: &mut Vec, + phase: &mut Complex64, + ops: &[DeferredOp], +) { + for op in ops.iter().rev() { + match *op { + DeferredOp::Cnot(c, t) => { + let has_x_c = flip_sites.contains(&c); + let has_z_t = sign_sites.contains(&t); + if has_x_c { + toggle(flip_sites, t); + } + if has_z_t { + toggle(sign_sites, c); + } + } + DeferredOp::H(q) => { + let has_x = flip_sites.contains(&q); + let has_z = sign_sites.contains(&q); + // Swap membership of q between flip and sign. + if has_x != has_z { + if has_x { + toggle(flip_sites, q); + toggle(sign_sites, q); + } else { + toggle(sign_sites, q); + toggle(flip_sites, q); + } + } + // If both: Y → -Y (H·Y·H = -Y). Membership stays. Phase flips. + if has_x && has_z { + *phase = -*phase; + } + } + DeferredOp::Cz(a, b) => { + let has_x_a = flip_sites.contains(&a); + let has_x_b = flip_sites.contains(&b); + if has_x_a { + toggle(sign_sites, b); + } + if has_x_b { + toggle(sign_sites, a); + } + } + DeferredOp::Z(q) => { + // Z·X_q·Z = -X_q. If X present at q (and Z not at q), phase flips. + // Z·Y_q·Z = -Y_q (Y has X factor). So if X present regardless of Z, phase flips. + // Z·Z_q·Z = Z_q. No flip if only Z at q. + if flip_sites.contains(&q) { + *phase = -*phase; + } + } + DeferredOp::SZdg(q) => { + // SZdg conjugation: SZdg†·P·SZdg = SZ·P·SZdg. + // SZ·X·SZdg = Y = iXZ; SZ·Z·SZdg = Z. + // If X at q and Z not at q: add q to sign, phase *= i. + // If X at q and Z at q: SZ·Y·SZdg = i·(SZ·X·SZdg)·(SZ·Z·SZdg) = i·Y·Z = i·(iXZ)·Z = -X. + // So XZ → X only (toggle z), phase *= i (aggregate: p · iXZ · Z = ip·X). + // Matrix sanity-check: SZ = [[1,0],[0,i]], SZdg = [[1,0],[0,-i]], + // Y = [[0,-i],[i,0]]. + // SZ·Y·SZdg = [[1,0],[0,i]]·[[0,-i],[i,0]]·[[1,0],[0,-i]] + // = [[0,-i],[-1,0]]·[[1,0],[0,-i]] + // = [[0, -1],[-1, 0]] = -X. ✓ + let has_x = flip_sites.contains(&q); + let has_z = sign_sites.contains(&q); + if has_x && !has_z { + // X only → XZ (add Z), phase *= i. + toggle(sign_sites, q); + *phase *= Complex64::new(0.0, 1.0); + } else if has_x && has_z { + // XZ → X only (remove Z), phase *= i. + toggle(sign_sites, q); + *phase *= Complex64::new(0.0, 1.0); + } + // Z only or none: unchanged. + } + DeferredOp::SZ(q) => { + // SZ conjugation: SZdg·P·SZ. + // SZdg·X·SZ = -Y = -i·X·Z; SZdg·Z·SZ = Z; SZdg·Y·SZ = X. + // X only → X·Z, phase *= -i. + // X·Z → X only, phase *= -i. + // Z only or none: unchanged. + let has_x = flip_sites.contains(&q); + if has_x { + toggle(sign_sites, q); + *phase *= Complex64::new(0.0, -1.0); + } + } + } + } +} + +/// Backwards-compatible CNOT-only conjugation wrapper. CNOT conjugation +/// doesn't touch phase, so this discards the phase output. +pub fn conjugate_pauli_by_deferred( + flip_sites: &mut Vec, + sign_sites: &mut Vec, + cnots: &[(usize, usize)], +) { + let ops: Vec = cnots.iter().map(|&(c, t)| DeferredOp::Cnot(c, t)).collect(); + let mut phase = Complex64::new(1.0, 0.0); + conjugate_pauli_by_deferred_ops(flip_sites, sign_sites, &mut phase, &ops); +} + +/// Apply the deferred op queue `V = ops[last]·...·ops[0]` to `mps` and clear. +/// +/// # Panics +/// +/// Panics if any MPS gate application fails on a valid site. +pub fn flush_deferred_ops(mps: &mut Mps, ops: &mut Vec) { + let h_gate = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(-std::f64::consts::FRAC_1_SQRT_2, 0.0), + ], + ); + let cz_diag = [ + Complex64::new(1.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(-1.0, 0.0), + ]; + for op in ops.iter() { + match *op { + DeferredOp::Cnot(c, t) => apply_cnot_to_mps(mps, c, t), + DeferredOp::H(q) => { + mps.apply_one_site_gate(q, &h_gate) + .expect("MPS op on valid site"); + } + DeferredOp::Cz(a, b) => { + // CZ is diagonal; use apply_two_site_gate (adjacent) or + // long-range two-site (non-adjacent). Either preserves bond + // dim since it's diagonal in the product basis. + let (q0, q1) = if a < b { (a, b) } else { (b, a) }; + let o = Complex64::new(0.0, 0.0); + let cz = DMatrix::from_row_slice( + 4, + 4, + &[ + cz_diag[0], o, o, o, o, cz_diag[1], o, o, o, o, cz_diag[2], o, o, o, o, + cz_diag[3], + ], + ); + if q1 == q0 + 1 { + mps.apply_two_site_gate(q0, &cz) + .expect("MPS op on valid site"); + } else { + mps.apply_long_range_two_site_gate(q0, q1, &cz) + .expect("MPS op on valid site"); + } + } + DeferredOp::Z(q) => { + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + mps.apply_diagonal_one_site(q, &z_diag) + .expect("MPS op on valid site"); + } + DeferredOp::SZdg(q) => { + let sdg_diag = [Complex64::new(1.0, 0.0), Complex64::new(0.0, -1.0)]; + mps.apply_diagonal_one_site(q, &sdg_diag) + .expect("MPS op on valid site"); + } + DeferredOp::SZ(q) => { + let s_diag = [Complex64::new(1.0, 0.0), Complex64::new(0.0, 1.0)]; + mps.apply_diagonal_one_site(q, &s_diag) + .expect("MPS op on valid site"); + } + } + } + ops.clear(); +} + +/// Backwards-compatible CNOT-only flush wrapper. +pub fn flush_deferred(mps: &mut Mps, cnots: &mut Vec<(usize, usize)>) { + let mut ops: Vec = cnots.iter().map(|&(c, t)| DeferredOp::Cnot(c, t)).collect(); + flush_deferred_ops(mps, &mut ops); + cnots.clear(); +} + +/// Returns true if `mps` tensor at `site` has the σ=`block`'s elements all +/// below tolerance (i.e., site has no amplitude at that physical dim value). +fn mps_site_block_is_zero(mps: &Mps, site: usize, block: usize) -> bool { + let chi_r = mps.bond_dim(site + 1); + let t = &mps.tensors()[site]; + let start_col = block * chi_r; + for i in 0..t.nrows() { + for j in 0..chi_r { + if t[(i, start_col + j)].norm_sqr() > 1e-20 { + return false; + } + } + } + true +} + +/// Project qubit `q_idx` onto `outcome` without renormalizing. Returns +/// `false` if the projection is to a zero-probability outcome. +/// +/// Unlike `project_forced_z`, the MPS is left UNNORMALIZED: its norm drops +/// by `sqrt(conditional_prob)` after this call. This is what lets the caller +/// recover the complex amplitude at the end via `mps.amplitude(&[0;N])`. +/// +/// Used by `StabMps::amplitude_iterative` (Liu-Clark VI.B). +/// +/// # Panics +/// +/// Panics if any MPS gate application fails on a valid site. +pub fn project_forced_z_unnormalized( + tableau: &mut SparseStabY, + mps: &mut Mps, + q_idx: usize, + outcome: bool, +) -> bool { + // Trivial MPS: just consult the tableau. + if is_mps_trivial(mps) { + let decomp = decompose_z(tableau.stabs(), tableau.destabs(), q_idx); + let ok = match decomp { + ZDecomposition::Stabilizer { phase, .. } => { + let det_outcome = phase.re < 0.0; + det_outcome == outcome + } + ZDecomposition::DestabilizerFlip { .. } => { + // MPS is trivial (|0⟩^N scaled); both outcomes contribute + // amplitude 1/sqrt(2). Apply the equal-sum projection by + // rescaling the (already trivial) MPS by 1/sqrt(2) on site + // 0 to preserve probability normalization. + mps.scale(Complex64::new(1.0 / std::f64::consts::SQRT_2, 0.0)); + true + } + }; + if ok { + tableau.mz_forced(q_idx, outcome); + } + return ok; + } + + pre_reduce_for_measurement(tableau, mps, q_idx, true); + let decomp = decompose_z(tableau.stabs(), tableau.destabs(), q_idx); + + match decomp { + ZDecomposition::Stabilizer { phase, sign_sites } => { + if sign_sites.is_empty() { + let det_outcome = phase.re < 0.0; + if det_outcome != outcome { + return false; + } + tableau.mz_forced(q_idx, outcome); + return true; + } + let sign_f = if outcome { -1.0 } else { 1.0 }; + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + let mut mps_z = mps.clone(); + for &k in &sign_sites { + mps_z + .apply_diagonal_one_site(k, &z_diag) + .expect("MPS op on valid site"); + } + mps_z.scale(Complex64::new(sign_f, 0.0) * phase * Complex64::new(0.5, 0.0)); + mps.scale(Complex64::new(0.5, 0.0)); + *mps = mps.add(&mps_z); + mps.compress(); + tableau.mz_forced(q_idx, outcome); + true + } + + ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } => { + let sign_f = if outcome { -1.0 } else { 1.0 }; + if flip_sites.len() == 1 && sign_sites.is_empty() { + let k = flip_sites[0]; + let chi_r = mps.bond_dim(k + 1); + let sp = Complex64::new(sign_f, 0.0) * phase; + let block_0 = crate::mps::tensor::phys_block(&mps.tensors()[k], 0, chi_r); + let block_1 = crate::mps::tensor::phys_block(&mps.tensors()[k], 1, chi_r); + // Project onto (I + sp·X_k)/2 eigenstate, then basis-change + // (X_k → Z_k via mz_forced). The projected state has σ_0 = σ_1; + // collapsing to the new Z=0 eigenstate keeps norm via √2 factor. + let inv_sqrt2 = Complex64::new(1.0 / std::f64::consts::SQRT_2, 0.0); + let projected = (&block_0 + &block_1 * sp) * inv_sqrt2; + let zero = DMatrix::zeros(mps.tensors()[k].nrows(), chi_r); + crate::mps::tensor::set_phys_block(&mut mps.tensors_mut()[k], 0, chi_r, &projected); + crate::mps::tensor::set_phys_block(&mut mps.tensors_mut()[k], 1, chi_r, &zero); + } else { + let x_gate = 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), + ], + ); + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + let mut mps_z = mps.clone(); + // Apply Z first, then X (order must match z_expectation_value). + for &k in &sign_sites { + mps_z + .apply_diagonal_one_site(k, &z_diag) + .expect("MPS op on valid site"); + } + for &j in &flip_sites { + mps_z + .apply_one_site_gate(j, &x_gate) + .expect("MPS op on valid site"); + } + mps_z.scale(Complex64::new(sign_f, 0.0) * phase * Complex64::new(0.5, 0.0)); + mps.scale(Complex64::new(0.5, 0.0)); + *mps = mps.add(&mps_z); + mps.compress(); + if flip_sites.len() == 1 { + let k = flip_sites[0]; + let chi_r = mps.bond_dim(k + 1); + let zero = DMatrix::zeros(mps.tensors()[k].nrows(), chi_r); + crate::mps::tensor::set_phys_block(&mut mps.tensors_mut()[k], 1, chi_r, &zero); + // Basis swap at flip site: σ_0_new absorbs both σ_0 and σ_1 + // of old basis → √2 factor to preserve norm. + mps.scale(Complex64::new(std::f64::consts::SQRT_2, 0.0)); + } + // For >1 flip sites, the multi-site projection distributes + // amplitude across sites in a way our simple basis-swap trick + // doesn't handle. Callers should pre-reduce the tableau + // (`pre_reduce_for_measurement`) to collapse to single-flip. + } + tableau.mz_forced(q_idx, outcome); + true + } + } +} + +/// Project qubit `q_idx` onto a forced Z-basis outcome and return the +/// probability of that outcome given the current state. +/// +/// Mirrors `measure_qubit_stab_mps` but deterministic: no RNG, the outcome is +/// supplied by the caller. Useful for bitstring-probability computation +/// (Liu-Clark 2412.17209 Algorithm 3 / VI.A). +/// +/// # Panics +/// +/// Panics if any MPS gate application fails on a valid site. +pub fn project_forced_z( + tableau: &mut SparseStabY, + mps: &mut Mps, + q_idx: usize, + outcome: bool, +) -> f64 { + if is_mps_trivial(mps) { + // Trivial MPS: delegate to tableau's deterministic/random path logic + // but force the outcome. The tableau tracks signs; for a deterministic + // result the probability is 1 if the outcome matches, 0 otherwise. + // For a random result probability is 0.5. + let decomp = decompose_z(tableau.stabs(), tableau.destabs(), q_idx); + let prob = match decomp { + ZDecomposition::Stabilizer { phase, .. } => { + let det_outcome = phase.re < 0.0; + if det_outcome == outcome { 1.0 } else { 0.0 } + } + ZDecomposition::DestabilizerFlip { .. } => 0.5, + }; + if prob > 0.0 { + tableau.mz_forced(q_idx, outcome); + } + return prob; + } + + pre_reduce_for_measurement(tableau, mps, q_idx, true); + let ev = z_expectation_value(tableau, mps, q_idx).re; + let decomp = decompose_z(tableau.stabs(), tableau.destabs(), q_idx); + + match decomp { + ZDecomposition::Stabilizer { phase, sign_sites } => { + if sign_sites.is_empty() { + let det_outcome = phase.re < 0.0; + if det_outcome == outcome { + tableau.mz_forced(q_idx, outcome); + return 1.0; + } + return 0.0; + } + let prob_plus = f64::midpoint(1.0, ev).clamp(0.0, 1.0); + let prob = if outcome { 1.0 - prob_plus } else { prob_plus }; + if prob < 1e-20 { + return 0.0; + } + let sign_f = if outcome { -1.0 } else { 1.0 }; + + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + let mut mps_z = mps.clone(); + for &k in &sign_sites { + mps_z + .apply_diagonal_one_site(k, &z_diag) + .expect("MPS op on valid site"); + } + mps_z.scale( + Complex64::new(sign_f, 0.0) * phase + / Complex64::new(2.0 * prob.max(1e-20).sqrt(), 0.0), + ); + mps.scale(Complex64::new(1.0 / (2.0 * prob.max(1e-20).sqrt()), 0.0)); + *mps = mps.add(&mps_z); + mps.compress(); + tableau.mz_forced(q_idx, outcome); + prob + } + + ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } => { + let prob_plus = f64::midpoint(1.0, ev).clamp(0.0, 1.0); + let prob = if outcome { 1.0 - prob_plus } else { prob_plus }; + if prob < 1e-20 { + return 0.0; + } + let sign_f = if outcome { -1.0 } else { 1.0 }; + + if flip_sites.len() == 1 && sign_sites.is_empty() { + let k = flip_sites[0]; + let chi_r = mps.bond_dim(k + 1); + let sp = Complex64::new(sign_f, 0.0) * phase; + let block_0 = crate::mps::tensor::phys_block(&mps.tensors()[k], 0, chi_r); + let block_1 = crate::mps::tensor::phys_block(&mps.tensors()[k], 1, chi_r); + let projected = (&block_0 + &block_1 * sp) + / Complex64::new((2.0 * prob).max(1e-20).sqrt(), 0.0); + let zero = DMatrix::zeros(mps.tensors()[k].nrows(), chi_r); + crate::mps::tensor::set_phys_block(&mut mps.tensors_mut()[k], 0, chi_r, &projected); + crate::mps::tensor::set_phys_block(&mut mps.tensors_mut()[k], 1, chi_r, &zero); + mps.normalize(); + } else { + let x_gate = 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), + ], + ); + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + let mut mps_z = mps.clone(); + // Order must match z_expectation_value: Z first, then X. + // At overlap sites, this yields XZ = Y-convention Y (not ZX + // = -Y_conv). Inconsistent order would project onto the + // opposite-sign operator, leaving state in wrong subspace. + for &k in &sign_sites { + mps_z + .apply_diagonal_one_site(k, &z_diag) + .expect("MPS op on valid site"); + } + for &j in &flip_sites { + mps_z + .apply_one_site_gate(j, &x_gate) + .expect("MPS op on valid site"); + } + mps_z.scale( + Complex64::new(sign_f, 0.0) * phase + / Complex64::new(2.0 * prob.max(1e-20).sqrt(), 0.0), + ); + mps.scale(Complex64::new(1.0 / (2.0 * prob.max(1e-20).sqrt()), 0.0)); + *mps = mps.add(&mps_z); + mps.compress(); + if flip_sites.len() == 1 { + let k = flip_sites[0]; + let chi_r = mps.bond_dim(k + 1); + let zero = DMatrix::zeros(mps.tensors()[k].nrows(), chi_r); + crate::mps::tensor::set_phys_block(&mut mps.tensors_mut()[k], 1, chi_r, &zero); + } + mps.normalize(); + } + + tableau.mz_forced(q_idx, outcome); + prob + } + } +} + +/// Measure qubit `q_idx` in the Z basis using the STN protocol. +/// +/// Uses the tableau for structure (stabilizer/destabilizer decomposition) +/// and the MPS for probability computation and projection. +/// Lazy-compensation measurement (V2): accumulates `pre_reduce` CNOTs AND +/// the post-projection `W⁻¹` (single-qubit H + diagonal CZs) into a +/// `DeferredOp` queue. Uses `V†`-conjugated Pauli for projection. State +/// invariant: `effective = C_tableau · V_deferred · stored_mps`. +/// +/// Derivation: +/// - After `pre_reduce` row ops, tableau's C -> C*A (A = product of CNOTs). +/// Push each CNOT to V: `V_new = A * V_old` (left-multiply). +/// - After projection `(I + sp*P)/2` in effective frame, stored MPS is +/// projected via conjugated `Q = V^dag * P * V`: `stored' = (I+sp*Q)/2*stored`. +/// - `mz_forced` updates tableau: C*A -> C*A*W where `W*Z_id*W^dag = P`. +/// To preserve `effective = C_tableau * V * stored`, absorb `W^-1` into +/// V: `V_new = W^-1 * V` (append `W^-1`'s primitives at end of queue). +/// - For single-flip `P = X_id * Z_{sign}`, `W = CZ(id, s_1)*...*CZ(id, s_k)*H_id` +/// and `W^-1 = H_id * CZ(id, s_1)*...*CZ(id, s_k)`. All cheap primitives +/// (single-site H, diagonal CZ). +/// +/// # Panics +/// +/// Panics if the tableau measurement iterator is empty (should not happen). +pub fn measure_qubit_stab_mps_lazy( + tableau: &mut SparseStabY, + mps: &mut Mps, + rng: &mut PecosRng, + q_idx: usize, + deferred: &mut Vec, +) -> MeasurementResult { + if is_mps_trivial(mps) { + return tableau + .mz(&[pecos_core::QubitId(q_idx)]) + .into_iter() + .next() + .expect("MPS op on valid site"); + } + + // Push pre_reduce CNOTs to deferred instead of applying eagerly. + { + let col_x = &tableau.stabs().col_x[q_idx]; + if col_x.len() > 1 { + let replaced_idx = find_replaced_stabilizer(tableau, q_idx); + let n = tableau.num_qubits(); + let anticom: Vec = tableau.stabs().col_x[q_idx] + .iter() + .filter(|&id| id != replaced_idx) + .collect(); + let stabs_snapshot = tableau.stabs().clone(); + let destabs_snapshot = tableau.destabs().clone(); + for other_id in anticom { + crate::stab_mps::tableau_compose::multiply_row( + tableau.stabs_mut(), + other_id, + &stabs_snapshot, + replaced_idx, + n, + ); + crate::stab_mps::tableau_compose::multiply_row( + tableau.destabs_mut(), + replaced_idx, + &destabs_snapshot, + other_id, + n, + ); + deferred.push(DeferredOp::Cnot(replaced_idx, other_id)); + } + } + } + + let decomp = decompose_z(tableau.stabs(), tableau.destabs(), q_idx); + match decomp { + ZDecomposition::Stabilizer { phase, sign_sites } => { + let mut flip_conj: Vec = Vec::new(); + let mut sign_conj: Vec = sign_sites; + let mut phase_conj = phase; + conjugate_pauli_by_deferred_ops( + &mut flip_conj, + &mut sign_conj, + &mut phase_conj, + deferred, + ); + + let ev = pauli_expectation(mps, &flip_conj, &sign_conj, phase_conj).re; + + if sign_conj.is_empty() && flip_conj.is_empty() { + let outcome = phase_conj.re < 0.0; + tableau.mz_forced(q_idx, outcome); + return MeasurementResult { + outcome, + is_deterministic: true, + }; + } + let prob_plus = f64::midpoint(1.0, ev).clamp(0.0, 1.0); + let is_determ = (ev.abs() - 1.0).abs() < 1e-6; + let outcome = if is_determ { + ev < 0.0 + } else { + rng.random_bool(1.0 - prob_plus) + }; + let sign_f = if outcome { -1.0 } else { 1.0 }; + let prob = if outcome { 1.0 - prob_plus } else { prob_plus }; + apply_pauli_projection(mps, &flip_conj, &sign_conj, phase_conj, sign_f, prob); + MeasurementResult { + outcome, + is_deterministic: is_determ, + } + } + ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } => { + // Pre_reduce ensures flip_sites.len() == 1. Let id = flip_sites[0]. + // Mz_forced will transform tableau as C → C·W where + // W · Z_id · W† = X_id · Z_{sign_sites} + // (the decomposition's Pauli content, phase absorbed in sp). + // Valid W: CZ(id, s_1)·...·CZ(id, s_k) · H_id. + // To preserve invariant, V_new = W⁻¹ · V_old. W⁻¹ = H_id · CZ_chain + // (reversed product with self-adjoint primitives). + let id = if flip_sites.len() == 1 { + flip_sites[0] + } else { + // Shouldn't happen after pre_reduce; use first as fallback. + debug_assert!( + !flip_sites.is_empty(), + "lazy measure: flip_sites empty in DestabilizerFlip" + ); + flip_sites[0] + }; + + // Conjugate the PRE-basis-rotation Pauli by existing V†. + let mut flip_conj: Vec = flip_sites.clone(); + let mut sign_conj: Vec = sign_sites.clone(); + let mut phase_conj = phase; + conjugate_pauli_by_deferred_ops( + &mut flip_conj, + &mut sign_conj, + &mut phase_conj, + deferred, + ); + + let ev = pauli_expectation(mps, &flip_conj, &sign_conj, phase_conj).re; + let prob_plus = f64::midpoint(1.0, ev).clamp(0.0, 1.0); + let outcome = rng.random_bool(1.0 - prob_plus); + let sign_f = if outcome { -1.0 } else { 1.0 }; + let prob = if outcome { 1.0 - prob_plus } else { prob_plus }; + + // Project stored MPS via conjugated Pauli. + apply_pauli_projection(mps, &flip_conj, &sign_conj, phase_conj, sign_f, prob); + // Absorb W⁻¹ into V. W satisfies: + // W · Z_id · W† = sp · X_flip · Z_sign (MPS-frame post-measurement Pauli) + // where `sp = sign_f · phase_conj` (sign_f = -1 if outcome else +1). + // sp is one of {+1, -1, +i, -i}. Hermiticity of Z_id forces a + // dichotomy on `X_flip · Z_sign` (single flip = {id}): + // - id ∉ sign: X_id · Z_sign is Hermitian, sp must be real. + // - id ∈ sign: X_id · Z_id · Z_rest = -i·Y_id·Z_rest is + // anti-Hermitian, sp must be imaginary. + // + // Basis-rotation constructions (each giving W·Z_id·W† = target): + // Real sp, id ∉ sign: + // sp = +1: W = [CZ(id, s) for s∈sign] · H_id + // sp = -1: W = Z_id · [CZ(id, s) for s∈sign] · H_id + // Imaginary sp, id ∈ sign: + // sp = +i: W = [CZ(id, s) for s∈sign\id] · SZ_id · H_id + // sp = -i: W = [CZ(id, s) for s∈sign\id] · SZdg_id · H_id + // + // W⁻¹ reverses the product and adjoints each primitive. Deferred + // queue push order is application order (first-pushed applied + // first), which corresponds to rightmost-in-product. So push + // W⁻¹'s primitives right-to-left: + // + // W is determined by mz_forced's action on the CURRENT tableau + // (post-pre_reduce). Use the original decomposition `phase`, not + // the V-conjugated `phase_conj` — V-conjugation is for MPS + // operations only; the tableau sees the original decomposition. + let sp = Complex64::new(sign_f, 0.0) * phase; + let id_in_sign = sign_sites.contains(&id); + if sp.im.abs() < 1e-9 { + // Real sp branch. id must not be in sign. + debug_assert!( + !id_in_sign, + "lazy measure: real sp={sp:?} but id in sign (expected imaginary)" + ); + if sp.re < 0.0 { + deferred.push(DeferredOp::Z(id)); + } + for &s in &sign_sites { + if s != id { + deferred.push(DeferredOp::Cz(id, s)); + } + } + } else { + // Imaginary sp branch. id must be in sign. + debug_assert!( + id_in_sign, + "lazy measure: imaginary sp={sp:?} but id not in sign (expected real)" + ); + debug_assert!( + sp.re.abs() < 1e-9, + "lazy measure: sp={sp:?} not pure imaginary" + ); + for &s in &sign_sites { + if s != id { + deferred.push(DeferredOp::Cz(id, s)); + } + } + // W inner rotation: SZ for sp=+i, SZdg for sp=-i. + // W⁻¹'s corresponding primitive: SZdg for sp=+i, SZ for sp=-i. + if sp.im > 0.0 { + deferred.push(DeferredOp::SZdg(id)); + } else { + deferred.push(DeferredOp::SZ(id)); + } + } + deferred.push(DeferredOp::H(id)); + + tableau.mz_forced(q_idx, outcome); + MeasurementResult { + outcome, + is_deterministic: false, + } + } + } +} + +/// Apply projection `(I + sign_f · phase · X_flip · Z_sign) / 2` to `mps`, +/// normalized by `1/√prob`. Uses MPS addition; no site-collapse step +/// (caller is responsible for collapse if exact state needed). +fn apply_pauli_projection( + mps: &mut Mps, + flip_sites: &[usize], + sign_sites: &[usize], + phase: Complex64, + sign_f: f64, + prob: f64, +) { + let x_gate = 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), + ], + ); + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + let denom = Complex64::new(2.0 * prob.max(1e-20).sqrt(), 0.0); + if flip_sites.is_empty() && sign_sites.is_empty() { + mps.scale(Complex64::new(1.0, 0.0) + Complex64::new(sign_f, 0.0) * phase); + mps.scale(Complex64::new(1.0, 0.0) / denom); + return; + } + let mut mps_z = mps.clone(); + for &k in sign_sites { + mps_z + .apply_diagonal_one_site(k, &z_diag) + .expect("MPS op on valid site"); + } + for &j in flip_sites { + mps_z + .apply_one_site_gate(j, &x_gate) + .expect("MPS op on valid site"); + } + mps_z.scale(Complex64::new(sign_f, 0.0) * phase / denom); + mps.scale(Complex64::new(1.0, 0.0) / denom); + *mps = mps.add(&mps_z); + mps.compress(); +} + +/// Measure qubit `q_idx` in the Z basis using the STN protocol. +/// +/// # Panics +/// +/// Panics if the tableau measurement iterator is empty (should not happen). +pub fn measure_qubit_stab_mps( + tableau: &mut SparseStabY, + mps: &mut Mps, + rng: &mut PecosRng, + q_idx: usize, +) -> MeasurementResult { + // Trivial MPS: delegate to tableau + if is_mps_trivial(mps) { + return tableau + .mz(&[pecos_core::QubitId(q_idx)]) + .into_iter() + .next() + .expect("MPS op on valid site"); + } + + // Pre-reduce the tableau so that Z_q has at most one anticommuting stabilizer. + // This avoids the problematic multi-flip projection path. + // + // MPS compensation is intentionally SKIPPED here (`false`). Random + // measurement doesn't require exact (tableau, mps) consistency — the + // sampled outcome statistics and subsequent measurement stats remain + // self-consistent (same row ops happen in both forward and reverse + // comparisons). Compensation would trigger O(N) long-range CNOTs per + // measurement (SWAP chain -> exponential bond growth in MAST's + // measurement-heavy workload). Exact-state paths + // (`project_forced_z`, `project_forced_z_unnormalized`) pass `true`. + pre_reduce_for_measurement(tableau, mps, q_idx, false); + + // Compute the expectation value + let ev = z_expectation_value(tableau, mps, q_idx).re; + + let decomp = decompose_z(tableau.stabs(), tableau.destabs(), q_idx); + + match decomp { + ZDecomposition::Stabilizer { phase, sign_sites } => { + // Z_q is in the stabilizer group: measurement is deterministic. + if sign_sites.is_empty() { + let outcome = phase.re < 0.0; + tableau.mz_forced(q_idx, outcome); + return MeasurementResult { + outcome, + is_deterministic: true, + }; + } + let prob_plus = f64::midpoint(1.0, ev).clamp(0.0, 1.0); + + // Check if measurement is deterministic (ev ≈ ±1) + let is_determ = (ev.abs() - 1.0).abs() < 1e-6; + let outcome = if is_determ { + ev < 0.0 + } else { + rng.random_bool(1.0 - prob_plus) + }; + + let sign_f = if outcome { -1.0 } else { 1.0 }; + let prob = if outcome { 1.0 - prob_plus } else { prob_plus }; + + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + let mut mps_z = mps.clone(); + for &k in &sign_sites { + mps_z + .apply_diagonal_one_site(k, &z_diag) + .expect("MPS op on valid site"); + } + mps_z.scale( + Complex64::new(sign_f, 0.0) * phase + / Complex64::new(2.0 * prob.max(1e-20).sqrt(), 0.0), + ); + mps.scale(Complex64::new(1.0 / (2.0 * prob.max(1e-20).sqrt()), 0.0)); + *mps = mps.add(&mps_z); + mps.compress(); + + tableau.mz_forced(q_idx, outcome); + MeasurementResult { + outcome, + is_deterministic: is_determ, + } + } + + ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } => { + let prob_plus = f64::midpoint(1.0, ev).clamp(0.0, 1.0); + let outcome = rng.random_bool(1.0 - prob_plus); + let prob = if outcome { 1.0 - prob_plus } else { prob_plus }; + + if flip_sites.len() == 1 && sign_sites.is_empty() { + // Single flip at site k. Project to eigenstate of phase*X_k. + // After mz_forced: the projected state always goes to σ=0, + // because mz_forced encodes the outcome in the stabilizer sign. + let k = flip_sites[0]; + let chi_r = mps.bond_dim(k + 1); + let sign_f = if outcome { -1.0 } else { 1.0 }; + let sp = Complex64::new(sign_f, 0.0) * phase; + + let block_0 = crate::mps::tensor::phys_block(&mps.tensors()[k], 0, chi_r); + let block_1 = crate::mps::tensor::phys_block(&mps.tensors()[k], 1, chi_r); + let projected = (&block_0 + &block_1 * sp) + / Complex64::new((2.0 * prob).max(1e-20).sqrt(), 0.0); + + let zero = DMatrix::zeros(mps.tensors()[k].nrows(), chi_r); + crate::mps::tensor::set_phys_block(&mut mps.tensors_mut()[k], 0, chi_r, &projected); + crate::mps::tensor::set_phys_block(&mut mps.tensors_mut()[k], 1, chi_r, &zero); + mps.normalize(); + } else { + // Multi-site case with sign_sites: use MPS addition then collapse flip site. + let sign_f = if outcome { -1.0 } else { 1.0 }; + let x_gate = 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), + ], + ); + let z_diag = [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)]; + + let mut mps_z = mps.clone(); + // Apply Z first, then X (order must match z_expectation_value). + for &k in &sign_sites { + mps_z + .apply_diagonal_one_site(k, &z_diag) + .expect("MPS op on valid site"); + } + for &j in &flip_sites { + mps_z + .apply_one_site_gate(j, &x_gate) + .expect("MPS op on valid site"); + } + mps_z.scale( + Complex64::new(sign_f, 0.0) * phase + / Complex64::new(2.0 * prob.max(1e-20).sqrt(), 0.0), + ); + mps.scale(Complex64::new(1.0 / (2.0 * prob.max(1e-20).sqrt()), 0.0)); + *mps = mps.add(&mps_z); + mps.compress(); + + // Collapse the flip site to σ=0. After the MPS addition projector, + // block_1 = sp * block_0 (eigenstate condition). After mz_forced, + // σ=0 is the stabilizer eigenstate. Just zero out σ=1 and renormalize. + if flip_sites.len() == 1 { + let k = flip_sites[0]; + let chi_r = mps.bond_dim(k + 1); + let zero = DMatrix::zeros(mps.tensors()[k].nrows(), chi_r); + crate::mps::tensor::set_phys_block(&mut mps.tensors_mut()[k], 1, chi_r, &zero); + } + + mps.normalize(); + } + + tableau.mz_forced(q_idx, outcome); + MeasurementResult { + outcome, + is_deterministic: false, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mps::MpsConfig; + + fn sort_dedup(v: &mut Vec) { + v.sort_unstable(); + v.dedup(); + } + + #[test] + fn conjugate_single_cnot_x_on_control() { + // V = CNOT(0,1). V†·X_0·V = X_0·X_1. + let mut flip = vec![0]; + let mut sign: Vec = vec![]; + conjugate_pauli_by_deferred(&mut flip, &mut sign, &[(0, 1)]); + sort_dedup(&mut flip); + assert_eq!(flip, vec![0, 1]); + assert!(sign.is_empty()); + } + + #[test] + fn conjugate_single_cnot_z_on_target() { + // V = CNOT(0,1). V†·Z_1·V = Z_0·Z_1. + let mut flip: Vec = vec![]; + let mut sign = vec![1]; + conjugate_pauli_by_deferred(&mut flip, &mut sign, &[(0, 1)]); + sort_dedup(&mut sign); + assert!(flip.is_empty()); + assert_eq!(sign, vec![0, 1]); + } + + #[test] + fn conjugate_cnot_x_on_target_unchanged() { + // V = CNOT(0,1). V†·X_1·V = X_1 (target X unchanged). + let mut flip = vec![1]; + let mut sign: Vec = vec![]; + conjugate_pauli_by_deferred(&mut flip, &mut sign, &[(0, 1)]); + assert_eq!(flip, vec![1]); + assert!(sign.is_empty()); + } + + #[test] + fn conjugate_cnot_z_on_control_unchanged() { + // V = CNOT(0,1). V†·Z_0·V = Z_0 (control Z unchanged). + let mut flip: Vec = vec![]; + let mut sign = vec![0]; + conjugate_pauli_by_deferred(&mut flip, &mut sign, &[(0, 1)]); + assert!(flip.is_empty()); + assert_eq!(sign, vec![0]); + } + + #[test] + fn conjugate_two_cnots_cancels() { + // V = CNOT(0,1)·CNOT(0,1) = I. V†·X_0·V = X_0. + let mut flip = vec![0]; + let mut sign: Vec = vec![]; + conjugate_pauli_by_deferred(&mut flip, &mut sign, &[(0, 1), (0, 1)]); + sort_dedup(&mut flip); + assert_eq!(flip, vec![0]); + } + + #[test] + fn conjugate_cnot_chain_fanout() { + // V = CNOT(0,3)·CNOT(0,2)·CNOT(0,1) — fan-out from qubit 0. + // V†·X_0·V = ? Chain conjugation: innermost first. + // Step 1 (CNOT(0,3)): X_0 -> X_0·X_3. flip={0,3}. + // Step 2 (CNOT(0,2)): X_0 -> X_0·X_2. flip={0,2,3}. + // Step 3 (CNOT(0,1)): X_0 -> X_0·X_1. flip={0,1,2,3}. + let mut flip = vec![0]; + let mut sign: Vec = vec![]; + // Pushed in chronological order: first pushed = CNOT(0,1). + // V = last·...·first = CNOT(0,3)·CNOT(0,2)·CNOT(0,1). + conjugate_pauli_by_deferred(&mut flip, &mut sign, &[(0, 1), (0, 2), (0, 3)]); + sort_dedup(&mut flip); + assert_eq!(flip, vec![0, 1, 2, 3]); + assert!(sign.is_empty()); + } + + #[test] + fn flush_deferred_matches_eager() { + // Two MPS: one where we apply CNOTs eagerly, one where we flush + // the queue at the end. Final states should agree. + let config = MpsConfig::default(); + let num_qubits = 4; + + let mut mps_eager = Mps::new(num_qubits, config.clone()); + // Put into a non-trivial state first: apply H on site 0 via + // single-site gate (to avoid bond-dim 1 trivial case). + let h = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.5_f64.sqrt(), 0.0), + Complex64::new(0.5_f64.sqrt(), 0.0), + Complex64::new(0.5_f64.sqrt(), 0.0), + Complex64::new(-0.5_f64.sqrt(), 0.0), + ], + ); + mps_eager + .apply_one_site_gate(0, &h) + .expect("MPS op on valid site"); + let mut mps_lazy = mps_eager.clone(); + + // Apply CNOT(0,1), CNOT(0,2), CNOT(1,3) eagerly. + let cnots = vec![(0usize, 1usize), (0, 2), (1, 3)]; + for &(c, t) in &cnots { + apply_cnot_to_mps(&mut mps_eager, c, t); + } + + // Flush the same CNOTs. + let mut queue = cnots; + flush_deferred(&mut mps_lazy, &mut queue); + assert!(queue.is_empty()); + + // Compare state vectors. + let sv_e = mps_eager.state_vector(); + let sv_l = mps_lazy.state_vector(); + assert_eq!(sv_e.len(), sv_l.len()); + for (a, b) in sv_e.iter().zip(sv_l.iter()) { + assert!((a - b).norm() < 1e-10, "eager vs lazy differ: {a} vs {b}"); + } + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/non_clifford.rs b/exp/pecos-stab-tn/src/stab_mps/non_clifford.rs new file mode 100644 index 000000000..25c1d7abf --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps/non_clifford.rs @@ -0,0 +1,549 @@ +// 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. + +//! Non-Clifford gate protocol for the STN simulator. +//! +//! Applies RZ(theta) on the MPS using the rotation decomposition approach +//! from the stabilizer-TN reference implementation. Single-site cases use +//! direct 2x2 gates. Multi-site cases use either MPS addition (for all-X +//! or all-Z Pauli strings) or CNOT cascade + RX rotation + basis changes +//! (for mixed Pauli strings with Y overlaps). +//! +//! References: +//! - Masot-Llima, Garcia-Saez. arXiv:2403.08724 (STN protocol). +//! - Reference code: stabilizer-TN `update_xvec` and `apply_xvec_rot`. + +use super::pauli_decomp::{ZDecomposition, decompose_z}; +use crate::mps::Mps; +use nalgebra::DMatrix; +use num_complex::Complex64; +use pecos_simulators::SparseStabY; + +fn z_diag() -> [Complex64; 2] { + [Complex64::new(1.0, 0.0), Complex64::new(-1.0, 0.0)] +} + +fn h_gate() -> DMatrix { + let r = Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0); + DMatrix::from_row_slice(2, 2, &[r, r, r, -r]) +} + +fn s_gate() -> DMatrix { + DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 1.0), + ], + ) +} + +fn sdg_gate() -> DMatrix { + DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, -1.0), + ], + ) +} + +fn rx_gate(theta: f64) -> DMatrix { + let half = theta / 2.0; + let c = Complex64::new(half.cos(), 0.0); + let s = Complex64::new(0.0, -half.sin()); + DMatrix::from_row_slice(2, 2, &[c, s, s, c]) +} + +/// CNOT with first qubit (lower index) as control. +fn cnot_lo_ctrl() -> DMatrix { + let o = Complex64::new(0.0, 0.0); + let one = Complex64::new(1.0, 0.0); + DMatrix::from_row_slice( + 4, + 4, + &[one, o, o, o, o, one, o, o, o, o, o, one, o, o, one, o], + ) +} + +/// CNOT with second qubit (higher index) as control. +fn cnot_hi_ctrl() -> DMatrix { + let o = Complex64::new(0.0, 0.0); + let one = Complex64::new(1.0, 0.0); + DMatrix::from_row_slice( + 4, + 4, + &[one, o, o, o, o, o, o, one, o, o, one, o, o, one, o, o], + ) +} + +/// Per-site Pauli type for the rotation decomposition. +#[derive(Clone, Copy, Debug, PartialEq)] +enum PauliType { + X, + Z, + Y, // Both flip AND sign on the same site. In Y convention: Y = iXZ (Hermitian). +} + +/// Mutable context carried alongside the rotation decomposition. +pub struct RzContext<'a> { + /// Per-site disentangling eigenstate flags. + pub disent_flags: &'a mut [Option], + /// GF(2) flip matrix for OFD diagnostics. + pub gf2_matrix: &'a mut super::ofd::Gf2FlipMatrix, + /// Running statistics for the STN simulator. + pub stats: &'a mut super::StabMpsStats, +} + +/// Apply RZ(theta) on qubit q using the rotation decomposition. +/// +/// Ported from stabilizer-TN's `update_xvec` and `apply_xvec_rot`. +/// Takes &mut tableau because the disentangle path composes compensating +/// Cliffords via right-composition. +/// +/// # Panics +/// +/// Panics if any MPS gate application fails on a valid site. +pub fn apply_rz_stab_mps( + tableau: &mut SparseStabY, + mps: &mut Mps, + cos_half: f64, + sin_half: f64, + q: usize, + normalize: bool, + ctx: &mut RzContext<'_>, +) { + let RzContext { + disent_flags, + gf2_matrix, + stats, + } = ctx; + stats.total_nonclifford += 1; + let decomp = decompose_z(tableau.stabs(), tableau.destabs(), q); + + // OFD diagnostic: check whether this gate's flip pattern is in the span + // of previously-recorded patterns. OFD says such gates can be implemented + // without bond-dim growth (using already-tracked flip structure). + // We also capture this BEFORE the branch decisions for cross-tab stats. + let is_ofd_in_span = if let ZDecomposition::DestabilizerFlip { ref flip_sites, .. } = decomp { + let flip_vec: Vec = flip_sites.clone(); + let in_span = gf2_matrix.is_in_span(&flip_vec); + if in_span { + stats.ofd_in_span += 1; + } else { + stats.ofd_new_dim += 1; + } + in_span + } else { + false + }; + + match decomp { + ZDecomposition::Stabilizer { + phase, + ref sign_sites, + } => { + stats.stabilizer += 1; + if sign_sites.is_empty() { + let scalar = Complex64::new(cos_half, 0.0) - Complex64::new(0.0, sin_half) * phase; + mps.scale(scalar); + // Scalar multiply doesn't modify site states -- flags unchanged. + } else if sign_sites.len() == 1 { + let k = sign_sites[0]; + let c0 = Complex64::new(cos_half, 0.0) - Complex64::new(0.0, sin_half) * phase; + let c1 = Complex64::new(cos_half, 0.0) + Complex64::new(0.0, sin_half) * phase; + mps.apply_diagonal_one_site(k, &[c0, c1]) + .expect("sign_site should be valid"); + disent_flags[k] = None; + } else { + // Multi-site Z diagonal via MPS addition (exact, no SVD until compress). + let mut mps_z = mps.clone(); + let zd = z_diag(); + for &j in sign_sites { + mps_z + .apply_diagonal_one_site(j, &zd) + .expect("MPS op on valid site"); + } + let scale2 = Complex64::new(0.0, -sin_half) * phase; + mps_z.scale(scale2); + mps.scale(Complex64::new(cos_half, 0.0)); + *mps = mps.add(&mps_z); + mps.compress(); + for &j in sign_sites { + disent_flags[j] = None; + } + } + } + + ZDecomposition::DestabilizerFlip { + ref flip_sites, + phase, + ref sign_sites, + } => { + // Build the per-site Pauli map (ind_dict in the reference). + // flip_sites -> X, sign_sites -> Z, both -> Y (= XZ = W) + let mut pauli_map: Vec<(usize, PauliType)> = Vec::new(); + for &j in flip_sites { + pauli_map.push((j, PauliType::X)); + } + for &k in sign_sites { + if let Some(entry) = pauli_map.iter_mut().find(|(s, _)| *s == k) { + entry.1 = PauliType::Y; // X + Z overlap -> Y (= W = XZ) + } else { + pauli_map.push((k, PauliType::Z)); + } + } + + let mut affected_sites: Vec = pauli_map.iter().map(|(s, _)| *s).collect(); + affected_sites.sort_unstable(); // Chain cascade requires sorted order + + if affected_sites.is_empty() { + return; + } + + // OFD disentangle check (Liu-Clark 2412.17209 Algorithm 1 Theorem 1): + // disentanglable iff some qubit i has MPS state |0⟩ AND P[i] ∈ {X, Y}. + // Our `disent_flags[i] = Some(Z(false))` means MPS is |0⟩ at i (fresh + // qubit never touched by a non-Clifford). We only ever have flags + // Z(false) or None after this session's semantic cleanup -- hence + // the simple check below. + let mut disent_site = None; + if affected_sites.len() > 1 { + for &(site, pt) in &pauli_map { + if matches!(pt, PauliType::X | PauliType::Y) + && matches!(disent_flags[site], Some(super::SiteEigenstate::Z(false))) + { + disent_site = Some(site); + break; + } + } + } + + if let Some(rot_site) = disent_site { + stats.multi_disent += 1; + if is_ofd_in_span { + stats.ofd_in_span_disent += 1; + } + // Record effective single-site flip pattern with rot_site metadata + gf2_matrix.add_row_with_meta(&[rot_site], super::ofd::RowMetadata { rot_site }); + + // Compute RX angle. Reference formula: + // co = -i*sin_half*phase * i^(Ys+1) + // After correction co should be real = ±sin(θ/2). + // We want RX(rx_angle)|0⟩ = cos(θ/2)|0⟩ - i·co·|1⟩, so + // rx_angle/2 must satisfy cos(rx_angle/2) = cos(θ/2) AND + // sin(rx_angle/2) = co. arcsin loses the cos sign for |θ/2| > π/2, + // so use the ±1 sign of co combined with the full angle θ. + let y_count = pauli_map.iter().filter(|(_, p)| *p == PauliType::Y).count(); + let mut co = Complex64::new(0.0, -sin_half) * phase; + let i_val = Complex64::new(0.0, 1.0); + let mut factor = Complex64::new(1.0, 0.0); + for _ in 0..=y_count { + factor *= i_val; + } + co *= factor; + debug_assert!( + co.im.abs() < 1e-8, + "co should be real after i^(Ys+1) correction: phase={phase}, Ys={y_count}, co={co}" + ); + // co = sin(θ/2) · s where s = ±1. Recover s from sign of co vs sin_half. + let rx_sign: f64 = if sin_half.abs() < 1e-12 + || (co.re - sin_half).abs() < (co.re + sin_half).abs() + { + 1.0 + } else { + -1.0 + }; + // Full angle: θ = 2·atan2(sin_half, cos_half). Use θ such that + // sin(rx_angle/2) matches co AND cos(rx_angle/2) = cos(θ/2). + // So rx_angle = s · θ. + let theta = 2.0 * sin_half.atan2(cos_half); + let rx_angle = rx_sign * theta; + + // Masot-Llima basis+CNOT pattern (inherited from stabilizer-TN + // reference). A direct CY/CZ "CP cascade" (Liu-Clark Algorithm 1) + // was attempted but produced subtle sign mismatches for rot_pt=Y + // cases driven by the phase pre-correction applied to rx_angle. + // Both patterns are mathematically equivalent; default is the + // tested one — keeping only it to avoid dual-path maintenance. + let rot_pt = pauli_map + .iter() + .find(|&&(s, _)| s == rot_site) + .expect("rot_site must be in pauli_map") + .1; + if matches!(rot_pt, PauliType::Y) { + mps.apply_one_site_gate(rot_site, &s_gate()) + .expect("MPS op on valid site"); + } + mps.apply_one_site_gate(rot_site, &rx_gate(rx_angle)) + .expect("MPS op on valid site"); + for &(site, pt) in &pauli_map { + match pt { + PauliType::Y => super::tableau_compose::right_compose_szdg(tableau, site), + PauliType::Z => super::tableau_compose::right_compose_h(tableau, site), + PauliType::X => {} + } + } + for &(other_site, _) in &pauli_map { + if other_site == rot_site { + continue; + } + super::tableau_compose::right_compose_cx(tableau, rot_site, other_site); + } + for &(site, pt) in &pauli_map { + if site == rot_site { + continue; + } + match pt { + PauliType::Y => super::tableau_compose::right_compose_sz(tableau, site), + PauliType::Z => super::tableau_compose::right_compose_h(tableau, site), + PauliType::X => {} + } + } + + // Clear the flag (rot_site's MPS is no longer |0⟩). + disent_flags[rot_site] = None; + } else if affected_sites.len() == 1 { + stats.single_site += 1; + if is_ofd_in_span { + stats.ofd_in_span_single += 1; + } + // Record single-site flip pattern + gf2_matrix.add_row_with_meta( + &affected_sites, + super::ofd::RowMetadata { + rot_site: affected_sites[0], + }, + ); + let site = affected_sites[0]; + let pt = pauli_map[0].1; + let c = Complex64::new(cos_half, 0.0); + let s = Complex64::new(0.0, -sin_half) * phase; // = -i*sin*phase + + let gate = match pt { + PauliType::X => { + // X = [[0,1],[1,0]] + DMatrix::from_row_slice(2, 2, &[c, s, s, c]) + } + PauliType::Z => { + // Z = [[1,0],[0,-1]] + DMatrix::from_row_slice( + 2, + 2, + &[ + c + s, + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + c - s, + ], + ) + } + PauliType::Y => { + // co = -i·sin·phase · (-1) = i·sin·phase. Ys=1, factor=i^2=-1. + let mut co = Complex64::new(0.0, -sin_half) * phase; + co *= Complex64::new(-1.0, 0.0); + let rx_sign: f64 = if sin_half.abs() < 1e-12 + || (co.re - sin_half).abs() < (co.re + sin_half).abs() + { + 1.0 + } else { + -1.0 + }; + let theta = 2.0 * sin_half.atan2(cos_half); + let rx_angle = rx_sign * theta; + + &sdg_gate() * &(&rx_gate(rx_angle) * &s_gate()) + } + }; + mps.apply_one_site_gate(site, &gate) + .expect("MPS op on valid site"); + // Clear flag for the affected site + disent_flags[site] = None; + } else if sign_sites.is_empty() { + stats.multi_std += 1; + if is_ofd_in_span { + stats.ofd_in_span_std += 1; + } + // Note: std path creates MPS entanglement (not absorbed into + // tableau). Do NOT add to gf2 basis — OFD's is_in_span should + // only match against truly-absorbed rows. + // All flip sites, no Z overlap: operator is cos*I + s*prod(X_j). + // Use MPS addition (exact, no SWAP-chain SVD drift). + let x_gate = 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), + ], + ); + let mut mps_x = mps.clone(); + for &j in flip_sites { + mps_x + .apply_one_site_gate(j, &x_gate) + .expect("MPS op on valid site"); + } + let s = Complex64::new(0.0, -sin_half) * phase; + mps_x.scale(s); + mps.scale(Complex64::new(cos_half, 0.0)); + *mps = mps.add(&mps_x); + mps.compress(); + for &j in flip_sites { + disent_flags[j] = None; + } + } else { + stats.multi_std += 1; + if is_ofd_in_span { + stats.ofd_in_span_std += 1; + } + // Std path creates MPS entanglement; do NOT add to gf2 basis. + // Multi-site rotation via CNOT cascade + RX + basis changes. + + // Count Y sites for the coefficient extraction + let y_count = pauli_map.iter().filter(|(_, p)| *p == PauliType::Y).count(); + + // co = -i*sin*phase · i^(Ys+1). After correction co = ±sin(θ/2). + let mut co = Complex64::new(0.0, -sin_half) * phase; + let i_val = Complex64::new(0.0, 1.0); + let mut factor = Complex64::new(1.0, 0.0); + for _ in 0..=y_count { + factor *= i_val; + } + co *= factor; + + let rx_sign: f64 = if sin_half.abs() < 1e-12 + || (co.re - sin_half).abs() < (co.re + sin_half).abs() + { + 1.0 + } else { + -1.0 + }; + let theta = 2.0 * sin_half.atan2(cos_half); + let rx_angle = rx_sign * theta; + + // Choose rotation site as the median of affected sites. + // This minimizes the total SWAP distance for the CNOT cascade. + let rot_idx = affected_sites.len() / 2; + let rot_site = affected_sites[rot_idx]; + + // Apply basis changes: H for Z, S for Y + for &(site, pt) in &pauli_map { + match pt { + PauliType::Z => { + mps.apply_one_site_gate(site, &h_gate()) + .expect("MPS op on valid site"); + } + PauliType::Y => { + mps.apply_one_site_gate(site, &s_gate()) + .expect("MPS op on valid site"); + } + PauliType::X => {} + } + } + + // CNOT chain cascade (matches reference: stabilizer-TN apply_xvec_rot). + // Chain through consecutive affected sites toward rot_site, + // accumulating parity. Each CNOT has the current site as control + // and the previous site as target (parity accumulator). + // This produces shorter-range CNOTs than the star pattern. + let cnot_lo = cnot_lo_ctrl(); + let cnot_hi = cnot_hi_ctrl(); + + // Helper: apply CNOT with `ctrl` as control and `tgt` as target. + let apply_cnot = |mps: &mut Mps, ctrl: usize, tgt: usize| { + let (lo, hi) = (ctrl.min(tgt), ctrl.max(tgt)); + // cnot_lo = lower qubit controls; cnot_hi = higher qubit controls + let gate = if ctrl < tgt { &cnot_lo } else { &cnot_hi }; + mps.apply_long_range_two_site_gate(lo, hi, gate) + .expect("CNOT should succeed"); + }; + + // Left chain: [0] <- [1] <- ... <- [rot_idx] + // Each step: control=current, target=previous + let mut prev = affected_sites[0]; + for &site in &affected_sites[1..=rot_idx] { + apply_cnot(mps, site, prev); + prev = site; + } + + // Right chain: [last] <- [last-1] <- ... <- [rot_idx] + if rot_idx + 1 < affected_sites.len() { + prev = *affected_sites + .last() + .expect("affected_sites must be non-empty"); + for &site in affected_sites[rot_idx..affected_sites.len() - 1] + .iter() + .rev() + { + apply_cnot(mps, site, prev); + prev = site; + } + } + + // Apply RX on rotation site (parity accumulated here) + mps.apply_one_site_gate(rot_site, &rx_gate(rx_angle)) + .expect("MPS op on valid site"); + + // Reverse CNOT cascade (undo in opposite order) + // Right chain reverse: [rot_idx] -> [rot_idx+1] -> ... -> [last] + if rot_idx + 1 < affected_sites.len() { + prev = affected_sites[rot_idx]; + for &site in &affected_sites[rot_idx + 1..] { + apply_cnot(mps, prev, site); + prev = site; + } + } + + // Left chain reverse: [rot_idx] -> [rot_idx-1] -> ... -> [0] + prev = affected_sites[rot_idx]; + for &site in affected_sites[..rot_idx].iter().rev() { + apply_cnot(mps, prev, site); + prev = site; + } + + // Undo basis changes + for &(site, pt) in &pauli_map { + match pt { + PauliType::Z => { + mps.apply_one_site_gate(site, &h_gate()) + .expect("MPS op on valid site"); + } + PauliType::Y => { + mps.apply_one_site_gate(site, &sdg_gate()) + .expect("MPS op on valid site"); + } + PauliType::X => {} + } + } + // MPS modified at all affected sites -- clear flags. + for &site in &affected_sites { + disent_flags[site] = None; + } + } + } + } + + // Flags are cleared in each branch above, tracking which sites had MPS + // modifications. See branch-specific `disent_flags[...] = None` calls. + + if normalize { + mps.normalize(); + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/ofd.rs b/exp/pecos-stab-tn/src/stab_mps/ofd.rs new file mode 100644 index 000000000..fb480fdd5 --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps/ofd.rs @@ -0,0 +1,439 @@ +// 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. + +//! GF(2) diagnostics for Optimization-Free Disentangling (OFD). +//! +//! Tracks the binary "flip pattern" of each non-Clifford gate applied to the STN. +//! The GF(2) rank of the accumulated flip matrix gives the theoretical minimum +//! bond dimension achievable by Clifford disentangling: `bond_dim` = 2^(t - rank), +//! where t is the number of non-Clifford gates. +//! +//! Based on: Liu & Clark, "Classical simulability of Clifford+T circuits with +//! CAMPS," arXiv:2412.17209 (2024). + +/// Metadata associated with each non-Clifford gate tracked by the OFD matrix. +/// +/// For OFD's fix-up Clifford construction, we need to know which qubit each +/// gate acted on. This lets a later "`in_span`" gate construct its absorption +/// Clifford from combinations of earlier gates' contributions. +#[derive(Clone, Copy, Debug)] +pub struct RowMetadata { + /// The rotation axis qubit for the gate. For multi-site gates, this is + /// the chosen `rot_site`; for single-site, it's the affected site. + pub rot_site: usize, +} + +/// GF(2) matrix tracking flip patterns from non-Clifford gate decompositions. +/// +/// Each row is a binary vector of length `num_sites` (MPS sites). A 1 at position +/// j means the j-th destabilizer index was flipped (X or Y Pauli) in the +/// decomposition of `Z_q` for that non-Clifford gate. +#[derive(Clone, Debug)] +pub struct Gf2FlipMatrix { + num_sites: usize, + /// 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. + metadata: Vec, +} + +impl Gf2FlipMatrix { + /// Create an empty matrix for `num_sites` MPS sites. + #[must_use] + pub fn new(num_sites: usize) -> Self { + Self { + num_sites, + rows: Vec::new(), + metadata: Vec::new(), + } + } + + /// Add a row from a non-Clifford gate's decomposition. + /// + /// `flip_sites` are the destabilizer indices that have X or Y in the + /// decomposition of `Z_q`. Metadata uses a default `rot_site` of 0 if not + /// otherwise known; prefer `add_row_with_meta` for OFD work. + pub fn add_row(&mut self, flip_sites: &[usize]) { + self.add_row_with_meta(flip_sites, RowMetadata { rot_site: 0 }); + } + + /// Add a row with explicit metadata for OFD fix-up construction. + pub fn add_row_with_meta(&mut self, flip_sites: &[usize], meta: RowMetadata) { + let mut row = vec![false; self.num_sites]; + for &site in flip_sites { + if site < self.num_sites { + row[site] = true; + } + } + self.rows.push(row); + self.metadata.push(meta); + } + + /// Metadata for row `i`, if it exists. + #[must_use] + pub fn row_metadata(&self, i: usize) -> Option { + self.metadata.get(i).copied() + } + + /// Number of non-Clifford gates tracked. + #[must_use] + pub fn num_gates(&self) -> usize { + self.rows.len() + } + + /// Compute the GF(2) rank via Gaussian elimination. + /// + /// Returns the rank (number of linearly independent rows over GF(2)). + #[must_use] + pub fn gf2_rank(&self) -> usize { + if self.rows.is_empty() { + return 0; + } + + // Work on a copy for row reduction + let mut matrix: Vec> = self.rows.clone(); + let num_rows = matrix.len(); + let num_cols = self.num_sites; + + let mut current_row = 0; + + // Standard GF(2) Gaussian elimination: sweep over columns. + // Row pointer only advances when a pivot is found. + for col in 0..num_cols { + if current_row >= num_rows { + break; + } + + // Find a row with a 1 in this column at or below current_row + let found = matrix[current_row..] + .iter() + .position(|row| row[col]) + .map(|offset| current_row + offset); + + if let Some(swap_row) = found { + matrix.swap(current_row, swap_row); + + // Eliminate all other 1s in this column. + // We need to XOR the pivot row into other rows, so split + // into slices to avoid double-borrow. + let pivot_row = matrix[current_row].clone(); + for (r, row) in matrix.iter_mut().enumerate() { + if r != current_row && row[col] { + for (cell, &piv) in row.iter_mut().zip(pivot_row.iter()) { + *cell ^= piv; + } + } + } + + current_row += 1; + } + } + + current_row // = rank + } + + /// Theoretical minimum bond dimension achievable by Clifford disentangling. + /// + /// When all non-Clifford gates' flip patterns are linearly independent over + /// GF(2), each can be disentangled to a single site (bond dim stays 1). + /// When there are dependencies, each dependency doubles the bond dim. + /// + /// Returns `2^(num_gates - rank)`. + #[must_use] + pub fn theoretical_min_bond_dim(&self) -> usize { + let t = self.num_gates(); + let r = self.gf2_rank(); + if t <= r { 1 } else { 1 << (t - r) } + } + + /// Reset the matrix (e.g., after simulator reset). + pub fn reset(&mut self) { + self.rows.clear(); + self.metadata.clear(); + } + + /// Check whether a new flip row is in the span of already-added rows. + /// + /// Returns `true` if adding this row would NOT increase the GF(2) rank, + /// meaning the corresponding non-Clifford gate can be implemented using + /// flip patterns already tracked (zero bond-dim growth). + #[must_use] + pub fn is_in_span(&self, new_row: &[usize]) -> bool { + self.span_decomposition(new_row).is_some() + } + + /// Find the linear combination of existing rows whose XOR equals `new_row`. + /// + /// Returns `Some(indices)` if `new_row` is in the span, where `indices` + /// are original row indices whose XOR equals `new_row`. Returns `None` + /// if `new_row` is linearly independent (would grow rank). + /// + /// Algorithm: augment the matrix with identity (tracking which original + /// rows contribute), perform row-reduction, then reduce the target row + /// against the augmented basis. + #[must_use] + pub fn span_decomposition(&self, new_row: &[usize]) -> Option> { + let num_rows = self.rows.len(); + let num_cols = self.num_sites; + // Augmented matrix: each row is (original row bits, provenance bits). + // provenance[i] tracks which original rows have been XORed into this row. + let mut aug: Vec<(Vec, Vec)> = self + .rows + .iter() + .enumerate() + .map(|(i, r)| { + let mut prov = vec![false; num_rows]; + prov[i] = true; + (r.clone(), prov) + }) + .collect(); + + // Gaussian eliminate to RREF, maintaining provenance. + let mut current_row = 0; + for col in 0..num_cols { + if current_row >= num_rows { + break; + } + let found = aug[current_row..] + .iter() + .position(|entry| entry.0[col]) + .map(|offset| current_row + offset); + if let Some(sw) = found { + aug.swap(current_row, sw); + let pivot_data = aug[current_row].0.clone(); + let pivot_prov = aug[current_row].1.clone(); + for (r, entry) in aug.iter_mut().enumerate() { + if r != current_row && entry.0[col] { + // XOR current_row into r (both data and provenance). + for (cell, &piv) in entry.0.iter_mut().zip(pivot_data.iter()) { + *cell ^= piv; + } + for (cell, &piv) in entry.1.iter_mut().zip(pivot_prov.iter()) { + *cell ^= piv; + } + } + } + current_row += 1; + } + } + + // Build target vector. + let mut v = vec![false; num_cols]; + for &s in new_row { + if s < num_cols { + v[s] = true; + } + } + let mut combination = vec![false; num_rows]; + + // Reduce v against RREF basis, accumulating provenance. + for entry in &aug[..current_row] { + if let Some(pivot) = entry.0.iter().position(|&b| b) + && v[pivot] + { + for (vc, &ec) in v.iter_mut().zip(entry.0.iter()) { + *vc ^= ec; + } + for (cc, &ep) in combination.iter_mut().zip(entry.1.iter()) { + *cc ^= ep; + } + } + } + + // If v is all-zero, the new_row was in span; combination gives the decomposition. + if v.iter().all(|&b| !b) { + Some( + combination + .iter() + .enumerate() + .filter_map(|(i, &b)| if b { Some(i) } else { None }) + .collect(), + ) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_matrix() { + let m = Gf2FlipMatrix::new(4); + assert_eq!(m.gf2_rank(), 0); + assert_eq!(m.theoretical_min_bond_dim(), 1); + } + + #[test] + fn test_single_row() { + let mut m = Gf2FlipMatrix::new(4); + m.add_row(&[0, 2]); // flip sites 0 and 2 + assert_eq!(m.gf2_rank(), 1); + assert_eq!(m.theoretical_min_bond_dim(), 1); // 2^(1-1) = 1 + } + + #[test] + fn test_two_independent_rows() { + let mut m = Gf2FlipMatrix::new(4); + m.add_row(&[0]); // [1,0,0,0] + m.add_row(&[1]); // [0,1,0,0] + assert_eq!(m.gf2_rank(), 2); + assert_eq!(m.theoretical_min_bond_dim(), 1); // 2^(2-2) = 1 + } + + #[test] + fn test_two_dependent_rows() { + let mut m = Gf2FlipMatrix::new(4); + m.add_row(&[0, 1]); // [1,1,0,0] + m.add_row(&[0, 1]); // [1,1,0,0] -- same row + assert_eq!(m.gf2_rank(), 1); + assert_eq!(m.theoretical_min_bond_dim(), 2); // 2^(2-1) = 2 + } + + #[test] + fn test_three_rows_one_dependent() { + let mut m = Gf2FlipMatrix::new(4); + m.add_row(&[0, 1]); // [1,1,0,0] + m.add_row(&[1, 2]); // [0,1,1,0] + m.add_row(&[0, 2]); // [1,0,1,0] = row1 XOR row2 + assert_eq!(m.gf2_rank(), 2); + assert_eq!(m.theoretical_min_bond_dim(), 2); // 2^(3-2) = 2 + } + + #[test] + fn test_full_rank_n_equals_t() { + // 4 independent rows in 4 columns = rank 4 + let mut m = Gf2FlipMatrix::new(4); + m.add_row(&[0]); + m.add_row(&[1]); + m.add_row(&[2]); + m.add_row(&[3]); + assert_eq!(m.gf2_rank(), 4); + assert_eq!(m.theoretical_min_bond_dim(), 1); + } + + #[test] + fn test_is_in_span_empty() { + let m = Gf2FlipMatrix::new(3); + // Empty basis -- only zero vector is in span. + assert!(m.is_in_span(&[])); // all-zero row is always in span (trivially) + assert!(!m.is_in_span(&[0])); + assert!(!m.is_in_span(&[1, 2])); + } + + #[test] + fn test_is_in_span_single_row() { + let mut m = Gf2FlipMatrix::new(3); + m.add_row(&[0]); // basis: {e_0} + assert!(m.is_in_span(&[0])); + assert!(!m.is_in_span(&[1])); + assert!(!m.is_in_span(&[0, 1])); // e_0 + e_1 not in span of {e_0} + } + + #[test] + fn test_is_in_span_dependency() { + let mut m = Gf2FlipMatrix::new(3); + m.add_row(&[0]); + m.add_row(&[1]); + // Now {e_0, e_1} basis. e_0 XOR e_1 = (1,1,0) is in span. + assert!(m.is_in_span(&[0, 1])); + // e_2 is NOT in span. + assert!(!m.is_in_span(&[2])); + // e_0 XOR e_1 XOR e_2 is NOT in span (needs e_2). + assert!(!m.is_in_span(&[0, 1, 2])); + } + + #[test] + fn test_span_decomposition_simple() { + let mut m = Gf2FlipMatrix::new(3); + m.add_row(&[0]); // row 0: e_0 + m.add_row(&[1]); // row 1: e_1 + // e_0 + e_1 = (1,1,0) should decompose to {0, 1}. + let dep = m.span_decomposition(&[0, 1]).expect("in span"); + assert_eq!(dep, vec![0, 1]); + // e_0 alone decomposes to {0}. + let dep = m.span_decomposition(&[0]).expect("in span"); + assert_eq!(dep, vec![0]); + // e_2 is not in span. + assert!(m.span_decomposition(&[2]).is_none()); + } + + #[test] + fn test_span_decomposition_verify_xor() { + // Property: the returned indices XOR to the input row. + let mut m = Gf2FlipMatrix::new(5); + m.add_row(&[0, 1]); + m.add_row(&[2, 3]); + m.add_row(&[1, 3, 4]); + m.add_row(&[0, 2, 4]); // Should be dependent: row0 XOR row1 XOR row2 = (1,1,0,0,0) XOR (0,0,1,1,0) XOR (0,1,0,1,1) = (1,0,1,0,1) + // Test that (1,0,1,0,1) decomposes properly. + let target = &[0, 2, 4]; + let dep = m.span_decomposition(target).expect("should be in span"); + // Verify the XOR reconstructs target. + let mut recon = vec![false; 5]; + for &i in &dep { + for (rc, &rv) in recon.iter_mut().zip(m.rows[i].iter()) { + *rc ^= rv; + } + } + let mut target_vec = vec![false; 5]; + for &s in target { + target_vec[s] = true; + } + assert_eq!(recon, target_vec, "XOR of rows {dep:?} should equal target"); + } + + #[test] + fn test_is_in_span_matches_rank_check() { + // Property: is_in_span(row) iff adding row doesn't change rank. + let mut m = Gf2FlipMatrix::new(4); + m.add_row(&[0, 1]); + m.add_row(&[2, 3]); + m.add_row(&[0, 2]); + let rank_before = m.gf2_rank(); + for row in [ + vec![0], + vec![1], + vec![2], + vec![3], + vec![0, 1], + vec![1, 2], + vec![0, 1, 2, 3], + ] { + let in_span = m.is_in_span(&row); + let mut m2 = m.clone(); + m2.add_row(&row); + let rank_after = m2.gf2_rank(); + assert_eq!( + in_span, + rank_after == rank_before, + "row {row:?}: is_in_span={in_span} but rank {rank_before} -> {rank_after}" + ); + } + } + + #[test] + fn test_more_rows_than_cols() { + // 5 rows, 3 cols -> rank <= 3, so at least 2 dependencies + let mut m = Gf2FlipMatrix::new(3); + m.add_row(&[0]); + m.add_row(&[1]); + m.add_row(&[2]); + m.add_row(&[0, 1]); // dependent: row1 XOR row2 + m.add_row(&[1, 2]); // dependent: row2 XOR row3 + assert_eq!(m.gf2_rank(), 3); + assert_eq!(m.theoretical_min_bond_dim(), 4); // 2^(5-3) = 4 + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/pauli_decomp.rs b/exp/pecos-stab-tn/src/stab_mps/pauli_decomp.rs new file mode 100644 index 000000000..6b9d97e1e --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps/pauli_decomp.rs @@ -0,0 +1,1019 @@ +// 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. + +//! Decompose Pauli operators in the stabilizer/destabilizer basis. +//! +//! The stabilizer tableau defines a basis for the Pauli group. Given stabilizer +//! generators {`S_0`, ..., S_{N-1}} and destabilizer generators {`D_0`, ..., D_{N-1}} +//! where `D_i` anticommutes with `S_i` and commutes with all other `S_j`, any Pauli +//! operator P can be written as: +//! +//! ```text +//! P = phase * prod_i S_i^{s_i} * prod_j D_j^{d_j} +//! ``` +//! +//! For the STN simulator, we need to decompose `Z_q` (Z on qubit q) in this basis. +//! The decomposition determines how RZ(theta) acts on the MPS. +//! +//! Uses the Y-convention phase table (matching the stabilizer-TN reference and +//! PECOS `SparseStabY`), where (x=1, z=1) represents Y (Hermitian, Y²=I). +//! +//! Reference: stabilizer-TN `gate_decomposition` function. + +use num_complex::Complex64; +use pecos_core::IndexSet; +use pecos_simulators::GensGeneric; + +/// Single-qubit Pauli kind for `decompose_pauli_string`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PauliKindForDecomp { + X, + Y, + Z, +} + +/// Decompose an arbitrary multi-qubit physical Pauli string into MPS-frame +/// `(flip_sites, sign_sites, phase)` such that +/// `C† · P · C = phase · X_{flip_sites} · Z_{sign_sites}` +/// where `C` is the Clifford encoded by the tableau. Used for +/// expectation-value computation `⟨Ψ|P|Ψ⟩ = ⟨MPS|C†·P·C|MPS⟩` at any `n`. +/// +/// Algorithm: each single-qubit factor's anticommutation set with the +/// stabilizer generators (`stabs`/`destabs`) determines its contribution +/// to `flip_sites`/`sign_sites`. Multiple factors XOR the contributions. +/// `Y = i·X·Z` adds combined contributions plus an `i` phase factor per +/// `Y` factor (`i^{Y-count}`). +/// +/// Anticommutation lookups: +/// - `X_q` anticom with `S_j` ⇔ `S_j` has `Z` or `Y` at q ⇔ `j ∈ stabs.col_z[q]`. +/// - `Z_q` anticom with `S_j` ⇔ `S_j` has `X` or `Y` at q ⇔ `j ∈ stabs.col_x[q]`. +/// - `Y_q` anticom with `S_j` ⇔ `S_j` has `X`-only or `Z`-only at q ⇔ +/// `j ∈ (stabs.col_x[q] ⊕ stabs.col_z[q])`. +/// # Panics +/// +/// Panics if any qubit index in `pauli` is >= the number of qubits in the +/// tableau. +pub fn decompose_pauli_string( + stabs: &GensGeneric, + destabs: &GensGeneric, + pauli: &[(usize, PauliKindForDecomp)], +) -> (Vec, Vec, Complex64) { + let n = stabs.get_num_qubits(); + // Y-convention single-qubit Pauli multiplication phase table. + // Indexing: Pauli codes I=0, Z=1, X=2, Y=3 (= 2*x_bit + z_bit). + // Entry y_table[a][b] = phase of (Pauli a) · (Pauli b) under the + // convention that Y = iXZ = (1,1) bit pattern is "pure Y" with no + // implicit phase. + let y_table: [[Complex64; 4]; 4] = { + let one = Complex64::new(1.0, 0.0); + let pi = Complex64::new(0.0, 1.0); + let mi = Complex64::new(0.0, -1.0); + [ + [one, one, one, one], // I · {I, Z, X, Y} + [one, one, pi, mi], // Z · {I, Z, X, Y} + [one, mi, one, pi], // X · {I, Z, X, Y} + [one, pi, mi, one], // Y · {I, Z, X, Y} + ] + }; + let pauli_code = |k: PauliKindForDecomp| -> u8 { + match k { + PauliKindForDecomp::Z => 1, + PauliKindForDecomp::X => 2, + PauliKindForDecomp::Y => 3, + } + }; + + // Aggregate per-qubit Pauli factors with Pauli multiplication phase. + // per_q[q] = (current Pauli bits at qubit q, accumulated phase). + let mut per_q: Vec<(u8, Complex64)> = vec![(0, Complex64::new(1.0, 0.0)); n]; + for &(q, kind) in pauli { + assert!(q < n, "decompose_pauli_string: qubit {q} >= num_qubits {n}"); + let new_code = pauli_code(kind); + let cur = per_q[q]; + let phase_factor = y_table[cur.0 as usize][new_code as usize]; + per_q[q] = ((cur.0 ^ new_code), cur.1 * phase_factor); + } + + // Aggregate flip/sign from per-qubit Pauli bits + total user-supplied + // Pauli phase. Each X-bit at q contributes the X anticommutation set; + // each Z-bit at q contributes the Z anticommutation set. A qubit with + // Y bits (1, 1) contributes BOTH (XOR of X- and Z-anticom sets); the + // associated `i` factor of Y = iXZ is captured naturally by the + // y_table in `compute_decomposition_phase`. + let mut flip = S::new(); + let mut sign = S::new(); + let mut total_phase = Complex64::new(1.0, 0.0); + for (q, &(bits, p)) in per_q.iter().enumerate() { + total_phase *= p; + let x_bit = (bits >> 1) & 1; + let z_bit = bits & 1; + if x_bit == 1 { + flip.xor_assign(&stabs.col_z[q]); + sign.xor_assign(&destabs.col_z[q]); + } + if z_bit == 1 { + flip.xor_assign(&stabs.col_x[q]); + sign.xor_assign(&destabs.col_x[q]); + } + } + + let flip_vec: Vec = flip.iter().collect(); + let sign_vec: Vec = sign.iter().collect(); + + let phase_from_compute = compute_decomposition_phase(stabs, destabs, &flip_vec, &sign_vec); + let final_phase = phase_from_compute * total_phase; + (flip_vec, sign_vec, final_phase) +} + +/// Result of decomposing `Z_q` in the stabilizer/destabilizer basis. +#[derive(Debug)] +pub enum ZDecomposition { + /// `Z_q` is in the stabilizer group (no destabilizer component). + /// + /// `Z_q` = phase * prod_{j in `sign_sites`} `S_j` + /// + /// On the MPS, each `S_j` contributes (-1) when the j-th destabilizer + /// index is active. When `sign_sites` is empty, this is a global scalar. + Stabilizer { + /// Overall phase from the decomposition. + phase: Complex64, + /// Stabilizer indices whose product appears in the decomposition. + /// The MPS picks up (-1) for each site j where the destabilizer is active. + sign_sites: Vec, + }, + + /// `Z_q` has a destabilizer component. + /// + /// `Z_q` = phase * (prod_{j in `flip_sites`} `D_j`) * (prod_{k in `sign_sites`} `S_k`) + /// + /// Acting on the MPS: + /// - Each `D_j` flips (X gate) the physical index at MPS site j + /// - Each `S_k` contributes (-1) (Z gate) when the k-th destabilizer is active + /// - The overall complex phase is included + DestabilizerFlip { + /// Destabilizer indices that get flipped (X gates on MPS). + flip_sites: Vec, + /// Overall complex phase from the decomposition. + phase: Complex64, + /// Stabilizer indices whose product appears in the decomposition. + /// The MPS picks up (-1) for each site k where the destabilizer is active. + sign_sites: Vec, + }, +} + +/// Brute-force verify a decomposition by constructing 2^n x 2^n matrices. +/// Only usable for small n (say n<=6). Returns true if correct. +/// +/// # Panics +/// +/// Panics if the number of qubits exceeds what can be represented as a +/// matrix dimension (realistically n > 20). +pub fn verify_decomposition_brute_force( + stabs: &GensGeneric, + destabs: &GensGeneric, + q: usize, + decomp: &ZDecomposition, +) -> bool { + use nalgebra::DMatrix; + + let n = stabs.get_num_qubits(); + let dim = 1usize << n; + let i_mat = DMatrix::::identity(2, 2); + let x_mat = 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), + ], + ); + let z_mat_1q = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(-1.0, 0.0), + ], + ); + let y_mat = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(0.0, -1.0), + Complex64::new(0.0, 1.0), + Complex64::new(0.0, 0.0), + ], + ); + + let gen_matrix = |gens: &GensGeneric, row: usize| -> DMatrix { + let mut result = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + for qq in 0..n { + let has_x = gens.row_x[row].contains(qq); + let has_z = gens.row_z[row].contains(qq); + let pauli = match (has_x, has_z) { + (false, false) => &i_mat, + (true, false) => &x_mat, + (false, true) => &z_mat_1q, + (true, true) => &y_mat, + }; + result = result.kronecker(pauli); + } + let mut phase = Complex64::new(1.0, 0.0); + if gens.signs_minus.contains(row) { + phase *= Complex64::new(-1.0, 0.0); + } + if gens.signs_i.contains(row) { + phase *= Complex64::new(0.0, 1.0); + } + result * phase + }; + + // Build Z_q matrix + let mut z_mat = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + for qq in 0..n { + let p = if qq == q { &z_mat_1q } else { &i_mat }; + z_mat = z_mat.kronecker(p); + } + + match decomp { + ZDecomposition::Stabilizer { phase, sign_sites } => { + let mut product = DMatrix::::identity(dim, dim); + for &k in sign_sites { + product = gen_matrix(stabs, k) * product; + } + product *= *phase; + (&z_mat - &product).norm() < 1e-10 + } + ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } => { + let mut product = DMatrix::::identity(dim, dim); + for &k in sign_sites { + product = gen_matrix(stabs, k) * product; + } + for &j in flip_sites { + product = gen_matrix(destabs, j) * product; + } + product *= *phase; + let diff = (&z_mat - &product).norm(); + if diff > 1e-10 { + // Find the correct phase by dividing z_mat by the unsigned product + let mut unsigned_product = DMatrix::::identity(dim, dim); + for &k in sign_sites { + unsigned_product = gen_matrix(stabs, k) * unsigned_product; + } + for &j in flip_sites { + unsigned_product = gen_matrix(destabs, j) * unsigned_product; + } + // Z = correct_phase * unsigned_product, so correct_phase = (Z * unsigned_product†)[0,0] + // Since both are unitary Pauli products, the correct phase is Z[0,0] / product[0,0] + // Or more robustly: trace(Z * product†) / dim + let adj = unsigned_product.adjoint(); + let correct = (&z_mat * &adj).trace() + / Complex64::new(f64::from(u32::try_from(dim).unwrap()), 0.0); + eprintln!(" PHASE MISMATCH: diff={diff:.4e}"); + eprintln!(" computed phase={phase}"); + eprintln!(" correct phase={correct:.4}"); + eprintln!(" flip={flip_sites:?}, sign={sign_sites:?}"); + } + diff < 1e-10 + } + } +} + +/// Decompose `Z_q` in the stabilizer/destabilizer basis. +/// +/// This mirrors the measurement logic in `SparseStabY`: `Z_q` is deterministic +/// (in the stabilizer group) when `stabs.col_x[q]` is empty, and requires +/// destabilizer decomposition otherwise. +pub fn decompose_z( + stabs: &GensGeneric, + destabs: &GensGeneric, + q: usize, +) -> ZDecomposition { + if stabs.col_x[q].is_empty() { + // Z_q commutes with all stabilizers -> it's in the stabilizer group. + // Z_q = phase * prod_{j in sign_sites} S_j + // where sign_sites = destabs.col_x[q] (destabilizers that anticommute with Z_q) + let sign = compute_stabilizer_sign(stabs, destabs, q); + let sign_sites: Vec = destabs.col_x[q].iter().collect(); + let phase = if sign < 0.0 { + Complex64::new(-1.0, 0.0) + } else { + Complex64::new(1.0, 0.0) + }; + ZDecomposition::Stabilizer { phase, sign_sites } + } else { + // Z_q anticommutes with at least one stabilizer. + // Find the destabilizer row that anticommutes with Z_q. + decompose_z_nondeterministic(stabs, destabs, q) + } +} + +/// Compute the sign of `Z_q` when it is in the stabilizer group. +/// +/// `Z_q` = (+/-1) * product of stabilizers. The sign is computed by tracking +/// how the destabilizer generators that have X on qubit q combine. +/// +/// This follows the same logic as `SparseStabY::deterministic_meas`. +fn compute_stabilizer_sign( + stabs: &GensGeneric, + destabs: &GensGeneric, + q: usize, +) -> f64 { + // Count minus signs from the destabilizer generators that have X on qubit q. + // These destabs "activate" certain stabilizers to reconstruct Z_q. + let mut num_minuses = destabs.col_x[q].intersection_count(&stabs.signs_minus); + let mut num_is = destabs.col_x[q].intersection_count(&stabs.signs_i); + + // Y-convention correction: add n_Y per participating stab + for row in destabs.col_x[q].iter() { + num_is += stabs.row_x[row].intersection_count(&stabs.row_z[row]); + } + + // W-convention commutation phase accumulation + let mut cumulative_x = S::new(); + for row in destabs.col_x[q].iter() { + num_minuses += stabs.row_z[row].intersection_count(&cumulative_x); + cumulative_x.xor_assign(&stabs.row_x[row]); + } + + // Convert i-count to sign. For the Stabilizer branch the total + // phase must be real (±1), so i-count must be even. + debug_assert!( + num_is.is_multiple_of(2), + "stabilizer sign: i-count {num_is} must be even for real phase" + ); + if num_is % 4 == 2 { + num_minuses += 1; + } + + if num_minuses & 1 != 0 { -1.0 } else { 1.0 } +} + +/// Decompose `Z_q` when it anticommutes with at least one stabilizer. +/// +/// `Z_q` = phase * (product of some stabilizers) * `D_k` +/// +/// We need to find: +/// - k: which destabilizer to flip +/// - phase: the overall complex phase +/// - `sign_sites`: which other stabilizer indices contribute signs +fn decompose_z_nondeterministic( + stabs: &GensGeneric, + destabs: &GensGeneric, + q: usize, +) -> ZDecomposition { + // The destabilizer col_x[q] tells us which destabilizer generators have X on qubit q. + // These are the ones that anticommute with Z_q. + // + // For STN, we need to pick one destabilizer D_k such that Z_q can be written as + // a product of stabilizers times D_k. The standard choice: pick the first + // destabilizer that anticommutes with Z_q (analogous to how measurement picks + // the first anticommuting stabilizer, but here we look at destabilizers). + // + // Actually, the key insight: in the stabilizer formalism, Z_q anticommutes with + // stabilizer generators indexed by stabs.col_x[q]. The destabilizer D_k that + // "pairs" with one of these stabilizers is the one we use. + // + // For the simplest decomposition: take the first anticommuting stabilizer index k. + // Then Z_q = (phase) * (product of stabilizers that anticommute with D_k's effect) * D_k. + // + // The key relationship: destabilizer D_k anticommutes with S_k and commutes with + // all other stabilizers. When we write Z_q in terms of destabilizers and stabilizers, + // the destabilizer component is determined by which stabilizers Z_q anticommutes with. + + // Pick the first stabilizer that anticommutes with Z_q. + // The paired destabilizer at that index is our flip site. + // Now we need to figure out what stabilizer product accompanies the destabilizers. + // Z_q * D_k should commute with all stabilizers (since Z_q anticommutes with S_k + // and D_k anticommutes with S_k, their product commutes with S_k). + // But Z_q might also anticommute with other stabilizers S_j (j != k). + // For those, we need additional destabilizer flips... or stabilizer factors. + // + // Actually, in the STN framework the decomposition is simpler. We express Z_q as: + // Z_q = phase * (prod of some S_j's) * (prod of some D_j's) + // + // The destabilizer part: check destabs.col_x[q] to find all destabilizers that + // have X or Y on qubit q. Wait -- that's the wrong direction. + // + // Let me reconsider. The correct approach follows from the symplectic structure: + // + // The stabilizer/destabilizer tableau T = [D_0, ..., D_{n-1}, S_0, ..., S_{n-1}] + // forms a symplectic basis. Any Pauli P can be uniquely decomposed as: + // P = phase * prod_i D_i^{d_i} * prod_j S_j^{s_j} + // + // where d_i = 1 iff P anticommutes with S_i, + // and s_j = 1 iff P anticommutes with D_j. + // + // For P = Z_q: + // - d_i = 1 iff Z_q anticommutes with S_i, i.e., S_i has X or Y on qubit q + // -> d_i = 1 for i in stabs.col_x[q] + // - s_j = 1 iff Z_q anticommutes with D_j, i.e., D_j has X or Y on qubit q + // -> s_j = 1 for j in destabs.col_x[q] + // + // The phase comes from the ordering and signs of the generators. + + // Collect the destabilizer flip sites (d_i = 1) + let flip_sites: Vec = stabs.col_x[q].iter().collect(); + + // Collect the stabilizer sign sites (s_j = 1) + let sign_sites: Vec = destabs.col_x[q].iter().collect(); + + let phase = compute_decomposition_phase(stabs, destabs, &flip_sites, &sign_sites); + + ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } +} + +/// Compute the complex phase of the decomposition `Z_q` = phase * prod(D) * prod(S). +/// +/// Uses the Y-convention per-qubit phase table (matching the stabilizer-TN reference). +/// In Y convention: (x=1, z=1) = Y = iXZ, and Y^2 = I. +fn compute_decomposition_phase( + stabs: &GensGeneric, + destabs: &GensGeneric, + flip_sites: &[usize], + sign_sites: &[usize], +) -> Complex64 { + // Cumulative Pauli string (x, z) parts and complex phase. + let mut cum_x = S::new(); + let mut cum_z = S::new(); + let mut phase = Complex64::new(1.0, 0.0); + + // Helper: multiply cumulative Pauli by a generator, accumulating phase. + let multiply_generator = |cum_x: &mut S, + cum_z: &mut S, + phase: &mut Complex64, + gen_x: &S, + gen_z: &S, + gen_is_minus: bool, + gen_is_i: bool| { + // Generator's own sign + if gen_is_minus { + *phase *= Complex64::new(-1.0, 0.0); + } + if gen_is_i { + *phase *= Complex64::new(0.0, 1.0); + } + + // Per-qubit phase using Y-convention table (matches reference). + // In Y convention: I=0, Z=1, X=2, Y=3 where (1,1) = Y = iXZ. + // Reference: phase_mat = [[1,1,1,1],[1,1,1j,-1j],[1,-1j,1,1j],[1,1j,-1j,1]] + let y_table: [[Complex64; 4]; 4] = { + let one = Complex64::new(1.0, 0.0); + let pi = Complex64::new(0.0, 1.0); + let mi = Complex64::new(0.0, -1.0); + [ + [one, one, one, one], // I * {I,Z,X,Y} + [one, one, pi, mi], // Z * {I,Z,X,Y} + [one, mi, one, pi], // X * {I,Z,X,Y} + [one, pi, mi, one], // Y * {I,Z,X,Y} + ] + }; + + for q in gen_x.iter() { + let p1 = 2 * usize::from(cum_x.contains(q)) + usize::from(cum_z.contains(q)); + let p2 = 2 + usize::from(gen_z.contains(q)); + *phase *= y_table[p1][p2]; + } + for q in gen_z.iter() { + if gen_x.contains(q) { + continue; + } + let p1 = 2 * usize::from(cum_x.contains(q)) + usize::from(cum_z.contains(q)); + *phase *= y_table[p1][1]; + } + + // Update cumulative Pauli + cum_x.xor_assign(gen_x); + cum_z.xor_assign(gen_z); + }; + + // Multiply destabilizers (D_j for j in flip_sites) + for &j in flip_sites { + let is_minus = destabs.signs_minus.contains(j); + let is_i = destabs.signs_i.contains(j); + multiply_generator( + &mut cum_x, + &mut cum_z, + &mut phase, + &destabs.row_x[j], + &destabs.row_z[j], + is_minus, + is_i, + ); + } + + // Multiply stabilizers (S_k for k in sign_sites) + for &k in sign_sites { + let is_minus = stabs.signs_minus.contains(k); + let is_i = stabs.signs_i.contains(k); + multiply_generator( + &mut cum_x, + &mut cum_z, + &mut phase, + &stabs.row_x[k], + &stabs.row_z[k], + is_minus, + is_i, + ); + } + + // (Z_q-specific sanity check removed: this routine is now also used by + // `decompose_pauli_string` for arbitrary Pauli decompositions where the + // cumulative X part can be non-empty.) + + // phase = product_phase (the phase of prod(D)*prod(S) as a Pauli string). + // We need decomp_phase such that Z_q = decomp_phase * prod. + // So decomp_phase = 1 / product_phase. + + if phase.norm_sqr() > 1e-20 { + Complex64::new(1.0, 0.0) / phase + } else { + Complex64::new(1.0, 0.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nalgebra::DMatrix; + use pecos_core::QubitId; + use pecos_simulators::{CliffordGateable, SparseStabY}; + + /// Build the 2^n x 2^n Pauli matrix for generator `row` of `gens`. + /// In Y-convention: (x=1,z=1) = Y (Hermitian). + fn generator_matrix( + gens: &GensGeneric, + row: usize, + n: usize, + ) -> DMatrix { + let i_mat = DMatrix::::identity(2, 2); + let x_mat = 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), + ], + ); + let z_mat = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(-1.0, 0.0), + ], + ); + let y_mat = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(0.0, -1.0), + Complex64::new(0.0, 1.0), + Complex64::new(0.0, 0.0), + ], + ); + + // Build tensor product of per-qubit Paulis + let mut result = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + for q in 0..n { + let has_x = gens.row_x[row].contains(q); + let has_z = gens.row_z[row].contains(q); + let pauli = match (has_x, has_z) { + (false, false) => &i_mat, + (true, false) => &x_mat, + (false, true) => &z_mat, + (true, true) => &y_mat, // Y convention + }; + result = result.kronecker(pauli); + } + + // Apply phase: (-1)^minus * i^i_bit + let mut phase = Complex64::new(1.0, 0.0); + if gens.signs_minus.contains(row) { + phase *= Complex64::new(-1.0, 0.0); + } + if gens.signs_i.contains(row) { + phase *= Complex64::new(0.0, 1.0); + } + + result * phase + } + + /// Build `Z_q` as a 2^n x 2^n matrix. + fn z_matrix(q: usize, n: usize) -> DMatrix { + let i_mat = DMatrix::::identity(2, 2); + let z_mat = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(-1.0, 0.0), + ], + ); + let mut result = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + for qq in 0..n { + let pauli = if qq == q { &z_mat } else { &i_mat }; + result = result.kronecker(pauli); + } + result + } + + /// Brute-force verify decomposition phase by matrix multiplication. + fn verify_decomposition_phase(sim: &SparseStabY, q: usize) { + let n = sim.stabs().get_num_qubits(); + let decomp = decompose_z(sim.stabs(), sim.destabs(), q); + + let z_mat = z_matrix(q, n); + + match &decomp { + ZDecomposition::Stabilizer { phase, sign_sites } => { + // Z_q = phase * prod(S_k for k in sign_sites) + let dim = 1usize << n; + let mut product = DMatrix::::identity(dim, dim); + for &k in sign_sites { + product = generator_matrix(sim.stabs(), k, n) * product; + } + product *= *phase; + // Check Z_q == product + let diff = (&z_mat - &product).norm(); + assert!( + diff < 1e-10, + "Stabilizer decomp for Z_{q}: ||Z - phase*prod(S)|| = {diff:.4e}, phase={phase}" + ); + } + ZDecomposition::DestabilizerFlip { + flip_sites, + phase, + sign_sites, + } => { + // Z_q = phase * prod(D_j) * prod(S_k) + let dim = 1usize << n; + let mut product = DMatrix::::identity(dim, dim); + // Multiply stabilizers first (rightmost) + for &k in sign_sites { + product = generator_matrix(sim.stabs(), k, n) * product; + } + // Then destabilizers + for &j in flip_sites { + product = generator_matrix(sim.destabs(), j, n) * product; + } + product *= *phase; + let diff = (&z_mat - &product).norm(); + assert!( + diff < 1e-10, + "DestabFlip decomp for Z_{q}: ||Z - phase*prod(D)*prod(S)|| = {diff:.4e}\n \ + phase={phase}, flip={flip_sites:?}, sign={sign_sites:?}" + ); + } + } + } + + // Phase verification is done via the verification test (uses StabMps which has destab sign fixups). + + #[test] + fn test_destab_sign_tracking_z() { + // Initial state: D_0 = X_0. Z(0) conjugates X_0 → -X_0. + // With destab sign tracking, the minus should appear in signs_minus. + let mut sim = SparseStabY::new(2).with_destab_sign_tracking(); + + // Initial: D_0 = X_0 (no minus) + assert!(sim.destabs().row_x[0].contains(0)); + assert!(!sim.destabs().signs_minus.contains(0)); + + // Z(0) should conjugate: Z*X*Z = -X + sim.z(&[QubitId(0)]); + + assert!( + sim.destabs().row_x[0].contains(0), + "D[0] should still have X on q0" + ); + assert!( + sim.destabs().signs_minus.contains(0), + "D[0] should have minus=true after Z (Z*X*Z = -X)" + ); + } + + #[test] + fn test_decomposition_phase_brute_force_seed102_circuit() { + // Reproduce the seed 102 Clifford prefix before the failing T gate. + // Gate sequence from fuzz(2,10,102): + // rz(q1), sz(q1), sz(q0), h(q0), sz(q0), x(q1), cx(1,0), x(q1), t(q0), x(q0) + // The first rz is on initial state (scalar), so the first DestabilizerFlip + // is at t(q0) = step 9. + let q0 = QubitId(0); + let q1 = QubitId(1); + let mut sim = SparseStabY::new(2); + + // Step 1: rz(q1) — on initial state, this is a scalar. Skip for tableau. + // Step 2: sz(q1) + sim.sz(&[q1]); + // Step 3: sz(q0) + sim.sz(&[q0]); + // Step 4: h(q0) + sim.h(&[q0]); + // Step 5: sz(q0) + sim.sz(&[q0]); + // Step 6: x(q1) + sim.x(&[q1]); + // Step 7: cx(1, 0) + sim.cx(&[(q1, q0)]); + // Step 8: x(q1) + sim.x(&[q1]); + + // Now verify the decomposition of Z_0 (the T gate target) + eprintln!("=== Seed 102 state before T(q0) ==="); + let n = 2; + for i in 0..n { + let stab_x: Vec = sim.stabs().row_x[i].iter().collect(); + let stab_z: Vec = sim.stabs().row_z[i].iter().collect(); + let destab_x: Vec = sim.destabs().row_x[i].iter().collect(); + let destab_z: Vec = sim.destabs().row_z[i].iter().collect(); + let s_minus = sim.stabs().signs_minus.contains(i); + let s_i = sim.stabs().signs_i.contains(i); + let d_minus = sim.destabs().signs_minus.contains(i); + let d_i = sim.destabs().signs_i.contains(i); + eprintln!(" stab[{i}]: x={stab_x:?} z={stab_z:?} minus={s_minus} i={s_i}"); + eprintln!(" destab[{i}]: x={destab_x:?} z={destab_z:?} minus={d_minus} i={d_i}"); + } + + let decomp = decompose_z(sim.stabs(), sim.destabs(), 0); + eprintln!(" decomposition: {decomp:?}"); + + verify_decomposition_phase(&sim, 0); + verify_decomposition_phase(&sim, 1); + } + + #[test] + fn test_z_on_initial_state_is_stabilizer() { + // Initial state |00>: stabilizers are Z_0, Z_1, destabilizers X_0, X_1 + // Z_0 is in the stabilizer group with phase +1 + // sign_sites = destabs.col_x[0] = {0} (X_0 has X on q0) + let sim = SparseStabY::new(2); + let decomp = decompose_z(sim.stabs(), sim.destabs(), 0); + match decomp { + ZDecomposition::Stabilizer { phase, .. } => { + assert!( + (phase.re - 1.0).abs() < f64::EPSILON, + "Z_0 should have phase +1 on |0>" + ); + } + ZDecomposition::DestabilizerFlip { .. } => panic!("Z_0 should be a stabilizer on |00>"), + } + } + + #[test] + fn test_z_on_x_state_is_stabilizer_minus() { + // State |10>: X on qubit 0. Z_0 eigenvalue is -1. + let mut sim = SparseStabY::new(2); + sim.x(&[QubitId(0)]); + let decomp = decompose_z(sim.stabs(), sim.destabs(), 0); + match decomp { + ZDecomposition::Stabilizer { phase, .. } => { + assert!( + (phase.re + 1.0).abs() < f64::EPSILON, + "Z_0 should have phase -1 on |1>" + ); + } + ZDecomposition::DestabilizerFlip { .. } => panic!("Z_0 should be a stabilizer on |10>"), + } + } + + #[test] + fn test_z_after_hadamard_is_destabilizer() { + // State |+> = H|0>: stabilizer is X, destabilizer is Z + let mut sim = SparseStabY::new(1); + sim.h(&[QubitId(0)]); + let decomp = decompose_z(sim.stabs(), sim.destabs(), 0); + match decomp { + ZDecomposition::DestabilizerFlip { + flip_sites, + sign_sites, + .. + } => { + assert_eq!(flip_sites, vec![0], "should flip destabilizer 0"); + assert!(sign_sites.is_empty(), "no sign sites for simple case"); + } + ZDecomposition::Stabilizer { .. } => panic!("Z should be a destabilizer flip after H"), + } + } + + /// Diagnostic: print (phase, `flip_sites`, `sign_sites`, Ys) for a variety + /// of states. Used to understand what cases `decompose_z` actually produces. + #[test] + fn test_decomposition_cases_survey() { + // Helper: apply gates, decompose Z_q, report (phase, Ys, flip, sign) + let survey_q = |label: &str, q: usize, gates: fn(&mut SparseStabY)| { + let mut sim = SparseStabY::new(3); + gates(&mut sim); + let decomp = decompose_z(sim.stabs(), sim.destabs(), q); + match decomp { + ZDecomposition::Stabilizer { + phase, + ref sign_sites, + } => { + eprintln!( + "{label} Z_{q}: Stabilizer phase={phase:.3} sign_sites={sign_sites:?}" + ); + } + ZDecomposition::DestabilizerFlip { + ref flip_sites, + phase, + ref sign_sites, + } => { + let ys = flip_sites.iter().filter(|f| sign_sites.contains(f)).count(); + eprintln!( + "{label} Z_{q}: DestabFlip phase={phase:.3} flip={flip_sites:?} sign={sign_sites:?} Ys={ys}" + ); + } + } + }; + let survey = |label: &str, gates: fn(&mut SparseStabY)| { + let mut sim = SparseStabY::new(3); + gates(&mut sim); + let decomp = decompose_z(sim.stabs(), sim.destabs(), 0); + match decomp { + ZDecomposition::Stabilizer { + phase, + ref sign_sites, + } => { + eprintln!("{label}: Stabilizer phase={phase:.3} sign_sites={sign_sites:?}"); + } + ZDecomposition::DestabilizerFlip { + ref flip_sites, + phase, + ref sign_sites, + } => { + let ys = flip_sites.iter().filter(|f| sign_sites.contains(f)).count(); + eprintln!( + "{label}: DestabFlip phase={phase:.3} flip={flip_sites:?} sign={sign_sites:?} Ys={ys}" + ); + } + } + }; + + survey("|0⟩", |s| { + let _ = s; + }); + survey("H|0⟩", |s| { + s.h(&[QubitId(0)]); + }); + survey("X|0⟩=|1⟩", |s| { + s.x(&[QubitId(0)]); + }); + survey("SH|0⟩", |s| { + s.h(&[QubitId(0)]); + s.sz(&[QubitId(0)]); + }); + // Multi-site cases via various tableau setups + // CX then decompose Z_0 or Z_1 -- decomp depends on which qubit + survey_q("H,CX(0,1)", 0, |s| { + s.h(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + survey_q("H,CX(0,1)", 1, |s| { + s.h(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + survey_q("H,H,CX(0,1)", 0, |s| { + s.h(&[QubitId(0)]); + s.h(&[QubitId(1)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + survey_q("H,H,CX(0,1)", 1, |s| { + s.h(&[QubitId(0)]); + s.h(&[QubitId(1)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + // Looking for multi-site: stab has X on multiple qubits after some sequence + survey_q("H(0),CX(0,1),H(0)", 0, |s| { + s.h(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + s.h(&[QubitId(0)]); + }); + survey_q("H(0),CX(0,1),H(0),CX(0,1)", 0, |s| { + s.h(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + s.h(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + // After H,CX,H on q0: X_0 X_1 stab, should give decompose with multiple flips + survey_q("H(0),CX(0,1),H(1)", 0, |s| { + s.h(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + s.h(&[QubitId(1)]); + }); + // GHZ-like state + survey_q("H,CX(0,1),CX(1,2)", 0, |s| { + s.h(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + s.cx(&[(QubitId(1), QubitId(2))]); + }); + survey_q("H,CX(0,1),CX(1,2)", 2, |s| { + s.h(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + s.cx(&[(QubitId(1), QubitId(2))]); + }); + // Setup with Y-type stabilizers: SH gives Y-basis + survey_q("SH,CX(0,1)", 0, |s| { + s.h(&[QubitId(0)]); + s.sz(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + survey_q("SH,CX(0,1)", 1, |s| { + s.h(&[QubitId(0)]); + s.sz(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + // Try to get Ys > 0: need same generator index in both flip_sites and sign_sites + // Requires stab_k and destab_k both have X on same qubit q + survey_q("S(0),H(0)", 0, |s| { + s.sz(&[QubitId(0)]); + s.h(&[QubitId(0)]); + }); + // H,S(0) gives stab=Y_0, destab=X_0. col_x[0] for both = {0}. Ys=1! + // For Z_0 decomp, flip = stabs.col_x[0] = {0}, sign = destabs.col_x[0] = {0}, Ys=1 + survey_q("H(0),S(0)", 0, |s| { + s.h(&[QubitId(0)]); + s.sz(&[QubitId(0)]); + }); + // More Y cases + survey_q("H(0),S(0),CX(0,1)", 0, |s| { + s.h(&[QubitId(0)]); + s.sz(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + survey_q("H(0),S(0),CX(0,1)", 1, |s| { + s.h(&[QubitId(0)]); + s.sz(&[QubitId(0)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + survey_q("H(1),S(1),CX(0,1)", 0, |s| { + s.h(&[QubitId(1)]); + s.sz(&[QubitId(1)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + survey_q("H,H,S(0),S(1),CX(0,1)", 0, |s| { + s.h(&[QubitId(0)]); + s.h(&[QubitId(1)]); + s.sz(&[QubitId(0)]); + s.sz(&[QubitId(1)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + survey_q("H,H,S(0),S(1),CX(0,1)", 1, |s| { + s.h(&[QubitId(0)]); + s.h(&[QubitId(1)]); + s.sz(&[QubitId(0)]); + s.sz(&[QubitId(1)]); + s.cx(&[(QubitId(0), QubitId(1))]); + }); + } + + #[test] + fn test_z_after_bell_state() { + // Bell state: H on q0, then CX(q0, q1) + // Stabilizers: X_0 X_1, Z_0 Z_1 + // Z_0 anticommutes with X_0 X_1 (has X on q0) + let mut sim = SparseStabY::new(2); + sim.h(&[QubitId(0)]); + sim.cx(&[(QubitId(0), QubitId(1))]); + let decomp = decompose_z(sim.stabs(), sim.destabs(), 0); + match decomp { + ZDecomposition::DestabilizerFlip { flip_sites, .. } => { + // Should have exactly one flip site + assert_eq!(flip_sites.len(), 1, "should have one flip site"); + } + ZDecomposition::Stabilizer { .. } => { + panic!("Z_0 should be a destabilizer flip in Bell state") + } + } + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/renyi.rs b/exp/pecos-stab-tn/src/stab_mps/renyi.rs new file mode 100644 index 000000000..9f3f33782 --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps/renyi.rs @@ -0,0 +1,1023 @@ +// 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. + +//! CAMPS-native second Rényi entropy `S_2`. +//! +//! Implements Pauli Coefficient Enumeration (PCE) from Liu-Clark 2412.17209 +//! Section VI.C. For a CAMPS state ρ = C |φ⟩⟨φ| C† where |φ⟩ is a product MPS: +//! +//! 1. For each MPS site j, find non-vanishing single-qubit Paulis (up to 3 per site) +//! and their coefficients (Bloch-vector components). +//! 2. Map each `G̃_k` to `G_k` = C · `G̃_k` · C† (a Pauli string on all N qubits). +//! 3. Gaussian-eliminate the region-B part of `G_k` to find generators {`Q_j`} supported +//! only on region A. +//! 4. Enumerate all 2^M combinations of `Q_j`'s, compute coefficients, and sum +//! squared coefficients to get `Tr(ρ_A²)`. +//! +//! PCE complexity: O(M · 2^M) enumeration. For magic-doped Clifford circuits with +//! few T gates, M is small relative to N, making PCE practical for many qubits. +//! +//! A PCMPS variant (next step) represents the coefficient state as an MPS to +//! reach 100+ qubits; deferred. + +use crate::mps::Mps; +use pecos_simulators::SparseStabY; + +/// A Pauli string on N qubits: (x, z) bitvectors and a real coefficient. +/// +/// The coefficient is real because every Pauli string produced here is +/// Hermitian (r·σ with real r, conjugated by a Clifford stays Hermitian). +#[derive(Clone, Debug)] +pub(crate) struct PauliString { + pub x: Vec, + pub z: Vec, + pub coef: f64, +} + +impl PauliString { + pub fn identity(n: usize) -> Self { + Self { + x: vec![false; n], + z: vec![false; n], + coef: 1.0, + } + } + + /// Returns true if this Pauli is supported only on qubits in `region_a`. + pub fn supported_on(&self, region_a: &[bool]) -> bool { + for (q, in_a) in region_a.iter().enumerate() { + if !in_a && (self.x[q] || self.z[q]) { + return false; + } + } + true + } +} + +/// Compute Bloch vector (`r_x`, `r_y`, `r_z`) for each MPS site, assuming bond dim 1. +/// +/// `r_x` = Tr(X `ρ_j`) = 2 `Re(ψ_0`* `ψ_1`) (for normalized state |`ψ_j`⟩ = `ψ_0|0`⟩ + `ψ_1|1`⟩) +/// `r_y` = Tr(Y `ρ_j`) = -2 `Im(ψ_0`* `ψ_1`) +/// `r_z` = Tr(Z `ρ_j`) = |`ψ_0|²` - |`ψ_1|²` +pub(crate) fn bloch_vectors(mps: &Mps) -> Result, String> { + if mps.max_bond_dim() != 1 { + return Err(format!( + "bloch_vectors requires bond dim 1, got {}", + mps.max_bond_dim() + )); + } + let n = mps.num_sites(); + let mut out = Vec::with_capacity(n); + for j in 0..n { + let t = &mps.tensors()[j]; + // Tensor shape: (chi_l=1, 2*chi_r=2). Extract psi_0 = t[0, 0], psi_1 = t[0, 1]. + let psi0 = t[(0, 0)]; + let psi1 = t[(0, 1)]; + let rx = 2.0 * (psi0.conj() * psi1).re; + let ry = -2.0 * (psi0.conj() * psi1).im; + let rz = psi0.norm_sqr() - psi1.norm_sqr(); + out.push((rx, ry, rz)); + } + Ok(out) +} + +/// Map a single-qubit Pauli at site j through the tableau's Clifford C. +/// Returns G = C · `P_j` · C† as a `PauliString` on N qubits. +/// +/// Uses: `destab_j` = C `X_j` C†, `stab_j` = C `Z_j` C†. Then C `Y_j` C† = i · `destab_j` · `stab_j` +/// = (`destab_j` · `stab_j` with sign/phase adjustments). +pub(crate) fn map_pauli_through_tableau( + tableau: &SparseStabY, + site: usize, + pauli: char, + coef: f64, +) -> PauliString { + let n = tableau.num_qubits(); + match pauli { + 'X' => { + let mut x = vec![false; n]; + let mut z = vec![false; n]; + for q in &tableau.destabs().row_x[site] { + x[q] = true; + } + for q in &tableau.destabs().row_z[site] { + z[q] = true; + } + let mut c = coef; + if tableau.destabs().signs_minus.contains(site) { + c = -c; + } + // signs_i contributes i^count. For Hermitian generators the + // total phase (including Y-count) must be real. Count Y-bits + // (x AND z set) to get the Y-count, combine with signs_i. + if tableau.destabs().signs_i.contains(site) { + let y_count: usize = x + .iter() + .zip(z.iter()) + .filter(|(xi, zi)| **xi && **zi) + .count(); + // Total i-count: 1 (from signs_i) + y_count. Must be even + // for a real coefficient. + let total_i = 1 + y_count; + debug_assert!( + total_i.is_multiple_of(2), + "destab signs_i + Y-count must be even for real coefficient, got {total_i}" + ); + if total_i % 4 == 2 { + c = -c; + } + } + PauliString { x, z, coef: c } + } + 'Z' => { + let mut x = vec![false; n]; + let mut z = vec![false; n]; + for q in &tableau.stabs().row_x[site] { + x[q] = true; + } + for q in &tableau.stabs().row_z[site] { + z[q] = true; + } + let mut c = coef; + if tableau.stabs().signs_minus.contains(site) { + c = -c; + } + if tableau.stabs().signs_i.contains(site) { + let y_count: usize = x + .iter() + .zip(z.iter()) + .filter(|(xi, zi)| **xi && **zi) + .count(); + let total_i = 1 + y_count; + debug_assert!( + total_i.is_multiple_of(2), + "stab signs_i + Y-count must be even for real coefficient, got {total_i}" + ); + if total_i % 4 == 2 { + c = -c; + } + } + PauliString { x, z, coef: c } + } + 'Y' => { + // Y_j = i · X_j · Z_j ⇒ C Y_j C† = i · D · S + // where D = C X_j C† and S = C Z_j C†. + // D and S anticommute (X_j, Z_j anticommute; conjugation preserves it), + // so the accumulated per-qubit i-count is odd, making i · D·S a real + // Hermitian Pauli with ±1 sign. + let dx = map_pauli_through_tableau(tableau, site, 'X', 1.0); + let dz = map_pauli_through_tableau(tableau, site, 'Z', 1.0); + let mut x = vec![false; n]; + let mut z = vec![false; n]; + let mut minus_count: u32 = 0; + let mut i_count: u32 = 0; + for q in 0..n { + x[q] = dx.x[q] ^ dz.x[q]; + z[q] = dx.z[q] ^ dz.z[q]; + let pa = pauli_idx(dx.x[q], dx.z[q]); + let pb = pauli_idx(dz.x[q], dz.z[q]); + let (m, i_b) = pauli_phase(pa, pb); + minus_count += u32::from(m); + i_count += u32::from(i_b); + } + // Total phase of i · D·S = i^{1 + i_count} · (-1)^{minus_count}. + // Must be real (i_count odd). Result sign: + // i^{1+k} for k odd, k=2j+1 → i^{2j+2} = (-1)^{j+1} + debug_assert_eq!(i_count % 2, 1, "C Y C† must be Hermitian"); + let j = (i_count - 1) / 2; + let mut c = coef * dx.coef * dz.coef; + if (j + 1) % 2 == 1 { + c = -c; + } + if minus_count % 2 == 1 { + c = -c; + } + PauliString { x, z, coef: c } + } + 'I' => PauliString::identity(n), + _ => panic!("invalid pauli: {pauli}"), + } +} + +/// Y-convention Pauli index (I=0, Z=1, X=2, Y=3) from (x, z) bits. +fn pauli_idx(x: bool, z: bool) -> u8 { + match (x, z) { + (false, false) => 0, + (false, true) => 1, + (true, false) => 2, + (true, true) => 3, + } +} + +/// Phase (`minus_bit`, `i_bit`) of `Pauli_a` · `Pauli_b` with I=0,Z=1,X=2,Y=3. +/// Encoding: +1=(0,0), -1=(1,0), +i=(0,1), -i=(1,1). +fn pauli_phase(a: u8, b: u8) -> (u8, u8) { + match (a, b) { + (1, 2) | (3, 1) | (2, 3) => (0, 1), // +i: Z·X, Y·Z, X·Y + (2, 1) | (1, 3) | (3, 2) => (1, 1), // -i: X·Z, Z·Y, Y·X + _ => (0, 0), // I or same Pauli + } +} + +/// (Deprecated / unused) Gauss-eliminate Pauli strings to find subset +/// supported only on region A. Kept for reference; PCE now enumerates +/// per-site choices directly since generators from same site anti-commute. +#[allow(dead_code)] +pub(crate) fn restrict_to_region_a( + generators: Vec, + region_a_mask: &[bool], + num_qubits: usize, +) -> Vec { + let region_b_mask: Vec = region_a_mask.iter().map(|b| !b).collect(); + // Build matrix: each row is 2N-bit (x then z), coefficient tracked separately. + // Eliminate rows by pivoting on region_b bits first. + let mut rows: Vec = generators; + // Bits of region B: for each qubit q in B, the x bit (position q) and z bit + // (position num_qubits + q). We want to zero these out. + let mut b_bit_positions: Vec<(usize, bool)> = Vec::new(); // (qubit, is_x) + for (q, &is_b) in region_b_mask.iter().enumerate() { + if is_b { + b_bit_positions.push((q, true)); // x-bit + b_bit_positions.push((q, false)); // z-bit + } + } + + let row_bit = |row: &PauliString, q: usize, is_x: bool| -> bool { + if is_x { row.x[q] } else { row.z[q] } + }; + + let mut current = 0; + for &(q, is_x) in &b_bit_positions { + if current >= rows.len() { + break; + } + // Find row with 1 in this bit. + let found = rows[current..] + .iter() + .position(|row| row_bit(row, q, is_x)) + .map(|offset| current + offset); + if let Some(piv) = found { + rows.swap(current, piv); + for r in 0..rows.len() { + if r != current && row_bit(&rows[r], q, is_x) { + // Combine rows as Pauli product: P_r · P_current. + // Bits XOR; coefficient picks up per-qubit phase. + // For commuting-generator case, total phase is real (±1); + // i-count must be even. + let mut minus_count: u32 = 0; + let mut i_count: u32 = 0; + for qq in 0..num_qubits { + let pa = pauli_idx(rows[r].x[qq], rows[r].z[qq]); + let pb = pauli_idx(rows[current].x[qq], rows[current].z[qq]); + let (m, i_b) = pauli_phase(pa, pb); + minus_count += u32::from(m); + i_count += u32::from(i_b); + rows[r].x[qq] ^= rows[current].x[qq]; + rows[r].z[qq] ^= rows[current].z[qq]; + } + // PCE assumes generators mutually commute → i_count even. + // Non-commuting generators (e.g. same-site X and Y) violate + // this; we approximate with |phase|. + let j = i_count / 2; + let mut combined = rows[r].coef * rows[current].coef; + if j % 2 == 1 { + combined = -combined; + } + if minus_count % 2 == 1 { + combined = -combined; + } + rows[r].coef = combined; + } + } + current += 1; + } + } + + // Return rows with zero support on B. + rows.into_iter() + .filter(|r| r.supported_on(region_a_mask)) + .collect() +} + +/// Compute `S_2` entropy via Pauli Coefficient Enumeration (PCE). +/// +/// Formula: `Tr(ρ_A²)` = (`1/2^{N_A`}) · Σ_{P̃ : supp(C P̃ C†) ⊆ A} ∏_j `c_j(P̃_j)²` +/// where `c_j(P̃_j)` = `Tr(ρ_j` · `P̃_j`) ∈ {1, `r_x`, `r_y`, `r_z`}. +/// +/// We enumerate site-independent choices (I or one of the non-zero Paulis at +/// each site), map the product through the tableau, and keep terms supported +/// on A. Combinations count is ∏_j (1 + `count_j`). For Clifford+T circuits, +/// most sites have `count_j` ∈ {0,1}; full-magic sites have `count_j` = 3 giving +/// up to 4^N. Errors out above 2^22 combos. +/// +/// # Errors +/// +/// Returns an error string if the mask length doesn't match the MPS, or if +/// the number of Pauli combinations exceeds the safety limit. +/// +/// # Panics +/// +/// Panics if the region-A size exceeds u16 range (would require > 65535 qubits). +pub fn compute_s2_pce( + mps: &Mps, + tableau: &SparseStabY, + region_a_mask: &[bool], +) -> Result { + let n = mps.num_sites(); + if region_a_mask.len() != n { + return Err("region_a_mask length mismatch".into()); + } + + let bvs = bloch_vectors(mps)?; + let tol = 1e-12; + + // For each site, list the available (coef, mapped_PauliString) choices. + // The identity choice has coef=1 and a zero PauliString on all qubits. + // Non-identity choices include non-zero X/Y/Z Bloch components. + let mut site_choices: Vec> = Vec::with_capacity(n); + let mut total_combos: u128 = 1; + for (j, &(rx, ry, rz)) in bvs.iter().enumerate() { + let mut opts: Vec<(f64, PauliString)> = Vec::with_capacity(4); + opts.push((1.0, PauliString::identity(n))); + if rx.abs() > tol { + opts.push((rx, map_pauli_through_tableau(tableau, j, 'X', 1.0))); + } + if ry.abs() > tol { + opts.push((ry, map_pauli_through_tableau(tableau, j, 'Y', 1.0))); + } + if rz.abs() > tol { + opts.push((rz, map_pauli_through_tableau(tableau, j, 'Z', 1.0))); + } + total_combos = total_combos.saturating_mul(opts.len() as u128); + site_choices.push(opts); + } + if total_combos > (1u128 << 22) { + return Err(format!("PCE would enumerate {total_combos} > limit 2^22")); + } + + // Enumerate all combinations as mixed-radix index across sites. + let n_a: usize = region_a_mask.iter().filter(|&&b| b).count(); + let mut tr_sq: f64 = 0.0; + let mut idx = vec![0usize; n]; + loop { + // Combine: product of per-site Bloch coefficients × XOR of mapped Paulis + // (cross-site Paulis commute so total sign is product of each mapped coef's sign). + let mut combined_x = vec![false; n]; + let mut combined_z = vec![false; n]; + let mut coef = 1.0; + for (j, opts) in site_choices.iter().enumerate() { + let (bloch, ps) = &opts[idx[j]]; + coef *= bloch; + // Accumulate Pauli product (cross-site, so just XOR; per-qubit phase + // is trivial because different-site Paulis share no qubit support in + // P̃, but the *mapped* ps spans all qubits, so we still track phase). + let mut minus_count: u32 = 0; + let mut i_count: u32 = 0; + for q in 0..n { + let pa = pauli_idx(combined_x[q], combined_z[q]); + let pb = pauli_idx(ps.x[q], ps.z[q]); + let (m, i_b) = pauli_phase(pa, pb); + minus_count += u32::from(m); + i_count += u32::from(i_b); + combined_x[q] ^= ps.x[q]; + combined_z[q] ^= ps.z[q]; + } + // All mapped P_j from different sites commute → i_count even. + debug_assert_eq!(i_count % 2, 0, "cross-site mapped Paulis must commute"); + let j_half = i_count / 2; + coef *= ps.coef; + if (j_half + minus_count) % 2 == 1 { + coef = -coef; + } + } + + // Check support on region A. + let mut on_a = true; + for q in 0..n { + if !region_a_mask[q] && (combined_x[q] || combined_z[q]) { + on_a = false; + break; + } + } + if on_a { + tr_sq += coef * coef; + } + + // Advance mixed-radix counter. + let mut carry = true; + for j in 0..n { + if !carry { + break; + } + idx[j] += 1; + if idx[j] >= site_choices[j].len() { + idx[j] = 0; + } else { + carry = false; + } + } + if carry { + break; + } + } + // n_a is bounded by the number of qubits (enforced by early checks) so n_a <= 22. + tr_sq *= 0.5_f64.powi(i32::from(u16::try_from(n_a).expect("n_a fits in u16"))); + + if tr_sq < 1e-30 { + Ok(f64::INFINITY) + } else { + Ok(-tr_sq.ln()) + } +} + +/// `S_2` via full `F_2` enumeration of the 2N-bit Pauli null-space with +/// site-separable squared-weights. Handles arbitrary (multi-axis) Bloch. +/// +/// Formula: `Tr(ρ_A²)` = (`1/2^{N_A`}) · Σ_{P̃ : supp(C P̃ C†) ⊆ A} ∏_j `w_j(P̃_j)` +/// where weights are squared Bloch components in Y-convention: +/// `w_j(x̃=0,z̃=0)` = 1 (I) +/// `w_j(x̃=1,z̃=0)` = `r_x²` (X) +/// `w_j(x̃=0,z̃=1)` = `r_z²` (Z) +/// `w_j(x̃=1,z̃=1)` = `r_y²` (XZ ≈ -iY in standard Pauli) +/// +/// Constraints: 2(N-N_A) linear parity checks over `F_2^{2N`} encoding that +/// (C P̃ C†) has no support on region B. Gauss-eliminate to find null +/// space of dim `d`; enumerate 2^d terms. Errors out above 2^22. +/// +/// Covers the single-axis case as a special case (inactive axes add +/// unit-weight constraints that reduce effective null dim). +/// +/// # Errors +/// +/// Returns an error string if the mask length doesn't match the MPS, or if +/// the null-space dimension exceeds the enumeration limit. +/// +/// # Panics +/// +/// Panics if the region-A size exceeds u16 range (would require > 65535 qubits). +pub fn compute_s2_pcmps_tn( + mps: &Mps, + tableau: &SparseStabY, + region_a_mask: &[bool], +) -> Result { + let n = mps.num_sites(); + if region_a_mask.len() != n { + return Err("region_a_mask length mismatch".into()); + } + let bvs = bloch_vectors(mps)?; + let tol = 1e-12; + + // Variables: for site j, x̃_j = bit 2j, z̃_j = bit 2j+1. Total 2N bits. + let n_bits = 2 * n; + + // Per-site weights indexed by (x̃, z̃) ∈ {0,1}² ordered 00, 10, 01, 11. + // Also record which patterns are forced zero (constraints to add). + let mut weights: Vec<[f64; 4]> = Vec::with_capacity(n); + let mut zero_bit_constraints: Vec> = Vec::new(); + for (j, &(rx, ry, rz)) in bvs.iter().enumerate() { + let rx2 = rx * rx; + let ry2 = ry * ry; + let rz2 = rz * rz; + weights.push([1.0, rx2, rz2, ry2]); + // Zero-weight patterns ⇒ force bit combinations to 0. + // If rx = 0 AND ry = 0: force x̃_j = 0 (eliminates X and Y options). + if rx2 < tol && ry2 < tol { + let mut row = vec![false; n_bits]; + row[2 * j] = true; + zero_bit_constraints.push(row); + } + // If rz = 0 AND ry = 0: force z̃_j = 0. + if rz2 < tol && ry2 < tol { + let mut row = vec![false; n_bits]; + row[2 * j + 1] = true; + zero_bit_constraints.push(row); + } + // If rx = 0 AND rz = 0 (only Y): force x̃_j = z̃_j. + if rx2 < tol && rz2 < tol && ry2 > tol { + let mut row = vec![false; n_bits]; + row[2 * j] = true; + row[2 * j + 1] = true; + zero_bit_constraints.push(row); + } + } + + // Support-on-A constraints: for each B-site q, 2 linear constraints (x and z bits of CP̃C†). + // (CP̃C†)_q x-bit = ⊕_j (x̃_j · destab[j].row_x[q] ⊕ z̃_j · stab[j].row_x[q]) + // (CP̃C†)_q z-bit = ⊕_j (x̃_j · destab[j].row_z[q] ⊕ z̃_j · stab[j].row_z[q]) + let destabs = tableau.destabs(); + let stabs = tableau.stabs(); + let mut support_constraints: Vec> = Vec::new(); + for (q, &is_a) in region_a_mask.iter().enumerate() { + if is_a { + continue; + } + let mut row_x = vec![false; n_bits]; + let mut row_z = vec![false; n_bits]; + for j in 0..n { + if destabs.row_x[j].contains(q) { + row_x[2 * j] ^= true; + } + if stabs.row_x[j].contains(q) { + row_x[2 * j + 1] ^= true; + } + if destabs.row_z[j].contains(q) { + row_z[2 * j] ^= true; + } + if stabs.row_z[j].contains(q) { + row_z[2 * j + 1] ^= true; + } + } + if row_x.iter().any(|&b| b) { + support_constraints.push(row_x); + } + if row_z.iter().any(|&b| b) { + support_constraints.push(row_z); + } + } + + // Combine all constraints. + let mut a_rows: Vec> = Vec::new(); + a_rows.extend(zero_bit_constraints); + a_rows.extend(support_constraints); + let n_rows = a_rows.len(); + + // RREF over F_2. + let mut pivot_col_of_row: Vec> = vec![None; n_rows]; + let mut col_is_pivot: Vec = vec![false; n_bits]; + let mut r = 0; + for c in 0..n_bits { + if r >= n_rows { + break; + } + let found = a_rows[r..] + .iter() + .position(|row| row[c]) + .map(|offset| r + offset); + if let Some(rr) = found { + a_rows.swap(r, rr); + let pivot_row = a_rows[r].clone(); + for (rr, row) in a_rows.iter_mut().enumerate() { + if rr != r && row[c] { + for (cell, &piv) in row.iter_mut().zip(pivot_row.iter()) { + *cell ^= piv; + } + } + } + pivot_col_of_row[r] = Some(c); + col_is_pivot[c] = true; + r += 1; + } + } + let rank = r; + let free_cols: Vec = (0..n_bits).filter(|&c| !col_is_pivot[c]).collect(); + let null_dim = free_cols.len(); + + let n_a: usize = region_a_mask.iter().filter(|&&b| b).count(); + + if null_dim > 30 { + return Err(format!("PCMPS-TN null-space dim {null_dim} > 30")); + } + + // Build null-space basis vectors. Pack as bitmasks: + // - For n ≤ 32 (n_bits ≤ 64): single u128 per basis vector. + // - Otherwise: Vec. + // Per-site weight lookup then becomes a single shift+mask. + let basis_u128: Option> = if n_bits <= 128 { + Some( + free_cols + .iter() + .map(|&f| { + let mut bits: u128 = 1u128 << f; + for rr in 0..rank { + if let Some(p) = pivot_col_of_row[rr] + && a_rows[rr][f] + { + bits ^= 1u128 << p; + } + } + bits + }) + .collect(), + ) + } else { + None + }; + + let total_combos = 1usize << null_dim; + + let accumulate_combo_u128 = |basis_masks: &[u128], combo: usize| -> f64 { + let mut bits: u128 = 0; + for (k, &mask) in basis_masks.iter().enumerate() { + if (combo >> k) & 1 == 1 { + bits ^= mask; + } + } + let mut w: f64 = 1.0; + for (j, wj) in weights.iter().enumerate() { + let idx = ((bits >> (2 * j)) & 0b11) as usize; + w *= wj[idx]; + if w == 0.0 { + return 0.0; + } + } + w + }; + + let tr_sq: f64 = if let Some(basis_masks) = basis_u128.as_ref() { + use rayon::prelude::*; + if total_combos >= (1 << 14) { + (0..total_combos) + .into_par_iter() + .map(|combo| accumulate_combo_u128(basis_masks, combo)) + .sum() + } else { + (0..total_combos) + .map(|combo| accumulate_combo_u128(basis_masks, combo)) + .sum() + } + } else { + // Fall back: boolean-vector enumeration (slow but correct for n > 64). + let mut basis: Vec> = Vec::with_capacity(null_dim); + for &f in &free_cols { + let mut v = vec![false; n_bits]; + v[f] = true; + for rr in 0..rank { + if let Some(p) = pivot_col_of_row[rr] + && a_rows[rr][f] + { + v[p] = true; + } + } + basis.push(v); + } + let mut sum: f64 = 0.0; + for combo in 0..total_combos { + let mut bits = vec![false; n_bits]; + for (k, bk) in basis.iter().enumerate() { + if (combo >> k) & 1 == 1 { + for (bi, &bki) in bits.iter_mut().zip(bk.iter()) { + *bi ^= bki; + } + } + } + let mut w: f64 = 1.0; + for j in 0..n { + let x = bits[2 * j]; + let z = bits[2 * j + 1]; + let idx = usize::from(x) | (usize::from(z) << 1); + w *= weights[j][idx]; + if w == 0.0 { + break; + } + } + sum += w; + } + sum + }; + let tr_sq = tr_sq / f64::from(1u32 << u32::try_from(n_a).unwrap()); + + if tr_sq < 1e-30 { + Ok(f64::INFINITY) + } else { + Ok(-tr_sq.ln()) + } +} + +/// Faster `S_2` via GF(2) null-space enumeration (PCMPS-style). +/// +/// Applicable when every MPS site has exactly ONE non-zero Bloch component +/// (i.e. lies on a Pauli axis). Most STN Clifford+T states satisfy this +/// because T gates absorb into the tableau, leaving MPS sites as |0⟩ (Z-axis). +/// +/// Algorithm: +/// 1. Each site j contributes one binary variable `v_j` ∈ {0,1} (I vs `P_j`). +/// 2. Support-on-A constraint on (C·P·C†) is a system of linear equations +/// in `v_j` over GF(2). +/// 3. Enumerate only the null space (dim d), not 2^N combos — 2^d evaluations. +/// +/// For single-axis states this is typically d ≈ `N_A`, i.e. `2^{N_A`} not 2^N. +/// Returns error if any site has multi-axis Bloch (caller can fall back to PCE). +/// +/// # Errors +/// +/// Returns an error string if the mask length doesn't match the MPS, if any +/// site has multi-axis Bloch vectors, or if the null-space exceeds the +/// enumeration limit. +/// +/// # Panics +/// +/// Panics if the region-A or null-space size exceeds i16 range. +pub fn compute_s2_pcmps( + mps: &Mps, + tableau: &SparseStabY, + region_a_mask: &[bool], +) -> Result { + let n = mps.num_sites(); + if region_a_mask.len() != n { + return Err("region_a_mask length mismatch".into()); + } + let bvs = bloch_vectors(mps)?; + let tol = 1e-12; + + // Per-site single-axis variable: (bloch_coef, mapped_pauli_string). + // Fail fast if any site has ≥ 2 non-zero Bloch components. + let mut vars: Vec<(f64, PauliString)> = Vec::with_capacity(n); + for (j, &(rx, ry, rz)) in bvs.iter().enumerate() { + let cands = [(rx, 'X'), (ry, 'Y'), (rz, 'Z')]; + let nonzero: Vec<&(f64, char)> = cands.iter().filter(|(r, _)| r.abs() > tol).collect(); + if nonzero.len() != 1 { + return Err(format!( + "PCMPS needs 1 non-zero Bloch axis per site; site {j} has {} (rx={rx}, ry={ry}, rz={rz})", + nonzero.len() + )); + } + let (r, p) = *nonzero[0]; + vars.push((r, map_pauli_through_tableau(tableau, j, p, 1.0))); + } + + // Build GF(2) constraint matrix A (rows = B-site bit constraints, cols = vars). + // For each B site q, two rows (x-bit, z-bit) constraining the combined Pauli + // to have 0 at that bit position. + let n_vars = vars.len(); + let mut a_rows: Vec> = Vec::new(); + for (q, &is_a) in region_a_mask.iter().enumerate() { + if is_a { + continue; + } + let row_x: Vec = vars.iter().map(|v| v.1.x[q]).collect(); + let row_z: Vec = vars.iter().map(|v| v.1.z[q]).collect(); + if row_x.iter().any(|&b| b) { + a_rows.push(row_x); + } + if row_z.iter().any(|&b| b) { + a_rows.push(row_z); + } + } + + // Gauss-eliminate to RREF; record pivot cols and free cols. + let n_rows = a_rows.len(); + let mut pivot_col_of_row: Vec> = vec![None; n_rows]; + let mut col_is_pivot: Vec = vec![false; n_vars]; + let mut r = 0; + for c in 0..n_vars { + if r >= n_rows { + break; + } + let found = a_rows[r..] + .iter() + .position(|row| row[c]) + .map(|offset| r + offset); + if let Some(rr) = found { + a_rows.swap(r, rr); + let pivot_row = a_rows[r].clone(); + for (rr, row) in a_rows.iter_mut().enumerate() { + if rr != r && row[c] { + for (cell, &piv) in row.iter_mut().zip(pivot_row.iter()) { + *cell ^= piv; + } + } + } + pivot_col_of_row[r] = Some(c); + col_is_pivot[c] = true; + r += 1; + } + } + let rank = r; + let free_cols: Vec = (0..n_vars).filter(|&c| !col_is_pivot[c]).collect(); + let null_dim = free_cols.len(); + debug_assert_eq!(rank + null_dim, n_vars); + + let n_a: usize = region_a_mask.iter().filter(|&&b| b).count(); + + // Short-circuit: all-Clifford states have |var_coef · ps.coef| = 1 at every + // variable. Then every null-space combination contributes coef² = 1, + // so tr_sq = 2^null_dim / 2^N_A. + let all_clifford = vars + .iter() + .all(|(r, ps)| (r.abs() * ps.coef.abs() - 1.0).abs() < 1e-9); + if all_clifford { + let diff = i16::try_from(null_dim).expect("null_dim fits in i16") + - i16::try_from(n_a).expect("n_a fits in i16"); + let s2 = -f64::from(diff) * (2.0f64).ln(); + return Ok(s2); + } + + if null_dim > 22 { + return Err(format!( + "PCMPS null-space dim {null_dim} > 22 (non-Clifford)" + )); + } + + // Null-space basis: for each free col f, basis vector e_f has v_f = 1 and + // v_p = A[r_p][f] for each pivot row r_p (pivot col p). + let mut basis: Vec> = Vec::with_capacity(null_dim); + for &f in &free_cols { + let mut v = vec![false; n_vars]; + v[f] = true; + for rr in 0..rank { + if let Some(p) = pivot_col_of_row[rr] + && a_rows[rr][f] + { + v[p] = true; + } + } + basis.push(v); + } + + // Enumerate 2^null_dim combinations of basis vectors; for each, compute + // product coefficient and accumulate coef². + let total_combos = 1usize << null_dim; + let mut tr_sq: f64 = 0.0; + for combo in 0..total_combos { + // XOR combination of basis vectors selected by bits of combo. + let mut selection = vec![false; n_vars]; + for (k, bk) in basis.iter().enumerate() { + if (combo >> k) & 1 == 1 { + for (sel, &bki) in selection.iter_mut().zip(bk.iter()) { + *sel ^= bki; + } + } + } + // Compute combined Pauli string and coefficient. + let mut cx = vec![false; n]; + let mut cz = vec![false; n]; + let mut coef: f64 = 1.0; + let mut mc: u32 = 0; + let mut ic: u32 = 0; + for (i, sel) in selection.iter().enumerate() { + if !sel { + continue; + } + let (bloch, ps) = &vars[i]; + coef *= bloch * ps.coef; + for q in 0..n { + let pa = pauli_idx(cx[q], cz[q]); + let pb = pauli_idx(ps.x[q], ps.z[q]); + let (m, i_b) = pauli_phase(pa, pb); + mc += u32::from(m); + ic += u32::from(i_b); + cx[q] ^= ps.x[q]; + cz[q] ^= ps.z[q]; + } + } + debug_assert_eq!(ic % 2, 0, "null-space combos must give even i-count"); + if (ic / 2 + mc) % 2 == 1 { + coef = -coef; + } + debug_assert!( + cx.iter() + .zip(&cz) + .enumerate() + .all(|(q, (x, z))| region_a_mask[q] || (!x && !z)), + "null-space vector violated support constraint (GF(2) bug)" + ); + tr_sq += coef * coef; + } + // n_a is bounded by the number of qubits (enforced by early checks) so n_a <= 22. + tr_sq *= 0.5_f64.powi(i32::from(u16::try_from(n_a).expect("n_a fits in u16"))); + + if tr_sq < 1e-30 { + Ok(f64::INFINITY) + } else { + Ok(-tr_sq.ln()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stab_mps::StabMps; + use pecos_core::QubitId; + use pecos_simulators::CliffordGateable; + + #[test] + fn test_bloch_vector_zero_state() { + let stn = StabMps::new(3); + let bvs = bloch_vectors(stn.mps()).unwrap(); + // |0⟩ state: (0, 0, +1). + for (rx, ry, rz) in bvs { + assert!((rx - 0.0).abs() < 1e-9); + assert!((ry - 0.0).abs() < 1e-9); + assert!((rz - 1.0).abs() < 1e-9); + } + } + + #[test] + fn test_pauli_string_supported_on() { + let mut p = PauliString::identity(4); + p.x[0] = true; + p.z[1] = true; + let region_a = vec![true, true, false, false]; + assert!(p.supported_on(®ion_a)); + p.x[2] = true; + assert!(!p.supported_on(®ion_a)); + } + + #[test] + fn test_map_pauli_identity_tableau() { + // Trivial tableau (identity Clifford): C P C† = P. + // X at site 0 maps to X_0 on all qubits. + let stn = StabMps::new(3); + let g = map_pauli_through_tableau(stn.tableau(), 0, 'X', 1.0); + assert!(g.x[0] && !g.z[0]); + assert!(!g.x[1] && !g.z[1]); + assert!(!g.x[2] && !g.z[2]); + assert!((g.coef - 1.0).abs() < 1e-9); + + // Z at site 1. + let g = map_pauli_through_tableau(stn.tableau(), 1, 'Z', 1.0); + assert!(!g.x[1] && g.z[1]); + assert!(!g.x[0] && !g.z[0]); + } + + #[test] + fn test_pce_s2_zero_state() { + // |0⟩^N: all stabilized by Z_j. S_2 = 0 for any bipartition. + let stn = StabMps::new(4); + let mask = vec![true, true, false, false]; + let s2 = compute_s2_pce(stn.mps(), stn.tableau(), &mask).unwrap(); + eprintln!("zero state S_2 = {s2}"); + assert!(s2.abs() < 1e-9, "zero state should have S_2=0, got {s2}"); + } + + #[test] + fn test_pce_s2_bell_state() { + // Bell state: S_2 = ln(2). + let mut stn = StabMps::new(2); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let mask = vec![true, false]; // q0 in A, q1 in B + let s2 = compute_s2_pce(stn.mps(), stn.tableau(), &mask).unwrap(); + eprintln!("Bell S_2 (PCE) = {s2}, expected ln(2) = {}", (2.0f64).ln()); + assert!((s2 - (2.0f64).ln()).abs() < 1e-9); + } + + #[test] + fn test_pce_matches_sv_for_clifford_plus_t() { + use pecos_core::Angle64; + use pecos_simulators::ArbitraryRotationGateable; + // H on all, CX, T on q0 (creates entangled magic state), + // CX between regions. Real Clifford+T circuit. + let mut stn = StabMps::new(4); + stn.h(&[QubitId(0), QubitId(1), QubitId(2), QubitId(3)]); + stn.cx(&[(QubitId(0), QubitId(2))]); // entangle A-B boundary + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); // T on q0 + stn.cx(&[(QubitId(1), QubitId(3))]); // entangle more + let mask = vec![true, true, false, false]; // A = {0,1}, B = {2,3} + + let s2_pce = compute_s2_pce(stn.mps(), stn.tableau(), &mask).unwrap(); + let s2_sv = stn.renyi_s2(2); + eprintln!("PCE: {s2_pce:.6}, SV: {s2_sv:.6}"); + assert!( + (s2_pce - s2_sv).abs() < 1e-6, + "PCE should match SV now that Y-sign is tracked: PCE={s2_pce} SV={s2_sv}" + ); + } + + #[test] + fn test_pce_entangled_clifford_plus_t() { + // Genuinely entangled Clifford+T across A-B boundary. + use pecos_core::Angle64; + use pecos_simulators::ArbitraryRotationGateable; + let mut stn = StabMps::new(4); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(2))]); // Bell across A-B + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); // T on B-side + stn.h(&[QubitId(1)]); + stn.cx(&[(QubitId(1), QubitId(3))]); // second Bell across A-B + let mask = vec![true, true, false, false]; + let s2_pce = compute_s2_pce(stn.mps(), stn.tableau(), &mask).unwrap(); + let s2_sv = stn.renyi_s2(2); + eprintln!("entangled PCE: {s2_pce:.6}, SV: {s2_sv:.6}"); + assert!( + (s2_pce - s2_sv).abs() < 1e-6, + "PCE should match SV for entangled Clifford+T: PCE={s2_pce} SV={s2_sv}" + ); + assert!(s2_pce > 0.5, "expected non-trivial entanglement"); + } + + #[test] + fn test_map_pauli_after_cx() { + // C = CX(0,1). Z_0 unchanged, Z_1 -> Z_0 Z_1, X_0 -> X_0 X_1, X_1 unchanged. + let mut stn = StabMps::new(2); + stn.cx(&[(QubitId(0), QubitId(1))]); + let gz0 = map_pauli_through_tableau(stn.tableau(), 0, 'Z', 1.0); + assert!(!gz0.x[0] && gz0.z[0]); // Z_0 + assert!(!gz0.x[1] && !gz0.z[1]); + let gz1 = map_pauli_through_tableau(stn.tableau(), 1, 'Z', 1.0); + assert!(!gz1.x[0] && gz1.z[0]); // Z_0 + assert!(!gz1.x[1] && gz1.z[1]); // Z_1 + let gx0 = map_pauli_through_tableau(stn.tableau(), 0, 'X', 1.0); + assert!(gx0.x[0] && !gx0.z[0]); // X_0 + assert!(gx0.x[1] && !gx0.z[1]); // X_1 + } +} diff --git a/exp/pecos-stab-tn/src/stab_mps/tableau_compose.rs b/exp/pecos-stab-tn/src/stab_mps/tableau_compose.rs new file mode 100644 index 000000000..77572337d --- /dev/null +++ b/exp/pecos-stab-tn/src/stab_mps/tableau_compose.rs @@ -0,0 +1,1006 @@ +// 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. + +//! Right-composition of Clifford gates onto a stabilizer tableau. +//! +//! The existing `SparseStabY` gate methods (h, sz, cx) perform LEFT-composition: +//! they transform C into G*C, conjugating each generator by G acting on physical +//! qubits. Right-composition transforms C into C*G, which acts on the "virtual" +//! side -- the generator rows themselves. +//! +//! Mathematical identity: +//! - Left-compose by G: stabilizer `S_k` = C `Z_k` C^dagger -> G `S_k` G^dagger +//! - Right-compose by G: `S_k` -> C (G `Z_k` G^dagger) C^dagger +//! +//! Right-composition is what the stabilizer-TN reference uses to absorb the +//! compensating CNOT cascade from the exact disentangling path. This allows +//! the disentangling to leave the MPS in a single-site rotation state without +//! needing the full multi-site CNOT cascade on the MPS. +//! +//! 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] +//! +//! Reference: Aaronson & Gottesman, "Improved Simulation of Stabilizer Circuits" +//! (PRA 70, 052328 (2004)); stabilizer-TN reference compose(..., front=True). + +use num_complex::Complex64; +use pecos_core::{BitSet, IndexSet}; +use pecos_simulators::{GensGeneric, SparseStabY}; + +/// Standard Pauli multiplication phase table (Y=iXZ). Index scheme: +/// I=0, Z=1, X=2, Y=3. +/// Returns (`minus_bit`, `i_bit`) for the phase factor of `Pauli_a` · `Pauli_b`. +/// Encoding: +1=(0,0), -1=(1,0), +i=(0,1), -i=(1,1). +const fn pauli_phase(a: u8, b: u8) -> (i8, i8) { + match (a, b) { + (1, 2) | (3, 1) | (2, 3) => (0, 1), // +i: Z·X, Y·Z, X·Y + (2, 1) | (1, 3) | (3, 2) => (1, 1), // -i: X·Z, Z·Y, Y·X + _ => (0, 0), // I or same Pauli + } +} + +/// Compute Pauli index (0=I, 1=Z, 2=X, 3=Y) from (x, z) bits. +const fn pauli_idx(x: bool, z: bool) -> u8 { + match (x, z) { + (false, false) => 0, // I + (false, true) => 1, // Z + (true, false) => 2, // X + (true, true) => 3, // Y + } +} + +/// Multiply row `a` by row `b` in place: a *= b. +/// +/// Result: `a.row_x` = `a.row_x` XOR `b.row_x` +/// `a.row_z` = `a.row_z` XOR `b.row_z` +/// a.sign *= b.sign * (product of per-qubit phases) +/// +/// Both rows are treated as Y-convention Pauli strings with optional signs. +pub(crate) fn multiply_row( + gens_a: &mut GensGeneric, + row_a: usize, + gens_b: &GensGeneric, + row_b: usize, + num_qubits: usize, +) { + // Compute per-qubit phase contribution + let mut phase = Complex64::new(1.0, 0.0); + for q in 0..num_qubits { + let a_x = gens_a.row_x[row_a].contains(q); + let a_z = gens_a.row_z[row_a].contains(q); + let b_x = gens_b.row_x[row_b].contains(q); + let b_z = gens_b.row_z[row_b].contains(q); + + if !a_x && !a_z { + continue; + } // I * anything, no phase + if !b_x && !b_z { + continue; + } // anything * I, no phase + + let pa = pauli_idx(a_x, a_z); + let pb = pauli_idx(b_x, b_z); + let (minus, i_bit) = pauli_phase(pa, pb); + if minus == 1 { + phase *= Complex64::new(-1.0, 0.0); + } + if i_bit == 1 { + phase *= Complex64::new(0.0, 1.0); + } + } + + // Update row a's bit vectors: XOR with row b + let b_row_x = gens_b.row_x[row_b].clone(); + let b_row_z = gens_b.row_z[row_b].clone(); + + // Update col_x and col_z columns based on the XOR + for q in b_row_x.iter() { + gens_a.col_x[q].toggle(row_a); + } + for q in b_row_z.iter() { + gens_a.col_z[q].toggle(row_a); + } + + gens_a.row_x[row_a].xor_assign(&b_row_x); + gens_a.row_z[row_a].xor_assign(&b_row_z); + + // Combine signs: a.sign *= b.sign * phase + let b_minus = gens_b.signs_minus.contains(row_b); + let b_i = gens_b.signs_i.contains(row_b); + if b_minus { + phase *= Complex64::new(-1.0, 0.0); + } + if b_i { + phase *= Complex64::new(0.0, 1.0); + } + + // Apply phase to row_a's signs + let a_minus = gens_a.signs_minus.contains(row_a); + let a_i = gens_a.signs_i.contains(row_a); + // Current sign of a is (a_minus, a_i). New sign = old * phase. + let old_sign = match (a_minus, a_i) { + (false, false) => Complex64::new(1.0, 0.0), + (true, false) => Complex64::new(-1.0, 0.0), + (false, true) => Complex64::new(0.0, 1.0), + (true, true) => Complex64::new(0.0, -1.0), + }; + let new_sign = old_sign * phase; + + // Round to nearest (minus, i) combination + let (new_minus, new_i) = if (new_sign - Complex64::new(1.0, 0.0)).norm() < 1e-9 { + (false, false) + } else if (new_sign - Complex64::new(-1.0, 0.0)).norm() < 1e-9 { + (true, false) + } else if (new_sign - Complex64::new(0.0, 1.0)).norm() < 1e-9 { + (false, true) + } else if (new_sign - Complex64::new(0.0, -1.0)).norm() < 1e-9 { + (true, true) + } else { + panic!("row multiplication produced non-quarter-phase: {new_sign}"); + }; + + if new_minus != a_minus { + gens_a.signs_minus.toggle(row_a); + } + if new_i != a_i { + gens_a.signs_i.toggle(row_a); + } +} + +/// Swap rows between two `GensGeneric` structures. +/// +/// Swaps row `row_a` of `gens_a` with row `row_b` of `gens_b`, including +/// the bit vectors (`row_x`, `row_z`) and the signs. Also updates `col_x/col_z`. +fn swap_rows_between( + gens_a: &mut GensGeneric, + row_a: usize, + gens_b: &mut GensGeneric, + row_b: usize, +) { + // Swap bit vectors + std::mem::swap(&mut gens_a.row_x[row_a], &mut gens_b.row_x[row_b]); + std::mem::swap(&mut gens_a.row_z[row_a], &mut gens_b.row_z[row_b]); + + // Update col_x/col_z consistency + // After swap: for each qubit q, toggle col_x[q] membership in row_a and row_b + // based on the new row_x contents. Do this by recomputing from the row contents. + // + // Actually, the col representation must remain consistent. The row contents + // swapped, so we need to: + // - For gens_a: the qubits in row_a's NEW row_x are those that were in gens_b's row_b + // before swap. But gens_a.col_x is indexed by qubit and contains row indices within gens_a. + // So for each qubit q: + // - If row_a was previously in col_x[q] but isn't in gens_a.row_x[row_a] anymore, remove. + // - If row_a wasn't but is now, add. + // The "now" content is what was in gens_b.row_x[row_b] before swap (= gens_a.row_x[row_a] after swap). + // + // Simplest: after swapping row_x bit vectors, rebuild the cols for row_a in gens_a and row_b in gens_b. + // + // For each qubit q: check if gens_a.row_x[row_a].contains(q) == gens_a.col_x[q].contains(row_a). + // If mismatch, toggle col_x[q] membership of row_a. Similar for row_z, row_b, etc. + + let num_qubits = gens_a.col_x.len(); + for q in 0..num_qubits { + // gens_a row_a + let row_x_has = gens_a.row_x[row_a].contains(q); + let col_x_has = gens_a.col_x[q].contains(row_a); + if row_x_has != col_x_has { + gens_a.col_x[q].toggle(row_a); + } + let row_z_has = gens_a.row_z[row_a].contains(q); + let col_z_has = gens_a.col_z[q].contains(row_a); + if row_z_has != col_z_has { + gens_a.col_z[q].toggle(row_a); + } + // gens_b row_b + let row_x_has = gens_b.row_x[row_b].contains(q); + let col_x_has = gens_b.col_x[q].contains(row_b); + if row_x_has != col_x_has { + gens_b.col_x[q].toggle(row_b); + } + let row_z_has = gens_b.row_z[row_b].contains(q); + let col_z_has = gens_b.col_z[q].contains(row_b); + if row_z_has != col_z_has { + gens_b.col_z[q].toggle(row_b); + } + } + + // Swap signs + let a_minus = gens_a.signs_minus.contains(row_a); + let b_minus = gens_b.signs_minus.contains(row_b); + if a_minus != b_minus { + gens_a.signs_minus.toggle(row_a); + gens_b.signs_minus.toggle(row_b); + } + let a_i = gens_a.signs_i.contains(row_a); + let b_i = gens_b.signs_i.contains(row_b); + if a_i != b_i { + gens_a.signs_i.toggle(row_a); + gens_b.signs_i.toggle(row_b); + } +} + +/// Right-compose Hadamard gate on qubit q onto the tableau. +/// +/// Semantically: C -> C * `H_q`. +/// +/// For each stabilizer/destabilizer row, this transforms `Z_k` -> (`H_q` `Z_k` `H_q`). +/// `H_q` `Z_q` `H_q` = `X_q`, so `S_q`' = C `X_q` C^dagger = `D_q` (old destabilizer). +/// `H_q` `Z_k` `H_q` = `Z_k` for k != q (unchanged). +/// Similarly for destabilizers: `H_q` `X_q` `H_q` = `Z_q`, so `D_q`' = old `S_q`. +/// +/// Implementation: swap stabs row q with destabs row q. +pub fn right_compose_h( + tableau: &mut SparseStabY, + q: usize, +) { + let (stabs, destabs) = tableau.stabs_and_destabs_mut(); + swap_rows_between(stabs, q, destabs, q); +} + +/// Right-compose `S_z` (phase) gate on qubit q onto the tableau. +/// +/// `S_z` `Z_q` `S_z^dagger` = `Z_q` (unchanged), so stabilizers unchanged. +/// `S_z` `X_q` `S_z^dagger` = `Y_q` = iXZ, so `D_q`' = i * `D_q` * `S_q`. +/// +/// Implementation: destabs row q *= stabs row q (with phase +i). +/// +/// # Panics +/// +/// Panics if the resulting phase is not a quarter-phase (indicates a bug in +/// the phase-tracking logic). +pub fn right_compose_sz( + tableau: &mut SparseStabY, + q: usize, +) { + let num_qubits = tableau.num_qubits(); + // D_q' = i * D_q * S_q + // Multiply destabs row q by stabs row q (get D_q * S_q) + let stabs_snapshot_x = tableau.stabs().row_x[q].clone(); + let stabs_snapshot_z = tableau.stabs().row_z[q].clone(); + let stabs_minus = tableau.stabs().signs_minus.contains(q); + let stabs_i = tableau.stabs().signs_i.contains(q); + + // Compute per-qubit phase of D_q * S_q (using snapshot of stabs row q) + let destabs = tableau.destabs(); + let mut phase = Complex64::new(1.0, 0.0); + for qq in 0..num_qubits { + let a_x = destabs.row_x[q].contains(qq); + let a_z = destabs.row_z[q].contains(qq); + let b_x = stabs_snapshot_x.contains(qq); + let b_z = stabs_snapshot_z.contains(qq); + if (!a_x && !a_z) || (!b_x && !b_z) { + continue; + } + let pa = pauli_idx(a_x, a_z); + let pb = pauli_idx(b_x, b_z); + let (minus, i_bit) = pauli_phase(pa, pb); + if minus == 1 { + phase *= Complex64::new(-1.0, 0.0); + } + if i_bit == 1 { + phase *= Complex64::new(0.0, 1.0); + } + } + // Include stabs sign + if stabs_minus { + phase *= Complex64::new(-1.0, 0.0); + } + if stabs_i { + phase *= Complex64::new(0.0, 1.0); + } + // Multiply by i (the S_z phase) + phase *= Complex64::new(0.0, 1.0); + + // Now update destabs row q: XOR bits with stabs row q, apply accumulated phase + let destabs_mut = tableau.destabs_mut(); + + // Update col_x/col_z + for qq in &stabs_snapshot_x { + destabs_mut.col_x[qq].toggle(q); + } + for qq in &stabs_snapshot_z { + destabs_mut.col_z[qq].toggle(q); + } + destabs_mut.row_x[q].xor_assign(&stabs_snapshot_x); + destabs_mut.row_z[q].xor_assign(&stabs_snapshot_z); + + // Update sign + let d_minus = destabs_mut.signs_minus.contains(q); + let d_i = destabs_mut.signs_i.contains(q); + let old_sign = match (d_minus, d_i) { + (false, false) => Complex64::new(1.0, 0.0), + (true, false) => Complex64::new(-1.0, 0.0), + (false, true) => Complex64::new(0.0, 1.0), + (true, true) => Complex64::new(0.0, -1.0), + }; + let new_sign = old_sign * phase; + let (new_minus, new_i) = if (new_sign - Complex64::new(1.0, 0.0)).norm() < 1e-9 { + (false, false) + } else if (new_sign - Complex64::new(-1.0, 0.0)).norm() < 1e-9 { + (true, false) + } else if (new_sign - Complex64::new(0.0, 1.0)).norm() < 1e-9 { + (false, true) + } else if (new_sign - Complex64::new(0.0, -1.0)).norm() < 1e-9 { + (true, true) + } else { + panic!("right_compose_sz produced non-quarter-phase: {new_sign}"); + }; + if new_minus != d_minus { + destabs_mut.signs_minus.toggle(q); + } + if new_i != d_i { + destabs_mut.signs_i.toggle(q); + } +} + +/// Right-compose CX(control, target) gate onto the tableau. +/// +/// CX(c, t) acting on the right: +/// - `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, + target: usize, +) { + debug_assert_ne!(control, target, "CX requires distinct qubits"); + let num_qubits = tableau.num_qubits(); + + // stabs[t] *= stabs[c] (multiply within stabs, self-reference) + { + let stabs = tableau.stabs_mut(); + multiply_row_within(stabs, target, control, num_qubits); + } + + // destabs[c] *= destabs[t] + { + let destabs = tableau.destabs_mut(); + multiply_row_within(destabs, control, target, num_qubits); + } +} + +/// Right-compose `S_z^dagger` (inverse phase) gate onto the tableau. +/// +/// Sdg Z Sdg^dagger = Z (unchanged), Sdg X Sdg^dagger = -Y = -iXZ. +/// So destabs[q] gets multiplied by stabs[q] with a -i phase. +pub fn right_compose_szdg( + tableau: &mut SparseStabY, + q: usize, +) { + let num_qubits = tableau.num_qubits(); + let stabs_snapshot_x = tableau.stabs().row_x[q].clone(); + let stabs_snapshot_z = tableau.stabs().row_z[q].clone(); + let stabs_minus = tableau.stabs().signs_minus.contains(q); + let stabs_i = tableau.stabs().signs_i.contains(q); + + let destabs = tableau.destabs(); + let mut phase = Complex64::new(1.0, 0.0); + for qq in 0..num_qubits { + let a_x = destabs.row_x[q].contains(qq); + let a_z = destabs.row_z[q].contains(qq); + let b_x = stabs_snapshot_x.contains(qq); + let b_z = stabs_snapshot_z.contains(qq); + if (!a_x && !a_z) || (!b_x && !b_z) { + continue; + } + let pa = pauli_idx(a_x, a_z); + let pb = pauli_idx(b_x, b_z); + let (minus, i_bit) = pauli_phase(pa, pb); + if minus == 1 { + phase *= Complex64::new(-1.0, 0.0); + } + if i_bit == 1 { + phase *= Complex64::new(0.0, 1.0); + } + } + if stabs_minus { + phase *= Complex64::new(-1.0, 0.0); + } + if stabs_i { + phase *= Complex64::new(0.0, 1.0); + } + // Multiply by -i (Sdg phase) + phase *= Complex64::new(0.0, -1.0); + + let destabs_mut = tableau.destabs_mut(); + for qq in &stabs_snapshot_x { + destabs_mut.col_x[qq].toggle(q); + } + for qq in &stabs_snapshot_z { + destabs_mut.col_z[qq].toggle(q); + } + destabs_mut.row_x[q].xor_assign(&stabs_snapshot_x); + destabs_mut.row_z[q].xor_assign(&stabs_snapshot_z); + + apply_phase_to_sign(destabs_mut, q, phase); +} + +/// Right-compose X gate on qubit q onto the tableau. +/// +/// X Z X = -Z, X X X = X. So stabs[q] sign flips, destabs[q] unchanged. +pub fn right_compose_x( + tableau: &mut SparseStabY, + q: usize, +) { + // X conjugates Z to -Z, so stabs[q] (which is C Z_q C^dagger) becomes C (-Z_q) C^dagger + // Result: flip sign of stabs row q. + let stabs = tableau.stabs_mut(); + stabs.signs_minus.toggle(q); +} + +/// Right-compose Z gate on qubit q onto the tableau. +/// +/// Z Z Z = Z, Z X Z = -X. So destabs[q] sign flips, stabs[q] unchanged. +pub fn right_compose_z( + tableau: &mut SparseStabY, + q: usize, +) { + let destabs = tableau.destabs_mut(); + destabs.signs_minus.toggle(q); +} + +/// Right-compose CY(control, target) gate onto the tableau. +/// +/// CY = (I ⊗ Sdg) · CX · (I ⊗ S) when acting on state (circuit-order first-to-last: S, CX, Sdg). +/// Verify: S · X · Sdg = Y (confirmed by matrix calculation). +/// +/// For right-composition (C' = C * U): U = Sdg · CX · S (matrix form, since virtual-side +/// circuit order is reverse of matrix product direction). No wait -- read carefully: +/// If right-compose applies U to virtual side BEFORE C, then virtual-side circuit order +/// for U = Sdg · CX · S is: S first, then CX, then Sdg. But that's not what we want. +/// +/// Let me restate: we want the VIRTUAL op to be CY = (in circuit order on virtual) S, CX, Sdg. +/// For this, the right-compose sequence is: call Sdg FIRST, then CX, then S. +/// Why? Each `right_compose_X(U)` multiplies C by U on the right: C := C * U. +/// After calls [A, B, C]: tableau = ((`C_init` * A) * B) * C = `C_init` * A * B * C. +/// Read as matrix: virtual op applied first is C (rightmost), then B, then A. +/// So for virtual circuit "S, CX, Sdg" (S first), we need matrix A·B·C = Sdg·CX·S, +/// which means call sequence: Sdg, CX, S. +pub fn right_compose_cy( + tableau: &mut SparseStabY, + control: usize, + target: usize, +) { + right_compose_szdg(tableau, target); + right_compose_cx(tableau, control, target); + right_compose_sz(tableau, target); +} + +/// Right-compose CZ(q1, q2) gate onto the tableau. +/// +/// CZ = `H_2` CX(1,2) `H_2`. The effect on generators: +/// - `Z_1` -> `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]) +pub fn right_compose_cz( + tableau: &mut SparseStabY, + q1: usize, + q2: usize, +) { + debug_assert_ne!(q1, q2, "CZ requires distinct qubits"); + let num_qubits = tableau.num_qubits(); + + // destabs[q1] *= stabs[q2] (X_1 -> X_1 Z_2 means D_1 gets a Z_2 factor = S_2) + multiply_row_across(tableau, q1, q2, num_qubits, /*dest_to_stab=*/ true); + // destabs[q2] *= stabs[q1] + multiply_row_across(tableau, q2, q1, num_qubits, /*dest_to_stab=*/ true); +} + +/// Multiply row `dst_row` by row `src_row` within the same generator set. +fn multiply_row_within( + gens: &mut GensGeneric, + dst_row: usize, + src_row: usize, + num_qubits: usize, +) { + // Snapshot source row + let src_x = gens.row_x[src_row].clone(); + let src_z = gens.row_z[src_row].clone(); + let src_minus = gens.signs_minus.contains(src_row); + let src_i = gens.signs_i.contains(src_row); + + // Compute phase + let mut phase = Complex64::new(1.0, 0.0); + for q in 0..num_qubits { + let a_x = gens.row_x[dst_row].contains(q); + let a_z = gens.row_z[dst_row].contains(q); + let b_x = src_x.contains(q); + let b_z = src_z.contains(q); + if (!a_x && !a_z) || (!b_x && !b_z) { + continue; + } + let pa = pauli_idx(a_x, a_z); + let pb = pauli_idx(b_x, b_z); + let (minus, i_bit) = pauli_phase(pa, pb); + if minus == 1 { + phase *= Complex64::new(-1.0, 0.0); + } + if i_bit == 1 { + phase *= Complex64::new(0.0, 1.0); + } + } + if src_minus { + phase *= Complex64::new(-1.0, 0.0); + } + if src_i { + phase *= Complex64::new(0.0, 1.0); + } + + // Update col_x/col_z + for q in src_x.iter() { + gens.col_x[q].toggle(dst_row); + } + for q in src_z.iter() { + gens.col_z[q].toggle(dst_row); + } + gens.row_x[dst_row].xor_assign(&src_x); + gens.row_z[dst_row].xor_assign(&src_z); + + // Apply accumulated phase to dst_row's sign + apply_phase_to_sign(gens, dst_row, phase); +} + +/// Multiply destabs[`dst_q`] by stabs[`src_q`] (or vice versa). +/// `dest_to_stab`: if true, dst is destabs and src is stabs. +fn multiply_row_across( + tableau: &mut SparseStabY, + dst_q: usize, + src_q: usize, + num_qubits: usize, + dest_to_stab: bool, +) { + if !dest_to_stab { + unimplemented!("only dest_to_stab=true is used here"); + } + // Snapshot source row (from stabs) + let src_x = tableau.stabs().row_x[src_q].clone(); + let src_z = tableau.stabs().row_z[src_q].clone(); + let src_minus = tableau.stabs().signs_minus.contains(src_q); + let src_i = tableau.stabs().signs_i.contains(src_q); + + // Compute phase using destabs[dst_q] (destination) and stabs[src_q] (source) + let destabs = tableau.destabs(); + let mut phase = Complex64::new(1.0, 0.0); + for q in 0..num_qubits { + let a_x = destabs.row_x[dst_q].contains(q); + let a_z = destabs.row_z[dst_q].contains(q); + let b_x = src_x.contains(q); + let b_z = src_z.contains(q); + if (!a_x && !a_z) || (!b_x && !b_z) { + continue; + } + let pa = pauli_idx(a_x, a_z); + let pb = pauli_idx(b_x, b_z); + let (minus, i_bit) = pauli_phase(pa, pb); + if minus == 1 { + phase *= Complex64::new(-1.0, 0.0); + } + if i_bit == 1 { + phase *= Complex64::new(0.0, 1.0); + } + } + if src_minus { + phase *= Complex64::new(-1.0, 0.0); + } + if src_i { + phase *= Complex64::new(0.0, 1.0); + } + + // Update destabs[dst_q] bits + let destabs_mut = tableau.destabs_mut(); + for q in &src_x { + destabs_mut.col_x[q].toggle(dst_q); + } + for q in &src_z { + destabs_mut.col_z[q].toggle(dst_q); + } + destabs_mut.row_x[dst_q].xor_assign(&src_x); + destabs_mut.row_z[dst_q].xor_assign(&src_z); + + apply_phase_to_sign(destabs_mut, dst_q, phase); +} + +/// Combine `phase` (expected to be a fourth root of unity) into the row's sign. +fn apply_phase_to_sign(gens: &mut GensGeneric, row: usize, phase: Complex64) { + let d_minus = gens.signs_minus.contains(row); + let d_i = gens.signs_i.contains(row); + let old_sign = match (d_minus, d_i) { + (false, false) => Complex64::new(1.0, 0.0), + (true, false) => Complex64::new(-1.0, 0.0), + (false, true) => Complex64::new(0.0, 1.0), + (true, true) => Complex64::new(0.0, -1.0), + }; + let new_sign = old_sign * phase; + let (new_minus, new_i) = if (new_sign - Complex64::new(1.0, 0.0)).norm() < 1e-9 { + (false, false) + } else if (new_sign - Complex64::new(-1.0, 0.0)).norm() < 1e-9 { + (true, false) + } else if (new_sign - Complex64::new(0.0, 1.0)).norm() < 1e-9 { + (false, true) + } else if (new_sign - Complex64::new(0.0, -1.0)).norm() < 1e-9 { + (true, true) + } else { + panic!("phase not a fourth root of unity: {new_sign}"); + }; + if new_minus != d_minus { + gens.signs_minus.toggle(row); + } + if new_i != d_i { + gens.signs_i.toggle(row); + } +} + +// Silence unused warnings +#[allow(dead_code)] +fn _type_check() -> BitSet { + BitSet::new() +} + +#[cfg(test)] +mod tests { + use super::*; + use nalgebra::DMatrix; + use pecos_core::QubitId; + use pecos_simulators::{CliffordGateable, SparseStabY}; + + /// Verify that right-composing G and then left-composing G^-1 gives identity. + /// (This doesn't fully test right-compose but is a sanity check.) + + #[test] + fn test_right_compose_h_twice_is_identity() { + // H * H = I, so right-composing H twice should leave tableau unchanged + let mut t = SparseStabY::new(3).with_destab_sign_tracking(); + t.h(&[QubitId(0)]); + t.cx(&[(QubitId(0), QubitId(1))]); + let before_stabs_x: Vec<_> = (0..3).map(|i| t.stabs().row_x[i].clone()).collect(); + let before_stabs_z: Vec<_> = (0..3).map(|i| t.stabs().row_z[i].clone()).collect(); + + right_compose_h(&mut t, 0); + right_compose_h(&mut t, 0); + + for i in 0..3 { + assert_eq!(t.stabs().row_x[i], before_stabs_x[i], "stab {i} x changed"); + assert_eq!(t.stabs().row_z[i], before_stabs_z[i], "stab {i} z changed"); + } + } + + #[test] + fn test_right_compose_h_swaps_stab_destab() { + // Initial state: stabs=[Z_0, Z_1], destabs=[X_0, X_1] + // After right-compose H_0: stabs[0] should become what destabs[0] was (X_0), + // destabs[0] should become what stabs[0] was (Z_0). + let mut t = SparseStabY::new(2); + // Initial: stab[0] = Z_0 (row_z={0}, row_x={}) + // destab[0] = X_0 (row_x={0}, row_z={}) + assert!(t.stabs().row_z[0].contains(0)); + assert!(!t.stabs().row_x[0].contains(0)); + assert!(t.destabs().row_x[0].contains(0)); + assert!(!t.destabs().row_z[0].contains(0)); + + right_compose_h(&mut t, 0); + + // After: stab[0] should be X_0, destab[0] should be Z_0 + assert!( + t.stabs().row_x[0].contains(0), + "stab[0] should have X after H" + ); + assert!(!t.stabs().row_z[0].contains(0)); + assert!( + t.destabs().row_z[0].contains(0), + "destab[0] should have Z after H" + ); + assert!(!t.destabs().row_x[0].contains(0)); + } + + /// Build the 2^n x 2^n matrix for a generator row. + fn gen_matrix( + gens: &GensGeneric, + row: usize, + n: usize, + ) -> nalgebra::DMatrix { + let i_mat = DMatrix::::identity(2, 2); + let x_mat = 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), + ], + ); + let z_mat = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(-1.0, 0.0), + ], + ); + let y_mat = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(0.0, -1.0), + Complex64::new(0.0, 1.0), + Complex64::new(0.0, 0.0), + ], + ); + let mut result = DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + for q in 0..n { + let has_x = gens.row_x[row].contains(q); + let has_z = gens.row_z[row].contains(q); + let p = match (has_x, has_z) { + (false, false) => &i_mat, + (true, false) => &x_mat, + (false, true) => &z_mat, + (true, true) => &y_mat, + }; + result = result.kronecker(p); + } + let mut phase = Complex64::new(1.0, 0.0); + if gens.signs_minus.contains(row) { + phase *= Complex64::new(-1.0, 0.0); + } + if gens.signs_i.contains(row) { + phase *= Complex64::new(0.0, 1.0); + } + result * phase + } + + /// Verify that right-composing G transforms `S_k` into C (G `Z_k` G^dagger) C^dagger. + /// We do this by brute-force: construct gate matrix G, compute expected `S_k`, compare. + #[test] + fn test_right_compose_h_correct_transformation() { + // Start with some non-trivial state + let mut t = SparseStabY::new(2).with_destab_sign_tracking(); + t.h(&[QubitId(0)]); + t.cx(&[(QubitId(0), QubitId(1))]); + + // Compute S_0, S_1 before right-compose + let s0_before = gen_matrix(t.stabs(), 0, 2); + let s1_before = gen_matrix(t.stabs(), 1, 2); + + // Right-compose H on qubit 0 + right_compose_h(&mut t, 0); + + // After right-compose by H_0, S_k should be C (H_0 Z_k H_0) C^dagger. + // H_0 Z_0 H_0 = X_0, H_0 Z_1 H_0 = Z_1 (H only on qubit 0). + // So S_0' = C X_0 C^dagger = D_0 (original destabilizer 0) + // S_1' = C Z_1 C^dagger = S_1 (unchanged) + let s0_after = gen_matrix(t.stabs(), 0, 2); + let s1_after = gen_matrix(t.stabs(), 1, 2); + + assert!( + (s1_after.clone() - s1_before).norm() < 1e-10, + "S_1 should be unchanged" + ); + // S_0 should equal what D_0 was before (we can't easily recompute that here, + // but we can verify S_0 != original S_0) + assert!( + (s0_after.clone() - s0_before).norm() > 1e-3, + "S_0 should have changed" + ); + + // Verify stabilizer algebra: S_0' and S_1' should anticommute appropriately with + // the destabilizers (which also got transformed). + let d0_after = gen_matrix(t.destabs(), 0, 2); + let d1_after = gen_matrix(t.destabs(), 1, 2); + + // S_k and D_k should anticommute: S_k D_k + D_k S_k = 0 + let anti_00 = &s0_after * &d0_after + &d0_after * &s0_after; + let anti_11 = &s1_after * &d1_after + &d1_after * &s1_after; + assert!(anti_00.norm() < 1e-10, "S_0 and D_0 should anticommute"); + assert!(anti_11.norm() < 1e-10, "S_1 and D_1 should anticommute"); + + // S_k and D_j (k != j) should commute: S_k D_j = D_j S_k + let comm_01 = &s0_after * &d1_after - &d1_after * &s0_after; + let comm_10 = &s1_after * &d0_after - &d0_after * &s1_after; + assert!(comm_01.norm() < 1e-10, "S_0 and D_1 should commute"); + assert!(comm_10.norm() < 1e-10, "S_1 and D_0 should commute"); + + // S_0 and S_1 should commute (stabilizers all commute with each other) + let comm_ss = &s0_after * &s1_after - &s1_after * &s0_after; + assert!(comm_ss.norm() < 1e-10, "S_0 and S_1 should commute"); + } + + #[test] + fn test_right_compose_cx_preserves_algebra() { + // Right-compose CX should preserve the symplectic structure + let mut t = SparseStabY::new(3).with_destab_sign_tracking(); + t.h(&[QubitId(0)]); + t.cx(&[(QubitId(0), QubitId(1))]); + + right_compose_cx(&mut t, 0, 2); + + // Verify stabilizer/destabilizer algebra + let stabs: Vec<_> = (0..3).map(|i| gen_matrix(t.stabs(), i, 3)).collect(); + let destabs: Vec<_> = (0..3).map(|i| gen_matrix(t.destabs(), i, 3)).collect(); + + for i in 0..3 { + // S_i and D_i anticommute + let anti = &stabs[i] * &destabs[i] + &destabs[i] * &stabs[i]; + assert!(anti.norm() < 1e-10, "S_{i} and D_{i} should anticommute"); + // S_i and S_j commute (j != i) + for j in 0..3 { + if i == j { + continue; + } + let comm_ss = &stabs[i] * &stabs[j] - &stabs[j] * &stabs[i]; + assert!(comm_ss.norm() < 1e-10, "S_{i} and S_{j} should commute"); + // S_i and D_j commute + let comm_sd = &stabs[i] * &destabs[j] - &destabs[j] * &stabs[i]; + assert!(comm_sd.norm() < 1e-10, "S_{i} and D_{j} should commute"); + } + } + } + + #[test] + fn test_right_compose_sz_preserves_algebra() { + let mut t = SparseStabY::new(2).with_destab_sign_tracking(); + t.h(&[QubitId(0)]); + t.cx(&[(QubitId(0), QubitId(1))]); + + right_compose_sz(&mut t, 0); + + let stabs: Vec<_> = (0..2).map(|i| gen_matrix(t.stabs(), i, 2)).collect(); + let destabs: Vec<_> = (0..2).map(|i| gen_matrix(t.destabs(), i, 2)).collect(); + + for i in 0..2 { + let anti = &stabs[i] * &destabs[i] + &destabs[i] * &stabs[i]; + assert!( + anti.norm() < 1e-10, + "S_{i} and D_{i} should anticommute after right-compose SZ" + ); + } + } + + #[test] + fn test_right_compose_szdg_preserves_algebra() { + let mut t = SparseStabY::new(2).with_destab_sign_tracking(); + t.h(&[QubitId(0)]); + right_compose_szdg(&mut t, 0); + + let stabs: Vec<_> = (0..2).map(|i| gen_matrix(t.stabs(), i, 2)).collect(); + let destabs: Vec<_> = (0..2).map(|i| gen_matrix(t.destabs(), i, 2)).collect(); + for i in 0..2 { + let anti = &stabs[i] * &destabs[i] + &destabs[i] * &stabs[i]; + assert!(anti.norm() < 1e-10); + } + } + + #[test] + fn test_right_compose_s_then_sdg_is_identity() { + // S * Sdg = I + let mut t = SparseStabY::new(2).with_destab_sign_tracking(); + t.h(&[QubitId(0)]); + t.cx(&[(QubitId(0), QubitId(1))]); + + let before = gen_matrix(t.destabs(), 0, 2); + right_compose_sz(&mut t, 0); + right_compose_szdg(&mut t, 0); + let after = gen_matrix(t.destabs(), 0, 2); + assert!( + (after - before).norm() < 1e-10, + "S then Sdg should be identity" + ); + } + + #[test] + fn test_right_compose_x_flips_stab_sign() { + let mut t = SparseStabY::new(2); + let before_sign = t.stabs().signs_minus.contains(0); + right_compose_x(&mut t, 0); + let after_sign = t.stabs().signs_minus.contains(0); + assert_ne!(before_sign, after_sign, "X should flip stab sign"); + // Destab unchanged + assert!(!t.destabs().signs_minus.contains(0)); + } + + /// Directly test: does `right_compose_cy` implement CY or -CY? + /// Build two tableaus: one with `right_compose_cy`, one with the reference's + /// Sdg-CX-S pattern. They should be equivalent if both implement CY. + #[test] + fn test_right_compose_cy_vs_reference_pattern() { + let mut t_mine = SparseStabY::new(3).with_destab_sign_tracking(); + t_mine.h(&[QubitId(0)]); + t_mine.cx(&[(QubitId(0), QubitId(1))]); + let mut t_ref = t_mine.clone(); + + // Mine: right_compose_cy = S, CX, Sdg (in call order) + right_compose_cy(&mut t_mine, 0, 2); + + // Reference pattern: Sdg, CX, S (in call order) + right_compose_szdg(&mut t_ref, 2); + right_compose_cx(&mut t_ref, 0, 2); + right_compose_sz(&mut t_ref, 2); + + // Compare generator matrices + let m_mine: Vec<_> = (0..3).map(|i| gen_matrix(t_mine.stabs(), i, 3)).collect(); + let m_ref: Vec<_> = (0..3).map(|i| gen_matrix(t_ref.stabs(), i, 3)).collect(); + + for i in 0..3 { + let diff = (&m_mine[i] - &m_ref[i]).norm(); + let neg_diff = (&m_mine[i] + &m_ref[i]).norm(); + eprintln!("stab {i}: diff_eq={diff:.3e}, diff_neg={neg_diff:.3e}"); + } + + let d_mine: Vec<_> = (0..3).map(|i| gen_matrix(t_mine.destabs(), i, 3)).collect(); + let d_ref: Vec<_> = (0..3).map(|i| gen_matrix(t_ref.destabs(), i, 3)).collect(); + for i in 0..3 { + let diff = (&d_mine[i] - &d_ref[i]).norm(); + let neg_diff = (&d_mine[i] + &d_ref[i]).norm(); + eprintln!("destab {i}: diff_eq={diff:.3e}, diff_neg={neg_diff:.3e}"); + } + } + + #[test] + fn test_right_compose_cy_preserves_algebra() { + let mut t = SparseStabY::new(3).with_destab_sign_tracking(); + t.h(&[QubitId(0)]); + t.cx(&[(QubitId(0), QubitId(1))]); + + right_compose_cy(&mut t, 0, 2); + + let stabs: Vec<_> = (0..3).map(|i| gen_matrix(t.stabs(), i, 3)).collect(); + let destabs: Vec<_> = (0..3).map(|i| gen_matrix(t.destabs(), i, 3)).collect(); + for i in 0..3 { + let anti = &stabs[i] * &destabs[i] + &destabs[i] * &stabs[i]; + assert!(anti.norm() < 1e-10, "S_{i} and D_{i} should anticommute"); + for j in 0..3 { + if i == j { + continue; + } + let comm_ss = &stabs[i] * &stabs[j] - &stabs[j] * &stabs[i]; + assert!(comm_ss.norm() < 1e-10); + let comm_sd = &stabs[i] * &destabs[j] - &destabs[j] * &stabs[i]; + assert!(comm_sd.norm() < 1e-10); + } + } + } + + #[test] + fn test_right_compose_cx_updates_rows() { + // Right-compose CX(0, 1) on identity tableau: + // stabs[1] *= stabs[0] means stab[1] = Z_1 * Z_0 = Z_0 Z_1 + // destabs[0] *= destabs[1] means destab[0] = X_0 * X_1 = X_0 X_1 + let mut t = SparseStabY::new(3).with_destab_sign_tracking(); + right_compose_cx(&mut t, 0, 1); + + // stab[1] should now have Z on both qubit 0 and qubit 1 + assert!( + t.stabs().row_z[1].contains(0), + "stab[1] should have Z on q0" + ); + assert!( + t.stabs().row_z[1].contains(1), + "stab[1] should have Z on q1" + ); + // stab[0] unchanged + assert!(t.stabs().row_z[0].contains(0)); + assert!(!t.stabs().row_z[0].contains(1)); + // destab[0] should have X on both qubits + assert!(t.destabs().row_x[0].contains(0)); + assert!(t.destabs().row_x[0].contains(1)); + // destab[1] unchanged + assert!(t.destabs().row_x[1].contains(1)); + assert!(!t.destabs().row_x[1].contains(0)); + } +} diff --git a/exp/pecos-stab-tn/tests/verification.rs b/exp/pecos-stab-tn/tests/verification.rs new file mode 100644 index 000000000..c69e51242 --- /dev/null +++ b/exp/pecos-stab-tn/tests/verification.rs @@ -0,0 +1,3264 @@ +// 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. + +//! Verification tests comparing STN and MAST against `StabVec`. + +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; + +/// Check that two state vectors match up to global phase. +fn assert_states_match(sv_a: &[Complex64], sv_b: &[Complex64], label: &str) { + assert_states_close(sv_a, sv_b, 0.01, label); +} + +fn assert_states_close(sv_a: &[Complex64], sv_b: &[Complex64], tol: f64, label: &str) { + assert_eq!(sv_a.len(), sv_b.len(), "{label}: dimension mismatch"); + let norm_a: f64 = sv_a.iter().map(num_complex::Complex::norm_sqr).sum(); + let norm_b: f64 = sv_b.iter().map(num_complex::Complex::norm_sqr).sum(); + assert!( + (norm_a - 1.0).abs() < tol + 0.01, + "{label}: norm_a = {norm_a:.4}" + ); + assert!( + (norm_b - 1.0).abs() < tol + 0.01, + "{label}: norm_b = {norm_b:.4}" + ); + let overlap: Complex64 = sv_a + .iter() + .zip(sv_b.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + assert!( + (overlap.norm_sqr() - 1.0).abs() < tol, + "{label}: overlap = {:.4} (should be 1.0, tol={tol})", + overlap.norm_sqr() + ); +} + +/// Apply a random-ish Clifford+T circuit to both STN and `StabVec`. +fn run_circuit_on_both( + n: usize, + gates: &[(&str, Vec, Option)], + seed: u64, +) -> (Vec, Vec) { + let mut stn = StabMps::with_seed(n, seed); + let mut crz = StabVec::builder(n).seed(seed).build(); + + for (gate, qubits, angle) in gates { + let qids: Vec = qubits.iter().map(|&q| QubitId(q)).collect(); + match *gate { + "h" => { + stn.h(&qids); + crz.h(&qids); + } + "sz" => { + stn.sz(&qids); + crz.sz(&qids); + } + "x" => { + stn.x(&qids); + crz.x(&qids); + } + "z" => { + stn.z(&qids); + crz.z(&qids); + } + "cx" => { + let pairs = vec![(QubitId(qubits[0]), QubitId(qubits[1]))]; + stn.cx(&pairs); + crz.cx(&pairs); + } + "cz" => { + let pairs = vec![(QubitId(qubits[0]), QubitId(qubits[1]))]; + stn.cz(&pairs); + crz.cz(&pairs); + } + "rz" => { + let theta = angle.unwrap(); + stn.rz(theta, &qids); + crz.rz(theta, &qids); + } + "rx" => { + let theta = angle.unwrap(); + stn.rx(theta, &qids); + crz.rx(theta, &qids); + } + "rzz" => { + let theta = angle.unwrap(); + let pairs = vec![(QubitId(qubits[0]), QubitId(qubits[1]))]; + stn.rzz(theta, &pairs); + crz.rzz(theta, &pairs); + } + "t" => { + let t = Angle64::QUARTER_TURN / 2u64; + stn.rz(t, &qids); + crz.rz(t, &qids); + } + _ => panic!("unknown gate: {gate}"), + } + } + + (stn.state_vector(), crz.state_vector()) +} + +// ============================================================================ +// State vector cross-validation tests +// ============================================================================ + +#[test] +fn test_4qubit_random_circuit() { + let gates = vec![ + ("h", vec![0], None), + ("cx", vec![0, 1], None), + ("h", vec![2], None), + ("cx", vec![2, 3], None), + ("t", vec![0], None), + ("t", vec![2], None), + ("cx", vec![1, 2], None), + ("t", vec![1], None), + ("h", vec![3], None), + ("rz", vec![3], Some(Angle64::from_radians(0.7))), + ]; + let (stn_sv, crz_sv) = run_circuit_on_both(4, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "4-qubit random circuit"); +} + +#[test] +fn test_5qubit_deep_circuit() { + let gates = vec![ + ("h", vec![0], None), + ("h", vec![1], None), + ("h", vec![2], None), + ("cx", vec![0, 1], None), + ("cx", vec![2, 3], None), + ("cx", vec![3, 4], None), + ("t", vec![0], None), + ("t", vec![1], None), + ("t", vec![2], None), + ("cx", vec![1, 2], None), + ("h", vec![0], None), + ("t", vec![0], None), + ("cz", vec![0, 3], None), + ("rz", vec![4], Some(Angle64::from_radians(1.5))), + ("cx", vec![4, 0], None), + ("t", vec![4], None), + ]; + let (stn_sv, crz_sv) = run_circuit_on_both(5, &gates, 123); + assert_states_match(&stn_sv, &crz_sv, "5-qubit deep circuit"); +} + +#[test] +fn test_repeated_t_on_same_qubit() { + // T^8 = I (up to phase). 8 T gates on the same qubit. + let mut gates = vec![("h", vec![0], None)]; + for _ in 0..8 { + gates.push(("t", vec![0], None)); + } + let (stn_sv, crz_sv) = run_circuit_on_both(1, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "T^8 on |+>"); +} + +#[test] +fn test_rx_gate() { + let theta = Angle64::from_radians(std::f64::consts::FRAC_PI_3); + let gates = vec![ + ("rx", vec![0], Some(theta)), + ("cx", vec![0, 1], None), + ("rx", vec![1], Some(theta)), + ]; + let (stn_sv, crz_sv) = run_circuit_on_both(2, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "RX circuits"); +} + +#[test] +fn test_rzz_gate() { + let theta = Angle64::from_radians(0.5); + let gates = vec![ + ("h", vec![0], None), + ("h", vec![1], None), + ("rzz", vec![0, 1], Some(theta)), + ]; + let (stn_sv, crz_sv) = run_circuit_on_both(2, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "RZZ gate"); +} + +#[test] +fn test_alternating_clifford_and_t() { + // H, T, S, T, H, T, S, T on 2 qubits with entangling + let gates = vec![ + ("h", vec![0], None), + ("t", vec![0], None), + ("sz", vec![0], None), + ("cx", vec![0, 1], None), + ("t", vec![1], None), + ("h", vec![1], None), + ("t", vec![1], None), + ("sz", vec![1], None), + ("cx", vec![1, 0], None), + ("t", vec![0], None), + ]; + let (stn_sv, crz_sv) = run_circuit_on_both(2, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "alternating Clifford+T"); +} + +// ============================================================================ +// Measurement probability tests +// ============================================================================ + +#[test] +fn test_rx_measurement_probabilities() { + // RX(pi/3)|0> has prob(0) = cos^2(pi/6) = 3/4 + let expected_p0 = 0.75; + let theta = Angle64::from_radians(std::f64::consts::FRAC_PI_3); + + let num_trials = 1000; + let mut count_0 = 0; + for trial in 0..num_trials { + let mut stn = StabMps::with_seed(1, 10_000 + trial); + stn.rx(theta, &[QubitId(0)]); + if !stn.mz(&[QubitId(0)])[0].outcome { + count_0 += 1; + } + } + let p0 = f64::from(count_0) / num_trials as f64; + assert!( + (p0 - expected_p0).abs() < 0.05, + "p(0) = {p0:.3}, expected {expected_p0:.3}" + ); +} + +#[test] +fn test_ghz_measurement_correlation() { + // GHZ state: H, CX chain, T on first qubit. + // All qubits should be correlated. + let n = 4; + let num_trials = 100; + let mut all_correlated = 0; + + for trial in 0..num_trials { + let mut stn = StabMps::with_seed(n, 20_000 + trial); + stn.h(&[QubitId(0)]); + for q in 0..(n - 1) { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + // Apply T to make it non-trivial + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + + let results: Vec = (0..n).map(|q| stn.mz(&[QubitId(q)])[0].outcome).collect(); + + if results.iter().all(|&r| r == results[0]) { + all_correlated += 1; + } + } + let rate = f64::from(all_correlated) / num_trials as f64; + assert!( + rate > 0.90, + "GHZ+T correlation rate {rate:.2} should be > 0.90" + ); +} + +// ============================================================================ +// MAST vs STN comparison +// ============================================================================ + +#[test] +fn test_mast_vs_stn_measurement_statistics() { + // Compare measurement outcome distributions between MAST and STN. + let num_trials = 500; + let mut stn_outcomes = [0u32; 4]; // 2 qubits -> 4 outcomes + let mut mast_outcomes = [0u32; 4]; + + for trial in 0..num_trials { + // STN version + let mut stn = StabMps::with_seed(2, 30_000 + trial); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let r0 = stn.mz(&[QubitId(0)])[0].outcome; + let r1 = stn.mz(&[QubitId(1)])[0].outcome; + let idx = (usize::from(r0) << 1) | usize::from(r1); + stn_outcomes[idx] += 1; + + // MAST version + let mut mast = Mast::with_seed(2, 4, 30_000 + trial); + mast.h(&[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let r0 = mast.mz(&[QubitId(0)])[0].outcome; + let r1 = mast.mz(&[QubitId(1)])[0].outcome; + let idx = (usize::from(r0) << 1) | usize::from(r1); + mast_outcomes[idx] += 1; + } + + // Bell+T: only |00> and |11> should appear (correlation preserved) + let stn_p00 = f64::from(stn_outcomes[0]) / num_trials as f64; + let stn_p11 = f64::from(stn_outcomes[3]) / num_trials as f64; + let mast_p00 = f64::from(mast_outcomes[0]) / num_trials as f64; + let mast_p11 = f64::from(mast_outcomes[3]) / num_trials as f64; + + // Both should have ~50% |00> and ~50% |11> + assert!((stn_p00 - 0.5).abs() < 0.1, "STN p(00) = {stn_p00:.2}"); + assert!((stn_p11 - 0.5).abs() < 0.1, "STN p(11) = {stn_p11:.2}"); + assert!((mast_p00 - 0.5).abs() < 0.1, "MAST p(00) = {mast_p00:.2}"); + assert!((mast_p11 - 0.5).abs() < 0.1, "MAST p(11) = {mast_p11:.2}"); + + // Both should have no |01> or |10> (perfect correlation) + assert!( + stn_outcomes[1] + stn_outcomes[2] == 0, + "STN has uncorrelated outcomes: {stn_outcomes:?}" + ); + assert!( + mast_outcomes[1] + mast_outcomes[2] == 0, + "MAST has uncorrelated outcomes: {mast_outcomes:?}" + ); +} + +// ============================================================================ +// Compression / bond dimension tests +// ============================================================================ + +#[test] +fn test_bond_dim_growth_with_t_gates() { + // Track bond dimension as T gates accumulate + let mut stn = StabMps::new(6); + for q in 0..6 { + stn.h(&[QubitId(q)]); + } + for q in 0..5 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + + let mut bond_dims = vec![stn.max_bond_dim()]; + for q in 0..6 { + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(q)]); + bond_dims.push(stn.max_bond_dim()); + } + + // Bond dimension should grow but stay reasonable with compression + assert!( + *bond_dims.last().unwrap() < 64, + "bond dim after 6 T gates: {bond_dims:?}" + ); +} + +// ============================================================================ +// Randomized fuzz testing +// ============================================================================ + +/// Generate a pseudo-random circuit and compare STN vs `DenseStateVec` state vectors. +fn fuzz_circuit(num_qubits: usize, num_gates: usize, seed: u64) { + // Tolerance scales with circuit depth: more SVD ops → more numerical drift + let tol = 0.01 + 0.002 * num_gates as f64; + fuzz_circuit_with_tol(num_qubits, num_gates, seed, tol); +} + +fn fuzz_circuit_with_tol(num_qubits: usize, num_gates: usize, seed: u64, tol: f64) { + let mut stn = StabMps::with_seed(num_qubits, seed); + // Use DenseStateVec as reference (not CRZ, which has frame optimization issues with CZ) + let mut crz = pecos_simulators::DenseStateVec::new(num_qubits); + + // Use seed to generate a deterministic sequence of gates + let mut rng_state = seed; + let next_rng = |state: &mut u64| -> u64 { + // Simple xorshift + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + + for _ in 0..num_gates { + let gate_type = next_rng(&mut rng_state) % 8; + 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)]); + crz.h(&[QubitId(q0)]); + } + 1 => { + stn.sz(&[QubitId(q0)]); + crz.sz(&[QubitId(q0)]); + } + 2 => { + stn.x(&[QubitId(q0)]); + crz.x(&[QubitId(q0)]); + } + 3 => { + stn.cx(&[(QubitId(q0), QubitId(q1))]); + crz.cx(&[(QubitId(q0), QubitId(q1))]); + } + 4 => { + stn.cz(&[(QubitId(q0), QubitId(q1))]); + crz.cz(&[(QubitId(q0), QubitId(q1))]); + } + 5 => { + // T gate + let t = Angle64::QUARTER_TURN / 2u64; + stn.rz(t, &[QubitId(q0)]); + crz.rz(t, &[QubitId(q0)]); + } + 6 => { + // Random RZ angle + let angle_bits = next_rng(&mut rng_state); + let angle = Angle64::from_radians( + (angle_bits % 1000) as f64 * 0.001 * std::f64::consts::TAU, + ); + stn.rz(angle, &[QubitId(q0)]); + crz.rz(angle, &[QubitId(q0)]); + } + _ => { + // RX + let angle_bits = next_rng(&mut rng_state); + let angle = Angle64::from_radians( + (angle_bits % 1000) as f64 * 0.001 * std::f64::consts::TAU, + ); + stn.rx(angle, &[QubitId(q0)]); + crz.rx(angle, &[QubitId(q0)]); + } + } + } + + let stn_sv = stn.state_vector(); + let dim = 1usize << num_qubits; + let ref_sv: Vec = (0..dim).map(|i| crz.get_amplitude(i)).collect(); + + let overlap: Complex64 = stn_sv + .iter() + .zip(ref_sv.iter()) + .map(|(a, b)| a.conj() * b) + .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 dsv2 = pecos_simulators::DenseStateVec::new(num_qubits); + let mut rng2 = seed; + let next2 = |state: &mut u64| -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + for step in 0..num_gates { + let gt = next2(&mut rng2) % 8; + let q0s = (next2(&mut rng2) % num_qubits as u64) as usize; + let q1s = loop { + let q = (next2(&mut rng2) % num_qubits as u64) as usize; + if q != q0s { + break q; + } + }; + let names = ["h", "sz", "x", "cx", "cz", "t", "rz", "rx"]; + match gt { + 0 => { + stn2.h(&[QubitId(q0s)]); + dsv2.h(&[QubitId(q0s)]); + } + 1 => { + stn2.sz(&[QubitId(q0s)]); + dsv2.sz(&[QubitId(q0s)]); + } + 2 => { + stn2.x(&[QubitId(q0s)]); + dsv2.x(&[QubitId(q0s)]); + } + 3 => { + stn2.cx(&[(QubitId(q0s), QubitId(q1s))]); + dsv2.cx(&[(QubitId(q0s), QubitId(q1s))]); + } + 4 => { + stn2.cz(&[(QubitId(q0s), QubitId(q1s))]); + dsv2.cz(&[(QubitId(q0s), QubitId(q1s))]); + } + 5 => { + let t = Angle64::QUARTER_TURN / 2u64; + stn2.rz(t, &[QubitId(q0s)]); + dsv2.rz(t, &[QubitId(q0s)]); + } + 6 => { + let ab = next2(&mut rng2); + let a = + Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn2.rz(a, &[QubitId(q0s)]); + dsv2.rz(a, &[QubitId(q0s)]); + } + _ => { + let ab = next2(&mut rng2); + let a = + Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn2.rx(a, &[QubitId(q0s)]); + dsv2.rx(a, &[QubitId(q0s)]); + } + } + let sv2 = stn2.state_vector(); + let rv2: Vec = (0..dim).map(|i| dsv2.get_amplitude(i)).collect(); + let ov: Complex64 = sv2.iter().zip(rv2.iter()).map(|(a, b)| a.conj() * b).sum(); + if (ov.norm_sqr() - 1.0).abs() > tol { + eprintln!( + "seed={seed}: diverged at step {step} ({}(q{q0s})): overlap={:.4}, bonds={:?}, mps_norm={:.4}", + names[gt as usize], + ov.norm_sqr(), + stn2.mps().bond_dims(), + stn2.mps().norm_squared() + ); + eprintln!( + " STN: {:?}", + sv2.iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + " REF: {:?}", + rv2.iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + break; + } + } + } + + assert_states_close( + &stn_sv, + &ref_sv, + tol, + &format!("fuzz n={num_qubits} gates={num_gates} seed={seed}"), + ); +} + +#[test] +fn test_fuzz_2qubit_circuits() { + for seed in 100..200 { + fuzz_circuit(2, 10, seed); + } +} + +#[test] +fn test_fuzz_seed_115_mps_check() { + let q0 = QubitId(0); + let q1 = QubitId(1); + let mut stn = StabMps::with_seed(2, 115); + stn.cx(&[(q0, q1)]); + stn.cz(&[(q0, q1)]); + stn.cx(&[(q1, q0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[q1]); + let mps3 = stn.mps().state_vector(); + eprintln!( + "Step 3 MPS: {:?}", + mps3.iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + + stn.cz(&[(q0, q1)]); + stn.h(&[q0]); + stn.cz(&[(q0, q1)]); + stn.sz(&[q0]); + stn.rz(Angle64::from_radians(0.2702), &[q1]); + let mps8 = stn.mps().state_vector(); + eprintln!( + "Step 8 MPS: {:?}", + mps8.iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + // Reference: [0.8639-0.5036i, 0, 0, 0] + approx::assert_relative_eq!(mps8[0].re, 0.8639, epsilon = 0.01); + approx::assert_relative_eq!(mps8[0].im, -0.5036, epsilon = 0.01); + + // Compare state_vector vs DenseStateVec + stn.cx(&[(q0, q1)]); // Step 9 + let stn_sv = stn.state_vector(); + let mut dsv = pecos_simulators::DenseStateVec::new(2); + dsv.cx(&[(q0, q1)]); + dsv.cz(&[(q0, q1)]); + dsv.cx(&[(q1, q0)]); + dsv.rz(Angle64::QUARTER_TURN / 2u64, &[q1]); + dsv.cz(&[(q0, q1)]); + dsv.h(&[q0]); + dsv.cz(&[(q0, q1)]); + dsv.sz(&[q0]); + dsv.rz(Angle64::from_radians(0.2702), &[q1]); + dsv.cx(&[(q0, q1)]); + let ref_sv: Vec = (0..4).map(|i| dsv.get_amplitude(i)).collect(); + eprintln!( + "STN SV: {:?}", + stn_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + "DSV SV: {:?}", + ref_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + let overlap: Complex64 = stn_sv + .iter() + .zip(ref_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!("Overlap: {:.4}", overlap.norm_sqr()); +} + +#[test] +fn test_fuzz_seed_101_measurement_stats() { + // Verify STN measurement probabilities match the state vector. + let t = Angle64::QUARTER_TURN / 2u64; + let rz_angle = Angle64::from_radians(4.0024); + let rx1 = Angle64::from_radians(5.6800); + let rx2 = Angle64::from_radians(5.3973); + + // Compute expected probabilities from state vector. + let mut stn_ref = StabMps::with_seed(2, 42); + stn_ref.rz(t, &[QubitId(1)]); + stn_ref.h(&[QubitId(0)]); + stn_ref.sz(&[QubitId(1)]); + stn_ref.sz(&[QubitId(0)]); + stn_ref.rz(rz_angle, &[QubitId(0)]); + stn_ref.rx(rx1, &[QubitId(0)]); + stn_ref.rx(rx2, &[QubitId(1)]); + stn_ref.x(&[QubitId(0)]); + stn_ref.x(&[QubitId(0)]); + stn_ref.rz(t, &[QubitId(0)]); + let sv = stn_ref.state_vector(); + // sv[i] uses DenseStateVec convention: bit 0 = q0, bit 1 = q1 + let expected_probs: Vec = sv.iter().map(num_complex::Complex::norm_sqr).collect(); + + // Sample measurements and compare. + let num_trials = 500; + let mut stn_outcomes = [0u32; 4]; + for trial in 0..num_trials { + let seed = 50_000 + trial; + let mut stn = StabMps::with_seed(2, seed); + stn.rz(t, &[QubitId(1)]); + stn.h(&[QubitId(0)]); + stn.sz(&[QubitId(1)]); + stn.sz(&[QubitId(0)]); + stn.rz(rz_angle, &[QubitId(0)]); + stn.rx(rx1, &[QubitId(0)]); + stn.rx(rx2, &[QubitId(1)]); + stn.x(&[QubitId(0)]); + stn.x(&[QubitId(0)]); + stn.rz(t, &[QubitId(0)]); + let s0 = stn.mz(&[QubitId(0)])[0].outcome; + let s1 = stn.mz(&[QubitId(1)])[0].outcome; + // Index: bit 0 = q0, bit 1 = q1 (matching DenseStateVec convention) + stn_outcomes[usize::from(s0) | (usize::from(s1) << 1)] += 1; + } + + for i in 0..4 { + let p_s = f64::from(stn_outcomes[i]) / num_trials as f64; + assert!( + (p_s - expected_probs[i]).abs() < 0.1, + "outcome {i}: STN p={p_s:.3} vs expected p={:.3}", + expected_probs[i] + ); + } +} + +#[test] +fn test_fuzz_debug_seed_101() { + // Minimal repro: T, H, S, S, RZ, RX sequence on 2 qubits + // Step-by-step comparison to find divergence point. + let t = Angle64::QUARTER_TURN / 2u64; + 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 crz = StabVec::builder(2).seed(101).build(); + + // Step 0: T on q1 + stn.rz(t, &[QubitId(1)]); + crz.rz(t, &[QubitId(1)]); + + let s1 = stn.state_vector(); + let c1 = crz.state_vector(); + assert_states_match(&s1, &c1, "after T(1)"); + + // Step 1: H on q0 + stn.h(&[QubitId(0)]); + crz.h(&[QubitId(0)]); + let s2 = stn.state_vector(); + let c2 = crz.state_vector(); + assert_states_match(&s2, &c2, "after H(0)"); + + // Step 2: S on q1 + stn.sz(&[QubitId(1)]); + crz.sz(&[QubitId(1)]); + let s3 = stn.state_vector(); + let c3 = crz.state_vector(); + assert_states_match(&s3, &c3, "after S(1)"); + + // Step 3: S on q0 + stn.sz(&[QubitId(0)]); + crz.sz(&[QubitId(0)]); + let s4 = stn.state_vector(); + let c4 = crz.state_vector(); + assert_states_match(&s4, &c4, "after S(0)"); + + // Step 4: RZ on q0 + stn.rz(rz_angle, &[QubitId(0)]); + crz.rz(rz_angle, &[QubitId(0)]); + eprintln!( + "after RZ(0): MPS norm={:.6}, bonds={:?}", + stn.mps().norm_squared(), + stn.mps().bond_dims() + ); + let s5 = stn.state_vector(); + let c5 = crz.state_vector(); + assert_states_match(&s5, &c5, "after RZ(0)"); + + // Step 5: RX on q0 = H + RZ + H + // Do manually to find where norm goes wrong + stn.h(&[QubitId(0)]); + crz.h(&[QubitId(0)]); + eprintln!( + "after H(0): MPS norm={:.6}, bonds={:?}", + stn.mps().norm_squared(), + stn.mps().bond_dims() + ); + let s5h = stn.state_vector(); + let c5h = crz.state_vector(); + assert_states_match(&s5h, &c5h, "after RZ then H"); + + // Check Z_0 decomposition before inner RZ + let decomp = pecos_stab_tn::stab_mps::pauli_decomp::decompose_z( + stn.tableau().stabs(), + stn.tableau().destabs(), + 0, + ); + eprintln!("Z_0 decomp before inner RZ: {decomp:?}"); + + stn.rz(rx_angle1, &[QubitId(0)]); + crz.rz(rx_angle1, &[QubitId(0)]); + eprintln!( + "after inner RZ: MPS norm={:.6}, bonds={:?}", + stn.mps().norm_squared(), + stn.mps().bond_dims() + ); + // Check MPS SV directly against reference + let mps5 = stn.mps().state_vector(); + eprintln!( + "Step5 MPS: {:?}", + mps5.iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!("Step5 ref: [0.4714+0.0969i, 0, 0.2176+0.8492i, 0]"); + let s5r = stn.state_vector(); + let c5r = crz.state_vector(); + assert_states_match(&s5r, &c5r, "after step 5"); + + // Continue with remaining gates from fuzz sequence + // Step 6: RX on q1 = H(1) + RZ(1) + H(1) + let rx_angle2 = Angle64::from_radians(5.3973); + stn.h(&[QubitId(1)]); + crz.h(&[QubitId(1)]); + eprintln!("step 6a (H1): norm={:.6}", stn.mps().norm_squared()); + assert_states_match(&stn.state_vector(), &crz.state_vector(), "step 6a"); + + stn.rz(rx_angle2, &[QubitId(1)]); + crz.rz(rx_angle2, &[QubitId(1)]); + eprintln!( + "step 6b (RZ1): norm={:.6}, bonds={:?}", + stn.mps().norm_squared(), + stn.mps().bond_dims() + ); + // Compare MPS with reference + let mps_sv = stn.mps().state_vector(); + eprintln!( + " Rust MPS: {:?}", + mps_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + // Reference: [0.4259+0.0876i, 0.202+0.0415i, 0.1966+0.7672i, 0.0933+0.3639i] + eprintln!(" Ref MPS: [0.4259+0.0876i, 0.2020+0.0415i, 0.1966+0.7672i, 0.0933+0.3639i]"); + // Check MPS directly vs through state_vector + let mps_sv = stn.mps().state_vector(); + let stn_sv = stn.state_vector(); + let crz_sv = crz.state_vector(); + eprintln!( + "MPS SV: {:?}", + mps_sv + .iter() + .map(|a| format!("{:.3}+{:.3}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + "STN SV: {:?}", + stn_sv + .iter() + .map(|a| format!("{:.3}+{:.3}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + "CRZ SV: {:?}", + crz_sv + .iter() + .map(|a| format!("{:.3}+{:.3}i", a.re, a.im)) + .collect::>() + ); + assert_states_match(&stn_sv, &crz_sv, "step 6b"); + + stn.h(&[QubitId(1)]); + crz.h(&[QubitId(1)]); + eprintln!("step 6c (H1): norm={:.6}", stn.mps().norm_squared()); + let s6 = stn.state_vector(); + let c6 = crz.state_vector(); + assert_states_match(&s6, &c6, "step 6c"); + + // Step 7: X on q0 + stn.x(&[QubitId(0)]); + crz.x(&[QubitId(0)]); + let s7 = stn.state_vector(); + let c7 = crz.state_vector(); + assert_states_match(&s7, &c7, "after step 7"); + + // Step 8: X on q0 + stn.x(&[QubitId(0)]); + crz.x(&[QubitId(0)]); + let s8 = stn.state_vector(); + let c8 = crz.state_vector(); + assert_states_match(&s8, &c8, "after step 8"); + + // Step 9: T on q0 + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + crz.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let s9 = stn.state_vector(); + let c9 = crz.state_vector(); + assert_states_match(&s9, &c9, "after step 9"); +} + +#[test] +fn test_debug_seed_502() { + let num_qubits = 2usize; + let num_gates = 30usize; + let seed = 502u64; + let dim = 1usize << num_qubits; + + let mut stn = StabMps::with_seed(num_qubits, seed); + let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); + + let mut rng_state = seed; + let next_rng = |state: &mut u64| -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + + for step in 0..num_gates { + let gate_type = next_rng(&mut rng_state) % 8; + 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; + } + }; + + let gate_name; + match gate_type { + 0 => { + gate_name = format!("H({q0})"); + stn.h(&[QubitId(q0)]); + dsv.h(&[QubitId(q0)]); + } + 1 => { + gate_name = format!("SZ({q0})"); + stn.sz(&[QubitId(q0)]); + dsv.sz(&[QubitId(q0)]); + } + 2 => { + gate_name = format!("X({q0})"); + stn.x(&[QubitId(q0)]); + dsv.x(&[QubitId(q0)]); + } + 3 => { + gate_name = format!("CX({q0},{q1})"); + stn.cx(&[(QubitId(q0), QubitId(q1))]); + dsv.cx(&[(QubitId(q0), QubitId(q1))]); + } + 4 => { + gate_name = format!("CZ({q0},{q1})"); + stn.cz(&[(QubitId(q0), QubitId(q1))]); + dsv.cz(&[(QubitId(q0), QubitId(q1))]); + } + 5 => { + let t = Angle64::QUARTER_TURN / 2u64; + gate_name = format!("T({q0})"); + stn.rz(t, &[QubitId(q0)]); + dsv.rz(t, &[QubitId(q0)]); + } + 6 => { + let angle_bits = next_rng(&mut rng_state); + let angle = Angle64::from_radians( + (angle_bits % 1000) as f64 * 0.001 * std::f64::consts::TAU, + ); + gate_name = format!("RZ({}, {:.4})", q0, angle.to_radians()); + stn.rz(angle, &[QubitId(q0)]); + dsv.rz(angle, &[QubitId(q0)]); + } + _ => { + let angle_bits = next_rng(&mut rng_state); + let angle = Angle64::from_radians( + (angle_bits % 1000) as f64 * 0.001 * std::f64::consts::TAU, + ); + gate_name = format!("RX({}, {:.4})", q0, angle.to_radians()); + stn.rx(angle, &[QubitId(q0)]); + dsv.rx(angle, &[QubitId(q0)]); + } + } + + let stn_sv = stn.state_vector(); + let ref_sv: Vec = (0..dim).map(|i| dsv.get_amplitude(i)).collect(); + let overlap: Complex64 = stn_sv + .iter() + .zip(ref_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + let ov = overlap.norm_sqr(); + let mps_norm = stn.mps().norm_squared(); + let bonds = stn.mps().bond_dims().to_vec(); + + if (ov - 1.0).abs() > 0.01 { + eprintln!("=== DIVERGENCE at step {step}: {gate_name} ==="); + eprintln!(" overlap={ov:.6}, mps_norm={mps_norm:.6}, bonds={bonds:?}"); + eprintln!( + " STN: {:?}", + stn_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + " REF: {:?}", + ref_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + panic!( + "Divergence at step {step} ({gate_name}): overlap={ov:.6}, mps_norm={mps_norm:.6}, bonds={bonds:?}" + ); + } + + eprintln!( + "step {step:2}: {gate_name:16} overlap={ov:.6} mps_norm={mps_norm:.6} bonds={bonds:?}" + ); + } +} + +#[test] +fn test_fuzz_3qubit_circuits() { + for seed in 200..300 { + fuzz_circuit(3, 12, seed); + } +} + +#[test] +fn test_fuzz_4qubit_circuits() { + for seed in 300..400 { + fuzz_circuit(4, 15, seed); + } +} + +#[test] +fn test_fuzz_5qubit() { + for seed in 400..450 { + fuzz_circuit(5, 12, seed); + } +} + +#[test] +fn test_fuzz_2qubit_deep() { + for seed in 500..600 { + fuzz_circuit(2, 30, seed); + } +} + +#[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 dsv = pecos_simulators::DenseStateVec::new(2); + let t = Angle64::QUARTER_TURN / 2u64; + let pi = Angle64::from_radians(std::f64::consts::PI); + + for (gate, qids, angle) in [ + ("h", vec![QubitId(0)], None), + ("h", vec![QubitId(1)], None), + ("t", vec![QubitId(0)], Some(t)), + ("cx_", vec![QubitId(0), QubitId(1)], None), + ("t", vec![QubitId(1)], Some(t)), + ("rx", vec![QubitId(0)], Some(pi)), + ] { + match gate { + "h" => { + stn.h(&qids); + dsv.h(&qids); + } + "t" => { + let a = angle.unwrap(); + stn.rz(a, &qids); + dsv.rz(a, &qids); + } + "cx_" => { + let p = vec![(qids[0], qids[1])]; + stn.cx(&p); + dsv.cx(&p); + } + "rx" => { + let a = angle.unwrap(); + stn.rx(a, &qids); + dsv.rx(a, &qids); + } + _ => {} + } + } + let stn_sv = stn.state_vector(); + let dsv_sv: Vec = (0..4).map(|i| dsv.get_amplitude(i)).collect(); + eprintln!( + "STN: {:?}", + stn_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + eprintln!( + "DSV: {:?}", + dsv_sv + .iter() + .map(|a| format!("{:.4}+{:.4}i", a.re, a.im)) + .collect::>() + ); + // Print destab signs and tracking flag + eprintln!( + " tracks_destab_signs: {}", + stn.tableau().tracks_destab_signs() + ); + for i in 0..2 { + let dm = stn.tableau().destabs().signs_minus.contains(i); + let di = stn.tableau().destabs().signs_i.contains(i); + eprintln!(" D[{i}] minus={dm} i={di}"); + } + // Also check state before RX(pi) + let mut stn2 = StabMps::with_seed(2, 42); + let mut dsv2 = pecos_simulators::DenseStateVec::new(2); + stn2.h(&[QubitId(0)]); + dsv2.h(&[QubitId(0)]); + stn2.h(&[QubitId(1)]); + dsv2.h(&[QubitId(1)]); + stn2.rz(t, &[QubitId(0)]); + dsv2.rz(t, &[QubitId(0)]); + stn2.cx(&[(QubitId(0), QubitId(1))]); + dsv2.cx(&[(QubitId(0), QubitId(1))]); + stn2.rz(t, &[QubitId(1)]); + dsv2.rz(t, &[QubitId(1)]); + let sv_before = stn2.state_vector(); + let dv_before: Vec = (0..4).map(|i| dsv2.get_amplitude(i)).collect(); + let ov_before: Complex64 = sv_before + .iter() + .zip(dv_before.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!("Before RX(pi): overlap={:.6}", ov_before.norm_sqr()); + assert_states_match(&stn_sv, &dsv_sv, "RX(pi) after non-Clifford"); +} + +#[test] +fn test_seed319_minimal() { + // Minimal circuit from seed 319: non-Clifford, entangling, then RX(pi) + let t = Angle64::QUARTER_TURN / 2u64; + let gates = vec![ + ("t", vec![0], None), + ("h", vec![1], None), + ("rz", vec![3], Some(Angle64::from_radians(3.3427))), + ("cz", vec![0, 3], None), + ("rz", vec![1], Some(t)), // T gate = RZ(pi/4) + ("cz", vec![1, 2], None), + ("sz", vec![1], None), + ("h", vec![2], None), + ("t", vec![2], None), + ("h", vec![2], None), + ("rz", vec![3], Some(Angle64::from_radians(3.0976))), + ("rx", vec![0], Some(Angle64::from_radians(5.2025))), + ( + "rx", + vec![1], + Some(Angle64::from_radians(std::f64::consts::PI)), + ), + ]; + let (stn_sv, crz_sv) = run_circuit_on_both(4, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "seed319 minimal"); +} + +#[test] +fn test_swap_then_t() { + // Minimal reproduction: H on both, SWAP, then T on q1. + let gates = vec![ + ("h", vec![0], None), + ("h", vec![1], None), + ("cx", vec![0, 1], None), + ("cx", vec![1, 0], None), + ("cx", vec![0, 1], None), + ("t", vec![1], None), + ]; + let (stn_sv, crz_sv) = run_circuit_on_both(2, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "SWAP then T"); +} + +#[test] +fn test_h_swap_rz_t() { + // Matches the seed 502 circuit prefix up to the failing step. + let gates = vec![ + ("h", vec![0], None), + ("h", vec![1], None), + ("cx", vec![0, 1], None), + ("cx", vec![1, 0], None), + ("cx", vec![0, 1], None), + ("rx", vec![1], Some(Angle64::from_radians(5.0265))), + ("t", vec![1], None), + ]; + let (stn_sv, crz_sv) = run_circuit_on_both(2, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "H SWAP RX T"); +} + +#[test] +fn test_seed502_prefix() { + // Seed 502 circuit prefix: T(1), RZ(0), X(0), T(1), X(0), RZ(0,~pi), + // H(0), H(1), CX CX CX, RX(1), T(1) + let rz_angle = Angle64::from_radians(3.6317); // angle from seed 502 RNG + let rx_angle = Angle64::from_radians(5.0265); + // Try: full prefix T, RZ, X, T, X, Z, H, H, SWAP, RX, T + let gates = vec![ + ("t", vec![1], None), + ("rz", vec![0], Some(rz_angle)), + ("x", vec![0], None), + ("t", vec![1], None), + ("x", vec![0], None), + ( + "rz", + vec![0], + Some(Angle64::from_radians(std::f64::consts::PI)), + ), + ("h", vec![0], None), + ("h", vec![1], None), + ("cx", vec![0, 1], None), + ("cx", vec![1, 0], None), + ("cx", vec![0, 1], None), + ("rx", vec![1], Some(rx_angle)), + ("t", vec![1], None), + ]; + let mut stn = StabMps::with_seed(2, 42); + let mut dsv = pecos_simulators::DenseStateVec::new(2); + let dim = 1usize << 2; + for (step, (gate, qubits, angle)) in gates.iter().enumerate() { + let qids: Vec = qubits.iter().map(|&q| QubitId(q)).collect(); + match *gate { + "h" => { + stn.h(&qids); + dsv.h(&qids); + } + "sz" => { + stn.sz(&qids); + dsv.sz(&qids); + } + "x" => { + stn.x(&qids); + dsv.x(&qids); + } + "z" => { + stn.z(&qids); + dsv.z(&qids); + } + "cx" => { + let p = vec![(QubitId(qubits[0]), QubitId(qubits[1]))]; + stn.cx(&p); + dsv.cx(&p); + } + "rz" => { + let a = angle.unwrap(); + stn.rz(a, &qids); + dsv.rz(a, &qids); + } + "rx" => { + let a = angle.unwrap(); + stn.rx(a, &qids); + dsv.rx(a, &qids); + } + "t" => { + let t = Angle64::QUARTER_TURN / 2u64; + stn.rz(t, &qids); + dsv.rz(t, &qids); + } + _ => panic!("unknown gate"), + } + let sv = stn.state_vector(); + let rv: Vec = (0..dim).map(|i| dsv.get_amplitude(i)).collect(); + let ov: Complex64 = sv.iter().zip(rv.iter()).map(|(a, b)| a.conj() * b).sum(); + if (ov.norm_sqr() - 1.0).abs() > 0.01 { + eprintln!( + "DIVERGE at step {step} ({gate}(q{})): overlap={:.4}", + qubits[0], + ov.norm_sqr() + ); + // Check decomposition phase at the divergence point + // For RX, the inner RZ acts on the SAME qubit after H + let target_q = qubits[0]; + let decomp = pecos_stab_tn::stab_mps::pauli_decomp::decompose_z( + stn.tableau().stabs(), + stn.tableau().destabs(), + target_q, + ); + let phase_ok = pecos_stab_tn::stab_mps::pauli_decomp::verify_decomposition_brute_force( + stn.tableau().stabs(), + stn.tableau().destabs(), + target_q, + &decomp, + ); + eprintln!(" decomp phase correct: {phase_ok}"); + eprintln!(" decomp: {decomp:?}"); + for i in 0..2 { + let sx: Vec = stn.tableau().stabs().row_x[i].iter().collect(); + let sz: Vec = stn.tableau().stabs().row_z[i].iter().collect(); + let sm = stn.tableau().stabs().signs_minus.contains(i); + let si = stn.tableau().stabs().signs_i.contains(i); + let dx: Vec = stn.tableau().destabs().row_x[i].iter().collect(); + let dz: Vec = stn.tableau().destabs().row_z[i].iter().collect(); + let dm = stn.tableau().destabs().signs_minus.contains(i); + let di = stn.tableau().destabs().signs_i.contains(i); + eprintln!( + " S[{i}]: x={sx:?} z={sz:?} m={sm} i={si} D[{i}]: x={dx:?} z={dz:?} m={dm} i={di}" + ); + } + break; + } + } + let stn_sv = stn.state_vector(); + let dsv_sv: Vec = (0..dim).map(|i| dsv.get_amplitude(i)).collect(); + + // Brute-force: compute |ψ⟩ = Σ_x ν_x * D^x * |stab⟩ directly from tableau + let n = 2usize; + let mps_raw = stn.mps().state_vector(); + let gen_matrix = |is_stab: bool, row: usize| -> nalgebra::DMatrix { + let gens = if is_stab { + stn.tableau().stabs() + } else { + stn.tableau().destabs() + }; + let i2 = nalgebra::DMatrix::::identity(2, 2); + let xm = nalgebra::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), + ], + ); + let zm = nalgebra::DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(-1.0, 0.0), + ], + ); + let ym = nalgebra::DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(0.0, -1.0), + Complex64::new(0.0, 1.0), + Complex64::new(0.0, 0.0), + ], + ); + let mut r = nalgebra::DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0)); + for q in 0..n { + let p = match (gens.row_x[row].contains(q), gens.row_z[row].contains(q)) { + (false, false) => &i2, + (true, false) => &xm, + (false, true) => &zm, + (true, true) => &ym, + }; + r = r.kronecker(p); + } + let mut ph = Complex64::new(1.0, 0.0); + if gens.signs_minus.contains(row) { + ph *= Complex64::new(-1.0, 0.0); + } + if gens.signs_i.contains(row) { + ph *= Complex64::new(0.0, 1.0); + } + r * ph + }; + let id4 = nalgebra::DMatrix::::identity(dim, dim); + let mut proj = id4.clone(); + for k in 0..n { + let sk = gen_matrix(true, k); + proj = (&id4 + &sk) * Complex64::new(0.5, 0.0) * &proj; + } + let mut ss = nalgebra::DVector::from_element(dim, Complex64::new(0.0, 0.0)); + ss[0] = Complex64::new(1.0, 0.0); + let ss = &proj * &ss; + let sn: f64 = ss.iter().map(num_complex::Complex::norm_sqr).sum(); + let ss = ss / Complex64::new(sn.sqrt(), 0.0); + let mut psi = nalgebra::DVector::from_element(dim, Complex64::new(0.0, 0.0)); + for (x, &nu) in mps_raw.iter().enumerate() { + if nu.norm_sqr() < 1e-20 { + continue; + } + let mut st = ss.clone(); + for k in 0..n { + if (x >> (n - 1 - k)) & 1 == 1 { + st = &gen_matrix(false, k) * &st; + } + } + psi += st * nu; + } + // Brute-force uses MSB-first (Kronecker convention). Bit-reverse to match DSV (LSB-first). + let mut bru = vec![Complex64::new(0.0, 0.0); dim]; + for (i, &a) in psi.iter().enumerate() { + let mut rev = 0; + for b in 0..n { + if (i >> b) & 1 == 1 { + rev |= 1 << (n - 1 - b); + } + } + bru[rev] = a; + } + let ov_bru: Complex64 = bru + .iter() + .zip(dsv_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!("BRU vs DSV overlap: {:.6}", ov_bru.norm_sqr()); + let ov_stn: Complex64 = stn_sv + .iter() + .zip(dsv_sv.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!("STN vs DSV overlap: {:.6}", ov_stn.norm_sqr()); + let ov_sb: Complex64 = stn_sv + .iter() + .zip(bru.iter()) + .map(|(a, b)| a.conj() * b) + .sum(); + eprintln!("STN vs BRU overlap: {:.6}", ov_sb.norm_sqr()); + + assert_states_match(&bru, &dsv_sv, "seed502 brute-force vs DSV"); +} + +#[test] +fn test_fuzz_3qubit_deep() { + for seed in 600..700 { + fuzz_circuit(3, 25, seed); + } +} + +#[test] +fn test_fuzz_4qubit_deep() { + for seed in 700..750 { + fuzz_circuit(4, 25, seed); + } +} + +#[test] +fn test_fuzz_6qubit() { + for seed in 750..790 { + fuzz_circuit(6, 15, seed); + } +} + +#[test] +#[ignore = "slow fuzz (~18s debug): run with `cargo test --test verification -- --include-ignored`"] +fn test_fuzz_7qubit() { + for seed in 790..810 { + fuzz_circuit(7, 12, seed); + } +} + +#[test] +#[ignore = "slow fuzz (~80s debug): run with `cargo test --test verification -- --include-ignored`"] +fn test_fuzz_8qubit() { + for seed in 810..820 { + fuzz_circuit(8, 10, seed); + } +} + +#[test] +#[ignore = "deep fuzz (~10min debug, ~30s release): run with `cargo test --release --test verification -- --ignored test_fuzz_deep`"] +fn test_fuzz_deep() { + // Heavy fuzz for pre-release validation. Sweeps 2-8 qubits with many + // seeds and deeper circuits to catch rare corner cases. Run in release + // mode for reasonable turnaround. + for n in 2..=6 { + let depth = 25; + for seed in 0..100u64 { + fuzz_circuit(n, depth, 10000 + seed); + } + } + for n in 7..=8 { + let depth = 15; + for seed in 0..50u64 { + fuzz_circuit(n, depth, 20000 + seed); + } + } +} + +// ============================================================================ +// Measurement probability validation (compare sampling vs state vector) +// ============================================================================ + +/// Build a random circuit using the fuzz RNG, apply it, then check that +/// measurement sampling probabilities match the state vector amplitudes. +fn measurement_probability_check(num_qubits: usize, num_gates: usize, seed: u64) { + // Build the circuit + let mut stn_ref = StabMps::with_seed(num_qubits, seed); + let mut rng_state = seed; + let next_rng = |state: &mut u64| -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + + for _ in 0..num_gates { + let gate_type = next_rng(&mut rng_state) % 8; + 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_ref.h(&[QubitId(q0)]); + } + 1 => { + stn_ref.sz(&[QubitId(q0)]); + } + 2 => { + stn_ref.x(&[QubitId(q0)]); + } + 3 => { + stn_ref.cx(&[(QubitId(q0), QubitId(q1))]); + } + 4 => { + stn_ref.cz(&[(QubitId(q0), QubitId(q1))]); + } + 5 => { + let t = Angle64::QUARTER_TURN / 2u64; + stn_ref.rz(t, &[QubitId(q0)]); + } + 6 => { + let angle_bits = next_rng(&mut rng_state); + let angle = Angle64::from_radians( + (angle_bits % 1000) as f64 * 0.001 * std::f64::consts::TAU, + ); + stn_ref.rz(angle, &[QubitId(q0)]); + } + _ => { + let angle_bits = next_rng(&mut rng_state); + let angle = Angle64::from_radians( + (angle_bits % 1000) as f64 * 0.001 * std::f64::consts::TAU, + ); + stn_ref.rx(angle, &[QubitId(q0)]); + } + } + } + + // Get expected probabilities from state vector + let sv = stn_ref.state_vector(); + let dim = 1usize << num_qubits; + let expected_probs: Vec = sv.iter().map(num_complex::Complex::norm_sqr).collect(); + + // For each qubit, check marginal probability matches sampling + for q in 0..num_qubits { + // Expected p(q=0) = sum of |a_i|^2 where bit q of i is 0 + let expected_p0: f64 = (0..dim) + .filter(|&i| (i >> q) & 1 == 0) + .map(|i| expected_probs[i]) + .sum(); + + let z_ev = pecos_stab_tn::stab_mps::measure::z_expectation_value( + stn_ref.tableau(), + stn_ref.mps(), + q, + ) + .re; + let stn_p0 = f64::midpoint(1.0, z_ev).clamp(0.0, 1.0); + + assert!( + (stn_p0 - expected_p0).abs() < 0.001, + "seed={seed} q={q}: p(0) from ={stn_p0:.4} vs state_vector={expected_p0:.4}" + ); + } +} + +#[test] +fn test_measurement_probabilities_2qubit() { + for seed in 1000..1100 { + measurement_probability_check(2, 10, seed); + } +} + +#[test] +fn test_measurement_probabilities_3qubit() { + for seed in 1100..1200 { + measurement_probability_check(3, 12, seed); + } +} + +#[test] +fn test_measurement_probabilities_4qubit() { + for seed in 1200..1280 { + measurement_probability_check(4, 15, seed); + } +} + +#[test] +fn test_measurement_probabilities_5qubit() { + for seed in 1280..1310 { + measurement_probability_check(5, 10, seed); + } +} + +// ============================================================================ +// Disentangle validation +// ============================================================================ + +#[test] +#[allow(clippy::type_complexity)] +fn test_disentangle_various_circuits() { + // Verify disentangle preserves state for several circuits + let circuits: Vec> = vec![ + Box::new(|stn: &mut StabMps| { + let t = Angle64::QUARTER_TURN / 2u64; + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(t, &[QubitId(0)]); + }), + Box::new(|stn: &mut StabMps| { + let t = Angle64::QUARTER_TURN / 2u64; + stn.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + stn.rz(t, &[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(t, &[QubitId(1)]); + }), + Box::new(|stn: &mut StabMps| { + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.cx(&[(QubitId(1), QubitId(2))]); + stn.rz(Angle64::from_radians(0.7), &[QubitId(1)]); + }), + ]; + + for (i, build) in circuits.iter().enumerate() { + let mut stn = StabMps::new(3); + build(&mut stn); + let sv_before = stn.state_vector(); + let _gates = stn.disentangle(5); + let sv_after = stn.state_vector(); + assert_states_match(&sv_before, &sv_after, &format!("disentangle circuit {i}")); + } +} + +// ============================================================================ +// Edge cases +// ============================================================================ + +#[test] +fn test_single_qubit_identity() { + // No gates at all + let (stn_sv, crz_sv) = run_circuit_on_both(1, &[], 42); + assert_states_match(&stn_sv, &crz_sv, "identity 1-qubit"); +} + +#[test] +fn test_only_cliffords_4qubit() { + let gates = vec![ + ("h", vec![0], None), + ("h", vec![1], None), + ("cx", vec![0, 1], None), + ("cz", vec![2, 3], None), + ("h", vec![2], None), + ("sz", vec![3], None), + ("cx", vec![1, 2], None), + ("cx", vec![3, 0], None), + ("h", vec![0], None), + ("h", vec![1], None), + ("h", vec![2], None), + ("h", vec![3], None), + ]; + let (stn_sv, crz_sv) = run_circuit_on_both(4, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "pure Clifford 4-qubit"); +} + +#[test] +fn test_t_on_every_qubit_product_state() { + // T on each qubit of a product state |+...+> + let n = 4; + let t = Angle64::QUARTER_TURN / 2u64; + let mut gates: Vec<(&str, Vec, Option)> = Vec::new(); + for q in 0..n { + gates.push(("h", vec![q], None)); + } + for q in 0..n { + gates.push(("rz", vec![q], Some(t))); + } + let (stn_sv, crz_sv) = run_circuit_on_both(n, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "T on product state"); +} + +#[test] +fn test_tdg_gate() { + // T-dagger = RZ(-pi/4) + let tdg = -(Angle64::QUARTER_TURN / 2u64); + let gates = vec![("h", vec![0], None), ("rz", vec![0], Some(tdg))]; + let (stn_sv, crz_sv) = run_circuit_on_both(1, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "Tdg gate"); +} + +#[test] +fn test_rz_near_zero_angle() { + // Very small angle -- should behave like identity + let tiny = Angle64::from_radians(1e-6); + let gates = vec![("h", vec![0], None), ("rz", vec![0], Some(tiny))]; + let (stn_sv, crz_sv) = run_circuit_on_both(1, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "near-zero RZ"); +} + +#[test] +fn test_rz_near_pi() { + // Angle near pi -- should behave like Z gate + let near_pi = Angle64::from_radians(std::f64::consts::PI - 1e-6); + let gates = vec![("h", vec![0], None), ("rz", vec![0], Some(near_pi))]; + let (stn_sv, crz_sv) = run_circuit_on_both(1, &gates, 42); + assert_states_match(&stn_sv, &crz_sv, "near-pi RZ"); +} + +#[test] +fn test_stn_reset_and_reuse() { + let mut stn = StabMps::new(2); + stn.h(&[QubitId(0)]); + stn.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + + stn.reset(); + + // After reset, should behave like fresh simulator + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + let sv = stn.state_vector(); + let norm: f64 = sv.iter().map(num_complex::Complex::norm_sqr).sum(); + assert!( + (norm - 1.0).abs() < 0.01, + "norm after reset+circuit: {norm}" + ); +} + +// ============================================================================ +// MAST-specific verification +// ============================================================================ + +#[test] +fn test_mast_multiple_t_gates() { + // Multiple T gates via MAST on entangled qubits + let mut mast = Mast::with_seed(3, 10, 42); + mast.h(&[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + mast.cx(&[(QubitId(1), QubitId(2))]); + // GHZ state + + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(1)]); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(2)]); + + assert_eq!(mast.num_ancillas_used(), 3); + assert!( + mast.mps().norm_squared() > 0.5, + "norm should be reasonable: {}", + mast.mps().norm_squared() + ); +} + +#[test] +fn test_mast_3qubit_ghz_correlation() { + // GHZ + T via MAST: all measurements should be correlated + let num_trials = 100; + let mut all_corr = 0; + for trial in 0..num_trials { + let mut mast = Mast::with_seed(3, 10, 40_000 + trial); + mast.h(&[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + mast.cx(&[(QubitId(1), QubitId(2))]); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + + let r0 = mast.mz(&[QubitId(0)])[0].outcome; + let r1 = mast.mz(&[QubitId(1)])[0].outcome; + let r2 = mast.mz(&[QubitId(2)])[0].outcome; + if r0 == r1 && r1 == r2 { + all_corr += 1; + } + } + let rate = f64::from(all_corr) / num_trials as f64; + assert!( + rate > 0.90, + "GHZ+T MAST correlation {rate:.2} should be > 0.90" + ); +} + +#[test] +fn test_mast_t_then_measure_then_more() { + // Apply T, measure, then apply more gates + let mut mast = Mast::with_seed(2, 4, 42); + mast.h(&[QubitId(0)]); + mast.rz(Angle64::QUARTER_TURN / 2u64, &[QubitId(0)]); + let _r0 = mast.mz(&[QubitId(0)])[0].outcome; + + // After measurement, apply more gates on q1 + mast.h(&[QubitId(1)]); + let r1 = mast.mz(&[QubitId(1)])[0].outcome; + // q1 was in |0>, H puts it in |+>, measurement is random + let _ = r1; // Just verify it doesn't panic +} + +// ============================================================================ +// RZZ fuzz tests +// ============================================================================ + +/// 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 dsv = pecos_simulators::DenseStateVec::new(num_qubits); + + let mut rng_state = seed; + let next_rng = |state: &mut u64| -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + + for _ in 0..num_gates { + let gate_type = next_rng(&mut rng_state) % 10; // expanded set includes rzz + 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)]); + dsv.h(&[QubitId(q0)]); + } + 1 => { + stn.sz(&[QubitId(q0)]); + dsv.sz(&[QubitId(q0)]); + } + 2 => { + stn.x(&[QubitId(q0)]); + dsv.x(&[QubitId(q0)]); + } + 3 => { + stn.cx(&[(QubitId(q0), QubitId(q1))]); + dsv.cx(&[(QubitId(q0), QubitId(q1))]); + } + 4 => { + stn.cz(&[(QubitId(q0), QubitId(q1))]); + dsv.cz(&[(QubitId(q0), QubitId(q1))]); + } + 5 => { + let t = Angle64::QUARTER_TURN / 2u64; + stn.rz(t, &[QubitId(q0)]); + dsv.rz(t, &[QubitId(q0)]); + } + 6 => { + let ab = next_rng(&mut rng_state); + let a = Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn.rz(a, &[QubitId(q0)]); + dsv.rz(a, &[QubitId(q0)]); + } + 7 => { + let ab = next_rng(&mut rng_state); + let a = Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn.rx(a, &[QubitId(q0)]); + dsv.rx(a, &[QubitId(q0)]); + } + 8 | 9 => { + // RZZ gate + let ab = next_rng(&mut rng_state); + let a = Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + let pairs = [(QubitId(q0), QubitId(q1))]; + stn.rzz(a, &pairs); + dsv.rzz(a, &pairs); + } + _ => {} + } + } + + let stn_sv = stn.state_vector(); + let dim = 1usize << num_qubits; + let ref_sv: Vec = (0..dim).map(|i| dsv.get_amplitude(i)).collect(); + let tol = 0.01 + 0.002 * num_gates as f64; + assert_states_close( + &stn_sv, + &ref_sv, + tol, + &format!("rzz fuzz n={num_qubits} g={num_gates} seed={seed}"), + ); +} + +#[test] +fn test_fuzz_rzz_2qubit() { + for seed in 2000..2100 { + fuzz_with_rzz(2, 12, seed); + } +} + +#[test] +fn test_fuzz_rzz_3qubit() { + for seed in 2100..2150 { + fuzz_with_rzz(3, 12, seed); + } +} + +#[test] +fn test_fuzz_rzz_4qubit() { + for seed in 2150..2200 { + fuzz_with_rzz(4, 12, seed); + } +} + +// ============================================================================ +// Sequential measurement tests +// ============================================================================ + +#[test] +fn test_sequential_measurement_correlations() { + // Apply non-Clifford gates, measure a qubit, apply more gates, measure again. + // Repeat many times and check that outcome distributions are consistent. + let num_trials = 200; + let mut outcomes = [[0u32; 2]; 2]; // [q0_outcome][q1_outcome] + + for trial in 0..num_trials { + let mut stn = StabMps::with_seed(2, 5000 + trial); + let mut dsv = pecos_simulators::DenseStateVec::new(2); + + // Prepare entangled state with non-Clifford component + stn.h(&[QubitId(0)]); + dsv.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + dsv.cx(&[(QubitId(0), QubitId(1))]); + let t = Angle64::QUARTER_TURN / 2u64; + stn.rz(t, &[QubitId(0)]); + dsv.rz(t, &[QubitId(0)]); + + // Measure q0 + let r0_stn = stn.mz(&[QubitId(0)])[0].outcome; + + // Apply more gates after measurement + stn.h(&[QubitId(1)]); + stn.rz(t, &[QubitId(1)]); + + // Measure q1 + let r1_stn = stn.mz(&[QubitId(1)])[0].outcome; + + outcomes[usize::from(r0_stn)][usize::from(r1_stn)] += 1; + } + + // Bell+T: q0 and q1 are correlated before first measurement. + // After measuring q0, the state collapses. The second measurement + // should give a definite result. Just check no panics and + // reasonable distribution. + let total: u32 = outcomes.iter().flat_map(|r| r.iter()).sum(); + assert_eq!(total, num_trials as u32); + // Both q0=0 and q0=1 should appear (non-deterministic) + let q0_zero: u32 = outcomes[0].iter().sum(); + let q0_one: u32 = outcomes[1].iter().sum(); + assert!(q0_zero > 10, "q0=0 too rare: {q0_zero}"); + assert!(q0_one > 10, "q0=1 too rare: {q0_one}"); +} + +#[test] +fn test_measure_apply_measure_3qubit() { + // GHZ + T, measure q0, then H+T on q1, measure q1, then measure q2. + for trial in 0..100u64 { + let mut stn = StabMps::with_seed(3, 6000 + trial); + let t = Angle64::QUARTER_TURN / 2u64; + + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.cx(&[(QubitId(1), QubitId(2))]); + stn.rz(t, &[QubitId(1)]); + + let r0 = stn.mz(&[QubitId(0)])[0].outcome; + + // After measuring q0, apply more gates + stn.h(&[QubitId(1)]); + stn.rz(t, &[QubitId(1)]); + + let r1 = stn.mz(&[QubitId(1)])[0].outcome; + let r2 = stn.mz(&[QubitId(2)])[0].outcome; + + // q0 and q2 were in GHZ: after measuring q0, q2 should be deterministic + // (same as q0 due to GHZ correlation, modulo the T gate on q1) + let _ = (r0, r1, r2); // Just verify no panics + } +} + +// ============================================================================ +// MAST vs STN measurement comparison +// ============================================================================ + +#[test] +fn test_mast_matches_stn_exact_probabilities_2q() { + // Compare MAST's sampled distribution to STN's EXACT probabilities + // (not STN's samples). STN's `prob_bitstring` gives the analytic + // value; MAST must sample from this distribution within 5σ. + let t = Angle64::QUARTER_TURN / 2u64; + + // Exact probabilities from STN. + let mut exact_probs = [0.0_f64; 4]; + let mut stn_for_probs = StabMps::with_seed(2, 1234); + stn_for_probs.h(&[QubitId(0)]); + stn_for_probs.cx(&[(QubitId(0), QubitId(1))]); + stn_for_probs.rz(t, &[QubitId(0)]); + 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]. + for (i, ep) in exact_probs.iter_mut().enumerate().take(4) { + let bits = [(i & 2) != 0, (i & 1) != 0]; + *ep = stn_for_probs.prob_bitstring(&bits); + } + let total: f64 = exact_probs.iter().sum(); + assert!( + (total - 1.0).abs() < 1e-9, + "exact probs must sum to 1, got {total}: {exact_probs:?}" + ); + + // Sample MAST many times and compare to exact. + let num_trials = 5000; + let mut mast_counts = [0u32; 4]; + for trial in 0..num_trials { + let seed = 7000 + trial; + let mut mast = Mast::with_seed(2, 4, seed); + mast.h(&[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + mast.rz(t, &[QubitId(0)]); + mast.h(&[QubitId(1)]); + mast.rz(t, &[QubitId(1)]); + let m0 = mast.mz(&[QubitId(0)])[0].outcome; + let m1 = mast.mz(&[QubitId(1)])[0].outcome; + mast_counts[usize::from(m0) | (usize::from(m1) << 1)] += 1; + } + + // 5σ bound on |p_sample - p_exact|: sqrt(p(1-p)/N) × 5 ≤ 0.04 for + // N=5000, any p. Leaves generous room for sampling noise. + for i in 0..4 { + let pe = exact_probs[i]; + let pm = f64::from(mast_counts[i]) / num_trials as f64; + let sigma = (pe * (1.0 - pe) / num_trials as f64).sqrt().max(1e-6); + let deviation = (pe - pm).abs() / sigma; + assert!( + deviation < 5.0, + "outcome {i}: exact={pe:.4} MAST={pm:.4}, deviation {deviation:.1}σ" + ); + } +} + +#[test] +fn test_mast_matches_stn_exact_probabilities_3q() { + let t = Angle64::QUARTER_TURN / 2u64; + + // Exact probs from STN. + let mut exact_probs = [0.0_f64; 8]; + let mut stn = StabMps::with_seed(3, 1234); + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.h(&[QubitId(2)]); + stn.rz(t, &[QubitId(0)]); + stn.rz(t, &[QubitId(2)]); + stn.cx(&[(QubitId(1), QubitId(2))]); + stn.flush(); + // prob_bitstring is MSB-first: bitstring = [q_{n-1}, ..., q_0]. + for (i, ep) in exact_probs.iter_mut().enumerate().take(8) { + let bits = [(i & 4) != 0, (i & 2) != 0, (i & 1) != 0]; + *ep = stn.prob_bitstring(&bits); + } + let total: f64 = exact_probs.iter().sum(); + assert!( + (total - 1.0).abs() < 1e-9, + "probs sum != 1: {exact_probs:?}" + ); + + let num_trials = 5000; + let mut mast_counts = [0u32; 8]; + for trial in 0..num_trials { + let seed = 8000 + trial; + let mut mast = Mast::with_seed(3, 4, seed); + mast.h(&[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + mast.h(&[QubitId(2)]); + mast.rz(t, &[QubitId(0)]); + mast.rz(t, &[QubitId(2)]); + mast.cx(&[(QubitId(1), QubitId(2))]); + let r: Vec = mast + .mz(&[QubitId(0), QubitId(1), QubitId(2)]) + .iter() + .map(|m| m.outcome) + .collect(); + mast_counts[usize::from(r[0]) | (usize::from(r[1]) << 1) | (usize::from(r[2]) << 2)] += 1; + } + + for i in 0..8 { + let pe = exact_probs[i]; + let pm = f64::from(mast_counts[i]) / num_trials as f64; + let sigma = (pe * (1.0 - pe) / num_trials as f64).sqrt().max(1e-6); + let deviation = (pe - pm).abs() / sigma; + assert!( + deviation < 5.0, + "3q outcome {i}: exact={pe:.4} MAST={pm:.4}, dev {deviation:.1}σ" + ); + } +} + +// ============================================================================ +// Large bond dimension stress tests +// ============================================================================ + +#[test] +fn test_many_t_gates_bond_dim_growth() { + // Apply T gates to all qubits of an entangled state. + // Bond dim grows but should stay bounded by max_bond_dim. + let num_qubits = 6; + let t = Angle64::QUARTER_TURN / 2u64; + + let mut stn = StabMps::builder(num_qubits).max_bond_dim(32).build(); + let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); + + // Create full entanglement: H on all, then CX chain + for q in 0..num_qubits { + stn.h(&[QubitId(q)]); + dsv.h(&[QubitId(q)]); + } + for q in 0..num_qubits - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + dsv.cx(&[(QubitId(q), QubitId(q + 1))]); + } + + // Apply T on every qubit — each one grows bond dim + for q in 0..num_qubits { + stn.rz(t, &[QubitId(q)]); + dsv.rz(t, &[QubitId(q)]); + } + + assert!( + stn.max_bond_dim() <= 32, + "bond dim {} exceeds limit", + stn.max_bond_dim() + ); + + let stn_sv = stn.state_vector(); + let dim = 1usize << num_qubits; + let ref_sv: Vec = (0..dim).map(|i| dsv.get_amplitude(i)).collect(); + assert_states_close(&stn_sv, &ref_sv, 0.05, "many T gates on entangled state"); +} + +#[test] +fn test_ghz_plus_t_ladder() { + // GHZ state, then T on alternating qubits, then entangling again. + let num_qubits = 5; + let t = Angle64::QUARTER_TURN / 2u64; + + let mut stn = StabMps::builder(num_qubits).max_bond_dim(64).build(); + let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); + + // GHZ: H(0), CX chain + stn.h(&[QubitId(0)]); + dsv.h(&[QubitId(0)]); + for q in 0..num_qubits - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + dsv.cx(&[(QubitId(q), QubitId(q + 1))]); + } + + // T on even qubits + for q in (0..num_qubits).step_by(2) { + stn.rz(t, &[QubitId(q)]); + dsv.rz(t, &[QubitId(q)]); + } + + // More entangling + for q in (0..num_qubits - 1).rev() { + stn.cx(&[(QubitId(q + 1), QubitId(q))]); + dsv.cx(&[(QubitId(q + 1), QubitId(q))]); + } + + // T on odd qubits + for q in (1..num_qubits).step_by(2) { + stn.rz(t, &[QubitId(q)]); + dsv.rz(t, &[QubitId(q)]); + } + + let stn_sv = stn.state_vector(); + let dim = 1usize << num_qubits; + let ref_sv: Vec = (0..dim).map(|i| dsv.get_amplitude(i)).collect(); + assert_states_close(&stn_sv, &ref_sv, 0.05, "GHZ+T ladder"); +} + +#[test] +fn test_repeated_t_layers_4qubit() { + // Multiple layers of T gates with entangling between layers. + // This is the worst case for bond dim growth. + let num_qubits = 4; + let t = Angle64::QUARTER_TURN / 2u64; + + let mut stn = StabMps::with_seed(num_qubits, 42); + let mut dsv = pecos_simulators::DenseStateVec::new(num_qubits); + + for _layer in 0..3 { + // H + CX entangling layer + for q in 0..num_qubits { + stn.h(&[QubitId(q)]); + dsv.h(&[QubitId(q)]); + } + for q in 0..num_qubits - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + dsv.cx(&[(QubitId(q), QubitId(q + 1))]); + } + // T layer + for q in 0..num_qubits { + stn.rz(t, &[QubitId(q)]); + dsv.rz(t, &[QubitId(q)]); + } + } + + let stn_sv = stn.state_vector(); + let dim = 1usize << num_qubits; + let ref_sv: Vec = (0..dim).map(|i| dsv.get_amplitude(i)).collect(); + // 3 layers * 4 T gates = 12 non-Clifford gates, deep circuit + assert_states_close(&stn_sv, &ref_sv, 0.1, "repeated T layers 4q"); +} + +#[test] +fn test_bond_dim_respects_config() { + // Verify that max_bond_dim is respected even under heavy non-Clifford load. + let num_qubits = 4; + let t = Angle64::QUARTER_TURN / 2u64; + let max_chi = 8; + + let mut stn = StabMps::builder(num_qubits).max_bond_dim(max_chi).build(); + + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.cx(&[(QubitId(1), QubitId(2))]); + stn.cx(&[(QubitId(2), QubitId(3))]); + + // Apply many T gates to push bond dim up + for _ in 0..5 { + for q in 0..num_qubits { + stn.rz(t, &[QubitId(q)]); + } + } + + assert!( + stn.max_bond_dim() <= max_chi, + "bond dim {} exceeds configured max {max_chi}", + stn.max_bond_dim() + ); + // MPS should still be approximately normalized + assert!( + (stn.mps().norm_squared() - 1.0).abs() < 0.5, + "MPS norm too far from 1: {}", + stn.mps().norm_squared() + ); +} + +// ============================================================================ +// Tdg (negative angle) fuzz +// ============================================================================ + +/// 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 dsv = pecos_simulators::DenseStateVec::new(num_qubits); + + let mut rng_state = seed; + let next_rng = |state: &mut u64| -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + + for _ in 0..num_gates { + let gate_type = next_rng(&mut rng_state) % 10; + 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)]); + dsv.h(&[QubitId(q0)]); + } + 1 => { + stn.sz(&[QubitId(q0)]); + dsv.sz(&[QubitId(q0)]); + } + 2 => { + stn.x(&[QubitId(q0)]); + dsv.x(&[QubitId(q0)]); + } + 3 => { + stn.cx(&[(QubitId(q0), QubitId(q1))]); + dsv.cx(&[(QubitId(q0), QubitId(q1))]); + } + 4 => { + // T gate + let t = Angle64::QUARTER_TURN / 2u64; + stn.rz(t, &[QubitId(q0)]); + dsv.rz(t, &[QubitId(q0)]); + } + 5 => { + // Tdg gate (negative T) + let tdg = -(Angle64::QUARTER_TURN / 2u64); + stn.rz(tdg, &[QubitId(q0)]); + dsv.rz(tdg, &[QubitId(q0)]); + } + 6 => { + // Random negative-angle RZ + let ab = next_rng(&mut rng_state); + let a = -Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn.rz(a, &[QubitId(q0)]); + dsv.rz(a, &[QubitId(q0)]); + } + 7 => { + // Random positive-angle RZ + let ab = next_rng(&mut rng_state); + let a = Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn.rz(a, &[QubitId(q0)]); + dsv.rz(a, &[QubitId(q0)]); + } + 8 => { + // RX with negative angle + let ab = next_rng(&mut rng_state); + let a = -Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn.rx(a, &[QubitId(q0)]); + dsv.rx(a, &[QubitId(q0)]); + } + _ => { + stn.cz(&[(QubitId(q0), QubitId(q1))]); + dsv.cz(&[(QubitId(q0), QubitId(q1))]); + } + } + } + let stn_sv = stn.state_vector(); + let dim = 1usize << num_qubits; + let ref_sv: Vec = (0..dim).map(|i| dsv.get_amplitude(i)).collect(); + let tol = 0.01 + 0.002 * num_gates as f64; + assert_states_close( + &stn_sv, + &ref_sv, + tol, + &format!("tdg fuzz n={num_qubits} g={num_gates} seed={seed}"), + ); +} + +#[test] +fn test_fuzz_tdg_2qubit() { + for seed in 3000..3100 { + fuzz_with_tdg(2, 12, seed); + } +} + +#[test] +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 dsv = pecos_simulators::DenseStateVec::new(2); + let mut rng_state = seed; + let next_rng = |state: &mut u64| -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + for _ in 0..12 { + let gt = next_rng(&mut rng_state) % 8; + let q0 = (next_rng(&mut rng_state) % 2) as usize; + let q1 = 1 - q0; + match gt { + 0 => { + stn.h(&[QubitId(q0)]); + dsv.h(&[QubitId(q0)]); + } + 1 => { + stn.sz(&[QubitId(q0)]); + dsv.sz(&[QubitId(q0)]); + } + 2 => { + stn.szdg(&[QubitId(q0)]); + dsv.szdg(&[QubitId(q0)]); + } + 3 => { + stn.cx(&[(QubitId(q0), QubitId(q1))]); + dsv.cx(&[(QubitId(q0), QubitId(q1))]); + } + 4 => { + let t = Angle64::QUARTER_TURN / 2u64; + stn.rz(t, &[QubitId(q0)]); + dsv.rz(t, &[QubitId(q0)]); + } + 5 => { + let tdg = -(Angle64::QUARTER_TURN / 2u64); + stn.rz(tdg, &[QubitId(q0)]); + dsv.rz(tdg, &[QubitId(q0)]); + } + 6 => { + let ab = next_rng(&mut rng_state); + let a = + Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn.rz(a, &[QubitId(q0)]); + dsv.rz(a, &[QubitId(q0)]); + } + _ => { + stn.cz(&[(QubitId(q0), QubitId(q1))]); + dsv.cz(&[(QubitId(q0), QubitId(q1))]); + } + } + } + let stn_sv = stn.state_vector(); + let ref_sv: Vec = (0..4).map(|i| dsv.get_amplitude(i)).collect(); + assert_states_close(&stn_sv, &ref_sv, 0.04, &format!("szdg fuzz seed={seed}")); + } +} + +#[test] +fn test_fuzz_tdg_3qubit() { + for seed in 3100..3150 { + fuzz_with_tdg(3, 12, seed); + } +} + +// ============================================================================ +// Post-measurement state correctness +// ============================================================================ + +#[test] +fn test_post_measurement_state_consistency() { + // After measuring q0, verify the STN state is internally consistent: + // the z_expectation_value of unmeasured qubits should match the + // probabilities from the state vector. + let t = Angle64::QUARTER_TURN / 2u64; + + for trial in 0..100u64 { + let seed = 9000 + trial; + let mut stn = StabMps::with_seed(2, seed); + + // Build non-trivial state + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(t, &[QubitId(0)]); + stn.h(&[QubitId(1)]); + stn.rz(t, &[QubitId(1)]); + + // Check before measurement matches state vector + let sv_before = stn.state_vector(); + let ev_z0_sv: f64 = sv_before + .iter() + .enumerate() + .map(|(i, a)| { + let sign = if i & 1 == 0 { 1.0 } else { -1.0 }; + sign * a.norm_sqr() + }) + .sum(); + let ev_z0_mps = + pecos_stab_tn::stab_mps::measure::z_expectation_value(stn.tableau(), stn.mps(), 0).re; + assert!( + (ev_z0_sv - ev_z0_mps).abs() < 0.01, + "trial {trial}: pre-meas sv={ev_z0_sv:.4} mps={ev_z0_mps:.4}" + ); + + // Measure q0 + let r0 = stn.mz(&[QubitId(0)])[0].outcome; + + // After measurement: from expectation value should match state_vector + let sv_after = stn.state_vector(); + let ev_z1_sv: f64 = sv_after + .iter() + .enumerate() + .map(|(i, a)| { + let sign = if (i >> 1) & 1 == 0 { 1.0 } else { -1.0 }; + sign * a.norm_sqr() + }) + .sum(); + let ev_z1_mps = + pecos_stab_tn::stab_mps::measure::z_expectation_value(stn.tableau(), stn.mps(), 1).re; + assert!( + (ev_z1_sv - ev_z1_mps).abs() < 0.05, + "trial {trial}: post-meas sv={ev_z1_sv:.4} mps={ev_z1_mps:.4}" + ); + + // Check after measurement + let ev_z0_after = + pecos_stab_tn::stab_mps::measure::z_expectation_value(stn.tableau(), stn.mps(), 0).re; + let expected_ev = if r0 { -1.0 } else { 1.0 }; + // Also check via state vector (brute-force) + let sv_after = stn.state_vector(); + let ev_z0_sv: f64 = sv_after + .iter() + .enumerate() + .map(|(i, a)| { + let sign = if i & 1 == 0 { 1.0 } else { -1.0 }; + sign * a.norm_sqr() + }) + .sum(); + if (ev_z0_after - expected_ev).abs() > 0.1 { + eprintln!( + "trial {trial}: outcome={r0}, mps={ev_z0_after:.4} sv={ev_z0_sv:.4} (expected {expected_ev})" + ); + // Print decomposition of Z_0 after measurement + let decomp = pecos_stab_tn::stab_mps::pauli_decomp::decompose_z( + stn.tableau().stabs(), + stn.tableau().destabs(), + 0, + ); + eprintln!(" Z_0 decomp after meas: {decomp:?}"); + eprintln!(" MPS bonds: {:?}", stn.mps().bond_dims()); + } + + // Re-measure q0: should give same outcome (collapsed state) + let r0_again = stn.mz(&[QubitId(0)]); + assert_eq!( + r0_again[0].outcome, r0, + "trial {trial}: re-measurement should give same outcome, ={ev_z0_after:.4}" + ); + } +} + +#[test] +fn test_post_measurement_multisite_collapse() { + // Trigger a multi-site DestabilizerFlip measurement (flip + sign sites). + // Then re-measure to verify collapse. + let t = Angle64::QUARTER_TURN / 2u64; + + for trial in 0..50u64 { + let seed = 9200 + trial; + let mut stn = StabMps::with_seed(3, seed); + + // Build state where Z_0 decomposes with both flip and sign sites + stn.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.cx(&[(QubitId(1), QubitId(2))]); + stn.rz(t, &[QubitId(0)]); + stn.rz(t, &[QubitId(1)]); + stn.h(&[QubitId(0)]); // Change basis to make Z_0 decomposition multi-site + + // Measure q0 + let r0 = stn.mz(&[QubitId(0)])[0].outcome; + + // Re-measure: should give same outcome + let r0_again = stn.mz(&[QubitId(0)]); + assert_eq!( + r0_again[0].outcome, r0, + "trial {trial}: multi-site re-measurement should give same outcome" + ); + + // Verify expectation value consistency: from MPS should match state_vector + let sv = stn.state_vector(); + for q in 0..3 { + let ev_sv: f64 = sv + .iter() + .enumerate() + .map(|(i, a)| { + let sign = if (i >> q) & 1 == 0 { 1.0 } else { -1.0 }; + sign * a.norm_sqr() + }) + .sum(); + let ev_mps = + pecos_stab_tn::stab_mps::measure::z_expectation_value(stn.tableau(), stn.mps(), q) + .re; + assert!( + (ev_sv - ev_mps).abs() < 0.05, + "trial {trial}: post-multisite-meas sv={ev_sv:.4} mps={ev_mps:.4}" + ); + } + } +} + +#[test] +fn test_post_measurement_state_3qubit() { + // Measure q0 on a 3-qubit entangled state, then check internal consistency. + let t = Angle64::QUARTER_TURN / 2u64; + + for trial in 0..50u64 { + let seed = 9500 + trial; + let mut stn = StabMps::with_seed(3, seed); + + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.h(&[QubitId(2)]); + stn.rz(t, &[QubitId(1)]); + + let _ = stn.mz(&[QubitId(0)])[0].outcome; + + // After measurement: check expectation values match state vector + let sv = stn.state_vector(); + for q in 1..3 { + let ev_sv: f64 = sv + .iter() + .enumerate() + .map(|(i, a)| { + let sign = if (i >> q) & 1 == 0 { 1.0 } else { -1.0 }; + sign * a.norm_sqr() + }) + .sum(); + let ev_mps = + pecos_stab_tn::stab_mps::measure::z_expectation_value(stn.tableau(), stn.mps(), q) + .re; + assert!( + (ev_sv - ev_mps).abs() < 0.05, + "trial {trial}: post-meas sv={ev_sv:.4} mps={ev_mps:.4}" + ); + } + } +} + +// ============================================================================ +// Single-qubit circuit fuzz +// ============================================================================ + +#[test] +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 dsv = pecos_simulators::DenseStateVec::new(1); + + let mut rng_state = seed; + let next_rng = |state: &mut u64| -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + + for _ in 0..15 { + let gate_type = next_rng(&mut rng_state) % 6; + match gate_type { + 0 => { + stn.h(&[QubitId(0)]); + dsv.h(&[QubitId(0)]); + } + 1 => { + stn.sz(&[QubitId(0)]); + dsv.sz(&[QubitId(0)]); + } + 2 => { + let t = Angle64::QUARTER_TURN / 2u64; + stn.rz(t, &[QubitId(0)]); + dsv.rz(t, &[QubitId(0)]); + } + 3 => { + let tdg = -(Angle64::QUARTER_TURN / 2u64); + stn.rz(tdg, &[QubitId(0)]); + dsv.rz(tdg, &[QubitId(0)]); + } + 4 => { + let ab = next_rng(&mut rng_state); + let a = + Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn.rz(a, &[QubitId(0)]); + dsv.rz(a, &[QubitId(0)]); + } + _ => { + let ab = next_rng(&mut rng_state); + let a = + Angle64::from_radians((ab % 1000) as f64 * 0.001 * std::f64::consts::TAU); + stn.rx(a, &[QubitId(0)]); + dsv.rx(a, &[QubitId(0)]); + } + } + } + let stn_sv = stn.state_vector(); + let ref_sv: Vec = (0..2).map(|i| dsv.get_amplitude(i)).collect(); + assert_states_close(&stn_sv, &ref_sv, 0.01, &format!("1q fuzz seed={seed}")); + } +} + +// ============================================================================ +// RZZ at Clifford angles +// ============================================================================ + +#[test] +fn test_rzz_clifford_angles() { + // RZZ at Clifford angles should not grow bond dimension. + let clifford_angles = [ + Angle64::ZERO, + Angle64::QUARTER_TURN, // pi/2 + Angle64::HALF_TURN, // pi + Angle64::THREE_QUARTERS_TURN, // 3pi/2 + ]; + + for &angle in &clifford_angles { + let mut stn = StabMps::with_seed(3, 42); + let mut dsv = pecos_simulators::DenseStateVec::new(3); + + // Create entangled state first + stn.h(&[QubitId(0)]); + dsv.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + dsv.cx(&[(QubitId(0), QubitId(1))]); + stn.h(&[QubitId(2)]); + dsv.h(&[QubitId(2)]); + + // Apply RZZ at Clifford angle + let pairs = [(QubitId(0), QubitId(1))]; + stn.rzz(angle, &pairs); + dsv.rzz(angle, &pairs); + + // Bond dim should stay 1 (Clifford doesn't grow MPS) + assert_eq!( + stn.max_bond_dim(), + 1, + "RZZ({angle:?}) should not grow bond dim" + ); + + let stn_sv = stn.state_vector(); + let ref_sv: Vec = (0..8).map(|i| dsv.get_amplitude(i)).collect(); + assert_states_match(&stn_sv, &ref_sv, &format!("RZZ Clifford angle {angle:?}")); + } +} + +#[test] +fn test_rzz_then_non_clifford() { + // RZZ at non-Clifford angle, then more gates. Verify state. + let angle = Angle64::from_radians(0.7); + let t = Angle64::QUARTER_TURN / 2u64; + + let mut stn = StabMps::with_seed(3, 42); + let mut dsv = pecos_simulators::DenseStateVec::new(3); + + stn.h(&[QubitId(0)]); + dsv.h(&[QubitId(0)]); + stn.h(&[QubitId(1)]); + dsv.h(&[QubitId(1)]); + stn.rzz(angle, &[(QubitId(0), QubitId(1))]); + dsv.rzz(angle, &[(QubitId(0), QubitId(1))]); + stn.rz(t, &[QubitId(0)]); + dsv.rz(t, &[QubitId(0)]); + stn.cx(&[(QubitId(1), QubitId(2))]); + dsv.cx(&[(QubitId(1), QubitId(2))]); + stn.rz(t, &[QubitId(2)]); + dsv.rz(t, &[QubitId(2)]); + + let stn_sv = stn.state_vector(); + let ref_sv: Vec = (0..8).map(|i| dsv.get_amplitude(i)).collect(); + assert_states_close(&stn_sv, &ref_sv, 0.05, "RZZ then non-Clifford"); +} + +// ============================================================================ +// compress() correctness +// ============================================================================ + +#[test] +fn test_compress_preserves_state() { + use pecos_stab_tn::mps::{Mps, MpsConfig}; + + // Build an MPS via addition (doubles bond dim), then compress. + // State vector should be unchanged. + let mps_a = Mps::new(3, MpsConfig::default()); + let mut mps_b = Mps::new(3, MpsConfig::default()); + + let h = nalgebra::DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(std::f64::consts::FRAC_1_SQRT_2, 0.0), + Complex64::new(-std::f64::consts::FRAC_1_SQRT_2, 0.0), + ], + ); + let cnot = nalgebra::DMatrix::from_row_slice( + 4, + 4, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ); + + // mps_a = |000> + // mps_b = H(0) CX(0,1) |000> = Bell on (0,1) ⊗ |0> + mps_b.apply_one_site_gate(0, &h).unwrap(); + mps_b.apply_two_site_gate(0, &cnot).unwrap(); + mps_b.scale(Complex64::new(0.5, 0.0)); // scale down + + let sum = mps_a.add(&mps_b); + let sv_before = sum.state_vector(); + let bond_before = sum.max_bond_dim(); + + let mut compressed = sum; + compressed.compress(); + let sv_after = compressed.state_vector(); + let bond_after = compressed.max_bond_dim(); + + // State should be preserved + assert_eq!(sv_before.len(), sv_after.len()); + for (i, (a, b)) in sv_before.iter().zip(sv_after.iter()).enumerate() { + assert!( + (a - b).norm() < 1e-10, + "compress changed amplitude at index {i}: {a:.6} -> {b:.6}" + ); + } + + // Bond dim should not increase + assert!( + bond_after <= bond_before, + "compress increased bond dim: {bond_before} -> {bond_after}" + ); +} + +// ============================================================================ +// MAST 3-qubit measurement investigation +// ============================================================================ + +#[test] +fn test_mast_single_t_measurement_distribution() { + // Simpler MAST test: H(0), T(0), measure. p(0)=p(1)=0.5. + let t = Angle64::QUARTER_TURN / 2u64; + let num_trials = 500; + let mut count_0 = 0u32; + + for trial in 0..num_trials { + let mut mast = Mast::with_seed(1, 2, 10000 + trial as u64); + mast.h(&[QubitId(0)]); + mast.rz(t, &[QubitId(0)]); + if !mast.mz(&[QubitId(0)])[0].outcome { + count_0 += 1; + } + } + let p0 = f64::from(count_0) / f64::from(num_trials); + assert!( + (p0 - 0.5).abs() < 0.1, + "MAST T|+> measurement: p(0)={p0:.3}, expected 0.5" + ); +} + +#[test] +fn test_mast_bell_t_measurement_correlation() { + // MAST: Bell + T, measure both. Outcomes should be correlated. + let t = Angle64::QUARTER_TURN / 2u64; + let num_trials = 200; + let mut correlated = 0u32; + + for trial in 0..num_trials { + let mut mast = Mast::with_seed(2, 2, 11000 + trial as u64); + mast.h(&[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + mast.rz(t, &[QubitId(0)]); + + let r0 = mast.mz(&[QubitId(0)])[0].outcome; + let r1 = mast.mz(&[QubitId(1)])[0].outcome; + if r0 == r1 { + correlated += 1; + } + } + let corr_rate = f64::from(correlated) / f64::from(num_trials); + // Bell state: outcomes should be perfectly correlated + assert!( + corr_rate > 0.95, + "MAST Bell+T correlation: {corr_rate:.3}, expected ~1.0" + ); +} + +#[test] +fn test_mast_3qubit_outcome_coverage() { + // Check that MAST produces multiple distinct outcomes for a 3-qubit circuit. + let t = Angle64::QUARTER_TURN / 2u64; + let num_trials = 300; + let mut seen = std::collections::HashSet::new(); + + for trial in 0..num_trials { + let mut mast = Mast::with_seed(3, 4, 12000 + trial as u64); + mast.h(&[QubitId(0)]); + mast.h(&[QubitId(1)]); + mast.h(&[QubitId(2)]); + mast.rz(t, &[QubitId(0)]); + mast.cx(&[(QubitId(0), QubitId(1))]); + mast.rz(t, &[QubitId(1)]); + + let results = mast.mz(&[QubitId(0), QubitId(1), QubitId(2)]); + let outcome: u8 = results + .iter() + .enumerate() + .map(|(i, r)| (u8::from(r.outcome)) << i) + .sum(); + seen.insert(outcome); + } + + // With H on all qubits + T + entangling, we should see many outcomes + assert!( + seen.len() >= 4, + "MAST 3q should produce at least 4 distinct outcomes, got {}", + seen.len() + ); +} + +// ============================================================================ +// Paper property verification +// ============================================================================ + +#[test] +fn test_property_cliffords_dont_grow_bond_dim() { + // Paper claim: Clifford gates only update the tableau. MPS stays at bond dim 1. + let mut stn = StabMps::with_seed(8, 42); + + // Apply many Clifford gates: H, S, CX, CZ on all qubits + for q in 0..8 { + stn.h(&[QubitId(q)]); + } + for q in 0..7 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + for q in 0..8 { + stn.sz(&[QubitId(q)]); + } + for q in (0..7).rev() { + stn.cz(&[(QubitId(q), QubitId(q + 1))]); + } + for q in 0..8 { + stn.h(&[QubitId(q)]); + } + + // Bond dim should still be 1 everywhere + assert_eq!( + stn.max_bond_dim(), + 1, + "Clifford gates should not grow bond dimension" + ); +} + +#[test] +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); + + // Create entangled state + for q in 0..4 { + stn.h(&[QubitId(q)]); + } + for q in 0..3 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + + let bond_before = stn.max_bond_dim(); + assert_eq!(bond_before, 1, "pure Clifford should have bond dim 1"); + + // Apply T gates — bond dim should grow + stn.rz(t, &[QubitId(0)]); + let bond_after_1t = stn.max_bond_dim(); + assert!( + bond_after_1t >= 1, + "T gate on entangled state should maintain or grow bond dim" + ); + + stn.rz(t, &[QubitId(2)]); + let bond_after_2t = stn.max_bond_dim(); + + eprintln!( + "STN bond dim: before={bond_before}, after 1T={bond_after_1t}, after 2T={bond_after_2t}" + ); +} + +#[test] +fn test_property_mast_bond_dim_stays_low() { + // Paper claim (PRL 2025): for random circuits with t <= N non-Clifford gates, + // MAST bond dimension stays ~3 on average. + let t = Angle64::QUARTER_TURN / 2u64; + let num_qubits = 8; + let num_t_gates = 8; // t = N + + let mut total_max_bond = 0usize; + let num_trials = 20; + + for trial in 0..num_trials { + let mut mast = Mast::with_seed(num_qubits, num_t_gates, 20000 + trial as u64); + + // Random Clifford layer + for q in 0..num_qubits { + mast.h(&[QubitId(q)]); + } + for q in 0..num_qubits - 1 { + mast.cx(&[(QubitId(q), QubitId(q + 1))]); + } + + // Apply t T gates on random qubits + let mut rng_state = 30000 + trial as u64; + for _ in 0..num_t_gates { + rng_state ^= rng_state << 13; + rng_state ^= rng_state >> 7; + rng_state ^= rng_state << 17; + let q = (rng_state % num_qubits as u64) as usize; + mast.rz(t, &[QubitId(q)]); + } + + // More Clifford entangling + for q in (0..num_qubits - 1).rev() { + mast.cx(&[(QubitId(q + 1), QubitId(q))]); + } + + // Force projection of all deferred measurements + mast.mz(&[QubitId(0)]); + + total_max_bond += mast.mps().max_bond_dim(); + } + + let avg_bond = total_max_bond as f64 / f64::from(num_trials); + eprintln!("MAST average max bond dim for {num_qubits}q, {num_t_gates}T: {avg_bond:.1}"); + + // Paper claims ~3 for t <= N. Allow some slack for our small test. + assert!( + avg_bond < 10.0, + "MAST bond dim should stay low for t <= N, got avg={avg_bond:.1}" + ); +} + +#[test] +fn test_property_mast_vs_stn_bond_dim() { + // Paper claim: MAST has lower bond dimension than plain STN for the same circuit. + let t = Angle64::QUARTER_TURN / 2u64; + let num_qubits = 6; + + let mut stn = StabMps::with_seed(num_qubits, 42); + let mut mast = Mast::with_seed(num_qubits, 4, 42); + + // Same circuit on both + for q in 0..num_qubits { + stn.h(&[QubitId(q)]); + mast.h(&[QubitId(q)]); + } + for q in 0..num_qubits - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + mast.cx(&[(QubitId(q), QubitId(q + 1))]); + } + for q in 0..4 { + stn.rz(t, &[QubitId(q)]); + mast.rz(t, &[QubitId(q)]); + } + + // Force MAST projection + mast.mz(&[QubitId(0)]); + + let stn_bond = stn.max_bond_dim(); + let mast_bond = mast.mps().max_bond_dim(); + + eprintln!("STN max bond: {stn_bond}, MAST max bond: {mast_bond}"); + + // MAST should generally have lower or equal bond dim + // (not always guaranteed for small circuits, so just log it) +} + +#[test] +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); + + stn.h(&[QubitId(0)]); + stn.cx(&[(QubitId(0), QubitId(1))]); + stn.rz(t, &[QubitId(0)]); + stn.h(&[QubitId(2)]); + stn.cx(&[(QubitId(1), QubitId(2))]); + stn.rz(t, &[QubitId(2)]); + + let bond_before = stn.max_bond_dim(); + let sv_before = stn.state_vector(); + + let num_gates = stn.disentangle(5); + + let bond_after = stn.max_bond_dim(); + let sv_after = stn.state_vector(); + + eprintln!("Disentangle: bond {bond_before} -> {bond_after}, applied {num_gates} gates"); + + // State should be preserved + assert_states_match(&sv_before, &sv_after, "disentangle preserves state"); + + // Bond dim should not increase (and ideally decreases) + assert!( + bond_after <= bond_before, + "disentangle should not increase bond dim: {bond_before} -> {bond_after}" + ); +} + +// ============================================================================ +// Large-scale bond dimension validation +// ============================================================================ + +/// Run a random circuit on STN/MAST at scale. No state vector check (too large). +/// Just verify bond dim stays bounded and measurements work. +fn large_scale_bond_dim_check( + num_qubits: usize, + num_t_gates: usize, + num_clifford_layers: usize, + seed: u64, +) -> (usize, usize) { + // STN path + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::builder(num_qubits) + .max_bond_dim(256) + .seed(seed) + .build(); + + let mut rng = seed; + let next = |state: &mut u64| -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + + // Alternating Clifford + T layers + let t_per_layer = num_t_gates / num_clifford_layers.max(1); + for _layer in 0..num_clifford_layers { + // Random Clifford entangling layer + for q in 0..num_qubits { + if next(&mut rng) % 2 == 0 { + stn.h(&[QubitId(q)]); + } + } + for q in 0..num_qubits - 1 { + if next(&mut rng) % 3 == 0 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + } + // T gates on random qubits + for _ in 0..t_per_layer { + let q = (next(&mut rng) % num_qubits as u64) as usize; + stn.rz(t, &[QubitId(q)]); + } + } + + let stn_bond = stn.max_bond_dim(); + + // MAST path (same circuit structure) + let mut mast = Mast::with_seed(num_qubits, num_t_gates + 4, seed); + let mut rng = seed; // reset RNG to get same circuit + + for _layer in 0..num_clifford_layers { + for q in 0..num_qubits { + if next(&mut rng) % 2 == 0 { + mast.h(&[QubitId(q)]); + } + } + for q in 0..num_qubits - 1 { + if next(&mut rng) % 3 == 0 { + mast.cx(&[(QubitId(q), QubitId(q + 1))]); + } + } + for _ in 0..t_per_layer { + let q = (next(&mut rng) % num_qubits as u64) as usize; + mast.rz(t, &[QubitId(q)]); + } + } + + // Force MAST projection + mast.mz(&[QubitId(0)]); + let mast_bond = mast.mps().max_bond_dim(); + + (stn_bond, mast_bond) +} + +#[test] +fn test_large_scale_50_qubits() { + let num_qubits = 50; + let num_t = 20; + let (stn_bond, mast_bond) = large_scale_bond_dim_check(num_qubits, num_t, 4, 42); + eprintln!("{num_qubits}q {num_t}T: STN bond={stn_bond}, MAST bond={mast_bond}"); + // Should complete without panic. MAST bond should be small. + assert!( + mast_bond < 50, + "MAST bond too large at {num_qubits}q: {mast_bond}" + ); +} + +#[test] +fn test_large_scale_100_qubits() { + let num_qubits = 100; + let num_t = 40; + let (stn_bond, mast_bond) = large_scale_bond_dim_check(num_qubits, num_t, 4, 123); + eprintln!("{num_qubits}q {num_t}T: STN bond={stn_bond}, MAST bond={mast_bond}"); + assert!( + mast_bond < 50, + "MAST bond too large at {num_qubits}q: {mast_bond}" + ); +} + +#[test] +fn test_large_scale_200_qubits() { + let num_qubits = 200; + let num_t = 50; + let (stn_bond, mast_bond) = large_scale_bond_dim_check(num_qubits, num_t, 5, 456); + eprintln!("{num_qubits}q {num_t}T: STN bond={stn_bond}, MAST bond={mast_bond}"); + assert!( + mast_bond < 50, + "MAST bond too large at {num_qubits}q: {mast_bond}" + ); +} + +#[test] +#[ignore = "slow (~3min debug): run with `cargo test --test verification -- --include-ignored`"] +fn test_large_scale_bond_dim_curve() { + // Track bond dim as a function of T-count for fixed qubit count. + let num_qubits = 50; + let t_counts = [5, 10, 20, 30, 40, 50]; + + eprintln!("\nBond dim curve for {num_qubits} qubits:"); + eprintln!(" T-count STN-bond MAST-bond"); + for &num_t in &t_counts { + let (stn_bond, mast_bond) = large_scale_bond_dim_check(num_qubits, num_t, 4, 789); + eprintln!(" {num_t:>7} {stn_bond:>8} {mast_bond:>9}"); + } +} + +#[test] +fn test_large_scale_measurement_works() { + // Verify measurement doesn't panic at 30 qubits. + let num_qubits = 30; + let t = Angle64::QUARTER_TURN / 2u64; + let mut stn = StabMps::with_seed(num_qubits, 42); + + // Build entangled state + for q in 0..num_qubits { + stn.h(&[QubitId(q)]); + } + for q in 0..num_qubits - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + // Apply some T gates + for q in (0..num_qubits).step_by(5) { + stn.rz(t, &[QubitId(q)]); + } + + // Measure a subset of qubits (measuring all 100 is too slow) + let measure_qubits: Vec = (0..10).map(QubitId).collect(); + let results = stn.mz(&measure_qubits); + assert_eq!(results.len(), 10); + + eprintln!( + "{num_qubits}q measurement: bond_dim={}, measured 10 of {num_qubits} qubits", + stn.max_bond_dim() + ); +} + +// ============================================================================ +// Shared measurement stress test suite +// ============================================================================ + +pecos_simulators::measurement_stress_test_suite!(StabMps, 4, StabMps::with_seed(4, 42)); + +// ============================================================================ +// Performance profiling (run with --nocapture to see timing) +// ============================================================================ + +#[test] +fn test_profile_operation_costs() { + use std::time::Instant; + let t = Angle64::QUARTER_TURN / 2u64; + + for &num_qubits in &[20, 50, 100, 200] { + let mut stn = StabMps::builder(num_qubits).seed(42).build(); + + // Clifford layer: H + CX chain + let start = Instant::now(); + for q in 0..num_qubits { + stn.h(&[QubitId(q)]); + } + for q in 0..num_qubits - 1 { + stn.cx(&[(QubitId(q), QubitId(q + 1))]); + } + let clifford_ms = start.elapsed().as_millis(); + + // T gates + let num_t = num_qubits / 4; + let start = Instant::now(); + for q in 0..num_t { + stn.rz(t, &[QubitId(q)]); + } + let t_ms = start.elapsed().as_millis(); + + // Second Clifford layer + let start = Instant::now(); + for q in (0..num_qubits - 1).rev() { + stn.cx(&[(QubitId(q + 1), QubitId(q))]); + } + for q in 0..num_qubits { + stn.h(&[QubitId(q)]); + } + let clifford2_ms = start.elapsed().as_millis(); + + eprintln!( + "{num_qubits:>3}q: clifford1={clifford_ms:>4}ms, {num_t}T={t_ms:>4}ms, clifford2={clifford2_ms:>4}ms, bond={}", + stn.max_bond_dim() + ); + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 83e86102d..21e546ce9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,7 +69,7 @@ nav: - CUDA Setup: user-guide/cuda-setup.md - Concepts: - concepts/index.md - - Clifford+RZ Simulator: concepts/clifford-rz-simulator.md + - StabVec Simulator: concepts/clifford-rz-simulator.md - API: - api/api-reference.md - Development: diff --git a/pyproject.toml b/pyproject.toml index e6dc56b43..6228d25b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,10 +11,13 @@ cuda = ["quantum-pecos[cuda]"] [tool.uv.workspace] members = [ "python/pecos-rslib", + "python/pecos-rslib-exp", "python/pecos-rslib-cuda", "python/pecos-rslib-llvm", "python/quantum-pecos", - "python/selene-plugins/pecos-selene-clifford-rz", + "python/selene-plugins/pecos-selene-mast", + "python/selene-plugins/pecos-selene-stab-mps", + "python/selene-plugins/pecos-selene-stab-vec", "python/selene-plugins/pecos-selene-stabilizer", "python/selene-plugins/pecos-selene-statevec", ] diff --git a/python/pecos-rslib-exp/Cargo.toml b/python/pecos-rslib-exp/Cargo.toml new file mode 100644 index 000000000..8180f9829 --- /dev/null +++ b/python/pecos-rslib-exp/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "pecos-rslib-exp" +version = "0.2.0-dev.0" +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Python bindings for experimental PECOS simulators (pecos-stab-tn)." +publish = false + +[lib] +name = "pecos_rslib_exp" +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +pecos-core.workspace = true +pecos-simulators.workspace = true +pecos-stab-tn = { path = "../../exp/pecos-stab-tn" } +pyo3 = { workspace = true, features = ["extension-module", "abi3-py310", "generate-import-lib", "num-complex"] } +num-complex.workspace = true + +[lints] +workspace = true diff --git a/python/pecos-rslib-exp/pyproject.toml b/python/pecos-rslib-exp/pyproject.toml new file mode 100644 index 000000000..2dc51abe2 --- /dev/null +++ b/python/pecos-rslib-exp/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "pecos-rslib-exp" +version = "0.8.0.dev8" +description = "Python bindings for experimental PECOS simulators (StabMps, Mast)." +authors = [ + {name = "The PECOS Developers"}, +] +maintainers =[ + {name = "Ciaran Ryan-Anderson", email = "ciaranra@gmail.com"}, +] +dependencies = [] +requires-python = ">= 3.10" +license = "Apache-2.0" + +[build-system] +requires = ["maturin>=1.13.1,<2.0"] +build-backend = "maturin" + +[tool.maturin] +module-name = "pecos_rslib_exp" diff --git a/python/pecos-rslib-exp/src/lib.rs b/python/pecos-rslib-exp/src/lib.rs new file mode 100644 index 000000000..382b2f4d3 --- /dev/null +++ b/python/pecos-rslib-exp/src/lib.rs @@ -0,0 +1,52 @@ +// 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. + +//! Python bindings for experimental PECOS simulators. +//! +//! Exposes `StabMps` (stabilizer + MPS hybrid) and `Mast` (magic state +//! injection) from `pecos-stab-tn` via `PyO3`. + +mod mast_bindings; +mod stab_mps_bindings; + +use pecos_core::Angle64; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +pub(crate) fn extract_angle( + params: Option<&Bound<'_, PyDict>>, + gate_name: &str, +) -> PyResult { + let params = params.ok_or_else(|| { + PyErr::new::(format!( + "{gate_name} requires params with 'angle'" + )) + })?; + let py_any = params.get_item("angle")?.ok_or_else(|| { + PyErr::new::(format!( + "{gate_name} requires an 'angle' parameter" + )) + })?; + let radians: f64 = py_any.extract().map_err(|_| { + PyErr::new::(format!( + "Expected a float 'angle' parameter for {gate_name}" + )) + })?; + Ok(Angle64::from_radians(radians)) +} + +#[pymodule] +fn pecos_rslib_exp(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/pecos-rslib-exp/src/mast_bindings.rs b/python/pecos-rslib-exp/src/mast_bindings.rs new file mode 100644 index 000000000..5e4ec8f1e --- /dev/null +++ b/python/pecos-rslib-exp/src/mast_bindings.rs @@ -0,0 +1,259 @@ +// 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::mast::Mast; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PySet, PyTuple}; + +#[pyclass(name = "Mast", module = "pecos_rslib_exp")] +pub struct PyMast { + inner: Mast, +} + +impl PyMast { + 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(()) + } +} + +#[pymethods] +impl PyMast { + #[new] + #[pyo3(signature = (num_qubits, max_non_clifford, seed=None, lazy_measure=false, merge_rz=false))] + fn new( + num_qubits: usize, + max_non_clifford: usize, + seed: Option, + lazy_measure: bool, + merge_rz: bool, + ) -> Self { + 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 merge_rz { + mast = mast.with_merge_rz(true); + } + PyMast { inner: mast } + } + + fn reset(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { + slf.inner.reset(); + slf + } + + #[getter] + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + #[getter] + fn num_data_qubits(&self) -> usize { + self.inner.num_data_qubits() + } + + #[getter] + fn num_ancillas_used(&self) -> usize { + self.inner.num_ancillas_used() + } + + #[getter] + fn max_bond_dim(&self) -> usize { + self.inner.max_bond_dim() + } + + fn flush(&mut self) { + self.inner.flush(); + } + + fn project_all(&mut self) { + self.inner.project_all(); + } + + // ---- Gate dispatch ---- + + #[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) + } + "S" | "SZ" | "SqrtZ" => { + self.inner.sz(q); + Ok(None) + } + "Sd" | "SZdg" | "SqrtZdg" => { + 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) + } + "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.into()) + } +} diff --git a/python/pecos-rslib-exp/src/stab_mps_bindings.rs b/python/pecos-rslib-exp/src/stab_mps_bindings.rs new file mode 100644 index 000000000..abf19d293 --- /dev/null +++ b/python/pecos-rslib-exp/src/stab_mps_bindings.rs @@ -0,0 +1,456 @@ +// 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. + +#![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_stab_tn::stab_mps::{PauliKind, StabMps}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList, PySet, PyTuple}; + +#[pyclass(name = "StabMps", module = "pecos_rslib_exp")] +pub struct PyStabMps { + inner: StabMps, +} + +impl PyStabMps { + 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(()) + } +} + +#[pymethods] +impl PyStabMps { + #[new] + #[pyo3(signature = ( + num_qubits, + seed=None, + max_bond_dim=None, + merge_rz=None, + pauli_frame_tracking=None, + lazy_measure=None, + for_qec=None, + auto_grow_bond_dim=None, + auto_grow_max_bond_dim=None, + max_truncation_error=None, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + num_qubits: usize, + seed: Option, + max_bond_dim: Option, + merge_rz: Option, + pauli_frame_tracking: Option, + lazy_measure: Option, + for_qec: Option, + auto_grow_bond_dim: Option, + auto_grow_max_bond_dim: Option, + max_truncation_error: Option, + ) -> Self { + let mut b = StabMps::builder(num_qubits); + if let Some(s) = seed { + b = b.seed(s); + } + if for_qec == Some(true) { + b = b.for_qec(); + } + if let Some(bd) = max_bond_dim { + b = b.max_bond_dim(bd); + } + if merge_rz == Some(true) { + b = b.merge_rz(true); + } + if pauli_frame_tracking == Some(true) { + b = b.pauli_frame_tracking(true); + } + if lazy_measure == Some(true) { + b = b.lazy_measure(true); + } + if let Some(t) = auto_grow_bond_dim { + b = b.auto_grow_bond_dim(t); + } + if let Some(c) = auto_grow_max_bond_dim { + b = b.auto_grow_max_bond_dim(c); + } + if let Some(e) = max_truncation_error { + b = b.max_truncation_error(e); + } + PyStabMps { inner: b.build() } + } + + fn reset(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { + slf.inner.reset(); + slf + } + + #[getter] + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + #[getter] + fn max_bond_dim(&self) -> usize { + self.inner.max_bond_dim() + } + + #[getter] + fn truncation_error(&self) -> f64 { + self.inner.truncation_error() + } + + #[getter] + fn pragmatic_drift_count(&self) -> u64 { + self.inner.pragmatic_drift_count() + } + + fn is_state_exact(&self) -> bool { + self.inner.is_state_exact() + } + + fn flush(&mut self) { + self.inner.flush(); + } + + fn flush_pauli_frame_to_state(&mut self) { + self.inner.flush_pauli_frame_to_state(); + } + + fn state_vector(&self, py: Python<'_>) -> PyResult> { + 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()) + } + + fn prob_bitstring(&self, bitstring: Vec) -> f64 { + self.inner.prob_bitstring(&bitstring) + } + + // ---- QEC helpers ---- + + fn reset_qubit(&mut self, q: usize) -> PyResult { + 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")?; + self.inner.pz(QubitId(q)); + Ok(()) + } + + fn px(&mut self, q: usize) -> PyResult<()> { + 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")?; + 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")?; + 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")?; + 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 + .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::>>()?; + 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_z_bit(&self, q: usize) -> bool { + self.inner.frame_z_bit(QubitId(q)) + } + + fn apply_depolarizing(&mut self, q: usize, p: f64) -> Option { + self.inner + .apply_depolarizing(QubitId(q), p) + .map(|k| format!("{k:?}")) + } + + 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); + } + + fn extract_syndromes( + &mut self, + generators: Vec>, + ancilla_qubits: Vec, + ) -> PyResult> { + 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::>>() + }) + .collect::>>()?; + 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::>>()?; + Ok(self.inner.pauli_expectation(&ps)) + } + + fn code_state_fidelity(&self, stabilizers: Vec>) -> PyResult { + 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::>>() + }) + .collect::>>()?; + Ok(self.inner.code_state_fidelity(&stabs)) + } + + fn sample_bitstring(&mut self, num_shots: usize) -> Vec> { + self.inner.sample_bitstring(num_shots) + } + + // ---- Gate dispatch (matches pecos-rslib pattern) ---- + + #[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) + } + "S" | "SZ" | "SqrtZ" => { + self.inner.sz(q); + Ok(None) + } + "Sd" | "SZdg" | "SqrtZdg" => { + 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>" => { + self.inner.pz(QubitId(location)); + Ok(None) + } + "PX" | "Init +X" | "init |+>" => { + self.inner.px(QubitId(location)); + 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) + } + "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.into()) + } +} diff --git a/python/pecos-rslib-exp/uv.lock b/python/pecos-rslib-exp/uv.lock new file mode 100644 index 000000000..4402a99f6 --- /dev/null +++ b/python/pecos-rslib-exp/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "pecos-rslib-exp" +version = "0.2.0.dev0" +source = { editable = "." } diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index fa1145048..f949198c5 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -833,8 +833,8 @@ class Stabilizer: @property def num_qubits(self) -> int: ... -class CliffordRz: - """Rust Clifford+RZ simulator.""" +class StabVec: + """Rust Clifford+RZ simulator (stabilizer sum / StabVec).""" def __init__( self, @@ -939,8 +939,8 @@ class StabilizerEngineBuilder: ... -class CliffordRzEngineBuilder: - """Builder for Clifford+RZ engines.""" +class StabVecEngineBuilder: + """Builder for StabVec (Clifford+RZ) engines.""" ... @@ -1237,13 +1237,13 @@ class quantum: state_vector: Callable[..., StateVectorEngineBuilder] sparse_stab: Callable[..., SparseStabEngineBuilder] stabilizer: Callable[..., StabilizerEngineBuilder] - clifford_rz: Callable[..., CliffordRzEngineBuilder] + stab_vec: Callable[..., StabVecEngineBuilder] density_matrix: Callable[..., DensityMatrixEngineBuilder] coin_toss: Callable[..., CoinTossEngineBuilder] StateVectorEngineBuilder: type[StateVectorEngineBuilder] SparseStabEngineBuilder: type[SparseStabEngineBuilder] StabilizerEngineBuilder: type[StabilizerEngineBuilder] - CliffordRzEngineBuilder: type[CliffordRzEngineBuilder] + StabVecEngineBuilder: type[StabVecEngineBuilder] DensityMatrixEngineBuilder: type[DensityMatrixEngineBuilder] CoinTossEngineBuilder: type[CoinTossEngineBuilder] @@ -1292,8 +1292,8 @@ def stabilizer(**kwargs: object) -> StabilizerEngineBuilder: """Create a stabilizer engine builder.""" ... -def clifford_rz(**kwargs: object) -> CliffordRzEngineBuilder: - """Create a Clifford+RZ engine builder.""" +def stab_vec(**kwargs: object) -> StabVecEngineBuilder: + """Create a StabVec (Clifford+RZ) engine builder.""" ... def density_matrix(**kwargs: object) -> DensityMatrixEngineBuilder: diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 85a0146e6..f18ef6271 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -16,7 +16,7 @@ type RustPhirJsonEngineBuilder = pecos_phir_json::PhirJsonEngineBuilder; type RustHugrEngineBuilder = pecos_hugr::HugrEngineBuilder; type RustPhirEngineBuilder = pecos_phir::PhirEngineBuilder; type RustCoinTossEngineBuilder = CoinTossEngineBuilder; -type RustCliffordRzEngineBuilder = CliffordRzEngineBuilder; +type RustStabVecEngineBuilder = StabVecEngineBuilder; type RustDensityMatrixEngineBuilder = DensityMatrixEngineBuilder; type RustStabilizerEngineBuilder = StabilizerEngineBuilder; type RustSparseStabEngineBuilder = SparseStabEngineBuilder; @@ -1432,19 +1432,19 @@ pub fn stabilizer() -> PyStabilizerEngineBuilder { PyStabilizerEngineBuilder::new() } -/// Python wrapper for `CliffordRzEngineBuilder` -#[pyclass(name = "CliffordRzEngineBuilder", from_py_object)] +/// Python wrapper for `StabVecEngineBuilder` +#[pyclass(name = "StabVecEngineBuilder", from_py_object)] #[derive(Clone)] -pub struct PyCliffordRzEngineBuilder { - pub(crate) inner: Option, +pub struct PyStabVecEngineBuilder { + pub(crate) inner: Option, } #[pymethods] -impl PyCliffordRzEngineBuilder { +impl PyStabVecEngineBuilder { #[new] fn new() -> Self { Self { - inner: Some(pecos_engines::clifford_rz()), + inner: Some(pecos_engines::stab_vec()), } } @@ -1465,8 +1465,8 @@ impl PyCliffordRzEngineBuilder { /// Create a Clifford+RZ quantum engine builder #[pyfunction] -pub fn clifford_rz() -> PyCliffordRzEngineBuilder { - PyCliffordRzEngineBuilder::new() +pub fn stab_vec() -> PyStabVecEngineBuilder { + PyStabVecEngineBuilder::new() } /// Python wrapper for `DensityMatrixEngineBuilder` @@ -1613,7 +1613,7 @@ pub fn register_engine_builders(m: &Bound<'_, PyModule>) -> PyResult<()> { // Quantum engine builders m.add_class::()?; m.add_class::()?; - m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -1644,7 +1644,7 @@ pub fn register_engine_builders(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(self::state_vector, m)?)?; m.add_function(wrap_pyfunction!(self::sparse_stab, m)?)?; m.add_function(wrap_pyfunction!(self::stabilizer, m)?)?; - m.add_function(wrap_pyfunction!(self::clifford_rz, m)?)?; + m.add_function(wrap_pyfunction!(self::stab_vec, m)?)?; m.add_function(wrap_pyfunction!(self::density_matrix, m)?)?; m.add_function(wrap_pyfunction!(self::coin_toss, m)?)?; diff --git a/python/pecos-rslib/src/engines_module.rs b/python/pecos-rslib/src/engines_module.rs index e5faac8bd..b1557b94c 100644 --- a/python/pecos-rslib/src/engines_module.rs +++ b/python/pecos-rslib/src/engines_module.rs @@ -66,8 +66,8 @@ pub fn register_engines_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { parent.getattr("StabilizerEngineBuilder")?, )?; engines.add( - "CliffordRzEngineBuilder", - parent.getattr("CliffordRzEngineBuilder")?, + "StabVecEngineBuilder", + parent.getattr("StabVecEngineBuilder")?, )?; engines.add( "DensityMatrixEngineBuilder", diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index e4baa8830..d2c529a08 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -25,7 +25,6 @@ mod bit_int_bindings; mod bit_uint_bindings; mod byte_message_bindings; mod clifford_rep_bindings; -mod clifford_rz_bindings; mod coin_toss_bindings; mod dag_circuit_bindings; mod decoder_bindings; @@ -58,6 +57,7 @@ mod sparse_sim; mod sparse_stab_bindings; mod sparse_stab_engine_bindings; mod stab_bindings; +mod stab_vec_bindings; mod stabilizer_code_bindings; mod stabilizer_group_bindings; mod state_vec_bindings; @@ -72,7 +72,6 @@ mod wasm_program_bindings; use bit_int_bindings::PyBitInt; use bit_uint_bindings::PyBitUInt; use byte_message_bindings::{PyByteMessage, PyByteMessageBuilder}; -use clifford_rz_bindings::PyCliffordRz; use coin_toss_bindings::PyCoinToss; use engine_builders::{PyHugr, PyPhirJson, PyQasm, PyQis}; use pauli_prop_bindings::PyPauliProp; @@ -82,6 +81,7 @@ use pyo3::prelude::*; use sparse_stab_bindings::PySparseStab; use sparse_stab_engine_bindings::PySparseStabEngine; use stab_bindings::PyStabilizer; +use stab_vec_bindings::PyStabVec; use state_vec_bindings::PyStateVec; use state_vec_engine_bindings::PyStateVecEngine; #[cfg(feature = "wasm")] @@ -229,7 +229,7 @@ fn pecos_rslib(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { } } - m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -332,7 +332,7 @@ fn pecos_rslib(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(engine_builders::state_vector, m)?)?; m.add_function(wrap_pyfunction!(engine_builders::sparse_stab, m)?)?; m.add_function(wrap_pyfunction!(engine_builders::stabilizer, m)?)?; - m.add_function(wrap_pyfunction!(engine_builders::clifford_rz, m)?)?; + m.add_function(wrap_pyfunction!(engine_builders::stab_vec, m)?)?; m.add_function(wrap_pyfunction!(engine_builders::density_matrix, m)?)?; m.add_function(wrap_pyfunction!(engine_builders::coin_toss, m)?)?; diff --git a/python/pecos-rslib/src/namespace_modules.rs b/python/pecos-rslib/src/namespace_modules.rs index e2a7e4493..865d6d718 100644 --- a/python/pecos-rslib/src/namespace_modules.rs +++ b/python/pecos-rslib/src/namespace_modules.rs @@ -52,7 +52,7 @@ pub fn register_quantum_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { quantum.add("state_vector", parent.getattr("state_vector")?)?; quantum.add("sparse_stab", parent.getattr("sparse_stab")?)?; quantum.add("stabilizer", parent.getattr("stabilizer")?)?; - quantum.add("clifford_rz", parent.getattr("clifford_rz")?)?; + quantum.add("stab_vec", parent.getattr("stab_vec")?)?; quantum.add("density_matrix", parent.getattr("density_matrix")?)?; quantum.add("coin_toss", parent.getattr("coin_toss")?)?; @@ -70,8 +70,8 @@ pub fn register_quantum_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { parent.getattr("StabilizerEngineBuilder")?, )?; quantum.add( - "CliffordRzEngineBuilder", - parent.getattr("CliffordRzEngineBuilder")?, + "StabVecEngineBuilder", + parent.getattr("StabVecEngineBuilder")?, )?; quantum.add( "DensityMatrixEngineBuilder", diff --git a/python/pecos-rslib/src/py_foreign_simulator.rs b/python/pecos-rslib/src/py_foreign_simulator.rs index 62647bc73..b35eaf917 100644 --- a/python/pecos-rslib/src/py_foreign_simulator.rs +++ b/python/pecos-rslib/src/py_foreign_simulator.rs @@ -116,6 +116,16 @@ impl QuantumSimulator for PyForeignSimulator { }); self } + + fn num_qubits(&self) -> usize { + Python::attach(|py| { + self.inner + .call_method0(py, "num_qubits") + .expect("Python simulator num_qubits() failed") + .extract::(py) + .expect("num_qubits() must return an integer") + }) + } } impl CliffordGateable for PyForeignSimulator { diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index dfb4eb525..2228fa4be 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -555,8 +555,8 @@ impl PySimBuilder { PyGeneralNoiseModelBuilder, }; use crate::engine_builders::{ - PyCliffordRzEngineBuilder, PyCoinTossEngineBuilder, PyDensityMatrixEngineBuilder, - PySparseStabEngineBuilder, PyStabilizerEngineBuilder, PyStateVectorEngineBuilder, + PyCoinTossEngineBuilder, PyDensityMatrixEngineBuilder, PySparseStabEngineBuilder, + PyStabVecEngineBuilder, PyStabilizerEngineBuilder, PyStateVectorEngineBuilder, }; match &self.inner { @@ -611,10 +611,8 @@ impl PySimBuilder { "Quantum engine builder has already been consumed", )); } - } else if let Ok(mut clifford_rz) = - qe_py.extract::(py) - { - if let Some(inner) = clifford_rz.inner.take() { + } else if let Ok(mut stab_vec) = qe_py.extract::(py) { + if let Some(inner) = stab_vec.inner.take() { sim_builder.quantum(inner) } else { return Err(PyErr::new::( @@ -700,8 +698,8 @@ impl PySimBuilder { PyGeneralNoiseModelBuilder, }; use crate::engine_builders::{ - PyCliffordRzEngineBuilder, PyCoinTossEngineBuilder, PyDensityMatrixEngineBuilder, - PySparseStabEngineBuilder, PyStabilizerEngineBuilder, PyStateVectorEngineBuilder, + PyCoinTossEngineBuilder, PyDensityMatrixEngineBuilder, PySparseStabEngineBuilder, + PyStabVecEngineBuilder, PyStabilizerEngineBuilder, PyStateVectorEngineBuilder, }; use crate::shot_results_bindings::PyShotVec; use pyo3::exceptions::PyRuntimeError; @@ -765,10 +763,9 @@ impl PySimBuilder { "Quantum engine builder has already been consumed", )) } - } else if let Ok(mut clifford_rz) = - qe_py.extract::(py) + } else if let Ok(mut stab_vec) = qe_py.extract::(py) { - if let Some(inner) = clifford_rz.inner.take() { + if let Some(inner) = stab_vec.inner.take() { Ok(sim_builder.quantum(inner)) } else { Err(PyErr::new::( @@ -889,10 +886,9 @@ impl PySimBuilder { "Quantum engine builder has already been consumed", )) } - } else if let Ok(mut clifford_rz) = - qe_py.extract::(py) + } else if let Ok(mut stab_vec) = qe_py.extract::(py) { - if let Some(inner) = clifford_rz.inner.take() { + if let Some(inner) = stab_vec.inner.take() { Ok(sim_builder.quantum(inner)) } else { Err(PyErr::new::( @@ -1057,10 +1053,9 @@ impl PySimBuilder { "Quantum engine builder has already been consumed", )) } - } else if let Ok(mut clifford_rz) = - qe_py.extract::(py) + } else if let Ok(mut stab_vec) = qe_py.extract::(py) { - if let Some(inner) = clifford_rz.inner.take() { + if let Some(inner) = stab_vec.inner.take() { Ok(sim_builder.quantum(inner)) } else { Err(PyErr::new::( @@ -1138,8 +1133,8 @@ impl PySimBuilder { PyGeneralNoiseModelBuilder, }; use crate::engine_builders::{ - PyCliffordRzEngineBuilder, PyCoinTossEngineBuilder, PyDensityMatrixEngineBuilder, - PySparseStabEngineBuilder, PyStabilizerEngineBuilder, PyStateVectorEngineBuilder, + PyCoinTossEngineBuilder, PyDensityMatrixEngineBuilder, PySparseStabEngineBuilder, + PyStabVecEngineBuilder, PyStabilizerEngineBuilder, PyStateVectorEngineBuilder, }; use crate::engine_builders::{PyPhirJsonSimulation, PyPhirSimulation, PyQasmSimulation}; use pyo3::exceptions::PyRuntimeError; @@ -1202,10 +1197,10 @@ impl PySimBuilder { "Quantum engine builder has already been consumed", )) } - } else if let Ok(mut clifford_rz) = - qe_py.extract::(py) + } else if let Ok(mut stab_vec) = + qe_py.extract::(py) { - if let Some(inner) = clifford_rz.inner.take() { + if let Some(inner) = stab_vec.inner.take() { Ok(sim_builder.quantum(inner)) } else { Err(PyErr::new::( @@ -1396,10 +1391,10 @@ impl PySimBuilder { "Quantum engine builder has already been consumed", )) } - } else if let Ok(mut clifford_rz) = - qe_py.extract::(py) + } else if let Ok(mut stab_vec) = + qe_py.extract::(py) { - if let Some(inner) = clifford_rz.inner.take() { + if let Some(inner) = stab_vec.inner.take() { Ok(sim_builder.quantum(inner)) } else { Err(PyErr::new::( @@ -1573,10 +1568,10 @@ impl PySimBuilder { "Quantum engine builder has already been consumed", )) } - } else if let Ok(mut clifford_rz) = - qe_py.extract::(py) + } else if let Ok(mut stab_vec) = + qe_py.extract::(py) { - if let Some(inner) = clifford_rz.inner.take() { + if let Some(inner) = stab_vec.inner.take() { Ok(sim_builder.quantum(inner)) } else { Err(PyErr::new::( diff --git a/python/pecos-rslib/src/simulators_module.rs b/python/pecos-rslib/src/simulators_module.rs index 6ac36d5b1..3752f6621 100644 --- a/python/pecos-rslib/src/simulators_module.rs +++ b/python/pecos-rslib/src/simulators_module.rs @@ -43,8 +43,8 @@ pub fn register_simulators_module(parent: &Bound<'_, PyModule>) -> PyResult<()> simulators.add("SparseStab", parent.getattr("SparseStab")?)?; simulators.add("Stabilizer", parent.getattr("Stabilizer")?)?; - // Clifford+RZ simulator - simulators.add("CliffordRz", parent.getattr("CliffordRz")?)?; + // StabVec simulator (Clifford+RZ) + simulators.add("StabVec", parent.getattr("StabVec")?)?; // State vector simulators simulators.add("StateVec", parent.getattr("StateVec")?)?; diff --git a/python/pecos-rslib/src/clifford_rz_bindings.rs b/python/pecos-rslib/src/stab_vec_bindings.rs similarity index 98% rename from python/pecos-rslib/src/clifford_rz_bindings.rs rename to python/pecos-rslib/src/stab_vec_bindings.rs index a8eab28d9..da54bca67 100644 --- a/python/pecos-rslib/src/clifford_rz_bindings.rs +++ b/python/pecos-rslib/src/stab_vec_bindings.rs @@ -12,18 +12,18 @@ use crate::dtypes::AngleParam; use crate::prelude::*; -use pecos_simulators::CliffordRz; +use pecos_simulators::StabVec; use pyo3::IntoPyObjectExt; use pyo3::prelude::*; use pyo3::types::{PyAny, PyDict, PyList, PySet, PyTuple}; -#[pyclass(name = "CliffordRz", module = "pecos_rslib")] -pub struct PyCliffordRz { - inner: CliffordRz, +#[pyclass(name = "StabVec", module = "pecos_rslib")] +pub struct PyStabVec { + inner: StabVec, } #[pymethods] -impl PyCliffordRz { +impl PyStabVec { /// Create a new Clifford+RZ simulator. /// /// Args: @@ -40,7 +40,7 @@ impl PyCliffordRz { pruning_threshold: Option, mc_threshold: Option, ) -> Self { - let mut builder = CliffordRz::builder(num_qubits); + let mut builder = StabVec::builder(num_qubits); if let Some(s) = seed { builder = builder.seed(s); } @@ -48,7 +48,7 @@ impl PyCliffordRz { builder = builder.pruning_threshold(pt); } builder = builder.mc_threshold(mc_threshold); - PyCliffordRz { + PyStabVec { inner: builder.build(), } } diff --git a/python/quantum-pecos/src/pecos/__init__.py b/python/quantum-pecos/src/pecos/__init__.py index edce53bee..e36ede4ed 100644 --- a/python/quantum-pecos/src/pecos/__init__.py +++ b/python/quantum-pecos/src/pecos/__init__.py @@ -283,7 +283,7 @@ def __getattr__(name: str): state_vector = pecos_rslib.state_vector sparse_stab = pecos_rslib.sparse_stab stabilizer = pecos_rslib.stabilizer -clifford_rz = pecos_rslib.clifford_rz +stab_vec = pecos_rslib.stab_vec density_matrix = pecos_rslib.density_matrix hugr_engine = pecos_rslib.hugr_engine @@ -300,33 +300,27 @@ def __getattr__(name: str): "SIGNED_INTEGER_TYPES", "UNSIGNED_INTEGER_TYPES", "AngleSource", - # Core types "Array", - # Deprecated "BiasedDepolarizingNoiseModelBuilder", - "BinArray", # Deprecated - use BitInt instead + "BinArray", "BitInt", "BitUInt", - # Type categories "Complex", "DepolarizingNoiseModelBuilder", "Float", "GateRegistry", "GateSignatureMismatchError", "GeneralNoiseModelBuilder", - # Program wrapper classes for sim() - also available via pecos.programs "Guppy", "Hugr", - # Legacy "HybridEngine", "Inexact", "Integer", - "Nanoseconds", # Time unit type + "Nanoseconds", "Numeric", "Pauli", "PauliString", "PhirJson", - # Engine builder classes "PhirJsonEngineBuilder", "Poly1d", "ProgramWrapper", @@ -338,26 +332,21 @@ def __getattr__(name: str): "ShotMap", "ShotVec", "SignedInteger", - "TimeUnits", # Time unit type + "TimeUnits", "UnsignedInteger", "Wasm", "WasmError", "WasmForeignObject", "Wat", - # Version "__version__", - # Mathematical functions "abs", "acos", "acosh", "all", "allclose", - # Subpackages - "analysis", # QEC analysis (threshold, fault tolerance, stabilizers) - # Angle type + "analysis", "angle64", "any", - # Polynomial and optimization "arange", "array", "array_equal", @@ -366,18 +355,13 @@ def __getattr__(name: str): "atan", "atan2", "atanh", - "benchmarks", # Performance benchmarking - # Noise model builders + "benchmarks", "biased_depolarizing_noise", "brentq", "ceil", - # Subpackages - Utilities "circuit_converters", "circuit_runners", - # Subpackages - Core "circuits", - "clifford_rz", - # Numeric submodules (like numpy.linalg, numpy.random) "compare", "complex64", "complex128", @@ -389,10 +373,9 @@ def __getattr__(name: str): "density_matrix", "depolarizing_noise", "diag", - # Data types "dtypes", "engines", - "exceptions", # Exception classes + "exceptions", "exp", "f32", "f64", @@ -400,7 +383,7 @@ def __getattr__(name: str): "general_noise", "get_guppy_backends", "graph", - "guppy", # Direct Guppy code generation + "guppy", "hugr_engine", "i8", "i16", @@ -424,7 +407,6 @@ def __getattr__(name: str): "num", "ones", "optimize", - # Engine builder functions "phir_json_engine", "polyfit", "polynomial", @@ -432,21 +414,20 @@ def __getattr__(name: str): "programs", "protocols", "qasm_engine", - "qec", # Pure QEC geometry (no SLR dependencies) + "qec", "qeccs", "qis_engine", "quantum", "random", "round", "selene_engine", - # Simulation entry point "sim", "simulators", "sin", "sinh", - # Quantum simulators "sparse_stab", "sqrt", + "stab_vec", "stabilizer", "state_vector", "stats", @@ -454,8 +435,8 @@ def __getattr__(name: str): "sum", "tan", "tanh", - "testing", # Testing utilities (like numpy.testing) - "tools", # Kept for backwards compatibility + "testing", + "tools", "typing", "u8", "u16", diff --git a/python/quantum-pecos/src/pecos/simulators/__init__.py b/python/quantum-pecos/src/pecos/simulators/__init__.py index 15cd77263..a804c43b4 100644 --- a/python/quantum-pecos/src/pecos/simulators/__init__.py +++ b/python/quantum-pecos/src/pecos/simulators/__init__.py @@ -18,22 +18,23 @@ # Rust simulators (direct exports without Python wrappers) # Simulator engine builder factory functions -from pecos_rslib import clifford_rz, coin_toss, density_matrix, sparse_stab, stabilizer, state_vector -from pecos_rslib.simulators import CliffordRz, SparseStab, Stabilizer +from pecos_rslib import ( + coin_toss, + density_matrix, + sparse_stab, + stab_vec, + stabilizer, + state_vector, +) +from pecos_rslib.simulators import SparseStab, Stabilizer, StabVec from pecos.simulators import sim_class_types - -# Coin toss simulator (uses Rust backend) from pecos.simulators.cointoss import CoinToss - -# Ignores quantum gates, coin toss for measurements from pecos.simulators.default_simulator import DefaultSimulator from pecos.simulators.pauliprop import ( PauliFaultProp, # Backward compatibility PauliProp, ) - -# Pauli fault propagation sim from pecos.simulators.sparsestab import ( SparseStabPy as SparseStabPy, ) @@ -71,28 +72,23 @@ __all__ = [ "MPS", - # Rust simulators - "CliffordRz", - # Python simulators "CoinToss", "CuStateVec", "CudaStabilizer", - # CUDA simulators (Rust cuQuantum bindings) "CudaStateVec", "DefaultSimulator", "PauliFaultProp", "PauliProp", "SparseStab", "SparseStabPy", + "StabVec", "Stabilizer", "StateVec", - # Factory functions - "clifford_rz", "coin_toss", "density_matrix", - # Submodules "sim_class_types", "sparse_stab", + "stab_vec", "stabilizer", "state_vector", ] diff --git a/python/selene-plugins/pecos-selene-mast/Cargo.toml b/python/selene-plugins/pecos-selene-mast/Cargo.toml new file mode 100644 index 000000000..2f14e16cb --- /dev/null +++ b/python/selene-plugins/pecos-selene-mast/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pecos-selene-mast" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true +description = "PECOS Mast (magic state injection) simulator plugin for the Selene quantum emulator" + +[lib] +name = "pecos_selene_mast" +path = "src/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +anyhow = { workspace = true } +pecos-core = { workspace = true } +pecos-simulators = { workspace = true } +pecos-stab-tn = { path = "../../../exp/pecos-stab-tn" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } + +[lints] +workspace = true diff --git a/python/selene-plugins/pecos-selene-mast/README.md b/python/selene-plugins/pecos-selene-mast/README.md new file mode 100644 index 000000000..d77706510 --- /dev/null +++ b/python/selene-plugins/pecos-selene-mast/README.md @@ -0,0 +1,13 @@ +# pecos-selene-mast + +PECOS Mast (magic state injection) simulator plugin for the Selene quantum emulator. + +Handles non-Clifford gates via deferred ancilla projection. Bond dimension stays bounded for Clifford+T circuits. + +## Usage + +```python +from pecos_selene_mast import MastPlugin + +sim = MastPlugin() +``` diff --git a/python/selene-plugins/pecos-selene-clifford-rz/hatch_build.py b/python/selene-plugins/pecos-selene-mast/hatch_build.py similarity index 93% rename from python/selene-plugins/pecos-selene-clifford-rz/hatch_build.py rename to python/selene-plugins/pecos-selene-mast/hatch_build.py index 46f331705..4ec910348 100644 --- a/python/selene-plugins/pecos-selene-clifford-rz/hatch_build.py +++ b/python/selene-plugins/pecos-selene-mast/hatch_build.py @@ -25,7 +25,7 @@ from packaging.tags import sys_tags -class PecosSeleneCliffordRzBuildHook(BuildHookInterface): +class PecosSeleneMastBuildHook(BuildHookInterface): """Build hook that compiles the Rust plugin and copies it to the Python package.""" def _set_wheel_tag(self, build_data: dict[str, Any]) -> None: @@ -60,7 +60,7 @@ def initialize( # Check if library already exists (e.g., from `make build-selene`) # If so, skip building and just collect artifacts - dist_dir = root / "python" / "pecos_selene_clifford_rz" / "_dist" + dist_dir = root / "python" / "pecos_selene_mast" / "_dist" lib_dir = dist_dir / "lib" if lib_dir.exists() and any(lib_dir.iterdir()): self.app.display_info("Library already built, skipping cargo build...") @@ -93,8 +93,8 @@ def initialize( msg = f"Unsupported platform: {system}" raise RuntimeError(msg) - lib_name = "pecos_selene_clifford_rz" - cargo_package = "pecos-selene-clifford-rz" + lib_name = "pecos_selene_mast" + cargo_package = "pecos-selene-mast" self.app.display_info(f"Building {cargo_package}...") @@ -130,7 +130,7 @@ def initialize( raise RuntimeError(msg) # Copy to the _dist/lib directory in the Python package - dest_dir = root / "python" / "pecos_selene_clifford_rz" / "_dist" / "lib" + dest_dir = root / "python" / "pecos_selene_mast" / "_dist" / "lib" dest_dir.mkdir(parents=True, exist_ok=True) dest_lib = dest_dir / lib_filename @@ -139,7 +139,7 @@ def initialize( # Collect artifacts artifacts = [] - dist_dir = root / "python" / "pecos_selene_clifford_rz" / "_dist" + dist_dir = root / "python" / "pecos_selene_mast" / "_dist" for artifact in dist_dir.rglob("*"): if artifact.is_file(): rel_path = artifact.relative_to(root) diff --git a/python/selene-plugins/pecos-selene-mast/pyproject.toml b/python/selene-plugins/pecos-selene-mast/pyproject.toml new file mode 100644 index 000000000..7f122af63 --- /dev/null +++ b/python/selene-plugins/pecos-selene-mast/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pecos-selene-mast" +version = "0.8.0.dev8" +requires-python = ">=3.10" +description = "PECOS Mast simulator plugin for the Selene quantum emulator" +license = "Apache-2.0" +dependencies = [ + "selene-core>=0.2", +] + +[project.optional-dependencies] +test = [ + "pytest>=9.0", + "selene-sim>=0.2", + "guppylang>=0.14", +] + +[project.urls] +homepage = "https://pecos.io" +repository = "https://github.com/PECOS-packages/PECOS" + +[build-system] +requires = ["hatchling", "packaging"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["python/pecos_selene_mast"] + +[tool.hatch.build.hooks.custom] +path = "hatch_build.py" + +[tool.uv] +cache-keys = [ + { file = "src/**/*.rs" }, + { file = "Cargo.toml" }, +] diff --git a/python/selene-plugins/pecos-selene-clifford-rz/python/pecos_selene_clifford_rz/__init__.py b/python/selene-plugins/pecos-selene-mast/python/pecos_selene_mast/__init__.py similarity index 81% rename from python/selene-plugins/pecos-selene-clifford-rz/python/pecos_selene_clifford_rz/__init__.py rename to python/selene-plugins/pecos-selene-mast/python/pecos_selene_mast/__init__.py index 0eaef3fdd..9beb3c930 100644 --- a/python/selene-plugins/pecos-selene-clifford-rz/python/pecos_selene_clifford_rz/__init__.py +++ b/python/selene-plugins/pecos-selene-mast/python/pecos_selene_mast/__init__.py @@ -10,8 +10,8 @@ # or implied. See the License for the specific language governing permissions and limitations under # the License. -"""PECOS CliffordRz Selene plugin.""" +"""PECOS Mast Selene plugin.""" -from pecos_selene_clifford_rz.plugin import CliffordRzPlugin +from pecos_selene_mast.plugin import MastPlugin -__all__ = ["CliffordRzPlugin"] +__all__ = ["MastPlugin"] diff --git a/python/selene-plugins/pecos-selene-mast/python/pecos_selene_mast/plugin.py b/python/selene-plugins/pecos-selene-mast/python/pecos_selene_mast/plugin.py new file mode 100644 index 000000000..662ce3443 --- /dev/null +++ b/python/selene-plugins/pecos-selene-mast/python/pecos_selene_mast/plugin.py @@ -0,0 +1,83 @@ +# 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. + +"""PECOS Mast plugin for Selene.""" + +import platform +from dataclasses import dataclass +from pathlib import Path + +from selene_core import Simulator + + +@dataclass +class MastPlugin(Simulator): + """PECOS Mast (stabilizer+MPS) simulator plugin for Selene. + + This plugin provides a Mast (stabilizer+MPS) simulator backend for Selene using a + magic state injection decomposition. Clifford gates are applied efficiently, + while bond dimension stays bounded for Clifford+T circuits. + + Cost is polynomial in qubits and Clifford gates, exponential in the number + of RZ gates applied. + + Parameters + ---------- + random_seed : int, optional + Seed for the random number generator. If not provided, the seed + will be determined by Selene's shot management. + """ + + random_seed: int | None = None + + def get_init_args(self) -> list[str]: + """Return the initialization arguments for the Rust plugin. + + Returns: + ------- + list[str] + Empty list as Mast plugin doesn't require additional arguments. + """ + return [] + + @property + def library_file(self) -> Path: + """Return the path to the compiled Rust library. + + Returns: + ------- + Path + Path to the shared library file. + + Raises: + ------ + FileNotFoundError + If no matching library file is found. + """ + libdir = Path(__file__).parent / "_dist" / "lib" + + # Platform-specific library naming + system = platform.system().lower() + if system == "darwin": + patterns = ["libpecos_selene_mast*.dylib"] + elif system == "windows": + patterns = ["pecos_selene_mast*.dll", "pecos_selene_mast*.pyd"] + else: # Linux and others + patterns = ["libpecos_selene_mast*.so"] + + for pattern in patterns: + matches = list(libdir.glob(pattern)) + if matches: + return matches[0] + + msg = f"Could not find PECOS Mast library in {libdir}" + raise FileNotFoundError(msg) diff --git a/python/selene-plugins/pecos-selene-mast/src/lib.rs b/python/selene-plugins/pecos-selene-mast/src/lib.rs new file mode 100644 index 000000000..4e43f518f --- /dev/null +++ b/python/selene-plugins/pecos-selene-mast/src/lib.rs @@ -0,0 +1,213 @@ +// 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. + +//! PECOS `Mast` (Magic State injection) simulator plugin for the Selene quantum emulator. +//! +//! Wraps the MAST simulator which handles non-Clifford gates via deferred ancilla +//! projection. Bond dimension stays bounded for Clifford+T circuits. + +use anyhow::{Result, anyhow, bail}; +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::mast::Mast; +use selene_core::export_simulator_plugin; +use selene_core::simulator::SimulatorInterface; +use selene_core::simulator::interface::SimulatorInterfaceFactory; +use selene_core::utils::MetricValue; +use std::sync::Arc; + +pub struct MastSimulator { + simulator: Mast, + n_qubits: u64, + max_non_clifford: usize, +} + +impl MastSimulator { + #[allow(clippy::cast_possible_truncation)] + #[inline] + const fn to_usize(value: u64) -> usize { + value as usize + } +} + +impl SimulatorInterface for MastSimulator { + fn exit(&mut self) -> Result<()> { + Ok(()) + } + + fn shot_start(&mut self, _shot_id: u64, seed: u64) -> Result<()> { + self.simulator = + Mast::with_seed(Self::to_usize(self.n_qubits), self.max_non_clifford, seed); + Ok(()) + } + + fn shot_end(&mut self) -> Result<()> { + Ok(()) + } + + fn rxy(&mut self, qubit: u64, theta: f64, phi: f64) -> Result<()> { + if qubit >= self.n_qubits { + return Err(anyhow!( + "RXY(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + let q = QubitId(Self::to_usize(qubit)); + self.simulator + .rz(Angle64::from_radians(-phi), &[q]) + .rx(Angle64::from_radians(theta), &[q]) + .rz(Angle64::from_radians(phi), &[q]); + Ok(()) + } + + fn rz(&mut self, qubit: u64, theta: f64) -> Result<()> { + if qubit >= self.n_qubits { + return Err(anyhow!( + "RZ(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + self.simulator.rz( + Angle64::from_radians(theta), + &[QubitId(Self::to_usize(qubit))], + ); + Ok(()) + } + + fn rzz(&mut self, qubit1: u64, qubit2: u64, theta: f64) -> Result<()> { + if qubit1 >= self.n_qubits || qubit2 >= self.n_qubits { + return Err(anyhow!( + "RZZ(qubit1={qubit1}, qubit2={qubit2}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + self.simulator.rzz( + Angle64::from_radians(theta), + &[( + QubitId(Self::to_usize(qubit1)), + QubitId(Self::to_usize(qubit2)), + )], + ); + Ok(()) + } + + fn measure(&mut self, qubit: u64) -> Result { + if qubit >= self.n_qubits { + return Err(anyhow!( + "Measure(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + let results = self.simulator.mz(&[QubitId(Self::to_usize(qubit))]); + Ok(results[0].outcome) + } + + fn postselect(&mut self, qubit: u64, target_value: bool) -> Result<()> { + if qubit >= self.n_qubits { + return Err(anyhow!( + "Postselect(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + let results = self.simulator.mz(&[QubitId(Self::to_usize(qubit))]); + if results[0].outcome != target_value { + return Err(anyhow!( + "Postselect(qubit={qubit}, target={target_value}) failed: got {}", + results[0].outcome + )); + } + Ok(()) + } + + fn reset(&mut self, qubit: u64) -> Result<()> { + if qubit >= self.n_qubits { + return Err(anyhow!( + "Reset(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + let q = QubitId(Self::to_usize(qubit)); + let results = self.simulator.mz(&[q]); + if results[0].outcome { + self.simulator.x(&[q]); + } + Ok(()) + } + + fn get_metric(&mut self, nth_metric: u8) -> Result> { + match nth_metric { + 0 => Ok(Some(( + "max_bond_dim".to_string(), + MetricValue::U64(self.simulator.max_bond_dim() as u64), + ))), + 1 => Ok(Some(( + "num_ancillas_used".to_string(), + MetricValue::U64(self.simulator.num_ancillas_used() as u64), + ))), + _ => Ok(None), + } + } + + fn dump_state(&mut self, _file: &std::path::Path, _qubits: &[u64]) -> Result<()> { + Err(anyhow!("State dumping not supported for Mast")) + } +} + +#[derive(Default)] +pub struct MastSimulatorFactory; + +impl SimulatorInterfaceFactory for MastSimulatorFactory { + type Interface = MastSimulator; + + fn init( + self: Arc, + n_qubits: u64, + args: &[impl AsRef], + ) -> Result> { + let args: Vec = args.iter().map(|s| s.as_ref().to_string()).collect(); + // Optional arg: max_non_clifford (default 100) + let max_nc = if args.len() > 1 { + args[1].parse::().map_err(|_| { + anyhow!( + "Mast plugin expects optional integer argument max_non_clifford, got '{}'", + args[1] + ) + })? + } else { + 100 + }; + if n_qubits == 0 { + bail!("Number of qubits must be greater than 0"); + } + Ok(Box::new(MastSimulator { + simulator: Mast::with_seed(MastSimulator::to_usize(n_qubits), max_nc, 0), + n_qubits, + max_non_clifford: max_nc, + })) + } +} + +export_simulator_plugin!(crate::MastSimulatorFactory); + +#[cfg(test)] +mod tests { + use super::MastSimulatorFactory; + use selene_core::simulator::conformance_testing::run_basic_tests; + use std::sync::Arc; + + #[test] + fn basic_conformance_test() { + let interface = Arc::new(MastSimulatorFactory); + let args: Vec = vec![String::new()]; + run_basic_tests(interface, args); + } +} diff --git a/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml b/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml new file mode 100644 index 000000000..ea91374eb --- /dev/null +++ b/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pecos-selene-stab-mps" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true +description = "PECOS StabMps simulator plugin for the Selene quantum emulator" + +[lib] +name = "pecos_selene_stab_mps" +path = "src/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +anyhow = { workspace = true } +pecos-core = { workspace = true } +pecos-simulators = { workspace = true } +pecos-stab-tn = { path = "../../../exp/pecos-stab-tn" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } + +[lints] +workspace = true diff --git a/python/selene-plugins/pecos-selene-stab-mps/README.md b/python/selene-plugins/pecos-selene-stab-mps/README.md new file mode 100644 index 000000000..9d919dfd1 --- /dev/null +++ b/python/selene-plugins/pecos-selene-stab-mps/README.md @@ -0,0 +1,13 @@ +# pecos-selene-stab-mps + +PECOS StabMps (stabilizer tableau + MPS) simulator plugin for the Selene quantum emulator. + +Stabilizer gates are O(n) on the tableau; non-Clifford rotations decompose in the stabilizer basis and apply to the MPS. Cost is polynomial when non-Clifford count is bounded. + +## Usage + +```python +from pecos_selene_stab_mps import StabMpsPlugin + +sim = StabMpsPlugin() +``` diff --git a/python/selene-plugins/pecos-selene-stab-mps/hatch_build.py b/python/selene-plugins/pecos-selene-stab-mps/hatch_build.py new file mode 100644 index 000000000..8a0e0f2f8 --- /dev/null +++ b/python/selene-plugins/pecos-selene-stab-mps/hatch_build.py @@ -0,0 +1,153 @@ +# 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. + +"""Custom hatch build hook to compile and include the Rust shared library.""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from packaging.tags import sys_tags + + +class PecosSeleneStabMpsBuildHook(BuildHookInterface): + """Build hook that compiles the Rust plugin and copies it to the Python package.""" + + def _set_wheel_tag(self, build_data: dict[str, Any]) -> None: + """Set platform-specific wheel tags. + + This ensures the wheel is marked as platform-specific (not pure Python). + We use py3-none-{platform} since we don't bind to Python ABI directly. + """ + build_data["pure_python"] = False + + # Get the appropriate platform tag + tag = next( + iter(t for t in sys_tags() if "manylinux" not in t.platform and "musllinux" not in t.platform), + ) + target_platform = tag.platform + if sys.platform == "darwin": + from hatchling.builders.macos import process_macos_plat_tag + + target_platform = process_macos_plat_tag(target_platform, compat=False) + build_data["tag"] = f"py3-none-{target_platform}" + + self.app.display_info(f"Wheel tag: {build_data['tag']}") + + def initialize( + self, + version: str, + build_data: dict[str, Any], + ) -> None: + """Build the Rust library and include it as an artifact.""" + # Get the root directory (where pyproject.toml is) + root = Path(self.root) + + # Check if library already exists (e.g., from `make build-selene`) + # If so, skip building and just collect artifacts + dist_dir = root / "python" / "pecos_selene_stab_mps" / "_dist" + lib_dir = dist_dir / "lib" + if lib_dir.exists() and any(lib_dir.iterdir()): + self.app.display_info("Library already built, skipping cargo build...") + # Collect artifacts + artifacts = [] + for artifact in dist_dir.rglob("*"): + if artifact.is_file(): + rel_path = artifact.relative_to(root) + artifacts.append(str(rel_path.as_posix())) + if artifacts: + self.app.display_info("Found existing artifacts:") + for a in artifacts: + self.app.display_info(f" {a}") + build_data["artifacts"] += artifacts + self._set_wheel_tag(build_data) + return + + # Determine library extension based on platform + system = platform.system() + if system == "Linux": + lib_prefix = "lib" + lib_suffix = ".so" + elif system == "Darwin": + lib_prefix = "lib" + lib_suffix = ".dylib" + elif system == "Windows": + lib_prefix = "" + lib_suffix = ".dll" + else: + msg = f"Unsupported platform: {system}" + raise RuntimeError(msg) + + lib_name = "pecos_selene_stab_mps" + cargo_package = "pecos-selene-stab-mps" + + self.app.display_info(f"Building {cargo_package}...") + + # Run cargo build from the PECOS workspace root + # Plugin is at python/selene-plugins//, so 3 levels up to workspace + workspace_root = root.parent.parent.parent + result = subprocess.run( + [ + "cargo", + "build", + "--release", + "--package", + cargo_package, + ], + check=False, + cwd=workspace_root, + capture_output=True, + text=True, + ) + + if result.returncode != 0: + self.app.display_error(f"Failed to build {cargo_package}:") + self.app.display_error(result.stderr) + msg = f"Cargo build failed for {cargo_package}" + raise RuntimeError(msg) + + # Find the compiled library + lib_filename = f"{lib_prefix}{lib_name}{lib_suffix}" + source_lib = workspace_root / "target" / "release" / lib_filename + + if not source_lib.exists(): + msg = f"Built library not found: {source_lib}" + raise RuntimeError(msg) + + # Copy to the _dist/lib directory in the Python package + dest_dir = root / "python" / "pecos_selene_stab_mps" / "_dist" / "lib" + dest_dir.mkdir(parents=True, exist_ok=True) + dest_lib = dest_dir / lib_filename + + self.app.display_info(f"Copying {source_lib} -> {dest_lib}") + shutil.copy2(source_lib, dest_lib) + + # Collect artifacts + artifacts = [] + dist_dir = root / "python" / "pecos_selene_stab_mps" / "_dist" + for artifact in dist_dir.rglob("*"): + if artifact.is_file(): + rel_path = artifact.relative_to(root) + artifacts.append(str(rel_path.as_posix())) + + self.app.display_info("Found artifacts:") + for a in artifacts: + self.app.display_info(f" {a}") + + build_data["artifacts"] += artifacts + self._set_wheel_tag(build_data) diff --git a/python/selene-plugins/pecos-selene-stab-mps/pyproject.toml b/python/selene-plugins/pecos-selene-stab-mps/pyproject.toml new file mode 100644 index 000000000..40b8b2cf1 --- /dev/null +++ b/python/selene-plugins/pecos-selene-stab-mps/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "pecos-selene-stab-mps" +version = "0.8.0.dev8" +requires-python = ">=3.10" +description = "PECOS StabMps simulator plugin for the Selene quantum emulator" +license = "Apache-2.0" +dependencies = [ + "selene-core>=0.2", +] + +[project.optional-dependencies] +test = [ + "pytest>=9.0", + "selene-sim>=0.2", + "guppylang>=0.14", +] + +[project.urls] +homepage = "https://pecos.io" +repository = "https://github.com/PECOS-packages/PECOS" + +[build-system] +requires = ["hatchling", "packaging"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["python/pecos_selene_stab_mps"] + +[tool.hatch.build.hooks.custom] +path = "hatch_build.py" + +[tool.uv] +cache-keys = [ + { file = "src/**/*.rs" }, + { file = "Cargo.toml" }, +] diff --git a/python/selene-plugins/pecos-selene-stab-mps/python/pecos_selene_stab_mps/__init__.py b/python/selene-plugins/pecos-selene-stab-mps/python/pecos_selene_stab_mps/__init__.py new file mode 100644 index 000000000..dee1a7d44 --- /dev/null +++ b/python/selene-plugins/pecos-selene-stab-mps/python/pecos_selene_stab_mps/__init__.py @@ -0,0 +1,17 @@ +# 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. + +"""PECOS StabMps Selene plugin.""" + +from pecos_selene_stab_mps.plugin import StabMpsPlugin + +__all__ = ["StabMpsPlugin"] diff --git a/python/selene-plugins/pecos-selene-stab-mps/python/pecos_selene_stab_mps/plugin.py b/python/selene-plugins/pecos-selene-stab-mps/python/pecos_selene_stab_mps/plugin.py new file mode 100644 index 000000000..df5738875 --- /dev/null +++ b/python/selene-plugins/pecos-selene-stab-mps/python/pecos_selene_stab_mps/plugin.py @@ -0,0 +1,83 @@ +# 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. + +"""PECOS StabMps plugin for Selene.""" + +import platform +from dataclasses import dataclass +from pathlib import Path + +from selene_core import Simulator + + +@dataclass +class StabMpsPlugin(Simulator): + """PECOS StabMps (stabilizer+MPS) simulator plugin for Selene. + + This plugin provides a StabMps (stabilizer+MPS) simulator backend for Selene using a + stabilizer tableau + MPS decomposition. Clifford gates are applied efficiently, + while cost is polynomial when non-Clifford count is bounded. + + Cost is polynomial in qubits and Clifford gates, exponential in the number + of RZ gates applied. + + Parameters + ---------- + random_seed : int, optional + Seed for the random number generator. If not provided, the seed + will be determined by Selene's shot management. + """ + + random_seed: int | None = None + + def get_init_args(self) -> list[str]: + """Return the initialization arguments for the Rust plugin. + + Returns: + ------- + list[str] + Empty list as StabMps plugin doesn't require additional arguments. + """ + return [] + + @property + def library_file(self) -> Path: + """Return the path to the compiled Rust library. + + Returns: + ------- + Path + Path to the shared library file. + + Raises: + ------ + FileNotFoundError + If no matching library file is found. + """ + libdir = Path(__file__).parent / "_dist" / "lib" + + # Platform-specific library naming + system = platform.system().lower() + if system == "darwin": + patterns = ["libpecos_selene_stab_mps*.dylib"] + elif system == "windows": + patterns = ["pecos_selene_stab_mps*.dll", "pecos_selene_stab_mps*.pyd"] + else: # Linux and others + patterns = ["libpecos_selene_stab_mps*.so"] + + for pattern in patterns: + matches = list(libdir.glob(pattern)) + if matches: + return matches[0] + + msg = f"Could not find PECOS StabMps library in {libdir}" + raise FileNotFoundError(msg) diff --git a/python/selene-plugins/pecos-selene-stab-mps/src/lib.rs b/python/selene-plugins/pecos-selene-stab-mps/src/lib.rs new file mode 100644 index 000000000..f6f72ff44 --- /dev/null +++ b/python/selene-plugins/pecos-selene-stab-mps/src/lib.rs @@ -0,0 +1,209 @@ +// 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. + +//! PECOS `StabMps` simulator plugin for the Selene quantum emulator. +//! +//! Stabilizer tableau + MPS hybrid simulator. Clifford gates are O(n) on the +//! tableau; non-Clifford rotations decompose in the stabilizer basis and +//! apply to the MPS. Cost is polynomial when non-Clifford count is bounded. + +use anyhow::{Result, anyhow, bail}; +use pecos_core::{Angle64, QubitId}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable}; +use pecos_stab_tn::stab_mps::StabMps; +use selene_core::export_simulator_plugin; +use selene_core::simulator::SimulatorInterface; +use selene_core::simulator::interface::SimulatorInterfaceFactory; +use selene_core::utils::MetricValue; +use std::sync::Arc; + +pub struct StabMpsSimulator { + simulator: StabMps, + n_qubits: u64, +} + +impl StabMpsSimulator { + #[allow(clippy::cast_possible_truncation)] + #[inline] + const fn to_usize(value: u64) -> usize { + value as usize + } +} + +impl SimulatorInterface for StabMpsSimulator { + fn exit(&mut self) -> Result<()> { + Ok(()) + } + + fn shot_start(&mut self, _shot_id: u64, seed: u64) -> Result<()> { + self.simulator = StabMps::builder(Self::to_usize(self.n_qubits)) + .seed(seed) + .for_qec() + .build(); + Ok(()) + } + + fn shot_end(&mut self) -> Result<()> { + Ok(()) + } + + fn rxy(&mut self, qubit: u64, theta: f64, phi: f64) -> Result<()> { + if qubit >= self.n_qubits { + return Err(anyhow!( + "RXY(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + let q = QubitId(Self::to_usize(qubit)); + self.simulator + .rz(Angle64::from_radians(-phi), &[q]) + .rx(Angle64::from_radians(theta), &[q]) + .rz(Angle64::from_radians(phi), &[q]); + Ok(()) + } + + fn rz(&mut self, qubit: u64, theta: f64) -> Result<()> { + if qubit >= self.n_qubits { + return Err(anyhow!( + "RZ(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + self.simulator.rz( + Angle64::from_radians(theta), + &[QubitId(Self::to_usize(qubit))], + ); + Ok(()) + } + + fn rzz(&mut self, qubit1: u64, qubit2: u64, theta: f64) -> Result<()> { + if qubit1 >= self.n_qubits || qubit2 >= self.n_qubits { + return Err(anyhow!( + "RZZ(qubit1={qubit1}, qubit2={qubit2}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + self.simulator.rzz( + Angle64::from_radians(theta), + &[( + QubitId(Self::to_usize(qubit1)), + QubitId(Self::to_usize(qubit2)), + )], + ); + Ok(()) + } + + fn measure(&mut self, qubit: u64) -> Result { + if qubit >= self.n_qubits { + return Err(anyhow!( + "Measure(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + let results = self.simulator.mz(&[QubitId(Self::to_usize(qubit))]); + Ok(results[0].outcome) + } + + fn postselect(&mut self, qubit: u64, target_value: bool) -> Result<()> { + if qubit >= self.n_qubits { + return Err(anyhow!( + "Postselect(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + let results = self.simulator.mz(&[QubitId(Self::to_usize(qubit))]); + if results[0].outcome != target_value { + return Err(anyhow!( + "Postselect(qubit={qubit}, target={target_value}) failed: got {}", + results[0].outcome + )); + } + Ok(()) + } + + fn reset(&mut self, qubit: u64) -> Result<()> { + if qubit >= self.n_qubits { + return Err(anyhow!( + "Reset(qubit={qubit}) out of bounds (n_qubits={})", + self.n_qubits + )); + } + self.simulator.reset_qubit(QubitId(Self::to_usize(qubit))); + Ok(()) + } + + fn get_metric(&mut self, nth_metric: u8) -> Result> { + match nth_metric { + 0 => Ok(Some(( + "max_bond_dim".to_string(), + MetricValue::U64(self.simulator.max_bond_dim() as u64), + ))), + 1 => Ok(Some(( + "pragmatic_drift_count".to_string(), + MetricValue::U64(self.simulator.pragmatic_drift_count()), + ))), + _ => Ok(None), + } + } + + fn dump_state(&mut self, _file: &std::path::Path, _qubits: &[u64]) -> Result<()> { + Err(anyhow!("State dumping not supported for StabMps")) + } +} + +#[derive(Default)] +pub struct StabMpsSimulatorFactory; + +impl SimulatorInterfaceFactory for StabMpsSimulatorFactory { + type Interface = StabMpsSimulator; + + fn init( + self: Arc, + n_qubits: u64, + args: &[impl AsRef], + ) -> Result> { + let args: Vec = args.iter().map(|s| s.as_ref().to_string()).collect(); + if args.len() > 1 { + bail!( + "Expected no arguments for StabMps plugin, got {}: {:?}", + args.len() - 1, + args.iter().skip(1).collect::>() + ); + } + if n_qubits == 0 { + bail!("Number of qubits must be greater than 0"); + } + Ok(Box::new(StabMpsSimulator { + simulator: StabMps::builder(StabMpsSimulator::to_usize(n_qubits)) + .seed(0) + .for_qec() + .build(), + n_qubits, + })) + } +} + +export_simulator_plugin!(crate::StabMpsSimulatorFactory); + +#[cfg(test)] +mod tests { + use super::StabMpsSimulatorFactory; + use selene_core::simulator::conformance_testing::run_basic_tests; + use std::sync::Arc; + + #[test] + fn basic_conformance_test() { + let interface = Arc::new(StabMpsSimulatorFactory); + let args: Vec = vec![String::new()]; + run_basic_tests(interface, args); + } +} diff --git a/python/selene-plugins/pecos-selene-clifford-rz/Cargo.toml b/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml similarity index 80% rename from python/selene-plugins/pecos-selene-clifford-rz/Cargo.toml rename to python/selene-plugins/pecos-selene-stab-vec/Cargo.toml index 26d3da1ee..40cb39f97 100644 --- a/python/selene-plugins/pecos-selene-clifford-rz/Cargo.toml +++ b/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "pecos-selene-clifford-rz" +name = "pecos-selene-stab-vec" version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true keywords.workspace = true categories.workspace = true -description = "PECOS Clifford+RZ simulator plugin for the Selene quantum emulator" +description = "PECOS StabVec simulator plugin for the Selene quantum emulator" [lib] -name = "pecos_selene_clifford_rz" +name = "pecos_selene_stab_vec" path = "src/lib.rs" crate-type = ["cdylib"] diff --git a/python/selene-plugins/pecos-selene-clifford-rz/README.md b/python/selene-plugins/pecos-selene-stab-vec/README.md similarity index 62% rename from python/selene-plugins/pecos-selene-clifford-rz/README.md rename to python/selene-plugins/pecos-selene-stab-vec/README.md index a9585677a..21e0e6a85 100644 --- a/python/selene-plugins/pecos-selene-clifford-rz/README.md +++ b/python/selene-plugins/pecos-selene-stab-vec/README.md @@ -1,27 +1,27 @@ -# PECOS CliffordRz Selene Plugin +# PECOS StabVec Selene Plugin -A Clifford+RZ simulator plugin for the [Selene](https://github.com/Quantinuum/selene) quantum emulator using the PECOS sum-over-Cliffords implementation. +A StabVec (Clifford+RZ) simulator plugin for the [Selene](https://github.com/Quantinuum/selene) quantum emulator using the PECOS sum-over-Cliffords implementation. ## Overview -This plugin provides a Clifford+RZ simulator backend for Selene. It handles Clifford gates efficiently and supports arbitrary RZ rotations via a sum-over-Cliffords decomposition. +This plugin provides a StabVec (Clifford+RZ) simulator backend for Selene. It handles Clifford gates efficiently and supports arbitrary RZ rotations via a sum-over-Cliffords decomposition. The cost is polynomial in qubits and Clifford gates, but exponential in the number of non-Clifford (RZ) gates applied. This makes it well-suited for circuits with many qubits but few non-Clifford gates. ## Installation ```bash -pip install pecos-selene-clifford-rz +pip install pecos-selene-stab-vec ``` ## Usage ```python from selene_sim.build import build -from pecos_selene_clifford_rz import CliffordRzPlugin +from pecos_selene_stab_vec import StabVecPlugin # Create a plugin instance -simulator = CliffordRzPlugin() +simulator = StabVecPlugin() # Use with Selene runner = build(program) @@ -44,7 +44,7 @@ This package requires Rust to build. The Rust components will be automatically c ```bash # From the PECOS repository root -cd python/selene-plugins/pecos-selene-clifford-rz +cd python/selene-plugins/pecos-selene-stab-vec pip install -e ".[test]" ``` diff --git a/python/selene-plugins/pecos-selene-stab-vec/hatch_build.py b/python/selene-plugins/pecos-selene-stab-vec/hatch_build.py new file mode 100644 index 000000000..b262d5464 --- /dev/null +++ b/python/selene-plugins/pecos-selene-stab-vec/hatch_build.py @@ -0,0 +1,153 @@ +# 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. + +"""Custom hatch build hook to compile and include the Rust shared library.""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from packaging.tags import sys_tags + + +class PecosSeleStabVecBuildHook(BuildHookInterface): + """Build hook that compiles the Rust plugin and copies it to the Python package.""" + + def _set_wheel_tag(self, build_data: dict[str, Any]) -> None: + """Set platform-specific wheel tags. + + This ensures the wheel is marked as platform-specific (not pure Python). + We use py3-none-{platform} since we don't bind to Python ABI directly. + """ + build_data["pure_python"] = False + + # Get the appropriate platform tag + tag = next( + iter(t for t in sys_tags() if "manylinux" not in t.platform and "musllinux" not in t.platform), + ) + target_platform = tag.platform + if sys.platform == "darwin": + from hatchling.builders.macos import process_macos_plat_tag + + target_platform = process_macos_plat_tag(target_platform, compat=False) + build_data["tag"] = f"py3-none-{target_platform}" + + self.app.display_info(f"Wheel tag: {build_data['tag']}") + + def initialize( + self, + version: str, + build_data: dict[str, Any], + ) -> None: + """Build the Rust library and include it as an artifact.""" + # Get the root directory (where pyproject.toml is) + root = Path(self.root) + + # Check if library already exists (e.g., from `make build-selene`) + # If so, skip building and just collect artifacts + dist_dir = root / "python" / "pecos_selene_stab_vec" / "_dist" + lib_dir = dist_dir / "lib" + if lib_dir.exists() and any(lib_dir.iterdir()): + self.app.display_info("Library already built, skipping cargo build...") + # Collect artifacts + artifacts = [] + for artifact in dist_dir.rglob("*"): + if artifact.is_file(): + rel_path = artifact.relative_to(root) + artifacts.append(str(rel_path.as_posix())) + if artifacts: + self.app.display_info("Found existing artifacts:") + for a in artifacts: + self.app.display_info(f" {a}") + build_data["artifacts"] += artifacts + self._set_wheel_tag(build_data) + return + + # Determine library extension based on platform + system = platform.system() + if system == "Linux": + lib_prefix = "lib" + lib_suffix = ".so" + elif system == "Darwin": + lib_prefix = "lib" + lib_suffix = ".dylib" + elif system == "Windows": + lib_prefix = "" + lib_suffix = ".dll" + else: + msg = f"Unsupported platform: {system}" + raise RuntimeError(msg) + + lib_name = "pecos_selene_stab_vec" + cargo_package = "pecos-selene-stab-vec" + + self.app.display_info(f"Building {cargo_package}...") + + # Run cargo build from the PECOS workspace root + # Plugin is at python/selene-plugins//, so 3 levels up to workspace + workspace_root = root.parent.parent.parent + result = subprocess.run( + [ + "cargo", + "build", + "--release", + "--package", + cargo_package, + ], + check=False, + cwd=workspace_root, + capture_output=True, + text=True, + ) + + if result.returncode != 0: + self.app.display_error(f"Failed to build {cargo_package}:") + self.app.display_error(result.stderr) + msg = f"Cargo build failed for {cargo_package}" + raise RuntimeError(msg) + + # Find the compiled library + lib_filename = f"{lib_prefix}{lib_name}{lib_suffix}" + source_lib = workspace_root / "target" / "release" / lib_filename + + if not source_lib.exists(): + msg = f"Built library not found: {source_lib}" + raise RuntimeError(msg) + + # Copy to the _dist/lib directory in the Python package + dest_dir = root / "python" / "pecos_selene_stab_vec" / "_dist" / "lib" + dest_dir.mkdir(parents=True, exist_ok=True) + dest_lib = dest_dir / lib_filename + + self.app.display_info(f"Copying {source_lib} -> {dest_lib}") + shutil.copy2(source_lib, dest_lib) + + # Collect artifacts + artifacts = [] + dist_dir = root / "python" / "pecos_selene_stab_vec" / "_dist" + for artifact in dist_dir.rglob("*"): + if artifact.is_file(): + rel_path = artifact.relative_to(root) + artifacts.append(str(rel_path.as_posix())) + + self.app.display_info("Found artifacts:") + for a in artifacts: + self.app.display_info(f" {a}") + + build_data["artifacts"] += artifacts + self._set_wheel_tag(build_data) diff --git a/python/selene-plugins/pecos-selene-clifford-rz/pyproject.toml b/python/selene-plugins/pecos-selene-stab-vec/pyproject.toml similarity index 79% rename from python/selene-plugins/pecos-selene-clifford-rz/pyproject.toml rename to python/selene-plugins/pecos-selene-stab-vec/pyproject.toml index c764de20a..ab99967f4 100644 --- a/python/selene-plugins/pecos-selene-clifford-rz/pyproject.toml +++ b/python/selene-plugins/pecos-selene-stab-vec/pyproject.toml @@ -1,8 +1,8 @@ [project] -name = "pecos-selene-clifford-rz" +name = "pecos-selene-stab-vec" version = "0.8.0.dev8" requires-python = ">=3.10" -description = "PECOS Clifford+RZ simulator plugin for the Selene quantum emulator" +description = "PECOS StabVec simulator plugin for the Selene quantum emulator" readme = "README.md" license = "Apache-2.0" dependencies = [ @@ -25,7 +25,7 @@ requires = ["hatchling", "packaging"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["python/pecos_selene_clifford_rz"] +packages = ["python/pecos_selene_stab_vec"] [tool.hatch.build.hooks.custom] path = "hatch_build.py" diff --git a/python/selene-plugins/pecos-selene-stab-vec/python/pecos_selene_stab_vec/__init__.py b/python/selene-plugins/pecos-selene-stab-vec/python/pecos_selene_stab_vec/__init__.py new file mode 100644 index 000000000..af68a3153 --- /dev/null +++ b/python/selene-plugins/pecos-selene-stab-vec/python/pecos_selene_stab_vec/__init__.py @@ -0,0 +1,17 @@ +# 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. + +"""PECOS StabVec Selene plugin.""" + +from pecos_selene_stab_vec.plugin import StabVecPlugin + +__all__ = ["StabVecPlugin"] diff --git a/python/selene-plugins/pecos-selene-clifford-rz/python/pecos_selene_clifford_rz/plugin.py b/python/selene-plugins/pecos-selene-stab-vec/python/pecos_selene_stab_vec/plugin.py similarity index 83% rename from python/selene-plugins/pecos-selene-clifford-rz/python/pecos_selene_clifford_rz/plugin.py rename to python/selene-plugins/pecos-selene-stab-vec/python/pecos_selene_stab_vec/plugin.py index cfcbe6707..dd427e99b 100644 --- a/python/selene-plugins/pecos-selene-clifford-rz/python/pecos_selene_clifford_rz/plugin.py +++ b/python/selene-plugins/pecos-selene-stab-vec/python/pecos_selene_stab_vec/plugin.py @@ -10,7 +10,7 @@ # or implied. See the License for the specific language governing permissions and limitations under # the License. -"""PECOS CliffordRz plugin for Selene.""" +"""PECOS StabVec plugin for Selene.""" import platform from dataclasses import dataclass @@ -20,7 +20,7 @@ @dataclass -class CliffordRzPlugin(Simulator): +class StabVecPlugin(Simulator): """PECOS Clifford+RZ simulator plugin for Selene. This plugin provides a Clifford+RZ simulator backend for Selene using a @@ -45,7 +45,7 @@ def get_init_args(self) -> list[str]: Returns: ------- list[str] - Empty list as CliffordRz plugin doesn't require additional arguments. + Empty list as StabVec plugin doesn't require additional arguments. """ return [] @@ -68,16 +68,16 @@ def library_file(self) -> Path: # Platform-specific library naming system = platform.system().lower() if system == "darwin": - patterns = ["libpecos_selene_clifford_rz*.dylib"] + patterns = ["libpecos_selene_stab_vec*.dylib"] elif system == "windows": - patterns = ["pecos_selene_clifford_rz*.dll", "pecos_selene_clifford_rz*.pyd"] + patterns = ["pecos_selene_stab_vec*.dll", "pecos_selene_stab_vec*.pyd"] else: # Linux and others - patterns = ["libpecos_selene_clifford_rz*.so"] + patterns = ["libpecos_selene_stab_vec*.so"] for pattern in patterns: matches = list(libdir.glob(pattern)) if matches: return matches[0] - msg = f"Could not find PECOS CliffordRz library in {libdir}" + msg = f"Could not find PECOS StabVec library in {libdir}" raise FileNotFoundError(msg) diff --git a/python/selene-plugins/pecos-selene-clifford-rz/src/lib.rs b/python/selene-plugins/pecos-selene-stab-vec/src/lib.rs similarity index 86% rename from python/selene-plugins/pecos-selene-clifford-rz/src/lib.rs rename to python/selene-plugins/pecos-selene-stab-vec/src/lib.rs index e2da98cbd..32c94769a 100644 --- a/python/selene-plugins/pecos-selene-clifford-rz/src/lib.rs +++ b/python/selene-plugins/pecos-selene-stab-vec/src/lib.rs @@ -10,7 +10,7 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! PECOS `CliffordRz` simulator plugin for the Selene quantum emulator. +//! PECOS `StabVec` simulator plugin for the Selene quantum emulator. //! //! This crate provides a Selene-compatible plugin wrapping the PECOS Clifford+RZ simulator. //! It supports Clifford gates efficiently plus arbitrary RZ rotations via a sum-over-Cliffords @@ -18,22 +18,22 @@ use anyhow::{Result, anyhow, bail}; use pecos_core::{Angle64, QubitId}; -use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, CliffordRz}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StabVec}; use selene_core::export_simulator_plugin; use selene_core::simulator::SimulatorInterface; use selene_core::simulator::interface::SimulatorInterfaceFactory; use selene_core::utils::MetricValue; use std::sync::Arc; -/// The PECOS `CliffordRz` simulator wrapped for Selene compatibility. -pub struct CliffordRzSimulator { +/// The PECOS `StabVec` simulator wrapped for Selene compatibility. +pub struct StabVecSimulator { /// The underlying PECOS Clifford+RZ simulator - simulator: CliffordRz, + simulator: StabVec, /// Number of qubits in the system n_qubits: u64, } -impl CliffordRzSimulator { +impl StabVecSimulator { /// Convert a `u64` to `usize` for use with the simulator. #[allow(clippy::cast_possible_truncation)] #[inline] @@ -42,13 +42,13 @@ impl CliffordRzSimulator { } } -impl SimulatorInterface for CliffordRzSimulator { +impl SimulatorInterface for StabVecSimulator { fn exit(&mut self) -> Result<()> { Ok(()) } fn shot_start(&mut self, _shot_id: u64, seed: u64) -> Result<()> { - self.simulator = CliffordRz::new_with_seed(Self::to_usize(self.n_qubits), seed); + self.simulator = StabVec::new_with_seed(Self::to_usize(self.n_qubits), seed); Ok(()) } @@ -180,17 +180,17 @@ impl SimulatorInterface for CliffordRzSimulator { fn dump_state(&mut self, _file: &std::path::Path, _qubits: &[u64]) -> Result<()> { Err(anyhow!( - "State dumping is not supported for the CliffordRz simulator" + "State dumping is not supported for the StabVec simulator" )) } } -/// Factory for creating `CliffordRzSimulator` instances. +/// Factory for creating `StabVecSimulator` instances. #[derive(Default)] -pub struct CliffordRzSimulatorFactory; +pub struct StabVecSimulatorFactory; -impl SimulatorInterfaceFactory for CliffordRzSimulatorFactory { - type Interface = CliffordRzSimulator; +impl SimulatorInterfaceFactory for StabVecSimulatorFactory { + type Interface = StabVecSimulator; fn init( self: Arc, @@ -201,7 +201,7 @@ impl SimulatorInterfaceFactory for CliffordRzSimulatorFactory { if args.len() > 1 { bail!( - "Expected no arguments for the PECOS CliffordRz plugin, got {} arguments: {:?}", + "Expected no arguments for the PECOS StabVec plugin, got {} arguments: {:?}", args.len() - 1, args.iter().skip(1).collect::>() ); @@ -211,25 +211,25 @@ impl SimulatorInterfaceFactory for CliffordRzSimulatorFactory { bail!("Number of qubits must be greater than 0"); } - Ok(Box::new(CliffordRzSimulator { - simulator: CliffordRz::new_with_seed(CliffordRzSimulator::to_usize(n_qubits), 0), + Ok(Box::new(StabVecSimulator { + simulator: StabVec::new_with_seed(StabVecSimulator::to_usize(n_qubits), 0), n_qubits, })) } } // Export the plugin using Selene's macro -export_simulator_plugin!(crate::CliffordRzSimulatorFactory); +export_simulator_plugin!(crate::StabVecSimulatorFactory); #[cfg(test)] mod tests { - use super::CliffordRzSimulatorFactory; + use super::StabVecSimulatorFactory; use selene_core::simulator::conformance_testing::run_basic_tests; use std::sync::Arc; #[test] fn basic_conformance_test() { - let interface = Arc::new(CliffordRzSimulatorFactory); + let interface = Arc::new(StabVecSimulatorFactory); let args: Vec = vec![String::new()]; run_basic_tests(interface, args); } diff --git a/python/selene-plugins/pecos-selene-clifford-rz/tests/test_clifford_rz.py b/python/selene-plugins/pecos-selene-stab-vec/tests/test_stab_vec.py similarity index 86% rename from python/selene-plugins/pecos-selene-clifford-rz/tests/test_clifford_rz.py rename to python/selene-plugins/pecos-selene-stab-vec/tests/test_stab_vec.py index 9c736031d..140cf10c0 100644 --- a/python/selene-plugins/pecos-selene-clifford-rz/tests/test_clifford_rz.py +++ b/python/selene-plugins/pecos-selene-stab-vec/tests/test_stab_vec.py @@ -10,19 +10,19 @@ # or implied. See the License for the specific language governing permissions and limitations under # the License. -"""Tests for the PECOS CliffordRz Selene plugin.""" +"""Tests for the PECOS StabVec Selene plugin.""" import pytest from guppylang import guppy from guppylang.std.angles import pi from guppylang.std.builtins import result from guppylang.std.quantum import crz, cx, discard, h, measure, qubit, reset, rx, ry, rz -from pecos_selene_clifford_rz import CliffordRzPlugin +from pecos_selene_stab_vec import StabVecPlugin from selene_sim.build import build -class TestCliffordRzBasic: - """Basic functionality tests for the CliffordRz plugin.""" +class TestStabVecBasic: + """Basic functionality tests for the StabVec plugin.""" def test_single_qubit_discard(self) -> None: """Test that a qubit can be created and discarded.""" @@ -33,7 +33,7 @@ def main() -> None: discard(q) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=42) + simulator = StabVecPlugin(random_seed=42) results = list(runner.run(simulator, n_qubits=1)) assert len(results) == 0 @@ -48,7 +48,7 @@ def main() -> None: result("outcome", bit) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=42) + simulator = StabVecPlugin(random_seed=42) results = list(runner.run(simulator, n_qubits=1)) assert dict(results)["outcome"] == 0 @@ -64,13 +64,13 @@ def main() -> None: result("outcome", bit) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=123) + simulator = StabVecPlugin(random_seed=123) results = list(runner.run(simulator, n_qubits=1)) assert dict(results)["outcome"] in [0, 1] -class TestCliffordRzBellState: +class TestStabVecBellState: """Tests involving Bell states and entanglement.""" def test_bell_state_correlation(self) -> None: @@ -88,14 +88,14 @@ def main() -> None: result("q1", b1) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=999) + simulator = StabVecPlugin(random_seed=999) results = list(runner.run(simulator, n_qubits=2)) d = dict(results) assert d["q0"] == d["q1"], f"Bell state correlation failed: {d}" -class TestCliffordRzArbitraryRotations: +class TestStabVecArbitraryRotations: """Tests for arbitrary rotation angles (non-Clifford).""" def test_t_gate_like_rotation(self) -> None: @@ -111,7 +111,7 @@ def main() -> None: result("outcome", bit) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=42) + simulator = StabVecPlugin(random_seed=42) for _ in range(5): results = list(runner.run(simulator, n_qubits=1)) @@ -130,7 +130,7 @@ def main() -> None: result("outcome", bit) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=42) + simulator = StabVecPlugin(random_seed=42) results = list(runner.run(simulator, n_qubits=1)) assert dict(results)["outcome"] in [0, 1] @@ -146,7 +146,7 @@ def main() -> None: result("outcome", bit) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=42) + simulator = StabVecPlugin(random_seed=42) results = list(runner.run(simulator, n_qubits=1)) assert dict(results)["outcome"] in [0, 1] @@ -162,7 +162,7 @@ def main() -> None: result("outcome", bit) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=42) + simulator = StabVecPlugin(random_seed=42) results = list(runner.run(simulator, n_qubits=1)) assert dict(results)["outcome"] in [0, 1] @@ -182,7 +182,7 @@ def main() -> None: result("q1", b1) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=42) + simulator = StabVecPlugin(random_seed=42) results = list(runner.run(simulator, n_qubits=2)) d = dict(results) @@ -190,7 +190,7 @@ def main() -> None: assert d["q1"] in [0, 1] -class TestCliffordRzReset: +class TestStabVecReset: """Tests for qubit reset.""" def test_reset_after_x(self) -> None: @@ -207,7 +207,7 @@ def main() -> None: result("outcome", bit) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=42) + simulator = StabVecPlugin(random_seed=42) results = list(runner.run(simulator, n_qubits=1)) assert dict(results)["outcome"] == 0 @@ -225,29 +225,29 @@ def main() -> None: result("outcome", bit) runner = build(main.compile()) - simulator = CliffordRzPlugin(random_seed=42) + simulator = StabVecPlugin(random_seed=42) results = list(runner.run(simulator, n_qubits=1)) assert dict(results)["outcome"] in [0, 1] -class TestCliffordRzPlugin: +class TestStabVecPlugin: """Tests for the plugin interface.""" def test_library_file_exists(self) -> None: """Test that the library file property returns a valid path.""" - plugin = CliffordRzPlugin() + plugin = StabVecPlugin() lib_path = plugin.library_file assert lib_path.name.startswith( - "libpecos_selene_clifford_rz", + "libpecos_selene_stab_vec", ) or lib_path.name.startswith( - "pecos_selene_clifford_rz", + "pecos_selene_stab_vec", ) def test_init_args_empty(self) -> None: """Test that init args are empty (no special parameters).""" - plugin = CliffordRzPlugin() + plugin = StabVecPlugin() args = plugin.get_init_args() assert len(args) == 0 diff --git a/uv.lock b/uv.lock index 2326c6202..244e8d009 100644 --- a/uv.lock +++ b/uv.lock @@ -16,8 +16,11 @@ resolution-markers = [ members = [ "pecos-rslib", "pecos-rslib-cuda", + "pecos-rslib-exp", "pecos-rslib-llvm", - "pecos-selene-clifford-rz", + "pecos-selene-mast", + "pecos-selene-stab-mps", + "pecos-selene-stab-vec", "pecos-selene-stabilizer", "pecos-selene-statevec", "pecos-workspace", @@ -2799,6 +2802,11 @@ test = [ dev = [] test = [{ name = "pytest", specifier = ">=9.0" }] +[[package]] +name = "pecos-rslib-exp" +version = "0.2.0.dev0" +source = { editable = "python/pecos-rslib-exp" } + [[package]] name = "pecos-rslib-llvm" version = "0.8.0.dev8" @@ -2816,9 +2824,57 @@ dev = [] test = [{ name = "pytest", specifier = ">=9.0" }] [[package]] -name = "pecos-selene-clifford-rz" +name = "pecos-selene-mast" +version = "0.2.0.dev0" +source = { editable = "python/selene-plugins/pecos-selene-mast" } +dependencies = [ + { name = "selene-core" }, +] + +[package.optional-dependencies] +test = [ + { name = "guppylang" }, + { name = "pytest" }, + { name = "selene-sim" }, +] + +[package.metadata] +requires-dist = [ + { name = "guppylang", marker = "extra == 'test'", specifier = ">=0.14" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0" }, + { name = "selene-core", specifier = ">=0.2" }, + { name = "selene-sim", marker = "extra == 'test'", specifier = ">=0.2" }, +] +provides-extras = ["test"] + +[[package]] +name = "pecos-selene-stab-mps" +version = "0.2.0.dev0" +source = { editable = "python/selene-plugins/pecos-selene-stab-mps" } +dependencies = [ + { name = "selene-core" }, +] + +[package.optional-dependencies] +test = [ + { name = "guppylang" }, + { name = "pytest" }, + { name = "selene-sim" }, +] + +[package.metadata] +requires-dist = [ + { name = "guppylang", marker = "extra == 'test'", specifier = ">=0.14" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0" }, + { name = "selene-core", specifier = ">=0.2" }, + { name = "selene-sim", marker = "extra == 'test'", specifier = ">=0.2" }, +] +provides-extras = ["test"] + +[[package]] +name = "pecos-selene-stab-vec" version = "0.8.0.dev8" -source = { editable = "python/selene-plugins/pecos-selene-clifford-rz" } +source = { editable = "python/selene-plugins/pecos-selene-stab-vec" } dependencies = [ { name = "selene-core" }, ]