diff --git a/Cargo.lock b/Cargo.lock index 449176f54..8bf49bdff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2262,6 +2262,15 @@ dependencies = [ "ppvm-traits-2", ] +[[package]] +name = "ppvm-phased-pauli-word-2" +version = "0.1.0" +dependencies = [ + "ppvm-lossy-pauli-word-2", + "ppvm-pauli-word-2", + "ppvm-traits-2", +] + [[package]] name = "ppvm-python-native" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 346a084b6..4d03c58c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ members = [ # Built by `cargo build --workspace --all-targets` in CI so the skill # can't silently drift away from the public API. "crates/ppvm-tui", - "skills/ppvm-usage/examples/rust", "crates/ppvm-lossy-pauli-word-2", + "skills/ppvm-usage/examples/rust", "crates/ppvm-lossy-pauli-word-2", "crates/ppvm-phased-pauli-word-2", ] # Single version for every crate and the `bloqade-ppvm` Python package diff --git a/crates/ppvm-lossy-pauli-word-2/src/data.rs b/crates/ppvm-lossy-pauli-word-2/src/data.rs index 743b223b6..165076e04 100644 --- a/crates/ppvm-lossy-pauli-word-2/src/data.rs +++ b/crates/ppvm-lossy-pauli-word-2/src/data.rs @@ -9,7 +9,7 @@ use std::{ use bitvec::array::BitArray; use ppvm_pauli_word_2::{DefaultStorage, PauliStorage}; -use ppvm_traits_2::{LossState, LossySite, Pauli, PauliBits, Word}; +use ppvm_traits_2::{LossState, LossySite, Pauli, PauliBits, Word, loss::LossInfo}; pub struct LossyPauliWord { pub(crate) xbits: BitArray, @@ -426,12 +426,19 @@ impl PauliBits for LossyPauliWord { } } -impl LossState for LossyPauliWord { +impl LossInfo for LossyPauliWord { #[inline] fn is_lost(&self, i: usize) -> bool { LossyPauliWord::is_lost(self, i) } + #[inline] + fn loss_weight(&self) -> usize { + LossyPauliWord::loss_weight(self) + } +} + +impl LossState for LossyPauliWord { #[inline] fn set_lost(&mut self, i: usize) { LossyPauliWord::set_lost(self, i); @@ -441,11 +448,6 @@ impl LossState for LossyPauliWord { fn clear_lost(&mut self, i: usize) { LossyPauliWord::clear_loss(self, i); } - - #[inline] - fn loss_weight(&self) -> usize { - LossyPauliWord::loss_weight(self) - } } #[cfg(test)] diff --git a/crates/ppvm-pauli-word-2/src/data.rs b/crates/ppvm-pauli-word-2/src/data.rs index 60d5d3eee..0af30f10e 100644 --- a/crates/ppvm-pauli-word-2/src/data.rs +++ b/crates/ppvm-pauli-word-2/src/data.rs @@ -10,7 +10,7 @@ use std::marker::PhantomData; use bitvec::array::BitArray; use bitvec::view::BitView; use num::{One, Zero}; -use ppvm_traits_2::{Pauli, PauliBits, Word}; +use ppvm_traits_2::{Pauli, PauliBits, Word, loss::LossInfo}; use crate::hash::structural_hash; use crate::{DefaultStorage, HashFinalize, PauliStorage}; @@ -226,6 +226,14 @@ impl Word for PauliWord { } } +/// Ordinary words report no loss. So, we just have NOOP LossInfo reporting. +impl LossInfo for PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ +} + impl PauliBits for PauliWord where A: PauliStorage, diff --git a/crates/ppvm-pauli-word-2/src/lib.rs b/crates/ppvm-pauli-word-2/src/lib.rs index 0f320bea7..b74274dfd 100644 --- a/crates/ppvm-pauli-word-2/src/lib.rs +++ b/crates/ppvm-pauli-word-2/src/lib.rs @@ -8,12 +8,10 @@ mod column; mod data; mod hash; mod pauli_parser; -mod phase; mod product; mod storage; pub use column::PauliKeyColumn; pub use data::PauliWord; pub use hash::HashFinalize; -pub use phase::Phased; pub use storage::{DefaultStorage, PauliStorage}; diff --git a/crates/ppvm-pauli-word-2/src/pauli_parser.rs b/crates/ppvm-pauli-word-2/src/pauli_parser.rs index 9137eae0f..9bc35c5ba 100644 --- a/crates/ppvm-pauli-word-2/src/pauli_parser.rs +++ b/crates/ppvm-pauli-word-2/src/pauli_parser.rs @@ -85,13 +85,6 @@ mod tests { for input in ["IIIIIIIII", "XXXXXXXXI", "IIIIIIIIX", "I_I_I_I_I_I_I_I_I"] { assert!(std::panic::catch_unwind(|| PauliWord::::from(input)).is_err()); assert!(std::panic::catch_unwind(|| PauliWord::::from(input.to_owned())).is_err()); - let phased = format!("+i{input}"); - assert!( - std::panic::catch_unwind(|| { - crate::Phased::>::from(phased.as_str()) - }) - .is_err() - ); } for (input, expected) in [ ("", ""), diff --git a/crates/ppvm-pauli-word-2/src/product.rs b/crates/ppvm-pauli-word-2/src/product.rs index 0b5df5b75..a82b9245d 100644 --- a/crates/ppvm-pauli-word-2/src/product.rs +++ b/crates/ppvm-pauli-word-2/src/product.rs @@ -23,19 +23,24 @@ where /// Returns the XORed word and its residual phase for the coefficient to absorb. /// Both inputs must have equal width; zero unused bits stay zero. fn key_mul(&self, other: &Self) -> (Self, Phase) { + let mut product = *self; + let phase = product.key_mul_assign(other); + (product, phase) + } + + /// Multiplies in place, returning the residual phase and refreshing the hash once. + #[inline(always)] + fn key_mul_assign(&mut self, other: &Self) -> Phase { debug_assert_eq!( self.nqubits, other.nqubits, "twisted product requires equal-width words", ); - let lhs_x = self.xbits.data.as_raw_slice(); - let lhs_z = self.zbits.data.as_raw_slice(); + let lhs_x = self.xbits.data.as_raw_mut_slice(); + let lhs_z = self.zbits.data.as_raw_mut_slice(); let rhs_x = other.xbits.data.as_raw_slice(); let rhs_z = other.zbits.data.as_raw_slice(); - let mut product = Self::new(self.nqubits); - let product_x = product.xbits.data.as_raw_mut_slice(); - let product_z = product.zbits.data.as_raw_mut_slice(); let mut sign_count = 0u32; let mut imaginary_count = 0u32; @@ -56,13 +61,13 @@ where imaginary_count += imaginary_mask.count_ones(); // XOR gives the product's Pauli letters; the masks account for phase. - product_x[chunk] = left_x ^ right_x; - product_z[chunk] = left_z ^ right_z; + lhs_x[chunk] = left_x ^ right_x; + lhs_z[chunk] = left_z ^ right_z; } - product.refresh_hash(); + self.refresh_hash(); let phase_exponent = ((2 * sign_count + imaginary_count) % 4) as u8; - (product, Phase::from_exponent(phase_exponent)) + Phase::from_exponent(phase_exponent) } } diff --git a/crates/ppvm-phased-pauli-word-2/Cargo.toml b/crates/ppvm-phased-pauli-word-2/Cargo.toml new file mode 100644 index 000000000..e4a9836d0 --- /dev/null +++ b/crates/ppvm-phased-pauli-word-2/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ppvm-phased-pauli-word-2" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +ppvm-traits-2 = { path = "../ppvm-traits-2" } +ppvm-pauli-word-2 = { path = "../ppvm-pauli-word-2" } + +[dev-dependencies] +ppvm-lossy-pauli-word-2 = { path = "../ppvm-lossy-pauli-word-2" } diff --git a/crates/ppvm-phased-pauli-word-2/src/clifford.rs b/crates/ppvm-phased-pauli-word-2/src/clifford.rs new file mode 100644 index 000000000..652ce1464 --- /dev/null +++ b/crates/ppvm-phased-pauli-word-2/src/clifford.rs @@ -0,0 +1,373 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Phase-tracking Clifford conjugation. Gates touching lost sites are no-ops. +//! The base word must implement both `PauliBits` and `LossInfo`. + +use ppvm_traits_2::{Clifford, PauliBits, Phase, loss::LossInfo}; + +use crate::data::Phased; + +impl Phased { + #[inline] + fn flip_sign_if(&mut self, cond: bool) { + if cond { + self.phase *= Phase::Neg1; + } + } +} + +impl Clifford for Phased { + #[inline] + fn x(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + self.flip_sign_if(self.word.z_bit(q)); + } + + #[inline] + fn y(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + self.flip_sign_if(self.word.x_bit(q) ^ self.word.z_bit(q)); + } + + #[inline] + fn z(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + self.flip_sign_if(self.word.x_bit(q)); + } + + #[inline] + fn h(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + let x = self.word.x_bit(q); + let z = self.word.z_bit(q); + self.word.set_xz_bits(q, z, x); + self.flip_sign_if(x && z); + } + + // Heisenberg convention: S† X S = -Y. + #[inline] + fn s(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + let x = self.word.x_bit(q); + let z = self.word.z_bit(q); + self.word.set_z_bit(q, z ^ x); + self.flip_sign_if(x && !z); + } + + #[inline] + fn cnot(&mut self, ctrl: usize, tgt: usize) { + debug_assert_ne!(ctrl, tgt, "CNOT requires distinct qubits"); + debug_assert!( + ctrl < self.word.n_sites() && tgt < self.word.n_sites(), + "qubit out of bounds" + ); + if self.word.is_lost(ctrl) || self.word.is_lost(tgt) { + return; + } + let xc = self.word.x_bit(ctrl); + let zc = self.word.z_bit(ctrl); + let xt = self.word.x_bit(tgt); + let zt = self.word.z_bit(tgt); + self.word.set_x_bit_and_z_bit(tgt, xt ^ xc, ctrl, zc ^ zt); + self.flip_sign_if(xc && zt && (xt == zc)); + } + + #[inline] + fn cz(&mut self, a: usize, b: usize) { + debug_assert_ne!(a, b, "CZ requires distinct qubits"); + debug_assert!( + a < self.word.n_sites() && b < self.word.n_sites(), + "qubit out of bounds" + ); + if self.word.is_lost(a) || self.word.is_lost(b) { + return; + } + let xa = self.word.x_bit(a); + let za = self.word.z_bit(a); + let xb = self.word.x_bit(b); + let zb = self.word.z_bit(b); + self.word.set_z_bit_pair(a, za ^ xb, b, zb ^ xa); + self.flip_sign_if(xa && xb && (za ^ zb)); + } + + #[inline] + fn s_dag(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + let x = self.word.x_bit(q); + let z = self.word.z_bit(q); + self.word.set_z_bit(q, z ^ x); + self.flip_sign_if(x && z); + } + + #[inline] + fn sqrt_x(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + let x = self.word.x_bit(q); + let z = self.word.z_bit(q); + self.word.set_x_bit(q, x ^ z); + self.flip_sign_if(x && z); + } + + #[inline] + fn sqrt_x_dag(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + let x = self.word.x_bit(q); + let z = self.word.z_bit(q); + self.word.set_x_bit(q, x ^ z); + self.flip_sign_if(!x && z); + } + + #[inline] + fn sqrt_y(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + let x = self.word.x_bit(q); + let z = self.word.z_bit(q); + self.word.set_xz_bits(q, z, x); + self.flip_sign_if(!x && z); + } + + #[inline] + fn sqrt_y_dag(&mut self, q: usize) { + if self.word.is_lost(q) { + return; + } + let x = self.word.x_bit(q); + let z = self.word.z_bit(q); + self.word.set_xz_bits(q, z, x); + self.flip_sign_if(x && !z); + } + + #[inline] + fn cy(&mut self, control: usize, target: usize) { + debug_assert_ne!(control, target, "CY requires distinct qubits"); + debug_assert!( + control < self.word.n_sites() && target < self.word.n_sites(), + "qubit out of bounds" + ); + if self.word.is_lost(control) || self.word.is_lost(target) { + return; + } + let xc = self.word.x_bit(control); + let zc = self.word.z_bit(control); + let xt = self.word.x_bit(target); + let zt = self.word.z_bit(target); + self.word + .set_xz_bits2(control, xc, zc ^ xt ^ zt, target, xt ^ xc, zt ^ xc); + self.flip_sign_if(xc && (xt ^ zt) && !(zc ^ zt)); + } +} + +#[cfg(test)] +mod tests { + use crate::{Phased, PhasedPauliWord}; + use ppvm_lossy_pauli_word_2::LossyPauliWord; + use ppvm_traits_2::Clifford; + + type TestWord = Phased; + + #[test] + fn gates_touching_lost_sites_preserve_word_and_phase() { + use ppvm_traits_2::Phase; + + let single: [fn(&mut TestWord, usize); 10] = [ + TestWord::x, + TestWord::y, + TestWord::z, + TestWord::h, + TestWord::s, + TestWord::s_dag, + TestWord::sqrt_x, + TestWord::sqrt_x_dag, + TestWord::sqrt_y, + TestWord::sqrt_y_dag, + ]; + let pairs: [fn(&mut TestWord, usize, usize); 3] = + [TestWord::cnot, TestWord::cz, TestWord::cy]; + for phase in [Phase::Pos1, Phase::PosI, Phase::Neg1, Phase::NegI] { + for input in ["XL", "LY", "LL"] { + let original = Phased::with_phase(LossyPauliWord::from(input), phase); + let lost = if input.starts_with('L') { 0 } else { 1 }; + for gate in single { + let mut word = original.clone(); + gate(&mut word, lost); + assert_eq!(word, original); + } + for gate in pairs { + for (a, b) in [(0, 1), (1, 0)] { + let mut word = original.clone(); + gate(&mut word, a, b); + assert_eq!(word, original); + } + } + } + } + } + + fn conj(input: &str, gate: impl Fn(&mut PhasedPauliWord)) -> String { + let mut w = PhasedPauliWord::from(input); + gate(&mut w); + w.to_string() + } + + #[test] + fn ordinary_word_reports_no_loss() { + use crate::PauliWord; + use ppvm_traits_2::loss::LossInfo; + + let word: PauliWord = PauliWord::from("XYZ"); + assert_eq!(word.loss_weight(), 0); + for i in 0..3 { + assert!(!word.is_lost(i)); + } + } + + #[test] + fn single_qubit_gates_track_sign() { + // (gate, input → target) ported from the old phase/clifford.rs tests. + let x = [("+I", "+I"), ("+X", "+X"), ("+Y", "-Y"), ("+Z", "-Z")]; + let y = [("+I", "+I"), ("+X", "-X"), ("+Y", "+Y"), ("+Z", "-Z")]; + let z = [("+I", "+I"), ("+X", "-X"), ("+Y", "-Y"), ("+Z", "+Z")]; + let h = [("+I", "+I"), ("+X", "+Z"), ("+Y", "-Y"), ("+Z", "+X")]; + let s = [("+I", "+I"), ("+X", "-Y"), ("+Y", "+X"), ("+Z", "+Z")]; + for (i, t) in x { + assert_eq!(conj(i, |w| w.x(0)), t, "X {i}"); + } + for (i, t) in y { + assert_eq!(conj(i, |w| w.y(0)), t, "Y {i}"); + } + for (i, t) in z { + assert_eq!(conj(i, |w| w.z(0)), t, "Z {i}"); + } + for (i, t) in h { + assert_eq!(conj(i, |w| w.h(0)), t, "H {i}"); + } + for (i, t) in s { + assert_eq!(conj(i, |w| w.s(0)), t, "S {i}"); + } + } + + #[test] + fn cnot_tracks_sign() { + for (i, t) in [ + ("+II", "+II"), + ("+IX", "+IX"), + ("+IZ", "+ZZ"), + ("+IY", "+ZY"), + ("+XI", "+XX"), + ("+XX", "+XI"), + ("+XY", "+YZ"), + ("+XZ", "-YY"), + ("+ZI", "+ZI"), + ("+ZX", "+ZX"), + ("+ZY", "+IY"), + ("+ZZ", "+IZ"), + ("+YI", "+YX"), + ("+YX", "+YI"), + ("+YY", "-XZ"), + ("+YZ", "+XY"), + ] { + assert_eq!(conj(i, |w| w.cnot(0, 1)), t, "CNOT {i}"); + } + } + + #[test] + fn cz_tracks_sign() { + for (i, t) in [ + ("+II", "+II"), + ("+IX", "+ZX"), + ("+IY", "+ZY"), + ("+IZ", "+IZ"), + ("+XI", "+XZ"), + ("+XX", "+YY"), + ("+XY", "-YX"), + ("+XZ", "+XI"), + ("+ZI", "+ZI"), + ("+ZX", "+IX"), + ("+ZY", "+IY"), + ("+ZZ", "+ZZ"), + ("+YI", "+YZ"), + ("+YX", "-XY"), + ("+YY", "+XX"), + ("+YZ", "+YI"), + ] { + assert_eq!(conj(i, |w| w.cz(0, 1)), t, "CZ {i}"); + } + } + + #[test] + fn phase_carries_through_gate() { + // A pre-existing +i factor is preserved under a signed conjugation: + // H (−iY) = −i·(−Y) = +iY. + assert_eq!(conj("-iY", |w| w.h(0)), "+iY"); + } + + #[test] + fn extension_gates_track_sign() { + // The single-qubit table of `ppvm-traits/src/traits/clifford.rs`. + let s_dag = [("+I", "+I"), ("+X", "+Y"), ("+Y", "-X"), ("+Z", "+Z")]; + let sqrt_x = [("+I", "+I"), ("+X", "+X"), ("+Y", "-Z"), ("+Z", "+Y")]; + let sqrt_x_dag = [("+I", "+I"), ("+X", "+X"), ("+Y", "+Z"), ("+Z", "-Y")]; + let sqrt_y = [("+I", "+I"), ("+X", "+Z"), ("+Y", "+Y"), ("+Z", "-X")]; + let sqrt_y_dag = [("+I", "+I"), ("+X", "-Z"), ("+Y", "+Y"), ("+Z", "+X")]; + for (i, t) in s_dag { + assert_eq!(conj(i, |w| w.s_dag(0)), t, "S† {i}"); + } + for (i, t) in sqrt_x { + assert_eq!(conj(i, |w| w.sqrt_x(0)), t, "√X {i}"); + } + for (i, t) in sqrt_x_dag { + assert_eq!(conj(i, |w| w.sqrt_x_dag(0)), t, "√X† {i}"); + } + for (i, t) in sqrt_y { + assert_eq!(conj(i, |w| w.sqrt_y(0)), t, "√Y {i}"); + } + for (i, t) in sqrt_y_dag { + assert_eq!(conj(i, |w| w.sqrt_y_dag(0)), t, "√Y† {i}"); + } + } + + #[test] + fn cy_tracks_sign() { + // The 16-entry CY table of `ppvm-traits/src/traits/clifford.rs`. + for (i, t) in [ + ("+II", "+II"), + ("+IX", "+ZX"), + ("+IY", "+IY"), + ("+IZ", "+ZZ"), + ("+XI", "+XY"), + ("+XX", "-YZ"), + ("+XY", "+XI"), + ("+XZ", "+YX"), + ("+YI", "+YY"), + ("+YX", "+XZ"), + ("+YY", "+YI"), + ("+YZ", "-XX"), + ("+ZI", "+ZI"), + ("+ZX", "+IX"), + ("+ZY", "+ZY"), + ("+ZZ", "+IZ"), + ] { + assert_eq!(conj(i, |w| w.cy(0, 1)), t, "CY {i}"); + assert_eq!(conj(i, |w| w.zcy(0, 1)), t, "ZCY {i}"); + } + } +} diff --git a/crates/ppvm-pauli-word-2/src/phase.rs b/crates/ppvm-phased-pauli-word-2/src/data.rs similarity index 67% rename from crates/ppvm-pauli-word-2/src/phase.rs rename to crates/ppvm-phased-pauli-word-2/src/data.rs index 444299ff1..511afb20c 100644 --- a/crates/ppvm-pauli-word-2/src/phase.rs +++ b/crates/ppvm-phased-pauli-word-2/src/data.rs @@ -6,7 +6,7 @@ use std::fmt; use std::hash::BuildHasher; -use crate::{HashFinalize, PauliStorage, PauliWord}; +use ppvm_pauli_word_2::{HashFinalize, PauliStorage, PauliWord}; use ppvm_traits_2::{Phase, Word}; /// A base word with a phase in `{+1, +i, -1, -i}`, representing `i^φ · g(w)`. @@ -147,6 +147,80 @@ where #[cfg(test)] mod tests { use super::*; + use ppvm_traits_2::Pauli; + + type Pw = Phased; + + #[test] + fn parsing_enforces_storage_capacity() { + for input in ["IIIIIIIII", "XXXXXXXXI", "IIIIIIIIX", "I_I_I_I_I_I_I_I_I"] { + let phased = format!("+i{input}"); + assert!( + std::panic::catch_unwind(|| { Phased::>::from(phased.as_str()) }) + .is_err() + ); + } + } + + #[test] + fn parse_sign_and_body() { + let cases = [ + ("+XYZI", Phase::Pos1), + ("-XYZI", Phase::Neg1), + ("+iXYZI", Phase::PosI), + ("-iXYZI", Phase::NegI), + ]; + for (s, want) in cases { + let pw: Pw = s.into(); + assert_eq!(pw.phase(), want, "{s} phase"); + assert_eq!(pw.n_sites(), 4, "{s} width"); + assert_eq!(pw.get(0), Pauli::X); + assert_eq!(pw.get(1), Pauli::Y); + assert_eq!(pw.get(2), Pauli::Z); + assert_eq!(pw.get(3), Pauli::I); + } + } + + #[test] + fn display_roundtrips_prefix() { + for s in ["+XYZI", "-XYZI", "+iXYZI", "-iXYZI"] { + let pw: Pw = s.into(); + assert_eq!(pw.to_string(), s); + } + } + + #[test] + fn is_positive_reads_sign() { + let pos: Pw = "+XY".into(); + let posi: Pw = "+iXY".into(); + let neg: Pw = "-XY".into(); + let negi: Pw = "-iXY".into(); + assert!(pos.is_positive() && posi.is_positive()); + assert!(!neg.is_positive() && !negi.is_positive()); + } + + #[test] + fn phase_participates_in_equality() { + let a: Pw = "+XY".into(); + let b: Pw = "-XY".into(); + let c: Pw = "+XY".into(); + assert_ne!(a, b, "different phase ⇒ distinct"); + assert_eq!(a, c); + } + + #[test] + fn weight_iter_delegate_to_word() { + let pw: Pw = "-iXIYZ".into(); + assert_eq!(pw.weight(), 3); + let via_iter: Vec = pw.iter().collect(); + assert_eq!(via_iter, vec![Pauli::X, Pauli::I, Pauli::Y, Pauli::Z]); + } + + #[test] + fn send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } #[test] fn phase_prefixes_roundtrip() { diff --git a/crates/ppvm-phased-pauli-word-2/src/lib.rs b/crates/ppvm-phased-pauli-word-2/src/lib.rs new file mode 100644 index 000000000..8281e238e --- /dev/null +++ b/crates/ppvm-phased-pauli-word-2/src/lib.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +mod clifford; +mod data; +mod product; + +// ppvm-phased-pauli-word-2: The phased Pauli word wrapper. +// +// `Phased` wraps a base word `W` with an explicit `ℤ₄` phase — the standalone +// phased Pauli operator (`i^φ · g(w)`). Unlike the bare PauliWord / LossyPauliWord, +// whose clifford drops every conjugation sign, Phased carries a fused clifford impl +// that tracks the ℤ₄ sign (`HYH = −Y`, `SXS† = Y`, the CNOT/CZ deltas). + +pub use data::Phased; +pub type PhasedPauliWord = Phased; +pub use ppvm_pauli_word_2::PauliWord; diff --git a/crates/ppvm-phased-pauli-word-2/src/product.rs b/crates/ppvm-phased-pauli-word-2/src/product.rs new file mode 100644 index 000000000..7bdfda51d --- /dev/null +++ b/crates/ppvm-phased-pauli-word-2/src/product.rs @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Multiplication for [`Phased`] where `W: KeyProduct`. +//! Reuses [`KeyProduct::key_mul`] and combines its residual phase with both prefactors. +//! `Mul` and `MulAssign` delegate to the same product implementation. +//! Lossy words do not implement `KeyProduct`, so they have no phased product. + +use std::ops::{Mul, MulAssign}; + +use ppvm_traits_2::KeyProduct; + +use crate::data::Phased; + +impl Phased { + /// Multiply the base words and combine both prefactors with the emitted phase. + /// `(φ, v) · (ψ, w) = (φ · ψ · iᵏ, v·w)`, where `key_mul` emits `iᵏ`. + #[inline] + pub fn mul(&self, rhs: &Self) -> Self { + let mut product = self.clone(); + product *= rhs; + product + } +} + +impl Mul for Phased { + type Output = Phased; + + #[inline] + fn mul(mut self, rhs: Self) -> Self { + self *= &rhs; + self + } +} + +impl Mul<&Phased> for &Phased { + type Output = Phased; + + #[inline] + fn mul(self, rhs: &Phased) -> Phased { + Phased::mul(self, rhs) + } +} + +impl MulAssign<&Phased> for Phased { + #[inline] + fn mul_assign(&mut self, rhs: &Phased) { + let emitted = self.word.key_mul_assign(&rhs.word); + self.phase *= rhs.phase * emitted; + } +} + +impl MulAssign for Phased { + #[inline] + fn mul_assign(&mut self, rhs: Phased) { + *self *= &rhs; + } +} + +#[cfg(test)] +mod tests { + use crate::PhasedPauliWord; + + fn product(lhs: &str, rhs: &str) -> String { + let x: PhasedPauliWord = lhs.into(); + let y: PhasedPauliWord = rhs.into(); + (x * y).to_string() + } + + #[test] + fn single_qubit_products() { + // Ported from the old phase/mul.rs tests. + for (lhs, rhs, ans) in [("+X", "+X", "+I"), ("+X", "+Y", "+iZ"), ("+X", "+Z", "-iY")] { + assert_eq!(product(lhs, rhs), ans, "{lhs}*{rhs}"); + } + } + + #[test] + fn multi_qubit_and_phase_accumulation() { + for (lhs, rhs, ans) in [ + ("+ZI", "-ZI", "-II"), + ("+II", "-ZI", "-ZI"), + ("+XI", "+iXI", "+iII"), + ("-XX", "-XX", "+II"), + ] { + assert_eq!(product(lhs, rhs), ans, "{lhs}*{rhs}"); + } + } + + #[test] + fn operator_forms_agree() { + let a: PhasedPauliWord = "+iXY".into(); + let b: PhasedPauliWord = "-ZX".into(); + let by_ref = (&a * &b).to_string(); + let mut acc = a.clone(); + acc *= &b; + assert_eq!(by_ref, acc.to_string()); + assert_eq!(by_ref, a.mul(&b).to_string()); + assert_eq!(by_ref, (a * b).to_string()); + } + + #[test] + fn square_is_identity_up_to_phase() { + // Every Pauli word with prefactor +1 squares to +I on all sites. + for s in ["+XYZI", "+YYXZ", "+ZZZZ", "+IXYX"] { + let w: PhasedPauliWord = s.into(); + let sq = w.mul(&w); + assert_eq!(sq.to_string(), "+IIII", "{s}²"); + } + } +} diff --git a/crates/ppvm-traits-2/src/algebra.rs b/crates/ppvm-traits-2/src/algebra.rs index a61bf21cd..04ef525d6 100644 --- a/crates/ppvm-traits-2/src/algebra.rs +++ b/crates/ppvm-traits-2/src/algebra.rs @@ -94,6 +94,7 @@ impl core::ops::MulAssign for Phase { pub trait KeyProduct: Eq + Clone { /// Product of two keys, with the phase it produces (folded onto the coeff). fn key_mul(&self, other: &Self) -> (Self, Phase); + fn key_mul_assign(&mut self, other: &Self) -> Phase; } /// A commutative coefficient ring with an imaginary unit. diff --git a/crates/ppvm-traits-2/src/containers/batch.rs b/crates/ppvm-traits-2/src/containers/batch.rs index 663e463b1..ccc2224db 100644 --- a/crates/ppvm-traits-2/src/containers/batch.rs +++ b/crates/ppvm-traits-2/src/containers/batch.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::containers::Indexable; -use crate::loss::LossState; +use crate::loss::LossInfo; use crate::word::PauliBits; /// An [`Indexable`] key with a structure-of-arrays column representation. @@ -86,12 +86,12 @@ where } } -/// Row-level loss access for columns whose keys implement [`LossState`]. +/// Row-level loss access for columns whose keys implement [`LossInfo`]. /// Defaults materialize the row; packed columns override. No blanket impl, for /// the same reason as [`PauliColumn`]. pub trait LossColumn: KeyColumn where - Self::Key: LossState, + Self::Key: LossInfo, { /// Read one row's loss bit. #[inline] diff --git a/crates/ppvm-traits-2/src/loss.rs b/crates/ppvm-traits-2/src/loss.rs index 3e2b8e604..96dce0146 100644 --- a/crates/ppvm-traits-2/src/loss.rs +++ b/crates/ppvm-traits-2/src/loss.rs @@ -8,8 +8,9 @@ pub enum LossySite { /// A lost site - the qubit no longer participates Lost, } -/// Loss state associated with word sites. -pub trait LossState { + +/// Read only loss gathering information +pub trait LossInfo { /// Whether site `i` is lost. Lossy words override this. fn is_lost(&self, _i: usize) -> bool { false @@ -19,7 +20,10 @@ pub trait LossState { fn loss_weight(&self) -> usize { 0 } +} +/// Loss state associated with word sites. +pub trait LossState: LossInfo { /// Mark index `i` lost. fn set_lost(&mut self, i: usize);