open-standards/wai/wai-quantum/src/quantum_spd.rs
Transaction Science d4cdbb7da2
All checks were successful
wai-quantum WebGPU member / member · DX12 and Vulkan (cbio-windows) (pull_request) Successful in 13m13s
wai-quantum on every processor / emulated · aarch64-unknown-linux-gnu (pull_request) Successful in 2m55s
wai-quantum on every processor / emulated · armv7-unknown-linux-gnueabihf (pull_request) Successful in 2m47s
wai-quantum on every processor / emulated · loongarch64-unknown-linux-gnu (pull_request) Successful in 3m31s
wai-quantum on every processor / emulated · powerpc64-unknown-linux-gnu (pull_request) Successful in 4m31s
wai-quantum on every processor / emulated · powerpc64le-unknown-linux-gnu (pull_request) Successful in 6m34s
wai-quantum on every processor / emulated · s390x-unknown-linux-gnu (pull_request) Successful in 4m26s
wai-quantum on every processor / emulated · riscv64gc-unknown-linux-gnu (pull_request) Successful in 6m32s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 6s
wai-quantum on every processor / emulated · i686-unknown-linux-gnu (pull_request) Successful in 1m51s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 2m49s
wai-quantum on every processor / emulated · sparc64-unknown-linux-gnu (pull_request) Successful in 7m6s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 5m32s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 2m54s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 7s
wai-audit collector and player integration / does this change touch it (pull_request) Successful in 6s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (pull_request) Successful in 6s
WAI operation catalogue / does this change touch it (pull_request) Successful in 7s
WAI conformance / does this change touch it (pull_request) Successful in 7s
WAI energy receipts / does this change touch it (pull_request) Successful in 8s
WAI examples / WAI examples · does this change touch them (pull_request) Successful in 8s
wai-quantum command line / does this change touch it (pull_request) Successful in 9s
wai-quantum contract / does this change touch it (pull_request) Successful in 8s
wai-quantum CUDA member / does this change touch it (pull_request) Successful in 7s
wai-quantum dataflow member / does this change touch it (pull_request) Successful in 7s
wai-quantum docs, course and locales / does this change touch it (pull_request) Successful in 8s
wai-quantum signal processor / does this change touch it (pull_request) Successful in 7s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 35s
wai-quantum editors / does this change touch it (pull_request) Successful in 7s
wai-quantum resource estimation / does this change touch it (pull_request) Successful in 8s
wai-audit collector and player integration / collector · linux (pull_request) Successful in 1m14s
wai-quantum FPGA member / does this change touch it (pull_request) Successful in 9s
wai-quantum other formats / does this change touch it (pull_request) Successful in 10s
wai-quantum HIP member / does this change touch it (pull_request) Successful in 7s
wai-quantum for JavaScript / does this change touch it (pull_request) Successful in 7s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (pull_request) Successful in 1m30s
wai-quantum OpenQASM reader / does this change touch it (pull_request) Successful in 10s
wai-quantum Metal member / does this change touch it (pull_request) Successful in 10s
wai-quantum npu-cores member / does this change touch it (pull_request) Successful in 7s
wai-quantum neural-processor member / does this change touch it (pull_request) Successful in 8s
wai-quantum OpenCL member / does this change touch it (pull_request) Successful in 8s
wai-quantum pulse and dynamics / does this change touch it (pull_request) Successful in 9s
wai-quantum for Python / does this change touch it (pull_request) Successful in 7s
wai-quantum error correction and mitigation / does this change touch it (pull_request) Successful in 7s
wai-quantum-qpu / does this change touch it (pull_request) Successful in 7s
wai-quantum service / does this change touch it (pull_request) Successful in 7s
wai-quantum matrix-extension member / does this change touch it (pull_request) Successful in 8s
wai-quantum studio and desktop / does this change touch it (pull_request) Successful in 7s
wai-quantum tensor-engine member / does this change touch it (pull_request) Successful in 9s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (pull_request) Successful in 2m20s
wai-quantum portable tensor program / does this change touch it (pull_request) Successful in 9s
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Successful in 1m31s
wai-quantum variational and algorithm library / does this change touch it (pull_request) Successful in 7s
wai-quantum CPU vector units / does this change touch it (pull_request) Successful in 7s
wai-quantum verdicts and repairs / does this change touch it (pull_request) Successful in 8s
wai-quantum views in agent hosts / does this change touch it (pull_request) Successful in 7s
wai-quantum CUDA member / member · CUDA (jetson-hub) (pull_request) Successful in 1m56s
wai-quantum WebGPU member / does this change touch it (pull_request) Successful in 8s
wai-sink-android native sink library / does this change touch it (pull_request) Successful in 7s
wai-quantum CUDA member / member · CUDA (thor) (pull_request) Successful in 2m6s
wai-sink-swift native sink package / does this change touch it (pull_request) Successful in 7s
WAI Studio / does this change touch it (pull_request) Successful in 7s
wai-web wasm32 / does this change touch it (pull_request) Successful in 7s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (pull_request) Successful in 3m20s
wai-quantum OpenCL member / member · OpenCL GPU and CPU (jetson-hub) (pull_request) Successful in 43s
wai-quantum OpenCL member / member · OpenCL GPU and CPU (thor) (pull_request) Successful in 51s
WAI conformance / fast integer kernels, every x86_64 kernel set (AVX-512 silicon) (pull_request) Successful in 3m42s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 4m31s
wai-quantum service / service energy · linux packages (pull_request) Successful in 1m54s
wai-quantum HIP member / member · HIP (framework-128gb) (pull_request) Successful in 1m20s
WAI conformance / faces from keypoints under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m58s
wai-quantum portable tensor program / tensor program · GPUs (jetson-hub) (pull_request) Successful in 30s
wai-quantum command line / command line · macos (pull_request) Successful in 1m35s
WAI operation catalogue / the catalogue and every door · linux (pull_request) Successful in 5m43s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 34s
wai-quantum dataflow member / member · dataflow device absent (macos) (pull_request) Successful in 21s
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 4m5s
wai-quantum npu-cores member / member · npu-cores, neural processor (framework-128gb silicon) (pull_request) Successful in 1m42s
wai-quantum OpenCL member / member · OpenCL GPU (framework-128gb) (pull_request) Successful in 34s
wai-quantum editors / editors · macos (pull_request) Successful in 1m22s
wai-quantum portable tensor program / tensor program · integrated GPU (framework-128gb) (pull_request) Successful in 16s
wai-quantum CPU vector units / member · vector units, avx2 and avx512 (framework-128gb silicon) (pull_request) Successful in 22s
wai-quantum FPGA member / member · FPGA datapath, simulated (macos) (pull_request) Successful in 55s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 7m32s
wai-quantum OpenCL member / member · OpenCL GPU (macos) (pull_request) Successful in 23s
wai-quantum command line / command line · linux (pull_request) Successful in 3m3s
wai-quantum pulse and dynamics / the same bits · macos (pull_request) Successful in 53s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 8m15s
wai-quantum for Python / python · macos (pull_request) Successful in 1m55s
wai-quantum studio and desktop / desktop app · windows (pull_request) Successful in 10m1s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 21s
wai-quantum-qpu / credentials in the keychain (macos) (pull_request) Successful in 21s
wai-quantum neural-processor member / member · neural processor (macos) (pull_request) Successful in 4m26s
wai-quantum contract / API compatibility · linux (pull_request) Successful in 5m33s
wai-quantum service / service · macos (pull_request) Successful in 3m43s
wai-quantum studio and desktop / desktop app · macos (pull_request) Successful in 3m21s
wai-quantum portable tensor program / tensor program · Metal and the arm64 CPU (macos) (pull_request) Successful in 15s
wai-quantum CPU vector units / member · vector unit absent (macos) (pull_request) Successful in 15s
WAI examples / WAI examples · portable · linux (pull_request) Successful in 11m48s
wai-quantum contract / the policy is linked, and the deprecations page reads (pull_request) Successful in 4s
wai-quantum WebGPU member / member · browser WebGPU (macos) (pull_request) Successful in 1m32s
wai-quantum WebGPU member / member · integrated GPU, Vulkan (framework-128gb) (pull_request) Successful in 10m19s
wai-quantum WebGPU member / member · Metal (macos) (pull_request) Successful in 2m6s
wai-quantum CUDA member / member · CUDA absent (linux) (pull_request) Successful in 1m48s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (pull_request) Successful in 17m30s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 19m52s
wai-quantum matrix-extension member / member · matrix extension absent (macos) (pull_request) Successful in 18s
wai-quantum Metal member / member · Metal (macos) (pull_request) Successful in 53s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 15m4s
wai-quantum contract / schemas and the committed tool lists · linux (pull_request) Successful in 7m5s
wai-quantum dataflow member / member · dataflow device, simulated (pci 1e52:401e) (pull_request) Successful in 1m52s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 22m1s
wai-quantum docs, course and locales / the Studio's words in every locale · linux (pull_request) Successful in 12s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (pull_request) Successful in 5m14s
wai-quantum dataflow member / member · dataflow device, simulated (pci 1e52:b140) (pull_request) Successful in 4m17s
wai-quantum FPGA member / member · FPGA simulation absent (linux) (pull_request) Successful in 23s
wai-quantum docs, course and locales / the course and the command line's languages · linux (pull_request) Successful in 2m8s
wai-quantum resource estimation / estimation and calibration · linux, release (pull_request) Successful in 1m55s
wai-quantum editors / editors · linux (pull_request) Successful in 2m41s
wai-quantum other formats / the set under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m36s
wai-quantum OpenQASM reader / corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 52s
wai-quantum other formats / readers, writers and the set · linux (pull_request) Successful in 3m32s
wai-quantum OpenQASM reader / reader and corpus · linux (pull_request) Successful in 1m49s
wai-quantum Metal member / member · Metal absent (linux) (pull_request) Successful in 33s
wai-quantum OpenCL member / member · OpenCL absent (linux) (pull_request) Successful in 25s
wai-quantum neural-processor member / member · neural processor absent (linux) (pull_request) Successful in 28s
wai-quantum for JavaScript / javascript · linux (pull_request) Successful in 2m55s
wai-quantum WebGPU member / member · GPU, Vulkan (thor) (pull_request) Successful in 24m59s
wai-quantum pulse and dynamics / reader, simulator and pulse jobs under wasm32-wasip2 (wasmtime) (pull_request) Successful in 50s
wai-quantum pulse and dynamics / reader, simulator and pulse jobs · linux, release (pull_request) Successful in 1m38s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 23m47s
wai-quantum-qpu / clients against their contracts (linux) (pull_request) Successful in 44s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (pull_request) Successful in 11m27s
wai-quantum matrix-extension member / member · matrix extension, emulated (aarch64 Linux) (pull_request) Successful in 17s
wai-quantum error correction and mitigation / codes, decoders, texts and mitigation · linux, release (pull_request) Successful in 2m29s
wai-quantum for Python / python · linux (pull_request) Successful in 3m7s
wai-quantum WebGPU member / member · GPU, Vulkan (jetson-hub) (pull_request) Successful in 27m4s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 17s
wai-quantum studio and desktop / studio · browser (pull_request) Successful in 3m52s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 49s
wai-quantum service / service · linux (pull_request) Successful in 4m55s
wai-quantum portable tensor program / tensor program · CPU, mutants, GPU builds (pull_request) Successful in 39s
wai-quantum CPU vector units / member · vector unit, rvv 1024-bit (riscv64, emulated) (pull_request) Successful in 20s
WAI Studio / Studio in Chromium and Firefox · macos (pull_request) Successful in 15m2s
wai-quantum CPU vector units / member · vector unit, sve 128-bit (aarch64, emulated) (pull_request) Successful in 28s
wai-quantum CPU vector units / member · vector unit, rvv 128-bit (riscv64, emulated) (pull_request) Successful in 11s
wai-quantum CPU vector units / member · vector unit, sve 2048-bit (aarch64, emulated) (pull_request) Successful in 10s
wai-quantum variational and algorithm library / parameters, gradients, optimisers, VQE, QAOA and builders · linux, release (pull_request) Successful in 1m32s
wai-quantum studio and desktop / desktop app · linux (pull_request) Successful in 5m20s
wai-quantum CPU vector units / member · vector unit, avx2 (x86_64 silicon) (pull_request) Successful in 19s
wai-quantum CPU vector units / member · vector unit, sve 512-bit (aarch64, emulated) (pull_request) Successful in 30s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 2m34s
wai-quantum docs, course and locales / every command and snippet in the docs · linux (pull_request) Successful in 15m39s
wai-quantum verdicts and repairs / the intent set and the verdict under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m23s
wai-quantum verdicts and repairs / verdicts, repairs and the intent set · linux (pull_request) Successful in 1m52s
wai-quantum signal processor / processor and member · signal processor, emulated (pull_request) Successful in 15m25s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (pull_request) Successful in 58s
wai-quantum tensor-engine member / member · tensor engine, the kernel toolchain's simulator (linux) (pull_request) Successful in 8m9s
wai-quantum views in agent hosts / views · the page and its engine (pull_request) Successful in 3m1s
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Successful in 2m8s
wai-quantum views in agent hosts / views · a scripted host (pull_request) Successful in 3m23s
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness · MSRV (pull_request) Successful in 3m42s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 5m56s
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Successful in 17m13s
WAI Studio / Studio in WebKit · linux (pull_request) Successful in 11m22s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 15m57s
wai-quantum HIP member / member · HIP absent (linux-arm64) (pull_request) Successful in 1m16s
WAI examples / WAI examples · android-app · linux-arm64 (pull_request) Successful in 7m20s
wai-quantum npu-cores member / member · npu-cores absent (linux-arm64) (pull_request) Successful in 31s
wai-quantum tensor-engine member / member · tensor engine absent (linux-arm64) (pull_request) Successful in 23s
wai-quantum pulse and dynamics / the same bits · linux-arm64 (pull_request) Successful in 1m44s
wai-quantum variational and algorithm library / parameters, gradients, optimisers, VQE, QAOA and builders · linux-arm64, release (pull_request) Successful in 1m58s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (pull_request) Successful in 2m46s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 1h12m28s
wai-quantum quantum_spd: expectation_table compiled only where it is called
Only the device reference (under `quantum_fabric`) and the host test read
the table. The pulse, error-correction and variational workflows check the
library on `quantum_engine` and the sets beside it under `-D warnings`,
where the function was dead and failed the build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:29:40 -04:00

1763 lines
71 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Sparse Pauli dynamics at utility scale (`wai.quantum.spd`).
//!
//! Computes `⟨ψ₀| U† O U |ψ₀⟩` for circuits of up to 1024 qubits by evolving the
//! *observable* in the Heisenberg picture as a sum of Pauli strings,
//! `O = Σ c_P P`. A Clifford gate maps each string to one signed string. A Pauli
//! rotation `exp(−iθP/2)` leaves strings that commute with `P` alone and splits
//! the rest in two: `Q ↦ cos θ·Q + sin θ·(−iQP)`. Strings whose coefficient falls
//! below a threshold are dropped. A circuit therefore costs what its
//! non-Clifford rotations and the observable's light cone make it cost, not
//! `2ⁿ`. This is the method that computed the 127-qubit kicked-Ising
//! observables of the heavy-hex "utility" experiment on a laptop.
//!
//! **Arbitrary angles.** The dyadic circuit core is byte-exact because every
//! phase it allows is a power-of-two root of unity. This module takes any real
//! angle, so its contract is the one the crate's other `f64` layers keep:
//!
//! - **Reproducible.** Every result is bit-identical across runs, machines and
//! targets. Terms live in an insertion-ordered table, so the order in which
//! coefficients are added never depends on hashing or on the platform's word
//! size. `sin` and `cos` are computed in-crate from `+ − × ÷`, because the
//! platform's libm may differ in the last place.
//! - **Bounded.** Every dropped coefficient is added to
//! [`SpdResult::truncation_bound`]. A dropped term `δ·P` would have evolved
//! under the remaining gates into `δ` times a unitary conjugate of `P`, whose
//! expectation is at most `|δ|` in magnitude. So `|exact − value|` is at most
//! the bound, up to floating-point rounding. Clifford gates and exact
//! quarter-turn rotations drop nothing.
//! - **Threaded, with the same bits.** A large sum is updated by a pool of
//! workers, started once per run, each over pieces of the table. A run of
//! consecutive Clifford gates is applied in one pass, each term through
//! the gates in order. A rotation's new terms have distinct strings, so each
//! existing coefficient receives at most one addition. The rest are
//! appended in table order, and dropped magnitudes are summed in table
//! order. The thread count therefore changes the time and never a bit. On a
//! 16-core desktop processor the 127-qubit kicked-Ising run (20 steps,
//! threshold 10⁻⁴, 2.2 million terms at its peak) takes 9.4 s, against 49 s
//! on one thread.
use std::fmt;
use std::sync::atomic::AtomicU32;
use std::sync::atomic::Ordering::Relaxed;
use crate::pool::Pool;
pub use crate::repro::sin_cos;
/// The propagation with its terms on a device, with the CPU's bits
/// ([`device::expectation_on`]).
pub mod device;
// ---------------------------------------------------------------------------
// Circuits
// ---------------------------------------------------------------------------
/// The largest register this module takes: 16 words of 64 qubits.
pub const MAX_QUBITS: u32 = 1024;
/// The largest angle magnitude a rotation may carry.
pub const MAX_ANGLE: f64 = 1_048_576.0;
/// A single-qubit Pauli factor.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Pauli {
X,
Y,
Z,
}
/// A Pauli string as `(qubit, factor)` pairs; qubits not named carry `I`.
pub type PauliString = Vec<(u32, Pauli)>;
/// One instruction.
#[derive(Clone, Debug, PartialEq)]
pub enum Op {
H(u32),
S(u32),
Sdg(u32),
/// √X.
SX(u32),
/// √X†.
SXdg(u32),
X(u32),
Y(u32),
Z(u32),
/// `CX(control, target)`.
CX(u32, u32),
CZ(u32, u32),
Swap(u32, u32),
/// `exp(−iθP/2)` for any Pauli string `P`: `RX`, `RY`, `RZ` for one factor,
/// `RZZ` and the like for two.
Rot { axis: PauliString, theta: f64 },
/// `exp(−i(kπ/2)P/2)` for `k ∈ {1, 2, 3}`: a Pauli rotation through a
/// multiple of π/2. These are Clifford, and applied exactly.
QuarterRot { axis: PauliString, k: u8 },
}
/// A circuit on `n` qubits.
#[derive(Clone, Debug, PartialEq)]
pub struct RotCircuit {
pub n: u32,
pub ops: Vec<Op>,
}
/// Why a circuit, observable or initial state was refused.
#[derive(Clone, Debug, PartialEq)]
pub enum SpdError {
TooManyQubits(u32),
QubitOutOfRange { qubit: u32, n: u32 },
/// A two-qubit gate or Pauli string naming one qubit twice.
RepeatedQubit(u32),
EmptyAxis,
BadAngle(f64),
BadQuarter(u8),
/// A dyadic gate with no Pauli-rotation form here (a controlled `H`).
Unsupported(String),
/// A receipt's meter returned without running the job.
NotRun,
}
impl fmt::Display for SpdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SpdError::TooManyQubits(n) => write!(f, "{n} qubits; at most {MAX_QUBITS}"),
SpdError::QubitOutOfRange { qubit, n } => write!(f, "qubit {qubit} of a {n}-qubit register"),
SpdError::RepeatedQubit(q) => write!(f, "qubit {q} named twice"),
SpdError::EmptyAxis => write!(f, "a rotation about the identity"),
SpdError::BadAngle(a) => write!(f, "angle {a} is not finite or exceeds {MAX_ANGLE}"),
SpdError::BadQuarter(k) => write!(f, "quarter turns {k}; expected 1, 2 or 3"),
SpdError::Unsupported(g) => write!(f, "unsupported gate: {g}"),
SpdError::NotRun => write!(f, "the meter did not run the job"),
}
}
}
impl std::error::Error for SpdError {}
impl RotCircuit {
pub fn new(n: u32) -> Self {
RotCircuit { n, ops: Vec::new() }
}
pub fn push(&mut self, op: Op) -> &mut Self {
self.ops.push(op);
self
}
pub fn rx(&mut self, q: u32, theta: f64) -> &mut Self {
self.push(Op::Rot { axis: vec![(q, Pauli::X)], theta })
}
pub fn ry(&mut self, q: u32, theta: f64) -> &mut Self {
self.push(Op::Rot { axis: vec![(q, Pauli::Y)], theta })
}
pub fn rz(&mut self, q: u32, theta: f64) -> &mut Self {
self.push(Op::Rot { axis: vec![(q, Pauli::Z)], theta })
}
/// `exp(−iθ Z⊗Z/2)`.
pub fn rzz(&mut self, a: u32, b: u32, theta: f64) -> &mut Self {
self.push(Op::Rot { axis: vec![(a, Pauli::Z), (b, Pauli::Z)], theta })
}
/// `exp(−iθ X⊗X/2)`.
pub fn rxx(&mut self, a: u32, b: u32, theta: f64) -> &mut Self {
self.push(Op::Rot { axis: vec![(a, Pauli::X), (b, Pauli::X)], theta })
}
/// Check every qubit index, axis and angle.
pub fn validate(&self) -> Result<(), SpdError> {
if self.n == 0 || self.n > MAX_QUBITS {
return Err(SpdError::TooManyQubits(self.n));
}
let q_ok = |q: u32| {
if q < self.n {
Ok(())
} else {
Err(SpdError::QubitOutOfRange { qubit: q, n: self.n })
}
};
let pair = |a: u32, b: u32| {
q_ok(a)?;
q_ok(b)?;
if a == b { Err(SpdError::RepeatedQubit(a)) } else { Ok(()) }
};
for op in &self.ops {
match op {
Op::H(q) | Op::S(q) | Op::Sdg(q) | Op::SX(q) | Op::SXdg(q) | Op::X(q) | Op::Y(q) | Op::Z(q) => {
q_ok(*q)?
}
Op::CX(a, b) | Op::CZ(a, b) | Op::Swap(a, b) => pair(*a, *b)?,
Op::Rot { axis, theta } => {
check_string(axis, self.n)?;
if axis.is_empty() {
return Err(SpdError::EmptyAxis);
}
if !theta.is_finite() || theta.abs() > MAX_ANGLE {
return Err(SpdError::BadAngle(*theta));
}
}
Op::QuarterRot { axis, k } => {
check_string(axis, self.n)?;
if axis.is_empty() {
return Err(SpdError::EmptyAxis);
}
if !(1..=3).contains(k) {
return Err(SpdError::BadQuarter(*k));
}
}
}
}
Ok(())
}
}
fn check_string(s: &[(u32, Pauli)], n: u32) -> Result<(), SpdError> {
for (i, (q, _)) in s.iter().enumerate() {
if *q >= n {
return Err(SpdError::QubitOutOfRange { qubit: *q, n });
}
if s[..i].iter().any(|(r, _)| r == q) {
return Err(SpdError::RepeatedQubit(*q));
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Pauli strings as bit words
// ---------------------------------------------------------------------------
/// A Hermitian Pauli string on up to `64·W` qubits: factor `X^x Z^z` at each
/// qubit, with `Y` (both bits set) meaning `i·XZ`, so every string is Hermitian.
#[derive(Clone, Copy, PartialEq, Eq)]
struct Key<const W: usize> {
x: [u64; W],
z: [u64; W],
}
impl<const W: usize> Key<W> {
const ZERO: Self = Key { x: [0; W], z: [0; W] };
fn from_string(s: &[(u32, Pauli)]) -> Self {
let mut k = Self::ZERO;
for &(q, p) in s {
let (w, b) = ((q / 64) as usize, 1u64 << (q % 64));
match p {
Pauli::X => k.x[w] |= b,
Pauli::Z => k.z[w] |= b,
Pauli::Y => {
k.x[w] |= b;
k.z[w] |= b;
}
}
}
k
}
#[inline]
fn get(&self, q: u32) -> (bool, bool) {
let (w, b) = ((q / 64) as usize, q % 64);
((self.x[w] >> b) & 1 == 1, (self.z[w] >> b) & 1 == 1)
}
#[inline]
fn set(&mut self, q: u32, x: bool, z: bool) {
let (w, b) = ((q / 64) as usize, 1u64 << (q % 64));
self.x[w] = (self.x[w] & !b) | if x { b } else { 0 };
self.z[w] = (self.z[w] & !b) | if z { b } else { 0 };
}
/// Whether two strings commute: the symplectic product is even.
#[inline]
fn commutes(&self, o: &Self) -> bool {
let mut p = 0u32;
for w in 0..W {
p += (self.x[w] & o.z[w]).count_ones() + (self.z[w] & o.x[w]).count_ones();
}
p & 1 == 0
}
/// `self · o = i^e · (self ⊕ o)`: the product string and the exponent `e`
/// (mod 4) of its phase.
#[inline]
fn mul(&self, o: &Self) -> (Self, u32) {
let mut out = Self::ZERO;
let mut e = 0u32;
for w in 0..W {
let (x, z) = (self.x[w] ^ o.x[w], self.z[w] ^ o.z[w]);
out.x[w] = x;
out.z[w] = z;
e = e.wrapping_add((self.x[w] & self.z[w]).count_ones());
e = e.wrapping_add((o.x[w] & o.z[w]).count_ones());
e = e.wrapping_add(2 * (self.z[w] & o.x[w]).count_ones());
e = e.wrapping_sub((x & z).count_ones());
}
(out, e & 3)
}
fn weight(&self) -> u32 {
(0..W).map(|w| (self.x[w] | self.z[w]).count_ones()).sum()
}
fn hash(&self) -> u64 {
let mut h = 0x9e37_79b9_7f4a_7c15u64;
for w in 0..W {
h = mix(h ^ self.x[w]);
h = mix(h ^ self.z[w]);
}
h
}
}
#[inline]
fn mix(mut z: u64) -> u64 {
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
/// Pauli terms in insertion order, with a hash index on the side. Iteration
/// follows the order terms were first inserted, so the order coefficients are
/// added in — and with it every bit of the result — is the same on every
/// platform.
///
/// A dropped term keeps its slot with coefficient `0.0`, so dropping costs
/// nothing to the index, and a term that reappears is revived in place. The
/// table is compacted, in order, when dead slots outnumber live ones, and
/// before a Clifford gate rewrites every key anyway. When it happens depends
/// only on the computation, so the result stays the same everywhere.
///
/// Large tables are updated by a pool of workers, each over a contiguous
/// share of the terms. Where a term sits in the hash index depends on which
/// thread inserted it first, but the index only finds terms; the order of the
/// terms and every coefficient's arithmetic are the sequential ones, so the
/// result is the same bits at any thread count.
struct Terms<const W: usize> {
keys: Vec<Key<W>>,
coef: Vec<f64>,
index: Vec<AtomicU32>,
stale: bool,
/// Slots whose coefficient is `0.0`.
dead: usize,
}
const EMPTY: u32 = u32::MAX;
/// Below this many terms a table is updated on one thread. Tests share far
/// smaller tables, so that every test runs the threaded paths too.
const PARALLEL_TERMS: usize = if cfg!(test) { 64 } else { 1 << 15 };
/// The coefficients, written by several threads at once in [`rotate`]'s
/// merge. Each new term's string is distinct from every other's, so each
/// coefficient is written by one thread at most.
#[derive(Clone, Copy)]
struct Coefs(*mut f64);
// SAFETY: see `Coefs`: the writes go to distinct indices.
unsafe impl Send for Coefs {}
unsafe impl Sync for Coefs {}
impl Coefs {
// Taken through a method, so a closure captures the whole `Coefs` (which
// is `Sync`) rather than its pointer (which is not).
fn ptr(self) -> *mut f64 {
self.0
}
}
impl<const W: usize> Terms<W> {
fn new() -> Self {
Terms { keys: Vec::new(), coef: Vec::new(), index: (0..16).map(|_| AtomicU32::new(EMPTY)).collect(), stale: false, dead: 0 }
}
/// Shares for a pass over this table: one below [`PARALLEL_TERMS`],
/// otherwise four a worker (so a busy core holds up less of the pass),
/// each at least a quarter of that size.
fn parts(&self, pool: &Pool) -> usize {
if self.keys.len() < PARALLEL_TERMS || pool.threads() == 1 { 1 } else { (self.keys.len() / (PARALLEL_TERMS / 4)).clamp(1, 4 * pool.threads()) }
}
/// The slot of `k`, if present. The index must be current.
fn find(&self, k: &Key<W>) -> Option<usize> {
let cap = self.index.len();
let mut slot = (k.hash() & (cap as u64 - 1)) as usize;
loop {
let i = self.index[slot].load(Relaxed);
if i == EMPTY {
return None;
}
if self.keys[i as usize] == *k {
return Some(i as usize);
}
slot = (slot + 1) & (cap - 1);
}
}
/// Enter term `i` in an index that does not hold it, by compare-and-swap
/// on the first empty slot, so threads can enter different terms at once.
fn enter(index: &[AtomicU32], k: &Key<W>, i: u32) {
let cap = index.len();
let mut slot = (k.hash() & (cap as u64 - 1)) as usize;
while index[slot].compare_exchange(EMPTY, i, Relaxed, Relaxed).is_err() {
slot = (slot + 1) & (cap - 1);
}
}
/// Terms with a nonzero coefficient.
fn live(&self) -> usize {
self.keys.len() - self.dead
}
/// Remove dead slots, keeping the order of the rest.
fn compact(&mut self) {
let mut keep = 0;
for i in 0..self.keys.len() {
if self.coef[i] != 0.0 {
self.keys[keep] = self.keys[i];
self.coef[keep] = self.coef[i];
keep += 1;
}
}
self.keys.truncate(keep);
self.coef.truncate(keep);
self.dead = 0;
self.stale = true;
}
/// Rebuild the index for at least `extra` more terms than the table holds.
/// An index of the right size is cleared and reused; one far too large
/// for the table, or too small, is replaced.
fn reindex_for(&mut self, extra: usize, pool: &Pool) {
let mut cap = 16usize;
while cap < 2 * (self.keys.len() + extra) + 2 {
cap *= 2;
}
let parts = self.parts(pool);
if self.index.len() < cap || self.index.len() > 8 * cap {
self.index = (0..cap).map(|_| AtomicU32::new(EMPTY)).collect();
} else {
let index = &self.index;
pool.split(index.len(), parts, &|r| {
for slot in &index[r] {
slot.store(EMPTY, Relaxed);
}
});
}
let (keys, index) = (&self.keys, &self.index);
pool.split(keys.len(), parts, &|r| {
for i in r {
Self::enter(index, &keys[i], i as u32);
}
});
self.stale = false;
}
fn reindex(&mut self, pool: &Pool) {
self.reindex_for(0, pool);
}
/// Add `c` to the coefficient of `k`, inserting it at the end if absent.
fn add(&mut self, k: Key<W>, c: f64, pool: &Pool) {
debug_assert!(!self.stale);
if 2 * (self.keys.len() + 1) > self.index.len() {
self.reindex(pool);
}
let cap = self.index.len();
let mut slot = (k.hash() & (cap as u64 - 1)) as usize;
loop {
let i = self.index[slot].load(Relaxed);
if i == EMPTY {
self.index[slot].store(self.keys.len() as u32, Relaxed);
self.keys.push(k);
self.coef.push(c);
if c == 0.0 {
self.dead += 1;
}
return;
}
if self.keys[i as usize] == k {
let before = self.coef[i as usize];
let after = before + c;
self.coef[i as usize] = after;
match (before == 0.0, after == 0.0) {
(true, false) => self.dead -= 1,
(false, true) => self.dead += 1,
_ => {}
}
return;
}
slot = (slot + 1) & (cap - 1);
}
}
/// Drop terms with `|c| < threshold`, or heavier than `max_weight`.
/// Returns the sum of dropped `|c|`, added in table order.
fn truncate(&mut self, threshold: f64, max_weight: Option<u32>, pool: &Pool) -> f64 {
let parts = self.parts(pool);
let per = self.keys.len().div_ceil(parts).max(1);
let keys = &self.keys;
// Each share zeroes its dropped terms and keeps their magnitudes in
// order; the magnitudes are then summed in table order.
let drop_share = |at: usize, coef: &mut [f64]| -> Vec<f64> {
let mut out = Vec::new();
for (j, c) in coef.iter_mut().enumerate() {
if *c == 0.0 {
continue;
}
if c.abs() < threshold || max_weight.is_some_and(|m| keys[at + j].weight() > m) {
out.push(c.abs());
*c = 0.0;
}
}
out
};
let shares = pool.chunks(&mut self.coef, per, &drop_share);
let mut dropped = 0.0;
for v in shares.iter().flatten() {
dropped += v;
self.dead += 1;
}
if 2 * self.dead > self.keys.len() {
self.compact();
}
dropped
}
}
// ---------------------------------------------------------------------------
// Conjugation by one gate
// ---------------------------------------------------------------------------
/// `G† (X^x Z^z) G` for a single-qubit Clifford `G` on one factor: the new
/// factor and whether the sign flips.
fn conj1(op: &Op, x: bool, z: bool) -> (bool, bool, bool) {
// factors: I (0,0), X (1,0), Z (0,1), Y (1,1)
match (op, x, z) {
(_, false, false) => (false, false, false),
(Op::H(_), true, false) => (false, true, false), // X → Z
(Op::H(_), false, true) => (true, false, false), // Z → X
(Op::H(_), true, true) => (true, true, true), // Y → −Y
(Op::S(_), true, false) => (true, true, true), // X → −Y
(Op::S(_), true, true) => (true, false, false), // Y → X
(Op::Sdg(_), true, false) => (true, true, false), // X → Y
(Op::Sdg(_), true, true) => (true, false, true), // Y → −X
(Op::SX(_), false, true) => (true, true, false), // Z → Y
(Op::SX(_), true, true) => (false, true, true), // Y → −Z
(Op::SXdg(_), false, true) => (true, true, true), // Z → −Y
(Op::SXdg(_), true, true) => (false, true, false), // Y → Z
(Op::X(_), _, true) => (x, z, true), // Z, Y → −
(Op::Y(_), true, false) | (Op::Y(_), false, true) => (x, z, true), // X, Z → −
(Op::Z(_), true, _) => (x, z, true), // X, Y → −
_ => (x, z, false),
}
}
/// A gate that maps each string to one signed string: a fixed Clifford gate,
/// or a quarter-turn rotation about a string.
enum Clifford<'a, const W: usize> {
Gate(&'a Op),
Quarter(Key<W>, u8),
}
/// Conjugate one term by a fixed Clifford gate.
#[inline]
fn clifford_term<const W: usize>(op: &Op, k: &mut Key<W>, c: &mut f64) {
let neg = match *op {
Op::H(q) | Op::S(q) | Op::Sdg(q) | Op::SX(q) | Op::SXdg(q) | Op::X(q) | Op::Y(q) | Op::Z(q) => {
let (x, z) = k.get(q);
let (nx, nz, neg) = conj1(op, x, z);
k.set(q, nx, nz);
neg
}
Op::CX(c, tq) => {
let ((xc, zc), (xt, zt)) = (k.get(c), k.get(tq));
k.set(tq, xt ^ xc, zt);
k.set(c, xc, zc ^ zt);
xc && zt && !(xt ^ zc)
}
Op::CZ(a, b) => {
let ((xa, za), (xb, zb)) = (k.get(a), k.get(b));
k.set(a, xa, za ^ xb);
k.set(b, xb, zb ^ xa);
xa && xb && (za ^ zb)
}
Op::Swap(a, b) => {
let (fa, fb) = (k.get(a), k.get(b));
k.set(a, fb.0, fb.1);
k.set(b, fa.0, fa.1);
false
}
_ => unreachable!("not a fixed Clifford"),
};
if neg {
*c = -*c;
}
}
/// Conjugate one term by `exp(−i(kπ/2)P/2)`, exactly. A string anticommuting
/// with `P` becomes `±(−iQP)` for `k = 1` or `3`, and `−Q` for `k = 2`.
#[inline]
fn quarter_term<const W: usize>(p: &Key<W>, k: u8, q: &mut Key<W>, c: &mut f64) {
if q.commutes(p) {
return;
}
if k == 2 {
*c = -*c;
return;
}
let (r, e) = q.mul(p);
// −i·QP = i^{e+3}·R; e is odd here, so the factor is ±1.
let mut neg = (e + 3) & 3 == 2;
if k == 3 {
neg = !neg;
}
*q = r;
if neg {
*c = -*c;
}
}
/// Apply consecutive Clifford gates to every term in one pass: each term
/// goes through the gates in order. Conjugation by a unitary is a bijection on
/// Pauli strings, so no two terms collide, and each term's result is what the
/// gates one pass at a time would give it.
fn cliffords<const W: usize>(t: &mut Terms<W>, gates: &[Clifford<'_, W>], pool: &Pool) {
if t.dead > 0 {
t.compact();
}
let parts = t.parts(pool);
map_terms(&mut t.keys, &mut t.coef, pool, parts, &|k, c| {
for g in gates {
match g {
Clifford::Gate(op) => clifford_term(op, k, c),
Clifford::Quarter(p, q) => quarter_term(p, *q, k, c),
}
}
});
t.stale = true;
}
/// Apply `f` to every term, in `parts` shares of the table.
fn map_terms<const W: usize>(keys: &mut [Key<W>], coef: &mut [f64], pool: &Pool, parts: usize, f: &(dyn Fn(&mut Key<W>, &mut f64) + Sync)) {
let per = keys.len().div_ceil(parts.max(1)).max(1);
pool.chunks2(keys, coef, per, &|ks, cs| {
for (k, c) in ks.iter_mut().zip(cs.iter_mut()) {
f(k, c);
}
});
}
/// Conjugate by `exp(−iθP/2)`: `Q ↦ cos θ·Q + sin θ·(−iQP)` for every `Q` that
/// anticommutes with `P`. The new terms are merged after every old one has been
/// scaled, so each old coefficient is read before it changes.
///
/// On a large table the work is shared: each thread scans its share and
/// lists its new terms in order, and the lists are joined in share order.
/// Multiplying by `P` maps distinct strings to distinct strings, so no two new
/// terms share a string. Each existing coefficient therefore receives at most
/// one addition, and the strings not yet in the table are appended in list
/// order. The result is the sequential merge's, bit for bit.
fn rotate<const W: usize>(t: &mut Terms<W>, p: &Key<W>, sin: f64, cos: f64, pending: &mut Vec<(Key<W>, f64)>, pool: &Pool) {
if t.stale {
t.reindex(pool);
}
pending.clear();
let parts = t.parts(pool);
if parts == 1 {
for i in 0..t.keys.len() {
let c = t.coef[i];
if c == 0.0 {
continue;
}
let q = t.keys[i];
if q.commutes(p) {
continue;
}
let (r, e) = q.mul(p);
let v = c * sin;
pending.push((r, if (e + 3) & 3 == 2 { -v } else { v }));
t.coef[i] = c * cos;
}
for &(r, v) in pending.iter() {
t.add(r, v, pool);
}
return;
}
// The scan: each share scales its anticommuting terms and lists the new ones.
let per = t.keys.len().div_ceil(parts);
let keys = &t.keys;
let shares: Vec<Vec<(Key<W>, f64)>> = pool.chunks(&mut t.coef, per, &|at, coef| {
let mut out = Vec::new();
for (j, c) in coef.iter_mut().enumerate() {
if *c == 0.0 {
continue;
}
let q = keys[at + j];
if q.commutes(p) {
continue;
}
let (r, e) = q.mul(p);
let v = *c * sin;
out.push((r, if (e + 3) & 3 == 2 { -v } else { v }));
*c *= cos;
}
out
});
// The merge, share by share: add each new term to its string's
// coefficient where the string is in the table, and list the rest in order.
let coefs = Coefs(t.coef.as_mut_ptr());
let table: &Terms<W> = t;
let len = table.keys.len();
let merged: Vec<(isize, Vec<usize>)> = pool.split(shares.len(), shares.len(), &|r| {
let (mut revived_less_killed, mut absent) = (0isize, Vec::new());
let share = &shares[r.start];
for (j, &(k, v)) in share.iter().enumerate() {
match table.find(&k) {
Some(i) => {
debug_assert!(i < len);
// SAFETY: `i` is a slot of the table, and no other new
// term has this string, so no other thread writes slot `i`.
let slot = unsafe { &mut *coefs.ptr().add(i) };
let before = *slot;
let after = before + v;
*slot = after;
match (before == 0.0, after == 0.0) {
(true, false) => revived_less_killed += 1,
(false, true) => revived_less_killed -= 1,
_ => {}
}
}
None => absent.push(j),
}
}
(revived_less_killed, absent)
});
let mut fresh = 0;
for (d, absent) in &merged {
t.dead = (t.dead as isize - d) as usize;
fresh += absent.len();
}
if 2 * (t.keys.len() + fresh) > t.index.len() {
t.reindex_for(fresh, pool);
}
let start = t.keys.len();
t.keys.reserve(fresh);
t.coef.reserve(fresh);
for (share, (_, absent)) in shares.iter().zip(&merged) {
for &j in absent {
let (k, v) = share[j];
t.keys.push(k);
t.coef.push(v);
if v == 0.0 {
t.dead += 1;
}
}
}
let (keys, index) = (&t.keys, &t.index);
pool.split(keys.len() - start, parts, &|r| {
for i in r {
Terms::<W>::enter(index, &keys[start + i], (start + i) as u32);
}
});
}
// ---------------------------------------------------------------------------
// Propagation
// ---------------------------------------------------------------------------
/// How hard to truncate.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpdConfig {
/// Drop terms whose coefficient magnitude falls below this after a rotation.
/// `0.0` drops only exact zeros: the computation is then exact up to
/// rounding, and its cost may be exponential.
pub threshold: f64,
/// Also drop strings acting on more than this many qubits.
pub max_weight: Option<u32>,
}
impl Default for SpdConfig {
fn default() -> Self {
SpdConfig { threshold: 1e-4, max_weight: None }
}
}
/// An expectation value, what it cost, and how far truncation can have moved it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpdResult {
/// `⟨ψ₀| U† O U |ψ₀⟩` as computed.
pub value: f64,
/// The sum of every dropped coefficient's magnitude: a bound on
/// `|exact − value|`, up to floating-point rounding.
pub truncation_bound: f64,
/// The most terms the sum held after any gate.
pub peak_terms: usize,
/// Terms left when propagation finished.
pub final_terms: usize,
/// Term-gate updates performed: a portable measure of the work, equal on
/// every machine.
pub term_updates: u64,
}
/// `⟨b| U† O U |b⟩` for the computational basis state `|b⟩` whose set qubits
/// are `ones` (empty for `|0…0⟩`), with `O` a weighted sum of Pauli strings.
/// Large sums are updated on every available thread; the result is the
/// same bits at any thread count ([`expectation_threaded`]).
pub fn expectation(
circuit: &RotCircuit,
observable: &[(f64, PauliString)],
ones: &[u32],
cfg: &SpdConfig,
) -> Result<SpdResult, SpdError> {
#[cfg(not(target_arch = "wasm32"))]
let threads = std::thread::available_parallelism().map_or(1, |t| t.get());
#[cfg(target_arch = "wasm32")]
let threads = 1;
expectation_threaded(circuit, observable, ones, cfg, threads)
}
/// [`expectation`] on at most `threads` threads (wasm32 runs one). The thread
/// count changes the time, never a bit of the result.
pub fn expectation_threaded(
circuit: &RotCircuit,
observable: &[(f64, PauliString)],
ones: &[u32],
cfg: &SpdConfig,
threads: usize,
) -> Result<SpdResult, SpdError> {
expectation_capped(circuit, observable, ones, cfg, threads, usize::MAX).map(|(r, _)| r)
}
/// [`expectation_threaded`] holding at most `max_terms` terms: whenever a
/// rotation leaves more, the threshold is doubled and the sum truncated again
/// until it fits, and every coefficient dropped that way is added to the
/// bound like any other. A run that never reaches the cap is the uncapped
/// run, bit for bit. Returns the result and the threshold the run ended at.
pub fn expectation_capped(
circuit: &RotCircuit,
observable: &[(f64, PauliString)],
ones: &[u32],
cfg: &SpdConfig,
threads: usize,
max_terms: usize,
) -> Result<(SpdResult, f64), SpdError> {
circuit.validate()?;
for (_, s) in observable {
check_string(s, circuit.n)?;
}
for &q in ones {
if q >= circuit.n {
return Err(SpdError::QubitOutOfRange { qubit: q, n: circuit.n });
}
}
Ok(match circuit.n.div_ceil(64) {
1 => run::<1>(circuit, observable, ones, cfg, threads, max_terms),
2 => run::<2>(circuit, observable, ones, cfg, threads, max_terms),
3 | 4 => run::<4>(circuit, observable, ones, cfg, threads, max_terms),
5..=8 => run::<8>(circuit, observable, ones, cfg, threads, max_terms),
_ => run::<16>(circuit, observable, ones, cfg, threads, max_terms),
})
}
/// `⟨0…0| U† Z_q U |0…0⟩`.
pub fn expect_z(circuit: &RotCircuit, q: u32, cfg: &SpdConfig) -> Result<SpdResult, SpdError> {
expectation(circuit, &[(1.0, vec![(q, Pauli::Z)])], &[], cfg)
}
fn run<const W: usize>(
circuit: &RotCircuit,
observable: &[(f64, PauliString)],
ones: &[u32],
cfg: &SpdConfig,
threads: usize,
max_terms: usize,
) -> (SpdResult, f64) {
crate::pool::scoped(threads, |pool| {
let (r, t, _) = propagate::<W>(circuit, observable, ones, cfg, pool, max_terms);
(r, t)
})
}
/// [`expectation_capped`] on one thread, with the table it ended with: every
/// slot's string (in [`device::key_of`]'s layout) and coefficient, in order,
/// dead slots included. What a device's certificate is held to.
#[cfg(any(test, feature = "quantum_fabric"))]
pub(crate) fn expectation_table(circuit: &RotCircuit, observable: &[(f64, PauliString)], ones: &[u32], cfg: &SpdConfig, max_terms: usize) -> Result<(SpdResult, f64, Vec<u64>, Vec<f64>), SpdError> {
circuit.validate()?;
for (_, s) in observable {
check_string(s, circuit.n)?;
}
if let Some(&q) = ones.iter().find(|&&q| q >= circuit.n) {
return Err(SpdError::QubitOutOfRange { qubit: q, n: circuit.n });
}
fn table<const W: usize>(circuit: &RotCircuit, observable: &[(f64, PauliString)], ones: &[u32], cfg: &SpdConfig, max_terms: usize) -> (SpdResult, f64, Vec<u64>, Vec<f64>) {
crate::pool::scoped(1, |pool| {
let (r, threshold, t) = propagate::<W>(circuit, observable, ones, cfg, pool, max_terms);
let keys = t.keys.iter().flat_map(|k| k.x.iter().chain(k.z.iter()).copied()).collect();
(r, threshold, keys, t.coef)
})
}
Ok(match circuit.n.div_ceil(64) {
1 => table::<1>(circuit, observable, ones, cfg, max_terms),
2 => table::<2>(circuit, observable, ones, cfg, max_terms),
3 | 4 => table::<4>(circuit, observable, ones, cfg, max_terms),
5..=8 => table::<8>(circuit, observable, ones, cfg, max_terms),
_ => table::<16>(circuit, observable, ones, cfg, max_terms),
})
}
fn propagate<const W: usize>(circuit: &RotCircuit, observable: &[(f64, PauliString)], ones: &[u32], cfg: &SpdConfig, pool: &Pool, max_terms: usize) -> (SpdResult, f64, Terms<W>) {
let mut t = Terms::<W>::new();
for (c, s) in observable {
t.add(Key::from_string(s), *c, pool);
}
let mut bound = t.truncate(0.0, None, pool);
let mut peak = t.live();
let mut work = 0u64;
let mut pending = Vec::new();
// Consecutive Clifford gates wait in `batch` and are applied in one pass,
// before the next rotation and at the end. A Clifford gate keeps the
// number of terms, so the work count and the peak are as gate by gate.
let mut batch: Vec<Clifford<'_, W>> = Vec::new();
let mut threshold = cfg.threshold;
for op in circuit.ops.iter().rev() {
work += t.live() as u64;
match op {
Op::Rot { axis, theta } => {
if !batch.is_empty() {
cliffords(&mut t, &batch, pool);
batch.clear();
}
let (s, c) = sin_cos(*theta);
rotate(&mut t, &Key::from_string(axis), s, c, &mut pending, pool);
bound += t.truncate(threshold, cfg.max_weight, pool);
// Over the cap: a doubled threshold until the sum fits.
while t.live() > max_terms {
threshold = if threshold > 0.0 { threshold * 2.0 } else { f64::MIN_POSITIVE };
bound += t.truncate(threshold, cfg.max_weight, pool);
}
}
Op::QuarterRot { axis, k } => batch.push(Clifford::Quarter(Key::from_string(axis), *k)),
other => batch.push(Clifford::Gate(other)),
}
peak = peak.max(t.live());
}
if !batch.is_empty() {
cliffords(&mut t, &batch, pool);
}
let b = Key::<W>::from_string(&ones.iter().map(|&q| (q, Pauli::Z)).collect::<Vec<_>>());
let mut value = 0.0;
for i in 0..t.keys.len() {
let k = &t.keys[i];
if t.coef[i] != 0.0 && k.x.iter().all(|&w| w == 0) {
let flips: u32 = (0..W).map(|w| (k.z[w] & b.z[w]).count_ones()).sum();
value += if flips & 1 == 1 { -t.coef[i] } else { t.coef[i] };
}
}
let live = t.live();
(SpdResult { value, truncation_bound: bound, peak_terms: peak, final_terms: live, term_updates: work }, threshold, t)
}
// ---------------------------------------------------------------------------
// The heavy-hex kicked-Ising experiment
// ---------------------------------------------------------------------------
/// The 127-qubit heavy-hex coupling map: seven rows (14, 15, 15, 15, 15, 15
/// and 14 qubits) joined by six rows of four bridge qubits. Returns the 144
/// edges, each with the smaller qubit first.
pub fn heavy_hex_127() -> Vec<(u32, u32)> {
let rows: [(u32, u32); 7] = [(0, 13), (18, 32), (37, 51), (56, 70), (75, 89), (94, 108), (113, 126)];
// Each bridge row: (first bridge qubit, column offset into the row above,
// column offset into the row below).
let bridges: [(u32, u32, u32); 6] = [(14, 0, 0), (33, 2, 2), (52, 0, 0), (71, 2, 2), (90, 0, 0), (109, 2, 2)];
let mut edges = Vec::with_capacity(144);
for &(a, b) in &rows {
for q in a..b {
edges.push((q, q + 1));
}
}
for (r, &(first, up, down)) in bridges.iter().enumerate() {
let (above, below) = (rows[r].0, rows[r + 1].0);
for j in 0..4 {
let top = above + up + 4 * j;
// Row 6 starts one column right of the rows above it, so its
// bridge columns are one index earlier.
let bottom = below + down + 4 * j - if r == 5 { 1 } else { 0 };
edges.push((top.min(first + j), top.max(first + j)));
edges.push(((first + j).min(bottom), (first + j).max(bottom)));
}
}
edges.sort_unstable();
edges
}
/// The edges as layers of vertex-disjoint edges: an edge colouring. A graph
/// whose maximum degree is `Δ` and which is bipartite, such as heavy-hex, gets
/// exactly `Δ` layers (König's theorem): each edge takes a colour free at both
/// ends, and when none is, an alternating path is recoloured to free one. A
/// graph with an odd cycle may need a layer more, which it is given. Within a
/// layer the edges are in their input order, and the layers in colour order.
pub fn edge_layers(n: u32, edges: &[(u32, u32)]) -> Vec<Vec<(u32, u32)>> {
let n = n as usize;
let mut deg = vec![0usize; n];
for &(a, b) in edges {
deg[a as usize] += 1;
deg[b as usize] += 1;
}
let mut ncol = deg.iter().copied().max().unwrap_or(0).max(1);
// at[v][c]: the edge of colour c at vertex v, if any
let mut at: Vec<Vec<Option<usize>>> = vec![vec![None; ncol]; n];
let mut colour = vec![usize::MAX; edges.len()];
let other = |f: usize, x: usize| {
let (a, b) = edges[f];
if a as usize == x { b as usize } else { a as usize }
};
for (e, &(u, v)) in edges.iter().enumerate() {
let (u, v) = (u as usize, v as usize);
let free_u = (0..ncol).find(|&c| at[u][c].is_none());
let free_v = (0..ncol).find(|&c| at[v][c].is_none());
let chosen = match (free_u, free_v) {
(Some(a), Some(_)) if at[v][a].is_none() => Some(a),
(Some(a), Some(b)) => {
// Walk the a/b alternating path from v; flipping it frees a at v.
let mut path = Vec::new();
let (mut x, mut c) = (v, a);
let mut reaches_u = false;
while let Some(f) = at[x][c] {
path.push(f);
x = other(f, x);
if x == u {
reaches_u = true;
break;
}
c = if c == a { b } else { a };
}
if reaches_u {
None
} else {
for &f in &path {
let (p, q) = (edges[f].0 as usize, edges[f].1 as usize);
at[p][colour[f]] = None;
at[q][colour[f]] = None;
}
for &f in &path {
let (p, q) = (edges[f].0 as usize, edges[f].1 as usize);
colour[f] = if colour[f] == a { b } else { a };
at[p][colour[f]] = Some(f);
at[q][colour[f]] = Some(f);
}
Some(a)
}
}
_ => None,
};
let c = chosen.unwrap_or_else(|| {
ncol += 1;
for row in at.iter_mut() {
row.push(None);
}
ncol - 1
});
colour[e] = c;
at[u][c] = Some(e);
at[v][c] = Some(e);
}
(0..ncol)
.map(|c| edges.iter().zip(&colour).filter(|&(_, &k)| k == c).map(|(&e, _)| e).collect::<Vec<_>>())
.filter(|layer| !layer.is_empty())
.collect()
}
/// The kicked-Ising circuit of the heavy-hex utility experiment: `steps`
/// Trotter steps, each `RX(θ_h)` on every qubit and then `RZZ(−π/2)` on every
/// edge, layer by layer as [`edge_layers`] partitions them: three layers of
/// vertex-disjoint edges on heavy-hex, as on the device. The `RZZ` gates are applied
/// exactly, as quarter turns.
pub fn kicked_ising(n: u32, edges: &[(u32, u32)], theta_h: f64, steps: u32) -> RotCircuit {
let layers = edge_layers(n, edges);
let mut c = RotCircuit::new(n);
for _ in 0..steps {
for q in 0..n {
c.rx(q, theta_h);
}
for layer in &layers {
for &(a, b) in layer {
// RZZ(−π/2) = exp(+iπ/4·ZZ) = exp(−i(3π/2)ZZ/2) up to a global phase.
c.push(Op::QuarterRot { axis: vec![(a, Pauli::Z), (b, Pauli::Z)], k: 3 });
}
}
}
c
}
// ---------------------------------------------------------------------------
// From the dyadic core
// ---------------------------------------------------------------------------
/// The dyadic circuit as Pauli rotations. Fixed Cliffords map one to one; `T`
/// and `P(k)` become `Z` rotations; a controlled phase on any number of
/// controls becomes rotations about `Z` strings, from
/// `|1…1⟩⟨1…1| = Π (I − Z)/2`; controlled `X` and `Y` beyond one control are
/// that, conjugated by `H` (and `S`) on the target. Global phases drop out of a
/// Heisenberg-picture expectation and are not tracked.
#[cfg(feature = "quantum")]
pub fn from_dyadic(c: &crate::quantum::Circuit) -> Result<RotCircuit, SpdError> {
use crate::quantum::BaseGate as B;
let mut out = RotCircuit::new(u32::from(c.n_qubits));
let tau = 2.0 * std::f64::consts::PI;
for g in &c.ops {
let t = u32::from(g.target);
let ctrls: Vec<u32> = g.controls.iter().map(|&q| u32::from(q)).collect();
// The phase a diagonal base applies to |1⟩, in turns of 2π.
let phase = |base: B| -> Option<f64> {
match base {
B::Z => Some(0.5),
B::S => Some(0.25),
B::Sdg => Some(-0.25),
B::T => Some(0.125),
B::Tdg => Some(-0.125),
B::P => Some(1.0 / (1u64 << g.param) as f64),
_ => None,
}
};
match (g.base, ctrls.as_slice()) {
(B::I, _) => {}
(B::H, []) => _ = out.push(Op::H(t)),
(B::X, []) => _ = out.push(Op::X(t)),
(B::Y, []) => _ = out.push(Op::Y(t)),
(B::Z, []) => _ = out.push(Op::Z(t)),
(B::S, []) => _ = out.push(Op::S(t)),
(B::Sdg, []) => _ = out.push(Op::Sdg(t)),
(B::X, [a]) => _ = out.push(Op::CX(*a, t)),
(B::Z, [a]) => _ = out.push(Op::CZ(*a, t)),
(B::Y, [a]) => {
out.push(Op::Sdg(t)).push(Op::CX(*a, t)).push(Op::S(t));
}
(B::X, _) => {
out.push(Op::H(t));
controlled_phase(&mut out, &ctrls, t, 0.5 * tau)?;
out.push(Op::H(t));
}
(B::Y, _) => {
out.push(Op::Sdg(t)).push(Op::H(t));
controlled_phase(&mut out, &ctrls, t, 0.5 * tau)?;
out.push(Op::H(t)).push(Op::S(t));
}
(base, _) => match phase(base) {
Some(turns) => controlled_phase(&mut out, &ctrls, t, turns * tau)?,
None => return Err(SpdError::Unsupported(format!("{base:?} on {} controls", ctrls.len()))),
},
}
}
Ok(out)
}
/// `diag(…, e^{iφ})` on `controls ∪ {target}`: the phase `φ` on `|1…1⟩` only.
/// With `m` qubits, `|1…1⟩⟨1…1| = 2⁻ᵐ Σ_S (−1)^{|S|} Z_S`, so the gate is
/// `Π_S exp(iφ(−1)^{|S|} Z_S / 2ᵐ)` over nonempty subsets `S` (the empty one is
/// a global phase): a rotation `exp(−iθZ_S/2)` with `θ = −2φ(−1)^{|S|}/2ᵐ`.
///
/// The subsets are counted in a `u32`, so `m` must be below 32; a gate on more
/// qubits (a circuit2 gate may carry up to 254 controls) is refused as
/// unsupported rather than shifted out of range.
#[cfg(feature = "quantum")]
fn controlled_phase(out: &mut RotCircuit, controls: &[u32], target: u32, phi: f64) -> Result<(), SpdError> {
let mut qs = controls.to_vec();
qs.push(target);
let subsets = u32::try_from(qs.len()).ok().and_then(|m| 1u32.checked_shl(m).map(|s| (m, s)));
let Some((m, subsets)) = subsets else {
return Err(SpdError::Unsupported(format!("a phase on {} qubits; at most 31", qs.len())));
};
for mask in 1u32..subsets {
let axis: PauliString = (0..m).filter(|i| mask >> i & 1 == 1).map(|i| (qs[i as usize], Pauli::Z)).collect();
let sign = if axis.len() % 2 == 0 { 1.0 } else { -1.0 };
out.push(Op::Rot { axis, theta: -2.0 * phi * sign / f64::from(subsets) });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// ---- an independent Schrödinger-picture reference ----------------------
type C = (f64, f64);
fn cmul(a: C, b: C) -> C {
(a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0)
}
/// `P|ψ⟩` for a Pauli string, on a dense state.
fn apply_pauli(psi: &[C], s: &[(u32, Pauli)]) -> Vec<C> {
let mut out = vec![(0.0, 0.0); psi.len()];
for (j, &a) in psi.iter().enumerate() {
let mut k = j;
let mut ph: C = (1.0, 0.0);
for &(q, p) in s {
let bit = (j >> q) & 1;
match p {
Pauli::X => k ^= 1 << q,
Pauli::Y => {
k ^= 1 << q;
// Y|0⟩ = i|1⟩, Y|1⟩ = −i|0⟩
ph = cmul(ph, if bit == 0 { (0.0, 1.0) } else { (0.0, -1.0) });
}
Pauli::Z => {
if bit == 1 {
ph = (-ph.0, -ph.1);
}
}
}
}
let v = cmul(ph, a);
out[k].0 += v.0;
out[k].1 += v.1;
}
out
}
fn one_qubit(psi: &mut [C], q: u32, m: [[C; 2]; 2]) {
for j in 0..psi.len() {
if (j >> q) & 1 == 0 {
let k = j | (1 << q);
let (a, b) = (psi[j], psi[k]);
let add = |x: C, y: C| (x.0 + y.0, x.1 + y.1);
psi[j] = add(cmul(m[0][0], a), cmul(m[0][1], b));
psi[k] = add(cmul(m[1][0], a), cmul(m[1][1], b));
}
}
}
/// Run `circuit` on `|ones⟩` as a dense state, with the platform's `sin`
/// and `cos`: nothing shared with the code under test but the `Op` type.
fn dense(circuit: &RotCircuit, ones: &[u32]) -> Vec<C> {
let n = circuit.n;
let mut psi = vec![(0.0, 0.0); 1 << n];
psi[ones.iter().fold(0usize, |a, &q| a | 1 << q)] = (1.0, 0.0);
let r = std::f64::consts::FRAC_1_SQRT_2;
let (o, z, i, ni) = ((1.0, 0.0), (0.0, 0.0), (0.0, 1.0), (0.0, -1.0));
for op in &circuit.ops {
match op {
Op::H(q) => one_qubit(&mut psi, *q, [[(r, 0.0), (r, 0.0)], [(r, 0.0), (-r, 0.0)]]),
Op::S(q) => one_qubit(&mut psi, *q, [[o, z], [z, i]]),
Op::Sdg(q) => one_qubit(&mut psi, *q, [[o, z], [z, ni]]),
Op::SX(q) => one_qubit(&mut psi, *q, [[(0.5, 0.5), (0.5, -0.5)], [(0.5, -0.5), (0.5, 0.5)]]),
Op::SXdg(q) => one_qubit(&mut psi, *q, [[(0.5, -0.5), (0.5, 0.5)], [(0.5, 0.5), (0.5, -0.5)]]),
Op::X(q) => psi = apply_pauli(&psi, &[(*q, Pauli::X)]),
Op::Y(q) => psi = apply_pauli(&psi, &[(*q, Pauli::Y)]),
Op::Z(q) => psi = apply_pauli(&psi, &[(*q, Pauli::Z)]),
Op::CX(c, t) => {
for j in 0..psi.len() {
if (j >> c) & 1 == 1 && (j >> t) & 1 == 0 {
psi.swap(j, j | 1 << t);
}
}
}
Op::CZ(a, b) => {
for (j, v) in psi.iter_mut().enumerate() {
if (j >> a) & 1 == 1 && (j >> b) & 1 == 1 {
*v = (-v.0, -v.1);
}
}
}
Op::Swap(a, b) => {
for j in 0..psi.len() {
if (j >> a) & 1 == 1 && (j >> b) & 1 == 0 {
psi.swap(j, (j & !(1 << a)) | 1 << b);
}
}
}
Op::Rot { axis, theta } => {
let p = apply_pauli(&psi, axis);
let (c, s) = ((theta / 2.0).cos(), (theta / 2.0).sin());
for (v, w) in psi.iter_mut().zip(&p) {
// cos(θ/2)ψ − i sin(θ/2) Pψ
*v = (c * v.0 + s * w.1, c * v.1 - s * w.0);
}
}
Op::QuarterRot { axis, k } => {
let theta = f64::from(*k) * std::f64::consts::FRAC_PI_2;
let p = apply_pauli(&psi, axis);
let (c, s) = ((theta / 2.0).cos(), (theta / 2.0).sin());
for (v, w) in psi.iter_mut().zip(&p) {
*v = (c * v.0 + s * w.1, c * v.1 - s * w.0);
}
}
}
}
psi
}
fn dense_expect(circuit: &RotCircuit, obs: &[(u32, Pauli)], ones: &[u32]) -> f64 {
let psi = dense(circuit, ones);
let p = apply_pauli(&psi, obs);
psi.iter().zip(&p).map(|(a, b)| a.0 * b.0 + a.1 * b.1).sum()
}
/// A small deterministic generator for test circuits.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
mix(self.0)
}
fn below(&mut self, n: u64) -> u64 {
self.next() % n
}
fn angle(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64 * 8.0 - 4.0
}
fn pauli(&mut self) -> Pauli {
[Pauli::X, Pauli::Y, Pauli::Z][self.below(3) as usize]
}
fn string(&mut self, n: u32, max: usize) -> PauliString {
let mut qs: Vec<u32> = (0..n).collect();
let len = 1 + self.below(max as u64) as usize;
let mut s = Vec::new();
for _ in 0..len.min(n as usize) {
let i = self.below(qs.len() as u64) as usize;
s.push((qs.swap_remove(i), self.pauli()));
}
s
}
}
fn random_circuit(rng: &mut Rng, n: u32, len: usize) -> RotCircuit {
let mut c = RotCircuit::new(n);
for _ in 0..len {
let q = rng.below(u64::from(n)) as u32;
let mut r = rng.below(u64::from(n - 1)) as u32;
if r >= q {
r += 1;
}
let op = match rng.below(14) {
0 => Op::H(q),
1 => Op::S(q),
2 => Op::Sdg(q),
3 => Op::SX(q),
4 => Op::SXdg(q),
5 => Op::X(q),
6 => Op::Y(q),
7 => Op::Z(q),
8 => Op::CX(q, r),
9 => Op::CZ(q, r),
10 => Op::Swap(q, r),
11 => Op::QuarterRot { axis: rng.string(n, 3), k: 1 + rng.below(3) as u8 },
_ => Op::Rot { axis: rng.string(n, 3), theta: rng.angle() },
};
c.push(op);
}
c
}
// ---- trigonometry --------------------------------------------------------
#[test]
fn sin_cos_is_within_an_ulp_of_the_platform() {
let mut rng = Rng(7);
let ulp = |a: f64, b: f64| (a.to_bits() as i64 - b.to_bits() as i64).unsigned_abs();
for i in 0..200_000 {
let x = match i % 4 {
0 => rng.angle(),
1 => rng.angle() * 1e-6,
2 => rng.angle() * 1e4,
_ => rng.angle() * 0.2,
};
let (s, c) = sin_cos(x);
// Both are faithful roundings; they may differ by one ulp at most,
// and only where the true value is close to a rounding boundary.
assert!(ulp(s, x.sin()) <= 1 || (s - x.sin()).abs() < 1e-300, "sin {x}: {s} vs {}", x.sin());
assert!(ulp(c, x.cos()) <= 1, "cos {x}: {c} vs {}", x.cos());
}
}
#[test]
fn sin_cos_bits_are_pinned() {
// Bit patterns this implementation must produce on every target: they
// are what the cross-architecture run (wasm32-wasip2) is checked against.
// Each is within 0.51 ulp of the true value, checked against an
// 80-digit Taylor series when it was pinned.
let pins: [(f64, u64, u64); 4] = [
(0.3, 0x3fd2_e9cd_95ba_ba33, 0x3fee_921d_d42f_09ba),
(1.0, 0x3fea_ed54_8f09_0cee, 0x3fe1_4a28_0fb5_068c),
(-2.5, 0xbfe3_26af_0dcf_cab0, 0xbfe9_a2f7_ef85_8b7d),
(100.0, 0xbfe0_3425_b78c_4db8, 0x3feb_981d_bf66_5fdf),
];
for (x, sb, cb) in pins {
let (s, c) = sin_cos(x);
assert_eq!((s.to_bits(), c.to_bits()), (sb, cb), "x = {x}: sin {s:e} cos {c:e}");
}
}
#[test]
fn quarter_turns_are_exact() {
for (k, x) in [(1, std::f64::consts::FRAC_PI_2), (2, std::f64::consts::PI)] {
let (s, c) = sin_cos(x);
// Not exactly 0 and ±1 — π/2 is not representable — which is why the
// Clifford rotations have their own exact op.
assert!(c.abs() < 1e-15 || k == 2, "k = {k}");
assert!((s.abs() - if k == 1 { 1.0 } else { 0.0 }).abs() < 1e-15);
}
}
// ---- Pauli algebra against matrices --------------------------------------
#[test]
fn every_clifford_conjugates_every_pauli_as_its_matrix_does() {
// For each gate G and each Pauli P on two qubits, the Heisenberg rule's
// G†PG must give the same expectation on random states as conjugating
// the state instead: ⟨ψ|G†PG|ψ⟩ = ⟨Gψ|P|Gψ⟩.
let gates = [
Op::H(0), Op::S(0), Op::Sdg(0), Op::SX(0), Op::SXdg(0), Op::X(0), Op::Y(0), Op::Z(0),
Op::H(1), Op::SX(1), Op::CX(0, 1), Op::CX(1, 0), Op::CZ(0, 1), Op::Swap(0, 1),
];
let mut rng = Rng(11);
let paulis = [None, Some(Pauli::X), Some(Pauli::Y), Some(Pauli::Z)];
for g in &gates {
for p0 in paulis {
for p1 in paulis {
let obs: PauliString = [(0, p0), (1, p1)].iter().filter_map(|&(q, p)| p.map(|p| (q, p))).collect();
if obs.is_empty() {
continue;
}
// a random state prepared by rotations, then the gate
let mut c = RotCircuit::new(2);
for _ in 0..6 {
c.push(Op::Rot { axis: rng.string(2, 2), theta: rng.angle() });
}
c.push(g.clone());
let want = dense_expect(&c, &obs, &[]);
let got = expectation(&c, &[(1.0, obs.clone())], &[], &SpdConfig { threshold: 0.0, max_weight: None }).unwrap();
assert!((got.value - want).abs() < 1e-12, "{g:?} {obs:?}: {} vs {want}", got.value);
}
}
}
}
#[test]
fn random_circuits_match_the_dense_reference() {
let mut rng = Rng(2026);
for trial in 0..300 {
let n = 2 + rng.below(6) as u32;
let len = 6 + rng.below(30) as usize;
let c = random_circuit(&mut rng, n, len);
let obs = rng.string(n, 3);
let ones: Vec<u32> = (0..n).filter(|_| rng.below(2) == 1).collect();
let want = dense_expect(&c, &obs, &ones);
let got = expectation(&c, &[(1.0, obs.clone())], &ones, &SpdConfig { threshold: 0.0, max_weight: None }).unwrap();
assert!((got.value - want).abs() < 1e-10, "trial {trial}: {} vs {want}", got.value);
assert_eq!(got.truncation_bound, 0.0, "nothing is dropped at threshold 0 but exact zeros");
}
}
#[test]
fn the_truncation_bound_holds() {
// At every threshold the computed value is within the reported bound of
// the exact one — the guarantee, checked on circuits where the bound is
// not vacuous.
let mut rng = Rng(99);
let mut nontrivial = 0;
for _ in 0..120 {
let n = 6;
let c = random_circuit(&mut rng, n, 60);
let obs = vec![(rng.below(6) as u32, Pauli::Z)];
let exact = dense_expect(&c, &obs, &[]);
for threshold in [1e-3, 1e-2, 5e-2, 0.2] {
let r = expectation(&c, &[(1.0, obs.clone())], &[], &SpdConfig { threshold, max_weight: None }).unwrap();
assert!((r.value - exact).abs() <= r.truncation_bound + 1e-12, "|{} − {exact}| > {}", r.value, r.truncation_bound);
if r.truncation_bound > 0.0 && r.truncation_bound < 1.0 {
nontrivial += 1;
}
}
}
assert!(nontrivial > 50, "the bound was exercised {nontrivial} times");
}
#[test]
fn results_are_bit_identical_from_run_to_run() {
// A fresh std HashMap is seeded differently every time, so a term table
// that summed in its order would already differ here, in one process.
let c = kicked_ising(127, &heavy_hex_127(), 0.6, 5);
let cfg = SpdConfig { threshold: 1e-4, max_weight: None };
let a = expect_z(&c, 62, &cfg).unwrap();
assert!(a.peak_terms > 1000, "the run merges many terms: {}", a.peak_terms);
for _ in 0..4 {
let b = expect_z(&c, 62, &cfg).unwrap();
assert_eq!(a.value.to_bits(), b.value.to_bits());
assert_eq!(a.truncation_bound.to_bits(), b.truncation_bound.to_bits());
assert_eq!((a.peak_terms, a.term_updates), (b.peak_terms, b.term_updates));
}
}
#[test]
fn threads_do_not_change_a_bit() {
let mut rng = Rng(77);
for trial in 0..6 {
let n = [9u32, 40, 70, 130, 200, 300][trial];
let c = random_circuit(&mut rng, n, 120);
let obs = vec![(0.7, rng.string(n, 4)), (-0.4, rng.string(n, 3)), (0.25, rng.string(n, 2))];
// Exactly (threshold 0) only where the sum stays small.
for threshold in if n <= 9 { vec![0.0, 1e-3] } else { vec![1e-5, 1e-3] } {
let cfg = SpdConfig { threshold, max_weight: None };
let one = expectation_threaded(&c, &obs, &[1, 2], &cfg, 1).unwrap();
for threads in [2usize, 3, 8] {
let many = expectation_threaded(&c, &obs, &[1, 2], &cfg, threads).unwrap();
assert_eq!((many.value.to_bits(), many.truncation_bound.to_bits(), many.peak_terms, many.final_terms, many.term_updates), (one.value.to_bits(), one.truncation_bound.to_bits(), one.peak_terms, one.final_terms, one.term_updates), "trial {trial}, threshold {threshold}, {threads} threads");
}
}
}
// The utility circuit, past the size at which shares go to threads.
let ki = kicked_ising(127, &heavy_hex_127(), 0.7, 6);
let cfg = SpdConfig { threshold: 1e-4, max_weight: None };
let one = expectation_threaded(&ki, &[(1.0, vec![(62, Pauli::Z)])], &[], &cfg, 1).unwrap();
let many = expectation_threaded(&ki, &[(1.0, vec![(62, Pauli::Z)])], &[], &cfg, 6).unwrap();
assert!(one.peak_terms > 64);
assert_eq!((one.value.to_bits(), one.truncation_bound.to_bits(), one.peak_terms), (many.value.to_bits(), many.truncation_bound.to_bits(), many.peak_terms));
}
#[test]
fn a_term_cap_raises_the_threshold_and_the_bound_still_holds() {
let ki = kicked_ising(127, &heavy_hex_127(), 0.7, 5);
let obs = [(1.0, vec![(62, Pauli::Z)])];
let cfg = SpdConfig { threshold: 1e-5, max_weight: None };
let (free, t0) = expectation_capped(&ki, &obs, &[], &cfg, 4, usize::MAX).unwrap();
assert_eq!(t0, 1e-5);
// A cap above the peak changes nothing.
let (same, t1) = expectation_capped(&ki, &obs, &[], &cfg, 4, free.peak_terms + 1).unwrap();
assert_eq!((same.value.to_bits(), same.truncation_bound.to_bits(), t1), (free.value.to_bits(), free.truncation_bound.to_bits(), 1e-5));
// A cap a tenth of the peak raises the threshold; the capped value
// stays within its own bound of the far more exact one.
let (capped, t2) = expectation_capped(&ki, &obs, &[], &cfg, 4, free.peak_terms / 10).unwrap();
assert!(t2 > 1e-5 && capped.peak_terms <= free.peak_terms);
assert!((capped.value - free.value).abs() <= capped.truncation_bound + free.truncation_bound + 1e-12);
// A small register, exact reference: the bound covers the true error.
let mut rng = Rng(5);
let c = random_circuit(&mut rng, 6, 60);
let o = vec![(1.0, vec![(0u32, Pauli::Z), (3, Pauli::X)])];
let exact = dense_expect(&c, &o[0].1, &[]);
let (r, _) = expectation_capped(&c, &o, &[], &SpdConfig { threshold: 0.0, max_weight: None }, 2, 40).unwrap();
assert!((r.value - exact).abs() <= r.truncation_bound + 1e-12, "{} vs {exact}, bound {}", r.value, r.truncation_bound);
}
#[test]
fn word_width_does_not_change_the_answer() {
// The same circuit, placed in a 30-qubit and a 900-qubit register, is
// computed with one and with sixteen words per string: the bits agree.
let chain: Vec<(u32, u32)> = (0..29).map(|q| (q, q + 1)).collect();
let small = kicked_ising(30, &chain, 0.5, 6);
let mut big = small.clone();
big.n = 900;
let cfg = SpdConfig { threshold: 1e-6, max_weight: None };
let a = expect_z(&small, 7, &cfg).unwrap();
let b = expect_z(&big, 7, &cfg).unwrap();
assert_eq!(a.value.to_bits(), b.value.to_bits());
assert_eq!(a.term_updates, b.term_updates);
}
#[test]
fn invalid_circuits_are_refused() {
let mut c = RotCircuit::new(3);
c.push(Op::CX(1, 1));
assert_eq!(c.validate(), Err(SpdError::RepeatedQubit(1)));
let mut c = RotCircuit::new(3);
c.rx(3, 0.1);
assert_eq!(c.validate(), Err(SpdError::QubitOutOfRange { qubit: 3, n: 3 }));
let mut c = RotCircuit::new(3);
c.rx(0, f64::NAN);
assert!(matches!(c.validate(), Err(SpdError::BadAngle(_))));
let mut c = RotCircuit::new(3);
c.push(Op::QuarterRot { axis: vec![(0, Pauli::Z)], k: 4 });
assert_eq!(c.validate(), Err(SpdError::BadQuarter(4)));
assert_eq!(RotCircuit::new(1025).validate(), Err(SpdError::TooManyQubits(1025)));
}
// ---- the heavy-hex experiment ---------------------------------------------
#[test]
fn the_heavy_hex_map_has_the_published_shape() {
let e = heavy_hex_127();
assert_eq!(e.len(), 144);
let mut deg = [0u32; 127];
for &(a, b) in &e {
assert!(a < b && b < 127);
deg[a as usize] += 1;
deg[b as usize] += 1;
}
assert!(deg.iter().all(|&d| (1..=3).contains(&d)));
// Heavy-hex: every bridge qubit has degree 2, and no two degree-3
// qubits are adjacent.
for &(a, b) in &e {
assert!(!(deg[a as usize] == 3 && deg[b as usize] == 3), "({a},{b})");
}
for b in [14u32, 15, 16, 17, 33, 34, 35, 36, 52, 53, 54, 55, 71, 72, 73, 74, 90, 91, 92, 93, 109, 110, 111, 112] {
assert_eq!(deg[b as usize], 2, "bridge {b}");
}
// connected
let mut seen = [false; 127];
let mut stack = vec![0u32];
while let Some(q) = stack.pop() {
if !std::mem::replace(&mut seen[q as usize], true) {
stack.extend(e.iter().filter(|&&(a, b)| a == q || b == q).map(|&(a, b)| a + b - q));
}
}
assert!(seen.iter().all(|&s| s));
// It is bipartite, so three matchings cover it, as the experiment's
// three RZZ layers do.
let mut colour = [u8::MAX; 127];
colour[0] = 0;
let mut stack = vec![0u32];
while let Some(q) = stack.pop() {
for &(a, b) in e.iter().filter(|&&(a, b)| a == q || b == q) {
let o = (a + b - q) as usize;
if colour[o] == u8::MAX {
colour[o] = 1 - colour[q as usize];
stack.push(o as u32);
}
assert_ne!(colour[o], colour[q as usize]);
}
}
}
#[test]
fn edge_layers_are_proper_colourings_with_as_few_layers_as_the_degree() {
let check = |n: u32, edges: &[(u32, u32)], want: usize| {
let layers = edge_layers(n, edges);
assert_eq!(layers.len(), want);
let mut all: Vec<(u32, u32)> = layers.iter().flatten().copied().collect();
all.sort_unstable();
let mut sorted = edges.to_vec();
sorted.sort_unstable();
assert_eq!(all, sorted, "every edge exactly once");
for layer in &layers {
let mut seen = std::collections::BTreeSet::new();
for &(a, b) in layer {
assert!(seen.insert(a) && seen.insert(b), "vertex-disjoint");
}
}
};
check(127, &heavy_hex_127(), 3);
let chain: Vec<(u32, u32)> = (0..9).map(|q| (q, q + 1)).collect();
check(10, &chain, 2);
// A triangle is not bipartite: three edges, degree two, three layers.
check(3, &[(0, 1), (1, 2), (0, 2)], 3);
}
#[test]
fn the_kicked_ising_clifford_points_are_exact() {
// θ_h = 0: no rotation, every ⟨Z_q⟩ = 1. θ_h = π/2 written as quarter
// turns: a Clifford circuit, so the Heisenberg sum stays one term and
// ⟨Z_q⟩ is exactly 0 or ±1 with nothing dropped.
let e = heavy_hex_127();
let still = kicked_ising(127, &e, 0.0, 20);
let r = expect_z(&still, 62, &SpdConfig::default()).unwrap();
assert_eq!((r.value, r.truncation_bound, r.peak_terms), (1.0, 0.0, 1));
let mut cliff = RotCircuit::new(127);
for _ in 0..20 {
for q in 0..127 {
cliff.push(Op::QuarterRot { axis: vec![(q, Pauli::X)], k: 1 });
}
for &(a, b) in &e {
cliff.push(Op::QuarterRot { axis: vec![(a, Pauli::Z), (b, Pauli::Z)], k: 3 });
}
}
for q in [0, 13, 62, 126] {
let r = expect_z(&cliff, q, &SpdConfig::default()).unwrap();
assert!(r.value == 0.0 || r.value.abs() == 1.0, "q {q}: {}", r.value);
assert_eq!((r.truncation_bound, r.peak_terms), (0.0, 1));
}
}
#[test]
fn kicked_ising_on_a_fragment_matches_the_dense_reference() {
// The same circuit family on a 15-qubit heavy-hex fragment, where a
// dense state fits: exact propagation agrees with it at every angle.
let e: Vec<(u32, u32)> = heavy_hex_127().into_iter().filter(|&(a, b)| a < 15 && b < 15).collect();
assert!(e.len() >= 13);
for theta_h in [0.1, 0.4, 0.7854, 1.2] {
let c = kicked_ising(15, &e, theta_h, 4);
for q in [0, 4, 14] {
let want = dense_expect(&c, &[(q, Pauli::Z)], &[]);
let got = expect_z(&c, q, &SpdConfig { threshold: 0.0, max_weight: None }).unwrap();
assert!((got.value - want).abs() < 1e-10, "θ_h {theta_h} q {q}: {} vs {want}", got.value);
}
}
}
// ---- the dyadic adapter ------------------------------------------------------
/// A controlled phase counts its `2^m` subsets in a `u32`. Forty controls
/// would shift out of range; the gate is refused instead.
#[cfg(feature = "quantum")]
#[test]
fn a_phase_on_forty_controls_does_not_overflow() {
use crate::quantum::{BaseGate, Circuit, Gate};
let mut out = RotCircuit::new(41);
let controls: Vec<u32> = (0..40).collect();
assert!(matches!(controlled_phase(&mut out, &controls, 40, 1.0), Err(SpdError::Unsupported(_))));
assert!(out.ops.is_empty(), "nothing is emitted before the refusal");
// At the edge: 31 controls and the target make 32 qubits, refused.
assert!(matches!(controlled_phase(&mut out, &(0..31).collect::<Vec<u32>>(), 31, 1.0), Err(SpdError::Unsupported(_))));
// Through the adapter, for every multi-control gate it expands.
for base in [BaseGate::X, BaseGate::Y, BaseGate::Z, BaseGate::T, BaseGate::P] {
let mut c = Circuit::new(41);
let param = if base == BaseGate::P { 5 } else { 0 };
c.ops.push(Gate { base, controls: (0..40).collect(), target: 40, param });
c.validate().unwrap();
assert!(matches!(from_dyadic(&c), Err(SpdError::Unsupported(_))), "{base:?}");
}
// A small gate still expands: two controls, 2^3 − 1 rotations.
let mut out = RotCircuit::new(3);
controlled_phase(&mut out, &[0, 1], 2, 1.0).unwrap();
assert_eq!(out.ops.len(), 7);
}
#[cfg(feature = "quantum")]
#[test]
fn every_dyadic_algorithm_agrees_with_the_byte_exact_core() {
use crate::quantum::Circuit;
// Random dyadic circuits with every base gate and up to three controls:
// ⟨Z_q⟩ from the byte-exact simulator's probabilities and from the
// rotation form agree.
let mut rng = Rng(31);
for trial in 0..150 {
let n = 3 + rng.below(4) as u8;
let mut c = Circuit::new(n);
for _ in 0..25 {
let t = rng.below(u64::from(n)) as u8;
let mut ctrls = Vec::new();
for _ in 0..rng.below(4) {
let q = rng.below(u64::from(n)) as u8;
if q != t && !ctrls.contains(&q) {
ctrls.push(q);
}
}
let (base, param) = match rng.below(9) {
0 => (crate::quantum::BaseGate::H, 0),
1 => (crate::quantum::BaseGate::X, 0),
2 => (crate::quantum::BaseGate::Y, 0),
3 => (crate::quantum::BaseGate::Z, 0),
4 => (crate::quantum::BaseGate::S, 0),
5 => (crate::quantum::BaseGate::Sdg, 0),
6 => (crate::quantum::BaseGate::T, 0),
7 => (crate::quantum::BaseGate::Tdg, 0),
_ => (crate::quantum::BaseGate::P, 1 + rng.below(6) as u16),
};
if base == crate::quantum::BaseGate::H {
ctrls.clear();
}
c.ops.push(crate::quantum::Gate { base, controls: ctrls, target: t, param });
}
let sv = c.simulate().unwrap();
let w = sv.prob_weights();
let total: f64 = w.iter().map(|&x| x as f64).sum();
let rc = from_dyadic(&c).unwrap();
for q in 0..n {
let want: f64 = w.iter().enumerate().map(|(j, &x)| if (j >> q) & 1 == 0 { x as f64 } else { -(x as f64) }).sum::<f64>() / total;
let got = expect_z(&rc, u32::from(q), &SpdConfig { threshold: 0.0, max_weight: None }).unwrap();
assert!((got.value - want).abs() < 1e-6, "trial {trial} q {q}: {} vs {want}", got.value);
}
}
}
}