diff --git a/Cargo.lock b/Cargo.lock index 84ea97ef..5fd7902f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1461,6 +1461,14 @@ dependencies = [ "rayon", ] +[[package]] +name = "ppvm-traits-2" +version = "0.1.0" +dependencies = [ + "num", + "rand 0.10.1", +] + [[package]] name = "ppvm-tui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 35cb8102..92cc7283 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/vihaco-circuit-isa", "crates/ppvm-vihaco", "crates/ppvm-cli", + "crates/ppvm-traits-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-traits-2/Cargo.toml b/crates/ppvm-traits-2/Cargo.toml new file mode 100644 index 00000000..e00b311b --- /dev/null +++ b/crates/ppvm-traits-2/Cargo.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "ppvm-traits-2" +version = "0.1.0" +edition = "2024" + +[dependencies] +num = "0.4" +rand = "0.10" diff --git a/crates/ppvm-traits-2/src/algebra.rs b/crates/ppvm-traits-2/src/algebra.rs new file mode 100644 index 00000000..181724b6 --- /dev/null +++ b/crates/ppvm-traits-2/src/algebra.rs @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Algebra capabilities that lift `C[K]` from a module to a (twisted) algebra: +//! the key product [`KeyProduct`] and the two coefficient-ring capabilities it +//! and the sesquilinear pairing need, [`ImaginaryUnit`] and [`Conjugate`], plus +//! the [`Phase`] the Pauli key product emits. +//! +//! Design: `traits-2-configuration-and-hashing.md` §"The map is a graded +//! algebra over `C[K]`" (L4 and its coefficient capabilities). + +use crate::arithmetic::Coefficient; + +/// A fourth root of unity `iᵏ`, `k ∈ ℤ/4`, i.e. an element of `{1, i, −1, −i}`. +/// +/// The Pauli key product is not closed on keys: `v·w = iᵏ (v⊕w)`, so +/// [`KeyProduct::key_mul`] returns the residual `Phase` for the coefficient to +/// absorb. `iᵏ` already spans `{1, i, −1, −i}`, so no separate `±` is carried. +/// +/// Design: §"The map is a graded algebra over `C[K]`" (`KeyProduct`). The +/// exponent `k` is the packed `phaseExp` of `lean/PPVM/Pauli/Phase.lean` +/// (`phaseExp_eq_ref`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Phase { + /// `i⁰ = +1`. + Pos1, + /// `i¹ = +i`. + PosI, + /// `i² = −1`. + Neg1, + /// `i³ = −i`. + NegI, +} + +impl Phase { + /// The exponent `k ∈ {0, 1, 2, 3}` such that this phase equals `iᵏ`. + #[inline] + pub fn exponent(self) -> u8 { + match self { + Phase::Pos1 => 0, + Phase::PosI => 1, + Phase::Neg1 => 2, + Phase::NegI => 3, + } + } + + /// The phase `iᵏ` for exponent `k` (taken mod 4). + #[inline] + pub fn from_exponent(k: u8) -> Self { + match k & 3 { + 0 => Phase::Pos1, + 1 => Phase::PosI, + 2 => Phase::Neg1, + _ => Phase::NegI, + } + } + + /// The group identity `i⁰ = +1` of the `ℤ/4` phase group. + /// + /// The neutral element for [`compose`](Self::compose): `p.compose(one()) == + /// p` for every phase `p`. + #[inline] + pub fn one() -> Self { + Phase::Pos1 + } + + /// The `ℤ/4` group product `iᵃ · iᵇ = i^{a+b}` (exponents added mod 4). + /// + /// This is the first-class group operation on phases: a `KeyProduct` chain + /// can accumulate the residual phases each [`KeyProduct::key_mul`] emits with + /// `compose` (or the equivalent [`Mul`](std::ops::Mul) impl) without a coefficient in hand, + /// deferring the [`apply`](Self::apply) fold onto the coefficient to the end. + /// The group is abelian, so `a.compose(b) == b.compose(a)`. + #[inline] + pub fn compose(self, other: Self) -> Self { + Phase::from_exponent(self.exponent() + other.exponent()) + } + + /// The group inverse `i^{-k} = i^{4-k}`, i.e. the phase `q` with + /// `self.compose(q) == Phase::one()`. + #[inline] + pub fn inverse(self) -> Self { + // 4 - k is exact for k ∈ {0,1,2,3}; the mod-4 reduction in + // `from_exponent` sends k = 0 back to 0. + Phase::from_exponent(4 - self.exponent()) + } + + /// Fold this phase onto a coefficient: return `iᵏ · c`. + /// + /// This is the `iPow` fold of `lean/PPVM/Algebra/Twisted.lean` — the phase a + /// [`KeyProduct::key_mul`] emits is absorbed by the coefficient here. Needs a + /// primitive fourth root of unity, hence the [`ImaginaryUnit`] bound. + /// + /// # Behaviour parity + /// + /// The `±i` arms go through [`ImaginaryUnit::mul_i`], **not** through a bare + /// `c * imaginary_unit()`. On `Complex` the two are *not* the same + /// function: the ring multiply computes `(re·0 − im·1, re·1 + im·0)`, and + /// `inf·0`/`NaN·0` are `NaN`, so `(inf + 0i)·i` is `NaN + inf·i` while the + /// old `ppvm_traits::ComplexCoefficient::mul_phase` — which swapped the + /// components by hand — gave `−0 + inf·i`. `mul_i` restores the old + /// component swap (and with it the sign of zero, visible through `Display` + /// and serialization). See `phase_apply_matches_old_mul_phase_encoding`. + /// + /// The whole fold is delegated to [`ImaginaryUnit::mul_i_pow`], which is an + /// **override point**: a ring whose values carry `iᵏ` symbolically (the + /// symbolic `Term` of `ppvm-sym-2`) folds `iᵏ` into its own representation + /// rather than through the `±1`/`mul_i` arms, which is what old's + /// `ComplexCoefficient::mul_phase` did. The default body *is* those arms, so + /// nothing changes for `f64`/`Complex`/`GaussianInt`. + #[inline] + pub fn apply(self, c: &C) -> C { + c.mul_i_pow(self.exponent()) + } +} + +/// `iᵃ · iᵇ = i^{a+b}` — [`compose`](Phase::compose) as the `*` operator, so a +/// residual-phase accumulator reads `acc *= phase` / `acc = a * b`. +impl core::ops::Mul for Phase { + type Output = Phase; + + #[inline] + fn mul(self, rhs: Phase) -> Phase { + self.compose(rhs) + } +} + +impl core::ops::MulAssign for Phase { + #[inline] + fn mul_assign(&mut self, rhs: Phase) { + *self = self.compose(rhs); + } +} + +/// A key whose set carries a product — the (projective) group structure that +/// lifts `C[K]` from a module to an algebra. +/// +/// The keys form a group only *up to phase*: the product is **not closed on +/// keys**, it emits an `iᵏ`, which is why `key_mul` returns `(Self, Phase)` and +/// why `C[PauliWord]` is a **2-cocycle-twisted** group algebra. +/// +/// # Laws +/// +/// Write `key_mul(u, v) = (u · v, i^{β(u,v)})`. Every impl must satisfy: +/// +/// * the key product is associative: `(u · v) · w == u · (v · w)`; +/// * the phase exponent is a **2-cocycle**: +/// `β(u,v) + β(u·v, w) == β(v,w) + β(u, v·w)` in `ℤ/4`. +/// +/// Under exactly those two hypotheses the twisted product on `C × K` is +/// associative for any commutative coefficient ring `C` with `i⁴ = 1` — proved +/// key-agnostically in `lean/PPVM/Algebra/Twisted.lean` (`gtmul_assoc`, over an +/// abstract `kmul` and `IsCocycle`), so the obligation is stated once and every +/// key discharges it. `PauliWord` does so via `Bool.xor_assoc` and +/// `lean/PPVM/Pauli/Phase.lean` (`phaseExp_cocycle`), recovered as the instance +/// in `phaseExp_isCocycle` / `tmul_assoc_of_gtmul` (with `tmul_assoc` the +/// concrete Pauli statement). A future ordered fermionic-word key must discharge +/// the same two hypotheses; it does **not** inherit associativity from the Pauli +/// proof. +/// +/// Design: §"The map is a graded algebra over `C[K]`" (`KeyProduct`). +pub trait KeyProduct: Eq + Clone { + /// Product of two keys, with the phase it produces (folded onto the coeff). + fn key_mul(&self, other: &Self) -> (Self, Phase); +} + +/// The phase capability L4 needs, over a **commutative** coefficient ring: a +/// distinguished primitive fourth root of unity `i`. +/// +/// Impls must satisfy `Self::imaginary_unit() * Self::imaginary_unit() == +/// -Self::one()` (hence `i⁴ = 1`). This is strictly weaker than requiring +/// `Complex`: `GaussianInt` (`ℤ[i]`), `Complex`, and cyclotomic +/// integers all satisfy it, so L4 does not foreclose exact Pauli multiplication. +/// +/// Design: §"The map is a graded algebra over `C[K]`" (`ImaginaryUnit`). Law +/// machine-checked in `lean/PPVM/Pauli/Matrix.lean` (`iU_sq`: `iU * iU = -1`), +/// and the twisted product is associative over any commutative ring with +/// `i⁴ = 1` in `lean/PPVM/Algebra/Twisted.lean` (`tmul_assoc`). +pub trait ImaginaryUnit: Coefficient + num::One { + /// The imaginary unit `i`; impls must satisfy + /// `Self::imaginary_unit() * Self::imaginary_unit() == -Self::one()`. + fn imaginary_unit() -> Self; + + /// Multiply by `i`. Semantically `self * imaginary_unit()`, which is the + /// default body — but it is an **override point**, because on a + /// floating-point ring the generic product is not extensionally equal to the + /// rotation it denotes. + /// + /// On `Complex`, `c * i` expands to + /// `(re·0 − im·1, re·1 + im·0)`; `inf·0` and `NaN·0` are `NaN`, so a + /// non-finite component contaminates *both* output components, and `re·0` + /// also loses the sign of zero. Multiplication by `i` is really the + /// component swap `(re, im) ↦ (−im, re)`, which is total and exact — this is + /// what the old `ppvm_traits::ComplexCoefficient::mul_phase` did, and the + /// `Complex` impl below restores it verbatim. It is also cheaper (two + /// negations instead of four multiplies and two adds). + #[inline] + fn mul_i(&self) -> Self { + self.clone() * Self::imaginary_unit() + } + + /// Multiply by `iᵏ` (`k` taken mod 4) — the fold [`Phase::apply`] delegates + /// to, and the second **override point**. + /// + /// The default body is the four-arm `{clone, mul_i, neg, neg∘mul_i}` fold, + /// which is the only sensible spelling on a ring whose values are numbers. + /// It is overridable because a ring whose values carry the `iᵏ` *as data* — + /// the symbolic `Term` of `ppvm-sym-2`, whose monomials hold a `ℤ/4` phase + /// byte — must fold the phase into that representation instead: old's + /// `ComplexCoefficient::mul_phase` promoted `Const(c)` to `One(i⁰, c)` + /// **unconditionally**, including at `k = 0`, and `Term`'s `PartialEq` and + /// `Display` are representational, so taking the `clone()` arm at `k = 0` + /// would be a user-visible divergence from old. + /// + /// Impls must satisfy `x.mul_i_pow(k) == iᵏ · x` denotationally, and + /// `mul_i_pow(1) == mul_i`. + #[inline] + fn mul_i_pow(&self, k: u8) -> Self { + match k & 3 { + 0 => self.clone(), + 1 => self.mul_i(), + 2 => -(self.clone()), + _ => -(self.mul_i()), + } + } +} + +/// A coefficient ring carrying a ring involution (a commutative `*`-ring): +/// complex conjugation on `Complex` / `GaussianInt` / cyclotomic integers, +/// and the identity on real rings. +/// +/// Supplies exactly the conjugation the sesquilinear +/// [`crate::containers::Pair::hermitian_overlap`] needs; nothing in propagation +/// requires it, so — like [`ImaginaryUnit`] — it is a separate capability, not a +/// `Coefficient` bound. +/// +/// Laws (commutative `*`-ring): `conj(conj(a)) == a`, `conj(a + b) == +/// conj(a) + conj(b)`, `conj(a · b) == conj(a) · conj(b)`; and when the ring is +/// also [`ImaginaryUnit`], `conj(i) == −i`. +/// +/// Design: §"The map is a graded algebra over `C[K]`" (`Conjugate`). The +/// `conj(i) == −i` law is machine-checked in `lean/PPVM/Pauli/Matrix.lean` +/// (`star_iU`: `star iU = -iU`). +pub trait Conjugate: Coefficient { + /// The ring involution applied to this value. + fn conj(&self) -> Self; +} + +impl ImaginaryUnit for num::Complex { + #[inline] + fn imaginary_unit() -> Self { + num::Complex::new(0.0, 1.0) + } + + /// The old `ComplexCoefficient::mul_phase(1)` component swap, verbatim + /// (`crates/ppvm-traits/src/traits/coefficient.rs`): total on non-finite + /// components and sign-of-zero exact, unlike the generic `self * i`. + #[inline] + fn mul_i(&self) -> Self { + num::Complex::new(-self.im, self.re) + } +} + +impl Conjugate for num::Complex { + #[inline] + fn conj(&self) -> Self { + num::Complex::conj(self) + } +} + +impl Conjugate for f64 { + /// Conjugation is the identity on a real ring. + #[inline] + fn conj(&self) -> Self { + *self + } +} diff --git a/crates/ppvm-traits-2/src/arithmetic/angle.rs b/crates/ppvm-traits-2/src/arithmetic/angle.rs new file mode 100644 index 00000000..8d898287 --- /dev/null +++ b/crates/ppvm-traits-2/src/arithmetic/angle.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::arithmetic::Coefficient; + +// A rotation angle that yields `(sin, cos)` already in coefficient domain `C`. +pub trait Angle { + /// Return `(sin θ, cos θ)` in the coefficient domain `C`. + fn sin_cos(&self) -> (C, C); +} + +impl Angle for f64 { + #[inline] + fn sin_cos(&self) -> (f64, f64) { + num::traits::Float::sin_cos(*self) + } +} + +/// The complex-coefficient angle domain, i.e. the defaulted `A = C` case of +/// [`crate::gates::RotationOne`] at `C = Complex`. +impl Angle> for num::Complex { + #[inline] + fn sin_cos(&self) -> (num::Complex, num::Complex) { + let (s, c) = num::traits::Float::sin_cos(self.re); + (num::Complex::new(s, 0.0), num::Complex::new(c, 0.0)) + } +} + +// A **real** angle driving a complex-coefficient sum. +impl Angle> for f64 { + #[inline] + fn sin_cos(&self) -> (num::Complex, num::Complex) { + let (s, c) = num::traits::Float::sin_cos(*self); + (num::Complex::new(s, 0.0), num::Complex::new(c, 0.0)) + } +} diff --git a/crates/ppvm-traits-2/src/arithmetic/coefficient.rs b/crates/ppvm-traits-2/src/arithmetic/coefficient.rs new file mode 100644 index 00000000..5b108b50 --- /dev/null +++ b/crates/ppvm-traits-2/src/arithmetic/coefficient.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub}; + +pub trait Coefficient: + PartialEq + + Clone + + num::Zero + + Neg + + Add + + Sub + + Mul + + AddAssign + + MulAssign + + std::iter::Sum + + Send + + Sync +{ + /// Multiply by `sign ∈ {-1, +1}` (encoded as `i8`). + fn mul_sign(&self, sign: i8) -> Self; + + /// Multiply this coefficient in place by `sign ∈ {-1, +1}`. + #[inline] + fn mul_sign_assign(&mut self, sign: i8) { + *self = self.mul_sign(sign) + } + + /// Accumulates a borrowed coefficient. + #[inline] + fn add_assign_ref(&mut self, rhs: &Self) { + *self += rhs.clone(); + } + + /// Add this coefficient to itself. Numeric implementations may use their + /// native multiply-by-two operation; exact rings retain the additive default. + #[inline(always)] + fn doubled(&self) -> Self { + self.clone() + self.clone() + } + + /// Nonnegative magnitude. Exposes a property of the value for a `Policy` to + /// threshold; it does not itself decide any cutoff. Replaces the old + /// `Coefficient::cutoff`. + fn magnitude(&self) -> f64; +} + +impl Coefficient for f64 { + #[inline] + fn mul_sign(&self, sign: i8) -> Self { + (sign as f64) * (*self) + } + + #[inline(always)] + fn doubled(&self) -> Self { + *self * 2.0 + } + + #[inline] + fn magnitude(&self) -> f64 { + self.abs() + } +} + +impl Coefficient for num::Complex { + #[inline] + fn mul_sign(&self, sign: i8) -> Self { + (sign as f64) * (*self) + } + + #[inline(always)] + fn doubled(&self) -> Self { + *self * 2.0 + } + + #[inline] + fn magnitude(&self) -> f64 { + self.norm() + } +} diff --git a/crates/ppvm-traits-2/src/arithmetic/halvable.rs b/crates/ppvm-traits-2/src/arithmetic/halvable.rs new file mode 100644 index 00000000..8c5cd32d --- /dev/null +++ b/crates/ppvm-traits-2/src/arithmetic/halvable.rs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::arithmetic::Coefficient; + +/// A coefficient ring in which halving (`0.5·x`) is total and exact: the +/// capability the projective computational-basis measurement kernel needs to +/// apply the `(I ± Z)/2` projectors. +pub trait Halvable: Coefficient { + /// Divide by two. Impls must be exact: `x.half() + x.half() == x`. + fn half(&self) -> Self; +} + +impl Halvable for f64 { + #[inline] + fn half(&self) -> Self { + *self / 2.0 + } +} + +impl Halvable for num::Complex { + #[inline] + fn half(&self) -> Self { + *self / 2.0 + } +} diff --git a/crates/ppvm-traits-2/src/arithmetic/mod.rs b/crates/ppvm-traits-2/src/arithmetic/mod.rs new file mode 100644 index 00000000..26f59b08 --- /dev/null +++ b/crates/ppvm-traits-2/src/arithmetic/mod.rs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Scalar arithmetic and rotation-angle capabilities. + +mod angle; +mod coefficient; +mod halvable; + +pub use angle::Angle; +pub use coefficient::Coefficient; +pub use halvable::Halvable; diff --git a/crates/ppvm-traits-2/src/containers/batch.rs b/crates/ppvm-traits-2/src/containers/batch.rs new file mode 100644 index 00000000..922eebfd --- /dev/null +++ b/crates/ppvm-traits-2/src/containers/batch.rs @@ -0,0 +1,460 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::containers::Indexable; +use crate::loss::LossState; +use crate::word::PauliBits; + +/// A key that can be laid out as a structure-of-arrays column. Separate from +/// [`Indexable`] so the minimal hashing contract is unchanged: a batched key is +/// both `Indexable` (a valid map key) and `Columnar` (has a column layout). +/// +/// Design: §"The batch contract". +pub trait Columnar: Indexable { + /// The concrete structure-of-arrays column for this key type. + type Column: KeyColumn; +} + +/// A structure-of-arrays column of keys, owned by the concrete key type (only it +/// knows its planes). Operates plane by plane, never scalar on the hot path. +/// +/// Design: §"The batch contract". +pub trait KeyColumn: Default + Clone { + /// The key type this column stores. + type Key: Columnar; + + /// Number of keys currently in the column. + fn len(&self) -> usize; + + /// Number of keys the existing plane allocations can hold without growing. + /// + /// Live stores use this when cloning persistent workspaces: cloning only the + /// populated rows would silently discard a caller's capacity hint. + fn capacity(&self) -> usize; + + /// Whether the column is empty. + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// A column pre-sized for `n` keys. + fn with_capacity(n: usize) -> Self; + + /// Append one produced key; the column keeps each plane contiguous. + fn push(&mut self, key: Self::Key); + + /// Reserve room for `additional` more keys, keeping the ones already stored. + /// + /// The column spelling of `Vec::reserve`, and the counterpart of + /// [`with_capacity`](Self::with_capacity) for a column that is a *live + /// support* rather than a throwaway batch: `ColumnStore`'s branch-merge pass + /// knows its worst-case append count up front (the scratch length), and + /// pre-sizing from it collapses a doubling chain of plane reallocations — + /// plus the parallel bucket-table `reindex`es — into one. + /// + /// **Default: a no-op.** Pre-sizing is a pure optimization, so a column that + /// cannot express it (a device-backed or fixed-extent one) stays legal and + /// simply reallocates on `push`. + #[inline] + fn reserve(&mut self, additional: usize) { + let _ = additional; + } + + /// Bulk structural hash of the whole column into a parallel hash column. + /// `out[i]` must equal the `i`-th key's [`Indexable::key_hash`]. + fn hash_into(&self, out: &mut [u64]); + + /// Join confirm: compare element `i` against a build-side key after a hash + /// or tag match, without materializing the whole element. + fn key_eq(&self, i: usize, other: &Self::Key) -> bool; + + /// Select or permute elements into a new column (radix partitioning, + /// compaction, device staging). Operates plane by plane, never scalar. + fn gather(&self, indices: &[u32]) -> Self; + + /// Scalar materialization of one element — a naive backend's fallback, + /// never the hot path. + fn get(&self, i: usize) -> Self::Key; + + /// Read one row's X bit without requiring scalar materialization when the + /// concrete column can address its packed plane directly. + #[inline] + fn x_bit(&self, row: usize, qubit: usize) -> bool + where + Self::Key: PauliBits, + { + self.get(row).x_bit(qubit) + } + + /// Read one row's Z bit directly when supported. + #[inline] + fn z_bit(&self, row: usize, qubit: usize) -> bool + where + Self::Key: PauliBits, + { + self.get(row).z_bit(qubit) + } + + /// Read one row's loss bit directly when supported. + #[inline] + fn is_lost(&self, row: usize, qubit: usize) -> bool + where + Self::Key: LossState, + { + self.get(row).is_lost(qubit) + } + + /// Materialize one row while toggling selected bits. Packed columns can + /// build the branch key directly from their planes. + #[inline] + fn toggled_bits(&self, row: usize, qubit: usize, toggle_x: bool, toggle_z: bool) -> Self::Key + where + Self::Key: PauliBits, + { + self.get(row).toggled_bits(qubit, toggle_x, toggle_z) + } + + /// Materialize one row while toggling bits at two sites. + #[inline] + #[allow(clippy::too_many_arguments)] + fn toggled_bits2( + &self, + row: usize, + i: usize, + toggle_x_i: bool, + toggle_z_i: bool, + j: usize, + toggle_x_j: bool, + toggle_z_j: bool, + ) -> Self::Key + where + Self::Key: PauliBits, + { + self.get(row) + .toggled_bits2(i, toggle_x_i, toggle_z_i, j, toggle_x_j, toggle_z_j) + } + + /// Reset to an empty column, **keeping the backing plane allocations**. + /// + /// # Friction: a column that is a *store* needs in-place mutation, and the + /// batch-only surface has none + /// + /// [`gather`](Self::gather) allocates a fresh column, and the design's + /// batch contract needs nothing more: a `TermBatch`'s column is built by + /// `push` and thrown away. The `ColumnStore` backend + /// (implementation-plan Phase 6) makes the *same* column type the live + /// support, and every one of its buffer-reusing fast paths — the old + /// crate's `map_add` clear→write→swap (architecture feature 1), the retain + /// compaction, the in-place Clifford re-key — is defined by mutating a + /// column it already owns. Expressed through `gather` alone each of those + /// allocates a whole new key column **per gate**, which is exactly the + /// per-gate allocation churn the double-buffer exists to remove. + /// + /// So this trio ([`clear`](Self::clear), [`set`](Self::set), + /// [`truncate`](Self::truncate)) is the minimal in-place surface: they are + /// the column spellings of `Vec::clear`/`IndexMut`/`Vec::truncate`, they + /// stay plane-oriented (a SIMD/GPU column implements them as plane writes), + /// and they expose no `&mut Key` — so design rule 4 of §"Backends are + /// containers" ("no signature exposes `&mut (W, C)` or `&mut [C]`") is + /// untouched and the AoS layout still cannot leak. + fn clear(&mut self); + + /// Overwrite element `i` in place, keeping every other element and the + /// backing allocation. Panics (or debug-panics) if `i >= len()`. + /// + /// The write side of the in-place re-key: a Clifford conjugation is a + /// bijection, so a columnar backend rewrites the key planes at each slot and + /// leaves the parallel coefficient column completely untouched. + fn set(&mut self, i: usize, key: Self::Key); + + /// Shorten to `len` elements, keeping the backing allocation. A no-op if + /// `len >= self.len()`. The tail step of a stable retain compaction + /// (`set` the survivors down, then cut). + fn truncate(&mut self, len: usize); + + /// Remove row `i` by moving the final row into its slot. + fn swap_remove(&mut self, i: usize) -> Self::Key { + let len = self.len(); + let removed = self.get(i); + if i + 1 != len { + self.set(i, self.get(len - 1)); + } + self.truncate(len - 1); + removed + } +} + +/// Keys plus their precomputed structural hashes, in parallel columns. The +/// probe side of the join; it carries no coefficients. +/// +/// See the module-level friction note: the key column is a scalar `Vec` +/// fallback rather than the design's `W::Column`, so the batch is expressible +/// for any `W: Eq + Clone` (not only `Columnar` keys). +/// +/// Design: §"The batch contract". +#[derive(Debug, Clone)] +pub struct KeyBatch { + keys: Vec, + hashes: Vec, + hashes_valid: bool, +} + +impl Default for KeyBatch { + fn default() -> Self { + Self { + keys: Vec::new(), + hashes: Vec::new(), + hashes_valid: false, + } + } +} + +impl KeyBatch { + /// An empty key batch. + pub fn new() -> Self { + Self::default() + } + + /// A key batch pre-sized for `n` keys. + pub fn with_capacity(n: usize) -> Self { + Self { + keys: Vec::with_capacity(n), + hashes: Vec::with_capacity(n), + hashes_valid: false, + } + } + + /// Number of keys in the batch. + pub fn len(&self) -> usize { + self.keys.len() + } + + /// Number of keys the existing columns can hold without growing. + pub fn capacity(&self) -> usize { + self.keys.capacity().min(self.hashes.capacity()) + } + + /// Whether the batch is empty. + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } + + /// The key column. + pub fn keys(&self) -> &[W] { + &self.keys + } + + /// The complete parallel hash column, or `None` if it has not been filled + /// since the last mutation. A filled empty batch returns `Some(&[])`. + pub fn hashes(&self) -> Option<&[u64]> { + self.hashes_valid.then_some(self.hashes.as_slice()) + } + + /// Iterate keys in insertion order. + pub fn iter(&self) -> impl Iterator { + self.keys.iter() + } + + /// Clear both columns without releasing capacity (for buffer reuse). + pub fn clear(&mut self) { + self.hashes_valid = false; + self.keys.clear(); + self.hashes.clear(); + } + + /// Append a key and invalidate cached hashes without releasing capacity. + /// Call [`KeyBatch::fill_hashes`] to make the full hash column available again. + pub fn push(&mut self, key: W) { + self.hashes_valid = false; + self.hashes.clear(); + self.keys.push(key); + } +} + +impl KeyBatch { + /// Fill the parallel hash column from each key's [`Indexable::key_hash`], so + /// `hashes().unwrap()[i] == keys()[i].key_hash()`. + /// The cache becomes available only after all keys have been hashed. + pub fn fill_hashes(&mut self) { + self.hashes_valid = false; + self.hashes.clear(); + self.hashes + .extend(self.keys.iter().map(Indexable::key_hash)); + self.hashes_valid = true; + } +} + +/// A [`KeyBatch`] with the coefficient column attached: the produced terms +/// awaiting merge. Coefficients are a separate column, touched only when a +/// probe resolves to an aggregate. +/// +/// Design: §"The batch contract". +#[derive(Debug, Clone)] +pub struct TermBatch { + keys: KeyBatch, + coeffs: Vec, +} + +impl Default for TermBatch { + fn default() -> Self { + Self { + keys: KeyBatch::new(), + coeffs: Vec::new(), + } + } +} + +impl TermBatch { + /// An empty term batch. + pub fn new() -> Self { + Self::default() + } + + /// A term batch pre-sized for `n` terms. + pub fn with_capacity(n: usize) -> Self { + Self { + keys: KeyBatch::with_capacity(n), + coeffs: Vec::with_capacity(n), + } + } + + /// Number of terms in the batch. + pub fn len(&self) -> usize { + self.coeffs.len() + } + + /// Number of terms all three columns can hold without growing. + pub fn capacity(&self) -> usize { + self.keys.capacity().min(self.coeffs.capacity()) + } + + /// Whether the batch is empty. + pub fn is_empty(&self) -> bool { + self.coeffs.is_empty() + } + + /// The probe-side key batch. + pub fn keys(&self) -> &KeyBatch { + &self.keys + } + + /// The coefficient column. + pub fn coeffs(&self) -> &[C] { + &self.coeffs + } + + /// Iterate `(key, coeff)` pairs — the read side an `accumulate_batch` merge + /// loop consumes. Synthesizes the pairs from the two columns; the layout + /// stays structure-of-arrays. + pub fn iter(&self) -> impl Iterator { + self.keys.keys().iter().zip(self.coeffs.iter()) + } + + /// Clear all columns without releasing capacity (for buffer reuse). + pub fn clear(&mut self) { + self.keys.clear(); + self.coeffs.clear(); + } +} + +/// The append side of a term batch: a producer pushes `(key, coeff)` terms into +/// a sink, filling the key and coefficient columns. A naive sink collects into a +/// scalar `Vec`; a columnar sink appends into planes. +/// +/// Design: §"Every gate is a producer feeding `accumulate`". +pub trait TermSink { + /// Append one produced term. + fn push(&mut self, key: K, coeff: C); +} + +impl TermSink for TermBatch { + #[inline] + fn push(&mut self, key: W, coeff: C) { + self.keys.push(key); + self.coeffs.push(coeff); + } +} + +/// A monomorphized, inlinable term producer — never `dyn`, since this is the +/// hot loop and the abstraction must compile to nothing. +/// +/// # `Send + Sync` is part of the contract (architecture feature 12) +/// +/// A producer is *read-only* over its own state (`produce` takes `&self`), so a +/// storage backend is free to split the produce walk across threads — which is +/// the whole point of keeping the backend a **configuration choice**: the old +/// crate bounded every driver closure `F: Fn(..) + Sync + Send` +/// (`ppvm-traits/src/map/hashmap.rs`, `map_add_assign`/`map_insert*`/`scale`) so +/// that a concurrent map (it shipped a `DashMap`-backed config benchmarked beside +/// the `HashMap`/`IndexMap` ones) was a **backend swap, not an engine rewrite**. +/// Requiring it here — and on the `ppvm-pauli-sum-2` in-place walk closures +/// (`ScaleByKey`/`SignFlipByKey`/`RekeyBijective`/`RotateInPlace`/ +/// `BranchInPlace`) — keeps that door open: widening the bound later would mean +/// touching every trait signature *and* every impl, i.e. exactly the coupling the +/// feature exists to prevent. Every real producer is a closure over gate indices +/// and ring elements, and `Coefficient` is already `Send + Sync`, so the bound +/// costs nothing today. +pub trait TermProducer: Send + Sync { + /// Push the produced terms for one existing `(key, coeff)` into the sink. + fn produce>(&self, key: &K, coeff: &C, sink: &mut S); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, PartialEq, Eq)] + struct Key(u64); + + impl std::hash::Hash for Key { + fn hash(&self, state: &mut H) { + state.write_u64(self.key_hash()); + } + } + + impl Indexable for Key { + fn key_hash(&self) -> u64 { + self.0.wrapping_mul(0x9E37_79B9_7F4A_7C15) + } + } + + #[test] + fn push_invalidates_hashes_and_refill_covers_every_key() { + let mut batch = KeyBatch::with_capacity(4); + batch.push(Key(1)); + assert_eq!(batch.hashes(), None); + batch.fill_hashes(); + assert_eq!(batch.hashes(), Some([Key(1).key_hash()].as_slice())); + let capacity = batch.capacity(); + let snapshot = batch.clone(); + + batch.push(Key(2)); + assert_eq!(batch.hashes(), None); + assert_eq!(batch.capacity(), capacity); + assert_eq!(snapshot.hashes(), Some([Key(1).key_hash()].as_slice())); + + batch.fill_hashes(); + assert_eq!( + batch.hashes(), + Some([Key(1).key_hash(), Key(2).key_hash()].as_slice()), + ); + } + + #[test] + fn empty_and_cleared_batches_have_explicit_cache_state() { + let mut batch = KeyBatch::::new(); + assert_eq!(batch.hashes(), None); + batch.fill_hashes(); + assert_eq!(batch.hashes(), Some([].as_slice())); + + batch.push(Key(3)); + batch.fill_hashes(); + let capacity = batch.capacity(); + batch.clear(); + assert!(batch.is_empty()); + assert_eq!(batch.hashes(), None); + assert_eq!(batch.capacity(), capacity); + batch.fill_hashes(); + assert_eq!(batch.hashes(), Some([].as_slice())); + } +} diff --git a/crates/ppvm-traits-2/src/containers/coordinate_list.rs b/crates/ppvm-traits-2/src/containers/coordinate_list.rs new file mode 100644 index 00000000..8ba5232f --- /dev/null +++ b/crates/ppvm-traits-2/src/containers/coordinate_list.rs @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! `Vec<(K, C)>` — the coordinate-list backend: an unsorted association list +//! scanned linearly, requiring only `K: Eq + Clone` (it never hashes). Best for +//! small support, e.g. the `GeneralizedTableau` amplitude vector. +//! +//! See [`super`] for the shared design references and the orphan-rule note. + +use crate::algebra::{Conjugate, ImaginaryUnit, KeyProduct}; +use crate::arithmetic::Coefficient; +use crate::containers::{Accumulate, Multiply, Pair, Retain, Scale, Support}; +use crate::containers::{KeyBatch, TermBatch}; + +impl Support for Vec<(K, C)> +where + K: Eq + Clone, + C: Coefficient, +{ + type Key = K; + type Coeff = C; + + #[inline] + fn len(&self) -> usize { + self.as_slice().len() + } + + #[inline] + fn get(&self, key: &K) -> Option { + self.as_slice() + .iter() + .find(|(k, _)| k == key) + .map(|(_, c)| c.clone()) + } + + #[inline] + fn iter(&self) -> impl Iterator { + self.as_slice().iter().map(|(k, c)| (k.clone(), c.clone())) + } + + /// The borrowing scan: no clone at all, so a reader that rejects most terms + /// pays nothing for the ones it rejects. + #[inline] + fn for_each_ref(&self, mut f: impl FnMut(&K, &C)) { + for (k, c) in self.as_slice() { + f(k, c); + } + } +} + +impl Accumulate for Vec<(K, C)> +where + K: Eq + Clone, + C: Coefficient, +{ + /// Linear-scan hash-join: for each produced term, find the matching key and + /// add onto it, else append. `O(n·m)` in the support size, which is the + /// right cost model for the small support this backend targets. + #[inline] + fn accumulate_batch(&mut self, terms: &TermBatch) { + for (k, c) in terms.iter() { + if let Some(slot) = self.iter_mut().find(|(ek, _)| ek == k) { + slot.1 += c.clone(); + } else { + self.push((k.clone(), c.clone())); + } + } + } + + /// Drop every zero-coefficient term (`reduce_structural`): canonicalize to + /// reduced finite support. Runs only at finalize, never inline. + #[inline] + fn reduce(&mut self) { + self.retain(|(_, v)| !v.is_zero()); + } +} + +impl Scale for Vec<(K, C)> +where + K: Eq + Clone, + C: Coefficient, +{ + #[inline] + fn scale(&mut self, s: &C) { + for (_, v) in self.iter_mut() { + *v *= s.clone(); + } + } +} + +impl Pair for Vec<(K, C)> +where + K: Eq + Clone, + C: Coefficient, +{ + #[inline] + fn probe_batch(&self, keys: &KeyBatch, out: &mut [Option]) { + debug_assert!(out.len() >= keys.keys().len()); + for (slot, k) in out.iter_mut().zip(keys.keys().iter()) { + *slot = Support::get(self, k); + } + } + + #[inline] + fn overlap(&self, other: &Self) -> C { + self.as_slice() + .iter() + .filter_map(|(k, a)| Support::get(other, k).map(|b| a.clone() * b)) + .sum() + } + + #[inline] + fn hermitian_overlap(&self, other: &Self) -> C + where + C: Conjugate, + { + self.as_slice() + .iter() + .filter_map(|(k, a)| Support::get(other, k).map(|b| a.conj() * b)) + .sum() + } +} + +impl Retain for Vec<(K, C)> +where + K: Eq + Clone, + C: Coefficient, +{ + #[inline] + fn retain(&mut self, keep: impl Fn(&K, &C) -> bool) { + // Inherent `Vec::retain` shadows the trait method (inherent-first + // resolution), so this does not recurse. + self.retain(|(k, v)| keep(k, v)); + } +} + +impl Multiply for Vec<(K, C)> +where + K: KeyProduct, + C: ImaginaryUnit, +{ + /// The twisted convolution `(A·B)[k] = Σ_{p·q = k} A[p]·B[q]·i^{β(p,q)}`, + /// accumulated into `acc` — the coordinate-list spelling of `twistedConv` + /// (`lean/PPVM/Algebra/Twisted.lean`), whose monomial case is `tmul`. + /// + /// Neither `reduce` nor any truncation runs: `acc` keeps an exact-zero + /// cancellation, exactly as `twistedConv` (a finitely-supported map is + /// canonicalized only by an explicit [`Accumulate::reduce`]). + fn multiply_into(&self, other: &Self, acc: &mut Self) { + for (p, a) in self.as_slice() { + for (q, b) in other.as_slice() { + let (k, phase) = p.key_mul(q); + let c = phase.apply(&(a.clone() * b.clone())); + if let Some(slot) = acc.iter_mut().find(|(ek, _)| *ek == k) { + slot.1 += c; + } else { + acc.push((k, c)); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::containers::TermSink; + + fn batch(terms: &[(&str, f64)]) -> TermBatch { + let mut b = TermBatch::with_capacity(terms.len()); + for (k, c) in terms { + b.push((*k).to_string(), *c); + } + b + } + + #[test] + fn vec_accumulate_combines_keys() { + let mut v: Vec<(String, f64)> = Vec::new(); + v.accumulate_batch(&batch(&[("a", 1.0), ("b", 2.0), ("a", 3.0)])); + assert_eq!(Support::get(&v, &"a".to_string()), Some(4.0)); + assert_eq!(Support::get(&v, &"b".to_string()), Some(2.0)); + assert_eq!(Support::len(&v), 2); + } + + #[test] + fn vec_reduce_drops_zero() { + let mut v: Vec<(String, f64)> = Vec::new(); + v.accumulate_batch(&batch(&[("a", 1.0), ("a", -1.0), ("b", 2.0)])); + v.reduce(); + assert_eq!(Support::len(&v), 1); + assert_eq!(Support::get(&v, &"b".to_string()), Some(2.0)); + } + + #[test] + fn vec_scale_and_overlap() { + let mut a: Vec<(String, f64)> = Vec::new(); + a.accumulate_batch(&batch(&[("x", 2.0), ("y", 3.0)])); + let mut b: Vec<(String, f64)> = Vec::new(); + b.accumulate_batch(&batch(&[("x", 5.0), ("z", 7.0)])); + a.scale(&2.0); + // overlap = (2*2)*5 = 20; y and z do not match. + assert_eq!(Pair::overlap(&a, &b), 20.0); + } + + #[test] + fn for_each_ref_agrees_with_iter() { + let terms = batch(&[("a", 1.0), ("b", 2.0), ("c", -3.0), ("a", 0.5)]); + + let mut v: Vec<(String, f64)> = Vec::new(); + v.accumulate_batch(&terms); + let mut seen: Vec<(String, f64)> = Vec::new(); + v.for_each_ref(|k, c| seen.push((k.clone(), *c))); + seen.sort_by(|a, b| a.0.cmp(&b.0)); + let mut want: Vec<(String, f64)> = Support::iter(&v).collect(); + want.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(seen, want); + assert_eq!(seen.len(), 3); + } + + #[test] + fn vec_retain_filters() { + let mut v: Vec<(String, f64)> = Vec::new(); + v.accumulate_batch(&batch(&[("keep", 2.0), ("drop", 0.5)])); + Retain::retain(&mut v, |_, c| *c >= 1.0); + assert_eq!(Support::len(&v), 1); + assert_eq!(Support::get(&v, &"keep".to_string()), Some(2.0)); + } +} diff --git a/crates/ppvm-traits-2/src/containers/graded.rs b/crates/ppvm-traits-2/src/containers/graded.rs new file mode 100644 index 00000000..c691454d --- /dev/null +++ b/crates/ppvm-traits-2/src/containers/graded.rs @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! The graded map algebra over `C[K]`: the free `C`-module on a key set, +//! layered by algebraic strength. Each layer is a distinct trait justified by a +//! distinct algebraic property *and* a distinct consumer. +//! +//! Design: `traits-2-configuration-and-hashing.md` §"The map is a graded algebra +//! over `C[K]`". The layers are `Support` (L0, the container), `Accumulate` (L1, +//! the module core), `Scale` (L2, the `C`-module action), `Pair` (L3, the trace +//! pairings), and `Multiply` (L4, the ring product). [`Retain`] sits *outside* +//! the algebra: dropping supported terms breaks module exactness, so it is a +//! non-algebraic capability that `Policy` (in `ppvm-pauli-sum-2`) — not the +//! algebra — consumes. +//! +//! The key type is bounded on `Eq + Clone`, **not** [`crate::containers::Indexable`]: +//! `C[K]` is the free module over any index set, so the algebra needs only a +//! valid map key. Hash backends re-add `Indexable` on *their* impls; Pauli +//! propagation re-adds `Word`/`PauliBits` on *its* methods. + +use crate::algebra::{Conjugate, ImaginaryUnit, KeyProduct}; +use crate::arithmetic::Coefficient; +use crate::containers::{KeyBatch, TermBatch, TermSink}; + +/// L0 — the container: a finitely-supported function `K ⇀ C`. +/// +/// No `&mut (K, C)` and no `&mut [C]` slot access is exposed, so a columnar +/// (structure-of-arrays) backend is expressible; `iter` is read-only export. +/// +/// Design: §"The map is a graded algebra over `C[K]`" (L0 `Support`). +pub trait Support { + /// The key type — minimal `Eq + Clone`; hash backends add `Indexable`. + type Key: Eq + Clone; + /// The coefficient ring. + type Coeff: Coefficient; + + /// Number of terms in the (reduced) support. + fn len(&self) -> usize; + + /// Whether the support is empty. + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The coefficient at `key`, if present. + fn get(&self, key: &Self::Key) -> Option; + + /// Read-only export of the support as `(key, coeff)` pairs. A SoA backend + /// synthesizes the pairs from its columns. + fn iter(&self) -> impl Iterator; + + /// Read-only **borrowing** visit of the support: `f(&key, &coeff)` once per + /// supported key, in the backend's own order. + /// + /// This is the scan side of L0 for readers that *reject most terms* — a + /// pattern trace, a predicate count, a max-weight scan. [`iter`](Self::iter) + /// hands out owned pairs (so that a columnar backend can synthesize them), + /// which forces a `Coeff::clone()` on **every** key before the reader gets to + /// look at it. That clone is free for `f64` and ruinous for a coefficient + /// that owns a heap table: on the `sym.random.circuit` shape (support 65534, + /// 255 matching keys, symbolic coefficients) cloning the whole support before + /// filtering cost 7.06 ms against 1.2 ms for the old crate's borrowing fold, + /// and the gap *grew* with coefficient size. Old could stay generic here + /// because its map iterator borrowed; this method restores that read side + /// without giving up the columnar option — a SoA backend passes + /// `(&keys[i], &coeffs[i])`, which needs no materialized pair. + /// + /// The default body goes through `iter`, so this is a capability an existing + /// backend may ignore; every backend whose keys and coefficients are actually + /// *stored* should override it, and the shipped ones do. + /// + /// It is a scan, not an iterator, deliberately: returning + /// `impl Iterator` would force every backend to have a + /// lending iterator, and a callback composes with the `Retain`/`Policy` + /// closures already used on this side of the API. + fn for_each_ref(&self, mut f: impl FnMut(&Self::Key, &Self::Coeff)) { + for (k, c) in self.iter() { + f(&k, &c); + } + } +} + +/// L1 — the module core: form linear combinations, then canonicalize. +/// +/// Design: §"The map is a graded algebra over `C[K]`" (L1 `Accumulate`). The +/// module laws are machine-checked in `lean/PPVM/Algebra/GradedMap.lean` +/// (`accumulate_comm`, `accumulate_assoc`); `reduce` drops exactly the zero +/// coefficients (`reduce_structural`). +pub trait Accumulate: Support { + /// Build side of the hash join: merge a produced batch, accumulating onto an + /// existing key or inserting a new one. Columnar in. + /// + /// The batch is algebraically a **multiset** of terms — folding it in is a + /// homomorphism from the free commutative monoid — so an impl is free to + /// reorder it and to split it across partitions/threads: order-invariance + /// (`accumulateTerms_perm`) and partition-invariance + /// (`accumulateTerms_add`, `accumulateTerms B m` for `B = B1 + B2`) are + /// machine-checked in `lean/PPVM/Algebra/GradedMap.lean`. + fn accumulate_batch(&mut self, terms: &TermBatch); + + /// Canonicalize to reduced finite-support form: drop every key whose + /// coefficient `is_zero()`. First-class and run **only** at finalize — never + /// inline during accumulation. + fn reduce(&mut self); + + /// Scalar sugar over a batch of one — accumulate a single `(key, coeff)`. + /// + /// Design: §"The map is a graded algebra over `C[K]`" ("the scalar + /// `accumulate(k, c)` is provided sugar over a batch of one"); that the + /// singleton batch is the same operation is `accumulateTerms_singleton` in + /// `lean/PPVM/Algebra/GradedMap.lean`. + fn accumulate(&mut self, key: Self::Key, coeff: Self::Coeff) { + let mut batch = TermBatch::with_capacity(1); + batch.push(key, coeff); + self.accumulate_batch(&batch); + } +} + +/// L2 — the `C`-module action: a pure elementwise map over the coefficients. +/// +/// Design: §"The map is a graded algebra over `C[K]`" (L2 `Scale`). Machine- +/// checked in `lean/PPVM/Algebra/GradedMap.lean` (`scale_scale`, +/// `scale_accumulate`). +pub trait Scale: Support { + /// Multiply every coefficient by `s`: `∀ k. c_k *= s`. + fn scale(&mut self, s: &Self::Coeff); +} + +/// L3 — the read side of the hash join. Two pairings live here, differing only +/// in whether the first operand is conjugated. +/// +/// `overlap` is the **symmetric bilinear** Hilbert–Schmidt trace pairing +/// `⟨A, B⟩ = ∑_k a_k b_k` — bilinear, *not* conjugated. Full `C`-bilinearity +/// (biadditivity, homogeneity in each slot, and symmetry over a commutative +/// ring) is machine-checked in `lean/PPVM/Algebra/GradedMap.lean` +/// (`overlap_add_left`/`overlap_add_right`, `overlap_smul_left`/`overlap_smul_right`, +/// `overlap_comm`); Pauli-basis orthonormality is `overlap_single_single` in +/// `lean/PPVM/Algebra/Noise.lean`. The `= Tr(A B)/2ⁿ` reading is proved, not +/// asserted: `overlap_eq_trace_div` in `lean/PPVM/Pauli/Matrix.lean` gives +/// `Tr(Â B̂) = 2ⁿ · ⟨A, B⟩` for the genuine `2ⁿ×2ⁿ` operators over `ℤ[i]` +/// (via `trace_tensorPauli_mul`), and `twistedConv_apply_id` in +/// `lean/PPVM/Algebra/Twisted.lean` states the same fact inside `C[K]`: +/// `⟨A, B⟩` is the identity coefficient of the L4 product. +/// +/// `hermitian_overlap` is the **sesquilinear** inner product +/// `⟨φ | ψ⟩ = ∑_k conj(a_k)·b_k`, conjugate-linear in the first argument, so it +/// requires the coefficient ring to carry a [`Conjugate`]. Conjugate symmetry, +/// sesquilinearity, and `⟨f, f⟩ ≥ 0` are machine-checked in +/// `lean/PPVM/Algebra/GradedMap.lean` (`hermitianOverlap_conj_symm`, +/// `hermitianOverlap_smul_left`/`smul_right`, `hermitianOverlap_self_nonneg`). +/// +/// Design: §"The map is a graded algebra over `C[K]`" (L3 `Pair`). +pub trait Pair: Support { + /// Read-only probe of a key column: `out[i]` is the coefficient at + /// `keys[i]`, or `None` on a miss. + fn probe_batch(&self, keys: &KeyBatch, out: &mut [Option]); + + /// The symmetric bilinear trace pairing `∑_k a_k b_k`. + fn overlap(&self, other: &Self) -> Self::Coeff; + + /// The sesquilinear inner product `∑_k conj(a_k)·b_k`. + fn hermitian_overlap(&self, other: &Self) -> Self::Coeff + where + Self::Coeff: Conjugate; +} + +/// `tr(self · value)` against a *different* right-hand type. +/// +/// The old `ppvm_traits::Trace` (`ppvm-traits/src/traits/trace.rs`), ported +/// unchanged. It is **not** subsumed by [`Pair::overlap`]: `overlap` pairs a map +/// with another map of the *same* type, while `Trace` is the heterogeneous form +/// the old crate used to trace a word or a whole sum against a `PauliPattern` +/// (`ppvm-pauli-word/src/pattern/trace.rs`, `ppvm-pauli-sum/src/sum/trace.rs`), +/// which is why the right-hand type and the numeric `Output` are both free. +/// +/// Design: §"Compatibility with current names" (`Trace`). Its `-2` implementers +/// land with the pattern port; the same-type Pauli-sum case is `Pair::overlap`, +/// whose bilinearity is machine-checked in `lean/PPVM/Algebra/GradedMap.lean`. +pub trait Trace<'a, RHS: 'a> { + /// Numeric output of the trace. + type Output; + /// Compute `tr(self · value)`. + fn trace(&'a self, value: &'a RHS) -> Self::Output; +} + +/// L4 — the ring product. The only layer that needs the *key* to carry a +/// product; it stays optional and is not implemented for a key type that has +/// none. The Pauli product injects powers of `i`, so the coefficient must absorb +/// phase — bounded on [`ImaginaryUnit`], the minimal requirement. +/// +/// Design: §"The map is a graded algebra over `C[K]`" (L4 `Multiply`). +/// Associativity of the twisted product holds over any commutative ring with +/// `i⁴ = 1`, machine-checked in `lean/PPVM/Algebra/Twisted.lean` (`tmul_assoc`); +/// the basis-monomial product is `multiply_single` in +/// `lean/PPVM/Algebra/GradedMap.lean`. The whole-map product is `twistedConv` +/// (`lean/PPVM/Algebra/Twisted.lean`), whose identity-key coefficient is exactly +/// [`Pair::overlap`] (`twistedConv_apply_id`) — the spec tying L4 to L3. +pub trait Multiply: Accumulate +where + Self::Key: KeyProduct, + Self::Coeff: ImaginaryUnit, +{ + /// Accumulate the ring product `self · other` into `acc`. + fn multiply_into(&self, other: &Self, acc: &mut Self); +} + +/// The one non-algebraic map operation: keep only the terms a predicate +/// selects. Dropping supported terms breaks module exactness, so this lives +/// outside the graded algebra and is consumed by `Policy`, not the algebra. +/// +/// Design: §"The map is a graded algebra over `C[K]`" and §"Truncation" +/// (`Policy::truncate` bounds on `Retain`). The truncation error incurred is +/// bounded in `lean/PPVM/Algebra/Truncation.lean` (`l1_bound` over `ℝ`, +/// `l1_bound_abv` over any coefficient ring whose +/// [`Coefficient::magnitude`] is an +/// absolute value — the law that bound consumes). +pub trait Retain { + /// Retain exactly the terms for which `keep(&word, &coeff)` is `true`. + fn retain(&mut self, keep: impl Fn(&W, &C) -> bool); +} diff --git a/crates/ppvm-traits-2/src/containers/hash.rs b/crates/ppvm-traits-2/src/containers/hash.rs new file mode 100644 index 00000000..15ae6d21 --- /dev/null +++ b/crates/ppvm-traits-2/src/containers/hash.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Indexable keys and the identity pass-through hasher. +//! +//! Design: `traits-2-configuration-and-hashing.md` §"Indexable values" and +//! §"The pass-through storage contract". `Indexable` is *not* the universal key +//! bound (that is `Eq + Clone`); it is required only on the hash backends. + +use std::hash::{BuildHasher, Hash, Hasher}; + +/// A key whose finalized structural digest is first class. +/// +/// The digest is avalanche-quality in both the low bits (the hashbrown bucket) +/// and the top 7 (the control tag), so it can be consumed *directly* as the map +/// hash. Contracts: +/// +/// * `Hash for Self` is exactly `state.write_u64(self.key_hash())`; +/// * structurally equal keys return equal digests; and +/// * `KeyColumn::hash_into` reproduces this value bit for bit. +/// +/// This exposes the digest *value*, not the cache mechanics — there is no cache +/// type or invalidation hook in the contract. +/// +/// Design: §"Indexable values". +pub trait Indexable: Clone + Eq + Hash { + /// The finalized structural digest of this key. + fn key_hash(&self) -> u64; +} + +/// A pass-through `Hasher`: a key writes its already-finalized `key_hash()` as a +/// single `u64` and this hands it back verbatim, so the digest reaches +/// hashbrown untouched. +/// +/// Design: §"The pass-through storage contract". +#[derive(Debug, Default, Clone)] +pub struct IdentityHasher(u64); + +impl Hasher for IdentityHasher { + #[inline] + fn write_u64(&mut self, n: u64) { + self.0 = n; // store the digest + } + + fn write(&mut self, _: &[u8]) { + unreachable!("Indexable keys write exactly one u64 (their key_hash())") + } + + #[inline] + fn finish(&self) -> u64 { + self.0 // hand it back verbatim + } +} + +/// `BuildHasher` for [`IdentityHasher`]; the storage aliases in +/// `ppvm-pauli-sum-2` bake this into their `HashMap` so `finish() == +/// key.key_hash()`. +/// +/// Design: §"The pass-through storage contract". +#[derive(Debug, Default, Clone)] +pub struct IdentityBuildHasher; + +impl BuildHasher for IdentityBuildHasher { + type Hasher = IdentityHasher; + + #[inline] + fn build_hasher(&self) -> IdentityHasher { + IdentityHasher::default() + } +} diff --git a/crates/ppvm-traits-2/src/containers/hash_join.rs b/crates/ppvm-traits-2/src/containers/hash_join.rs new file mode 100644 index 00000000..5657c2a7 --- /dev/null +++ b/crates/ppvm-traits-2/src/containers/hash_join.rs @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! `HashMap` — the hash-join backend: `accumulate` +//! is a probe-and-merge against the bucket table, requiring `K: Indexable` (the +//! direct structural digest, consumed pass-through through +//! [`IdentityBuildHasher`]). Best for large support, e.g. `PauliSum`. +//! +//! See [`super`] for the shared design references and the orphan-rule note. + +use std::collections::HashMap; + +use crate::algebra::{Conjugate, ImaginaryUnit, KeyProduct}; +use crate::arithmetic::Coefficient; +use crate::containers::{Accumulate, Multiply, Pair, Retain, Scale, Support}; +use crate::containers::{IdentityBuildHasher, Indexable}; +use crate::containers::{KeyBatch, TermBatch}; + +impl Support for HashMap +where + K: Indexable, + C: Coefficient, +{ + type Key = K; + type Coeff = C; + + #[inline] + fn len(&self) -> usize { + HashMap::len(self) + } + + #[inline] + fn get(&self, key: &K) -> Option { + HashMap::get(self, key).cloned() + } + + #[inline] + fn iter(&self) -> impl Iterator { + HashMap::iter(self).map(|(k, v)| (k.clone(), v.clone())) + } + + /// The borrowing scan: hands out `(&K, &C)` straight from the buckets, so a + /// filtering reader never clones a coefficient it is about to reject. Same + /// order as [`Support::iter`] (the map's own bucket order). + #[inline] + fn for_each_ref(&self, mut f: impl FnMut(&K, &C)) { + for (k, v) in HashMap::iter(self) { + f(k, v); + } + } +} + +impl Accumulate for HashMap +where + K: Indexable, + C: Coefficient, +{ + /// Build side of the hash join: probe each produced term, accumulate its + /// coefficient onto a matching key, insert on a miss. + #[inline] + fn accumulate_batch(&mut self, terms: &TermBatch) { + for (k, c) in terms.iter() { + self.entry(k.clone()) + .and_modify(|e| *e += c.clone()) + .or_insert_with(|| c.clone()); + } + } + + /// Drop every zero-coefficient key (`reduce_structural`). + #[inline] + fn reduce(&mut self) { + self.retain(|_, v| !v.is_zero()); + } +} + +impl Scale for HashMap +where + K: Indexable, + C: Coefficient, +{ + #[inline] + fn scale(&mut self, s: &C) { + for v in self.values_mut() { + *v *= s.clone(); + } + } +} + +impl Pair for HashMap +where + K: Indexable, + C: Coefficient, +{ + #[inline] + fn probe_batch(&self, keys: &KeyBatch, out: &mut [Option]) { + debug_assert!(out.len() >= keys.keys().len()); + for (slot, k) in out.iter_mut().zip(keys.keys().iter()) { + *slot = HashMap::get(self, k).cloned(); + } + } + + /// `Σ_k self[k]·other[k]`, driven from the **smaller** support. + /// + /// The shared support is contained in both, so scanning either side and + /// probing the other is `O(1)` per candidate and yields the same pair set; + /// walking the smaller one makes the cost `O(min(|a|, |b|))` instead of + /// `O(|self|)`. Against old's `data().trace(k)`-per-term + /// (`ppvm-pauli-sum/src/sum/trace.rs`, a full linear scan of `self` per term + /// of `other` — anti-feature 13) this is the intended asymptotic improvement; + /// picking the smaller side is the second half of it, and it matters when the + /// operands are deliberately unequal (`|a| = 10⁵`, `|b| = 10`). + /// + /// The *value* is unchanged — the pairing is symmetric and the left factor + /// stays `self`'s coefficient either way — only the float **summation order** + /// depends on the direction, which is why the differential bar on `overlap` is + /// relative (`1e-12`) rather than bit-exact. + #[inline] + fn overlap(&self, other: &Self) -> C { + if self.len() <= other.len() { + HashMap::iter(self) + .filter_map(|(k, a)| HashMap::get(other, k).map(|b| a.clone() * b.clone())) + .sum() + } else { + HashMap::iter(other) + .filter_map(|(k, b)| HashMap::get(self, k).map(|a| a.clone() * b.clone())) + .sum() + } + } + + /// `Σ_k conj(self[k])·other[k]`, driven from the smaller support — see + /// [`Pair::overlap`] for why the direction is free to differ. + #[inline] + fn hermitian_overlap(&self, other: &Self) -> C + where + C: Conjugate, + { + if self.len() <= other.len() { + HashMap::iter(self) + .filter_map(|(k, a)| HashMap::get(other, k).map(|b| a.conj() * b.clone())) + .sum() + } else { + HashMap::iter(other) + .filter_map(|(k, b)| HashMap::get(self, k).map(|a| a.conj() * b.clone())) + .sum() + } + } +} + +impl Retain for HashMap +where + K: Indexable, + C: Coefficient, +{ + #[inline] + fn retain(&mut self, keep: impl Fn(&K, &C) -> bool) { + // Inherent `HashMap::retain` shadows the trait method; no recursion. + self.retain(|k, v| keep(k, v)); + } +} + +impl Multiply for HashMap +where + K: Indexable + KeyProduct, + C: ImaginaryUnit, +{ + /// The twisted convolution `(A·B)[k] = Σ_{p·q = k} A[p]·B[q]·i^{β(p,q)}`, + /// accumulated into `acc` through the hash join — `twistedConv` of + /// `lean/PPVM/Algebra/Twisted.lean` (monomial case `tmul`; associative by + /// `tmul_assoc`/`gtmul_assoc`, and `(A·B)[I] = ⟨A, B⟩` by + /// `twistedConv_apply_id`, tying L4 back to [`Pair::overlap`]). + /// + /// Every `(p, q)` pair contributes: the outer product is `O(|A|·|B|)` and is + /// accumulated **into a distinct `acc`**, never folded back into an operand. + /// (That is the bilinearity old's `MulAssign` loses — see + /// `crate::containers::Multiply` and `ppvm-pauli-sum-2::multiply`.) + /// + /// Neither `reduce` nor any truncation runs here: an exact-zero cancellation + /// stays in `acc`'s support. Canonicalization is the caller's explicit + /// [`Accumulate::reduce`], per §"`reduce()` is first-class, and runs only at + /// finalize". + fn multiply_into(&self, other: &Self, acc: &mut Self) { + for (p, a) in HashMap::iter(self) { + for (q, b) in HashMap::iter(other) { + let (k, phase) = p.key_mul(q); + let c = phase.apply(&(a.clone() * b.clone())); + // Warm the fresh product key's structural digest *before* the + // probe, for the reason `RotateInPlace` does: `key_mul` returns a + // key with an empty hash cache, and letting the finalize fold fire + // lazily inside `entry()` puts its mul-chain latency on the + // bucket-index critical path with nothing to hide it. Semantic + // no-op (identical digest). + let _ = k.key_hash(); + acc.entry(k).and_modify(|e| *e += c.clone()).or_insert(c); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal [`Indexable`] key, so the hash backend is testable here (the + /// only real one, `PauliWord`, lives downstream). `Hash` is exactly + /// `write_u64(key_hash())`, as the contract requires. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct Key(u64); + + impl std::hash::Hash for Key { + fn hash(&self, state: &mut H) { + state.write_u64(self.key_hash()); + } + } + + impl Indexable for Key { + fn key_hash(&self) -> u64 { + self.0.wrapping_mul(0x9E37_79B9_7F4A_7C15) + } + } + + #[test] + fn for_each_ref_agrees_with_iter() { + let mut m: HashMap = HashMap::default(); + for (k, c) in [(1u64, 1.0), (2, 2.0), (3, -3.0)] { + m.insert(Key(k), c); + } + let mut seen: Vec<(Key, f64)> = Vec::new(); + m.for_each_ref(|k, c| seen.push((*k, *c))); + seen.sort_by_key(|(k, _)| k.0); + let mut want: Vec<(Key, f64)> = Support::iter(&m).collect(); + want.sort_by_key(|(k, _)| k.0); + assert_eq!(seen, want); + assert_eq!(seen.len(), 3); + } +} diff --git a/crates/ppvm-traits-2/src/containers/mod.rs b/crates/ppvm-traits-2/src/containers/mod.rs new file mode 100644 index 00000000..90f65c9a --- /dev/null +++ b/crates/ppvm-traits-2/src/containers/mod.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Container algebra, batches, hashing, and traversal preferences. +//! +//! The private backend modules implement these traits for `Vec` and `HashMap`. + +mod batch; +mod coordinate_list; +mod graded; +mod hash; +mod hash_join; +mod storage; + +pub use batch::{Columnar, KeyBatch, KeyColumn, TermBatch, TermProducer, TermSink}; +pub use graded::{Accumulate, Multiply, Pair, Retain, Scale, Support, Trace}; +pub use hash::{IdentityBuildHasher, IdentityHasher, Indexable}; +pub use storage::RekeyStrategy; diff --git a/crates/ppvm-traits-2/src/containers/storage.rs b/crates/ppvm-traits-2/src/containers/storage.rs new file mode 100644 index 00000000..f81d48d0 --- /dev/null +++ b/crates/ppvm-traits-2/src/containers/storage.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +/// Implemented by storage backends, independently of coefficient arithmetic. +/// Both traversals must preserve the same keys and coefficient values. +pub trait RekeyStrategy { + /// Prefer draining entries and moving coefficients when true. + /// When false, prefer borrowing entries and cloning coefficients. + const PREFER_MOVED_REKEY: bool = false; +} diff --git a/crates/ppvm-traits-2/src/fermion_factor.rs b/crates/ppvm-traits-2/src/fermion_factor.rs new file mode 100644 index 00000000..fc8abacd --- /dev/null +++ b/crates/ppvm-traits-2/src/fermion_factor.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +/// The action a fermionic factor performs on its mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FermionAction { + /// A creation operator `a†`. + Create, + /// An annihilation operator `a`. + Annihilate, +} + +/// One factor of an ordered fermionic product — the alphabet of a future +/// ordered fermionic word (`Word`), whose index denotes +/// factor order while the site carries the physical mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FermionSite { + /// The physical mode this factor acts on. + pub mode: usize, + /// Whether this factor creates or annihilates. + pub action: FermionAction, +} diff --git a/crates/ppvm-traits-2/src/gates/channel.rs b/crates/ppvm-traits-2/src/gates/channel.rs new file mode 100644 index 00000000..0a19a8b2 --- /dev/null +++ b/crates/ppvm-traits-2/src/gates/channel.rs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::arithmetic::Coefficient; + +/// Coefficients that can calculate single-qubit Pauli-channel factors. +/// +/// This optional channel capability keeps noise formulas out of scalar +/// arithmetic. Implementers may use the generic default or specialize it. +pub trait PauliErrorFactors: Coefficient + num::One { + /// Transfer eigenvalues `(λ_X, λ_Z, λ_Y)` for Pauli probabilities + /// `(p_X, p_Y, p_Z)`. + #[inline(always)] + fn pauli_error_factors(probabilities: [Self; 3]) -> [Self; 3] { + let [px, py, pz] = probabilities; + let one = Self::one(); + [ + one.clone() - py.doubled() - pz.doubled(), + one.clone() - px.doubled() - py.doubled(), + one - px.doubled() - pz.doubled(), + ] + } +} + +impl PauliErrorFactors for f64 { + #[inline(always)] + fn pauli_error_factors([px, py, pz]: [Self; 3]) -> [Self; 3] { + [ + 1.0 - py * 2.0 - pz * 2.0, + 1.0 - px * 2.0 - py * 2.0, + 1.0 - px * 2.0 - pz * 2.0, + ] + } +} + +impl PauliErrorFactors for num::Complex { + #[inline(always)] + fn pauli_error_factors([px, py, pz]: [Self; 3]) -> [Self; 3] { + let one = Self::new(1.0, 0.0); + [ + one - py * 2.0 - pz * 2.0, + one - px * 2.0 - py * 2.0, + one - px * 2.0 - pz * 2.0, + ] + } +} + +/// A unital single-qubit Pauli error channel `P ↦ λ_P·P`. +pub trait PauliError { + /// Apply a single-qubit Pauli channel with `X`, `Y`, `Z` probabilities. + fn pauli_error( + &mut self, + qubit: usize, + probabilities: [C; 3], + rng: &mut R, + ); + + /// stim `X_ERROR(p)` — apply `X` with probability `p` to one qubit. + fn x_error(&mut self, qubit: usize, p: C, rng: &mut R) { + let zero = C::zero(); + self.pauli_error(qubit, [p, zero.clone(), zero], rng) + } + + /// stim `Y_ERROR(p)` — apply `Y` with probability `p` to one qubit. + fn y_error(&mut self, qubit: usize, p: C, rng: &mut R) { + let zero = C::zero(); + self.pauli_error(qubit, [zero.clone(), p, zero], rng) + } + + /// stim `Z_ERROR(p)` — apply `Z` with probability `p` to one qubit. + fn z_error(&mut self, qubit: usize, p: C, rng: &mut R) { + let zero = C::zero(); + self.pauli_error(qubit, [zero.clone(), zero, p], rng) + } + + /// Explicit batched Pauli-error channel. + fn pauli_error_many( + &mut self, + targets: &[usize], + p: [C; 3], + rng: &mut R, + ) { + for &q in targets { + self.pauli_error(q, p.clone(), rng); + } + } + + /// Explicit batched `X_ERROR(p)`. + fn x_error_many(&mut self, targets: &[usize], p: C, rng: &mut R) { + for &q in targets { + self.x_error(q, p.clone(), rng); + } + } + + /// Explicit batched `Y_ERROR(p)`. + fn y_error_many(&mut self, targets: &[usize], p: C, rng: &mut R) { + for &q in targets { + self.y_error(q, p.clone(), rng); + } + } + + /// Explicit batched `Z_ERROR(p)`. + fn z_error_many(&mut self, targets: &[usize], p: C, rng: &mut R) { + for &q in targets { + self.z_error(q, p.clone(), rng); + } + } +} + +/// Apply the same single-qubit Pauli error channel uniformly to every qubit in +/// the system. +pub trait PauliErrorAll { + /// Apply the Pauli channel `p = [p_x, p_y, p_z]` to every qubit. + fn pauli_error_all(&mut self, p: [C; 3], rng: &mut R); +} + +/// Two-qubit Pauli error channel. +pub trait TwoQubitPauliError { + /// Apply a two-qubit Pauli-error channel to one pair. Probabilities are given + /// in the order + /// `{IX, IY, IZ, XI, XX, XY, XZ, YI, YX, YY, YZ, ZI, ZX, ZY, ZZ}`. + fn two_qubit_pauli_error( + &mut self, + qubit0: usize, + qubit1: usize, + p: [C; 15], + rng: &mut R, + ); + + /// Explicit batched two-qubit Pauli-error channel. + fn two_qubit_pauli_error_many( + &mut self, + pairs: &[(usize, usize)], + p: [C; 15], + rng: &mut R, + ) { + for &(a, b) in pairs { + self.two_qubit_pauli_error(a, b, p.clone(), rng); + } + } +} + +/// Single-qubit depolarizing channel. +pub trait Depolarizing { + /// Depolarize one qubit with probability `p`. + fn depolarize1(&mut self, qubit: usize, p: C, rng: &mut R); + + /// Explicit batched single-qubit depolarizing channel. + fn depolarize1_many(&mut self, targets: &[usize], p: C, rng: &mut R) { + for &q in targets { + self.depolarize1(q, p.clone(), rng); + } + } +} + +/// Two-qubit depolarizing channel. +pub trait Depolarizing2 { + /// Depolarize one qubit pair with probability `p`. + fn depolarize2( + &mut self, + qubit0: usize, + qubit1: usize, + p: C, + rng: &mut R, + ); + + /// Explicit batched two-qubit depolarizing channel. + fn depolarize2_many( + &mut self, + pairs: &[(usize, usize)], + p: C, + rng: &mut R, + ) { + for &(a, b) in pairs { + self.depolarize2(a, b, p.clone(), rng); + } + } +} + +/// Amplitude-damping channel (single qubit). +pub trait AmplitudeDamping { + /// Apply amplitude damping with damping parameter `gamma`. + fn amplitude_damping(&mut self, qubit: usize, gamma: C); +} + +/// Single-qubit loss channel — with probability `p`, mark the qubit as lost +/// (see [`LossState`](crate::loss::LossState)). +pub trait LossChannel { + /// Apply a loss channel to `qubit` with loss probability `p`. + fn loss_channel(&mut self, qubit: usize, p: C, rng: &mut R); +} + +/// Correlated two-qubit loss channel. +pub trait CorrelatedLossChannel { + /// Apply a correlated loss channel to `qubit0` and `qubit1`. + /// + /// The three probabilities are: + /// * `p[0]`: losing both qubits simultaneously when both are in the qubit + /// subspace. + /// * `p[1]`: losing either one qubit when both are in the qubit subspace. + /// * `p[2]`: losing one qubit when the other has already been lost prior to + /// the channel. + fn correlated_loss_channel( + &mut self, + qubit0: usize, + qubit1: usize, + p: [C; 3], + rng: &mut R, + ); +} + +/// Reset the loss bit on a qubit — models a re-cooling / re-loading event that +/// brings a previously-lost atom back. +pub trait ResetLossChannel { + /// Clear the loss bit at `qubit`. + fn reset_loss_channel(&mut self, qubit: usize); +} + +/// State-dependent ("asymmetric") single-qubit loss channel: a qubit is lost from +/// `|0⟩` with probability `p0` and from `|1⟩` with probability `p1`. Unlike +/// [`LossChannel`], the total loss probability depends on the qubit's +/// populations, so the channel reads the current `⟨Z⟩`. +pub trait AsymmetricLossChannel { + /// Apply asymmetric loss to `qubit`, with `p0` / `p1` the loss probabilities + /// from `|0⟩` / `|1⟩`. See the backend impl for the trajectory approximation + /// used (the survival back-action is omitted). + fn asymmetric_loss_channel( + &mut self, + qubit: usize, + p0: C, + p1: C, + rng: &mut R, + ); +} + +#[cfg(test)] +mod tests { + use super::PauliErrorFactors; + use num::Complex; + + #[test] + fn deterministic_pauli_errors_have_expected_conjugation_signs() { + // Inputs are X/Y/Z probabilities; outputs are X/Z/Y eigenvalues. + for (probabilities, expected) in [ + ([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]), + ([1.0, 0.0, 0.0], [1.0, -1.0, -1.0]), + ([0.0, 1.0, 0.0], [-1.0, -1.0, 1.0]), + ([0.0, 0.0, 1.0], [-1.0, 1.0, -1.0]), + ] { + assert_eq!(f64::pauli_error_factors(probabilities), expected); + assert_eq!( + Complex::::pauli_error_factors(probabilities.map(|p| Complex::new(p, 0.0)),), + expected.map(|factor| Complex::new(factor, 0.0)), + ); + } + } +} diff --git a/crates/ppvm-traits-2/src/gates/clifford.rs b/crates/ppvm-traits-2/src/gates/clifford.rs new file mode 100644 index 00000000..b45f404c --- /dev/null +++ b/crates/ppvm-traits-2/src/gates/clifford.rs @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::pauli::{BlanketClifford, PhaseTrack, SymplecticColumns}; + +/// The Clifford gate set, applied in the Heisenberg picture. +pub trait Clifford { + /// Apply Pauli `X` to one qubit. + fn x(&mut self, qubit: usize); + /// Apply Pauli `Y` to one qubit. + fn y(&mut self, qubit: usize); + /// Apply Pauli `Z` to one qubit. + fn z(&mut self, qubit: usize); + /// Apply Hadamard `H` to one qubit. + fn h(&mut self, qubit: usize); + /// Apply the phase gate `S` to one qubit. + fn s(&mut self, qubit: usize); + /// Apply `CNOT` to one `(control, target)` pair. + fn cnot(&mut self, control: usize, target: usize); + /// Apply `CZ` to one qubit pair. + fn cz(&mut self, qubit0: usize, qubit1: usize); + + /// stim alias for [`cnot`](Clifford::cnot). + fn cx(&mut self, control: usize, target: usize) { + self.cnot(control, target) + } + /// stim alias for [`cnot`](Clifford::cnot). + fn zcx(&mut self, control: usize, target: usize) { + self.cnot(control, target) + } + /// stim alias for [`cz`](Clifford::cz). + fn zcz(&mut self, qubit0: usize, qubit1: usize) { + self.cz(qubit0, qubit1) + } +} + +/// Additional Clifford gates beyond the minimal set: `S†`, `√X`, `√X†`, `√Y`, +/// `√Y†`, and `CY`. +pub trait CliffordExtensions: Clifford { + /// Apply `S†` to one qubit. + fn s_dag(&mut self, qubit: usize); + /// Apply `√X` to one qubit. + fn sqrt_x(&mut self, qubit: usize); + /// Apply `(√X)†` to one qubit. + fn sqrt_x_dag(&mut self, qubit: usize); + /// Apply `√Y` to one qubit. + fn sqrt_y(&mut self, qubit: usize); + /// Apply `(√Y)†` to one qubit. + fn sqrt_y_dag(&mut self, qubit: usize); + /// Apply `CY` to one `(control, target)` pair. + fn cy(&mut self, control: usize, target: usize); + /// stim alias for [`cy`](CliffordExtensions::cy). + fn zcy(&mut self, control: usize, target: usize) { + self.cy(control, target) + } +} + +/// Batched Clifford gates: apply the same gate to many qubits in one call. +pub trait CliffordBatch: Clifford { + /// Apply Pauli `X` to every qubit in `indices`. + fn x_many(&mut self, indices: &[usize]) { + for &q in indices { + self.x(q); + } + } + /// Apply Pauli `Y` to every qubit in `indices`. + fn y_many(&mut self, indices: &[usize]) { + for &q in indices { + self.y(q); + } + } + /// Apply Pauli `Z` to every qubit in `indices`. + fn z_many(&mut self, indices: &[usize]) { + for &q in indices { + self.z(q); + } + } + /// Apply Hadamard `H` to every qubit in `indices`. + fn h_many(&mut self, indices: &[usize]) { + for &q in indices { + self.h(q); + } + } + /// Apply the phase gate `S` to every qubit in `indices`. + fn s_many(&mut self, indices: &[usize]) { + for &q in indices { + self.s(q); + } + } + /// Apply `CNOT` to every `(control, target)` pair. + fn cnot_many(&mut self, pairs: &[(usize, usize)]) { + for &(c, t) in pairs { + self.cnot(c, t); + } + } + /// Apply `CZ` to every `(control, target)` pair. + fn cz_many(&mut self, pairs: &[(usize, usize)]) { + for &(c, t) in pairs { + self.cz(c, t); + } + } +} + +pub trait CliffordExtensionsBatch: CliffordExtensions + CliffordBatch { + /// Apply `S†` to every qubit in `indices`. + fn s_dag_many(&mut self, indices: &[usize]) { + for &q in indices { + self.s_dag(q); + } + } + /// Apply `√X` to every qubit in `indices`. + fn sqrt_x_many(&mut self, indices: &[usize]) { + for &q in indices { + self.sqrt_x(q); + } + } + /// Apply `(√X)†` to every qubit in `indices`. + fn sqrt_x_dag_many(&mut self, indices: &[usize]) { + for &q in indices { + self.sqrt_x_dag(q); + } + } + /// Apply `√Y` to every qubit in `indices`. + fn sqrt_y_many(&mut self, indices: &[usize]) { + for &q in indices { + self.sqrt_y(q); + } + } + /// Apply `(√Y)†` to every qubit in `indices`. + fn sqrt_y_dag_many(&mut self, indices: &[usize]) { + for &q in indices { + self.sqrt_y_dag(q); + } + } + /// Apply `CY` to every `(control, target)` pair. + fn cy_many(&mut self, pairs: &[(usize, usize)]) { + for &(c, t) in pairs { + self.cy(c, t); + } + } +} + +impl Clifford for T { + #[inline] + fn x(&mut self, q: usize) { + self.x_phase(q); + } + + #[inline] + fn y(&mut self, q: usize) { + self.y_phase(q); + } + + #[inline] + fn z(&mut self, q: usize) { + self.z_phase(q); + } + + #[inline] + fn h(&mut self, q: usize) { + self.flip_phase_where_xz(q); + self.swap_xz(q); + } + + #[inline] + fn s(&mut self, q: usize) { + self.s_phase(q); + self.xor_z_from_x(q); + } + + #[inline] + fn cnot(&mut self, c: usize, t: usize) { + self.cnot_phase(c, t); + self.xor_x_col(c, t); + self.xor_z_col(t, c); + } + + #[inline] + fn cz(&mut self, a: usize, b: usize) { + self.cz_phase(a, b); + self.cz_bits(a, b); + } +} + +/// The derived [`CliffordExtensions`] behavior, blanket-implemented for the same +/// [`BlanketClifford`] opt-ins — the counterpart of the old crate's +/// `impl CliffordExtensions for T`. +/// +/// # Why generator products rather than new primitives +/// +/// The old blanket could write each extension gate as a raw bit rule because a +/// bare `PauliWordTrait` carries no phase, so the *only* content was the +/// `Sp(2n,2)` action. Here the blanket must also be correct for phase-carrying +/// opt-ins (`Tableau`), so each gate is expressed as a product of the audited +/// [`Clifford`] generators instead: the bit rule and the sign then follow from +/// generators whose signs are already machine-checked, rather than from six new +/// hand-written [`PhaseTrack`] deltas that would have to be re-proved (and +/// re-implemented by every word crate). +/// +/// Calls compose in the *backward* Heisenberg convention this crate uses +/// (`P ↦ U†PU`; `lean/PPVM/Pauli/Conjugation.lean`, `conjSdag`): applying `A` +/// then `B` conjugates by the operator product `A·B`. With that, and using +/// `S³ = S†`, `SZ = S³`: +/// +/// | gate | operator identity | call sequence | +/// |:---:|:---|:---| +/// | `s_dag` | `S† = S·Z` | `s`, `z` | +/// | `sqrt_x` | `√X ≃ H·S·H` | `h`, `s`, `h` | +/// | `sqrt_x_dag` | `√X† ≃ H·S†·H` | `h`, `s_dag`, `h` | +/// | `sqrt_y` | `√Y ≃ H·Z` | `h`, `z` | +/// | `sqrt_y_dag` | `√Y† ≃ Z·H` | `z`, `h` | +/// | `cy` | `CY = (I⊗S)·CNOT·(I⊗S†)` | `s(t)`, `cnot(c,t)`, `s_dag(t)` | +/// +/// Each row reproduces the old crate's bit rule **and** the old phased word's +/// sign formula exactly — pinned by `tests/phase1_gate_surface.rs` +/// (`blanket_clifford_extensions_match_old_conjugation_table`, +/// `blanket_cy_matches_old_two_qubit_table`), which replays the gates on a +/// ℤ₄-phased stub and checks the full `s`/`s_dag`/`√X`/`√X†`/`√Y`/`√Y†` +/// conjugation table plus the 16-entry `CY` table of `ppvm-traits`; by the +/// `ppvm-conformance-2` differential suites against the old crate; and, for the +/// gate identities themselves, by the `ℤ[i]` matrix oracle +/// (`phased_pauli_word_lean.rs`). +/// +/// The composition itself — the step the stub tests cannot check for a +/// phase-*carrying* opt-in — is machine-checked in +/// `lean/PPVM/Pauli/Conjugation.lean`, where each row above is *defined* as the +/// product of the audited generator homs (the crate's backward `s` is `conjSdag` +/// there) and is therefore a `MonoidHom` for free: `extSdag`/`extSqrtX`/ +/// `extSqrtXdag`/`extSqrtY`/`extSqrtYdag` (+ `extSdagHom`… `MonoidHom.comp`s), +/// with the tables as `extSdag_eq_conjS`, `extSqrtX_X`/`_Y`/`_Z`, …, the +/// dagger-inverse pairs as `extSqrtXdag_extSqrtX`/`extSqrtYdag_extSqrtY`/ +/// `extSdag_conjSdag`, and `extSqrtX_sq`/`extSqrtY_sq` for `√X² = X`, `√Y² = Y`. +/// `cy` is the same on `𝒫₂`: `conjCY` (= `conjST ∘ conjCNOT ∘ conjSdagT`, with +/// `conjCY_calls` collapsing the literal four-primitive sequence and +/// `conjCYHom` the hom), whose `conjCY_bits` + `conjCY_sign` *are* the old +/// 16-entry table. Corollary — every composite delta is still real +/// (`extSqrtX_isRealPhase`, …, `conjCY_isRealPhase`), so the `±1` drain in +/// `ppvm-pauli-sum-2` stays total on the extension gates too. +/// +/// # Loss guard +/// +/// A lossy word implements the guard inside its column primitives ("a gate +/// touching a lost qubit is a no-op"), so the single-qubit rows above inherit it +/// unchanged. `cy` is the one case worth stating: with a **lost control and a +/// present target**, the old whole-gate skip did nothing, while the decomposition +/// still runs `s(t)` and `s_dag(t)`. Those two share the `z ⊕= x` bit map and are +/// inverse conjugations, so they cancel exactly and the word is left untouched — +/// verified against the old reference over the full 25-word lossy alphabet in +/// `ppvm-conformance-2::lossy_pauli_word_diff`, and proven on every loss +/// configuration by `sActL_cnotActL_sActL_eq_cyActL` +/// (`lean/PPVM/Pauli/Symplectic.lean`): the guarded composite equals the old +/// crate's atomic whole-gate skip `cyActL`. The phase half of the same +/// cancellation, for a phase-carrying opt-in, is `conjS_conjSdag`/ +/// `conjSdag_conjS` (`lean/PPVM/Pauli/Conjugation.lean`). +/// +/// A concrete type that wants the old fused single-pass cost (the `Tableau`'s +/// per-gate bit-plane sweep) opts out of [`BlanketClifford`] and writes its own +/// `impl CliffordExtensions`, exactly as `Phased` does for [`Clifford`]. +impl CliffordExtensions for T { + #[inline] + fn s_dag(&mut self, q: usize) { + self.s(q); + self.z(q); + } + + #[inline] + fn sqrt_x(&mut self, q: usize) { + self.h(q); + self.s(q); + self.h(q); + } + + #[inline] + fn sqrt_x_dag(&mut self, q: usize) { + self.h(q); + self.s_dag(q); + self.h(q); + } + + #[inline] + fn sqrt_y(&mut self, q: usize) { + self.h(q); + self.z(q); + } + + #[inline] + fn sqrt_y_dag(&mut self, q: usize) { + self.z(q); + self.h(q); + } + + #[inline] + fn cy(&mut self, control: usize, target: usize) { + self.s(target); + self.cnot(control, target); + self.s_dag(target); + } +} diff --git a/crates/ppvm-traits-2/src/gates/measure.rs b/crates/ppvm-traits-2/src/gates/measure.rs new file mode 100644 index 00000000..0745284b --- /dev/null +++ b/crates/ppvm-traits-2/src/gates/measure.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Measurement, projection, and reset operations. + +use super::{Clifford, CliffordExtensions}; + +/// Loss-aware projective computational-basis measurement. +/// +pub trait Measure { + /// Measure `qubit`; `None` if the qubit has been lost. + fn measure(&mut self, qubit: usize, rng: &mut R) -> Option; + + /// Measure each target in order, one result per target. + fn measure_many( + &mut self, + targets: &[usize], + rng: &mut R, + ) -> Vec> { + targets.iter().map(|&q| self.measure(q, rng)).collect() + } +} + +// Reset one qubit to a computational/Pauli basis state. +pub trait Reset: Clifford + CliffordExtensions { + /// Reset one qubit to `|0⟩` (stim `R`/`RZ`). + fn reset(&mut self, qubit: usize, rng: &mut R); + + /// stim `RZ` alias — reset to `|0⟩`. + fn reset_z(&mut self, qubit: usize, rng: &mut R) { + self.reset(qubit, rng) + } + + /// stim `RX` — reset to `|+⟩`. + fn reset_x(&mut self, qubit: usize, rng: &mut R) { + self.reset(qubit, rng); + self.h(qubit); + } + + /// stim `RY` — reset to `|i⟩`. + fn reset_y(&mut self, qubit: usize, rng: &mut R) { + self.reset(qubit, rng); + self.h(qubit); + self.s(qubit); + } + + /// Explicit batched reset to `|0⟩`. + fn reset_many(&mut self, targets: &[usize], rng: &mut R) { + for &q in targets { + self.reset(q, rng); + } + } + + /// Explicit batched `RZ` alias. + fn reset_z_many(&mut self, targets: &[usize], rng: &mut R) { + self.reset_many(targets, rng) + } + + /// Explicit batched `RX`. + fn reset_x_many(&mut self, targets: &[usize], rng: &mut R) { + for &q in targets { + self.reset_x(q, rng); + } + } + + /// Explicit batched `RY`. + fn reset_y_many(&mut self, targets: &[usize], rng: &mut R) { + for &q in targets { + self.reset_y(q, rng); + } + } +} + +/// Projective Z-basis projectors `|0⟩⟨0|` and `|1⟩⟨1|` +pub trait Projection { + /// Project `qubit` onto `|0⟩`. + fn p0(&mut self, qubit: usize); + /// Project `qubit` onto `|1⟩`. + fn p1(&mut self, qubit: usize); +} diff --git a/crates/ppvm-traits-2/src/gates/mod.rs b/crates/ppvm-traits-2/src/gates/mod.rs new file mode 100644 index 00000000..4a835542 --- /dev/null +++ b/crates/ppvm-traits-2/src/gates/mod.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Gate, measurement, reset, and noise-channel interfaces. + +mod channel; +mod clifford; +mod measure; +mod rot; + +pub use channel::{ + AmplitudeDamping, AsymmetricLossChannel, CorrelatedLossChannel, Depolarizing, Depolarizing2, + LossChannel, PauliError, PauliErrorAll, PauliErrorFactors, ResetLossChannel, + TwoQubitPauliError, +}; +pub use clifford::{Clifford, CliffordBatch, CliffordExtensions, CliffordExtensionsBatch}; +pub use measure::{Measure, Projection, Reset}; +pub use rot::{CRx, RotXY, RotationOne, RotationTwo, TGate, U3Gate}; diff --git a/crates/ppvm-traits-2/src/gates/rot.rs b/crates/ppvm-traits-2/src/gates/rot.rs new file mode 100644 index 00000000..1d391cb0 --- /dev/null +++ b/crates/ppvm-traits-2/src/gates/rot.rs @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::{ + arithmetic::{Angle, Coefficient}, + pauli::Pauli, +}; + +/// Single-qubit rotations parameterized by an angle domain `A` that yields +/// amplitudes in coefficient domain `C`. +/// +/// The angle defaults to the coefficient (`A = C`), recovering today's +/// `rx(theta: C)` while permitting a symbolic/parametric angle over an +/// `f64`-coefficient sum. +pub trait RotationOne = C> { + /// Rotate about `axis` (one of `X`, `Y`, `Z`) on `qubit` by `theta`. + /// + /// `Pauli::I` commutes with every term, so an `I` axis is a no-op. + fn rotate_1(&mut self, axis: Pauli, qubit: usize, theta: A); + + /// Rotate about `X` on `qubit` by `theta`. + fn rx(&mut self, qubit: usize, theta: A) { + self.rotate_1(Pauli::X, qubit, theta) + } + /// Rotate about `Y` on `qubit` by `theta`. + fn ry(&mut self, qubit: usize, theta: A) { + self.rotate_1(Pauli::Y, qubit, theta) + } + /// Rotate about `Z` on `qubit` by `theta`. + fn rz(&mut self, qubit: usize, theta: A) { + self.rotate_1(Pauli::Z, qubit, theta) + } + + /// Explicit batched `RX(θ)`. + fn rx_many(&mut self, targets: &[usize], theta: A) + where + A: Clone, + { + for &q in targets { + self.rx(q, theta.clone()) + } + } + /// Explicit batched `RY(θ)`. + fn ry_many(&mut self, targets: &[usize], theta: A) + where + A: Clone, + { + for &q in targets { + self.ry(q, theta.clone()) + } + } + /// Explicit batched `RZ(θ)`. + fn rz_many(&mut self, targets: &[usize], theta: A) + where + A: Clone, + { + for &q in targets { + self.rz(q, theta.clone()) + } + } +} + +/// Two-qubit rotations `exp(-i θ/2 · P_a ⊗ P_b)`. +pub trait RotationTwo = C> { + /// Rotate about the supplied Pauli axes. + /// + /// The generator is `axis_a ⊗ axis_b` on sites `a` and `b`. + fn rotate_2(&mut self, axis_a: Pauli, axis_b: Pauli, a: usize, b: usize, theta: A); + + /// Rotate about X ⊗ X. + fn rxx(&mut self, a: usize, b: usize, theta: A) { + self.rotate_2(Pauli::X, Pauli::X, a, b, theta); + } + + /// Rotate about X ⊗ Y. + fn rxy(&mut self, a: usize, b: usize, theta: A) { + self.rotate_2(Pauli::X, Pauli::Y, a, b, theta); + } + + /// Rotate about X ⊗ Z. + fn rxz(&mut self, a: usize, b: usize, theta: A) { + self.rotate_2(Pauli::X, Pauli::Z, a, b, theta); + } + + /// Rotate about Y ⊗ X. + fn ryx(&mut self, a: usize, b: usize, theta: A) { + self.rotate_2(Pauli::Y, Pauli::X, a, b, theta); + } + + /// Rotate about Y ⊗ Y. + fn ryy(&mut self, a: usize, b: usize, theta: A) { + self.rotate_2(Pauli::Y, Pauli::Y, a, b, theta); + } + + /// Rotate about Y ⊗ Z. + fn ryz(&mut self, a: usize, b: usize, theta: A) { + self.rotate_2(Pauli::Y, Pauli::Z, a, b, theta); + } + + /// Rotate about Z ⊗ X. + fn rzx(&mut self, a: usize, b: usize, theta: A) { + self.rotate_2(Pauli::Z, Pauli::X, a, b, theta); + } + + /// Rotate about Z ⊗ Y. + fn rzy(&mut self, a: usize, b: usize, theta: A) { + self.rotate_2(Pauli::Z, Pauli::Y, a, b, theta); + } + + /// Rotate about Z ⊗ Z. + fn rzz(&mut self, a: usize, b: usize, theta: A) { + self.rotate_2(Pauli::Z, Pauli::Z, a, b, theta); + } + + /// Apply RXX to each pair in order. + fn rxx_many(&mut self, pairs: &[(usize, usize)], theta: A) + where + A: Clone, + { + for &(a, b) in pairs { + self.rxx(a, b, theta.clone()); + } + } + + /// Apply RXY to each pair in order. + fn rxy_many(&mut self, pairs: &[(usize, usize)], theta: A) + where + A: Clone, + { + for &(a, b) in pairs { + self.rxy(a, b, theta.clone()); + } + } + + /// Apply RXZ to each pair in order. + fn rxz_many(&mut self, pairs: &[(usize, usize)], theta: A) + where + A: Clone, + { + for &(a, b) in pairs { + self.rxz(a, b, theta.clone()); + } + } + + /// Apply RYX to each pair in order. + fn ryx_many(&mut self, pairs: &[(usize, usize)], theta: A) + where + A: Clone, + { + for &(a, b) in pairs { + self.ryx(a, b, theta.clone()); + } + } + + /// Apply RYY to each pair in order. + fn ryy_many(&mut self, pairs: &[(usize, usize)], theta: A) + where + A: Clone, + { + for &(a, b) in pairs { + self.ryy(a, b, theta.clone()); + } + } + + /// Apply RYZ to each pair in order. + fn ryz_many(&mut self, pairs: &[(usize, usize)], theta: A) + where + A: Clone, + { + for &(a, b) in pairs { + self.ryz(a, b, theta.clone()); + } + } + + /// Apply RZX to each pair in order. + fn rzx_many(&mut self, pairs: &[(usize, usize)], theta: A) + where + A: Clone, + { + for &(a, b) in pairs { + self.rzx(a, b, theta.clone()); + } + } + + /// Apply RZY to each pair in order. + fn rzy_many(&mut self, pairs: &[(usize, usize)], theta: A) + where + A: Clone, + { + for &(a, b) in pairs { + self.rzy(a, b, theta.clone()); + } + } + + /// Apply RZZ to each pair in order. + fn rzz_many(&mut self, pairs: &[(usize, usize)], theta: A) + where + A: Clone, + { + for &(a, b) in pairs { + self.rzz(a, b, theta.clone()); + } + } +} + +/// Rotation about an axis in the x/y plane: +/// `R(axis_angle, θ) = exp(−i·θ/2·(cos(axis_angle)·X + sin(axis_angle)·Y))`. +/// +/// The in-plane axis is `X` rotated about `Z` by `axis_angle`, so +/// `R(axis_angle, θ) = RZ(axis_angle)·RX(θ)·RZ(−axis_angle)` +pub trait RotXY = C> { + /// `R(axis_angle, θ)` on `qubit`. + fn r(&mut self, qubit: usize, axis_angle: A, theta: A); +} + +/// Controlled `RX` rotation +pub trait CRx = C> { + /// Apply `CRX(θ)` with the given control and target. + fn crx(&mut self, control: usize, target: usize, theta: A); +} + +/// The general single-qubit `U3(θ, φ, λ)` gate +pub trait U3Gate = C> { + /// Apply `U3(θ, φ, λ)` to `qubit`. + fn u3(&mut self, qubit: usize, theta: A, phi: A, lambda: A); +} + +/// The non-Clifford `T` gate and its adjoint, `T = diag(1, e^{iπ/4})`. +pub trait TGate { + /// Apply `T` (`diag(1, e^{iπ/4})`) to one qubit. + fn t(&mut self, qubit: usize); + /// Apply `T†` to one qubit. + fn t_dag(&mut self, qubit: usize); + + /// Explicit batched `T`. + fn t_many(&mut self, targets: &[usize]) { + for &q in targets { + self.t(q); + } + } + + /// Explicit batched `T†`. + fn t_dag_many(&mut self, targets: &[usize]) { + for &q in targets { + self.t_dag(q); + } + } +} diff --git a/crates/ppvm-traits-2/src/lib.rs b/crates/ppvm-traits-2/src/lib.rs new file mode 100644 index 00000000..6008a250 --- /dev/null +++ b/crates/ppvm-traits-2/src/lib.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Shared arithmetic, word, gate, and container interfaces. +//! +//! Includes small algebraic types, default gate implementations, and +//! container implementations for `Vec` and `HashMap`. + +pub mod algebra; +pub mod arithmetic; +pub mod containers; +pub mod fermion_factor; +pub mod gates; +pub mod loss; +pub mod pauli; +pub mod word; + +pub use algebra::{Conjugate, ImaginaryUnit, KeyProduct, Phase}; +pub use arithmetic::{Angle, Coefficient, Halvable}; +pub use containers::{ + Accumulate, Columnar, IdentityBuildHasher, IdentityHasher, Indexable, KeyBatch, KeyColumn, + Multiply, Pair, RekeyStrategy, Retain, Scale, Support, TermBatch, TermProducer, TermSink, + Trace, +}; +pub use fermion_factor::{FermionAction, FermionSite}; +pub use gates::{ + AmplitudeDamping, AsymmetricLossChannel, CRx, Clifford, CliffordBatch, CliffordExtensions, + CliffordExtensionsBatch, CorrelatedLossChannel, Depolarizing, Depolarizing2, LossChannel, + Measure, PauliError, PauliErrorAll, PauliErrorFactors, Projection, Reset, ResetLossChannel, + RotXY, RotationOne, RotationTwo, TGate, TwoQubitPauliError, U3Gate, +}; +pub use loss::LossState; +pub use pauli::{BlanketClifford, Pauli, PhaseTrack, StabilizerFrame, SymplecticColumns}; +pub use word::{PauliBits, Word}; + +/// Common traits and types for implementing and using the interfaces. +pub mod prelude { + pub use crate::{ + Accumulate, AmplitudeDamping, Angle, AsymmetricLossChannel, BlanketClifford, CRx, Clifford, + CliffordBatch, CliffordExtensions, CliffordExtensionsBatch, Coefficient, Columnar, + Conjugate, CorrelatedLossChannel, Depolarizing, Depolarizing2, FermionAction, FermionSite, + Halvable, IdentityBuildHasher, IdentityHasher, ImaginaryUnit, Indexable, KeyBatch, + KeyColumn, KeyProduct, LossChannel, LossState, Measure, Multiply, Pair, Pauli, PauliBits, + PauliError, PauliErrorAll, PauliErrorFactors, Phase, PhaseTrack, Projection, RekeyStrategy, + Reset, ResetLossChannel, Retain, RotXY, RotationOne, RotationTwo, Scale, StabilizerFrame, + Support, SymplecticColumns, TGate, TermBatch, TermProducer, TermSink, Trace, + TwoQubitPauliError, U3Gate, Word, + }; +} diff --git a/crates/ppvm-traits-2/src/loss.rs b/crates/ppvm-traits-2/src/loss.rs new file mode 100644 index 00000000..15f95a20 --- /dev/null +++ b/crates/ppvm-traits-2/src/loss.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +// Loss state associated with word sites +pub trait LossState { + // Whether site i is lost. LossyPauliWord overrides this + fn is_lost(&self, _i: usize) -> bool { + false + } + + // Number of lost sites + fn loss_weight(&self) -> usize { + 0 + } + + // Mark index `i` lost + fn set_lost(&mut self, i: usize); + + // Clear the loss flag at index `i`, returning the site to identity. + fn clear_lost(&mut self, i: usize); + + /// A copy of this word with the loss flag at `i` cleared. + fn loss_cleared(&self, i: usize) -> Self + where + Self: Sized + Clone, + { + let mut out = self.clone(); + out.clear_lost(i); + out + } +} diff --git a/crates/ppvm-traits-2/src/pauli.rs b/crates/ppvm-traits-2/src/pauli.rs new file mode 100644 index 00000000..e8b32e9c --- /dev/null +++ b/crates/ppvm-traits-2/src/pauli.rs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +/// A single-qubit Pauli symbol — the site alphabet of an ordinary packed Pauli +/// word (`Word`). +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Pauli { + /// Identity `I`. + I, + /// Pauli `X`. + X, + /// Pauli `Y`. + Y, + /// Pauli `Z`. + Z, +} +/// `Sp`-part: bit-plane column algebra, written **once** and shared by +/// `PhasedPauliWord` (1-bit columns) and `Tableau` (SIMD blocks over its `2n` +/// rows). Same meaning, different width. No phase — this is the role-independent +/// symplectic action. +/// +/// Design: §"Pauli algebra traits". The bit rules realize the per-generator +/// `Sp(2n, 2)` isometries of `lean/PPVM/Pauli/Symplectic.lean` +/// (`hAct_isometry`/`sAct_isometry`/`cnotAct_isometry`/`czAct_isometry`). +pub trait SymplecticColumns { + /// Number of qubits (columns) this operator spans. + fn n_qubits(&self) -> usize; + + /// `H` on `q`: swap the X and Z columns. + fn swap_xz(&mut self, q: usize); + + /// `S` on `q`: `z_q ⊕= x_q` (maps `X → Y`). + /// + /// (Completes the design's abbreviated `// ...`; see the module friction + /// note.) + fn xor_z_from_x(&mut self, q: usize); + + /// `CNOT` bit rule, part one: `x_tgt ⊕= x_ctrl`. + fn xor_x_col(&mut self, ctrl: usize, tgt: usize); + + /// `CNOT` bit rule, part two: `z_ctrl ⊕= z_tgt`. + fn xor_z_col(&mut self, tgt: usize, ctrl: usize); + + /// `CZ` bit rule on `(a, b)`: `z_a ⊕= x_b` and `z_b ⊕= x_a`. + /// + /// (Completes the design's abbreviated `// ...`; see the module friction + /// note.) + fn cz_bits(&mut self, a: usize, b: usize); +} + +/// Extension-part: the phase algebra. `ℤ₄` for a phased word, `ℤ₂` + the +/// Aaronson–Gottesman `g`-rule for a tableau. One phase delta per gate; the +/// role-dependent half of conjugation, written **per type**. +pub trait PhaseTrack { + /// `H` phase delta: flip the sign of a component with both `x` and `z` set + /// (`Y → −Y`). + fn flip_phase_where_xz(&mut self, q: usize); + + /// `S` phase delta on `q`. + fn s_phase(&mut self, q: usize); + + /// `CNOT` phase delta on `(ctrl, tgt)`. + fn cnot_phase(&mut self, ctrl: usize, tgt: usize); + + /// `CZ` phase delta on `(a, b)`. + fn cz_phase(&mut self, a: usize, b: usize); + + /// `X` phase delta on `q` (pure sign; no bit change). + fn x_phase(&mut self, q: usize); + + /// `Y` phase delta on `q` (pure sign; no bit change). + fn y_phase(&mut self, q: usize); + + /// `Z` phase delta on `q` (pure sign; no bit change). + fn z_phase(&mut self, q: usize); +} + +/// Role-*exclusive* operations that interpret the rows as a symplectic basis +/// rather than as independent operators. A tableau-only trait a word never +/// implements. Holds the frame **primitives**, not `measure` itself — the two +/// measurement algorithms are built *on* these. +pub trait StabilizerFrame { + /// Find a generator that anticommutes with the measured Pauli (the pivot). + fn anticommuting_pivot(&self, qubit: usize) -> Option; + + /// Multiply generator `src` into `dst` (uses the Aaronson–Gottesman + /// `g`-rule). + fn row_multiply(&mut self, src: usize, dst: usize); + + /// Restore canonical form after elimination. + fn canonicalize(&mut self); +} + +pub trait BlanketClifford {} diff --git a/crates/ppvm-traits-2/src/word.rs b/crates/ppvm-traits-2/src/word.rs new file mode 100644 index 00000000..e9dfd0d2 --- /dev/null +++ b/crates/ppvm-traits-2/src/word.rs @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +/// The common **read-only** concept for an indexed algebraic monomial. +pub trait Word { + /// The operator alphabet at one index. + type Site; + + /// Number of sites (for a dense Pauli word, the qubit width). + fn n_sites(&self) -> usize; + + /// Read the site at `index`. + fn get(&self, index: usize) -> Self::Site; + + /// Number of non-identity factors according to the concrete site alphabet. + /// + /// A Pauli-motivated read (the `MaxPauliWeight` policy needs it); an ordered + /// representation that stores no explicit identities may have + /// `weight() == n_sites()`. + fn weight(&self) -> usize; + + /// Iterate the sites in index order. + fn iter(&self) -> impl Iterator; +} + +// Mutable single-vector X/Z access — a point of `GF(2)^{2n}`. +pub trait PauliBits: Word { + /// Read the X bit at index `i`. + fn x_bit(&self, i: usize) -> bool; + + /// Read the Z bit at index `i`. + fn z_bit(&self, i: usize) -> bool; + + /// Set the X bit at index `i` (refreshes any structural-hash cache). + fn set_x_bit(&mut self, i: usize, v: bool); + + /// Set the Z bit at index `i` (refreshes any structural-hash cache). + fn set_z_bit(&mut self, i: usize, v: bool); + + /// Set both bit planes at one site. + /// + /// The default composes the scalar setters. Packed words whose setters + /// refresh structural metadata eagerly may override this to refresh once. + #[inline(always)] + fn set_xz_bits(&mut self, i: usize, x: bool, z: bool) { + self.set_x_bit(i, x); + self.set_z_bit(i, z); + } + /// Set both packed bit planes at two sites. + /// + /// The default composes the one-site setter. Packed words with eager + /// structural metadata can override this to refresh exactly once after the + /// four writes. + #[inline(always)] + fn set_xz_bits2(&mut self, i: usize, xi: bool, zi: bool, j: usize, xj: bool, zj: bool) { + self.set_xz_bits(i, xi, zi); + self.set_xz_bits(j, xj, zj); + } + /// Set one X-plane bit and one Z-plane bit as one structural mutation. + /// + /// `CNOT` updates exactly this pair (`x_target`, `z_control`). Packed words + /// with eager structural metadata can override this to refresh once without + /// also reading or rewriting the two unchanged companion bits. + #[inline(always)] + fn set_x_bit_and_z_bit(&mut self, x_i: usize, x: bool, z_i: usize, z: bool) { + self.set_x_bit(x_i, x); + self.set_z_bit(z_i, z); + } + /// Set two Z-plane bits as one structural mutation. + /// + /// `CZ` updates exactly this pair (`z_a`, `z_b`) and leaves both X bits + /// alone — the counterpart of [`set_x_bit_and_z_bit`](PauliBits::set_x_bit_and_z_bit) + /// for `CNOT`. Routing it through the four-bit + /// [`set_xz_bits2`](PauliBits::set_xz_bits2) instead makes a packed word read + /// and rewrite the two unchanged X bits on every gate. The default composes + /// the scalar setters. + #[inline(always)] + fn set_z_bit_pair(&mut self, i: usize, zi: bool, j: usize, zj: bool) { + self.set_z_bit(i, zi); + self.set_z_bit(j, zj); + } + + /// Packed local Pauli code: `0=I, 1=X, 2=Z, 3=Y`. + #[inline(always)] + fn pauli_code(&self, i: usize) -> u8 { + (self.x_bit(i) as u8) | ((self.z_bit(i) as u8) << 1) + } + + /// A copy of this word with the X and/or Z bit at `i` toggled — the + /// **rotation-branch key builder** (`iGP` from a diagonal `P`). + /// + /// Provided as clone-then-flip so every `PauliBits` implementer gets a branch + /// builder for free and the rotation/branching kernels can be generic over the + /// word type (the ordinary and the lossy key run the *same* kernel — see + /// `ppvm-pauli-sum-2`'s rotation and loss modules). `PauliWord` overrides it + /// with a direct plane copy that computes the digest exactly once, skipping + /// the redundant refresh a `clone` + `set_*_bit` pair performs. + fn toggled_bits(&self, i: usize, toggle_x: bool, toggle_z: bool) -> Self + where + Self: Sized + Clone, + { + let mut out = self.clone(); + if toggle_x { + let b = out.x_bit(i); + out.set_x_bit(i, !b); + } + if toggle_z { + let b = out.z_bit(i); + out.set_z_bit(i, !b); + } + out + } + + /// A copy of this word with the X and/or Z bits at **two** sites toggled — + /// the **two-qubit** rotation-branch key builder (`iG_aG_b·P`). + /// + /// Chaining [`toggled_bits`](PauliBits::toggled_bits) twice would build two + /// whole words per produced branch term, and for a packed word each of those is + /// a full copy of *both* bit planes plus a word rebuild — so the intermediate + /// is pure waste on the hot path of `rzz`/`rxx`/`ryy`/`rotate_2` (old built + /// **one** `k.clone()` and then wrote four bits into it, + /// `ppvm-pauli-sum/src/sum/rot2.rs`). The two-site entry point makes the single + /// copy the *only* copy, and it scales with the storage tier: at `[u8; 32]` the + /// chained form moved 64 redundant bytes per branch term. + /// + /// The default is clone-then-flip (one clone, up to four bit writes), which is + /// already old's shape; `PauliWord` overrides it with a direct plane copy that + /// computes the digest exactly once, as it does for the single-site form. + #[inline] + #[allow(clippy::too_many_arguments)] + fn toggled_bits2( + &self, + i: usize, + toggle_x_i: bool, + toggle_z_i: bool, + j: usize, + toggle_x_j: bool, + toggle_z_j: bool, + ) -> Self + where + Self: Sized + Clone, + { + let mut out = self.clone(); + if toggle_x_i { + let b = out.x_bit(i); + out.set_x_bit(i, !b); + } + if toggle_z_i { + let b = out.z_bit(i); + out.set_z_bit(i, !b); + } + if toggle_x_j { + let b = out.x_bit(j); + out.set_x_bit(j, !b); + } + if toggle_z_j { + let b = out.z_bit(j); + out.set_z_bit(j, !b); + } + out + } + + /// Consume a word and toggle X/Z bits at two sites in place. + /// + /// Re-keying kernels already own their key, so this avoids the structural + /// copy required by [`toggled_bits2`](PauliBits::toggled_bits2). Packed words + /// may override it to defer derived-hash refresh until all writes complete. + #[inline] + #[allow(clippy::too_many_arguments)] + fn into_toggled_bits2( + mut self, + i: usize, + toggle_x_i: bool, + toggle_z_i: bool, + j: usize, + toggle_x_j: bool, + toggle_z_j: bool, + ) -> Self + where + Self: Sized, + { + if toggle_x_i { + let b = self.x_bit(i); + self.set_x_bit(i, !b); + } + if toggle_z_i { + let b = self.z_bit(i); + self.set_z_bit(i, !b); + } + if toggle_x_j { + let b = self.x_bit(j); + self.set_x_bit(j, !b); + } + if toggle_z_j { + let b = self.z_bit(j); + self.set_z_bit(j, !b); + } + self + } + + /// Whether this word anticommutes with the single-qubit Pauli + /// `pauli = (x_bit, z_bit)` at index `i`, i.e. whether the symplectic form + /// `ω(P, Q) = x_P·z_Q ⊕ z_P·x_Q` is `1` there. + /// + /// The old `PauliWordTrait::anticommutes_at` + /// (`ppvm-traits/src/traits/word_trait.rs`), reproduced verbatim as a + /// provided method — it is the pivot test the tableau measurement search + /// runs (`ppvm-tableau/src/data.rs`), and it is derivable from the two bit + /// reads, so it needs no new required method. + #[inline] + fn anticommutes_at(&self, i: usize, pauli: (bool, bool)) -> bool { + (self.x_bit(i) & pauli.1) ^ (self.z_bit(i) & pauli.0) + } +}