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
+{
+}