diff --git a/Cargo.lock b/Cargo.lock index 180d95d5b..e63a4b55e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2239,6 +2239,17 @@ dependencies = [ "serde", ] +[[package]] +name = "ppvm-pauli-word-2" +version = "0.1.0" +dependencies = [ + "bitvec", + "bytemuck", + "fxhash", + "num", + "ppvm-traits-2", +] + [[package]] name = "ppvm-python-native" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 732d82300..4813f788c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ members = [ "crates/ppvm-vihaco", "crates/ppvm-cli", "crates/ppvm-traits-2", + "crates/ppvm-pauli-word-2", # Runnable copies of the Rust code blocks in skills/ppvm-usage/SKILL.md. # Built by `cargo build --workspace --all-targets` in CI so the skill # can't silently drift away from the public API. diff --git a/crates/ppvm-pauli-word-2/Cargo.toml b/crates/ppvm-pauli-word-2/Cargo.toml new file mode 100644 index 000000000..f2d0fcce5 --- /dev/null +++ b/crates/ppvm-pauli-word-2/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ppvm-pauli-word-2" +version = "0.1.0" +edition = "2024" + +[dependencies] +ppvm-traits-2 = { version = "0.1.0", path = "../ppvm-traits-2"} +bitvec = "1.0.1" +bytemuck = { version = "1", features = ["min_const_generics"] } +fxhash = "0.2.1" +num = "0.4.3" diff --git a/crates/ppvm-pauli-word-2/src/clifford.rs b/crates/ppvm-pauli-word-2/src/clifford.rs new file mode 100644 index 000000000..77a155c20 --- /dev/null +++ b/crates/ppvm-pauli-word-2/src/clifford.rs @@ -0,0 +1,367 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Clifford conjugation of packed Pauli words, discarding all phase signs. +//! Fused gates refresh the eager hash once instead of hashing each primitive step. +//! Symplectic bit operations remain available; `PhaseTrack` is a no-op. +//! Phase signs must be handled separately by phased words or the sum engine. + +use std::hash::BuildHasher; + +use ppvm_traits_2::{Clifford, PhaseTrack, SymplecticColumns}; + +use crate::data::PauliWord; +use crate::{HashFinalize, PauliStorage}; + +impl Clifford for PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + #[inline] + fn x(&mut self, q: usize) { + debug_assert!(q < self.nqubits, "qubit {q} out of bounds"); + } + + #[inline] + fn y(&mut self, q: usize) { + debug_assert!(q < self.nqubits, "qubit {q} out of bounds"); + } + + #[inline] + fn z(&mut self, q: usize) { + debug_assert!(q < self.nqubits, "qubit {q} out of bounds"); + } + + #[inline] + fn h(&mut self, q: usize) { + debug_assert!(q < self.nqubits, "qubit {q} out of bounds"); + let x = self.xbits[q]; + self.xbits.set(q, self.zbits[q]); + self.zbits.set(q, x); + self.refresh_hash(); + } + + #[inline] + fn s(&mut self, q: usize) { + debug_assert!(q < self.nqubits, "qubit {q} out of bounds"); + let z = self.zbits[q] ^ self.xbits[q]; + self.zbits.set(q, z); + self.refresh_hash(); + } + + #[inline] + fn cnot(&mut self, control: usize, target: usize) { + debug_assert_ne!(control, target, "CNOT requires distinct qubits"); + debug_assert!( + control < self.nqubits && target < self.nqubits, + "qubit out of bounds" + ); + let xt = self.xbits[target] ^ self.xbits[control]; + let zc = self.zbits[control] ^ self.zbits[target]; + self.xbits.set(target, xt); + self.zbits.set(control, zc); + self.refresh_hash(); + } + + #[inline] + fn cz(&mut self, a: usize, b: usize) { + debug_assert_ne!(a, b, "CZ requires distinct qubits"); + debug_assert!(a < self.nqubits && b < self.nqubits, "qubit out of bounds"); + let za = self.zbits[a] ^ self.xbits[b]; + let zb = self.zbits[b] ^ self.xbits[a]; + self.zbits.set(a, za); + self.zbits.set(b, zb); + self.refresh_hash(); + } + #[inline] + fn s_dag(&mut self, q: usize) { + self.s(q); + } + + #[inline] + fn sqrt_x(&mut self, q: usize) { + debug_assert!(q < self.nqubits, "qubit {q} out of bounds"); + let x = self.xbits[q] ^ self.zbits[q]; + self.xbits.set(q, x); + self.refresh_hash(); + } + + #[inline] + fn sqrt_x_dag(&mut self, q: usize) { + self.sqrt_x(q); + } + + #[inline] + fn sqrt_y(&mut self, q: usize) { + self.h(q); + } + + #[inline] + fn sqrt_y_dag(&mut self, q: usize) { + self.h(q); + } + + #[inline] + fn cy(&mut self, control: usize, target: usize) { + debug_assert_ne!(control, target, "CY requires distinct qubits"); + debug_assert!( + control < self.nqubits && target < self.nqubits, + "qubit out of bounds" + ); + let xc = self.xbits[control]; + let zc = self.zbits[control]; + let xt = self.xbits[target]; + let zt = self.zbits[target]; + self.zbits.set(control, zc ^ xt ^ zt); + self.xbits.set(target, xt ^ xc); + self.zbits.set(target, zt ^ xc); + self.refresh_hash(); + } +} + +impl SymplecticColumns for PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + #[inline] + fn n_qubits(&self) -> usize { + self.nqubits + } + + /// `H` on `q`: swap the X and Z bits. Touches only in-range slot `q`, so the + /// canonical-unused-bits invariant is preserved. + #[inline] + fn swap_xz(&mut self, q: usize) { + debug_assert!(q < self.nqubits, "qubit {q} out of bounds"); + let xb = self.xbits[q]; + let zb = self.zbits[q]; + self.xbits.set(q, zb); + self.zbits.set(q, xb); + self.refresh_hash(); + } + + /// `S` on `q`: `z_q ⊕= x_q` (maps `X → Y`). + #[inline] + fn xor_z_from_x(&mut self, q: usize) { + debug_assert!(q < self.nqubits, "qubit {q} out of bounds"); + let z = self.zbits[q] ^ self.xbits[q]; + self.zbits.set(q, z); + self.refresh_hash(); + } + + /// `CNOT` bit rule, part one: `x_tgt ⊕= x_ctrl`. + #[inline] + fn xor_x_col(&mut self, ctrl: usize, tgt: usize) { + debug_assert!( + ctrl < self.nqubits && tgt < self.nqubits, + "qubit out of bounds" + ); + let x = self.xbits[tgt] ^ self.xbits[ctrl]; + self.xbits.set(tgt, x); + self.refresh_hash(); + } + + /// `CNOT` bit rule, part two: `z_ctrl ⊕= z_tgt`. + #[inline] + fn xor_z_col(&mut self, tgt: usize, ctrl: usize) { + debug_assert!( + ctrl < self.nqubits && tgt < self.nqubits, + "qubit out of bounds" + ); + let z = self.zbits[ctrl] ^ self.zbits[tgt]; + self.zbits.set(ctrl, z); + self.refresh_hash(); + } + + /// `CZ` on `(a, b)`: `z_a ⊕= x_b` and `z_b ⊕= x_a`. + #[inline] + fn cz_bits(&mut self, a: usize, b: usize) { + debug_assert!(a < self.nqubits && b < self.nqubits, "qubit out of bounds"); + let za = self.zbits[a] ^ self.xbits[b]; + let zb = self.zbits[b] ^ self.xbits[a]; + self.zbits.set(a, za); + self.zbits.set(b, zb); + self.refresh_hash(); + } +} + +/// Bare words store no phase, so phase updates deliberately do nothing. +impl PhaseTrack for PauliWord { + #[inline] + fn flip_phase_where_xz(&mut self, _q: usize) {} + #[inline] + fn s_phase(&mut self, _q: usize) {} + #[inline] + fn cnot_phase(&mut self, _ctrl: usize, _tgt: usize) {} + #[inline] + fn cz_phase(&mut self, _a: usize, _b: usize) {} + #[inline] + fn x_phase(&mut self, _q: usize) {} + #[inline] + fn y_phase(&mut self, _q: usize) {} + #[inline] + fn z_phase(&mut self, _q: usize) {} +} + +#[cfg(test)] +mod tests { + use super::*; + use ppvm_traits_2::{Clifford, Indexable}; + + fn assert_rejected_unchanged(input: &str, operation: impl FnOnce(&mut PauliWord)) { + use std::panic::{AssertUnwindSafe, catch_unwind}; + + let mut word = PauliWord::from(input); + let original = word; + let hash = word.key_hash(); + assert!(catch_unwind(AssertUnwindSafe(|| operation(&mut word))).is_err()); + assert_eq!(word, original); + assert_eq!(word.key_hash(), hash); + } + + #[test] + fn single_qubit_operations_enforce_logical_bounds() { + let operations: [fn(&mut PauliWord, usize); 12] = [ + PauliWord::x, + PauliWord::y, + PauliWord::z, + PauliWord::h, + PauliWord::s, + PauliWord::s_dag, + PauliWord::sqrt_x, + PauliWord::sqrt_x_dag, + PauliWord::sqrt_y, + PauliWord::sqrt_y_dag, + PauliWord::swap_xz, + PauliWord::xor_z_from_x, + ]; + for operation in operations { + for invalid in [1, 7, 63] { + assert_rejected_unchanged("X", |word| operation(word, invalid)); + } + assert_rejected_unchanged("", |word| operation(word, 0)); + } + } + + #[test] + fn two_qubit_operations_enforce_logical_bounds() { + let operations: [fn(&mut PauliWord, usize, usize); 6] = [ + PauliWord::cnot, + PauliWord::cy, + PauliWord::cz, + PauliWord::xor_x_col, + PauliWord::xor_z_col, + PauliWord::cz_bits, + ]; + for operation in operations { + for input in ["X", "Y", "Z"] { + for invalid in [1, 7, 63] { + assert_rejected_unchanged(input, |word| operation(word, 0, invalid)); + assert_rejected_unchanged(input, |word| operation(word, invalid, 0)); + } + } + } + } + + #[test] + fn two_qubit_gates_reject_identical_indices_before_mutation() { + use std::panic::{AssertUnwindSafe, catch_unwind}; + + let gates: [fn(&mut PauliWord, usize, usize); 3] = [ + ::cnot, + ::cy, + ::cz, + ]; + for gate in gates { + for input in ["I", "X", "Y", "Z"] { + let mut word = PauliWord::from(input); + let original = word; + let hash = word.key_hash(); + assert!(catch_unwind(AssertUnwindSafe(|| gate(&mut word, 0, 0))).is_err()); + assert_eq!(word, original); + assert_eq!(word.key_hash(), hash); + } + } + } + + fn conj(input: &str, gate: impl Fn(&mut PauliWord)) -> String { + let mut w: PauliWord = input.into(); + gate(&mut w); + w.to_string() + } + + #[test] + fn hadamard_bit_map() { + // Bit-only: X↔Z, Y→Y (the −Y sign a phased word carries is dropped). + for (input, target) in [("I", "I"), ("X", "Z"), ("Y", "Y"), ("Z", "X")] { + assert_eq!(conj(input, |w| w.h(0)), target, "H {input}"); + } + } + + #[test] + fn phase_gate_bit_map() { + for (input, target) in [("I", "I"), ("X", "Y"), ("Y", "X"), ("Z", "Z")] { + assert_eq!(conj(input, |w| w.s(0)), target, "S {input}"); + } + } + + #[test] + fn cnot_bit_map() { + for (input, target) 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(input, |w| w.cnot(0, 1)), target, "CNOT {input}"); + } + } + + #[test] + fn cz_bit_map() { + for (input, target) 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(input, |w| w.cz(0, 1)), target, "CZ {input}"); + } + } + + #[test] + fn pauli_gates_are_bit_noops() { + // X/Y/Z conjugation is pure sign; on a phaseless word it changes nothing. + for input in ["I", "X", "Y", "Z"] { + assert_eq!(conj(input, |w| w.x(0)), input); + assert_eq!(conj(input, |w| w.y(0)), input); + assert_eq!(conj(input, |w| w.z(0)), input); + } + } +} diff --git a/crates/ppvm-pauli-word-2/src/column.rs b/crates/ppvm-pauli-word-2/src/column.rs new file mode 100644 index 000000000..e16444233 --- /dev/null +++ b/crates/ppvm-pauli-word-2/src/column.rs @@ -0,0 +1,437 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Stores Pauli words in separate X/Z plane arrays with a shared qubit width. +//! Each plane entry holds one word’s packed storage; `ColumnStore` manages hash columns. +//! Implements column reading, mutation, and Pauli access; selected via `Columnar`. +//! Uses the shared structural hash so column and individual-word hashes agree. + +use std::marker::PhantomData; + +use bitvec::array::BitArray; +use ppvm_traits_2::{Columnar, KeyColumn, KeyColumnMut, PauliColumn}; + +use crate::data::PauliWord; +use crate::hash::structural_hash; +use crate::{HashFinalize, PauliStorage}; +use std::hash::BuildHasher; + +/// A structure-of-arrays column of [`PauliWord`]s: parallel X and Z plane blocks +/// plus the shared qubit width. +pub struct PauliKeyColumn { + xplanes: Vec, + zplanes: Vec, + nqubits: usize, + _hasher: PhantomData H>, +} + +impl PauliKeyColumn +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + /// Flips one selected bit directly, without constructing or hashing a word. + #[inline(always)] + fn toggle_plane(plane: &mut A, qubit: usize, toggle: bool) { + if !toggle { + return; + } + #[cfg(target_endian = "little")] + { + bytemuck::bytes_of_mut(plane)[qubit >> 3] ^= 1 << (qubit & 7); + } + #[cfg(target_endian = "big")] + { + let mut bits = BitArray::new(*plane); + let value = bits[qubit]; + bits.set(qubit, !value); + *plane = bits.data; + } + } + + #[inline(always)] + fn plane_bit(plane: &A, qubit: usize) -> bool { + #[cfg(target_endian = "little")] + { + let bytes = bytemuck::bytes_of(plane); + bytes[qubit >> 3] & (1 << (qubit & 7)) != 0 + } + #[cfg(target_endian = "big")] + { + BitArray::::new(*plane)[qubit] + } + } +} + +impl Default for PauliKeyColumn { + #[inline] + fn default() -> Self { + Self { + xplanes: Vec::new(), + zplanes: Vec::new(), + nqubits: 0, + _hasher: PhantomData, + } + } +} + +impl Clone for PauliKeyColumn { + #[inline] + fn clone(&self) -> Self { + Self { + xplanes: self.xplanes.clone(), + zplanes: self.zplanes.clone(), + nqubits: self.nqubits, + _hasher: PhantomData, + } + } +} + +impl KeyColumn for PauliKeyColumn +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + type Key = PauliWord; + + #[inline] + fn len(&self) -> usize { + self.xplanes.len() + } + + #[inline] + fn capacity(&self) -> usize { + self.xplanes.capacity().min(self.zplanes.capacity()) + } + + /// Hashes each pair of planes so `out[i]` equals `self.get(i).key_hash()`. + #[inline] + fn hash_into(&self, out: &mut [u64]) { + debug_assert_eq!(out.len(), self.len(), "hash column length mismatch"); + for (i, slot) in out.iter_mut().enumerate() { + *slot = structural_hash::(&self.xplanes[i], &self.zplanes[i], self.nqubits); + } + } + + #[inline] + fn key_eq(&self, i: usize, other: &Self::Key) -> bool { + self.nqubits == other.nqubits + && self.xplanes[i] == other.xbits.data + && self.zplanes[i] == other.zbits.data + } + + #[inline] + fn gather(&self, indices: &[u32]) -> Self { + let mut xplanes = Vec::with_capacity(indices.len()); + let mut zplanes = Vec::with_capacity(indices.len()); + for &idx in indices { + xplanes.push(self.xplanes[idx as usize]); + zplanes.push(self.zplanes[idx as usize]); + } + Self { + xplanes, + zplanes, + nqubits: self.nqubits, + _hasher: PhantomData, + } + } + + #[inline] + fn get(&self, i: usize) -> Self::Key { + PauliWord::from_planes( + BitArray::new(self.xplanes[i]), + BitArray::new(self.zplanes[i]), + self.nqubits, + ) + } +} + +impl KeyColumnMut for PauliKeyColumn +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + #[inline] + fn with_capacity(n: usize) -> Self { + Self { + xplanes: Vec::with_capacity(n), + zplanes: Vec::with_capacity(n), + nqubits: 0, + _hasher: PhantomData, + } + } + + /// Appends a key’s planes. The first key sets the width; later keys must match. + #[inline] + fn push(&mut self, key: Self::Key) { + if self.xplanes.is_empty() { + self.nqubits = key.nqubits; + } else { + debug_assert_eq!(self.nqubits, key.nqubits, "column width mismatch"); + } + self.xplanes.push(key.xbits.data); + self.zplanes.push(key.zbits.data); + } + + /// Reserves room in both plane arrays to reduce reallocations during batch insertion. + #[inline] + fn reserve(&mut self, additional: usize) { + self.xplanes.reserve(additional); + self.zplanes.reserve(additional); + } + + /// Clears both plane arrays while retaining their allocations and stored width. + /// The next push sets the width again because the column is empty. + #[inline] + fn clear(&mut self) { + self.xplanes.clear(); + self.zplanes.clear(); + } + + /// Replaces one key’s planes without moving other entries or reallocating. + /// Used by `ColumnStore` for in-place Clifford updates. + #[inline] + fn set(&mut self, i: usize, key: Self::Key) { + debug_assert_eq!(self.nqubits, key.nqubits, "column width mismatch"); + self.xplanes[i] = key.xbits.data; + self.zplanes[i] = key.zbits.data; + } + + #[inline] + fn truncate(&mut self, len: usize) { + self.xplanes.truncate(len); + self.zplanes.truncate(len); + } + + #[inline] + fn swap_remove(&mut self, i: usize) -> Self::Key { + let x = self.xplanes.swap_remove(i); + let z = self.zplanes.swap_remove(i); + PauliWord::from_planes(BitArray::new(x), BitArray::new(z), self.nqubits) + } +} + +impl PauliColumn for PauliKeyColumn +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + #[inline(always)] + fn x_bit(&self, row: usize, qubit: usize) -> bool { + Self::plane_bit(&self.xplanes[row], qubit) + } + + #[inline(always)] + fn z_bit(&self, row: usize, qubit: usize) -> bool { + Self::plane_bit(&self.zplanes[row], qubit) + } + + #[inline(always)] + fn toggled_bits(&self, row: usize, qubit: usize, toggle_x: bool, toggle_z: bool) -> Self::Key { + debug_assert!(qubit < self.nqubits, "qubit {qubit} out of bounds"); + let mut x = self.xplanes[row]; + let mut z = self.zplanes[row]; + Self::toggle_plane(&mut x, qubit, toggle_x); + Self::toggle_plane(&mut z, qubit, toggle_z); + PauliWord::from_planes(BitArray::new(x), BitArray::new(z), self.nqubits) + } + + /// Copies the planes once, applies both masks, and hashes only the final word. + #[inline(always)] + fn toggled_bits2( + &self, + row: usize, + i: usize, + [xi, zi]: [bool; 2], + j: usize, + [xj, zj]: [bool; 2], + ) -> Self::Key { + debug_assert!(i < self.nqubits, "qubit {i} out of bounds"); + debug_assert!(j < self.nqubits, "qubit {j} out of bounds"); + let mut x = self.xplanes[row]; + let mut z = self.zplanes[row]; + Self::toggle_plane(&mut x, i, xi); + Self::toggle_plane(&mut z, i, zi); + Self::toggle_plane(&mut x, j, xj); + Self::toggle_plane(&mut z, j, zj); + PauliWord::from_planes(BitArray::new(x), BitArray::new(z), self.nqubits) + } +} + +impl Columnar for PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + type Column = PauliKeyColumn; +} + +#[cfg(test)] +mod tests { + use super::*; + use ppvm_traits_2::{Indexable, PauliBits}; + + #[test] + fn mismatched_width_mutations_leave_column_unchanged() { + use std::panic::{AssertUnwindSafe, catch_unwind}; + + for (original, mismatched) in [("I", "II"), ("II", "I")] { + let mut col = column(&[original]); + let expected = PauliWord::from(original); + let capacity = col.capacity(); + + assert!( + catch_unwind(AssertUnwindSafe(|| { + col.push(PauliWord::from(mismatched)); + })) + .is_err() + ); + assert_eq!(col.len(), 1); + assert_eq!(col.get(0), expected); + assert_eq!(col.capacity(), capacity); + + assert!( + catch_unwind(AssertUnwindSafe(|| { + col.set(0, PauliWord::from(mismatched)); + })) + .is_err() + ); + assert_eq!(col.len(), 1); + assert_eq!(col.get(0), expected); + assert_eq!(col.capacity(), capacity); + + let mut hashes = [0]; + col.hash_into(&mut hashes); + assert_eq!(hashes[0], expected.key_hash()); + } + } + + #[test] + fn toggles_reject_indices_beyond_logical_width() { + let col = column(&["I"]); + for invalid in [1, 7, 63] { + for mask in 0..4 { + let toggle = [mask & 1 != 0, mask & 2 != 0]; + assert!( + std::panic::catch_unwind(|| { + col.toggled_bits(0, invalid, toggle[0], toggle[1]) + }) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| { + col.toggled_bits2(0, invalid, toggle, 0, [false; 2]) + }) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| { + col.toggled_bits2(0, 0, [false; 2], invalid, toggle) + }) + .is_err() + ); + } + } + assert_eq!(col.get(0), PauliWord::from("I")); + } + + fn check_toggles() { + let word = PauliWord::::from("XYZI".repeat(32).as_str()); + let mut col = PauliKeyColumn::::default(); + col.push(word); + for i in 0..128 { + assert_eq!(col.x_bit(0, i), word.x_bit(i)); + assert_eq!(col.z_bit(0, i), word.z_bit(i)); + for mask in 0..4 { + let (x, z) = (mask & 1 != 0, mask & 2 != 0); + let actual = col.toggled_bits(0, i, x, z); + let expected = word.toggled_bits(i, x, z); + assert_eq!(actual, expected); + assert_eq!(actual.key_hash(), expected.key_hash()); + } + } + for (i, j) in [(0, 0), (2, 5), (7, 8), (63, 64), (127, 0)] { + for mask in 0..16 { + let left = [mask & 1 != 0, mask & 2 != 0]; + let right = [mask & 4 != 0, mask & 8 != 0]; + let actual = col.toggled_bits2(0, i, left, j, right); + let expected = word.toggled_bits2(i, left, j, right); + assert_eq!(actual, expected); + assert_eq!(actual.key_hash(), expected.key_hash()); + } + } + assert_eq!(col.get(0), word); + } + + #[test] + fn packed_toggles_match_words_across_storage_widths() { + check_toggles::<[u8; 16]>(); + check_toggles::<[usize; 4]>(); + } + + #[test] + fn mutations_preserve_planes_hashes_and_capacity() { + let mut col = column(&["XYZI", "IIIZ", "YYYY"]); + col.reserve(8); + let capacity = col.capacity(); + col.set(1, PauliWord::from("ZZZZ")); + assert_eq!(col.swap_remove(0), PauliWord::from("XYZI")); + assert_eq!(col.get(0), PauliWord::from("YYYY")); + assert_eq!(col.get(1), PauliWord::from("ZZZZ")); + let picked = col.gather(&[1, 0, 1]); + let mut hashes = vec![0; picked.len()]; + picked.hash_into(&mut hashes); + for (i, hash) in hashes.into_iter().enumerate() { + assert_eq!(hash, picked.get(i).key_hash()); + } + col.truncate(1); + assert_eq!(col.len(), 1); + col.clear(); + assert!(col.is_empty()); + assert_eq!(col.capacity(), capacity); + col.push(PauliWord::from("XY")); + assert_eq!(col.get(0), PauliWord::from("XY")); + } + + fn column(words: &[&str]) -> PauliKeyColumn { + let mut col = PauliKeyColumn::::with_capacity(words.len()); + for w in words { + col.push(PauliWord::from(*w)); + } + col + } + + #[test] + fn roundtrip_and_len() { + let words = ["XYZI", "IIIZ", "YYYY"]; + let col = column(&words); + assert_eq!(col.len(), 3); + assert!(!col.is_empty()); + for (i, w) in words.iter().enumerate() { + assert_eq!(col.get(i), PauliWord::from(*w)); + } + } + + #[test] + fn hash_into_matches_scalar_key_hash() { + let words = ["XYZI", "IIIZ", "YYYY", "ZXZX"]; + let col = column(&words); + let mut out = vec![0u64; col.len()]; + col.hash_into(&mut out); + for (i, w) in words.iter().enumerate() { + assert_eq!(out[i], PauliWord::::from(*w).key_hash(), "key {i}"); + } + } + + #[test] + fn key_eq_and_gather() { + let col = column(&["XYZI", "IIIZ", "YYYY"]); + assert!(col.key_eq(1, &PauliWord::from("IIIZ"))); + assert!(!col.key_eq(1, &PauliWord::from("XYZI"))); + + let picked = col.gather(&[2, 0]); + assert_eq!(picked.len(), 2); + assert_eq!(picked.get(0), PauliWord::from("YYYY")); + assert_eq!(picked.get(1), PauliWord::from("XYZI")); + } +} diff --git a/crates/ppvm-pauli-word-2/src/data.rs b/crates/ppvm-pauli-word-2/src/data.rs new file mode 100644 index 000000000..60d5d3eee --- /dev/null +++ b/crates/ppvm-pauli-word-2/src/data.rs @@ -0,0 +1,520 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Packed Pauli words with construction, inspection, and mutation. +//! Equality compares qubit count and X/Z planes; mutations refresh the hash. + +use std::hash::BuildHasher; +use std::marker::PhantomData; + +use bitvec::array::BitArray; +use bitvec::view::BitView; +use num::{One, Zero}; +use ppvm_traits_2::{Pauli, PauliBits, Word}; + +use crate::hash::structural_hash; +use crate::{DefaultStorage, HashFinalize, PauliStorage}; + +/// A Pauli word in packed X/Z planes: I=(0,0), X=(1,0), Z=(0,1), Y=(1,1). +/// `A` selects backing storage; `H` selects the internal hash algorithm. +/// Unused bits stay zero; equality compares qubit count and both planes. +/// An eagerly refreshed `u64` hash makes lookup a field read and preserves `Copy`. +pub struct PauliWord { + /// X-bit plane (one logical bit per qubit; unused high bits are `0`). + pub(crate) xbits: BitArray, + /// Z-bit plane (one logical bit per qubit; unused high bits are `0`). + pub(crate) zbits: BitArray, + /// Number of qubits (logical width). + pub(crate) nqubits: usize, + /// Eager finalized structural digest. + pub(crate) hash_cache: u64, + /// The private internal digest algorithm; never a runtime value. + /// `fn() -> H` keeps `PauliWord` `Send + Sync` for any `H`. + pub(crate) _hasher: PhantomData H>, +} + +/// The storage word holding logical bit `i`, and `i`'s offset inside it. +#[inline(always)] +fn word_of(i: usize) -> (usize, usize) { + let bits = std::mem::size_of::<::Store>() * 8; + (i / bits, i % bits) +} + +/// `1 << offset` when `toggle`, else `0` — a select on a register, never a +/// branch on memory. +#[inline(always)] +fn bit_mask(offset: usize, toggle: bool) -> ::Store { + let one = ::Store::one(); + let zero = ::Store::zero(); + if toggle { one << offset } else { zero } +} + +/// Toggles two bits in one plane, updating each affected storage word once. +/// Combines masks with XOR so toggling the same bit twice cancels. +/// Used for data-dependent Clifford toggles to avoid separate conditional writes. +/// Rotation builders use direct flips instead; see `PauliWord::with_bits_toggled2`. +#[inline(always)] +fn xor_bits2( + plane: &mut BitArray, + i: usize, + toggle_i: bool, + j: usize, + toggle_j: bool, +) { + let (wi, oi) = word_of::(i); + let (wj, oj) = word_of::(j); + let mask_i = bit_mask::(oi, toggle_i); + let mask_j = bit_mask::(oj, toggle_j); + let raw = plane.data.as_raw_mut_slice(); + if wi == wj { + raw[wi] = raw[wi] ^ (mask_i ^ mask_j); + } else { + raw[wi] = raw[wi] ^ mask_i; + raw[wj] = raw[wj] ^ mask_j; + } +} + +impl PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + /// Constructs the identity word on `nqubits` qubits with zeroed planes. + /// Computes the hash eagerly so the word is ready for map insertion. + #[inline] + pub fn new(nqubits: usize) -> Self { + Self::from_planes(BitArray::ZERO, BitArray::ZERO, nqubits) + } + + /// Assembles packed planes, rejecting widths beyond the backing storage capacity. + #[inline] + pub(crate) fn from_planes(xbits: BitArray, zbits: BitArray, nqubits: usize) -> Self { + debug_assert!( + nqubits <= 8 * std::mem::size_of::(), + "nqubits {nqubits} exceeds the {}-bit backing storage", + 8 * std::mem::size_of::(), + ); + let hash_cache = structural_hash::(&xbits.data, &zbits.data, nqubits); + Self { + xbits, + zbits, + nqubits, + hash_cache, + _hasher: PhantomData, + } + } + + /// Refreshes the cached hash immediately after a structural mutation. + #[inline] + pub(crate) fn refresh_hash(&mut self) { + self.hash_cache = structural_hash::(&self.xbits.data, &self.zbits.data, self.nqubits); + } + + /// Copies the planes, toggles the requested X/Z bits at `i`, and hashes once. + /// Builds a rotation-branch key without copying and then replacing the old hash. + #[inline] + pub fn with_bits_toggled(&self, i: usize, toggle_x: bool, toggle_z: bool) -> Self { + debug_assert!(i < self.nqubits, "qubit {i} out of bounds"); + let mut xbits = self.xbits; + let mut zbits = self.zbits; + if toggle_x { + let b = xbits[i]; + xbits.set(i, !b); + } + if toggle_z { + let b = zbits[i]; + zbits.set(i, !b); + } + Self::from_planes(xbits, zbits, self.nqubits) + } + + /// Copies both planes once, toggles up to four bits at two sites, and hashes once. + /// Avoids rebuilding the word twice when constructing a two-qubit rotation branch. + /// Keeps direct flips separate from `xor_bits2`: constant rotation toggles let + /// the compiler remove branches without testing whether storage words coincide. + #[inline] + pub fn with_bits_toggled2( + &self, + i: usize, + toggle_x_i: bool, + toggle_z_i: bool, + j: usize, + toggle_x_j: bool, + toggle_z_j: bool, + ) -> Self { + debug_assert!(i < self.nqubits, "qubit {i} out of bounds"); + debug_assert!(j < self.nqubits, "qubit {j} out of bounds"); + let mut xbits = self.xbits; + let mut zbits = self.zbits; + if toggle_x_i { + let b = xbits[i]; + xbits.set(i, !b); + } + if toggle_z_i { + let b = zbits[i]; + zbits.set(i, !b); + } + if toggle_x_j { + let b = xbits[j]; + xbits.set(j, !b); + } + if toggle_z_j { + let b = zbits[j]; + zbits.set(j, !b); + } + Self::from_planes(xbits, zbits, self.nqubits) + } +} + +impl Word for PauliWord { + type Site = Pauli; + + #[inline] + fn n_sites(&self) -> usize { + self.nqubits + } + + #[inline] + fn get(&self, index: usize) -> Pauli { + debug_assert!(index < self.nqubits, "index {index} out of bounds"); + match (self.xbits[index], self.zbits[index]) { + (false, false) => Pauli::I, + (true, false) => Pauli::X, + (false, true) => Pauli::Z, + (true, true) => Pauli::Y, + } + } + + /// Counts non-identity factors by counting set bits in `x | z` in packed chunks. + #[inline] + fn weight(&self) -> usize { + let xs: &[u8] = bytemuck::bytes_of(&self.xbits.data); + let zs: &[u8] = bytemuck::bytes_of(&self.zbits.data); + debug_assert_eq!(xs.len(), zs.len()); + + let mut total: u32 = 0; + let (mut i, n) = (0usize, xs.len()); + + while i + 8 <= n { + let x = u64::from_ne_bytes(xs[i..i + 8].try_into().unwrap()); + let z = u64::from_ne_bytes(zs[i..i + 8].try_into().unwrap()); + total += (x | z).count_ones(); + i += 8; + } + if i + 4 <= n { + let x = u32::from_ne_bytes(xs[i..i + 4].try_into().unwrap()); + let z = u32::from_ne_bytes(zs[i..i + 4].try_into().unwrap()); + total += (x | z).count_ones(); + i += 4; + } + if i + 2 <= n { + let x = u16::from_ne_bytes(xs[i..i + 2].try_into().unwrap()); + let z = u16::from_ne_bytes(zs[i..i + 2].try_into().unwrap()); + total += (x | z).count_ones(); + i += 2; + } + if i < n { + total += (xs[i] | zs[i]).count_ones(); + } + + total as usize + } + + #[inline] + fn iter(&self) -> impl Iterator { + (0..self.nqubits).map(move |i| self.get(i)) + } +} + +impl PauliBits for PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + // TODO: in the case that we wish to perform operations on PauliWord + // in a hot patch, but don't wish to index it, hashing can be a significant + // overhead. If that were to happen, add functionality to support the toggling + // for hashing capability. + #[inline(always)] + fn x_bit(&self, i: usize) -> bool { + debug_assert!(i < self.nqubits, "index {i} out of bounds"); + self.xbits[i] + } + + #[inline(always)] + fn z_bit(&self, i: usize) -> bool { + debug_assert!(i < self.nqubits, "index {i} out of bounds"); + self.zbits[i] + } + + #[inline(always)] + fn pauli_code(&self, i: usize) -> u8 { + debug_assert!(i < self.nqubits, "index {i} out of bounds"); + #[cfg(target_endian = "little")] + { + let byte = i >> 3; + let shift = i & 7; + let x = (bytemuck::bytes_of(&self.xbits.data)[byte] >> shift) & 1; + let z = (bytemuck::bytes_of(&self.zbits.data)[byte] >> shift) & 1; + x | (z << 1) + } + #[cfg(target_endian = "big")] + { + (self.xbits[i] as u8) | ((self.zbits[i] as u8) << 1) + } + } + + /// Sets the X bit at `i` and eagerly refreshes the hash only if the bit changes. + /// Unchanged bits retain the cached hash, avoiding redundant work during gates. + #[inline(always)] + fn set_x_bit(&mut self, i: usize, v: bool) { + debug_assert!(i < self.nqubits, "index {i} out of bounds"); + if self.xbits[i] != v { + self.xbits.set(i, v); + self.refresh_hash(); + } + } + + #[inline(always)] + fn set_z_bit(&mut self, i: usize, v: bool) { + debug_assert!(i < self.nqubits, "index {i} out of bounds"); + if self.zbits[i] != v { + self.zbits.set(i, v); + self.refresh_hash(); + } + } + + #[inline(always)] + fn set_xz_bits(&mut self, i: usize, x: bool, z: bool) { + debug_assert!(i < self.nqubits, "index {i} out of bounds"); + if self.xbits[i] != x || self.zbits[i] != z { + self.xbits.set(i, x); + self.zbits.set(i, z); + self.refresh_hash(); + } + } + + #[inline(always)] + fn set_xz_bits2(&mut self, i: usize, xi: bool, zi: bool, j: usize, xj: bool, zj: bool) { + debug_assert!(i < self.nqubits && j < self.nqubits, "index out of bounds"); + if self.xbits[i] != xi || self.zbits[i] != zi || self.xbits[j] != xj || self.zbits[j] != zj + { + self.xbits.set(i, xi); + self.zbits.set(i, zi); + self.xbits.set(j, xj); + self.zbits.set(j, zj); + self.refresh_hash(); + } + } + + #[inline(always)] + fn set_x_bit_and_z_bit(&mut self, x_i: usize, x: bool, z_i: usize, z: bool) { + debug_assert!( + x_i < self.nqubits && z_i < self.nqubits, + "index out of bounds" + ); + if self.xbits[x_i] != x || self.zbits[z_i] != z { + self.xbits.set(x_i, x); + self.zbits.set(z_i, z); + self.refresh_hash(); + } + } + + #[inline(always)] + fn set_z_bit_pair(&mut self, i: usize, zi: bool, j: usize, zj: bool) { + debug_assert!(i < self.nqubits && j < self.nqubits, "index out of bounds"); + if i == j { + // The trait default is two scalar sets, so a repeated index is + // last-write-wins; the fused arm below would instead treat the two + // requests as independent toggles. + self.set_z_bit(j, zj); + return; + } + let toggle_i = self.zbits[i] != zi; + let toggle_j = self.zbits[j] != zj; + if toggle_i || toggle_j { + xor_bits2(&mut self.zbits, i, toggle_i, j, toggle_j); + self.refresh_hash(); + } + } + + /// Uses `with_bits_toggled` to copy planes and hash once instead of clone-then-flip. + #[inline] + fn toggled_bits(&self, i: usize, toggle_x: bool, toggle_z: bool) -> Self { + PauliWord::with_bits_toggled(self, i, toggle_x, toggle_z) + } + + /// Uses `with_bits_toggled2` to copy planes once for a two-site rotation branch. + #[inline] + fn toggled_bits2( + &self, + i: usize, + [toggle_x_i, toggle_z_i]: [bool; 2], + j: usize, + [toggle_x_j, toggle_z_j]: [bool; 2], + ) -> Self { + PauliWord::with_bits_toggled2(self, i, toggle_x_i, toggle_z_i, j, toggle_x_j, toggle_z_j) + } + + #[inline(always)] + fn into_toggled_bits2( + mut self, + i: usize, + [toggle_x_i, toggle_z_i]: [bool; 2], + j: usize, + [toggle_x_j, toggle_z_j]: [bool; 2], + ) -> Self { + debug_assert!(i < self.nqubits, "qubit {i} out of bounds"); + debug_assert!(j < self.nqubits, "qubit {j} out of bounds"); + xor_bits2(&mut self.xbits, i, toggle_x_i, j, toggle_x_j); + xor_bits2(&mut self.zbits, i, toggle_z_i, j, toggle_z_j); + self.refresh_hash(); + self + } +} + +impl Clone for PauliWord { + /// Copies the word and its valid cached hash without requiring `H: Clone`. + #[inline] + fn clone(&self) -> Self { + *self + } +} + +impl Copy for PauliWord {} + +/// Structural equality over `(nqubits, X bits, Z bits)`; the cache and the +/// `PhantomData` marker are excluded. The canonical-unused-bits invariant makes +/// the full-blob comparison equivalent to comparing only the logical bits. +impl PartialEq for PauliWord { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.nqubits == other.nqubits + && self.xbits.data == other.xbits.data + && self.zbits.data == other.zbits.data + } +} + +impl Eq for PauliWord {} + +#[cfg(test)] +mod tests { + use super::*; + use ppvm_traits_2::Indexable; + + #[test] + fn constructors_enforce_storage_capacity() { + assert!(std::panic::catch_unwind(|| PauliWord::::new(9)).is_err()); + assert!( + std::panic::catch_unwind(|| { + PauliWord::::from_planes(BitArray::ZERO, BitArray::ZERO, 9) + }) + .is_err() + ); + for width in [0, 8] { + let word = PauliWord::::new(width); + assert_eq!(word.n_sites(), width); + assert_eq!(word.to_string(), "I".repeat(width)); + } + } + + /// The fused two-site toggle must equal two sequential one-site toggles for + /// every index pair, including a repeated index (where the two requests + /// cancel) and one straddling a storage word. + #[test] + fn toggled_bits2_matches_two_single_toggles() { + let text = "XYZI".repeat(32); + let base: PauliWord<[u8; 16]> = PauliWord::from(text.as_str()); + for (i, j) in [(1usize, 5usize), (5, 1), (3, 3), (2, 70), (70, 2), (70, 71)] { + for bits in 0..16u8 { + let (xi, zi) = (bits & 1 != 0, bits & 2 != 0); + let (xj, zj) = (bits & 4 != 0, bits & 8 != 0); + let fused = base.with_bits_toggled2(i, xi, zi, j, xj, zj); + let chained = base + .with_bits_toggled(i, xi, zi) + .with_bits_toggled(j, xj, zj); + assert_eq!(fused, chained, "({i},{j}) toggles {bits:04b}"); + assert_eq!(fused.key_hash(), chained.key_hash(), "digest {bits:04b}"); + let borrowed = base.toggled_bits2(i, [xi, zi], j, [xj, zj]); + assert_eq!(borrowed, chained, "borrowed ({i},{j}) toggles {bits:04b}"); + assert_eq!(borrowed.key_hash(), chained.key_hash()); + assert_eq!( + base.into_toggled_bits2(i, [xi, zi], j, [xj, zj]), + chained, + "owned ({i},{j}) toggles {bits:04b}" + ); + } + } + } + + /// `set_z_bit_pair` must agree with two scalar `set_z_bit` calls — including + /// when both sites share one storage word (the fused single-XOR arm) and + /// when they straddle two (the split arm), and when neither bit moves (no + /// write, and the digest must be unchanged rather than merely equal). + #[test] + fn set_z_bit_pair_matches_scalar_setters() { + for (i, j) in [(1usize, 5usize), (5, 1), (3, 3), (2, 70), (70, 2), (70, 71)] { + for zi in [false, true] { + for zj in [false, true] { + // With byte-backed storage, sites 2 and 70 lie in different bytes. + let text = "XYZI".repeat(32); + let base: PauliWord<[u8; 16]> = PauliWord::from(text.as_str()); + let mut fused = base; + fused.set_z_bit_pair(i, zi, j, zj); + let mut scalar = base; + scalar.set_z_bit(i, zi); + scalar.set_z_bit(j, zj); + assert_eq!(fused, scalar, "({i},{j}) <- ({zi},{zj})"); + assert_eq!( + fused.key_hash(), + scalar.key_hash(), + "digest ({i},{j}) <- ({zi},{zj})" + ); + assert_eq!(fused.x_bit(i), base.x_bit(i), "X plane must not move"); + assert_eq!(fused.x_bit(j), base.x_bit(j), "X plane must not move"); + } + } + } + } + + #[test] + fn weight_counts_nonidentity() { + let w: PauliWord = "XIYZI".into(); + assert_eq!(w.weight(), 3); + assert_eq!(PauliWord::::new(5).weight(), 0); + } + + #[test] + fn iter_matches_get() { + let w: PauliWord = "XYZI".into(); + let via_iter: Vec = w.iter().collect(); + let via_get: Vec = (0..w.n_sites()).map(|i| w.get(i)).collect(); + assert_eq!(via_iter, via_get); + } + + #[test] + fn set_bits_build_word() { + let mut w: PauliWord = PauliWord::new(3); + w.set_x_bit(0, true); + w.set_z_bit(0, true); // Y + w.set_z_bit(2, true); // Z + assert_eq!(w.get(0), Pauli::Y); + assert_eq!(w.get(1), Pauli::I); + assert_eq!(w.get(2), Pauli::Z); + assert!(w.x_bit(0) && w.z_bit(0)); + assert!(!w.x_bit(2) && w.z_bit(2)); + } + + #[test] + fn equality_excludes_width_mismatch() { + let a: PauliWord = "XY".into(); + let b: PauliWord = "XYI".into(); + assert_ne!(a, b, "different widths are structurally distinct"); + } + + #[test] + fn send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/crates/ppvm-pauli-word-2/src/hash.rs b/crates/ppvm-pauli-word-2/src/hash.rs new file mode 100644 index 000000000..982dd41bc --- /dev/null +++ b/crates/ppvm-pauli-word-2/src/hash.rs @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Packed-word hashing, cached key lookup, and hash-table distribution helpers. + +use std::hash::{BuildHasher, Hash, Hasher}; + +use ppvm_traits_2::Indexable; + +use crate::{PauliStorage, PauliWord}; + +/// Adjusts a structural hash for hash-table lookup according to hasher and width. +/// `hashbrown` uses low bits for buckets and the top seven bits for control tags. +/// The default leaves the digest unchanged; hashers can override mixing as needed. +pub trait HashFinalize { + /// Finalizes `Hasher::finish()` using the storage size in bytes per bit plane. + /// Callers supply a compile-time size, allowing width-dependent branches to fold away. + #[inline(always)] + fn finalize_hash(raw: u64, _storage_bytes: usize) -> u64 { + raw + } + + /// Apply the map-index transform to a finalized structural digest. + #[inline(always)] + fn index_hash(raw: u64) -> u64 + where + Self: BuildHasher + Default, + { + let mut hasher = Self::default().build_hasher(); + hasher.write_u64(raw); + hasher.finish() + } +} + +impl HashFinalize for fxhash::FxBuildHasher { + /// For planes up to eight bytes, folds high bits into weakly mixed low bits. + /// Wider inputs pass through to avoid coupling control-tag bits into bucket bits. + #[inline(always)] + fn finalize_hash(raw: u64, storage_bytes: usize) -> u64 { + if storage_bytes <= std::mem::size_of::() { + raw ^ (raw >> 32) + } else { + raw + } + } + + #[cfg(target_pointer_width = "64")] + #[inline(always)] + fn index_hash(raw: u64) -> u64 { + raw.wrapping_mul(0x517c_c1b7_2722_0a95) + } +} + +/// Hashes the X/Z planes, then applies width-specific mixing and map indexing. +/// Shared by scalar and column storage so equal words receive identical digests. +#[inline(always)] +pub(crate) fn structural_hash(x: &A, z: &A, _nqubits: usize) -> u64 +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + let mut hasher = H::default().build_hasher(); + // Width is deliberately omitted, matching the legacy digest. Equality still + // checks it, so cross-width words merely collide; sums already require one + // common width. + hasher.write(bytemuck::bytes_of(x)); + hasher.write(bytemuck::bytes_of(z)); + let structural = H::finalize_hash(hasher.finish(), std::mem::size_of::()); + // Preserve the legacy map’s extra hash transform before direct bucket lookup. + H::index_hash(structural) +} + +/// Writes the cached key hash as one `u64` for pass-through identity hashing. +impl Hash for PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + #[inline(always)] + fn hash(&self, state: &mut S) { + state.write_u64(self.key_hash()); + } +} + +/// Returns the cached digest, refreshed eagerly by constructors and mutations. +/// Reading a key hash does not mutate the word or recompute its digest. +impl Indexable for PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + #[inline(always)] + fn key_hash(&self) -> u64 { + self.hash_cache + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ppvm_traits_2::{IdentityBuildHasher, PauliBits}; + use std::collections::HashMap; + + #[test] + fn equal_words_equal_digest() { + let a: PauliWord = "XYZI".into(); + let b: PauliWord = "XYZI".into(); + assert_eq!(a.key_hash(), b.key_hash()); + } + + #[test] + fn hash_writes_key_hash() { + // The `Hash` impl must reproduce `key_hash()` exactly through the + // identity build-hasher. + let w: PauliWord = "XYZI".into(); + let bh = IdentityBuildHasher; + assert_eq!(bh.hash_one(w), w.key_hash()); + } + + #[test] + fn cache_is_stable_across_clone_and_mutation() { + let w: PauliWord = "XYZI".into(); + let h0 = w.key_hash(); + let c = w; + assert_eq!(c.key_hash(), h0, "clone copies the cached digest"); + + let mut m = w; + m.set_x_bit(3, true); // I -> X on qubit 3, a structural change + assert_ne!(m.key_hash(), h0, "mutation refreshes the digest"); + } + + // The eager cache is a plain `u64`; stored keys have no interior mutability. + #[test] + fn usable_as_identity_hashmap_key() { + let mut map: HashMap = HashMap::default(); + map.insert("XYZI".into(), 7); + assert_eq!(map.get(&PauliWord::from("XYZI")), Some(&7)); + } + + #[test] + fn avalanche_low_bits_distribute() { + // A weak distribution property test (Design's stated contract, not a + // type-level guarantee): enumerating single-qubit-different words, the + // low 8 bits of the digest should not collapse into a few buckets. + use std::collections::HashSet; + let mut buckets = HashSet::new(); + for i in 0..8usize { + let mut w: PauliWord = PauliWord::new(16); + w.set_x_bit(i, true); + buckets.insert(w.key_hash() & 0xff); + let mut z: PauliWord = PauliWord::new(16); + z.set_z_bit(i, true); + buckets.insert(z.key_hash() & 0xff); + } + assert!(buckets.len() >= 12, "low bits collapsed: {}", buckets.len()); + } + + const RAW: u64 = 0xDEAD_BEEF_0000_0001; + + #[test] + fn fxhash_folds_narrow_storage() { + for width in [1, 2, 4, 8] { + assert_eq!( + ::finalize_hash(RAW, width), + RAW ^ (RAW >> 32), + ); + } + } + + #[test] + fn fxhash_passes_wide_storage_through() { + for width in [16, 32, 64] { + assert_eq!( + ::finalize_hash(RAW, width), + RAW, + ); + } + } + + #[test] + fn fxhash_index_fast_path_matches_hasher() { + for raw in [0, 1, RAW, u64::MAX] { + let mut hasher = fxhash::FxBuildHasher::default().build_hasher(); + hasher.write_u64(raw); + assert_eq!( + ::index_hash(raw), + hasher.finish() + ); + } + } +} diff --git a/crates/ppvm-pauli-word-2/src/lib.rs b/crates/ppvm-pauli-word-2/src/lib.rs new file mode 100644 index 000000000..0f320bea7 --- /dev/null +++ b/crates/ppvm-pauli-word-2/src/lib.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Packed Pauli words, explicit phases, and hashing utilities. + +mod clifford; +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 new file mode 100644 index 000000000..9137eae0f --- /dev/null +++ b/crates/ppvm-pauli-word-2/src/pauli_parser.rs @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Parsing and text formatting for packed Pauli words. + +use std::fmt; +use std::hash::BuildHasher; + +use bitvec::array::BitArray; +use ppvm_traits_2::{Pauli, Word}; + +use crate::{HashFinalize, PauliStorage, PauliWord}; + +impl fmt::Debug for PauliWord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PauliWord") + .field("nqubits", &self.nqubits) + .field("word", &self.to_string()) + .finish() + } +} + +impl fmt::Display for PauliWord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for i in 0..self.nqubits { + let c = match self.get(i) { + Pauli::I => 'I', + Pauli::X => 'X', + Pauli::Y => 'Y', + Pauli::Z => 'Z', + }; + f.write_str(c.encode_utf8(&mut [0u8; 1]))?; + } + Ok(()) + } +} + +impl From<&str> for PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + /// Parse a Pauli string of `I`/`X`/`Y`/`Z` symbols (underscores are ignored + /// separators), mirroring `ppvm-pauli-word`'s `From<&str>`. Panics on any + /// other character or if the width exceeds the backing storage. + fn from(value: &str) -> Self { + let mut xbits = BitArray::::ZERO; + let mut zbits = BitArray::::ZERO; + let mut i = 0usize; + for ch in value.chars() { + match ch { + 'I' => {} + 'X' => xbits.set(i, true), + 'Z' => zbits.set(i, true), + 'Y' => { + xbits.set(i, true); + zbits.set(i, true); + } + '_' => continue, + other => panic!("invalid Pauli character: {other}"), + } + i += 1; + } + Self::from_planes(xbits, zbits, i) + } +} + +impl From for PauliWord +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + #[inline] + fn from(value: String) -> Self { + Self::from(value.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parsing_enforces_storage_capacity() { + 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 [ + ("", ""), + ("I_I_I_I_I_I_I_I", "IIIIIIII"), + ("XYZIXYZI", "XYZIXYZI"), + ] { + let word = PauliWord::::from(input); + assert_eq!(word.n_sites(), expected.len()); + assert_eq!(word.to_string(), expected); + } + } + + #[test] + fn parse_get_roundtrip() { + let w: PauliWord = "XZYI".into(); + assert_eq!(w.n_sites(), 4); + assert_eq!(w.get(0), Pauli::X); + assert_eq!(w.get(1), Pauli::Z); + assert_eq!(w.get(2), Pauli::Y); + assert_eq!(w.get(3), Pauli::I); + assert_eq!(w.to_string(), "XZYI"); + } + + #[test] + fn underscore_is_ignored() { + let a: PauliWord = "X_Y_Z".into(); + let b: PauliWord = "XYZ".into(); + assert_eq!(a, b); + } +} diff --git a/crates/ppvm-pauli-word-2/src/phase.rs b/crates/ppvm-pauli-word-2/src/phase.rs new file mode 100644 index 000000000..444299ff1 --- /dev/null +++ b/crates/ppvm-pauli-word-2/src/phase.rs @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Phased words with explicit prefactors, parsing, display, and equality. + +use std::fmt; +use std::hash::BuildHasher; + +use crate::{HashFinalize, PauliStorage, PauliWord}; +use ppvm_traits_2::{Phase, Word}; + +/// A base word with a phase in `{+1, +i, -1, -i}`, representing `i^φ · g(w)`. +/// Equality and display include the phase; site inspection uses only the word. +/// Implements neither `Hash` nor `Indexable`, so it is not a map key. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Phased { + /// The bare base word (carries no phase of its own). + pub(crate) word: W, + /// The explicit `ℤ₄` prefactor `i^φ`. + pub(crate) phase: Phase, +} + +impl Phased { + /// Wrap `word` with the trivial phase `+1`. + #[inline] + pub fn new(word: W) -> Self { + Self { + word, + phase: Phase::one(), + } + } + + /// Wrap `word` with an explicit `phase`. + #[inline] + pub fn with_phase(word: W, phase: Phase) -> Self { + Self { word, phase } + } + + /// The `ℤ₄` prefactor `i^φ`. + #[inline] + pub fn phase(&self) -> Phase { + self.phase + } + + /// Borrow the bare base word (phase stripped). + #[inline] + pub fn word(&self) -> &W { + &self.word + } + + /// Consume the wrapper, returning the base word and its phase. + #[inline] + pub fn into_parts(self) -> (W, Phase) { + (self.word, self.phase) + } + + /// Multiply the stored phase by `delta` (the `ℤ₄` group product). + #[inline] + pub fn add_phase(&mut self, delta: Phase) { + self.phase *= delta; + } + + /// Whether the phase has a positive sign (`+1` or `+i`). + #[inline] + pub fn is_positive(&self) -> bool { + matches!(self.phase, Phase::Pos1 | Phase::PosI) + } +} + +/// Site inspection delegates to the inner word; the scalar phase changes no sites. +impl Word for Phased { + type Site = W::Site; + + #[inline] + fn n_sites(&self) -> usize { + self.word.n_sites() + } + + #[inline] + fn get(&self, index: usize) -> Self::Site { + self.word.get(index) + } + + #[inline] + fn weight(&self) -> usize { + self.word.weight() + } + + #[inline] + fn iter(&self) -> impl Iterator { + self.word.iter() + } +} + +/// Renders the phase as `+`, `+i`, `-`, or `-i`, matching the legacy display. +fn phase_prefix(phase: Phase) -> &'static str { + match phase { + Phase::Pos1 => "+", + Phase::PosI => "+i", + Phase::Neg1 => "-", + Phase::NegI => "-i", + } +} + +/// `Display` prints the phase prefix followed by the base word, e.g. `+iXYZI`. +impl fmt::Display for Phased { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}{}", phase_prefix(self.phase), self.word) + } +} + +impl From<&str> for Phased> +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + /// Parses a required `+`/`-`, optional `i`, then a [`PauliWord`] string. + /// Panics if the sign is missing or the word is invalid. + fn from(s: &str) -> Self { + let mut chars = s.chars(); + let (phase, prefix_len) = match (chars.next(), chars.next()) { + (Some('+'), Some('i')) => (Phase::PosI, 2), + (Some('-'), Some('i')) => (Phase::NegI, 2), + (Some('+'), _) => (Phase::Pos1, 1), + (Some('-'), _) => (Phase::Neg1, 1), + _ => panic!("invalid phase format: {s:?} (expected a leading +/-)"), + }; + // Recognized prefixes are ASCII, so `prefix_len` is a UTF-8 boundary. + Self { + word: PauliWord::::from(&s[prefix_len..]), + phase, + } + } +} + +impl From for Phased> +where + A: PauliStorage, + H: BuildHasher + Default + HashFinalize, +{ + #[inline] + fn from(s: String) -> Self { + Self::from(s.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phase_prefixes_roundtrip() { + for (prefix, phase) in [ + ("+", Phase::Pos1), + ("+i", Phase::PosI), + ("-", Phase::Neg1), + ("-i", Phase::NegI), + ] { + for body in ["", "XYZI"] { + let text = format!("{prefix}{body}"); + let word = Phased::::from(text.as_str()); + assert_eq!(word.phase(), phase); + assert_eq!(word.word().to_string(), body); + assert_eq!(word.to_string(), text); + } + } + } + + #[test] + #[should_panic(expected = "invalid Pauli character")] + fn unicode_body_is_rejected_by_word_parser() { + let _ = Phased::::from("+λ"); + } +} diff --git a/crates/ppvm-pauli-word-2/src/product.rs b/crates/ppvm-pauli-word-2/src/product.rs new file mode 100644 index 000000000..0b5df5b75 --- /dev/null +++ b/crates/ppvm-pauli-word-2/src/product.rs @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Packed Pauli multiplication: XOR the X/Z planes and return the residual phase. +//! One pass counts sign and imaginary masks to compute `i^k`, with `k` modulo four. +//! The coefficient absorbs the phase; zero unused bits remain zero after XOR. +//! Phase identities are verified in `lean/PPVM/Pauli/{Phase,Word}.lean`. + +use bitvec::view::BitView; +use num::PrimInt; +use ppvm_traits_2::{KeyProduct, Phase}; +use std::hash::BuildHasher; + +use crate::{HashFinalize, PauliStorage, PauliWord}; + +impl KeyProduct for PauliWord +where + A: PauliStorage, + ::Store: PrimInt, + H: BuildHasher + Default + HashFinalize, + // `Eq` and `Clone` add no further bounds on `H`. +{ + /// 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) { + 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 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; + + // Each iteration multiplies a packed group of qubits in parallel. + for chunk in 0..lhs_x.len() { + let (left_x, left_z) = (lhs_x[chunk], lhs_z[chunk]); + let (right_x, right_z) = (rhs_x[chunk], rhs_z[chunk]); + + // Each set bit contributes a factor of -1 (i²) or i, respectively. + let sign_mask = (left_x & left_z & right_x & !right_z) + | (left_x & !left_z & !right_x & right_z) + | (!left_x & left_z & right_x & right_z); + let imaginary_mask = (left_x & !left_z & right_z) + | (left_x & !right_x & right_z) + | (!left_x & left_z & right_x) + | (left_z & right_x & !right_z); + sign_count += sign_mask.count_ones(); + 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; + } + + product.refresh_hash(); + let phase_exponent = ((2 * sign_count + imaginary_count) % 4) as u8; + (product, Phase::from_exponent(phase_exponent)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multiplication_rejects_mismatched_widths() { + for (lhs, rhs) in [("I", "IX"), ("IX", "I"), ("", "I"), ("I", "")] { + let left = PauliWord::::from(lhs); + let right = PauliWord::::from(rhs); + assert!( + std::panic::catch_unwind(|| left.key_mul(&right)).is_err(), + "accepted mismatched widths: {lhs:?} * {rhs:?}" + ); + } + } + + /// A `Phase` rendered as a `+ / +i / - / -i` prefix, for readable asserts. + fn phase_str(p: Phase) -> &'static str { + match p { + Phase::Pos1 => "+", + Phase::PosI => "+i", + Phase::Neg1 => "-", + Phase::NegI => "-i", + } + } + + fn product_str(lhs: &str, rhs: &str) -> String { + let x: PauliWord = lhs.into(); + let y: PauliWord = rhs.into(); + let (w, p) = x.key_mul(&y); + format!("{}{}", phase_str(p), w) + } + + #[test] + fn single_qubit_products() { + // Ported from `phase/mul.rs` tests; identity/phaseless cases added. + for (lhs, rhs, ans) in [ + ("X", "X", "+I"), + ("X", "Y", "+iZ"), + ("X", "Z", "-iY"), + ("Y", "Z", "+iX"), + ("Z", "X", "+iY"), + ("Y", "X", "-iZ"), + ("I", "Y", "+Y"), + ("Z", "Z", "+I"), + ] { + assert_eq!(product_str(lhs, rhs), ans, "{lhs}*{rhs}"); + } + } + + #[test] + fn multi_qubit_products() { + for (lhs, rhs, ans) in [ + ("ZI", "ZI", "+II"), + ("II", "ZI", "+ZI"), + ("XX", "XX", "+II"), + ] { + assert_eq!(product_str(lhs, rhs), ans, "{lhs}*{rhs}"); + } + } + + #[test] + fn square_is_identity_up_to_phase() { + // `phaseExpN_self`: P·P = +I (each Pauli squares to +I). + for s in ["XYZI", "YYXZ", "ZZZZ", "IXYX"] { + let (w, p) = PauliWord::::from(s).key_mul(&s.into()); + assert_eq!(w, PauliWord::::new(4), "{s}² word"); + assert_eq!(p, Phase::Pos1, "{s}² phase"); + } + } + + #[test] + fn commutation_sign_law() { + // `phaseExpN_sub_comm`: P·Q = (−1)^{ω(P,Q)} Q·P. Compare the two orders' + // phases: they are equal (commute) or differ by −1 (anticommute). + for (l, r) in [("XY", "ZX"), ("XZ", "ZX"), ("YI", "IY"), ("XX", "ZZ")] { + let (_, pq) = PauliWord::::from(l).key_mul(&r.into()); + let (_, qp) = PauliWord::::from(r).key_mul(&l.into()); + let ratio = pq.compose(qp.inverse()); + assert!( + ratio == Phase::Pos1 || ratio == Phase::Neg1, + "{l},{r}: phase ratio {ratio:?} is not ±1", + ); + } + } + + #[test] + fn associativity() { + // `tmul_assoc`: the twisted product is associative once the emitted + // phase is folded onto a commutative coefficient (`Complex` here). + let u: PauliWord = "XYZ".into(); + let v: PauliWord = "ZXY".into(); + let w: PauliWord = "YZX".into(); + + let (uv, p_uv) = u.key_mul(&v); + let (uv_w, p2) = uv.key_mul(&w); + let left_word = uv_w; + let left_phase = p_uv.compose(p2); + + let (vw, p_vw) = v.key_mul(&w); + let (u_vw, p3) = u.key_mul(&vw); + let right_word = u_vw; + let right_phase = p_vw.compose(p3); + + assert_eq!(left_word, right_word); + // Fold both onto a coefficient and compare. + let one = num::Complex::new(1.0, 0.0); + assert_eq!(left_phase.apply(&one), right_phase.apply(&one)); + } +} diff --git a/crates/ppvm-pauli-word-2/src/storage.rs b/crates/ppvm-pauli-word-2/src/storage.rs new file mode 100644 index 000000000..7295d487c --- /dev/null +++ b/crates/ppvm-pauli-word-2/src/storage.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use bitvec::view::{BitView, BitViewSized}; +use num::PrimInt; +use std::hash::Hash; + +/// Default storage is `u64`, or `usize` on wasm32 where `bitvec` lacks `u64` support. +#[cfg(not(target_arch = "wasm32"))] +pub type DefaultStorage = u64; + +/// wasm32 default storage; `bitvec` requires a supported native integer type. +#[cfg(target_arch = "wasm32")] +pub type DefaultStorage = usize; + +/// Fixed-size storage for one Pauli bit plane: `u64`, `[u8; N]`, or `[u64; N]`. +/// `Pod` permits safe byte views for hashing; `PrimInt` enables whole-integer kernels. +pub trait PauliStorage: + BitViewSized + + BitView + + Copy + + Hash + + Eq + + Send + + Sync + + std::fmt::Debug + + bytemuck::Pod +{ +} + +impl PauliStorage for A where + A: BitViewSized + + BitView + + Copy + + Hash + + Eq + + Send + + Sync + + std::fmt::Debug + + bytemuck::Pod +{ +}