From 528d8ea6a92bbd0c6e6210c526172c9fab83b0d8 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 12:08:33 +0200 Subject: [PATCH 1/2] feat(ppvm-vihaco): add circuit component crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the `ppvm-vihaco` crate with just the circuit component slice: - `component.rs` — the `Circuit` component: dispatches `vihaco_circuit_isa::CircuitInstruction`s to a tableau or PauliSum backend, emitting measurement / trace effects (includes the R gate and trace/reset dispatch now that RotXY and tableau expectation/reset are on `main`). - `measurements.rs` — measurement / trace effect + observer types. - `device_info.rs` — `PPVM_MAGIC`, `BackendKind`, `PPVMDeviceInfo`, relocated out of the composite so `component` compiles without the composite machine (which lands in a follow-up PR). - `lib.rs` — module wiring for the component-only slice; the composite-driven helpers (load/run/dump/parse) come with the composite. `chumsky` / `vihaco-parser-core` are transitive through the `vihaco_parser::Parse` derive on `BackendKind`, so they carry a machete ignore. Includes standalone smoke tests for the tableau backend (single-qubit flip and CNOT propagation via the component's instruction dispatch); the fuller integration coverage arrives with the composite's `.sst` fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 18 + Cargo.toml | 1 + crates/ppvm-vihaco/Cargo.toml | 23 + crates/ppvm-vihaco/src/component.rs | 985 +++++++++++++++++++++++++ crates/ppvm-vihaco/src/device_info.rs | 43 ++ crates/ppvm-vihaco/src/lib.rs | 19 + crates/ppvm-vihaco/src/measurements.rs | 93 +++ 7 files changed, 1182 insertions(+) create mode 100644 crates/ppvm-vihaco/Cargo.toml create mode 100644 crates/ppvm-vihaco/src/component.rs create mode 100644 crates/ppvm-vihaco/src/device_info.rs create mode 100644 crates/ppvm-vihaco/src/lib.rs create mode 100644 crates/ppvm-vihaco/src/measurements.rs diff --git a/Cargo.lock b/Cargo.lock index 6aeff87ef..4575ddfcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1288,6 +1288,24 @@ dependencies = [ "rayon", ] +[[package]] +name = "ppvm-vihaco" +version = "0.1.0" +dependencies = [ + "bitvec", + "bnum", + "chumsky 0.10.1", + "eyre", + "num", + "ppvm-pauli-sum", + "ppvm-tableau", + "smallvec", + "vihaco", + "vihaco-circuit-isa", + "vihaco-parser", + "vihaco-parser-core", +] + [[package]] name = "prettyplease" version = "0.2.37" diff --git a/Cargo.toml b/Cargo.toml index 84451c01d..722b49ef0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/ppvm-stim", "crates/stim-parser", "crates/ppvm-tableau-sum", "crates/vihaco-circuit-isa", + "crates/ppvm-vihaco", # Runnable copies of the Rust code blocks in skills/ppvm-usage/SKILL.md. # Built by `cargo build --workspace --all-targets` in CI so the skill # can't silently drift away from the public API. diff --git a/crates/ppvm-vihaco/Cargo.toml b/crates/ppvm-vihaco/Cargo.toml new file mode 100644 index 000000000..f3267aa7e --- /dev/null +++ b/crates/ppvm-vihaco/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ppvm-vihaco" +version = "0.1.0" +edition = "2024" + +[dependencies] +bitvec = "1.0.1" +bnum = { version = "0.13.0", features = ["num-traits"] } +chumsky = "0.10.0" +eyre = "0.6.12" +num = "0.4.3" +smallvec = "1.15.1" +ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } +ppvm-tableau = { version = "0.1.0", path = "../ppvm-tableau" } +vihaco = "0.1.1" +vihaco-parser = "0.1.1" +vihaco-parser-core = "0.1.1" +vihaco-circuit-isa = { version = "0.1.0", path = "../vihaco-circuit-isa" } + +# chumsky and vihaco-parser-core are pulled in by the vihaco_parser::Parse +# derive on BackendKind; machete can't see macro-expanded usage. +[package.metadata.cargo-machete] +ignored = ["chumsky", "vihaco-parser-core"] diff --git a/crates/ppvm-vihaco/src/component.rs b/crates/ppvm-vihaco/src/component.rs new file mode 100644 index 000000000..69accec9f --- /dev/null +++ b/crates/ppvm-vihaco/src/component.rs @@ -0,0 +1,985 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::device_info::PPVMDeviceInfo; +use crate::measurements::{ + CircuitOutcomeEffect, MeasurementEffect, MeasurementOutcome, TraceEffect, +}; +use bitvec::view::BitView; +use bnum::types::{U256, U512, U1024, U2048}; +use eyre::{Result, eyre}; +use num::PrimInt; +use num::complex::Complex64; +use ppvm_pauli_sum::config::fx64hash::Byte8F64; +use ppvm_pauli_sum::config::indexmap::ByteFxHashF64; +use ppvm_pauli_sum::strategy::{CoefficientThreshold, CombinedStrategy, MaxPauliWeight}; +use ppvm_tableau::prelude::*; +use vihaco::{Effects, component, observe}; +use vihaco_circuit_isa::{CircuitEffect, CircuitInstruction, CircuitMessage}; + +/// Truncation strategy used by every `PauliSum` / `LossyPauliSum` size bucket. +/// Coefficient-threshold pruning is always on; the Pauli-weight cap is set per +/// run from the header (defaults to `usize::MAX` = no cap). +type PauliSumStrategy = CombinedStrategy; + +/// `PauliSum`'s `T` for the lossless backend: `[u8; N]` storage, fx hash, +/// f64 coefficients, the strategy above. +type PauliSumConfig = ByteFxHashF64; + +/// Same as `PauliSumConfig` but with `LossyPauliWord` as the word type, so the +/// loss-channel methods are dispatchable on the resulting `PauliSum`. +/// `LossyPauliWord`'s second type parameter (hasher) defaults to +/// `fxhash::FxBuildHasher`, matching `ByteFxHashF64`'s internal hasher. +type LossyPauliSumConfig = + ByteFxHashF64>; + +/// Build a `PauliSumStrategy` value from a `PPVMDeviceInfo`. Pulled out so the +/// six size-bucket constructors don't each repeat the strategy spelling. +fn paulisum_strategy(info: &PPVMDeviceInfo) -> PauliSumStrategy { + CombinedStrategy( + CoefficientThreshold(info.coefficient_threshold), + MaxPauliWeight(info.max_pauli_weight.unwrap_or(usize::MAX)), + ) +} + +macro_rules! batch_for { + ($tab:expr, $method:ident, $addrs:expr) => { + for addr in $addrs { $tab.$method(*addr); } + }; + ($tab:expr, $method:ident, $addrs:expr, $($arg:expr),+) => { + for addr in $addrs { $tab.$method(*addr, $($arg),+); } + }; +} + +/// Two-qubit sibling of [`batch_for!`]: drives a method that takes two qubit +/// addresses (plus optional extra args) over a slice of `(usize, usize)` pairs. +macro_rules! batch_pairs_for { + ($state:expr, $method:ident, $pairs:expr) => { + for &(a, b) in $pairs { $state.$method(a, b); } + }; + ($state:expr, $method:ident, $pairs:expr, $($arg:expr),+) => { + for &(a, b) in $pairs { $state.$method(a, b, $($arg),+); } + }; +} + +pub struct CircuitExecutor, I: TableauIndex, C: SparseVector> { + pub tab: GeneralizedTableau, +} + +#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +impl CircuitExecutor +where + T: Config, + <::Storage as BitView>::Store: PrimInt, + I: TableauIndex + Send + Sync + std::fmt::Debug, + C: SparseVector + std::fmt::Debug, +{ + fn execute( + &mut self, + inst: CircuitInstruction, + msg: CircuitMessage, + ) -> Result> { + self.execute_instruction(&inst, &msg) + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + use CircuitInstruction::*; + use CircuitMessage::*; + + match (inst, msg) { + // Single-qubit Clifford + (X, &Qubit(addr)) => self.tab.x(addr), + (Y, &Qubit(addr)) => self.tab.y(addr), + (Z, &Qubit(addr)) => self.tab.z(addr), + (H, &Qubit(addr)) => self.tab.h(addr), + (S, &Qubit(addr)) => self.tab.s(addr), + (SAdj, &Qubit(addr)) => self.tab.s_dag(addr), + (SqrtX, &Qubit(addr)) => self.tab.sqrt_x(addr), + (SqrtY, &Qubit(addr)) => self.tab.sqrt_y(addr), + (SqrtXAdj, &Qubit(addr)) => self.tab.sqrt_x_dag(addr), + (SqrtYAdj, &Qubit(addr)) => self.tab.sqrt_y_dag(addr), + + // Controlled gates + (CNOT, &TwoQubit(addr0, addr1)) => self.tab.cnot(addr0, addr1), + (CZ, &TwoQubit(addr0, addr1)) => self.tab.cz(addr0, addr1), + + // T gate + (T, &Qubit(addr)) => self.tab.t(addr), + (TAdj, &Qubit(addr)) => self.tab.t_dag(addr), + + // Single-qubit rotations + (RX, &QubitAndFloat(addr, angle)) => self.tab.rx(addr, angle), + (RY, &QubitAndFloat(addr, angle)) => self.tab.ry(addr, angle), + (RZ, &QubitAndFloat(addr, angle)) => self.tab.rz(addr, angle), + + // Two-qubit rotations + (RXX, &TwoQubitAndFloat(addr0, addr1, angle)) => self.tab.rxx(addr0, addr1, angle), + (RYY, &TwoQubitAndFloat(addr0, addr1, angle)) => self.tab.ryy(addr0, addr1, angle), + (RZZ, &TwoQubitAndFloat(addr0, addr1, angle)) => self.tab.rzz(addr0, addr1, angle), + + // U3 + (U3, &QubitU3(addr, theta, phi, lam)) => self.tab.u3(addr, theta, phi, lam), + + // RXY: rotation about an axis in the x/y plane + (R, &QubitAndTwoFloats(addr, axis_angle, theta)) => self.tab.r(addr, axis_angle, theta), + + // Measure & Reset + (Measure, &Qubit(addr)) => { + let outcome: MeasurementOutcome = self.tab.measure(addr).into(); + return Ok(Effects::one(CircuitOutcomeEffect::Measurement( + MeasurementEffect { + measurement_results: smallvec::smallvec![outcome], + }, + ))); + } + (Reset, &Qubit(addr)) => self.tab.reset(addr), + + // Noise + (Depolarize, &QubitAndFloat(addr, p)) => self.tab.depolarize1(addr, p), + (Depolarize2, &TwoQubitAndFloat(addr0, addr1, p)) => { + self.tab.depolarize2(addr0, addr1, p) + } + (PauliError, QubitAndFloatArr3(addr0, ps)) => self.tab.pauli_error(*addr0, *ps), + (TwoQubitPauliError, TwoQubitAndFloatArr15(addr0, addr1, ps)) => { + self.tab.two_qubit_pauli_error(*addr0, *addr1, *ps) + } + + // Loss + (Loss, &QubitAndFloat(addr, p)) => self.tab.loss_channel(addr, p), + (CorrelatedLoss, TwoQubitAndFloatArr3(addr0, addr1, ps)) => { + self.tab.correlated_loss_channel(*addr0, *addr1, *ps) + } + + /* BATCH OPERATIONS START HERE */ + // Batch: dedicated batch methods + (SqrtX, QubitBatch(addrs)) => self.tab.sqrt_x_many(addrs), + (SqrtY, QubitBatch(addrs)) => self.tab.sqrt_y_many(addrs), + (SqrtXAdj, QubitBatch(addrs)) => self.tab.sqrt_x_dag_many(addrs), + (SqrtYAdj, QubitBatch(addrs)) => self.tab.sqrt_y_dag_many(addrs), + (H, QubitBatch(addrs)) => self.tab.h_many(addrs), + (CZ, TwoQubitBatch(pairs)) => self.tab.cz_many(pairs), + + // TODO: replace things below by actual batched methods once they are available + // Batch: single-qubit for loops + (X, QubitBatch(addrs)) => batch_for!(self.tab, x, addrs), + (Y, QubitBatch(addrs)) => batch_for!(self.tab, y, addrs), + (Z, QubitBatch(addrs)) => batch_for!(self.tab, z, addrs), + (S, QubitBatch(addrs)) => batch_for!(self.tab, s, addrs), + (SAdj, QubitBatch(addrs)) => batch_for!(self.tab, s_dag, addrs), + (T, QubitBatch(addrs)) => batch_for!(self.tab, t, addrs), + (TAdj, QubitBatch(addrs)) => batch_for!(self.tab, t_dag, addrs), + (Reset, QubitBatch(addrs)) => batch_for!(self.tab, reset, addrs), + (RX, QubitBatchAndFloat(addrs, angle)) => batch_for!(self.tab, rx, addrs, *angle), + (RY, QubitBatchAndFloat(addrs, angle)) => batch_for!(self.tab, ry, addrs, *angle), + (RZ, QubitBatchAndFloat(addrs, angle)) => batch_for!(self.tab, rz, addrs, *angle), + (Depolarize, QubitBatchAndFloat(addrs, p)) => { + batch_for!(self.tab, depolarize1, addrs, *p) + } + (Loss, QubitBatchAndFloat(addrs, p)) => batch_for!(self.tab, loss_channel, addrs, *p), + (PauliError, QubitBatchAndFloatArr3(addrs, ps)) => { + batch_for!(self.tab, pauli_error, addrs, *ps) + } + (U3, QubitBatchU3(addrs, theta, phi, lam)) => { + batch_for!(self.tab, u3, addrs, *theta, *phi, *lam) + } + + // Batch: two-qubit for loops + (CNOT, TwoQubitBatch(pairs)) => { + for &(a, b) in pairs { + self.tab.cnot(a, b); + } + } + (RXX, TwoQubitBatchAndFloat(pairs, angle)) => { + for &(a, b) in pairs { + self.tab.rxx(a, b, *angle); + } + } + (RYY, TwoQubitBatchAndFloat(pairs, angle)) => { + for &(a, b) in pairs { + self.tab.ryy(a, b, *angle); + } + } + (RZZ, TwoQubitBatchAndFloat(pairs, angle)) => { + for &(a, b) in pairs { + self.tab.rzz(a, b, *angle); + } + } + (Depolarize2, TwoQubitBatchAndFloat(pairs, p)) => { + for &(a, b) in pairs { + self.tab.depolarize2(a, b, *p); + } + } + (TwoQubitPauliError, TwoQubitBatchAndFloatArr15(pairs, ps)) => { + for &(a, b) in pairs { + self.tab.two_qubit_pauli_error(a, b, *ps); + } + } + (CorrelatedLoss, TwoQubitBatchAndFloatArr3(pairs, ps)) => { + for &(a, b) in pairs { + self.tab.correlated_loss_channel(a, b, *ps); + } + } + + // Batch: measure (emits per qubit) + (Measure, QubitBatch(addrs)) => { + let outcomes = addrs.iter().map(|&addr| self.tab.measure(addr).into()); + return Ok(Effects::one(CircuitOutcomeEffect::Measurement( + MeasurementEffect { + measurement_results: outcomes.collect(), + }, + ))); + } + + // Truncate is a silent no-op on the Tableau backend — the tableau's + // gate methods already prune via the configured coefficient + // threshold, so there's nothing extra to do here. + (Truncate, None) => {} + + // Trace: parse the resolved pattern and compute Σ_{P matches} ⟨ψ|P|ψ⟩ + // on the tableau state. Asymmetric with the PauliSum semantics by + // design (Decision 9): on the tableau this is a sum of expectations, + // not a coefficient filter. + (Trace, PauliPatternStr(s)) => { + let pat = PauliPattern::parse(s) + .map_err(|e| eyre!("invalid Pauli pattern `{s}`: {e:?}"))?; + let value = self.tab.trace(&pat); + return Ok(Effects::one(CircuitOutcomeEffect::Trace(TraceEffect { + value, + }))); + } + + // Fallback + (inst, msg) => { + return Err(eyre!( + "Invalid circuit instruction arguments {:?} for instruction {:?}", + msg, + inst + )); + } + }; + + Ok(Effects::None) + } +} + +impl vihaco::Reset for CircuitExecutor +where + T: Config, + <::Storage as BitView>::Store: PrimInt, + I: TableauIndex + Send + Sync + std::fmt::Debug, + C: SparseVector + std::fmt::Debug, +{ + fn reset(&mut self) { + self.tab.reset_all(); + } +} + +/// Shared dispatch body for `PauliSumExecutor` and `LossyPauliSumExecutor`. +/// Every non-loss `CircuitInstruction` lands here. `LossyPauliSumExecutor` +/// matches `Loss` / `CorrelatedLoss` (single + batched) before invoking this +/// macro and never reaches the loss-rejection arm below. +/// +/// `$self` is passed as an `ident` (typically `self`) so the macro's +/// expansion shares hygiene with the surrounding method's `self` parameter. +/// `$inst` / `$msg` are passed the same way; `$backend` is the human-readable +/// backend name baked into error messages. +macro_rules! dispatch_common_paulisum { + ($self:ident, $inst:ident, $msg:ident, $backend:literal) => {{ + use CircuitInstruction::*; + use CircuitMessage::*; + match ($inst, $msg) { + // Single-qubit Clifford + (X, &Qubit(addr)) => $self.state.x(addr), + (Y, &Qubit(addr)) => $self.state.y(addr), + (Z, &Qubit(addr)) => $self.state.z(addr), + (H, &Qubit(addr)) => $self.state.h(addr), + (S, &Qubit(addr)) => $self.state.s(addr), + (SAdj, &Qubit(addr)) => $self.state.s_dag(addr), + (SqrtX, &Qubit(addr)) => $self.state.sqrt_x(addr), + (SqrtY, &Qubit(addr)) => $self.state.sqrt_y(addr), + (SqrtXAdj, &Qubit(addr)) => $self.state.sqrt_x_dag(addr), + (SqrtYAdj, &Qubit(addr)) => $self.state.sqrt_y_dag(addr), + + // Controlled gates + (CNOT, &TwoQubit(addr0, addr1)) => $self.state.cnot(addr0, addr1), + (CZ, &TwoQubit(addr0, addr1)) => $self.state.cz(addr0, addr1), + + // Single-qubit rotations + (RX, &QubitAndFloat(addr, angle)) => $self.state.rx(addr, angle), + (RY, &QubitAndFloat(addr, angle)) => $self.state.ry(addr, angle), + (RZ, &QubitAndFloat(addr, angle)) => $self.state.rz(addr, angle), + + // Two-qubit rotations + (RXX, &TwoQubitAndFloat(addr0, addr1, angle)) => { + $self.state.rxx(addr0, addr1, angle) + } + (RYY, &TwoQubitAndFloat(addr0, addr1, angle)) => { + $self.state.ryy(addr0, addr1, angle) + } + (RZZ, &TwoQubitAndFloat(addr0, addr1, angle)) => { + $self.state.rzz(addr0, addr1, angle) + } + + // RXY: rotation about an axis in the x/y plane + (R, &QubitAndTwoFloats(addr, axis_angle, theta)) => { + $self.state.r(addr, axis_angle, theta) + } + + // Noise + (Depolarize, &QubitAndFloat(addr, p)) => $self.state.depolarize1(addr, p), + (Depolarize2, &TwoQubitAndFloat(addr0, addr1, p)) => { + $self.state.depolarize2(addr0, addr1, p) + } + (PauliError, QubitAndFloatArr3(addr0, ps)) => { + $self.state.pauli_error(*addr0, *ps) + } + (TwoQubitPauliError, TwoQubitAndFloatArr15(addr0, addr1, ps)) => { + $self.state.two_qubit_pauli_error(*addr0, *addr1, *ps) + } + + // Truncate: pruning per the configured strategy. + (Truncate, None) => $self.state.truncate(), + + // Batched arms: simple for-loop dispatch (no dedicated batch + // methods on PauliSum, unlike GeneralizedTableau). + (X, QubitBatch(addrs)) => batch_for!($self.state, x, addrs), + (Y, QubitBatch(addrs)) => batch_for!($self.state, y, addrs), + (Z, QubitBatch(addrs)) => batch_for!($self.state, z, addrs), + (H, QubitBatch(addrs)) => batch_for!($self.state, h, addrs), + (S, QubitBatch(addrs)) => batch_for!($self.state, s, addrs), + (SAdj, QubitBatch(addrs)) => batch_for!($self.state, s_dag, addrs), + (SqrtX, QubitBatch(addrs)) => batch_for!($self.state, sqrt_x, addrs), + (SqrtY, QubitBatch(addrs)) => batch_for!($self.state, sqrt_y, addrs), + (SqrtXAdj, QubitBatch(addrs)) => batch_for!($self.state, sqrt_x_dag, addrs), + (SqrtYAdj, QubitBatch(addrs)) => batch_for!($self.state, sqrt_y_dag, addrs), + (RX, QubitBatchAndFloat(addrs, angle)) => { + batch_for!($self.state, rx, addrs, *angle) + } + (RY, QubitBatchAndFloat(addrs, angle)) => { + batch_for!($self.state, ry, addrs, *angle) + } + (RZ, QubitBatchAndFloat(addrs, angle)) => { + batch_for!($self.state, rz, addrs, *angle) + } + (Depolarize, QubitBatchAndFloat(addrs, p)) => { + batch_for!($self.state, depolarize1, addrs, *p) + } + (PauliError, QubitBatchAndFloatArr3(addrs, ps)) => { + batch_for!($self.state, pauli_error, addrs, *ps) + } + (CNOT, TwoQubitBatch(pairs)) => batch_pairs_for!($self.state, cnot, pairs), + (CZ, TwoQubitBatch(pairs)) => batch_pairs_for!($self.state, cz, pairs), + (RXX, TwoQubitBatchAndFloat(pairs, angle)) => { + batch_pairs_for!($self.state, rxx, pairs, *angle) + } + (RYY, TwoQubitBatchAndFloat(pairs, angle)) => { + batch_pairs_for!($self.state, ryy, pairs, *angle) + } + (RZZ, TwoQubitBatchAndFloat(pairs, angle)) => { + batch_pairs_for!($self.state, rzz, pairs, *angle) + } + (Depolarize2, TwoQubitBatchAndFloat(pairs, p)) => { + batch_pairs_for!($self.state, depolarize2, pairs, *p) + } + (TwoQubitPauliError, TwoQubitBatchAndFloatArr15(pairs, ps)) => { + batch_pairs_for!($self.state, two_qubit_pauli_error, pairs, *ps) + } + + // Not supported on either backend (Decision 11 + Gate Support + // Matrix). Loss / CorrelatedLoss handling differs by backend + // and lives in the caller's impl block, not this macro. + (Measure | Reset, _) => { + return Err(eyre!("{} is not supported on the {} backend", $inst, $backend)); + } + + // T / T_dag / U3 are listed as supported on PauliSum in the + // plan's Gate Support Matrix, but ppvm-runtime does not yet + // implement TGate or U3Gate for PauliSum (only for + // GeneralizedTableau). + (T | TAdj | U3, _) => { + return Err(eyre!( + "{} on {} requires upstream ppvm-runtime support that is not yet implemented", + $inst, + $backend + )); + } + + // Trace: parse the resolved pattern string and compute the + // trace. Per plan Decision 9, parsing happens on every + // execution; no module-load caching. + (Trace, PauliPatternStr(s)) => { + let pat = PauliPattern::parse(s) + .map_err(|e| eyre!("invalid Pauli pattern `{}`: {:?}", s, e))?; + let value = $self.state.trace(&pat); + return Ok(Effects::one(CircuitOutcomeEffect::Trace(TraceEffect { + value, + }))); + } + + // Fallback (mismatched shapes, etc.) + (inst, msg) => { + return Err(eyre!( + "Invalid circuit instruction arguments {:?} for instruction {:?} on the {} backend", + msg, + inst, + $backend + )); + } + }; + Ok(Effects::None) + }}; +} + +/// PauliSum-backed executor (Heisenberg picture). Holds a `PauliSum` and +/// answers the same `CircuitInstruction` vocabulary as `CircuitExecutor`, +/// but without measurement / reset / loss support. +pub struct PauliSumExecutor> { + pub state: PauliSum, + /// Snapshot of the seeded observable, restored by `reset`. + initial: PauliSum, +} + +#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +impl PauliSumExecutor +where + T: Config, + for<'a> PauliSum: Trace<'a, PauliPattern, Output = f64>, +{ + fn execute( + &mut self, + inst: CircuitInstruction, + msg: CircuitMessage, + ) -> Result> { + self.execute_instruction(&inst, &msg) + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + use CircuitInstruction::*; + + if matches!(inst, Loss | CorrelatedLoss) { + return Err(eyre!( + "{inst} is not supported on the PauliSum backend; use the LossyPauliSum backend instead" + )); + } + + dispatch_common_paulisum!(self, inst, msg, "PauliSum") + } +} + +impl vihaco::Reset for PauliSumExecutor +where + T: Config, + PauliSum: Clone, +{ + fn reset(&mut self) { + self.state = self.initial.clone(); + } +} + +/// LossyPauliSum-backed executor. Same dispatch as `PauliSumExecutor` plus +/// `Loss` / `CorrelatedLoss` channels. The concrete `T` used by the +/// enclosing `Circuit::LossyPauliSum` variant is a `Config` whose +/// `PauliWordType` is `LossyPauliWord` (see `LossyPauliSumConfig`). +pub struct LossyPauliSumExecutor> { + pub state: PauliSum, + /// Snapshot of the seeded observable, restored by `reset`. + initial: PauliSum, +} + +#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +impl LossyPauliSumExecutor +where + T: Config, + for<'a> PauliSum: Trace<'a, PauliPattern, Output = f64>, +{ + fn execute( + &mut self, + inst: CircuitInstruction, + msg: CircuitMessage, + ) -> Result> { + self.execute_instruction(&inst, &msg) + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + use CircuitInstruction::*; + use CircuitMessage::*; + + // Loss / CorrelatedLoss are the only instructions that differ from + // PauliSum; handle them here then delegate everything else to the + // shared dispatch. + match (inst, msg) { + (Loss, &QubitAndFloat(addr, p)) => { + self.state.loss_channel(addr, p); + return Ok(Effects::None); + } + (CorrelatedLoss, TwoQubitAndFloatArr3(addr0, addr1, ps)) => { + self.state.correlated_loss_channel(*addr0, *addr1, *ps); + return Ok(Effects::None); + } + (Loss, QubitBatchAndFloat(addrs, p)) => { + batch_for!(self.state, loss_channel, addrs, *p); + return Ok(Effects::None); + } + (CorrelatedLoss, TwoQubitBatchAndFloatArr3(pairs, ps)) => { + batch_pairs_for!(self.state, correlated_loss_channel, pairs, *ps); + return Ok(Effects::None); + } + _ => {} + } + + dispatch_common_paulisum!(self, inst, msg, "LossyPauliSum") + } +} + +impl vihaco::Reset for LossyPauliSumExecutor +where + T: Config, + PauliSum: Clone, +{ + fn reset(&mut self) { + self.state = self.initial.clone(); + } +} + +/// Tableau-backed inner enum (Schrödinger picture). Carries the six +/// size-bucketed `CircuitExecutor` variants; bucket is picked from `n_qubits`. +pub enum TableauCircuit { + Bits64(CircuitExecutor, usize, Vec<(Complex64, usize)>>), + Bits128(CircuitExecutor, u128, Vec<(Complex64, u128)>>), + Bits256(CircuitExecutor, U256, Vec<(Complex64, U256)>>), + Bits512(CircuitExecutor, U512, Vec<(Complex64, U512)>>), + Bits1024(CircuitExecutor, U1024, Vec<(Complex64, U1024)>>), + Bits2048(CircuitExecutor, U2048, Vec<(Complex64, U2048)>>), +} + +impl TableauCircuit { + pub fn new(n_qubits: usize, coefficient_threshold: f64) -> Self { + if n_qubits <= 64 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Self::Bits64(CircuitExecutor { tab }) + } else if n_qubits <= 128 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Self::Bits128(CircuitExecutor { tab }) + } else if n_qubits <= 256 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Self::Bits256(CircuitExecutor { tab }) + } else if n_qubits <= 512 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Self::Bits512(CircuitExecutor { tab }) + } else if n_qubits <= 1024 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Self::Bits1024(CircuitExecutor { tab }) + } else if n_qubits <= 2048 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Self::Bits2048(CircuitExecutor { tab }) + } else { + panic!("No matching executor for {} qubits", n_qubits); + } + } + + /// Same as [`TableauCircuit::new`], but seed the RNG deterministically so a + /// shot is reproducible. + pub fn new_with_seed(n_qubits: usize, coefficient_threshold: f64, seed: u64) -> Self { + macro_rules! seeded { + ($variant:ident) => {{ + let tab = GeneralizedTableau::new_with_seed(n_qubits, coefficient_threshold, seed); + Self::$variant(CircuitExecutor { tab }) + }}; + } + if n_qubits <= 64 { + seeded!(Bits64) + } else if n_qubits <= 128 { + seeded!(Bits128) + } else if n_qubits <= 256 { + seeded!(Bits256) + } else if n_qubits <= 512 { + seeded!(Bits512) + } else if n_qubits <= 1024 { + seeded!(Bits1024) + } else if n_qubits <= 2048 { + seeded!(Bits2048) + } else { + panic!("No matching executor for {} qubits", n_qubits); + } + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + match self { + Self::Bits64(ex) => ex.execute_instruction(inst, msg), + Self::Bits128(ex) => ex.execute_instruction(inst, msg), + Self::Bits256(ex) => ex.execute_instruction(inst, msg), + Self::Bits512(ex) => ex.execute_instruction(inst, msg), + Self::Bits1024(ex) => ex.execute_instruction(inst, msg), + Self::Bits2048(ex) => ex.execute_instruction(inst, msg), + } + } + + pub fn state_string(&self) -> String { + match self { + Self::Bits64(ex) => ex.tab.to_string(), + Self::Bits128(ex) => ex.tab.to_string(), + Self::Bits256(ex) => ex.tab.to_string(), + Self::Bits512(ex) => ex.tab.to_string(), + Self::Bits1024(ex) => ex.tab.to_string(), + Self::Bits2048(ex) => ex.tab.to_string(), + } + } +} + +impl vihaco::Reset for TableauCircuit { + fn reset(&mut self) { + match self { + Self::Bits64(ex) => ex.reset(), + Self::Bits128(ex) => ex.reset(), + Self::Bits256(ex) => ex.reset(), + Self::Bits512(ex) => ex.reset(), + Self::Bits1024(ex) => ex.reset(), + Self::Bits2048(ex) => ex.reset(), + }; + } +} + +/// PauliSum-backed inner enum (Heisenberg picture). Per Decision 7 of the plan, +/// the size buckets carry `[u8; N]`-storage `ByteFxHashF64` configs (N = 8, 16, +/// …, 256) rather than the tableau's `[u64; N]` configs; bucket labels match +/// the semantic qubit count (`Bits64` = 64 qubits) so the outer enum's dispatch +/// is uniform across backends. +pub enum PauliSumCircuit { + Bits64(PauliSumExecutor>), + Bits128(PauliSumExecutor>), + Bits256(PauliSumExecutor>), + Bits512(PauliSumExecutor>), + Bits1024(PauliSumExecutor>), + Bits2048(PauliSumExecutor>), +} + +impl PauliSumCircuit { + /// Build a PauliSum-backed circuit, seeding the state with every term: + /// `for (word, coef) in terms { state += (word, coef); }`. Words must + /// already be validated against `info.n_qubits` by the caller. + pub fn new(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Self { + macro_rules! build { + ($variant:ident, $N:literal) => {{ + let mut state = PauliSum::>::builder() + .n_qubits(info.n_qubits) + .strategy(paulisum_strategy(info)) + .build(); + for (word, coef) in terms { + state += (word.as_str(), *coef); + } + let initial = state.clone(); + Self::$variant(PauliSumExecutor { state, initial }) + }}; + } + if info.n_qubits <= 64 { + build!(Bits64, 8) + } else if info.n_qubits <= 128 { + build!(Bits128, 16) + } else if info.n_qubits <= 256 { + build!(Bits256, 32) + } else if info.n_qubits <= 512 { + build!(Bits512, 64) + } else if info.n_qubits <= 1024 { + build!(Bits1024, 128) + } else if info.n_qubits <= 2048 { + build!(Bits2048, 256) + } else { + panic!("No matching PauliSum executor for {} qubits", info.n_qubits); + } + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + match self { + Self::Bits64(ex) => ex.execute_instruction(inst, msg), + Self::Bits128(ex) => ex.execute_instruction(inst, msg), + Self::Bits256(ex) => ex.execute_instruction(inst, msg), + Self::Bits512(ex) => ex.execute_instruction(inst, msg), + Self::Bits1024(ex) => ex.execute_instruction(inst, msg), + Self::Bits2048(ex) => ex.execute_instruction(inst, msg), + } + } + + pub fn state_string(&self) -> String { + match self { + Self::Bits64(ex) => ex.state.to_string(), + Self::Bits128(ex) => ex.state.to_string(), + Self::Bits256(ex) => ex.state.to_string(), + Self::Bits512(ex) => ex.state.to_string(), + Self::Bits1024(ex) => ex.state.to_string(), + Self::Bits2048(ex) => ex.state.to_string(), + } + } +} + +impl vihaco::Reset for PauliSumCircuit { + fn reset(&mut self) { + match self { + Self::Bits64(ex) => ex.reset(), + Self::Bits128(ex) => ex.reset(), + Self::Bits256(ex) => ex.reset(), + Self::Bits512(ex) => ex.reset(), + Self::Bits1024(ex) => ex.reset(), + Self::Bits2048(ex) => ex.reset(), + }; + } +} + +/// LossyPauliSum-backed inner enum. Identical shape to [`PauliSumCircuit`] +/// but with `LossyPauliWord`-keyed configs so loss-channel methods dispatch. +pub enum LossyPauliSumCircuit { + Bits64(LossyPauliSumExecutor>), + Bits128(LossyPauliSumExecutor>), + Bits256(LossyPauliSumExecutor>), + Bits512(LossyPauliSumExecutor>), + Bits1024(LossyPauliSumExecutor>), + Bits2048(LossyPauliSumExecutor>), +} + +impl LossyPauliSumCircuit { + /// Build a LossyPauliSum-backed circuit, seeding every term via + /// `state += (word, coef)`. Words must already be validated against + /// `info.n_qubits` by the caller. + pub fn new(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Self { + macro_rules! build { + ($variant:ident, $N:literal) => {{ + let mut state = PauliSum::>::builder() + .n_qubits(info.n_qubits) + .strategy(paulisum_strategy(info)) + .build(); + for (word, coef) in terms { + state += (word.as_str(), *coef); + } + let initial = state.clone(); + Self::$variant(LossyPauliSumExecutor { state, initial }) + }}; + } + if info.n_qubits <= 64 { + build!(Bits64, 8) + } else if info.n_qubits <= 128 { + build!(Bits128, 16) + } else if info.n_qubits <= 256 { + build!(Bits256, 32) + } else if info.n_qubits <= 512 { + build!(Bits512, 64) + } else if info.n_qubits <= 1024 { + build!(Bits1024, 128) + } else if info.n_qubits <= 2048 { + build!(Bits2048, 256) + } else { + panic!( + "No matching LossyPauliSum executor for {} qubits", + info.n_qubits + ); + } + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + match self { + Self::Bits64(ex) => ex.execute_instruction(inst, msg), + Self::Bits128(ex) => ex.execute_instruction(inst, msg), + Self::Bits256(ex) => ex.execute_instruction(inst, msg), + Self::Bits512(ex) => ex.execute_instruction(inst, msg), + Self::Bits1024(ex) => ex.execute_instruction(inst, msg), + Self::Bits2048(ex) => ex.execute_instruction(inst, msg), + } + } + + pub fn state_string(&self) -> String { + match self { + Self::Bits64(ex) => ex.state.to_string(), + Self::Bits128(ex) => ex.state.to_string(), + Self::Bits256(ex) => ex.state.to_string(), + Self::Bits512(ex) => ex.state.to_string(), + Self::Bits1024(ex) => ex.state.to_string(), + Self::Bits2048(ex) => ex.state.to_string(), + } + } +} + +impl vihaco::Reset for LossyPauliSumCircuit { + fn reset(&mut self) { + match self { + Self::Bits64(ex) => ex.reset(), + Self::Bits128(ex) => ex.reset(), + Self::Bits256(ex) => ex.reset(), + Self::Bits512(ex) => ex.reset(), + Self::Bits1024(ex) => ex.reset(), + Self::Bits2048(ex) => ex.reset(), + }; + } +} + +/// Outer `Circuit` enum: backend selector. Picks one of the three inner enums +/// based on `info.backend` at construction time; from there, every per-step +/// call routes outer → inner → executor. +pub enum Circuit { + Tableau(TableauCircuit), + PauliSum(PauliSumCircuit), + LossyPauliSum(LossyPauliSumCircuit), +} + +#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +impl Circuit { + /// Build a Tableau-backed circuit. Tableau init only needs `n_qubits` and + /// `coefficient_threshold` from `info`; no observable required. + pub fn tableau(info: &PPVMDeviceInfo) -> Self { + Self::Tableau(TableauCircuit::new( + info.n_qubits, + info.coefficient_threshold, + )) + } + + /// Same as [`Circuit::tableau`], but seed the tableau RNG deterministically + /// so a shot is reproducible. + pub fn tableau_with_seed(info: &PPVMDeviceInfo, seed: u64) -> Self { + Self::Tableau(TableauCircuit::new_with_seed( + info.n_qubits, + info.coefficient_threshold, + seed, + )) + } + + /// Build a PauliSum-backed circuit, seeding the state with every term in + /// `terms`. Each `(word, coef)` is added via `state += (word, coef)`; the + /// caller is responsible for having parsed/validated the words against + /// `info.n_qubits` (see `parse_observable_terms` in `composite.rs`). + pub fn paulisum(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Self { + Self::PauliSum(PauliSumCircuit::new(info, terms)) + } + + /// Build a LossyPauliSum-backed circuit. Same contract as + /// [`Circuit::paulisum`]. + pub fn lossy_paulisum(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Self { + Self::LossyPauliSum(LossyPauliSumCircuit::new(info, terms)) + } + + fn execute( + &mut self, + inst: CircuitInstruction, + msg: CircuitMessage, + ) -> Result> { + self.execute_instruction(&inst, &msg) + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + match self { + Self::Tableau(c) => c.execute_instruction(inst, msg), + Self::PauliSum(c) => c.execute_instruction(inst, msg), + Self::LossyPauliSum(c) => c.execute_instruction(inst, msg), + } + } + + /// Render the current state. Used by the REPL's `show` command. + pub fn state_string(&self) -> String { + match self { + Self::Tableau(c) => c.state_string(), + Self::PauliSum(c) => c.state_string(), + Self::LossyPauliSum(c) => c.state_string(), + } + } +} + +#[observe(CircuitEffect, effect=CircuitOutcomeEffect)] +impl Circuit { + fn observe_circuit_effect( + &mut self, + effect: &CircuitEffect, + ) -> Result> { + self.execute_instruction(&effect.inst, &effect.msg) + } +} + +impl vihaco::Reset for Circuit { + fn reset(&mut self) { + match self { + Self::Tableau(c) => c.reset(), + Self::PauliSum(c) => c.reset(), + Self::LossyPauliSum(c) => c.reset(), + }; + } +} + +impl Default for Circuit { + fn default() -> Self { + // Default backend is Tableau, which doesn't require an observable. + Self::tableau(&PPVMDeviceInfo::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info(n_qubits: usize) -> PPVMDeviceInfo { + PPVMDeviceInfo { + n_qubits, + ..Default::default() + } + } + + /// Dispatch a single-qubit `Measure` and read the outcome out of the + /// returned effect. + fn measure(circuit: &mut Circuit, addr: usize) -> MeasurementOutcome { + let effects = circuit + .execute_instruction(&CircuitInstruction::Measure, &CircuitMessage::Qubit(addr)) + .unwrap(); + match effects.into_iter().next() { + Some(CircuitOutcomeEffect::Measurement(m)) => m.measurement_results[0], + other => panic!("expected a measurement effect, got {other:?}"), + } + } + + /// Smoke test: a single-qubit gate dispatches to the tableau backend and + /// flips the qubit. + #[test] + fn tableau_backend_x_flips_qubit() { + let mut circuit = Circuit::tableau(&info(1)); + circuit + .execute_instruction(&CircuitInstruction::X, &CircuitMessage::Qubit(0)) + .unwrap(); + assert_eq!(measure(&mut circuit, 0), MeasurementOutcome::One); + } + + /// Smoke test: a two-qubit gate dispatches correctly — `X(0); CNOT(0, 1)` + /// leaves both qubits in |1⟩. + #[test] + fn tableau_backend_cnot_propagates_flip() { + let mut circuit = Circuit::tableau(&info(2)); + circuit + .execute_instruction(&CircuitInstruction::X, &CircuitMessage::Qubit(0)) + .unwrap(); + circuit + .execute_instruction(&CircuitInstruction::CNOT, &CircuitMessage::TwoQubit(0, 1)) + .unwrap(); + assert_eq!(measure(&mut circuit, 0), MeasurementOutcome::One); + assert_eq!(measure(&mut circuit, 1), MeasurementOutcome::One); + } +} diff --git a/crates/ppvm-vihaco/src/device_info.rs b/crates/ppvm-vihaco/src/device_info.rs new file mode 100644 index 000000000..77e60ac79 --- /dev/null +++ b/crates/ppvm-vihaco/src/device_info.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Device configuration for the circuit component. Split into its own module +//! so [`crate::component`] can depend on [`PPVMDeviceInfo`] without pulling in +//! the composite machine (which is added in a later PR). + +pub const PPVM_MAGIC: u32 = 0x5050564D; + +/// Which execution backend the circuit runs on. Selected via the +/// `device circuit.backend` header; defaults to `Tableau` so existing +/// programs that don't declare a backend keep working. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, vihaco_parser::Parse)] +pub enum BackendKind { + #[default] + Tableau, + PauliSum, + #[token = "lossy_paulisum"] + LossyPauliSum, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PPVMDeviceInfo { + pub magic: u32, + pub n_qubits: usize, + pub coefficient_threshold: f64, + pub backend: BackendKind, + pub observable: Option, + pub max_pauli_weight: Option, +} + +impl Default for PPVMDeviceInfo { + fn default() -> Self { + Self { + magic: PPVM_MAGIC, + n_qubits: 0, + coefficient_threshold: 1e-10, + backend: BackendKind::default(), + observable: None, + max_pauli_weight: None, + } + } +} diff --git a/crates/ppvm-vihaco/src/lib.rs b/crates/ppvm-vihaco/src/lib.rs new file mode 100644 index 000000000..534f523c9 --- /dev/null +++ b/crates/ppvm-vihaco/src/lib.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! The circuit component of the PPVM: a `Circuit` that dispatches +//! [`vihaco_circuit_isa::CircuitInstruction`]s to a tableau or PauliSum +//! backend. The composite machine that drives it is added in a later PR. + +pub mod component; +pub mod device_info; +pub mod measurements; + +/// Re-exported so consumers can name gates for the circuit component without +/// depending on the ISA crate directly. +pub use vihaco_circuit_isa::CircuitInstruction; + +pub mod prelude { + pub use crate::component::Circuit; + pub use crate::device_info::{BackendKind, PPVMDeviceInfo}; +} diff --git a/crates/ppvm-vihaco/src/measurements.rs b/crates/ppvm-vihaco/src/measurements.rs new file mode 100644 index 000000000..1881c0d51 --- /dev/null +++ b/crates/ppvm-vihaco/src/measurements.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use eyre::Result; +use smallvec::SmallVec; +use vihaco::{Effects, observe}; + +/// Measurement results are represented as an integer enum: +/// 0: state |0> +/// 1: state |1> +/// 2: qubit has been lost prior to measurement +/// In bytecode, this is represented as a u32 integer, which is simpler than +/// e.g. two boolean values and matches semantics elsewhere +#[repr(u8)] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum MeasurementOutcome { + Zero = 0, + One = 1, + Lost = 2, +} + +pub type MeasurementResult = SmallVec<[MeasurementOutcome; 8]>; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct MeasurementEffect { + pub measurement_results: MeasurementResult, +} + +impl From> for MeasurementOutcome { + fn from(m: Option) -> Self { + match m { + Some(false) => Self::Zero, + Some(true) => Self::One, + None => Self::Lost, + } + } +} + +#[derive(Debug, Default)] +pub struct MeasurementObserver { + pub record: Vec, +} + +#[observe(MeasurementEffect)] +impl MeasurementObserver { + fn observe_measurement_effect(&mut self, effect: &MeasurementEffect) -> Result> { + self.record.push(effect.measurement_results.clone()); + Ok(Effects::none()) + } +} + +/// Per-step trace value emitted by the `Trace` instruction on the PauliSum and +/// LossyPauliSum backends. The plan's Decision 5 keeps trace and measurement +/// records as two parallel streams; this effect feeds the trace stream. +#[derive(Debug, Clone, PartialEq)] +pub struct TraceEffect { + pub value: f64, +} + +#[derive(Debug, Default)] +pub struct TraceObserver { + pub record: Vec, +} + +#[observe(TraceEffect)] +impl TraceObserver { + fn observe_trace_effect(&mut self, effect: &TraceEffect) -> Result> { + self.record.push(effect.value); + Ok(Effects::none()) + } +} + +/// Union of the two effect types a circuit instruction can produce. The plan's +/// structural note (Task 6) calls for broadening the `#[component(..., effect = +/// ...)]` annotation on `Circuit` and the executors to a union so a single +/// `Trace` (or `Measure`) instruction can fan out to the right observer. +#[derive(Debug, Clone, PartialEq)] +pub enum CircuitOutcomeEffect { + Measurement(MeasurementEffect), + Trace(TraceEffect), +} + +impl From for CircuitOutcomeEffect { + fn from(value: MeasurementEffect) -> Self { + Self::Measurement(value) + } +} + +impl From for CircuitOutcomeEffect { + fn from(value: TraceEffect) -> Self { + Self::Trace(value) + } +} From be9136dbcd10affaf377fb2342b61b613add72c6 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 14:34:10 +0200 Subject: [PATCH 2/2] refactor(ppvm-vihaco): return eyre::Result from circuit constructors The size-bucketed circuit constructors panicked when n_qubits exceeded the widest executor bucket. Since n_qubits comes from user-supplied device headers and this backend drives a TUI, a panic would tear down the terminal rather than surface a friendly message. Return eyre::Result from the four constructor pairs (Tableau/PauliSum/LossyPauliSum, plus new_with_seed) so callers can report the error instead of aborting. Add a MAX_QUBITS constant so the ceiling is single-sourced across the bucket checks and error messages. Default stays infallible (0 qubits always fits the smallest bucket). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-vihaco/src/component.rs | 119 +++++++++++++++++++--------- 1 file changed, 82 insertions(+), 37 deletions(-) diff --git a/crates/ppvm-vihaco/src/component.rs b/crates/ppvm-vihaco/src/component.rs index 69accec9f..7ed668233 100644 --- a/crates/ppvm-vihaco/src/component.rs +++ b/crates/ppvm-vihaco/src/component.rs @@ -17,6 +17,11 @@ use ppvm_tableau::prelude::*; use vihaco::{Effects, component, observe}; use vihaco_circuit_isa::{CircuitEffect, CircuitInstruction, CircuitMessage}; +/// Largest qubit count any backend can simulate. The widest size bucket is +/// backed by 2048-bit integers (`U2048` / `[u8; 256]`), so every constructor +/// rejects `n_qubits > MAX_QUBITS` rather than panicking. +pub const MAX_QUBITS: usize = 2048; + /// Truncation strategy used by every `PauliSum` / `LossyPauliSum` size bucket. /// Coefficient-threshold pruning is always on; the Pauli-weight cap is set per /// run from the header (defaults to `usize::MAX` = no cap). @@ -565,37 +570,39 @@ pub enum TableauCircuit { } impl TableauCircuit { - pub fn new(n_qubits: usize, coefficient_threshold: f64) -> Self { + pub fn new(n_qubits: usize, coefficient_threshold: f64) -> Result { if n_qubits <= 64 { let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); - Self::Bits64(CircuitExecutor { tab }) + Ok(Self::Bits64(CircuitExecutor { tab })) } else if n_qubits <= 128 { let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); - Self::Bits128(CircuitExecutor { tab }) + Ok(Self::Bits128(CircuitExecutor { tab })) } else if n_qubits <= 256 { let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); - Self::Bits256(CircuitExecutor { tab }) + Ok(Self::Bits256(CircuitExecutor { tab })) } else if n_qubits <= 512 { let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); - Self::Bits512(CircuitExecutor { tab }) + Ok(Self::Bits512(CircuitExecutor { tab })) } else if n_qubits <= 1024 { let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); - Self::Bits1024(CircuitExecutor { tab }) - } else if n_qubits <= 2048 { + Ok(Self::Bits1024(CircuitExecutor { tab })) + } else if n_qubits <= MAX_QUBITS { let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); - Self::Bits2048(CircuitExecutor { tab }) + Ok(Self::Bits2048(CircuitExecutor { tab })) } else { - panic!("No matching executor for {} qubits", n_qubits); + Err(eyre!( + "cannot simulate {n_qubits} qubits: maximum is {MAX_QUBITS}" + )) } } /// Same as [`TableauCircuit::new`], but seed the RNG deterministically so a /// shot is reproducible. - pub fn new_with_seed(n_qubits: usize, coefficient_threshold: f64, seed: u64) -> Self { + pub fn new_with_seed(n_qubits: usize, coefficient_threshold: f64, seed: u64) -> Result { macro_rules! seeded { ($variant:ident) => {{ let tab = GeneralizedTableau::new_with_seed(n_qubits, coefficient_threshold, seed); - Self::$variant(CircuitExecutor { tab }) + Ok(Self::$variant(CircuitExecutor { tab })) }}; } if n_qubits <= 64 { @@ -608,10 +615,12 @@ impl TableauCircuit { seeded!(Bits512) } else if n_qubits <= 1024 { seeded!(Bits1024) - } else if n_qubits <= 2048 { + } else if n_qubits <= MAX_QUBITS { seeded!(Bits2048) } else { - panic!("No matching executor for {} qubits", n_qubits); + Err(eyre!( + "cannot simulate {n_qubits} qubits: maximum is {MAX_QUBITS}" + )) } } @@ -673,7 +682,7 @@ impl PauliSumCircuit { /// Build a PauliSum-backed circuit, seeding the state with every term: /// `for (word, coef) in terms { state += (word, coef); }`. Words must /// already be validated against `info.n_qubits` by the caller. - pub fn new(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Self { + pub fn new(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Result { macro_rules! build { ($variant:ident, $N:literal) => {{ let mut state = PauliSum::>::builder() @@ -684,7 +693,7 @@ impl PauliSumCircuit { state += (word.as_str(), *coef); } let initial = state.clone(); - Self::$variant(PauliSumExecutor { state, initial }) + Ok(Self::$variant(PauliSumExecutor { state, initial })) }}; } if info.n_qubits <= 64 { @@ -697,10 +706,13 @@ impl PauliSumCircuit { build!(Bits512, 64) } else if info.n_qubits <= 1024 { build!(Bits1024, 128) - } else if info.n_qubits <= 2048 { + } else if info.n_qubits <= MAX_QUBITS { build!(Bits2048, 256) } else { - panic!("No matching PauliSum executor for {} qubits", info.n_qubits); + Err(eyre!( + "cannot simulate {} qubits: maximum is {MAX_QUBITS}", + info.n_qubits + )) } } @@ -759,7 +771,7 @@ impl LossyPauliSumCircuit { /// Build a LossyPauliSum-backed circuit, seeding every term via /// `state += (word, coef)`. Words must already be validated against /// `info.n_qubits` by the caller. - pub fn new(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Self { + pub fn new(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Result { macro_rules! build { ($variant:ident, $N:literal) => {{ let mut state = PauliSum::>::builder() @@ -770,7 +782,7 @@ impl LossyPauliSumCircuit { state += (word.as_str(), *coef); } let initial = state.clone(); - Self::$variant(LossyPauliSumExecutor { state, initial }) + Ok(Self::$variant(LossyPauliSumExecutor { state, initial })) }}; } if info.n_qubits <= 64 { @@ -783,13 +795,13 @@ impl LossyPauliSumCircuit { build!(Bits512, 64) } else if info.n_qubits <= 1024 { build!(Bits1024, 128) - } else if info.n_qubits <= 2048 { + } else if info.n_qubits <= MAX_QUBITS { build!(Bits2048, 256) } else { - panic!( - "No matching LossyPauliSum executor for {} qubits", + Err(eyre!( + "cannot simulate {} qubits: maximum is {MAX_QUBITS}", info.n_qubits - ); + )) } } @@ -846,35 +858,35 @@ pub enum Circuit { impl Circuit { /// Build a Tableau-backed circuit. Tableau init only needs `n_qubits` and /// `coefficient_threshold` from `info`; no observable required. - pub fn tableau(info: &PPVMDeviceInfo) -> Self { - Self::Tableau(TableauCircuit::new( + pub fn tableau(info: &PPVMDeviceInfo) -> Result { + Ok(Self::Tableau(TableauCircuit::new( info.n_qubits, info.coefficient_threshold, - )) + )?)) } /// Same as [`Circuit::tableau`], but seed the tableau RNG deterministically /// so a shot is reproducible. - pub fn tableau_with_seed(info: &PPVMDeviceInfo, seed: u64) -> Self { - Self::Tableau(TableauCircuit::new_with_seed( + pub fn tableau_with_seed(info: &PPVMDeviceInfo, seed: u64) -> Result { + Ok(Self::Tableau(TableauCircuit::new_with_seed( info.n_qubits, info.coefficient_threshold, seed, - )) + )?)) } /// Build a PauliSum-backed circuit, seeding the state with every term in /// `terms`. Each `(word, coef)` is added via `state += (word, coef)`; the /// caller is responsible for having parsed/validated the words against /// `info.n_qubits` (see `parse_observable_terms` in `composite.rs`). - pub fn paulisum(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Self { - Self::PauliSum(PauliSumCircuit::new(info, terms)) + pub fn paulisum(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Result { + Ok(Self::PauliSum(PauliSumCircuit::new(info, terms)?)) } /// Build a LossyPauliSum-backed circuit. Same contract as /// [`Circuit::paulisum`]. - pub fn lossy_paulisum(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Self { - Self::LossyPauliSum(LossyPauliSumCircuit::new(info, terms)) + pub fn lossy_paulisum(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Result { + Ok(Self::LossyPauliSum(LossyPauliSumCircuit::new(info, terms)?)) } fn execute( @@ -929,8 +941,9 @@ impl vihaco::Reset for Circuit { impl Default for Circuit { fn default() -> Self { - // Default backend is Tableau, which doesn't require an observable. - Self::tableau(&PPVMDeviceInfo::default()) + // Default backend is Tableau with 0 qubits, which always fits the + // smallest bucket, so construction here is infallible. + Self::tableau(&PPVMDeviceInfo::default()).expect("0-qubit tableau is always constructible") } } @@ -961,7 +974,7 @@ mod tests { /// flips the qubit. #[test] fn tableau_backend_x_flips_qubit() { - let mut circuit = Circuit::tableau(&info(1)); + let mut circuit = Circuit::tableau(&info(1)).unwrap(); circuit .execute_instruction(&CircuitInstruction::X, &CircuitMessage::Qubit(0)) .unwrap(); @@ -972,7 +985,7 @@ mod tests { /// leaves both qubits in |1⟩. #[test] fn tableau_backend_cnot_propagates_flip() { - let mut circuit = Circuit::tableau(&info(2)); + let mut circuit = Circuit::tableau(&info(2)).unwrap(); circuit .execute_instruction(&CircuitInstruction::X, &CircuitMessage::Qubit(0)) .unwrap(); @@ -982,4 +995,36 @@ mod tests { assert_eq!(measure(&mut circuit, 0), MeasurementOutcome::One); assert_eq!(measure(&mut circuit, 1), MeasurementOutcome::One); } + + // ─── Construction rejects more qubits than the backend ceiling ──────── + // + // The widest executor bucket is 2048 qubits (U2048 / `[u8; 256]`); beyond + // that there is no backing width, so the constructors return an error + // rather than panicking — a panic would tear down the TUI that drives this. + + #[test] + fn tableau_rejects_more_than_2048_qubits() { + assert!(Circuit::tableau(&info(MAX_QUBITS + 1)).is_err()); + } + + #[test] + fn tableau_with_seed_rejects_more_than_2048_qubits() { + assert!(Circuit::tableau_with_seed(&info(MAX_QUBITS + 1), 0).is_err()); + } + + #[test] + fn paulisum_rejects_more_than_2048_qubits() { + assert!(Circuit::paulisum(&info(MAX_QUBITS + 1), &[]).is_err()); + } + + #[test] + fn lossy_paulisum_rejects_more_than_2048_qubits() { + assert!(Circuit::lossy_paulisum(&info(MAX_QUBITS + 1), &[]).is_err()); + } + + #[test] + fn constructs_at_the_2048_qubit_boundary() { + // 2048 is the last valid bucket; it must still succeed. + assert!(Circuit::tableau(&info(MAX_QUBITS)).is_ok()); + } }