From 93ba84bb03db4c689e40d24092864a953015916d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 27 Feb 2026 21:23:35 -0700 Subject: [PATCH 01/12] fix QuantumCircuit --- Cargo.lock | 117 ++--- Cargo.toml | 1 + crates/benchmarks/benches/benchmarks.rs | 6 + .../benches/modules/cuquantum.rs} | 46 +- crates/pecos-core/Cargo.toml | 6 + crates/pecos-core/src/gate_registry.rs | 406 ++++++++++++++++++ crates/pecos-core/src/gate_type.rs | 12 +- crates/pecos-core/src/gates.rs | 6 + crates/pecos-core/src/lib.rs | 7 + crates/pecos-core/src/value.rs | 249 +++++++++++ crates/pecos-cuquantum-sys/Cargo.toml | 2 +- crates/pecos-cuquantum/Cargo.toml | 9 - .../src/noise/biased_depolarizing.rs | 3 +- .../pecos-engines/src/noise/depolarizing.rs | 3 +- crates/pecos-engines/src/noise/utils.rs | 3 + crates/pecos-engines/src/quantum.rs | 4 +- .../pecos-experimental/src/hugr_executor.rs | 3 +- crates/pecos-qasm/src/engine.rs | 3 +- crates/pecos-quantum/src/lib.rs | 3 +- crates/pecos-quantum/src/tick_circuit.rs | 305 ++++++++++++- crates/pecos-quest/src/quantum_engine.rs | 9 +- crates/pecos/src/lib.rs | 4 +- .../pecos-rslib/src/dag_circuit_bindings.rs | 155 ++++++- .../pecos-rslib/src/gate_registry_bindings.rs | 329 ++++++++++++++ python/pecos-rslib/src/lib.rs | 4 + python/pecos-rslib/src/types_module.rs | 5 + python/quantum-pecos/src/pecos/__init__.py | 6 + .../src/pecos/circuits/quantum_circuit.py | 133 +++--- .../src/pecos/simulators/default_simulator.py | 41 +- .../src/pecos/simulators/sim_class_types.py | 48 --- uv.lock | 48 +-- 31 files changed, 1701 insertions(+), 275 deletions(-) rename crates/{pecos-cuquantum/benches/cuquantum_benchmark.rs => benchmarks/benches/modules/cuquantum.rs} (91%) create mode 100644 crates/pecos-core/src/gate_registry.rs create mode 100644 crates/pecos-core/src/value.rs create mode 100644 python/pecos-rslib/src/gate_registry_bindings.rs diff --git a/Cargo.lock b/Cargo.lock index e17914ca3..c6f85b994 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -319,7 +319,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" name = "benchmarks" version = "0.1.1" dependencies = [ - "criterion 0.8.2", + "criterion", "num-complex 0.4.6", "pecos", "pecos-core", @@ -341,9 +341,9 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.71.1" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags", "cexpr", @@ -1026,32 +1026,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "criterion" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot 0.5.0", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - [[package]] name = "criterion" version = "0.8.2" @@ -1063,7 +1037,7 @@ dependencies = [ "cast", "ciborium", "clap", - "criterion-plot 0.8.2", + "criterion-plot", "itertools 0.13.0", "num-traits", "oorandom", @@ -1077,16 +1051,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "criterion-plot" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" -dependencies = [ - "cast", - "itertools 0.10.5", -] - [[package]] name = "criterion-plot" version = "0.8.2" @@ -2576,32 +2540,12 @@ dependencies = [ "serde", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -2684,9 +2628,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.90" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -2779,7 +2723,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags", "libc", - "redox_syscall 0.7.2", + "redox_syscall 0.7.3", ] [[package]] @@ -3403,6 +3347,8 @@ dependencies = [ "rand 0.10.0", "rand_core 0.10.0", "rand_xoshiro 0.8.0", + "serde", + "serde_json", "smallvec", "thiserror 2.0.18", ] @@ -3422,7 +3368,6 @@ dependencies = [ name = "pecos-cuquantum" version = "0.1.1" dependencies = [ - "criterion 0.5.1", "env_logger", "fastrand", "log", @@ -4012,9 +3957,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -4546,9 +4491,9 @@ checksum = "77fbdd1602101dbbd6da38e7dd8d7bd47d864a23dd1b552d5ca3c20a8f41b2a3" [[package]] name = "range-alloc" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" [[package]] name = "rapidhash" @@ -4614,9 +4559,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ "bitflags", ] @@ -6049,9 +5994,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -6062,9 +6007,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.63" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -6076,9 +6021,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6086,9 +6031,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -6099,9 +6044,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -6393,9 +6338,9 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" [[package]] name = "web-sys" -version = "0.3.90" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -7130,18 +7075,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index c5266ceee..cd9e39d68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ wat = "1" ron = "0.12" tket = { version = "0.17", default-features = false } tket-qsystem = { version = "0.23", default-features = false } +bindgen = "0.72" cc = "1" cxx = "1" cxx-build = "1" diff --git a/crates/benchmarks/benches/benchmarks.rs b/crates/benchmarks/benches/benchmarks.rs index 5919aa7ab..97bdfed4c 100644 --- a/crates/benchmarks/benches/benchmarks.rs +++ b/crates/benchmarks/benches/benchmarks.rs @@ -18,6 +18,8 @@ mod modules { pub mod dem_sampler; pub mod dod_statevec; // TODO: pub mod hadamard_ops; + #[cfg(feature = "cuquantum")] + pub mod cuquantum; #[cfg(feature = "gpu-sims")] pub mod gpu_influence_sampler; pub mod measurement_sampling; @@ -34,6 +36,8 @@ mod modules { pub mod trig; } +#[cfg(feature = "cuquantum")] +use modules::cuquantum; #[cfg(feature = "gpu-sims")] use modules::gpu_influence_sampler; #[cfg(feature = "cppsparsesim")] @@ -47,6 +51,8 @@ use modules::{ fn all_benchmarks(c: &mut Criterion) { allocation_overhead::benchmarks(c); cpu_stabilizer_comparison::benchmarks(c); + #[cfg(feature = "cuquantum")] + cuquantum::benchmarks(c); dem_sampler::benchmarks(c); dod_statevec::benchmarks(c); #[cfg(feature = "gpu-sims")] diff --git a/crates/pecos-cuquantum/benches/cuquantum_benchmark.rs b/crates/benchmarks/benches/modules/cuquantum.rs similarity index 91% rename from crates/pecos-cuquantum/benches/cuquantum_benchmark.rs rename to crates/benchmarks/benches/modules/cuquantum.rs index c4a0ff7c1..e1cd8081b 100644 --- a/crates/pecos-cuquantum/benches/cuquantum_benchmark.rs +++ b/crates/benchmarks/benches/modules/cuquantum.rs @@ -1,17 +1,29 @@ -//! Benchmarks for pecos-cuquantum GPU simulators +// 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. + +//! Benchmarks for pecos-cuquantum GPU simulators. //! -//! These benchmarks compare cuQuantum GPU simulation performance against -//! other backends (e.g., wgpu-based simulators). +//! Benchmarks cuQuantum state vector and stabilizer simulation performance. //! -//! Run with: `cargo bench -p pecos-cuquantum --features integration-tests` +//! Run with: `cargo bench -p benchmarks --features cuquantum` //! //! **Requires cuQuantum to be installed.** -use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use criterion::{BenchmarkId, Criterion, Throughput}; use pecos_core::Angle64; use pecos_cuquantum::{CuStabilizer, CuStateVec, QubitId, TryClone, is_cuquantum_available}; use pecos_qsim::{ArbitraryRotationGateable, CliffordGateable, QuantumSimulator}; use std::f64::consts::PI; +use std::hint::black_box; /// Benchmark state vector simulation for different qubit counts fn bench_statevec_gates(c: &mut Criterion) { @@ -368,7 +380,7 @@ fn bench_sampling(c: &mut Criterion) { group.finish(); } -/// Benchmark Clone and TryClone operations +/// Benchmark Clone and `TryClone` operations fn bench_clone(c: &mut Criterion) { if !is_cuquantum_available() { eprintln!("Skipping clone benchmarks: cuQuantum not available"); @@ -424,15 +436,13 @@ fn bench_clone(c: &mut Criterion) { group.finish(); } -criterion_group!( - benches, - bench_statevec_gates, - bench_stabilizer_gates, - bench_bell_state, - bench_surface_code_syndrome, - bench_rotation_gates, - bench_two_qubit_rotation_gates, - bench_sampling, - bench_clone, -); -criterion_main!(benches); +pub fn benchmarks(c: &mut Criterion) { + bench_statevec_gates(c); + bench_stabilizer_gates(c); + bench_bell_state(c); + bench_surface_code_syndrome(c); + bench_rotation_gates(c); + bench_two_qubit_rotation_gates(c); + bench_sampling(c); + bench_clone(c); +} diff --git a/crates/pecos-core/Cargo.toml b/crates/pecos-core/Cargo.toml index 1b55e0cfb..7861f1a7b 100644 --- a/crates/pecos-core/Cargo.toml +++ b/crates/pecos-core/Cargo.toml @@ -22,13 +22,19 @@ smallvec.workspace = true thiserror.workspace = true # Optional dependencies for error conversions anyhow = { workspace = true, optional = true } +# Optional serde support +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } [dev-dependencies] rand_xoshiro.workspace = true +serde_json.workspace = true [features] default = [] anyhow = ["dep:anyhow"] +serde = ["dep:serde"] +json = ["serde", "dep:serde_json"] [lints] workspace = true diff --git a/crates/pecos-core/src/gate_registry.rs b/crates/pecos-core/src/gate_registry.rs new file mode 100644 index 000000000..aa50eda41 --- /dev/null +++ b/crates/pecos-core/src/gate_registry.rs @@ -0,0 +1,406 @@ +// 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. + +//! Gate registration system for ahead-of-time custom gate definitions. +//! +//! This module provides a registry where users can define custom gates with +//! decompositions into base gates. Registered gates are decomposed at simulation +//! time, not at circuit construction time. + +use crate::gate_type::GateType; +use crate::value::Value; +use crate::{Angle64, QubitId}; +use std::collections::HashMap; + +/// A concrete decomposition step: (`gate_type`, qubits, angles, metadata). +pub type ConcreteStep = (GateType, Vec, Vec, HashMap); + +/// The signature of a gate: its quantum and angle arities. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GateSignature { + pub quantum_arity: usize, + pub angle_arity: usize, +} + +/// Where a decomposition step gets its angle value. +#[derive(Debug, Clone, PartialEq)] +pub enum AngleSource { + /// Use the i-th angle from the parent gate's input angles. + Input(u8), + /// A fixed angle value. + Fixed(Angle64), + /// Negate the i-th input angle. + NegInput(u8), +} + +/// A single gate in a decomposition sequence. +/// Qubit indices are positional -- index 0 is the first qubit of the custom gate, etc. +#[derive(Debug, Clone, PartialEq)] +pub struct DecompStep { + pub gate_type: GateType, + pub qubit_indices: Vec, + pub angles: Vec, + pub metadata: HashMap, +} + +/// Definition of a registered custom gate. +#[derive(Debug, Clone, PartialEq)] +pub struct GateDefinition { + pub name: String, + pub quantum_arity: usize, + pub angle_arity: usize, + pub decomposition: Vec, +} + +/// Registry mapping gate names to definitions with decompositions. +#[derive(Debug, Clone, Default)] +pub struct GateRegistry { + gates: HashMap, +} + +impl GateRegistry { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, def: GateDefinition) { + self.gates.insert(def.name.clone(), def); + } + + #[must_use] + pub fn get(&self, name: &str) -> Option<&GateDefinition> { + self.gates.get(name) + } + + #[must_use] + pub fn contains(&self, name: &str) -> bool { + self.gates.contains_key(name) + } + + #[must_use] + pub fn len(&self) -> usize { + self.gates.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.gates.is_empty() + } + + /// Extract signatures from all registered gates. + #[must_use] + pub fn signatures(&self) -> HashMap { + self.gates + .iter() + .map(|(name, def)| { + ( + name.clone(), + GateSignature { + quantum_arity: def.quantum_arity, + angle_arity: def.angle_arity, + }, + ) + }) + .collect() + } + + /// Expand a custom gate into concrete (`GateType`, qubits, angles, metadata) tuples. + /// Returns None if not registered or decomposition is empty. + #[must_use] + pub fn decompose( + &self, + name: &str, + qubits: &[QubitId], + input_angles: &[Angle64], + ) -> Option> { + let def = self.gates.get(name)?; + if def.decomposition.is_empty() { + return None; + } + let mut result = Vec::with_capacity(def.decomposition.len()); + for step in &def.decomposition { + let concrete_qubits: Vec = step + .qubit_indices + .iter() + .map(|&idx| qubits[idx as usize]) + .collect(); + let concrete_angles: Vec = step + .angles + .iter() + .map(|src| match src { + AngleSource::Input(i) => input_angles[*i as usize], + AngleSource::Fixed(a) => *a, + AngleSource::NegInput(i) => -input_angles[*i as usize], + }) + .collect(); + result.push(( + step.gate_type, + concrete_qubits, + concrete_angles, + step.metadata.clone(), + )); + } + Some(result) + } +} + +/// Builder for constructing gate definitions with a fluent API. +pub struct GateDefinitionBuilder { + name: String, + quantum_arity: usize, + angle_arity: usize, + decomposition: Vec, +} + +impl GateDefinitionBuilder { + #[must_use] + pub fn new(name: impl Into, quantum_arity: usize) -> Self { + Self { + name: name.into(), + quantum_arity, + angle_arity: 0, + decomposition: Vec::new(), + } + } + + #[must_use] + pub fn angle_arity(mut self, arity: usize) -> Self { + self.angle_arity = arity; + self + } + + #[must_use] + pub fn step(mut self, gate_type: GateType, qubit_indices: &[u8]) -> Self { + self.decomposition.push(DecompStep { + gate_type, + qubit_indices: qubit_indices.to_vec(), + angles: Vec::new(), + metadata: HashMap::new(), + }); + self + } + + #[must_use] + pub fn step_with_angles( + mut self, + gate_type: GateType, + qubit_indices: &[u8], + angles: &[AngleSource], + ) -> Self { + self.decomposition.push(DecompStep { + gate_type, + qubit_indices: qubit_indices.to_vec(), + angles: angles.to_vec(), + metadata: HashMap::new(), + }); + self + } + + #[must_use] + pub fn step_with_metadata( + mut self, + gate_type: GateType, + qubit_indices: &[u8], + angles: &[AngleSource], + metadata: HashMap, + ) -> Self { + self.decomposition.push(DecompStep { + gate_type, + qubit_indices: qubit_indices.to_vec(), + angles: angles.to_vec(), + metadata, + }); + self + } + + #[must_use] + pub fn build(self) -> GateDefinition { + GateDefinition { + name: self.name, + quantum_arity: self.quantum_arity, + angle_arity: self.angle_arity, + decomposition: self.decomposition, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builder_round_trip() { + let def = GateDefinitionBuilder::new("MY_SWAP", 2) + .step(GateType::CX, &[0, 1]) + .step(GateType::CX, &[1, 0]) + .step(GateType::CX, &[0, 1]) + .build(); + + assert_eq!(def.name, "MY_SWAP"); + assert_eq!(def.quantum_arity, 2); + assert_eq!(def.angle_arity, 0); + assert_eq!(def.decomposition.len(), 3); + assert_eq!(def.decomposition[0].gate_type, GateType::CX); + assert_eq!(def.decomposition[0].qubit_indices, vec![0, 1]); + assert_eq!(def.decomposition[1].qubit_indices, vec![1, 0]); + } + + #[test] + fn test_decompose_positional_qubits() { + let mut registry = GateRegistry::new(); + let def = GateDefinitionBuilder::new("MY_SWAP", 2) + .step(GateType::CX, &[0, 1]) + .step(GateType::CX, &[1, 0]) + .step(GateType::CX, &[0, 1]) + .build(); + registry.register(def); + + let qubits = [QubitId::from(5usize), QubitId::from(10usize)]; + let result = registry.decompose("MY_SWAP", &qubits, &[]).unwrap(); + assert_eq!(result.len(), 3); + assert_eq!( + result[0].1, + vec![QubitId::from(5usize), QubitId::from(10usize)] + ); + assert_eq!( + result[1].1, + vec![QubitId::from(10usize), QubitId::from(5usize)] + ); + assert_eq!( + result[2].1, + vec![QubitId::from(5usize), QubitId::from(10usize)] + ); + assert!(result[0].3.is_empty()); + } + + #[test] + fn test_angle_source_resolution() { + let mut registry = GateRegistry::new(); + let fixed_angle = Angle64::from_turns(0.25); + let def = GateDefinitionBuilder::new("CRZ_LIKE", 2) + .angle_arity(1) + .step_with_angles(GateType::RZ, &[1], &[AngleSource::Input(0)]) + .step(GateType::CX, &[0, 1]) + .step_with_angles(GateType::RZ, &[1], &[AngleSource::NegInput(0)]) + .step(GateType::CX, &[0, 1]) + .step_with_angles(GateType::RZ, &[0], &[AngleSource::Fixed(fixed_angle)]) + .build(); + registry.register(def); + + let qubits = [QubitId::from(0usize), QubitId::from(1usize)]; + let input_angle = Angle64::from_turns(0.125); + let result = registry + .decompose("CRZ_LIKE", &qubits, &[input_angle]) + .unwrap(); + + assert_eq!(result.len(), 5); + assert_eq!(result[0].2, vec![input_angle]); + assert!(result[1].2.is_empty()); + assert_eq!(result[2].2, vec![-input_angle]); + assert_eq!(result[4].2, vec![fixed_angle]); + } + + #[test] + fn test_step_with_metadata() { + let mut registry = GateRegistry::new(); + let mut meta = HashMap::new(); + meta.insert("duration".to_string(), Value::Float(100.0)); + meta.insert("label".to_string(), Value::String("fast".to_string())); + meta.insert("count".to_string(), Value::Int(3)); + meta.insert("noisy".to_string(), Value::Bool(true)); + + let def = GateDefinitionBuilder::new("ANNOTATED", 1) + .step_with_metadata(GateType::H, &[0], &[], meta) + .build(); + registry.register(def); + + let result = registry + .decompose("ANNOTATED", &[QubitId::from(0usize)], &[]) + .unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].3.get("duration"), Some(&Value::Float(100.0))); + assert_eq!( + result[0].3.get("label"), + Some(&Value::String("fast".to_string())) + ); + assert_eq!(result[0].3.get("count"), Some(&Value::Int(3))); + assert_eq!(result[0].3.get("noisy"), Some(&Value::Bool(true))); + } + + #[test] + fn test_empty_decomposition_returns_none() { + let mut registry = GateRegistry::new(); + let def = GateDefinitionBuilder::new("EMPTY", 1).build(); + registry.register(def); + + let result = registry.decompose("EMPTY", &[QubitId::from(0usize)], &[]); + assert!(result.is_none()); + } + + #[test] + fn test_unregistered_gate_returns_none() { + let registry = GateRegistry::new(); + let result = registry.decompose("NONEXISTENT", &[QubitId::from(0usize)], &[]); + assert!(result.is_none()); + } + + #[test] + fn test_signatures() { + let mut registry = GateRegistry::new(); + let def1 = GateDefinitionBuilder::new("MY_SWAP", 2) + .step(GateType::CX, &[0, 1]) + .build(); + let def2 = GateDefinitionBuilder::new("MY_RZ", 1) + .angle_arity(1) + .step_with_angles(GateType::RZ, &[0], &[AngleSource::Input(0)]) + .build(); + registry.register(def1); + registry.register(def2); + + let sigs = registry.signatures(); + assert_eq!(sigs.len(), 2); + assert_eq!( + sigs.get("MY_SWAP"), + Some(&GateSignature { + quantum_arity: 2, + angle_arity: 0 + }) + ); + assert_eq!( + sigs.get("MY_RZ"), + Some(&GateSignature { + quantum_arity: 1, + angle_arity: 1 + }) + ); + } + + #[test] + fn test_registry_operations() { + let mut registry = GateRegistry::new(); + assert!(registry.is_empty()); + assert_eq!(registry.len(), 0); + assert!(!registry.contains("SWAP")); + + let def = GateDefinitionBuilder::new("SWAP", 2) + .step(GateType::CX, &[0, 1]) + .build(); + registry.register(def); + + assert!(!registry.is_empty()); + assert_eq!(registry.len(), 1); + assert!(registry.contains("SWAP")); + assert!(registry.get("SWAP").is_some()); + assert_eq!(registry.get("SWAP").unwrap().quantum_arity, 2); + } +} diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index 0348f51d3..f42e02d57 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -101,6 +101,8 @@ pub enum GateType { Idle = 200, MeasCrosstalkGlobalPayload = 218, MeasCrosstalkLocalPayload = 219, + /// Custom/unrecognized gate type, with actual name stored in metadata + Custom = 255, } impl From for GateType { @@ -145,6 +147,7 @@ impl From for GateType { 200 => GateType::Idle, 218 => GateType::MeasCrosstalkGlobalPayload, 219 => GateType::MeasCrosstalkLocalPayload, + 255 => GateType::Custom, _ => panic!("Invalid gate type ID: {value}"), } } @@ -188,7 +191,8 @@ impl GateType { | GateType::MeasCrosstalkLocalPayload | GateType::Prep | GateType::QAlloc - | GateType::QFree => 0, + | GateType::QFree + | GateType::Custom => 0, // Gates with one parameter GateType::RX @@ -244,7 +248,8 @@ impl GateType { | GateType::QFree | GateType::Idle | GateType::MeasCrosstalkGlobalPayload - | GateType::MeasCrosstalkLocalPayload => 1, + | GateType::MeasCrosstalkLocalPayload + | GateType::Custom => 1, // Two-qubit gates GateType::CX @@ -356,6 +361,7 @@ impl fmt::Display for GateType { GateType::Idle => write!(f, "Idle"), GateType::MeasCrosstalkGlobalPayload => write!(f, "MeasCrosstalkGlobalPayload"), GateType::MeasCrosstalkLocalPayload => write!(f, "MeasCrosstalkLocalPayload"), + GateType::Custom => write!(f, "Custom"), } } } @@ -384,6 +390,7 @@ mod tests { assert_eq!(GateType::Idle as u8, 200); assert_eq!(GateType::MeasCrosstalkGlobalPayload as u8, 218); assert_eq!(GateType::MeasCrosstalkLocalPayload as u8, 219); + assert_eq!(GateType::Custom as u8, 255); assert_eq!(GateType::from(0u8), GateType::I); assert_eq!(GateType::from(1u8), GateType::X); @@ -403,6 +410,7 @@ mod tests { assert_eq!(GateType::from(200u8), GateType::Idle); assert_eq!(GateType::from(218u8), GateType::MeasCrosstalkGlobalPayload); assert_eq!(GateType::from(219u8), GateType::MeasCrosstalkLocalPayload); + assert_eq!(GateType::from(255u8), GateType::Custom); } #[test] diff --git a/crates/pecos-core/src/gates.rs b/crates/pecos-core/src/gates.rs index ff582cbcd..95eb66fdc 100644 --- a/crates/pecos-core/src/gates.rs +++ b/crates/pecos-core/src/gates.rs @@ -110,6 +110,12 @@ impl Gate { .collect() } + /// Create a Custom gate on the given qubits + #[must_use] + pub fn custom(qubits: impl Into) -> Self { + Self::simple(GateType::Custom, qubits) + } + /// Create Identity gate on multiple qubits #[must_use] pub fn i(qubits: &[impl Into + Copy]) -> Self { diff --git a/crates/pecos-core/src/lib.rs b/crates/pecos-core/src/lib.rs index 0c5e47a4c..a8604af38 100644 --- a/crates/pecos-core/src/lib.rs +++ b/crates/pecos-core/src/lib.rs @@ -19,6 +19,7 @@ pub mod clifford_rep; pub mod duration; pub mod element; pub mod errors; +pub mod gate_registry; pub mod gate_type; pub mod gates; pub mod index_set; @@ -30,6 +31,7 @@ pub mod qubit_id; pub mod rng; pub mod sets; pub mod sorted_vec_set; +pub mod value; pub use angle::{Angle, Angle8, Angle16, Angle32, Angle64, Angle128, LossyInto}; pub use bit::{Bit, Bits}; @@ -53,6 +55,10 @@ pub use rng::{choose_weighted, coin_flip, gen_bools}; // Random utilities struct for improved RNG API pub use rng::RandomUtils; +pub use gate_registry::{ + AngleSource, ConcreteStep, DecompStep, GateDefinition, GateDefinitionBuilder, GateRegistry, + GateSignature, +}; pub use gates::{Gate, GateAngles, GateParams, GateQubits}; pub use pauli::pauli_bitmap::PauliBitmap; pub use pauli::pauli_sparse::PauliSparse; @@ -60,6 +66,7 @@ pub use pauli::pauli_string::{ParsePauliStringError, PauliString}; pub use pauli::{Pauli, PauliOperator}; pub use phase::Phase; pub use rng::choices::Choices; +pub use value::Value; // Operator algebra pub use operator::{I, Is, Operator, X, Xs, Y, Ys, Z, Zs}; diff --git a/crates/pecos-core/src/value.rs b/crates/pecos-core/src/value.rs new file mode 100644 index 000000000..c59a39915 --- /dev/null +++ b/crates/pecos-core/src/value.rs @@ -0,0 +1,249 @@ +// 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. + +//! A general-purpose typed value for structured data. +//! +//! `Value` provides a canonical enum for carrying heterogeneous data +//! (strings, numbers, booleans, and nested structures) across the PECOS crate +//! ecosystem. Optional serde/JSON support is available behind feature flags. + +use std::collections::HashMap; +use std::fmt; + +/// A general-purpose typed value for structured data. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Value { + String(String), + Int(i64), + Float(f64), + Bool(bool), + List(Vec), + Dict(HashMap), +} + +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::String(s) => write!(f, "\"{s}\""), + Value::Int(i) => write!(f, "{i}"), + Value::Float(v) => write!(f, "{v}"), + Value::Bool(b) => write!(f, "{b}"), + Value::List(items) => { + write!(f, "[")?; + for (i, item) in items.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{item}")?; + } + write!(f, "]") + } + Value::Dict(map) => { + write!(f, "{{")?; + for (i, (k, v)) in map.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "\"{k}\": {v}")?; + } + write!(f, "}}") + } + } + } +} + +impl From for Value { + fn from(s: String) -> Self { + Value::String(s) + } +} + +impl From<&str> for Value { + fn from(s: &str) -> Self { + Value::String(s.to_string()) + } +} + +impl From for Value { + fn from(i: i64) -> Self { + Value::Int(i) + } +} + +impl From for Value { + fn from(f: f64) -> Self { + Value::Float(f) + } +} + +impl From for Value { + fn from(b: bool) -> Self { + Value::Bool(b) + } +} + +impl From> for Value { + fn from(v: Vec) -> Self { + Value::List(v) + } +} + +impl From> for Value { + fn from(m: HashMap) -> Self { + Value::Dict(m) + } +} + +#[cfg(feature = "json")] +impl From for Value { + fn from(json: serde_json::Value) -> Self { + match json { + serde_json::Value::Null => Value::String(String::new()), + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Int(i) + } else { + Value::Float(n.as_f64().unwrap_or(0.0)) + } + } + serde_json::Value::String(s) => Value::String(s), + serde_json::Value::Array(arr) => { + Value::List(arr.into_iter().map(Value::from).collect()) + } + serde_json::Value::Object(obj) => { + Value::Dict(obj.into_iter().map(|(k, v)| (k, Value::from(v))).collect()) + } + } + } +} + +#[cfg(feature = "json")] +impl From for serde_json::Value { + fn from(val: Value) -> Self { + match val { + Value::String(s) => serde_json::Value::String(s), + Value::Int(i) => serde_json::Value::Number(i.into()), + Value::Float(f) => serde_json::Number::from_f64(f) + .map_or(serde_json::Value::Null, serde_json::Value::Number), + Value::Bool(b) => serde_json::Value::Bool(b), + Value::List(items) => { + serde_json::Value::Array(items.into_iter().map(serde_json::Value::from).collect()) + } + Value::Dict(map) => serde_json::Value::Object( + map.into_iter() + .map(|(k, v)| (k, serde_json::Value::from(v))) + .collect(), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display() { + assert_eq!(Value::String("hello".into()).to_string(), "\"hello\""); + assert_eq!(Value::Int(42).to_string(), "42"); + assert_eq!(Value::Float(2.78).to_string(), "2.78"); + assert_eq!(Value::Bool(true).to_string(), "true"); + assert_eq!( + Value::List(vec![Value::Int(1), Value::Int(2)]).to_string(), + "[1, 2]" + ); + } + + #[test] + fn test_from_conversions() { + assert_eq!(Value::from("hello"), Value::String("hello".into())); + assert_eq!(Value::from(42i64), Value::Int(42)); + assert_eq!(Value::from(2.78f64), Value::Float(2.78)); + assert_eq!(Value::from(true), Value::Bool(true)); + } + + #[test] + fn test_nested_structures() { + let mut inner = HashMap::new(); + inner.insert("x".to_string(), Value::Int(1)); + let val = Value::Dict(inner); + + let list = Value::List(vec![val.clone(), Value::String("test".into())]); + if let Value::List(items) = &list { + assert_eq!(items.len(), 2); + if let Value::Dict(d) = &items[0] { + assert_eq!(d.get("x"), Some(&Value::Int(1))); + } else { + panic!("Expected Dict"); + } + } else { + panic!("Expected List"); + } + } + + #[cfg(feature = "json")] + #[test] + fn test_json_round_trip() { + let val = Value::Dict(HashMap::from([ + ("name".to_string(), Value::String("test".into())), + ("count".to_string(), Value::Int(42)), + ("rate".to_string(), Value::Float(2.78)), + ("active".to_string(), Value::Bool(true)), + ( + "tags".to_string(), + Value::List(vec![Value::String("a".into()), Value::String("b".into())]), + ), + ])); + + let json: serde_json::Value = val.clone().into(); + let back: Value = json.into(); + + // Int and Float round-trip correctly + assert_eq!(back.clone(), val); + + // Check JSON structure + let json2: serde_json::Value = back.into(); + assert_eq!(json2["name"], "test"); + assert_eq!(json2["count"], 42); + assert_eq!(json2["active"], true); + } + + #[cfg(feature = "json")] + #[test] + fn test_json_null_becomes_empty_string() { + let json = serde_json::Value::Null; + let val: Value = json.into(); + assert_eq!(val, Value::String(String::new())); + } + + #[cfg(feature = "json")] + #[test] + fn test_json_nested_objects() { + let json: serde_json::Value = serde_json::json!({ + "outer": { + "inner": [1, 2, 3] + } + }); + let val: Value = json.into(); + if let Value::Dict(map) = &val + && let Some(Value::Dict(inner_map)) = map.get("outer") + && let Some(Value::List(items)) = inner_map.get("inner") + { + assert_eq!(items.len(), 3); + assert_eq!(items[0], Value::Int(1)); + return; + } + panic!("Unexpected structure: {val:?}"); + } +} diff --git a/crates/pecos-cuquantum-sys/Cargo.toml b/crates/pecos-cuquantum-sys/Cargo.toml index cc89978a0..b742a6758 100644 --- a/crates/pecos-cuquantum-sys/Cargo.toml +++ b/crates/pecos-cuquantum-sys/Cargo.toml @@ -14,7 +14,7 @@ readme = "README.md" [dependencies] [build-dependencies] -bindgen = "0.71" +bindgen.workspace = true pecos-build = { path = "../pecos-build" } log.workspace = true env_logger.workspace = true diff --git a/crates/pecos-cuquantum/Cargo.toml b/crates/pecos-cuquantum/Cargo.toml index 875502661..e1a328a13 100644 --- a/crates/pecos-cuquantum/Cargo.toml +++ b/crates/pecos-cuquantum/Cargo.toml @@ -24,10 +24,6 @@ pecos-build = { path = "../pecos-build" } log.workspace = true env_logger.workspace = true -[dev-dependencies] -# For testing, we don't actually need CUDA - tests use stub mode -criterion = { version = "0.5", features = ["html_reports"] } - [features] default = [] # Enable this feature when cuQuantum is installed and you want to run integration tests @@ -36,8 +32,3 @@ integration-tests = [] [package.metadata.docs.rs] # Don't try to build docs on docs.rs (no CUDA/cuQuantum available) targets = [] - -[[bench]] -name = "cuquantum_benchmark" -harness = false -required-features = ["integration-tests"] diff --git a/crates/pecos-engines/src/noise/biased_depolarizing.rs b/crates/pecos-engines/src/noise/biased_depolarizing.rs index 97988241f..30943f26e 100644 --- a/crates/pecos-engines/src/noise/biased_depolarizing.rs +++ b/crates/pecos-engines/src/noise/biased_depolarizing.rs @@ -213,7 +213,8 @@ impl BiasedDepolarizingNoiseModel { | GateType::Idle | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload - | GateType::QFree => {} + | GateType::QFree + | GateType::Custom => {} } } diff --git a/crates/pecos-engines/src/noise/depolarizing.rs b/crates/pecos-engines/src/noise/depolarizing.rs index eded2c7b2..6d8b6539a 100644 --- a/crates/pecos-engines/src/noise/depolarizing.rs +++ b/crates/pecos-engines/src/noise/depolarizing.rs @@ -218,7 +218,8 @@ impl DepolarizingNoiseModel { | GateType::Idle | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload - | GateType::QFree => { + | GateType::QFree + | GateType::Custom => { // Just pass through with no added noise // QFree has no physical operation to apply noise to } diff --git a/crates/pecos-engines/src/noise/utils.rs b/crates/pecos-engines/src/noise/utils.rs index 5ff0dd208..e6a97af52 100644 --- a/crates/pecos-engines/src/noise/utils.rs +++ b/crates/pecos-engines/src/noise/utils.rs @@ -250,6 +250,9 @@ impl NoiseUtils { builder.add_idle(gate.params[0], &qubits_usize); } + // Custom is a placeholder (actual gate name is in metadata); skip it + GateType::Custom => {} + // Invalid cases (not enough qubits, missing parameters, etc.) _ => panic!( "Invalid gate type {:?} or insufficient parameters/qubits", diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index a305827dc..ba8ac761b 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -496,10 +496,12 @@ where | GateType::Idle | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload - | GateType::QFree => { + | GateType::QFree + | GateType::Custom => { // Just let the system naturally evolve for the specified duration // No active operation needed in the simulator // QFree is a no-op for state vector simulation (qubit tracking is handled elsewhere) + // Custom is a no-op placeholder (actual gate name is in metadata) } GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { return Err(quantum_error(format!( diff --git a/crates/pecos-experimental/src/hugr_executor.rs b/crates/pecos-experimental/src/hugr_executor.rs index b1f8564ba..3db8bc720 100644 --- a/crates/pecos-experimental/src/hugr_executor.rs +++ b/crates/pecos-experimental/src/hugr_executor.rs @@ -308,7 +308,8 @@ where | GateType::SWAP | GateType::CRZ | GateType::CH - | GateType::CCX => { + | GateType::CCX + | GateType::Custom => { return Err(HugrExecutionError::UnsupportedGate { gate_type: gate.gate_type, gate_index: gate_idx, diff --git a/crates/pecos-qasm/src/engine.rs b/crates/pecos-qasm/src/engine.rs index 0925dce9e..2d62d3ff3 100644 --- a/crates/pecos-qasm/src/engine.rs +++ b/crates/pecos-qasm/src/engine.rs @@ -627,7 +627,8 @@ impl QASMEngine { | GateType::Idle | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload - | GateType::QFree => Ok(()), // No-op gates (QFree is just a marker) + | GateType::QFree + | GateType::Custom => Ok(()), // No-op gates (QFree is just a marker, Custom is a placeholder) GateType::X | GateType::Z | GateType::Y diff --git a/crates/pecos-quantum/src/lib.rs b/crates/pecos-quantum/src/lib.rs index 46b734f65..2f1058619 100644 --- a/crates/pecos-quantum/src/lib.rs +++ b/crates/pecos-quantum/src/lib.rs @@ -77,7 +77,8 @@ pub use dag_circuit::{ Attribute, DagCircuit, DagTraversalIndex, MeasureHandle, PrepHandle, TraversalWorkBuffers, }; pub use tick_circuit::{ - QubitConflictError, Tick, TickCircuit, TickHandle, TickMeasureHandle, TickPrepHandle, + CustomGateError, GateSignatureMismatchError, QubitConflictError, Tick, TickCircuit, TickHandle, + TickMeasureHandle, TickPrepHandle, }; pub use tick_circuit_soa::{ CircuitIndexes, GateBatch, GateId, GateStorage, MetadataStorage, TickBatches, TickCircuitSoA, diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 4806f8f82..0e655ace7 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -61,8 +61,8 @@ //! ``` use pecos_core::gate_type::GateType; -use pecos_core::{Angle64, Gate, GateQubits, Nanoseconds, QubitId}; -use std::collections::{BTreeMap, BTreeSet}; +use pecos_core::{Angle64, Gate, GateQubits, GateSignature, Nanoseconds, QubitId}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use crate::Attribute; use crate::dag_circuit::DagCircuit; @@ -103,6 +103,62 @@ impl fmt::Display for QubitConflictError { impl std::error::Error for QubitConflictError {} +/// Error when a custom gate is used with a different signature than previously established. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GateSignatureMismatchError { + pub name: String, + pub expected_quantum_arity: usize, + pub actual_quantum_arity: usize, + pub expected_angle_arity: usize, + pub actual_angle_arity: usize, +} + +impl fmt::Display for GateSignatureMismatchError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Gate '{}' signature mismatch: expected ({} qubits, {} angles), got ({} qubits, {} angles)", + self.name, + self.expected_quantum_arity, + self.expected_angle_arity, + self.actual_quantum_arity, + self.actual_angle_arity, + ) + } +} + +impl std::error::Error for GateSignatureMismatchError {} + +/// Error when adding a custom gate to a tick. +#[derive(Debug, Clone)] +pub enum CustomGateError { + SignatureMismatch(GateSignatureMismatchError), + QubitConflict(QubitConflictError), +} + +impl fmt::Display for CustomGateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SignatureMismatch(e) => write!(f, "{e}"), + Self::QubitConflict(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for CustomGateError {} + +impl From for CustomGateError { + fn from(e: GateSignatureMismatchError) -> Self { + Self::SignatureMismatch(e) + } +} + +impl From for CustomGateError { + fn from(e: QubitConflictError) -> Self { + Self::QubitConflict(e) + } +} + /// A single time slice containing gates that execute in parallel. #[derive(Debug, Clone, Default)] pub struct Tick { @@ -372,6 +428,8 @@ pub struct TickCircuit { next_tick: usize, /// Circuit-level metadata. circuit_attrs: BTreeMap, + /// Gate signatures for custom gate validation (JIT + AOT). + gate_signatures: HashMap, } /// Handle to a specific tick for adding gates. @@ -452,17 +510,14 @@ impl TickCircuit { ticks: Vec::new(), next_tick: 0, circuit_attrs: BTreeMap::new(), + gate_signatures: HashMap::new(), } } - /// Get the number of ticks (excluding trailing empty ticks). + /// Get the number of ticks in the circuit. #[must_use] pub fn num_ticks(&self) -> usize { - let mut count = self.ticks.len(); - while count > 0 && self.ticks[count - 1].is_empty() { - count -= 1; - } - count + self.ticks.len() } /// Get the total number of gates across all ticks. @@ -593,6 +648,7 @@ impl TickCircuit { self.ticks.clear(); self.next_tick = 0; self.circuit_attrs.clear(); + self.gate_signatures.clear(); } /// Reserve empty ticks in advance. @@ -731,6 +787,57 @@ impl TickCircuit { .map(|tick| tick.discard(&qubit_ids)) } + // ========================================================================= + // Gate signature validation + // ========================================================================= + + /// Import gate signatures in bulk (e.g., from a `GateRegistry`). + pub fn import_signatures(&mut self, sigs: &HashMap) { + self.gate_signatures + .extend(sigs.iter().map(|(name, sig)| (name.clone(), sig.clone()))); + } + + /// Get read access to the gate signatures. + #[must_use] + pub fn gate_signatures(&self) -> &HashMap { + &self.gate_signatures + } + + /// Validate a custom gate against its previously established signature, + /// or register it if this is the first use. + /// + /// # Errors + /// + /// Returns `GateSignatureMismatchError` if the gate has been seen before + /// with a different quantum or angle arity. + pub fn validate_or_register_gate( + &mut self, + name: &str, + quantum_arity: usize, + angle_arity: usize, + ) -> Result<(), GateSignatureMismatchError> { + if let Some(existing) = self.gate_signatures.get(name) { + if existing.quantum_arity != quantum_arity || existing.angle_arity != angle_arity { + return Err(GateSignatureMismatchError { + name: name.to_string(), + expected_quantum_arity: existing.quantum_arity, + actual_quantum_arity: quantum_arity, + expected_angle_arity: existing.angle_arity, + actual_angle_arity: angle_arity, + }); + } + } else { + self.gate_signatures.insert( + name.to_string(), + GateSignature { + quantum_arity, + angle_arity, + }, + ); + } + Ok(()) + } + // ========================================================================= // Iteration helpers // ========================================================================= @@ -1442,6 +1549,52 @@ impl<'a> TickHandle<'a> { qubits.iter().map(|&q| q.into()).collect::(), )) } + + // ========================================================================= + // Custom gates with signature validation + // ========================================================================= + + /// Add a custom gate with signature validation. + /// + /// On first use, the gate name's signature (quantum arity, angle arity) + /// is recorded. Subsequent uses are validated against this signature. + /// + /// The `_symbol` metadata is automatically set to the gate name. + /// + /// # Errors + /// + /// Returns `CustomGateError::SignatureMismatch` if the arity does not match + /// a previous use, or `CustomGateError::QubitConflict` if a qubit is already + /// in use in this tick. + pub fn custom_gate( + &mut self, + name: &str, + qubits: &[usize], + angles: &[Angle64], + ) -> Result<&mut Self, CustomGateError> { + self.circuit + .validate_or_register_gate(name, qubits.len(), angles.len())?; + + let qubit_ids: GateQubits = qubits.iter().map(|&q| QubitId::from(q)).collect(); + let gate = Gate::new(GateType::Custom, angles.to_vec(), vec![], qubit_ids); + + match self.circuit.ticks[self.tick_idx].try_add_gate(gate) { + Ok(idx) => { + self.last_gate_idx = Some(idx); + // Auto-store _symbol metadata + self.circuit.ticks[self.tick_idx].set_gate_attr( + idx, + "_symbol", + Attribute::String(name.to_string()), + ); + Ok(self) + } + Err(mut err) => { + err.tick_idx = Some(self.tick_idx); + Err(CustomGateError::QubitConflict(err)) + } + } + } } // ============================================================================ @@ -2361,4 +2514,140 @@ mod tests { let removed = tc.discard(&[0], 5); assert_eq!(removed, None); } + + // ========================================================================= + // Gate signature validation tests + // ========================================================================= + + #[test] + fn test_custom_gate_jit_registration() { + let mut tc = TickCircuit::new(); + tc.tick() + .custom_gate("MY_GATE", &[0, 1], &[]) + .expect("first use should succeed"); + + assert!(tc.gate_signatures().contains_key("MY_GATE")); + let sig = &tc.gate_signatures()["MY_GATE"]; + assert_eq!(sig.quantum_arity, 2); + assert_eq!(sig.angle_arity, 0); + } + + #[test] + fn test_custom_gate_consistent_use_ok() { + let mut tc = TickCircuit::new(); + tc.tick() + .custom_gate("MY_GATE", &[0, 1], &[]) + .expect("first use"); + tc.tick() + .custom_gate("MY_GATE", &[2, 3], &[]) + .expect("consistent use should succeed"); + } + + #[test] + fn test_custom_gate_mismatch_quantum_arity() { + let mut tc = TickCircuit::new(); + tc.tick() + .custom_gate("MY_GATE", &[0, 1], &[]) + .expect("first use"); + let mut handle = tc.tick(); + let result = handle.custom_gate("MY_GATE", &[0, 1, 2], &[]); + if let Err(CustomGateError::SignatureMismatch(e)) = result { + assert_eq!(e.expected_quantum_arity, 2); + assert_eq!(e.actual_quantum_arity, 3); + } else { + panic!("expected SignatureMismatch error"); + } + } + + #[test] + fn test_custom_gate_mismatch_angle_arity() { + let mut tc = TickCircuit::new(); + let angle = Angle64::from_radians(1.0); + tc.tick() + .custom_gate("PARAM_GATE", &[0], &[angle]) + .expect("first use"); + let mut handle = tc.tick(); + let result = handle.custom_gate("PARAM_GATE", &[0], &[]); + if let Err(CustomGateError::SignatureMismatch(e)) = result { + assert_eq!(e.expected_angle_arity, 1); + assert_eq!(e.actual_angle_arity, 0); + } else { + panic!("expected SignatureMismatch error"); + } + } + + #[test] + fn test_custom_gate_stores_symbol_metadata() { + let mut tc = TickCircuit::new(); + tc.tick() + .custom_gate("FOOBAR", &[0], &[]) + .expect("should succeed"); + + let tick = tc.get_tick(0).unwrap(); + let symbol = tick.get_gate_attr(0, "_symbol"); + assert_eq!(symbol, Some(&Attribute::String("FOOBAR".to_string()))); + } + + #[test] + fn test_custom_gate_with_angles() { + let mut tc = TickCircuit::new(); + let a1 = Angle64::from_radians(0.5); + let a2 = Angle64::from_radians(1.0); + tc.tick() + .custom_gate("PARAM2", &[0], &[a1, a2]) + .expect("should succeed"); + + let tick = tc.get_tick(0).unwrap(); + let gate = &tick.gates()[0]; + assert_eq!(gate.gate_type, GateType::Custom); + assert_eq!(gate.angles.len(), 2); + assert_eq!(gate.angles[0], a1); + assert_eq!(gate.angles[1], a2); + } + + #[test] + fn test_custom_gate_qubit_conflict() { + let mut tc = TickCircuit::new(); + let mut handle = tc.tick(); + handle.h(&[0]); + let result = handle.custom_gate("MY_GATE", &[0], &[]); + assert!(matches!(result, Err(CustomGateError::QubitConflict(_)))); + } + + #[test] + fn test_import_signatures() { + let mut tc = TickCircuit::new(); + let mut sigs = HashMap::new(); + sigs.insert( + "AOT_GATE".to_string(), + GateSignature { + quantum_arity: 2, + angle_arity: 1, + }, + ); + tc.import_signatures(&sigs); + + // Now using AOT_GATE with correct arity succeeds + let angle = Angle64::from_radians(0.5); + tc.tick() + .custom_gate("AOT_GATE", &[0, 1], &[angle]) + .expect("correct arity"); + + // Wrong arity fails + let mut handle = tc.tick(); + let result = handle.custom_gate("AOT_GATE", &[0], &[angle]); + assert!(matches!(result, Err(CustomGateError::SignatureMismatch(_)))); + } + + #[test] + fn test_reset_clears_signatures() { + let mut tc = TickCircuit::new(); + tc.tick() + .custom_gate("MY_GATE", &[0, 1], &[]) + .expect("first use"); + assert!(!tc.gate_signatures().is_empty()); + + tc.reset(); + assert!(tc.gate_signatures().is_empty()); + } } diff --git a/crates/pecos-quest/src/quantum_engine.rs b/crates/pecos-quest/src/quantum_engine.rs index 9b110d3e0..a1e4d9938 100644 --- a/crates/pecos-quest/src/quantum_engine.rs +++ b/crates/pecos-quest/src/quantum_engine.rs @@ -192,7 +192,8 @@ impl Engine for QuestStateVecEngine { | GateType::Idle | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload - | GateType::QFree => { + | GateType::QFree + | GateType::Custom => { // No operation needed (QFree is just a marker for qubit lifecycle) } GateType::U => { @@ -412,7 +413,8 @@ impl Engine for QuestDensityMatrixEngine { | GateType::Idle | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload - | GateType::QFree => { + | GateType::QFree + | GateType::Custom => { // No operation needed (QFree is just a marker for qubit lifecycle) } GateType::U => { @@ -1157,10 +1159,11 @@ impl Engine for QuestCudaStateVecEngine { } GateType::I | GateType::Idle + | GateType::Custom | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload | GateType::QFree => { - // No operation needed (QFree is just a marker for qubit lifecycle) + // No operation needed (Custom is a placeholder whose actual gate name is in metadata) } GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { return Err(PecosError::Processing(format!( diff --git a/crates/pecos/src/lib.rs b/crates/pecos/src/lib.rs index 35946ac8f..0ff590510 100644 --- a/crates/pecos/src/lib.rs +++ b/crates/pecos/src/lib.rs @@ -191,8 +191,8 @@ pub mod engines { pub mod quantum { // Circuit representation from pecos-quantum pub use pecos_quantum::{ - Attribute, Circuit, CircuitMut, DagCircuit, DagWouldCycleError, Gate, GateHandle, GateType, - GateView, QubitId, Tick, TickCircuit, + Attribute, Circuit, CircuitMut, CustomGateError, DagCircuit, DagWouldCycleError, Gate, + GateHandle, GateType, GateView, QubitId, Tick, TickCircuit, }; // HUGR conversion (requires hugr feature) diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index eb74f1322..6e92e6ec9 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -23,10 +23,12 @@ //! from the pecos-quantum crate, as well as HUGR conversion utilities. use crate::dtypes::AngleParam; -use pecos::core::{Angle64, Nanoseconds, TimeUnits}; +use crate::gate_registry_bindings::PyGateRegistry; +use pecos::core::{Angle64, GateQubits, GateSignature, Nanoseconds, TimeUnits}; use pecos::quantum::{Attribute, DagCircuit, Gate, GateType, QubitId, Tick, TickCircuit}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList}; +use std::collections::HashMap; /// Convert a Rust Attribute to a Python object. fn attribute_to_py(py: Python<'_>, attr: &Attribute) -> Py { @@ -407,6 +409,14 @@ impl PyGateType { inner: GateType::QFree, } } + + #[classattr] + #[pyo3(name = "Custom")] + fn custom() -> Self { + Self { + inner: GateType::Custom, + } + } } impl From for PyGateType { @@ -1342,6 +1352,12 @@ pyo3::create_exception!( ); // Qubit conflict exception +pyo3::create_exception!( + pecos_rslib, + GateSignatureMismatchError, + pyo3::exceptions::PyValueError +); + pyo3::create_exception!( pecos_rslib, QubitConflictError, @@ -2154,6 +2170,52 @@ impl PyTickCircuit { } } + // ========================================================================= + // Gate signature validation + // ========================================================================= + + /// Import gate signatures for validation. + /// + /// Args: + /// sigs: A dictionary mapping gate names to (`quantum_arity`, `angle_arity`) tuples. + fn import_gate_signatures(&mut self, sigs: &Bound<'_, PyDict>) -> PyResult<()> { + let mut sig_map = HashMap::new(); + for (key, value) in sigs.iter() { + let name: String = key.extract()?; + let (quantum_arity, angle_arity): (usize, usize) = value.extract()?; + sig_map.insert( + name, + GateSignature { + quantum_arity, + angle_arity, + }, + ); + } + self.inner.import_signatures(&sig_map); + Ok(()) + } + + /// Get gate signatures as a dictionary. + /// + /// Returns: + /// A dictionary mapping gate names to (`quantum_arity`, `angle_arity`) tuples. + fn gate_signatures(&self, py: Python<'_>) -> PyResult> { + let dict = PyDict::new(py); + for (name, sig) in self.inner.gate_signatures() { + dict.set_item(name, (sig.quantum_arity, sig.angle_arity))?; + } + Ok(dict.into()) + } + + /// Import signatures from a `GateRegistry`. + /// + /// Extracts signatures from all registered gates and imports them + /// for validation when adding custom gates. + fn import_registry(&mut self, registry: &PyGateRegistry) { + let sigs = registry.inner.signatures(); + self.inner.import_signatures(&sigs); + } + fn __repr__(&self) -> String { format!( "TickCircuit(ticks={}, gates={})", @@ -2596,6 +2658,93 @@ impl PyTickHandle { Ok(slf) } + // ========================================================================= + // Custom (unrecognized) gates + // ========================================================================= + + /// Add a custom (unrecognized) gate on the given qubits. + fn custom(slf: Py, py: Python<'_>, qubits: Vec) -> PyResult> { + let qubit_ids: GateQubits = qubits.into_iter().map(QubitId::from).collect(); + slf.borrow_mut(py) + .add_gate_internal(py, Gate::custom(qubit_ids))?; + Ok(slf) + } + + /// Add a custom gate with signature validation. + /// + /// On first use, the gate name's signature (quantum arity, angle arity) + /// is recorded. Subsequent uses are validated against this signature. + /// + /// Args: + /// name: The gate name. + /// qubits: List of qubit IDs. + /// angles: Optional list of angle values (radians). + /// + /// Raises: + /// `GateSignatureMismatchError`: If the arity does not match a previous use. + /// `QubitConflictError`: If a qubit is already in use in this tick. + #[pyo3(signature = (name, qubits, angles=None))] + fn custom_gate( + slf: Py, + py: Python<'_>, + name: &str, + qubits: Vec, + angles: Option>, + ) -> PyResult> { + let angle_vals: Vec = angles + .unwrap_or_default() + .into_iter() + .map(Angle64::from_radians) + .collect(); + + let handle = slf.borrow_mut(py); + let tick_idx = handle.tick_idx; + let circuit_py = handle.circuit.clone_ref(py); + + // Validate/register and add gate + let mut circuit = circuit_py.borrow_mut(py); + match circuit + .inner + .validate_or_register_gate(name, qubits.len(), angle_vals.len()) + { + Ok(()) => {} + Err(e) => { + return Err(PyErr::new::(e.to_string())); + } + } + + let qubit_ids: GateQubits = qubits.into_iter().map(QubitId::from).collect(); + let gate = Gate::new(GateType::Custom, angle_vals, vec![], qubit_ids); + + if let Some(tick) = circuit.inner.get_tick_mut(tick_idx) { + match tick.try_add_gate(gate) { + Ok(idx) => { + tick.set_gate_attr(idx, "_symbol", Attribute::String(name.to_string())); + drop(circuit); + drop(handle); + // Update last_gate_idx through a fresh borrow + slf.borrow_mut(py).last_gate_idx = Some(idx); + Ok(slf) + } + Err(err) => { + let msg = format!( + "Qubit(s) {:?} already in use in tick {}", + err.conflicting_qubits + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + tick_idx + ); + Err(PyErr::new::(msg)) + } + } + } else { + drop(circuit); + drop(handle); + Ok(slf) + } + } + // ========================================================================= // State preparation and measurement // ========================================================================= @@ -2729,6 +2878,10 @@ pub fn register_quantum_circuit_types(parent_module: &Bound<'_, PyModule>) -> Py )?; parent_module.add("HugrConversionError", py.get_type::())?; parent_module.add("QubitConflictError", py.get_type::())?; + parent_module.add( + "GateSignatureMismatchError", + py.get_type::(), + )?; // Add HUGR conversion functions parent_module.add_function(wrap_pyfunction!(py_hugr_to_dag_circuit, parent_module)?)?; diff --git a/python/pecos-rslib/src/gate_registry_bindings.rs b/python/pecos-rslib/src/gate_registry_bindings.rs new file mode 100644 index 000000000..24e0cc205 --- /dev/null +++ b/python/pecos-rslib/src/gate_registry_bindings.rs @@ -0,0 +1,329 @@ +// 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 the gate registration system. + +use pecos::core::Value; +use pecos::core::gate_type::GateType; +use pecos::core::{Angle64, AngleSource, GateDefinitionBuilder, GateRegistry, QubitId}; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyDict, PyList}; +use std::collections::HashMap; + +/// Parse a gate name string into a `GateType`. +fn parse_gate_type(name: &str) -> PyResult { + match name { + "I" => Ok(GateType::I), + "X" => Ok(GateType::X), + "Y" => Ok(GateType::Y), + "Z" => Ok(GateType::Z), + "SX" => Ok(GateType::SX), + "SXdg" => Ok(GateType::SXdg), + "SY" => Ok(GateType::SY), + "SYdg" => Ok(GateType::SYdg), + "SZ" | "S" => Ok(GateType::SZ), + "SZdg" | "Sdg" => Ok(GateType::SZdg), + "H" => Ok(GateType::H), + "RX" => Ok(GateType::RX), + "RY" => Ok(GateType::RY), + "RZ" => Ok(GateType::RZ), + "T" => Ok(GateType::T), + "Tdg" => Ok(GateType::Tdg), + "U" => Ok(GateType::U), + "R1XY" => Ok(GateType::R1XY), + "CX" | "CNOT" => Ok(GateType::CX), + "CY" => Ok(GateType::CY), + "CZ" => Ok(GateType::CZ), + "CH" => Ok(GateType::CH), + "SZZ" => Ok(GateType::SZZ), + "SZZdg" => Ok(GateType::SZZdg), + "SWAP" => Ok(GateType::SWAP), + "CRZ" => Ok(GateType::CRZ), + "RXX" => Ok(GateType::RXX), + "RYY" => Ok(GateType::RYY), + "RZZ" => Ok(GateType::RZZ), + "CCX" | "Toffoli" => Ok(GateType::CCX), + "Measure" => Ok(GateType::Measure), + "MeasureLeaked" => Ok(GateType::MeasureLeaked), + "MeasureFree" => Ok(GateType::MeasureFree), + "Prep" => Ok(GateType::Prep), + "QAlloc" => Ok(GateType::QAlloc), + "QFree" => Ok(GateType::QFree), + "Idle" => Ok(GateType::Idle), + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unknown gate type: '{name}'" + ))), + } +} + +/// Convert a Python object to a `Value`. +fn py_to_value(obj: &Bound<'_, PyAny>) -> PyResult { + // Try bool before int since Python bools are ints + if let Ok(b) = obj.extract::() { + return Ok(Value::Bool(b)); + } + if let Ok(i) = obj.extract::() { + return Ok(Value::Int(i)); + } + if let Ok(f) = obj.extract::() { + return Ok(Value::Float(f)); + } + if let Ok(s) = obj.extract::() { + return Ok(Value::String(s)); + } + if let Ok(dict) = obj.cast::() { + return Ok(Value::Dict(py_dict_to_value_map(dict)?)); + } + if let Ok(list) = obj.cast::() { + let items: PyResult> = list.iter().map(|item| py_to_value(&item)).collect(); + return Ok(Value::List(items?)); + } + Err(pyo3::exceptions::PyTypeError::new_err(format!( + "Metadata values must be str, int, float, bool, list, or dict, got {}", + obj.get_type().name()? + ))) +} + +/// Convert a `Value` to a Python object. +fn value_to_py(py: Python<'_>, val: &Value) -> PyResult> { + match val { + Value::String(s) => Ok(s.into_pyobject(py)?.into_any().unbind()), + Value::Int(i) => Ok(i.into_pyobject(py)?.into_any().unbind()), + Value::Float(f) => Ok(f.into_pyobject(py)?.into_any().unbind()), + Value::Bool(b) => Ok(b.into_pyobject(py)?.to_owned().into_any().unbind()), + Value::List(items) => { + let py_list = PyList::empty(py); + for item in items { + py_list.append(value_to_py(py, item)?)?; + } + Ok(py_list.unbind().into_any()) + } + Value::Dict(map) => { + let py_dict = PyDict::new(py); + for (k, v) in map { + py_dict.set_item(k, value_to_py(py, v)?)?; + } + Ok(py_dict.unbind().into_any()) + } + } +} + +/// Convert a Python dict to a `HashMap`. +fn py_dict_to_value_map(dict: &Bound<'_, PyDict>) -> PyResult> { + let mut metadata = HashMap::new(); + for (key, val) in dict.iter() { + let k: String = key.extract()?; + let v = py_to_value(&val)?; + metadata.insert(k, v); + } + Ok(metadata) +} + +/// Python-friendly angle source specification for decomposition steps. +#[pyclass(name = "AngleSource", from_py_object)] +#[derive(Clone)] +pub struct PyAngleSource { + inner: AngleSource, +} + +#[pymethods] +impl PyAngleSource { + /// Forward the i-th input angle from the parent gate. + #[staticmethod] + fn input(index: u8) -> Self { + Self { + inner: AngleSource::Input(index), + } + } + + /// Use a fixed angle value (in turns, where 1.0 = full turn). + #[staticmethod] + fn fixed(value: f64) -> Self { + Self { + inner: AngleSource::Fixed(Angle64::from_turns(value)), + } + } + + /// Negate the i-th input angle from the parent gate. + #[staticmethod] + fn neg_input(index: u8) -> Self { + Self { + inner: AngleSource::NegInput(index), + } + } + + fn __repr__(&self) -> String { + match &self.inner { + AngleSource::Input(i) => format!("AngleSource.input({i})"), + AngleSource::Fixed(a) => format!("AngleSource.fixed({a})"), + AngleSource::NegInput(i) => format!("AngleSource.neg_input({i})"), + } + } +} + +/// Builder for constructing gate definitions with a fluent API. +#[pyclass(name = "GateDefBuilder")] +pub struct PyGateDefBuilder { + inner: Option, +} + +#[pymethods] +impl PyGateDefBuilder { + /// Set the number of angle parameters this gate accepts. + fn angle_arity(slf: Py, py: Python<'_>, arity: usize) -> Py { + let mut this = slf.borrow_mut(py); + let builder = this.inner.take().expect("Builder already consumed"); + this.inner = Some(builder.angle_arity(arity)); + drop(this); + slf + } + + /// Add a non-parameterized gate step to the decomposition. + fn step( + slf: Py, + py: Python<'_>, + gate_name: &str, + qubit_indices: Vec, + ) -> PyResult> { + let gate_type = parse_gate_type(gate_name)?; + let mut this = slf.borrow_mut(py); + let builder = this.inner.take().expect("Builder already consumed"); + this.inner = Some(builder.step(gate_type, &qubit_indices)); + drop(this); + Ok(slf) + } + + /// Add a parameterized gate step to the decomposition. + fn step_with_angles( + slf: Py, + py: Python<'_>, + gate_name: &str, + qubit_indices: Vec, + angle_sources: Vec, + ) -> PyResult> { + let gate_type = parse_gate_type(gate_name)?; + let sources: Vec = angle_sources.into_iter().map(|s| s.inner).collect(); + let mut this = slf.borrow_mut(py); + let builder = this.inner.take().expect("Builder already consumed"); + this.inner = Some(builder.step_with_angles(gate_type, &qubit_indices, &sources)); + drop(this); + Ok(slf) + } + + /// Add a gate step with angles and per-step metadata. + /// + /// Metadata values can be str, int, float, or bool. + fn step_with_metadata( + slf: Py, + py: Python<'_>, + gate_name: &str, + qubit_indices: Vec, + angle_sources: Vec, + metadata: &Bound<'_, PyDict>, + ) -> PyResult> { + let gate_type = parse_gate_type(gate_name)?; + let sources: Vec = angle_sources.into_iter().map(|s| s.inner).collect(); + let meta = py_dict_to_value_map(metadata)?; + let mut this = slf.borrow_mut(py); + let builder = this.inner.take().expect("Builder already consumed"); + this.inner = Some(builder.step_with_metadata(gate_type, &qubit_indices, &sources, meta)); + drop(this); + Ok(slf) + } + + /// Finalize and register this gate definition into a registry. + fn register_into(&mut self, registry: &mut PyGateRegistry) -> PyResult<()> { + let builder = self + .inner + .take() + .ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("Builder already consumed"))?; + registry.inner.register(builder.build()); + Ok(()) + } +} + +/// Registry mapping gate names to definitions with decompositions. +#[pyclass(name = "GateRegistry")] +pub struct PyGateRegistry { + pub(crate) inner: GateRegistry, +} + +#[pymethods] +impl PyGateRegistry { + #[new] + fn new() -> Self { + Self { + inner: GateRegistry::new(), + } + } + + /// Start building a gate definition. + fn define(&self, name: String, quantum_arity: usize) -> PyGateDefBuilder { + PyGateDefBuilder { + inner: Some(GateDefinitionBuilder::new(name, quantum_arity)), + } + } + + /// Check if a gate is registered. + fn contains(&self, name: &str) -> bool { + self.inner.contains(name) + } + + fn __len__(&self) -> usize { + self.inner.len() + } + + /// Decompose a registered gate into concrete steps. + /// + /// Returns a list of (`gate_name`, qubits, angles, metadata) tuples, or None if + /// the gate is not registered or has no decomposition. + fn decompose( + &self, + py: Python<'_>, + name: &str, + qubits: Vec, + angles: Vec, + ) -> PyResult> { + let qubit_ids: Vec = qubits.into_iter().map(QubitId::from).collect(); + let angle_vals: Vec = angles.into_iter().map(Angle64::from_turns).collect(); + + match self.inner.decompose(name, &qubit_ids, &angle_vals) { + None => Ok(py.None()), + Some(steps) => { + let result = PyList::empty(py); + for (gate_type, step_qubits, step_angles, step_meta) in steps { + let gate_name = format!("{gate_type}"); + let py_qubits: Vec = + step_qubits.iter().map(|q| usize::from(*q)).collect(); + let py_angles: Vec = step_angles + .iter() + .map(|a| a.to_radians() / std::f64::consts::TAU) + .collect(); + let py_meta = PyDict::new(py); + for (k, v) in &step_meta { + py_meta.set_item(k, value_to_py(py, v)?)?; + } + result.append((gate_name, py_qubits, py_angles, py_meta))?; + } + Ok(result.unbind().into_any()) + } + } + } +} + +/// Register gate registry types into a Python module. +pub fn register_gate_registry_types(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index 9ca833a8c..0f796da1c 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -40,6 +40,7 @@ mod pecos_rng_bindings; mod phir_json_bridge; // mod qir_bindings; // Removed - replaced by llvm_bindings mod engines_module; +mod gate_registry_bindings; mod llvm_bindings; mod programs_module; mod quest_bindings; @@ -268,6 +269,9 @@ fn pecos_rslib(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { // Register quantum circuit types (DagCircuit, Gate, GateType, QubitId) dag_circuit_bindings::register_quantum_circuit_types(m)?; + // Register gate registry types (GateRegistry, GateDefBuilder, AngleSource) + gate_registry_bindings::register_gate_registry_types(m)?; + // Register time unit types at top level (Nanoseconds, TimeUnits) dag_circuit_bindings::register_time_unit_types(m)?; diff --git a/python/pecos-rslib/src/types_module.rs b/python/pecos-rslib/src/types_module.rs index 7c198d02b..d19dfd7a0 100644 --- a/python/pecos-rslib/src/types_module.rs +++ b/python/pecos-rslib/src/types_module.rs @@ -60,6 +60,11 @@ pub fn register_types_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { types.add("ByteMessage", parent.getattr("ByteMessage")?)?; types.add("ByteMessageBuilder", parent.getattr("ByteMessageBuilder")?)?; + // Gate registry types + types.add("GateRegistry", parent.getattr("GateRegistry")?)?; + types.add("GateDefBuilder", parent.getattr("GateDefBuilder")?)?; + types.add("AngleSource", parent.getattr("AngleSource")?)?; + // Foreign object types (conditionally compiled) #[cfg(feature = "wasm")] types.add("WasmForeignObject", parent.getattr("WasmForeignObject")?)?; diff --git a/python/quantum-pecos/src/pecos/__init__.py b/python/quantum-pecos/src/pecos/__init__.py index c95f6746d..98dc10357 100644 --- a/python/quantum-pecos/src/pecos/__init__.py +++ b/python/quantum-pecos/src/pecos/__init__.py @@ -32,8 +32,11 @@ import pecos_rslib from pecos_rslib import ( + AngleSource, # Angle source specification for gate decomposition Array, # Array type with generic dtype support (Array[f64], etc.) BitInt, # Fixed-width binary integer type + GateRegistry, # Gate registration system for custom gate decomposition + GateSignatureMismatchError, # Raised when custom gate arity mismatches Nanoseconds, # Time duration in nanoseconds Pauli, # Quantum Pauli operators (I, X, Y, Z) PauliString, # Multi-qubit Pauli operators @@ -249,6 +252,7 @@ "NUMERIC_TYPES", "SIGNED_INTEGER_TYPES", "UNSIGNED_INTEGER_TYPES", + "AngleSource", # Core types "Array", # Deprecated @@ -257,6 +261,8 @@ # Type categories "Complex", "Float", + "GateRegistry", + "GateSignatureMismatchError", "GeneralNoiseModelBuilder", # Program wrapper classes for sim() - also available via pecos.programs "Guppy", diff --git a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py index 2bad7d6c9..63b2d9b5a 100644 --- a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py +++ b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py @@ -190,6 +190,7 @@ class QuantumCircuit(MutableSequence): def __init__( self, circuit_setup: CircuitSetup = None, + gate_registry: object | None = None, **metadata: JSONValue, ) -> None: """Initialize a QuantumCircuit. @@ -197,6 +198,7 @@ def __init__( Args: circuit_setup (None, int, list of dict): Initial circuit configuration. Can be None (empty circuit), int (number of initial ticks), or list of dicts (pre-configured ticks). + gate_registry: Optional GateRegistry for ahead-of-time custom gate signature validation. **metadata: Additional metadata to associate with the circuit as keyword arguments. """ if TickCircuit is None: @@ -209,6 +211,10 @@ def __init__( # Track logically reserved ticks (for backwards compatibility with empty tick creation) self._reserved_ticks = 0 + if gate_registry is not None: + self._inner.import_registry(gate_registry) + self._gate_registry = gate_registry + if "tracked_qudits" in metadata: msg = "tracked_qudits is not a valid metadata key" raise ValueError(msg) @@ -259,6 +265,9 @@ def _add_gate_to_tick( # Convert locations to list, filtering out None values (placeholders for logical gates) loc_list = [loc for loc in locations if loc is not None] if not loc_list: + # No qubit operands -- store symbol as tick-level metadata + # (e.g., global barriers or marker gates) + tick_handle.meta("_symbol", symbol) return # Serialize params for storage (handle tuples -> lists) @@ -492,36 +501,17 @@ def add_with_symbol( else: add_with_symbol(method, loc) else: - # Store unrecognized gates using a no-op gate with metadata - # This allows round-trip preservation for simulator-specific gates - # Use I gate (identity) as carrier for unknown single-qubit gates + # Store unrecognized gates using validated custom_gate method. + # First use of a name establishes its signature; subsequent uses are validated. + angles = self._extract_angles(params) for loc in loc_list: - if isinstance(loc, tuple): - if len(loc) == 2: - # Two-qubit gate - use CX as carrier - result = tick_handle.cx(loc[0], loc[1]) - if hasattr(result, "meta"): - result.meta("_symbol", symbol) - result.meta("_custom_gate", "true") - if params_json: - result.meta("_params", params_json) - else: - # Multi-qubit locations as individual qubits - for q in loc: - result = tick_handle.i(q) - if hasattr(result, "meta"): - result.meta("_symbol", symbol) - result.meta("_custom_gate", "true") - if params_json: - result.meta("_params", params_json) - else: - # Single-qubit gate - use I (identity) as carrier - result = tick_handle.i(loc) - if hasattr(result, "meta"): - result.meta("_symbol", symbol) - result.meta("_custom_gate", "true") - if params_json: - result.meta("_params", params_json) + qubits = list(loc) if isinstance(loc, tuple) else [loc] + try: + result = tick_handle.custom_gate(symbol, qubits, angles if angles else None) + except QubitConflictError: + continue + if hasattr(result, "meta") and params_json: + result.meta("_params", params_json) def append( self, @@ -543,13 +533,12 @@ def append( tick_handle = self._inner.tick() for gate_symbol, gate_locations in gate_dict.items(): - if gate_locations: - self._add_gate_to_tick( - tick_handle, - gate_symbol, - gate_locations, - **params, - ) + self._add_gate_to_tick( + tick_handle, + gate_symbol, + gate_locations, + **params, + ) def update( self, @@ -575,8 +564,6 @@ def update( # Get logical and physical tick counts logical_ticks = len(self) # includes reserved ticks - # Use next_tick_index() to get actual tick count including empty ticks - # (num_ticks() excludes trailing empty ticks which breaks reserved ticks) physical_ticks = self._inner.next_tick_index() # Handle empty circuit case with negative tick index @@ -595,13 +582,12 @@ def update( tick_handle = self._inner.tick() if actual_tick >= physical_ticks else self._inner.tick_at(actual_tick) for gate_symbol, gate_locations in gate_dict.items(): - if gate_locations: - self._add_gate_to_tick( - tick_handle, - gate_symbol, - gate_locations, - **params, - ) + self._add_gate_to_tick( + tick_handle, + gate_symbol, + gate_locations, + **params, + ) def discard(self, locations: LocationSet, tick: int = -1) -> None: """Discards ``locations`` for tick ``tick``. @@ -759,6 +745,13 @@ def _iter_tick( # Create new group grouped[key] = ({location}, params) + # Handle ticks with no gates but a tick-level symbol (e.g., global barriers) + if not grouped: + tick_symbol = tick_obj.get_attr("_symbol") + if tick_symbol is not None: + yield tick_symbol, set(), {} + return + # Yield grouped results for (symbol, _), (locations, params) in grouped.items(): yield symbol, locations, params @@ -791,13 +784,12 @@ def insert( tick_handle = self._inner.insert_tick(tick) for gate_symbol, gate_locations in gate_dict.items(): - if gate_locations: - self._add_gate_to_tick( - tick_handle, - gate_symbol, - gate_locations, - **params, - ) + self._add_gate_to_tick( + tick_handle, + gate_symbol, + gate_locations, + **params, + ) def _circuit_setup(self, circuit_setup: CircuitSetup) -> None: if isinstance(circuit_setup, int): @@ -834,6 +826,17 @@ def to_json_str(self) -> str: return json.dumps(prog) + @staticmethod + def _extract_angles(params: dict) -> list[float]: + """Extract angle values from gate parameters.""" + if not params: + return [] + if "angles" in params: + return list(params["angles"]) + if "angle" in params: + return [params["angle"]] + return [] + @staticmethod def _fix_json_meta(meta: JSONDict) -> JSONDict: """Fix some of the type issues for converting json rep back to a QuantumCircuit.""" @@ -894,13 +897,12 @@ def __setitem__(self, tick: int, item: tuple[GateDict, JSONDict]) -> None: # Add new gates tick_handle = self._inner.tick_at(actual_tick) for gate_symbol, gate_locations in gate_dict.items(): - if gate_locations: - self._add_gate_to_tick( - tick_handle, - gate_symbol, - gate_locations, - **params, - ) + self._add_gate_to_tick( + tick_handle, + gate_symbol, + gate_locations, + **params, + ) def __len__(self) -> int: """Used to return number of ticks when len() is used on an instance of this class.""" @@ -1055,13 +1057,12 @@ def add( if gate_dict: tick_handle = self._circuit._inner.tick_at(self._tick_idx) for gate_symbol, gate_locations in gate_dict.items(): - if gate_locations: - self._circuit._add_gate_to_tick( - tick_handle, - gate_symbol, - gate_locations, - **params, - ) + self._circuit._add_gate_to_tick( + tick_handle, + gate_symbol, + gate_locations, + **params, + ) return self diff --git a/python/quantum-pecos/src/pecos/simulators/default_simulator.py b/python/quantum-pecos/src/pecos/simulators/default_simulator.py index 27109e3f4..ab62163a5 100644 --- a/python/quantum-pecos/src/pecos/simulators/default_simulator.py +++ b/python/quantum-pecos/src/pecos/simulators/default_simulator.py @@ -21,6 +21,8 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from pecos_rslib import GateRegistry + from pecos.circuits import QuantumCircuit JSONType = dict[str, Any] | list[Any] | str | int | float | bool | None @@ -82,12 +84,17 @@ def run_circuit( self, circuit: QuantumCircuit, removed_locations: set | None = None, + gate_registry: GateRegistry | None = None, ) -> dict[int | tuple[int, ...], JSONType]: """Run a quantum circuit on the simulator. + If a gate_registry is provided and a gate symbol is registered in it, + the gate is decomposed into base gates before being passed to run_gate. + Args: circuit (QuantumCircuit): A circuit instance or object with an appropriate items() generator. removed_locations (set | None): Optional set of locations to skip when running the circuit. + gate_registry: Optional GateRegistry for custom gate decomposition at simulation time. Returns: dict[int | tuple[int, ...], JSONType]: Circuit output. Note that this output format may differ @@ -102,9 +109,41 @@ def run_circuit( gate_locations = set(locations) - removed_locations # TODO: need to handle multi-qubit ops that are partially removed - gate_output = self.run_gate(symbol, gate_locations, **params) + if gate_registry and symbol not in self.bindings and gate_registry.contains(symbol): + gate_output = self._run_decomposed_gate(gate_registry, symbol, gate_locations, **params) + else: + gate_output = self.run_gate(symbol, gate_locations, **params) if gate_output: output.update(gate_output) return output + + def _run_decomposed_gate( + self, + gate_registry: GateRegistry, + symbol: str, + locations: set[int] | set[tuple[int, ...]], + **params: JSONType, + ) -> dict[int | tuple[int, ...], JSONType]: + """Decompose a registered gate and run each step via run_gate.""" + output = {} + for location in locations: + qubits = list(location) if isinstance(location, tuple) else [location] + angles = list(params.get("angles", ())) + steps = gate_registry.decompose(symbol, qubits, angles) + for step_symbol, step_qubits, step_angles, step_meta in steps: + step_loc = {step_qubits[0] if len(step_qubits) == 1 else tuple(step_qubits)} + step_params = dict(params) + if step_angles: + step_params["angles"] = tuple(step_angles) + if len(step_angles) == 1: + step_params["angle"] = step_angles[0] + else: + step_params.pop("angles", None) + step_params.pop("angle", None) + step_params.update(step_meta) + step_output = self.run_gate(step_symbol, step_loc, **step_params) + if step_output: + output.update(step_output) + return output diff --git a/python/quantum-pecos/src/pecos/simulators/sim_class_types.py b/python/quantum-pecos/src/pecos/simulators/sim_class_types.py index d3f83cc41..db3a8edf0 100644 --- a/python/quantum-pecos/src/pecos/simulators/sim_class_types.py +++ b/python/quantum-pecos/src/pecos/simulators/sim_class_types.py @@ -22,70 +22,22 @@ class PauliPropagation(DefaultSimulator): """Base class for Pauli-propagation simulators.""" - def __init__(self) -> None: - """Initialize the PauliPropagation simulator. - - Initializes the base DefaultSimulator and sets up bindings for - Pauli propagation simulation. - """ - super().__init__() - class Stabilizer(DefaultSimulator): """Base class for stabilizer simulators.""" - def __init__(self) -> None: - """Initialize the Stabilizer simulator. - - Initializes the base DefaultSimulator and sets up bindings for - stabilizer state simulation. - """ - super().__init__() - class StateVector(DefaultSimulator): """Base class for state-vector simulators.""" - def __init__(self) -> None: - """Initialize the StateVector simulator. - - Initializes the base DefaultSimulator and sets up bindings for - state vector simulation. - """ - super().__init__() - class StateTN(DefaultSimulator): """Base class for simulators whose state is represented as a tensor network.""" - def __init__(self) -> None: - """Initialize the StateTN simulator. - - Initializes the base DefaultSimulator and sets up bindings for - tensor network state simulation. - """ - super().__init__() - class DensityMatrix(DefaultSimulator): """Base class for density-matrix simulators.""" - def __init__(self) -> None: - """Initialize the DensityMatrix simulator. - - Initializes the base DefaultSimulator and sets up bindings for - density matrix simulation. - """ - super().__init__() - class ProcessMatrix(DefaultSimulator): """Base class for process-matrix simulators.""" - - def __init__(self) -> None: - """Initialize the ProcessMatrix simulator. - - Initializes the base DefaultSimulator and sets up bindings for - process matrix simulation. - """ - super().__init__() diff --git a/uv.lock b/uv.lock index 301f5e471..b731b7ac5 100644 --- a/uv.lock +++ b/uv.lock @@ -3891,27 +3891,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/3b/20d9a0bc954d51b63f20cf710cf506bfe675d1e6138139342dd5ccc90326/ruff-0.15.3.tar.gz", hash = "sha256:78757853320d8ddb9da24e614ef69a37bcbcfd477e5a6435681188d4bce4eaa1", size = 4569031, upload-time = "2026-02-26T15:39:38.015Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/00/c544ab1d70f86dc50a2f2a8e1262e5af5025897ccd820415f559f9f2f63f/ruff-0.15.3-py3-none-linux_armv6l.whl", hash = "sha256:f7df0fd6f889a8d8de2ddb48a9eb55150954400f2157ea15b21a2f49ecaaf988", size = 10444066, upload-time = "2026-02-26T15:39:47.708Z" }, - { url = "https://files.pythonhosted.org/packages/fb/15/9dee3f4e891261adbd690f8c6f075418a7cd76e845601b00a0da2ae2ad6e/ruff-0.15.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0198b5445197d443c3bbf2cc358f4bd477fb3951e3c7f2babc13e9bb490614a8", size = 10853125, upload-time = "2026-02-26T15:40:18.943Z" }, - { url = "https://files.pythonhosted.org/packages/88/ba/fc5aeda852c89faf821d36c951df866117342e88439e1b1e1e762a07b7fd/ruff-0.15.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:adf95b5be57b25fbbbc07cd68d37414bee8729e807ad0217219558027186967e", size = 10180833, upload-time = "2026-02-26T15:40:13.282Z" }, - { url = "https://files.pythonhosted.org/packages/06/87/e2f80a39164476fac4d45752a0d4721d6645f40b7f851e48add12af9947e/ruff-0.15.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b56dbd9cd86489ccbad96bb58fa4c958342b5510fdeb60ea13d9d3566bd845c", size = 10536806, upload-time = "2026-02-26T15:40:24.129Z" }, - { url = "https://files.pythonhosted.org/packages/fd/89/2e5bf0ed30ea3778460ea4d8cc6cb4d88ba96d9732d2c0cc33349cd65196/ruff-0.15.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6f263ce511871955d8c5401b62c7e863988ea4d0527aa0a3b1b7ecff4d4abc4", size = 10276093, upload-time = "2026-02-26T15:39:44.654Z" }, - { url = "https://files.pythonhosted.org/packages/82/cb/318206d778c7f42917ca7b0f9436cf27652d1731fe434d3c9990c4a611fa/ruff-0.15.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e90fa1bed82ffede5768232b9bd23212c547ab7cd74c752007ecade1d895ee1a", size = 11051593, upload-time = "2026-02-26T15:39:35.157Z" }, - { url = "https://files.pythonhosted.org/packages/58/8f/65ee4c1b88e49dd4c0a3fc43e81832536c7942f0c702b6f3d25db0f95d6c/ruff-0.15.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e9d53760b7061ddbe5ea9e25381332c607fc14c40bde78f8a25392a93a68d74", size = 11885820, upload-time = "2026-02-26T15:39:59.504Z" }, - { url = "https://files.pythonhosted.org/packages/db/04/d4261f6729ad9a356bc6e3223ba297acf3b66118cef4795b4a8953b255ff/ruff-0.15.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec90e3b78c56c4acca4264d371dd48e29215ecb673cc2fa3c4b799b72050e491", size = 11340583, upload-time = "2026-02-26T15:39:50.781Z" }, - { url = "https://files.pythonhosted.org/packages/24/84/490f38b2bc104e0fdc9496c2a66a48fb2d24a01de46ba0c60c4f6c4d4590/ruff-0.15.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ce448fd395f822e34c8f6f7dfcd84b6726340082950858f92c4daa6baf8915", size = 11160701, upload-time = "2026-02-26T15:40:02.447Z" }, - { url = "https://files.pythonhosted.org/packages/ad/25/eae9cb7b6c28b425ed8cbe797da89c78146071102181ba74c4cdfd06bbeb/ruff-0.15.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14f7d763962d385f75b9b3b57fcc5661c56c20d8b1ddc9f5c881b5fa0ba499fa", size = 11111482, upload-time = "2026-02-26T15:39:56.462Z" }, - { url = "https://files.pythonhosted.org/packages/95/18/16d0b5ef143cb9e52724f18cbccb4b3c5cd4d4e2debbd95e2be3aeb64c9e/ruff-0.15.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b57084e3a3d65418d376c7023711c37cce023cd2fb038a76ba15ee21f3c2c2ee", size = 10497151, upload-time = "2026-02-26T15:40:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b4/1829314241ddba07c54a742ab387da343fe56a0267a6b6498f3e2ae99821/ruff-0.15.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d567523ff7dcf3112b0f71231d18c3506dd06943359476ee64dea0f9c8f63976", size = 10281955, upload-time = "2026-02-26T15:40:16.033Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/80a4ec4bd3cf58ca9b49dccf2bd232b520db14184912fb7e0eb6f3ecc484/ruff-0.15.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4223088d255bf31a50b6640445b39f668164d64c23e5fa403edfb1e0b11122e5", size = 10766613, upload-time = "2026-02-26T15:40:21.55Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/fe016b862295dc57499997e7f2edc58119469b210f4f03ccb763fa65f130/ruff-0.15.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32399ddae088970b2db6efd8d3f49981375cb828075359b6c088ed1fe63d64e1", size = 11262113, upload-time = "2026-02-26T15:39:41.5Z" }, - { url = "https://files.pythonhosted.org/packages/42/b1/77dcd05940388d9ba3de03ac4b8b598826d57935728071e1be9f2ef5b714/ruff-0.15.3-py3-none-win32.whl", hash = "sha256:1f1eb95ff614351e3a89a862b6d94e6c42c170e61916e1f20facd6c38477f5f3", size = 10509423, upload-time = "2026-02-26T15:40:05.217Z" }, - { url = "https://files.pythonhosted.org/packages/29/d5/76aab0fabbd54e8c77d02fcff2494906ba85b539d22aa9b7124f7100f008/ruff-0.15.3-py3-none-win_amd64.whl", hash = "sha256:2b22dffe5f5e1e537097aa5208684f069e495f980379c4491b1cfb198a444d0c", size = 11637739, upload-time = "2026-02-26T15:39:53.951Z" }, - { url = "https://files.pythonhosted.org/packages/f2/61/9b4e3682dfd26054321e1b2fdb67a51361dd6ec2fb63f2b50d711f8832ae/ruff-0.15.3-py3-none-win_arm64.whl", hash = "sha256:82443c14d694d4cbd9e598ede27ef5d6f08389ccad91c933be775ea2f4e66f76", size = 10957794, upload-time = "2026-02-26T15:40:08.045Z" }, +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] [[package]] @@ -4449,7 +4449,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.0.0" +version = "21.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -4458,9 +4458,9 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/4f/d6a5ff3b020c801c808b14e2d2330cdc8ebefe1cdfbc457ecc368e971fec/virtualenv-21.0.0.tar.gz", hash = "sha256:e8efe4271b4a5efe7a4dce9d60a05fd11859406c0d6aa8464f4cf451bc132889", size = 5836591, upload-time = "2026-02-25T20:21:07.691Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471, upload-time = "2026-02-27T08:49:29.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/d1/3f62e4f9577b28c352c11623a03fb916096d5c131303d4861b4914481b6b/virtualenv-21.0.0-py3-none-any.whl", hash = "sha256:d44e70637402c7f4b10f48491c02a6397a3a187152a70cba0b6bc7642d69fb05", size = 5817167, upload-time = "2026-02-25T20:21:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" }, ] [[package]] From 7368da1a4f4b710846251a30028b16f618a2a253 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 28 Feb 2026 12:37:53 -0700 Subject: [PATCH 02/12] Updating dependencies --- Cargo.lock | 201 +++++++++++--------------- Cargo.toml | 4 +- crates/benchmarks/Cargo.toml | 8 +- crates/pecos-cuquantum-sys/Cargo.toml | 2 +- crates/pecos-cuquantum/Cargo.toml | 14 +- crates/pecos-qec/Cargo.toml | 10 +- python/pecos-rslib/Cargo.toml | 2 +- uv.lock | 32 ++-- 8 files changed, 125 insertions(+), 148 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c6f85b994..ff51170f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.25.1" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +checksum = "9698bf0769c641b18618039fe2ebd41eb3541f98433000f64e663fab7cea2c87" dependencies = [ "gimli", ] @@ -866,46 +866,47 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a04121a197fde2fe896f8e7cac9812fc41ed6ee9c63e1906090f9f497845f6" +checksum = "40630d663279bc855bff805d6f5e8a0b6a1867f9df95b010511ac6dc894e9395" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a09e699a94f477303820fb2167024f091543d6240783a2d3b01a3f21c42bc744" +checksum = "3ee6aec5ceb55e5fdbcf7ef677d7c7195531360ff181ce39b2b31df11d57305f" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f07732c662a9755529e332d86f8c5842171f6e98ba4d5976a178043dad838654" +checksum = "9a92d78cc3f087d7e7073828f08d98c7074a3a062b6b29a1b7783ce74305685e" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18391da761cf362a06def7a7cf11474d79e55801dd34c2e9ba105b33dc0aef88" +checksum = "edcc73d756f2e0d7eda6144fe64a2bc69c624de893cb1be51f1442aed77881d2" dependencies = [ "serde", "serde_derive", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b3a09b3042c69810d255aef59ddc3b3e4c0644d1d90ecfd6e3837798cc88a3c" +checksum = "683d94c2cd0d73b41369b88da1129589bc3a2d99cf49979af1d14751f35b7a1b" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -918,6 +919,7 @@ dependencies = [ "cranelift-isle", "gimli", "hashbrown 0.15.5", + "libm", "log", "pulley-interpreter", "regalloc2", @@ -925,14 +927,14 @@ dependencies = [ "serde", "smallvec", "target-lexicon", - "wasmtime-internal-math", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen-meta" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75817926ec812241889208d1b190cadb7fedded4592a4bb01b8524babb9e4849" +checksum = "235da0e52ee3a0052d0e944c3470ff025b1f4234f6ec4089d3109f2d2ffa6cbd" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -943,35 +945,36 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "859158f87a59476476eda3884d883c32e08a143cf3d315095533b362a3250a63" +checksum = "20c07c6c440bd1bf920ff7597a1e743ede1f68dcd400730bd6d389effa7662af" [[package]] name = "cranelift-control" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b65a9aec442d715cbf54d14548b8f395476c09cef7abe03e104a378291ab88" +checksum = "8797c022e02521901e1aee483dea3ed3c67f2bf0a26405c9dd48e8ee7a70944b" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8334c99a7e86060c24028732efd23bac84585770dcb752329c69f135d64f2fc1" +checksum = "59d8e72637246edd2cba337939850caa8b201f6315925ec4c156fdd089999699" dependencies = [ "cranelift-bitset", "serde", "serde_derive", + "wasmtime-internal-core", ] [[package]] name = "cranelift-frontend" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43ac6c095aa5b3e845d7ca3461e67e2b65249eb5401477a5ff9100369b745111" +checksum = "4c31db0085c3dfa131e739c3b26f9f9c84d69a9459627aac1ac4ef8355e3411b" dependencies = [ "cranelift-codegen", "log", @@ -981,15 +984,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d3d992870ed4f0f2e82e2175275cb3a123a46e9660c6558c46417b822c91fa" +checksum = "524d804c1ebd8c542e6f64e71aa36934cec17c5da4a9ae3799796220317f5d23" [[package]] name = "cranelift-native" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee32e36beaf80f309edb535274cfe0349e1c5cf5799ba2d9f42e828285c6b52e" +checksum = "dc9598f02540e382e1772416eba18e93c5275b746adbbf06ac1f3cf149415270" dependencies = [ "cranelift-codegen", "libc", @@ -998,9 +1001,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.128.4" +version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "903adeaf4938e60209a97b53a2e4326cd2d356aab9764a1934630204bae381c9" +checksum = "d953932541249c91e3fa70a75ff1e52adc62979a2a8132145d4b9b3e6d1a9b6a" [[package]] name = "crc" @@ -1517,12 +1520,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - [[package]] name = "fastrand" version = "2.3.0" @@ -1815,11 +1812,12 @@ dependencies = [ [[package]] name = "gimli" -version = "0.32.3" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" dependencies = [ - "fallible-iterator", + "fnv", + "hashbrown 0.16.1", "indexmap 2.13.0", "stable_deref_trait", ] @@ -2717,12 +2715,13 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "bitflags", "libc", + "plain", "redox_syscall 0.7.3", ] @@ -3973,6 +3972,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plotters" version = "0.3.7" @@ -4188,21 +4193,21 @@ checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" [[package]] name = "pulley-interpreter" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9812652c1feb63cf39f8780cecac154a32b22b3665806c733cd4072547233a4" +checksum = "bc2d61e068654529dc196437f8df0981db93687fdc67dec6a5de92363120b9da" dependencies = [ "cranelift-bitset", "log", "pulley-macros", - "wasmtime-internal-math", + "wasmtime-internal-core", ] [[package]] name = "pulley-macros" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56000349b6896e3d44286eb9c330891237f40b27fd43c1ccc84547d0b463cb40" +checksum = "c3f210c61b6ecfaebbba806b6d9113a222519d4e5cc4ab2d5ecca047bb7927ae" dependencies = [ "proc-macro2", "quote", @@ -6051,16 +6056,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55db9c896d70bd9fa535ce83cd4e1f2ec3726b0edd2142079f594fc3be1cb35" -dependencies = [ - "leb128fmt", - "wasmparser 0.243.0", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -6093,19 +6088,6 @@ dependencies = [ "wasmparser 0.244.0", ] -[[package]] -name = "wasmparser" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d8db401b0528ec316dfbe579e6ab4152d61739cfe076706d2009127970159d" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", - "serde", -] - [[package]] name = "wasmparser" version = "0.244.0" @@ -6116,6 +6098,7 @@ dependencies = [ "hashbrown 0.15.5", "indexmap 2.13.0", "semver", + "serde", ] [[package]] @@ -6131,30 +6114,27 @@ dependencies = [ [[package]] name = "wasmprinter" -version = "0.243.0" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2b6035559e146114c29a909a3232928ee488d6507a1504d8934e8607b36d7b" +checksum = "09390d7b2bd7b938e563e4bff10aa345ef2e27a3bc99135697514ef54495e68f" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.243.0", + "wasmparser 0.244.0", ] [[package]] name = "wasmtime" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2a83182bf04af87571b4c642300479501684f26bab5597f68f68cded5b098fd" +checksum = "39bef52be4fb4c5b47d36f847172e896bc94b35c9c6a6f07117686bd16ed89a7" dependencies = [ "addr2line", - "anyhow", "async-trait", "bitflags", "bumpalo", "cc", "cfg-if", - "hashbrown 0.15.5", - "indexmap 2.13.0", "libc", "log", "mach2", @@ -6168,14 +6148,13 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon", - "wasmparser 0.243.0", + "wasmparser 0.244.0", "wasmtime-environ", + "wasmtime-internal-core", "wasmtime-internal-cranelift", "wasmtime-internal-fiber", "wasmtime-internal-jit-debug", "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-math", - "wasmtime-internal-slab", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", "wat", @@ -6184,14 +6163,15 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb201c41aa23a3642365cfb2e4a183573d85127a3c9d528f56b9997c984541ab" +checksum = "bb637d5aa960ac391ca5a4cbf3e45807632e56beceeeb530e14dfa67fdfccc62" dependencies = [ "anyhow", "cranelift-bitset", "cranelift-entity", "gimli", + "hashbrown 0.15.5", "indexmap 2.13.0", "log", "object", @@ -6200,16 +6180,26 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", "wasmprinter", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-core" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a4a3f055a804a2f3d86e816a9df78a8fa57762212a8506164959224a40cd48" +dependencies = [ + "libm", ] [[package]] name = "wasmtime-internal-cranelift" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633e889cdae76829738db0114ab3b02fce51ea4a1cd9675a67a65fce92e8b418" +checksum = "55154a91d22ad51f9551124ce7fb49ddddc6a82c4910813db4c790c97c9ccf32" dependencies = [ "cfg-if", "cranelift-codegen", @@ -6225,18 +6215,18 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.243.0", + "wasmparser 0.244.0", "wasmtime-environ", - "wasmtime-internal-math", + "wasmtime-internal-core", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", ] [[package]] name = "wasmtime-internal-fiber" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deb126adc5d0c72695cfb77260b357f1b81705a0f8fa30b3944e7c2219c17341" +checksum = "05decfad1021ad2efcca5c1be9855acb54b6ee7158ac4467119b30b7481508e3" dependencies = [ "cc", "cfg-if", @@ -6249,9 +6239,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e66ff7f90a8002187691ff6237ffd09f954a0ebb9de8b2ff7f5c62632134120" +checksum = "924980c50427885fd4feed2049b88380178e567768aaabf29045b02eb262eaa7" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -6259,36 +6249,21 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b96df23179ae16d54fb3a420f84ffe4383ec9dd06fad3e5bc782f85f66e8e08" +checksum = "c57d24e8d1334a0e5a8b600286ffefa1fc4c3e8176b110dff6fbc1f43c4a599b" dependencies = [ - "anyhow", "cfg-if", "libc", + "wasmtime-internal-core", "windows-sys 0.61.2", ] -[[package]] -name = "wasmtime-internal-math" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d1380926682b44c383e9a67f47e7a95e60c6d3fa8c072294dab2c7de6168a0" -dependencies = [ - "libm", -] - -[[package]] -name = "wasmtime-internal-slab" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b63cbea1c0192c7feb7c0dfb35f47166988a3742f29f46b585ef57246c65764" - [[package]] name = "wasmtime-internal-unwinder" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25c392c7e5fb891a7416e3c34cfbd148849271e8c58744fda875dde4bec4d6a" +checksum = "3a1a144bd4393593a868ba9df09f34a6a360cb5db6e71815f20d3f649c6e6735" dependencies = [ "cfg-if", "cranelift-codegen", @@ -6299,9 +6274,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "41.0.4" +version = "42.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f8b9796a3f0451a7b702508b303d654de640271ac80287176de222f187a237" +checksum = "9a6948b56bb00c62dbd205ea18a4f1ceccbe1e4b8479651fdb0bab2553790f20" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index cd9e39d68..1c097f8d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ pest = "2" pest_derive = "2" tempfile = "3" assert_cmd = "2" -wasmtime = { version = "41", default-features = false, features = [ +wasmtime = { version = "42", default-features = false, features = [ "cranelift", "runtime", "wat", @@ -99,6 +99,7 @@ bitvec = { version = "1", features = ["serde"] } ndarray = "0.17" # RNG +fastrand = "2" rand = "0.10" rand_core = "0.10" rand_xoshiro = "0.8" @@ -146,6 +147,7 @@ pecos-num = { version = "0.1.1", path = "crates/pecos-num" } pecos-quantum = { version = "0.1.1", path = "crates/pecos-quantum" } pecos-gpu-sims = { version = "0.1.1", path = "crates/pecos-gpu-sims" } pecos-cuquantum = { version = "0.1.1", path = "crates/pecos-cuquantum" } +pecos-cuquantum-sys = { version = "0.1.1", path = "crates/pecos-cuquantum-sys" } # Decoder crates pecos-decoder-core = { version = "0.1.1", path = "crates/pecos-decoder-core" } diff --git a/crates/benchmarks/Cargo.toml b/crates/benchmarks/Cargo.toml index 567e575ba..266705d9c 100644 --- a/crates/benchmarks/Cargo.toml +++ b/crates/benchmarks/Cargo.toml @@ -25,10 +25,10 @@ all-sims = ["gpu-sims", "cuquantum", "quest", "qulacs", "cppsparsesim"] [dependencies] # Optional simulator dependencies for benchmarking -pecos-gpu-sims = { path = "../pecos-gpu-sims", optional = true } -pecos-cuquantum = { path = "../pecos-cuquantum", optional = true } -pecos-quest = { path = "../pecos-quest", optional = true } -pecos-qulacs = { path = "../pecos-qulacs", optional = true } +pecos-gpu-sims = { workspace = true, optional = true } +pecos-cuquantum = { workspace = true, optional = true } +pecos-quest = { workspace = true, optional = true } +pecos-qulacs = { workspace = true, optional = true } pecos-cppsparsesim = { workspace = true, optional = true } pecos-core.workspace = true pecos-qsim.workspace = true diff --git a/crates/pecos-cuquantum-sys/Cargo.toml b/crates/pecos-cuquantum-sys/Cargo.toml index b742a6758..3c80927c2 100644 --- a/crates/pecos-cuquantum-sys/Cargo.toml +++ b/crates/pecos-cuquantum-sys/Cargo.toml @@ -15,7 +15,7 @@ readme = "README.md" [build-dependencies] bindgen.workspace = true -pecos-build = { path = "../pecos-build" } +pecos-build.workspace = true log.workspace = true env_logger.workspace = true diff --git a/crates/pecos-cuquantum/Cargo.toml b/crates/pecos-cuquantum/Cargo.toml index e1a328a13..237814c28 100644 --- a/crates/pecos-cuquantum/Cargo.toml +++ b/crates/pecos-cuquantum/Cargo.toml @@ -12,15 +12,15 @@ categories = ["science", "simulation"] readme = "README.md" [dependencies] -pecos-cuquantum-sys = { path = "../pecos-cuquantum-sys" } -pecos-build = { path = "../pecos-build" } -pecos-core = { path = "../pecos-core" } -pecos-qsim = { path = "../pecos-qsim" } -thiserror = "2.0" -fastrand = "2.3" +pecos-cuquantum-sys.workspace = true +pecos-build.workspace = true +pecos-core.workspace = true +pecos-qsim.workspace = true +thiserror.workspace = true +fastrand.workspace = true [build-dependencies] -pecos-build = { path = "../pecos-build" } +pecos-build.workspace = true log.workspace = true env_logger.workspace = true diff --git a/crates/pecos-qec/Cargo.toml b/crates/pecos-qec/Cargo.toml index 07b1d75c4..32bd01c4f 100644 --- a/crates/pecos-qec/Cargo.toml +++ b/crates/pecos-qec/Cargo.toml @@ -13,11 +13,11 @@ readme = "README.md" [dependencies] ndarray.workspace = true -pecos-core = { path = "../pecos-core" } -pecos-decoder-core = { path = "../pecos-decoder-core" } -pecos-quantum = { path = "../pecos-quantum" } -pecos-qsim = { path = "../pecos-qsim" } -pecos-rng = { path = "../pecos-rng" } +pecos-core.workspace = true +pecos-decoder-core.workspace = true +pecos-quantum.workspace = true +pecos-qsim.workspace = true +pecos-rng.workspace = true rand.workspace = true rand_core.workspace = true rayon.workspace = true diff --git a/python/pecos-rslib/Cargo.toml b/python/pecos-rslib/Cargo.toml index 37a228104..745fc2cea 100644 --- a/python/pecos-rslib/Cargo.toml +++ b/python/pecos-rslib/Cargo.toml @@ -35,7 +35,7 @@ pecos-qasm = { workspace = true, features = ["wasm"] } pyo3 = { workspace=true, features = ["extension-module", "abi3-py310", "generate-import-lib", "num-complex"] } rand.workspace = true -pecos-rng = { path = "../../crates/pecos-rng" } +pecos-rng.workspace = true ndarray.workspace = true num-complex.workspace = true parking_lot.workspace = true diff --git a/uv.lock b/uv.lock index b731b7ac5..fb525a643 100644 --- a/uv.lock +++ b/uv.lock @@ -2044,26 +2044,26 @@ wheels = [ [[package]] name = "maturin" -version = "1.12.4" +version = "1.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/a6/54e73f0ec0224488ae25196ce8b4df298cae613b099ad0c4f39dd7e3a8d2/maturin-1.12.4.tar.gz", hash = "sha256:06f6438be7e723aaf4b412fb34839854b540a1350f7614fadf5bd1db2b98d5f7", size = 262134, upload-time = "2026-02-21T10:24:25.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/cd/8285f37bf968b8485e3c7eb43349a5adbccfddfc487cd4327fb9104578cc/maturin-1.12.4-py3-none-linux_armv6l.whl", hash = "sha256:cf8a0eddef9ab8773bc823c77aed3de9a5c85fb760c86448048a79ef89794c81", size = 9758449, upload-time = "2026-02-21T10:24:35.382Z" }, - { url = "https://files.pythonhosted.org/packages/d9/91/f51191db83735f77bc988c8034730bb63b750a4a1a04f9c8cba10f44ad45/maturin-1.12.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:eba1bd1c1513d00fec75228da98622c68a9f50f9693aaa6fb7dacb244e7bbf26", size = 18938848, upload-time = "2026-02-21T10:24:10.701Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/03c422adeac93b903354b322bba632754fdb134b27ace71b5603feba5906/maturin-1.12.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:89749cfc0e6baf5517fa370729a98955552e42fefc406b95732d5c8e85bc90c0", size = 9791641, upload-time = "2026-02-21T10:24:21.72Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/dd78acf6afc48d358512b5ed928fd24e2bc6b68db69b1f6bba3ffd7bcaed/maturin-1.12.4-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:4d68664e5b81f282144a3b717a7e8593ec94ac87d7ae563a4c464e93d6cde877", size = 9811625, upload-time = "2026-02-21T10:24:08.152Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9a/a6e358a18815ab090ef55187da0066df01a955c7c44a61fb83b127055f23/maturin-1.12.4-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:88e09e6c386b08974fab0c7e4c07d7c7c50a0ba63095d31e930d80568488e1be", size = 10255812, upload-time = "2026-02-21T10:24:15.117Z" }, - { url = "https://files.pythonhosted.org/packages/4a/c5/84dfcce1f3475237cba6e6201a1939980025afbb41c076aa5147b10ac202/maturin-1.12.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5cc56481b0f360571587c35a1d960ce6d0a0258d49aebb6af98fff9db837c337", size = 9645462, upload-time = "2026-02-21T10:24:28.814Z" }, - { url = "https://files.pythonhosted.org/packages/de/82/0845fff86ea044028302db17bc611e9bfe1b7b2c992756162cbe71267df5/maturin-1.12.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8fd7eb0c9bb017e98d81aa86a1d440b912fe4f7f219571035dd6ab330c82071c", size = 9593649, upload-time = "2026-02-21T10:24:33.376Z" }, - { url = "https://files.pythonhosted.org/packages/2b/14/6e8969cd48c7c8ea27d7638e572d46eeba9aa0cb370d3031eb6a3f10ff8d/maturin-1.12.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:5bb07c349dd066277a61e017a6d6e0860cd54b7b33f8ead10b9e5a4ffb740a0a", size = 12681515, upload-time = "2026-02-21T10:24:31.097Z" }, - { url = "https://files.pythonhosted.org/packages/ac/8d/2ad86623dca3cfa394049f4220188dececa6e4cefd73ac1f1385fc79c876/maturin-1.12.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c21baaed066b5bec893db2d261bfe3b9da054d99c018326f0bdcf1dc4c3a1eb9", size = 10448453, upload-time = "2026-02-21T10:24:26.827Z" }, - { url = "https://files.pythonhosted.org/packages/9c/eb/c66e2d3272e74dd590ae81bb51590bd98c3cd4e3f6629d4e4218bd6a5c28/maturin-1.12.4-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:939c4c57efa8ea982a991ee3ccb3992364622e9cbd1ede922b5cfb0f652bf517", size = 9970879, upload-time = "2026-02-21T10:24:12.881Z" }, - { url = "https://files.pythonhosted.org/packages/38/a0/998f8063d67fa19639179af7e8ea46016ceaa12f85b9720a2e4846449f43/maturin-1.12.4-py3-none-win32.whl", hash = "sha256:d72f626616292cb3e283941f47835ffc608207ebd8f95f4c50523a6631ffcb2e", size = 8518146, upload-time = "2026-02-21T10:24:17.296Z" }, - { url = "https://files.pythonhosted.org/packages/69/14/6ceea315db6e47093442ec70c2d01bb011d69f5243de5fc0e6a5fab97513/maturin-1.12.4-py3-none-win_amd64.whl", hash = "sha256:ab32c5ff7579a549421cae03e6297d3b03d7b81fa2934e3bdf24a102d99eb378", size = 9863686, upload-time = "2026-02-21T10:24:19.35Z" }, - { url = "https://files.pythonhosted.org/packages/d4/28/73e14739c6f7605ff9b9d108726d3ff529d4f91a7838739b4dd0afd33ec1/maturin-1.12.4-py3-none-win_arm64.whl", hash = "sha256:b8c05d24209af50ed9ae9e5de473c84866b9676c637fcfad123ee57f4a9ed098", size = 8557843, upload-time = "2026-02-21T10:24:23.894Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a4/15/4c41c4c951718f8c17ed1621b7999afb8d72d69c731c987b47e3c138d4ad/maturin-1.12.5.tar.gz", hash = "sha256:236943b7aff3e85ccd2b7a35ca10b64595f2c169bbb605e349e54534ff561a15", size = 267392, upload-time = "2026-02-28T12:18:14.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/9e/4b910f5e0a46d7ab0050b58eecd2eaf4e0df8665982a0c5925371d4b0593/maturin-1.12.5-py3-none-linux_armv6l.whl", hash = "sha256:e5945534107439cf4f3734f195bc54f56515f5d465e96041f4866a2f15605ee8", size = 9793120, upload-time = "2026-02-28T12:18:19.707Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/d670dfd96dc331d4a7cedeee49705f0dc5b5d0d6e78d46b92ada2825711c/maturin-1.12.5-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:74bc126c4d4606cd526aedc993de320b1529c2659b4d3f4029a824c96ef39b92", size = 19018577, upload-time = "2026-02-28T12:18:39.779Z" }, + { url = "https://files.pythonhosted.org/packages/e2/86/91d829eb28f2d21f001df29b98f73b017e3adfaa49a3b4b2666ffcf7c12f/maturin-1.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:98e49546bff6319c3c59b22f9de43161fa09d3a756dc2f04829ea22ed00e2ed6", size = 9838517, upload-time = "2026-02-28T12:18:22.916Z" }, + { url = "https://files.pythonhosted.org/packages/bb/37/9a17341679710b79c530557703eb1fd2732aa41a5c46810633cf3305c225/maturin-1.12.5-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:e1a3ceb55349a16fef6e1662c170af2f2636a690a6fdae8edb5e71edcf5a3a5a", size = 9827192, upload-time = "2026-02-28T12:18:34.031Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/cec17826cdbfcbcc8482540de1559053a26e2c3cf4df5ee8515e04bd2cf2/maturin-1.12.5-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:ed59f6a24a9b107a2812b8c0ef48a0f0abddcfa18e120dd028bfc8fa2883ff2c", size = 10242722, upload-time = "2026-02-28T12:18:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8a/63f992c82bfbeb79293c1db294e8e4045cd8b3560ce1139aebde47c53be6/maturin-1.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:841b243e4212d343aac1e6b02a523d14bd8ae1291594fa1d875b08448863742a", size = 9686989, upload-time = "2026-02-28T12:18:16.939Z" }, + { url = "https://files.pythonhosted.org/packages/db/05/b03a7cbfa019a3cce3fc9acd47426494cd906bd19068d995e6c49e7a75cd/maturin-1.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7e647fad0236b80fd28b82b6f8bfe7771c7dbd8f41b96cff1f8b3d06e2ebb188", size = 9632401, upload-time = "2026-02-28T12:18:42.698Z" }, + { url = "https://files.pythonhosted.org/packages/59/ab/2d799e638df24b13ca74cc1c7b0d657653c0660d4241fd386c516ebdbc97/maturin-1.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:8e3ff2729a0ea5853ff000041701d3f0284d73ef0c9c29dd3568116bd5936e38", size = 12724579, upload-time = "2026-02-28T12:18:45.626Z" }, + { url = "https://files.pythonhosted.org/packages/93/1c/0eb3a9382ec11eec76451e2560812a600245e33ca3245c4ef45e32a664d5/maturin-1.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90a499ec738f3cfbb7ad0256c357b4d5b8e74ec98568c8ffbe4911830fc8e233", size = 10446845, upload-time = "2026-02-28T12:18:28.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/df/1779773ca5561abb22d968289285a6ca2f40d87472f28a7318eff26b9f24/maturin-1.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:710240583c0431c63975bd975b3c54987fe3f014081cac2b18d68e5176fb54c2", size = 10005785, upload-time = "2026-02-28T12:18:26.197Z" }, + { url = "https://files.pythonhosted.org/packages/c8/1b/b02bec7f44b48f2ef7c6729c931fe1329bd95074b7b85abb547146912a9b/maturin-1.12.5-py3-none-win32.whl", hash = "sha256:91c163cd96978eba35137284714065052357f6d73096956b39bce38e0e62f81a", size = 8555052, upload-time = "2026-02-28T12:18:09.819Z" }, + { url = "https://files.pythonhosted.org/packages/65/b2/ff02747eedc86972822e2c06ffb2e0cff2f01a71d626122ba289529b256d/maturin-1.12.5-py3-none-win_amd64.whl", hash = "sha256:452382b0ccd9416df5a4eabe77efad7d7ac204eabff61d043f243b888f59bc46", size = 9894151, upload-time = "2026-02-28T12:18:36.752Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a4/ba4160439870cd434525943c19eedbc7a761d3dba91ee8e0b5caf56320e3/maturin-1.12.5-py3-none-win_arm64.whl", hash = "sha256:91dcb25b0d6e2c76d18af4491ca6d7df34b91f9a026415b447908b8c5c2a7fc0", size = 8590220, upload-time = "2026-02-28T12:18:31.462Z" }, ] [[package]] From 235e03c09a919b6cf5775ee56b12b979b35cb694 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 28 Feb 2026 17:45:24 -0700 Subject: [PATCH 03/12] graph state sim --- crates/pecos-qsim/src/clifford_frame.rs | 274 +++++++++ crates/pecos-qsim/src/graph_state.rs | 745 ++++++++++++++++++++++++ crates/pecos-qsim/src/lib.rs | 2 + crates/pecos-qsim/src/prelude.rs | 1 + 4 files changed, 1022 insertions(+) create mode 100644 crates/pecos-qsim/src/graph_state.rs diff --git a/crates/pecos-qsim/src/clifford_frame.rs b/crates/pecos-qsim/src/clifford_frame.rs index 2db01d237..014e3432d 100644 --- a/crates/pecos-qsim/src/clifford_frame.rs +++ b/crates/pecos-qsim/src/clifford_frame.rs @@ -276,6 +276,262 @@ const COMPOSE: [[u8; 24]; 24] = compute_compose(); const INVERSE: [u8; 24] = compute_inverse(); const DECOMPOSE: [(u8, u8); 24] = compute_decompose(); +// ============================================================================ +// VOP removal decomposition table (for graph state simulator) +// ============================================================================ + +/// Maximum length of a VOP removal sequence. +const VOP_DECOMP_MAX_LEN: usize = 5; + +/// Decomposition of each Clifford into a sequence of LC generators. +/// +/// For the graph state simulator's `remove_vop`, each of the 24 Cliffords +/// can be decomposed as a product of two generators: +/// U = local complement on vertex v (right-multiplies v's VOP by SXDG, index 12) +/// V = local complement on neighbor vb (right-multiplies v's VOP by SZ, index 4) +/// +/// The sequence is stored as (length, [steps]), where each step is 0=U or 1=V. +/// Steps are applied in reverse order (last step first). +const fn compute_vop_decomp() -> [(u8, [u8; VOP_DECOMP_MAX_LEN]); 24] { + // U = SXDG (index 12), V = SZ (index 4) + // Right-multiplying element e by SXDG: compose[12][e] = e * SXDG as element + // Right-multiplying element e by SZ: compose[4][e] = e * SZ as element + + // BFS from identity (0) through right-multiplication by SXDG^{-1} and SZ^{-1} + // (equivalently, searching backward: which elements can reach 0?) + // Actually: we do forward BFS from 0, applying right-mult by SXDG and SZ. + // If we reach element C via path g1, g2, ..., gn, it means + // 0 * g1 * g2 * ... * gn = C, i.e. I * g1 * ... * gn = C + // So C = g1 * g2 * ... * gn. + // To go from C back to I: C * gn^{-1} * ... * g1^{-1} = I. + // For the remove_vop algorithm, we need to apply LCs that right-multiply by + // the generators (not their inverses). So we need a different approach. + + // Better: BFS from each element toward identity. + // From element e, applying generator U (right-mult by SXDG): next = compose[12][e] + // From element e, applying generator V (right-mult by SZ): next = compose[4][e] + // We want the shortest path from e to 0. + + // Reverse BFS from 0: predecessors of element `next` under U are elements e + // such that compose[12][e] = next (e * SXDG = next, so e = next * SXDG^{-1}). + // Similarly for V. Since SXDG^4 = I (order 4), SXDG^{-1} = SXDG^3. + // SZ^4 = I, SZ^{-1} = SZ^3 = SZDG. + + // Simpler: compute inverse of generators + let inv = compute_inverse(); + let sxdg_inv = inv[12]; // SXDG^{-1} + let sz_inv = inv[4]; // SZ^{-1} + + // For element e: predecessor via U is compose[sxdg_inv][e]? No. + // If applying U to element p gives e (i.e., compose[12][p] = e, meaning p * SXDG = e), + // then p = e * SXDG^{-1} = compose[sxdg_inv][e]... wait: + // compose[a][b] = COMPOSE[a][b] = element of b * a. + // Hmm no: compose(self=a, gate=b) = COMPOSE[a][b] = element of (b * a). + // We want p * SXDG = e, so p = e * SXDG^{-1}. + // e * SXDG^{-1}: this is right-mult of e by SXDG^{-1} = COMPOSE[sxdg_inv][e]. + // Wait: COMPOSE[self][gate] = gate * self. So COMPOSE[sxdg_inv][e] = e * sxdg_inv. + // Yes! + + // BFS from 0 (identity), expanding via inverse generators. + // visited[e] = true if we've found the path to e. + // parent_gen[e] = which generator (0=U, 1=V) was applied to reach e from its parent. + // parent[e] = the parent element. + + let mut result = [(0u8, [0u8; VOP_DECOMP_MAX_LEN]); 24]; + let mut visited = [false; 24]; + let mut parent = [255u8; 24]; // parent element + let mut parent_gen = [255u8; 24]; // 0=U, 1=V + let mut queue = [0u8; 24]; + let mut q_head = 0usize; + let mut q_tail = 0usize; + + // Start BFS from identity + visited[0] = true; + queue[q_tail] = 0; + q_tail += 1; + + while q_head < q_tail { + let current = queue[q_head] as usize; + q_head += 1; + + // Try expanding via U (predecessor under U is: compose[sxdg_inv][current]) + // This represents: neighbor = current * SXDG^{-1} + // If we go from neighbor by applying U, we get neighbor * SXDG = current + let u_nbr = COMPOSE[sxdg_inv as usize][current] as usize; + if !visited[u_nbr] { + visited[u_nbr] = true; + parent[u_nbr] = current as u8; + parent_gen[u_nbr] = 0; // U + queue[q_tail] = u_nbr as u8; + q_tail += 1; + } + + // Try expanding via V + let v_nbr = COMPOSE[sz_inv as usize][current] as usize; + if !visited[v_nbr] { + visited[v_nbr] = true; + parent[v_nbr] = current as u8; + parent_gen[v_nbr] = 1; // V + queue[q_tail] = v_nbr as u8; + q_tail += 1; + } + } + + // Reconstruct paths. For element e, trace back to 0 to get the sequence. + // The sequence represents: applying gen at e brings us closer to I. + // parent_gen[e] = the generator that was applied to reach parent[e] from e (so to say). + // Wait: actually parent[e] is closer to I, and parent_gen[e] is the generator + // that when applied to e gives parent[e]. + let mut e = 0; + while e < 24 { + if e == 0 { + result[0] = (0, [0; VOP_DECOMP_MAX_LEN]); + } else { + let mut path = [0u8; VOP_DECOMP_MAX_LEN]; + let mut len = 0usize; + let mut cur = e; + while cur != 0 { + path[len] = parent_gen[cur]; + len += 1; + cur = parent[cur] as usize; + } + // path[0..len] is the sequence from e toward I (forward order). + // The remove_vop algorithm should apply these in order: + // first path[0], then path[1], etc. + result[e] = (len as u8, path); + } + e += 1; + } + + result +} + +/// VOP removal decomposition table. +/// +/// `VOP_DECOMP[i]` = `(len, steps)` where `steps[0..len]` are the generators +/// (0=U on vertex, 1=V on neighbor) to apply in order to reduce element `i` to identity. +pub const VOP_DECOMP: [(u8, [u8; VOP_DECOMP_MAX_LEN]); 24] = compute_vop_decomp(); + +// ============================================================================ +// CZ (cphase) lookup table +// ============================================================================ + +/// Mapping from reference (GraphSim) Clifford indices to our CliffordFrame indices. +/// Derived by generating all 24 elements from H and S in both systems. +const REF_TO_OURS: [u8; 24] = [ + 0, 1, 2, 3, 20, 5, 4, 23, 18, 10, 6, 9, 17, 19, 12, 13, 14, 15, 22, 8, 7, 11, 21, 16, +]; + +/// Mapping from our CliffordFrame indices to reference (GraphSim) indices. +const OURS_TO_REF: [u8; 24] = [ + 0, 1, 2, 3, 6, 5, 10, 20, 19, 11, 9, 21, 14, 15, 16, 17, 23, 12, 8, 13, 4, 22, 18, 7, +]; + +/// Reference CZ table from GraphSim (Anders & Briegel), indexed by reference indices. +/// Layout: `REF_CPHASE[was_edge][v1_ref][v2_ref]` = `[new_edge, new_v1_ref, new_v2_ref]`. +/// This is the verified table from `cphase.tbl` in the GraphSim reference implementation. +#[rustfmt::skip] +const REF_CPHASE: [[[[u8; 3]; 24]; 24]; 2] = [ + // was_edge = 0 + [ + [[1,0,0],[1,0,0],[1,0,3],[1,0,3],[1,0,5],[1,0,5],[1,0,6],[1,0,6],[0,3,8],[0,3,8],[0,0,10],[0,0,10],[1,0,3],[1,0,3],[1,0,0],[1,0,0],[1,0,6],[1,0,6],[1,0,5],[1,0,5],[0,0,10],[0,0,10],[0,3,8],[0,3,8]], + [[1,0,0],[1,0,0],[1,0,3],[1,0,3],[1,0,5],[1,0,5],[1,0,6],[1,0,6],[0,2,8],[0,2,8],[0,0,10],[0,0,10],[1,0,3],[1,0,3],[1,0,0],[1,0,0],[1,0,6],[1,0,6],[1,0,5],[1,0,5],[0,0,10],[0,0,10],[0,2,8],[0,2,8]], + [[1,2,3],[1,0,1],[1,0,2],[1,2,0],[1,0,4],[1,2,6],[1,2,5],[1,0,7],[0,0,8],[0,0,8],[0,2,10],[0,2,10],[1,0,2],[1,0,2],[1,0,1],[1,0,1],[1,0,7],[1,0,7],[1,0,4],[1,0,4],[0,2,10],[0,2,10],[0,0,8],[0,0,8]], + [[1,3,0],[1,0,1],[1,0,2],[1,3,3],[1,0,4],[1,3,5],[1,3,6],[1,0,7],[0,0,8],[0,0,8],[0,3,10],[0,3,10],[1,0,2],[1,0,2],[1,0,1],[1,0,1],[1,0,7],[1,0,7],[1,0,4],[1,0,4],[0,3,10],[0,3,10],[0,0,8],[0,0,8]], + [[1,4,3],[1,4,3],[1,4,0],[1,4,0],[1,4,6],[1,4,6],[1,4,5],[1,4,5],[0,6,8],[0,6,8],[0,4,10],[0,4,10],[1,4,0],[1,4,0],[1,4,3],[1,4,3],[1,4,5],[1,4,5],[1,4,6],[1,4,6],[0,4,10],[0,4,10],[0,6,8],[0,6,8]], + [[1,5,0],[1,5,0],[1,5,3],[1,5,3],[1,5,5],[1,5,5],[1,5,6],[1,5,6],[0,6,8],[0,6,8],[0,5,10],[0,5,10],[1,5,3],[1,5,3],[1,5,0],[1,5,0],[1,5,6],[1,5,6],[1,5,5],[1,5,5],[0,5,10],[0,5,10],[0,6,8],[0,6,8]], + [[1,6,0],[1,5,1],[1,5,2],[1,6,3],[1,5,4],[1,6,5],[1,6,6],[1,5,7],[0,5,8],[0,5,8],[0,6,10],[0,6,10],[1,5,2],[1,5,2],[1,5,1],[1,5,1],[1,5,7],[1,5,7],[1,5,4],[1,5,4],[0,6,10],[0,6,10],[0,5,8],[0,5,8]], + [[1,6,0],[1,4,2],[1,4,1],[1,6,3],[1,4,7],[1,6,5],[1,6,6],[1,4,4],[0,4,8],[0,4,8],[0,6,10],[0,6,10],[1,4,1],[1,4,1],[1,4,2],[1,4,2],[1,4,4],[1,4,4],[1,4,7],[1,4,7],[0,6,10],[0,6,10],[0,4,8],[0,4,8]], + [[0,8,3],[0,8,2],[0,8,0],[0,8,0],[0,8,6],[0,8,6],[0,8,5],[0,8,4],[0,8,8],[0,8,8],[0,8,10],[0,8,10],[0,8,0],[0,8,0],[0,8,2],[0,8,2],[0,8,4],[0,8,4],[0,8,6],[0,8,6],[0,8,10],[0,8,10],[0,8,8],[0,8,8]], + [[0,8,3],[0,8,2],[0,8,0],[0,8,0],[0,8,6],[0,8,6],[0,8,5],[0,8,4],[0,8,8],[0,8,8],[0,8,10],[0,8,10],[0,8,0],[0,8,0],[0,8,2],[0,8,2],[0,8,4],[0,8,4],[0,8,6],[0,8,6],[0,8,10],[0,8,10],[0,8,8],[0,8,8]], + [[0,10,0],[0,10,0],[0,10,2],[0,10,3],[0,10,4],[0,10,5],[0,10,6],[0,10,6],[0,10,8],[0,10,8],[0,10,10],[0,10,10],[0,10,2],[0,10,2],[0,10,0],[0,10,0],[0,10,6],[0,10,6],[0,10,4],[0,10,4],[0,10,10],[0,10,10],[0,10,8],[0,10,8]], + [[0,10,0],[0,10,0],[0,10,2],[0,10,3],[0,10,4],[0,10,5],[0,10,6],[0,10,6],[0,10,8],[0,10,8],[0,10,10],[0,10,10],[0,10,2],[0,10,2],[0,10,0],[0,10,0],[0,10,6],[0,10,6],[0,10,4],[0,10,4],[0,10,10],[0,10,10],[0,10,8],[0,10,8]], + [[1,2,3],[1,0,1],[1,0,2],[1,2,0],[1,0,4],[1,2,6],[1,2,5],[1,0,7],[0,0,8],[0,0,8],[0,2,10],[0,2,10],[1,0,2],[1,0,2],[1,0,1],[1,0,1],[1,0,7],[1,0,7],[1,0,4],[1,0,4],[0,2,10],[0,2,10],[0,0,8],[0,0,8]], + [[1,2,3],[1,0,1],[1,0,2],[1,2,0],[1,0,4],[1,2,6],[1,2,5],[1,0,7],[0,0,8],[0,0,8],[0,2,10],[0,2,10],[1,0,2],[1,0,2],[1,0,1],[1,0,1],[1,0,7],[1,0,7],[1,0,4],[1,0,4],[0,2,10],[0,2,10],[0,0,8],[0,0,8]], + [[1,0,0],[1,0,0],[1,0,3],[1,0,3],[1,0,5],[1,0,5],[1,0,6],[1,0,6],[0,2,8],[0,2,8],[0,0,10],[0,0,10],[1,0,3],[1,0,3],[1,0,0],[1,0,0],[1,0,6],[1,0,6],[1,0,5],[1,0,5],[0,0,10],[0,0,10],[0,2,8],[0,2,8]], + [[1,0,0],[1,0,0],[1,0,3],[1,0,3],[1,0,5],[1,0,5],[1,0,6],[1,0,6],[0,2,8],[0,2,8],[0,0,10],[0,0,10],[1,0,3],[1,0,3],[1,0,0],[1,0,0],[1,0,6],[1,0,6],[1,0,5],[1,0,5],[0,0,10],[0,0,10],[0,2,8],[0,2,8]], + [[1,6,0],[1,4,2],[1,4,1],[1,6,3],[1,4,7],[1,6,5],[1,6,6],[1,4,4],[0,4,8],[0,4,8],[0,6,10],[0,6,10],[1,4,1],[1,4,1],[1,4,2],[1,4,2],[1,4,4],[1,4,4],[1,4,7],[1,4,7],[0,6,10],[0,6,10],[0,4,8],[0,4,8]], + [[1,6,0],[1,4,2],[1,4,1],[1,6,3],[1,4,7],[1,6,5],[1,6,6],[1,4,4],[0,4,8],[0,4,8],[0,6,10],[0,6,10],[1,4,1],[1,4,1],[1,4,2],[1,4,2],[1,4,4],[1,4,4],[1,4,7],[1,4,7],[0,6,10],[0,6,10],[0,4,8],[0,4,8]], + [[1,4,3],[1,4,3],[1,4,0],[1,4,0],[1,4,6],[1,4,6],[1,4,5],[1,4,5],[0,6,8],[0,6,8],[0,4,10],[0,4,10],[1,4,0],[1,4,0],[1,4,3],[1,4,3],[1,4,5],[1,4,5],[1,4,6],[1,4,6],[0,4,10],[0,4,10],[0,6,8],[0,6,8]], + [[1,4,3],[1,4,3],[1,4,0],[1,4,0],[1,4,6],[1,4,6],[1,4,5],[1,4,5],[0,6,8],[0,6,8],[0,4,10],[0,4,10],[1,4,0],[1,4,0],[1,4,3],[1,4,3],[1,4,5],[1,4,5],[1,4,6],[1,4,6],[0,4,10],[0,4,10],[0,6,8],[0,6,8]], + [[0,10,0],[0,10,0],[0,10,2],[0,10,3],[0,10,4],[0,10,5],[0,10,6],[0,10,6],[0,10,8],[0,10,8],[0,10,10],[0,10,10],[0,10,2],[0,10,2],[0,10,0],[0,10,0],[0,10,6],[0,10,6],[0,10,4],[0,10,4],[0,10,10],[0,10,10],[0,10,8],[0,10,8]], + [[0,10,0],[0,10,0],[0,10,2],[0,10,3],[0,10,4],[0,10,5],[0,10,6],[0,10,6],[0,10,8],[0,10,8],[0,10,10],[0,10,10],[0,10,2],[0,10,2],[0,10,0],[0,10,0],[0,10,6],[0,10,6],[0,10,4],[0,10,4],[0,10,10],[0,10,10],[0,10,8],[0,10,8]], + [[0,8,3],[0,8,2],[0,8,0],[0,8,0],[0,8,6],[0,8,6],[0,8,5],[0,8,4],[0,8,8],[0,8,8],[0,8,10],[0,8,10],[0,8,0],[0,8,0],[0,8,2],[0,8,2],[0,8,4],[0,8,4],[0,8,6],[0,8,6],[0,8,10],[0,8,10],[0,8,8],[0,8,8]], + [[0,8,3],[0,8,2],[0,8,0],[0,8,0],[0,8,6],[0,8,6],[0,8,5],[0,8,4],[0,8,8],[0,8,8],[0,8,10],[0,8,10],[0,8,0],[0,8,0],[0,8,2],[0,8,2],[0,8,4],[0,8,4],[0,8,6],[0,8,6],[0,8,10],[0,8,10],[0,8,8],[0,8,8]], + ], + // was_edge = 1 + [ + [[0,0,0],[0,3,0],[0,3,2],[0,0,3],[0,3,4],[0,0,5],[0,0,6],[0,3,6],[1,5,23],[1,5,22],[1,5,21],[1,5,20],[0,5,2],[0,6,2],[0,5,0],[0,6,0],[0,6,6],[0,5,6],[0,6,4],[0,5,4],[1,5,10],[1,5,11],[1,5,8],[1,5,9]], + [[0,0,3],[0,2,2],[0,2,0],[0,0,0],[0,2,6],[0,0,6],[0,0,5],[0,2,4],[1,4,23],[1,4,22],[1,4,21],[1,4,20],[0,6,0],[0,4,0],[0,6,2],[0,4,2],[0,4,4],[0,6,4],[0,4,6],[0,6,6],[1,4,10],[1,4,11],[1,4,8],[1,4,9]], + [[0,2,3],[0,0,2],[0,0,0],[0,2,0],[0,0,6],[0,2,6],[0,2,5],[0,0,4],[1,4,22],[1,4,23],[1,4,20],[1,4,21],[0,4,0],[0,6,0],[0,4,2],[0,6,2],[0,6,4],[0,4,4],[0,6,6],[0,4,6],[1,4,11],[1,4,10],[1,4,9],[1,4,8]], + [[0,3,0],[0,0,0],[0,0,2],[0,3,3],[0,0,4],[0,3,5],[0,3,6],[0,0,6],[1,5,22],[1,5,23],[1,5,20],[1,5,21],[0,6,2],[0,5,2],[0,6,0],[0,5,0],[0,5,6],[0,6,6],[0,5,4],[0,6,4],[1,5,11],[1,5,10],[1,5,9],[1,5,8]], + [[0,4,3],[0,6,2],[0,6,0],[0,4,0],[0,6,6],[0,4,6],[0,4,5],[0,6,4],[1,0,21],[1,0,20],[1,0,23],[1,0,22],[0,0,0],[0,2,0],[0,0,2],[0,2,2],[0,2,4],[0,0,4],[0,2,6],[0,0,6],[1,0,8],[1,0,9],[1,0,10],[1,0,11]], + [[0,5,0],[0,6,0],[0,6,2],[0,5,3],[0,6,4],[0,5,5],[0,5,6],[0,6,6],[1,0,22],[1,0,23],[1,0,20],[1,0,21],[0,3,2],[0,0,2],[0,3,0],[0,0,0],[0,0,6],[0,3,6],[0,0,4],[0,3,4],[1,0,11],[1,0,10],[1,0,9],[1,0,8]], + [[0,6,0],[0,5,0],[0,5,2],[0,6,3],[0,5,4],[0,6,5],[0,6,6],[0,5,6],[1,0,23],[1,0,22],[1,0,21],[1,0,20],[0,0,2],[0,3,2],[0,0,0],[0,3,0],[0,3,6],[0,0,6],[0,3,4],[0,0,4],[1,0,10],[1,0,11],[1,0,8],[1,0,9]], + [[0,6,3],[0,4,2],[0,4,0],[0,6,0],[0,4,6],[0,6,6],[0,6,5],[0,4,4],[1,0,20],[1,0,21],[1,0,22],[1,0,23],[0,2,0],[0,0,0],[0,2,2],[0,0,2],[0,0,4],[0,2,4],[0,0,6],[0,2,6],[1,0,9],[1,0,8],[1,0,11],[1,0,10]], + [[1,22,6],[1,20,5],[1,20,6],[1,22,5],[1,20,3],[1,22,0],[1,22,3],[1,20,0],[0,0,0],[0,0,2],[0,2,2],[0,2,0],[0,6,6],[0,4,4],[0,6,4],[0,4,6],[0,4,2],[0,6,0],[0,4,0],[0,6,2],[0,2,4],[0,2,6],[0,0,6],[0,0,4]], + [[1,22,5],[1,20,6],[1,20,5],[1,22,6],[1,20,0],[1,22,3],[1,22,0],[1,20,3],[0,2,0],[0,2,2],[0,0,2],[0,0,0],[0,4,6],[0,6,4],[0,4,4],[0,6,6],[0,6,2],[0,4,0],[0,6,0],[0,4,2],[0,0,4],[0,0,6],[0,2,6],[0,2,4]], + [[1,20,6],[1,20,7],[1,20,4],[1,20,5],[1,20,1],[1,20,0],[1,20,3],[1,20,2],[0,2,2],[0,2,0],[0,0,0],[0,0,2],[0,6,4],[0,4,6],[0,6,6],[0,4,4],[0,4,0],[0,6,2],[0,4,2],[0,6,0],[0,0,6],[0,0,4],[0,2,4],[0,2,6]], + [[1,20,5],[1,20,4],[1,20,7],[1,20,6],[1,20,2],[1,20,3],[1,20,0],[1,20,1],[0,0,2],[0,0,0],[0,2,0],[0,2,2],[0,4,4],[0,6,6],[0,4,6],[0,6,4],[0,6,0],[0,4,2],[0,6,2],[0,4,0],[0,2,6],[0,2,4],[0,0,4],[0,0,6]], + [[0,2,5],[0,0,6],[0,0,4],[0,2,6],[0,0,0],[0,2,3],[0,2,0],[0,0,2],[0,6,6],[0,6,4],[0,4,6],[0,4,4],[1,16,18],[1,16,19],[1,16,16],[1,16,17],[1,16,12],[1,16,13],[1,16,14],[1,16,15],[0,4,2],[0,4,0],[0,6,2],[0,6,0]], + [[0,2,6],[0,0,4],[0,0,6],[0,2,5],[0,0,2],[0,2,0],[0,2,3],[0,0,0],[0,4,4],[0,4,6],[0,6,4],[0,6,6],[1,16,17],[1,16,16],[1,16,19],[1,16,18],[1,16,15],[1,16,14],[1,16,13],[1,16,12],[0,6,0],[0,6,2],[0,4,0],[0,4,2]], + [[0,0,5],[0,2,6],[0,2,4],[0,0,6],[0,2,0],[0,0,3],[0,0,0],[0,2,2],[0,4,6],[0,4,4],[0,6,6],[0,6,4],[1,16,16],[1,16,17],[1,16,18],[1,16,19],[1,16,14],[1,16,15],[1,16,12],[1,16,13],[0,6,2],[0,6,0],[0,4,2],[0,4,0]], + [[0,0,6],[0,2,4],[0,2,6],[0,0,5],[0,2,2],[0,0,0],[0,0,3],[0,2,0],[0,6,4],[0,6,6],[0,4,4],[0,4,6],[1,16,19],[1,16,18],[1,16,17],[1,16,16],[1,16,13],[1,16,12],[1,16,15],[1,16,14],[0,4,0],[0,4,2],[0,6,0],[0,6,2]], + [[0,6,6],[0,4,4],[0,4,6],[0,6,5],[0,4,2],[0,6,0],[0,6,3],[0,4,0],[0,2,4],[0,2,6],[0,0,4],[0,0,6],[1,12,16],[1,12,17],[1,12,18],[1,12,19],[1,12,14],[1,12,15],[1,12,12],[1,12,13],[0,0,0],[0,0,2],[0,2,0],[0,2,2]], + [[0,6,5],[0,4,6],[0,4,4],[0,6,6],[0,4,0],[0,6,3],[0,6,0],[0,4,2],[0,0,6],[0,0,4],[0,2,6],[0,2,4],[1,12,19],[1,12,18],[1,12,17],[1,12,16],[1,12,13],[1,12,12],[1,12,15],[1,12,14],[0,2,2],[0,2,0],[0,0,2],[0,0,0]], + [[0,4,6],[0,6,4],[0,6,6],[0,4,5],[0,6,2],[0,4,0],[0,4,3],[0,6,0],[0,0,4],[0,0,6],[0,2,4],[0,2,6],[1,12,18],[1,12,19],[1,12,16],[1,12,17],[1,12,12],[1,12,13],[1,12,14],[1,12,15],[0,2,0],[0,2,2],[0,0,0],[0,0,2]], + [[0,4,5],[0,6,6],[0,6,4],[0,4,6],[0,6,0],[0,4,3],[0,4,0],[0,6,2],[0,2,6],[0,2,4],[0,0,6],[0,0,4],[1,12,17],[1,12,16],[1,12,19],[1,12,18],[1,12,15],[1,12,14],[1,12,13],[1,12,12],[0,0,2],[0,0,0],[0,2,2],[0,2,0]], + [[1,10,5],[1,8,6],[1,8,5],[1,10,6],[1,8,0],[1,10,3],[1,10,0],[1,8,3],[0,4,2],[0,4,0],[0,6,0],[0,6,2],[0,2,4],[0,0,6],[0,2,6],[0,0,4],[0,0,0],[0,2,2],[0,0,2],[0,2,0],[0,6,6],[0,6,4],[0,4,4],[0,4,6]], + [[1,10,6],[1,8,5],[1,8,6],[1,10,5],[1,8,3],[1,10,0],[1,10,3],[1,8,0],[0,6,2],[0,6,0],[0,4,0],[0,4,2],[0,0,4],[0,2,6],[0,0,6],[0,2,4],[0,2,0],[0,0,2],[0,2,2],[0,0,0],[0,4,6],[0,4,4],[0,6,4],[0,6,6]], + [[1,8,5],[1,8,4],[1,8,7],[1,8,6],[1,8,2],[1,8,3],[1,8,0],[1,8,1],[0,6,0],[0,6,2],[0,4,2],[0,4,0],[0,2,6],[0,0,4],[0,2,4],[0,0,6],[0,0,2],[0,2,0],[0,0,0],[0,2,2],[0,4,4],[0,4,6],[0,6,6],[0,6,4]], + [[1,8,6],[1,8,7],[1,8,4],[1,8,5],[1,8,1],[1,8,0],[1,8,3],[1,8,2],[0,4,0],[0,4,2],[0,6,2],[0,6,0],[0,0,6],[0,2,4],[0,0,4],[0,2,6],[0,2,2],[0,0,0],[0,2,0],[0,0,2],[0,6,4],[0,6,6],[0,4,6],[0,4,4]], + ], +]; + +/// Compute the CZ lookup table by remapping the reference GraphSim table +/// to our CliffordFrame index system. +/// +/// For each `(was_edge, v1, v2)`, finds `(new_edge, v1', v2')` such that +/// after applying CZ to state `(V1 x V2) |G_{was_edge}>`, +/// the result is `(V1' x V2') |G_{new_edge}>`. +/// +/// Layout: `[was_edge * 24 + v1][v2]` = `[new_edge, v1', v2']`. +const fn compute_cphase_table() -> [[[u8; 3]; 24]; 48] { + let mut table = [[[0u8; 3]; 24]; 48]; + + let mut we: usize = 0; + while we < 2 { + let mut v1: usize = 0; + while v1 < 24 { + let mut v2: usize = 0; + while v2 < 24 { + // Map our indices to reference indices + let rv1 = OURS_TO_REF[v1] as usize; + let rv2 = OURS_TO_REF[v2] as usize; + + // Look up in reference table + let [ne, rnv1, rnv2] = REF_CPHASE[we][rv1][rv2]; + + // Map reference result indices back to our indices + let onv1 = REF_TO_OURS[rnv1 as usize]; + let onv2 = REF_TO_OURS[rnv2 as usize]; + + table[we * 24 + v1][v2] = [ne, onv1, onv2]; + v2 += 1; + } + v1 += 1; + } + we += 1; + } + + table +} + +/// CZ lookup table, computed at compile time. +/// +/// `CPHASE_TBL[was_edge * 24 + vop1][vop2]` = `[new_edge, new_vop1, new_vop2]` +pub const CPHASE_TBL: [[[u8; 3]; 24]; 48] = compute_cphase_table(); + // ============================================================================ // Phase cocycle tables for exact phase tracking // ============================================================================ @@ -502,6 +758,16 @@ impl CliffordFrame { self.0 == 0 } + /// Whether this Clifford is diagonal in the computational basis. + /// + /// A Clifford is diagonal iff its Z-image axis is Z (maps Z to +/-Z). + /// The four diagonal Cliffords are: I, Z, S, Sdg. + #[inline] + #[must_use] + pub fn is_diagonal(self) -> bool { + HEIS[self.0 as usize].2 == 2 // z_axis == Z + } + /// Decompose into Pauli × Coset: `self_matrix` = pauli · coset. /// /// The coset representative is one of {I, S, H, SH, HS, SHS}. @@ -520,6 +786,14 @@ impl CliffordFrame { self.0 } + /// Construct from a raw index. Only valid for indices 0..24. + #[inline] + #[must_use] + pub fn from_index(idx: u8) -> Self { + debug_assert!(idx < 24, "CliffordFrame index out of range: {idx}"); + Self(idx) + } + /// Pauli symplectic representation: (`x_bit`, `z_bit`). /// Only valid for Pauli elements (index 0-3). /// I=(false,false), X=(true,false), Y=(true,true), Z=(false,true). diff --git a/crates/pecos-qsim/src/graph_state.rs b/crates/pecos-qsim/src/graph_state.rs new file mode 100644 index 000000000..1369bd77b --- /dev/null +++ b/crates/pecos-qsim/src/graph_state.rs @@ -0,0 +1,745 @@ +// 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. + +//! Graph state stabilizer simulator inspired by the Anders & Briegel algorithm. +//! +//! Any stabilizer state can be written as local Cliffords applied to a graph state: +//! `|psi> = (tensor_v VOP_v) |G>` where `|G>` is the graph state defined by an +//! adjacency graph. Single-qubit Clifford gates are O(1) VOP updates. Two-qubit +//! gates and measurements require local complementation operations that are +//! O(degree) or O(degree^2). +//! +//! # References +//! - Anders & Briegel, "Fast simulation of stabilizer circuits using a graph-state +//! representation", [quant-ph/0504117](https://arxiv.org/abs/quant-ph/0504117) + +use crate::clifford_frame::{CliffordFrame, PauliAxis}; +use crate::{CliffordGateable, MeasurementResult, QuantumSimulator}; +use core::fmt::Debug; +use pecos_core::{BitSet, QubitId, RngManageable}; +use pecos_rng::rng_ext::RngProbabilityExt; +use pecos_rng::{PecosRng, Rng, SeedableRng}; + +use crate::stabilizer_test_utils::{ForcedMeasurement, StabilizerSimulator}; + +/// Graph state stabilizer simulator. +/// +/// Represents a stabilizer state as `|psi> = (tensor_v VOP_v) |G>` where +/// `VOP_v` is a single-qubit Clifford (vertex operator) on each qubit and +/// `|G>` is the graph state defined by the adjacency graph. +/// +/// Single-qubit gates are O(1). Two-qubit gates are O(degree) amortized. +/// Measurements are O(degree^2) in the worst case. +#[derive(Clone, Debug)] +pub struct GraphState { + num_qubits: usize, + /// Vertex operators: one single-qubit Clifford per qubit. + vops: Vec, + /// Adjacency lists: `neighbors[v]` is the set of vertices adjacent to v. + neighbors: Vec, + rng: R, +} + +// ============================================================================ +// Constructors +// ============================================================================ + +impl GraphState { + /// Create a new graph state simulator with the default RNG. + #[inline] + #[must_use] + pub fn new(num_qubits: usize) -> Self { + let rng = rand::make_rng(); + Self::with_rng(num_qubits, rng) + } + + /// Create a new graph state simulator with a specific seed. + #[inline] + #[must_use] + pub fn with_seed(num_qubits: usize, seed: u64) -> Self { + let rng = PecosRng::seed_from_u64(seed); + Self::with_rng(num_qubits, rng) + } +} + +impl GraphState { + /// Create a new graph state simulator with a custom RNG. + #[inline] + pub fn with_rng(num_qubits: usize, rng: R) -> Self { + let mut state = Self { + num_qubits, + vops: vec![CliffordFrame::IDENTITY; num_qubits], + neighbors: vec![BitSet::new(); num_qubits], + rng, + }; + state.reset(); + state + } + + /// Returns the number of qubits. + #[inline] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + // ======================================================================== + // Internal: adjacency helpers + // ======================================================================== + + /// Toggle edge (a, b) in the graph. + #[inline] + fn toggle_edge(&mut self, a: usize, b: usize) { + self.neighbors[a].toggle(b); + self.neighbors[b].toggle(a); + } + + /// Disconnect vertex a from all neighbors. + fn disconnect(&mut self, a: usize) { + // Collect neighbors to avoid borrow issues + let nbrs: Vec = self.neighbors[a].iter().collect(); + for &u in &nbrs { + self.neighbors[u].toggle(a); + } + self.neighbors[a].clear(); + } + + // ======================================================================== + // Internal: local complementation + // ======================================================================== + + /// Perform local complementation about vertex `a`. + /// + /// This complements all edges among neighbors of `a`, then updates VOPs: + /// - Prepend sqrt(-iX) to VOP_a + /// - Prepend sqrt(iZ) to each neighbor's VOP + fn local_complement(&mut self, a: usize) { + let nbrs: Vec = self.neighbors[a].iter().collect(); + + // Complement edges among N(a) + for i in 0..nbrs.len() { + for j in (i + 1)..nbrs.len() { + self.toggle_edge(nbrs[i], nbrs[j]); + } + } + + // Update VOPs: prepend sqrt(-iX) = SXDG to vertex a + self.vops[a] = CliffordFrame::SXDG.compose(self.vops[a]); + + // Prepend sqrt(iZ) = SZ to each neighbor + for &u in &nbrs { + self.vops[u] = CliffordFrame::SZ.compose(self.vops[u]); + } + } + + // ======================================================================== + // Internal: CZ implementation + // ======================================================================== + + /// Check whether vertex `v` has any neighbor other than `other`. + fn has_non_operand_neighbors(&self, v: usize, other: usize) -> bool { + let nbrs = &self.neighbors[v]; + if nbrs.contains(other) { + nbrs.len() >= 2 + } else { + nbrs.len() >= 1 + } + } + + /// Remove the VOP on vertex `v` by decomposing it into a sequence of + /// local complementations on `v` and a chosen neighbor `vb`. + /// + /// Uses a precomputed decomposition table (BFS over the 24-element Clifford + /// group using the two generators: LC on v appends SXDG, LC on vb appends SZ). + fn remove_vop(&mut self, v: usize, avoid: usize) { + use crate::clifford_frame::VOP_DECOMP; + + debug_assert!( + !self.neighbors[v].is_empty(), + "remove_vop called with isolated vertex" + ); + + // Pick a neighbor that isn't `avoid` (if possible) + let mut vb = self.neighbors[v].iter().next().unwrap(); + if vb == avoid { + if let Some(alt) = self.neighbors[v].iter().find(|&u| u != avoid) { + vb = alt; + } + // If avoid is the only neighbor, we'll use it anyway + } + + let (len, steps) = VOP_DECOMP[self.vops[v].index() as usize]; + + // Apply steps in forward order: each step reduces the VOP toward identity + for i in 0..len as usize { + if steps[i] == 0 { + // U: local complement on v + self.local_complement(v); + } else { + // V: local complement on neighbor vb + self.local_complement(vb); + } + } + + debug_assert!( + self.vops[v].is_identity(), + "remove_vop failed: VOP is {:?} (expected identity)", + self.vops[v] + ); + } + + /// Internal CZ implementation using the reference's 3-pass structure. + /// + /// Follows the Anders & Briegel `cphase` algorithm: + /// 1. If v1 has non-operand neighbors, remove its VOP. + /// 2. If v2 has non-operand neighbors, remove its VOP. + /// 3. If v1 still has non-operand neighbors and non-diagonal VOP, remove again. + /// 4. Apply CZ via lookup table. + fn cz_internal(&mut self, v1: usize, v2: usize) { + use crate::clifford_frame::CPHASE_TBL; + + if self.has_non_operand_neighbors(v1, v2) { + self.remove_vop(v1, v2); + } + if self.has_non_operand_neighbors(v2, v1) { + self.remove_vop(v2, v1); + } + if self.has_non_operand_neighbors(v1, v2) && !self.vops[v1].is_diagonal() { + self.remove_vop(v1, v2); + } + + // Use the CZ lookup table + let was_edge = self.neighbors[v1].contains(v2); + let op1 = self.vops[v1].index() as usize; + let op2 = self.vops[v2].index() as usize; + + let we_idx = if was_edge { 1 } else { 0 }; + let [new_edge, new_op1, new_op2] = CPHASE_TBL[we_idx * 24 + op1][op2]; + + // Set edge state + let should_have_edge = new_edge == 1; + if was_edge && !should_have_edge { + // Remove edge + self.neighbors[v1].toggle(v2); + self.neighbors[v2].toggle(v1); + } else if !was_edge && should_have_edge { + // Add edge + self.neighbors[v1].toggle(v2); + self.neighbors[v2].toggle(v1); + } + + self.vops[v1] = CliffordFrame::from_index(new_op1); + self.vops[v2] = CliffordFrame::from_index(new_op2); + } + + // ======================================================================== + // Internal: measurement + // ======================================================================== + + /// Measure qubit `a` in the Z basis with a given outcome for non-deterministic cases. + /// + /// Follows the reference's `measure` function: conjugate the Z basis through the VOP + /// to determine which graph-state measurement to perform. If the conjugation produces + /// a negative sign, flip the forced outcome before and the result after. + fn measure_z_internal(&mut self, a: usize, forced_outcome: Option) -> MeasurementResult { + // The effective Pauli being measured on the graph state + let sigma = self.vops[a].z_image(); + let negative = !sigma.positive; + + // If the VOP conjugation gives a negative sign, flip the forced outcome + let adjusted_forced = if negative { + forced_outcome.map(|f| !f) + } else { + forced_outcome + }; + + let mut result = match sigma.axis { + PauliAxis::X => self.measure_x_on_graph(a, adjusted_forced), + PauliAxis::Y => self.measure_y_on_graph(a, adjusted_forced), + PauliAxis::Z => self.measure_z_on_graph(a, adjusted_forced), + }; + + // If the sign was negative, flip the result + if negative { + result.outcome = !result.outcome; + } + + result + } + + /// Measure X on the graph state at vertex `v`. + /// + /// Follows the reference's `graph_X_measure` algorithm. + /// If N(v) is empty: deterministic, outcome = 0 (always +1 eigenvalue). + /// Otherwise: non-deterministic with 3-step edge toggling. + fn measure_x_on_graph( + &mut self, + v: usize, + forced_outcome: Option, + ) -> MeasurementResult { + if self.neighbors[v].is_empty() { + // Deterministic: isolated graph state vertex is |+>, X eigenvalue +1 + return MeasurementResult { + outcome: false, + is_deterministic: true, + }; + } + + // Non-deterministic + let outcome = forced_outcome.unwrap_or_else(|| self.rng.coin_flip()); + + // Pick a neighbor vb + let vb = self.neighbors[v].iter().next().unwrap(); + + // Save neighborhoods BEFORE modifications + let vn: Vec = self.neighbors[v].iter().collect(); + let vbn: Vec = self.neighbors[vb].iter().collect(); + + // Build sets for fast lookup + let vn_set: BitSet = self.neighbors[v].clone(); + let vbn_set: BitSet = self.neighbors[vb].clone(); + + // VOP updates + if !outcome { + // Measured +1 (|+>): SYDG on vb, Z on N(v) \ N(vb) \ {vb} + self.vops[vb] = CliffordFrame::SYDG.compose(self.vops[vb]); + for &u in &vn { + if u != vb && !vbn_set.contains(u) { + self.vops[u] = CliffordFrame::Z.compose(self.vops[u]); + } + } + } else { + // Measured -1 (|->): SY on vb, Z on v, Z on N(vb) \ N(v) \ {v} + self.vops[vb] = CliffordFrame::SY.compose(self.vops[vb]); + self.vops[v] = CliffordFrame::Z.compose(self.vops[v]); + for &u in &vbn { + if u != v && !vn_set.contains(u) { + self.vops[u] = CliffordFrame::Z.compose(self.vops[u]); + } + } + } + + // Edge toggles (using saved neighborhoods) + // STEP 1: Toggle edges between N(v) and N(vb), avoiding double-toggling + { + let mut processed = BitSet::new(); + for &i in &vn { + for &j in &vbn { + if i != j { + let edge = if i < j { (i, j) } else { (j, i) }; + let edge_key = edge.0 * self.num_qubits + edge.1; + if !processed.contains(edge_key) { + processed.insert(edge_key); + self.toggle_edge(i, j); + } + } + } + } + } + + // STEP 2: Toggle complete subgraph on N(v) intersect N(vb) + { + let intersection: Vec = vn + .iter() + .filter(|&&u| vbn_set.contains(u)) + .copied() + .collect(); + for i in 0..intersection.len() { + for j in (i + 1)..intersection.len() { + self.toggle_edge(intersection[i], intersection[j]); + } + } + } + + // STEP 3: Toggle edges from vb to N(v) \ {vb} + for &u in &vn { + if u != vb { + self.toggle_edge(vb, u); + } + } + + MeasurementResult { + outcome, + is_deterministic: false, + } + } + + /// Measure Y on the graph state at vertex `v`. + /// + /// Follows the reference's `graph_Y_measure` algorithm (direct, no reduction to X). + /// Always non-deterministic. + fn measure_y_on_graph( + &mut self, + v: usize, + forced_outcome: Option, + ) -> MeasurementResult { + let outcome = forced_outcome.unwrap_or_else(|| self.rng.coin_flip()); + + // Right-multiply each neighbor's VOP by SZDG (outcome=1) or SZ (outcome=0) + let vnbg: Vec = self.neighbors[v].iter().collect(); + for &u in &vnbg { + if outcome { + self.vops[u] = CliffordFrame::SZDG.compose(self.vops[u]); + } else { + self.vops[u] = CliffordFrame::SZ.compose(self.vops[u]); + } + } + + // Toggle all edges in complete subgraph of {v} union N(v) + let mut all_vertices = vnbg.clone(); + all_vertices.push(v); + for i in 0..all_vertices.len() { + for j in (i + 1)..all_vertices.len() { + self.toggle_edge(all_vertices[i], all_vertices[j]); + } + } + + // Right-multiply v's VOP by SZ (outcome=0) or SZDG (outcome=1) + if !outcome { + self.vops[v] = CliffordFrame::SZ.compose(self.vops[v]); + } else { + self.vops[v] = CliffordFrame::SZDG.compose(self.vops[v]); + } + + MeasurementResult { + outcome, + is_deterministic: false, + } + } + + /// Measure Z on the graph state at vertex `v`. + /// + /// Follows the reference's `graph_Z_measure` algorithm. + /// Disconnects v from all neighbors (no edge complement among neighbors). + /// If outcome=1, right-multiplies each neighbor's VOP by Z. + /// Sets v's VOP by right-multiplying by H (outcome=0) or X*H=SY (outcome=1). + fn measure_z_on_graph( + &mut self, + v: usize, + forced_outcome: Option, + ) -> MeasurementResult { + let outcome = forced_outcome.unwrap_or_else(|| self.rng.coin_flip()); + + let nbrs: Vec = self.neighbors[v].iter().collect(); + + // Disconnect v from all neighbors (no edge complement) + self.disconnect(v); + + // If outcome=1, right-multiply each neighbor's VOP by Z + if outcome { + for &u in &nbrs { + self.vops[u] = CliffordFrame::Z.compose(self.vops[u]); + } + } + + // Set v's VOP: right-multiply by H (outcome=0) or X*H=SY (outcome=1) + if !outcome { + self.vops[v] = CliffordFrame::H.compose(self.vops[v]); + } else { + // X * H = SY (index 10). Right-multiply: compose(SY, VOP) = VOP * SY + self.vops[v] = CliffordFrame::SY.compose(self.vops[v]); + } + + // Determine if deterministic: isolated vertices (no neighbors) have + // deterministic Z measurement result. But after graph_Z_measure, + // the result is always "non-deterministic" from the graph measurement + // perspective. The determinism is handled by the caller (measure_z_internal). + // + // Actually: if the vertex had no neighbors to begin with, the graph state + // X stabilizer means Z is non-deterministic. + MeasurementResult { + outcome, + is_deterministic: false, + } + } +} + +// ============================================================================ +// Trait implementations +// ============================================================================ + +impl QuantumSimulator for GraphState { + 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. + for v in &mut self.vops { + *v = CliffordFrame::H; + } + for n in &mut self.neighbors { + n.clear(); + } + self + } +} + +impl RngManageable for GraphState { + type Rng = R; + + fn set_rng(&mut self, rng: Self::Rng) { + self.rng = rng; + } + + #[inline] + fn rng(&self) -> &Self::Rng { + &self.rng + } + + #[inline] + fn rng_mut(&mut self) -> &mut Self::Rng { + &mut self.rng + } +} + +impl CliffordGateable for GraphState { + // -- Single-qubit gates: O(1) VOP composition -- + + fn x(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::X); + } + self + } + + fn y(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::Y); + } + self + } + + fn z(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::Z); + } + self + } + + fn sz(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SZ); + } + self + } + + fn szdg(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SZDG); + } + self + } + + fn h(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::H); + } + self + } + + fn sx(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SX); + } + self + } + + fn sxdg(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SXDG); + } + self + } + + fn sy(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SY); + } + self + } + + fn sydg(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + self.vops[q.index()] = self.vops[q.index()].compose(CliffordFrame::SYDG); + } + self + } + + // -- Two-qubit gates -- + + fn cx(&mut self, qubits: &[QubitId]) -> &mut Self { + debug_assert!( + qubits.len().is_multiple_of(2), + "CX requires pairs of qubits" + ); + for pair in qubits.chunks_exact(2) { + let ctrl = pair[0].index(); + let targ = pair[1].index(); + // CX = (I x H) CZ (I x H) + self.vops[targ] = self.vops[targ].compose(CliffordFrame::H); + self.cz_internal(ctrl, targ); + self.vops[targ] = self.vops[targ].compose(CliffordFrame::H); + } + self + } + + fn cz(&mut self, qubits: &[QubitId]) -> &mut Self { + debug_assert!( + qubits.len().is_multiple_of(2), + "CZ requires pairs of qubits" + ); + for pair in qubits.chunks_exact(2) { + self.cz_internal(pair[0].index(), pair[1].index()); + } + self + } + + // -- Measurement -- + + fn mz(&mut self, qubits: &[QubitId]) -> Vec { + qubits + .iter() + .map(|&q| self.measure_z_internal(q.index(), None)) + .collect() + } +} + +// ============================================================================ +// ForcedMeasurement & StabilizerSimulator +// ============================================================================ + +impl ForcedMeasurement for GraphState { + fn mz_forced(&mut self, qubit: usize, forced_outcome: bool) -> MeasurementResult { + self.measure_z_internal(qubit, Some(forced_outcome)) + } +} + +impl StabilizerSimulator for GraphState { + fn with_seed(num_qubits: usize, seed: u64) -> Self { + Self::with_seed(num_qubits, seed) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::stabilizer_test_suite; + use pecos_core::qid; + + stabilizer_test_suite!(GraphState); + + #[test] + fn test_initial_state_is_all_zero() { + let mut sim = GraphState::with_seed(3, 42); + for i in 0..3 { + let result = sim.mz(&[QubitId::new(i)]); + assert!(result[0].is_deterministic, "qubit {i} should be deterministic"); + assert!(!result[0].outcome, "qubit {i} should be |0>"); + } + } + + #[test] + fn test_single_qubit_x_flips() { + let mut sim = GraphState::with_seed(1, 42); + sim.x(&qid(0)); + let result = sim.mz(&qid(0)); + assert!(result[0].is_deterministic); + assert!(result[0].outcome, "X|0> = |1>"); + } + + #[test] + fn test_hadamard_creates_superposition() { + let mut sim = GraphState::with_seed(1, 42); + sim.h(&qid(0)); + let result = sim.mz(&qid(0)); + assert!(!result[0].is_deterministic, "H|0> = |+> should be non-deterministic for mz"); + } + + #[test] + fn test_bell_state_correlations() { + // Create Bell state and verify correlations over many seeds + for seed in 0..20 { + let mut sim = GraphState::with_seed(2, seed); + sim.h(&qid(0)); + sim.cx(&[QubitId::new(0), QubitId::new(1)]); + + let r0 = sim.mz(&qid(0)); + let r1 = sim.mz(&qid(1)); + assert!(!r0[0].is_deterministic); + assert!(r1[0].is_deterministic, "second qubit should be deterministic after first measured"); + assert_eq!(r0[0].outcome, r1[0].outcome, "Bell state qubits should be correlated"); + } + } + + #[test] + fn test_cz_creates_cluster_state() { + let mut sim = GraphState::with_seed(2, 42); + sim.h(&qid(0)); + sim.h(&[QubitId::new(1)]); + sim.cz(&[QubitId::new(0), QubitId::new(1)]); + + // CZ|++> should give a 2-qubit cluster state + // Measuring Z on qubit 0 should be non-deterministic + let r = sim.mz(&qid(0)); + assert!(!r[0].is_deterministic); + } + + #[test] + fn test_ghz_state() { + for seed in 0..20 { + let mut sim = GraphState::with_seed(3, seed); + sim.h(&qid(0)); + sim.cx(&[QubitId::new(0), QubitId::new(1)]); + sim.cx(&[QubitId::new(1), QubitId::new(2)]); + + let r0 = sim.mz(&qid(0)); + let r1 = sim.mz(&[QubitId::new(1)]); + let r2 = sim.mz(&[QubitId::new(2)]); + + assert!(!r0[0].is_deterministic); + assert_eq!(r0[0].outcome, r1[0].outcome, "GHZ: q0 == q1"); + assert_eq!(r1[0].outcome, r2[0].outcome, "GHZ: q1 == q2"); + } + } + + #[test] + fn test_measurement_idempotent() { + let mut sim = GraphState::with_seed(1, 42); + sim.h(&qid(0)); + let r1 = sim.mz(&qid(0)); + let r2 = sim.mz(&qid(0)); + assert!(r2[0].is_deterministic, "second measurement should be deterministic"); + assert_eq!(r1[0].outcome, r2[0].outcome, "repeated measurement should give same result"); + } + + #[test] + fn test_sz_gate() { + let mut sim = GraphState::with_seed(1, 42); + // SZ SZ = Z, and Z|0> = |0> + sim.sz(&qid(0)); + sim.sz(&qid(0)); + let result = sim.mz(&qid(0)); + assert!(result[0].is_deterministic); + assert!(!result[0].outcome, "Z|0> = |0>"); + } + + #[test] + fn test_cross_validation_random_circuits() { + use crate::stabilizer_test_utils::compare_simulators_on_random_circuits_direct; + use crate::SparseStab; + + let mut gs = GraphState::with_seed(6, 0); + let mut ss = SparseStab::with_seed(6, 0); + compare_simulators_on_random_circuits_direct(&mut gs, &mut ss, 6, 30, 50, 98765); + } +} diff --git a/crates/pecos-qsim/src/lib.rs b/crates/pecos-qsim/src/lib.rs index dae3efcd5..14f547fb4 100644 --- a/crates/pecos-qsim/src/lib.rs +++ b/crates/pecos-qsim/src/lib.rs @@ -18,6 +18,7 @@ pub mod clifford_gateable; pub mod clifford_test_utils; pub mod coin_toss; pub mod dense_stab; +pub mod graph_state; pub mod dense_stab_variants; pub mod density_matrix; pub mod density_matrix_test_utils; @@ -56,6 +57,7 @@ pub use dense_stab::DenseStab; pub use dense_stab_variants::{DenseStabColOnly, DenseStabRowOnly, SparseColOnly}; pub use density_matrix::DensityMatrix; pub use gens::{Gens, GensBitSet, GensGeneric, GensHybrid, GensVecSet, PauliClassification}; +pub use graph_state::GraphState; pub use gpu_stab::GpuStab; pub use gpu_stab_opt::GpuStabOpt; pub use gpu_stab_parallel::GpuStabParallel; diff --git a/crates/pecos-qsim/src/prelude.rs b/crates/pecos-qsim/src/prelude.rs index be2d45a5d..62349cede 100644 --- a/crates/pecos-qsim/src/prelude.rs +++ b/crates/pecos-qsim/src/prelude.rs @@ -17,6 +17,7 @@ pub use crate::{ clifford_gateable::{CliffordGateable, MeasurementResult}, coin_toss::CoinToss, gens::Gens, + graph_state::GraphState, measurement_sampler::{MeasurementSampler, SampleResult, SequentialMeasurementSampler}, pauli_prop::PauliProp, quantum_simulator::QuantumSimulator, From 382a7cc493b1e27389716a6b3b36bbcee9a2b51f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 28 Feb 2026 23:06:02 -0700 Subject: [PATCH 04/12] graph state representation and visualization --- crates/pecos-qsim/src/graph_state.rs | 52 +- crates/pecos-qsim/src/graph_state_repr.rs | 2086 +++++++++++++++++++++ crates/pecos-qsim/src/lib.rs | 4 +- crates/pecos-qsim/src/prelude.rs | 3 +- 4 files changed, 2123 insertions(+), 22 deletions(-) create mode 100644 crates/pecos-qsim/src/graph_state_repr.rs diff --git a/crates/pecos-qsim/src/graph_state.rs b/crates/pecos-qsim/src/graph_state.rs index 1369bd77b..d0ee7a533 100644 --- a/crates/pecos-qsim/src/graph_state.rs +++ b/crates/pecos-qsim/src/graph_state.rs @@ -40,12 +40,12 @@ use crate::stabilizer_test_utils::{ForcedMeasurement, StabilizerSimulator}; /// Single-qubit gates are O(1). Two-qubit gates are O(degree) amortized. /// Measurements are O(degree^2) in the worst case. #[derive(Clone, Debug)] -pub struct GraphState { +pub struct GraphStateSim { num_qubits: usize, /// Vertex operators: one single-qubit Clifford per qubit. - vops: Vec, + pub(crate) vops: Vec, /// Adjacency lists: `neighbors[v]` is the set of vertices adjacent to v. - neighbors: Vec, + pub(crate) neighbors: Vec, rng: R, } @@ -53,7 +53,7 @@ pub struct GraphState { // Constructors // ============================================================================ -impl GraphState { +impl GraphStateSim { /// Create a new graph state simulator with the default RNG. #[inline] #[must_use] @@ -71,7 +71,7 @@ impl GraphState { } } -impl GraphState { +impl GraphStateSim { /// Create a new graph state simulator with a custom RNG. #[inline] pub fn with_rng(num_qubits: usize, rng: R) -> Self { @@ -91,6 +91,18 @@ impl GraphState { self.num_qubits } + /// Extract the graph state representation (cloning VOPs and neighbors). + #[must_use] + pub fn to_graph_state(&self) -> crate::graph_state_repr::GraphState { + crate::graph_state_repr::GraphState::from_parts(self.vops.clone(), self.neighbors.clone()) + } + + /// Consume this simulator and return the graph state representation. + #[must_use] + pub fn into_graph_state(self) -> crate::graph_state_repr::GraphState { + crate::graph_state_repr::GraphState::from_parts(self.vops, self.neighbors) + } + // ======================================================================== // Internal: adjacency helpers // ======================================================================== @@ -466,7 +478,7 @@ impl GraphState { // Trait implementations // ============================================================================ -impl QuantumSimulator for GraphState { +impl QuantumSimulator for GraphStateSim { 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. @@ -480,7 +492,7 @@ impl QuantumSimulator for GraphState { } } -impl RngManageable for GraphState { +impl RngManageable for GraphStateSim { type Rng = R; fn set_rng(&mut self, rng: Self::Rng) { @@ -498,7 +510,7 @@ impl RngManageable for GraphState { } } -impl CliffordGateable for GraphState { +impl CliffordGateable for GraphStateSim { // -- Single-qubit gates: O(1) VOP composition -- fn x(&mut self, qubits: &[QubitId]) -> &mut Self { @@ -614,13 +626,13 @@ impl CliffordGateable for GraphState { // ForcedMeasurement & StabilizerSimulator // ============================================================================ -impl ForcedMeasurement for GraphState { +impl ForcedMeasurement for GraphStateSim { fn mz_forced(&mut self, qubit: usize, forced_outcome: bool) -> MeasurementResult { self.measure_z_internal(qubit, Some(forced_outcome)) } } -impl StabilizerSimulator for GraphState { +impl StabilizerSimulator for GraphStateSim { fn with_seed(num_qubits: usize, seed: u64) -> Self { Self::with_seed(num_qubits, seed) } @@ -636,11 +648,11 @@ mod tests { use crate::stabilizer_test_suite; use pecos_core::qid; - stabilizer_test_suite!(GraphState); + stabilizer_test_suite!(GraphStateSim); #[test] fn test_initial_state_is_all_zero() { - let mut sim = GraphState::with_seed(3, 42); + let mut sim = GraphStateSim::with_seed(3, 42); for i in 0..3 { let result = sim.mz(&[QubitId::new(i)]); assert!(result[0].is_deterministic, "qubit {i} should be deterministic"); @@ -650,7 +662,7 @@ mod tests { #[test] fn test_single_qubit_x_flips() { - let mut sim = GraphState::with_seed(1, 42); + let mut sim = GraphStateSim::with_seed(1, 42); sim.x(&qid(0)); let result = sim.mz(&qid(0)); assert!(result[0].is_deterministic); @@ -659,7 +671,7 @@ mod tests { #[test] fn test_hadamard_creates_superposition() { - let mut sim = GraphState::with_seed(1, 42); + let mut sim = GraphStateSim::with_seed(1, 42); sim.h(&qid(0)); let result = sim.mz(&qid(0)); assert!(!result[0].is_deterministic, "H|0> = |+> should be non-deterministic for mz"); @@ -669,7 +681,7 @@ mod tests { fn test_bell_state_correlations() { // Create Bell state and verify correlations over many seeds for seed in 0..20 { - let mut sim = GraphState::with_seed(2, seed); + let mut sim = GraphStateSim::with_seed(2, seed); sim.h(&qid(0)); sim.cx(&[QubitId::new(0), QubitId::new(1)]); @@ -683,7 +695,7 @@ mod tests { #[test] fn test_cz_creates_cluster_state() { - let mut sim = GraphState::with_seed(2, 42); + let mut sim = GraphStateSim::with_seed(2, 42); sim.h(&qid(0)); sim.h(&[QubitId::new(1)]); sim.cz(&[QubitId::new(0), QubitId::new(1)]); @@ -697,7 +709,7 @@ mod tests { #[test] fn test_ghz_state() { for seed in 0..20 { - let mut sim = GraphState::with_seed(3, seed); + let mut sim = GraphStateSim::with_seed(3, seed); sim.h(&qid(0)); sim.cx(&[QubitId::new(0), QubitId::new(1)]); sim.cx(&[QubitId::new(1), QubitId::new(2)]); @@ -714,7 +726,7 @@ mod tests { #[test] fn test_measurement_idempotent() { - let mut sim = GraphState::with_seed(1, 42); + let mut sim = GraphStateSim::with_seed(1, 42); sim.h(&qid(0)); let r1 = sim.mz(&qid(0)); let r2 = sim.mz(&qid(0)); @@ -724,7 +736,7 @@ mod tests { #[test] fn test_sz_gate() { - let mut sim = GraphState::with_seed(1, 42); + let mut sim = GraphStateSim::with_seed(1, 42); // SZ SZ = Z, and Z|0> = |0> sim.sz(&qid(0)); sim.sz(&qid(0)); @@ -738,7 +750,7 @@ mod tests { use crate::stabilizer_test_utils::compare_simulators_on_random_circuits_direct; use crate::SparseStab; - let mut gs = GraphState::with_seed(6, 0); + let mut gs = GraphStateSim::with_seed(6, 0); let mut ss = SparseStab::with_seed(6, 0); compare_simulators_on_random_circuits_direct(&mut gs, &mut ss, 6, 30, 50, 98765); } diff --git a/crates/pecos-qsim/src/graph_state_repr.rs b/crates/pecos-qsim/src/graph_state_repr.rs new file mode 100644 index 000000000..36f7bd414 --- /dev/null +++ b/crates/pecos-qsim/src/graph_state_repr.rs @@ -0,0 +1,2086 @@ +// 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. + +//! Graph state representation and manipulation API. +//! +//! This module provides [`GraphState`], a mathematical representation of graph states +//! for QEC researchers. Unlike [`GraphStateSim`](crate::GraphStateSim), which is a +//! circuit simulator (taking gates and measurements), `GraphState` is for constructing, +//! manipulating, and analyzing graph states as mathematical objects. +//! +//! # Graph states +//! +//! A graph state `|G>` is defined by an undirected graph G = (V, E). Each vertex +//! starts in `|+>`, then a CZ gate is applied for each edge. The stabilizer +//! generators are K_v = X_v * prod_{u in N(v)} Z_u. +//! +//! Any stabilizer state can be written as local Cliffords applied to a graph state: +//! `|psi> = (tensor_v VOP_v) |G>`. The VOP (vertex operator) on each qubit is a +//! single-qubit Clifford tracked as a [`CliffordFrame`]. +//! +//! # Examples +//! +//! ``` +//! use pecos_qsim::GraphState; +//! +//! // Create a 3-qubit linear cluster state: 0 - 1 - 2 +//! let gs = GraphState::linear_cluster(3); +//! assert_eq!(gs.num_qubits(), 3); +//! assert_eq!(gs.num_edges(), 2); +//! assert!(gs.has_edge(0, 1)); +//! assert!(gs.has_edge(1, 2)); +//! assert!(!gs.has_edge(0, 2)); +//! ``` +//! +//! # References +//! +//! - Hein, Eisert, Briegel, "Multi-party entanglement in graph states", +//! [quant-ph/0307130](https://arxiv.org/abs/quant-ph/0307130) +//! - Van den Nest, Dehaene, De Moor, "Graphical description of the action of +//! local Clifford transformations on graph states", +//! [quant-ph/0308151](https://arxiv.org/abs/quant-ph/0308151) + +use crate::clifford_frame::{CliffordFrame, PauliAxis}; +use core::fmt::{self, Write as _}; +use pecos_core::{BitSet, Pauli, Phase, PauliString, QuarterPhase}; +use pecos_rng::{PecosRng, SeedableRng}; +use std::collections::{BTreeSet, VecDeque}; + +// ============================================================================ +// Core type +// ============================================================================ + +/// A graph state representation for mathematical manipulation. +/// +/// Stores vertex operators (VOPs) and an adjacency graph. The quantum state is +/// `|psi> = (tensor_v VOP_v) |G>` where `|G>` is the graph state. +/// +/// Unlike [`GraphStateSim`](crate::GraphStateSim), this type has no RNG and is +/// not a circuit simulator. It is for constructing, transforming, and analyzing +/// graph states as mathematical objects. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GraphState { + vops: Vec, + neighbors: Vec, +} + +// ============================================================================ +// Constructors +// ============================================================================ + +impl GraphState { + /// Create an n-qubit graph state with all VOPs identity and no edges. + /// + /// This represents `|+>^n` (the tensor product of n `|+>` states). + #[must_use] + pub fn new(n: usize) -> Self { + Self { + vops: vec![CliffordFrame::IDENTITY; n], + neighbors: vec![BitSet::new(); n], + } + } + + /// Create a pure graph state from an edge list. + /// + /// All VOPs are identity. Panics if any vertex index is >= n. + #[must_use] + pub fn from_edges(n: usize, edges: &[(usize, usize)]) -> Self { + let mut gs = Self::new(n); + for &(u, v) in edges { + assert!(u < n && v < n, "vertex index out of range"); + assert!(u != v, "self-loops not allowed"); + gs.neighbors[u].insert(v); + gs.neighbors[v].insert(u); + } + gs + } + + /// Create a graph state from a symmetric boolean adjacency matrix. + /// + /// Panics if the matrix is not square or not symmetric. + #[must_use] + pub fn from_adjacency_matrix(matrix: &[Vec]) -> Self { + let n = matrix.len(); + for row in matrix { + assert_eq!(row.len(), n, "adjacency matrix must be square"); + } + let mut gs = Self::new(n); + for i in 0..n { + for j in (i + 1)..n { + assert_eq!( + matrix[i][j], matrix[j][i], + "adjacency matrix must be symmetric" + ); + if matrix[i][j] { + gs.neighbors[i].insert(j); + gs.neighbors[j].insert(i); + } + } + } + gs + } + + /// Create a graph state from raw parts (VOPs and adjacency lists). + /// + /// Panics if the lengths do not match. + #[must_use] + pub fn from_parts(vops: Vec, neighbors: Vec) -> Self { + assert_eq!( + vops.len(), + neighbors.len(), + "vops and neighbors must have the same length" + ); + Self { vops, neighbors } + } + + // ======================================================================== + // Pattern factories + // ======================================================================== + + /// Linear cluster state: 0-1-2-..-(n-1). + #[must_use] + pub fn linear_cluster(n: usize) -> Self { + if n == 0 { + return Self::new(0); + } + let edges: Vec<(usize, usize)> = (0..n - 1).map(|i| (i, i + 1)).collect(); + Self::from_edges(n, &edges) + } + + /// Ring graph state: 0-1-..-(n-1)-0. + /// + /// Requires n >= 3. + #[must_use] + pub fn ring(n: usize) -> Self { + assert!(n >= 3, "ring requires at least 3 vertices"); + let mut edges: Vec<(usize, usize)> = (0..n - 1).map(|i| (i, i + 1)).collect(); + edges.push((n - 1, 0)); + Self::from_edges(n, &edges) + } + + /// Star graph state: vertex 0 connected to all others. + #[must_use] + pub fn star(n: usize) -> Self { + assert!(n >= 2, "star requires at least 2 vertices"); + let edges: Vec<(usize, usize)> = (1..n).map(|i| (0, i)).collect(); + Self::from_edges(n, &edges) + } + + /// 2D rectangular lattice graph state. + #[must_use] + pub fn lattice_2d(rows: usize, cols: usize) -> Self { + let n = rows * cols; + let mut edges = Vec::new(); + for r in 0..rows { + for c in 0..cols { + let v = r * cols + c; + if c + 1 < cols { + edges.push((v, v + 1)); + } + if r + 1 < rows { + edges.push((v, v + cols)); + } + } + } + Self::from_edges(n, &edges) + } + + /// Complete graph state K_n. + #[must_use] + pub fn complete(n: usize) -> Self { + let mut edges = Vec::new(); + for i in 0..n { + for j in (i + 1)..n { + edges.push((i, j)); + } + } + Self::from_edges(n, &edges) + } +} + +// ============================================================================ +// Accessors +// ============================================================================ + +impl GraphState { + /// Returns the number of qubits (vertices). + #[inline] + #[must_use] + pub fn num_qubits(&self) -> usize { + self.vops.len() + } + + /// Returns the VOP (vertex operator) for vertex v. + #[inline] + #[must_use] + pub fn vop(&self, v: usize) -> CliffordFrame { + self.vops[v] + } + + /// Returns the neighbor set of vertex v. + #[inline] + #[must_use] + pub fn neighbors(&self, v: usize) -> &BitSet { + &self.neighbors[v] + } + + /// Returns true if there is an edge between u and v. + #[inline] + #[must_use] + pub fn has_edge(&self, u: usize, v: usize) -> bool { + self.neighbors[u].contains(v) + } + + /// Returns the degree of vertex v. + #[inline] + #[must_use] + pub fn degree(&self, v: usize) -> usize { + self.neighbors[v].len() + } + + /// Returns the total number of edges. + #[must_use] + pub fn num_edges(&self) -> usize { + let total: usize = self.neighbors.iter().map(BitSet::len).sum(); + total / 2 + } + + /// Iterate over all edges (u, v) with u < v. + pub fn edges(&self) -> impl Iterator + '_ { + let n = self.num_qubits(); + (0..n).flat_map(move |u| { + self.neighbors[u] + .iter() + .filter(move |&v| v > u) + .map(move |v| (u, v)) + }) + } + + /// Returns true if all VOPs are identity (a "pure" graph state). + #[must_use] + pub fn is_pure_graph_state(&self) -> bool { + self.vops.iter().all(|v| v.is_identity()) + } + + /// Returns the adjacency matrix as a vector of vectors. + #[must_use] + pub fn adjacency_matrix(&self) -> Vec> { + let n = self.num_qubits(); + let mut matrix = vec![vec![false; n]; n]; + for (u, v) in self.edges() { + matrix[u][v] = true; + matrix[v][u] = true; + } + matrix + } +} + +// ============================================================================ +// Mutators +// ============================================================================ + +impl GraphState { + /// Set the VOP for vertex v. + #[inline] + pub fn set_vop(&mut self, v: usize, cliff: CliffordFrame) { + self.vops[v] = cliff; + } + + /// Apply a local Clifford gate to vertex v (right-composes with existing VOP). + #[inline] + pub fn apply_local_clifford(&mut self, v: usize, gate: CliffordFrame) { + self.vops[v] = self.vops[v].compose(gate); + } + + /// Toggle edge (u, v): add if absent, remove if present. + pub fn toggle_edge(&mut self, u: usize, v: usize) { + assert_ne!(u, v, "self-loops not allowed"); + self.neighbors[u].toggle(v); + self.neighbors[v].toggle(u); + } + + /// Add edge (u, v). No-op if already present. + pub fn add_edge(&mut self, u: usize, v: usize) { + assert_ne!(u, v, "self-loops not allowed"); + self.neighbors[u].insert(v); + self.neighbors[v].insert(u); + } + + /// Remove edge (u, v). No-op if not present. + pub fn remove_edge(&mut self, u: usize, v: usize) { + self.neighbors[u].remove(v); + self.neighbors[v].remove(u); + } +} + +// ============================================================================ +// Local complementation +// ============================================================================ + +impl GraphState { + /// Perform local complementation about vertex v. + /// + /// This complements all edges among N(v) and updates VOPs: + /// - Prepend sqrt(-iX) = SXDG to VOP_v + /// - Prepend sqrt(iZ) = SZ to each neighbor's VOP + pub fn local_complement(&mut self, v: usize) { + let nbrs: Vec = self.neighbors[v].iter().collect(); + + // Complement edges among N(v) + for i in 0..nbrs.len() { + for j in (i + 1)..nbrs.len() { + self.neighbors[nbrs[i]].toggle(nbrs[j]); + self.neighbors[nbrs[j]].toggle(nbrs[i]); + } + } + + // Update VOPs: prepend SXDG to vertex v + self.vops[v] = CliffordFrame::SXDG.compose(self.vops[v]); + + // Prepend SZ to each neighbor + for &u in &nbrs { + self.vops[u] = CliffordFrame::SZ.compose(self.vops[u]); + } + } + + /// Perform a pivot on edge (u, v): LC(u), LC(v), LC(u). + /// + /// Panics if u and v are not adjacent. + pub fn pivot(&mut self, u: usize, v: usize) { + assert!( + self.has_edge(u, v), + "pivot requires u and v to be adjacent" + ); + self.local_complement(u); + self.local_complement(v); + self.local_complement(u); + } + + /// Graph-only local complementation: complement edges among N(v). + /// + /// Unlike [`local_complement`](Self::local_complement), this does NOT update VOPs. + /// Used internally for LC-orbit enumeration where we work with graphs only. + fn graph_local_complement(&mut self, v: usize) { + let nbrs: Vec = self.neighbors[v].iter().collect(); + for i in 0..nbrs.len() { + for j in (i + 1)..nbrs.len() { + self.neighbors[nbrs[i]].toggle(nbrs[j]); + self.neighbors[nbrs[j]].toggle(nbrs[i]); + } + } + } + + /// Absorb all VOPs into the graph, producing an equivalent pure graph state. + /// + /// Computes the stabilizer generators, then extracts the equivalent graph + /// from the canonical stabilizer form. For each generator, the X position + /// identifies the vertex, and Z positions identify its neighbors. + /// + /// Note: isolated vertices with non-identity VOPs cannot be fully absorbed + /// since there are no neighbors to use for LC operations. Their VOPs + /// remain unchanged. + pub fn absorb_vops(&mut self) { + if self.is_pure_graph_state() { + return; + } + + let n = self.num_qubits(); + + // Compute stabilizer generators for the current state + let gens = self.stabilizer_generators(); + + // Build a new pure graph state from the stabilizer generators. + // For a graph state, each stabilizer generator has exactly one X + // (or can be brought to that form). The generator for vertex v + // is: (+/-)X_v * prod_{u in N(v)} Z_u + // + // We need to find generators that have a single X and the rest Z/I. + // This works when the state is equivalent to a graph state (which + // any stabilizer state is, up to local Cliffords -- and our state + // IS local Cliffords applied to a graph state). + + // Try to extract graph structure from generators. + // For each generator, check if it has the form (+/-)X_v * (Z terms). + // If all generators have this form, we can directly read off the graph. + let mut new_neighbors = vec![BitSet::new(); n]; + let mut success = true; + + for (idx, g) in gens.iter().enumerate() { + // Find the single X position + let mut x_pos = None; + let mut valid = true; + + for q in 0..n { + match g.get(q) { + Pauli::X => { + if x_pos.is_some() { + valid = false; + break; + } + x_pos = Some(q); + } + Pauli::Y => { + valid = false; + break; + } + Pauli::Z | Pauli::I => {} + } + } + + if !valid || x_pos.is_none() { + success = false; + break; + } + + let v = x_pos.unwrap(); + if v != idx { + // Generator ordering doesn't match vertex ordering + // This could happen but shouldn't for our construction + success = false; + break; + } + + for q in 0..n { + if g.get(q) == Pauli::Z { + new_neighbors[v].insert(q); + } + } + } + + if success { + self.neighbors = new_neighbors; + for v in 0..n { + self.vops[v] = CliffordFrame::IDENTITY; + } + } + // If not successful (state has Y terms in generators), the VOPs + // cannot be trivially absorbed. This is fine for LC-equivalence + // which uses graph-only operations. + } +} + +// ============================================================================ +// Stabilizer extraction (Phase 3) +// ============================================================================ + +impl GraphState { + /// Compute the stabilizer generator for vertex v. + /// + /// The bare generator is K_v = X_v * prod_{u in N(v)} Z_u. + /// The conjugated generator is VOP_v(X_v) * prod_{u in N(v)} VOP_u(Z_u). + #[must_use] + pub fn stabilizer_generator(&self, v: usize) -> PauliString { + let n = self.num_qubits(); + let mut paulis = vec![Pauli::I; n]; + let mut phase = QuarterPhase::PlusOne; + + // Vertex v contributes: VOP_v maps X + let x_img = self.vops[v].x_image(); + paulis[v] = pauli_axis_to_pauli(x_img.axis); + if !x_img.positive { + phase = phase.multiply(&QuarterPhase::MinusOne); + } + + // Each neighbor u contributes: VOP_u maps Z + for u in self.neighbors[v].iter() { + let z_img = self.vops[u].z_image(); + let u_pauli = pauli_axis_to_pauli(z_img.axis); + + if !z_img.positive { + phase = phase.multiply(&QuarterPhase::MinusOne); + } + + // Multiply with existing Pauli at position u (could overlap if u == v's neighbor + // and there's already something there from a previous neighbor -- but neighbors + // are distinct from v, and each neighbor contributes to its own position) + if paulis[u] == Pauli::I { + paulis[u] = u_pauli; + } else { + // Two non-identity Paulis at same position: multiply them + let (result_pauli, extra_phase) = multiply_paulis(paulis[u], u_pauli); + paulis[u] = result_pauli; + phase = phase.multiply(&extra_phase); + } + } + + PauliString::from_paulis_with_phase(phase, &paulis) + } + + /// Compute all n stabilizer generators. + #[must_use] + pub fn stabilizer_generators(&self) -> Vec { + (0..self.num_qubits()) + .map(|v| self.stabilizer_generator(v)) + .collect() + } +} + +// ============================================================================ +// Conversions (Phase 4) +// ============================================================================ + +impl GraphState { + /// Convert into a simulator by providing an RNG. + #[must_use] + pub fn into_sim( + self, + rng: R, + ) -> crate::graph_state::GraphStateSim { + crate::graph_state::GraphStateSim::from_graph_state(self, rng) + } + + /// Convert into a simulator with a specific seed. + #[must_use] + pub fn into_sim_with_seed(self, seed: u64) -> crate::graph_state::GraphStateSim { + let rng = PecosRng::seed_from_u64(seed); + self.into_sim(rng) + } + + /// Tensor product of two graph states. + /// + /// The second graph state's vertex indices are shifted by `self.num_qubits()`. + #[must_use] + pub fn tensor_product(&self, other: &Self) -> Self { + let n1 = self.num_qubits(); + let n2 = other.num_qubits(); + let n = n1 + n2; + + let mut vops = self.vops.clone(); + vops.extend_from_slice(&other.vops); + + let mut neighbors = self.neighbors.clone(); + // Shift other's neighbor indices by n1 + for nbrs in &other.neighbors { + let mut shifted = BitSet::new(); + for u in nbrs.iter() { + shifted.insert(u + n1); + } + neighbors.push(shifted); + } + + debug_assert_eq!(vops.len(), n); + debug_assert_eq!(neighbors.len(), n); + + Self { vops, neighbors } + } + + /// Disconnect vertex v from all neighbors and reset its VOP to identity. + pub fn delete_vertex(&mut self, v: usize) { + let nbrs: Vec = self.neighbors[v].iter().collect(); + for &u in &nbrs { + self.neighbors[u].remove(v); + } + self.neighbors[v].clear(); + self.vops[v] = CliffordFrame::IDENTITY; + } + + /// Extract the induced subgraph on the given vertices, re-indexed 0, 1, 2, ... + #[must_use] + pub fn induced_subgraph(&self, vertices: &[usize]) -> Self { + let n = vertices.len(); + // Build mapping from old index to new index + let mut old_to_new = vec![None; self.num_qubits()]; + for (new_idx, &old_idx) in vertices.iter().enumerate() { + old_to_new[old_idx] = Some(new_idx); + } + + let mut vops = Vec::with_capacity(n); + let mut neighbors = vec![BitSet::new(); n]; + + for (new_idx, &old_idx) in vertices.iter().enumerate() { + vops.push(self.vops[old_idx]); + for u in self.neighbors[old_idx].iter() { + if let Some(new_u) = old_to_new[u] { + neighbors[new_idx].insert(new_u); + } + } + } + + Self { vops, neighbors } + } +} + +// ============================================================================ +// LC-equivalence (Phase 5) +// ============================================================================ + +impl GraphState { + /// Enumerate the entire LC orbit of this graph state. + /// + /// Returns all pure graph states (identity VOPs) reachable by graph-level + /// local complementations from this one's underlying graph. VOPs are + /// irrelevant for LC-equivalence since they are local Cliffords. + /// + /// Only practical for small graphs (the orbit can be exponential in size). + #[must_use] + pub fn lc_orbit(&self) -> Vec { + // Start from the underlying graph (ignoring VOPs) + let start = GraphState::from_parts( + vec![CliffordFrame::IDENTITY; self.num_qubits()], + self.neighbors.clone(), + ); + + let mut visited: BTreeSet>> = BTreeSet::new(); + let mut queue: VecDeque = VecDeque::new(); + let mut orbit: Vec = Vec::new(); + + visited.insert(start.adjacency_matrix()); + queue.push_back(start); + + while let Some(current) = queue.pop_front() { + let n = current.num_qubits(); + orbit.push(current.clone()); + + for v in 0..n { + if current.neighbors[v].is_empty() { + continue; + } + let mut next = current.clone(); + // Graph-only LC: just complement edges among N(v) + next.graph_local_complement(v); + + let adj = next.adjacency_matrix(); + if visited.insert(adj) { + queue.push_back(next); + } + } + } + + orbit + } + + /// Compute a canonical form for LC-equivalence. + /// + /// Returns the lexicographically smallest adjacency matrix reachable by + /// graph-level LC. Two graph states are LC-equivalent iff their canonical + /// forms are equal. VOPs are irrelevant (they are local Cliffords). + /// + /// Uses orbit enumeration, so only practical for small graphs. + #[must_use] + pub fn lc_canonical_form(&self) -> GraphState { + let orbit = self.lc_orbit(); + orbit + .into_iter() + .min_by(|a, b| { + let adj_a = a.adjacency_matrix(); + let adj_b = b.adjacency_matrix(); + adj_a.cmp(&adj_b) + }) + .expect("orbit is never empty") + } + + /// Check if two graph states are LC-equivalent. + /// + /// Two graph states are LC-equivalent if their underlying graphs are in + /// the same LC orbit. VOPs are irrelevant since they are local Cliffords. + #[must_use] + pub fn is_lc_equivalent(&self, other: &Self) -> bool { + let canon_self = self.lc_canonical_form(); + let canon_other = other.lc_canonical_form(); + canon_self.adjacency_matrix() == canon_other.adjacency_matrix() + } +} + +// ============================================================================ +// Export / Display (Phase 6) +// ============================================================================ + +/// Names for the 24 single-qubit Cliffords. +const CLIFFORD_NAMES: [&str; 24] = [ + "I", "X", "Y", "Z", "S", "Sdg", "H", "SH", "HS", "S2H", "HS2", "S3H", + "SHS", "HSH", "SHSH", "S2HS", "SHS2", "S3HS", "S2HS2", "S2HSH", "HS2HS", + "S3HS2", "S3HSH", "HS2HS3", +]; + +// ============================================================================ +// VOP Color Algebra +// ============================================================================ +// +// Three independent visual dimensions encode Clifford structure: +// +// 1. **Fill hue** — axis permutation coset (which pair of Pauli axes +// the Clifford interconverts, ignoring signs): +// Blue (#6495ED) — identity perm (X→X, Z→Z) +// Purple (#C850C0) — X↔Z swap (H-type) +// Gold (#DAA520) — X↔Y swap (S-type) +// Cyan (#00B4D8) — Y↔Z swap (SX-type) +// Gray — 3-cycle (dark #707070 = fwd X→Y→Z→X, +// light #B0B0B0 = inv X→Z→Y→X) +// +// 2. **Fill brightness** — sign parity of the Heisenberg action: +// Saturated — even parity (0 or 2 negative signs) +// Light — odd parity (1 negative sign) +// +// 3. **Stroke colour** — gate family (geometric rotation type on the +// Bloch sphere): +// Navy (#1E3A8A) — Pauli (identity / π-rotations) +// Green (#2D6A2E) — sqrt-of-Pauli / S-like (π/2 rotations) +// Maroon (#8B1A1A) — Hadamard-like (π rotations about face diagonals) +// Charcoal (#404040) — Face-like / cyclic (2π/3 rotations) + +/// Visual style for a VOP vertex: fill, stroke, and text colours. +struct VopStyle { + fill: &'static str, + stroke: &'static str, + text: &'static str, +} + +/// Precomputed visual styles for all 24 single-qubit Cliffords. +/// +/// Indexed by [`CliffordFrame::index()`]. Derived from the HEIS table: +/// unsigned axis permutation → fill hue, sign parity → brightness, +/// geometric rotation type → stroke colour. +#[rustfmt::skip] +const VOP_STYLES: [VopStyle; 24] = [ + // fill stroke text + // Identity coset (X→X, Z→Z) — Pauli family + VopStyle { fill: "#6495ED", stroke: "#1E3A8A", text: "white" }, // 0: I even + VopStyle { fill: "#A0BEF5", stroke: "#1E3A8A", text: "#333" }, // 1: X odd + VopStyle { fill: "#6495ED", stroke: "#1E3A8A", text: "white" }, // 2: Y even + VopStyle { fill: "#A0BEF5", stroke: "#1E3A8A", text: "#333" }, // 3: Z odd + // X↔Y coset (S-type) + VopStyle { fill: "#F0D080", stroke: "#2D6A2E", text: "#333" }, // 4: S odd, S-like + VopStyle { fill: "#DAA520", stroke: "#2D6A2E", text: "white" }, // 5: Sdg even, S-like + // X↔Z coset (H-type) + VopStyle { fill: "#C850C0", stroke: "#8B1A1A", text: "white" }, // 6: H even, H-like + // Cyclic forward (X→Y→Z→X) + VopStyle { fill: "#707070", stroke: "#404040", text: "white" }, // 7: SH F-like + // Cyclic inverse (X→Z→Y→X) + VopStyle { fill: "#B0B0B0", stroke: "#404040", text: "#333" }, // 8: HS F-like + // X↔Z coset cont. + VopStyle { fill: "#E8A0E0", stroke: "#2D6A2E", text: "#333" }, // 9: S²H odd, S-like (=SYdg) + VopStyle { fill: "#E8A0E0", stroke: "#2D6A2E", text: "#333" }, // 10: HS² odd, S-like (=SY) + // Cyclic forward cont. + VopStyle { fill: "#707070", stroke: "#404040", text: "white" }, // 11: S³H F-like + // Y↔Z coset (SX-type) + VopStyle { fill: "#80D8E8", stroke: "#2D6A2E", text: "#333" }, // 12: SHS odd, S-like (=SXdg) + VopStyle { fill: "#00B4D8", stroke: "#2D6A2E", text: "white" }, // 13: HSH even, S-like (=SX) + // Cyclic inverse cont. + VopStyle { fill: "#B0B0B0", stroke: "#404040", text: "#333" }, // 14: SHSH F-like + VopStyle { fill: "#B0B0B0", stroke: "#404040", text: "#333" }, // 15: S²HS F-like + // Cyclic forward cont. + VopStyle { fill: "#707070", stroke: "#404040", text: "white" }, // 16: SHS² F-like + // Y↔Z coset cont. + VopStyle { fill: "#00B4D8", stroke: "#8B1A1A", text: "white" }, // 17: S³HS even, H-like + // X↔Z coset cont. + VopStyle { fill: "#C850C0", stroke: "#8B1A1A", text: "white" }, // 18: S²HS² even, H-like + // Y↔Z coset cont. + VopStyle { fill: "#80D8E8", stroke: "#8B1A1A", text: "#333" }, // 19: S²HSH odd, H-like + // X↔Y coset cont. + VopStyle { fill: "#DAA520", stroke: "#8B1A1A", text: "white" }, // 20: HS²HS even, H-like + // Cyclic forward cont. + VopStyle { fill: "#707070", stroke: "#404040", text: "white" }, // 21: S³HS² F-like + // Cyclic inverse cont. + VopStyle { fill: "#B0B0B0", stroke: "#404040", text: "#333" }, // 22: S³HSH F-like + // X↔Y coset cont. + VopStyle { fill: "#F0D080", stroke: "#8B1A1A", text: "#333" }, // 23: HS²HS³ odd, H-like +]; + +/// Returns the visual style for a VOP by its Clifford index. +fn vop_style(idx: u8) -> &'static VopStyle { + &VOP_STYLES[idx as usize] +} + +/// ANSI SGR escape codes for each of the 24 single-qubit Cliffords. +/// +/// Encodes coset (colour) and sign parity (bold/normal): +/// Identity -> blue (34), X<->Z -> magenta (35), X<->Y -> yellow (33), +/// Y<->Z -> cyan (36), cyclic fwd -> white (37), cyclic inv -> bright black (90). +/// Even parity (saturated) -> bold; odd parity (light) -> normal. +#[rustfmt::skip] +const VOP_ANSI: [&str; 24] = [ + "\x1b[1;34m", // 0: I Identity even + "\x1b[34m", // 1: X Identity odd + "\x1b[1;34m", // 2: Y Identity even + "\x1b[34m", // 3: Z Identity odd + "\x1b[33m", // 4: S X<->Y odd + "\x1b[1;33m", // 5: Sdg X<->Y even + "\x1b[1;35m", // 6: H X<->Z even + "\x1b[1;37m", // 7: SH Cyclic fwd + "\x1b[90m", // 8: HS Cyclic inv + "\x1b[35m", // 9: S2H X<->Z odd + "\x1b[35m", // 10: HS2 X<->Z odd + "\x1b[1;37m", // 11: S3H Cyclic fwd + "\x1b[36m", // 12: SHS Y<->Z odd + "\x1b[1;36m", // 13: HSH Y<->Z even + "\x1b[90m", // 14: SHSH Cyclic inv + "\x1b[90m", // 15: S2HS Cyclic inv + "\x1b[1;37m", // 16: SHS2 Cyclic fwd + "\x1b[1;36m", // 17: S3HS Y<->Z even + "\x1b[1;35m", // 18: S2HS2 X<->Z even + "\x1b[36m", // 19: S2HSH Y<->Z odd + "\x1b[1;33m", // 20: HS2HS X<->Y even + "\x1b[1;37m", // 21: S3HS2 Cyclic fwd + "\x1b[90m", // 22: S3HSH Cyclic inv + "\x1b[33m", // 23: HS2HS3 X<->Y odd +]; + +/// Bracket pairs for each of the 24 Cliffords, encoding gate family. +/// +/// Pauli -> `( )`, S-like -> `[ ]`, H-like -> `< >`, F-like -> `{ }`. +#[rustfmt::skip] +const VOP_BRACKETS: [(&str, &str); 24] = [ + ("(", ")"), // 0: I Pauli + ("(", ")"), // 1: X Pauli + ("(", ")"), // 2: Y Pauli + ("(", ")"), // 3: Z Pauli + ("[", "]"), // 4: S S-like + ("[", "]"), // 5: Sdg S-like + ("<", ">"), // 6: H H-like + ("{", "}"), // 7: SH F-like + ("{", "}"), // 8: HS F-like + ("[", "]"), // 9: S2H S-like + ("[", "]"), // 10: HS2 S-like + ("{", "}"), // 11: S3H F-like + ("[", "]"), // 12: SHS S-like + ("[", "]"), // 13: HSH S-like + ("{", "}"), // 14: SHSH F-like + ("{", "}"), // 15: S2HS F-like + ("{", "}"), // 16: SHS2 F-like + ("<", ">"), // 17: S3HS H-like + ("<", ">"), // 18: S2HS2 H-like + ("<", ">"), // 19: S2HSH H-like + ("<", ">"), // 20: HS2HS H-like + ("{", "}"), // 21: S3HS2 F-like + ("{", "}"), // 22: S3HSH F-like + ("<", ">"), // 23: HS2HS3 H-like +]; + +/// Append a compact SVG legend showing coset hues and gate-family strokes. +fn svg_legend(svg: &mut String, width: f64, height: f64, legend_height: f64) { + let y_top = height - legend_height + 8.0; + let r = 6.0; // legend circle radius + + // Row 1: fill hues (axis permutation cosets) + let cosets: &[(&str, &str, &str)] = &[ + ("#6495ED", "#1E3A8A", "I/Pauli"), + ("#C850C0", "#6A006A", "X\u{2194}Z"), + ("#DAA520", "#8B6914", "X\u{2194}Y"), + ("#00B4D8", "#006880", "Y\u{2194}Z"), + ("#808080", "#404040", "Cyclic"), + ]; + + let total_items = cosets.len(); + let spacing = width / (total_items as f64 + 1.0); + + for (i, &(fill, stroke, label)) in cosets.iter().enumerate() { + let cx = spacing * (i as f64 + 1.0); + svg.push_str(&format!( + " \n" + )); + let tx = cx + r + 4.0; + svg.push_str(&format!( + " \ + {label}\n", + y_top + 3.0 + )); + } + + // Row 2: stroke colours (gate families) + let families: &[(&str, &str)] = &[ + ("#1E3A8A", "Pauli"), + ("#2D6A2E", "S-like"), + ("#8B1A1A", "H-like"), + ("#404040", "F-like"), + ]; + + let y_row2 = y_top + 18.0; + let fam_spacing = width / (families.len() as f64 + 1.0); + + for (i, &(stroke_col, label)) in families.iter().enumerate() { + let cx = fam_spacing * (i as f64 + 1.0); + svg.push_str(&format!( + " \n" + )); + let tx = cx + r + 4.0; + svg.push_str(&format!( + " \ + {label}\n", + y_row2 + 3.0 + )); + } +} + +/// Map a VOP fill hex colour to its TikZ colour name. +fn tikz_fill_name(hex: &str) -> &'static str { + match hex { + "#6495ED" => "vopIdentity", + "#A0BEF5" => "vopIdentityLt", + "#C850C0" => "vopXZ", + "#E8A0E0" => "vopXZLt", + "#DAA520" => "vopXY", + "#F0D080" => "vopXYLt", + "#00B4D8" => "vopYZ", + "#80D8E8" => "vopYZLt", + "#707070" => "vopCyclicFwd", + "#B0B0B0" => "vopCyclicInv", + _ => "black", + } +} + +/// Map a VOP stroke hex colour to its TikZ colour name. +fn tikz_stroke_name(hex: &str) -> &'static str { + match hex { + "#1E3A8A" => "famPauli", + "#2D6A2E" => "famSqrt", + "#8B1A1A" => "famHadamard", + "#404040" => "famCyclic", + _ => "black", + } +} + +impl GraphState { + /// Export to DOT format for Graphviz visualization. + /// + /// Vertices are coloured using the PECOS colour algebra (fill hue = axis + /// permutation coset, stroke = gate family). + #[must_use] + pub fn to_dot(&self) -> String { + let n = self.num_qubits(); + let mut dot = String::from("graph G {\n"); + dot.push_str(" node [shape=circle, style=filled, fontsize=12];\n"); + + for v in 0..n { + let idx = self.vops[v].index(); + let name = CLIFFORD_NAMES[idx as usize]; + let style = vop_style(idx); + dot.push_str(&format!( + " {v} [label=\"{v}\\n{name}\" fillcolor=\"{}\" \ + color=\"{}\" fontcolor=\"{}\"];\n", + style.fill, style.stroke, style.text + )); + } + + for (u, v) in self.edges() { + dot.push_str(&format!(" {u} -- {v};\n")); + } + + dot.push_str("}\n"); + dot + } + + /// Compute vertex positions using a circular layout. + /// + /// Returns (x, y) pairs for each vertex, centered at (`cx`, `cy`) with + /// the given `radius`. Single-vertex graphs place the vertex at center. + fn circular_layout(n: usize, cx: f64, cy: f64, radius: f64) -> Vec<(f64, f64)> { + if n == 0 { + return Vec::new(); + } + if n == 1 { + return vec![(cx, cy)]; + } + (0..n) + .map(|i| { + let angle = + -std::f64::consts::FRAC_PI_2 + 2.0 * std::f64::consts::PI * (i as f64) / (n as f64); + (cx + radius * angle.cos(), cy + radius * angle.sin()) + }) + .collect() + } + + /// Export to SVG format. + /// + /// Produces a standalone SVG image with vertices arranged in a circular + /// layout. Vertex colours encode Clifford structure via the PECOS colour + /// algebra (fill hue = axis permutation, brightness = sign parity, + /// stroke = gate family). Non-identity VOPs are labeled below their node. + /// A compact legend is drawn at the bottom. + #[must_use] + pub fn to_svg(&self) -> String { + let n = self.num_qubits(); + let node_radius = 20.0; + let layout_radius = if n <= 2 { 60.0 } else { 40.0 + 25.0 * n as f64 }; + let margin = node_radius + 40.0; + let width = 2.0 * (layout_radius + margin); + let legend_height = 50.0; + let height = width + legend_height; + let center = layout_radius + margin; + + let positions = Self::circular_layout(n, center, center, layout_radius); + + let mut svg = format!( + "\n" + ); + svg.push_str(&format!( + " \n" + )); + + // Draw edges + for (u, v) in self.edges() { + let (x1, y1) = positions[u]; + let (x2, y2) = positions[v]; + svg.push_str(&format!( + " \n" + )); + } + + // Draw vertices + for v in 0..n { + let (x, y) = positions[v]; + let idx = self.vops[v].index(); + let vop_name = CLIFFORD_NAMES[idx as usize]; + let style = vop_style(idx); + + svg.push_str(&format!( + " \n", + style.fill, style.stroke + )); + + // Vertex index label + svg.push_str(&format!( + " {v}\n", + style.text + )); + + // VOP label (below the node, only if non-identity) + if !self.vops[v].is_identity() { + let label_y = y + node_radius + 14.0; + svg.push_str(&format!( + " {vop_name}\n" + )); + } + } + + // Legend + svg_legend(&mut svg, width, height, legend_height); + + svg.push_str("\n"); + svg + } + + /// Export to TikZ format for LaTeX documents. + /// + /// Produces a `tikzpicture` environment with vertices coloured by the + /// PECOS colour algebra. Requires `\usepackage{tikz}` and + /// `\usepackage[dvipsnames]{xcolor}` (or `\usepackage{xcolor}`) in + /// your LaTeX preamble. + #[must_use] + pub fn to_tikz(&self) -> String { + let n = self.num_qubits(); + let radius = if n <= 2 { 1.5 } else { 1.0 + 0.5 * n as f64 }; + let positions = Self::circular_layout(n, 0.0, 0.0, radius); + + let mut tikz = String::from("\\begin{tikzpicture}\n"); + + // Colour definitions — fill hues (axis permutation cosets) + tikz.push_str(" % Fill: axis permutation coset (bright / light)\n"); + for &(name, hex) in &[ + ("vopIdentity", "6495ED"), ("vopIdentityLt", "A0BEF5"), + ("vopXZ", "C850C0"), ("vopXZLt", "E8A0E0"), + ("vopXY", "DAA520"), ("vopXYLt", "F0D080"), + ("vopYZ", "00B4D8"), ("vopYZLt", "80D8E8"), + ("vopCyclicFwd", "707070"), ("vopCyclicInv", "B0B0B0"), + ] { + tikz.push_str(&format!(" \\definecolor{{{name}}}{{HTML}}{{{hex}}}\n")); + } + // Stroke colours — gate families + tikz.push_str(" % Stroke: gate family\n"); + for &(name, hex) in &[ + ("famPauli", "1E3A8A"), + ("famSqrt", "2D6A2E"), + ("famHadamard", "8B1A1A"), + ("famCyclic", "404040"), + ] { + tikz.push_str(&format!(" \\definecolor{{{name}}}{{HTML}}{{{hex}}}\n")); + } + + // Base vertex style + tikz.push_str( + " \\tikzstyle{vertex}=[circle, minimum size=20pt, \ + inner sep=0pt, font=\\small, line width=1.5pt]\n", + ); + tikz.push_str( + " \\tikzstyle{vop label}=[font=\\scriptsize, text=gray]\n", + ); + + // Draw vertices + for v in 0..n { + let (x, y) = positions[v]; + let idx = self.vops[v].index(); + let style = vop_style(idx); + let fill_name = tikz_fill_name(style.fill); + let draw_name = tikz_stroke_name(style.stroke); + let text_opt = if style.text == "white" { ", text=white" } else { "" }; + + tikz.push_str(&format!( + " \\node[vertex, fill={fill_name}, draw={draw_name}{text_opt}] \ + (v{v}) at ({x:.2}, {y:.2}) {{{v}}};\n" + )); + + // VOP annotation + if !self.vops[v].is_identity() { + let vop_name = CLIFFORD_NAMES[idx as usize]; + let label_y = y - 0.45; + tikz.push_str(&format!( + " \\node[vop label] at ({x:.2}, {label_y:.2}) {{${vop_name}$}};\n" + )); + } + } + + // Draw edges + for (u, v) in self.edges() { + tikz.push_str(&format!(" \\draw (v{u}) -- (v{v});\n")); + } + + tikz.push_str("\\end{tikzpicture}\n"); + tikz + } + + /// Export as plain ASCII text (no escape codes). + /// + /// Compact vertex-per-line format. When all VOPs are identity (a pure + /// graph state), the VOP column is omitted for a clean adjacency-list + /// view. When any VOP is non-trivial, non-identity VOPs are shown in + /// brackets encoding gate family: `()` Pauli, `[]` S-like, `<>` H-like, + /// `{}` F-like. Identity vertices get a blank VOP column. + #[must_use] + pub fn to_ascii(&self) -> String { + self.format_ascii(false) + } + + /// Export as ANSI-colored ASCII text for terminal display. + /// + /// Same layout as [`to_ascii`](Self::to_ascii) with 16-color ANSI codes + /// encoding the coset (hue) and sign parity (bold = even, normal = odd). + /// A two-line legend is appended when non-identity VOPs are present. + #[must_use] + pub fn to_ascii_color(&self) -> String { + self.format_ascii(true) + } + + /// Shared layout logic for [`to_ascii`] and [`to_ascii_color`]. + fn format_ascii(&self, color: bool) -> String { + let n = self.num_qubits(); + let num_edges = self.num_edges(); + let mut out = format!("GraphState: {n} qubits, {num_edges} edges\n\n"); + + if n == 0 { + return out; + } + + let idx_width = (n - 1).to_string().len(); + let show_vops = !self.is_pure_graph_state(); + + // Compute maximum bracketed VOP width across non-identity vertices. + let max_vop_width = if show_vops { + (0..n) + .filter(|&v| !self.vops[v].is_identity()) + .map(|v| { + let idx = self.vops[v].index() as usize; + CLIFFORD_NAMES[idx].len() + 2 // +2 for brackets + }) + .max() + .unwrap_or(0) + } else { + 0 + }; + + for v in 0..n { + write!(out, " {v:>idx_width$}").unwrap(); + + if show_vops { + let idx = self.vops[v].index() as usize; + if self.vops[v].is_identity() { + // Blank VOP column so `--` aligns with other rows. + write!(out, " {: = self.neighbors[v].iter().collect(); + if !nbrs.is_empty() { + let nbr_str: Vec = nbrs.iter().map(|u| u.to_string()).collect(); + write!(out, " -- {}", nbr_str.join(", ")).unwrap(); + } + + out.push('\n'); + } + + if color && show_vops { + out.push('\n'); + out.push_str( + " \x1b[1;34mIdentity\x1b[0m \ + \x1b[1;35mX\u{2194}Z\x1b[0m \ + \x1b[1;33mX\u{2194}Y\x1b[0m \ + \x1b[1;36mY\u{2194}Z\x1b[0m \ + \x1b[1;37mCyc.fwd\x1b[0m \ + \x1b[90mCyc.inv\x1b[0m \ + (bold=even)\n", + ); + out.push_str(" ()Pauli []S-like <>H-like {}F-like\n"); + } + + out + } +} + +impl fmt::Display for GraphState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let n = self.num_qubits(); + write!(f, "GraphState({n} qubits")?; + + // Show non-identity VOPs + let non_id: Vec = (0..n) + .filter(|&v| !self.vops[v].is_identity()) + .map(|v| { + let name = CLIFFORD_NAMES[self.vops[v].index() as usize]; + format!("v{v}={name}") + }) + .collect(); + + if !non_id.is_empty() { + write!(f, ", VOPs: {}", non_id.join(", "))?; + } + + // Show edges + let edges: Vec = self.edges().map(|(u, v)| format!("{u}-{v}")).collect(); + if !edges.is_empty() { + write!(f, ", edges: {}", edges.join(", "))?; + } + + write!(f, ")") + } +} + +// ============================================================================ +// GraphStateSim conversion support +// ============================================================================ + +impl crate::graph_state::GraphStateSim { + /// Create a simulator from a graph state representation with a seed. + #[must_use] + pub fn from_graph_state_with_seed(gs: GraphState, seed: u64) -> Self { + let rng = PecosRng::seed_from_u64(seed); + Self::from_graph_state(gs, rng) + } +} + +impl crate::graph_state::GraphStateSim { + /// Create a simulator from a graph state representation. + #[must_use] + pub fn from_graph_state(gs: GraphState, rng: R) -> Self { + let num_qubits = gs.num_qubits(); + let mut sim = Self::with_rng(num_qubits, rng); + sim.vops = gs.vops; + sim.neighbors = gs.neighbors; + sim + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn pauli_axis_to_pauli(axis: PauliAxis) -> Pauli { + match axis { + PauliAxis::X => Pauli::X, + PauliAxis::Y => Pauli::Y, + PauliAxis::Z => Pauli::Z, + } +} + +/// Multiply two single-qubit Paulis, returning (result, phase). +/// P1 * P2 = phase * result +fn multiply_paulis(a: Pauli, b: Pauli) -> (Pauli, QuarterPhase) { + use Pauli::{I, X, Y, Z}; + match (a, b) { + (I, p) | (p, I) => (p, QuarterPhase::PlusOne), + (X, X) | (Y, Y) | (Z, Z) => (I, QuarterPhase::PlusOne), + (X, Y) => (Z, QuarterPhase::PlusI), + (Y, X) => (Z, QuarterPhase::MinusI), + (Y, Z) => (X, QuarterPhase::PlusI), + (Z, Y) => (X, QuarterPhase::MinusI), + (Z, X) => (Y, QuarterPhase::PlusI), + (X, Z) => (Y, QuarterPhase::MinusI), + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::CliffordGateable; + + // ======================================================================== + // Phase 1: Core type tests + // ======================================================================== + + #[test] + fn test_new_creates_plus_state() { + let gs = GraphState::new(3); + assert_eq!(gs.num_qubits(), 3); + assert_eq!(gs.num_edges(), 0); + assert!(gs.is_pure_graph_state()); + for v in 0..3 { + assert!(gs.vop(v).is_identity()); + assert_eq!(gs.degree(v), 0); + } + } + + #[test] + fn test_from_edges() { + let gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + assert_eq!(gs.num_qubits(), 3); + assert_eq!(gs.num_edges(), 2); + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(1, 2)); + assert!(!gs.has_edge(0, 2)); + assert_eq!(gs.degree(0), 1); + assert_eq!(gs.degree(1), 2); + assert_eq!(gs.degree(2), 1); + } + + #[test] + fn test_from_adjacency_matrix() { + let matrix = vec![ + vec![false, true, false], + vec![true, false, true], + vec![false, true, false], + ]; + let gs = GraphState::from_adjacency_matrix(&matrix); + assert_eq!(gs.num_edges(), 2); + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(1, 2)); + } + + #[test] + fn test_adjacency_matrix_roundtrip() { + let gs = GraphState::from_edges(4, &[(0, 1), (1, 2), (2, 3), (0, 3)]); + let matrix = gs.adjacency_matrix(); + let gs2 = GraphState::from_adjacency_matrix(&matrix); + assert_eq!(gs, gs2); + } + + #[test] + fn test_edges_iterator() { + let gs = GraphState::from_edges(4, &[(0, 1), (2, 3), (0, 3)]); + let mut edges: Vec<(usize, usize)> = gs.edges().collect(); + edges.sort(); + assert_eq!(edges, vec![(0, 1), (0, 3), (2, 3)]); + } + + #[test] + fn test_mutators() { + let mut gs = GraphState::new(3); + gs.add_edge(0, 1); + assert!(gs.has_edge(0, 1)); + gs.toggle_edge(0, 1); + assert!(!gs.has_edge(0, 1)); + gs.toggle_edge(1, 2); + assert!(gs.has_edge(1, 2)); + gs.remove_edge(1, 2); + assert!(!gs.has_edge(1, 2)); + } + + #[test] + fn test_set_vop_and_apply_local_clifford() { + let mut gs = GraphState::new(2); + gs.set_vop(0, CliffordFrame::H); + assert_eq!(gs.vop(0), CliffordFrame::H); + assert!(!gs.is_pure_graph_state()); + + gs.apply_local_clifford(0, CliffordFrame::H); + // H * H = I + assert!(gs.vop(0).is_identity()); + assert!(gs.is_pure_graph_state()); + } + + // ======================================================================== + // Phase 2: Patterns and local complementation + // ======================================================================== + + #[test] + fn test_linear_cluster() { + let gs = GraphState::linear_cluster(4); + assert_eq!(gs.num_qubits(), 4); + assert_eq!(gs.num_edges(), 3); + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(1, 2)); + assert!(gs.has_edge(2, 3)); + assert!(!gs.has_edge(0, 2)); + } + + #[test] + fn test_ring() { + let gs = GraphState::ring(4); + assert_eq!(gs.num_edges(), 4); + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(1, 2)); + assert!(gs.has_edge(2, 3)); + assert!(gs.has_edge(3, 0)); + } + + #[test] + fn test_star() { + let gs = GraphState::star(4); + assert_eq!(gs.num_edges(), 3); + for i in 1..4 { + assert!(gs.has_edge(0, i)); + } + assert!(!gs.has_edge(1, 2)); + } + + #[test] + fn test_lattice_2d() { + let gs = GraphState::lattice_2d(2, 3); + assert_eq!(gs.num_qubits(), 6); + // 2x3 grid: 7 edges (3 horizontal + 2 rows * 2 vertical-ish... actually: + // row 0: 0-1, 1-2 (2 horiz) + // row 1: 3-4, 4-5 (2 horiz) + // cols: 0-3, 1-4, 2-5 (3 vert) + // total = 7 + assert_eq!(gs.num_edges(), 7); + } + + #[test] + fn test_complete() { + let gs = GraphState::complete(4); + assert_eq!(gs.num_edges(), 6); // C(4,2) = 6 + for i in 0..4 { + for j in (i + 1)..4 { + assert!(gs.has_edge(i, j)); + } + } + } + + #[test] + fn test_local_complement_toggles_neighbor_edges() { + // Star on 4 vertices: 0 connected to 1, 2, 3 + let mut gs = GraphState::star(4); + assert!(!gs.has_edge(1, 2)); + assert!(!gs.has_edge(1, 3)); + assert!(!gs.has_edge(2, 3)); + + // LC on vertex 0: complement edges among {1, 2, 3} + gs.local_complement(0); + + // Now 1-2, 1-3, 2-3 should all exist (complete among neighbors) + assert!(gs.has_edge(1, 2)); + assert!(gs.has_edge(1, 3)); + assert!(gs.has_edge(2, 3)); + + // Original edges 0-1, 0-2, 0-3 should still exist + assert!(gs.has_edge(0, 1)); + assert!(gs.has_edge(0, 2)); + assert!(gs.has_edge(0, 3)); + } + + #[test] + fn test_local_complement_double_is_identity_on_graph() { + // Two LCs on the same vertex should restore the graph (but change VOPs) + let gs_orig = GraphState::star(4); + let mut gs = gs_orig.clone(); + + gs.local_complement(0); + gs.local_complement(0); + + // Graph should be restored + assert_eq!(gs.adjacency_matrix(), gs_orig.adjacency_matrix()); + } + + #[test] + fn test_pivot() { + let mut gs = GraphState::from_edges(4, &[(0, 1), (0, 2), (1, 3)]); + gs.pivot(0, 1); + // Pivot is LC(0), LC(1), LC(0) - it should complete without panicking + // and maintain valid state + assert_eq!(gs.num_qubits(), 4); + } + + #[test] + fn test_absorb_vops_on_pure_graph_state() { + // A pure graph state should remain unchanged + let gs_orig = GraphState::linear_cluster(4); + let mut gs = gs_orig.clone(); + gs.absorb_vops(); + assert!(gs.is_pure_graph_state()); + assert_eq!(gs.adjacency_matrix(), gs_orig.adjacency_matrix()); + } + + #[test] + fn test_absorb_vops_on_identity_vops() { + // Pure graph states with identity VOPs: generators have X_v Z_neighbors form + let gs = GraphState::linear_cluster(3); + let gens = gs.stabilizer_generators(); + + // Each generator should have exactly one X + for (v, g) in gens.iter().enumerate() { + assert_eq!(g.get(v), Pauli::X); + for u in 0..3 { + if u != v { + if gs.has_edge(v, u) { + assert_eq!(g.get(u), Pauli::Z); + } else { + assert_eq!(g.get(u), Pauli::I); + } + } + } + } + } + + #[test] + fn test_absorb_vops_produces_pure_graph_state() { + // Pure graph state: absorb is a no-op + let mut gs = GraphState::linear_cluster(4); + let adj_before = gs.adjacency_matrix(); + gs.absorb_vops(); + assert!(gs.is_pure_graph_state()); + assert_eq!(gs.adjacency_matrix(), adj_before); + } + + #[test] + fn test_absorb_vops_preserves_stabilizers() { + // Verify that absorb_vops preserves the stabilizer group + use pecos_core::PauliOperator; + + let mut gs = GraphState::linear_cluster(4); + gs.set_vop(1, CliffordFrame::SZ); + + // Compute stabilizers before absorb + let gens_before = gs.stabilizer_generators(); + + gs.absorb_vops(); + + // Compute stabilizers after absorb + let gens_after = gs.stabilizer_generators(); + + // All generators should commute across the two sets + // (same stabilizer group means mutual commutativity) + for ga in &gens_after { + for gb in &gens_before { + assert!( + ga.commutes_with(gb), + "absorb_vops should preserve stabilizer group" + ); + } + } + } + + // ======================================================================== + // Phase 3: Stabilizer extraction + // ======================================================================== + + #[test] + fn test_stabilizer_generator_single_qubit() { + // Single qubit |+> state: stabilizer is +X + let gs = GraphState::new(1); + let stab = gs.stabilizer_generator(0); + assert_eq!(stab.get(0), Pauli::X); + assert_eq!(stab.phase(), QuarterPhase::PlusOne); + } + + #[test] + fn test_stabilizer_generators_two_qubit_graph() { + // Two qubits with edge 0-1: |G> has stabilizers X_0 Z_1 and Z_0 X_1 + let gs = GraphState::from_edges(2, &[(0, 1)]); + let gens = gs.stabilizer_generators(); + + // Generator for vertex 0: X_0 * Z_1 + assert_eq!(gens[0].get(0), Pauli::X); + assert_eq!(gens[0].get(1), Pauli::Z); + assert_eq!(gens[0].phase(), QuarterPhase::PlusOne); + + // Generator for vertex 1: Z_0 * X_1 + assert_eq!(gens[1].get(0), Pauli::Z); + assert_eq!(gens[1].get(1), Pauli::X); + assert_eq!(gens[1].phase(), QuarterPhase::PlusOne); + } + + #[test] + fn test_stabilizer_generators_linear_cluster() { + // 3-qubit linear cluster 0-1-2 + // K_0 = X_0 Z_1 I_2 + // K_1 = Z_0 X_1 Z_2 + // K_2 = I_0 Z_1 X_2 + let gs = GraphState::linear_cluster(3); + let gens = gs.stabilizer_generators(); + + assert_eq!(gens[0].get(0), Pauli::X); + assert_eq!(gens[0].get(1), Pauli::Z); + assert_eq!(gens[0].get(2), Pauli::I); + + assert_eq!(gens[1].get(0), Pauli::Z); + assert_eq!(gens[1].get(1), Pauli::X); + assert_eq!(gens[1].get(2), Pauli::Z); + + assert_eq!(gens[2].get(0), Pauli::I); + assert_eq!(gens[2].get(1), Pauli::Z); + assert_eq!(gens[2].get(2), Pauli::X); + } + + #[test] + fn test_stabilizer_generators_commute() { + // All stabilizer generators of a graph state must commute + use pecos_core::PauliOperator; + + let gs = GraphState::linear_cluster(4); + let gens = gs.stabilizer_generators(); + + for i in 0..gens.len() { + for j in (i + 1)..gens.len() { + assert!( + gens[i].commutes_with(&gens[j]), + "generators {i} and {j} should commute" + ); + } + } + } + + #[test] + fn test_stabilizer_generators_with_vops() { + // Apply H to vertex 0 of a 2-qubit graph state + // This should conjugate the generator at vertex 0 + let mut gs = GraphState::from_edges(2, &[(0, 1)]); + gs.set_vop(0, CliffordFrame::H); + + let gens = gs.stabilizer_generators(); + + // H maps X->Z, Z->X. So: + // Generator for v0: H(X_0) * Z_1 = Z_0 * Z_1 + assert_eq!(gens[0].get(0), Pauli::Z); + assert_eq!(gens[0].get(1), Pauli::Z); + + // Generator for v1: H(Z_0) * X_1 = X_0 * X_1 + assert_eq!(gens[1].get(0), Pauli::X); + assert_eq!(gens[1].get(1), Pauli::X); + } + + #[test] + fn test_lc_preserves_stabilizer_group() { + // Local complementation should preserve the stabilizer group + // (generators may change but they should generate the same group). + // We verify by checking that all new generators commute with all old generators + // AND that new generators are in the stabilizer group of the original state. + use pecos_core::PauliOperator; + + let gs_before = GraphState::linear_cluster(3); + let gens_before = gs_before.stabilizer_generators(); + + let mut gs_after = gs_before.clone(); + gs_after.local_complement(1); + let gens_after = gs_after.stabilizer_generators(); + + // All generators after LC should commute with all generators before + for ga in &gens_after { + for gb in &gens_before { + assert!( + ga.commutes_with(gb), + "LC should preserve stabilizer group commutativity" + ); + } + } + } + + // ======================================================================== + // Phase 4: Conversions + // ======================================================================== + + #[test] + fn test_roundtrip_graph_state_to_sim() { + let gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + + let sim = gs.clone().into_sim_with_seed(42); + let gs2 = sim.to_graph_state(); + + assert_eq!(gs, gs2); + } + + #[test] + fn test_tensor_product() { + let a = GraphState::from_edges(2, &[(0, 1)]); + let b = GraphState::from_edges(2, &[(0, 1)]); + let ab = a.tensor_product(&b); + + assert_eq!(ab.num_qubits(), 4); + assert_eq!(ab.num_edges(), 2); + assert!(ab.has_edge(0, 1)); + assert!(ab.has_edge(2, 3)); + assert!(!ab.has_edge(1, 2)); + } + + #[test] + fn test_delete_vertex() { + let mut gs = GraphState::star(4); + gs.delete_vertex(0); + assert_eq!(gs.degree(0), 0); + assert!(gs.vop(0).is_identity()); + for i in 1..4 { + assert!(!gs.has_edge(0, i)); + } + } + + #[test] + fn test_induced_subgraph() { + let gs = GraphState::linear_cluster(5); // 0-1-2-3-4 + let sub = gs.induced_subgraph(&[1, 2, 3]); + + assert_eq!(sub.num_qubits(), 3); + assert_eq!(sub.num_edges(), 2); + assert!(sub.has_edge(0, 1)); // was 1-2 + assert!(sub.has_edge(1, 2)); // was 2-3 + } + + // ======================================================================== + // Phase 5: LC-equivalence + // ======================================================================== + + #[test] + fn test_lc_orbit_single_qubit() { + let gs = GraphState::new(1); + let orbit = gs.lc_orbit(); + // Single isolated qubit: LC is a no-op on graph structure + assert_eq!(orbit.len(), 1); + } + + #[test] + fn test_lc_orbit_two_qubit_edge() { + let gs = GraphState::from_edges(2, &[(0, 1)]); + let orbit = gs.lc_orbit(); + // Two vertices with one edge: LC on either vertex just toggles + // the edges among neighbors (which is empty for the non-target), + // so the graph stays the same. + assert_eq!(orbit.len(), 1); + } + + #[test] + fn test_lc_equivalence_star_complete() { + // K_4 and star on 4 vertices should be LC-equivalent + // (well-known result) + let star = GraphState::star(4); + let complete = GraphState::complete(4); + + // LC on center of star produces K_4 + assert!(star.is_lc_equivalent(&complete)); + } + + #[test] + fn test_lc_inequivalence() { + // 4-qubit linear cluster and 4-qubit ring are NOT LC-equivalent + // (they have different interlace polynomials) + let linear = GraphState::linear_cluster(4); + let ring = GraphState::ring(4); + assert!(!linear.is_lc_equivalent(&ring)); + } + + #[test] + fn test_lc_canonical_form_deterministic() { + let gs = GraphState::star(4); + let canon1 = gs.lc_canonical_form(); + let canon2 = gs.lc_canonical_form(); + assert_eq!(canon1, canon2); + } + + // ======================================================================== + // Phase 6: Export + // ======================================================================== + + #[test] + fn test_display() { + let gs = GraphState::linear_cluster(3); + let s = format!("{gs}"); + assert!(s.contains("3 qubits")); + assert!(s.contains("0-1")); + assert!(s.contains("1-2")); + } + + #[test] + fn test_to_dot() { + let gs = GraphState::from_edges(2, &[(0, 1)]); + let dot = gs.to_dot(); + assert!(dot.contains("graph G {")); + assert!(dot.contains("0 -- 1")); + assert!(dot.contains("}")); + } + + #[test] + fn test_to_svg() { + let gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + let svg = gs.to_svg(); + assert!(svg.contains("")); + // 3 vertex circles + 9 legend circles = 12, and 2 edge lines + assert_eq!(svg.matches("Z coset fill (H) + // Gate family strokes: Pauli (identity) vs H-like (H gate) + assert!(svg.contains("#1E3A8A")); // Pauli stroke + assert!(svg.contains("#8B1A1A")); // H-like stroke + } + + #[test] + fn test_to_svg_empty() { + let gs = GraphState::new(0); + let svg = gs.to_svg(); + assert!(svg.contains("")); + // No vertex circles, but legend has 5 coset + 4 family = 9 circles + assert_eq!(svg.matches(". Apply H to get |+>, then CZ for edges. + sim.h(&[QubitId::new(0), QubitId::new(1), QubitId::new(2)]); + sim.cz(&[QubitId::new(0), QubitId::new(1)]); + sim.cz(&[QubitId::new(1), QubitId::new(2)]); + + let sim_gs = sim.to_graph_state(); + let sim_gens = sim_gs.stabilizer_generators(); + + // Both should have the same stabilizer generators + // (possibly in different order or with different signs, but same Paulis) + assert_eq!(math_gens.len(), sim_gens.len()); + + // For a pure graph state with the same graph, generators should match exactly + for (i, (mg, sg)) in math_gens.iter().zip(sim_gens.iter()).enumerate() { + assert_eq!( + mg.phase(), + sg.phase(), + "generator {i}: phase mismatch" + ); + for q in 0..3 { + assert_eq!( + mg.get(q), + sg.get(q), + "generator {i}, qubit {q}: Pauli mismatch" + ); + } + } + } + + #[test] + fn test_cross_validate_roundtrip_preserves_measurement() { + // Build a state via simulator, convert to GraphState and back, + // verify measurements give same results. + use pecos_core::QubitId; + + let mut sim1 = crate::GraphStateSim::with_seed(3, 42); + sim1.h(&[QubitId::new(0), QubitId::new(1), QubitId::new(2)]); + sim1.cz(&[QubitId::new(0), QubitId::new(1)]); + sim1.cz(&[QubitId::new(1), QubitId::new(2)]); + + // Round-trip through GraphState + let gs = sim1.to_graph_state(); + let mut sim2 = gs.into_sim_with_seed(42); + + // Both sims should produce the same measurement outcomes (same seed) + let r1 = sim1.mz(&[QubitId::new(0)]); + let r2 = sim2.mz(&[QubitId::new(0)]); + assert_eq!(r1[0].outcome, r2[0].outcome); + } + + // ======================================================================== + // ASCII export + // ======================================================================== + + #[test] + fn test_to_ascii_pure_graph_state() { + let gs = GraphState::linear_cluster(3); + let ascii = gs.to_ascii(); + + // Header + assert!(ascii.contains("GraphState: 3 qubits, 2 edges")); + + // Pure graph state: VOP column is omitted entirely + assert!(!ascii.contains("(I)"), "identity VOPs should be hidden: {ascii}"); + + // Edge info + assert!(ascii.contains("-- 1")); + assert!(ascii.contains("-- 0, 2")); + + // No ANSI escapes + assert!(!ascii.contains("\x1b[")); + } + + #[test] + fn test_to_ascii_color_contains_ansi() { + // Need non-identity VOPs for color output (pure states have no VOPs to color) + let mut gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + gs.set_vop(0, CliffordFrame::H); + let colored = gs.to_ascii_color(); + + // Should contain ANSI escape codes and resets + assert!(colored.contains("\x1b["), "missing ANSI codes: {colored}"); + assert!(colored.contains("\x1b[0m")); + + // Should still have structure + assert!(colored.contains("GraphState: 3 qubits, 2 edges")); + assert!(colored.contains("")); + + // Legend + assert!(colored.contains("()Pauli")); + assert!(colored.contains("bold=even")); + } + + #[test] + fn test_to_ascii_color_pure_has_no_ansi() { + // Pure graph state: nothing to color, no legend + let gs = GraphState::linear_cluster(3); + let colored = gs.to_ascii_color(); + assert!(!colored.contains("\x1b["), "pure state should have no ANSI: {colored}"); + assert!(!colored.contains("Pauli"), "pure state should have no legend"); + } + + #[test] + fn test_to_ascii_isolated_vertices() { + let gs = GraphState::new(2); + let ascii = gs.to_ascii(); + + // Isolated pure graph: no edges, no VOP column + assert!(!ascii.contains("--")); + assert!(ascii.contains("2 qubits")); + assert!(ascii.contains("0 edges")); + } + + #[test] + fn test_to_ascii_non_identity_vops() { + let mut gs = GraphState::from_edges(2, &[(0, 1)]); + gs.set_vop(0, CliffordFrame::H); + let ascii = gs.to_ascii(); + + // H is H-like family -> angle brackets + assert!(ascii.contains(""), "H bracket missing: {ascii}"); + // Vertex 1 is identity -> blank VOP column (no brackets) + assert!(!ascii.contains("(I)"), "identity should be blank: {ascii}"); + } + + #[test] + fn test_to_ascii_bracket_families() { + let mut gs = GraphState::new(4); + gs.set_vop(0, CliffordFrame::from_index(1)); // idx 1: X, Pauli -> () + gs.set_vop(1, CliffordFrame::SZ); // idx 4: S-like -> [] + gs.set_vop(2, CliffordFrame::H); // idx 6: H-like -> <> + gs.set_vop(3, CliffordFrame::from_index(7)); // idx 7: F-like -> {} + let ascii = gs.to_ascii(); + + assert!(ascii.contains("(X)"), "Pauli bracket missing: {ascii}"); + assert!(ascii.contains("[S]"), "S-like bracket missing: {ascii}"); + assert!(ascii.contains(""), "H-like bracket missing: {ascii}"); + assert!(ascii.contains("{SH}"), "F-like bracket missing: {ascii}"); + } + + #[test] + fn test_to_ascii_identity_alignment() { + // When mixed VOPs are present, identity and non-identity rows + // should have `--` at the same column. + let mut gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + gs.set_vop(0, CliffordFrame::H); + let ascii = gs.to_ascii(); + + // Find the `--` column for each line that has neighbors + let dash_cols: Vec = ascii + .lines() + .filter_map(|line| line.find("--")) + .collect(); + assert!(dash_cols.len() >= 2, "expected at least 2 lines with --"); + assert!( + dash_cols.windows(2).all(|w| w[0] == w[1]), + "-- columns should align: {dash_cols:?}\n{ascii}" + ); + } + + #[test] + fn test_to_ascii_empty_graph() { + let gs = GraphState::new(0); + let ascii = gs.to_ascii(); + assert!(ascii.contains("0 qubits, 0 edges")); + } +} diff --git a/crates/pecos-qsim/src/lib.rs b/crates/pecos-qsim/src/lib.rs index 14f547fb4..c5090c40c 100644 --- a/crates/pecos-qsim/src/lib.rs +++ b/crates/pecos-qsim/src/lib.rs @@ -19,6 +19,7 @@ pub mod clifford_test_utils; pub mod coin_toss; pub mod dense_stab; pub mod graph_state; +pub mod graph_state_repr; pub mod dense_stab_variants; pub mod density_matrix; pub mod density_matrix_test_utils; @@ -57,7 +58,8 @@ pub use dense_stab::DenseStab; pub use dense_stab_variants::{DenseStabColOnly, DenseStabRowOnly, SparseColOnly}; pub use density_matrix::DensityMatrix; pub use gens::{Gens, GensBitSet, GensGeneric, GensHybrid, GensVecSet, PauliClassification}; -pub use graph_state::GraphState; +pub use graph_state::GraphStateSim; +pub use graph_state_repr::GraphState; pub use gpu_stab::GpuStab; pub use gpu_stab_opt::GpuStabOpt; pub use gpu_stab_parallel::GpuStabParallel; diff --git a/crates/pecos-qsim/src/prelude.rs b/crates/pecos-qsim/src/prelude.rs index 62349cede..76205bcfc 100644 --- a/crates/pecos-qsim/src/prelude.rs +++ b/crates/pecos-qsim/src/prelude.rs @@ -17,7 +17,8 @@ pub use crate::{ clifford_gateable::{CliffordGateable, MeasurementResult}, coin_toss::CoinToss, gens::Gens, - graph_state::GraphState, + graph_state::GraphStateSim, + graph_state_repr::GraphState, measurement_sampler::{MeasurementSampler, SampleResult, SequentialMeasurementSampler}, pauli_prop::PauliProp, quantum_simulator::QuantumSimulator, From fd2a0a1e167682f1c022c99dccd082239562f014 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 1 Mar 2026 12:17:57 -0700 Subject: [PATCH 05/12] visualizations --- crates/pecos-core/src/circuit_diagram.rs | 1427 +++++++++++++++++++ crates/pecos-core/src/lib.rs | 1 + crates/pecos-core/src/operator.rs | 368 ++--- crates/pecos-qsim/src/graph_state.rs | 54 + crates/pecos-qsim/src/graph_state_repr.rs | 37 +- crates/pecos-quantum/src/circuit_display.rs | 696 +++++++++ crates/pecos-quantum/src/dag_circuit.rs | 86 ++ crates/pecos-quantum/src/lib.rs | 1 + crates/pecos-quantum/src/tick_circuit.rs | 81 ++ 9 files changed, 2527 insertions(+), 224 deletions(-) create mode 100644 crates/pecos-core/src/circuit_diagram.rs create mode 100644 crates/pecos-quantum/src/circuit_display.rs diff --git a/crates/pecos-core/src/circuit_diagram.rs b/crates/pecos-core/src/circuit_diagram.rs new file mode 100644 index 000000000..e07f585e0 --- /dev/null +++ b/crates/pecos-core/src/circuit_diagram.rs @@ -0,0 +1,1427 @@ +// 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 circuit diagram rendering engine. +//! +//! Produces horizontal qubit-wire diagrams with gate columns, used by +//! [`Operator`](crate::Operator), [`TickCircuit`], and [`DagCircuit`]. + +use std::fmt::Write; + +// ============================================================================ +// Types +// ============================================================================ + +/// What occupies a single (row, column) position in the diagram grid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DiagramCell { + /// Empty wire segment. + Wire, + /// A gate symbol to render on this qubit wire, with its family style. + Gate(String, GateFamily), + /// Control dot for a multi-qubit gate. + Control, + /// Vertical connector between qubits of a multi-qubit gate. + Connector, + /// Wire crossing: a wire passes through a vertical connector. + Crossing, +} + +/// Color category for a diagram cell. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CellColor { + /// No special color (default terminal color). + #[default] + None, + /// Single-qubit gate (blue). + SingleQubit, + /// Multi-qubit gate (green). + MultiQubit, + /// Measurement (yellow). + Measurement, + /// Preparation / allocation (cyan). + Preparation, + /// Control dot (bold green). + ControlDot, +} + +impl CellColor { + /// SVG/DOT fill color (light tint for gates, solid for controls). + #[must_use] + pub fn hex_fill(self) -> &'static str { + match self { + Self::None => "#FFFFFF", + Self::SingleQubit => "#A8C8F0", + Self::MultiQubit => "#A8E0A8", + Self::Measurement => "#F0E0A0", + Self::Preparation => "#A0E8E8", + Self::ControlDot => "#2D8A2D", + } + } + + /// SVG/TikZ border/stroke color. + #[must_use] + pub fn hex_stroke(self) -> &'static str { + match self { + Self::None => "#888888", + Self::SingleQubit => "#2255AA", + Self::MultiQubit => "#226622", + Self::Measurement => "#AA8800", + Self::Preparation => "#008888", + Self::ControlDot => "#1A5A1A", + } + } + + /// Text color inside gates (SVG/TikZ). + #[must_use] + pub fn hex_text(self) -> &'static str { + match self { + Self::None => "#333333", + Self::SingleQubit => "#1A3A7A", + Self::MultiQubit => "#1A4A1A", + Self::Measurement => "#6A5500", + Self::Preparation => "#005A5A", + Self::ControlDot => "#FFFFFF", + } + } + + /// Short name for `\definecolor` in `TikZ`. + #[must_use] + pub fn tikz_name(self) -> &'static str { + match self { + Self::None => "cellNone", + Self::SingleQubit => "cellSQ", + Self::MultiQubit => "cellMQ", + Self::Measurement => "cellMeas", + Self::Preparation => "cellPrep", + Self::ControlDot => "cellCtrl", + } + } +} + +/// Gate family classification for visual bracket/stroke styling. +/// +/// This provides a second visual dimension (shape/stroke) orthogonal to the +/// existing color dimension. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum GateFamily { + /// Default bracket style `[T]`, solid stroke. + #[default] + Default, + /// Pauli gates `(X)`, solid stroke. + Pauli, + /// S-like gates `[SZ]`, dashed stroke. + SLike, + /// Hadamard-like gates ``, dotted stroke. + HLike, + /// F-like composites `{F}`, dash-dot stroke (reserved). + FLike, + /// Measurement gates `|MZ)`, solid stroke. + Measurement, + /// Preparation gates `(PZ|`, solid stroke. + Preparation, +} + +impl GateFamily { + /// Opening bracket for text rendering. + #[must_use] + pub fn open_bracket(self) -> &'static str { + match self { + Self::Default | Self::SLike => "[", + Self::Pauli | Self::Preparation => "(", + Self::HLike => "<", + Self::FLike => "{", + Self::Measurement => "|", + } + } + + /// Closing bracket for text rendering. + #[must_use] + pub fn close_bracket(self) -> &'static str { + match self { + Self::Default | Self::SLike => "]", + Self::Pauli | Self::Measurement => ")", + Self::HLike => ">", + Self::FLike => "}", + Self::Preparation => "|", + } + } + + /// SVG `stroke-dasharray` value. Empty string means solid. + #[must_use] + pub fn svg_dasharray(self) -> &'static str { + match self { + Self::Default | Self::Pauli | Self::Measurement | Self::Preparation => "", + Self::SLike => "4,3", + Self::HLike => "2,2", + Self::FLike => "6,2,2,2", + } + } + + /// `TikZ` dash pattern name. Empty string means solid. + #[must_use] + pub fn tikz_dash(self) -> &'static str { + match self { + Self::Default | Self::Pauli | Self::Measurement | Self::Preparation => "", + Self::SLike => "dashed", + Self::HLike => "dotted", + Self::FLike => "dashdotted", + } + } + + /// DOT/Graphviz `style` value. Empty string means default (solid). + #[must_use] + pub fn dot_style(self) -> &'static str { + match self { + Self::Default | Self::Pauli | Self::Measurement | Self::Preparation => "", + Self::SLike | Self::FLike => "dashed", + Self::HLike => "dotted", + } + } +} + +/// Which character set to use for rendering. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SymbolSet { + /// Plain ASCII: `-`, `|`, `.`, `+` + #[default] + Ascii, + /// Unicode box-drawing: `─`, `│`, `●`, `+` + Unicode, +} + +/// Options controlling diagram appearance. +#[derive(Clone, Debug)] +pub struct DiagramOptions { + pub symbols: SymbolSet, + pub color: bool, +} + +impl DiagramOptions { + /// Plain ASCII, no color. + #[must_use] + pub fn ascii() -> Self { + Self { + symbols: SymbolSet::Ascii, + color: false, + } + } + + /// ASCII with ANSI color. + #[must_use] + pub fn color_ascii() -> Self { + Self { + symbols: SymbolSet::Ascii, + color: true, + } + } + + /// Unicode box-drawing, no color. + #[must_use] + pub fn unicode() -> Self { + Self { + symbols: SymbolSet::Unicode, + color: false, + } + } + + /// Unicode box-drawing with ANSI color. + #[must_use] + pub fn color_unicode() -> Self { + Self { + symbols: SymbolSet::Unicode, + color: true, + } + } +} + +// ============================================================================ +// ANSI color codes +// ============================================================================ + +const ANSI_RESET: &str = "\x1b[0m"; + +fn ansi_code(color: CellColor) -> &'static str { + match color { + CellColor::None => "", + CellColor::SingleQubit => "\x1b[34m", + CellColor::MultiQubit => "\x1b[32m", + CellColor::Measurement => "\x1b[33m", + CellColor::Preparation => "\x1b[36m", + CellColor::ControlDot => "\x1b[1;32m", + } +} + +// ============================================================================ +// CircuitDiagram builder +// ============================================================================ + +/// A grid-based circuit diagram builder. +/// +/// The diagram is organized as a grid of `columns x rows`, where each row +/// corresponds to a qubit wire and each column to a time step / layer. +pub struct CircuitDiagram { + labels: Vec, + columns: Vec>, + current_col: usize, +} + +impl CircuitDiagram { + /// Create a new diagram for `n` qubits with default labels `q0`, `q1`, ... + #[must_use] + pub fn new(n: usize) -> Self { + let labels: Vec = (0..n).map(|i| format!("q{i}")).collect(); + Self { + labels, + columns: vec![vec![(DiagramCell::Wire, CellColor::None); n]], + current_col: 0, + } + } + + /// Create a new diagram with custom labels. + #[must_use] + pub fn with_labels(labels: Vec) -> Self { + let n = labels.len(); + Self { + labels, + columns: vec![vec![(DiagramCell::Wire, CellColor::None); n]], + current_col: 0, + } + } + + /// Number of qubit rows. + #[must_use] + pub fn num_rows(&self) -> usize { + self.labels.len() + } + + fn ensure_column(&mut self) { + while self.current_col >= self.columns.len() { + self.columns.push(vec![ + (DiagramCell::Wire, CellColor::None); + self.num_rows() + ]); + } + } + + /// Set a cell at the given row in the current column. + pub fn set_cell(&mut self, row: usize, cell: DiagramCell, color: CellColor) { + self.ensure_column(); + if row < self.num_rows() { + self.columns[self.current_col][row] = (cell, color); + } + } + + /// Place a gate symbol on a row with a family bracket/stroke style. + pub fn add_gate(&mut self, row: usize, name: &str, color: CellColor, family: GateFamily) { + self.set_cell(row, DiagramCell::Gate(name.to_string(), family), color); + } + + /// Place a control dot on a row. + pub fn add_control(&mut self, row: usize) { + self.set_cell(row, DiagramCell::Control, CellColor::ControlDot); + } + + /// Fill vertical connectors/crossings between `top` and `bottom` (exclusive). + /// + /// Rows that are qubit wires get `Crossing`; other rows get `Connector`. + /// Since every row in a `CircuitDiagram` is a qubit wire, this always + /// places `Crossing` cells. The `color` is applied to all intermediate cells. + pub fn connect_vertical(&mut self, top: usize, bottom: usize, color: CellColor) { + self.ensure_column(); + let (lo, hi) = if top < bottom { + (top, bottom) + } else { + (bottom, top) + }; + for row in (lo + 1)..hi { + if row < self.num_rows() { + // All rows in CircuitDiagram are qubit wires -> Crossing. + self.columns[self.current_col][row] = (DiagramCell::Crossing, color); + } + } + } + + /// Advance to the next column. + pub fn advance(&mut self) { + self.current_col += 1; + } + + /// Render the diagram to a string. + /// + /// If `header` is non-empty, it is printed as the first line followed by + /// a blank line. + #[must_use] + pub fn render(&self, header: &str, options: &DiagramOptions) -> String { + let num_rows = self.num_rows(); + if num_rows == 0 { + return if header.is_empty() { + String::new() + } else { + format!("{header}\n") + }; + } + + // Strip trailing all-Wire columns. + let num_cols = self.effective_columns(); + if num_cols == 0 { + return if header.is_empty() { + String::new() + } else { + format!("{header}\n") + }; + } + + // Column widths (based on widest cell content). + let col_widths: Vec = (0..num_cols) + .map(|c| { + self.columns[c] + .iter() + .map(|(cell, _)| cell_content_width(cell)) + .max() + .unwrap_or(1) + }) + .collect(); + + let label_width = self.labels.iter().map(String::len).max().unwrap_or(2); + + let wire_char = match options.symbols { + SymbolSet::Ascii => '-', + SymbolSet::Unicode => '\u{2500}', // ─ + }; + + let mut out = String::new(); + if !header.is_empty() { + writeln!(out, "{header}").unwrap(); + writeln!(out).unwrap(); + } + + for row in 0..num_rows { + write!(out, "{:>label_width$}: ", self.labels[row]).unwrap(); + + for (col_idx, &width) in col_widths.iter().enumerate() { + let (ref cell, color) = self.columns[col_idx][row]; + let rendered = render_cell(cell, width, wire_char, options); + + if options.color && !matches!(cell, DiagramCell::Wire) { + let code = ansi_code(color); + if code.is_empty() { + write!(out, "{wire_char}{rendered}{wire_char}").unwrap(); + } else { + write!(out, "{wire_char}{code}{rendered}{ANSI_RESET}{wire_char}").unwrap(); + } + } else { + write!(out, "{wire_char}{rendered}{wire_char}").unwrap(); + } + } + + writeln!(out).unwrap(); + + // Connector row between qubit wires. + if row + 1 < num_rows { + let connector_line = + self.render_connector_row(row, num_cols, &col_widths, options); + if let Some(line) = connector_line { + writeln!(out, "{}", line.trim_end()).unwrap(); + } + } + } + + out + } + + // ======================================================================== + // SVG rendering + // ======================================================================== + + /// Render the diagram as a standalone SVG string. + /// + /// If `header` is non-empty it is rendered as a `` title at the top. + #[must_use] + pub fn render_svg(&self, header: &str) -> String { + const ROW_SPACING: f64 = 40.0; + const COL_SPACING: f64 = 60.0; + const GATE_H: f64 = 24.0; + const LABEL_MARGIN: f64 = 50.0; + const CTRL_RADIUS: f64 = 5.0; + const FONT_SIZE: f64 = 13.0; + const GATE_RX: f64 = 4.0; + + let num_rows = self.num_rows(); + let num_cols = self.effective_columns(); + + if num_rows == 0 || num_cols == 0 { + return if header.is_empty() { + "".to_string() + } else { + format!( + "\ + {header}\ + " + ) + }; + } + + // Column widths in characters (used to compute gate box widths). + let col_widths: Vec = (0..num_cols) + .map(|c| { + self.columns[c] + .iter() + .map(|(cell, _)| cell_content_width(cell)) + .max() + .unwrap_or(1) + }) + .collect(); + + let header_offset: f64 = if header.is_empty() { 0.0 } else { 30.0 }; + let svg_width = + LABEL_MARGIN + (num_cols as f64) * COL_SPACING + COL_SPACING * 0.5 + 20.0; + let svg_height = header_offset + (num_rows as f64) * ROW_SPACING + ROW_SPACING * 0.5; + + let mut out = String::new(); + writeln!( + out, + "" + ) + .unwrap(); + writeln!( + out, + "" + ) + .unwrap(); + + if !header.is_empty() { + writeln!( + out, + "{header}" + ) + .unwrap(); + } + + // Qubit labels and wires. + for row in 0..num_rows { + let y = header_offset + ROW_SPACING * (row as f64 + 0.5); + // Label + writeln!( + out, + "{label}", + x = LABEL_MARGIN - 6.0, + ty = y, + label = self.labels[row], + ) + .unwrap(); + // Wire + let x_end = LABEL_MARGIN + (num_cols as f64) * COL_SPACING; + writeln!( + out, + "", + ) + .unwrap(); + } + + // Gate cells. + for (col_idx, col_width) in col_widths.iter().enumerate() { + let cx = LABEL_MARGIN + COL_SPACING * (col_idx as f64 + 0.5); + let gate_w = (*col_width as f64) * 9.0 + 8.0; + + for row in 0..num_rows { + let cy = header_offset + ROW_SPACING * (row as f64 + 0.5); + let (ref cell, color) = self.columns[col_idx][row]; + + match cell { + DiagramCell::Wire => {} + DiagramCell::Gate(s, family) => { + let dash = family.svg_dasharray(); + let dash_attr = if dash.is_empty() { + String::new() + } else { + format!(" stroke-dasharray=\"{dash}\"") + }; + writeln!( + out, + "", + rx = cx - gate_w / 2.0, + ry = cy - GATE_H / 2.0, + fill = color.hex_fill(), + stroke = color.hex_stroke(), + ) + .unwrap(); + writeln!( + out, + "{s}", + fill = color.hex_text(), + ) + .unwrap(); + } + DiagramCell::Control => { + writeln!( + out, + "", + fill = color.hex_fill(), + stroke = color.hex_stroke(), + ) + .unwrap(); + } + DiagramCell::Crossing => { + // Vertical line segment through this row (rendered below + // as a connector) + horizontal wire already drawn. + writeln!( + out, + "", + ) + .unwrap(); + } + DiagramCell::Connector => { + // Pure vertical connector (no qubit wire) -- small dot. + writeln!( + out, + "", + ) + .unwrap(); + } + } + } + + // Vertical connectors between multi-qubit cells in this column. + let mut top: Option = None; + let mut bottom: Option = None; + for row in 0..num_rows { + let (ref cell, color) = self.columns[col_idx][row]; + let is_part = !matches!(cell, DiagramCell::Wire) && is_multi_color(color); + if is_part { + if top.is_none() { + top = Some(row); + } + bottom = Some(row); + } + } + if let (Some(t), Some(b)) = (top, bottom) + && t != b + { + let y1 = header_offset + ROW_SPACING * (t as f64 + 0.5); + let y2 = header_offset + ROW_SPACING * (b as f64 + 0.5); + writeln!( + out, + "", + stroke = CellColor::ControlDot.hex_stroke(), + ) + .unwrap(); + } + } + + writeln!(out, "").unwrap(); + out + } + + // ======================================================================== + // TikZ rendering + // ======================================================================== + + /// Render the diagram as a `TikZ` `tikzpicture` environment. + /// + /// Requires only `\usepackage{tikz}` -- no quantikz. If `header` is + /// non-empty it is emitted as a `TikZ` comment. + #[must_use] + pub fn render_tikz(&self, header: &str) -> String { + const ROW_STEP: f64 = 0.8; + const COL_STEP: f64 = 1.2; + const GATE_W: f64 = 0.7; + const GATE_H: f64 = 0.5; + const CTRL_R: f64 = 0.08; + + let num_rows = self.num_rows(); + let num_cols = self.effective_columns(); + + let mut out = String::new(); + + if !header.is_empty() { + writeln!(out, "% {header}").unwrap(); + } + + writeln!(out, "\\begin{{tikzpicture}}").unwrap(); + + // Color definitions. + for &c in &[ + CellColor::None, + CellColor::SingleQubit, + CellColor::MultiQubit, + CellColor::Measurement, + CellColor::Preparation, + CellColor::ControlDot, + ] { + let name = c.tikz_name(); + writeln!( + out, + " \\definecolor{{{name}Fill}}{{HTML}}{{{fill}}}", + fill = &c.hex_fill()[1..], // strip # + ) + .unwrap(); + writeln!( + out, + " \\definecolor{{{name}Stroke}}{{HTML}}{{{stroke}}}", + stroke = &c.hex_stroke()[1..], + ) + .unwrap(); + writeln!( + out, + " \\definecolor{{{name}Text}}{{HTML}}{{{text}}}", + text = &c.hex_text()[1..], + ) + .unwrap(); + } + + // Styles. + writeln!( + out, + " \\tikzstyle{{gate}}=[draw, rounded corners=2pt, minimum width={GATE_W}cm, \ + minimum height={GATE_H}cm, inner sep=1pt, font=\\footnotesize\\ttfamily]" + ) + .unwrap(); + writeln!( + out, + " \\tikzstyle{{ctrl}}=[circle, fill, inner sep=0pt, minimum size={r}cm]", + r = CTRL_R * 2.0, + ) + .unwrap(); + + if num_rows == 0 || num_cols == 0 { + writeln!(out, "\\end{{tikzpicture}}").unwrap(); + return out; + } + + // Wires and labels. + for row in 0..num_rows { + let y = -(row as f64) * ROW_STEP; + let x_start = -0.5; + let x_end = (num_cols as f64) * COL_STEP + 0.3; + writeln!( + out, + " \\draw[gray] ({x_start:.2},{y:.2}) -- ({x_end:.2},{y:.2});", + ) + .unwrap(); + writeln!( + out, + " \\node[anchor=east, font=\\footnotesize\\ttfamily] at ({lx:.2},{y:.2}) {{{label}}};", + lx = x_start - 0.15, + label = self.labels[row], + ) + .unwrap(); + } + + // Gates, controls, connectors. + for col_idx in 0..num_cols { + let x = (col_idx as f64 + 0.5) * COL_STEP; + + for row in 0..num_rows { + let y = -(row as f64) * ROW_STEP; + let (ref cell, color) = self.columns[col_idx][row]; + let name = color.tikz_name(); + + match cell { + DiagramCell::Wire => {} + DiagramCell::Gate(s, family) => { + let dash = family.tikz_dash(); + let dash_opt = if dash.is_empty() { + String::new() + } else { + format!(", {dash}") + }; + writeln!( + out, + " \\node[gate, fill={name}Fill, draw={name}Stroke, text={name}Text{dash_opt}] at ({x:.2},{y:.2}) {{{s}}};", + ) + .unwrap(); + } + DiagramCell::Control => { + writeln!( + out, + " \\node[ctrl, fill={name}Fill, draw={name}Stroke] at ({x:.2},{y:.2}) {{}};", + ) + .unwrap(); + } + DiagramCell::Crossing | DiagramCell::Connector => { + writeln!( + out, + " \\node[circle, fill=gray, inner sep=0pt, minimum size=0.06cm] at ({x:.2},{y:.2}) {{}};", + ) + .unwrap(); + } + } + } + + // Vertical connector lines. + let mut top: Option = None; + let mut bottom: Option = None; + for row in 0..num_rows { + let (ref cell, color) = self.columns[col_idx][row]; + if !matches!(cell, DiagramCell::Wire) && is_multi_color(color) { + if top.is_none() { + top = Some(row); + } + bottom = Some(row); + } + } + if let (Some(t), Some(b)) = (top, bottom) + && t != b + { + let y1 = -(t as f64) * ROW_STEP; + let y2 = -(b as f64) * ROW_STEP; + writeln!( + out, + " \\draw[cellCtrlStroke] ({x:.2},{y1:.2}) -- ({x:.2},{y2:.2});", + ) + .unwrap(); + } + } + + writeln!(out, "\\end{{tikzpicture}}").unwrap(); + out + } + + // ======================================================================== + // DOT / Graphviz rendering + // ======================================================================== + + /// Render the diagram as a Graphviz DOT `digraph` with `rankdir=LR`. + /// + /// If `header` is non-empty it is set as the graph `label`. + #[must_use] + pub fn render_dot(&self, header: &str) -> String { + let num_rows = self.num_rows(); + let num_cols = self.effective_columns(); + + let mut out = String::new(); + writeln!(out, "digraph circuit {{").unwrap(); + writeln!(out, " rankdir=LR;").unwrap(); + writeln!(out, " node [fontname=\"Courier\", fontsize=11];").unwrap(); + writeln!(out, " edge [arrowhead=none];").unwrap(); + + if !header.is_empty() { + writeln!(out, " label=\"{header}\";").unwrap(); + writeln!(out, " labelloc=t;").unwrap(); + } + + if num_rows == 0 || num_cols == 0 { + writeln!(out, "}}").unwrap(); + return out; + } + + // Node IDs: "r{row}c{col}" for gate cells, "r{row}_in"/"r{row}_out" for endpoints. + + // Input label nodes. + writeln!(out, " // Input labels").unwrap(); + writeln!(out, " {{ rank=same;").unwrap(); + for row in 0..num_rows { + writeln!( + out, + " r{row}_in [label=\"{label}\", shape=plaintext];", + label = self.labels[row], + ) + .unwrap(); + } + writeln!(out, " }}").unwrap(); + + // Output nodes (invisible). + writeln!(out, " // Output nodes").unwrap(); + writeln!(out, " {{ rank=same;").unwrap(); + for row in 0..num_rows { + writeln!( + out, + " r{row}_out [label=\"\", shape=none, width=0, height=0];", + ) + .unwrap(); + } + writeln!(out, " }}").unwrap(); + + // Gate columns. + for col_idx in 0..num_cols { + writeln!(out, " // Column {col_idx}").unwrap(); + writeln!(out, " {{ rank=same;").unwrap(); + for row in 0..num_rows { + let (ref cell, color) = self.columns[col_idx][row]; + let node_id = format!("r{row}c{col_idx}"); + + match cell { + DiagramCell::Wire => { + writeln!( + out, + " {node_id} [label=\"\", shape=point, width=0.01];", + ) + .unwrap(); + } + DiagramCell::Gate(s, family) => { + let dot_style = family.dot_style(); + let style_val = if dot_style.is_empty() { + "filled".to_string() + } else { + format!("\"filled,{dot_style}\"") + }; + writeln!( + out, + " {node_id} [label=\"{s}\", shape=box, style={style_val}, \ + fillcolor=\"{fill}\", color=\"{stroke}\", fontcolor=\"{text}\"];", + fill = color.hex_fill(), + stroke = color.hex_stroke(), + text = color.hex_text(), + ) + .unwrap(); + } + DiagramCell::Control => { + writeln!( + out, + " {node_id} [label=\"\", shape=point, width=0.12, \ + style=filled, fillcolor=\"{fill}\"];", + fill = color.hex_fill(), + ) + .unwrap(); + } + DiagramCell::Crossing | DiagramCell::Connector => { + writeln!( + out, + " {node_id} [label=\"\", shape=point, width=0.05];", + ) + .unwrap(); + } + } + } + writeln!(out, " }}").unwrap(); + } + + // Wire edges. + writeln!(out, " // Wires").unwrap(); + for row in 0..num_rows { + let mut prev = format!("r{row}_in"); + for col_idx in 0..num_cols { + let cur = format!("r{row}c{col_idx}"); + writeln!(out, " {prev} -> {cur};").unwrap(); + prev = cur; + } + writeln!(out, " {prev} -> r{row}_out;").unwrap(); + } + + // Vertical connector edges. + writeln!(out, " // Vertical connectors").unwrap(); + for col_idx in 0..num_cols { + let mut top: Option = None; + let mut bottom: Option = None; + for row in 0..num_rows { + let (ref cell, color) = self.columns[col_idx][row]; + if !matches!(cell, DiagramCell::Wire) && is_multi_color(color) { + if top.is_none() { + top = Some(row); + } + bottom = Some(row); + } + } + if let (Some(t), Some(b)) = (top, bottom) + && t != b + { + // Connect consecutive multi-qubit rows. + let mut prev_row = t; + for row in (t + 1)..=b { + let (ref cell, color) = self.columns[col_idx][row]; + if !matches!(cell, DiagramCell::Wire) && is_multi_color(color) { + writeln!( + out, + " r{prev_row}c{col_idx} -> r{row}c{col_idx} [style=dashed, dir=none, constraint=false];", + ) + .unwrap(); + prev_row = row; + } + } + } + } + + writeln!(out, "}}").unwrap(); + out + } + + /// Count effective columns (strip trailing all-Wire columns). + fn effective_columns(&self) -> usize { + let mut n = self.columns.len(); + while n > 0 { + let all_wire = self.columns[n - 1] + .iter() + .all(|(cell, _)| matches!(cell, DiagramCell::Wire)); + if all_wire { + n -= 1; + } else { + break; + } + } + n + } + + /// Render the connector row between `row` and `row + 1`. + /// Returns `None` if no connectors are needed. + fn render_connector_row( + &self, + row: usize, + num_cols: usize, + col_widths: &[usize], + options: &DiagramOptions, + ) -> Option { + let label_width = self.labels.iter().map(String::len).max().unwrap_or(2); + let mut line = String::new(); + write!(line, "{:>width$} ", "", width = label_width).unwrap(); + let mut has_connector = false; + + for (col_idx, &width) in col_widths.iter().enumerate() { + if col_idx >= num_cols { + break; + } + let (ref cell, color) = self.columns[col_idx][row]; + let (ref next_cell, next_color) = self.columns[col_idx][row + 1]; + + // Show a vertical connector when both this row and the next have + // non-Wire cells that are part of a multi-qubit gate (same color + // category, both colored). + let show = !matches!(cell, DiagramCell::Wire) + && !matches!(next_cell, DiagramCell::Wire) + && is_multi_color(color) + && is_multi_color(next_color); + + if show { + has_connector = true; + let pad_total = width.saturating_sub(1); + let pad_left = pad_total / 2; + let pad_right = pad_total - pad_left; + let left: String = std::iter::repeat_n(' ', pad_left).collect(); + let right: String = std::iter::repeat_n(' ', pad_right).collect(); + if options.color { + write!( + line, + " {left}{}{ANSI_RESET}{right} ", + format_args!("{}|", ansi_code(CellColor::ControlDot)), + ) + .unwrap(); + } else { + write!(line, " {left}|{right} ").unwrap(); + } + } else { + let spaces: String = std::iter::repeat_n(' ', width + 2).collect(); + write!(line, "{spaces}").unwrap(); + } + } + + if has_connector { Some(line) } else { None } + } +} + +// ============================================================================ +// Rendering helpers +// ============================================================================ + +/// Content width of a cell (before padding). +fn cell_content_width(cell: &DiagramCell) -> usize { + match cell { + DiagramCell::Gate(s, _) => s.len() + 2, // +2 for brackets + DiagramCell::Wire | DiagramCell::Control | DiagramCell::Crossing | DiagramCell::Connector => 1, + } +} + +/// Render a single cell into the given column width. +fn render_cell(cell: &DiagramCell, width: usize, wire_char: char, options: &DiagramOptions) -> String { + match cell { + DiagramCell::Wire => { + std::iter::repeat_n(wire_char, width).collect() + } + DiagramCell::Gate(s, family) => { + let bracketed = format!("{}{s}{}", family.open_bracket(), family.close_bracket()); + pad_center(&bracketed, width, wire_char) + } + DiagramCell::Control => { + let dot = match options.symbols { + SymbolSet::Ascii => ".", + SymbolSet::Unicode => "\u{25CF}", // ● + }; + pad_center(dot, width, wire_char) + } + DiagramCell::Crossing => { + pad_center("+", width, wire_char) + } + DiagramCell::Connector => { + // Connector on a qubit wire row -- treat as crossing. + pad_center("|", width, wire_char) + } + } +} + +/// Center `s` within `width` characters, padding with `pad_char`. +fn pad_center(s: &str, width: usize, pad_char: char) -> String { + let content_width = s.chars().count(); + let pad_total = width.saturating_sub(content_width); + let pad_left = pad_total / 2; + let pad_right = pad_total - pad_left; + let left: String = std::iter::repeat_n(pad_char, pad_left).collect(); + let right: String = std::iter::repeat_n(pad_char, pad_right).collect(); + format!("{left}{s}{right}") +} + +/// Whether a color indicates a multi-qubit gate context. +fn is_multi_color(color: CellColor) -> bool { + matches!( + color, + CellColor::MultiQubit | CellColor::ControlDot + ) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_diagram() { + let d = CircuitDiagram::new(0); + let out = d.render("test", &DiagramOptions::ascii()); + assert_eq!(out, "test\n"); + } + + #[test] + fn single_gate_ascii() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); + let out = d.render("", &DiagramOptions::ascii()); + assert!(out.contains("[H]")); + // q1 should be just wire + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(!q1_line.contains("[")); + } + + #[test] + fn single_gate_unicode() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); + let out = d.render("", &DiagramOptions::unicode()); + assert!(out.contains("[H]")); + assert!(out.contains('\u{2500}')); // ─ + assert!(!out.contains('-')); + } + + #[test] + fn control_dot_ascii_vs_unicode() { + let mut d = CircuitDiagram::new(2); + d.add_control(0); + d.add_gate(1, "X", CellColor::MultiQubit, GateFamily::Default); + d.connect_vertical(0, 1, CellColor::MultiQubit); + + let ascii = d.render("", &DiagramOptions::ascii()); + assert!(ascii.contains('.')); + + let unicode = d.render("", &DiagramOptions::unicode()); + assert!(unicode.contains('\u{25CF}')); // ● + } + + #[test] + fn color_output_contains_ansi() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); + + let plain = d.render("", &DiagramOptions::ascii()); + let color = d.render("", &DiagramOptions::color_ascii()); + + assert!(!plain.contains("\x1b[")); + assert!(color.contains("\x1b[34m")); // blue + assert!(color.contains(ANSI_RESET)); + } + + #[test] + fn crossing_between_qubits() { + let mut d = CircuitDiagram::new(3); + d.add_control(0); + d.add_gate(2, "X", CellColor::MultiQubit, GateFamily::Default); + d.connect_vertical(0, 2, CellColor::MultiQubit); + + let out = d.render("", &DiagramOptions::ascii()); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q1_line.contains('+')); + } + + #[test] + fn multi_column_advance() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); + d.advance(); + d.add_gate(1, "X", CellColor::SingleQubit, GateFamily::Default); + + let out = d.render("", &DiagramOptions::ascii()); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1 = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q0.contains("[H]")); + assert!(!q0.contains("[X]")); + assert!(q1.contains("[X]")); + assert!(!q1.contains("[H]")); + } + + #[test] + fn header_is_printed() { + let d = CircuitDiagram::new(1); + // Single wire column is all-Wire, so effective_columns == 0 + let out = d.render("My Header", &DiagramOptions::ascii()); + assert!(out.starts_with("My Header\n")); + } + + #[test] + fn connector_row_between_multi_qubit() { + let mut d = CircuitDiagram::new(2); + d.add_control(0); + d.add_gate(1, "X", CellColor::MultiQubit, GateFamily::Default); + + let out = d.render("", &DiagramOptions::ascii()); + // Should have a | connector between q0 and q1 + assert!(out.contains('|')); + } + + #[test] + fn lines_have_equal_length() { + let mut d = CircuitDiagram::new(3); + d.add_gate(0, "SX", CellColor::SingleQubit, GateFamily::Default); + d.advance(); + d.add_control(0); + d.add_gate(2, "X", CellColor::MultiQubit, GateFamily::Default); + d.connect_vertical(0, 2, CellColor::MultiQubit); + + let out = d.render("", &DiagramOptions::ascii()); + let qubit_lines: Vec<&str> = out.lines().filter(|l| l.starts_with('q')).collect(); + assert!(qubit_lines.len() >= 2); + let len0 = qubit_lines[0].len(); + for line in &qubit_lines { + assert_eq!(line.len(), len0, "qubit lines should have equal length"); + } + } + + // ====================== SVG tests ====================== + + #[test] + fn svg_empty_diagram() { + let d = CircuitDiagram::new(0); + let out = d.render_svg(""); + assert!(out.contains("")); + } + + #[test] + fn svg_single_gate() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); + let out = d.render_svg(""); + assert!(out.contains("H")); + assert!(out.contains("q0")); + assert!(out.contains("q1")); + assert!(out.contains("#A8C8F0")); // SingleQubit fill + } + + #[test] + fn svg_control_and_connector() { + let mut d = CircuitDiagram::new(2); + d.add_control(0); + d.add_gate(1, "X", CellColor::MultiQubit, GateFamily::Default); + let out = d.render_svg(""); + assert!(out.contains("")); + } + + #[test] + fn slike_brackets() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::SingleQubit, GateFamily::SLike); + let out = d.render("", &DiagramOptions::ascii()); + assert!(out.contains("[SZ]")); + } + + #[test] + fn flike_brackets() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "F", CellColor::SingleQubit, GateFamily::FLike); + let out = d.render("", &DiagramOptions::ascii()); + assert!(out.contains("{F}")); + } + + #[test] + fn measurement_brackets() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "MZ", CellColor::Measurement, GateFamily::Measurement); + let out = d.render("", &DiagramOptions::ascii()); + assert!(out.contains("|MZ)")); + } + + #[test] + fn preparation_brackets() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "PZ", CellColor::Preparation, GateFamily::Preparation); + let out = d.render("", &DiagramOptions::ascii()); + assert!(out.contains("(PZ|")); + } + + // ====================== Gate family stroke tests ====================== + + #[test] + fn svg_slike_dasharray() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::SingleQubit, GateFamily::SLike); + let out = d.render_svg(""); + assert!(out.contains("stroke-dasharray=\"4,3\"")); + } + + #[test] + fn svg_hlike_dasharray() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::HLike); + let out = d.render_svg(""); + assert!(out.contains("stroke-dasharray=\"2,2\"")); + } + + #[test] + fn svg_default_no_dasharray() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "T", CellColor::SingleQubit, GateFamily::Default); + let out = d.render_svg(""); + assert!(!out.contains("stroke-dasharray")); + } + + #[test] + fn tikz_slike_dashed() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::SingleQubit, GateFamily::SLike); + let out = d.render_tikz(""); + assert!(out.contains(", dashed]")); + } + + #[test] + fn dot_hlike_dotted() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::HLike); + let out = d.render_dot(""); + assert!(out.contains("filled,dotted")); + } +} diff --git a/crates/pecos-core/src/lib.rs b/crates/pecos-core/src/lib.rs index a8604af38..8ef6e7c4a 100644 --- a/crates/pecos-core/src/lib.rs +++ b/crates/pecos-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod bit; pub mod bit_int; pub mod bitset; pub mod bitvec; +pub mod circuit_diagram; pub mod clifford_rep; pub mod duration; pub mod element; diff --git a/crates/pecos-core/src/operator.rs b/crates/pecos-core/src/operator.rs index a344abe2c..3456d5599 100644 --- a/crates/pecos-core/src/operator.rs +++ b/crates/pecos-core/src/operator.rs @@ -2516,19 +2516,93 @@ impl Mul for Operator { // Circuit diagram generation // ============================================================================ +use crate::circuit_diagram::{CellColor, CircuitDiagram, DiagramOptions, GateFamily}; + +/// Map a `GateType` to its `GateFamily` for diagram bracket/stroke styling. +fn gate_type_family(gt: GateType) -> GateFamily { + match gt { + GateType::I | GateType::X | GateType::Y | GateType::Z => GateFamily::Pauli, + GateType::SX + | GateType::SXdg + | GateType::SY + | GateType::SYdg + | GateType::SZ + | GateType::SZdg => GateFamily::SLike, + GateType::H => GateFamily::HLike, + GateType::Measure | GateType::MeasureLeaked | GateType::MeasureFree => { + GateFamily::Measurement + } + GateType::Prep | GateType::QAlloc | GateType::QFree => GateFamily::Preparation, + _ => GateFamily::Default, + } +} + impl Operator { - /// Generates an ASCII circuit diagram for this expression. + /// Generates a Unicode circuit diagram for this expression. + /// + /// This is an alias for [`to_unicode`](Self::to_unicode). #[must_use] pub fn to_diagram(&self, num_qubits: usize) -> String { + self.to_unicode(num_qubits) + } + + /// Plain ASCII circuit diagram. + #[must_use] + pub fn to_ascii(&self, num_qubits: usize) -> String { + self.render_diagram(num_qubits, &DiagramOptions::ascii()) + } + + /// ASCII circuit diagram with ANSI colors. + #[must_use] + pub fn to_color_ascii(&self, num_qubits: usize) -> String { + self.render_diagram(num_qubits, &DiagramOptions::color_ascii()) + } + + /// Unicode circuit diagram. + #[must_use] + pub fn to_unicode(&self, num_qubits: usize) -> String { + self.render_diagram(num_qubits, &DiagramOptions::unicode()) + } + + /// Unicode circuit diagram with ANSI colors. + #[must_use] + pub fn to_color_unicode(&self, num_qubits: usize) -> String { + self.render_diagram(num_qubits, &DiagramOptions::color_unicode()) + } + + /// Export as an SVG circuit diagram. + #[must_use] + pub fn to_svg(&self, num_qubits: usize) -> String { let mut diagram = CircuitDiagram::new(num_qubits); self.add_to_diagram(&mut diagram); - diagram.render() + diagram.render_svg("") + } + + /// Export as a `TikZ` `tikzpicture`. + #[must_use] + pub fn to_tikz(&self, num_qubits: usize) -> String { + let mut diagram = CircuitDiagram::new(num_qubits); + self.add_to_diagram(&mut diagram); + diagram.render_tikz("") + } + + /// Export as a Graphviz DOT digraph. + #[must_use] + pub fn to_dot(&self, num_qubits: usize) -> String { + let mut diagram = CircuitDiagram::new(num_qubits); + self.add_to_diagram(&mut diagram); + diagram.render_dot("") + } + + fn render_diagram(&self, num_qubits: usize, options: &DiagramOptions) -> String { + let mut diagram = CircuitDiagram::new(num_qubits); + self.add_to_diagram(&mut diagram); + diagram.render("", options) } fn add_to_diagram(&self, diagram: &mut CircuitDiagram) { match self { Self::Pauli(ps) => { - // Draw each Pauli on its qubit for (pauli, qubit) in ps.iter_pairs() { let q = usize::from(qubit); let name = match pauli { @@ -2537,7 +2611,7 @@ impl Operator { crate::Pauli::Y => "Y", crate::Pauli::Z => "Z", }; - diagram.add_single_gate(q, name); + diagram.add_gate(q, name, CellColor::SingleQubit, GateFamily::Pauli); } } Self::Rotation { @@ -2545,256 +2619,83 @@ impl Operator { angle, qubits, } => { - let name = if let Some(gate_type) = rotation_to_gate_type(*rotation_type, *angle) { - format!("{gate_type:?}") + let resolved_gt = rotation_to_gate_type(*rotation_type, *angle); + let name = if let Some(gt) = resolved_gt { + format!("{gt:?}") } else { format!("{rotation_type:?}") }; + let family = resolved_gt.map_or(GateFamily::Default, gate_type_family); if qubits.len() == 1 { - diagram.add_single_gate(qubits[0], &name); + diagram.add_gate(qubits[0], &name, CellColor::SingleQubit, family); } else if qubits.len() == 2 { - diagram.add_two_qubit_gate(qubits[0], qubits[1], &name); + let color = CellColor::MultiQubit; + diagram.add_gate(qubits[0], &name, color, family); + diagram.add_gate(qubits[1], &name, color, family); + diagram.connect_vertical(qubits[0], qubits[1], color); } } Self::Gate { gate_type, qubits } => match gate_type { GateType::CX => { - diagram.add_controlled_gate(qubits[0], qubits[1], "X"); + diagram.add_control(qubits[0]); + diagram.add_gate(qubits[1], "X", CellColor::MultiQubit, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::MultiQubit); } GateType::CY => { - diagram.add_controlled_gate(qubits[0], qubits[1], "Y"); + diagram.add_control(qubits[0]); + diagram.add_gate(qubits[1], "Y", CellColor::MultiQubit, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::MultiQubit); } GateType::CZ => { - diagram.add_controlled_gate(qubits[0], qubits[1], "Z"); + diagram.add_control(qubits[0]); + diagram.add_gate(qubits[1], "Z", CellColor::MultiQubit, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::MultiQubit); } GateType::SWAP => { - diagram.add_swap(qubits[0], qubits[1]); + let color = CellColor::MultiQubit; + diagram.add_gate(qubits[0], "x", color, GateFamily::Default); + diagram.add_gate(qubits[1], "x", color, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], color); } GateType::CCX => { - diagram.add_toffoli(qubits[0], qubits[1], qubits[2]); + diagram.add_control(qubits[0]); + diagram.add_control(qubits[1]); + diagram.add_gate(qubits[2], "X", CellColor::MultiQubit, GateFamily::Default); + let min_q = qubits[0].min(qubits[1]).min(qubits[2]); + let max_q = qubits[0].max(qubits[1]).max(qubits[2]); + diagram.connect_vertical(min_q, max_q, CellColor::MultiQubit); } _ => { if qubits.len() == 1 { - diagram.add_single_gate(qubits[0], &format!("{gate_type:?}")); + let family = gate_type_family(*gate_type); + diagram.add_gate( + qubits[0], + &format!("{gate_type:?}"), + CellColor::SingleQubit, + family, + ); } } }, Self::Tensor(parts) => { - // Tensor products can be drawn simultaneously for part in parts { part.add_to_diagram(diagram); } } Self::Compose(parts) => { - // Sequential composition: draw in order for part in parts { part.add_to_diagram(diagram); diagram.advance(); } } - Self::Adjoint(inner) => { - // Mark as adjoint somehow? - inner.add_to_diagram(diagram); - } - Self::Phase { inner, .. } => { - // Global phase doesn't appear in circuit diagrams + Self::Adjoint(inner) | Self::Phase { inner, .. } => { inner.add_to_diagram(diagram); } } } } -struct CircuitDiagram { - num_qubits: usize, - columns: Vec>, - current_col: usize, -} - -impl CircuitDiagram { - fn new(num_qubits: usize) -> Self { - Self { - num_qubits, - columns: vec![vec![String::new(); num_qubits * 2 - 1]], - current_col: 0, - } - } - - fn ensure_column(&mut self) { - if self.current_col >= self.columns.len() { - self.columns - .push(vec![String::new(); self.num_qubits * 2 - 1]); - } - } - - fn advance(&mut self) { - self.current_col += 1; - } - - fn add_single_gate(&mut self, qubit: usize, name: &str) { - self.ensure_column(); - let row = qubit * 2; - if row < self.columns[self.current_col].len() { - self.columns[self.current_col][row] = format!("[{name}]"); - } - } - - fn add_controlled_gate(&mut self, control: usize, target: usize, target_name: &str) { - self.ensure_column(); - let ctrl_row = control * 2; - let targ_row = target * 2; - - if ctrl_row < self.columns[self.current_col].len() { - self.columns[self.current_col][ctrl_row] = "●".to_string(); - } - if targ_row < self.columns[self.current_col].len() { - self.columns[self.current_col][targ_row] = format!("[{target_name}]"); - } - - // Draw vertical line - let (min_row, max_row) = if ctrl_row < targ_row { - (ctrl_row, targ_row) - } else { - (targ_row, ctrl_row) - }; - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn add_swap(&mut self, q0: usize, q1: usize) { - self.ensure_column(); - let row0 = q0 * 2; - let row1 = q1 * 2; - - if row0 < self.columns[self.current_col].len() { - self.columns[self.current_col][row0] = "×".to_string(); - } - if row1 < self.columns[self.current_col].len() { - self.columns[self.current_col][row1] = "×".to_string(); - } - - // Draw vertical line - let (min_row, max_row) = (row0.min(row1), row0.max(row1)); - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn add_toffoli(&mut self, c0: usize, c1: usize, target: usize) { - self.ensure_column(); - let c0_row = c0 * 2; - let c1_row = c1 * 2; - let targ_row = target * 2; - - if c0_row < self.columns[self.current_col].len() { - self.columns[self.current_col][c0_row] = "●".to_string(); - } - if c1_row < self.columns[self.current_col].len() { - self.columns[self.current_col][c1_row] = "●".to_string(); - } - if targ_row < self.columns[self.current_col].len() { - self.columns[self.current_col][targ_row] = "[X]".to_string(); - } - - // Draw vertical lines - let min_row = c0_row.min(c1_row).min(targ_row); - let max_row = c0_row.max(c1_row).max(targ_row); - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn add_two_qubit_gate(&mut self, q0: usize, q1: usize, name: &str) { - self.ensure_column(); - let row0 = q0 * 2; - let row1 = q1 * 2; - - if row0 < self.columns[self.current_col].len() { - self.columns[self.current_col][row0] = format!("[{name}]"); - } - if row1 < self.columns[self.current_col].len() { - self.columns[self.current_col][row1] = format!("[{name}]"); - } - - // Draw vertical line - let (min_row, max_row) = (row0.min(row1), row0.max(row1)); - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn render(&self) -> String { - let lines: Vec = (0..self.num_qubits).map(|q| format!("q{q}: ")).collect(); - - // Add spacing lines between qubits - let mut all_lines: Vec = Vec::new(); - for (idx, line) in lines.iter().enumerate() { - all_lines.push(line.clone()); - if idx < self.num_qubits - 1 { - all_lines.push(" ".to_string()); // spacing line - } - } - - // Process each column - for col in &self.columns { - // Find max width in this column - let max_width = col - .iter() - .map(|s| s.chars().count()) - .max() - .unwrap_or(0) - .max(3); - - for (row, cell) in col.iter().enumerate() { - if row < all_lines.len() { - if cell.is_empty() { - // Wire or empty - if row % 2 == 0 { - all_lines[row].push_str(&"─".repeat(max_width)); - } else { - all_lines[row].push_str(&" ".repeat(max_width)); - } - } else { - // Center the cell content - let padding = max_width.saturating_sub(cell.chars().count()); - let left_pad = padding / 2; - let right_pad = padding - left_pad; - - if row % 2 == 0 { - // Qubit line - all_lines[row].push_str(&"─".repeat(left_pad)); - all_lines[row].push_str(cell); - all_lines[row].push_str(&"─".repeat(right_pad)); - } else { - // Spacing line - all_lines[row].push_str(&" ".repeat(left_pad)); - all_lines[row].push_str(cell); - all_lines[row].push_str(&" ".repeat(right_pad)); - } - } - } - } - } - - // Add trailing wire - for (idx, line) in all_lines.iter_mut().enumerate() { - if idx % 2 == 0 { - line.push('─'); - } - } - - all_lines.join("\n") - } -} - // ============================================================================ // Tests // ============================================================================ @@ -2903,15 +2804,15 @@ mod tests { fn test_diagram_single_qubit() { let h = H(0); let diagram = h.to_diagram(1); - assert!(diagram.contains("[H]")); + assert!(diagram.contains("")); // HLike family } #[test] fn test_diagram_cx() { let cx = CX(0, 1); let diagram = cx.to_diagram(2); - assert!(diagram.contains("●")); - assert!(diagram.contains("[X]")); + assert!(diagram.contains("\u{25CF}")); // control dot + assert!(diagram.contains("[X]")); // Default family for controlled target } #[test] @@ -4095,4 +3996,39 @@ mod tests { assert_eq!(ps.get(0), crate::Pauli::X); assert_eq!(ps.phase(), QuarterPhase::PlusI); } + + // ====================== SVG/TikZ/DOT export ====================== + + #[test] + fn operator_svg() { + let op = H(0); + let svg = op.to_svg(2); + assert!(svg.contains("H")); + assert!(svg.contains("q0")); + } + + #[test] + fn operator_tikz() { + let op = H(0); + let tikz = op.to_tikz(2); + assert!(tikz.contains("\\begin{tikzpicture}")); + assert!(tikz.contains("{H}")); + } + + #[test] + fn operator_dot() { + let op = H(0); + let dot = op.to_dot(2); + assert!(dot.contains("digraph circuit")); + assert!(dot.contains("label=\"H\"")); + } + + #[test] + fn operator_cx_svg() { + let op = CX(0, 1); + let svg = op.to_svg(2); + assert!(svg.contains(" ForcedMeasurement for GraphStateSim { } } +impl crate::StabilizerTableauSimulator for GraphStateSim { + fn stab_tableau(&self) -> String { + let gs = self.to_graph_state(); + let n = gs.num_qubits(); + let gens = gs.stabilizer_generators(); + let mut result = String::with_capacity(n * (n + 3)); + for g in &gens { + pauli_string_to_tableau_line(g, n, &mut result); + } + result + } + + fn destab_tableau(&self) -> String { + let n = self.num_qubits; + let mut result = String::with_capacity(n * (n + 3)); + for v in 0..n { + let z_img = self.vops[v].z_image(); + let pauli = match z_img.axis { + PauliAxis::X => pecos_core::Pauli::X, + PauliAxis::Y => pecos_core::Pauli::Y, + PauliAxis::Z => pecos_core::Pauli::Z, + }; + let phase = if z_img.positive { + pecos_core::QuarterPhase::PlusOne + } else { + pecos_core::QuarterPhase::MinusOne + }; + let mut paulis = vec![pecos_core::Pauli::I; n]; + paulis[v] = pauli; + let ps = pecos_core::PauliString::from_paulis_with_phase(phase, &paulis); + pauli_string_to_tableau_line(&ps, n, &mut result); + } + result + } + + fn num_qubits(&self) -> usize { + self.num_qubits + } +} + +/// Format a `PauliString` as a tableau line matching the `DenseStab` format. +/// +/// Produces e.g. `"+ZI\n"` or `"-iXY\n"`. +fn pauli_string_to_tableau_line(ps: &pecos_core::PauliString, n: usize, out: &mut String) { + use std::fmt::Write; + let phase_str = match ps.phase() { + pecos_core::QuarterPhase::PlusOne => "+", + pecos_core::QuarterPhase::MinusOne => "-", + pecos_core::QuarterPhase::PlusI => "+i", + pecos_core::QuarterPhase::MinusI => "-i", + }; + writeln!(out, "{}{}", phase_str, ps.pauli_str(Some(n))).unwrap(); +} + impl StabilizerSimulator for GraphStateSim { fn with_seed(num_qubits: usize, seed: u64) -> Self { Self::with_seed(num_qubits, seed) diff --git a/crates/pecos-qsim/src/graph_state_repr.rs b/crates/pecos-qsim/src/graph_state_repr.rs index 36f7bd414..0cb872879 100644 --- a/crates/pecos-qsim/src/graph_state_repr.rs +++ b/crates/pecos-qsim/src/graph_state_repr.rs @@ -1160,21 +1160,43 @@ impl GraphState { /// `{}` F-like. Identity vertices get a blank VOP column. #[must_use] pub fn to_ascii(&self) -> String { - self.format_ascii(false) + self.format_graph(false, "--") } - /// Export as ANSI-colored ASCII text for terminal display. + /// ASCII text with ANSI color codes. /// /// Same layout as [`to_ascii`](Self::to_ascii) with 16-color ANSI codes /// encoding the coset (hue) and sign parity (bold = even, normal = odd). /// A two-line legend is appended when non-identity VOPs are present. #[must_use] + pub fn to_color_ascii(&self) -> String { + self.format_graph(true, "--") + } + + /// Unicode text (no escape codes). + /// + /// Same layout as [`to_ascii`](Self::to_ascii) with a Unicode separator + /// (`\u{2500}\u{2500}`) instead of `--`. + #[must_use] + pub fn to_unicode(&self) -> String { + self.format_graph(false, "\u{2500}\u{2500}") + } + + /// Unicode text with ANSI color codes. + #[must_use] + pub fn to_color_unicode(&self) -> String { + self.format_graph(true, "\u{2500}\u{2500}") + } + + /// Deprecated: use [`to_color_ascii`](Self::to_color_ascii) instead. + #[deprecated(note = "renamed to to_color_ascii")] + #[must_use] pub fn to_ascii_color(&self) -> String { - self.format_ascii(true) + self.to_color_ascii() } - /// Shared layout logic for [`to_ascii`] and [`to_ascii_color`]. - fn format_ascii(&self, color: bool) -> String { + /// Shared layout logic. + fn format_graph(&self, color: bool, separator: &str) -> String { let n = self.num_qubits(); let num_edges = self.num_edges(); let mut out = format!("GraphState: {n} qubits, {num_edges} edges\n\n"); @@ -1206,7 +1228,6 @@ impl GraphState { if show_vops { let idx = self.vops[v].index() as usize; if self.vops[v].is_identity() { - // Blank VOP column so `--` aligns with other rows. write!(out, " {: = self.neighbors[v].iter().collect(); if !nbrs.is_empty() { - let nbr_str: Vec = nbrs.iter().map(|u| u.to_string()).collect(); - write!(out, " -- {}", nbr_str.join(", ")).unwrap(); + let nbr_str: Vec = nbrs.iter().map(ToString::to_string).collect(); + write!(out, " {separator} {}", nbr_str.join(", ")).unwrap(); } out.push('\n'); diff --git a/crates/pecos-quantum/src/circuit_display.rs b/crates/pecos-quantum/src/circuit_display.rs new file mode 100644 index 000000000..5e281ebbf --- /dev/null +++ b/crates/pecos-quantum/src/circuit_display.rs @@ -0,0 +1,696 @@ +// 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. + +//! Circuit diagram rendering for [`TickCircuit`] and [`DagCircuit`]. +//! +//! Produces horizontal qubit-wire diagrams with gate symbols placed at +//! tick/layer columns, suitable for terminal display. Delegates the actual +//! grid layout and character rendering to +//! [`pecos_core::circuit_diagram::CircuitDiagram`]. + +use pecos_core::circuit_diagram::{CellColor, CircuitDiagram, DiagramCell, DiagramOptions, GateFamily}; +use pecos_core::gate_type::GateType; +use pecos_core::{Gate, QubitId}; +use std::collections::BTreeSet; + +// ==================== Gate symbols ==================== + +/// Short symbol for a gate type. +fn gate_symbol(gate_type: GateType) -> &'static str { + match gate_type { + GateType::H => "H", + GateType::X => "X", + GateType::Y => "Y", + GateType::Z => "Z", + GateType::SX => "SX", + GateType::SXdg => "SXdg", + GateType::SY => "SY", + GateType::SYdg => "SYdg", + GateType::SZ => "SZ", + GateType::SZdg => "SZdg", + GateType::T => "T", + GateType::Tdg => "Tdg", + GateType::RX => "Rx", + GateType::RY => "Ry", + GateType::RZ => "Rz", + GateType::U => "U", + GateType::R1XY => "R1XY", + GateType::CX => "CX", + GateType::CY => "CY", + GateType::CZ => "CZ", + GateType::CH => "CH", + GateType::SZZ => "SZZ", + GateType::SZZdg => "SZZdg", + GateType::SWAP => "SWAP", + GateType::CRZ => "CRZ", + GateType::RXX => "RXX", + GateType::RYY => "RYY", + GateType::RZZ => "RZZ", + GateType::CCX => "CCX", + GateType::Measure => "MZ", + GateType::MeasureLeaked => "ML", + GateType::MeasureFree => "MF", + GateType::Prep => "PZ", + GateType::QAlloc => "QA", + GateType::QFree => "QF", + GateType::I | GateType::Idle => "I", + GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload => "XT", + GateType::Custom => "?", + } +} + +/// Format an angle as a compact string in turns, e.g. `.25` or `.333333`. +fn format_angle_turns(angle: pecos_core::Angle64) -> String { + let radians = angle.to_radians(); + let turns = radians / std::f64::consts::TAU; + let turns = turns.rem_euclid(1.0); + if (turns - 0.0).abs() < 1e-9 { + return "0".to_string(); + } + format!(".{}", format!("{turns:.6}").trim_start_matches("0.").trim_end_matches('0')) +} + +/// Build the full symbol string for a gate, including angles if parameterized. +fn full_gate_symbol(gate: &Gate) -> String { + let base = gate_symbol(gate.gate_type); + if gate.angles.is_empty() { + return base.to_string(); + } + let angle_strs: Vec = gate.angles.iter().copied().map(format_angle_turns).collect(); + format!("{base}({})", angle_strs.join(",")) +} + +// ==================== Color mapping ==================== + +/// Map a `GateType` to its diagram color category. +fn gate_color(gate_type: GateType) -> CellColor { + match gate_type { + GateType::Measure | GateType::MeasureLeaked | GateType::MeasureFree => { + CellColor::Measurement + } + GateType::Prep | GateType::QAlloc | GateType::QFree => CellColor::Preparation, + _ if gate_type.quantum_arity() >= 2 => CellColor::MultiQubit, + GateType::Idle | GateType::I => CellColor::None, + _ => CellColor::SingleQubit, + } +} + +// ==================== Family mapping ==================== + +/// Map a `GateType` to its diagram family bracket/stroke style. +fn gate_family(gate_type: GateType) -> GateFamily { + match gate_type { + GateType::I | GateType::X | GateType::Y | GateType::Z => GateFamily::Pauli, + GateType::SX + | GateType::SXdg + | GateType::SY + | GateType::SYdg + | GateType::SZ + | GateType::SZdg => GateFamily::SLike, + GateType::H => GateFamily::HLike, + GateType::Measure | GateType::MeasureLeaked | GateType::MeasureFree => { + GateFamily::Measurement + } + GateType::Prep | GateType::QAlloc | GateType::QFree => GateFamily::Preparation, + _ => GateFamily::Default, + } +} + +// ==================== Grid building ==================== + +/// Decompose a single `Gate` into per-row cell assignments. +fn decompose_gate( + gate: &Gate, + qubit_to_row: &std::collections::BTreeMap, + num_rows: usize, +) -> Vec<(usize, DiagramCell, CellColor)> { + let arity = gate.gate_type.quantum_arity(); + let qubits = &gate.qubits; + let mut cells = Vec::new(); + let color = gate_color(gate.gate_type); + + if arity == 1 { + let sym = full_gate_symbol(gate); + let family = gate_family(gate.gate_type); + for &q in qubits { + if let Some(&row) = qubit_to_row.get(&q) { + cells.push((row, DiagramCell::Gate(sym.clone(), family), color)); + } + } + } else if arity == 2 { + let sym = full_gate_symbol(gate); + for pair in qubits.chunks(2) { + if pair.len() < 2 { + continue; + } + let (q_a, q_b) = (pair[0], pair[1]); + let Some(&row_a) = qubit_to_row.get(&q_a) else { + continue; + }; + let Some(&row_b) = qubit_to_row.get(&q_b) else { + continue; + }; + + let (top, bottom) = if row_a < row_b { + (row_a, row_b) + } else { + (row_b, row_a) + }; + + match gate.gate_type { + GateType::CX => { + cells.push((row_a, DiagramCell::Control, CellColor::ControlDot)); + cells.push(( + row_b, + DiagramCell::Gate("X".to_string(), GateFamily::Default), + CellColor::MultiQubit, + )); + } + GateType::CY => { + cells.push((row_a, DiagramCell::Control, CellColor::ControlDot)); + cells.push(( + row_b, + DiagramCell::Gate("Y".to_string(), GateFamily::Default), + CellColor::MultiQubit, + )); + } + GateType::CZ => { + cells.push((row_a, DiagramCell::Control, CellColor::ControlDot)); + cells.push((row_b, DiagramCell::Control, CellColor::ControlDot)); + } + GateType::CH => { + cells.push((row_a, DiagramCell::Control, CellColor::ControlDot)); + cells.push(( + row_b, + DiagramCell::Gate("H".to_string(), GateFamily::Default), + CellColor::MultiQubit, + )); + } + GateType::SWAP => { + cells.push(( + row_a, + DiagramCell::Gate("x".to_string(), GateFamily::Default), + CellColor::MultiQubit, + )); + cells.push(( + row_b, + DiagramCell::Gate("x".to_string(), GateFamily::Default), + CellColor::MultiQubit, + )); + } + _ => { + let family = gate_family(gate.gate_type); + cells.push((row_a, DiagramCell::Gate(sym.clone(), family), color)); + cells.push((row_b, DiagramCell::Gate(sym.clone(), family), color)); + } + } + + // Intermediate rows: crossings on qubit wires. + for row in (top + 1)..bottom { + if row < num_rows { + cells.push((row, DiagramCell::Crossing, CellColor::MultiQubit)); + } + } + } + } else if arity == 3 { + for triple in qubits.chunks(3) { + if triple.len() < 3 { + continue; + } + let (c0, c1, t) = (triple[0], triple[1], triple[2]); + let rows: Vec> = [c0, c1, t] + .iter() + .map(|q| qubit_to_row.get(q).copied()) + .collect(); + if rows.iter().any(Option::is_none) { + continue; + } + let rows: Vec = rows.into_iter().map(|r| r.unwrap()).collect(); + let top = *rows.iter().min().unwrap(); + let bottom = *rows.iter().max().unwrap(); + + cells.push((rows[0], DiagramCell::Control, CellColor::ControlDot)); + cells.push((rows[1], DiagramCell::Control, CellColor::ControlDot)); + cells.push(( + rows[2], + DiagramCell::Gate("X".to_string(), GateFamily::Default), + CellColor::MultiQubit, + )); + + let gate_rows: BTreeSet = rows.iter().copied().collect(); + for row in (top + 1)..bottom { + if !gate_rows.contains(&row) && row < num_rows { + cells.push((row, DiagramCell::Crossing, CellColor::MultiQubit)); + } + } + } + } + + cells +} + +// ==================== Diagram building ==================== + +/// Build a `CircuitDiagram` from gate layers. +/// +/// Returns `None` when `layers` contain no qubits. +fn build_diagram(layers: &[Vec<&Gate>]) -> Option { + let mut qubit_set = BTreeSet::new(); + for layer in layers { + for gate in layer { + for &q in &gate.qubits { + qubit_set.insert(q); + } + } + } + let qubits: Vec = qubit_set.into_iter().collect(); + if qubits.is_empty() { + return None; + } + + let qubit_to_row: std::collections::BTreeMap = qubits + .iter() + .enumerate() + .map(|(i, &q)| (q, i)) + .collect(); + let num_rows = qubits.len(); + + let labels: Vec = qubits.iter().map(|q| format!("q{}", q.0)).collect(); + let mut diagram = CircuitDiagram::with_labels(labels); + + for (layer_idx, layer) in layers.iter().enumerate() { + if layer_idx > 0 { + diagram.advance(); + } + for gate in layer { + let entries = decompose_gate(gate, &qubit_to_row, num_rows); + for (row, cell, color) in entries { + if row < num_rows { + diagram.set_cell(row, cell, color); + } + } + } + } + + Some(diagram) +} + +// ==================== Public rendering entry points ==================== + +/// Format a circuit as a text wire diagram. +/// +/// `header` - text for the first line (e.g. "`TickCircuit`: 3 qubits, 4 ticks"). +/// `layers` - each element is a slice of gates that execute in parallel. +/// `options` - rendering options (symbol set, color). +pub(crate) fn format_circuit( + header: &str, + layers: &[Vec<&Gate>], + options: &DiagramOptions, +) -> String { + match build_diagram(layers) { + Some(diagram) => diagram.render(header, options), + None => format!("{header}\n"), + } +} + +/// Format a circuit as an SVG wire diagram. +pub(crate) fn format_circuit_svg(header: &str, layers: &[Vec<&Gate>]) -> String { + match build_diagram(layers) { + Some(diagram) => diagram.render_svg(header), + None => format!("{header}"), + } +} + +/// Format a circuit as a `TikZ` `tikzpicture`. +pub(crate) fn format_circuit_tikz(header: &str, layers: &[Vec<&Gate>]) -> String { + if let Some(diagram) = build_diagram(layers) { + diagram.render_tikz(header) + } else { + let mut out = String::new(); + if !header.is_empty() { + use std::fmt::Write; + writeln!(out, "% {header}").unwrap(); + } + out.push_str("\\begin{tikzpicture}\n\\end{tikzpicture}\n"); + out + } +} + +/// Format a circuit as a Graphviz DOT digraph. +pub(crate) fn format_circuit_dot(header: &str, layers: &[Vec<&Gate>]) -> String { + if let Some(diagram) = build_diagram(layers) { + diagram.render_dot(header) + } else { + let mut out = String::from("digraph circuit {\n rankdir=LR;\n"); + if !header.is_empty() { + use std::fmt::Write; + writeln!(out, " label=\"{header}\";").unwrap(); + } + out.push_str("}\n"); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pecos_core::Angle64; + + fn render_tick(build: impl FnOnce(&mut crate::TickCircuit)) -> String { + let mut tc = crate::TickCircuit::new(); + build(&mut tc); + tc.to_ascii() + } + + fn render_tick_color(build: impl FnOnce(&mut crate::TickCircuit)) -> String { + let mut tc = crate::TickCircuit::new(); + build(&mut tc); + tc.to_color_ascii() + } + + #[test] + fn single_qubit_gates_on_correct_wires() { + let out = render_tick(|tc| { + tc.tick().h(&[0]); + tc.tick().x(&[1]); + }); + assert!(out.contains("q0:")); + assert!(out.contains("q1:")); + let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q0_line.contains("")); + assert!(!q0_line.contains("(X)")); + assert!(q1_line.contains("(X)")); + assert!(!q1_line.contains("")); + } + + #[test] + fn cx_shows_control_target_connector() { + let out = render_tick(|tc| { + tc.tick().h(&[0, 1, 2]); + tc.tick().cx(&[(0, 2)]); + }); + assert!(out.contains('.')); + assert!(out.contains("[X]")); // CX target uses Default brackets + assert!(out.contains('|')); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q1_line.contains('+')); + } + + #[test] + fn multi_tick_alignment() { + let out = render_tick(|tc| { + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[1]); + }); + let qubit_lines: Vec<&str> = out.lines().filter(|l| l.starts_with('q')).collect(); + assert!(qubit_lines.len() >= 2); + let len0 = qubit_lines[0].len(); + for line in &qubit_lines { + assert_eq!(line.len(), len0, "Lines should have equal length"); + } + } + + #[test] + fn parameterized_gate_includes_angle() { + let out = render_tick(|tc| { + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + }); + assert!(out.contains("Rz(")); + assert!(out.contains(".25")); + } + + #[test] + fn empty_circuit_shows_header_only() { + let tc = crate::TickCircuit::new(); + let out = tc.to_ascii(); + assert!(out.contains("TickCircuit:")); + assert!(!out.contains("q0:")); + } + + #[test] + fn color_version_contains_ansi_plain_does_not() { + let plain = render_tick(|tc| { + tc.tick().h(&[0]); + }); + let colored = render_tick_color(|tc| { + tc.tick().h(&[0]); + }); + assert!(!plain.contains("\x1b[")); + assert!(colored.contains("\x1b[")); + } + + #[test] + fn non_contiguous_qubit_ids() { + let out = render_tick(|tc| { + tc.tick().h(&[5]); + tc.tick().h(&[10]); + }); + assert!(out.contains("q5:")); + assert!(out.contains("q10:")); + } + + #[test] + fn dag_and_tick_produce_identical_output() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[1]); + + let mut dag = crate::DagCircuit::new(); + dag.h(0); + dag.cx(0, 1); + dag.h(1); + + let tick_out = tc.to_ascii(); + let dag_out = dag.to_ascii(); + + let tick_lines: Vec<&str> = tick_out.lines().filter(|l| l.starts_with('q')).collect(); + let dag_lines: Vec<&str> = dag_out.lines().filter(|l| l.starts_with('q')).collect(); + assert_eq!(tick_lines, dag_lines); + } + + #[test] + fn cz_shows_two_controls() { + let out = render_tick(|tc| { + tc.tick().cz(&[(0, 1)]); + }); + let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q0_line.contains('.')); + assert!(q1_line.contains('.')); + } + + #[test] + fn swap_shows_x_on_both() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]).h(&[1]); + tc.tick(); + let swap_gate = Gate::simple( + GateType::SWAP, + smallvec::smallvec![QubitId::from(0usize), QubitId::from(1usize)], + ); + tc.get_tick_mut(1).unwrap().add_gate(swap_gate); + let out = tc.to_ascii(); + let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + assert!(q0_line.contains("[x]")); + assert!(q1_line.contains("[x]")); + } + + #[test] + fn measurement_and_prep() { + let out = render_tick(|tc| { + tc.tick().pz(&[0]); + tc.tick().h(&[0]); + tc.tick().mz(&[0]); + }); + assert!(out.contains("(PZ|")); + assert!(out.contains("")); + assert!(out.contains("|MZ)")); + } + + #[test] + fn batched_single_qubit_gates() { + let out = render_tick(|tc| { + tc.tick().h(&[0, 1, 2]); + }); + let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + let q2_line = out.lines().find(|l| l.starts_with("q2:")).unwrap(); + assert!(q0_line.contains("")); + assert!(q1_line.contains("")); + assert!(q2_line.contains("")); + } + + #[test] + fn unicode_uses_box_drawing() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + let out = tc.to_unicode(); + assert!(out.contains('\u{2500}')); // ─ + assert!(!out.contains("---")); // no plain dashes as wire + } + + #[test] + fn unicode_control_dot() { + let mut tc = crate::TickCircuit::new(); + tc.tick().cx(&[(0, 1)]); + let out = tc.to_unicode(); + assert!(out.contains('\u{25CF}')); // ● + } + + #[test] + fn to_ascii_color_deprecated_alias() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + #[allow(deprecated)] + let a = tc.to_ascii_color(); + let b = tc.to_color_ascii(); + assert_eq!(a, b); + } + + // ====================== SVG integration ====================== + + #[test] + fn tick_svg_contains_gate_elements() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + let svg = tc.to_svg(); + assert!(svg.contains("")); + assert!(svg.contains(">H")); + assert!(svg.contains("H")); + assert!(dag_svg.contains(">H")); + assert!(tick_svg.contains(">X")); + assert!(dag_svg.contains(">X")); + } + + // ====================== TikZ integration ====================== + + #[test] + fn tick_tikz_contains_commands() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + let tikz = tc.to_tikz(); + assert!(tikz.contains("\\begin{tikzpicture}")); + assert!(tikz.contains("\\end{tikzpicture}")); + assert!(tikz.contains("{H}")); + assert!(tikz.contains("\\node[ctrl")); + } + + #[test] + fn dag_tikz_contains_commands() { + let mut dag = crate::DagCircuit::new(); + dag.h(0); + dag.cx(0, 1); + let tikz = dag.to_tikz(); + assert!(tikz.contains("\\begin{tikzpicture}")); + assert!(tikz.contains("{H}")); + } + + // ====================== DOT integration ====================== + + #[test] + fn tick_dot_contains_graph() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + let dot = tc.to_dot(); + assert!(dot.contains("digraph circuit")); + assert!(dot.contains("rankdir=LR")); + assert!(dot.contains("label=\"H\"")); + assert!(dot.contains("shape=point, width=0.12")); // control + } + + #[test] + fn dag_dot_contains_graph() { + let mut dag = crate::DagCircuit::new(); + dag.h(0); + dag.cx(0, 1); + let dot = dag.to_dot(); + assert!(dot.contains("digraph circuit")); + assert!(dot.contains("label=\"H\"")); + } + + // ====================== Gate family integration ====================== + + #[test] + fn family_brackets_in_tick_output() { + let out = render_tick(|tc| { + tc.tick().pz(&[0]); + tc.tick().h(&[0]); + tc.tick().sx(&[0]); + tc.tick().x(&[0]); + tc.tick().mz(&[0]); + }); + assert!(out.contains("(PZ|")); // Preparation + assert!(out.contains("")); // HLike + assert!(out.contains("[SX]")); // SLike + assert!(out.contains("(X)")); // Pauli + assert!(out.contains("|MZ)")); // Measurement + } + + #[test] + fn family_brackets_in_dag_output() { + let mut dag = crate::DagCircuit::new(); + dag.pz(0); + dag.h(0); + dag.sx(0); + dag.x(0); + dag.mz(0); + let out = dag.to_ascii(); + assert!(out.contains("(PZ|")); + assert!(out.contains("")); + assert!(out.contains("[SX]")); + assert!(out.contains("(X)")); + assert!(out.contains("|MZ)")); + } + + #[test] + fn svg_hlike_has_dotted_stroke() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + let svg = tc.to_svg(); + assert!(svg.contains("stroke-dasharray=\"2,2\"")); + } + + #[test] + fn svg_slike_has_dashed_stroke() { + let mut tc = crate::TickCircuit::new(); + tc.tick().sz(&[0]); + let svg = tc.to_svg(); + assert!(svg.contains("stroke-dasharray=\"4,3\"")); + } +} diff --git a/crates/pecos-quantum/src/dag_circuit.rs b/crates/pecos-quantum/src/dag_circuit.rs index 22a2eb848..d332d02d4 100644 --- a/crates/pecos-quantum/src/dag_circuit.rs +++ b/crates/pecos-quantum/src/dag_circuit.rs @@ -795,6 +795,92 @@ impl DagCircuit { self.dag.layers(roots) } + /// Export as a plain ASCII circuit diagram. + /// + /// Uses [`layers`](Self::layers) to determine column layout. + /// Horizontal qubit wires with gate symbols placed at each layer column. + #[must_use] + pub fn to_ascii(&self) -> String { + self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::ascii()) + } + + /// ASCII circuit diagram with ANSI color codes. + /// + /// Same layout as [`to_ascii`](Self::to_ascii) with color-coded gate + /// categories: blue for single-qubit, green for two-qubit, yellow for + /// measurements, cyan for preparations. + #[must_use] + pub fn to_color_ascii(&self) -> String { + self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::color_ascii()) + } + + /// Unicode circuit diagram with box-drawing characters. + #[must_use] + pub fn to_unicode(&self) -> String { + self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::unicode()) + } + + /// Unicode circuit diagram with ANSI color codes. + #[must_use] + pub fn to_color_unicode(&self) -> String { + self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::color_unicode()) + } + + /// Export as an SVG circuit diagram. + #[must_use] + pub fn to_svg(&self) -> String { + let (header, layers) = self.diagram_parts(); + crate::circuit_display::format_circuit_svg(&header, &layers) + } + + /// Export as a `TikZ` `tikzpicture`. + #[must_use] + pub fn to_tikz(&self) -> String { + let (header, layers) = self.diagram_parts(); + crate::circuit_display::format_circuit_tikz(&header, &layers) + } + + /// Export as a Graphviz DOT digraph. + #[must_use] + pub fn to_dot(&self) -> String { + let (header, layers) = self.diagram_parts(); + crate::circuit_display::format_circuit_dot(&header, &layers) + } + + /// Deprecated: use [`to_color_ascii`](Self::to_color_ascii) instead. + #[deprecated(note = "renamed to to_color_ascii")] + #[must_use] + pub fn to_ascii_color(&self) -> String { + self.to_color_ascii() + } + + fn diagram_parts(&self) -> (String, Vec>) { + let layers: Vec> = self + .layers() + .map(|node_ids| { + node_ids + .iter() + .filter_map(|&id| self.gate(id)) + .collect() + }) + .collect(); + let num_qubits = self.qubits().len(); + let num_layers = layers.len(); + let header = format!( + "DagCircuit: {} qubit{}, {} layer{}", + num_qubits, + if num_qubits == 1 { "" } else { "s" }, + num_layers, + if num_layers == 1 { "" } else { "s" }, + ); + (header, layers) + } + + fn format_diagram(&self, options: &pecos_core::circuit_diagram::DiagramOptions) -> String { + let (header, layers) = self.diagram_parts(); + crate::circuit_display::format_circuit(&header, &layers, options) + } + /// Returns the root gates (gates with no incoming wires). #[must_use] pub fn roots(&self) -> Vec { diff --git a/crates/pecos-quantum/src/lib.rs b/crates/pecos-quantum/src/lib.rs index 2f1058619..ea4a4f320 100644 --- a/crates/pecos-quantum/src/lib.rs +++ b/crates/pecos-quantum/src/lib.rs @@ -64,6 +64,7 @@ //! ``` mod circuit; +mod circuit_display; mod dag_circuit; pub mod operator_matrix; mod tick_circuit; diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 0e655ace7..c5619244e 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -543,6 +543,87 @@ impl TickCircuit { &self.ticks } + /// Export as a plain ASCII circuit diagram. + /// + /// Produces horizontal qubit-wire lines with gate symbols placed at each + /// tick column. Two-qubit gates show `.`/`[X]` with `|` connectors. + #[must_use] + pub fn to_ascii(&self) -> String { + self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::ascii()) + } + + /// ASCII circuit diagram with ANSI color codes. + /// + /// Same layout as [`to_ascii`](Self::to_ascii) with color-coded gate + /// categories: blue for single-qubit, green for two-qubit, yellow for + /// measurements, cyan for preparations. + #[must_use] + pub fn to_color_ascii(&self) -> String { + self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::color_ascii()) + } + + /// Unicode circuit diagram with box-drawing characters. + #[must_use] + pub fn to_unicode(&self) -> String { + self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::unicode()) + } + + /// Unicode circuit diagram with ANSI color codes. + #[must_use] + pub fn to_color_unicode(&self) -> String { + self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::color_unicode()) + } + + /// Export as an SVG circuit diagram. + #[must_use] + pub fn to_svg(&self) -> String { + let (header, layers) = self.diagram_parts(); + crate::circuit_display::format_circuit_svg(&header, &layers) + } + + /// Export as a `TikZ` `tikzpicture`. + #[must_use] + pub fn to_tikz(&self) -> String { + let (header, layers) = self.diagram_parts(); + crate::circuit_display::format_circuit_tikz(&header, &layers) + } + + /// Export as a Graphviz DOT digraph. + #[must_use] + pub fn to_dot(&self) -> String { + let (header, layers) = self.diagram_parts(); + crate::circuit_display::format_circuit_dot(&header, &layers) + } + + /// Deprecated: use [`to_color_ascii`](Self::to_color_ascii) instead. + #[deprecated(note = "renamed to to_color_ascii")] + #[must_use] + pub fn to_ascii_color(&self) -> String { + self.to_color_ascii() + } + + fn diagram_parts(&self) -> (String, Vec>) { + let layers: Vec> = self + .ticks + .iter() + .map(|t| t.gates().iter().collect()) + .collect(); + let num_qubits = self.all_qubits().len(); + let header = format!( + "TickCircuit: {} qubit{}, {} tick{}", + num_qubits, + if num_qubits == 1 { "" } else { "s" }, + self.ticks.len(), + if self.ticks.len() == 1 { "" } else { "s" }, + ); + (header, layers) + } + + fn format_diagram(&self, options: &pecos_core::circuit_diagram::DiagramOptions) -> String { + let (header, layers) = self.diagram_parts(); + crate::circuit_display::format_circuit(&header, &layers, options) + } + /// Get the next tick index that will be allocated. #[must_use] pub fn next_tick_index(&self) -> usize { From b0151883596e9d166d7e627cd31026c14d555254 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 1 Mar 2026 19:39:36 -0700 Subject: [PATCH 06/12] visualization --- Cargo.lock | 1 + crates/pecos-core/src/circuit_diagram.rs | 1976 ++++++++++-- crates/pecos-core/src/lib.rs | 6 + crates/pecos-core/src/operator.rs | 136 +- crates/pecos-qsim/src/clifford_frame.rs | 12 +- crates/pecos-qsim/src/graph_state.rs | 96 +- crates/pecos-qsim/src/graph_state_repr.rs | 935 ++++-- crates/pecos-qsim/src/lib.rs | 8 +- crates/pecos-quantum/Cargo.toml | 3 + crates/pecos-quantum/examples/style_demo.rs | 714 +++++ crates/pecos-quantum/src/circuit_display.rs | 755 ++++- crates/pecos-quantum/src/dag_circuit.rs | 62 +- crates/pecos-quantum/src/lib.rs | 1 + crates/pecos-quantum/src/operator_matrix.rs | 9 +- crates/pecos-quantum/src/pass.rs | 3116 +++++++++++++++++++ crates/pecos-quantum/src/tick_circuit.rs | 65 +- examples/svg_demo.rs | 60 + 17 files changed, 7013 insertions(+), 942 deletions(-) create mode 100644 crates/pecos-quantum/examples/style_demo.rs create mode 100644 crates/pecos-quantum/src/pass.rs create mode 100644 examples/svg_demo.rs diff --git a/Cargo.lock b/Cargo.lock index ff51170f8..c28e8be61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3733,6 +3733,7 @@ dependencies = [ "num-complex 0.4.6", "pecos-core", "pecos-num", + "pecos-qsim", "smallvec", "tket", ] diff --git a/crates/pecos-core/src/circuit_diagram.rs b/crates/pecos-core/src/circuit_diagram.rs index e07f585e0..4bc77986c 100644 --- a/crates/pecos-core/src/circuit_diagram.rs +++ b/crates/pecos-core/src/circuit_diagram.rs @@ -34,23 +34,36 @@ pub enum DiagramCell { Connector, /// Wire crossing: a wire passes through a vertical connector. Crossing, + /// Labeled connector: a label displayed on the vertical connector between + /// two control dots (e.g. `ZZ` for symmetric two-qubit interactions). + LabeledConnector(String), } /// Color category for a diagram cell. +/// +/// Follows the PECOS color algebra based on Pauli axis interconversion: +/// - Base axes: X = Red, Y = Green, Z = Blue +/// - Mixed axes use additive RGB: X<->Z = Magenta, X<->Y = Yellow, Y<->Z = Cyan #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum CellColor { /// No special color (default terminal color). #[default] None, - /// Single-qubit gate (blue). - SingleQubit, - /// Multi-qubit gate (green). - MultiQubit, - /// Measurement (yellow). - Measurement, - /// Preparation / allocation (cyan). - Preparation, - /// Control dot (bold green). + /// X-axis (red): X, RX, CX target, etc. + XAxis, + /// Y-axis (green): Y, RY, CY target, etc. + YAxis, + /// Z-axis (blue): Z, RZ, T, MZ, PZ, etc. + ZAxis, + /// X<->Z mixing (magenta): H, SY, `SYdg`. + XZMix, + /// X<->Y mixing (yellow): SZ, `SZdg`. + XYMix, + /// Y<->Z mixing (cyan): SX, `SXdg`. + YZMix, + /// All-axis mixing (grey): F-like composites. + XYZMix, + /// Control dot (dark). ControlDot, } @@ -60,11 +73,14 @@ impl CellColor { pub fn hex_fill(self) -> &'static str { match self { Self::None => "#FFFFFF", - Self::SingleQubit => "#A8C8F0", - Self::MultiQubit => "#A8E0A8", - Self::Measurement => "#F0E0A0", - Self::Preparation => "#A0E8E8", - Self::ControlDot => "#2D8A2D", + Self::XAxis => "#FFB0B0", + Self::YAxis => "#B0E8B0", + Self::ZAxis => "#A8C8F0", + Self::XZMix => "#E0B0E0", + Self::XYMix => "#F0E0A0", + Self::YZMix => "#A0E0E8", + Self::XYZMix => "#D0D0D0", + Self::ControlDot => "#333333", } } @@ -72,12 +88,14 @@ impl CellColor { #[must_use] pub fn hex_stroke(self) -> &'static str { match self { - Self::None => "#888888", - Self::SingleQubit => "#2255AA", - Self::MultiQubit => "#226622", - Self::Measurement => "#AA8800", - Self::Preparation => "#008888", - Self::ControlDot => "#1A5A1A", + Self::XAxis => "#AA2222", + Self::YAxis => "#226622", + Self::ZAxis => "#2255AA", + Self::XZMix => "#882288", + Self::XYMix => "#AA8800", + Self::YZMix => "#008888", + Self::XYZMix => "#666666", + Self::None | Self::ControlDot => "#222222", } } @@ -85,11 +103,13 @@ impl CellColor { #[must_use] pub fn hex_text(self) -> &'static str { match self { - Self::None => "#333333", - Self::SingleQubit => "#1A3A7A", - Self::MultiQubit => "#1A4A1A", - Self::Measurement => "#6A5500", - Self::Preparation => "#005A5A", + Self::XAxis => "#7A1A1A", + Self::YAxis => "#1A4A1A", + Self::ZAxis => "#1A3A7A", + Self::XZMix => "#5A1A5A", + Self::XYMix => "#6A5500", + Self::YZMix => "#005A5A", + Self::None | Self::XYZMix => "#333333", Self::ControlDot => "#FFFFFF", } } @@ -99,10 +119,13 @@ impl CellColor { pub fn tikz_name(self) -> &'static str { match self { Self::None => "cellNone", - Self::SingleQubit => "cellSQ", - Self::MultiQubit => "cellMQ", - Self::Measurement => "cellMeas", - Self::Preparation => "cellPrep", + Self::XAxis => "cellX", + Self::YAxis => "cellY", + Self::ZAxis => "cellZ", + Self::XZMix => "cellXZ", + Self::XYMix => "cellXY", + Self::YZMix => "cellYZ", + Self::XYZMix => "cellXYZ", Self::ControlDot => "cellCtrl", } } @@ -189,6 +212,17 @@ impl GateFamily { } } +/// How to display rotation angles in gate labels. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AngleUnit { + /// Display as multiples of pi with fractions, e.g. `\u{03C0}/4`, `3\u{03C0}/2`. + /// Falls back to decimal radians for non-nice fractions. + #[default] + Radians, + /// Display as fractional turns, e.g. `.25`, `.125`. + Turns, +} + /// Which character set to use for rendering. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum SymbolSet { @@ -199,49 +233,387 @@ pub enum SymbolSet { Unicode, } -/// Options controlling diagram appearance. +// ============================================================================ +// Color palette types +// ============================================================================ + +/// Fill, stroke, and text colors for a single diagram element category. #[derive(Clone, Debug)] -pub struct DiagramOptions { - pub symbols: SymbolSet, - pub color: bool, +pub struct ColorTriplet { + pub fill: String, + pub stroke: String, + pub text: String, } -impl DiagramOptions { - /// Plain ASCII, no color. +impl ColorTriplet { + /// Create a new triplet from string slices. #[must_use] - pub fn ascii() -> Self { + pub fn new(fill: &str, stroke: &str, text: &str) -> Self { Self { - symbols: SymbolSet::Ascii, - color: false, + fill: fill.to_string(), + stroke: stroke.to_string(), + text: text.to_string(), } } +} - /// ASCII with ANSI color. +/// Complete color palette for all diagram cell categories. +#[derive(Clone, Debug)] +pub struct ColorPalette { + pub none: ColorTriplet, + pub x_axis: ColorTriplet, + pub y_axis: ColorTriplet, + pub z_axis: ColorTriplet, + pub xz_mix: ColorTriplet, + pub xy_mix: ColorTriplet, + pub yz_mix: ColorTriplet, + pub xyz_mix: ColorTriplet, + pub control_dot: ColorTriplet, +} + +impl Default for ColorPalette { + fn default() -> Self { + Self { + none: ColorTriplet::new("#FFFFFF", "#222222", "#222222"), + x_axis: ColorTriplet::new("#FFB0B0", "#AA2222", "#7A1A1A"), + y_axis: ColorTriplet::new("#B0E8B0", "#226622", "#1A4A1A"), + z_axis: ColorTriplet::new("#A8C8F0", "#2255AA", "#1A3A7A"), + xz_mix: ColorTriplet::new("#E0B0E0", "#882288", "#5A1A5A"), + xy_mix: ColorTriplet::new("#F0E0A0", "#AA8800", "#6A5500"), + yz_mix: ColorTriplet::new("#A0E0E8", "#008888", "#005A5A"), + xyz_mix: ColorTriplet::new("#D0D0D0", "#666666", "#333333"), + control_dot: ColorTriplet::new("#333333", "#222222", "#FFFFFF"), + } + } +} + +impl ColorPalette { + /// Look up the color triplet for a given cell color category. #[must_use] - pub fn color_ascii() -> Self { + pub fn get(&self, color: CellColor) -> &ColorTriplet { + match color { + CellColor::None => &self.none, + CellColor::XAxis => &self.x_axis, + CellColor::YAxis => &self.y_axis, + CellColor::ZAxis => &self.z_axis, + CellColor::XZMix => &self.xz_mix, + CellColor::XYMix => &self.xy_mix, + CellColor::YZMix => &self.yz_mix, + CellColor::XYZMix => &self.xyz_mix, + CellColor::ControlDot => &self.control_dot, + } + } +} + +// ============================================================================ +// DiagramStyle +// ============================================================================ + +/// Full configuration for diagram rendering. +/// +/// Controls text symbol set, color modes, dash patterns, and the color palette. +/// Use [`DiagramStyle::builder()`] for convenient construction. +#[derive(Clone, Debug)] +pub struct DiagramStyle { + pub symbols: SymbolSet, + /// Whether to emit ANSI color codes in text output. + pub ansi_color: bool, + /// Whether graphical outputs (SVG, `TikZ`, DOT) use color. When false, + /// all gates use the `none` palette entry (monochrome). + pub color: bool, + /// Whether to render stroke dash patterns. When false, all strokes are solid. + pub show_dashes: bool, + /// How to display rotation angles in gate labels. + pub angle_unit: AngleUnit, + pub palette: ColorPalette, +} + +impl Default for DiagramStyle { + fn default() -> Self { Self { symbols: SymbolSet::Ascii, + ansi_color: false, color: true, + show_dashes: true, + angle_unit: AngleUnit::Radians, + palette: ColorPalette::default(), } } +} - /// Unicode box-drawing, no color. +impl DiagramStyle { + /// Create a builder for constructing a custom `DiagramStyle`. #[must_use] - pub fn unicode() -> Self { + pub fn builder() -> DiagramStyleBuilder { + DiagramStyleBuilder::new() + } + + /// Look up the effective color triplet for a cell, respecting the `color` flag. + /// Control dots are always filled (even in monochrome) so they remain visible. + #[must_use] + pub fn triplet(&self, color: CellColor) -> &ColorTriplet { + if self.color || color == CellColor::ControlDot { + self.palette.get(color) + } else { + self.palette.get(CellColor::None) + } + } + + /// Effective SVG dasharray for a gate family, respecting `show_dashes`. + #[must_use] + pub fn svg_dasharray(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.svg_dasharray() + } else { + "" + } + } + + /// Effective `TikZ` dash pattern for a gate family, respecting `show_dashes`. + #[must_use] + pub fn tikz_dash(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.tikz_dash() + } else { + "" + } + } + + /// Effective DOT style for a gate family, respecting `show_dashes`. + #[must_use] + pub fn dot_style(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.dot_style() + } else { + "" + } + } +} + +// ============================================================================ +// DiagramStyleBuilder +// ============================================================================ + +/// Builder for [`DiagramStyle`]. +#[derive(Clone, Debug)] +pub struct DiagramStyleBuilder { + style: DiagramStyle, +} + +impl DiagramStyleBuilder { + /// Create a new builder with default settings. + #[must_use] + pub fn new() -> Self { Self { - symbols: SymbolSet::Unicode, - color: false, + style: DiagramStyle::default(), } } - /// Unicode box-drawing with ANSI color. + /// Preset: plain ASCII, no ANSI color. + #[must_use] + pub fn ascii() -> Self { + Self::new() + } + + /// Preset: ASCII with ANSI color. + #[must_use] + pub fn color_ascii() -> Self { + let mut b = Self::new(); + b.style.ansi_color = true; + b + } + + /// Preset: Unicode box-drawing, no ANSI color. + #[must_use] + pub fn unicode() -> Self { + let mut b = Self::new(); + b.style.symbols = SymbolSet::Unicode; + b + } + + /// Preset: Unicode with ANSI color. #[must_use] pub fn color_unicode() -> Self { + let mut b = Self::new(); + b.style.symbols = SymbolSet::Unicode; + b.style.ansi_color = true; + b + } + + /// Set the character symbol set. + #[must_use] + pub fn symbols(mut self, s: SymbolSet) -> Self { + self.style.symbols = s; + self + } + + /// Enable or disable ANSI color in text output. + #[must_use] + pub fn ansi_color(mut self, b: bool) -> Self { + self.style.ansi_color = b; + self + } + + /// Enable or disable color in graphical output (SVG, `TikZ`, DOT). + #[must_use] + pub fn color(mut self, b: bool) -> Self { + self.style.color = b; + self + } + + /// Enable or disable dash stroke patterns. + #[must_use] + pub fn show_dashes(mut self, b: bool) -> Self { + self.style.show_dashes = b; + self + } + + /// Set the angle display unit for rotation gate labels. + #[must_use] + pub fn angle_unit(mut self, u: AngleUnit) -> Self { + self.style.angle_unit = u; + self + } + + /// Set the entire color palette. + #[must_use] + pub fn palette(mut self, p: ColorPalette) -> Self { + self.style.palette = p; + self + } + + /// Set X-axis colors. + #[must_use] + pub fn x_axis(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.x_axis = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set Y-axis colors. + #[must_use] + pub fn y_axis(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.y_axis = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set Z-axis colors. + #[must_use] + pub fn z_axis(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.z_axis = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set X-Z mix colors. + #[must_use] + pub fn xz_mix(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.xz_mix = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set X-Y mix colors. + #[must_use] + pub fn xy_mix(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.xy_mix = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set Y-Z mix colors. + #[must_use] + pub fn yz_mix(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.yz_mix = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set XYZ mix colors. + #[must_use] + pub fn xyz_mix(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.xyz_mix = ColorTriplet::new(fill, stroke, text); + self + } + + /// Set control dot colors. + #[must_use] + pub fn control_dot(mut self, fill: &str, stroke: &str, text: &str) -> Self { + self.style.palette.control_dot = ColorTriplet::new(fill, stroke, text); + self + } + + /// Build the final `DiagramStyle`. + #[must_use] + pub fn build(self) -> DiagramStyle { + self.style + } +} + +impl Default for DiagramStyleBuilder { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// DiagramRenderer +// ============================================================================ + +/// A prepared diagram bound to a style, ready to render in any output format. +/// +/// Obtained from `render_with` on [`crate::Operator`], `TickCircuit`, or `DagCircuit`. +pub struct DiagramRenderer<'a> { + diagram: CircuitDiagram, + header: String, + style: &'a DiagramStyle, +} + +impl<'a> DiagramRenderer<'a> { + /// Create a new renderer from a pre-built diagram and style. + #[must_use] + pub fn new(diagram: CircuitDiagram, header: String, style: &'a DiagramStyle) -> Self { Self { - symbols: SymbolSet::Unicode, - color: true, + diagram, + header, + style, } } + + /// Render as a text wire diagram using the style's symbol set. + #[must_use] + pub fn text(&self) -> String { + self.diagram.render_text(&self.header, self.style) + } + + /// Render as an ASCII text diagram (overrides symbols to ASCII). + #[must_use] + pub fn ascii(&self) -> String { + let mut s = self.style.clone(); + s.symbols = SymbolSet::Ascii; + self.diagram.render_text(&self.header, &s) + } + + /// Render as a Unicode text diagram (overrides symbols to Unicode). + #[must_use] + pub fn unicode(&self) -> String { + let mut s = self.style.clone(); + s.symbols = SymbolSet::Unicode; + self.diagram.render_text(&self.header, &s) + } + + /// Render as an SVG string. + #[must_use] + pub fn svg(&self) -> String { + self.diagram.render_svg_with(&self.header, self.style) + } + + /// Render as a `TikZ` `tikzpicture`. + #[must_use] + pub fn tikz(&self) -> String { + self.diagram.render_tikz_with(&self.header, self.style) + } + + /// Render as a Graphviz DOT digraph. + #[must_use] + pub fn dot(&self) -> String { + self.diagram.render_dot_with(&self.header, self.style) + } } // ============================================================================ @@ -253,11 +625,14 @@ const ANSI_RESET: &str = "\x1b[0m"; fn ansi_code(color: CellColor) -> &'static str { match color { CellColor::None => "", - CellColor::SingleQubit => "\x1b[34m", - CellColor::MultiQubit => "\x1b[32m", - CellColor::Measurement => "\x1b[33m", - CellColor::Preparation => "\x1b[36m", - CellColor::ControlDot => "\x1b[1;32m", + CellColor::XAxis => "\x1b[31m", + CellColor::YAxis => "\x1b[32m", + CellColor::ZAxis => "\x1b[34m", + CellColor::XZMix => "\x1b[35m", + CellColor::XYMix => "\x1b[33m", + CellColor::YZMix => "\x1b[36m", + CellColor::XYZMix => "\x1b[37m", + CellColor::ControlDot => "\x1b[1m", } } @@ -273,6 +648,10 @@ pub struct CircuitDiagram { labels: Vec, columns: Vec>, current_col: usize, + /// Explicit vertical connector spans: `(column, top_row, bottom_row, optional_label)`. + connector_spans: Vec<(usize, usize, usize, Option)>, + /// Groups of columns representing a single logical tick: `(label, start_col, end_col)`. + column_groups: Vec<(String, usize, usize)>, } impl CircuitDiagram { @@ -284,6 +663,8 @@ impl CircuitDiagram { labels, columns: vec![vec![(DiagramCell::Wire, CellColor::None); n]], current_col: 0, + connector_spans: Vec::new(), + column_groups: Vec::new(), } } @@ -295,6 +676,8 @@ impl CircuitDiagram { labels, columns: vec![vec![(DiagramCell::Wire, CellColor::None); n]], current_col: 0, + connector_spans: Vec::new(), + column_groups: Vec::new(), } } @@ -304,12 +687,21 @@ impl CircuitDiagram { self.labels.len() } + /// Current column index. + #[must_use] + pub fn current_col(&self) -> usize { + self.current_col + } + + /// Register a group of columns that represent a single logical tick. + pub fn add_column_group(&mut self, label: String, start: usize, end: usize) { + self.column_groups.push((label, start, end)); + } + fn ensure_column(&mut self) { while self.current_col >= self.columns.len() { - self.columns.push(vec![ - (DiagramCell::Wire, CellColor::None); - self.num_rows() - ]); + self.columns + .push(vec![(DiagramCell::Wire, CellColor::None); self.num_rows()]); } } @@ -331,11 +723,12 @@ impl CircuitDiagram { self.set_cell(row, DiagramCell::Control, CellColor::ControlDot); } - /// Fill vertical connectors/crossings between `top` and `bottom` (exclusive). + /// Fill vertical connectors/crossings between `top` and `bottom` (exclusive) + /// and record the span for vertical line rendering. /// /// Rows that are qubit wires get `Crossing`; other rows get `Connector`. /// Since every row in a `CircuitDiagram` is a qubit wire, this always - /// places `Crossing` cells. The `color` is applied to all intermediate cells. + /// places `Crossing` cells. pub fn connect_vertical(&mut self, top: usize, bottom: usize, color: CellColor) { self.ensure_column(); let (lo, hi) = if top < bottom { @@ -343,6 +736,7 @@ impl CircuitDiagram { } else { (bottom, top) }; + self.connector_spans.push((self.current_col, lo, hi, None)); for row in (lo + 1)..hi { if row < self.num_rows() { // All rows in CircuitDiagram are qubit wires -> Crossing. @@ -351,17 +745,41 @@ impl CircuitDiagram { } } + /// Record a vertical connector span without setting intermediate cells. + /// + /// Use this when intermediate `Crossing` cells are set separately + /// (e.g. by `set_cell` in circuit display code). + pub fn add_connector(&mut self, top: usize, bottom: usize) { + let (lo, hi) = if top < bottom { + (top, bottom) + } else { + (bottom, top) + }; + self.connector_spans.push((self.current_col, lo, hi, None)); + } + + /// Record a labeled vertical connector span without setting intermediate cells. + /// + /// The label is rendered on the connector line between the two endpoints + /// (e.g. "ZZ" for symmetric two-qubit interactions). + pub fn add_labeled_connector(&mut self, top: usize, bottom: usize, label: String) { + let (lo, hi) = if top < bottom { + (top, bottom) + } else { + (bottom, top) + }; + self.connector_spans + .push((self.current_col, lo, hi, Some(label))); + } + /// Advance to the next column. pub fn advance(&mut self) { self.current_col += 1; } - /// Render the diagram to a string. - /// - /// If `header` is non-empty, it is printed as the first line followed by - /// a blank line. + /// Render the diagram to a text string using a full [`DiagramStyle`]. #[must_use] - pub fn render(&self, header: &str, options: &DiagramOptions) -> String { + pub fn render_text(&self, header: &str, style: &DiagramStyle) -> String { let num_rows = self.num_rows(); if num_rows == 0 { return if header.is_empty() { @@ -382,7 +800,7 @@ impl CircuitDiagram { } // Column widths (based on widest cell content). - let col_widths: Vec = (0..num_cols) + let mut col_widths: Vec = (0..num_cols) .map(|c| { self.columns[c] .iter() @@ -392,9 +810,19 @@ impl CircuitDiagram { }) .collect(); + // Widen columns that carry a connector label so the label text fits. + for (col, _top, _bottom, label) in &self.connector_spans { + if let Some(text) = label { + let text_len = text.chars().count() + 2; // +2 for brackets + if *col < col_widths.len() { + col_widths[*col] = col_widths[*col].max(text_len); + } + } + } + let label_width = self.labels.iter().map(String::len).max().unwrap_or(2); - let wire_char = match options.symbols { + let wire_char = match style.symbols { SymbolSet::Ascii => '-', SymbolSet::Unicode => '\u{2500}', // ─ }; @@ -405,14 +833,66 @@ impl CircuitDiagram { writeln!(out).unwrap(); } + // Bracket annotation line for column groups. + if !self.column_groups.is_empty() { + let mut col_offsets = Vec::with_capacity(num_cols); + let mut offset = 0usize; + for &w in &col_widths { + col_offsets.push(offset); + offset += w + 2; + } + let total_width = offset; + + let mut bracket_chars: Vec = vec![' '; total_width]; + + let (open_bracket, close_bracket, dash) = match style.symbols { + SymbolSet::Ascii => ('|', '|', '-'), + SymbolSet::Unicode => ('\u{251C}', '\u{2524}', '\u{2500}'), + }; + + for (label, start, end) in &self.column_groups { + if *start >= num_cols || *end >= num_cols { + continue; + } + let char_start = col_offsets[*start]; + let char_end = col_offsets[*end] + col_widths[*end] + 2; + + if char_end <= char_start { + continue; + } + + for c in &mut bracket_chars[char_start..char_end] { + *c = dash; + } + bracket_chars[char_start] = open_bracket; + bracket_chars[char_end - 1] = close_bracket; + + let span_len = char_end - char_start; + let label_len = label.chars().count(); + if label_len < span_len.saturating_sub(2) { + let pad = (span_len - label_len) / 2; + for (i, ch) in label.chars().enumerate() { + let pos = char_start + pad + i; + if pos < char_end { + bracket_chars[pos] = ch; + } + } + } + } + + write!(out, "{:>width$} ", "", width = label_width).unwrap(); + let bracket_line: String = bracket_chars.into_iter().collect(); + writeln!(out, "{}", bracket_line.trim_end()).unwrap(); + } + for row in 0..num_rows { write!(out, "{:>label_width$}: ", self.labels[row]).unwrap(); for (col_idx, &width) in col_widths.iter().enumerate() { let (ref cell, color) = self.columns[col_idx][row]; - let rendered = render_cell(cell, width, wire_char, options); + let rendered = render_cell(cell, width, wire_char, style); - if options.color && !matches!(cell, DiagramCell::Wire) { + if style.ansi_color && !matches!(cell, DiagramCell::Wire) { let code = ansi_code(color); if code.is_empty() { write!(out, "{wire_char}{rendered}{wire_char}").unwrap(); @@ -428,9 +908,32 @@ impl CircuitDiagram { // Connector row between qubit wires. if row + 1 < num_rows { - let connector_line = - self.render_connector_row(row, num_cols, &col_widths, options); - if let Some(line) = connector_line { + let has_adjacent_label = + self.connector_spans.iter().any(|(_, top, bottom, label)| { + label.is_some() && *bottom - *top == 1 && *top == row + }); + if has_adjacent_label { + // | row above label + if let Some(line) = + self.render_connector_row(row, num_cols, &col_widths, style, false) + { + writeln!(out, "{}", line.trim_end()).unwrap(); + } + // label row + if let Some(line) = + self.render_connector_row(row, num_cols, &col_widths, style, true) + { + writeln!(out, "{}", line.trim_end()).unwrap(); + } + // | row below label + if let Some(line) = + self.render_connector_row(row, num_cols, &col_widths, style, false) + { + writeln!(out, "{}", line.trim_end()).unwrap(); + } + } else if let Some(line) = + self.render_connector_row(row, num_cols, &col_widths, style, true) + { writeln!(out, "{}", line.trim_end()).unwrap(); } } @@ -448,11 +951,22 @@ impl CircuitDiagram { /// If `header` is non-empty it is rendered as a `` title at the top. #[must_use] pub fn render_svg(&self, header: &str) -> String { + self.render_svg_with(header, &DiagramStyle::default()) + } + + /// Render the diagram as a standalone SVG string using a full [`DiagramStyle`]. + #[must_use] + pub fn render_svg_with(&self, header: &str, style: &DiagramStyle) -> String { const ROW_SPACING: f64 = 40.0; - const COL_SPACING: f64 = 60.0; + const MIN_COL_SPACING: f64 = 40.0; + const COL_PAD: f64 = 10.0; + const CHAR_WIDTH: f64 = 9.0; + const BOX_PAD_SHORT: f64 = 18.0; + const BOX_PAD: f64 = 12.0; + const BOX_PAD_LONG: f64 = 6.0; const GATE_H: f64 = 24.0; const LABEL_MARGIN: f64 = 50.0; - const CTRL_RADIUS: f64 = 5.0; + const CTRL_RADIUS: f64 = 3.5; const FONT_SIZE: f64 = 13.0; const GATE_RX: f64 = 4.0; @@ -472,19 +986,59 @@ impl CircuitDiagram { } // Column widths in characters (used to compute gate box widths). + // Uses gate name length without bracket padding for tighter SVG boxes. let col_widths: Vec = (0..num_cols) .map(|c| { self.columns[c] .iter() - .map(|(cell, _)| cell_content_width(cell)) + .map(|(cell, _)| cell_svg_width(cell)) .max() .unwrap_or(1) }) .collect(); + let box_pad_for = |char_count: usize| -> f64 { + match char_count { + 0..=1 => BOX_PAD_SHORT, + 2..=4 => BOX_PAD, + _ => BOX_PAD_LONG, + } + }; + + // Gate box pixel widths per column. + let mut gate_ws: Vec = col_widths + .iter() + .map(|&w| ((w as f64) * CHAR_WIDTH + box_pad_for(w)).max(GATE_H)) + .collect(); + + // Widen columns that carry a connector label (e.g. "RZZ" on the line + // between two control dots) so the label box doesn't overlap neighbours. + for (col, _top, _bottom, label) in &self.connector_spans { + if let Some(text) = label { + let cc = text.chars().count(); + let label_w = ((cc as f64) * CHAR_WIDTH + box_pad_for(cc)).max(GATE_H); + if *col < gate_ws.len() { + gate_ws[*col] = gate_ws[*col].max(label_w); + } + } + } + + // Per-column spacing: enough for the gate box plus padding, at least MIN_COL_SPACING. + let col_spacings: Vec = gate_ws + .iter() + .map(|&gw| (gw + COL_PAD).max(MIN_COL_SPACING)) + .collect(); + + // Column center x-positions, placed edge-to-edge. + let mut col_cx: Vec = Vec::with_capacity(num_cols); + let mut x_cursor = LABEL_MARGIN; + for &spacing in &col_spacings { + col_cx.push(x_cursor + spacing / 2.0); + x_cursor += spacing; + } + let header_offset: f64 = if header.is_empty() { 0.0 } else { 30.0 }; - let svg_width = - LABEL_MARGIN + (num_cols as f64) * COL_SPACING + COL_SPACING * 0.5 + 20.0; + let svg_width = x_cursor + 20.0; let svg_height = header_offset + (num_rows as f64) * ROW_SPACING + ROW_SPACING * 0.5; let mut out = String::new(); @@ -493,11 +1047,7 @@ impl CircuitDiagram { "" ) .unwrap(); - writeln!( - out, - "" - ) - .unwrap(); + writeln!(out, "").unwrap(); if !header.is_empty() { writeln!( @@ -507,10 +1057,37 @@ impl CircuitDiagram { .unwrap(); } - // Qubit labels and wires. + // Layer 0: Column group backgrounds. + for (label, start, end) in &self.column_groups { + if *start >= num_cols || *end >= num_cols { + continue; + } + let x1 = col_cx[*start] - col_spacings[*start] / 2.0; + let x2 = col_cx[*end] + col_spacings[*end] / 2.0; + let y1 = header_offset + ROW_SPACING * 0.5 - ROW_SPACING * 0.4; + let y2 = + header_offset + ROW_SPACING * ((num_rows - 1) as f64 + 0.5) + ROW_SPACING * 0.4; + let w = x2 - x1; + let h = y2 - y1; + writeln!( + out, + "", + ) + .unwrap(); + let lx = f64::midpoint(x1, x2); + let ly = y1 - 2.0; + writeln!( + out, + "{label}", + ) + .unwrap(); + } + + // Layer 1: Qubit labels and horizontal wires. for row in 0..num_rows { let y = header_offset + ROW_SPACING * (row as f64 + 0.5); - // Label writeln!( out, "", + "", ) .unwrap(); } - // Gate cells. - for (col_idx, col_width) in col_widths.iter().enumerate() { - let cx = LABEL_MARGIN + COL_SPACING * (col_idx as f64 + 0.5); - let gate_w = (*col_width as f64) * 9.0 + 8.0; + // Layer 2: Vertical connector lines (drawn before gates so gates sit on top). + for (col, top, bottom, label) in &self.connector_spans { + let col = *col; + let top = *top; + let bottom = *bottom; + if col >= num_cols { + continue; + } + let cx = col_cx[col]; + let y1 = header_offset + ROW_SPACING * (top as f64 + 0.5); + let y2 = header_offset + ROW_SPACING * (bottom as f64 + 0.5); + let conn_color = if !style.color { + CellColor::ControlDot + } else if label.is_some() { + self.columns[col][top].1 + } else { + CellColor::ControlDot + }; + let conn_stroke = style.triplet(conn_color).stroke.clone(); + writeln!( + out, + "", + ) + .unwrap(); + // Render label on the midpoint of the connector line. + if let Some(text) = label { + let mid_y = f64::midpoint(y1, y2); + let lbl_color = self.columns[col][top].1; + let t = style.triplet(lbl_color); + let char_count = text.chars().count(); + let pad = if char_count <= 1 { + BOX_PAD_SHORT + } else if char_count <= 4 { + BOX_PAD + } else { + BOX_PAD_LONG + }; + let lw = ((char_count as f64) * CHAR_WIDTH + pad).max(GATE_H); + let lh = GATE_H * 0.85; + writeln!( + out, + "", + rx = cx - lw / 2.0, + ry = mid_y - lh / 2.0, + fill = t.fill, + stroke = t.stroke, + ) + .unwrap(); + writeln!( + out, + "{text}", + fs = FONT_SIZE - 1.0, + fill = t.text, + ) + .unwrap(); + } + } + // Layer 3: Gate boxes, control dots, and crossing markers (on top of wires). + for (col_idx, &cx) in col_cx.iter().enumerate().take(num_cols) { for row in 0..num_rows { let cy = header_offset + ROW_SPACING * (row as f64 + 0.5); let (ref cell, color) = self.columns[col_idx][row]; match cell { - DiagramCell::Wire => {} + DiagramCell::Wire | DiagramCell::LabeledConnector(_) => {} DiagramCell::Gate(s, family) => { - let dash = family.svg_dasharray(); + let t = style.triplet(color); + // Per-gate width: sized to its own label, centered in the column. + let char_count = s.chars().count(); + let gw = ((char_count as f64) * CHAR_WIDTH + box_pad_for(char_count)) + .max(GATE_H); + let dash = style.svg_dasharray(*family); let dash_attr = if dash.is_empty() { String::new() } else { format!(" stroke-dasharray=\"{dash}\"") }; - writeln!( - out, - "", - rx = cx - gate_w / 2.0, - ry = cy - GATE_H / 2.0, - fill = color.hex_fill(), - stroke = color.hex_stroke(), - ) - .unwrap(); + let x1 = cx - gw / 2.0; + let y1 = cy - GATE_H / 2.0; + let x2 = x1 + gw; + let y2 = y1 + GATE_H; + let r = GATE_H / 2.0; // curve radius + match family { + GateFamily::Preparation => { + // Curved left side, flat right side. + writeln!( + out, + "", + lx = x1 + r, + fill = t.fill, + stroke = t.stroke, + ) + .unwrap(); + } + GateFamily::Measurement => { + // Flat left side, curved right side. + writeln!( + out, + "", + rx_pt = x2 - r, + fill = t.fill, + stroke = t.stroke, + ) + .unwrap(); + } + _ => { + writeln!( + out, + "", + fill = t.fill, + stroke = t.stroke, + ) + .unwrap(); + } + } writeln!( out, "{s}", - fill = color.hex_text(), + fill = t.text, ) .unwrap(); } DiagramCell::Control => { + let effective = if style.color { + color + } else { + CellColor::ControlDot + }; + let t = style.triplet(effective); writeln!( out, "", - fill = color.hex_fill(), - stroke = color.hex_stroke(), + fill = t.fill, + stroke = t.stroke, ) .unwrap(); } - DiagramCell::Crossing => { - // Vertical line segment through this row (rendered below - // as a connector) + horizontal wire already drawn. - writeln!( - out, - "", - ) - .unwrap(); - } - DiagramCell::Connector => { - // Pure vertical connector (no qubit wire) -- small dot. + DiagramCell::Crossing | DiagramCell::Connector => { writeln!( out, - "", + "", ) .unwrap(); } } } - - // Vertical connectors between multi-qubit cells in this column. - let mut top: Option = None; - let mut bottom: Option = None; - for row in 0..num_rows { - let (ref cell, color) = self.columns[col_idx][row]; - let is_part = !matches!(cell, DiagramCell::Wire) && is_multi_color(color); - if is_part { - if top.is_none() { - top = Some(row); - } - bottom = Some(row); - } - } - if let (Some(t), Some(b)) = (top, bottom) - && t != b - { - let y1 = header_offset + ROW_SPACING * (t as f64 + 0.5); - let y2 = header_offset + ROW_SPACING * (b as f64 + 0.5); - writeln!( - out, - "", - stroke = CellColor::ControlDot.hex_stroke(), - ) - .unwrap(); - } } writeln!(out, "").unwrap(); @@ -637,6 +1279,12 @@ impl CircuitDiagram { /// non-empty it is emitted as a `TikZ` comment. #[must_use] pub fn render_tikz(&self, header: &str) -> String { + self.render_tikz_with(header, &DiagramStyle::default()) + } + + /// Render the diagram as a `TikZ` `tikzpicture` using a full [`DiagramStyle`]. + #[must_use] + pub fn render_tikz_with(&self, header: &str, style: &DiagramStyle) -> String { const ROW_STEP: f64 = 0.8; const COL_STEP: f64 = 1.2; const GATE_W: f64 = 0.7; @@ -654,34 +1302,30 @@ impl CircuitDiagram { writeln!(out, "\\begin{{tikzpicture}}").unwrap(); - // Color definitions. + // Color definitions from the style palette. for &c in &[ CellColor::None, - CellColor::SingleQubit, - CellColor::MultiQubit, - CellColor::Measurement, - CellColor::Preparation, + CellColor::XAxis, + CellColor::YAxis, + CellColor::ZAxis, + CellColor::XZMix, + CellColor::XYMix, + CellColor::YZMix, + CellColor::XYZMix, CellColor::ControlDot, ] { let name = c.tikz_name(); + let t = style.palette.get(c); + let fill_hex = t.fill.strip_prefix('#').unwrap_or(&t.fill); + let stroke_hex = t.stroke.strip_prefix('#').unwrap_or(&t.stroke); + let text_hex = t.text.strip_prefix('#').unwrap_or(&t.text); + writeln!(out, " \\definecolor{{{name}Fill}}{{HTML}}{{{fill_hex}}}",).unwrap(); writeln!( out, - " \\definecolor{{{name}Fill}}{{HTML}}{{{fill}}}", - fill = &c.hex_fill()[1..], // strip # - ) - .unwrap(); - writeln!( - out, - " \\definecolor{{{name}Stroke}}{{HTML}}{{{stroke}}}", - stroke = &c.hex_stroke()[1..], - ) - .unwrap(); - writeln!( - out, - " \\definecolor{{{name}Text}}{{HTML}}{{{text}}}", - text = &c.hex_text()[1..], + " \\definecolor{{{name}Stroke}}{{HTML}}{{{stroke_hex}}}", ) .unwrap(); + writeln!(out, " \\definecolor{{{name}Text}}{{HTML}}{{{text_hex}}}",).unwrap(); } // Styles. @@ -703,6 +1347,29 @@ impl CircuitDiagram { return out; } + // Column group backgrounds. + for (label, start, end) in &self.column_groups { + if *start >= num_cols || *end >= num_cols { + continue; + } + let x1 = (*start as f64 + 0.5) * COL_STEP - GATE_W / 2.0 - 0.1; + let x2 = (*end as f64 + 0.5) * COL_STEP + GATE_W / 2.0 + 0.1; + let y1 = ROW_STEP * 0.3; + let y2 = -((num_rows - 1) as f64) * ROW_STEP - ROW_STEP * 0.3; + writeln!( + out, + " \\fill[black!10, rounded corners=2pt] ({x1:.2},{y1:.2}) rectangle ({x2:.2},{y2:.2});", + ) + .unwrap(); + let mid_x = f64::midpoint(x1, x2); + let label_y = y1 + 0.2; + writeln!( + out, + " \\node[font=\\tiny\\ttfamily, gray] at ({mid_x:.2},{label_y:.2}) {{{label}}};", + ) + .unwrap(); + } + // Wires and labels. for row in 0..num_rows { let y = -(row as f64) * ROW_STEP; @@ -729,12 +1396,14 @@ impl CircuitDiagram { for row in 0..num_rows { let y = -(row as f64) * ROW_STEP; let (ref cell, color) = self.columns[col_idx][row]; - let name = color.tikz_name(); + // When style.color is false, use CellColor::None for all gates. + let effective = if style.color { color } else { CellColor::None }; + let name = effective.tikz_name(); match cell { - DiagramCell::Wire => {} + DiagramCell::Wire | DiagramCell::LabeledConnector(_) => {} DiagramCell::Gate(s, family) => { - let dash = family.tikz_dash(); + let dash = style.tikz_dash(*family); let dash_opt = if dash.is_empty() { String::new() } else { @@ -747,9 +1416,15 @@ impl CircuitDiagram { .unwrap(); } DiagramCell::Control => { + let ctrl_effective = if style.color { + color + } else { + CellColor::ControlDot + }; + let ctrl_name = ctrl_effective.tikz_name(); writeln!( out, - " \\node[ctrl, fill={name}Fill, draw={name}Stroke] at ({x:.2},{y:.2}) {{}};", + " \\node[ctrl, fill={ctrl_name}Fill, draw={ctrl_name}Stroke] at ({x:.2},{y:.2}) {{}};", ) .unwrap(); } @@ -762,27 +1437,44 @@ impl CircuitDiagram { } } } + } - // Vertical connector lines. - let mut top: Option = None; - let mut bottom: Option = None; - for row in 0..num_rows { - let (ref cell, color) = self.columns[col_idx][row]; - if !matches!(cell, DiagramCell::Wire) && is_multi_color(color) { - if top.is_none() { - top = Some(row); - } - bottom = Some(row); - } + // Vertical connector lines (from explicit spans). + for (col, top, bottom, label) in &self.connector_spans { + let col = *col; + let top = *top; + let bottom = *bottom; + if col >= num_cols { + continue; } - if let (Some(t), Some(b)) = (top, bottom) - && t != b - { - let y1 = -(t as f64) * ROW_STEP; - let y2 = -(b as f64) * ROW_STEP; + let x = (col as f64 + 0.5) * COL_STEP; + let y1 = -(top as f64) * ROW_STEP; + let y2 = -(bottom as f64) * ROW_STEP; + let conn_color = if !style.color { + CellColor::ControlDot + } else if label.is_some() { + self.columns[col][top].1 + } else { + CellColor::ControlDot + }; + let conn_name = conn_color.tikz_name(); + writeln!( + out, + " \\draw[{conn_name}Stroke] ({x:.2},{y1:.2}) -- ({x:.2},{y2:.2});", + ) + .unwrap(); + if let Some(text) = label { + let mid_y = f64::midpoint(y1, y2); + let lbl_color = self.columns[col][top].1; + let effective = if style.color { + lbl_color + } else { + CellColor::None + }; + let name = effective.tikz_name(); writeln!( out, - " \\draw[cellCtrlStroke] ({x:.2},{y1:.2}) -- ({x:.2},{y2:.2});", + " \\node[gate, fill={name}Fill, draw={name}Stroke, text={name}Text] at ({x:.2},{mid_y:.2}) {{\\footnotesize {text}}};", ) .unwrap(); } @@ -801,6 +1493,12 @@ impl CircuitDiagram { /// If `header` is non-empty it is set as the graph `label`. #[must_use] pub fn render_dot(&self, header: &str) -> String { + self.render_dot_with(header, &DiagramStyle::default()) + } + + /// Render the diagram as a Graphviz DOT `digraph` using a full [`DiagramStyle`]. + #[must_use] + pub fn render_dot_with(&self, header: &str, style: &DiagramStyle) -> String { let num_rows = self.num_rows(); let num_cols = self.effective_columns(); @@ -857,14 +1555,12 @@ impl CircuitDiagram { match cell { DiagramCell::Wire => { - writeln!( - out, - " {node_id} [label=\"\", shape=point, width=0.01];", - ) - .unwrap(); + writeln!(out, " {node_id} [label=\"\", shape=point, width=0.01];",) + .unwrap(); } DiagramCell::Gate(s, family) => { - let dot_style = family.dot_style(); + let t = style.triplet(color); + let dot_style = style.dot_style(*family); let style_val = if dot_style.is_empty() { "filled".to_string() } else { @@ -874,25 +1570,30 @@ impl CircuitDiagram { out, " {node_id} [label=\"{s}\", shape=box, style={style_val}, \ fillcolor=\"{fill}\", color=\"{stroke}\", fontcolor=\"{text}\"];", - fill = color.hex_fill(), - stroke = color.hex_stroke(), - text = color.hex_text(), + fill = t.fill, + stroke = t.stroke, + text = t.text, ) .unwrap(); } DiagramCell::Control => { + let t = style.triplet(color); writeln!( out, " {node_id} [label=\"\", shape=point, width=0.12, \ style=filled, fillcolor=\"{fill}\"];", - fill = color.hex_fill(), + fill = t.fill, ) .unwrap(); } DiagramCell::Crossing | DiagramCell::Connector => { + writeln!(out, " {node_id} [label=\"\", shape=point, width=0.05];",) + .unwrap(); + } + DiagramCell::LabeledConnector(s) => { writeln!( out, - " {node_id} [label=\"\", shape=point, width=0.05];", + " {node_id} [label=\"{s}\", shape=box, style=filled, fillcolor=white, fontsize=10];", ) .unwrap(); } @@ -901,6 +1602,27 @@ impl CircuitDiagram { writeln!(out, " }}").unwrap(); } + // Column group clusters. + for (i, (label, start, end)) in self.column_groups.iter().enumerate() { + if *start >= num_cols || *end >= num_cols { + continue; + } + writeln!(out, " subgraph cluster_group{i} {{").unwrap(); + writeln!(out, " style=filled;").unwrap(); + writeln!(out, " color=\"#D0D8E0\";").unwrap(); + writeln!(out, " fillcolor=\"#D0D8E080\";").unwrap(); + writeln!(out, " label=\"{label}\";").unwrap(); + writeln!(out, " fontname=\"Courier\";").unwrap(); + writeln!(out, " fontsize=9;").unwrap(); + writeln!(out, " fontcolor=\"#888888\";").unwrap(); + for col_idx in *start..=*end { + for row in 0..num_rows { + writeln!(out, " r{row}c{col_idx};").unwrap(); + } + } + writeln!(out, " }}").unwrap(); + } + // Wire edges. writeln!(out, " // Wires").unwrap(); for row in 0..num_rows { @@ -913,35 +1635,25 @@ impl CircuitDiagram { writeln!(out, " {prev} -> r{row}_out;").unwrap(); } - // Vertical connector edges. + // Vertical connector edges (from explicit spans). writeln!(out, " // Vertical connectors").unwrap(); - for col_idx in 0..num_cols { - let mut top: Option = None; - let mut bottom: Option = None; - for row in 0..num_rows { - let (ref cell, color) = self.columns[col_idx][row]; - if !matches!(cell, DiagramCell::Wire) && is_multi_color(color) { - if top.is_none() { - top = Some(row); - } - bottom = Some(row); - } + for (col, top, bottom, _label) in &self.connector_spans { + let col = *col; + let top = *top; + let bottom = *bottom; + if col >= num_cols { + continue; } - if let (Some(t), Some(b)) = (top, bottom) - && t != b - { - // Connect consecutive multi-qubit rows. - let mut prev_row = t; - for row in (t + 1)..=b { - let (ref cell, color) = self.columns[col_idx][row]; - if !matches!(cell, DiagramCell::Wire) && is_multi_color(color) { - writeln!( - out, - " r{prev_row}c{col_idx} -> r{row}c{col_idx} [style=dashed, dir=none, constraint=false];", - ) - .unwrap(); - prev_row = row; - } + // Connect top to bottom through all intermediate non-Wire rows. + let mut prev_row = top; + for row in (top + 1)..=bottom { + if row < num_rows && !matches!(self.columns[col][row].0, DiagramCell::Wire) { + writeln!( + out, + " r{prev_row}c{col} -> r{row}c{col} [style=dashed, dir=none, constraint=false];", + ) + .unwrap(); + prev_row = row; } } } @@ -973,7 +1685,8 @@ impl CircuitDiagram { row: usize, num_cols: usize, col_widths: &[usize], - options: &DiagramOptions, + style: &DiagramStyle, + show_labels: bool, ) -> Option { let label_width = self.labels.iter().map(String::len).max().unwrap_or(2); let mut line = String::new(); @@ -984,33 +1697,58 @@ impl CircuitDiagram { if col_idx >= num_cols { break; } - let (ref cell, color) = self.columns[col_idx][row]; - let (ref next_cell, next_color) = self.columns[col_idx][row + 1]; - - // Show a vertical connector when both this row and the next have - // non-Wire cells that are part of a multi-qubit gate (same color - // category, both colored). - let show = !matches!(cell, DiagramCell::Wire) - && !matches!(next_cell, DiagramCell::Wire) - && is_multi_color(color) - && is_multi_color(next_color); + // Show a vertical connector when this row and the next are both + // inside a connector span for this column. + // Find the connector span for this column/row, if any. + let span = self + .connector_spans + .iter() + .find(|(col, top, bottom, _)| *col == col_idx && row >= *top && row < *bottom); + let show = span.is_some(); if show { has_connector = true; - let pad_total = width.saturating_sub(1); + // Check if this connector row is the midpoint and has a label. + let label_here = if show_labels { + span.and_then(|(_, top, bottom, label)| { + label.as_ref().filter(|_| { + // Place label on the midpoint connector row. + let mid = (top + bottom - 1) / 2; + row == mid + }) + }) + } else { + None + }; + // Center a `|` (or label text) within the column width + 2 + // surrounding spaces, matching the cell rendering padding. + let total = width + 2; + let content = if let Some(text) = label_here { + format!("[{text}]") + } else { + "|".to_string() + }; + let content_len = content.chars().count(); + let pad_total = total.saturating_sub(content_len); let pad_left = pad_total / 2; let pad_right = pad_total - pad_left; let left: String = std::iter::repeat_n(' ', pad_left).collect(); let right: String = std::iter::repeat_n(' ', pad_right).collect(); - if options.color { - write!( - line, - " {left}{}{ANSI_RESET}{right} ", - format_args!("{}|", ansi_code(CellColor::ControlDot)), - ) - .unwrap(); + // Labeled spans use the endpoint cell color; unlabeled use ControlDot. + let connector_color = if span.is_some_and(|(_, _, _, label)| label.is_some()) { + let &(col, top, _, _) = span.unwrap(); + self.columns + .get(col) + .and_then(|c| c.get(top)) + .map_or(CellColor::ControlDot, |&(_, color)| color) + } else { + CellColor::ControlDot + }; + let code = ansi_code(connector_color); + if style.ansi_color && !code.is_empty() { + write!(line, "{left}{code}{content}{ANSI_RESET}{right}").unwrap(); } else { - write!(line, " {left}|{right} ").unwrap(); + write!(line, "{left}{content}{right}").unwrap(); } } else { let spaces: String = std::iter::repeat_n(' ', width + 2).collect(); @@ -1026,38 +1764,52 @@ impl CircuitDiagram { // Rendering helpers // ============================================================================ -/// Content width of a cell (before padding). +/// Content width of a cell in characters (before padding). fn cell_content_width(cell: &DiagramCell) -> usize { match cell { - DiagramCell::Gate(s, _) => s.len() + 2, // +2 for brackets - DiagramCell::Wire | DiagramCell::Control | DiagramCell::Crossing | DiagramCell::Connector => 1, + DiagramCell::Gate(s, _) | DiagramCell::LabeledConnector(s) => s.chars().count() + 2, // +2 for brackets + DiagramCell::Wire + | DiagramCell::Control + | DiagramCell::Crossing + | DiagramCell::Connector => 1, + } +} + +/// Gate name width in characters without bracket padding (for SVG box sizing). +fn cell_svg_width(cell: &DiagramCell) -> usize { + match cell { + DiagramCell::Gate(s, _) | DiagramCell::LabeledConnector(s) => s.chars().count(), + DiagramCell::Wire + | DiagramCell::Control + | DiagramCell::Crossing + | DiagramCell::Connector => 1, } } /// Render a single cell into the given column width. -fn render_cell(cell: &DiagramCell, width: usize, wire_char: char, options: &DiagramOptions) -> String { +fn render_cell(cell: &DiagramCell, width: usize, wire_char: char, style: &DiagramStyle) -> String { match cell { - DiagramCell::Wire => { - std::iter::repeat_n(wire_char, width).collect() - } + DiagramCell::Wire => std::iter::repeat_n(wire_char, width).collect(), DiagramCell::Gate(s, family) => { let bracketed = format!("{}{s}{}", family.open_bracket(), family.close_bracket()); pad_center(&bracketed, width, wire_char) } DiagramCell::Control => { - let dot = match options.symbols { + let dot = match style.symbols { SymbolSet::Ascii => ".", SymbolSet::Unicode => "\u{25CF}", // ● }; pad_center(dot, width, wire_char) } - DiagramCell::Crossing => { - pad_center("+", width, wire_char) - } + DiagramCell::Crossing => pad_center("+", width, wire_char), DiagramCell::Connector => { // Connector on a qubit wire row -- treat as crossing. pad_center("|", width, wire_char) } + DiagramCell::LabeledConnector(s) => { + let bracketed = format!("[{s}]"); + pad_center(&bracketed, width, ' ') + } } } @@ -1072,12 +1824,353 @@ fn pad_center(s: &str, width: usize, pad_char: char) -> String { format!("{left}{s}{right}") } -/// Whether a color indicates a multi-qubit gate context. -fn is_multi_color(color: CellColor) -> bool { - matches!( - color, - CellColor::MultiQubit | CellColor::ControlDot - ) +// ============================================================================ +// Graph state style types +// ============================================================================ + +/// Blend two `#RRGGBB` hex colors at ratio `t` (0.0 = a, 1.0 = b). +/// +/// Returns a new `#RRGGBB` string. Clamps `t` to `[0.0, 1.0]`. +#[must_use] +pub fn blend_hex(a: &str, b: &str, t: f64) -> String { + let t = t.clamp(0.0, 1.0); + let parse = |hex: &str| -> (u8, u8, u8) { + let h = hex.strip_prefix('#').unwrap_or(hex); + let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(0); + let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(0); + let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(0); + (r, g, b) + }; + let (r1, g1, b1) = parse(a); + let (r2, g2, b2) = parse(b); + let mix = + |c1: u8, c2: u8| -> u8 { (f64::from(c1) * (1.0 - t) + f64::from(c2) * t).round() as u8 }; + format!("#{:02X}{:02X}{:02X}", mix(r1, r2), mix(g1, g2), mix(b1, b2)) +} + +/// Fill pattern overlay for graph state vertices. +/// +/// Provides a third visual dimension (pattern) beyond color (fill hue) +/// and stroke style (dash pattern), useful for monochrome rendering +/// where cosets would otherwise be indistinguishable. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum FillPattern { + /// No pattern overlay (plain solid fill). + #[default] + Solid, + /// Diagonal lines going up-right (/). + DiagonalUp, + /// Crosshatch pattern (X). + Crosshatch, + /// Small dots. + Dots, + /// Horizontal lines (-). + HorizontalLines, +} + +impl FillPattern { + /// SVG pattern element ID. Empty for `Solid`. + #[must_use] + pub fn svg_id(self) -> &'static str { + match self { + Self::Solid => "", + Self::DiagonalUp => "pat-diag", + Self::Crosshatch => "pat-cross", + Self::Dots => "pat-dots", + Self::HorizontalLines => "pat-hlines", + } + } + + /// Full SVG `` element definition. Empty for `Solid`. + #[must_use] + pub fn svg_pattern_def(self) -> &'static str { + match self { + Self::Solid => "", + Self::DiagonalUp => concat!( + "\n", + " \n", + " " + ), + Self::Crosshatch => concat!( + "\n", + " \n", + " \n", + " " + ), + Self::Dots => concat!( + "\n", + " \n", + " " + ), + Self::HorizontalLines => concat!( + "\n", + " \n", + " " + ), + } + } + + /// `TikZ` pattern name for `postaction`. Empty for `Solid`. + #[must_use] + pub fn tikz_pattern(self) -> &'static str { + match self { + Self::Solid => "", + Self::DiagonalUp => "north east lines", + Self::Crosshatch => "crosshatch", + Self::Dots => "crosshatch dots", + Self::HorizontalLines => "horizontal lines", + } + } +} + +/// Fill patterns per axis-permutation coset. +/// +/// Each coset can have an independent pattern overlay to distinguish +/// them when fill colors are similar or identical (e.g. monochrome). +#[derive(Clone, Debug)] +pub struct CosetPatterns { + pub identity: FillPattern, + pub xz_mix: FillPattern, + pub xy_mix: FillPattern, + pub yz_mix: FillPattern, + pub xyz_mix: FillPattern, +} + +impl Default for CosetPatterns { + fn default() -> Self { + Self { + identity: FillPattern::Solid, + xz_mix: FillPattern::Solid, + xy_mix: FillPattern::Solid, + yz_mix: FillPattern::Solid, + xyz_mix: FillPattern::Solid, + } + } +} + +impl CosetPatterns { + /// Look up the fill pattern for a given coset. + #[must_use] + pub fn get(&self, coset: CellColor) -> FillPattern { + match coset { + CellColor::ZAxis => self.identity, + CellColor::XZMix => self.xz_mix, + CellColor::XYMix => self.xy_mix, + CellColor::YZMix => self.yz_mix, + CellColor::XYZMix => self.xyz_mix, + _ => FillPattern::Solid, + } + } +} + +/// Stroke colors for graph state gate families (rotation types). +/// +/// These encode geometric rotation type on the Bloch sphere, orthogonal +/// to the coset fill colors. +#[derive(Clone, Debug)] +pub struct FamilyPalette { + /// Pauli gates (identity / pi-rotations). Default: navy `#1E3A8A`. + pub pauli: String, + /// sqrt-of-Pauli / S-like (pi/2 rotations). Default: green `#2D6A2E`. + pub s_like: String, + /// Hadamard-like (pi rotations about face diagonals). Default: maroon `#8B1A1A`. + pub h_like: String, + /// Face-like / cyclic (2pi/3 rotations). Default: charcoal `#404040`. + pub f_like: String, +} + +impl Default for FamilyPalette { + fn default() -> Self { + Self { + pauli: "#1E3A8A".to_string(), + s_like: "#2D6A2E".to_string(), + h_like: "#8B1A1A".to_string(), + f_like: "#404040".to_string(), + } + } +} + +impl FamilyPalette { + /// Look up the stroke color for a gate family. + #[must_use] + pub fn get(&self, family: GateFamily) -> &str { + match family { + GateFamily::Pauli + | GateFamily::Default + | GateFamily::Measurement + | GateFamily::Preparation => &self.pauli, + GateFamily::SLike => &self.s_like, + GateFamily::HLike => &self.h_like, + GateFamily::FLike => &self.f_like, + } + } +} + +/// Full configuration for graph state visualization. +/// +/// Controls fill colors (from [`ColorPalette`]), family stroke colors +/// (from [`FamilyPalette`]), and ANSI color output. Use +/// [`GraphStyle::builder()`] for convenient construction. +#[derive(Clone, Debug, Default)] +pub struct GraphStyle { + pub palette: ColorPalette, + pub family_strokes: FamilyPalette, + pub ansi_color: bool, + /// Whether to render stroke dash patterns on vertices. + /// When false, all strokes are solid. + pub show_dashes: bool, + /// Fill pattern overlays per coset (for monochrome differentiation). + pub coset_patterns: CosetPatterns, +} + +impl GraphStyle { + /// Create a builder for constructing a custom `GraphStyle`. + #[must_use] + pub fn builder() -> GraphStyleBuilder { + GraphStyleBuilder::new() + } + + /// Compute the fill color for a VOP vertex. + /// + /// Saturated vertices (even sign parity) get a midpoint blend of + /// the palette's fill and stroke colors; light vertices get the + /// palette's fill directly. + #[must_use] + pub fn vop_fill(&self, coset: CellColor, saturated: bool) -> String { + let triplet = self.palette.get(coset); + if saturated { + blend_hex(&triplet.fill, &triplet.stroke, 0.5) + } else { + triplet.fill.clone() + } + } + + /// Look up the stroke color for a gate family. + #[must_use] + pub fn vop_stroke(&self, family: GateFamily) -> &str { + self.family_strokes.get(family) + } + + /// Compute the text color for a VOP vertex. + /// + /// Saturated vertices get white text; light vertices get the + /// palette's text color. + #[must_use] + pub fn vop_text(&self, coset: CellColor, saturated: bool) -> &str { + if saturated { + "white" + } else { + &self.palette.get(coset).text + } + } + + /// Effective SVG `stroke-dasharray` for a gate family, respecting `show_dashes`. + #[must_use] + pub fn vop_dasharray(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.svg_dasharray() + } else { + "" + } + } + + /// Effective `TikZ` dash pattern for a gate family, respecting `show_dashes`. + #[must_use] + pub fn vop_tikz_dash(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.tikz_dash() + } else { + "" + } + } + + /// Effective DOT style for a gate family, respecting `show_dashes`. + #[must_use] + pub fn vop_dot_style(&self, family: GateFamily) -> &'static str { + if self.show_dashes { + family.dot_style() + } else { + "" + } + } + + /// Look up the fill pattern for a coset. + #[must_use] + pub fn vop_pattern(&self, coset: CellColor) -> FillPattern { + self.coset_patterns.get(coset) + } +} + +/// Builder for [`GraphStyle`]. +#[derive(Clone, Debug)] +pub struct GraphStyleBuilder { + style: GraphStyle, +} + +impl GraphStyleBuilder { + /// Create a new builder with default settings. + #[must_use] + pub fn new() -> Self { + Self { + style: GraphStyle::default(), + } + } + + /// Set the entire color palette. + #[must_use] + pub fn palette(mut self, p: ColorPalette) -> Self { + self.style.palette = p; + self + } + + /// Set the entire family stroke palette. + #[must_use] + pub fn family_strokes(mut self, f: FamilyPalette) -> Self { + self.style.family_strokes = f; + self + } + + /// Enable or disable ANSI color in text output. + #[must_use] + pub fn ansi_color(mut self, b: bool) -> Self { + self.style.ansi_color = b; + self + } + + /// Enable or disable stroke dash patterns on vertices. + #[must_use] + pub fn show_dashes(mut self, b: bool) -> Self { + self.style.show_dashes = b; + self + } + + /// Set the fill pattern overlays per coset. + #[must_use] + pub fn coset_patterns(mut self, p: CosetPatterns) -> Self { + self.style.coset_patterns = p; + self + } + + /// Build the final `GraphStyle`. + #[must_use] + pub fn build(self) -> GraphStyle { + self.style + } +} + +impl Default for GraphStyleBuilder { + fn default() -> Self { + Self::new() + } } // ============================================================================ @@ -1088,29 +2181,72 @@ fn is_multi_color(color: CellColor) -> bool { mod tests { use super::*; + #[test] + fn blend_hex_endpoints() { + assert_eq!(blend_hex("#FF0000", "#0000FF", 0.0), "#FF0000"); + assert_eq!(blend_hex("#FF0000", "#0000FF", 1.0), "#0000FF"); + } + + #[test] + fn blend_hex_midpoint() { + // Midpoint of red and blue + assert_eq!(blend_hex("#FF0000", "#0000FF", 0.5), "#800080"); + } + + #[test] + fn graph_style_default_vop_fill() { + let style = GraphStyle::default(); + // ZAxis saturated: blend of fill #A8C8F0 and stroke #2255AA + let sat = style.vop_fill(CellColor::ZAxis, true); + assert!(sat.starts_with('#')); + assert_eq!(sat.len(), 7); + // ZAxis light: just the fill + let light = style.vop_fill(CellColor::ZAxis, false); + assert_eq!(light, "#A8C8F0"); + } + + #[test] + fn graph_style_vop_text() { + let style = GraphStyle::default(); + assert_eq!(style.vop_text(CellColor::ZAxis, true), "white"); + assert_eq!(style.vop_text(CellColor::ZAxis, false), "#1A3A7A"); + } + + #[test] + fn family_palette_get() { + let fp = FamilyPalette::default(); + assert_eq!(fp.get(GateFamily::Pauli), "#1E3A8A"); + assert_eq!(fp.get(GateFamily::SLike), "#2D6A2E"); + assert_eq!(fp.get(GateFamily::HLike), "#8B1A1A"); + assert_eq!(fp.get(GateFamily::FLike), "#404040"); + } + #[test] fn empty_diagram() { let d = CircuitDiagram::new(0); - let out = d.render("test", &DiagramOptions::ascii()); + let out = d.render_text("test", &DiagramStyle::default()); assert_eq!(out, "test\n"); } #[test] fn single_gate_ascii() { let mut d = CircuitDiagram::new(2); - d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); - let out = d.render("", &DiagramOptions::ascii()); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let out = d.render_text("", &DiagramStyle::default()); assert!(out.contains("[H]")); // q1 should be just wire let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); - assert!(!q1_line.contains("[")); + assert!(!q1_line.contains('[')); } #[test] fn single_gate_unicode() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); - let out = d.render("", &DiagramOptions::unicode()); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let out = d.render_text( + "", + &DiagramStyle::builder().symbols(SymbolSet::Unicode).build(), + ); assert!(out.contains("[H]")); assert!(out.contains('\u{2500}')); // ─ assert!(!out.contains('-')); @@ -1120,23 +2256,26 @@ mod tests { fn control_dot_ascii_vs_unicode() { let mut d = CircuitDiagram::new(2); d.add_control(0); - d.add_gate(1, "X", CellColor::MultiQubit, GateFamily::Default); - d.connect_vertical(0, 1, CellColor::MultiQubit); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.connect_vertical(0, 1, CellColor::XAxis); - let ascii = d.render("", &DiagramOptions::ascii()); + let ascii = d.render_text("", &DiagramStyle::default()); assert!(ascii.contains('.')); - let unicode = d.render("", &DiagramOptions::unicode()); + let unicode = d.render_text( + "", + &DiagramStyle::builder().symbols(SymbolSet::Unicode).build(), + ); assert!(unicode.contains('\u{25CF}')); // ● } #[test] fn color_output_contains_ansi() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); - let plain = d.render("", &DiagramOptions::ascii()); - let color = d.render("", &DiagramOptions::color_ascii()); + let plain = d.render_text("", &DiagramStyle::default()); + let color = d.render_text("", &DiagramStyle::builder().ansi_color(true).build()); assert!(!plain.contains("\x1b[")); assert!(color.contains("\x1b[34m")); // blue @@ -1147,10 +2286,10 @@ mod tests { fn crossing_between_qubits() { let mut d = CircuitDiagram::new(3); d.add_control(0); - d.add_gate(2, "X", CellColor::MultiQubit, GateFamily::Default); - d.connect_vertical(0, 2, CellColor::MultiQubit); + d.add_gate(2, "X", CellColor::XAxis, GateFamily::Default); + d.connect_vertical(0, 2, CellColor::XAxis); - let out = d.render("", &DiagramOptions::ascii()); + let out = d.render_text("", &DiagramStyle::default()); let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); assert!(q1_line.contains('+')); } @@ -1158,11 +2297,11 @@ mod tests { #[test] fn multi_column_advance() { let mut d = CircuitDiagram::new(2); - d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); d.advance(); - d.add_gate(1, "X", CellColor::SingleQubit, GateFamily::Default); + d.add_gate(1, "X", CellColor::ZAxis, GateFamily::Default); - let out = d.render("", &DiagramOptions::ascii()); + let out = d.render_text("", &DiagramStyle::default()); let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); let q1 = out.lines().find(|l| l.starts_with("q1:")).unwrap(); assert!(q0.contains("[H]")); @@ -1175,7 +2314,7 @@ mod tests { fn header_is_printed() { let d = CircuitDiagram::new(1); // Single wire column is all-Wire, so effective_columns == 0 - let out = d.render("My Header", &DiagramOptions::ascii()); + let out = d.render_text("My Header", &DiagramStyle::default()); assert!(out.starts_with("My Header\n")); } @@ -1183,9 +2322,10 @@ mod tests { fn connector_row_between_multi_qubit() { let mut d = CircuitDiagram::new(2); d.add_control(0); - d.add_gate(1, "X", CellColor::MultiQubit, GateFamily::Default); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.add_connector(0, 1); - let out = d.render("", &DiagramOptions::ascii()); + let out = d.render_text("", &DiagramStyle::default()); // Should have a | connector between q0 and q1 assert!(out.contains('|')); } @@ -1193,13 +2333,13 @@ mod tests { #[test] fn lines_have_equal_length() { let mut d = CircuitDiagram::new(3); - d.add_gate(0, "SX", CellColor::SingleQubit, GateFamily::Default); + d.add_gate(0, "SX", CellColor::ZAxis, GateFamily::Default); d.advance(); d.add_control(0); - d.add_gate(2, "X", CellColor::MultiQubit, GateFamily::Default); - d.connect_vertical(0, 2, CellColor::MultiQubit); + d.add_gate(2, "X", CellColor::XAxis, GateFamily::Default); + d.connect_vertical(0, 2, CellColor::XAxis); - let out = d.render("", &DiagramOptions::ascii()); + let out = d.render_text("", &DiagramStyle::default()); let qubit_lines: Vec<&str> = out.lines().filter(|l| l.starts_with('q')).collect(); assert!(qubit_lines.len() >= 2); let len0 = qubit_lines[0].len(); @@ -1221,7 +2361,7 @@ mod tests { #[test] fn svg_single_gate() { let mut d = CircuitDiagram::new(2); - d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::Default); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); let out = d.render_svg(""); assert!(out.contains("")); } #[test] fn slike_brackets() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "SZ", CellColor::SingleQubit, GateFamily::SLike); - let out = d.render("", &DiagramOptions::ascii()); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let out = d.render_text("", &DiagramStyle::default()); assert!(out.contains("[SZ]")); } #[test] fn flike_brackets() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "F", CellColor::SingleQubit, GateFamily::FLike); - let out = d.render("", &DiagramOptions::ascii()); + d.add_gate(0, "F", CellColor::ZAxis, GateFamily::FLike); + let out = d.render_text("", &DiagramStyle::default()); assert!(out.contains("{F}")); } #[test] fn measurement_brackets() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "MZ", CellColor::Measurement, GateFamily::Measurement); - let out = d.render("", &DiagramOptions::ascii()); + d.add_gate(0, "MZ", CellColor::ZAxis, GateFamily::Measurement); + let out = d.render_text("", &DiagramStyle::default()); assert!(out.contains("|MZ)")); } #[test] fn preparation_brackets() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "PZ", CellColor::Preparation, GateFamily::Preparation); - let out = d.render("", &DiagramOptions::ascii()); + d.add_gate(0, "PZ", CellColor::ZAxis, GateFamily::Preparation); + let out = d.render_text("", &DiagramStyle::default()); assert!(out.contains("(PZ|")); } @@ -1388,7 +2529,7 @@ mod tests { #[test] fn svg_slike_dasharray() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "SZ", CellColor::SingleQubit, GateFamily::SLike); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); let out = d.render_svg(""); assert!(out.contains("stroke-dasharray=\"4,3\"")); } @@ -1396,7 +2537,7 @@ mod tests { #[test] fn svg_hlike_dasharray() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::HLike); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::HLike); let out = d.render_svg(""); assert!(out.contains("stroke-dasharray=\"2,2\"")); } @@ -1404,7 +2545,7 @@ mod tests { #[test] fn svg_default_no_dasharray() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "T", CellColor::SingleQubit, GateFamily::Default); + d.add_gate(0, "T", CellColor::ZAxis, GateFamily::Default); let out = d.render_svg(""); assert!(!out.contains("stroke-dasharray")); } @@ -1412,7 +2553,7 @@ mod tests { #[test] fn tikz_slike_dashed() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "SZ", CellColor::SingleQubit, GateFamily::SLike); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); let out = d.render_tikz(""); assert!(out.contains(", dashed]")); } @@ -1420,8 +2561,227 @@ mod tests { #[test] fn dot_hlike_dotted() { let mut d = CircuitDiagram::new(1); - d.add_gate(0, "H", CellColor::SingleQubit, GateFamily::HLike); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::HLike); let out = d.render_dot(""); assert!(out.contains("filled,dotted")); } + + // ==================== DiagramStyle tests ==================== + + #[test] + fn default_style_matches_old_text_output() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let old = d.render_text("header", &DiagramStyle::default()); + let new = d.render_text("header", &DiagramStyle::default()); + assert_eq!(old, new); + } + + #[test] + fn default_style_matches_old_svg_output() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let old = d.render_svg(""); + let new = d.render_svg_with("", &DiagramStyle::default()); + assert_eq!(old, new); + } + + #[test] + fn default_style_matches_old_tikz_output() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let old = d.render_tikz(""); + let new = d.render_tikz_with("", &DiagramStyle::default()); + assert_eq!(old, new); + } + + #[test] + fn default_style_matches_old_dot_output() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let old = d.render_dot(""); + let new = d.render_dot_with("", &DiagramStyle::default()); + assert_eq!(old, new); + } + + #[test] + fn custom_palette_appears_in_svg() { + let style = DiagramStyle::builder() + .x_axis("#FF0000", "#CC0000", "#880000") + .build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "X", CellColor::XAxis, GateFamily::Pauli); + let svg = d.render_svg_with("", &style); + assert!(svg.contains("#FF0000")); // custom fill + assert!(svg.contains("#CC0000")); // custom stroke + assert!(svg.contains("#880000")); // custom text + } + + #[test] + fn color_false_monochrome_svg() { + let style = DiagramStyle::builder().color(false).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "X", CellColor::XAxis, GateFamily::Pauli); + let svg = d.render_svg_with("", &style); + // When color is false, should use the None palette (white fill, black stroke). + assert!(svg.contains("#FFFFFF")); // none fill + assert!(svg.contains("#222222")); // none stroke + // Should NOT contain the XAxis fill. + assert!(!svg.contains("#FFB0B0")); + } + + #[test] + fn show_dashes_false_no_dasharray_in_svg() { + let style = DiagramStyle::builder().show_dashes(false).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let svg = d.render_svg_with("", &style); + assert!(!svg.contains("stroke-dasharray")); + } + + #[test] + fn show_dashes_true_has_dasharray_in_svg() { + let style = DiagramStyle::builder().show_dashes(true).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let svg = d.render_svg_with("", &style); + assert!(svg.contains("stroke-dasharray")); + } + + #[test] + fn builder_presets() { + let s = DiagramStyleBuilder::ascii().build(); + assert_eq!(s.symbols, SymbolSet::Ascii); + assert!(!s.ansi_color); + + let s = DiagramStyleBuilder::color_ascii().build(); + assert_eq!(s.symbols, SymbolSet::Ascii); + assert!(s.ansi_color); + + let s = DiagramStyleBuilder::unicode().build(); + assert_eq!(s.symbols, SymbolSet::Unicode); + assert!(!s.ansi_color); + + let s = DiagramStyleBuilder::color_unicode().build(); + assert_eq!(s.symbols, SymbolSet::Unicode); + assert!(s.ansi_color); + } + + #[test] + fn diagram_renderer_text_and_svg() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let style = DiagramStyle::default(); + let r = DiagramRenderer::new(d, String::new(), &style); + let text = r.text(); + let svg = r.svg(); + assert!(text.contains("[H]")); + assert!(svg.contains(">H")); + } + + #[test] + fn diagram_renderer_ascii_and_unicode() { + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let style = DiagramStyle::default(); + let r = DiagramRenderer::new(d, String::new(), &style); + let ascii = r.ascii(); + let unicode = r.unicode(); + assert!(ascii.contains('-')); + assert!(unicode.contains('\u{2500}')); + } + + #[test] + fn color_false_monochrome_dot() { + let style = DiagramStyle::builder().color(false).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "X", CellColor::XAxis, GateFamily::Pauli); + let dot = d.render_dot_with("", &style); + // Should use the None palette colors. + assert!(dot.contains("#FFFFFF")); + assert!(dot.contains("#222222")); + assert!(!dot.contains("#FFB0B0")); + } + + #[test] + fn show_dashes_false_no_dashed_in_tikz() { + let style = DiagramStyle::builder().show_dashes(false).build(); + let mut d = CircuitDiagram::new(1); + d.add_gate(0, "SZ", CellColor::ZAxis, GateFamily::SLike); + let tikz = d.render_tikz_with("", &style); + assert!(!tikz.contains(", dashed]")); + } + + // ==================== Column group tests ==================== + + #[test] + fn column_group_bracket_ascii() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + d.advance(); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.add_column_group("t0".to_string(), 0, 1); + let out = d.render_text("", &DiagramStyle::default()); + assert!(out.contains('|'), "bracket should use | chars: {out}"); + assert!(out.contains("t0"), "bracket should contain label: {out}"); + } + + #[test] + fn column_group_bracket_unicode() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + d.advance(); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.add_column_group("t0".to_string(), 0, 1); + let out = d.render_text( + "", + &DiagramStyle::builder().symbols(SymbolSet::Unicode).build(), + ); + assert!( + out.contains('\u{251C}'), + "bracket should use unicode open: {out}" + ); + assert!( + out.contains('\u{2524}'), + "bracket should use unicode close: {out}" + ); + assert!(out.contains("t0"), "bracket should contain label: {out}"); + } + + #[test] + fn no_groups_no_bracket_row() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + let out = d.render_text("", &DiagramStyle::default()); + let lines: Vec<&str> = out.lines().collect(); + // Should have only qubit rows and connector row, no bracket line. + assert!( + lines + .iter() + .all(|l| l.starts_with('q') || l.trim().is_empty() || l.contains('|')), + "no bracket line expected: {out}" + ); + } + + #[test] + fn svg_column_group_background() { + let mut d = CircuitDiagram::new(2); + d.add_gate(0, "H", CellColor::ZAxis, GateFamily::Default); + d.advance(); + d.add_gate(1, "X", CellColor::XAxis, GateFamily::Default); + d.add_column_group("t0".to_string(), 0, 1); + let svg = d.render_svg(""); + assert!( + svg.contains("fill=\"#D0D8E0\""), + "SVG should have group background: {svg}" + ); + assert!( + svg.contains("fill-opacity=\"0.5\""), + "SVG should have opacity: {svg}" + ); + assert!( + svg.contains(">t0"), + "SVG should have group label: {svg}" + ); + } } diff --git a/crates/pecos-core/src/lib.rs b/crates/pecos-core/src/lib.rs index 8ef6e7c4a..794895c8e 100644 --- a/crates/pecos-core/src/lib.rs +++ b/crates/pecos-core/src/lib.rs @@ -69,5 +69,11 @@ pub use phase::Phase; pub use rng::choices::Choices; pub use value::Value; +// Circuit diagram styling +pub use circuit_diagram::{ + AngleUnit, ColorPalette, ColorTriplet, CosetPatterns, DiagramRenderer, DiagramStyle, + DiagramStyleBuilder, FamilyPalette, FillPattern, GraphStyle, GraphStyleBuilder, blend_hex, +}; + // Operator algebra pub use operator::{I, Is, Operator, X, Xs, Y, Ys, Z, Zs}; diff --git a/crates/pecos-core/src/operator.rs b/crates/pecos-core/src/operator.rs index 3456d5599..918694e5e 100644 --- a/crates/pecos-core/src/operator.rs +++ b/crates/pecos-core/src/operator.rs @@ -2516,19 +2516,38 @@ impl Mul for Operator { // Circuit diagram generation // ============================================================================ -use crate::circuit_diagram::{CellColor, CircuitDiagram, DiagramOptions, GateFamily}; +use crate::circuit_diagram::{ + CellColor, CircuitDiagram, DiagramRenderer, DiagramStyle, GateFamily, SymbolSet, +}; + +/// Map a `GateType` to its axis color using PECOS color algebra. +fn gate_type_color(gt: GateType) -> CellColor { + match gt { + GateType::X | GateType::RX | GateType::RXX => CellColor::XAxis, + GateType::Y | GateType::RY | GateType::RYY => CellColor::YAxis, + GateType::Z + | GateType::RZ + | GateType::T + | GateType::Tdg + | GateType::RZZ + | GateType::Measure + | GateType::Prep + | GateType::SZZ + | GateType::SZZdg + | GateType::CRZ => CellColor::ZAxis, + GateType::SX | GateType::SXdg => CellColor::YZMix, + GateType::SY | GateType::SYdg | GateType::H | GateType::CH => CellColor::XZMix, + GateType::SZ | GateType::SZdg => CellColor::XYMix, + _ => CellColor::None, + } +} /// Map a `GateType` to its `GateFamily` for diagram bracket/stroke styling. +/// +/// Most gates use `Default` brackets (`[G]`). Only measurement and preparation +/// gates keep their asymmetric brackets (`|MZ)` and `(PZ|`). fn gate_type_family(gt: GateType) -> GateFamily { match gt { - GateType::I | GateType::X | GateType::Y | GateType::Z => GateFamily::Pauli, - GateType::SX - | GateType::SXdg - | GateType::SY - | GateType::SYdg - | GateType::SZ - | GateType::SZdg => GateFamily::SLike, - GateType::H => GateFamily::HLike, GateType::Measure | GateType::MeasureLeaked | GateType::MeasureFree => { GateFamily::Measurement } @@ -2549,55 +2568,75 @@ impl Operator { /// Plain ASCII circuit diagram. #[must_use] pub fn to_ascii(&self, num_qubits: usize) -> String { - self.render_diagram(num_qubits, &DiagramOptions::ascii()) + self.render_with(num_qubits, &DiagramStyle::default()) + .ascii() } /// ASCII circuit diagram with ANSI colors. #[must_use] pub fn to_color_ascii(&self, num_qubits: usize) -> String { - self.render_diagram(num_qubits, &DiagramOptions::color_ascii()) + self.render_with( + num_qubits, + &DiagramStyle::builder().ansi_color(true).build(), + ) + .ascii() } /// Unicode circuit diagram. #[must_use] pub fn to_unicode(&self, num_qubits: usize) -> String { - self.render_diagram(num_qubits, &DiagramOptions::unicode()) + self.render_with( + num_qubits, + &DiagramStyle::builder().symbols(SymbolSet::Unicode).build(), + ) + .unicode() } /// Unicode circuit diagram with ANSI colors. #[must_use] pub fn to_color_unicode(&self, num_qubits: usize) -> String { - self.render_diagram(num_qubits, &DiagramOptions::color_unicode()) + self.render_with( + num_qubits, + &DiagramStyle::builder() + .symbols(SymbolSet::Unicode) + .ansi_color(true) + .build(), + ) + .unicode() } /// Export as an SVG circuit diagram. #[must_use] pub fn to_svg(&self, num_qubits: usize) -> String { - let mut diagram = CircuitDiagram::new(num_qubits); - self.add_to_diagram(&mut diagram); - diagram.render_svg("") + self.render_with(num_qubits, &DiagramStyle::default()).svg() } /// Export as a `TikZ` `tikzpicture`. #[must_use] pub fn to_tikz(&self, num_qubits: usize) -> String { - let mut diagram = CircuitDiagram::new(num_qubits); - self.add_to_diagram(&mut diagram); - diagram.render_tikz("") + self.render_with(num_qubits, &DiagramStyle::default()) + .tikz() } /// Export as a Graphviz DOT digraph. #[must_use] pub fn to_dot(&self, num_qubits: usize) -> String { - let mut diagram = CircuitDiagram::new(num_qubits); - self.add_to_diagram(&mut diagram); - diagram.render_dot("") + self.render_with(num_qubits, &DiagramStyle::default()).dot() } - fn render_diagram(&self, num_qubits: usize, options: &DiagramOptions) -> String { + /// Create a [`DiagramRenderer`] bound to a custom [`DiagramStyle`]. + /// + /// The renderer can produce text, SVG, `TikZ`, or DOT output using the + /// given style configuration. + #[must_use] + pub fn render_with<'a>( + &self, + num_qubits: usize, + style: &'a DiagramStyle, + ) -> DiagramRenderer<'a> { let mut diagram = CircuitDiagram::new(num_qubits); self.add_to_diagram(&mut diagram); - diagram.render("", options) + DiagramRenderer::new(diagram, String::new(), style) } fn add_to_diagram(&self, diagram: &mut CircuitDiagram) { @@ -2605,13 +2644,13 @@ impl Operator { Self::Pauli(ps) => { for (pauli, qubit) in ps.iter_pairs() { let q = usize::from(qubit); - let name = match pauli { + let (name, color) = match pauli { crate::Pauli::I => continue, - crate::Pauli::X => "X", - crate::Pauli::Y => "Y", - crate::Pauli::Z => "Z", + crate::Pauli::X => ("X", CellColor::XAxis), + crate::Pauli::Y => ("Y", CellColor::YAxis), + crate::Pauli::Z => ("Z", CellColor::ZAxis), }; - diagram.add_gate(q, name, CellColor::SingleQubit, GateFamily::Pauli); + diagram.add_gate(q, name, color, GateFamily::Default); } } Self::Rotation { @@ -2626,55 +2665,50 @@ impl Operator { format!("{rotation_type:?}") }; let family = resolved_gt.map_or(GateFamily::Default, gate_type_family); + let color = resolved_gt.map_or(CellColor::None, gate_type_color); if qubits.len() == 1 { - diagram.add_gate(qubits[0], &name, CellColor::SingleQubit, family); + diagram.add_gate(qubits[0], &name, color, family); } else if qubits.len() == 2 { - let color = CellColor::MultiQubit; diagram.add_gate(qubits[0], &name, color, family); diagram.add_gate(qubits[1], &name, color, family); - diagram.connect_vertical(qubits[0], qubits[1], color); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } } Self::Gate { gate_type, qubits } => match gate_type { GateType::CX => { diagram.add_control(qubits[0]); - diagram.add_gate(qubits[1], "X", CellColor::MultiQubit, GateFamily::Default); - diagram.connect_vertical(qubits[0], qubits[1], CellColor::MultiQubit); + diagram.add_gate(qubits[1], "X", CellColor::XAxis, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } GateType::CY => { diagram.add_control(qubits[0]); - diagram.add_gate(qubits[1], "Y", CellColor::MultiQubit, GateFamily::Default); - diagram.connect_vertical(qubits[0], qubits[1], CellColor::MultiQubit); + diagram.add_gate(qubits[1], "Y", CellColor::YAxis, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } GateType::CZ => { diagram.add_control(qubits[0]); - diagram.add_gate(qubits[1], "Z", CellColor::MultiQubit, GateFamily::Default); - diagram.connect_vertical(qubits[0], qubits[1], CellColor::MultiQubit); + diagram.add_gate(qubits[1], "Z", CellColor::ZAxis, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } GateType::SWAP => { - let color = CellColor::MultiQubit; - diagram.add_gate(qubits[0], "x", color, GateFamily::Default); - diagram.add_gate(qubits[1], "x", color, GateFamily::Default); - diagram.connect_vertical(qubits[0], qubits[1], color); + diagram.add_gate(qubits[0], "x", CellColor::None, GateFamily::Default); + diagram.add_gate(qubits[1], "x", CellColor::None, GateFamily::Default); + diagram.connect_vertical(qubits[0], qubits[1], CellColor::None); } GateType::CCX => { diagram.add_control(qubits[0]); diagram.add_control(qubits[1]); - diagram.add_gate(qubits[2], "X", CellColor::MultiQubit, GateFamily::Default); + diagram.add_gate(qubits[2], "X", CellColor::XAxis, GateFamily::Default); let min_q = qubits[0].min(qubits[1]).min(qubits[2]); let max_q = qubits[0].max(qubits[1]).max(qubits[2]); - diagram.connect_vertical(min_q, max_q, CellColor::MultiQubit); + diagram.connect_vertical(min_q, max_q, CellColor::None); } _ => { if qubits.len() == 1 { let family = gate_type_family(*gate_type); - diagram.add_gate( - qubits[0], - &format!("{gate_type:?}"), - CellColor::SingleQubit, - family, - ); + let color = gate_type_color(*gate_type); + diagram.add_gate(qubits[0], &format!("{gate_type:?}"), color, family); } } }, @@ -2804,7 +2838,7 @@ mod tests { fn test_diagram_single_qubit() { let h = H(0); let diagram = h.to_diagram(1); - assert!(diagram.contains("")); // HLike family + assert!(diagram.contains("[H]")); // Default family } #[test] diff --git a/crates/pecos-qsim/src/clifford_frame.rs b/crates/pecos-qsim/src/clifford_frame.rs index 014e3432d..7be4c363a 100644 --- a/crates/pecos-qsim/src/clifford_frame.rs +++ b/crates/pecos-qsim/src/clifford_frame.rs @@ -416,20 +416,20 @@ pub const VOP_DECOMP: [(u8, [u8; VOP_DECOMP_MAX_LEN]); 24] = compute_vop_decomp( // CZ (cphase) lookup table // ============================================================================ -/// Mapping from reference (GraphSim) Clifford indices to our CliffordFrame indices. +/// Mapping from reference (`GraphSim`) Clifford indices to our `CliffordFrame` indices. /// Derived by generating all 24 elements from H and S in both systems. const REF_TO_OURS: [u8; 24] = [ 0, 1, 2, 3, 20, 5, 4, 23, 18, 10, 6, 9, 17, 19, 12, 13, 14, 15, 22, 8, 7, 11, 21, 16, ]; -/// Mapping from our CliffordFrame indices to reference (GraphSim) indices. +/// Mapping from our `CliffordFrame` indices to reference (`GraphSim`) indices. const OURS_TO_REF: [u8; 24] = [ 0, 1, 2, 3, 6, 5, 10, 20, 19, 11, 9, 21, 14, 15, 16, 17, 23, 12, 8, 13, 4, 22, 18, 7, ]; -/// Reference CZ table from GraphSim (Anders & Briegel), indexed by reference indices. +/// Reference CZ table from `GraphSim` (Anders & Briegel), indexed by reference indices. /// Layout: `REF_CPHASE[was_edge][v1_ref][v2_ref]` = `[new_edge, new_v1_ref, new_v2_ref]`. -/// This is the verified table from `cphase.tbl` in the GraphSim reference implementation. +/// This is the verified table from `cphase.tbl` in the `GraphSim` reference implementation. #[rustfmt::skip] const REF_CPHASE: [[[[u8; 3]; 24]; 24]; 2] = [ // was_edge = 0 @@ -488,8 +488,8 @@ const REF_CPHASE: [[[[u8; 3]; 24]; 24]; 2] = [ ], ]; -/// Compute the CZ lookup table by remapping the reference GraphSim table -/// to our CliffordFrame index system. +/// Compute the CZ lookup table by remapping the reference `GraphSim` table +/// to our `CliffordFrame` index system. /// /// For each `(was_edge, v1, v2)`, finds `(new_edge, v1', v2')` such that /// after applying CZ to state `(V1 x V2) |G_{was_edge}>`, diff --git a/crates/pecos-qsim/src/graph_state.rs b/crates/pecos-qsim/src/graph_state.rs index 07010bafc..ad2cf37c6 100644 --- a/crates/pecos-qsim/src/graph_state.rs +++ b/crates/pecos-qsim/src/graph_state.rs @@ -131,7 +131,7 @@ impl GraphStateSim { /// Perform local complementation about vertex `a`. /// /// This complements all edges among neighbors of `a`, then updates VOPs: - /// - Prepend sqrt(-iX) to VOP_a + /// - Prepend sqrt(-iX) to `VOP_a` /// - Prepend sqrt(iZ) to each neighbor's VOP fn local_complement(&mut self, a: usize) { let nbrs: Vec = self.neighbors[a].iter().collect(); @@ -162,7 +162,7 @@ impl GraphStateSim { if nbrs.contains(other) { nbrs.len() >= 2 } else { - nbrs.len() >= 1 + !nbrs.is_empty() } } @@ -181,12 +181,12 @@ impl GraphStateSim { // Pick a neighbor that isn't `avoid` (if possible) let mut vb = self.neighbors[v].iter().next().unwrap(); - if vb == avoid { - if let Some(alt) = self.neighbors[v].iter().find(|&u| u != avoid) { - vb = alt; - } - // If avoid is the only neighbor, we'll use it anyway + if vb == avoid + && let Some(alt) = self.neighbors[v].iter().find(|&u| u != avoid) + { + vb = alt; } + // If avoid is the only neighbor, we'll use it anyway let (len, steps) = VOP_DECOMP[self.vops[v].index() as usize]; @@ -233,7 +233,7 @@ impl GraphStateSim { let op1 = self.vops[v1].index() as usize; let op2 = self.vops[v2].index() as usize; - let we_idx = if was_edge { 1 } else { 0 }; + let we_idx = usize::from(was_edge); let [new_edge, new_op1, new_op2] = CPHASE_TBL[we_idx * 24 + op1][op2]; // Set edge state @@ -292,11 +292,7 @@ impl GraphStateSim { /// Follows the reference's `graph_X_measure` algorithm. /// If N(v) is empty: deterministic, outcome = 0 (always +1 eigenvalue). /// Otherwise: non-deterministic with 3-step edge toggling. - fn measure_x_on_graph( - &mut self, - v: usize, - forced_outcome: Option, - ) -> MeasurementResult { + fn measure_x_on_graph(&mut self, v: usize, forced_outcome: Option) -> MeasurementResult { if self.neighbors[v].is_empty() { // Deterministic: isolated graph state vertex is |+>, X eigenvalue +1 return MeasurementResult { @@ -320,15 +316,7 @@ impl GraphStateSim { let vbn_set: BitSet = self.neighbors[vb].clone(); // VOP updates - if !outcome { - // Measured +1 (|+>): SYDG on vb, Z on N(v) \ N(vb) \ {vb} - self.vops[vb] = CliffordFrame::SYDG.compose(self.vops[vb]); - for &u in &vn { - if u != vb && !vbn_set.contains(u) { - self.vops[u] = CliffordFrame::Z.compose(self.vops[u]); - } - } - } else { + if outcome { // Measured -1 (|->): SY on vb, Z on v, Z on N(vb) \ N(v) \ {v} self.vops[vb] = CliffordFrame::SY.compose(self.vops[vb]); self.vops[v] = CliffordFrame::Z.compose(self.vops[v]); @@ -337,6 +325,14 @@ impl GraphStateSim { self.vops[u] = CliffordFrame::Z.compose(self.vops[u]); } } + } else { + // Measured +1 (|+>): SYDG on vb, Z on N(v) \ N(vb) \ {vb} + self.vops[vb] = CliffordFrame::SYDG.compose(self.vops[vb]); + for &u in &vn { + if u != vb && !vbn_set.contains(u) { + self.vops[u] = CliffordFrame::Z.compose(self.vops[u]); + } + } } // Edge toggles (using saved neighborhoods) @@ -388,11 +384,7 @@ impl GraphStateSim { /// /// Follows the reference's `graph_Y_measure` algorithm (direct, no reduction to X). /// Always non-deterministic. - fn measure_y_on_graph( - &mut self, - v: usize, - forced_outcome: Option, - ) -> MeasurementResult { + fn measure_y_on_graph(&mut self, v: usize, forced_outcome: Option) -> MeasurementResult { let outcome = forced_outcome.unwrap_or_else(|| self.rng.coin_flip()); // Right-multiply each neighbor's VOP by SZDG (outcome=1) or SZ (outcome=0) @@ -415,10 +407,10 @@ impl GraphStateSim { } // Right-multiply v's VOP by SZ (outcome=0) or SZDG (outcome=1) - if !outcome { - self.vops[v] = CliffordFrame::SZ.compose(self.vops[v]); - } else { + if outcome { self.vops[v] = CliffordFrame::SZDG.compose(self.vops[v]); + } else { + self.vops[v] = CliffordFrame::SZ.compose(self.vops[v]); } MeasurementResult { @@ -433,11 +425,7 @@ impl GraphStateSim { /// Disconnects v from all neighbors (no edge complement among neighbors). /// If outcome=1, right-multiplies each neighbor's VOP by Z. /// Sets v's VOP by right-multiplying by H (outcome=0) or X*H=SY (outcome=1). - fn measure_z_on_graph( - &mut self, - v: usize, - forced_outcome: Option, - ) -> MeasurementResult { + fn measure_z_on_graph(&mut self, v: usize, forced_outcome: Option) -> MeasurementResult { let outcome = forced_outcome.unwrap_or_else(|| self.rng.coin_flip()); let nbrs: Vec = self.neighbors[v].iter().collect(); @@ -453,11 +441,11 @@ impl GraphStateSim { } // Set v's VOP: right-multiply by H (outcome=0) or X*H=SY (outcome=1) - if !outcome { - self.vops[v] = CliffordFrame::H.compose(self.vops[v]); - } else { + if outcome { // X * H = SY (index 10). Right-multiply: compose(SY, VOP) = VOP * SY self.vops[v] = CliffordFrame::SY.compose(self.vops[v]); + } else { + self.vops[v] = CliffordFrame::H.compose(self.vops[v]); } // Determine if deterministic: isolated vertices (no neighbors) have @@ -709,7 +697,10 @@ mod tests { let mut sim = GraphStateSim::with_seed(3, 42); for i in 0..3 { let result = sim.mz(&[QubitId::new(i)]); - assert!(result[0].is_deterministic, "qubit {i} should be deterministic"); + assert!( + result[0].is_deterministic, + "qubit {i} should be deterministic" + ); assert!(!result[0].outcome, "qubit {i} should be |0>"); } } @@ -728,7 +719,10 @@ mod tests { let mut sim = GraphStateSim::with_seed(1, 42); sim.h(&qid(0)); let result = sim.mz(&qid(0)); - assert!(!result[0].is_deterministic, "H|0> = |+> should be non-deterministic for mz"); + assert!( + !result[0].is_deterministic, + "H|0> = |+> should be non-deterministic for mz" + ); } #[test] @@ -742,8 +736,14 @@ mod tests { let r0 = sim.mz(&qid(0)); let r1 = sim.mz(&qid(1)); assert!(!r0[0].is_deterministic); - assert!(r1[0].is_deterministic, "second qubit should be deterministic after first measured"); - assert_eq!(r0[0].outcome, r1[0].outcome, "Bell state qubits should be correlated"); + assert!( + r1[0].is_deterministic, + "second qubit should be deterministic after first measured" + ); + assert_eq!( + r0[0].outcome, r1[0].outcome, + "Bell state qubits should be correlated" + ); } } @@ -784,8 +784,14 @@ mod tests { sim.h(&qid(0)); let r1 = sim.mz(&qid(0)); let r2 = sim.mz(&qid(0)); - assert!(r2[0].is_deterministic, "second measurement should be deterministic"); - assert_eq!(r1[0].outcome, r2[0].outcome, "repeated measurement should give same result"); + assert!( + r2[0].is_deterministic, + "second measurement should be deterministic" + ); + assert_eq!( + r1[0].outcome, r2[0].outcome, + "repeated measurement should give same result" + ); } #[test] @@ -801,8 +807,8 @@ mod tests { #[test] fn test_cross_validation_random_circuits() { - use crate::stabilizer_test_utils::compare_simulators_on_random_circuits_direct; use crate::SparseStab; + use crate::stabilizer_test_utils::compare_simulators_on_random_circuits_direct; let mut gs = GraphStateSim::with_seed(6, 0); let mut ss = SparseStab::with_seed(6, 0); diff --git a/crates/pecos-qsim/src/graph_state_repr.rs b/crates/pecos-qsim/src/graph_state_repr.rs index 0cb872879..5417fb09d 100644 --- a/crates/pecos-qsim/src/graph_state_repr.rs +++ b/crates/pecos-qsim/src/graph_state_repr.rs @@ -21,7 +21,7 @@ //! //! A graph state `|G>` is defined by an undirected graph G = (V, E). Each vertex //! starts in `|+>`, then a CZ gate is applied for each edge. The stabilizer -//! generators are K_v = X_v * prod_{u in N(v)} Z_u. +//! generators are `K_v` = `X_v` * prod_{u in N(v)} `Z_u`. //! //! Any stabilizer state can be written as local Cliffords applied to a graph state: //! `|psi> = (tensor_v VOP_v) |G>`. The VOP (vertex operator) on each qubit is a @@ -51,7 +51,8 @@ use crate::clifford_frame::{CliffordFrame, PauliAxis}; use core::fmt::{self, Write as _}; -use pecos_core::{BitSet, Pauli, Phase, PauliString, QuarterPhase}; +use pecos_core::circuit_diagram::{CellColor, FillPattern, GateFamily, GraphStyle, blend_hex}; +use pecos_core::{BitSet, Pauli, PauliString, Phase, QuarterPhase}; use pecos_rng::{PecosRng, SeedableRng}; use std::collections::{BTreeSet, VecDeque}; @@ -194,7 +195,7 @@ impl GraphState { Self::from_edges(n, &edges) } - /// Complete graph state K_n. + /// Complete graph state `K_n`. #[must_use] pub fn complete(n: usize) -> Self { let mut edges = Vec::new(); @@ -330,7 +331,7 @@ impl GraphState { /// Perform local complementation about vertex v. /// /// This complements all edges among N(v) and updates VOPs: - /// - Prepend sqrt(-iX) = SXDG to VOP_v + /// - Prepend sqrt(-iX) = SXDG to `VOP_v` /// - Prepend sqrt(iZ) = SZ to each neighbor's VOP pub fn local_complement(&mut self, v: usize) { let nbrs: Vec = self.neighbors[v].iter().collect(); @@ -356,10 +357,7 @@ impl GraphState { /// /// Panics if u and v are not adjacent. pub fn pivot(&mut self, u: usize, v: usize) { - assert!( - self.has_edge(u, v), - "pivot requires u and v to be adjacent" - ); + assert!(self.has_edge(u, v), "pivot requires u and v to be adjacent"); self.local_complement(u); self.local_complement(v); self.local_complement(u); @@ -475,8 +473,8 @@ impl GraphState { impl GraphState { /// Compute the stabilizer generator for vertex v. /// - /// The bare generator is K_v = X_v * prod_{u in N(v)} Z_u. - /// The conjugated generator is VOP_v(X_v) * prod_{u in N(v)} VOP_u(Z_u). + /// The bare generator is `K_v` = `X_v` * prod_{u in N(v)} `Z_u`. + /// The conjugated generator is `VOP_v(X_v)` * prod_{u in N(v)} `VOP_u(Z_u)`. #[must_use] pub fn stabilizer_generator(&self, v: usize) -> PauliString { let n = self.num_qubits(); @@ -491,7 +489,7 @@ impl GraphState { } // Each neighbor u contributes: VOP_u maps Z - for u in self.neighbors[v].iter() { + for u in &self.neighbors[v] { let z_img = self.vops[u].z_image(); let u_pauli = pauli_axis_to_pauli(z_img.axis); @@ -561,7 +559,7 @@ impl GraphState { // Shift other's neighbor indices by n1 for nbrs in &other.neighbors { let mut shifted = BitSet::new(); - for u in nbrs.iter() { + for u in nbrs { shifted.insert(u + n1); } neighbors.push(shifted); @@ -598,7 +596,7 @@ impl GraphState { for (new_idx, &old_idx) in vertices.iter().enumerate() { vops.push(self.vops[old_idx]); - for u in self.neighbors[old_idx].iter() { + for u in &self.neighbors[old_idx] { if let Some(new_u) = old_to_new[u] { neighbors[new_idx].insert(new_u); } @@ -696,9 +694,8 @@ impl GraphState { /// Names for the 24 single-qubit Cliffords. const CLIFFORD_NAMES: [&str; 24] = [ - "I", "X", "Y", "Z", "S", "Sdg", "H", "SH", "HS", "S2H", "HS2", "S3H", - "SHS", "HSH", "SHSH", "S2HS", "SHS2", "S3HS", "S2HS2", "S2HSH", "HS2HS", - "S3HS2", "S3HSH", "HS2HS3", + "I", "X", "Y", "Z", "S", "Sdg", "H", "SH", "HS", "S2H", "HS2", "S3H", "SHS", "HSH", "SHSH", + "S2HS", "SHS2", "S3HS", "S2HS2", "S2HSH", "HS2HS", "S3HS2", "S3HSH", "HS2HS3", ]; // ============================================================================ @@ -709,12 +706,11 @@ const CLIFFORD_NAMES: [&str; 24] = [ // // 1. **Fill hue** — axis permutation coset (which pair of Pauli axes // the Clifford interconverts, ignoring signs): -// Blue (#6495ED) — identity perm (X→X, Z→Z) -// Purple (#C850C0) — X↔Z swap (H-type) -// Gold (#DAA520) — X↔Y swap (S-type) -// Cyan (#00B4D8) — Y↔Z swap (SX-type) -// Gray — 3-cycle (dark #707070 = fwd X→Y→Z→X, -// light #B0B0B0 = inv X→Z→Y→X) +// Blue — identity perm (X->X, Z->Z) -> CellColor::ZAxis +// Purple — X<->Z swap (H-type) -> CellColor::XZMix +// Gold — X<->Y swap (S-type) -> CellColor::XYMix +// Cyan — Y<->Z swap (SX-type) -> CellColor::YZMix +// Gray — 3-cycle -> CellColor::XYZMix // // 2. **Fill brightness** — sign parity of the Heisenberg action: // Saturated — even parity (0 or 2 negative signs) @@ -722,72 +718,47 @@ const CLIFFORD_NAMES: [&str; 24] = [ // // 3. **Stroke colour** — gate family (geometric rotation type on the // Bloch sphere): -// Navy (#1E3A8A) — Pauli (identity / π-rotations) -// Green (#2D6A2E) — sqrt-of-Pauli / S-like (π/2 rotations) -// Maroon (#8B1A1A) — Hadamard-like (π rotations about face diagonals) -// Charcoal (#404040) — Face-like / cyclic (2π/3 rotations) - -/// Visual style for a VOP vertex: fill, stroke, and text colours. -struct VopStyle { - fill: &'static str, - stroke: &'static str, - text: &'static str, +// Navy — Pauli (identity / pi-rotations) -> GateFamily::Pauli +// Green — sqrt-of-Pauli / S-like -> GateFamily::SLike +// Maroon — Hadamard-like -> GateFamily::HLike +// Charcoal — Face-like / cyclic -> GateFamily::FLike + +/// Map a Clifford index to its axis permutation coset [`CellColor`]. +#[rustfmt::skip] +fn vop_cell_color(idx: u8) -> CellColor { + match idx { + 0..=3 => CellColor::ZAxis, // Identity/Pauli + 4 | 5 | 20 | 23 => CellColor::XYMix, // X<->Y (S-type) + 6 | 9 | 10 | 18 => CellColor::XZMix, // X<->Z (H-type) + 12 | 13 | 17 | 19 => CellColor::YZMix, // Y<->Z (SX-type) + 7 | 8 | 11 | 14 | 15 | 16 | 21 | 22 => CellColor::XYZMix, // Cyclic + _ => CellColor::None, + } } -/// Precomputed visual styles for all 24 single-qubit Cliffords. -/// -/// Indexed by [`CliffordFrame::index()`]. Derived from the HEIS table: -/// unsigned axis permutation → fill hue, sign parity → brightness, -/// geometric rotation type → stroke colour. +/// Map a Clifford index to its gate family ([`GateFamily`]). #[rustfmt::skip] -const VOP_STYLES: [VopStyle; 24] = [ - // fill stroke text - // Identity coset (X→X, Z→Z) — Pauli family - VopStyle { fill: "#6495ED", stroke: "#1E3A8A", text: "white" }, // 0: I even - VopStyle { fill: "#A0BEF5", stroke: "#1E3A8A", text: "#333" }, // 1: X odd - VopStyle { fill: "#6495ED", stroke: "#1E3A8A", text: "white" }, // 2: Y even - VopStyle { fill: "#A0BEF5", stroke: "#1E3A8A", text: "#333" }, // 3: Z odd - // X↔Y coset (S-type) - VopStyle { fill: "#F0D080", stroke: "#2D6A2E", text: "#333" }, // 4: S odd, S-like - VopStyle { fill: "#DAA520", stroke: "#2D6A2E", text: "white" }, // 5: Sdg even, S-like - // X↔Z coset (H-type) - VopStyle { fill: "#C850C0", stroke: "#8B1A1A", text: "white" }, // 6: H even, H-like - // Cyclic forward (X→Y→Z→X) - VopStyle { fill: "#707070", stroke: "#404040", text: "white" }, // 7: SH F-like - // Cyclic inverse (X→Z→Y→X) - VopStyle { fill: "#B0B0B0", stroke: "#404040", text: "#333" }, // 8: HS F-like - // X↔Z coset cont. - VopStyle { fill: "#E8A0E0", stroke: "#2D6A2E", text: "#333" }, // 9: S²H odd, S-like (=SYdg) - VopStyle { fill: "#E8A0E0", stroke: "#2D6A2E", text: "#333" }, // 10: HS² odd, S-like (=SY) - // Cyclic forward cont. - VopStyle { fill: "#707070", stroke: "#404040", text: "white" }, // 11: S³H F-like - // Y↔Z coset (SX-type) - VopStyle { fill: "#80D8E8", stroke: "#2D6A2E", text: "#333" }, // 12: SHS odd, S-like (=SXdg) - VopStyle { fill: "#00B4D8", stroke: "#2D6A2E", text: "white" }, // 13: HSH even, S-like (=SX) - // Cyclic inverse cont. - VopStyle { fill: "#B0B0B0", stroke: "#404040", text: "#333" }, // 14: SHSH F-like - VopStyle { fill: "#B0B0B0", stroke: "#404040", text: "#333" }, // 15: S²HS F-like - // Cyclic forward cont. - VopStyle { fill: "#707070", stroke: "#404040", text: "white" }, // 16: SHS² F-like - // Y↔Z coset cont. - VopStyle { fill: "#00B4D8", stroke: "#8B1A1A", text: "white" }, // 17: S³HS even, H-like - // X↔Z coset cont. - VopStyle { fill: "#C850C0", stroke: "#8B1A1A", text: "white" }, // 18: S²HS² even, H-like - // Y↔Z coset cont. - VopStyle { fill: "#80D8E8", stroke: "#8B1A1A", text: "#333" }, // 19: S²HSH odd, H-like - // X↔Y coset cont. - VopStyle { fill: "#DAA520", stroke: "#8B1A1A", text: "white" }, // 20: HS²HS even, H-like - // Cyclic forward cont. - VopStyle { fill: "#707070", stroke: "#404040", text: "white" }, // 21: S³HS² F-like - // Cyclic inverse cont. - VopStyle { fill: "#B0B0B0", stroke: "#404040", text: "#333" }, // 22: S³HSH F-like - // X↔Y coset cont. - VopStyle { fill: "#F0D080", stroke: "#8B1A1A", text: "#333" }, // 23: HS²HS³ odd, H-like -]; +fn vop_gate_family(idx: u8) -> GateFamily { + match idx { + 0..=3 => GateFamily::Pauli, + 4 | 5 | 9 | 10 | 12 | 13 => GateFamily::SLike, + 6 | 17 | 18 | 19 | 20 | 23 => GateFamily::HLike, + 7 | 8 | 11 | 14 | 15 | 16 | 21 | 22 => GateFamily::FLike, + _ => GateFamily::Default, + } +} -/// Returns the visual style for a VOP by its Clifford index. -fn vop_style(idx: u8) -> &'static VopStyle { - &VOP_STYLES[idx as usize] +/// Returns true if the Clifford at this index has even sign parity (saturated fill). +/// +/// Even parity = 0 or 2 negative signs in the Heisenberg image. +/// For cyclic coset: forward (7,11,16,21) = saturated, inverse (8,14,15,22) = light. +#[rustfmt::skip] +fn vop_saturated(idx: u8) -> bool { + match idx { + 0 | 2 | 5 | 6 | 7 | 11 | 13 | 16 | 17 | 18 | 20 | 21 => true, + 1 | 3 | 4 | 8 | 9 | 10 | 12 | 14 | 15 | 19 | 22 | 23 => false, + _ => true, + } } /// ANSI SGR escape codes for each of the 24 single-qubit Cliffords. @@ -855,121 +826,57 @@ const VOP_BRACKETS: [(&str, &str); 24] = [ ("<", ">"), // 23: HS2HS3 H-like ]; -/// Append a compact SVG legend showing coset hues and gate-family strokes. -fn svg_legend(svg: &mut String, width: f64, height: f64, legend_height: f64) { - let y_top = height - legend_height + 8.0; - let r = 6.0; // legend circle radius - - // Row 1: fill hues (axis permutation cosets) - let cosets: &[(&str, &str, &str)] = &[ - ("#6495ED", "#1E3A8A", "I/Pauli"), - ("#C850C0", "#6A006A", "X\u{2194}Z"), - ("#DAA520", "#8B6914", "X\u{2194}Y"), - ("#00B4D8", "#006880", "Y\u{2194}Z"), - ("#808080", "#404040", "Cyclic"), - ]; - - let total_items = cosets.len(); - let spacing = width / (total_items as f64 + 1.0); - - for (i, &(fill, stroke, label)) in cosets.iter().enumerate() { - let cx = spacing * (i as f64 + 1.0); - svg.push_str(&format!( - " \n" - )); - let tx = cx + r + 4.0; - svg.push_str(&format!( - " \ - {label}\n", - y_top + 3.0 - )); - } - - // Row 2: stroke colours (gate families) - let families: &[(&str, &str)] = &[ - ("#1E3A8A", "Pauli"), - ("#2D6A2E", "S-like"), - ("#8B1A1A", "H-like"), - ("#404040", "F-like"), - ]; - - let y_row2 = y_top + 18.0; - let fam_spacing = width / (families.len() as f64 + 1.0); - - for (i, &(stroke_col, label)) in families.iter().enumerate() { - let cx = fam_spacing * (i as f64 + 1.0); - svg.push_str(&format!( - " \n" - )); - let tx = cx + r + 4.0; - svg.push_str(&format!( - " \ - {label}\n", - y_row2 + 3.0 - )); - } -} - -/// Map a VOP fill hex colour to its TikZ colour name. -fn tikz_fill_name(hex: &str) -> &'static str { - match hex { - "#6495ED" => "vopIdentity", - "#A0BEF5" => "vopIdentityLt", - "#C850C0" => "vopXZ", - "#E8A0E0" => "vopXZLt", - "#DAA520" => "vopXY", - "#F0D080" => "vopXYLt", - "#00B4D8" => "vopYZ", - "#80D8E8" => "vopYZLt", - "#707070" => "vopCyclicFwd", - "#B0B0B0" => "vopCyclicInv", +/// `TikZ` color name for a [`CellColor`] coset. +fn tikz_coset_name(color: CellColor, saturated: bool) -> &'static str { + match (color, saturated) { + (CellColor::ZAxis, true) => "vopIdentity", + (CellColor::ZAxis, false) => "vopIdentityLt", + (CellColor::XZMix, true) => "vopXZ", + (CellColor::XZMix, false) => "vopXZLt", + (CellColor::XYMix, true) => "vopXY", + (CellColor::XYMix, false) => "vopXYLt", + (CellColor::YZMix, true) => "vopYZ", + (CellColor::YZMix, false) => "vopYZLt", + (CellColor::XYZMix, true) => "vopCyclicFwd", + (CellColor::XYZMix, false) => "vopCyclicInv", _ => "black", } } -/// Map a VOP stroke hex colour to its TikZ colour name. -fn tikz_stroke_name(hex: &str) -> &'static str { - match hex { - "#1E3A8A" => "famPauli", - "#2D6A2E" => "famSqrt", - "#8B1A1A" => "famHadamard", - "#404040" => "famCyclic", - _ => "black", +/// `TikZ` color name for a [`GateFamily`] stroke. +fn tikz_family_name(family: GateFamily) -> &'static str { + match family { + GateFamily::Pauli + | GateFamily::Default + | GateFamily::Measurement + | GateFamily::Preparation => "famPauli", + GateFamily::SLike => "famSqrt", + GateFamily::HLike => "famHadamard", + GateFamily::FLike => "famCyclic", } } impl GraphState { - /// Export to DOT format for Graphviz visualization. + /// Create a renderer bound to a [`GraphStyle`]. + /// + /// # Examples + /// ``` + /// use pecos_qsim::GraphState; + /// use pecos_core::GraphStyle; /// - /// Vertices are coloured using the PECOS colour algebra (fill hue = axis - /// permutation coset, stroke = gate family). + /// let gs = GraphState::linear_cluster(3); + /// let svg = gs.render_with(&GraphStyle::default()).svg(); + /// assert!(svg.contains(" String { - let n = self.num_qubits(); - let mut dot = String::from("graph G {\n"); - dot.push_str(" node [shape=circle, style=filled, fontsize=12];\n"); - - for v in 0..n { - let idx = self.vops[v].index(); - let name = CLIFFORD_NAMES[idx as usize]; - let style = vop_style(idx); - dot.push_str(&format!( - " {v} [label=\"{v}\\n{name}\" fillcolor=\"{}\" \ - color=\"{}\" fontcolor=\"{}\"];\n", - style.fill, style.stroke, style.text - )); - } - - for (u, v) in self.edges() { - dot.push_str(&format!(" {u} -- {v};\n")); - } + pub fn render_with<'a>(&'a self, style: &'a GraphStyle) -> GraphStateRenderer<'a> { + GraphStateRenderer { graph: self, style } + } - dot.push_str("}\n"); - dot + /// Export to DOT format with default style. + #[must_use] + pub fn to_dot(&self) -> String { + self.render_with(&GraphStyle::default()).dot() } /// Compute vertex positions using a circular layout. @@ -985,23 +892,107 @@ impl GraphState { } (0..n) .map(|i| { - let angle = - -std::f64::consts::FRAC_PI_2 + 2.0 * std::f64::consts::PI * (i as f64) / (n as f64); + let angle = -std::f64::consts::FRAC_PI_2 + + 2.0 * std::f64::consts::PI * (i as f64) / (n as f64); (cx + radius * angle.cos(), cy + radius * angle.sin()) }) .collect() } - /// Export to SVG format. - /// - /// Produces a standalone SVG image with vertices arranged in a circular - /// layout. Vertex colours encode Clifford structure via the PECOS colour - /// algebra (fill hue = axis permutation, brightness = sign parity, - /// stroke = gate family). Non-identity VOPs are labeled below their node. - /// A compact legend is drawn at the bottom. + /// Export to SVG with default style. #[must_use] pub fn to_svg(&self) -> String { - let n = self.num_qubits(); + self.render_with(&GraphStyle::default()).svg() + } + + /// Export to `TikZ` with default style. + #[must_use] + pub fn to_tikz(&self) -> String { + self.render_with(&GraphStyle::default()).tikz() + } + + /// Export as plain ASCII text (no escape codes). + #[must_use] + pub fn to_ascii(&self) -> String { + self.render_with(&GraphStyle::default()).ascii() + } + + /// ASCII text with ANSI color codes. + #[must_use] + pub fn to_color_ascii(&self) -> String { + self.render_with(&GraphStyle::builder().ansi_color(true).build()) + .ascii() + } + + /// Unicode text (no escape codes). + #[must_use] + pub fn to_unicode(&self) -> String { + self.render_with(&GraphStyle::default()).unicode() + } + + /// Unicode text with ANSI color codes. + #[must_use] + pub fn to_color_unicode(&self) -> String { + self.render_with(&GraphStyle::builder().ansi_color(true).build()) + .unicode() + } +} + +// ============================================================================ +// GraphStateRenderer +// ============================================================================ + +/// A graph state bound to a [`GraphStyle`], ready to render in any output format. +/// +/// Obtained via [`GraphState::render_with`]. +pub struct GraphStateRenderer<'a> { + graph: &'a GraphState, + style: &'a GraphStyle, +} + +impl GraphStateRenderer<'_> { + /// Render as a Graphviz DOT graph. + #[must_use] + pub fn dot(&self) -> String { + let n = self.graph.num_qubits(); + let mut dot = String::from("graph G {\n"); + dot.push_str(" node [shape=circle, style=filled, fontsize=12];\n"); + + for v in 0..n { + let idx = self.graph.vops[v].index(); + let name = CLIFFORD_NAMES[idx as usize]; + let coset = vop_cell_color(idx); + let family = vop_gate_family(idx); + let sat = vop_saturated(idx); + let fill = self.style.vop_fill(coset, sat); + let stroke = self.style.vop_stroke(family); + let text = self.style.vop_text(coset, sat); + let dot_style = self.style.vop_dot_style(family); + let style_attr = if dot_style.is_empty() { + "filled".to_string() + } else { + format!("filled,{dot_style}") + }; + writeln!( + dot, + " {v} [label=\"{v}\\n{name}\" fillcolor=\"{fill}\" \ + color=\"{stroke}\" fontcolor=\"{text}\" style=\"{style_attr}\"];", + ) + .unwrap(); + } + + for (u, v) in self.graph.edges() { + writeln!(dot, " {u} -- {v};").unwrap(); + } + + dot.push_str("}\n"); + dot + } + + /// Render as a standalone SVG string. + #[must_use] + pub fn svg(&self) -> String { + let n = self.graph.num_qubits(); let node_radius = 20.0; let layout_radius = if n <= 2 { 60.0 } else { 40.0 + 25.0 * n as f64 }; let margin = node_radius + 40.0; @@ -1010,103 +1001,265 @@ impl GraphState { let height = width + legend_height; let center = layout_radius + margin; - let positions = Self::circular_layout(n, center, center, layout_radius); + let positions = GraphState::circular_layout(n, center, center, layout_radius); let mut svg = format!( "\n" ); - svg.push_str(&format!( - " \n" - )); + writeln!( + svg, + " " + ) + .unwrap(); + + // Collect needed fill patterns and emit + let mut needed_patterns = BTreeSet::new(); + for v in 0..n { + let idx = self.graph.vops[v].index(); + let pattern = self.style.vop_pattern(vop_cell_color(idx)); + if pattern != FillPattern::Solid { + needed_patterns.insert(pattern); + } + } + // Also include patterns used by legend cosets + for coset in [ + CellColor::ZAxis, + CellColor::XZMix, + CellColor::XYMix, + CellColor::YZMix, + CellColor::XYZMix, + ] { + let pattern = self.style.vop_pattern(coset); + if pattern != FillPattern::Solid { + needed_patterns.insert(pattern); + } + } + if !needed_patterns.is_empty() { + svg.push_str(" \n"); + for pat in &needed_patterns { + writeln!(svg, " {}", pat.svg_pattern_def()).unwrap(); + } + svg.push_str(" \n"); + } // Draw edges - for (u, v) in self.edges() { + for (u, v) in self.graph.edges() { let (x1, y1) = positions[u]; let (x2, y2) = positions[v]; - svg.push_str(&format!( + writeln!( + svg, " \n" - )); + stroke=\"#555\" stroke-width=\"1.5\"/>" + ) + .unwrap(); } // Draw vertices - for v in 0..n { - let (x, y) = positions[v]; - let idx = self.vops[v].index(); + for (v, &(x, y)) in positions.iter().enumerate() { + let idx = self.graph.vops[v].index(); let vop_name = CLIFFORD_NAMES[idx as usize]; - let style = vop_style(idx); + let coset = vop_cell_color(idx); + let family = vop_gate_family(idx); + let sat = vop_saturated(idx); + let fill = self.style.vop_fill(coset, sat); + let stroke = self.style.vop_stroke(family); + let text = self.style.vop_text(coset, sat); + let dash = self.style.vop_dasharray(family); + let dash_attr = if dash.is_empty() { + String::new() + } else { + format!(" stroke-dasharray=\"{dash}\"") + }; - svg.push_str(&format!( + writeln!( + svg, " \n", - style.fill, style.stroke - )); + fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"2\"{dash_attr}/>" + ) + .unwrap(); + + // Pattern overlay + let pattern = self.style.vop_pattern(coset); + if pattern != FillPattern::Solid { + let pat_r = node_radius - 1.0; + writeln!( + svg, + " ", + pattern.svg_id() + ) + .unwrap(); + } // Vertex index label - svg.push_str(&format!( + writeln!( + svg, " {v}\n", - style.text - )); + fill=\"{text}\" font-weight=\"bold\">{v}" + ) + .unwrap(); // VOP label (below the node, only if non-identity) - if !self.vops[v].is_identity() { + if !self.graph.vops[v].is_identity() { let label_y = y + node_radius + 14.0; - svg.push_str(&format!( + writeln!( + svg, " {vop_name}\n" - )); + fill=\"#666\">{vop_name}" + ) + .unwrap(); } } // Legend - svg_legend(&mut svg, width, height, legend_height); + self.svg_legend(&mut svg, width, height, legend_height); svg.push_str("\n"); svg } - /// Export to TikZ format for LaTeX documents. - /// - /// Produces a `tikzpicture` environment with vertices coloured by the - /// PECOS colour algebra. Requires `\usepackage{tikz}` and - /// `\usepackage[dvipsnames]{xcolor}` (or `\usepackage{xcolor}`) in - /// your LaTeX preamble. + /// Append an SVG legend derived from the style palette. + fn svg_legend(&self, svg: &mut String, width: f64, height: f64, legend_height: f64) { + let y_top = height - legend_height + 8.0; + let r = 6.0; + + // Row 1: fill hues (cosets) -- show saturated fill + family stroke + let cosets: &[(CellColor, &str)] = &[ + (CellColor::ZAxis, "I/Pauli"), + (CellColor::XZMix, "X\u{2194}Z"), + (CellColor::XYMix, "X\u{2194}Y"), + (CellColor::YZMix, "Y\u{2194}Z"), + (CellColor::XYZMix, "Cyclic"), + ]; + + let spacing = width / (cosets.len() as f64 + 1.0); + for (i, &(coset, label)) in cosets.iter().enumerate() { + let cx = spacing * (i as f64 + 1.0); + let fill = self.style.vop_fill(coset, true); + let stroke = blend_hex(&fill, "#000000", 0.4); + writeln!( + svg, + " " + ) + .unwrap(); + let pattern = self.style.vop_pattern(coset); + if pattern != FillPattern::Solid { + let pr = r - 0.5; + writeln!( + svg, + " ", + pattern.svg_id() + ) + .unwrap(); + } + let tx = cx + r + 4.0; + let ty = y_top + 3.0; + writeln!( + svg, + " \ + {label}" + ) + .unwrap(); + } + + // Row 2: stroke colours (gate families) + let families: &[(GateFamily, &str)] = &[ + (GateFamily::Pauli, "Pauli"), + (GateFamily::SLike, "S-like"), + (GateFamily::HLike, "H-like"), + (GateFamily::FLike, "F-like"), + ]; + + let y_row2 = y_top + 18.0; + let fam_spacing = width / (families.len() as f64 + 1.0); + for (i, &(family, label)) in families.iter().enumerate() { + let cx = fam_spacing * (i as f64 + 1.0); + let stroke_col = self.style.vop_stroke(family); + let dash = self.style.vop_dasharray(family); + let dash_attr = if dash.is_empty() { + String::new() + } else { + format!(" stroke-dasharray=\"{dash}\"") + }; + writeln!( + svg, + " " + ) + .unwrap(); + let tx = cx + r + 4.0; + let ty = y_row2 + 3.0; + writeln!( + svg, + " \ + {label}" + ) + .unwrap(); + } + } + + /// Render as a `TikZ` `tikzpicture` environment. #[must_use] - pub fn to_tikz(&self) -> String { - let n = self.num_qubits(); + pub fn tikz(&self) -> String { + let n = self.graph.num_qubits(); let radius = if n <= 2 { 1.5 } else { 1.0 + 0.5 * n as f64 }; - let positions = Self::circular_layout(n, 0.0, 0.0, radius); + let positions = GraphState::circular_layout(n, 0.0, 0.0, radius); let mut tikz = String::from("\\begin{tikzpicture}\n"); - // Colour definitions — fill hues (axis permutation cosets) + // Colour definitions derived from style tikz.push_str(" % Fill: axis permutation coset (bright / light)\n"); - for &(name, hex) in &[ - ("vopIdentity", "6495ED"), ("vopIdentityLt", "A0BEF5"), - ("vopXZ", "C850C0"), ("vopXZLt", "E8A0E0"), - ("vopXY", "DAA520"), ("vopXYLt", "F0D080"), - ("vopYZ", "00B4D8"), ("vopYZLt", "80D8E8"), - ("vopCyclicFwd", "707070"), ("vopCyclicInv", "B0B0B0"), + for &(coset, sat, name) in &[ + (CellColor::ZAxis, true, "vopIdentity"), + (CellColor::ZAxis, false, "vopIdentityLt"), + (CellColor::XZMix, true, "vopXZ"), + (CellColor::XZMix, false, "vopXZLt"), + (CellColor::XYMix, true, "vopXY"), + (CellColor::XYMix, false, "vopXYLt"), + (CellColor::YZMix, true, "vopYZ"), + (CellColor::YZMix, false, "vopYZLt"), + (CellColor::XYZMix, true, "vopCyclicFwd"), + (CellColor::XYZMix, false, "vopCyclicInv"), ] { - tikz.push_str(&format!(" \\definecolor{{{name}}}{{HTML}}{{{hex}}}\n")); + let hex = self.style.vop_fill(coset, sat); + let hex = hex.strip_prefix('#').unwrap_or(&hex); + writeln!(tikz, " \\definecolor{{{name}}}{{HTML}}{{{hex}}}").unwrap(); } - // Stroke colours — gate families tikz.push_str(" % Stroke: gate family\n"); - for &(name, hex) in &[ - ("famPauli", "1E3A8A"), - ("famSqrt", "2D6A2E"), - ("famHadamard", "8B1A1A"), - ("famCyclic", "404040"), + for &(family, name) in &[ + (GateFamily::Pauli, "famPauli"), + (GateFamily::SLike, "famSqrt"), + (GateFamily::HLike, "famHadamard"), + (GateFamily::FLike, "famCyclic"), ] { - tikz.push_str(&format!(" \\definecolor{{{name}}}{{HTML}}{{{hex}}}\n")); + let hex = self.style.vop_stroke(family); + let hex = hex.strip_prefix('#').unwrap_or(hex); + writeln!(tikz, " \\definecolor{{{name}}}{{HTML}}{{{hex}}}").unwrap(); + } + + // Check if any coset uses patterns (need patterns library) + let any_pattern = [ + CellColor::ZAxis, + CellColor::XZMix, + CellColor::XYMix, + CellColor::YZMix, + CellColor::XYZMix, + ] + .iter() + .any(|&c| self.style.vop_pattern(c) != FillPattern::Solid); + if any_pattern { + tikz.push_str(" % Requires: \\usetikzlibrary{patterns}\n"); } // Base vertex style @@ -1114,91 +1267,83 @@ impl GraphState { " \\tikzstyle{vertex}=[circle, minimum size=20pt, \ inner sep=0pt, font=\\small, line width=1.5pt]\n", ); - tikz.push_str( - " \\tikzstyle{vop label}=[font=\\scriptsize, text=gray]\n", - ); + tikz.push_str(" \\tikzstyle{vop label}=[font=\\scriptsize, text=gray]\n"); // Draw vertices - for v in 0..n { - let (x, y) = positions[v]; - let idx = self.vops[v].index(); - let style = vop_style(idx); - let fill_name = tikz_fill_name(style.fill); - let draw_name = tikz_stroke_name(style.stroke); - let text_opt = if style.text == "white" { ", text=white" } else { "" }; - - tikz.push_str(&format!( - " \\node[vertex, fill={fill_name}, draw={draw_name}{text_opt}] \ - (v{v}) at ({x:.2}, {y:.2}) {{{v}}};\n" - )); + for (v, &(x, y)) in positions.iter().enumerate() { + let idx = self.graph.vops[v].index(); + let coset = vop_cell_color(idx); + let family = vop_gate_family(idx); + let sat = vop_saturated(idx); + let fill_name = tikz_coset_name(coset, sat); + let draw_name = tikz_family_name(family); + let text_opt = if self.style.vop_text(coset, sat) == "white" { + ", text=white" + } else { + "" + }; + let tikz_dash = self.style.vop_tikz_dash(family); + let dash_opt = if tikz_dash.is_empty() { + String::new() + } else { + format!(", {tikz_dash}") + }; + let tikz_pat = self.style.vop_pattern(coset).tikz_pattern(); + let pat_opt = if tikz_pat.is_empty() { + String::new() + } else { + format!(", postaction={{pattern={tikz_pat}, pattern color=black!30}}") + }; + + writeln!( + tikz, + " \\node[vertex, fill={fill_name}, draw={draw_name}{text_opt}{dash_opt}{pat_opt}] \ + (v{v}) at ({x:.2}, {y:.2}) {{{v}}};", + ) + .unwrap(); // VOP annotation - if !self.vops[v].is_identity() { + if !self.graph.vops[v].is_identity() { let vop_name = CLIFFORD_NAMES[idx as usize]; let label_y = y - 0.45; - tikz.push_str(&format!( - " \\node[vop label] at ({x:.2}, {label_y:.2}) {{${vop_name}$}};\n" - )); + writeln!( + tikz, + " \\node[vop label] at ({x:.2}, {label_y:.2}) {{${vop_name}$}};", + ) + .unwrap(); } } // Draw edges - for (u, v) in self.edges() { - tikz.push_str(&format!(" \\draw (v{u}) -- (v{v});\n")); + for (u, v) in self.graph.edges() { + writeln!(tikz, " \\draw (v{u}) -- (v{v});").unwrap(); } tikz.push_str("\\end{tikzpicture}\n"); tikz } - /// Export as plain ASCII text (no escape codes). - /// - /// Compact vertex-per-line format. When all VOPs are identity (a pure - /// graph state), the VOP column is omitted for a clean adjacency-list - /// view. When any VOP is non-trivial, non-identity VOPs are shown in - /// brackets encoding gate family: `()` Pauli, `[]` S-like, `<>` H-like, - /// `{}` F-like. Identity vertices get a blank VOP column. - #[must_use] - pub fn to_ascii(&self) -> String { - self.format_graph(false, "--") - } - - /// ASCII text with ANSI color codes. + /// Render as plain ASCII text. /// - /// Same layout as [`to_ascii`](Self::to_ascii) with 16-color ANSI codes - /// encoding the coset (hue) and sign parity (bold = even, normal = odd). - /// A two-line legend is appended when non-identity VOPs are present. + /// Produces ANSI color codes when `style.ansi_color` is true. #[must_use] - pub fn to_color_ascii(&self) -> String { - self.format_graph(true, "--") + pub fn ascii(&self) -> String { + self.format_text("--") } - /// Unicode text (no escape codes). + /// Render as Unicode text. /// - /// Same layout as [`to_ascii`](Self::to_ascii) with a Unicode separator - /// (`\u{2500}\u{2500}`) instead of `--`. - #[must_use] - pub fn to_unicode(&self) -> String { - self.format_graph(false, "\u{2500}\u{2500}") - } - - /// Unicode text with ANSI color codes. - #[must_use] - pub fn to_color_unicode(&self) -> String { - self.format_graph(true, "\u{2500}\u{2500}") - } - - /// Deprecated: use [`to_color_ascii`](Self::to_color_ascii) instead. - #[deprecated(note = "renamed to to_color_ascii")] + /// Produces ANSI color codes when `style.ansi_color` is true. #[must_use] - pub fn to_ascii_color(&self) -> String { - self.to_color_ascii() + pub fn unicode(&self) -> String { + self.format_text("\u{2500}\u{2500}") } - /// Shared layout logic. - fn format_graph(&self, color: bool, separator: &str) -> String { - let n = self.num_qubits(); - let num_edges = self.num_edges(); + /// Shared text layout logic. + fn format_text(&self, separator: &str) -> String { + let color = self.style.ansi_color; + let n = self.graph.num_qubits(); + let num_edges = self.graph.num_edges(); let mut out = format!("GraphState: {n} qubits, {num_edges} edges\n\n"); if n == 0 { @@ -1206,14 +1351,14 @@ impl GraphState { } let idx_width = (n - 1).to_string().len(); - let show_vops = !self.is_pure_graph_state(); + let show_vops = !self.graph.is_pure_graph_state(); // Compute maximum bracketed VOP width across non-identity vertices. let max_vop_width = if show_vops { (0..n) - .filter(|&v| !self.vops[v].is_identity()) + .filter(|&v| !self.graph.vops[v].is_identity()) .map(|v| { - let idx = self.vops[v].index() as usize; + let idx = self.graph.vops[v].index() as usize; CLIFFORD_NAMES[idx].len() + 2 // +2 for brackets }) .max() @@ -1226,8 +1371,8 @@ impl GraphState { write!(out, " {v:>idx_width$}").unwrap(); if show_vops { - let idx = self.vops[v].index() as usize; - if self.vops[v].is_identity() { + let idx = self.graph.vops[v].index() as usize; + if self.graph.vops[v].is_identity() { write!(out, " {: = self.neighbors[v].iter().collect(); + let nbrs: Vec = self.graph.neighbors[v].iter().collect(); if !nbrs.is_empty() { let nbr_str: Vec = nbrs.iter().map(ToString::to_string).collect(); write!(out, " {separator} {}", nbr_str.join(", ")).unwrap(); @@ -1418,7 +1559,7 @@ mod tests { fn test_edges_iterator() { let gs = GraphState::from_edges(4, &[(0, 1), (2, 3), (0, 3)]); let mut edges: Vec<(usize, usize)> = gs.edges().collect(); - edges.sort(); + edges.sort_unstable(); assert_eq!(edges, vec![(0, 1), (0, 3), (2, 3)]); } @@ -1852,7 +1993,7 @@ mod tests { let dot = gs.to_dot(); assert!(dot.contains("graph G {")); assert!(dot.contains("0 -- 1")); - assert!(dot.contains("}")); + assert!(dot.contains('}')); } #[test] @@ -1868,14 +2009,26 @@ mod tests { #[test] fn test_to_svg_with_vops() { + use pecos_core::GraphStyle; + let mut gs = GraphState::from_edges(2, &[(0, 1)]); gs.set_vop(0, CliffordFrame::H); let svg = gs.to_svg(); // Non-identity VOP should get a label - assert!(svg.contains("H")); - // Identity vertex gets identity fill, H vertex gets H-type fill - assert!(svg.contains("#6495ED")); // identity coset fill - assert!(svg.contains("#C850C0")); // X<->Z coset fill (H) + assert!(svg.contains('H')); + + // Colors are now derived from the palette via blend_hex. + // Check that the computed fills for identity (saturated ZAxis) + // and H (saturated XZMix) both appear. + let style = GraphStyle::default(); + let identity_fill = style.vop_fill(CellColor::ZAxis, true); + let h_fill = style.vop_fill(CellColor::XZMix, true); + assert!( + svg.contains(&identity_fill), + "identity fill missing: {identity_fill}" + ); + assert!(svg.contains(&h_fill), "H fill missing: {h_fill}"); + // Gate family strokes: Pauli (identity) vs H-like (H gate) assert!(svg.contains("#1E3A8A")); // Pauli stroke assert!(svg.contains("#8B1A1A")); // H-like stroke @@ -1952,11 +2105,7 @@ mod tests { // For a pure graph state with the same graph, generators should match exactly for (i, (mg, sg)) in math_gens.iter().zip(sim_gens.iter()).enumerate() { - assert_eq!( - mg.phase(), - sg.phase(), - "generator {i}: phase mismatch" - ); + assert_eq!(mg.phase(), sg.phase(), "generator {i}: phase mismatch"); for q in 0..3 { assert_eq!( mg.get(q), @@ -2001,7 +2150,10 @@ mod tests { assert!(ascii.contains("GraphState: 3 qubits, 2 edges")); // Pure graph state: VOP column is omitted entirely - assert!(!ascii.contains("(I)"), "identity VOPs should be hidden: {ascii}"); + assert!( + !ascii.contains("(I)"), + "identity VOPs should be hidden: {ascii}" + ); // Edge info assert!(ascii.contains("-- 1")); @@ -2012,11 +2164,11 @@ mod tests { } #[test] - fn test_to_ascii_color_contains_ansi() { + fn test_to_color_ascii_contains_ansi() { // Need non-identity VOPs for color output (pure states have no VOPs to color) let mut gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); gs.set_vop(0, CliffordFrame::H); - let colored = gs.to_ascii_color(); + let colored = gs.to_color_ascii(); // Should contain ANSI escape codes and resets assert!(colored.contains("\x1b["), "missing ANSI codes: {colored}"); @@ -2032,12 +2184,40 @@ mod tests { } #[test] - fn test_to_ascii_color_pure_has_no_ansi() { + fn test_to_color_ascii_pure_has_no_ansi() { // Pure graph state: nothing to color, no legend let gs = GraphState::linear_cluster(3); - let colored = gs.to_ascii_color(); - assert!(!colored.contains("\x1b["), "pure state should have no ANSI: {colored}"); - assert!(!colored.contains("Pauli"), "pure state should have no legend"); + let colored = gs.to_color_ascii(); + assert!( + !colored.contains("\x1b["), + "pure state should have no ANSI: {colored}" + ); + assert!( + !colored.contains("Pauli"), + "pure state should have no legend" + ); + } + + #[test] + fn render_with_ansi_color_matches_to_color_ascii() { + let mut gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + gs.set_vop(0, CliffordFrame::H); + let via_convenience = gs.to_color_ascii(); + let via_render_with = gs + .render_with(&GraphStyle::builder().ansi_color(true).build()) + .ascii(); + assert_eq!(via_convenience, via_render_with); + } + + #[test] + fn render_with_ansi_color_matches_to_color_unicode() { + let mut gs = GraphState::from_edges(3, &[(0, 1), (1, 2)]); + gs.set_vop(0, CliffordFrame::H); + let via_convenience = gs.to_color_unicode(); + let via_render_with = gs + .render_with(&GraphStyle::builder().ansi_color(true).build()) + .unicode(); + assert_eq!(via_convenience, via_render_with); } #[test] @@ -2067,9 +2247,9 @@ mod tests { fn test_to_ascii_bracket_families() { let mut gs = GraphState::new(4); gs.set_vop(0, CliffordFrame::from_index(1)); // idx 1: X, Pauli -> () - gs.set_vop(1, CliffordFrame::SZ); // idx 4: S-like -> [] - gs.set_vop(2, CliffordFrame::H); // idx 6: H-like -> <> - gs.set_vop(3, CliffordFrame::from_index(7)); // idx 7: F-like -> {} + gs.set_vop(1, CliffordFrame::SZ); // idx 4: S-like -> [] + gs.set_vop(2, CliffordFrame::H); // idx 6: H-like -> <> + gs.set_vop(3, CliffordFrame::from_index(7)); // idx 7: F-like -> {} let ascii = gs.to_ascii(); assert!(ascii.contains("(X)"), "Pauli bracket missing: {ascii}"); @@ -2087,10 +2267,7 @@ mod tests { let ascii = gs.to_ascii(); // Find the `--` column for each line that has neighbors - let dash_cols: Vec = ascii - .lines() - .filter_map(|line| line.find("--")) - .collect(); + let dash_cols: Vec = ascii.lines().filter_map(|line| line.find("--")).collect(); assert!(dash_cols.len() >= 2, "expected at least 2 lines with --"); assert!( dash_cols.windows(2).all(|w| w[0] == w[1]), @@ -2104,4 +2281,74 @@ mod tests { let ascii = gs.to_ascii(); assert!(ascii.contains("0 qubits, 0 edges")); } + + // ======================================================================== + // render_with tests + // ======================================================================== + + #[test] + fn render_with_default_matches_to_svg() { + use pecos_core::GraphStyle; + + let gs = GraphState::linear_cluster(3); + let default_style = GraphStyle::default(); + assert_eq!(gs.render_with(&default_style).svg(), gs.to_svg()); + } + + #[test] + fn render_with_custom_palette() { + use pecos_core::{ColorPalette, ColorTriplet, GraphStyle}; + + let mut palette = ColorPalette::default(); + palette.z_axis = ColorTriplet::new("#FF0000", "#880000", "#440000"); + let style = GraphStyle::builder().palette(palette).build(); + + let gs = GraphState::linear_cluster(3); // pure: all identity (ZAxis coset) + let svg = gs.render_with(&style).svg(); + + // Saturated ZAxis fill = blend("#FF0000", "#880000", 0.5) + let expected_fill = pecos_core::blend_hex("#FF0000", "#880000", 0.5); + assert!( + svg.contains(&expected_fill), + "custom ZAxis fill {expected_fill} not found in SVG" + ); + } + + #[test] + fn render_with_monochrome() { + use pecos_core::{ColorPalette, ColorTriplet, GraphStyle}; + + // Set all cosets to the same color + let grey = ColorTriplet::new("#CCCCCC", "#666666", "#333333"); + let palette = ColorPalette { + z_axis: grey.clone(), + xz_mix: grey.clone(), + xy_mix: grey.clone(), + yz_mix: grey.clone(), + xyz_mix: grey.clone(), + ..ColorPalette::default() + }; + let style = GraphStyle::builder().palette(palette).build(); + + let mut gs = GraphState::from_edges(2, &[(0, 1)]); + gs.set_vop(0, CliffordFrame::H); // XZMix coset + + let svg = gs.render_with(&style).svg(); + // Both vertices should use the same grey palette + let sat_fill = pecos_core::blend_hex("#CCCCCC", "#666666", 0.5); + // Count occurrences of the saturated fill (both vertices are saturated: I=even, H=even) + assert!( + svg.matches(&sat_fill).count() >= 2, + "monochrome fill {sat_fill} should appear at least twice" + ); + } + + #[test] + fn render_with_ascii_matches_to_ascii() { + use pecos_core::GraphStyle; + + let gs = GraphState::linear_cluster(4); + let default_style = GraphStyle::default(); + assert_eq!(gs.render_with(&default_style).ascii(), gs.to_ascii()); + } } diff --git a/crates/pecos-qsim/src/lib.rs b/crates/pecos-qsim/src/lib.rs index 1138849a5..8e7deaf2b 100644 --- a/crates/pecos-qsim/src/lib.rs +++ b/crates/pecos-qsim/src/lib.rs @@ -18,8 +18,6 @@ pub mod clifford_gateable; pub mod clifford_test_utils; pub mod coin_toss; pub mod dense_stab; -pub mod graph_state; -pub mod graph_state_repr; pub mod dense_stab_variants; pub mod density_matrix; pub mod density_matrix_test_utils; @@ -27,6 +25,8 @@ pub mod gens; pub mod gpu_stab; pub mod gpu_stab_opt; pub mod gpu_stab_parallel; +pub mod graph_state; +pub mod graph_state_repr; pub mod measurement_sampler; pub mod pauli_prop; // pub mod paulis; @@ -68,11 +68,11 @@ pub use dense_stab::DenseStab; pub use dense_stab_variants::{DenseStabColOnly, DenseStabRowOnly, SparseColOnly, SparseRowOnly}; pub use density_matrix::DensityMatrix; pub use gens::{Gens, GensBitSet, GensGeneric, GensHybrid, GensVecSet, PauliClassification}; -pub use graph_state::GraphStateSim; -pub use graph_state_repr::GraphState; pub use gpu_stab::GpuStab; 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 paulis::Paulis; pub use measurement_sampler::{ MeasurementKind, MeasurementSampler, MeasurementValidationError, SampleResult, diff --git a/crates/pecos-quantum/Cargo.toml b/crates/pecos-quantum/Cargo.toml index f4ce0c663..55f6a663d 100644 --- a/crates/pecos-quantum/Cargo.toml +++ b/crates/pecos-quantum/Cargo.toml @@ -20,6 +20,9 @@ smallvec.workspace = true tket = { workspace = true, optional = true } log.workspace = true +[dev-dependencies] +pecos-qsim.workspace = true + [features] default = [] hugr = ["tket"] diff --git a/crates/pecos-quantum/examples/style_demo.rs b/crates/pecos-quantum/examples/style_demo.rs new file mode 100644 index 000000000..7855b1e0f --- /dev/null +++ b/crates/pecos-quantum/examples/style_demo.rs @@ -0,0 +1,714 @@ +// Standalone binary to generate an HTML demo of all diagram styles. +// Run from the PECOS workspace root: +// cargo run --example style_demo -p pecos-quantum + +use pecos_core::circuit_diagram::{AngleUnit, DiagramStyle, GraphStyle}; +use pecos_core::{Angle64, ColorPalette, ColorTriplet, CosetPatterns, FamilyPalette, FillPattern}; +use pecos_qsim::GraphState; +use pecos_quantum::TickCircuit; +use pecos_quantum::pass::{ + AbsorbBasisGates, CancelInverses, CircuitPass, CompactTicks, MergeAdjacentRotations, + PassPipeline, PeepholeOptimize, RemoveIdentity, SimplifyRotations, +}; +use std::fs; + +fn build_circuit() -> TickCircuit { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1, 2, 3]); + tc.tick().x(&[0]).y(&[1]).z(&[2]); + tc.tick().sx(&[0]).sy(&[1]).sz(&[2]); + tc.tick().h(&[0, 1, 2, 3]); + tc.tick().t(&[0]).tdg(&[1]).rz(Angle64::QUARTER_TURN, &[2]); + tc.tick().cx(&[(0, 1)]).cz(&[(2, 3)]); + let eighth = Angle64::QUARTER_TURN / 2u64; + tc.tick() + .rzz(Angle64::QUARTER_TURN, &[(0, 1)]) + .rzz(eighth, &[(2, 3)]); + tc.tick().mz(&[0, 1, 2, 3]); + tc +} + +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn ansi_to_html(s: &str) -> String { + let mut out = String::new(); + let mut in_span = false; + let mut i = 0; + let bytes = s.as_bytes(); + + while i < bytes.len() { + if bytes[i] == b'\x1b' && i + 1 < bytes.len() && bytes[i + 1] == b'[' { + // Parse ANSI escape + let start = i + 2; + let mut end = start; + while end < bytes.len() && bytes[end] != b'm' { + end += 1; + } + if end < bytes.len() { + let code = &s[start..end]; + if in_span { + out.push_str(""); + in_span = false; + } + // Handle compound codes like "1;34" (bold + color) + let style = ansi_code_to_css(code); + if let Some(css) = style { + out.push_str(&format!("")); + in_span = true; + } + i = end + 1; + continue; + } + } + + let ch = s[i..].chars().next().unwrap(); + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(ch), + } + i += ch.len_utf8(); + } + if in_span { + out.push_str(""); + } + out +} + +fn ansi_code_to_css(code: &str) -> Option { + if code == "0" { + return None; + } + let parts: Vec<&str> = code.split(';').collect(); + let mut bold = false; + let mut color = None; + for part in &parts { + match *part { + "1" => bold = true, + "31" => color = Some("#AA2222"), + "32" => color = Some("#226622"), + "33" => color = Some("#AA8800"), + "34" => color = Some("#2255AA"), + "35" => color = Some("#882288"), + "36" => color = Some("#008888"), + "37" => color = Some("#666666"), + "90" => color = Some("#888888"), + _ => {} + } + } + match (bold, color) { + (true, Some(c)) => Some(format!("font-weight:bold;color:{c}")), + (true, None) => Some("font-weight:bold".to_string()), + (false, Some(c)) => Some(format!("color:{c}")), + (false, None) => None, + } +} + +fn section(title: &str, body: &str) -> String { + format!("

{title}

\n{body}\n") +} + +fn pre_block(content: &str) -> String { + format!("
{content}
") +} + +fn svg_block(svg: &str) -> String { + format!("
{svg}
") +} + +fn code_block(lang: &str, content: &str) -> String { + format!( + "
Show {lang} source
{}
", + escape_html(content) + ) +} + +fn main() { + let tc = build_circuit(); + + let default_style = DiagramStyle::default(); + + let custom_palette = DiagramStyle::builder() + .x_axis("#FF6666", "#CC0000", "#660000") + .z_axis("#6666FF", "#0000CC", "#000066") + .xz_mix("#CC66CC", "#990099", "#660066") + .build(); + + let monochrome = DiagramStyle::builder().color(false).build(); + + let no_dashes = DiagramStyle::builder().show_dashes(false).build(); + + let mono_no_dashes = DiagramStyle::builder() + .color(false) + .show_dashes(false) + .build(); + + let mut html = String::from( + r#" + + + +PECOS Visualization Demo + + + +

PECOS Visualization Demo

+

Circuit diagrams and graph state visualizations with configurable styles.

+

Circuit Diagrams

+

All gate families: Prep, Pauli, S-like, H-like, Default (T), multi-qubit (CX/CZ), Measure.

+"#, + ); + + // -- Text outputs -- + html.push_str(§ion( + "ASCII (plain)", + &pre_block(&escape_html(&tc.to_ascii())), + )); + + html.push_str(§ion( + "ASCII (ANSI color)", + &pre_block(&ansi_to_html(&tc.to_color_ascii())), + )); + + html.push_str(§ion( + "Unicode (plain)", + &pre_block(&escape_html(&tc.to_unicode())), + )); + + html.push_str(§ion( + "Unicode (ANSI color)", + &pre_block(&ansi_to_html(&tc.to_color_unicode())), + )); + + // -- SVG outputs -- + html.push_str("

SVG Outputs

\n
\n"); + + let r_default = tc.render_with(&default_style); + html.push_str(&format!( + "

Default

{}
", + svg_block(&r_default.svg()) + )); + + let r_custom = tc.render_with(&custom_palette); + html.push_str(&format!( + "

Custom Palette

{}
", + svg_block(&r_custom.svg()) + )); + + let r_mono = tc.render_with(&monochrome); + html.push_str(&format!( + "

Monochrome (color: false)

{}
", + svg_block(&r_mono.svg()) + )); + + let r_nodash = tc.render_with(&no_dashes); + html.push_str(&format!( + "

No Dashes (show_dashes: false)

{}
", + svg_block(&r_nodash.svg()) + )); + + let r_mono_nodash = tc.render_with(&mono_no_dashes); + html.push_str(&format!( + "

Monochrome + No Dashes

{}
", + svg_block(&r_mono_nodash.svg()) + )); + + html.push_str("
\n"); + + // -- TikZ -- + html.push_str(§ion( + "TikZ (default)", + &code_block("TikZ", &r_default.tikz()), + )); + html.push_str(§ion( + "TikZ (monochrome)", + &code_block("TikZ", &r_mono.tikz()), + )); + + // -- DOT -- + html.push_str(§ion( + "DOT / Graphviz (default)", + &code_block("DOT", &r_default.dot()), + )); + html.push_str(§ion( + "DOT / Graphviz (monochrome)", + &code_block("DOT", &r_mono.dot()), + )); + + // -- Angle unit comparison -- + html.push_str("

Angle Units: Radians vs Turns

\n"); + { + // Build a circuit with several parameterized gates + let mut angle_tc = TickCircuit::new(); + angle_tc.tick().pz(&[0, 1, 2]); + let eighth = Angle64::QUARTER_TURN / 2u64; + angle_tc + .tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(eighth, &[1]) + .rz(Angle64::HALF_TURN, &[2]); + angle_tc + .tick() + .rx(Angle64::QUARTER_TURN, &[0]) + .ry(eighth, &[1]); + angle_tc.tick().mz(&[0, 1, 2]); + + let radians_style = DiagramStyle::builder() + .angle_unit(AngleUnit::Radians) + .build(); + let turns_style = DiagramStyle::builder().angle_unit(AngleUnit::Turns).build(); + + let r_rad = angle_tc.render_with(&radians_style); + let r_turns = angle_tc.render_with(&turns_style); + + html.push_str("
\n"); + html.push_str(&format!( + "

Radians (default)

{}{}
", + pre_block(&escape_html(&r_rad.ascii())), + svg_block(&r_rad.svg()), + )); + html.push_str(&format!( + "

Turns

{}{}
", + pre_block(&escape_html(&r_turns.ascii())), + svg_block(&r_turns.svg()), + )); + html.push_str("
\n"); + } + + // -- Rotation simplification comparison (pass-based) -- + html.push_str("

Rotation Simplification (Circuit Pass)

\n"); + { + let mut rot_tc = TickCircuit::new(); + rot_tc.tick().pz(&[0, 1, 2, 3]); + rot_tc + .tick() + .rz(Angle64::HALF_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]) + .rx(Angle64::QUARTER_TURN, &[2]) + .ry(Angle64::QUARTER_TURN, &[3]); + let eighth = Angle64::QUARTER_TURN / 2u64; + rot_tc + .tick() + .rz(eighth, &[0]) + .rx(Angle64::HALF_TURN, &[1]) + .ry(Angle64::HALF_TURN, &[2]) + .rz(Angle64::THREE_QUARTERS_TURN, &[3]); + rot_tc.tick().mz(&[0, 1, 2, 3]); + + // Clone and apply the SimplifyRotations pass to one copy. + let mut simplified_tc = rot_tc.clone(); + SimplifyRotations.apply_tick(&mut simplified_tc); + + let style = DiagramStyle::default(); + let r_before = rot_tc.render_with(&style); + let r_after = simplified_tc.render_with(&style); + + html.push_str("
\n"); + html.push_str(&format!( + "

Before pass

{}{}
", + pre_block(&escape_html(&r_before.ascii())), + svg_block(&r_before.svg()), + )); + html.push_str(&format!( + "

After SimplifyRotations

{}{}
", + pre_block(&escape_html(&r_after.ascii())), + svg_block(&r_after.svg()), + )); + html.push_str("
\n"); + } + + // -- Peephole optimization comparison -- + html.push_str("

Peephole Optimization (Circuit Pass)

\n"); + { + let mut peep_tc = TickCircuit::new(); + peep_tc.tick().pz(&[0, 1, 2, 3]); + // H-CX-H on target -> CZ + peep_tc.tick().h(&[1]); + peep_tc.tick().cx(&[(0, 1)]); + peep_tc.tick().h(&[1]); + // H-CZ-H on one qubit -> CX + peep_tc.tick().h(&[2]); + peep_tc.tick().cz(&[(2, 3)]); + peep_tc.tick().h(&[2]); + peep_tc.tick().mz(&[0, 1, 2, 3]); + + let mut optimized_tc = peep_tc.clone(); + PeepholeOptimize.apply_tick(&mut optimized_tc); + + let style = DiagramStyle::default(); + let r_before = peep_tc.render_with(&style); + let r_after = optimized_tc.render_with(&style); + + html.push_str("
\n"); + html.push_str(&format!( + "

Before pass

{}{}
", + pre_block(&escape_html(&r_before.ascii())), + svg_block(&r_before.svg()), + )); + html.push_str(&format!( + "

After PeepholeOptimize

{}{}
", + pre_block(&escape_html(&r_after.ascii())), + svg_block(&r_after.svg()), + )); + html.push_str("
\n"); + } + + // -- Full pass pipeline comparison -- + html.push_str("

Full Pass Pipeline

\n"); + { + let mut pipe_tc = TickCircuit::new(); + pipe_tc.tick().pz(&[0, 1, 2, 3]); + // Z-diagonal after prep (absorbed) + pipe_tc.tick().t(&[0]).sz(&[1]).cz(&[(2, 3)]); + // Mergeable rotations + pipe_tc.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + pipe_tc.tick().rz(Angle64::QUARTER_TURN, &[0]).cx(&[(1, 2)]); + // Cancellable pair + pipe_tc.tick().h(&[1]); + // Z-diagonal before measure (absorbed) + pipe_tc.tick().tdg(&[2]).sz(&[3]); + pipe_tc.tick().mz(&[0, 1, 2, 3]); + + let pipeline = PassPipeline::new() + .then(AbsorbBasisGates) + .then(MergeAdjacentRotations) + .then(RemoveIdentity) + .then(SimplifyRotations) + .then(CancelInverses) + .then(PeepholeOptimize) + .then(CompactTicks); + + let mut optimized_tc = pipe_tc.clone(); + pipeline.apply_tick(&mut optimized_tc); + + let style = DiagramStyle::default(); + let r_before = pipe_tc.render_with(&style); + let r_after = optimized_tc.render_with(&style); + + html.push_str("
\n"); + html.push_str(&format!( + "

Before pipeline

{}{}
", + pre_block(&escape_html(&r_before.ascii())), + svg_block(&r_before.svg()), + )); + html.push_str(&format!( + "

After pipeline

{}{}
", + pre_block(&escape_html(&r_after.ascii())), + svg_block(&r_after.svg()), + )); + html.push_str("
\n"); + } + + // -- Operator example -- + html.push_str("

Operator Algebra

\n"); + { + use pecos_core::operator::{CX, H, T}; + let circuit = T(1) * CX(0, 1) * H(0); + let op_renderer = circuit.render_with(2, &default_style); + html.push_str(&format!( + "

T(1) * CX(0,1) * H(0)

\n{}{}", + pre_block(&escape_html(&op_renderer.ascii())), + svg_block(&op_renderer.svg()), + )); + } + + // -- Overlapping multi-qubit gates -- + html.push_str("

Overlapping Multi-qubit Gates (Sub-column Splitting)

\n"); + { + let mut overlap_tc = TickCircuit::new(); + overlap_tc.tick().h(&[0, 1, 2, 3]); + let mut t = overlap_tc.tick(); + t.cx(&[(0, 2)]); + t.cz(&[(1, 3)]); + overlap_tc.tick().mz(&[0, 1, 2, 3]); + + let r = overlap_tc.render_with(&default_style); + html.push_str("

CX(0,2) and CZ(1,3) in the same tick have overlapping visual ranges, \ + so they are split into separate sub-columns with a bracket annotation.

\n"); + html.push_str(&format!( + "{}{}", + pre_block(&escape_html(&r.ascii())), + svg_block(&r.svg()), + )); + } + + // ================================================================ + // Graph State Visualization + // ================================================================ + + html.push_str( + "

\ + Graph State Visualization

\n", + ); + html.push_str("

Graph states visualized with the PECOS color algebra: \ + fill hue = axis permutation coset, brightness = sign parity, \ + stroke = gate family. All formats share the same GraphStyle palette.

\n"); + + let gs_default = GraphStyle::default(); + + // -- Pattern gallery -- + html.push_str("

Graph State Patterns

\n
\n"); + + let patterns: &[(&str, GraphState)] = &[ + ("Linear Cluster (5)", GraphState::linear_cluster(5)), + ("Ring (6)", GraphState::ring(6)), + ("Star (5)", GraphState::star(5)), + ("Complete K4", GraphState::complete(4)), + ("2D Lattice (2x3)", GraphState::lattice_2d(2, 3)), + ]; + + for (label, gs) in patterns { + html.push_str(&format!( + "

{label}

{}{}
", + pre_block(&escape_html(&gs.to_ascii())), + svg_block(&gs.render_with(&gs_default).svg()), + )); + } + html.push_str("
\n"); + + // -- Graph state with non-identity VOPs -- + html.push_str("

Graph States with VOPs

\n"); + html.push_str( + "

When local Cliffords (VOPs) are applied to vertices, \ + the fill color encodes the axis permutation coset and \ + the stroke encodes the gate family.

\n", + ); + { + use pecos_qsim::clifford_frame::CliffordFrame; + + let mut gs = GraphState::ring(6); + gs.set_vop(0, CliffordFrame::H); // H-like, X<->Z coset + gs.set_vop(1, CliffordFrame::SZ); // S-like, X<->Y coset + gs.set_vop(2, CliffordFrame::SX); // S-like, Y<->Z coset + gs.set_vop(4, CliffordFrame::from_index(7)); // F-like, cyclic fwd + gs.set_vop(5, CliffordFrame::from_index(8)); // F-like, cyclic inv + + html.push_str("
\n"); + html.push_str(&format!( + "

ASCII

{}
", + pre_block(&escape_html(&gs.to_ascii())), + )); + html.push_str(&format!( + "

Color ASCII

{}
", + pre_block(&ansi_to_html(&gs.to_color_ascii())), + )); + html.push_str(&format!( + "

Unicode

{}
", + pre_block(&escape_html(&gs.to_unicode())), + )); + html.push_str(&format!( + "

Color Unicode

{}
", + pre_block(&ansi_to_html(&gs.to_color_unicode())), + )); + html.push_str("
\n"); + + let r = gs.render_with(&gs_default); + html.push_str("
\n"); + html.push_str(&format!("

SVG

{}
", svg_block(&r.svg()),)); + html.push_str(&format!( + "

DOT

{}
", + code_block("DOT", &r.dot()), + )); + html.push_str("
\n"); + html.push_str(§ion("TikZ", &code_block("TikZ", &r.tikz()))); + } + + // -- All 24 Cliffords showcase -- + html.push_str("

All 24 Single-Qubit Cliffords

\n"); + html.push_str( + "

Each vertex has a different Clifford VOP, showing the full \ + color algebra: 5 coset hues, 2 brightness levels (saturated/light), \ + 4 gate-family strokes.

\n", + ); + { + use pecos_qsim::clifford_frame::CliffordFrame; + + // Build a 24-vertex graph with each vertex having a unique Clifford VOP. + // No edges -- just showcasing the VOP colors. + let mut gs = GraphState::new(24); + for i in 0..24 { + gs.set_vop(i, CliffordFrame::from_index(i as u8)); + } + + let r = gs.render_with(&gs_default); + html.push_str(&format!( + "{}{}", + pre_block(&escape_html(&gs.to_ascii())), + svg_block(&r.svg()), + )); + } + + // -- SVG style variations -- + html.push_str("

Graph Style Variations

\n
\n"); + { + use pecos_qsim::clifford_frame::CliffordFrame; + + let mut gs = GraphState::star(5); + gs.set_vop(1, CliffordFrame::H); + gs.set_vop(2, CliffordFrame::SZ); + gs.set_vop(3, CliffordFrame::SX); + gs.set_vop(4, CliffordFrame::from_index(7)); + + // Default + html.push_str(&format!( + "

Default

{}
", + svg_block(&gs.render_with(&gs_default).svg()), + )); + + // Custom palette: warm tones + let warm_palette = ColorPalette { + z_axis: ColorTriplet::new("#FFB0B0", "#AA2222", "#7A1A1A"), + xz_mix: ColorTriplet::new("#E0B0E0", "#882288", "#5A1A5A"), + xy_mix: ColorTriplet::new("#F0E0A0", "#AA8800", "#6A5500"), + yz_mix: ColorTriplet::new("#FFD0B0", "#CC6600", "#884400"), + xyz_mix: ColorTriplet::new("#E0D0C0", "#887766", "#554433"), + ..ColorPalette::default() + }; + let warm_style = GraphStyle::builder().palette(warm_palette).build(); + html.push_str(&format!( + "

Custom Palette (warm)

{}
", + svg_block(&gs.render_with(&warm_style).svg()), + )); + + // Monochrome: varying grey levels, uniform strokes, dashes + patterns + let mono_palette = ColorPalette { + z_axis: ColorTriplet::new("#D8D8D8", "#555555", "#333333"), + xz_mix: ColorTriplet::new("#C4C4C4", "#555555", "#333333"), + xy_mix: ColorTriplet::new("#B0B0B0", "#555555", "#333333"), + yz_mix: ColorTriplet::new("#9C9C9C", "#555555", "#222222"), + xyz_mix: ColorTriplet::new("#888888", "#555555", "#222222"), + ..ColorPalette::default() + }; + let mono_families = FamilyPalette { + pauli: "#555555".to_string(), + s_like: "#555555".to_string(), + h_like: "#555555".to_string(), + f_like: "#555555".to_string(), + }; + let mono_patterns = CosetPatterns { + identity: FillPattern::Solid, + xz_mix: FillPattern::DiagonalUp, + xy_mix: FillPattern::Crosshatch, + yz_mix: FillPattern::Dots, + xyz_mix: FillPattern::HorizontalLines, + }; + let mono_style = GraphStyle::builder() + .palette(mono_palette) + .family_strokes(mono_families) + .show_dashes(true) + .coset_patterns(mono_patterns) + .build(); + html.push_str(&format!( + "

Monochrome (grey + patterns + dashes)

{}
", + svg_block(&gs.render_with(&mono_style).svg()), + )); + + // Custom family strokes + let bold_families = FamilyPalette { + pauli: "#0000AA".to_string(), + s_like: "#00AA00".to_string(), + h_like: "#AA0000".to_string(), + f_like: "#AA00AA".to_string(), + }; + let bold_style = GraphStyle::builder().family_strokes(bold_families).build(); + html.push_str(&format!( + "

Bold Family Strokes

{}
", + svg_block(&gs.render_with(&bold_style).svg()), + )); + } + html.push_str("
\n"); + + // -- Local complementation -- + html.push_str("

Local Complementation

\n"); + html.push_str( + "

Applying local complementation to vertex 0 of a star graph \ + complements edges among its neighbors and updates VOPs.

\n", + ); + { + let gs_before = GraphState::star(5); + let mut gs_after = gs_before.clone(); + gs_after.local_complement(0); + + html.push_str("
\n"); + html.push_str(&format!( + "

Before LC(0)

{}{}
", + pre_block(&escape_html(&gs_before.to_ascii())), + svg_block(&gs_before.render_with(&gs_default).svg()), + )); + html.push_str(&format!( + "

After LC(0)

{}{}
", + pre_block(&escape_html(&gs_after.to_ascii())), + svg_block(&gs_after.render_with(&gs_default).svg()), + )); + html.push_str("
\n"); + } + + html.push_str("\n\n"); + + let path = "/tmp/pecos_style_demo.html"; + fs::write(path, &html).unwrap(); + println!("Written to {path}"); + + // Open in default browser + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("xdg-open").arg(path).spawn(); + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(path).spawn(); + #[cfg(target_os = "windows")] + let _ = std::process::Command::new("cmd") + .args(["/C", "start", "", path]) + .spawn(); +} diff --git a/crates/pecos-quantum/src/circuit_display.rs b/crates/pecos-quantum/src/circuit_display.rs index 5e281ebbf..2236c2865 100644 --- a/crates/pecos-quantum/src/circuit_display.rs +++ b/crates/pecos-quantum/src/circuit_display.rs @@ -19,7 +19,7 @@ //! grid layout and character rendering to //! [`pecos_core::circuit_diagram::CircuitDiagram`]. -use pecos_core::circuit_diagram::{CellColor, CircuitDiagram, DiagramCell, DiagramOptions, GateFamily}; +use pecos_core::circuit_diagram::{AngleUnit, CellColor, CircuitDiagram, DiagramCell, GateFamily}; use pecos_core::gate_type::GateType; use pecos_core::{Gate, QubitId}; use std::collections::BTreeSet; @@ -41,9 +41,9 @@ fn gate_symbol(gate_type: GateType) -> &'static str { GateType::SZdg => "SZdg", GateType::T => "T", GateType::Tdg => "Tdg", - GateType::RX => "Rx", - GateType::RY => "Ry", - GateType::RZ => "Rz", + GateType::RX => "RX", + GateType::RY => "RY", + GateType::RZ => "RZ", GateType::U => "U", GateType::R1XY => "R1XY", GateType::CX => "CX", @@ -70,55 +70,148 @@ fn gate_symbol(gate_type: GateType) -> &'static str { } } -/// Format an angle as a compact string in turns, e.g. `.25` or `.333333`. +/// Format an angle according to the given unit. +fn format_angle(angle: pecos_core::Angle64, unit: AngleUnit) -> String { + match unit { + AngleUnit::Radians => format_angle_radians(angle), + AngleUnit::Turns => format_angle_turns(angle), + } +} + +/// Format an angle as a compact string using pi notation with fractions where +/// possible, e.g. `\u{03C0}/4`, `3\u{03C0}/2`, `\u{03C0}`. +/// +/// The internal fixed-point representation stores angles as `fraction / 2^64` +/// turns, so the coefficient of pi is `fraction / 2^63`. Since the denominator +/// is a power of two, we reduce by extracting trailing zeros to get an exact +/// `p/q` ratio. When the fraction is not "nice" we fall back to decimal radians. +fn format_angle_radians(angle: pecos_core::Angle64) -> String { + let fraction = angle.fraction(); + if fraction == 0 { + return "0".to_string(); + } + + // coefficient of pi = fraction / 2^63 + let k = fraction.trailing_zeros(); // 0..=63 for non-zero u64 + let p = fraction >> k; // numerator (odd, >= 1) + let q_exp = 63_u32.saturating_sub(k); // denominator = 2^q_exp + let q: u64 = 1_u64.checked_shl(q_exp).unwrap_or(0); + + // Only use pi notation if the fraction is "nice". + if q == 0 || q > 128 || p > 512 { + let radians = angle.to_radians(); + return format!("{radians:.4}"); + } + + let pi = '\u{03C0}'; + match (p, q) { + (1, 1) => format!("{pi}"), + (2, 1) => format!("2{pi}"), + (p, 1) => format!("{p}{pi}"), + (1, q) => format!("{pi}/{q}"), + (p, q) => format!("{p}{pi}/{q}"), + } +} + +/// Format an angle as a compact string in turns using fractions where possible, +/// e.g. `1/4`, `3/8`, `1`. Falls back to decimal for non-nice fractions. +/// +/// The internal representation stores angles as `fraction / 2^64` turns. +/// Since the denominator is a power of two we reduce by extracting trailing +/// zeros to get an exact `p/q` ratio. fn format_angle_turns(angle: pecos_core::Angle64) -> String { - let radians = angle.to_radians(); - let turns = radians / std::f64::consts::TAU; - let turns = turns.rem_euclid(1.0); - if (turns - 0.0).abs() < 1e-9 { + let fraction = angle.fraction(); + if fraction == 0 { return "0".to_string(); } - format!(".{}", format!("{turns:.6}").trim_start_matches("0.").trim_end_matches('0')) + + // turns = fraction / 2^64 + let k = fraction.trailing_zeros(); // 0..=63 for non-zero u64 + let p = fraction >> k; // numerator (odd, >= 1) + let q_exp = 64_u32.saturating_sub(k); // denominator = 2^q_exp + let q: u128 = 1_u128.checked_shl(q_exp).unwrap_or(0); + + if q == 0 || q > 128 || p > 512 { + // Fall back to decimal + let turns = angle.to_radians() / std::f64::consts::TAU; + let turns = turns.rem_euclid(1.0); + return format!("{turns:.6}") + .trim_end_matches('0') + .trim_end_matches('.') + .to_string(); + } + + match (p, q) { + (1, 1) => "1".to_string(), + (p, 1) => format!("{p}"), + (1, q) => format!("1/{q}"), + (p, q) => format!("{p}/{q}"), + } } /// Build the full symbol string for a gate, including angles if parameterized. -fn full_gate_symbol(gate: &Gate) -> String { +fn full_gate_symbol(gate: &Gate, unit: AngleUnit) -> String { let base = gate_symbol(gate.gate_type); if gate.angles.is_empty() { return base.to_string(); } - let angle_strs: Vec = gate.angles.iter().copied().map(format_angle_turns).collect(); + let angle_strs: Vec = gate + .angles + .iter() + .copied() + .map(|a| format_angle(a, unit)) + .collect(); format!("{base}({})", angle_strs.join(",")) } // ==================== Color mapping ==================== -/// Map a `GateType` to its diagram color category. +/// Map a `GateType` to its diagram color using the PECOS axis color algebra. fn gate_color(gate_type: GateType) -> CellColor { match gate_type { - GateType::Measure | GateType::MeasureLeaked | GateType::MeasureFree => { - CellColor::Measurement - } - GateType::Prep | GateType::QAlloc | GateType::QFree => CellColor::Preparation, - _ if gate_type.quantum_arity() >= 2 => CellColor::MultiQubit, - GateType::Idle | GateType::I => CellColor::None, - _ => CellColor::SingleQubit, + GateType::X | GateType::RX | GateType::RXX => CellColor::XAxis, + GateType::Y | GateType::RY | GateType::RYY => CellColor::YAxis, + GateType::Z + | GateType::RZ + | GateType::T + | GateType::Tdg + | GateType::RZZ + | GateType::Measure + | GateType::Prep + | GateType::SZZ + | GateType::SZZdg + | GateType::CRZ => CellColor::ZAxis, + GateType::SX | GateType::SXdg => CellColor::YZMix, + GateType::SY | GateType::SYdg | GateType::H | GateType::CH => CellColor::XZMix, + GateType::SZ | GateType::SZdg => CellColor::XYMix, + GateType::Idle + | GateType::I + | GateType::MeasureLeaked + | GateType::MeasureFree + | GateType::QAlloc + | GateType::QFree + | GateType::Custom + | GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload => CellColor::None, + // Multi-qubit gates that don't have a clear single-axis color: + GateType::CX + | GateType::CY + | GateType::CZ + | GateType::CCX + | GateType::SWAP + | GateType::U + | GateType::R1XY => CellColor::None, } } // ==================== Family mapping ==================== /// Map a `GateType` to its diagram family bracket/stroke style. +/// +/// Most gates use `Default` brackets (`[G]`). Only measurement and preparation +/// gates keep their asymmetric brackets (`|MZ)` and `(PZ|`). fn gate_family(gate_type: GateType) -> GateFamily { match gate_type { - GateType::I | GateType::X | GateType::Y | GateType::Z => GateFamily::Pauli, - GateType::SX - | GateType::SXdg - | GateType::SY - | GateType::SYdg - | GateType::SZ - | GateType::SZdg => GateFamily::SLike, - GateType::H => GateFamily::HLike, GateType::Measure | GateType::MeasureLeaked | GateType::MeasureFree => { GateFamily::Measurement } @@ -127,21 +220,125 @@ fn gate_family(gate_type: GateType) -> GateFamily { } } +// ==================== Visual range and sublayer splitting ==================== + +/// Compute the set of rows a gate visually occupies. +/// +/// Single-qubit gates occupy only their target rows. Multi-qubit gates occupy +/// `min_row..=max_row` (all intermediate rows included) because the vertical +/// connector line passes through them. +fn compute_visual_range( + gate: &Gate, + qubit_to_row: &std::collections::BTreeMap, +) -> BTreeSet { + let rows: Vec = gate + .qubits + .iter() + .filter_map(|q| qubit_to_row.get(q).copied()) + .collect(); + if rows.is_empty() { + return BTreeSet::new(); + } + let arity = gate.gate_type.quantum_arity(); + if arity <= 1 { + rows.into_iter().collect() + } else { + let min = *rows.iter().min().unwrap(); + let max = *rows.iter().max().unwrap(); + (min..=max).collect() + } +} + +/// Split a layer of gates into sublayers such that no two gates in the same +/// sublayer have overlapping visual row ranges. +/// +/// Uses a first-fit algorithm with gates sorted by their minimum visual row. +/// For interval graphs (contiguous visual ranges, which all multi-qubit gates +/// produce), sorting by left endpoint guarantees an optimal split that uses the +/// minimum number of sublayers (equal to the maximum clique size). Without the +/// sort, insertion order can produce unnecessary extra sublayers. +fn split_layer_into_sublayers<'a>( + layer: &[&'a Gate], + qubit_to_row: &std::collections::BTreeMap, +) -> Vec> { + // Compute visual ranges and sort by minimum row for optimal coloring. + let mut gates_with_range: Vec<(&'a Gate, BTreeSet)> = layer + .iter() + .map(|&gate| (gate, compute_visual_range(gate, qubit_to_row))) + .collect(); + gates_with_range.sort_by_key(|(_, range)| range.iter().next().copied().unwrap_or(0)); + + let mut sublayers: Vec<(BTreeSet, Vec<&'a Gate>)> = Vec::new(); + + for (gate, visual_range) in gates_with_range { + let mut placed = false; + for (occupied, gates) in &mut sublayers { + if occupied.is_disjoint(&visual_range) { + occupied.extend(&visual_range); + gates.push(gate); + placed = true; + break; + } + } + if !placed { + sublayers.push((visual_range, vec![gate])); + } + } + + sublayers.into_iter().map(|(_, gates)| gates).collect() +} + // ==================== Grid building ==================== +/// Place a single gate's decomposed cells and connectors into a diagram. +fn place_gate( + gate: &Gate, + diagram: &mut CircuitDiagram, + qubit_to_row: &std::collections::BTreeMap, + num_rows: usize, + angle_unit: AngleUnit, +) { + let decomposed = decompose_gate(gate, qubit_to_row, num_rows, angle_unit); + for (row, cell, color) in decomposed.cells { + if row < num_rows { + diagram.set_cell(row, cell, color); + } + } + if let Some((top, bottom)) = decomposed.connector { + if let Some(label) = decomposed.connector_label { + diagram.add_labeled_connector(top, bottom, label); + } else { + diagram.add_connector(top, bottom); + } + } +} + +/// Result of decomposing a gate: cells and optional connector span. +struct DecomposedGate { + cells: Vec<(usize, DiagramCell, CellColor)>, + /// Vertical connector span `(top_row, bottom_row)` if this is a multi-qubit gate. + connector: Option<(usize, usize)>, + /// Optional label to display on the connector line (for symmetric two-qubit gates). + connector_label: Option, +} + /// Decompose a single `Gate` into per-row cell assignments. fn decompose_gate( gate: &Gate, qubit_to_row: &std::collections::BTreeMap, num_rows: usize, -) -> Vec<(usize, DiagramCell, CellColor)> { + angle_unit: AngleUnit, +) -> DecomposedGate { let arity = gate.gate_type.quantum_arity(); let qubits = &gate.qubits; let mut cells = Vec::new(); + let color = gate_color(gate.gate_type); + let mut connector = None; + let mut connector_label = None; if arity == 1 { - let sym = full_gate_symbol(gate); + let sym = full_gate_symbol(gate, angle_unit); let family = gate_family(gate.gate_type); for &q in qubits { if let Some(&row) = qubit_to_row.get(&q) { @@ -149,7 +346,7 @@ fn decompose_gate( } } } else if arity == 2 { - let sym = full_gate_symbol(gate); + let sym = full_gate_symbol(gate, angle_unit); for pair in qubits.chunks(2) { if pair.len() < 2 { continue; @@ -174,7 +371,7 @@ fn decompose_gate( cells.push(( row_b, DiagramCell::Gate("X".to_string(), GateFamily::Default), - CellColor::MultiQubit, + CellColor::XAxis, )); } GateType::CY => { @@ -182,7 +379,7 @@ fn decompose_gate( cells.push(( row_b, DiagramCell::Gate("Y".to_string(), GateFamily::Default), - CellColor::MultiQubit, + CellColor::YAxis, )); } GateType::CZ => { @@ -194,21 +391,28 @@ fn decompose_gate( cells.push(( row_b, DiagramCell::Gate("H".to_string(), GateFamily::Default), - CellColor::MultiQubit, + CellColor::XZMix, )); } GateType::SWAP => { cells.push(( row_a, DiagramCell::Gate("x".to_string(), GateFamily::Default), - CellColor::MultiQubit, + CellColor::None, )); cells.push(( row_b, DiagramCell::Gate("x".to_string(), GateFamily::Default), - CellColor::MultiQubit, + CellColor::None, )); } + // Symmetric two-qubit interactions: dots on both wires, + // label on the connector line between them. + GateType::RXX | GateType::RYY | GateType::RZZ | GateType::SZZ | GateType::SZZdg => { + cells.push((row_a, DiagramCell::Control, color)); + cells.push((row_b, DiagramCell::Control, color)); + connector_label = Some(sym.clone()); + } _ => { let family = gate_family(gate.gate_type); cells.push((row_a, DiagramCell::Gate(sym.clone(), family), color)); @@ -216,10 +420,12 @@ fn decompose_gate( } } + connector = Some((top, bottom)); + // Intermediate rows: crossings on qubit wires. for row in (top + 1)..bottom { if row < num_rows { - cells.push((row, DiagramCell::Crossing, CellColor::MultiQubit)); + cells.push((row, DiagramCell::Crossing, CellColor::None)); } } } @@ -245,19 +451,25 @@ fn decompose_gate( cells.push(( rows[2], DiagramCell::Gate("X".to_string(), GateFamily::Default), - CellColor::MultiQubit, + CellColor::XAxis, )); + connector = Some((top, bottom)); + let gate_rows: BTreeSet = rows.iter().copied().collect(); for row in (top + 1)..bottom { if !gate_rows.contains(&row) && row < num_rows { - cells.push((row, DiagramCell::Crossing, CellColor::MultiQubit)); + cells.push((row, DiagramCell::Crossing, CellColor::None)); } } } } - cells + DecomposedGate { + cells, + connector, + connector_label, + } } // ==================== Diagram building ==================== @@ -265,7 +477,7 @@ fn decompose_gate( /// Build a `CircuitDiagram` from gate layers. /// /// Returns `None` when `layers` contain no qubits. -fn build_diagram(layers: &[Vec<&Gate>]) -> Option { +fn build_diagram(layers: &[Vec<&Gate>], angle_unit: AngleUnit) -> Option { let mut qubit_set = BTreeSet::new(); for layer in layers { for gate in layer { @@ -279,87 +491,53 @@ fn build_diagram(layers: &[Vec<&Gate>]) -> Option { return None; } - let qubit_to_row: std::collections::BTreeMap = qubits - .iter() - .enumerate() - .map(|(i, &q)| (q, i)) - .collect(); + let qubit_to_row: std::collections::BTreeMap = + qubits.iter().enumerate().map(|(i, &q)| (q, i)).collect(); let num_rows = qubits.len(); let labels: Vec = qubits.iter().map(|q| format!("q{}", q.0)).collect(); let mut diagram = CircuitDiagram::with_labels(labels); for (layer_idx, layer) in layers.iter().enumerate() { - if layer_idx > 0 { - diagram.advance(); - } - for gate in layer { - let entries = decompose_gate(gate, &qubit_to_row, num_rows); - for (row, cell, color) in entries { - if row < num_rows { - diagram.set_cell(row, cell, color); - } + if layer.is_empty() { + if layer_idx > 0 { + diagram.advance(); } + continue; } - } - Some(diagram) -} + let sublayers = split_layer_into_sublayers(layer, &qubit_to_row); + let is_split = sublayers.len() > 1; + let mut start_col = 0; -// ==================== Public rendering entry points ==================== - -/// Format a circuit as a text wire diagram. -/// -/// `header` - text for the first line (e.g. "`TickCircuit`: 3 qubits, 4 ticks"). -/// `layers` - each element is a slice of gates that execute in parallel. -/// `options` - rendering options (symbol set, color). -pub(crate) fn format_circuit( - header: &str, - layers: &[Vec<&Gate>], - options: &DiagramOptions, -) -> String { - match build_diagram(layers) { - Some(diagram) => diagram.render(header, options), - None => format!("{header}\n"), - } -} - -/// Format a circuit as an SVG wire diagram. -pub(crate) fn format_circuit_svg(header: &str, layers: &[Vec<&Gate>]) -> String { - match build_diagram(layers) { - Some(diagram) => diagram.render_svg(header), - None => format!("{header}"), - } -} + for (sub_idx, sublayer) in sublayers.iter().enumerate() { + if layer_idx > 0 || sub_idx > 0 { + diagram.advance(); + } + if sub_idx == 0 && is_split { + start_col = diagram.current_col(); + } + for gate in sublayer { + place_gate(gate, &mut diagram, &qubit_to_row, num_rows, angle_unit); + } + } -/// Format a circuit as a `TikZ` `tikzpicture`. -pub(crate) fn format_circuit_tikz(header: &str, layers: &[Vec<&Gate>]) -> String { - if let Some(diagram) = build_diagram(layers) { - diagram.render_tikz(header) - } else { - let mut out = String::new(); - if !header.is_empty() { - use std::fmt::Write; - writeln!(out, "% {header}").unwrap(); + if is_split { + let end_col = diagram.current_col(); + diagram.add_column_group(format!("t{layer_idx}"), start_col, end_col); } - out.push_str("\\begin{tikzpicture}\n\\end{tikzpicture}\n"); - out } + + Some(diagram) } -/// Format a circuit as a Graphviz DOT digraph. -pub(crate) fn format_circuit_dot(header: &str, layers: &[Vec<&Gate>]) -> String { - if let Some(diagram) = build_diagram(layers) { - diagram.render_dot(header) - } else { - let mut out = String::from("digraph circuit {\n rankdir=LR;\n"); - if !header.is_empty() { - use std::fmt::Write; - writeln!(out, " label=\"{header}\";").unwrap(); - } - out.push_str("}\n"); - out - } +/// Build a `CircuitDiagram` from layers, returning an empty 0-qubit diagram if +/// there are no qubits. Used by `render_with` on `TickCircuit`/`DagCircuit`. +pub(crate) fn build_diagram_or_empty( + layers: &[Vec<&Gate>], + angle_unit: AngleUnit, +) -> CircuitDiagram { + build_diagram(layers, angle_unit).unwrap_or_else(|| CircuitDiagram::new(0)) } #[cfg(test)] @@ -389,10 +567,10 @@ mod tests { assert!(out.contains("q1:")); let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); - assert!(q0_line.contains("")); - assert!(!q0_line.contains("(X)")); - assert!(q1_line.contains("(X)")); - assert!(!q1_line.contains("")); + assert!(q0_line.contains("[H]")); + assert!(!q0_line.contains("[X]")); + assert!(q1_line.contains("[X]")); + assert!(!q1_line.contains("[H]")); } #[test] @@ -425,11 +603,56 @@ mod tests { #[test] fn parameterized_gate_includes_angle() { + // Use a non-special angle (pi/8) that won't be simplified to a named gate. let out = render_tick(|tc| { - tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::from_turn_ratio(1, 16), &[0]); }); - assert!(out.contains("Rz(")); - assert!(out.contains(".25")); + assert!(out.contains("RZ(")); + assert!(out.contains("\u{03C0}/8")); + } + + #[test] + fn angle_format_common_fractions() { + // pi (half turn) + let out = render_tick(|tc| { + tc.tick().rz(Angle64::HALF_TURN, &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!( + q0.contains("\u{03C0})"), + "half turn should show as pi: {q0}" + ); + assert!( + !q0.contains('/'), + "half turn should not have a denominator: {q0}" + ); + + // pi/4 (eighth turn) + let out = render_tick(|tc| { + tc.tick().rz(Angle64::from_turn_ratio(1, 8), &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!( + q0.contains("\u{03C0}/4"), + "eighth turn should show as pi/4: {q0}" + ); + + // 3pi/4 + let out = render_tick(|tc| { + tc.tick().rz(Angle64::from_turn_ratio(3, 8), &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!( + q0.contains("3\u{03C0}/4"), + "3/8 turn should show as 3pi/4: {q0}" + ); + + // zero + let out = render_tick(|tc| { + tc.tick().rz(Angle64::ZERO, &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!(q0.contains("(0)"), "zero should show as 0: {q0}"); } #[test] @@ -518,7 +741,7 @@ mod tests { tc.tick().mz(&[0]); }); assert!(out.contains("(PZ|")); - assert!(out.contains("")); + assert!(out.contains("[H]")); assert!(out.contains("|MZ)")); } @@ -530,9 +753,9 @@ mod tests { let q0_line = out.lines().find(|l| l.starts_with("q0:")).unwrap(); let q1_line = out.lines().find(|l| l.starts_with("q1:")).unwrap(); let q2_line = out.lines().find(|l| l.starts_with("q2:")).unwrap(); - assert!(q0_line.contains("")); - assert!(q1_line.contains("")); - assert!(q2_line.contains("")); + assert!(q0_line.contains("[H]")); + assert!(q1_line.contains("[H]")); + assert!(q2_line.contains("[H]")); } #[test] @@ -552,16 +775,6 @@ mod tests { assert!(out.contains('\u{25CF}')); // ● } - #[test] - fn to_ascii_color_deprecated_alias() { - let mut tc = crate::TickCircuit::new(); - tc.tick().h(&[0]); - #[allow(deprecated)] - let a = tc.to_ascii_color(); - let b = tc.to_color_ascii(); - assert_eq!(a, b); - } - // ====================== SVG integration ====================== #[test] @@ -656,9 +869,9 @@ mod tests { tc.tick().mz(&[0]); }); assert!(out.contains("(PZ|")); // Preparation - assert!(out.contains("")); // HLike - assert!(out.contains("[SX]")); // SLike - assert!(out.contains("(X)")); // Pauli + assert!(out.contains("[H]")); // Default + assert!(out.contains("[SX]")); // Default + assert!(out.contains("[X]")); // Default assert!(out.contains("|MZ)")); // Measurement } @@ -672,25 +885,289 @@ mod tests { dag.mz(0); let out = dag.to_ascii(); assert!(out.contains("(PZ|")); - assert!(out.contains("")); + assert!(out.contains("[H]")); assert!(out.contains("[SX]")); - assert!(out.contains("(X)")); + assert!(out.contains("[X]")); assert!(out.contains("|MZ)")); } #[test] - fn svg_hlike_has_dotted_stroke() { + fn svg_gates_have_solid_strokes() { let mut tc = crate::TickCircuit::new(); tc.tick().h(&[0]); + tc.tick().sz(&[0]); let svg = tc.to_svg(); - assert!(svg.contains("stroke-dasharray=\"2,2\"")); + // All gates now use Default family with solid strokes (no dasharray). + assert!(!svg.contains("stroke-dasharray")); } + // ==================== render_with tests ==================== + #[test] - fn svg_slike_has_dashed_stroke() { + fn tick_render_with_default_matches_to_ascii() { let mut tc = crate::TickCircuit::new(); - tc.tick().sz(&[0]); - let svg = tc.to_svg(); - assert!(svg.contains("stroke-dasharray=\"4,3\"")); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + let style = pecos_core::circuit_diagram::DiagramStyle::default(); + let via_render_with = tc.render_with(&style).text(); + let via_to_ascii = tc.to_ascii(); + assert_eq!(via_render_with, via_to_ascii); + } + + #[test] + fn tick_render_with_custom_palette_svg() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + let style = pecos_core::circuit_diagram::DiagramStyle::builder() + .xz_mix("#AABBCC", "#112233", "#445566") + .build(); + let svg = tc.render_with(&style).svg(); + // H is XZMix, so the custom colors should appear. + assert!(svg.contains("#AABBCC")); + assert!(svg.contains("#112233")); + } + + #[test] + fn tick_render_with_monochrome() { + let mut tc = crate::TickCircuit::new(); + tc.tick().x(&[0]); + let style = pecos_core::circuit_diagram::DiagramStyle::builder() + .color(false) + .build(); + let svg = tc.render_with(&style).svg(); + // XAxis color should NOT appear. + assert!(!svg.contains("#FFB0B0")); + } + + #[test] + fn dag_render_with_default_matches_to_ascii() { + let mut dag = crate::DagCircuit::new(); + dag.h(0); + dag.cx(0, 1); + let style = pecos_core::circuit_diagram::DiagramStyle::default(); + let via_render_with = dag.render_with(&style).text(); + let via_to_ascii = dag.to_ascii(); + assert_eq!(via_render_with, via_to_ascii); + } + + #[test] + fn tick_render_with_ascii_and_unicode() { + let mut tc = crate::TickCircuit::new(); + tc.tick().h(&[0]); + let style = pecos_core::circuit_diagram::DiagramStyle::default(); + let r = tc.render_with(&style); + let ascii = r.ascii(); + let unicode = r.unicode(); + assert!(ascii.contains('-')); + assert!(unicode.contains('\u{2500}')); + } + + // ==================== Rotation display tests ==================== + + #[test] + fn rotation_displays_angle_faithfully() { + // Visualizer should display the rotation gate as-is (no simplification). + let out = render_tick(|tc| { + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!(q0.contains("RZ("), "should show RZ label: {q0}"); + assert!(q0.contains("\u{03C0}/2"), "should show angle: {q0}"); + } + + #[test] + fn non_special_angle_displays_rotation() { + let out = render_tick(|tc| { + tc.tick().rz(Angle64::from_turn_ratio(1, 6), &[0]); + }); + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + assert!( + q0.contains("RZ("), + "non-special angle should keep RZ label: {q0}" + ); + } + + #[test] + fn rzz_displays_as_symmetric_gate() { + let out = render_tick(|tc| { + let eighth = Angle64::QUARTER_TURN / 2u64; + tc.tick().rzz(eighth, &[(0, 1)]); + }); + assert!( + out.contains("[RZZ("), + "RZZ should show bracketed label: {out}" + ); + } + + // ==================== Sub-column splitting tests ==================== + + #[test] + fn overlapping_cx_cz_splits_into_two_columns() { + let out = render_tick(|tc| { + tc.tick().h(&[0, 1, 2, 3]); + let mut t = tc.tick(); + t.cx(&[(0, 2)]); + t.cz(&[(1, 3)]); + tc.tick().mz(&[0, 1, 2, 3]); + }); + // Both gates should be visible (no overwriting). + assert!(out.contains("[X]"), "CX target should be visible: {out}"); + let dot_count = out.matches('.').count(); + assert!( + dot_count >= 3, + "should have control dots for CX and CZ: {out}" + ); + // Bracket annotation should be present. + assert!( + out.contains("t1"), + "bracket label for tick 1 should appear: {out}" + ); + } + + #[test] + fn non_overlapping_gates_stay_in_one_column() { + let out = render_tick(|tc| { + let mut t = tc.tick(); + t.cx(&[(0, 1)]); + t.cz(&[(2, 3)]); + }); + // No bracket annotation since no splitting needed. + assert!( + !out.contains("|--"), + "should not have bracket dashes: {out}" + ); + } + + #[test] + fn single_qubit_gates_never_split() { + let out = render_tick(|tc| { + tc.tick().h(&[0]).x(&[1]).z(&[2]); + }); + // No bracket annotation for single-qubit gates in the same tick. + assert!( + !out.contains("|--"), + "should not have bracket dashes: {out}" + ); + } + + #[test] + fn chain_overlap_uses_optimal_two_sublayers() { + // Chain pattern: CZ(0,2)-CX(1,4)-CX(3,6)-CZ(5,7) + // Max clique = 2, so optimal split is 2 sub-columns. + // Without sorting by min row, naive first-fit with this insertion + // order would produce 3: CZ(0,2)+CZ(5,7) in bin 1, then CX(1,4) + // conflicts bin 1 -> bin 2, then CX(3,6) conflicts both -> bin 3. + let out = render_tick(|tc| { + tc.tick().h(&[0, 1, 2, 3, 4, 5, 6, 7]); + let mut t = tc.tick(); + // Deliberately add in worst-case order for naive greedy. + t.cz(&[(0, 2)]); + t.cz(&[(5, 7)]); + t.cx(&[(1, 4)]); + t.cx(&[(3, 6)]); + }); + // Count the bracket dashes in the annotation line to determine the + // number of sub-columns. With 2 sub-columns we get one bracket group + // spanning 2 columns. With 3 we would get a wider span. + // Verify only one bracket group (one "t1" label). + let bracket_lines: Vec<&str> = out.lines().filter(|l| l.contains("t1")).collect(); + assert_eq!( + bracket_lines.len(), + 1, + "should have exactly one bracket: {out}" + ); + + // Count diagram columns: with optimal splitting, the tick should use + // exactly 2 sub-columns. Count distinct column positions by looking + // at how many gate symbols appear on the first qubit wire. + let q0 = out.lines().find(|l| l.starts_with("q0:")).unwrap(); + let q1 = out.lines().find(|l| l.starts_with("q1:")).unwrap(); + // q0 has H in tick 0, then control dot (.) in the overlap tick. + // q1 has H in tick 0, then either crossing or control in the overlap tick. + // All 4 gates should be visible. + assert!(out.contains("[X]"), "CX target should be visible: {out}"); + // Count control dots: CZ(0,2) has 2 dots, CZ(5,7) has 2 dots, + // CX(1,4) has 1 dot, CX(3,6) has 1 dot = 6 total. + let dot_count = out.matches('.').count(); + assert!( + dot_count >= 6, + "all 6 control dots should be visible (got {dot_count}): {out}" + ); + + // Verify 2 sub-columns, not 3: count the column separators in the + // bracket line. A 2-sub-column bracket has the pattern |---t1---| + // spanning 2 column widths. A 3-sub-column bracket would be wider. + // More direct check: count how many [H] appear on q0. + let h_on_q0 = q0.matches("[H]").count(); + let dots_on_q0 = q0.matches('.').count(); + assert_eq!(h_on_q0, 1, "q0 should have one H: {q0}"); + assert_eq!(dots_on_q0, 1, "q0 should have one control dot: {q0}"); + // q1 should have H and either a crossing or a control + let h_on_q1 = q1.matches("[H]").count(); + assert_eq!(h_on_q1, 1, "q1 should have one H: {q1}"); + } + + #[test] + fn overlapping_rzz_szz_splits_correctly() { + let out = render_tick(|tc| { + let quarter = Angle64::QUARTER_TURN; + let mut t = tc.tick(); + t.rzz(quarter, &[(0, 2)]); + t.szz(&[(1, 3)]); + }); + // Both gates should be visible after splitting. + assert!(out.contains("t0"), "bracket should appear: {out}"); + // RZZ label should appear in a connector row. + assert!( + out.contains("[RZZ(") || out.contains("RZZ("), + "RZZ label should be visible: {out}" + ); + // SZZ label should appear. + assert!( + out.contains("[SZZ]") || out.contains("SZZ"), + "SZZ label should be visible: {out}" + ); + } + + #[test] + fn non_adjacent_rzz_renders_label() { + // RZZ spanning 3 rows (1 intermediate qubit). + let out = render_tick(|tc| { + let quarter = Angle64::QUARTER_TURN; + tc.tick().h(&[0, 1, 2]); + tc.tick().rzz(quarter, &[(0, 2)]); + }); + assert!(out.contains("RZZ("), "RZZ label should be visible: {out}"); + + // RZZ spanning 5 rows (3 intermediate qubits). + let out = render_tick(|tc| { + let quarter = Angle64::QUARTER_TURN; + tc.tick().h(&[0, 1, 2, 3, 4]); + tc.tick().rzz(quarter, &[(0, 4)]); + }); + assert!( + out.contains("RZZ("), + "RZZ label should be visible on wide span: {out}" + ); + } + + #[test] + fn three_mutually_overlapping_gates_use_three_sublayers() { + // Three gates that all pairwise overlap: need 3 sub-columns. + let out = render_tick(|tc| { + let mut t = tc.tick(); + t.cx(&[(0, 3)]); + t.cz(&[(1, 4)]); + t.cx(&[(2, 5)]); + }); + // All three gates pairwise overlap (ranges {0..3}, {1..4}, {2..5}), + // so max clique = 3, requiring 3 sub-columns. + assert!(out.contains("t0"), "bracket should appear: {out}"); + // All gates should be visible. + let dot_count = out.matches('.').count(); + assert!( + dot_count >= 4, + "should have control dots for all gates (got {dot_count}): {out}" + ); } } diff --git a/crates/pecos-quantum/src/dag_circuit.rs b/crates/pecos-quantum/src/dag_circuit.rs index d332d02d4..c817420d8 100644 --- a/crates/pecos-quantum/src/dag_circuit.rs +++ b/crates/pecos-quantum/src/dag_circuit.rs @@ -801,7 +801,8 @@ impl DagCircuit { /// Horizontal qubit wires with gate symbols placed at each layer column. #[must_use] pub fn to_ascii(&self) -> String { - self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::ascii()) + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .ascii() } /// ASCII circuit diagram with ANSI color codes. @@ -811,58 +812,74 @@ impl DagCircuit { /// measurements, cyan for preparations. #[must_use] pub fn to_color_ascii(&self) -> String { - self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::color_ascii()) + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .ansi_color(true) + .build(), + ) + .ascii() } /// Unicode circuit diagram with box-drawing characters. #[must_use] pub fn to_unicode(&self) -> String { - self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::unicode()) + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .symbols(pecos_core::circuit_diagram::SymbolSet::Unicode) + .build(), + ) + .unicode() } /// Unicode circuit diagram with ANSI color codes. #[must_use] pub fn to_color_unicode(&self) -> String { - self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::color_unicode()) + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .symbols(pecos_core::circuit_diagram::SymbolSet::Unicode) + .ansi_color(true) + .build(), + ) + .unicode() } /// Export as an SVG circuit diagram. #[must_use] pub fn to_svg(&self) -> String { - let (header, layers) = self.diagram_parts(); - crate::circuit_display::format_circuit_svg(&header, &layers) + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .svg() } /// Export as a `TikZ` `tikzpicture`. #[must_use] pub fn to_tikz(&self) -> String { - let (header, layers) = self.diagram_parts(); - crate::circuit_display::format_circuit_tikz(&header, &layers) + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .tikz() } /// Export as a Graphviz DOT digraph. #[must_use] pub fn to_dot(&self) -> String { - let (header, layers) = self.diagram_parts(); - crate::circuit_display::format_circuit_dot(&header, &layers) + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .dot() } - /// Deprecated: use [`to_color_ascii`](Self::to_color_ascii) instead. - #[deprecated(note = "renamed to to_color_ascii")] + /// Create a [`DiagramRenderer`](pecos_core::circuit_diagram::DiagramRenderer) + /// bound to a custom [`DiagramStyle`](pecos_core::circuit_diagram::DiagramStyle). #[must_use] - pub fn to_ascii_color(&self) -> String { - self.to_color_ascii() + pub fn render_with<'a>( + &self, + style: &'a pecos_core::circuit_diagram::DiagramStyle, + ) -> pecos_core::circuit_diagram::DiagramRenderer<'a> { + let (header, layers) = self.diagram_parts(); + let diagram = crate::circuit_display::build_diagram_or_empty(&layers, style.angle_unit); + pecos_core::circuit_diagram::DiagramRenderer::new(diagram, header, style) } fn diagram_parts(&self) -> (String, Vec>) { let layers: Vec> = self .layers() - .map(|node_ids| { - node_ids - .iter() - .filter_map(|&id| self.gate(id)) - .collect() - }) + .map(|node_ids| node_ids.iter().filter_map(|&id| self.gate(id)).collect()) .collect(); let num_qubits = self.qubits().len(); let num_layers = layers.len(); @@ -876,11 +893,6 @@ impl DagCircuit { (header, layers) } - fn format_diagram(&self, options: &pecos_core::circuit_diagram::DiagramOptions) -> String { - let (header, layers) = self.diagram_parts(); - crate::circuit_display::format_circuit(&header, &layers, options) - } - /// Returns the root gates (gates with no incoming wires). #[must_use] pub fn roots(&self) -> Vec { diff --git a/crates/pecos-quantum/src/lib.rs b/crates/pecos-quantum/src/lib.rs index ea4a4f320..92228067f 100644 --- a/crates/pecos-quantum/src/lib.rs +++ b/crates/pecos-quantum/src/lib.rs @@ -67,6 +67,7 @@ mod circuit; mod circuit_display; mod dag_circuit; pub mod operator_matrix; +pub mod pass; mod tick_circuit; pub mod tick_circuit_soa; diff --git a/crates/pecos-quantum/src/operator_matrix.rs b/crates/pecos-quantum/src/operator_matrix.rs index d8eaa358c..62b1c52cf 100644 --- a/crates/pecos-quantum/src/operator_matrix.rs +++ b/crates/pecos-quantum/src/operator_matrix.rs @@ -241,7 +241,14 @@ pub fn operators_equiv_with_tolerance(a: &Operator, b: &Operator, tol: f64) -> b } /// Checks if two matrices are equal up to a global phase factor. -fn matrices_equiv_up_to_phase(a: &DMatrix, b: &DMatrix, tol: f64) -> bool { +/// +/// Returns `true` if A = e^{i*phi} * B for some real phi, within the given tolerance. +#[must_use] +pub fn matrices_equiv_up_to_phase( + a: &DMatrix, + b: &DMatrix, + tol: f64, +) -> bool { if a.nrows() != b.nrows() || a.ncols() != b.ncols() { return false; } diff --git a/crates/pecos-quantum/src/pass.rs b/crates/pecos-quantum/src/pass.rs new file mode 100644 index 000000000..8e0cbe9c8 --- /dev/null +++ b/crates/pecos-quantum/src/pass.rs @@ -0,0 +1,3116 @@ +// 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. + +//! Circuit transformation passes. +//! +//! Passes are explicit transformations applied to circuits before display or +//! simulation. Each pass implements [`CircuitPass`] and can modify both +//! [`TickCircuit`] and [`DagCircuit`] in place. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use pecos_core::gate_type::GateType; +use pecos_core::{Angle64, Gate, GateQubits, QubitId}; + +use crate::{Attribute, DagCircuit, TickCircuit}; + +/// A transformation pass that can be applied to circuits. +pub trait CircuitPass { + /// Apply this pass to a [`TickCircuit`]. + fn apply_tick(&self, circuit: &mut TickCircuit); + /// Apply this pass to a [`DagCircuit`]. + fn apply_dag(&self, circuit: &mut DagCircuit); +} + +/// An ordered collection of passes applied sequentially. +/// +/// `PassPipeline` itself implements [`CircuitPass`], so pipelines can be +/// nested inside other pipelines. +/// +/// # Examples +/// +/// ``` +/// use pecos_quantum::pass::*; +/// +/// let pipeline = PassPipeline::new() +/// .then(AbsorbBasisGates) +/// .then(MergeAdjacentRotations) +/// .then(RemoveIdentity) +/// .then(SimplifyRotations) +/// .then(CancelInverses) +/// .then(PeepholeOptimize); +/// ``` +pub struct PassPipeline { + passes: Vec>, +} + +impl PassPipeline { + /// Create an empty pipeline. + #[must_use] + pub fn new() -> Self { + Self { passes: Vec::new() } + } + + /// Append a pass to the pipeline and return `self` for chaining. + #[must_use] + pub fn then(mut self, pass: impl CircuitPass + 'static) -> Self { + self.passes.push(Box::new(pass)); + self + } +} + +impl Default for PassPipeline { + fn default() -> Self { + Self::new() + } +} + +impl CircuitPass for PassPipeline { + fn apply_tick(&self, circuit: &mut TickCircuit) { + for pass in &self.passes { + pass.apply_tick(circuit); + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + for pass in &self.passes { + pass.apply_dag(circuit); + } + } +} + +/// Replace rotation gates at special angles with their named equivalents. +/// +/// For example, `RZ(pi/2)` becomes `SZ`, `RX(pi)` becomes `X`, and +/// `RZZ(pi)` decomposes into two independent `Z` gates. +/// +/// # Single-qubit simplifications (in-place) +/// +/// | Rotation | Angle | Result | +/// |----------|-------|--------| +/// | RZ | pi | Z | +/// | RZ | pi/2 | SZ | +/// | RZ | 3pi/2 | `SZdg` | +/// | RZ | pi/4 | T | +/// | RZ | 7pi/4 | `Tdg` | +/// | RX | pi | X | +/// | RX | pi/2 | SX | +/// | RX | 3pi/2 | `SXdg` | +/// | RY | pi | Y | +/// | RY | pi/2 | SY | +/// | RY | 3pi/2 | `SYdg` | +/// | RZZ | pi/2 | SZZ | +/// | RZZ | 3pi/2 | `SZZdg` | +/// +/// # Two-qubit decompositions +/// +/// | Rotation | Angle | Result | +/// |----------|-------|--------| +/// | RZZ | pi | Z + Z | +/// | RXX | pi | X + X | +/// | RYY | pi | Y + Y | +pub struct SimplifyRotations; + +/// Eighth-turn constant for T gate: fraction = 1 << 61. +const EIGHTH: u64 = 1 << 61; +/// Seven-eighths-turn constant for Tdg gate: fraction = 7 << 61. +const SEVEN_EIGHTHS: u64 = 7 << 61; + +/// Map a rotation gate at a special angle to the equivalent named gate. +/// +/// Returns `None` when the rotation/angle pair has no named equivalent. +fn simplify_rotation(gate_type: GateType, angle: Angle64) -> Option { + let f = angle.fraction(); + + match gate_type { + GateType::RZ => match f { + f if f == Angle64::HALF_TURN.fraction() => Some(GateType::Z), + f if f == Angle64::QUARTER_TURN.fraction() => Some(GateType::SZ), + f if f == Angle64::THREE_QUARTERS_TURN.fraction() => Some(GateType::SZdg), + EIGHTH => Some(GateType::T), + SEVEN_EIGHTHS => Some(GateType::Tdg), + _ => None, + }, + GateType::RX => match f { + f if f == Angle64::HALF_TURN.fraction() => Some(GateType::X), + f if f == Angle64::QUARTER_TURN.fraction() => Some(GateType::SX), + f if f == Angle64::THREE_QUARTERS_TURN.fraction() => Some(GateType::SXdg), + _ => None, + }, + GateType::RY => match f { + f if f == Angle64::HALF_TURN.fraction() => Some(GateType::Y), + f if f == Angle64::QUARTER_TURN.fraction() => Some(GateType::SY), + f if f == Angle64::THREE_QUARTERS_TURN.fraction() => Some(GateType::SYdg), + _ => None, + }, + GateType::RZZ => match f { + f if f == Angle64::QUARTER_TURN.fraction() => Some(GateType::SZZ), + f if f == Angle64::THREE_QUARTERS_TURN.fraction() => Some(GateType::SZZdg), + _ => None, + }, + _ => None, + } +} + +/// Check whether a two-qubit rotation at half turn should decompose into two +/// single-qubit Pauli gates. Returns the Pauli gate type if so. +fn half_turn_decomposition(gate_type: GateType, angle: Angle64) -> Option { + if angle.fraction() != Angle64::HALF_TURN.fraction() { + return None; + } + match gate_type { + GateType::RZZ => Some(GateType::Z), + GateType::RXX => Some(GateType::X), + GateType::RYY => Some(GateType::Y), + _ => None, + } +} + +/// Apply an in-place simplification to a gate. Returns `true` if the gate was +/// simplified (either renamed in place or needs decomposition handling). +fn simplify_gate_in_place(gate: &mut Gate) -> bool { + if gate.angles.len() != 1 { + return false; + } + if let Some(named) = simplify_rotation(gate.gate_type, gate.angles[0]) { + gate.gate_type = named; + gate.angles.clear(); + return true; + } + false +} + +// === Helper functions for circuit transformation passes === + +/// Returns `true` if the gate is an identity operation (I, Idle, or zero-angle rotation). +fn is_identity_gate(gate: &Gate) -> bool { + match gate.gate_type { + GateType::I | GateType::Idle => true, + gt if is_rotation(gt) => gate.angles.len() == 1 && gate.angles[0].is_zero(), + _ => false, + } +} + +/// Returns `true` if the gate type is a rotation (parameterized by a single angle). +fn is_rotation(gt: GateType) -> bool { + matches!( + gt, + GateType::RX + | GateType::RY + | GateType::RZ + | GateType::RXX + | GateType::RYY + | GateType::RZZ + | GateType::CRZ + ) +} + +/// Returns `true` if the gate type is its own inverse. +fn is_self_inverse(gt: GateType) -> bool { + matches!( + gt, + GateType::X + | GateType::Y + | GateType::Z + | GateType::H + | GateType::I + | GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SWAP + | GateType::CCX + ) +} + +/// Returns the named inverse of a gate type, if one exists. +fn named_inverse(gt: GateType) -> Option { + match gt { + GateType::SX => Some(GateType::SXdg), + GateType::SXdg => Some(GateType::SX), + GateType::SY => Some(GateType::SYdg), + GateType::SYdg => Some(GateType::SY), + GateType::SZ => Some(GateType::SZdg), + GateType::SZdg => Some(GateType::SZ), + GateType::T => Some(GateType::Tdg), + GateType::Tdg => Some(GateType::T), + GateType::SZZ => Some(GateType::SZZdg), + GateType::SZZdg => Some(GateType::SZZ), + _ => None, + } +} + +/// Returns `true` if gates `a` and `b` are inverses of each other. +/// +/// Checks (in order): +/// 1. Qubits must match exactly. +/// 2. Self-inverse identical gates (e.g., H*H, CX*CX). +/// 3. Named inverse pairs (e.g., `SX*SXdg`, `T*Tdg`). +/// 4. Same rotation type with angles summing to zero (e.g., `RZ(t)*RZ(-t)`). +fn are_inverses(a: &Gate, b: &Gate) -> bool { + if a.qubits != b.qubits { + return false; + } + // Self-inverse identical gates + if a.gate_type == b.gate_type && is_self_inverse(a.gate_type) && a.angles == b.angles { + return true; + } + // Named inverse pairs + if let Some(inv) = named_inverse(a.gate_type) + && inv == b.gate_type + && a.angles == b.angles + { + return true; + } + // Rotation angles summing to zero + if a.gate_type == b.gate_type + && is_rotation(a.gate_type) + && a.angles.len() == 1 + && b.angles.len() == 1 + && (a.angles[0] + b.angles[0]).is_zero() + { + return true; + } + false +} + +/// Check if all qubit stacks agree on the same top-of-stack position. +/// +/// Returns `Some((tick_idx, gate_idx))` if every qubit in `qubits` has +/// a non-empty stack whose top entry is the same position, `None` otherwise. +fn check_all_stacks_agree( + stacks: &HashMap>, + qubits: &[QubitId], +) -> Option<(usize, usize)> { + let mut agreed: Option<(usize, usize)> = None; + for &q in qubits { + let top = *stacks.get(&q)?.last()?; + match agreed { + None => agreed = Some(top), + Some(prev) => { + if prev != top { + return None; + } + } + } + } + agreed +} + +/// Check if the successor of `node` on every qubit in `qubits` is the same DAG node. +fn dag_common_successor(circuit: &DagCircuit, node: usize, qubits: &[QubitId]) -> Option { + let mut result: Option = None; + for &q in qubits { + let s = circuit.successor_on_qubit(node, q)?; + match result { + None => result = Some(s), + Some(prev) if prev == s => {} + _ => return None, + } + } + result +} + +/// Check if a gate conjugated by H on a specific qubit simplifies. +/// +/// Returns `Some((new_gate_type, new_qubits))` if: +/// - H on target of CX -> CZ (same qubits) +/// - H on either qubit of CZ -> CX (other qubit becomes control, H qubit becomes target) +fn peephole_conjugation(middle: &Gate, h_qubit: QubitId) -> Option<(GateType, GateQubits)> { + match middle.gate_type { + GateType::CX if middle.qubits.len() == 2 && middle.qubits[1] == h_qubit => { + // H(target) CX(c,t) H(target) = CZ(c,t) + Some((GateType::CZ, middle.qubits.clone())) + } + GateType::CZ if middle.qubits.len() == 2 && middle.qubits.contains(&h_qubit) => { + // H(q) CZ(a,b) H(q) = CX(other, q) + let other = if middle.qubits[0] == h_qubit { + middle.qubits[1] + } else { + middle.qubits[0] + }; + Some((GateType::CX, smallvec::smallvec![other, h_qubit])) + } + _ => None, + } +} + +impl CircuitPass for SimplifyRotations { + fn apply_tick(&self, circuit: &mut TickCircuit) { + for tick in circuit.ticks_mut() { + // First pass: collect two-qubit decompositions. + // We need to know which gate indices to remove and what to add. + let mut decompositions: Vec<(usize, GateType)> = Vec::new(); + + for (i, gate) in tick.gates().iter().enumerate() { + if gate.angles.len() == 1 + && let Some(pauli) = half_turn_decomposition(gate.gate_type, gate.angles[0]) + { + decompositions.push((i, pauli)); + } + } + + // Process decompositions in reverse order to keep indices valid. + for &(idx, pauli) in decompositions.iter().rev() { + let qubits = tick.gates()[idx].qubits.clone(); + // Remove the two-qubit gate, add two single-qubit gates. + tick.remove_gate(idx); + for pair in qubits.chunks(2) { + if pair.len() == 2 { + tick.add_gate(Gate::simple(pauli, smallvec::smallvec![pair[0]])); + tick.add_gate(Gate::simple(pauli, smallvec::smallvec![pair[1]])); + } + } + } + + // Second pass: in-place simplification of remaining gates. + for gate in tick.gates_mut() { + simplify_gate_in_place(gate); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let nodes = circuit.nodes(); + + for node in nodes { + let Some(gate) = circuit.gate(node) else { + continue; + }; + + // Check for two-qubit half-turn decomposition first. + if gate.angles.len() == 1 + && let Some(pauli) = half_turn_decomposition(gate.gate_type, gate.angles[0]) + { + let qubits = gate.qubits.clone(); + + // Collect predecessor/successor nodes *before* removal + // (remove_gate deletes edges too). + let mut pred_map = Vec::new(); + let mut succ_map = Vec::new(); + for &q in &qubits { + pred_map.push((q, circuit.predecessor_on_qubit(node, q))); + succ_map.push((q, circuit.successor_on_qubit(node, q))); + } + + // Remove the two-qubit gate (and its edges). + circuit.remove_gate(node); + + // Add two single-qubit gates and rewire. + for pair in qubits.chunks(2) { + if pair.len() < 2 { + continue; + } + let node_a = + circuit.add_gate(Gate::simple(pauli, smallvec::smallvec![pair[0]])); + let node_b = + circuit.add_gate(Gate::simple(pauli, smallvec::smallvec![pair[1]])); + + // Rewire predecessors -> new nodes. + for &(q, pred) in &pred_map { + if let Some(pred) = pred { + if q == pair[0] { + let _ = circuit.connect(pred, node_a, q); + } else if q == pair[1] { + let _ = circuit.connect(pred, node_b, q); + } + } + } + + // Rewire new nodes -> successors. + for &(q, succ) in &succ_map { + if let Some(succ) = succ { + if q == pair[0] { + let _ = circuit.connect(node_a, succ, q); + } else if q == pair[1] { + let _ = circuit.connect(node_b, succ, q); + } + } + } + } + continue; + } + + // In-place simplification for single-qubit and two-qubit named replacements. + if let Some(gate) = circuit.gate_mut(node) { + simplify_gate_in_place(gate); + } + } + } +} + +/// Remove identity gates (I, Idle, zero-angle rotations) from circuits. +pub struct RemoveIdentity; + +impl CircuitPass for RemoveIdentity { + fn apply_tick(&self, circuit: &mut TickCircuit) { + for tick in circuit.ticks_mut() { + let to_remove: Vec = tick + .gates() + .iter() + .enumerate() + .filter(|(_, g)| is_identity_gate(g)) + .map(|(i, _)| i) + .collect(); + for &idx in to_remove.iter().rev() { + tick.remove_gate(idx); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let nodes = circuit.nodes(); + for node in nodes { + let Some(gate) = circuit.gate(node) else { + continue; + }; + if !is_identity_gate(gate) { + continue; + } + let qubits: Vec = gate.qubits.iter().copied().collect(); + let mut rewire = Vec::new(); + for &q in &qubits { + let pred = circuit.predecessor_on_qubit(node, q); + let succ = circuit.successor_on_qubit(node, q); + rewire.push((q, pred, succ)); + } + circuit.remove_gate(node); + for (q, pred, succ) in rewire { + if let (Some(p), Some(s)) = (pred, succ) { + let _ = circuit.connect(p, s, q); + } + } + } + } +} + +/// Cancel adjacent inverse gate pairs (e.g., `H*H`, `SX*SXdg`, `RZ(t)*RZ(-t)`). +/// +/// Uses a per-qubit stack to handle nested cancellations (A B B^-1 A^-1) +/// in a single pass over tick circuits. +pub struct CancelInverses; + +impl CircuitPass for CancelInverses { + fn apply_tick(&self, circuit: &mut TickCircuit) { + let mut stacks: HashMap> = HashMap::new(); + let mut to_remove: Vec<(usize, usize)> = Vec::new(); + + for (ti, tick) in circuit.ticks().iter().enumerate() { + for (gi, gate) in tick.gates().iter().enumerate() { + let qubits: Vec = gate.qubits.iter().copied().collect(); + + if let Some((pred_ti, pred_gi)) = check_all_stacks_agree(&stacks, &qubits) { + let pred_gate = &circuit.ticks()[pred_ti].gates()[pred_gi]; + if are_inverses(pred_gate, gate) { + for &q in &qubits { + if let Some(stack) = stacks.get_mut(&q) { + stack.pop(); + } + } + to_remove.push((pred_ti, pred_gi)); + to_remove.push((ti, gi)); + continue; + } + } + + for &q in &qubits { + stacks.entry(q).or_default().push((ti, gi)); + } + } + } + + to_remove.sort_unstable(); + to_remove.dedup(); + for &(ti, gi) in to_remove.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let topo = circuit.topological_order(); + for node in topo { + let Some(gate) = circuit.gate(node) else { + continue; + }; + let qubits: Vec = gate.qubits.iter().copied().collect(); + + let Some(succ) = dag_common_successor(circuit, node, &qubits) else { + continue; + }; + let Some(succ_gate) = circuit.gate(succ) else { + continue; + }; + + if !are_inverses(gate, succ_gate) { + continue; + } + + let mut rewire = Vec::new(); + for &q in &qubits { + let pred = circuit.predecessor_on_qubit(node, q); + let succ_succ = circuit.successor_on_qubit(succ, q); + rewire.push((q, pred, succ_succ)); + } + + circuit.remove_gate(node); + circuit.remove_gate(succ); + + for (q, pred, succ_succ) in rewire { + if let (Some(p), Some(s)) = (pred, succ_succ) { + let _ = circuit.connect(p, s, q); + } + } + } + } +} + +/// Merge consecutive same-axis rotations (e.g., RZ(a)*RZ(b) -> RZ(a+b)). +/// +/// Uses a per-qubit stack to handle chains of rotations. After merging, +/// the surviving gate's angle is the sum of all merged angles. +pub struct MergeAdjacentRotations; + +impl CircuitPass for MergeAdjacentRotations { + fn apply_tick(&self, circuit: &mut TickCircuit) { + let mut stacks: HashMap> = HashMap::new(); + let mut angle_adjustments: HashMap<(usize, usize), Angle64> = HashMap::new(); + let mut to_remove: Vec<(usize, usize)> = Vec::new(); + + for (ti, tick) in circuit.ticks().iter().enumerate() { + for (gi, gate) in tick.gates().iter().enumerate() { + let qubits: Vec = gate.qubits.iter().copied().collect(); + + if is_rotation(gate.gate_type) + && gate.angles.len() == 1 + && let Some((pred_ti, pred_gi)) = check_all_stacks_agree(&stacks, &qubits) + { + let pred_gate = &circuit.ticks()[pred_ti].gates()[pred_gi]; + if pred_gate.gate_type == gate.gate_type && pred_gate.qubits == gate.qubits { + *angle_adjustments + .entry((pred_ti, pred_gi)) + .or_insert(Angle64::ZERO) += gate.angles[0]; + to_remove.push((ti, gi)); + // Don't push; predecessor stays on stack for chain merging. + continue; + } + } + + // Push to stacks (for rotation or non-rotation gates). + for &q in &qubits { + stacks.entry(q).or_default().push((ti, gi)); + } + } + } + + // Apply angle adjustments to surviving gates. + for (&(ti, gi), &delta) in &angle_adjustments { + if let Some(tick) = circuit.get_tick_mut(ti) + && let Some(gate) = tick.gates_mut().get_mut(gi) + { + gate.angles[0] += delta; + } + } + + // Remove merged gates in reverse order. + to_remove.sort_unstable(); + for &(ti, gi) in to_remove.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let topo = circuit.topological_order(); + for node in topo { + loop { + let Some(gate) = circuit.gate(node) else { + break; + }; + if !is_rotation(gate.gate_type) || gate.angles.len() != 1 { + break; + } + let gate_type = gate.gate_type; + let qubits: Vec = gate.qubits.iter().copied().collect(); + + let Some(succ) = dag_common_successor(circuit, node, &qubits) else { + break; + }; + let Some(succ_gate) = circuit.gate(succ) else { + break; + }; + + if succ_gate.gate_type != gate_type + || succ_gate.qubits[..] != qubits[..] + || succ_gate.angles.len() != 1 + { + break; + } + + let succ_angle = succ_gate.angles[0]; + + // Save succ-of-successor for rewiring. + let mut rewire = Vec::new(); + for &q in &qubits { + let succ_succ = circuit.successor_on_qubit(succ, q); + rewire.push((q, succ_succ)); + } + + // Merge angle and remove successor. + circuit.gate_mut(node).unwrap().angles[0] += succ_angle; + circuit.remove_gate(succ); + + for (q, succ_succ) in rewire { + if let Some(ss) = succ_succ { + let _ = circuit.connect(node, ss, q); + } + } + } + } + } +} + +/// Recognize and simplify multi-gate patterns. +/// +/// Current rules: +/// - `H(q) CX(c,q) H(q)` -> `CZ(c,q)` +/// - `H(q) CZ(a,b) H(q)` -> `CX(other, q)` +pub struct PeepholeOptimize; + +impl CircuitPass for PeepholeOptimize { + fn apply_tick(&self, circuit: &mut TickCircuit) { + // Build per-qubit timeline: Vec of (tick_idx, gate_idx) in order. + let mut timelines: HashMap> = HashMap::new(); + for (ti, tick) in circuit.ticks().iter().enumerate() { + for (gi, gate) in tick.gates().iter().enumerate() { + for &q in &gate.qubits { + timelines.entry(q).or_default().push((ti, gi)); + } + } + } + + let mut replacements: Vec<((usize, usize), GateType, GateQubits)> = Vec::new(); + let mut to_remove: HashSet<(usize, usize)> = HashSet::new(); + + // Scan each qubit's timeline for H - middle - H pattern. + for (q, timeline) in &timelines { + if timeline.len() < 3 { + continue; + } + let mut i = 0; + while i + 2 < timeline.len() { + let (h1_ti, h1_gi) = timeline[i]; + let (mid_ti, mid_gi) = timeline[i + 1]; + let (h2_ti, h2_gi) = timeline[i + 2]; + + // Skip if any of these gates are already consumed. + if to_remove.contains(&(h1_ti, h1_gi)) + || to_remove.contains(&(mid_ti, mid_gi)) + || to_remove.contains(&(h2_ti, h2_gi)) + { + i += 1; + continue; + } + + let h1 = &circuit.ticks()[h1_ti].gates()[h1_gi]; + let mid = &circuit.ticks()[mid_ti].gates()[mid_gi]; + let h2 = &circuit.ticks()[h2_ti].gates()[h2_gi]; + + // Both must be single-qubit H on this qubit. + if h1.gate_type != GateType::H + || h1.qubits.len() != 1 + || h2.gate_type != GateType::H + || h2.qubits.len() != 1 + { + i += 1; + continue; + } + + if let Some((new_gt, new_qubits)) = peephole_conjugation(mid, *q) { + to_remove.insert((h1_ti, h1_gi)); + to_remove.insert((h2_ti, h2_gi)); + replacements.push(((mid_ti, mid_gi), new_gt, new_qubits)); + i += 3; // skip past the consumed triple + } else { + i += 1; + } + } + } + + // Apply replacements. + for ((ti, gi), new_gt, new_qubits) in &replacements { + if let Some(tick) = circuit.get_tick_mut(*ti) + && let Some(gate) = tick.gates_mut().get_mut(*gi) + { + gate.gate_type = *new_gt; + gate.qubits.clone_from(new_qubits); + } + } + + // Remove H gates in reverse order to preserve indices. + let mut remove_list: Vec<(usize, usize)> = to_remove + .iter() + .filter(|pos| !replacements.iter().any(|(p, _, _)| p == *pos)) + .copied() + .collect(); + remove_list.sort_unstable(); + for &(ti, gi) in remove_list.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let topo = circuit.topological_order(); + for node in topo { + let Some(gate) = circuit.gate(node) else { + continue; + }; + // Look for two-qubit gates (CX, CZ) where one qubit has H before and after. + if !matches!(gate.gate_type, GateType::CX | GateType::CZ) || gate.qubits.len() != 2 { + continue; + } + let qubits: Vec = gate.qubits.iter().copied().collect(); + + // Check each qubit for H-conjugation. + for &q in &qubits { + let Some(pred) = circuit.predecessor_on_qubit(node, q) else { + continue; + }; + let Some(succ) = circuit.successor_on_qubit(node, q) else { + continue; + }; + let Some(pred_gate) = circuit.gate(pred) else { + continue; + }; + let Some(succ_gate) = circuit.gate(succ) else { + continue; + }; + + // Both must be single-qubit H on this qubit. + if pred_gate.gate_type != GateType::H + || pred_gate.qubits.len() != 1 + || succ_gate.gate_type != GateType::H + || succ_gate.qubits.len() != 1 + { + continue; + } + + let gate = circuit.gate(node).unwrap(); + if let Some((new_gt, new_qubits)) = peephole_conjugation(gate, q) { + // Rewire around the two H gates. + let h_pred = circuit.predecessor_on_qubit(pred, q); + let h_succ = circuit.successor_on_qubit(succ, q); + circuit.remove_gate(pred); + circuit.remove_gate(succ); + // Update the middle gate in place. + let g = circuit.gate_mut(node).unwrap(); + g.gate_type = new_gt; + g.qubits = new_qubits; + // Rewire: h_pred -> node, node -> h_succ + if let Some(hp) = h_pred { + let _ = circuit.connect(hp, node, q); + } + if let Some(hs) = h_succ { + let _ = circuit.connect(node, hs, q); + } + break; // gate changed, move to next node + } + } + } + } +} + +// === Helper functions for AbsorbBasisGates === + +/// Returns `true` if the gate is a Z-basis preparation (produces |0>). +fn is_z_prep(gt: GateType) -> bool { + matches!(gt, GateType::Prep | GateType::QAlloc) +} + +/// Returns `true` if the gate is a Z-basis measurement. +fn is_z_measure(gt: GateType) -> bool { + matches!(gt, GateType::Measure | GateType::MeasureFree) +} + +/// Returns `true` if the gate is Z-diagonal (single- or multi-qubit). +/// +/// Z-diagonal gates are diagonal in the computational basis: they map each +/// basis state to itself times a phase. Applying one when every qubit is in +/// a Z eigenstate only adds a global phase (no-op), and it does not change +/// Z-measurement statistics. +fn is_z_diagonal(gate: &Gate) -> bool { + matches!( + gate.gate_type, + GateType::Z + | GateType::SZ + | GateType::SZdg + | GateType::T + | GateType::Tdg + | GateType::RZ + | GateType::CZ + | GateType::SZZ + | GateType::SZZdg + | GateType::RZZ + | GateType::CRZ + ) +} + +/// Remove Z-diagonal gates that are redundant due to adjacent Z-basis +/// preparations or measurements. +/// +/// Z-basis preparations (PZ / `QAlloc`) produce |0>, an eigenstate of every +/// Z-diagonal operator. Applying any Z-diagonal gate (Z, SZ, `SZdg`, T, +/// `Tdg`, RZ, CZ, SZZ, `SZZdg`, RZZ, CRZ) when all its qubits are still +/// in a Z eigenstate only adds a global phase -- a physical no-op. +/// Similarly, Z-diagonal gates immediately before Z-basis measurements +/// (MZ / `MeasureFree`) do not change measurement statistics and can be +/// removed. +pub struct AbsorbBasisGates; + +impl CircuitPass for AbsorbBasisGates { + fn apply_tick(&self, circuit: &mut TickCircuit) { + let mut to_remove: Vec<(usize, usize)> = Vec::new(); + + // Forward scan: absorb Z-diagonal gates after Z-preps. + let mut z_eigenstate: HashSet = HashSet::new(); + for (ti, tick) in circuit.ticks().iter().enumerate() { + for (gi, gate) in tick.gates().iter().enumerate() { + if is_z_prep(gate.gate_type) { + for &q in &gate.qubits { + z_eigenstate.insert(q); + } + } else if is_z_diagonal(gate) + && gate.qubits.iter().all(|q| z_eigenstate.contains(q)) + { + to_remove.push((ti, gi)); + } else { + for &q in &gate.qubits { + z_eigenstate.remove(&q); + } + } + } + } + + // Backward scan: absorb Z-diagonal gates before Z-measures. + let mut before_z_measure: HashSet = HashSet::new(); + for (ti, tick) in circuit.ticks().iter().enumerate().rev() { + for (gi, gate) in tick.gates().iter().enumerate().rev() { + if is_z_measure(gate.gate_type) { + for &q in &gate.qubits { + before_z_measure.insert(q); + } + } else if is_z_diagonal(gate) + && gate.qubits.iter().all(|q| before_z_measure.contains(q)) + { + to_remove.push((ti, gi)); + } else { + for &q in &gate.qubits { + before_z_measure.remove(&q); + } + } + } + } + + // Deduplicate and remove in reverse order to preserve indices. + to_remove.sort_unstable(); + to_remove.dedup(); + for &(ti, gi) in to_remove.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, circuit: &mut DagCircuit) { + let topo = circuit.topological_order(); + let mut to_remove: Vec = Vec::new(); + + // Forward: track qubits in Z eigenstates, absorb Z-diagonal gates. + let mut z_eigenstate: HashSet = HashSet::new(); + for &node in &topo { + let Some(gate) = circuit.gate(node) else { + continue; + }; + if is_z_prep(gate.gate_type) { + for &q in &gate.qubits { + z_eigenstate.insert(q); + } + } else if is_z_diagonal(gate) && gate.qubits.iter().all(|q| z_eigenstate.contains(q)) { + to_remove.push(node); + } else { + for &q in &gate.qubits { + z_eigenstate.remove(&q); + } + } + } + + // Backward: track qubits whose next operation is a Z-measure. + let mut before_z_measure: HashSet = HashSet::new(); + for &node in topo.iter().rev() { + let Some(gate) = circuit.gate(node) else { + continue; + }; + if is_z_measure(gate.gate_type) { + for &q in &gate.qubits { + before_z_measure.insert(q); + } + } else if is_z_diagonal(gate) + && gate.qubits.iter().all(|q| before_z_measure.contains(q)) + { + to_remove.push(node); + } else { + for &q in &gate.qubits { + before_z_measure.remove(&q); + } + } + } + + // Deduplicate and remove, rewiring around each removed node. + to_remove.sort_unstable(); + to_remove.dedup(); + for &node in &to_remove { + let Some(gate) = circuit.gate(node) else { + continue; + }; + let qubits: Vec = gate.qubits.iter().copied().collect(); + let mut rewire = Vec::new(); + for &q in &qubits { + let pred = circuit.predecessor_on_qubit(node, q); + let succ = circuit.successor_on_qubit(node, q); + rewire.push((q, pred, succ)); + } + circuit.remove_gate(node); + for (q, pred, succ) in rewire { + if let (Some(p), Some(s)) = (pred, succ) { + let _ = circuit.connect(p, s, q); + } + } + } + } +} + +/// ASAP-schedule gates to minimise tick count, then drop empty ticks. +/// +/// For each gate (processed in original tick order), the pass assigns it to +/// the earliest tick where none of its qubits are still occupied. The +/// resulting circuit has the same gate order per qubit but fewer ticks. +/// +/// This is a `TickCircuit`-only optimisation; `apply_dag` is a no-op because +/// a DAG already represents the dependency graph without fixed time slots. +pub struct CompactTicks; + +impl CircuitPass for CompactTicks { + fn apply_tick(&self, circuit: &mut TickCircuit) { + // Collect every gate together with its per-gate attributes. + let mut entries: Vec<(Gate, BTreeMap)> = Vec::new(); + for tick in circuit.ticks() { + for (gi, gate) in tick.gates().iter().enumerate() { + let attrs: BTreeMap = tick + .gate_attrs(gi) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + entries.push((gate.clone(), attrs)); + } + } + + if entries.is_empty() { + circuit.clear(); + return; + } + + // ASAP scheduling: for each gate, find the earliest tick where none + // of its qubits are busy. + // `qubit_ready[q]` = the next tick index at which qubit q is free. + let mut qubit_ready: HashMap = HashMap::new(); + let mut assignments: Vec = Vec::with_capacity(entries.len()); + let mut num_ticks: usize = 0; + + for (gate, _) in &entries { + let earliest = gate + .qubits + .iter() + .map(|q| qubit_ready.get(q).copied().unwrap_or(0)) + .max() + .unwrap_or(0); + assignments.push(earliest); + for &q in &gate.qubits { + qubit_ready.insert(q, earliest + 1); + } + if earliest + 1 > num_ticks { + num_ticks = earliest + 1; + } + } + + // Save and restore circuit-level metadata across the rebuild. + let saved_attrs: BTreeMap = circuit + .circuit_attrs() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + circuit.clear(); + circuit.reserve_ticks(num_ticks); + + for (i, (gate, attrs)) in entries.into_iter().enumerate() { + let ti = assignments[i]; + let tick = circuit.get_tick_mut(ti).unwrap(); + let gi = tick.add_gate(gate); + if !attrs.is_empty() { + tick.set_gate_attrs(gi, attrs); + } + } + + if !saved_attrs.is_empty() { + circuit.set_metas(saved_attrs); + } + } + + fn apply_dag(&self, _circuit: &mut DagCircuit) { + // No-op: a DAG has no fixed time slots to compact. + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== simplify_rotation unit tests ==================== + + #[test] + fn simplify_rz_quarter_turn_to_sz() { + assert_eq!( + simplify_rotation(GateType::RZ, Angle64::QUARTER_TURN), + Some(GateType::SZ) + ); + } + + #[test] + fn simplify_rz_half_turn_to_z() { + assert_eq!( + simplify_rotation(GateType::RZ, Angle64::HALF_TURN), + Some(GateType::Z) + ); + } + + #[test] + fn simplify_rz_three_quarters_to_szdg() { + assert_eq!( + simplify_rotation(GateType::RZ, Angle64::THREE_QUARTERS_TURN), + Some(GateType::SZdg) + ); + } + + #[test] + fn simplify_rz_eighth_turn_to_t() { + let eighth = Angle64::from_turn_ratio(1, 8); + assert_eq!(simplify_rotation(GateType::RZ, eighth), Some(GateType::T)); + } + + #[test] + fn simplify_rz_seven_eighths_to_tdg() { + let seven_eighths = Angle64::from_turn_ratio(7, 8); + assert_eq!( + simplify_rotation(GateType::RZ, seven_eighths), + Some(GateType::Tdg) + ); + } + + #[test] + fn simplify_rx_quarter_turn_to_sx() { + assert_eq!( + simplify_rotation(GateType::RX, Angle64::QUARTER_TURN), + Some(GateType::SX) + ); + } + + #[test] + fn simplify_rx_half_turn_to_x() { + assert_eq!( + simplify_rotation(GateType::RX, Angle64::HALF_TURN), + Some(GateType::X) + ); + } + + #[test] + fn simplify_rx_three_quarters_to_sxdg() { + assert_eq!( + simplify_rotation(GateType::RX, Angle64::THREE_QUARTERS_TURN), + Some(GateType::SXdg) + ); + } + + #[test] + fn simplify_ry_quarter_turn_to_sy() { + assert_eq!( + simplify_rotation(GateType::RY, Angle64::QUARTER_TURN), + Some(GateType::SY) + ); + } + + #[test] + fn simplify_ry_half_turn_to_y() { + assert_eq!( + simplify_rotation(GateType::RY, Angle64::HALF_TURN), + Some(GateType::Y) + ); + } + + #[test] + fn simplify_ry_three_quarters_to_sydg() { + assert_eq!( + simplify_rotation(GateType::RY, Angle64::THREE_QUARTERS_TURN), + Some(GateType::SYdg) + ); + } + + #[test] + fn simplify_rzz_quarter_turn_to_szz() { + assert_eq!( + simplify_rotation(GateType::RZZ, Angle64::QUARTER_TURN), + Some(GateType::SZZ) + ); + } + + #[test] + fn simplify_rzz_three_quarters_to_szzdg() { + assert_eq!( + simplify_rotation(GateType::RZZ, Angle64::THREE_QUARTERS_TURN), + Some(GateType::SZZdg) + ); + } + + #[test] + fn simplify_non_special_angle_unchanged() { + assert_eq!( + simplify_rotation(GateType::RZ, Angle64::from_turn_ratio(1, 6)), + None + ); + } + + #[test] + fn simplify_non_rotation_unchanged() { + assert_eq!(simplify_rotation(GateType::H, Angle64::QUARTER_TURN), None); + } + + // ==================== half_turn_decomposition tests ==================== + + #[test] + fn rzz_half_turn_decomposes_to_z() { + assert_eq!( + half_turn_decomposition(GateType::RZZ, Angle64::HALF_TURN), + Some(GateType::Z) + ); + } + + #[test] + fn rxx_half_turn_decomposes_to_x() { + assert_eq!( + half_turn_decomposition(GateType::RXX, Angle64::HALF_TURN), + Some(GateType::X) + ); + } + + #[test] + fn ryy_half_turn_decomposes_to_y() { + assert_eq!( + half_turn_decomposition(GateType::RYY, Angle64::HALF_TURN), + Some(GateType::Y) + ); + } + + #[test] + fn rzz_non_half_turn_no_decomposition() { + assert_eq!( + half_turn_decomposition(GateType::RZZ, Angle64::QUARTER_TURN), + None + ); + } + + // ==================== TickCircuit pass tests ==================== + + #[test] + fn tick_simplify_rz_quarter_to_sz() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::SZ); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_rz_half_to_z() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::HALF_TURN, &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::Z); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_rx_quarter_to_sx() { + let mut tc = TickCircuit::new(); + tc.tick().rx(Angle64::QUARTER_TURN, &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::SX); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_ry_half_to_y() { + let mut tc = TickCircuit::new(); + tc.tick().ry(Angle64::HALF_TURN, &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::Y); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_rzz_quarter_to_szz() { + let mut tc = TickCircuit::new(); + tc.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::SZZ); + assert!(gate.angles.is_empty()); + } + + #[test] + fn tick_simplify_rzz_half_to_zz() { + let mut tc = TickCircuit::new(); + tc.tick().rzz(Angle64::HALF_TURN, &[(0, 1)]); + SimplifyRotations.apply_tick(&mut tc); + let gates = tc.ticks()[0].gates(); + assert_eq!(gates.len(), 2); + assert_eq!(gates[0].gate_type, GateType::Z); + assert_eq!(gates[1].gate_type, GateType::Z); + } + + #[test] + fn tick_simplify_rxx_half_to_xx() { + let mut tc = TickCircuit::new(); + tc.tick().rxx(Angle64::HALF_TURN, &[(0, 1)]); + SimplifyRotations.apply_tick(&mut tc); + let gates = tc.ticks()[0].gates(); + assert_eq!(gates.len(), 2); + assert_eq!(gates[0].gate_type, GateType::X); + assert_eq!(gates[1].gate_type, GateType::X); + } + + #[test] + fn tick_non_special_angle_unchanged() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::from_turn_ratio(1, 6), &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles.len(), 1); + } + + #[test] + fn tick_non_rotation_unchanged() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::H); + } + + #[test] + fn tick_simplify_eighth_turn_to_t() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::from_turn_ratio(1, 8), &[0]); + SimplifyRotations.apply_tick(&mut tc); + let gate = &tc.ticks()[0].gates()[0]; + assert_eq!(gate.gate_type, GateType::T); + assert!(gate.angles.is_empty()); + } + + // ==================== DagCircuit pass tests ==================== + + #[test] + fn dag_simplify_rz_quarter_to_sz() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::QUARTER_TURN, 0); + let nodes = dag.nodes(); + SimplifyRotations.apply_dag(&mut dag); + let gate = dag.gate(nodes[0]).unwrap(); + assert_eq!(gate.gate_type, GateType::SZ); + assert!(gate.angles.is_empty()); + } + + #[test] + fn dag_simplify_rz_half_to_z() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::HALF_TURN, 0); + let nodes = dag.nodes(); + SimplifyRotations.apply_dag(&mut dag); + let gate = dag.gate(nodes[0]).unwrap(); + assert_eq!(gate.gate_type, GateType::Z); + assert!(gate.angles.is_empty()); + } + + #[test] + fn dag_simplify_rzz_quarter_to_szz() { + let mut dag = DagCircuit::new(); + dag.rzz(Angle64::QUARTER_TURN, 0, 1); + let nodes = dag.nodes(); + SimplifyRotations.apply_dag(&mut dag); + let gate = dag.gate(nodes[0]).unwrap(); + assert_eq!(gate.gate_type, GateType::SZZ); + assert!(gate.angles.is_empty()); + } + + #[test] + fn dag_simplify_rzz_half_to_zz() { + let mut dag = DagCircuit::new(); + dag.rzz(Angle64::HALF_TURN, 0, 1); + SimplifyRotations.apply_dag(&mut dag); + // The old node is removed, two new Z gates are added. + let nodes = dag.nodes(); + assert_eq!(nodes.len(), 2); + for &n in &nodes { + let g = dag.gate(n).unwrap(); + assert_eq!(g.gate_type, GateType::Z); + assert!(g.angles.is_empty()); + assert_eq!(g.qubits.len(), 1); + } + } + + #[test] + fn dag_non_special_angle_unchanged() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::from_turn_ratio(1, 6), 0); + let nodes = dag.nodes(); + SimplifyRotations.apply_dag(&mut dag); + let gate = dag.gate(nodes[0]).unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles.len(), 1); + } + + // ==================== Matrix equivalence tests (Operator level) ==================== + // + // These verify that each simplification mapping preserves the unitary + // (up to global phase) by comparing the rotation Operator against the + // named-gate Operator using dense matrix comparison. + + use crate::operator_matrix::{ + matrices_equiv_up_to_phase, operators_equiv, to_matrix_with_size, + }; + + use pecos_core::operator::{self, Operator}; + + #[test] + fn matrix_rz_half_equiv_z() { + assert!(operators_equiv( + &operator::RZ(Angle64::HALF_TURN, 0), + &operator::Z(0), + )); + } + + #[test] + fn matrix_rz_quarter_equiv_sz() { + assert!(operators_equiv( + &operator::RZ(Angle64::QUARTER_TURN, 0), + &operator::SZ(0), + )); + } + + #[test] + fn matrix_rz_three_quarters_equiv_szdg() { + assert!(operators_equiv( + &operator::RZ(Angle64::THREE_QUARTERS_TURN, 0), + &operator::SZ(0).dg(), + )); + } + + #[test] + fn matrix_rz_eighth_equiv_t() { + assert!(operators_equiv( + &operator::RZ(Angle64::from_turn_ratio(1, 8), 0), + &operator::T(0), + )); + } + + #[test] + fn matrix_rz_seven_eighths_equiv_tdg() { + assert!(operators_equiv( + &operator::RZ(Angle64::from_turn_ratio(7, 8), 0), + &operator::T(0).dg(), + )); + } + + #[test] + fn matrix_rx_half_equiv_x() { + assert!(operators_equiv( + &operator::RX(Angle64::HALF_TURN, 0), + &operator::X(0), + )); + } + + #[test] + fn matrix_rx_quarter_equiv_sx() { + assert!(operators_equiv( + &operator::RX(Angle64::QUARTER_TURN, 0), + &operator::SX(0), + )); + } + + #[test] + fn matrix_rx_three_quarters_equiv_sxdg() { + assert!(operators_equiv( + &operator::RX(Angle64::THREE_QUARTERS_TURN, 0), + &operator::SX(0).dg(), + )); + } + + #[test] + fn matrix_ry_half_equiv_y() { + assert!(operators_equiv( + &operator::RY(Angle64::HALF_TURN, 0), + &operator::Y(0), + )); + } + + #[test] + fn matrix_ry_quarter_equiv_sy() { + assert!(operators_equiv( + &operator::RY(Angle64::QUARTER_TURN, 0), + &operator::SY(0), + )); + } + + #[test] + fn matrix_ry_three_quarters_equiv_sydg() { + assert!(operators_equiv( + &operator::RY(Angle64::THREE_QUARTERS_TURN, 0), + &operator::SY(0).dg(), + )); + } + + #[test] + fn matrix_rzz_quarter_equiv_szz() { + assert!(operators_equiv( + &operator::RZZ(Angle64::QUARTER_TURN, 0, 1), + &operator::SZZ(0, 1), + )); + } + + #[test] + fn matrix_rzz_three_quarters_equiv_szzdg() { + assert!(operators_equiv( + &operator::RZZ(Angle64::THREE_QUARTERS_TURN, 0, 1), + &operator::SZZ(0, 1).dg(), + )); + } + + #[test] + fn matrix_rzz_half_equiv_z_tensor_z() { + let rzz_pi = operator::RZZ(Angle64::HALF_TURN, 0, 1); + let z_z = operator::Z(0) & operator::Z(1); + assert!(operators_equiv(&rzz_pi, &z_z)); + } + + #[test] + fn matrix_rxx_half_equiv_x_tensor_x() { + let rxx_pi = operator::RXX(Angle64::HALF_TURN, 0, 1); + let x_x = operator::X(0) & operator::X(1); + assert!(operators_equiv(&rxx_pi, &x_x)); + } + + #[test] + fn matrix_ryy_half_equiv_y_tensor_y() { + let ryy_pi = operator::RYY(Angle64::HALF_TURN, 0, 1); + let y_y = operator::Y(0) & operator::Y(1); + assert!(operators_equiv(&ryy_pi, &y_y)); + } + + // ==================== Full-circuit matrix equivalence tests ==================== + // + // Convert a TickCircuit to an Operator chain, compute its unitary, + // apply SimplifyRotations, compute the new unitary, and compare. + + /// Convert a `TickCircuit` to an `Operator` by composing gates in order. + /// + /// Each tick's gates are tensored (parallel), then ticks are composed + /// (sequential). Returns `None` for an empty circuit. + fn tick_circuit_to_operator(tc: &TickCircuit) -> Option { + let mut tick_ops: Vec = Vec::new(); + + for tick in tc.ticks() { + let gates = tick.gates(); + if gates.is_empty() { + continue; + } + let mut gate_ops: Vec = Vec::new(); + for gate in gates { + let op = gate_to_operator(gate)?; + gate_ops.push(op); + } + // Tensor all gates in this tick (they act on disjoint qubits). + let tick_op = gate_ops.into_iter().reduce(|a, b| a & b).unwrap(); + tick_ops.push(tick_op); + } + + if tick_ops.is_empty() { + return None; + } + + // Compose ticks: last tick is outermost in matrix multiplication. + // Operator::Compose applies in reverse (like matrix multiplication), + // so we reverse to get time-ordering right. + tick_ops.reverse(); + Some(tick_ops.into_iter().reduce(|a, b| a * b).unwrap()) + } + + /// Convert a single `Gate` to an `Operator`. + fn gate_to_operator(gate: &pecos_core::Gate) -> Option { + let q0 = gate.qubits.first().copied()?; + match gate.gate_type { + GateType::H => Some(operator::H(q0)), + GateType::X => Some(operator::X(q0)), + GateType::Y => Some(operator::Y(q0)), + GateType::Z => Some(operator::Z(q0)), + GateType::SX => Some(operator::SX(q0)), + GateType::SXdg => Some(operator::SX(q0).dg()), + GateType::SY => Some(operator::SY(q0)), + GateType::SYdg => Some(operator::SY(q0).dg()), + GateType::SZ => Some(operator::SZ(q0)), + GateType::SZdg => Some(operator::SZ(q0).dg()), + GateType::T => Some(operator::T(q0)), + GateType::Tdg => Some(operator::T(q0).dg()), + GateType::RX => { + let angle = *gate.angles.first()?; + Some(operator::RX(angle, q0)) + } + GateType::RY => { + let angle = *gate.angles.first()?; + Some(operator::RY(angle, q0)) + } + GateType::RZ => { + let angle = *gate.angles.first()?; + Some(operator::RZ(angle, q0)) + } + GateType::CX => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::CX(q0, q1)) + } + GateType::CY => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::CY(q0, q1)) + } + GateType::CZ => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::CZ(q0, q1)) + } + GateType::RXX => { + let q1 = gate.qubits.get(1).copied()?; + let angle = *gate.angles.first()?; + Some(operator::RXX(angle, q0, q1)) + } + GateType::RYY => { + let q1 = gate.qubits.get(1).copied()?; + let angle = *gate.angles.first()?; + Some(operator::RYY(angle, q0, q1)) + } + GateType::RZZ => { + let q1 = gate.qubits.get(1).copied()?; + let angle = *gate.angles.first()?; + Some(operator::RZZ(angle, q0, q1)) + } + GateType::SZZ => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::SZZ(q0, q1)) + } + GateType::SZZdg => { + let q1 = gate.qubits.get(1).copied()?; + Some(operator::SZZ(q0, q1).dg()) + } + GateType::I | GateType::Idle => Some(operator::I(q0)), + _ => None, + } + } + + /// Assert that two `TickCircuit`s produce the same unitary (up to global phase). + fn assert_circuits_equiv(a: &TickCircuit, b: &TickCircuit) { + let op_a = tick_circuit_to_operator(a).expect("circuit A should be non-empty"); + let op_b = tick_circuit_to_operator(b).expect("circuit B should be non-empty"); + + // Determine qubit count from both operators. + let nq_a = op_a.qubits().into_iter().max().map_or(1, |q| q + 1); + let nq_b = op_b.qubits().into_iter().max().map_or(1, |q| q + 1); + let num_qubits = nq_a.max(nq_b); + + let mat_a = to_matrix_with_size(&op_a, num_qubits); + let mat_b = to_matrix_with_size(&op_b, num_qubits); + + assert!( + matrices_equiv_up_to_phase(&mat_a, &mat_b, 1e-10), + "circuits are not unitarily equivalent (up to global phase)", + ); + } + + #[test] + fn circuit_equiv_single_rz_quarter() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_single_rz_half() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::HALF_TURN, &[0]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_single_rx_quarter() { + let mut original = TickCircuit::new(); + original.tick().rx(Angle64::QUARTER_TURN, &[0]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_single_ry_half() { + let mut original = TickCircuit::new(); + original.tick().ry(Angle64::HALF_TURN, &[0]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_rzz_quarter() { + let mut original = TickCircuit::new(); + original.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_rzz_half_decomposition() { + let mut original = TickCircuit::new(); + original.tick().rzz(Angle64::HALF_TURN, &[(0, 1)]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_rxx_half_decomposition() { + let mut original = TickCircuit::new(); + original.tick().rxx(Angle64::HALF_TURN, &[(0, 1)]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_ryy_half_decomposition() { + let mut original = TickCircuit::new(); + original.tick().ryy(Angle64::HALF_TURN, &[(0, 1)]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_multi_gate_mixed() { + // A circuit with multiple rotation gates, some simplifiable, some not. + let mut original = TickCircuit::new(); + original + .tick() + .rz(Angle64::HALF_TURN, &[0]) + .rx(Angle64::QUARTER_TURN, &[1]); + original.tick().cx(&[(0, 1)]); + original + .tick() + .rz(Angle64::from_turn_ratio(1, 8), &[0]) + .ry(Angle64::THREE_QUARTERS_TURN, &[1]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_mixed_with_non_special_angles() { + // Mix of simplifiable and non-simplifiable rotations. + let mut original = TickCircuit::new(); + original + .tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(1, 6), &[1]); + original.tick().h(&[0, 1]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_rzz_half_in_larger_circuit() { + // RZZ decomposition embedded in a multi-tick circuit. + let mut original = TickCircuit::new(); + original.tick().h(&[0, 1]); + original.tick().rzz(Angle64::HALF_TURN, &[(0, 1)]); + original.tick().h(&[0, 1]); + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_all_single_qubit_simplifications() { + // One gate for every single-qubit entry in the mapping table. + let seventh_eighth = Angle64::from_turn_ratio(7, 8); + let eighth = Angle64::from_turn_ratio(1, 8); + let mut original = TickCircuit::new(); + original + .tick() + .rz(Angle64::HALF_TURN, &[0]) // -> Z + .rz(Angle64::QUARTER_TURN, &[1]) // -> SZ + .rz(Angle64::THREE_QUARTERS_TURN, &[2]) // -> SZdg + .rz(eighth, &[3]); // -> T + original + .tick() + .rz(seventh_eighth, &[0]) // -> Tdg + .rx(Angle64::HALF_TURN, &[1]) // -> X + .rx(Angle64::QUARTER_TURN, &[2]) // -> SX + .rx(Angle64::THREE_QUARTERS_TURN, &[3]); // -> SXdg + original + .tick() + .ry(Angle64::HALF_TURN, &[0]) // -> Y + .ry(Angle64::QUARTER_TURN, &[1]) // -> SY + .ry(Angle64::THREE_QUARTERS_TURN, &[2]); // -> SYdg + let mut simplified = original.clone(); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + // ==================== is_identity_gate tests ==================== + + #[test] + fn identity_gate_i() { + let gate = Gate::i(&[0]); + assert!(is_identity_gate(&gate)); + } + + #[test] + fn identity_gate_idle() { + let gate = Gate::idle(1.0, vec![QubitId::from(0)]); + assert!(is_identity_gate(&gate)); + } + + #[test] + fn identity_gate_rz_zero() { + let gate = Gate::rz(Angle64::ZERO, &[0]); + assert!(is_identity_gate(&gate)); + } + + #[test] + fn identity_gate_rxx_zero() { + let gate = Gate::rxx(Angle64::ZERO, &[(0, 1)]); + assert!(is_identity_gate(&gate)); + } + + #[test] + fn not_identity_gate_rz_nonzero() { + let gate = Gate::rz(Angle64::QUARTER_TURN, &[0]); + assert!(!is_identity_gate(&gate)); + } + + #[test] + fn not_identity_gate_h() { + let gate = Gate::h(&[0]); + assert!(!is_identity_gate(&gate)); + } + + // ==================== is_self_inverse tests ==================== + + #[test] + fn self_inverse_x() { + assert!(is_self_inverse(GateType::X)); + } + + #[test] + fn self_inverse_cx() { + assert!(is_self_inverse(GateType::CX)); + } + + #[test] + fn not_self_inverse_sx() { + assert!(!is_self_inverse(GateType::SX)); + } + + // ==================== named_inverse tests ==================== + + #[test] + fn named_inverse_sx_sxdg() { + assert_eq!(named_inverse(GateType::SX), Some(GateType::SXdg)); + assert_eq!(named_inverse(GateType::SXdg), Some(GateType::SX)); + } + + #[test] + fn named_inverse_t_tdg() { + assert_eq!(named_inverse(GateType::T), Some(GateType::Tdg)); + assert_eq!(named_inverse(GateType::Tdg), Some(GateType::T)); + } + + #[test] + fn named_inverse_szz_szzdg() { + assert_eq!(named_inverse(GateType::SZZ), Some(GateType::SZZdg)); + assert_eq!(named_inverse(GateType::SZZdg), Some(GateType::SZZ)); + } + + #[test] + fn named_inverse_h_none() { + assert_eq!(named_inverse(GateType::H), None); + } + + // ==================== are_inverses tests ==================== + + #[test] + fn inverses_x_x() { + let a = Gate::x(&[0]); + let b = Gate::x(&[0]); + assert!(are_inverses(&a, &b)); + } + + #[test] + fn inverses_cx_cx() { + let a = Gate::cx(&[(0, 1)]); + let b = Gate::cx(&[(0, 1)]); + assert!(are_inverses(&a, &b)); + } + + #[test] + fn inverses_sx_sxdg() { + let a = Gate::sx(&[0]); + let b = Gate::sxdg(&[0]); + assert!(are_inverses(&a, &b)); + } + + #[test] + fn inverses_rz_neg() { + let angle = Angle64::QUARTER_TURN; + let a = Gate::rz(angle, &[0]); + let b = Gate::rz(-angle, &[0]); + assert!(are_inverses(&a, &b)); + } + + #[test] + fn not_inverses_different_qubits() { + let a = Gate::x(&[0]); + let b = Gate::x(&[1]); + assert!(!are_inverses(&a, &b)); + } + + // ==================== RemoveIdentity tick tests ==================== + + #[test] + fn tick_remove_identity_i() { + let mut tc = TickCircuit::new(); + tc.tick(); + tc.ticks_mut()[0].add_gate(Gate::i(&[0])); + RemoveIdentity.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + } + + #[test] + fn tick_remove_identity_rz_zero() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::ZERO, &[0]); + RemoveIdentity.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + } + + #[test] + fn tick_remove_identity_preserves_nonzero() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + RemoveIdentity.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].gates().len(), 1); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::RZ); + } + + #[test] + fn tick_remove_identity_mixed() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.ticks_mut()[0].add_gate(Gate::i(&[1])); + RemoveIdentity.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].gates().len(), 1); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::H); + } + + // ==================== RemoveIdentity DAG tests ==================== + + #[test] + fn dag_remove_identity_i() { + let mut dag = DagCircuit::new(); + dag.add_gate(Gate::i(&[0])); + RemoveIdentity.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_remove_identity_rz_zero() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::ZERO, 0); + RemoveIdentity.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_remove_identity_preserves_h() { + let mut dag = DagCircuit::new(); + dag.h(0); + RemoveIdentity.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + } + + #[test] + fn dag_remove_identity_rewires() { + let mut dag = DagCircuit::new(); + dag.h(0); + dag.add_gate(Gate::i(&[0])); + dag.z(0); + let nodes_before = dag.nodes(); + assert_eq!(nodes_before.len(), 3); + RemoveIdentity.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 2); + } + + // ==================== CancelInverses tick tests ==================== + + #[test] + fn tick_cancel_h_h() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().h(&[0]); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_x_x() { + let mut tc = TickCircuit::new(); + tc.tick().x(&[0]); + tc.tick().x(&[0]); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_sx_sxdg() { + let mut tc = TickCircuit::new(); + tc.tick().sx(&[0]); + tc.tick(); + tc.ticks_mut()[1].add_gate(Gate::sxdg(&[0])); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_t_tdg() { + let mut tc = TickCircuit::new(); + tc.tick().t(&[0]); + tc.tick(); + tc.ticks_mut()[1].add_gate(Gate::tdg(&[0])); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_cx_cx() { + let mut tc = TickCircuit::new(); + tc.tick().cx(&[(0, 1)]); + tc.tick().cx(&[(0, 1)]); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_rz_neg() { + let angle = Angle64::QUARTER_TURN; + let mut tc = TickCircuit::new(); + tc.tick().rz(angle, &[0]); + tc.tick().rz(-angle, &[0]); + CancelInverses.apply_tick(&mut tc); + assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[1].gates().is_empty()); + } + + #[test] + fn tick_cancel_nested() { + // H T Tdg H -> all cancel + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().t(&[0]); + tc.tick(); + tc.ticks_mut()[2].add_gate(Gate::tdg(&[0])); + tc.tick().h(&[0]); + CancelInverses.apply_tick(&mut tc); + for tick in tc.ticks() { + assert!(tick.gates().is_empty()); + } + } + + #[test] + fn tick_no_cancel_with_intervening_gate() { + // H X H -> no cancellation (X on same qubit between the H gates) + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().x(&[0]); + tc.tick().h(&[0]); + CancelInverses.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].gates().len(), 1); + assert_eq!(tc.ticks()[1].gates().len(), 1); + assert_eq!(tc.ticks()[2].gates().len(), 1); + } + + #[test] + fn tick_no_cancel_different_qubits() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().h(&[1]); + CancelInverses.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].gates().len(), 1); + assert_eq!(tc.ticks()[1].gates().len(), 1); + } + + // ==================== CancelInverses DAG tests ==================== + + #[test] + fn dag_cancel_h_h() { + let mut dag = DagCircuit::new(); + dag.h(0).h(0); + CancelInverses.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_cancel_cx_cx() { + let mut dag = DagCircuit::new(); + dag.cx(0, 1).cx(0, 1); + CancelInverses.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_cancel_rz_neg() { + let angle = Angle64::QUARTER_TURN; + let mut dag = DagCircuit::new(); + dag.rz(angle, 0).rz(-angle, 0); + CancelInverses.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 0); + } + + #[test] + fn dag_no_cancel_with_intervening_gate() { + let mut dag = DagCircuit::new(); + dag.h(0).x(0).h(0); + CancelInverses.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 3); + } + + // ==================== MergeAdjacentRotations tick tests ==================== + + #[test] + fn tick_merge_rz_rz() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + let gate = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .next() + .unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles[0], Angle64::HALF_TURN); + } + + #[test] + fn tick_merge_chain_of_three() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + let gate = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .next() + .unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles[0], Angle64::THREE_QUARTERS_TURN); + } + + #[test] + fn tick_merge_to_zero() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::THREE_QUARTERS_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + let gate = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .next() + .unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert!(gate.angles[0].is_zero()); + } + + #[test] + fn tick_merge_rzz() { + let mut tc = TickCircuit::new(); + tc.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + tc.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + MergeAdjacentRotations.apply_tick(&mut tc); + let gate = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .next() + .unwrap(); + assert_eq!(gate.gate_type, GateType::RZZ); + assert_eq!(gate.angles[0], Angle64::HALF_TURN); + } + + #[test] + fn tick_no_merge_different_types() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rx(Angle64::QUARTER_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + assert_eq!(tc.gate_count(), 2); + } + + #[test] + fn tick_no_merge_with_intervening_gate() { + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().h(&[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + MergeAdjacentRotations.apply_tick(&mut tc); + assert_eq!(tc.gate_count(), 3); + } + + // ==================== MergeAdjacentRotations DAG tests ==================== + + #[test] + fn dag_merge_rz_rz() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::QUARTER_TURN, 0) + .rz(Angle64::QUARTER_TURN, 0); + MergeAdjacentRotations.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + let node = dag.nodes()[0]; + let gate = dag.gate(node).unwrap(); + assert_eq!(gate.gate_type, GateType::RZ); + assert_eq!(gate.angles[0], Angle64::HALF_TURN); + } + + #[test] + fn dag_merge_chain_of_three() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::QUARTER_TURN, 0) + .rz(Angle64::QUARTER_TURN, 0) + .rz(Angle64::QUARTER_TURN, 0); + MergeAdjacentRotations.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + let node = dag.nodes()[0]; + let gate = dag.gate(node).unwrap(); + assert_eq!(gate.angles[0], Angle64::THREE_QUARTERS_TURN); + } + + #[test] + fn dag_no_merge_with_intervening_gate() { + let mut dag = DagCircuit::new(); + dag.rz(Angle64::QUARTER_TURN, 0) + .h(0) + .rz(Angle64::QUARTER_TURN, 0); + MergeAdjacentRotations.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 3); + } + + // ==================== New pass matrix equivalence tests ==================== + + #[test] + fn circuit_equiv_remove_identity() { + let mut original = TickCircuit::new(); + original.tick().h(&[0]); + original.ticks_mut()[0].add_gate(Gate::i(&[1])); + original.tick().cx(&[(0, 1)]); + let mut simplified = original.clone(); + RemoveIdentity.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_cancel_inverses() { + let mut original = TickCircuit::new(); + original.tick().h(&[0, 1]); + original.tick().sx(&[0]).t(&[1]); + original.tick(); + original.ticks_mut()[2].add_gate(Gate::sxdg(&[0])); + original.ticks_mut()[2].add_gate(Gate::tdg(&[1])); + original.tick().cx(&[(0, 1)]); + let mut simplified = original.clone(); + CancelInverses.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_merge_adjacent() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + let mut simplified = original.clone(); + MergeAdjacentRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_merge_then_simplify() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + original.tick().cx(&[(0, 1)]); + let mut simplified = original.clone(); + MergeAdjacentRotations.apply_tick(&mut simplified); + SimplifyRotations.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_merge_then_remove_identity() { + let mut original = TickCircuit::new(); + original.tick().h(&[0]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + original.tick().rz(Angle64::THREE_QUARTERS_TURN, &[0]); + original.tick().h(&[0]); + let mut simplified = original.clone(); + MergeAdjacentRotations.apply_tick(&mut simplified); + RemoveIdentity.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + #[test] + fn circuit_equiv_full_pipeline() { + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + original.tick().cx(&[(0, 1)]); + let mut simplified = original.clone(); + MergeAdjacentRotations.apply_tick(&mut simplified); + RemoveIdentity.apply_tick(&mut simplified); + SimplifyRotations.apply_tick(&mut simplified); + CancelInverses.apply_tick(&mut simplified); + assert_circuits_equiv(&original, &simplified); + } + + // ==================== Pass effectiveness analysis ==================== + + /// Count total gates across all ticks. + fn count_gates(tc: &TickCircuit) -> usize { + tc.ticks().iter().map(|t| t.gates().len()).sum() + } + + /// Apply the full pipeline and return (before, after) gate counts. + fn pipeline_stats(tc: &mut TickCircuit) -> (usize, usize) { + let before = count_gates(tc); + MergeAdjacentRotations.apply_tick(tc); + RemoveIdentity.apply_tick(tc); + SimplifyRotations.apply_tick(tc); + CancelInverses.apply_tick(tc); + PeepholeOptimize.apply_tick(tc); + let after = count_gates(tc); + (before, after) + } + + #[test] + fn analysis_pass_effectiveness() { + // -- Circuit 1: Redundant basis changes (common in compiled circuits) -- + // Pattern: H-CX-H on target qubit is equivalent to CZ + let mut c1 = TickCircuit::new(); + c1.tick().h(&[1]); + c1.tick().cx(&[(0, 1)]); + c1.tick().h(&[1]); + // PeepholeOptimize: H(target) CX(c,t) H(target) -> CZ(c,t) + let (b1, a1) = pipeline_stats(&mut c1); + + // -- Circuit 2: Rotation accumulation (variational / compiled) -- + let mut c2 = TickCircuit::new(); + c2.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(1, 8), &[1]); + c2.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(1, 8), &[1]); + c2.tick().cx(&[(0, 1)]); + c2.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(3, 8), &[1]); + c2.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::from_turn_ratio(3, 8), &[1]); + // Merge: RZ(pi/2)+RZ(pi/2)->RZ(pi) on q0, RZ(1/8)+RZ(1/8)->RZ(1/4) on q1 + // Simplify: RZ(pi)->Z, RZ(pi/4)->T, etc. + // After CX: same pattern again + let (b2, a2) = pipeline_stats(&mut c2); + + // -- Circuit 3: Inverse cancellation (from circuit composition) -- + let mut c3 = TickCircuit::new(); + // Subcircuit A applies some basis change + c3.tick().h(&[0, 1]); + c3.tick().sx(&[0]).t(&[1]); + c3.tick().cx(&[(0, 1)]); + // Subcircuit B undoes the basis change then does something else + c3.tick().cx(&[(0, 1)]); + c3.ticks_mut()[3].add_gate(Gate::sxdg(&[0])); + c3.ticks_mut()[3].add_gate(Gate::tdg(&[1])); + // Wait, this won't cancel because CX is between SX and SXdg on different ticks. + // Let me restructure: undo in reverse order + let mut c3 = TickCircuit::new(); + c3.tick().h(&[0, 1]); + c3.tick().t(&[0]).sx(&[1]); + c3.tick().cx(&[(0, 1)]); + c3.tick().cx(&[(0, 1)]); // CX*CX = I + c3.tick(); + c3.ticks_mut()[4].add_gate(Gate::tdg(&[0])); + c3.ticks_mut()[4].add_gate(Gate::sxdg(&[1])); + c3.tick().h(&[0, 1]); // H*H = I (but intervening gates block) + c3.tick().z(&[0]); // actual operation + let (b3, a3) = pipeline_stats(&mut c3); + + // -- Circuit 4: Zero-angle rotations (from parameterized circuits at theta=0) -- + let mut c4 = TickCircuit::new(); + c4.tick().h(&[0, 1, 2]); + c4.tick() + .rz(Angle64::ZERO, &[0]) + .rx(Angle64::ZERO, &[1]) + .ry(Angle64::ZERO, &[2]); + c4.tick().cx(&[(0, 1)]); + c4.tick().cz(&[(1, 2)]); + c4.tick().rz(Angle64::ZERO, &[0]).rz(Angle64::ZERO, &[1]); + c4.tick().h(&[0, 1, 2]); + let (b4, a4) = pipeline_stats(&mut c4); + + // -- Circuit 5: Mixed redundancies (realistic compiled output) -- + let mut c5 = TickCircuit::new(); + c5.tick().h(&[0, 1, 2, 3]); + // Rotation chain on q0 + c5.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]); + c5.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]); + c5.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]); + c5.tick() + .rz(Angle64::QUARTER_TURN, &[0]) + .rz(Angle64::QUARTER_TURN, &[1]); + // Identity rotations on q2, q3 + c5.tick().rz(Angle64::ZERO, &[2]).rx(Angle64::ZERO, &[3]); + // Two-qubit rotation merge + c5.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + c5.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); + // Self-inverse pair + c5.tick().h(&[2, 3]); + c5.tick().h(&[2, 3]); + c5.tick().cx(&[(0, 1)]).cz(&[(2, 3)]); + let (b5, a5) = pipeline_stats(&mut c5); + + // -- Circuit 6: Steane-style syndrome extraction fragment -- + let mut c6 = TickCircuit::new(); + // Ancilla prep + c6.tick().h(&[4, 5, 6]); + // CNOT fan-out + c6.tick().cx(&[(4, 0)]); + c6.tick().cx(&[(4, 1)]); + c6.tick().cx(&[(5, 1)]); + c6.tick().cx(&[(5, 2)]); + c6.tick().cx(&[(6, 2)]); + c6.tick().cx(&[(6, 3)]); + // Ancilla readout + c6.tick().h(&[4, 5, 6]); + // No redundancy here -- well-optimized QEC circuit + let (b6, a6) = pipeline_stats(&mut c6); + + println!(); + println!("=== Pass Pipeline Effectiveness ==="); + println!( + "Pipeline: MergeAdjacentRotations -> RemoveIdentity -> SimplifyRotations -> CancelInverses -> PeepholeOptimize" + ); + println!(); + println!( + "{:<45} {:>6} {:>6} {:>7}", + "Circuit", "Before", "After", "Saved" + ); + println!("{:-<45} {:->6} {:->6} {:->7}", "", "", "", ""); + for (name, b, a) in [ + ("1. Basis change (H-CX-H)", b1, a1), + ("2. Rotation accumulation", b2, a2), + ("3. Inverse cancellation (composed)", b3, a3), + ("4. Zero-angle rotations (theta=0)", b4, a4), + ("5. Mixed redundancies (compiled)", b5, a5), + ("6. QEC syndrome extraction", b6, a6), + ] { + let saved = b.saturating_sub(a); + let pct = if b > 0 { + saved as f64 / b as f64 * 100.0 + } else { + 0.0 + }; + println!("{name:<45} {b:>6} {a:>6} {saved:>4} ({pct:.0}%)"); + } + println!(); + } + + // ==================== peephole_conjugation helper tests ==================== + + #[test] + fn peephole_h_cx_target_to_cz() { + // H on CX target -> CZ + let gate = Gate::cx(&[(0, 1)]); + let result = peephole_conjugation(&gate, QubitId::from(1)); + assert!(result.is_some()); + let (gt, qubits) = result.unwrap(); + assert_eq!(gt, GateType::CZ); + assert_eq!(qubits[0], QubitId::from(0)); + assert_eq!(qubits[1], QubitId::from(1)); + } + + #[test] + fn peephole_h_cz_to_cx() { + // H on CZ qubit -> CX + let gate = Gate::cz(&[(0, 1)]); + let result = peephole_conjugation(&gate, QubitId::from(0)); + assert!(result.is_some()); + let (gt, qubits) = result.unwrap(); + assert_eq!(gt, GateType::CX); + assert_eq!(qubits[0], QubitId::from(1)); // other qubit becomes control + assert_eq!(qubits[1], QubitId::from(0)); // H qubit becomes target + } + + #[test] + fn peephole_h_cx_control_none() { + // H on CX control -> None (not a valid simplification) + let gate = Gate::cx(&[(0, 1)]); + assert!(peephole_conjugation(&gate, QubitId::from(0)).is_none()); + } + + #[test] + fn peephole_non_matching_none() { + // H with non-CX/CZ gate -> None + let gate = Gate::h(&[0]); + assert!(peephole_conjugation(&gate, QubitId::from(0)).is_none()); + } + + // ==================== PeepholeOptimize TickCircuit tests ==================== + + #[test] + fn peephole_tick_h_cx_h_to_cz() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[1]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[1]); + PeepholeOptimize.apply_tick(&mut tc); + // Should have 1 gate total: CZ(0,1) + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::CZ); + assert_eq!(gates[0].qubits[0], QubitId::from(0)); + assert_eq!(gates[0].qubits[1], QubitId::from(1)); + } + + #[test] + fn peephole_tick_h_cz_h_to_cx() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cz(&[(0, 1)]); + tc.tick().h(&[0]); + PeepholeOptimize.apply_tick(&mut tc); + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::CX); + assert_eq!(gates[0].qubits[0], QubitId::from(1)); // other is control + assert_eq!(gates[0].qubits[1], QubitId::from(0)); // H qubit is target + } + + #[test] + fn peephole_tick_no_match_wrong_qubit() { + // H on CX control qubit does not trigger + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[0]); + PeepholeOptimize.apply_tick(&mut tc); + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 3); // unchanged + } + + #[test] + fn peephole_tick_preserves_other_gates() { + // Surrounding gates are untouched + let mut tc = TickCircuit::new(); + tc.tick().x(&[2]); // unrelated gate + tc.tick().h(&[1]); + tc.tick().cx(&[(0, 1)]); + tc.tick().h(&[1]); + tc.tick().z(&[2]); // unrelated gate + PeepholeOptimize.apply_tick(&mut tc); + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 3); // X, CZ, Z + assert_eq!(gates[0].gate_type, GateType::X); + assert_eq!(gates[1].gate_type, GateType::CZ); + assert_eq!(gates[2].gate_type, GateType::Z); + } + + #[test] + fn peephole_tick_multiple_patterns() { + // Two independent H-CX-H patterns + let mut tc = TickCircuit::new(); + tc.tick().h(&[1]).h(&[3]); + tc.tick().cx(&[(0, 1)]).cx(&[(2, 3)]); + tc.tick().h(&[1]).h(&[3]); + PeepholeOptimize.apply_tick(&mut tc); + let gates: Vec<&Gate> = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gates) + .collect(); + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|g| g.gate_type == GateType::CZ)); + } + + // ==================== PeepholeOptimize DagCircuit tests ==================== + + #[test] + fn peephole_dag_h_cx_h_to_cz() { + let mut dag = DagCircuit::new(); + dag.h(1).cx(0, 1).h(1); + PeepholeOptimize.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + let node = dag.nodes()[0]; + let gate = dag.gate(node).unwrap(); + assert_eq!(gate.gate_type, GateType::CZ); + } + + #[test] + fn peephole_dag_h_cz_h_to_cx() { + let mut dag = DagCircuit::new(); + dag.h(0).cz(0, 1).h(0); + PeepholeOptimize.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 1); + let node = dag.nodes()[0]; + let gate = dag.gate(node).unwrap(); + assert_eq!(gate.gate_type, GateType::CX); + assert_eq!(gate.qubits[0], QubitId::from(1)); // other is control + assert_eq!(gate.qubits[1], QubitId::from(0)); // H qubit is target + } + + #[test] + fn peephole_dag_no_match() { + // H on CX control qubit does not trigger + let mut dag = DagCircuit::new(); + dag.h(0).cx(0, 1).h(0); + PeepholeOptimize.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 3); // unchanged + } + + // ==================== Peephole matrix equivalence tests ==================== + + #[test] + fn peephole_preserves_unitary_h_cx_h() { + // H(1) CX(0,1) H(1) should equal CZ(0,1) + let mut original = TickCircuit::new(); + original.tick().h(&[1]); + original.tick().cx(&[(0, 1)]); + original.tick().h(&[1]); + let mut optimized = original.clone(); + PeepholeOptimize.apply_tick(&mut optimized); + assert_circuits_equiv(&original, &optimized); + } + + #[test] + fn peephole_preserves_unitary_h_cz_h() { + // H(0) CZ(0,1) H(0) should equal CX(1,0) + let mut original = TickCircuit::new(); + original.tick().h(&[0]); + original.tick().cz(&[(0, 1)]); + original.tick().h(&[0]); + let mut optimized = original.clone(); + PeepholeOptimize.apply_tick(&mut optimized); + assert_circuits_equiv(&original, &optimized); + } + + #[test] + fn peephole_pipeline_with_peephole() { + // Full pipeline on a circuit combining rotation merging and peephole. + let mut original = TickCircuit::new(); + original.tick().rz(Angle64::QUARTER_TURN, &[0]).h(&[1]); + original.tick().rz(Angle64::QUARTER_TURN, &[0]); + original.tick().cx(&[(0, 1)]); + original.tick().h(&[1]); + let mut optimized = original.clone(); + MergeAdjacentRotations.apply_tick(&mut optimized); + RemoveIdentity.apply_tick(&mut optimized); + SimplifyRotations.apply_tick(&mut optimized); + CancelInverses.apply_tick(&mut optimized); + PeepholeOptimize.apply_tick(&mut optimized); + assert_circuits_equiv(&original, &optimized); + } + + // ==================== AbsorbBasisGates tick tests ==================== + + #[test] + fn tick_absorb_z_after_prep() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().z(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ stays + assert_eq!(tc.ticks()[1].len(), 0); // Z removed + } + + #[test] + fn tick_absorb_rz_after_prep() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().rz(Angle64::from_turn_ratio(3, 7), &[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); + assert_eq!(tc.ticks()[1].len(), 0); + } + + #[test] + fn tick_absorb_chain_after_prep() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().t(&[0]); + tc.tick().sz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); + assert_eq!(tc.ticks()[1].len(), 0); + assert_eq!(tc.ticks()[2].len(), 0); + } + + #[test] + fn tick_no_absorb_x_after_prep() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().x(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); + assert_eq!(tc.ticks()[1].len(), 1); // X stays + } + + #[test] + fn tick_absorb_before_measure() { + let mut tc = TickCircuit::new(); + tc.tick().sz(&[0]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 0); // SZ removed + assert_eq!(tc.ticks()[1].len(), 1); // MZ stays + } + + #[test] + fn tick_absorb_chain_before_measure() { + let mut tc = TickCircuit::new(); + tc.tick().t(&[0]); + tc.tick().sz(&[0]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 0); + assert_eq!(tc.ticks()[1].len(), 0); + assert_eq!(tc.ticks()[2].len(), 1); + } + + #[test] + fn tick_no_absorb_h_before_measure() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // H stays + assert_eq!(tc.ticks()[1].len(), 1); + } + + #[test] + fn tick_absorb_both_ends() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().t(&[0]); // absorbed by prep + tc.tick().x(&[0]); // breaks eigenstate + tc.tick().sz(&[0]); // absorbed by measure + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ + assert_eq!(tc.ticks()[1].len(), 0); // T removed + assert_eq!(tc.ticks()[2].len(), 1); // X stays + assert_eq!(tc.ticks()[3].len(), 0); // SZ removed + assert_eq!(tc.ticks()[4].len(), 1); // MZ + } + + #[test] + fn tick_z_diagonal_between_non_z_preserved() { + // PZ -> X -> T -> MZ + // Forward: T not absorbed (X breaks eigenstate) + // Backward: T absorbed (before MZ) + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().x(&[0]); + tc.tick().t(&[0]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ + assert_eq!(tc.ticks()[1].len(), 1); // X stays + assert_eq!(tc.ticks()[2].len(), 0); // T removed (before MZ) + assert_eq!(tc.ticks()[3].len(), 1); // MZ + } + + // ==================== AbsorbBasisGates DAG tests ==================== + + #[test] + fn dag_absorb_z_after_prep() { + let mut dag = DagCircuit::new(); + dag.pz(0); + dag.z(0); + dag.h(0); // non-Z-diagonal anchor + AbsorbBasisGates.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 2); // PZ + H remain + let topo = dag.topological_order(); + assert_eq!(dag.gate(topo[0]).unwrap().gate_type, GateType::Prep); + assert_eq!(dag.gate(topo[1]).unwrap().gate_type, GateType::H); + } + + #[test] + fn dag_absorb_before_measure() { + let mut dag = DagCircuit::new(); + dag.h(0); // non-Z-diagonal anchor + dag.sz(0); + dag.mz(0); + AbsorbBasisGates.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 2); // H + MZ remain + let topo = dag.topological_order(); + assert_eq!(dag.gate(topo[0]).unwrap().gate_type, GateType::H); + assert_eq!(dag.gate(topo[1]).unwrap().gate_type, GateType::Measure); + } + + // ==================== AbsorbBasisGates multi-qubit tests ==================== + + #[test] + fn tick_absorb_cz_after_two_preps() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().cz(&[(0, 1)]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ(0,1) stays + assert_eq!(tc.ticks()[1].len(), 0); // CZ removed + } + + #[test] + fn tick_no_absorb_cz_after_one_prep() { + // Only qubit 0 is prepped; qubit 1 is not in Z eigenstate. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().cz(&[(0, 1)]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // PZ stays + assert_eq!(tc.ticks()[1].len(), 1); // CZ stays + } + + #[test] + fn tick_absorb_cz_before_two_measures() { + let mut tc = TickCircuit::new(); + tc.tick().cz(&[(0, 1)]); + tc.tick().mz(&[0, 1]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 0); // CZ removed + assert_eq!(tc.ticks()[1].len(), 1); // MZ(0,1) stays + } + + #[test] + fn tick_no_absorb_cz_before_one_measure() { + // Only qubit 0 is measured; qubit 1 continues. + let mut tc = TickCircuit::new(); + tc.tick().cz(&[(0, 1)]); + tc.tick().mz(&[0]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); // CZ stays + assert_eq!(tc.ticks()[1].len(), 1); + } + + #[test] + fn tick_absorb_szz_after_two_preps() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().szz(&[(0, 1)]); + AbsorbBasisGates.apply_tick(&mut tc); + assert_eq!(tc.ticks()[1].len(), 0); // SZZ removed + } + + #[test] + fn dag_absorb_cz_after_two_preps() { + let mut dag = DagCircuit::new(); + dag.pz(0); + dag.pz(1); + dag.cz(0, 1); + dag.h(0); // anchor + dag.h(1); // anchor + AbsorbBasisGates.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 4); // 2 PZ + 2 H, CZ removed + } + + #[test] + fn dag_absorb_cz_before_two_measures() { + let mut dag = DagCircuit::new(); + dag.h(0); // anchor + dag.h(1); // anchor + dag.cz(0, 1); + dag.mz(0); + dag.mz(1); + AbsorbBasisGates.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 4); // 2 H + 2 MZ, CZ removed + } + + // ==================== PassPipeline tests ==================== + + #[test] + fn pipeline_empty_is_noop() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().x(&[0]); + let pipeline = PassPipeline::new(); + pipeline.apply_tick(&mut tc); + assert_eq!(tc.ticks()[0].len(), 1); + assert_eq!(tc.ticks()[1].len(), 1); + } + + #[test] + fn pipeline_applies_passes_in_order() { + // RZ(pi/4) RZ(pi/4) -> merge to RZ(pi/2) -> simplify to SZ + let mut tc = TickCircuit::new(); + tc.tick().rz(Angle64::from_turn_ratio(1, 8), &[0]); + tc.tick().rz(Angle64::from_turn_ratio(1, 8), &[0]); + let pipeline = PassPipeline::new() + .then(MergeAdjacentRotations) + .then(SimplifyRotations); + pipeline.apply_tick(&mut tc); + assert_eq!(count_gates(&tc), 1); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::SZ); + } + + #[test] + fn pipeline_full_tick() { + // PZ -> T -> RZ(q) -> RZ(q) -> H -> H -> MZ + // AbsorbBasisGates removes T (after prep), merging combines RZs, + // CancelInverses removes H-H pair. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().t(&[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().rz(Angle64::QUARTER_TURN, &[0]); + tc.tick().h(&[0]); + tc.tick().h(&[0]); + tc.tick().mz(&[0]); + let pipeline = PassPipeline::new() + .then(AbsorbBasisGates) + .then(MergeAdjacentRotations) + .then(RemoveIdentity) + .then(SimplifyRotations) + .then(CancelInverses); + pipeline.apply_tick(&mut tc); + // PZ stays, T and both RZs absorbed (after PZ), H+H cancelled, MZ stays + assert_eq!(count_gates(&tc), 2); // PZ + MZ + } + + #[test] + fn pipeline_full_dag() { + let mut dag = DagCircuit::new(); + dag.pz(0); + dag.z(0); // absorbed after prep + dag.h(0); + dag.h(0); // cancel with previous H + dag.mz(0); + let pipeline = PassPipeline::new() + .then(AbsorbBasisGates) + .then(CancelInverses); + pipeline.apply_dag(&mut dag); + assert_eq!(dag.gate_count(), 2); // PZ + MZ + } + + #[test] + fn pipeline_default_is_empty() { + let pipeline = PassPipeline::default(); + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + pipeline.apply_tick(&mut tc); + assert_eq!(count_gates(&tc), 1); + } + + // ==================== CompactTicks tests ==================== + + #[test] + fn compact_independent_gates_merge_into_one_tick() { + // H(0) and X(1) are on different qubits -- can be parallel. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().x(&[1]); + assert_eq!(tc.num_ticks(), 2); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 1); + assert_eq!(tc.ticks()[0].len(), 2); + } + + #[test] + fn compact_dependent_gates_stay_sequential() { + // H(0) then X(0) -- same qubit, must stay in order. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().x(&[0]); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 2); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::H); + assert_eq!(tc.ticks()[1].gates()[0].gate_type, GateType::X); + } + + #[test] + fn compact_removes_empty_ticks() { + // After CancelInverses there may be empty ticks. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().h(&[0]); // will be cancelled + tc.tick().x(&[0]); + CancelInverses.apply_tick(&mut tc); + // Now ticks 0 and 1 are empty, tick 2 has X. + assert_eq!(tc.num_ticks(), 3); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 1); + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::X); + } + + #[test] + fn compact_empty_circuit() { + let mut tc = TickCircuit::new(); + tc.tick(); // empty tick + tc.tick(); // another empty tick + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 0); + } + + #[test] + fn compact_already_optimal() { + // All gates on different qubits in one tick -- already optimal. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]).x(&[1]).z(&[2]); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 1); + assert_eq!(tc.ticks()[0].len(), 3); + } + + #[test] + fn compact_diamond_pattern() { + // PZ(0,1) -> H(0), X(1) -> CX(0,1) -> MZ(0,1) + // Spread across 4 ticks but H and X can share a tick. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().h(&[0]); + tc.tick().x(&[1]); + tc.tick().cx(&[(0, 1)]); + tc.tick().mz(&[0, 1]); + assert_eq!(tc.num_ticks(), 5); + CompactTicks.apply_tick(&mut tc); + // PZ(0,1) | H(0)+X(1) | CX(0,1) | MZ(0,1) = 4 ticks + assert_eq!(tc.num_ticks(), 4); + assert_eq!(tc.ticks()[1].len(), 2); // H and X merged + } + + #[test] + fn compact_preserves_gate_order_per_qubit() { + // Ensure per-qubit ordering is maintained. + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().t(&[0]); + tc.tick().sz(&[0]); + CompactTicks.apply_tick(&mut tc); + assert_eq!(tc.num_ticks(), 3); // all same qubit, no compaction + assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::H); + assert_eq!(tc.ticks()[1].gates()[0].gate_type, GateType::T); + assert_eq!(tc.ticks()[2].gates()[0].gate_type, GateType::SZ); + } + + #[test] + fn compact_in_pipeline() { + // Full pipeline: absorb + cancel + compact. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().t(&[0]); // absorbed after prep + tc.tick().h(&[1]); + tc.tick().x(&[0]); + tc.tick().h(&[1]); // H-H cancel + tc.tick().mz(&[0, 1]); + let pipeline = PassPipeline::new() + .then(AbsorbBasisGates) + .then(CancelInverses) + .then(CompactTicks); + pipeline.apply_tick(&mut tc); + // After absorb+cancel: PZ(0,1), X(0), MZ(0,1) + // X(0) can't merge with PZ (qubit 0 busy) or MZ (qubit 0 busy). + assert_eq!(tc.num_ticks(), 3); + assert_eq!(count_gates(&tc), 3); + } +} diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index c5619244e..ac0ea8adc 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -195,6 +195,11 @@ impl Tick { &self.gates } + /// Get mutable access to the gates in this tick. + pub fn gates_mut(&mut self) -> &mut [Gate] { + &mut self.gates + } + /// Add a gate to this tick. pub fn add_gate(&mut self, gate: Gate) -> usize { let idx = self.gates.len(); @@ -543,13 +548,19 @@ impl TickCircuit { &self.ticks } + /// Get mutable access to all ticks. + pub fn ticks_mut(&mut self) -> &mut [Tick] { + &mut self.ticks + } + /// Export as a plain ASCII circuit diagram. /// /// Produces horizontal qubit-wire lines with gate symbols placed at each /// tick column. Two-qubit gates show `.`/`[X]` with `|` connectors. #[must_use] pub fn to_ascii(&self) -> String { - self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::ascii()) + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .ascii() } /// ASCII circuit diagram with ANSI color codes. @@ -559,47 +570,68 @@ impl TickCircuit { /// measurements, cyan for preparations. #[must_use] pub fn to_color_ascii(&self) -> String { - self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::color_ascii()) + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .ansi_color(true) + .build(), + ) + .ascii() } /// Unicode circuit diagram with box-drawing characters. #[must_use] pub fn to_unicode(&self) -> String { - self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::unicode()) + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .symbols(pecos_core::circuit_diagram::SymbolSet::Unicode) + .build(), + ) + .unicode() } /// Unicode circuit diagram with ANSI color codes. #[must_use] pub fn to_color_unicode(&self) -> String { - self.format_diagram(&pecos_core::circuit_diagram::DiagramOptions::color_unicode()) + self.render_with( + &pecos_core::circuit_diagram::DiagramStyle::builder() + .symbols(pecos_core::circuit_diagram::SymbolSet::Unicode) + .ansi_color(true) + .build(), + ) + .unicode() } /// Export as an SVG circuit diagram. #[must_use] pub fn to_svg(&self) -> String { - let (header, layers) = self.diagram_parts(); - crate::circuit_display::format_circuit_svg(&header, &layers) + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .svg() } /// Export as a `TikZ` `tikzpicture`. #[must_use] pub fn to_tikz(&self) -> String { - let (header, layers) = self.diagram_parts(); - crate::circuit_display::format_circuit_tikz(&header, &layers) + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .tikz() } /// Export as a Graphviz DOT digraph. #[must_use] pub fn to_dot(&self) -> String { - let (header, layers) = self.diagram_parts(); - crate::circuit_display::format_circuit_dot(&header, &layers) + self.render_with(&pecos_core::circuit_diagram::DiagramStyle::default()) + .dot() } - /// Deprecated: use [`to_color_ascii`](Self::to_color_ascii) instead. - #[deprecated(note = "renamed to to_color_ascii")] + /// Create a [`DiagramRenderer`](pecos_core::circuit_diagram::DiagramRenderer) + /// bound to a custom [`DiagramStyle`](pecos_core::circuit_diagram::DiagramStyle). #[must_use] - pub fn to_ascii_color(&self) -> String { - self.to_color_ascii() + pub fn render_with<'a>( + &self, + style: &'a pecos_core::circuit_diagram::DiagramStyle, + ) -> pecos_core::circuit_diagram::DiagramRenderer<'a> { + let (header, layers) = self.diagram_parts(); + let diagram = crate::circuit_display::build_diagram_or_empty(&layers, style.angle_unit); + pecos_core::circuit_diagram::DiagramRenderer::new(diagram, header, style) } fn diagram_parts(&self) -> (String, Vec>) { @@ -619,11 +651,6 @@ impl TickCircuit { (header, layers) } - fn format_diagram(&self, options: &pecos_core::circuit_diagram::DiagramOptions) -> String { - let (header, layers) = self.diagram_parts(); - crate::circuit_display::format_circuit(&header, &layers, options) - } - /// Get the next tick index that will be allocated. #[must_use] pub fn next_tick_index(&self) -> usize { diff --git a/examples/svg_demo.rs b/examples/svg_demo.rs new file mode 100644 index 000000000..42824d724 --- /dev/null +++ b/examples/svg_demo.rs @@ -0,0 +1,60 @@ +// Standalone binary to generate demo SVGs. +// Run from the PECOS workspace root: +// cargo run --example svg_demo + +use pecos_quantum::{DagCircuit, TickCircuit}; +use pecos_core::Angle64; +use std::fs; + +fn main() { + let dir = "/tmp/pecos_svg_demo"; + + // --- Circuit 1: All gate families in a TickCircuit --- + let mut tc = TickCircuit::new(); + // tick 0: prep + tc.tick().pz(&[0, 1, 2, 3]); + // tick 1: Pauli family + tc.tick().x(&[0]).y(&[1]).z(&[2]).i(&[3]); + // tick 2: S-like family + tc.tick().sx(&[0]).sy(&[1]).sz(&[2]); + // tick 3: H-like family + tc.tick().h(&[0, 1, 2, 3]); + // tick 4: Default (T gate) + tc.tick().t(&[0]).tdg(&[1]).rz(Angle64::QUARTER_TURN, &[2]); + // tick 5: multi-qubit + tc.tick().cx(&[(0, 1)]).cz(&[(2, 3)]); + // tick 6: measure + tc.tick().mz(&[0, 1, 2, 3]); + + let svg1 = tc.to_svg(); + fs::write(format!("{dir}/families.svg"), &svg1).unwrap(); + + // --- Circuit 2: Teleportation-style circuit --- + let mut tc2 = TickCircuit::new(); + tc2.tick().pz(&[0, 1, 2]); + tc2.tick().h(&[1]); + tc2.tick().cx(&[(1, 2)]); + tc2.tick().cx(&[(0, 1)]); + tc2.tick().h(&[0]); + tc2.tick().mz(&[0, 1]); + + let svg2 = tc2.to_svg(); + fs::write(format!("{dir}/teleport.svg"), &svg2).unwrap(); + + // --- Circuit 3: DagCircuit with mixed gates --- + let mut dag = DagCircuit::new(); + dag.pz(0); + dag.pz(1); + dag.h(0); + dag.sx(1); + dag.cx(0, 1); + dag.sz(0); + dag.h(1); + dag.mz(0); + dag.mz(1); + + let svg3 = dag.to_svg(); + fs::write(format!("{dir}/dag_mixed.svg"), &svg3).unwrap(); + + println!("SVGs written to {dir}/"); +} From f4bd8b9cffe7fcc244acd381a33e8d129b3fd35e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 3 Mar 2026 16:55:55 -0700 Subject: [PATCH 07/12] fix metadata/params --- .../src/pecos/circuits/quantum_circuit.py | 32 +- .../integration/test_quantum_circuits.py | 854 ++++++++++++++++++ 2 files changed, 875 insertions(+), 11 deletions(-) diff --git a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py index 84d44ae51..0119d11f6 100644 --- a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py +++ b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py @@ -262,14 +262,6 @@ def _add_gate_to_tick( symbol = symbol.symbol if hasattr(symbol, "symbol") else str(symbol) symbol_upper = symbol.upper() - # Convert locations to list, filtering out None values (placeholders for logical gates) - loc_list = [loc for loc in locations if loc is not None] - if not loc_list: - # No qubit operands -- store symbol as tick-level metadata - # (e.g., global barriers or marker gates) - tick_handle.meta("_symbol", symbol) - return - # Serialize params for storage (handle tuples -> lists) def make_serializable(obj: object) -> object: if isinstance(obj, tuple): @@ -282,6 +274,16 @@ def make_serializable(obj: object) -> object: params_json = json.dumps({k: make_serializable(v) for k, v in params.items()}) if params else "" + # Convert locations to list, filtering out None values (placeholders for logical gates) + loc_list = [loc for loc in locations if loc is not None] + if not loc_list: + # No qubit operands -- store symbol and params as tick-level metadata + # (e.g., global barriers or marker gates) + tick_handle.meta("_symbol", symbol) + if params_json: + tick_handle.meta("_params", params_json) + return + # Helper to store original symbol and params in metadata (idempotent - skips if qubit already used) def add_with_symbol( method: Callable[..., object], @@ -757,7 +759,15 @@ def _iter_tick( if not grouped: tick_symbol = tick_obj.get_attr("_symbol") if tick_symbol is not None: - yield tick_symbol, set(), {} + tick_params: JSONDict = {} + tick_params_json = tick_obj.get_attr("_params") + if tick_params_json is not None: + try: + tick_params = json.loads(tick_params_json) + tick_params = self._fix_json_meta(tick_params) + except json.JSONDecodeError: + pass + yield tick_symbol, set(), tick_params return # Yield grouped results @@ -898,7 +908,7 @@ def __setitem__(self, tick: int, item: tuple[GateDict, JSONDict]) -> None: # Get qubits to discard first tick_obj = self._inner.get_tick(actual_tick) if tick_obj is not None: - qubits_to_discard = list(tick_obj.active_qubits()) + qubits_to_discard = [int(q) for q in tick_obj.active_qubits()] if qubits_to_discard: self._inner.discard(qubits_to_discard, actual_tick) @@ -926,7 +936,7 @@ def __delitem__(self, tick: int) -> None: actual_tick = tick if tick >= 0 else len(self) + tick tick_obj = self._inner.get_tick(actual_tick) if tick_obj is not None: - qubits_to_discard = list(tick_obj.active_qubits()) + qubits_to_discard = [int(q) for q in tick_obj.active_qubits()] if qubits_to_discard: self._inner.discard(qubits_to_discard, actual_tick) diff --git a/python/quantum-pecos/tests/pecos/integration/test_quantum_circuits.py b/python/quantum-pecos/tests/pecos/integration/test_quantum_circuits.py index c87d4b1f2..0220bf998 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_quantum_circuits.py +++ b/python/quantum-pecos/tests/pecos/integration/test_quantum_circuits.py @@ -13,6 +13,9 @@ """Integration tests for quantum circuit operations.""" from __future__ import annotations +import copy +import json + from pecos.circuits import QuantumCircuit @@ -167,3 +170,854 @@ def test_tick_view_symbols_via_iter_ticks() -> None: symbols_per_tick.append(list(tick_view.symbols.keys())) assert symbols_per_tick == [["H"], ["CX"]] + + +def test_append_empty_locations_with_params() -> None: + """Test that params are preserved when appending a gate with empty locations.""" + qc = QuantumCircuit() + qc.append("cop", set(), a=1, b=2) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "cop" + assert locations == set() + assert params["a"] == 1 + assert params["b"] == 2 + + +def test_append_empty_locations_no_params() -> None: + """Test that a gate with empty locations and no params still round-trips.""" + qc = QuantumCircuit() + qc.append("barrier", set()) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "barrier" + assert locations == set() + assert params == {} + + +def test_custom_gate_with_arbitrary_params() -> None: + """Test that arbitrary keyword params are preserved on custom gates with qubits.""" + qc = QuantumCircuit() + qc.append("my_gate", {0}, foo="bar", count=42) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "my_gate" + assert locations == {0} + assert params["foo"] == "bar" + assert params["count"] == 42 + + +def test_known_gate_with_extra_params() -> None: + """Test that extra keyword params beyond angle are preserved on known gates.""" + qc = QuantumCircuit() + qc.append("RZ", {0}, angle=0.5, var_output={0: (1, 2)}) + + results = list(qc.items()) + assert len(results) == 1 + symbol, _locations, params = results[0] + assert symbol == "RZ" + assert params["angle"] == 0.5 + assert params["var_output"] == {0: (1, 2)} + + +# --------------------------------------------------------------------------- +# Constructor variants +# --------------------------------------------------------------------------- + + +def test_empty_constructor() -> None: + """Test default empty constructor.""" + qc = QuantumCircuit() + assert len(qc) == 0 + assert qc.metadata == {} + assert qc.qudits == set() + assert qc.active_qudits == [] + + +def test_constructor_with_num_ticks() -> None: + """Test constructor with integer creates reserved empty ticks.""" + qc = QuantumCircuit(3) + assert len(qc) == 3 + # All ticks should be empty + results = list(qc.items()) + assert results == [] + + +def test_constructor_with_gate_dicts() -> None: + """Test constructor with list of gate dictionaries.""" + qc = QuantumCircuit([{"H": {0}}, {"CX": {(0, 1)}}, {"measure Z": {0, 1}}]) + assert len(qc) == 3 + results = list(qc.items()) + assert len(results) == 3 + assert results[0][0] == "H" + assert results[1][0] == "CX" + + +def test_constructor_with_metadata() -> None: + """Test constructor with keyword metadata.""" + qc = QuantumCircuit(num_qubits=5, error_free=True) + assert qc.metadata["num_qubits"] == 5 + assert qc.metadata["error_free"] is True + + +# --------------------------------------------------------------------------- +# append() -- gate symbol variants +# --------------------------------------------------------------------------- + + +def test_append_gate_dict() -> None: + """Test append with gate dictionary (no locations arg).""" + qc = QuantumCircuit() + qc.append({"H": {0, 1}, "X": {2}}) + + results = list(qc.items()) + symbols = {r[0] for r in results} + assert symbols == {"H", "X"} + + +def test_append_string_symbol() -> None: + """Test append with string symbol and locations.""" + qc = QuantumCircuit() + qc.append("H", {0, 1, 2}) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][0] == "H" + assert results[0][1] == {0, 1, 2} + + +def test_append_two_qubit_gate() -> None: + """Test append with two-qubit gate locations as tuples.""" + qc = QuantumCircuit() + qc.append("CX", {(0, 1), (2, 3)}) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][0] == "CX" + assert results[0][1] == {(0, 1), (2, 3)} + + +def test_append_prep_and_measure() -> None: + """Test append with prep and measure gates.""" + qc = QuantumCircuit() + qc.append("init |0>", {0, 1}) + qc.append("measure Z", {0, 1}) + + results = list(qc.items()) + assert len(results) == 2 + assert results[0][0] == "init |0>" + assert results[1][0] == "measure Z" + + +def test_append_rotation_gate_with_angle() -> None: + """Test append with rotation gate using angle param.""" + qc = QuantumCircuit() + qc.append("RX", {0}, angle=1.57) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "RX" + assert locations == {0} + assert params["angle"] == 1.57 + + +def test_append_rotation_gate_with_angles_tuple() -> None: + """Test append with rotation gate using angles tuple param.""" + qc = QuantumCircuit() + qc.append("RZ", {0, 1}, angles=(0.5,)) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][2]["angles"] == (0.5,) + + +def test_append_r1xy_gate() -> None: + """Test R1XY gate with theta and phi angles.""" + qc = QuantumCircuit() + qc.append("R1XY", {0}, angles=(0.3, 0.7)) + + results = list(qc.items()) + assert len(results) == 1 + symbol, _, params = results[0] + assert symbol == "R1XY" + assert params["angles"] == (0.3, 0.7) + + +def test_append_two_qubit_rotation() -> None: + """Test two-qubit rotation gate with angle.""" + qc = QuantumCircuit() + qc.append("RZZ", {(0, 1)}, angle=0.25) + + results = list(qc.items()) + assert len(results) == 1 + symbol, locations, params = results[0] + assert symbol == "RZZ" + assert (0, 1) in locations + assert params["angle"] == 0.25 + + +# --------------------------------------------------------------------------- +# append() -- params round-trip +# --------------------------------------------------------------------------- + + +def test_params_with_qec_metadata() -> None: + """Test params round-trip with QEC-style metadata (ancilla_ticks, datas, etc.).""" + qc = QuantumCircuit() + qc.append( + "X check", + set(), + ancilla_ticks=0, + data_ticks=[2, 4, 3, 5], + meas_ticks=7, + datas=[1, 2, 3, 4], + ancillas=0, + ) + + results = list(qc.items()) + assert len(results) == 1 + _, _, params = results[0] + assert params["ancilla_ticks"] == 0 + assert params["data_ticks"] == [2, 4, 3, 5] + assert params["meas_ticks"] == 7 + assert params["datas"] == [1, 2, 3, 4] + assert params["ancillas"] == 0 + + +def test_params_with_boolean_values() -> None: + """Test params with boolean values.""" + qc = QuantumCircuit() + qc.append("H", {0}, error_free=True, noiseless=False) + + results = list(qc.items()) + _, _, params = results[0] + assert params["error_free"] is True + assert params["noiseless"] is False + + +def test_params_with_string_values() -> None: + """Test params with string values.""" + qc = QuantumCircuit() + qc.append("my_gate", {0}, label="ancilla_prep", kind="stabilizer") + + results = list(qc.items()) + _, _, params = results[0] + assert params["label"] == "ancilla_prep" + assert params["kind"] == "stabilizer" + + +def test_params_with_nested_dict() -> None: + """Test params with nested dictionary values.""" + qc = QuantumCircuit() + qc.append("H", {0}, config={"depth": 3, "rounds": 10}) + + results = list(qc.items()) + _, _, params = results[0] + assert params["config"] == {"depth": 3, "rounds": 10} + + +# --------------------------------------------------------------------------- +# update() +# --------------------------------------------------------------------------- + + +def test_update_at_specific_tick() -> None: + """Test update adds gates to a specific existing tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("H", {2}) + qc.update("X", {1}, tick=0) + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert symbols == {"H", "X"} + + +def test_update_last_tick_default() -> None: + """Test update defaults to last tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.update("X", {1}) + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert symbols == {"H", "X"} + + +def test_update_with_gate_dict() -> None: + """Test update with gate dictionary form.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.update({"X": {1}, "Z": {2}}) + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert symbols == {"H", "X", "Z"} + + +def test_update_with_params() -> None: + """Test update passes params through to the gate on a free qubit.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.update("measure Z", {1}, tick=0, forced_outcome=0) + + results = list(qc.items(tick=0)) + measure_results = [r for r in results if r[0] == "measure Z"] + assert len(measure_results) == 1 + assert measure_results[0][2].get("forced_outcome") == 0 + + +def test_update_emptyappend() -> None: + """Test update with emptyappend on empty circuit.""" + qc = QuantumCircuit() + assert len(qc) == 0 + qc.update("H", {0}, emptyappend=True) + assert len(qc) == 1 + results = list(qc.items()) + assert results[0][0] == "H" + + +def test_update_negative_tick() -> None: + """Test update with negative tick index.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + qc.update("Z", {2}, tick=-2) # Should target tick 0 + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert "Z" in symbols + + +# --------------------------------------------------------------------------- +# discard() +# --------------------------------------------------------------------------- + + +def test_discard_single_qubit() -> None: + """Test discard removes a single qubit from a tick.""" + qc = QuantumCircuit() + qc.append("H", {0, 1, 2}) + qc.discard({1}) + + results = list(qc.items(tick=-1)) + assert len(results) == 1 + assert 1 not in results[0][1] + + +def test_discard_at_specific_tick() -> None: + """Test discard at a specific tick index.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("X", {0, 1}) + qc.discard({0}, tick=0) + + results = list(qc.items(tick=0)) + for _, locations, _ in results: + assert 0 not in locations + + # Tick 1 should be unaffected + results = list(qc.items(tick=1)) + all_locs = set() + for _, locations, _ in results: + all_locs.update(locations) + assert 0 in all_locs + + +# --------------------------------------------------------------------------- +# items() iteration +# --------------------------------------------------------------------------- + + +def test_items_all_ticks() -> None: + """Test items() iterates across all ticks.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + qc.append("Z", {2}) + + results = list(qc.items()) + assert len(results) == 3 + symbols = [r[0] for r in results] + assert symbols == ["H", "X", "Z"] + + +def test_items_specific_tick() -> None: + """Test items(tick=N) iterates only that tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + results = list(qc.items(tick=1)) + assert len(results) == 1 + assert results[0][0] == "X" + + +def test_items_negative_tick() -> None: + """Test items(tick=-1) iterates last tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + results = list(qc.items(tick=-1)) + assert len(results) == 1 + assert results[0][0] == "X" + + +def test_items_yields_symbol_locations_params() -> None: + """Test that items() yields (symbol, locations, params) tuples.""" + qc = QuantumCircuit() + qc.append("RZ", {0}, angle=0.5) + + for symbol, locations, params in qc.items(): + assert isinstance(symbol, str) + assert isinstance(locations, set) + assert isinstance(params, dict) + + +def test_items_same_symbol_same_params_merged() -> None: + """Test that gates with same symbol and params in the same tick have locations merged.""" + qc = QuantumCircuit() + qc.append({"H": {0, 1, 2}}) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][1] == {0, 1, 2} + + +def test_items_multiple_gate_types_in_tick() -> None: + """Test items with multiple gate types in same tick via gate dict.""" + qc = QuantumCircuit() + qc.append({"H": {0}, "X": {1}, "Z": {2}}) + + results = list(qc.items()) + assert len(results) == 3 + symbols = {r[0] for r in results} + assert symbols == {"H", "X", "Z"} + + +# --------------------------------------------------------------------------- +# iter_ticks() +# --------------------------------------------------------------------------- + + +def test_iter_ticks_yields_tick_view() -> None: + """Test iter_ticks yields (TickView, tick_index, metadata).""" + qc = QuantumCircuit(num_qubits=2) + qc.append("H", {0}) + qc.append("CX", {(0, 1)}) + + ticks = list(qc.iter_ticks()) + assert len(ticks) == 2 + for tick_view, tick_idx, meta in ticks: + assert isinstance(tick_idx, int) + assert meta == qc.metadata + # TickView should support items() + results = list(tick_view.items()) + assert len(results) >= 1 + + +def test_iter_ticks_metadata_is_circuit_metadata() -> None: + """Test that iter_ticks yields the circuit-level metadata for every tick.""" + qc = QuantumCircuit(label="test_circuit") + qc.append("H", {0}) + qc.append("X", {1}) + + for _, _, meta in qc.iter_ticks(): + assert meta["label"] == "test_circuit" + + +# --------------------------------------------------------------------------- +# Indexing: __getitem__, __setitem__, __delitem__ +# --------------------------------------------------------------------------- + + +def test_getitem_returns_tick_view() -> None: + """Test qc[i] returns a TickView for that tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + tick0 = qc[0] + results = list(tick0.items()) + assert results[0][0] == "H" + + tick1 = qc[1] + results = list(tick1.items()) + assert results[0][0] == "X" + + +def test_getitem_negative_index() -> None: + """Test qc[-1] returns last tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + tick = qc[-1] + results = list(tick.items()) + assert results[0][0] == "X" + + +def test_delitem_clears_tick() -> None: + """Test del qc[i] clears the tick.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + del qc[0] + + results = list(qc.items(tick=0)) + assert results == [] + # Length should remain the same (tick is cleared, not removed) + assert len(qc) == 2 + + +# --------------------------------------------------------------------------- +# __len__, __iter__, __str__ +# --------------------------------------------------------------------------- + + +def test_len() -> None: + """Test len(qc) returns number of ticks.""" + qc = QuantumCircuit() + assert len(qc) == 0 + qc.append("H", {0}) + assert len(qc) == 1 + qc.append("X", {1}) + assert len(qc) == 2 + + +def test_iter() -> None: + """Test iterating over qc yields same as items().""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("X", {1}) + + from_iter = list(qc) + from_items = list(qc.items()) + assert len(from_iter) == len(from_items) + for a, b in zip(from_iter, from_items, strict=False): + assert a[0] == b[0] + + +def test_str_representation() -> None: + """Test string representation includes gate info.""" + qc = QuantumCircuit() + qc.append("H", {0}) + s = str(qc) + assert "H" in s + assert "QuantumCircuit" in s + + +def test_str_with_metadata() -> None: + """Test string representation includes metadata when present.""" + qc = QuantumCircuit(label="test") + qc.append("H", {0}) + s = str(qc) + assert "label" in s + + +# --------------------------------------------------------------------------- +# add_ticks() +# --------------------------------------------------------------------------- + + +def test_add_ticks() -> None: + """Test add_ticks creates empty ticks.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.add_ticks(2) + + assert len(qc) == 3 + # Empty ticks should yield nothing + results = list(qc.items(tick=1)) + assert results == [] + results = list(qc.items(tick=2)) + assert results == [] + + +# --------------------------------------------------------------------------- +# qudits and active_qudits +# --------------------------------------------------------------------------- + + +def test_qudits_tracks_all_used() -> None: + """Test qudits property returns all qubits ever used.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("CX", {(2, 3)}) + + assert qc.qudits == {0, 1, 2, 3} + + +def test_active_qudits_per_tick() -> None: + """Test active_qudits returns list of sets, one per tick.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("CX", {(2, 3)}) + + active = qc.active_qudits + assert len(active) == 2 + assert active[0] == {0, 1} + assert active[1] == {2, 3} + + +# --------------------------------------------------------------------------- +# copy() +# --------------------------------------------------------------------------- + + +def test_copy_preserves_gates() -> None: + """Test copy preserves all gates and params.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("RZ", {0}, angle=0.5) + qc.append("CX", {(0, 1)}) + + qc2 = qc.copy() + assert len(qc2) == len(qc) + + orig = list(qc.items()) + copied = list(qc2.items()) + assert len(orig) == len(copied) + for o, c in zip(orig, copied, strict=False): + assert o[0] == c[0] # symbol + assert o[1] == c[1] # locations + assert o[2] == c[2] # params + + +def test_copy_is_independent() -> None: + """Test that modifying a copy does not affect the original.""" + qc = QuantumCircuit() + qc.append("H", {0}) + + qc2 = qc.copy() + qc2.append("X", {1}) + + assert len(qc) == 1 + assert len(qc2) == 2 + + +def test_copy_preserves_metadata() -> None: + """Test copy preserves circuit metadata.""" + qc = QuantumCircuit(label="original") + qc.append("H", {0}) + + qc2 = qc.copy() + assert qc2.metadata["label"] == "original" + + +def test_copy_module() -> None: + """Test copy.copy works on QuantumCircuit.""" + qc = QuantumCircuit() + qc.append("H", {0}) + qc.append("RZ", {0}, angle=0.5) + + qc2 = copy.copy(qc) + assert len(qc2) == len(qc) + assert next(iter(qc.items()))[0] == next(iter(qc2.items()))[0] + + +# --------------------------------------------------------------------------- +# JSON round-trip +# --------------------------------------------------------------------------- + + +def test_json_roundtrip_basic() -> None: + """Test to_json_str / from_json_str round-trip.""" + qc = QuantumCircuit() + qc.append("H", {0, 1}) + qc.append("CX", {(0, 1)}) + qc.append("measure Z", {0, 1}) + + json_str = qc.to_json_str() + qc2 = QuantumCircuit.from_json_str(json_str) + + assert len(qc2) == len(qc) + orig = list(qc.items()) + restored = list(qc2.items()) + for o, r in zip(orig, restored, strict=False): + assert o[0] == r[0] + + +def test_json_roundtrip_with_params() -> None: + """Test JSON round-trip preserves gate params.""" + qc = QuantumCircuit() + qc.append("RZ", {0}, angle=0.5) + qc.append("my_gate", {1}, custom_param="hello", count=42) + + json_str = qc.to_json_str() + qc2 = QuantumCircuit.from_json_str(json_str) + + results = list(qc2.items()) + rz_params = results[0][2] + assert rz_params["angle"] == 0.5 + + custom_params = results[1][2] + assert custom_params["custom_param"] == "hello" + assert custom_params["count"] == 42 + + +def test_json_roundtrip_with_metadata() -> None: + """Test JSON round-trip preserves circuit metadata.""" + qc = QuantumCircuit(label="test", num_qubits=5) + qc.append("H", {0}) + + json_str = qc.to_json_str() + qc2 = QuantumCircuit.from_json_str(json_str) + + assert qc2.metadata["label"] == "test" + assert qc2.metadata["num_qubits"] == 5 + + +def test_json_roundtrip_var_output() -> None: + """Test JSON round-trip preserves var_output with int keys and tuple values.""" + qc = QuantumCircuit() + qc.append("measure Z", {0}, var_output={0: (1, 2)}) + + json_str = qc.to_json_str() + qc2 = QuantumCircuit.from_json_str(json_str) + + results = list(qc2.items()) + assert results[0][2]["var_output"] == {0: (1, 2)} + + +def test_json_str_is_valid_json() -> None: + """Test to_json_str produces valid JSON.""" + qc = QuantumCircuit(label="test") + qc.append("H", {0}) + + json_str = qc.to_json_str() + parsed = json.loads(json_str) + assert parsed["prog_type"] == "PECOS.QuantumCircuit" + assert "gates" in parsed + + +# --------------------------------------------------------------------------- +# TickView API +# --------------------------------------------------------------------------- + + +def test_tick_view_add() -> None: + """Test TickView.add() method adds gates.""" + qc = QuantumCircuit() + qc.append("H", {0}) + + tick = qc[0] + tick.add("X", {1}) + + results = list(qc.items(tick=0)) + symbols = {r[0] for r in results} + assert symbols == {"H", "X"} + + +def test_tick_view_discard() -> None: + """Test TickView.discard() method removes locations.""" + qc = QuantumCircuit() + qc.append("H", {0, 1, 2}) + + tick = qc[0] + tick.discard({1}) + + results = list(qc.items(tick=0)) + assert 1 not in results[0][1] + + +def test_tick_view_active_qudits() -> None: + """Test TickView.active_qudits property.""" + qc = QuantumCircuit() + qc.append({"H": {0}, "CX": {(1, 2)}}) + + tick = qc[0] + assert tick.active_qudits == {0, 1, 2} or tick.active_qudits == {0, (1, 2)} + + +def test_tick_view_metadata() -> None: + """Test TickView.metadata returns circuit metadata.""" + qc = QuantumCircuit(label="test") + qc.append("H", {0}) + + tick = qc[0] + assert tick.metadata["label"] == "test" + + +def test_tick_view_str() -> None: + """Test TickView string representation.""" + qc = QuantumCircuit() + qc.append("H", {0}) + + tick = qc[0] + s = str(tick) + assert "H" in s + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +def test_multiple_appends_increment_ticks() -> None: + """Test each append creates a new tick.""" + qc = QuantumCircuit() + for i in range(5): + qc.append("H", {i}) + assert len(qc) == 5 + + +def test_empty_gate_dict_append() -> None: + """Test appending an empty gate dict.""" + qc = QuantumCircuit() + qc.append({}) + # Empty tick should exist but yield nothing + assert len(qc) == 0 or list(qc.items()) == [] + + +def test_gate_symbol_case_preserved() -> None: + """Test that the original gate symbol case is preserved in round-trip.""" + qc = QuantumCircuit() + qc.append("init |0>", {0}) + qc.append("measure Z", {0}) + + results = list(qc.items()) + assert results[0][0] == "init |0>" + assert results[1][0] == "measure Z" + + +def test_swap_gate() -> None: + """Test SWAP gate round-trips correctly.""" + qc = QuantumCircuit() + qc.append("SWAP", {(0, 1)}) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][0] == "SWAP" + assert (0, 1) in results[0][1] + + +def test_r2xxyyzz_gate() -> None: + """Test R2XXYYZZ gate preserves all three angles.""" + qc = QuantumCircuit() + qc.append("R2XXYYZZ", {(0, 1)}, angles=(0.1, 0.2, 0.3)) + + results = list(qc.items()) + assert len(results) == 1 + symbol, _, params = results[0] + assert symbol == "R2XXYYZZ" + assert len(params["angles"]) == 3 + + +def test_u_gate() -> None: + """Test U gate with three angle parameters.""" + qc = QuantumCircuit() + qc.append("U", {0}, angles=(0.1, 0.2, 0.3)) + + results = list(qc.items()) + assert len(results) == 1 + assert results[0][0] == "U" + assert results[0][2]["angles"] == (0.1, 0.2, 0.3) From d6c99f53696d4f278cffc612032d2009dc880749 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 3 Mar 2026 19:37:06 -0700 Subject: [PATCH 08/12] lint --- crates/pecos-qsim/src/graph_state.rs | 4 +- crates/pecos-qsim/src/graph_state_repr.rs | 23 ++- .../pecos-qsim/src/stabilizer_test_utils.rs | 4 +- crates/pecos-quantum/examples/style_demo.rs | 173 ++++++++++++------ crates/pecos-quantum/src/circuit_display.rs | 6 +- 5 files changed, 133 insertions(+), 77 deletions(-) diff --git a/crates/pecos-qsim/src/graph_state.rs b/crates/pecos-qsim/src/graph_state.rs index ad2cf37c6..f67a44d09 100644 --- a/crates/pecos-qsim/src/graph_state.rs +++ b/crates/pecos-qsim/src/graph_state.rs @@ -191,8 +191,8 @@ impl GraphStateSim { let (len, steps) = VOP_DECOMP[self.vops[v].index() as usize]; // Apply steps in forward order: each step reduces the VOP toward identity - for i in 0..len as usize { - if steps[i] == 0 { + for &step in &steps[..len as usize] { + if step == 0 { // U: local complement on v self.local_complement(v); } else { diff --git a/crates/pecos-qsim/src/graph_state_repr.rs b/crates/pecos-qsim/src/graph_state_repr.rs index 5417fb09d..bbc03769e 100644 --- a/crates/pecos-qsim/src/graph_state_repr.rs +++ b/crates/pecos-qsim/src/graph_state_repr.rs @@ -115,13 +115,10 @@ impl GraphState { assert_eq!(row.len(), n, "adjacency matrix must be square"); } let mut gs = Self::new(n); - for i in 0..n { + for (i, row) in matrix.iter().enumerate() { for j in (i + 1)..n { - assert_eq!( - matrix[i][j], matrix[j][i], - "adjacency matrix must be symmetric" - ); - if matrix[i][j] { + assert_eq!(row[j], matrix[j][i], "adjacency matrix must be symmetric"); + if row[j] { gs.neighbors[i].insert(j); gs.neighbors[j].insert(i); } @@ -732,7 +729,7 @@ fn vop_cell_color(idx: u8) -> CellColor { 6 | 9 | 10 | 18 => CellColor::XZMix, // X<->Z (H-type) 12 | 13 | 17 | 19 => CellColor::YZMix, // Y<->Z (SX-type) 7 | 8 | 11 | 14 | 15 | 16 | 21 | 22 => CellColor::XYZMix, // Cyclic - _ => CellColor::None, + _ => panic!("invalid Clifford index: {idx} (expected 0..24)"), } } @@ -744,7 +741,7 @@ fn vop_gate_family(idx: u8) -> GateFamily { 4 | 5 | 9 | 10 | 12 | 13 => GateFamily::SLike, 6 | 17 | 18 | 19 | 20 | 23 => GateFamily::HLike, 7 | 8 | 11 | 14 | 15 | 16 | 21 | 22 => GateFamily::FLike, - _ => GateFamily::Default, + _ => panic!("invalid Clifford index: {idx} (expected 0..24)"), } } @@ -757,7 +754,7 @@ fn vop_saturated(idx: u8) -> bool { match idx { 0 | 2 | 5 | 6 | 7 | 11 | 13 | 16 | 17 | 18 | 20 | 21 => true, 1 | 3 | 4 | 8 | 9 | 10 | 12 | 14 | 15 | 19 | 22 | 23 => false, - _ => true, + _ => panic!("invalid Clifford index: {idx} (expected 0..24)"), } } @@ -839,7 +836,7 @@ fn tikz_coset_name(color: CellColor, saturated: bool) -> &'static str { (CellColor::YZMix, false) => "vopYZLt", (CellColor::XYZMix, true) => "vopCyclicFwd", (CellColor::XYZMix, false) => "vopCyclicInv", - _ => "black", + (other, _) => panic!("unexpected CellColor for VOP coset: {other:?}"), } } @@ -2299,8 +2296,10 @@ mod tests { fn render_with_custom_palette() { use pecos_core::{ColorPalette, ColorTriplet, GraphStyle}; - let mut palette = ColorPalette::default(); - palette.z_axis = ColorTriplet::new("#FF0000", "#880000", "#440000"); + let palette = ColorPalette { + z_axis: ColorTriplet::new("#FF0000", "#880000", "#440000"), + ..ColorPalette::default() + }; let style = GraphStyle::builder().palette(palette).build(); let gs = GraphState::linear_cluster(3); // pure: all identity (ZAxis coset) diff --git a/crates/pecos-qsim/src/stabilizer_test_utils.rs b/crates/pecos-qsim/src/stabilizer_test_utils.rs index a1a09894f..24ba1f218 100644 --- a/crates/pecos-qsim/src/stabilizer_test_utils.rs +++ b/crates/pecos-qsim/src/stabilizer_test_utils.rs @@ -119,14 +119,14 @@ macro_rules! stabilizer_test_suite { paste::paste! { #[test] fn []() { - use $crate::stabilizer_test_utils::{run_basic_stabilizer_test_suite, StabilizerSimulator}; + use $crate::stabilizer_test_utils::run_basic_stabilizer_test_suite; let mut sim = <$sim_type>::with_seed($num_qubits, 42); run_basic_stabilizer_test_suite(&mut sim, $num_qubits); } #[test] fn []() { - use $crate::stabilizer_test_utils::{run_full_stabilizer_test_suite, StabilizerSimulator}; + use $crate::stabilizer_test_utils::run_full_stabilizer_test_suite; let mut sim = <$sim_type>::with_seed($num_qubits, 42); run_full_stabilizer_test_suite(&mut sim, $num_qubits); } diff --git a/crates/pecos-quantum/examples/style_demo.rs b/crates/pecos-quantum/examples/style_demo.rs index 7855b1e0f..10a7a010c 100644 --- a/crates/pecos-quantum/examples/style_demo.rs +++ b/crates/pecos-quantum/examples/style_demo.rs @@ -10,6 +10,7 @@ use pecos_quantum::pass::{ AbsorbBasisGates, CancelInverses, CircuitPass, CompactTicks, MergeAdjacentRotations, PassPipeline, PeepholeOptimize, RemoveIdentity, SimplifyRotations, }; +use std::fmt::Write as _; use std::fs; fn build_circuit() -> TickCircuit { @@ -57,7 +58,7 @@ fn ansi_to_html(s: &str) -> String { // Handle compound codes like "1;34" (bold + color) let style = ansi_code_to_css(code); if let Some(css) = style { - out.push_str(&format!("")); + write!(out, "").unwrap(); in_span = true; } i = end + 1; @@ -237,34 +238,44 @@ fn main() { html.push_str("

SVG Outputs

\n
\n"); let r_default = tc.render_with(&default_style); - html.push_str(&format!( + write!( + html, "

Default

{}
", svg_block(&r_default.svg()) - )); + ) + .unwrap(); let r_custom = tc.render_with(&custom_palette); - html.push_str(&format!( + write!( + html, "

Custom Palette

{}
", svg_block(&r_custom.svg()) - )); + ) + .unwrap(); let r_mono = tc.render_with(&monochrome); - html.push_str(&format!( + write!( + html, "

Monochrome (color: false)

{}
", svg_block(&r_mono.svg()) - )); + ) + .unwrap(); let r_nodash = tc.render_with(&no_dashes); - html.push_str(&format!( + write!( + html, "

No Dashes (show_dashes: false)

{}
", svg_block(&r_nodash.svg()) - )); + ) + .unwrap(); let r_mono_nodash = tc.render_with(&mono_no_dashes); - html.push_str(&format!( + write!( + html, "

Monochrome + No Dashes

{}
", svg_block(&r_mono_nodash.svg()) - )); + ) + .unwrap(); html.push_str("
\n"); @@ -315,16 +326,20 @@ fn main() { let r_turns = angle_tc.render_with(&turns_style); html.push_str("
\n"); - html.push_str(&format!( + write!( + html, "

Radians (default)

{}{}
", pre_block(&escape_html(&r_rad.ascii())), svg_block(&r_rad.svg()), - )); - html.push_str(&format!( + ) + .unwrap(); + write!( + html, "

Turns

{}{}
", pre_block(&escape_html(&r_turns.ascii())), svg_block(&r_turns.svg()), - )); + ) + .unwrap(); html.push_str("
\n"); } @@ -357,16 +372,20 @@ fn main() { let r_after = simplified_tc.render_with(&style); html.push_str("
\n"); - html.push_str(&format!( + write!( + html, "

Before pass

{}{}
", pre_block(&escape_html(&r_before.ascii())), svg_block(&r_before.svg()), - )); - html.push_str(&format!( + ) + .unwrap(); + write!( + html, "

After SimplifyRotations

{}{}
", pre_block(&escape_html(&r_after.ascii())), svg_block(&r_after.svg()), - )); + ) + .unwrap(); html.push_str("
\n"); } @@ -393,16 +412,20 @@ fn main() { let r_after = optimized_tc.render_with(&style); html.push_str("
\n"); - html.push_str(&format!( + write!( + html, "

Before pass

{}{}
", pre_block(&escape_html(&r_before.ascii())), svg_block(&r_before.svg()), - )); - html.push_str(&format!( + ) + .unwrap(); + write!( + html, "

After PeepholeOptimize

{}{}
", pre_block(&escape_html(&r_after.ascii())), svg_block(&r_after.svg()), - )); + ) + .unwrap(); html.push_str("
\n"); } @@ -439,16 +462,20 @@ fn main() { let r_after = optimized_tc.render_with(&style); html.push_str("
\n"); - html.push_str(&format!( + write!( + html, "

Before pipeline

{}{}
", pre_block(&escape_html(&r_before.ascii())), svg_block(&r_before.svg()), - )); - html.push_str(&format!( + ) + .unwrap(); + write!( + html, "

After pipeline

{}{}
", pre_block(&escape_html(&r_after.ascii())), svg_block(&r_after.svg()), - )); + ) + .unwrap(); html.push_str("
\n"); } @@ -458,11 +485,13 @@ fn main() { use pecos_core::operator::{CX, H, T}; let circuit = T(1) * CX(0, 1) * H(0); let op_renderer = circuit.render_with(2, &default_style); - html.push_str(&format!( + write!( + html, "

T(1) * CX(0,1) * H(0)

\n{}{}", pre_block(&escape_html(&op_renderer.ascii())), svg_block(&op_renderer.svg()), - )); + ) + .unwrap(); } // -- Overlapping multi-qubit gates -- @@ -478,11 +507,13 @@ fn main() { let r = overlap_tc.render_with(&default_style); html.push_str("

CX(0,2) and CZ(1,3) in the same tick have overlapping visual ranges, \ so they are split into separate sub-columns with a bracket annotation.

\n"); - html.push_str(&format!( + write!( + html, "{}{}", pre_block(&escape_html(&r.ascii())), svg_block(&r.svg()), - )); + ) + .unwrap(); } // ================================================================ @@ -511,11 +542,13 @@ fn main() { ]; for (label, gs) in patterns { - html.push_str(&format!( + write!( + html, "

{label}

{}{}
", pre_block(&escape_html(&gs.to_ascii())), svg_block(&gs.render_with(&gs_default).svg()), - )); + ) + .unwrap(); } html.push_str("\n"); @@ -537,31 +570,41 @@ fn main() { gs.set_vop(5, CliffordFrame::from_index(8)); // F-like, cyclic inv html.push_str("
\n"); - html.push_str(&format!( + write!( + html, "

ASCII

{}
", pre_block(&escape_html(&gs.to_ascii())), - )); - html.push_str(&format!( + ) + .unwrap(); + write!( + html, "

Color ASCII

{}
", pre_block(&ansi_to_html(&gs.to_color_ascii())), - )); - html.push_str(&format!( + ) + .unwrap(); + write!( + html, "

Unicode

{}
", pre_block(&escape_html(&gs.to_unicode())), - )); - html.push_str(&format!( + ) + .unwrap(); + write!( + html, "

Color Unicode

{}
", pre_block(&ansi_to_html(&gs.to_color_unicode())), - )); + ) + .unwrap(); html.push_str("
\n"); let r = gs.render_with(&gs_default); html.push_str("
\n"); - html.push_str(&format!("

SVG

{}
", svg_block(&r.svg()),)); - html.push_str(&format!( + write!(html, "

SVG

{}
", svg_block(&r.svg())).unwrap(); + write!( + html, "

DOT

{}
", code_block("DOT", &r.dot()), - )); + ) + .unwrap(); html.push_str("
\n"); html.push_str(§ion("TikZ", &code_block("TikZ", &r.tikz()))); } @@ -584,11 +627,13 @@ fn main() { } let r = gs.render_with(&gs_default); - html.push_str(&format!( + write!( + html, "{}{}", pre_block(&escape_html(&gs.to_ascii())), svg_block(&r.svg()), - )); + ) + .unwrap(); } // -- SVG style variations -- @@ -603,10 +648,12 @@ fn main() { gs.set_vop(4, CliffordFrame::from_index(7)); // Default - html.push_str(&format!( + write!( + html, "

Default

{}
", svg_block(&gs.render_with(&gs_default).svg()), - )); + ) + .unwrap(); // Custom palette: warm tones let warm_palette = ColorPalette { @@ -618,10 +665,12 @@ fn main() { ..ColorPalette::default() }; let warm_style = GraphStyle::builder().palette(warm_palette).build(); - html.push_str(&format!( + write!( + html, "

Custom Palette (warm)

{}
", svg_block(&gs.render_with(&warm_style).svg()), - )); + ) + .unwrap(); // Monochrome: varying grey levels, uniform strokes, dashes + patterns let mono_palette = ColorPalette { @@ -651,10 +700,12 @@ fn main() { .show_dashes(true) .coset_patterns(mono_patterns) .build(); - html.push_str(&format!( + write!( + html, "

Monochrome (grey + patterns + dashes)

{}
", svg_block(&gs.render_with(&mono_style).svg()), - )); + ) + .unwrap(); // Custom family strokes let bold_families = FamilyPalette { @@ -664,10 +715,12 @@ fn main() { f_like: "#AA00AA".to_string(), }; let bold_style = GraphStyle::builder().family_strokes(bold_families).build(); - html.push_str(&format!( + write!( + html, "

Bold Family Strokes

{}
", svg_block(&gs.render_with(&bold_style).svg()), - )); + ) + .unwrap(); } html.push_str("\n"); @@ -683,16 +736,20 @@ fn main() { gs_after.local_complement(0); html.push_str("
\n"); - html.push_str(&format!( + write!( + html, "

Before LC(0)

{}{}
", pre_block(&escape_html(&gs_before.to_ascii())), svg_block(&gs_before.render_with(&gs_default).svg()), - )); - html.push_str(&format!( + ) + .unwrap(); + write!( + html, "

After LC(0)

{}{}
", pre_block(&escape_html(&gs_after.to_ascii())), svg_block(&gs_after.render_with(&gs_default).svg()), - )); + ) + .unwrap(); html.push_str("
\n"); } diff --git a/crates/pecos-quantum/src/circuit_display.rs b/crates/pecos-quantum/src/circuit_display.rs index 2236c2865..107f988b1 100644 --- a/crates/pecos-quantum/src/circuit_display.rs +++ b/crates/pecos-quantum/src/circuit_display.rs @@ -184,6 +184,7 @@ fn gate_color(gate_type: GateType) -> CellColor { GateType::SX | GateType::SXdg => CellColor::YZMix, GateType::SY | GateType::SYdg | GateType::H | GateType::CH => CellColor::XZMix, GateType::SZ | GateType::SZdg => CellColor::XYMix, + // No clear single-axis color: idle, alloc/free, multi-qubit, custom GateType::Idle | GateType::I | GateType::MeasureLeaked @@ -192,9 +193,8 @@ fn gate_color(gate_type: GateType) -> CellColor { | GateType::QFree | GateType::Custom | GateType::MeasCrosstalkGlobalPayload - | GateType::MeasCrosstalkLocalPayload => CellColor::None, - // Multi-qubit gates that don't have a clear single-axis color: - GateType::CX + | GateType::MeasCrosstalkLocalPayload + | GateType::CX | GateType::CY | GateType::CZ | GateType::CCX From 9d3f7cbdcaf27e85e19a5f29ff566cce0d9d8ec4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 3 Mar 2026 19:57:58 -0700 Subject: [PATCH 09/12] cleanup --- .../src/pecos/circuits/quantum_circuit.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py index 5606384e6..0119d11f6 100644 --- a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py +++ b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py @@ -262,14 +262,6 @@ def _add_gate_to_tick( symbol = symbol.symbol if hasattr(symbol, "symbol") else str(symbol) symbol_upper = symbol.upper() - # Convert locations to list, filtering out None values (placeholders for logical gates) - loc_list = [loc for loc in locations if loc is not None] - if not loc_list: - # No qubit operands -- store symbol as tick-level metadata - # (e.g., global barriers or marker gates) - tick_handle.meta("_symbol", symbol) - return - # Serialize params for storage (handle tuples -> lists) def make_serializable(obj: object) -> object: if isinstance(obj, tuple): @@ -767,7 +759,15 @@ def _iter_tick( if not grouped: tick_symbol = tick_obj.get_attr("_symbol") if tick_symbol is not None: - yield tick_symbol, set(), {} + tick_params: JSONDict = {} + tick_params_json = tick_obj.get_attr("_params") + if tick_params_json is not None: + try: + tick_params = json.loads(tick_params_json) + tick_params = self._fix_json_meta(tick_params) + except json.JSONDecodeError: + pass + yield tick_symbol, set(), tick_params return # Yield grouped results From 6b9c86328c91483cbade275a59c2cd1f407544a4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 3 Mar 2026 20:47:48 -0700 Subject: [PATCH 10/12] supporting more TickCircuit gates --- crates/pecos-core/src/gate_type.rs | 134 ++++++- crates/pecos-core/src/gates.rs | 206 ++++++++++ .../src/noise/biased_depolarizing.rs | 6 + .../pecos-engines/src/noise/depolarizing.rs | 6 + crates/pecos-engines/src/noise/utils.rs | 9 + crates/pecos-engines/src/quantum.rs | 58 ++- .../pecos-experimental/src/hugr_executor.rs | 6 + crates/pecos-qasm/src/engine.rs | 14 +- crates/pecos-qsim/src/circuit_executor.rs | 18 + crates/pecos-quantum/src/circuit_display.rs | 12 +- crates/pecos-quantum/src/tick_circuit.rs | 58 +++ crates/pecos-quest/src/quantum_engine.rs | 52 ++- .../pecos-rslib/src/dag_circuit_bindings.rs | 249 +++++++++++- .../src/pecos/circuits/quantum_circuit.py | 371 +++--------------- 14 files changed, 867 insertions(+), 332 deletions(-) diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index f42e02d57..6c1b35ba3 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -33,8 +33,10 @@ pub enum GateType { // H4 = 13 // H5 = 14 // H6 = 15 - // F = 16 - // Fdg = 17 + /// F gate (face gate) + F = 16, + /// F-dagger gate + Fdg = 17, // F2 = 18 // F2dg = 19 // F3 = 20 @@ -53,10 +55,14 @@ pub enum GateType { CX = 50, CY = 51, CZ = 52, - // SXX = 53 - // SXXdg = 54 - // SYY = 55 - // SYYdg = 56 + /// sqrt(XX) gate + SXX = 53, + /// sqrt(XX)-dagger gate + SXXdg = 54, + /// sqrt(YY) gate + SYY = 55, + /// sqrt(YY)-dagger gate + SYYdg = 56, SZZ = 57, SZZdg = 58, SWAP = 59, @@ -119,6 +125,8 @@ impl From for GateType { 8 => GateType::SZ, 9 => GateType::SZdg, 10 => GateType::H, + 16 => GateType::F, + 17 => GateType::Fdg, 30 => GateType::RX, 31 => GateType::RY, 32 => GateType::RZ, @@ -129,6 +137,10 @@ impl From for GateType { 50 => GateType::CX, 51 => GateType::CY, 52 => GateType::CZ, + 53 => GateType::SXX, + 54 => GateType::SXXdg, + 55 => GateType::SYY, + 56 => GateType::SYYdg, 57 => GateType::SZZ, 58 => GateType::SZZdg, 59 => GateType::SWAP, @@ -174,12 +186,18 @@ impl GateType { | GateType::SZ | GateType::SZdg | GateType::H + | GateType::F + | GateType::Fdg | GateType::T | GateType::Tdg | GateType::CX | GateType::CY | GateType::CZ | GateType::CH + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SZZ | GateType::SZZdg | GateType::SWAP @@ -233,6 +251,8 @@ impl GateType { | GateType::SZ | GateType::SZdg | GateType::H + | GateType::F + | GateType::Fdg | GateType::RX | GateType::RY | GateType::RZ @@ -256,6 +276,10 @@ impl GateType { | GateType::CY | GateType::CZ | GateType::CH + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SZZ | GateType::SZZdg | GateType::SWAP @@ -333,6 +357,8 @@ impl fmt::Display for GateType { GateType::SZ => write!(f, "SZ"), GateType::SZdg => write!(f, "SZdg"), GateType::H => write!(f, "H"), + GateType::F => write!(f, "F"), + GateType::Fdg => write!(f, "Fdg"), GateType::RX => write!(f, "RX"), GateType::RY => write!(f, "RY"), GateType::RZ => write!(f, "RZ"), @@ -344,6 +370,10 @@ impl fmt::Display for GateType { GateType::CY => write!(f, "CY"), GateType::CZ => write!(f, "CZ"), GateType::CH => write!(f, "CH"), + GateType::SXX => write!(f, "SXX"), + GateType::SXXdg => write!(f, "SXXdg"), + GateType::SYY => write!(f, "SYY"), + GateType::SYYdg => write!(f, "SYYdg"), GateType::SZZ => write!(f, "SZZ"), GateType::SZZdg => write!(f, "SZZdg"), GateType::RXX => write!(f, "RXX"), @@ -366,6 +396,58 @@ impl fmt::Display for GateType { } } +impl std::str::FromStr for GateType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "I" | "i" => Ok(GateType::I), + "X" | "x" => Ok(GateType::X), + "Y" | "y" => Ok(GateType::Y), + "Z" | "z" => Ok(GateType::Z), + "H" | "h" => Ok(GateType::H), + "F" | "f" => Ok(GateType::F), + "FDG" | "Fdg" | "fdg" => Ok(GateType::Fdg), + "SX" | "sx" | "Q" | "q" => Ok(GateType::SX), + "SXDG" | "SXdg" | "sxdg" | "QD" | "qd" => Ok(GateType::SXdg), + "SY" | "sy" | "R" => Ok(GateType::SY), + "SYDG" | "SYdg" | "sydg" | "RD" | "rd" => Ok(GateType::SYdg), + "SZ" | "sz" | "S" | "s" => Ok(GateType::SZ), + "SZDG" | "SZdg" | "szdg" | "SD" | "sd" | "SDG" | "sdg" => Ok(GateType::SZdg), + "T" | "t" => Ok(GateType::T), + "TDG" | "Tdg" | "tdg" => Ok(GateType::Tdg), + "RX" | "rx" => Ok(GateType::RX), + "RY" | "ry" => Ok(GateType::RY), + "RZ" | "rz" => Ok(GateType::RZ), + "R1XY" | "r1xy" => Ok(GateType::R1XY), + "U" | "u" => Ok(GateType::U), + "CX" | "cx" | "CNOT" | "cnot" => Ok(GateType::CX), + "CY" | "cy" => Ok(GateType::CY), + "CZ" | "cz" => Ok(GateType::CZ), + "CH" | "ch" => Ok(GateType::CH), + "SXX" | "sxx" => Ok(GateType::SXX), + "SXXDG" | "SXXdg" | "sxxdg" => Ok(GateType::SXXdg), + "SYY" | "syy" => Ok(GateType::SYY), + "SYYDG" | "SYYdg" | "syydg" => Ok(GateType::SYYdg), + "SZZ" | "szz" => Ok(GateType::SZZ), + "SZZDG" | "SZZdg" | "szzdg" => Ok(GateType::SZZdg), + "RXX" | "rxx" => Ok(GateType::RXX), + "RYY" | "ryy" => Ok(GateType::RYY), + "RZZ" | "rzz" => Ok(GateType::RZZ), + "CRZ" | "crz" => Ok(GateType::CRZ), + "CCX" | "ccx" | "TOFFOLI" | "toffoli" => Ok(GateType::CCX), + "SWAP" | "swap" => Ok(GateType::SWAP), + "MEASURE" | "MZ" | "measure" | "measure Z" | "Measure" => Ok(GateType::Measure), + "PREP" | "prep" | "init" | "Init" | "init |0>" | "Init |0>" | "RESET" | "Reset" + | "reset" => Ok(GateType::Prep), + "QALLOC" | "QAlloc" | "qalloc" => Ok(GateType::QAlloc), + "QFREE" | "QFree" | "qfree" => Ok(GateType::QFree), + "IDLE" | "Idle" | "idle" => Ok(GateType::Idle), + _ => Err(format!("Unknown gate type: {s}")), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -377,7 +459,13 @@ mod tests { assert_eq!(GateType::Z as u8, 2); assert_eq!(GateType::Y as u8, 3); assert_eq!(GateType::H as u8, 10); + assert_eq!(GateType::F as u8, 16); + assert_eq!(GateType::Fdg as u8, 17); assert_eq!(GateType::CX as u8, 50); + assert_eq!(GateType::SXX as u8, 53); + assert_eq!(GateType::SXXdg as u8, 54); + assert_eq!(GateType::SYY as u8, 55); + assert_eq!(GateType::SYYdg as u8, 56); assert_eq!(GateType::SZZ as u8, 57); assert_eq!(GateType::RZ as u8, 32); assert_eq!(GateType::R1XY as u8, 36); @@ -397,7 +485,13 @@ mod tests { assert_eq!(GateType::from(2u8), GateType::Z); assert_eq!(GateType::from(3u8), GateType::Y); assert_eq!(GateType::from(10u8), GateType::H); + assert_eq!(GateType::from(16u8), GateType::F); + assert_eq!(GateType::from(17u8), GateType::Fdg); assert_eq!(GateType::from(50u8), GateType::CX); + assert_eq!(GateType::from(53u8), GateType::SXX); + assert_eq!(GateType::from(54u8), GateType::SXXdg); + assert_eq!(GateType::from(55u8), GateType::SYY); + assert_eq!(GateType::from(56u8), GateType::SYYdg); assert_eq!(GateType::from(57u8), GateType::SZZ); assert_eq!(GateType::from(32u8), GateType::RZ); assert_eq!(GateType::from(36u8), GateType::R1XY); @@ -413,6 +507,34 @@ mod tests { assert_eq!(GateType::from(255u8), GateType::Custom); } + #[test] + fn test_from_str() { + use std::str::FromStr; + + // Standard names + assert_eq!(GateType::from_str("H").unwrap(), GateType::H); + assert_eq!(GateType::from_str("X").unwrap(), GateType::X); + assert_eq!(GateType::from_str("CX").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("F").unwrap(), GateType::F); + assert_eq!(GateType::from_str("Fdg").unwrap(), GateType::Fdg); + assert_eq!(GateType::from_str("SXX").unwrap(), GateType::SXX); + assert_eq!(GateType::from_str("SXXdg").unwrap(), GateType::SXXdg); + assert_eq!(GateType::from_str("SYY").unwrap(), GateType::SYY); + assert_eq!(GateType::from_str("SYYdg").unwrap(), GateType::SYYdg); + assert_eq!(GateType::from_str("SWAP").unwrap(), GateType::SWAP); + assert_eq!(GateType::from_str("CCX").unwrap(), GateType::CCX); + + // Aliases + assert_eq!(GateType::from_str("CNOT").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("Q").unwrap(), GateType::SX); + assert_eq!(GateType::from_str("S").unwrap(), GateType::SZ); + assert_eq!(GateType::from_str("TOFFOLI").unwrap(), GateType::CCX); + assert_eq!(GateType::from_str("init |0>").unwrap(), GateType::Prep); + + // Unknown + assert!(GateType::from_str("FOOBAR").is_err()); + } + #[test] fn test_classical_arity() { // Gates with no parameters diff --git a/crates/pecos-core/src/gates.rs b/crates/pecos-core/src/gates.rs index 95eb66fdc..18b485038 100644 --- a/crates/pecos-core/src/gates.rs +++ b/crates/pecos-core/src/gates.rs @@ -215,6 +215,24 @@ impl Gate { ) } + /// Create F gate on multiple qubits + #[must_use] + pub fn f(qubits: &[impl Into + Copy]) -> Self { + Self::simple( + GateType::F, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create Fdg gate on multiple qubits + #[must_use] + pub fn fdg(qubits: &[impl Into + Copy]) -> Self { + Self::simple( + GateType::Fdg, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + /// Create T gate on multiple qubits #[must_use] pub fn t(qubits: &[impl Into + Copy]) -> Self { @@ -353,6 +371,194 @@ impl Gate { Self::szzdg_vec(&flat_qubits) } + /// Create SXX gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn sxx_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SXX gate requires an even number of qubits" + ); + Self::simple( + GateType::SXX, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create SXX gate on multiple qubit pairs + #[must_use] + pub fn sxx(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::sxx_vec(&flat_qubits) + } + + /// Create SXXdg gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn sxxdg_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SXXdg gate requires an even number of qubits" + ); + Self::simple( + GateType::SXXdg, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create SXXdg gate on multiple qubit pairs + #[must_use] + pub fn sxxdg(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::sxxdg_vec(&flat_qubits) + } + + /// Create SYY gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn syy_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SYY gate requires an even number of qubits" + ); + Self::simple( + GateType::SYY, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create SYY gate on multiple qubit pairs + #[must_use] + pub fn syy(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::syy_vec(&flat_qubits) + } + + /// Create SYYdg gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn syydg_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SYYdg gate requires an even number of qubits" + ); + Self::simple( + GateType::SYYdg, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create SYYdg gate on multiple qubit pairs + #[must_use] + pub fn syydg(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::syydg_vec(&flat_qubits) + } + + /// Create SWAP gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn swap_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "SWAP gate requires an even number of qubits" + ); + Self::simple( + GateType::SWAP, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create SWAP gate on multiple qubit pairs + #[must_use] + pub fn swap(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::swap_vec(&flat_qubits) + } + + /// Create CH gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn ch_vec(qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "CH gate requires an even number of qubits" + ); + Self::simple( + GateType::CH, + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create CH gate on multiple qubit pairs + #[must_use] + pub fn ch(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::ch_vec(&flat_qubits) + } + + /// Create CRZ gate from flat qubit list + /// + /// # Panics + /// + /// Panics if the number of qubits is not even. + #[must_use] + pub fn crz_vec(theta: Angle64, qubits: &[impl Into + Copy]) -> Self { + assert!( + qubits.len().is_multiple_of(2), + "CRZ gate requires an even number of qubits" + ); + Self::with_angles( + GateType::CRZ, + vec![theta], + qubits.iter().map(|&q| q.into()).collect::(), + ) + } + + /// Create CRZ gate on multiple qubit pairs + #[must_use] + pub fn crz( + theta: Angle64, + qubit_pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> Self { + let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); + Self::crz_vec(theta, &flat_qubits) + } + + /// Create CCX (Toffoli) gate on qubit triples + #[must_use] + pub fn ccx( + triples: &[( + impl Into + Copy, + impl Into + Copy, + impl Into + Copy, + )], + ) -> Self { + let qubits: GateQubits = triples + .iter() + .flat_map(|&(c1, c2, t)| [c1.into(), c2.into(), t.into()]) + .collect(); + Self::simple(GateType::CCX, qubits) + } + /// Create RXX gate from flat qubit list (`qubit1_1`, `qubit2_1`, `qubit1_2`, `qubit2_2`, ...) /// /// # Panics diff --git a/crates/pecos-engines/src/noise/biased_depolarizing.rs b/crates/pecos-engines/src/noise/biased_depolarizing.rs index 30943f26e..784e73160 100644 --- a/crates/pecos-engines/src/noise/biased_depolarizing.rs +++ b/crates/pecos-engines/src/noise/biased_depolarizing.rs @@ -166,6 +166,8 @@ impl BiasedDepolarizingNoiseModel { | GateType::SZ | GateType::SZdg | GateType::H + | GateType::F + | GateType::Fdg | GateType::RX | GateType::RY | GateType::RZ @@ -181,6 +183,10 @@ impl BiasedDepolarizingNoiseModel { | GateType::CY | GateType::CZ | GateType::CH + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SZZ | GateType::SZZdg | GateType::SWAP diff --git a/crates/pecos-engines/src/noise/depolarizing.rs b/crates/pecos-engines/src/noise/depolarizing.rs index 6d8b6539a..33600c69f 100644 --- a/crates/pecos-engines/src/noise/depolarizing.rs +++ b/crates/pecos-engines/src/noise/depolarizing.rs @@ -172,6 +172,8 @@ impl DepolarizingNoiseModel { | GateType::SZ | GateType::SZdg | GateType::H + | GateType::F + | GateType::Fdg | GateType::RX | GateType::RY | GateType::RZ @@ -187,6 +189,10 @@ impl DepolarizingNoiseModel { | GateType::CY | GateType::CZ | GateType::CH + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SZZ | GateType::SZZdg | GateType::SWAP diff --git a/crates/pecos-engines/src/noise/utils.rs b/crates/pecos-engines/src/noise/utils.rs index e6a97af52..f53ff48fa 100644 --- a/crates/pecos-engines/src/noise/utils.rs +++ b/crates/pecos-engines/src/noise/utils.rs @@ -250,6 +250,15 @@ impl NoiseUtils { builder.add_idle(gate.params[0], &qubits_usize); } + // Gates that are handled by the simulator directly (via CliffordGateable) + // but don't have byte message builder methods + GateType::F + | GateType::Fdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg => {} + // Custom is a placeholder (actual gate name is in metadata); skip it GateType::Custom => {} diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index ba8ac761b..027016e25 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -369,6 +369,62 @@ where debug!("Processing SZZdg gate on qubits {:?}", cmd.qubits); self.simulator.szzdg(&cmd.qubits); } + GateType::F => { + debug!("Processing F gate on qubits {:?}", cmd.qubits); + self.simulator.f(&cmd.qubits); + } + GateType::Fdg => { + debug!("Processing Fdg gate on qubits {:?}", cmd.qubits); + self.simulator.fdg(&cmd.qubits); + } + GateType::SY => { + debug!("Processing SY gate on qubits {:?}", cmd.qubits); + self.simulator.sy(&cmd.qubits); + } + GateType::SYdg => { + debug!("Processing SYdg gate on qubits {:?}", cmd.qubits); + self.simulator.sydg(&cmd.qubits); + } + GateType::SXX => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "SXX gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + debug!("Processing SXX gate on qubits {:?}", cmd.qubits); + self.simulator.sxx(&cmd.qubits); + } + GateType::SXXdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "SXXdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + debug!("Processing SXXdg gate on qubits {:?}", cmd.qubits); + self.simulator.sxxdg(&cmd.qubits); + } + GateType::SYY => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "SYY gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + debug!("Processing SYY gate on qubits {:?}", cmd.qubits); + self.simulator.syy(&cmd.qubits); + } + GateType::SYYdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "SYYdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + debug!("Processing SYYdg gate on qubits {:?}", cmd.qubits); + self.simulator.syydg(&cmd.qubits); + } GateType::SWAP => { if cmd.qubits.len() % 2 != 0 { return Err(quantum_error(format!( @@ -503,7 +559,7 @@ where // QFree is a no-op for state vector simulation (qubit tracking is handled elsewhere) // Custom is a no-op placeholder (actual gate name is in metadata) } - GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { + GateType::RXX | GateType::RYY => { return Err(quantum_error(format!( "Gate type {:?} is not yet supported by StateVecEngine", cmd.gate_type diff --git a/crates/pecos-experimental/src/hugr_executor.rs b/crates/pecos-experimental/src/hugr_executor.rs index 3db8bc720..d1980915e 100644 --- a/crates/pecos-experimental/src/hugr_executor.rs +++ b/crates/pecos-experimental/src/hugr_executor.rs @@ -303,6 +303,12 @@ where | GateType::Tdg | GateType::U | GateType::R1XY + | GateType::F + | GateType::Fdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SZZ | GateType::SZZdg | GateType::SWAP diff --git a/crates/pecos-qasm/src/engine.rs b/crates/pecos-qasm/src/engine.rs index 2d62d3ff3..63f70f4e8 100644 --- a/crates/pecos-qasm/src/engine.rs +++ b/crates/pecos-qasm/src/engine.rs @@ -639,13 +639,21 @@ impl QASMEngine { | GateType::SZ | GateType::SZdg | GateType::H + | GateType::F + | GateType::Fdg | GateType::T | GateType::Tdg | GateType::Prep | GateType::QAlloc => self.process_single_qubit_gate(gate.gate_type, &qubits), - GateType::CX | GateType::CY | GateType::CZ | GateType::SZZ | GateType::SZZdg => { - self.process_two_qubit_gate(gate.gate_type, &qubits) - } + GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg => self.process_two_qubit_gate(gate.gate_type, &qubits), // Gates not yet supported in QASM engine GateType::SWAP | GateType::CCX | GateType::CRZ | GateType::CH => { Err(PecosError::Processing(format!( diff --git a/crates/pecos-qsim/src/circuit_executor.rs b/crates/pecos-qsim/src/circuit_executor.rs index ef0f587e2..6af601a74 100644 --- a/crates/pecos-qsim/src/circuit_executor.rs +++ b/crates/pecos-qsim/src/circuit_executor.rs @@ -145,6 +145,12 @@ fn execute_single_batch( GateType::H => { sim.h(qubits); } + GateType::F => { + sim.f(qubits); + } + GateType::Fdg => { + sim.fdg(qubits); + } GateType::SX => { sim.sx(qubits); } @@ -172,6 +178,18 @@ fn execute_single_batch( GateType::CZ => { sim.cz(qubits); } + GateType::SXX => { + sim.sxx(qubits); + } + GateType::SXXdg => { + sim.sxxdg(qubits); + } + GateType::SYY => { + sim.syy(qubits); + } + GateType::SYYdg => { + sim.syydg(qubits); + } GateType::SZZ => { sim.szz(qubits); } diff --git a/crates/pecos-quantum/src/circuit_display.rs b/crates/pecos-quantum/src/circuit_display.rs index 107f988b1..00e7fc0b0 100644 --- a/crates/pecos-quantum/src/circuit_display.rs +++ b/crates/pecos-quantum/src/circuit_display.rs @@ -30,6 +30,8 @@ use std::collections::BTreeSet; fn gate_symbol(gate_type: GateType) -> &'static str { match gate_type { GateType::H => "H", + GateType::F => "F", + GateType::Fdg => "Fdg", GateType::X => "X", GateType::Y => "Y", GateType::Z => "Z", @@ -50,6 +52,10 @@ fn gate_symbol(gate_type: GateType) -> &'static str { GateType::CY => "CY", GateType::CZ => "CZ", GateType::CH => "CH", + GateType::SXX => "SXX", + GateType::SXXdg => "SXXdg", + GateType::SYY => "SYY", + GateType::SYYdg => "SYYdg", GateType::SZZ => "SZZ", GateType::SZZdg => "SZZdg", GateType::SWAP => "SWAP", @@ -181,8 +187,8 @@ fn gate_color(gate_type: GateType) -> CellColor { | GateType::SZZ | GateType::SZZdg | GateType::CRZ => CellColor::ZAxis, - GateType::SX | GateType::SXdg => CellColor::YZMix, - GateType::SY | GateType::SYdg | GateType::H | GateType::CH => CellColor::XZMix, + GateType::SX | GateType::SXdg | GateType::SXX | GateType::SXXdg => CellColor::YZMix, + GateType::SY | GateType::SYdg | GateType::SYY | GateType::SYYdg | GateType::H | GateType::F | GateType::Fdg | GateType::CH => CellColor::XZMix, GateType::SZ | GateType::SZdg => CellColor::XYMix, // No clear single-axis color: idle, alloc/free, multi-qubit, custom GateType::Idle @@ -408,7 +414,7 @@ fn decompose_gate( } // Symmetric two-qubit interactions: dots on both wires, // label on the connector line between them. - GateType::RXX | GateType::RYY | GateType::RZZ | GateType::SZZ | GateType::SZZdg => { + GateType::RXX | GateType::RYY | GateType::RZZ | GateType::SXX | GateType::SXXdg | GateType::SYY | GateType::SYYdg | GateType::SZZ | GateType::SZZdg => { cells.push((row_a, DiagramCell::Control, color)); cells.push((row_b, DiagramCell::Control, color)); connector_label = Some(sym.clone()); diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index ac0ea8adc..b409a4ba5 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -1256,6 +1256,16 @@ impl<'a> TickHandle<'a> { self.add_gate(Gate::szdg(qubits)) } + /// Apply F gate(s) to one or more qubits. + pub fn f(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { + self.add_gate(Gate::f(qubits)) + } + + /// Apply F-dagger gate(s) to one or more qubits. + pub fn fdg(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { + self.add_gate(Gate::fdg(qubits)) + } + /// Apply T gate(s) to one or more qubits. pub fn t(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { self.add_gate(Gate::t(qubits)) @@ -1427,6 +1437,54 @@ impl<'a> TickHandle<'a> { self.add_gate(Gate::szzdg(pairs)) } + /// Apply SXX gate(s) (sqrt-XX) to one or more qubit pairs. + pub fn sxx( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::sxx(pairs)) + } + + /// Apply SXX-dagger gate(s) to one or more qubit pairs. + pub fn sxxdg( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::sxxdg(pairs)) + } + + /// Apply SYY gate(s) (sqrt-YY) to one or more qubit pairs. + pub fn syy( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::syy(pairs)) + } + + /// Apply SYY-dagger gate(s) to one or more qubit pairs. + pub fn syydg( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::syydg(pairs)) + } + + /// Apply SWAP gate(s) to one or more qubit pairs. + pub fn swap( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::swap(pairs)) + } + + /// Apply CH (controlled-Hadamard) gate(s) to one or more qubit pairs. + pub fn ch( + &mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> &mut Self { + self.add_gate(Gate::ch(pairs)) + } + /// Apply RXX rotation(s) to one or more qubit pairs. /// /// # Examples diff --git a/crates/pecos-quest/src/quantum_engine.rs b/crates/pecos-quest/src/quantum_engine.rs index a1e4d9938..1e02627f0 100644 --- a/crates/pecos-quest/src/quantum_engine.rs +++ b/crates/pecos-quest/src/quantum_engine.rs @@ -112,6 +112,30 @@ impl Engine for QuestStateVecEngine { GateType::SZZdg => { self.simulator.szzdg(&cmd.qubits); } + GateType::F => { + self.simulator.f(&cmd.qubits); + } + GateType::Fdg => { + self.simulator.fdg(&cmd.qubits); + } + GateType::SY => { + self.simulator.sy(&cmd.qubits); + } + GateType::SYdg => { + self.simulator.sydg(&cmd.qubits); + } + GateType::SXX => { + self.simulator.sxx(&cmd.qubits); + } + GateType::SXXdg => { + self.simulator.sxxdg(&cmd.qubits); + } + GateType::SYY => { + self.simulator.syy(&cmd.qubits); + } + GateType::SYYdg => { + self.simulator.syydg(&cmd.qubits); + } GateType::SWAP => { self.simulator.swap(&cmd.qubits); } @@ -202,7 +226,7 @@ impl Engine for QuestStateVecEngine { .u(cmd.angles[0], cmd.angles[1], cmd.angles[2], &cmd.qubits); } } - GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { + GateType::RXX | GateType::RYY => { return Err(PecosError::Processing(format!( "Gate type {:?} is not yet supported by QuestStateVecEngine", cmd.gate_type @@ -333,6 +357,30 @@ impl Engine for QuestDensityMatrixEngine { GateType::SZZdg => { self.simulator.szzdg(&cmd.qubits); } + GateType::F => { + self.simulator.f(&cmd.qubits); + } + GateType::Fdg => { + self.simulator.fdg(&cmd.qubits); + } + GateType::SY => { + self.simulator.sy(&cmd.qubits); + } + GateType::SYdg => { + self.simulator.sydg(&cmd.qubits); + } + GateType::SXX => { + self.simulator.sxx(&cmd.qubits); + } + GateType::SXXdg => { + self.simulator.sxxdg(&cmd.qubits); + } + GateType::SYY => { + self.simulator.syy(&cmd.qubits); + } + GateType::SYYdg => { + self.simulator.syydg(&cmd.qubits); + } GateType::SWAP => { self.simulator.swap(&cmd.qubits); } @@ -423,7 +471,7 @@ impl Engine for QuestDensityMatrixEngine { .u(cmd.angles[0], cmd.angles[1], cmd.angles[2], &cmd.qubits); } } - GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { + GateType::RXX | GateType::RYY => { return Err(PecosError::Processing(format!( "Gate type {:?} is not yet supported by QuestDensityMatrixEngine", cmd.gate_type diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 6e92e6ec9..b538c786a 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -370,6 +370,100 @@ impl PyGateType { Self { inner: GateType::U } } + #[classattr] + #[pyo3(name = "F")] + fn f() -> Self { + Self { inner: GateType::F } + } + + #[classattr] + #[pyo3(name = "Fdg")] + fn fdg() -> Self { + Self { + inner: GateType::Fdg, + } + } + + #[classattr] + #[pyo3(name = "SXX")] + fn sxx() -> Self { + Self { + inner: GateType::SXX, + } + } + + #[classattr] + #[pyo3(name = "SXXdg")] + fn sxxdg() -> Self { + Self { + inner: GateType::SXXdg, + } + } + + #[classattr] + #[pyo3(name = "SYY")] + fn syy() -> Self { + Self { + inner: GateType::SYY, + } + } + + #[classattr] + #[pyo3(name = "SYYdg")] + fn syydg() -> Self { + Self { + inner: GateType::SYYdg, + } + } + + #[classattr] + #[pyo3(name = "SZZ")] + fn szz() -> Self { + Self { + inner: GateType::SZZ, + } + } + + #[classattr] + #[pyo3(name = "SZZdg")] + fn szzdg() -> Self { + Self { + inner: GateType::SZZdg, + } + } + + #[classattr] + #[pyo3(name = "SWAP")] + fn swap() -> Self { + Self { + inner: GateType::SWAP, + } + } + + #[classattr] + #[pyo3(name = "CH")] + fn ch() -> Self { + Self { + inner: GateType::CH, + } + } + + #[classattr] + #[pyo3(name = "CRZ")] + fn crz() -> Self { + Self { + inner: GateType::CRZ, + } + } + + #[classattr] + #[pyo3(name = "CCX")] + fn ccx() -> Self { + Self { + inner: GateType::CCX, + } + } + #[classattr] #[pyo3(name = "Measure")] fn measure() -> Self { @@ -1765,11 +1859,11 @@ impl PyTick { /// Get the set of qubits used in this tick. /// /// Returns a sorted list of qubit IDs that are acted upon by gates in this tick. - fn active_qubits(&self) -> Vec { + fn active_qubits(&self) -> Vec { self.inner .active_qubits() .into_iter() - .map(|q| PyQubitId { inner: q }) + .map(|q| q.0) .collect() } @@ -2619,6 +2713,81 @@ impl PyTickHandle { Ok(slf) } + /// Apply an F gate. + fn f(slf: Py, py: Python<'_>, q: usize) -> PyResult> { + slf.borrow_mut(py).add_gate_internal(py, Gate::f(&[q]))?; + Ok(slf) + } + + /// Apply an F-dagger gate. + fn fdg(slf: Py, py: Python<'_>, q: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::fdg(&[q]))?; + Ok(slf) + } + + /// Apply an SXX gate (sqrt-XX). + fn sxx(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::sxx(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply an SXX-dagger gate. + fn sxxdg(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::sxxdg(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply an SYY gate (sqrt-YY). + fn syy(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::syy(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply an SYY-dagger gate. + fn syydg(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::syydg(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply a SWAP gate. + fn swap(slf: Py, py: Python<'_>, q1: usize, q2: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::swap(&[(q1, q2)]))?; + Ok(slf) + } + + /// Apply a CH gate (controlled-Hadamard). + fn ch(slf: Py, py: Python<'_>, ctrl: usize, tgt: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::ch(&[(ctrl, tgt)]))?; + Ok(slf) + } + + /// Apply a CRZ gate (controlled-RZ). + fn crz( + slf: Py, + py: Python<'_>, + theta: AngleParam, + ctrl: usize, + tgt: usize, + ) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::crz(theta.0, &[(ctrl, tgt)]))?; + Ok(slf) + } + + /// Apply a CCX gate (Toffoli). + fn ccx(slf: Py, py: Python<'_>, q1: usize, q2: usize, q3: usize) -> PyResult> { + slf.borrow_mut(py) + .add_gate_internal(py, Gate::ccx(&[(q1, q2, q3)]))?; + Ok(slf) + } + /// Apply an RXX rotation. fn rxx( slf: Py, @@ -2658,6 +2827,82 @@ impl PyTickHandle { Ok(slf) } + // ========================================================================= + // Generic gate dispatch (name-based) + // ========================================================================= + + /// Add a gate by name, resolving to a native GateType if possible. + /// + /// If the name matches a known gate type (e.g., "H", "CX", "SZZ"), it is + /// added as that native type. Otherwise, it falls through to `custom_gate`. + /// + /// Args: + /// name: The gate name (case-insensitive for standard gates). + /// qubits: List of qubit IDs. + /// angles: Optional list of angle values (radians). + #[pyo3(signature = (name, qubits, angles=None))] + fn add_gate( + slf: Py, + py: Python<'_>, + name: &str, + qubits: Vec, + angles: Option>, + ) -> PyResult> { + use std::str::FromStr; + + match GateType::from_str(name) { + Ok(gate_type) => { + let angle_vals: Vec = angles + .unwrap_or_default() + .into_iter() + .map(Angle64::from_radians) + .collect(); + let qubit_ids: GateQubits = qubits.into_iter().map(QubitId::from).collect(); + let gate = Gate::new(gate_type, angle_vals, vec![], qubit_ids); + + let handle = slf.borrow_mut(py); + let tick_idx = handle.tick_idx; + let circuit_py = handle.circuit.clone_ref(py); + + let mut circuit = circuit_py.borrow_mut(py); + if let Some(tick) = circuit.inner.get_tick_mut(tick_idx) { + match tick.try_add_gate(gate) { + Ok(idx) => { + tick.set_gate_attr( + idx, + "_symbol", + Attribute::String(name.to_string()), + ); + drop(circuit); + drop(handle); + slf.borrow_mut(py).last_gate_idx = Some(idx); + Ok(slf) + } + Err(err) => { + let msg = format!( + "Qubit(s) {:?} already in use in tick {}", + err.conflicting_qubits + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + tick_idx + ); + Err(PyErr::new::(msg)) + } + } + } else { + drop(circuit); + drop(handle); + Ok(slf) + } + } + Err(_) => { + // Unknown gate name - fall through to custom_gate + PyTickHandle::custom_gate(slf, py, name, qubits, angles) + } + } + } + // ========================================================================= // Custom (unrecognized) gates // ========================================================================= diff --git a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py index 0119d11f6..6c3ba44bb 100644 --- a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py +++ b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py @@ -34,7 +34,7 @@ QubitConflictError = None # type: ignore[misc, assignment] if TYPE_CHECKING: - from collections.abc import Callable, Iterator + from collections.abc import Iterator from pecos.typing import JSONDict, JSONValue @@ -44,95 +44,8 @@ GateDict = dict[str, LocationSet] CircuitSetup = int | list[GateDict] | None -# Symbol to TickHandle method mapping for single-qubit gates -_SINGLE_QUBIT_GATES = { - "I": "i", - "H": "h", - "F": "f", - "FDG": "fdg", - "X": "x", - "Y": "y", - "Z": "z", - # sqrt gates - "SX": "sx", - "SXDG": "sxdg", - "SY": "sy", - "SYDG": "sydg", - "SZ": "sz", - "SZDG": "szdg", - # Aliases - "Q": "sx", - "QD": "sxdg", - "R": "sy", - "RD": "sydg", - "S": "sz", - "SD": "szdg", - "SDG": "szdg", # Also accept SDG as alias - "T": "t", - "TDG": "tdg", -} - -# Symbol to TickHandle method mapping for rotation gates (take angle parameter) -_ROTATION_GATES = { - "RX": "rx", - "RY": "ry", - "RZ": "rz", -} - -# Symbol to TickHandle method mapping for two-qubit gates -_TWO_QUBIT_GATES = { - "CX": "cx", - "CNOT": "cx", - "CY": "cy", - "CZ": "cz", - "SXX": "sxx", - "SXXDG": "sxxdg", - "SYY": "syy", - "SYYDG": "syydg", - "SZZ": "szz", - "SZZDG": "szzdg", -} - -# Symbol to TickHandle method mapping for two-qubit rotation gates -_TWO_QUBIT_ROTATION_GATES = { - "RXX": "rxx", - "RYY": "ryy", - "RZZ": "rzz", -} - -# Symbol to TickHandle method mapping for R1XY gate (takes theta, phi angles) -_R1XY_GATES = { - "R1XY": "r1xy", -} - -# Symbol to TickHandle method mapping for U gate (takes theta, phi, lambda angles) -_U_GATES = { - "U": "u", -} - -# Symbol to TickHandle method mapping for R2XXYYZZ gate (takes 3 angles: zz, yy, xx) -# This gate is decomposed into RZZ + RYY + RXX -_R2XXYYZZ_GATES = { - "R2XXYYZZ": "r2xxyyzz", - "RZZRYYRXX": "r2xxyyzz", # Alternative name - "RXXYYZZ": "r2xxyyzz", # Alternative name -} - -# SWAP gate - decomposed into CX gates: SWAP(a,b) = CX(a,b) CX(b,a) CX(a,b) -_SWAP_GATES = {"SWAP"} - -# Prep/measure gates -_PREP_GATES = { - "PREP", - "init", - "Init", - "init |0>", - "Init |0>", - "RESET", - "Reset", - "reset", -} -_MEASURE_GATES = {"MEASURE", "MZ", "measure", "Measure", "measure Z"} +# R2XXYYZZ gate names (composite gate, not a single native GateType) +_R2XXYYZZ_GATES = {"R2XXYYZZ", "RZZRYYRXX", "RXXYYZZ"} # GateType string to symbol mapping (for iteration) _GATETYPE_TO_SYMBOL = { @@ -168,6 +81,10 @@ "RXX": "RXX", "RYY": "RYY", "RZZ": "RZZ", + "CRZ": "CRZ", + "CH": "CH", + "CCX": "CCX", + "SWAP": "SWAP", "R2XXYYZZ": "R2XXYYZZ", "Prep": "init |0>", "Measure": "measure", @@ -256,11 +173,15 @@ def _add_gate_to_tick( locations: LocationSet, **params: JSONValue, ) -> None: - """Add a gate to a tick handle based on symbol.""" + """Add a gate to a tick handle based on symbol. + + Uses the Rust-side ``add_gate`` method which resolves gate names via + ``GateType::from_str``. Special handling is only needed for composite + gates (R2XXYYZZ) that don't map to a single GateType. + """ # Handle logical gate objects that have a .symbol attribute if not isinstance(symbol, str): symbol = symbol.symbol if hasattr(symbol, "symbol") else str(symbol) - symbol_upper = symbol.upper() # Serialize params for storage (handle tuples -> lists) def make_serializable(obj: object) -> object: @@ -284,141 +205,11 @@ def make_serializable(obj: object) -> object: tick_handle.meta("_params", params_json) return - # Helper to store original symbol and params in metadata (idempotent - skips if qubit already used) - def add_with_symbol( - method: Callable[..., object], - *args: float, - ) -> object | None: - try: - result = method(*args) - except QubitConflictError: - # Qubit already in use in this tick - skip (idempotent behavior) - return None - else: - # Store original symbol and params for round-trip preservation - if hasattr(result, "meta"): - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - return result - - # Handle single-qubit gates - if symbol_upper in _SINGLE_QUBIT_GATES: - method_name = _SINGLE_QUBIT_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - add_with_symbol(method, q) - else: - add_with_symbol(method, loc) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle rotation gates - if symbol_upper in _ROTATION_GATES: - method_name = _ROTATION_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - angles_val = params.get("angles") - if angles_val is not None and len(angles_val) >= 1: - angle = angles_val[0] - else: - angle = params.get("angle", params.get("theta", 0.0)) - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - add_with_symbol(method, angle, q) - else: - add_with_symbol(method, angle, loc) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle two-qubit gates - if symbol_upper in _TWO_QUBIT_GATES: - method_name = _TWO_QUBIT_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - for loc in loc_list: - if isinstance(loc, tuple) and len(loc) == 2: - add_with_symbol(method, loc[0], loc[1]) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle two-qubit rotation gates - if symbol_upper in _TWO_QUBIT_ROTATION_GATES: - method_name = _TWO_QUBIT_ROTATION_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - angles_val = params.get("angles") - if angles_val is not None and len(angles_val) >= 1: - angle = angles_val[0] - else: - angle = params.get("angle", params.get("theta", 0.0)) - for loc in loc_list: - if isinstance(loc, tuple) and len(loc) == 2: - add_with_symbol(method, angle, loc[0], loc[1]) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle R1XY gate (takes theta, phi angles) - if symbol_upper in _R1XY_GATES: - method_name = _R1XY_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - # Handle angles tuple or individual theta/phi params - angles = params.get("angles") - if angles is not None and len(angles) >= 2: - theta = angles[0] - phi = angles[1] - else: - theta = params.get("theta", params.get("angle", 0.0)) - phi = params.get("phi", 0.0) - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - add_with_symbol(method, theta, phi, q) - else: - add_with_symbol(method, theta, phi, loc) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle U gate (takes theta, phi, lambda angles) - if symbol_upper in _U_GATES: - method_name = _U_GATES[symbol_upper] - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - # Handle angles tuple or individual theta/phi/lambda params - angles = params.get("angles") - if angles is not None and len(angles) >= 3: - theta = angles[0] - phi = angles[1] - lambda_ = angles[2] - else: - theta = params.get("theta", 0.0) - phi = params.get("phi", 0.0) - lambda_ = params.get("lambda", params.get("lambda_", 0.0)) - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - add_with_symbol(method, theta, phi, lambda_, q) - else: - add_with_symbol(method, theta, phi, lambda_, loc) - return - # Fall through to custom gate handler if method doesn't exist - - # Handle R2XXYYZZ gate (takes 3 angles: zz, yy, xx) - # R2XXYYZZ is not a native GateType. We store it as RZZ with metadata - # containing all three angles and the original symbol. When iterating, - # _iter_tick reconstructs the R2XXYYZZ gate from this metadata. - if symbol_upper in _R2XXYYZZ_GATES: - # Handle angles tuple or individual parameters + # Handle R2XXYYZZ gate (composite, not a single native GateType) + if symbol.upper() in _R2XXYYZZ_GATES: angles = params.get("angles") if angles is not None and len(angles) >= 3: - zz_angle = angles[0] - yy_angle = angles[1] - xx_angle = angles[2] + zz_angle, yy_angle, xx_angle = angles[0], angles[1], angles[2] else: zz_angle = params.get("zz", 0.0) yy_angle = params.get("yy", 0.0) @@ -426,102 +217,27 @@ def add_with_symbol( for loc in loc_list: if isinstance(loc, tuple) and len(loc) == 2: - # Store as RZZ with R2XXYYZZ metadata result = tick_handle.rzz(zz_angle, loc[0], loc[1]) if hasattr(result, "meta"): result.meta("_symbol", symbol) - # Store all three angles as comma-separated string - result.meta( - "_r2xxyyzz_angles", - f"{zz_angle},{yy_angle},{xx_angle}", - ) + result.meta("_r2xxyyzz_angles", f"{zz_angle},{yy_angle},{xx_angle}") if params_json: result.meta("_params", params_json) return - # Handle SWAP gate - stored as CX with metadata - # SWAP is not a native GateType. We store it as CX with metadata - # indicating it's a SWAP. The simulator bindings handle SWAP directly. - if symbol_upper in _SWAP_GATES: - for loc in loc_list: - if isinstance(loc, tuple) and len(loc) == 2: - # Store as CX with SWAP metadata - result = tick_handle.cx(loc[0], loc[1]) - if hasattr(result, "meta"): - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - return - - # Handle prep gates - idempotent (skip if qubit already used in tick) - if symbol in _PREP_GATES or symbol_upper == "PREP": - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - try: - result = tick_handle.pz(q) - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - except QubitConflictError: - pass # Qubit already initialized in this tick - else: - try: - result = tick_handle.pz(loc) - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - except QubitConflictError: - pass # Qubit already initialized in this tick - return - - # Handle measure gates - idempotent (skip if qubit already used in tick) - if symbol in _MEASURE_GATES or symbol_upper == "MEASURE": - for loc in loc_list: - if isinstance(loc, tuple): - for q in loc: - try: - result = tick_handle.mz(q) - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - except QubitConflictError: - pass # Qubit already measured in this tick - else: - try: - result = tick_handle.mz(loc) - result.meta("_symbol", symbol) - if params_json: - result.meta("_params", params_json) - except QubitConflictError: - pass # Qubit already measured in this tick - return + # Extract angles from params + angles = self._extract_angles_full(params) - # Fallback: try to use the symbol directly as a method name - method_name = symbol.lower() - if hasattr(tick_handle, method_name): - method = getattr(tick_handle, method_name) - for loc in loc_list: - if isinstance(loc, tuple): - if len(loc) == 2: - add_with_symbol(method, loc[0], loc[1]) - else: - for q in loc: - add_with_symbol(method, q) - else: - add_with_symbol(method, loc) - else: - # Store unrecognized gates using validated custom_gate method. - # First use of a name establishes its signature; subsequent uses are validated. - angles = self._extract_angles(params) - for loc in loc_list: - qubits = list(loc) if isinstance(loc, tuple) else [loc] - try: - result = tick_handle.custom_gate(symbol, qubits, angles if angles else None) - except QubitConflictError: - continue - if hasattr(result, "meta") and params_json: - result.meta("_params", params_json) + # Dispatch each location through Rust's add_gate (which resolves + # the name via GateType::from_str and falls back to custom_gate) + for loc in loc_list: + qubits = list(loc) if isinstance(loc, tuple) else [loc] + try: + result = tick_handle.add_gate(symbol, qubits, angles if angles else None) + except QubitConflictError: + continue + if hasattr(result, "meta") and params_json: + result.meta("_params", params_json) def append( self, @@ -855,6 +571,31 @@ def _extract_angles(params: dict) -> list[float]: return [params["angle"]] return [] + @staticmethod + def _extract_angles_full(params: dict) -> list[float]: + """Extract angle values from gate parameters, supporting all param formats. + + Handles: angles (list), angle (single), theta, phi, lambda/lambda_. + """ + if not params: + return [] + # If explicit angles list is provided, use it directly + if "angles" in params: + return list(params["angles"]) + # Build angle list from named parameters + angles = [] + if "angle" in params: + angles.append(params["angle"]) + elif "theta" in params: + angles.append(params["theta"]) + if "phi" in params: + angles.append(params["phi"]) + if "lambda" in params: + angles.append(params["lambda"]) + elif "lambda_" in params: + angles.append(params["lambda_"]) + return angles + @staticmethod def _fix_json_meta(meta: JSONDict) -> JSONDict: """Fix some of the type issues for converting json rep back to a QuantumCircuit.""" @@ -908,7 +649,7 @@ def __setitem__(self, tick: int, item: tuple[GateDict, JSONDict]) -> None: # Get qubits to discard first tick_obj = self._inner.get_tick(actual_tick) if tick_obj is not None: - qubits_to_discard = [int(q) for q in tick_obj.active_qubits()] + qubits_to_discard = tick_obj.active_qubits() if qubits_to_discard: self._inner.discard(qubits_to_discard, actual_tick) @@ -936,7 +677,7 @@ def __delitem__(self, tick: int) -> None: actual_tick = tick if tick >= 0 else len(self) + tick tick_obj = self._inner.get_tick(actual_tick) if tick_obj is not None: - qubits_to_discard = [int(q) for q in tick_obj.active_qubits()] + qubits_to_discard = tick_obj.active_qubits() if qubits_to_discard: self._inner.discard(qubits_to_discard, actual_tick) From d83869a27aaed3c6280051bd5818e056684f4f12 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 3 Mar 2026 21:27:32 -0700 Subject: [PATCH 11/12] lint --- crates/pecos-core/src/gates.rs | 8 +- crates/pecos-engines/src/noise/utils.rs | 9 +- crates/pecos-engines/src/quantum.rs | 32 ++- crates/pecos-quantum/src/circuit_display.rs | 19 +- crates/pecos-quest/src/quantum_engine.rs | 185 ++++++++++++++++-- .../pecos-rslib/src/dag_circuit_bindings.rs | 11 +- 6 files changed, 225 insertions(+), 39 deletions(-) diff --git a/crates/pecos-core/src/gates.rs b/crates/pecos-core/src/gates.rs index 18b485038..b60546250 100644 --- a/crates/pecos-core/src/gates.rs +++ b/crates/pecos-core/src/gates.rs @@ -395,7 +395,7 @@ impl Gate { Self::sxx_vec(&flat_qubits) } - /// Create SXXdg gate from flat qubit list + /// Create `SXXdg` gate from flat qubit list /// /// # Panics /// @@ -412,7 +412,7 @@ impl Gate { ) } - /// Create SXXdg gate on multiple qubit pairs + /// Create `SXXdg` gate on multiple qubit pairs #[must_use] pub fn sxxdg(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); @@ -443,7 +443,7 @@ impl Gate { Self::syy_vec(&flat_qubits) } - /// Create SYYdg gate from flat qubit list + /// Create `SYYdg` gate from flat qubit list /// /// # Panics /// @@ -460,7 +460,7 @@ impl Gate { ) } - /// Create SYYdg gate on multiple qubit pairs + /// Create `SYYdg` gate on multiple qubit pairs #[must_use] pub fn syydg(qubit_pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { let flat_qubits = Self::flatten_qubit_pairs(qubit_pairs); diff --git a/crates/pecos-engines/src/noise/utils.rs b/crates/pecos-engines/src/noise/utils.rs index f53ff48fa..88a4460c3 100644 --- a/crates/pecos-engines/src/noise/utils.rs +++ b/crates/pecos-engines/src/noise/utils.rs @@ -251,16 +251,15 @@ impl NoiseUtils { } // Gates that are handled by the simulator directly (via CliffordGateable) - // but don't have byte message builder methods + // but don't have byte message builder methods. + // Custom is a placeholder (actual gate name is in metadata). GateType::F | GateType::Fdg | GateType::SXX | GateType::SXXdg | GateType::SYY - | GateType::SYYdg => {} - - // Custom is a placeholder (actual gate name is in metadata); skip it - GateType::Custom => {} + | GateType::SYYdg + | GateType::Custom => {} // Invalid cases (not enough qubits, missing parameters, etc.) _ => panic!( diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index 027016e25..320a9b395 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -559,11 +559,33 @@ where // QFree is a no-op for state vector simulation (qubit tracking is handled elsewhere) // Custom is a no-op placeholder (actual gate name is in metadata) } - GateType::RXX | GateType::RYY => { - return Err(quantum_error(format!( - "Gate type {:?} is not yet supported by StateVecEngine", - cmd.gate_type - ))); + GateType::RXX => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "RXX gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + if cmd.angles.is_empty() { + return Err(quantum_error("RXX gate requires at least one angle")); + } + let angle = cmd.angles[0]; + debug!("Processing RXX gate on qubits {:?}", cmd.qubits); + self.simulator.rxx(angle, &cmd.qubits); + } + GateType::RYY => { + if cmd.qubits.len() % 2 != 0 { + return Err(quantum_error(format!( + "RYY gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } + if cmd.angles.is_empty() { + return Err(quantum_error("RYY gate requires at least one angle")); + } + let angle = cmd.angles[0]; + debug!("Processing RYY gate on qubits {:?}", cmd.qubits); + self.simulator.ryy(angle, &cmd.qubits); } GateType::QAlloc => { // Allocate qubits in |0⟩ state - for state vector sim, same as Prep diff --git a/crates/pecos-quantum/src/circuit_display.rs b/crates/pecos-quantum/src/circuit_display.rs index 00e7fc0b0..972fb419b 100644 --- a/crates/pecos-quantum/src/circuit_display.rs +++ b/crates/pecos-quantum/src/circuit_display.rs @@ -188,7 +188,14 @@ fn gate_color(gate_type: GateType) -> CellColor { | GateType::SZZdg | GateType::CRZ => CellColor::ZAxis, GateType::SX | GateType::SXdg | GateType::SXX | GateType::SXXdg => CellColor::YZMix, - GateType::SY | GateType::SYdg | GateType::SYY | GateType::SYYdg | GateType::H | GateType::F | GateType::Fdg | GateType::CH => CellColor::XZMix, + GateType::SY + | GateType::SYdg + | GateType::SYY + | GateType::SYYdg + | GateType::H + | GateType::F + | GateType::Fdg + | GateType::CH => CellColor::XZMix, GateType::SZ | GateType::SZdg => CellColor::XYMix, // No clear single-axis color: idle, alloc/free, multi-qubit, custom GateType::Idle @@ -414,7 +421,15 @@ fn decompose_gate( } // Symmetric two-qubit interactions: dots on both wires, // label on the connector line between them. - GateType::RXX | GateType::RYY | GateType::RZZ | GateType::SXX | GateType::SXXdg | GateType::SYY | GateType::SYYdg | GateType::SZZ | GateType::SZZdg => { + GateType::RXX + | GateType::RYY + | GateType::RZZ + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SZZ + | GateType::SZZdg => { cells.push((row_a, DiagramCell::Control, color)); cells.push((row_b, DiagramCell::Control, color)); connector_label = Some(sym.clone()); diff --git a/crates/pecos-quest/src/quantum_engine.rs b/crates/pecos-quest/src/quantum_engine.rs index 1e02627f0..72ca62919 100644 --- a/crates/pecos-quest/src/quantum_engine.rs +++ b/crates/pecos-quest/src/quantum_engine.rs @@ -226,11 +226,15 @@ impl Engine for QuestStateVecEngine { .u(cmd.angles[0], cmd.angles[1], cmd.angles[2], &cmd.qubits); } } - GateType::RXX | GateType::RYY => { - return Err(PecosError::Processing(format!( - "Gate type {:?} is not yet supported by QuestStateVecEngine", - cmd.gate_type - ))); + GateType::RXX => { + if !cmd.angles.is_empty() { + self.simulator.rxx(cmd.angles[0], &cmd.qubits); + } + } + GateType::RYY => { + if !cmd.angles.is_empty() { + self.simulator.ryy(cmd.angles[0], &cmd.qubits); + } } } } @@ -471,11 +475,15 @@ impl Engine for QuestDensityMatrixEngine { .u(cmd.angles[0], cmd.angles[1], cmd.angles[2], &cmd.qubits); } } - GateType::RXX | GateType::RYY => { - return Err(PecosError::Processing(format!( - "Gate type {:?} is not yet supported by QuestDensityMatrixEngine", - cmd.gate_type - ))); + GateType::RXX => { + if !cmd.angles.is_empty() { + self.simulator.rxx(cmd.angles[0], &cmd.qubits); + } + } + GateType::RYY => { + if !cmd.angles.is_empty() { + self.simulator.ryy(cmd.angles[0], &cmd.qubits); + } } } } @@ -1205,6 +1213,129 @@ impl Engine for QuestCudaStateVecEngine { } } } + GateType::F => { + // F = SX · SZ = RX(pi/2) · RZ(pi/2) + for q in &cmd.qubits { + let qubit = usize::from(*q) as i32; + unsafe { + (self.backend.apply_rotation_z)( + self.qureg_handle, + qubit, + std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_rotation_x)( + self.qureg_handle, + qubit, + std::f64::consts::FRAC_PI_2, + ); + } + } + } + GateType::Fdg => { + // Fdg = F† = SZ† · SX† = RZ(-pi/2) · RX(-pi/2) + for q in &cmd.qubits { + let qubit = usize::from(*q) as i32; + unsafe { + (self.backend.apply_rotation_x)( + self.qureg_handle, + qubit, + -std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_rotation_z)( + self.qureg_handle, + qubit, + -std::f64::consts::FRAC_PI_2, + ); + } + } + } + GateType::SY => { + // SY = RY(pi/2) + for q in &cmd.qubits { + let qubit = usize::from(*q) as i32; + unsafe { + (self.backend.apply_rotation_y)( + self.qureg_handle, + qubit, + std::f64::consts::FRAC_PI_2, + ); + } + } + } + GateType::SYdg => { + // SYdg = RY(-pi/2) + for q in &cmd.qubits { + let qubit = usize::from(*q) as i32; + unsafe { + (self.backend.apply_rotation_y)( + self.qureg_handle, + qubit, + -std::f64::consts::FRAC_PI_2, + ); + } + } + } + GateType::SXX => { + // SXX = RXX(pi/2): decompose as H⊗H · SZZ · H⊗H + // Or equivalently: CNOT(a,b) · RX(pi/2, b) · CNOT(a,b) + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_x)( + self.qureg_handle, + b, + std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } + GateType::SXXdg => { + // SXXdg = RXX(-pi/2) + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_x)( + self.qureg_handle, + b, + -std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } + GateType::SYY => { + // SYY = RYY(pi/2): decompose as CNOT(a,b) · RY(pi/2, b) · CNOT(a,b) + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_y)( + self.qureg_handle, + b, + std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } + GateType::SYYdg => { + // SYYdg = RYY(-pi/2) + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_y)( + self.qureg_handle, + b, + -std::f64::consts::FRAC_PI_2, + ); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } GateType::I | GateType::Idle | GateType::Custom @@ -1213,11 +1344,35 @@ impl Engine for QuestCudaStateVecEngine { | GateType::QFree => { // No operation needed (Custom is a placeholder whose actual gate name is in metadata) } - GateType::SY | GateType::SYdg | GateType::RXX | GateType::RYY => { - return Err(PecosError::Processing(format!( - "Gate type {:?} is not yet supported by QuestCudaStateVecEngine", - cmd.gate_type - ))); + GateType::RXX => { + // RXX(theta) = CNOT(a,b) · RX(theta, b) · CNOT(a,b) + if !cmd.angles.is_empty() { + let theta = cmd.angles[0].to_radians(); + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = + (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_x)(self.qureg_handle, b, theta); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } + } + GateType::RYY => { + // RYY(theta) = CNOT(a,b) · RY(theta, b) · CNOT(a,b) + if !cmd.angles.is_empty() { + let theta = cmd.angles[0].to_radians(); + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = + (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_y)(self.qureg_handle, b, theta); + (self.backend.apply_cnot)(self.qureg_handle, a, b); + } + } + } } } } diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index b538c786a..a32a1f2f8 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -2721,8 +2721,7 @@ impl PyTickHandle { /// Apply an F-dagger gate. fn fdg(slf: Py, py: Python<'_>, q: usize) -> PyResult> { - slf.borrow_mut(py) - .add_gate_internal(py, Gate::fdg(&[q]))?; + slf.borrow_mut(py).add_gate_internal(py, Gate::fdg(&[q]))?; Ok(slf) } @@ -2831,7 +2830,7 @@ impl PyTickHandle { // Generic gate dispatch (name-based) // ========================================================================= - /// Add a gate by name, resolving to a native GateType if possible. + /// Add a gate by name, resolving to a native `GateType` if possible. /// /// If the name matches a known gate type (e.g., "H", "CX", "SZZ"), it is /// added as that native type. Otherwise, it falls through to `custom_gate`. @@ -2868,11 +2867,7 @@ impl PyTickHandle { if let Some(tick) = circuit.inner.get_tick_mut(tick_idx) { match tick.try_add_gate(gate) { Ok(idx) => { - tick.set_gate_attr( - idx, - "_symbol", - Attribute::String(name.to_string()), - ); + tick.set_gate_attr(idx, "_symbol", Attribute::String(name.to_string())); drop(circuit); drop(handle); slf.borrow_mut(py).last_gate_idx = Some(idx); From f36b99f59123478dbb809fcb116f86ba7dcc69ba Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 4 Mar 2026 08:28:25 -0700 Subject: [PATCH 12/12] polish --- crates/pecos-core/src/gate_type.rs | 108 ++++++++++------ crates/pecos-engines/src/noise/utils.rs | 23 +--- crates/pecos-quest/src/quantum_engine.rs | 120 ++++++++++++++---- .../pecos-rslib/src/dag_circuit_bindings.rs | 72 ++++++++++- 4 files changed, 230 insertions(+), 93 deletions(-) diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index 6c1b35ba3..3eb595acd 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -400,49 +400,57 @@ impl std::str::FromStr for GateType { type Err = String; fn from_str(s: &str) -> Result { + // Try exact match first for multi-word aliases with specific casing match s { - "I" | "i" => Ok(GateType::I), - "X" | "x" => Ok(GateType::X), - "Y" | "y" => Ok(GateType::Y), - "Z" | "z" => Ok(GateType::Z), - "H" | "h" => Ok(GateType::H), - "F" | "f" => Ok(GateType::F), - "FDG" | "Fdg" | "fdg" => Ok(GateType::Fdg), - "SX" | "sx" | "Q" | "q" => Ok(GateType::SX), - "SXDG" | "SXdg" | "sxdg" | "QD" | "qd" => Ok(GateType::SXdg), - "SY" | "sy" | "R" => Ok(GateType::SY), - "SYDG" | "SYdg" | "sydg" | "RD" | "rd" => Ok(GateType::SYdg), - "SZ" | "sz" | "S" | "s" => Ok(GateType::SZ), - "SZDG" | "SZdg" | "szdg" | "SD" | "sd" | "SDG" | "sdg" => Ok(GateType::SZdg), - "T" | "t" => Ok(GateType::T), - "TDG" | "Tdg" | "tdg" => Ok(GateType::Tdg), - "RX" | "rx" => Ok(GateType::RX), - "RY" | "ry" => Ok(GateType::RY), - "RZ" | "rz" => Ok(GateType::RZ), - "R1XY" | "r1xy" => Ok(GateType::R1XY), - "U" | "u" => Ok(GateType::U), - "CX" | "cx" | "CNOT" | "cnot" => Ok(GateType::CX), - "CY" | "cy" => Ok(GateType::CY), - "CZ" | "cz" => Ok(GateType::CZ), - "CH" | "ch" => Ok(GateType::CH), - "SXX" | "sxx" => Ok(GateType::SXX), - "SXXDG" | "SXXdg" | "sxxdg" => Ok(GateType::SXXdg), - "SYY" | "syy" => Ok(GateType::SYY), - "SYYDG" | "SYYdg" | "syydg" => Ok(GateType::SYYdg), - "SZZ" | "szz" => Ok(GateType::SZZ), - "SZZDG" | "SZZdg" | "szzdg" => Ok(GateType::SZZdg), - "RXX" | "rxx" => Ok(GateType::RXX), - "RYY" | "ryy" => Ok(GateType::RYY), - "RZZ" | "rzz" => Ok(GateType::RZZ), - "CRZ" | "crz" => Ok(GateType::CRZ), - "CCX" | "ccx" | "TOFFOLI" | "toffoli" => Ok(GateType::CCX), - "SWAP" | "swap" => Ok(GateType::SWAP), - "MEASURE" | "MZ" | "measure" | "measure Z" | "Measure" => Ok(GateType::Measure), - "PREP" | "prep" | "init" | "Init" | "init |0>" | "Init |0>" | "RESET" | "Reset" - | "reset" => Ok(GateType::Prep), - "QALLOC" | "QAlloc" | "qalloc" => Ok(GateType::QAlloc), - "QFREE" | "QFree" | "qfree" => Ok(GateType::QFree), - "IDLE" | "Idle" | "idle" => Ok(GateType::Idle), + "init |0>" | "Init |0>" => return Ok(GateType::Prep), + "measure Z" => return Ok(GateType::Measure), + _ => {} + } + + // Case-insensitive match for all standard gate names + let upper = s.to_ascii_uppercase(); + match upper.as_str() { + "I" => Ok(GateType::I), + "X" => Ok(GateType::X), + "Y" => Ok(GateType::Y), + "Z" => Ok(GateType::Z), + "H" => Ok(GateType::H), + "F" => Ok(GateType::F), + "FDG" => Ok(GateType::Fdg), + "SX" | "Q" => Ok(GateType::SX), + "SXDG" | "QD" => Ok(GateType::SXdg), + "SY" | "R" => Ok(GateType::SY), + "SYDG" | "RD" => Ok(GateType::SYdg), + "SZ" | "S" => Ok(GateType::SZ), + "SZDG" | "SD" | "SDG" => Ok(GateType::SZdg), + "T" => Ok(GateType::T), + "TDG" => Ok(GateType::Tdg), + "RX" => Ok(GateType::RX), + "RY" => Ok(GateType::RY), + "RZ" => Ok(GateType::RZ), + "R1XY" => Ok(GateType::R1XY), + "U" => Ok(GateType::U), + "CX" | "CNOT" => Ok(GateType::CX), + "CY" => Ok(GateType::CY), + "CZ" => Ok(GateType::CZ), + "CH" => Ok(GateType::CH), + "SXX" => Ok(GateType::SXX), + "SXXDG" => Ok(GateType::SXXdg), + "SYY" => Ok(GateType::SYY), + "SYYDG" => Ok(GateType::SYYdg), + "SZZ" => Ok(GateType::SZZ), + "SZZDG" => Ok(GateType::SZZdg), + "RXX" => Ok(GateType::RXX), + "RYY" => Ok(GateType::RYY), + "RZZ" => Ok(GateType::RZZ), + "CRZ" => Ok(GateType::CRZ), + "CCX" | "TOFFOLI" => Ok(GateType::CCX), + "SWAP" => Ok(GateType::SWAP), + "MEASURE" | "MZ" | "MEASURE Z" => Ok(GateType::Measure), + "PREP" | "INIT" | "INIT |0>" | "RESET" => Ok(GateType::Prep), + "QALLOC" => Ok(GateType::QAlloc), + "QFREE" => Ok(GateType::QFree), + "IDLE" => Ok(GateType::Idle), _ => Err(format!("Unknown gate type: {s}")), } } @@ -531,6 +539,22 @@ mod tests { assert_eq!(GateType::from_str("TOFFOLI").unwrap(), GateType::CCX); assert_eq!(GateType::from_str("init |0>").unwrap(), GateType::Prep); + // Case-insensitive matching + assert_eq!(GateType::from_str("h").unwrap(), GateType::H); + assert_eq!(GateType::from_str("cx").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("Cx").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("cX").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("cnot").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("Cnot").unwrap(), GateType::CX); + assert_eq!(GateType::from_str("fdg").unwrap(), GateType::Fdg); + assert_eq!(GateType::from_str("sxxdg").unwrap(), GateType::SXXdg); + assert_eq!(GateType::from_str("r").unwrap(), GateType::SY); + assert_eq!(GateType::from_str("R").unwrap(), GateType::SY); + assert_eq!(GateType::from_str("q").unwrap(), GateType::SX); + assert_eq!(GateType::from_str("s").unwrap(), GateType::SZ); + assert_eq!(GateType::from_str("toffoli").unwrap(), GateType::CCX); + assert_eq!(GateType::from_str("Toffoli").unwrap(), GateType::CCX); + // Unknown assert!(GateType::from_str("FOOBAR").is_err()); } diff --git a/crates/pecos-engines/src/noise/utils.rs b/crates/pecos-engines/src/noise/utils.rs index 88a4460c3..d8036a313 100644 --- a/crates/pecos-engines/src/noise/utils.rs +++ b/crates/pecos-engines/src/noise/utils.rs @@ -250,22 +250,13 @@ impl NoiseUtils { builder.add_idle(gate.params[0], &qubits_usize); } - // Gates that are handled by the simulator directly (via CliffordGateable) - // but don't have byte message builder methods. - // Custom is a placeholder (actual gate name is in metadata). - GateType::F - | GateType::Fdg - | GateType::SXX - | GateType::SXXdg - | GateType::SYY - | GateType::SYYdg - | GateType::Custom => {} - - // Invalid cases (not enough qubits, missing parameters, etc.) - _ => panic!( - "Invalid gate type {:?} or insufficient parameters/qubits", - gate.gate_type - ), + // Custom is a placeholder (actual gate name is in metadata) -- skip. + GateType::Custom => {} + + // All other gates: use generic serialization (gate type + qubits + angles/params). + _ => { + builder.add_gate_command(gate); + } } } diff --git a/crates/pecos-quest/src/quantum_engine.rs b/crates/pecos-quest/src/quantum_engine.rs index 72ca62919..ccdcd5ffc 100644 --- a/crates/pecos-quest/src/quantum_engine.rs +++ b/crates/pecos-quest/src/quantum_engine.rs @@ -125,15 +125,39 @@ impl Engine for QuestStateVecEngine { self.simulator.sydg(&cmd.qubits); } GateType::SXX => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SXX gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } self.simulator.sxx(&cmd.qubits); } GateType::SXXdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SXXdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } self.simulator.sxxdg(&cmd.qubits); } GateType::SYY => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SYY gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } self.simulator.syy(&cmd.qubits); } GateType::SYYdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SYYdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } self.simulator.syydg(&cmd.qubits); } GateType::SWAP => { @@ -227,14 +251,20 @@ impl Engine for QuestStateVecEngine { } } GateType::RXX => { - if !cmd.angles.is_empty() { - self.simulator.rxx(cmd.angles[0], &cmd.qubits); + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RXX gate requires at least one angle".to_string(), + )); } + self.simulator.rxx(cmd.angles[0], &cmd.qubits); } GateType::RYY => { - if !cmd.angles.is_empty() { - self.simulator.ryy(cmd.angles[0], &cmd.qubits); + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RYY gate requires at least one angle".to_string(), + )); } + self.simulator.ryy(cmd.angles[0], &cmd.qubits); } } } @@ -374,15 +404,39 @@ impl Engine for QuestDensityMatrixEngine { self.simulator.sydg(&cmd.qubits); } GateType::SXX => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SXX gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } self.simulator.sxx(&cmd.qubits); } GateType::SXXdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SXXdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } self.simulator.sxxdg(&cmd.qubits); } GateType::SYY => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SYY gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } self.simulator.syy(&cmd.qubits); } GateType::SYYdg => { + if cmd.qubits.len() % 2 != 0 { + return Err(PecosError::Processing(format!( + "SYYdg gate requires even number of qubits, got {}", + cmd.qubits.len() + ))); + } self.simulator.syydg(&cmd.qubits); } GateType::SWAP => { @@ -476,14 +530,20 @@ impl Engine for QuestDensityMatrixEngine { } } GateType::RXX => { - if !cmd.angles.is_empty() { - self.simulator.rxx(cmd.angles[0], &cmd.qubits); + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RXX gate requires at least one angle".to_string(), + )); } + self.simulator.rxx(cmd.angles[0], &cmd.qubits); } GateType::RYY => { - if !cmd.angles.is_empty() { - self.simulator.ryy(cmd.angles[0], &cmd.qubits); + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RYY gate requires at least one angle".to_string(), + )); } + self.simulator.ryy(cmd.angles[0], &cmd.qubits); } } } @@ -1346,31 +1406,35 @@ impl Engine for QuestCudaStateVecEngine { } GateType::RXX => { // RXX(theta) = CNOT(a,b) · RX(theta, b) · CNOT(a,b) - if !cmd.angles.is_empty() { - let theta = cmd.angles[0].to_radians(); - for qubits in cmd.qubits.chunks_exact(2) { - let (a, b) = - (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); - unsafe { - (self.backend.apply_cnot)(self.qureg_handle, a, b); - (self.backend.apply_rotation_x)(self.qureg_handle, b, theta); - (self.backend.apply_cnot)(self.qureg_handle, a, b); - } + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RXX gate requires at least one angle".to_string(), + )); + } + let theta = cmd.angles[0].to_radians(); + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_x)(self.qureg_handle, b, theta); + (self.backend.apply_cnot)(self.qureg_handle, a, b); } } } GateType::RYY => { // RYY(theta) = CNOT(a,b) · RY(theta, b) · CNOT(a,b) - if !cmd.angles.is_empty() { - let theta = cmd.angles[0].to_radians(); - for qubits in cmd.qubits.chunks_exact(2) { - let (a, b) = - (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); - unsafe { - (self.backend.apply_cnot)(self.qureg_handle, a, b); - (self.backend.apply_rotation_y)(self.qureg_handle, b, theta); - (self.backend.apply_cnot)(self.qureg_handle, a, b); - } + if cmd.angles.is_empty() { + return Err(PecosError::Processing( + "RYY gate requires at least one angle".to_string(), + )); + } + let theta = cmd.angles[0].to_radians(); + for qubits in cmd.qubits.chunks_exact(2) { + let (a, b) = (usize::from(qubits[0]) as i32, usize::from(qubits[1]) as i32); + unsafe { + (self.backend.apply_cnot)(self.qureg_handle, a, b); + (self.backend.apply_rotation_y)(self.qureg_handle, b, theta); + (self.backend.apply_cnot)(self.qureg_handle, a, b); } } } diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index a32a1f2f8..320216cfd 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -2851,20 +2851,82 @@ impl PyTickHandle { match GateType::from_str(name) { Ok(gate_type) => { + let arity = gate_type.quantum_arity(); + let angle_arity = gate_type.angle_arity(); + + // Validate angle count for parameterized gates let angle_vals: Vec = angles .unwrap_or_default() .into_iter() .map(Angle64::from_radians) .collect(); - let qubit_ids: GateQubits = qubits.into_iter().map(QubitId::from).collect(); - let gate = Gate::new(gate_type, angle_vals, vec![], qubit_ids); + if angle_arity > 0 && angle_vals.len() != angle_arity { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Gate '{name}' requires {angle_arity} angle(s), got {}", + angle_vals.len() + ))); + } + + // Determine if we need to broadcast (e.g. single-qubit gate on multiple qubits) + let needs_broadcast = + arity > 0 && qubits.len() > arity && qubits.len().is_multiple_of(arity); + + if arity > 0 && qubits.len() != arity && !needs_broadcast { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Gate '{name}' requires {} qubit(s), got {} (not a valid multiple)", + arity, + qubits.len() + ))); + } let handle = slf.borrow_mut(py); let tick_idx = handle.tick_idx; let circuit_py = handle.circuit.clone_ref(py); let mut circuit = circuit_py.borrow_mut(py); - if let Some(tick) = circuit.inner.get_tick_mut(tick_idx) { + let tick = circuit.inner.get_tick_mut(tick_idx).ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err(format!( + "Tick {tick_idx} does not exist" + )) + })?; + + if needs_broadcast { + // Broadcast: create one gate per arity-chunk of qubits + let mut last_idx = None; + for chunk in qubits.chunks(arity) { + let qubit_ids: GateQubits = + chunk.iter().copied().map(QubitId::from).collect(); + let gate = Gate::new(gate_type, angle_vals.clone(), vec![], qubit_ids); + match tick.try_add_gate(gate) { + Ok(idx) => { + tick.set_gate_attr( + idx, + "_symbol", + Attribute::String(name.to_string()), + ); + last_idx = Some(idx); + } + Err(err) => { + let msg = format!( + "Qubit(s) {:?} already in use in tick {}", + err.conflicting_qubits + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + tick_idx + ); + return Err(PyErr::new::(msg)); + } + } + } + drop(circuit); + drop(handle); + slf.borrow_mut(py).last_gate_idx = last_idx; + Ok(slf) + } else { + // Normal: create single gate + let qubit_ids: GateQubits = qubits.into_iter().map(QubitId::from).collect(); + let gate = Gate::new(gate_type, angle_vals, vec![], qubit_ids); match tick.try_add_gate(gate) { Ok(idx) => { tick.set_gate_attr(idx, "_symbol", Attribute::String(name.to_string())); @@ -2885,10 +2947,6 @@ impl PyTickHandle { Err(PyErr::new::(msg)) } } - } else { - drop(circuit); - drop(handle); - Ok(slf) } } Err(_) => {