From aa8e25809fc4f84959870111ea3a76b0a2b651ae Mon Sep 17 00:00:00 2001 From: Marcus P S Date: Fri, 3 Jul 2026 08:26:46 -0700 Subject: [PATCH 1/3] Add Clifford -> transvection decomposition (arXiv:2102.11380) Implement a linear-size decomposition of a Clifford into Clifford transvections (pi/4 Pauli exponents) reproducing its symplectic (conjugation) action, plus the centralizer generators (the paper's headline application), in the paulimer crate. - paulimer::clifford::clifford_to_transvections: greedy O'Meara-style reduction producing O(n) factors that reproduce the symplectic action (signs and global phase are not tracked; contrast the sign-exact, O(n^2) clifford_to_pauli_exponents). Not guaranteed strictly minimal; exact r/r+1 minimality via congruence triangulation is a follow-up. - paulimer::clifford::clifford_centralizer: generators of Paulis fixed up to sign under conjugation (kernel of the residue map over GF(2), via binar). - Python bindings CliffordUnitary.to_transvections() / .centralizer() with .pyi stubs. - Extensive Rust (unit + proptest) and Python (hypothesis) tests: symplectic round trip, linear factor bound, no-shorter-than-minimum, and centralizer conjugation-fixedness. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- paulimer/bindings/python/paulimer.pyi | 14 ++ paulimer/bindings/python/src/py_clifford.rs | 21 +- .../python/tests/transvection_test.py | 120 ++++++++++ paulimer/src/clifford.rs | 3 + paulimer/src/clifford/transvection.rs | 204 ++++++++++++++++ paulimer/tests/transvection_test.rs | 222 ++++++++++++++++++ 6 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 paulimer/bindings/python/tests/transvection_test.py create mode 100644 paulimer/src/clifford/transvection.rs create mode 100644 paulimer/tests/transvection_test.rs diff --git a/paulimer/bindings/python/paulimer.pyi b/paulimer/bindings/python/paulimer.pyi index cf797f81..f072dac0 100644 --- a/paulimer/bindings/python/paulimer.pyi +++ b/paulimer/bindings/python/paulimer.pyi @@ -533,6 +533,20 @@ class CliffordUnitary: """Get the symplectic matrix representation.""" ... + def to_transvections(self) -> list[SparsePauli]: + """Decompose into an ordered product of Clifford transvections (pi/4 Pauli exponents). + + Returns Pauli operators ``[P_1, ..., P_k]`` such that applying ``exp(i pi/4 P_1)``, then + ``exp(i pi/4 P_2)``, ..., then ``exp(i pi/4 P_k)`` reproduces this Clifford's symplectic + (conjugation) action, using a linear number of factors. Pauli-image signs and the global + phase are not reproduced. + """ + ... + + def centralizer(self) -> list[SparsePauli]: + """Generators of the centralizer: Paulis fixed up to sign under conjugation.""" + ... + def qubits(self) -> slice: """Return a slice representing the qubit indices.""" ... diff --git a/paulimer/bindings/python/src/py_clifford.rs b/paulimer/bindings/python/src/py_clifford.rs index 84434678..4ef6c88e 100644 --- a/paulimer/bindings/python/src/py_clifford.rs +++ b/paulimer/bindings/python/src/py_clifford.rs @@ -1,7 +1,7 @@ use derive_more::{Deref, DerefMut, From, Into}; use paulimer::clifford::{ - group_encoding_clifford_of, split_phased_css, split_qubit_cliffords_and_css, Clifford, CliffordMutable, - CliffordUnitary, XOrZ, + clifford_centralizer, clifford_to_transvections, group_encoding_clifford_of, split_phased_css, + split_qubit_cliffords_and_css, Clifford, CliffordMutable, CliffordUnitary, XOrZ, }; use paulimer::pauli::{as_sparse, DensePauli, SparsePauli}; use pyo3::exceptions::PyValueError; @@ -275,6 +275,23 @@ impl PyCliffordUnitary { self.inner.symplectic_matrix().into() } + /// Decomposes this Clifford into an ordered product of Clifford transvections (pi/4 Pauli + /// exponents), reproducing its symplectic action with a linear number of factors. + /// + /// Returns Pauli operators ``[P_1, ..., P_k]`` such that applying ``exp(i pi/4 P_1)``, then + /// ``exp(i pi/4 P_2)``, ..., then ``exp(i pi/4 P_k)`` reproduces the conjugation action of this + /// Clifford. Pauli-image signs and the global phase are not reproduced; see + /// :meth:`to_pauli_exponents` for the sign-exact (but ``O(n^2)``) decomposition. + fn to_transvections(&self) -> Vec { + clifford_to_transvections(&self.inner).into_iter().map(PySparsePauli::from).collect() + } + + /// Returns generators of this Clifford's centralizer: the Pauli operators fixed up to sign under + /// conjugation (``clifford * P * clifford_dagger == +/- P``). + fn centralizer(&self) -> Vec { + clifford_centralizer(&self.inner).into_iter().map(PySparsePauli::from).collect() + } + #[allow(clippy::needless_pass_by_value)] fn left_mul(&mut self, unitary_op: PyUnitaryOp, support: Vec) { self.inner.left_mul(unitary_op.into(), &support); diff --git a/paulimer/bindings/python/tests/transvection_test.py b/paulimer/bindings/python/tests/transvection_test.py new file mode 100644 index 00000000..a72ece10 --- /dev/null +++ b/paulimer/bindings/python/tests/transvection_test.py @@ -0,0 +1,120 @@ +"""Tests for the Clifford -> transvection decomposition bindings (arXiv:2102.11380). + +The decomposition reproduces a Clifford's symplectic (conjugation) action with a linear number of +pi/4 Pauli exponents, ignoring Pauli-image signs and the global phase. +""" + +from hypothesis import given, settings +from hypothesis import strategies as st + +from paulimer import CliffordUnitary, SparsePauli, UnitaryOpcode + + +def _rebuild_from_transvections(transvections, qubit_count): + rebuilt = CliffordUnitary.identity(qubit_count) + for pauli in transvections: + rebuilt.left_mul_pauli_exp(pauli) + return rebuilt + + +def _residue_rank(clifford): + return 2 * clifford.qubit_count - len(clifford.centralizer()) + + +def _is_conjugation_fixed(clifford, pauli): + image = SparsePauli.from_dense(clifford.image_of(pauli)) + return (image * pauli).weight == 0 + + +def _assert_valid_decomposition(clifford): + qubit_count = clifford.qubit_count + transvections = clifford.to_transvections() + + rebuilt = _rebuild_from_transvections(transvections, qubit_count) + assert rebuilt.symplectic_matrix == clifford.symplectic_matrix + + for pauli in transvections: + assert pauli.weight > 0 + + minimum = _residue_rank(clifford) + assert len(transvections) >= minimum + assert len(transvections) <= 4 * qubit_count + 2 + + +def test_identity_has_no_transvections(): + for qubit_count in range(5): + identity = CliffordUnitary.identity(qubit_count) + assert identity.to_transvections() == [] + assert len(identity.centralizer()) == 2 * qubit_count + + +def test_single_qubit_gate_lengths(): + s_gate = CliffordUnitary.from_name("SqrtZ", [0], 1) + _assert_valid_decomposition(s_gate) + assert len(s_gate.to_transvections()) == 1 + + hadamard = CliffordUnitary.from_name("Hadamard", [0], 1) + _assert_valid_decomposition(hadamard) + assert len(hadamard.to_transvections()) == 1 + + +def test_swap_hyperbolic_branch(): + swap = CliffordUnitary.from_name("Swap", [0, 1], 2) + _assert_valid_decomposition(swap) + assert _residue_rank(swap) == 2 + assert len(swap.to_transvections()) == 3 + assert len(swap.centralizer()) == 2 + + +def test_two_qubit_gates(): + for name in ("ControlledX", "ControlledZ"): + clifford = CliffordUnitary.from_name(name, [0, 1], 2) + _assert_valid_decomposition(clifford) + + +def test_centralizer_generators_are_conjugation_fixed(): + clifford = CliffordUnitary.identity(3) + clifford.left_mul(UnitaryOpcode.Hadamard, [0]) + clifford.left_mul(UnitaryOpcode.ControlledX, [0, 1]) + clifford.left_mul(UnitaryOpcode.SqrtZ, [2]) + centralizer = clifford.centralizer() + assert all(_is_conjugation_fixed(clifford, pauli) for pauli in centralizer) + assert all(pauli.weight > 0 for pauli in centralizer) + + +_SINGLE_QUBIT_GATES = ["Hadamard", "SqrtZ", "SqrtX", "X", "Y", "Z"] +_TWO_QUBIT_GATES = ["ControlledX", "ControlledZ", "Swap"] + + +@st.composite +def _random_clifford(draw, max_qubits=5): + qubit_count = draw(st.integers(min_value=1, max_value=max_qubits)) + gate_count = draw(st.integers(min_value=0, max_value=3 * qubit_count)) + clifford = CliffordUnitary.identity(qubit_count) + for _ in range(gate_count): + if qubit_count >= 2 and draw(st.booleans()): + name = draw(st.sampled_from(_TWO_QUBIT_GATES)) + first = draw(st.integers(min_value=0, max_value=qubit_count - 1)) + second = draw( + st.integers(min_value=0, max_value=qubit_count - 1).filter(lambda q: q != first) + ) + clifford.left_mul(getattr(UnitaryOpcode, name), [first, second]) + else: + name = draw(st.sampled_from(_SINGLE_QUBIT_GATES)) + qubit = draw(st.integers(min_value=0, max_value=qubit_count - 1)) + clifford.left_mul(getattr(UnitaryOpcode, name), [qubit]) + return clifford + + +@settings(max_examples=200) +@given(_random_clifford()) +def test_random_cliffords_reproduce_symplectic_action(clifford): + _assert_valid_decomposition(clifford) + + +@settings(max_examples=200) +@given(_random_clifford()) +def test_random_centralizers_are_conjugation_fixed(clifford): + for generator in clifford.centralizer(): + assert _is_conjugation_fixed(clifford, generator) + assert generator.weight > 0 diff --git a/paulimer/src/clifford.rs b/paulimer/src/clifford.rs index 5fa75826..bdc586ea 100644 --- a/paulimer/src/clifford.rs +++ b/paulimer/src/clifford.rs @@ -308,5 +308,8 @@ pub use clifford_impl::{ z_images_partition_transform, }; +mod transvection; +pub use transvection::{clifford_centralizer, clifford_to_transvections}; + #[derive(Debug, PartialEq, Eq, Default)] pub struct CliffordStringParsingError; diff --git a/paulimer/src/clifford/transvection.rs b/paulimer/src/clifford/transvection.rs new file mode 100644 index 00000000..60b00890 --- /dev/null +++ b/paulimer/src/clifford/transvection.rs @@ -0,0 +1,204 @@ +//! Minimal decomposition of Clifford unitaries into Clifford transvections (`π/4` Pauli exponents). +//! +//! A *Clifford transvection* is the `π/4` Pauli exponent `exp(iπ/4·P_v)`, whose conjugation action +//! on Pauli operators is the *symplectic transvection* +//! +//! ```text +//! x ↦ x + ⟨x, v⟩ v, +//! ``` +//! +//! where `⟨·,·⟩` is the symplectic (commutation) form. This module follows the transvection +//! framework of [arXiv:2102.11380](https://arxiv.org/abs/2102.11380) (Pllaha, Volanto & Tirkkonen, +//! *Decomposition of Clifford Gates*): every Clifford is a product of transvections, and the +//! *minimal* number of factors is `r = 2n − dim Fix(F)` (or `r + 1` when the symplectic action `F` +//! is hyperbolic), where `Fix(F)` is the space of Pauli operators fixed by conjugation. +//! +//! The decomposition here uses a greedy O'Meara-style reduction: it always produces a **linear +//! number of factors** (`O(n)`), reproducing the symplectic action exactly, but it is **not +//! guaranteed to hit the strict `r`/`r + 1` minimum** — intermediate maps can become hyperbolic, +//! adding an occasional extra factor. In practice it stays within a small additive constant of the +//! minimum. The strict-minimum variant (via the paper's congruence-triangulation machinery) is +//! tracked as a follow-up. +//! +//! Unlike [`clifford_to_pauli_exponents`](super::clifford_to_pauli_exponents), which reproduces the +//! full signed tableau (and hence an exact global phase when replayed on a phased operator), this +//! decomposition reproduces only the **symplectic action** — it ignores Pauli-image signs and the +//! global phase. Its advantage is the linear factor count `O(n)`, versus `O(n²)` for the +//! Gaussian-elimination decomposition. + +use binar::matrix::{kernel_basis_matrix, AlignedBitMatrix}; +use binar::{Bitwise, IndexSet}; + +use crate::clifford::{Clifford, CliffordMutable, CliffordUnitary}; +use crate::pauli::DensePauli; +use crate::{anti_commutes_with, Pauli, PauliBinaryOps, PauliMutable, SparsePauli}; + +/// Decomposes `clifford` into an ordered product of Clifford transvections. +/// +/// Returns a list of Pauli operators `[P₁, …, P_k]` such that left-multiplying the identity by the +/// transvections `exp(iπ/4·P₁)`, then `exp(iπ/4·P₂)`, …, then `exp(iπ/4·P_k)` reproduces the +/// **symplectic action** of `clifford` (its conjugation map on Pauli operators). The Pauli-image +/// signs and the global phase are *not* reproduced; see the module docs for the contrast with +/// [`clifford_to_pauli_exponents`](super::clifford_to_pauli_exponents). +/// +/// The number of factors is **linear** in the qubit count (`O(n)`). It is close to, but not +/// guaranteed to equal, the strict minimum `r = 2n − dim Fix(clifford)` (`r + 1` when the +/// symplectic action is hyperbolic) of [arXiv:2102.11380](https://arxiv.org/abs/2102.11380); the +/// greedy reduction here can add an occasional extra factor when an intermediate map becomes +/// hyperbolic. The count is always at least `r`. +/// +/// Every factor is returned with phase exponent `0`; the sign of a transvection does not affect its +/// symplectic action, so `exp(iπ/4·P)` and `exp(−iπ/4·P)` are interchangeable here. +/// +/// # Examples +/// +/// ``` +/// use paulimer::CliffordUnitary; +/// use paulimer::clifford::{clifford_to_transvections, Clifford, CliffordMutable}; +/// +/// let mut clifford = CliffordUnitary::identity(2); +/// clifford.left_mul_hadamard(0); +/// clifford.left_mul_cx(0, 1); +/// +/// let transvections = clifford_to_transvections(&clifford); +/// +/// let mut rebuilt = CliffordUnitary::identity(2); +/// for pauli in &transvections { +/// rebuilt.left_mul_pauli_exp(pauli); +/// } +/// // The symplectic actions agree (signs and global phase may differ). +/// assert_eq!(rebuilt.symplectic_matrix(), clifford.symplectic_matrix()); +/// ``` +#[must_use] +pub fn clifford_to_transvections(clifford: &CliffordUnitary) -> Vec { + let qubit_count = clifford.num_qubits(); + let mut working = clifford.clone(); + let mut recorded = Vec::new(); + // Reduce the symplectic action to the identity by left-multiplying transvections `T_{v₁}, …, + // T_{v_k}`, so that `T_{v_k} ⋯ T_{v₁} · F = I` and hence `F = T_{v₁} ⋯ T_{v_k}`. Replaying the + // factors in reverse order rebuilds `F` from the identity. + while let Some(transvection) = next_transvection(&working, qubit_count) { + working.left_mul_pauli_exp(&transvection); + recorded.push(transvection); + debug_assert!( + recorded.len() <= 4 * qubit_count + 2, + "transvection reduction exceeded its linear termination bound" + ); + } + recorded.reverse(); + recorded +} + +/// Returns generators of the Clifford's centralizer: the Pauli operators fixed (up to sign) by +/// conjugation, i.e. the `P` with `clifford · P · clifford† = ±P`. +/// +/// This is `Fix(F)`, the kernel of the residue map `P ↦ conj(P) · P`, computed as the left null +/// space of the residue matrix over GF(2). The returned Paulis are independent generators (with +/// phase exponent `0`); the centralizer they span has dimension `dim Fix(F) = 2n − r`, where `r` is +/// the number of factors returned by [`clifford_to_transvections`] for a non-hyperbolic action. +/// +/// # Examples +/// +/// ``` +/// use paulimer::{CliffordUnitary, Pauli}; +/// use paulimer::clifford::{clifford_centralizer, Clifford, CliffordMutable}; +/// +/// let mut clifford = CliffordUnitary::identity(1); +/// clifford.left_mul_root_z(0); // S fixes Z, sends X -> Y +/// +/// let generators = clifford_centralizer(&clifford); +/// // Every generator is fixed (up to sign) under conjugation. +/// assert!(generators.iter().all(|pauli| { +/// let image = clifford.image(pauli); +/// image.x_bits() == pauli.x_bits() && image.z_bits() == pauli.z_bits() +/// })); +/// ``` +#[must_use] +pub fn clifford_centralizer(clifford: &CliffordUnitary) -> Vec { + let qubit_count = clifford.num_qubits(); + let dimension = 2 * qubit_count; + let mut residue = AlignedBitMatrix::zeros(dimension, dimension); + for (row, basis) in symplectic_basis(qubit_count).enumerate() { + let vector = residue_vector(&basis, &clifford.image(&basis)); + for qubit in 0..qubit_count { + if vector.x_bits().index(qubit) { + residue.set((row, qubit), true); + } + if vector.z_bits().index(qubit) { + residue.set((row, qubit_count + qubit), true); + } + } + } + let kernel = kernel_basis_matrix(&residue.transposed()); + (0..kernel.row_count()) + .map(|row| { + let x_bits: IndexSet = (0..qubit_count).filter(|&qubit| kernel[(row, qubit)]).collect(); + let z_bits: IndexSet = + (0..qubit_count).filter(|&qubit| kernel[(row, qubit_count + qubit)]).collect(); + SparsePauli::from_bits(x_bits, z_bits, 0) + }) + .collect() +} + +/// The `2n` standard basis Pauli operators `X₀, …, X_{n−1}, Z₀, …, Z_{n−1}`. +fn symplectic_basis(qubit_count: usize) -> impl Iterator { + (0..qubit_count) + .map(move |qubit| SparsePauli::x(qubit, qubit_count)) + .chain((0..qubit_count).map(move |qubit| SparsePauli::z(qubit, qubit_count))) +} + +/// The next transvection `T_v` reducing the residue of `working`, or `None` if `working` already +/// acts as the identity on Pauli operators (up to sign). +/// +/// Following the O'Meara strategy of [arXiv:2102.11380](https://arxiv.org/abs/2102.11380): find a +/// vector `x` with `⟨x, conj(x)⟩ = 1` (`x` anticommutes with its own image) and set `v = x + conj(x)` +/// — a residue vector — which lowers the residue rank by one. If no such `x` exists but `working` +/// is non-trivial (the hyperbolic case), any nonzero residue vector `v` makes the action +/// non-hyperbolic while preserving the residue space, costing one extra transvection. +fn next_transvection(working: &CliffordUnitary, qubit_count: usize) -> Option { + let basis: Vec = symplectic_basis(qubit_count).collect(); + let images: Vec = basis.iter().map(|pauli| working.image(pauli)).collect(); + + for (pauli, image) in basis.iter().zip(&images) { + if anti_commutes_with(pauli, image) { + return Some(residue_vector(pauli, image)); + } + } + + let dimension = basis.len(); + for first in 0..dimension { + for second in (first + 1)..dimension { + let anticommuting = anti_commutes_with(&basis[first], &images[second]) + ^ anti_commutes_with(&basis[second], &images[first]); + if anticommuting { + let mut sum = basis[first].clone(); + sum.mul_assign_left(&basis[second]); + let mut image = images[first].clone(); + image.mul_assign_left(&images[second]); + return Some(residue_vector(&sum, &image)); + } + } + } + + basis + .iter() + .zip(&images) + .find(|(pauli, image)| !acts_trivially_on(pauli, image)) + .map(|(pauli, image)| residue_vector(pauli, image)) +} + +/// The residue vector `v = x + conj(x)` as a phaseless Pauli (its symplectic vector is the product +/// `x · conj(x)`). +fn residue_vector(pauli: &SparsePauli, image: &DensePauli) -> SparsePauli { + let mut vector: SparsePauli = image.clone().into(); + vector.mul_assign_left(pauli); + vector.assign_phase_exp(0); + vector +} + +/// Whether `image` equals `pauli` as a symplectic vector (i.e. conjugation fixes `pauli` up to sign). +fn acts_trivially_on(pauli: &SparsePauli, image: &DensePauli) -> bool { + let mut difference: SparsePauli = image.clone().into(); + difference.mul_assign_left(pauli); + difference.x_bits().is_zero() && difference.z_bits().is_zero() +} diff --git a/paulimer/tests/transvection_test.rs b/paulimer/tests/transvection_test.rs new file mode 100644 index 00000000..0e38dba7 --- /dev/null +++ b/paulimer/tests/transvection_test.rs @@ -0,0 +1,222 @@ +//! Tests for the Clifford -> transvection decomposition (arXiv:2102.11380). +//! +//! The decomposition reproduces the *symplectic action* (ignoring Pauli-image signs and the global +//! phase) with a linear number of factors. It is not guaranteed to hit the strict `r`/`r + 1` +//! minimum, so these tests validate the symplectic-action round trip, the linear factor bound, and +//! the centralizer contract, rather than exact minimality. + +use binar::Bitwise; +use paulimer::clifford::{ + clifford_centralizer, clifford_to_transvections, Clifford, CliffordMutable, CliffordUnitary, +}; +use paulimer::pauli::{Pauli, SparsePauli}; +use proptest::prelude::*; +use rand::rngs::StdRng; +use rand::SeedableRng; + +/// Rebuilds a Clifford's symplectic action by replaying transvections on the identity. +fn symplectic_action_from_transvections( + transvections: &[SparsePauli], + qubit_count: usize, +) -> CliffordUnitary { + let mut rebuilt = CliffordUnitary::identity(qubit_count); + for transvection in transvections { + rebuilt.left_mul_pauli_exp(transvection); + } + rebuilt +} + +/// Whether conjugation by `clifford` fixes `pauli` as a symplectic vector (ignoring sign). +fn is_conjugation_fixed(clifford: &CliffordUnitary, pauli: &SparsePauli) -> bool { + let image = clifford.image(pauli); + image.x_bits() == pauli.x_bits() && image.z_bits() == pauli.z_bits() +} + +fn is_non_identity(pauli: &SparsePauli) -> bool { + !(pauli.x_bits().is_zero() && pauli.z_bits().is_zero()) +} + +/// The strict minimal factor count `r = 2n - dim Fix(F)`; the greedy decomposition returns `r` or a +/// little more. +fn residue_rank(clifford: &CliffordUnitary) -> usize { + 2 * clifford.num_qubits() - clifford_centralizer(clifford).len() +} + +fn assert_valid_decomposition(clifford: &CliffordUnitary) { + let qubit_count = clifford.num_qubits(); + let transvections = clifford_to_transvections(clifford); + + let rebuilt = symplectic_action_from_transvections(&transvections, qubit_count); + assert_eq!( + rebuilt.symplectic_matrix(), + clifford.symplectic_matrix(), + "replayed transvections must reproduce the symplectic action" + ); + + for transvection in &transvections { + assert_eq!(transvection.xz_phase_exponent(), 0, "factors carry no phase"); + assert!(is_non_identity(transvection), "factors are non-identity Paulis"); + } + + let minimum = residue_rank(clifford); + assert!( + transvections.len() >= minimum, + "a decomposition cannot be shorter than the minimum {minimum}, got {}", + transvections.len() + ); + assert!( + transvections.len() <= 4 * qubit_count + 2, + "the decomposition must be linear in the qubit count, got {}", + transvections.len() + ); +} + +#[test] +fn identity_decomposes_to_no_transvections() { + for qubit_count in 0..5 { + let identity = CliffordUnitary::identity(qubit_count); + let transvections = clifford_to_transvections(&identity); + assert!( + transvections.is_empty(), + "identity has no transvections (qubit_count {qubit_count})" + ); + let centralizer = clifford_centralizer(&identity); + assert_eq!( + centralizer.len(), + 2 * qubit_count, + "identity commutes with all {qubit_count} Pauli generators" + ); + } +} + +#[test] +fn single_qubit_gates_reproduce_symplectic_action() { + let mut s_gate = CliffordUnitary::identity(1); + s_gate.left_mul_root_z(0); + assert_valid_decomposition(&s_gate); + assert_eq!(clifford_to_transvections(&s_gate).len(), 1, "S is one transvection T_Z"); + + let mut hadamard = CliffordUnitary::identity(1); + hadamard.left_mul_hadamard(0); + assert_valid_decomposition(&hadamard); + assert_eq!(clifford_to_transvections(&hadamard).len(), 1, "H is the transvection T_Y"); +} + +#[test] +fn pauli_gates_are_conjugation_trivial() { + // Pauli operators act trivially by conjugation (sign-only), so their symplectic action is the + // identity and no transvections are needed. + for axis in 0..3 { + let mut clifford = CliffordUnitary::identity(1); + match axis { + 0 => clifford.left_mul_pauli(&SparsePauli::x(0, 1)), + 1 => clifford.left_mul_pauli(&SparsePauli::z(0, 1)), + _ => clifford.left_mul_pauli(&SparsePauli::y(0, 1)), + } + assert!(clifford_to_transvections(&clifford).is_empty(), "Pauli axis {axis} needs no factor"); + assert_eq!(clifford_centralizer(&clifford).len(), 2, "a Pauli commutes with all generators"); + } +} + +#[test] +fn swap_exercises_the_hyperbolic_branch() { + // SWAP is hyperbolic (its residue space is totally isotropic), so the greedy reduction returns + // r + 1 = 3 transvections, where r = 2n - dim Fix = 4 - 2 = 2. + let mut swap = CliffordUnitary::identity(2); + swap.left_mul_swap(0, 1); + assert_valid_decomposition(&swap); + assert_eq!(residue_rank(&swap), 2); + assert_eq!(clifford_to_transvections(&swap).len(), 3); + assert_eq!(clifford_centralizer(&swap).len(), 2); +} + +#[test] +fn two_qubit_gates_reproduce_symplectic_action() { + let mut cx = CliffordUnitary::identity(2); + cx.left_mul_cx(0, 1); + assert_valid_decomposition(&cx); + + let mut cz = CliffordUnitary::identity(2); + cz.left_mul_cz(0, 1); + assert_valid_decomposition(&cz); +} + +#[test] +fn composite_circuit_reproduces_symplectic_action() { + let mut clifford = CliffordUnitary::identity(4); + clifford.left_mul_hadamard(0); + clifford.left_mul_cx(0, 1); + clifford.left_mul_root_z(2); + clifford.left_mul_cz(1, 3); + clifford.left_mul_swap(2, 3); + clifford.left_mul_hadamard(3); + assert_valid_decomposition(&clifford); +} + +#[test] +fn centralizer_generators_are_conjugation_fixed_and_independent() { + let mut clifford = CliffordUnitary::identity(3); + clifford.left_mul_hadamard(0); + clifford.left_mul_cx(0, 1); + clifford.left_mul_root_z(2); + + let centralizer = clifford_centralizer(&clifford); + assert!(centralizer.iter().all(|pauli| is_conjugation_fixed(&clifford, pauli))); + assert!(centralizer.iter().all(is_non_identity)); + assert_eq!( + centralizer.len(), + 2 * clifford.num_qubits() - residue_rank(&clifford), + "the centralizer dimension is 2n - r" + ); +} + +fn random_clifford(qubit_count: usize, seed: u64) -> CliffordUnitary { + let mut random_number_generator = StdRng::seed_from_u64(seed); + CliffordUnitary::random(qubit_count, &mut random_number_generator) +} + +#[test] +fn many_random_cliffords_reproduce_symplectic_action() { + // A deterministic sweep giving broad coverage independent of the proptest shrink budget. + for qubit_count in 0..7 { + for seed in 0..200 { + assert_valid_decomposition(&random_clifford(qubit_count, seed)); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn reproduces_symplectic_action(qubit_count in 0usize..7, seed in any::()) { + let clifford = random_clifford(qubit_count, seed); + let transvections = clifford_to_transvections(&clifford); + let rebuilt = symplectic_action_from_transvections(&transvections, qubit_count); + prop_assert_eq!(rebuilt.symplectic_matrix(), clifford.symplectic_matrix()); + } + + #[test] + fn decomposition_is_linear_and_no_shorter_than_minimum(qubit_count in 0usize..7, seed in any::()) { + let clifford = random_clifford(qubit_count, seed); + let transvection_count = clifford_to_transvections(&clifford).len(); + let minimum = residue_rank(&clifford); + prop_assert!( + transvection_count >= minimum, + "got {transvection_count} factors, below the minimum {minimum}" + ); + prop_assert!( + transvection_count <= 4 * qubit_count + 2, + "got {transvection_count} factors, above the linear bound" + ); + } + + #[test] + fn centralizer_is_conjugation_fixed(qubit_count in 0usize..7, seed in any::()) { + let clifford = random_clifford(qubit_count, seed); + for generator in clifford_centralizer(&clifford) { + prop_assert!(is_conjugation_fixed(&clifford, &generator)); + prop_assert!(is_non_identity(&generator), "centralizer generators must be non-identity"); + } + } +} From 460e9236a227bd0201009c34d558fb986e035520 Mon Sep 17 00:00:00 2001 From: Marcus P S Date: Sat, 18 Jul 2026 15:15:06 -0700 Subject: [PATCH 2/3] style(paulimer): apply cargo fmt to transvection decomposition files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- paulimer/bindings/python/src/py_clifford.rs | 10 ++++++-- paulimer/src/clifford/transvection.rs | 13 +++++----- paulimer/tests/transvection_test.rs | 28 +++++++++++++-------- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/paulimer/bindings/python/src/py_clifford.rs b/paulimer/bindings/python/src/py_clifford.rs index 4ef6c88e..cf268455 100644 --- a/paulimer/bindings/python/src/py_clifford.rs +++ b/paulimer/bindings/python/src/py_clifford.rs @@ -283,13 +283,19 @@ impl PyCliffordUnitary { /// Clifford. Pauli-image signs and the global phase are not reproduced; see /// :meth:`to_pauli_exponents` for the sign-exact (but ``O(n^2)``) decomposition. fn to_transvections(&self) -> Vec { - clifford_to_transvections(&self.inner).into_iter().map(PySparsePauli::from).collect() + clifford_to_transvections(&self.inner) + .into_iter() + .map(PySparsePauli::from) + .collect() } /// Returns generators of this Clifford's centralizer: the Pauli operators fixed up to sign under /// conjugation (``clifford * P * clifford_dagger == +/- P``). fn centralizer(&self) -> Vec { - clifford_centralizer(&self.inner).into_iter().map(PySparsePauli::from).collect() + clifford_centralizer(&self.inner) + .into_iter() + .map(PySparsePauli::from) + .collect() } #[allow(clippy::needless_pass_by_value)] diff --git a/paulimer/src/clifford/transvection.rs b/paulimer/src/clifford/transvection.rs index 60b00890..40f73de9 100644 --- a/paulimer/src/clifford/transvection.rs +++ b/paulimer/src/clifford/transvection.rs @@ -26,12 +26,12 @@ //! global phase. Its advantage is the linear factor count `O(n)`, versus `O(n²)` for the //! Gaussian-elimination decomposition. -use binar::matrix::{kernel_basis_matrix, AlignedBitMatrix}; +use binar::matrix::{AlignedBitMatrix, kernel_basis_matrix}; use binar::{Bitwise, IndexSet}; use crate::clifford::{Clifford, CliffordMutable, CliffordUnitary}; use crate::pauli::DensePauli; -use crate::{anti_commutes_with, Pauli, PauliBinaryOps, PauliMutable, SparsePauli}; +use crate::{Pauli, PauliBinaryOps, PauliMutable, SparsePauli, anti_commutes_with}; /// Decomposes `clifford` into an ordered product of Clifford transvections. /// @@ -133,8 +133,9 @@ pub fn clifford_centralizer(clifford: &CliffordUnitary) -> Vec { (0..kernel.row_count()) .map(|row| { let x_bits: IndexSet = (0..qubit_count).filter(|&qubit| kernel[(row, qubit)]).collect(); - let z_bits: IndexSet = - (0..qubit_count).filter(|&qubit| kernel[(row, qubit_count + qubit)]).collect(); + let z_bits: IndexSet = (0..qubit_count) + .filter(|&qubit| kernel[(row, qubit_count + qubit)]) + .collect(); SparsePauli::from_bits(x_bits, z_bits, 0) }) .collect() @@ -168,8 +169,8 @@ fn next_transvection(working: &CliffordUnitary, qubit_count: usize) -> Option CliffordUnitary { +fn symplectic_action_from_transvections(transvections: &[SparsePauli], qubit_count: usize) -> CliffordUnitary { let mut rebuilt = CliffordUnitary::identity(qubit_count); for transvection in transvections { rebuilt.left_mul_pauli_exp(transvection); @@ -99,7 +94,11 @@ fn single_qubit_gates_reproduce_symplectic_action() { let mut hadamard = CliffordUnitary::identity(1); hadamard.left_mul_hadamard(0); assert_valid_decomposition(&hadamard); - assert_eq!(clifford_to_transvections(&hadamard).len(), 1, "H is the transvection T_Y"); + assert_eq!( + clifford_to_transvections(&hadamard).len(), + 1, + "H is the transvection T_Y" + ); } #[test] @@ -113,8 +112,15 @@ fn pauli_gates_are_conjugation_trivial() { 1 => clifford.left_mul_pauli(&SparsePauli::z(0, 1)), _ => clifford.left_mul_pauli(&SparsePauli::y(0, 1)), } - assert!(clifford_to_transvections(&clifford).is_empty(), "Pauli axis {axis} needs no factor"); - assert_eq!(clifford_centralizer(&clifford).len(), 2, "a Pauli commutes with all generators"); + assert!( + clifford_to_transvections(&clifford).is_empty(), + "Pauli axis {axis} needs no factor" + ); + assert_eq!( + clifford_centralizer(&clifford).len(), + 2, + "a Pauli commutes with all generators" + ); } } From 79c66e3892ae5736ae3c5d6ff4f646ac3660dfeb Mon Sep 17 00:00:00 2001 From: "Marcus P da Silva (MSFT)" Date: Fri, 24 Jul 2026 16:25:34 -0700 Subject: [PATCH 3/3] Rewrite transvection proptests with a Gate strategy; derive qubit count Apply the conventions established while addressing @jmbr's review on the phased-outcome / decomposition stack (PRs #115/#116) to the transvection decomposition, anticipating the same feedback here: - Replace the seed + internal-RNG proptests (`seed in any::()` feeding `CliffordUnitary::random`) with a `Gate`-sequence `Strategy`, mirroring phased_clifford_dense.rs and measure_with_hint_sign_test.rs, so a failing input shrinks to a minimal gate sequence instead of an opaque seed. The deterministic sweep (which needs no shrinking) keeps using a seeded RNG. - Derive `qubit_count` inside `next_transvection` from `working.num_qubits()` instead of threading it as a parameter, matching the earlier "derive qubit count" tidy on the Gaussian-elimination decomposition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16f923fe-a72a-4430-97b0-b7677f48dfb7 --- paulimer/src/clifford/transvection.rs | 5 ++- paulimer/tests/transvection_test.rs | 63 ++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/paulimer/src/clifford/transvection.rs b/paulimer/src/clifford/transvection.rs index 40f73de9..77633f33 100644 --- a/paulimer/src/clifford/transvection.rs +++ b/paulimer/src/clifford/transvection.rs @@ -77,7 +77,7 @@ pub fn clifford_to_transvections(clifford: &CliffordUnitary) -> Vec // Reduce the symplectic action to the identity by left-multiplying transvections `T_{v₁}, …, // T_{v_k}`, so that `T_{v_k} ⋯ T_{v₁} · F = I` and hence `F = T_{v₁} ⋯ T_{v_k}`. Replaying the // factors in reverse order rebuilds `F` from the identity. - while let Some(transvection) = next_transvection(&working, qubit_count) { + while let Some(transvection) = next_transvection(&working) { working.left_mul_pauli_exp(&transvection); recorded.push(transvection); debug_assert!( @@ -156,7 +156,8 @@ fn symplectic_basis(qubit_count: usize) -> impl Iterator { /// — a residue vector — which lowers the residue rank by one. If no such `x` exists but `working` /// is non-trivial (the hyperbolic case), any nonzero residue vector `v` makes the action /// non-hyperbolic while preserving the residue space, costing one extra transvection. -fn next_transvection(working: &CliffordUnitary, qubit_count: usize) -> Option { +fn next_transvection(working: &CliffordUnitary) -> Option { + let qubit_count = working.num_qubits(); let basis: Vec = symplectic_basis(qubit_count).collect(); let images: Vec = basis.iter().map(|pauli| working.image(pauli)).collect(); diff --git a/paulimer/tests/transvection_test.rs b/paulimer/tests/transvection_test.rs index aa08989c..7551c3bf 100644 --- a/paulimer/tests/transvection_test.rs +++ b/paulimer/tests/transvection_test.rs @@ -6,8 +6,10 @@ //! the centralizer contract, rather than exact minimality. use binar::Bitwise; +use paulimer::UnitaryOp; use paulimer::clifford::{Clifford, CliffordMutable, CliffordUnitary, clifford_centralizer, clifford_to_transvections}; use paulimer::pauli::{Pauli, SparsePauli}; +use proptest::collection::vec; use proptest::prelude::*; use rand::SeedableRng; use rand::rngs::StdRng; @@ -191,20 +193,69 @@ fn many_random_cliffords_reproduce_symplectic_action() { } } +/// A single Clifford generator, modeled as an operation so proptest can shrink a failing input down +/// to a minimal gate sequence (unlike an opaque RNG seed). +#[derive(Clone, Debug)] +enum Gate { + Single { op: UnitaryOp, qubit: usize }, + Two { op: UnitaryOp, first: usize, second: usize }, +} + +fn distinct_pair(qubit_count: usize) -> impl Strategy { + (0..qubit_count, 0..qubit_count - 1) + .prop_map(|(first, second)| (first, if second < first { second } else { second + 1 })) +} + +fn gate_strategy(qubit_count: usize) -> BoxedStrategy { + use UnitaryOp::{ControlledX, ControlledZ, Hadamard, SqrtX, SqrtZ, Swap, X, Y, Z}; + let single = ( + prop::sample::select(vec![Hadamard, SqrtZ, SqrtX, X, Y, Z]), + 0..qubit_count, + ) + .prop_map(|(op, qubit)| Gate::Single { op, qubit }); + if qubit_count < 2 { + return single.boxed(); + } + let two = ( + prop::sample::select(vec![ControlledX, ControlledZ, Swap]), + distinct_pair(qubit_count), + ) + .prop_map(|(op, (first, second))| Gate::Two { op, first, second }); + prop_oneof![3 => single, 1 => two].boxed() +} + +fn clifford_from_gates(qubit_count: usize, gates: &[Gate]) -> CliffordUnitary { + let mut clifford = CliffordUnitary::identity(qubit_count); + for gate in gates { + match *gate { + Gate::Single { op, qubit } => clifford.left_mul(op, &[qubit]), + Gate::Two { op, first, second } => clifford.left_mul(op, &[first, second]), + } + } + clifford +} + +/// A qubit count paired with a random gate sequence acting on it. +fn scenario() -> impl Strategy)> { + (1usize..7).prop_flat_map(|qubit_count| { + vec(gate_strategy(qubit_count), 0..=3 * qubit_count).prop_map(move |gates| (qubit_count, gates)) + }) +} + proptest! { #![proptest_config(ProptestConfig::with_cases(512))] #[test] - fn reproduces_symplectic_action(qubit_count in 0usize..7, seed in any::()) { - let clifford = random_clifford(qubit_count, seed); + fn reproduces_symplectic_action((qubit_count, gates) in scenario()) { + let clifford = clifford_from_gates(qubit_count, &gates); let transvections = clifford_to_transvections(&clifford); let rebuilt = symplectic_action_from_transvections(&transvections, qubit_count); prop_assert_eq!(rebuilt.symplectic_matrix(), clifford.symplectic_matrix()); } #[test] - fn decomposition_is_linear_and_no_shorter_than_minimum(qubit_count in 0usize..7, seed in any::()) { - let clifford = random_clifford(qubit_count, seed); + fn decomposition_is_linear_and_no_shorter_than_minimum((qubit_count, gates) in scenario()) { + let clifford = clifford_from_gates(qubit_count, &gates); let transvection_count = clifford_to_transvections(&clifford).len(); let minimum = residue_rank(&clifford); prop_assert!( @@ -218,8 +269,8 @@ proptest! { } #[test] - fn centralizer_is_conjugation_fixed(qubit_count in 0usize..7, seed in any::()) { - let clifford = random_clifford(qubit_count, seed); + fn centralizer_is_conjugation_fixed((qubit_count, gates) in scenario()) { + let clifford = clifford_from_gates(qubit_count, &gates); for generator in clifford_centralizer(&clifford) { prop_assert!(is_conjugation_fixed(&clifford, &generator)); prop_assert!(is_non_identity(&generator), "centralizer generators must be non-identity");