From 0b6a276da2f157d532a6ea010bf7cf1072dac351 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 12:20:58 +0200 Subject: [PATCH 1/7] feat(ppvm-vihaco): add the composite machine and VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the `ppvm-vihaco` crate on top of the circuit component: - `composite.rs` — the `PPVM` composite: the vihaco machine (CPU + program loader) driving the circuit component, measurement/trace observers, backend selection from the device header, resets, and single-instruction stepping. Imports `BackendKind` / `PPVMDeviceInfo` / `PPVM_MAGIC` from `device_info` and re-exports them so existing `crate::composite::{…}` paths keep resolving. - `syntax.rs` — `.sst` header parsing / lowering (`PPVMHeader`, `PPVMResolver`). - `bytecode.rs` — `.ssb` bytecode emission / loading (magic-tagged). - `observable.rs` — observable-header parsing for the PauliSum backends. - `shots.rs` — multi-shot execution (serial + optional rayon parallelism). - `lib.rs` — full module wiring plus the `load`/`run`/`dump`/`compile`/`parse` helpers and `PPVMModule` type. - `Cargo.toml` — adds `vihaco-cpu`, `log`, and the optional `rayon` feature. Includes the `.sst` integration fixtures (bell, GHZ, function calls, branching, trotter truncation, loss/trace, rotations) and the composite half of the PauliSum reset. Tests: `cargo test -p ppvm-vihaco` — 111 unit + 28 integration; green with and without `--features rayon`. clippy + machete clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 19 + crates/ppvm-vihaco/Cargo.toml | 18 +- crates/ppvm-vihaco/src/bytecode.rs | 539 +++++++ crates/ppvm-vihaco/src/composite.rs | 1351 +++++++++++++++++ crates/ppvm-vihaco/src/lib.rs | 113 +- crates/ppvm-vihaco/src/observable.rs | 349 +++++ crates/ppvm-vihaco/src/shots.rs | 203 +++ crates/ppvm-vihaco/src/syntax.rs | 724 +++++++++ crates/ppvm-vihaco/tests/bell.sst | 18 + .../ppvm-vihaco/tests/branch_on_outcome.sst | 29 + .../ppvm-vihaco/tests/branch_on_outcome_x.sst | 30 + crates/ppvm-vihaco/tests/function_call.sst | 19 + .../tests/function_call_branch_both.sst | 63 + .../ppvm-vihaco/tests/function_call_ret.sst | 42 + crates/ppvm-vihaco/tests/hello_circuit.sst | 16 + .../tests/lossy_paulisum_loss_trace.sst | 28 + .../ppvm-vihaco/tests/paulisum_bell_trace.sst | 18 + .../tests/paulisum_ghz_xxx_trace.sst | 27 + .../tests/paulisum_measure_error.sst | 9 + .../tests/paulisum_multi_term_trace.sst | 11 + .../ppvm-vihaco/tests/paulisum_ry_z_trace.sst | 17 + .../tests/paulisum_trotter_truncate.sst | 37 + crates/ppvm-vihaco/tests/rotxy.sst | 15 + crates/ppvm-vihaco/tests/sst_fixtures.rs | 532 +++++++ .../ppvm-vihaco/tests/tableau_bell_trace.sst | 18 + .../tests/tableau_ghz_xxx_trace.sst | 20 + .../ppvm-vihaco/tests/tableau_ry_z_trace.sst | 13 + 27 files changed, 4265 insertions(+), 13 deletions(-) create mode 100644 crates/ppvm-vihaco/src/bytecode.rs create mode 100644 crates/ppvm-vihaco/src/composite.rs create mode 100644 crates/ppvm-vihaco/src/observable.rs create mode 100644 crates/ppvm-vihaco/src/shots.rs create mode 100644 crates/ppvm-vihaco/src/syntax.rs create mode 100644 crates/ppvm-vihaco/tests/bell.sst create mode 100644 crates/ppvm-vihaco/tests/branch_on_outcome.sst create mode 100644 crates/ppvm-vihaco/tests/branch_on_outcome_x.sst create mode 100644 crates/ppvm-vihaco/tests/function_call.sst create mode 100644 crates/ppvm-vihaco/tests/function_call_branch_both.sst create mode 100644 crates/ppvm-vihaco/tests/function_call_ret.sst create mode 100644 crates/ppvm-vihaco/tests/hello_circuit.sst create mode 100644 crates/ppvm-vihaco/tests/lossy_paulisum_loss_trace.sst create mode 100644 crates/ppvm-vihaco/tests/paulisum_bell_trace.sst create mode 100644 crates/ppvm-vihaco/tests/paulisum_ghz_xxx_trace.sst create mode 100644 crates/ppvm-vihaco/tests/paulisum_measure_error.sst create mode 100644 crates/ppvm-vihaco/tests/paulisum_multi_term_trace.sst create mode 100644 crates/ppvm-vihaco/tests/paulisum_ry_z_trace.sst create mode 100644 crates/ppvm-vihaco/tests/paulisum_trotter_truncate.sst create mode 100644 crates/ppvm-vihaco/tests/rotxy.sst create mode 100644 crates/ppvm-vihaco/tests/sst_fixtures.rs create mode 100644 crates/ppvm-vihaco/tests/tableau_bell_trace.sst create mode 100644 crates/ppvm-vihaco/tests/tableau_ghz_xxx_trace.sst create mode 100644 crates/ppvm-vihaco/tests/tableau_ry_z_trace.sst diff --git a/Cargo.lock b/Cargo.lock index 4575ddfcd..d8e9dab88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1296,12 +1296,15 @@ dependencies = [ "bnum", "chumsky 0.10.1", "eyre", + "log", "num", "ppvm-pauli-sum", "ppvm-tableau", + "rayon", "smallvec", "vihaco", "vihaco-circuit-isa", + "vihaco-cpu", "vihaco-parser", "vihaco-parser-core", ] @@ -1870,6 +1873,22 @@ dependencies = [ "vihaco-parser-core", ] +[[package]] +name = "vihaco-cpu" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be6cffffe3fca901bcddc389422dd9d2f7041038571a758a03c63e53ce1b6d5e" +dependencies = [ + "chumsky 0.10.1", + "codespan", + "eyre", + "log", + "vihaco", + "vihaco-derive", + "vihaco-parser", + "vihaco-parser-core", +] + [[package]] name = "vihaco-derive" version = "0.1.1" diff --git a/crates/ppvm-vihaco/Cargo.toml b/crates/ppvm-vihaco/Cargo.toml index f3267aa7e..f1e479543 100644 --- a/crates/ppvm-vihaco/Cargo.toml +++ b/crates/ppvm-vihaco/Cargo.toml @@ -3,21 +3,27 @@ name = "ppvm-vihaco" version = "0.1.0" edition = "2024" +[features] +# Enables shot-level parallelism in `run_shots_parallel`/`run_shots`. +rayon = ["dep:rayon"] + [dependencies] bitvec = "1.0.1" bnum = { version = "0.13.0", features = ["num-traits"] } chumsky = "0.10.0" eyre = "0.6.12" +log = "0.4.29" num = "0.4.3" -smallvec = "1.15.1" -ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } +rayon = { version = "1.10", optional = true } ppvm-tableau = { version = "0.1.0", path = "../ppvm-tableau" } +smallvec = "1.15.1" vihaco = "0.1.1" +vihaco-cpu = "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" } +ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } -# 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"] +# enable rayon if not on wasm +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ppvm-tableau = { version = "0.1.0", path = "../ppvm-tableau", features = ["rayon"] } diff --git a/crates/ppvm-vihaco/src/bytecode.rs b/crates/ppvm-vihaco/src/bytecode.rs new file mode 100644 index 000000000..ef1153716 --- /dev/null +++ b/crates/ppvm-vihaco/src/bytecode.rs @@ -0,0 +1,539 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Binary bytecode (`.ssb`) round-trip for PPVM modules. +//! +//! Serializes a resolved [`Module`] to a little-endian container — header +//! (magic + version + device info) → strings section → code section — and +//! reads it back. The per-instruction codec is the existing `WriteBytes` / +//! `FromBytes` generated for [`PPVMInstruction`]; this module only frames the +//! container around it. + +use std::io::{Read, Write}; + +use vihaco::instruction::{FromBytes, WriteBytes}; + +use crate::PPVMModule; +use crate::composite::{BackendKind, PPVM_MAGIC, PPVMDeviceInfo, PPVMInstruction}; + +/// Current `.ssb` format version. The reader rejects any other version. +pub const PPVM_BYTECODE_VERSION: u16 = 1; + +/// Byte length of the fixed portion of the header. The actual `header_size` +/// in the stream may exceed this when the optional `observable` string is +/// populated; the reader uses `header_size` to skip to the strings section. +/// +/// Field widths (bytes): magic(4) + version(2) + header_size(4) + n_qubits(4) +/// + coefficient_threshold(8) + backend(1) + max_pauli_weight_present(1) +/// + max_pauli_weight(8) + observable_present(1) = 33. +const FIXED_HEADER_SIZE: u32 = 4 + 2 + 4 + 4 + 8 + 1 + 1 + 8 + 1; + +/// Serialize a resolved module to the v1 `.ssb` byte stream. +pub fn write_module(module: &PPVMModule, w: &mut W) -> eyre::Result<()> { + // v1 serializes only code, strings, and device info. Refuse to silently + // drop any table a future feature might populate. + let populated = if !module.functions.is_empty() { + Some("functions") + } else if !module.labels.is_empty() { + Some("labels") + } else if !module.constants.is_empty() { + Some("constants") + } else if !module.source_symbols.is_empty() { + Some("source_symbols") + } else if module.main_function.is_some() { + Some("main_function") + } else if module.file != 0 { + Some("file") + } else { + None + }; + if let Some(table) = populated { + return Err(eyre::eyre!( + "bytecode v1 cannot represent a populated `{table}`" + )); + } + + let info = &module.extra; + let n_qubits = u32::try_from(info.n_qubits) + .map_err(|_| eyre::eyre!("n_qubits {} does not fit in u32", info.n_qubits))?; + + // The header is `FIXED_HEADER_SIZE` bytes plus, when an observable is + // present, a u32 length followed by its UTF-8 bytes. + let observable_bytes: &[u8] = info + .observable + .as_ref() + .map(String::as_bytes) + .unwrap_or(&[]); + let observable_present: u8 = u8::from(info.observable.is_some()); + let observable_len = u32::try_from(observable_bytes.len()).map_err(|_| { + eyre::eyre!( + "observable length {} does not fit in u32", + observable_bytes.len() + ) + })?; + let observable_trailer: u32 = if info.observable.is_some() { + 4 + observable_len + } else { + 0 + }; + let header_size = FIXED_HEADER_SIZE + observable_trailer; + + // Header. + w.write_all(&PPVM_MAGIC.to_le_bytes())?; + w.write_all(&PPVM_BYTECODE_VERSION.to_le_bytes())?; + w.write_all(&header_size.to_le_bytes())?; + w.write_all(&n_qubits.to_le_bytes())?; + w.write_all(&info.coefficient_threshold.to_le_bytes())?; + w.write_all(&[backend_to_u8(info.backend)])?; + let (mpw_present, mpw_value) = match info.max_pauli_weight { + Some(w) => ( + 1u8, + u64::try_from(w) + .map_err(|_| eyre::eyre!("max_pauli_weight {} does not fit in u64", w))?, + ), + None => (0u8, 0u64), + }; + w.write_all(&[mpw_present])?; + w.write_all(&mpw_value.to_le_bytes())?; + w.write_all(&[observable_present])?; + if info.observable.is_some() { + w.write_all(&observable_len.to_le_bytes())?; + w.write_all(observable_bytes)?; + } + + // Strings section: count, then each entry as len-prefixed UTF-8. + let string_count = + u32::try_from(module.strings.len()).map_err(|_| eyre::eyre!("string count exceeds u32"))?; + w.write_all(&string_count.to_le_bytes())?; + for s in &module.strings { + let len = u32::try_from(s.len()).map_err(|_| eyre::eyre!("string length exceeds u32"))?; + w.write_all(&len.to_le_bytes())?; + w.write_all(s.as_bytes())?; + } + + // Code section: count, then each instruction's fixed-width frame. + let code_count = + u32::try_from(module.code.len()).map_err(|_| eyre::eyre!("code length exceeds u32"))?; + w.write_all(&code_count.to_le_bytes())?; + for inst in &module.code { + inst.write_bytes(w)?; + } + + Ok(()) +} + +/// Reconstruct a module from a v1 `.ssb` byte stream. +pub fn read_module(r: &mut R) -> eyre::Result { + // Header. + let magic = read_u32(r)?; + if magic != PPVM_MAGIC { + return Err(eyre::eyre!( + "not a PPVM bytecode file (magic 0x{magic:08X})" + )); + } + let version = read_u16(r)?; + if version != PPVM_BYTECODE_VERSION { + return Err(eyre::eyre!("unsupported bytecode version {version}")); + } + let header_size = read_u32(r)?; + let n_qubits = read_u32(r)? as usize; + let coefficient_threshold = read_f64(r)?; + let backend = backend_from_u8(read_u8(r)?)?; + let mpw_present = read_u8(r)?; + let mpw_value = read_u64(r)?; + let max_pauli_weight = match mpw_present { + 0 => None, + 1 => Some(usize::try_from(mpw_value).map_err(|_| { + eyre::eyre!("max_pauli_weight {mpw_value} does not fit in usize on this platform") + })?), + other => { + return Err(eyre::eyre!( + "invalid max_pauli_weight presence byte {other}" + )); + } + }; + let observable_present = read_u8(r)?; + let observable = match observable_present { + 0 => None, + 1 => { + let len = read_u32(r)? as usize; + let mut bytes = vec![0u8; len]; + r.read_exact(&mut bytes)?; + Some(String::from_utf8(bytes)?) + } + other => { + return Err(eyre::eyre!("invalid observable presence byte {other}")); + } + }; + + // Sections begin at `header_size`; skip any header bytes beyond what this + // reader knows about (forward compat / self-description). + let consumed = FIXED_HEADER_SIZE + + if observable.is_some() { + 4 + u32::try_from(observable.as_deref().unwrap().len()) + .map_err(|_| eyre::eyre!("observable length does not fit in u32"))? + } else { + 0 + }; + if header_size < consumed { + return Err(eyre::eyre!( + "header_size {header_size} smaller than the {consumed} bytes already consumed" + )); + } + skip_bytes(r, u64::from(header_size - consumed))?; + + // Don't pre-allocate from an untrusted count; grow as entries are read. + let string_count = read_u32(r)?; + let mut strings = Vec::new(); + for _ in 0..string_count { + let len = read_u32(r)? as usize; + let mut bytes = vec![0u8; len]; + r.read_exact(&mut bytes)?; + strings.push(String::from_utf8(bytes)?); + } + + let code_count = read_u32(r)?; + let mut code = Vec::new(); + for _ in 0..code_count { + code.push(PPVMInstruction::from_bytes(r)?); + } + + Ok(PPVMModule { + extra: PPVMDeviceInfo { + magic, + n_qubits, + coefficient_threshold, + backend, + observable, + max_pauli_weight, + }, + strings, + code, + ..Default::default() + }) +} + +fn backend_to_u8(backend: BackendKind) -> u8 { + match backend { + BackendKind::Tableau => 0, + BackendKind::PauliSum => 1, + BackendKind::LossyPauliSum => 2, + } +} + +fn backend_from_u8(byte: u8) -> eyre::Result { + match byte { + 0 => Ok(BackendKind::Tableau), + 1 => Ok(BackendKind::PauliSum), + 2 => Ok(BackendKind::LossyPauliSum), + other => Err(eyre::eyre!("invalid backend tag {other}")), + } +} + +/// Serialize a module to an owned byte vector. +pub fn module_to_bytes(module: &PPVMModule) -> eyre::Result> { + let mut buf = Vec::new(); + write_module(module, &mut buf)?; + Ok(buf) +} + +/// Reconstruct a module from a byte slice. +pub fn module_from_bytes(bytes: &[u8]) -> eyre::Result { + read_module(&mut &bytes[..]) +} + +/// Cheap sniff: does this byte stream begin with the PPVM `.ssb` magic? +/// +/// Reads the leading four bytes the same way [`read_module`] does — as a +/// little-endian `u32` — so a positive result here means [`read_module`] will +/// accept the magic. A stream shorter than the magic is not bytecode. +pub fn is_bytecode(bytes: &[u8]) -> bool { + bytes.len() >= 4 && u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) == PPVM_MAGIC +} + +/// "Dump": compile `.sst` source straight to the `.ssb` byte stream. +pub fn compile_to_bytes(source: &str) -> eyre::Result> { + let module = crate::compile_program(source)?; + module_to_bytes(&module) +} + +fn read_u8(r: &mut R) -> eyre::Result { + let mut b = [0u8; 1]; + r.read_exact(&mut b)?; + Ok(b[0]) +} + +fn read_u16(r: &mut R) -> eyre::Result { + let mut b = [0u8; 2]; + r.read_exact(&mut b)?; + Ok(u16::from_le_bytes(b)) +} + +fn read_u32(r: &mut R) -> eyre::Result { + let mut b = [0u8; 4]; + r.read_exact(&mut b)?; + Ok(u32::from_le_bytes(b)) +} + +fn read_u64(r: &mut R) -> eyre::Result { + let mut b = [0u8; 8]; + r.read_exact(&mut b)?; + Ok(u64::from_le_bytes(b)) +} + +fn read_f64(r: &mut R) -> eyre::Result { + let mut b = [0u8; 8]; + r.read_exact(&mut b)?; + Ok(f64::from_le_bytes(b)) +} + +fn skip_bytes(r: &mut R, n: u64) -> eyre::Result<()> { + let skipped = std::io::copy(&mut r.take(n), &mut std::io::sink())?; + if skipped != n { + return Err(eyre::eyre!("unexpected EOF skipping {n} header bytes")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use vihaco::Value; + + use super::*; + + fn empty_module() -> PPVMModule { + PPVMModule::default() + } + + #[test] + fn round_trips_device_info() { + let mut m = empty_module(); + m.extra.n_qubits = 7; + m.extra.coefficient_threshold = 1e-9; + + let mut buf = Vec::new(); + write_module(&m, &mut buf).unwrap(); + let back = read_module(&mut buf.as_slice()).unwrap(); + + assert_eq!(back, m); + } + + #[test] + fn round_trips_paulisum_device_info() { + let mut m = empty_module(); + m.extra.n_qubits = 6; + m.extra.backend = BackendKind::PauliSum; + m.extra.observable = Some("ZZIIII".to_string()); + m.extra.max_pauli_weight = Some(8); + + let mut buf = Vec::new(); + write_module(&m, &mut buf).unwrap(); + let back = read_module(&mut buf.as_slice()).unwrap(); + + assert_eq!(back, m); + } + + #[test] + fn round_trips_lossy_backend_without_observable() { + let mut m = empty_module(); + m.extra.n_qubits = 4; + m.extra.backend = BackendKind::LossyPauliSum; + // observable and max_pauli_weight stay None — verifies the absent path. + + let mut buf = Vec::new(); + write_module(&m, &mut buf).unwrap(); + let back = read_module(&mut buf.as_slice()).unwrap(); + + assert_eq!(back, m); + assert_eq!(back.extra.observable, None); + assert_eq!(back.extra.max_pauli_weight, None); + } + + #[test] + fn round_trips_code() { + use vihaco_circuit_isa::CircuitInstruction; + use vihaco_cpu::Instruction as Cpu; + + let mut m = empty_module(); + m.extra.n_qubits = 2; + m.code = vec![ + PPVMInstruction::Cpu(Cpu::Const(Value::U64(0))), + PPVMInstruction::Circuit(CircuitInstruction::H), + PPVMInstruction::Circuit(CircuitInstruction::R), + PPVMInstruction::Cpu(Cpu::Branch(1)), + PPVMInstruction::Cpu(Cpu::ConditionalBranch(0, 1)), + PPVMInstruction::Cpu(Cpu::Call(0, 1)), + PPVMInstruction::Cpu(Cpu::Return(0)), + ]; + + let mut buf = Vec::new(); + write_module(&m, &mut buf).unwrap(); + let back = read_module(&mut buf.as_slice()).unwrap(); + + assert_eq!(back, m); + } + + #[test] + fn read_honors_header_size_with_padding() { + let mut m = empty_module(); + m.extra.n_qubits = 3; + m.strings = vec!["hi".to_string()]; + m.code = vec![PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(0))]; + + let mut buf = Vec::new(); + write_module(&m, &mut buf).unwrap(); + + // Simulate a larger header: 4 padding bytes after the fixed fields, + // with header_size bumped to match. The reader must skip to it. + // (This test uses an empty observable, so the on-disk header size + // equals FIXED_HEADER_SIZE.) + buf[6..10].copy_from_slice(&(FIXED_HEADER_SIZE + 4).to_le_bytes()); + for i in 0..4 { + buf.insert(FIXED_HEADER_SIZE as usize + i, 0x00); + } + + let back = read_module(&mut buf.as_slice()).unwrap(); + assert_eq!(back, m); + } + + #[test] + fn compile_to_bytes_round_trips_through_resolve() { + let src = "device circuit.n_qubits 2;\n\ + fn @main() {\n\ + const.u64 0\n\ + circuit.h\n\ + const.u64 0\n\ + const.u64 1\n\ + circuit.cnot\n\ + ret\n\ + }\n"; + + let bytes = compile_to_bytes(src).unwrap(); + let back = module_from_bytes(&bytes).unwrap(); + let expected = crate::compile_program(src).unwrap(); + + assert_eq!(back, expected); + } + + #[test] + fn loaded_bytecode_executes_like_text() { + let src = "device circuit.n_qubits 2;\n\ + fn @main() {\n\ + const.u64 0\n circuit.h\n\ + const.u64 0\n const.u64 1\n circuit.cnot\n\ + const.u64 0\n circuit.measure\n\ + const.u64 1\n circuit.measure\n\ + ret\n }\n"; + let bytes = compile_to_bytes(src).unwrap(); + + let mut machine = crate::composite::PPVM::default(); + machine.load_bytecode(&bytes).unwrap(); + machine.run().unwrap(); + + assert_eq!(machine.measurement_record().len(), 2); + } + + #[test] + fn load_bytecode_file_reads_from_disk() { + let src = "device circuit.n_qubits 1;\n\ + fn @main() { const.u64 0\n circuit.measure\n ret }\n"; + let bytes = compile_to_bytes(src).unwrap(); + let path = std::env::temp_dir().join("ppvm_load_bytecode_file_test.ssb"); + std::fs::write(&path, &bytes).unwrap(); + + let mut machine = crate::composite::PPVM::default(); + machine.load_bytecode_file(path.to_str().unwrap()).unwrap(); + machine.run().unwrap(); + + assert_eq!(machine.measurement_record().len(), 1); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn read_rejects_truncated_input() { + let mut m = empty_module(); + m.extra.n_qubits = 2; + m.code = vec![PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(0))]; + + let mut buf = Vec::new(); + write_module(&m, &mut buf).unwrap(); + buf.truncate(buf.len() - 3); // cut off mid-instruction + + assert!(read_module(&mut buf.as_slice()).is_err()); + } + + #[test] + fn write_rejects_populated_functions_table() { + use vihaco::module::{FunctionInfo, Signature}; + + let mut m = empty_module(); + m.extra.n_qubits = 1; + m.functions.push(FunctionInfo { + name: 0, + signature: Signature { + params: vec![], + ret: vec![], + }, + local_count: 0, + start_address: 0, + end_address: 0, + file: 0, + }); + + let mut buf = Vec::new(); + let err = write_module(&m, &mut buf).unwrap_err(); + assert!(err.to_string().contains("functions"), "err: {err}"); + } + + #[test] + fn read_rejects_bad_magic() { + let mut m = empty_module(); + m.extra.n_qubits = 2; + let mut buf = Vec::new(); + write_module(&m, &mut buf).unwrap(); + // Corrupt the magic (first 4 bytes). + buf[0] ^= 0xFF; + + let err = read_module(&mut buf.as_slice()).unwrap_err(); + assert!( + err.to_string().contains("not a PPVM bytecode file"), + "err: {err}" + ); + } + + #[test] + fn round_trips_strings() { + let mut m = empty_module(); + m.extra.n_qubits = 1; + m.strings = vec![ + String::new(), + "hello".to_string(), + "tab\tnl\nquote\"".to_string(), + "üñîçødé ⚛".to_string(), + ]; + + let mut buf = Vec::new(); + write_module(&m, &mut buf).unwrap(); + let back = read_module(&mut buf.as_slice()).unwrap(); + + assert_eq!(back, m); + } + + #[test] + fn read_rejects_unsupported_version() { + let mut m = empty_module(); + m.extra.n_qubits = 2; + let mut buf = Vec::new(); + write_module(&m, &mut buf).unwrap(); + // Bump the version (bytes 4..6) past the supported one. + let bad = (PPVM_BYTECODE_VERSION + 1).to_le_bytes(); + buf[4] = bad[0]; + buf[5] = bad[1]; + + let err = read_module(&mut buf.as_slice()).unwrap_err(); + assert!( + err.to_string().contains("unsupported bytecode version"), + "err: {err}" + ); + } +} diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs new file mode 100644 index 000000000..f95fb924e --- /dev/null +++ b/crates/ppvm-vihaco/src/composite.rs @@ -0,0 +1,1351 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use eyre::{Result, eyre}; +use vihaco::frame::Frame; +use vihaco::machine::StackFrame; +use vihaco::observer::stdio::{StdoutEffect, StdoutObserver}; +use vihaco::traits::{GetProgramGlobal, ProgramCounter, StackMemory}; +use vihaco::{Effects, Observe, ProgramLoader, Value, composite, observe}; +use vihaco_cpu::{CPU, CPUMessage}; + +/// Re-exported so consumers (e.g. the CLI debugger) can match on step results +/// without depending on `vihaco-cpu` directly. +pub use vihaco_cpu::StepOutcome; + +use crate::component::Circuit; +#[cfg(test)] +use crate::component::TableauCircuit; +use crate::measurements::{ + CircuitOutcomeEffect, MeasurementEffect, MeasurementObserver, MeasurementResult, TraceEffect, + TraceObserver, +}; +use vihaco_circuit_isa::{CircuitEffect, CircuitInstruction, CircuitMessage}; + +// Device configuration lives in `device_info` (shipped with the circuit +// component so it compiles without the composite). Re-exported here so the +// existing `crate::composite::{…}` paths keep resolving. +pub use crate::device_info::{BackendKind, PPVM_MAGIC, PPVMDeviceInfo}; + +pub type Instruction = PPVMInstruction; + +#[composite] +#[derive(Default)] +pub struct PPVM { + #[program] + loader: ProgramLoader, + + #[device(0x00)] + cpu: CPU, + + #[device(0x01)] + circuit: Circuit, + + stdout: StdoutObserver, + + measurement_record: MeasurementObserver, + + trace_record: TraceObserver, +} + +#[derive(Debug, Clone)] +pub enum PPVMEffect { + Step(StepOutcome), + Stdout(StdoutEffect), + Circuit(Box), + Measurement(MeasurementEffect), + Trace(TraceEffect), +} + +#[observe(vihaco::observer::stdio::StdoutEffect, effect = PPVMEffect)] +impl PPVM { + fn observe_stdout_effect(&mut self, effect: &StdoutEffect) -> eyre::Result> { + Observe::::observe(&mut self.stdout, effect) + } +} + +#[observe(CircuitEffect, effect = PPVMEffect)] +impl PPVM { + fn observe_circuit_effect( + &mut self, + effect: &CircuitEffect, + ) -> eyre::Result> { + Observe::::observe(&mut self.circuit, effect) + } +} + +#[observe(MeasurementEffect, effect = PPVMEffect)] +impl PPVM { + fn observe_measurement_effect( + &mut self, + effect: &MeasurementEffect, + ) -> eyre::Result> { + Observe::::observe(&mut self.measurement_record, effect) + } +} + +#[observe(TraceEffect, effect = PPVMEffect)] +impl PPVM { + fn observe_trace_effect(&mut self, effect: &TraceEffect) -> eyre::Result> { + Observe::::observe(&mut self.trace_record, effect) + } +} + +impl From for PPVMEffect { + fn from(value: StdoutEffect) -> Self { + Self::Stdout(value) + } +} + +impl From for PPVMEffect { + fn from(value: MeasurementEffect) -> Self { + Self::Measurement(value) + } +} + +impl From for PPVMEffect { + fn from(value: TraceEffect) -> Self { + Self::Trace(value) + } +} + +impl From for PPVMEffect { + fn from(value: CircuitOutcomeEffect) -> Self { + match value { + CircuitOutcomeEffect::Measurement(m) => Self::Measurement(m), + CircuitOutcomeEffect::Trace(t) => Self::Trace(t), + } + } +} + +impl std::fmt::Display for PPVMInstruction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PPVMInstruction::Cpu(inst) => inst.fmt(f), + PPVMInstruction::Circuit(inst) => inst.fmt(f), + } + } +} + +impl PartialEq for PPVMInstruction { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (PPVMInstruction::Cpu(a), PPVMInstruction::Cpu(b)) => a == b, + (PPVMInstruction::Circuit(a), PPVMInstruction::Circuit(b)) => a == b, + _ => false, + } + } +} + +impl From for PPVMInstruction { + fn from(value: vihaco_cpu::Instruction) -> Self { + Self::Cpu(value) + } +} + +impl From for PPVMInstruction { + fn from(value: CircuitInstruction) -> Self { + Self::Circuit(value) + } +} + +impl PPVM { + fn resolve_cpu(&mut self, inst: &vihaco_cpu::Instruction) -> eyre::Result { + match inst { + vihaco_cpu::Instruction::IndirectCall => { + let function_id: u32 = self.cpu.stack_top()?.get_function_ref()?; + let function = self.loader.get_function(function_id as usize)?; + Ok(CPUMessage::FunctionInfo { + arity: function.signature.params.len() as u32, + start_address: function.start_address, + }) + } + vihaco_cpu::Instruction::Print => { + let value = *self.cpu.stack_top()?; + match value { + vihaco::Value::String(addr) => { + let string = self.loader.get_string(addr as usize)?.clone(); + Ok(CPUMessage::Print(string)) + } + value => Ok(CPUMessage::Print(value.to_string())), + } + } + _ => Ok(CPUMessage::None), + } + } + + fn resolve_circuit(&mut self, inst: &CircuitInstruction) -> eyre::Result { + use CircuitInstruction::*; + match inst { + X | Y | Z | H | S | SAdj | SqrtX | SqrtY | SqrtXAdj | SqrtYAdj | T | TAdj | Measure + | Reset => { + let q = self.pop_qubit()?; + Ok(CircuitMessage::Qubit(q)) + } + CNOT | CZ => { + let q1 = self.pop_qubit()?; + let q0 = self.pop_qubit()?; + Ok(CircuitMessage::TwoQubit(q0, q1)) + } + RX | RY | RZ | Depolarize | Loss => { + let theta = self.pop_f64()?; + let q = self.pop_qubit()?; + Ok(CircuitMessage::QubitAndFloat(q, theta)) + } + RXX | RYY | RZZ | Depolarize2 => { + let theta = self.pop_f64()?; + let q1 = self.pop_qubit()?; + let q0 = self.pop_qubit()?; + Ok(CircuitMessage::TwoQubitAndFloat(q0, q1, theta)) + } + R => { + let theta = self.pop_f64()?; + let axis_angle = self.pop_f64()?; + let q = self.pop_qubit()?; + Ok(CircuitMessage::QubitAndTwoFloats(q, axis_angle, theta)) + } + U3 => { + let lam = self.pop_f64()?; + let phi = self.pop_f64()?; + let theta = self.pop_f64()?; + let q = self.pop_qubit()?; + Ok(CircuitMessage::QubitU3(q, theta, phi, lam)) + } + + // TODO: pop actual float arrays? + PauliError => { + let pz = self.pop_f64()?; + let py = self.pop_f64()?; + let px = self.pop_f64()?; + let q = self.pop_qubit()?; + Ok(CircuitMessage::QubitAndFloatArr3(q, [px, py, pz])) + } + CorrelatedLoss => { + let p2 = self.pop_f64()?; + let p1 = self.pop_f64()?; + let p0 = self.pop_f64()?; + let q1 = self.pop_qubit()?; + let q0 = self.pop_qubit()?; + Ok(CircuitMessage::TwoQubitAndFloatArr3(q0, q1, [p0, p1, p2])) + } + TwoQubitPauliError => { + let mut ps = [0.0; 15]; + for p in ps.iter_mut().rev() { + *p = self.pop_f64()?; + } + let q1 = self.pop_qubit()?; + let q0 = self.pop_qubit()?; + Ok(CircuitMessage::TwoQubitAndFloatArr15(q0, q1, ps)) + } + Trace => { + let s = self.pop_string()?; + Ok(CircuitMessage::PauliPatternStr(s)) + } + Truncate => Ok(CircuitMessage::None), + } + } + + fn pop_qubit(&mut self) -> eyre::Result { + match self.cpu.stack_pop()? { + vihaco::Value::U32(v) => Ok(v as usize), + vihaco::Value::U64(v) => usize::try_from(v).map_err(Into::into), + vihaco::Value::I64(v) => usize::try_from(v).map_err(Into::into), + v => Err(eyre::eyre!("Expected qubit address, got {:?}", v)), + } + } + + fn pop_f64(&mut self) -> eyre::Result { + match self.cpu.stack_pop()? { + vihaco::Value::F64(v) => Ok(v), + v => Err(eyre::eyre!("Expected f64 argument, got {:?}", v)), + } + } + + /// Pop a `Value::String(addr)`, look up the addr in the module's string + /// table, and return the owned string. Used by `Trace` to resolve its + /// Pauli-pattern operand before the executor sees the message. + fn pop_string(&mut self) -> eyre::Result { + match self.cpu.stack_pop()? { + vihaco::Value::String(addr) => Ok(self.loader.get_string(addr as usize)?.clone()), + v => Err(eyre::eyre!("Expected string operand, got {:?}", v)), + } + } + + pub fn init(&mut self) -> eyre::Result<()> { + self.init_inner(None) + } + + /// Like [`PPVM::init`], but seed the circuit RNG deterministically so the + /// run is reproducible. + pub fn init_with_seed(&mut self, seed: u64) -> eyre::Result<()> { + self.init_inner(Some(seed)) + } + + fn init_inner(&mut self, seed: Option) -> eyre::Result<()> { + let info = &self.loader.module.extra; + if info.n_qubits == 0 { + return Err(eyre::eyre!("device circuit.n_qubits must be declared")); + } + self.circuit = match (info.backend, seed) { + (BackendKind::Tableau, None) => Circuit::tableau(info), + (BackendKind::Tableau, Some(seed)) => Circuit::tableau_with_seed(info, seed), + (BackendKind::PauliSum, _) => { + let terms = parse_observable_terms(info)?; + Circuit::paulisum(info, &terms) + } + (BackendKind::LossyPauliSum, _) => { + let terms = parse_observable_terms(info)?; + Circuit::lossy_paulisum(info, &terms) + } + }; + + // push entry frame + self.cpu.push_frame(Frame { + base: 0, + span: (0, 0, 0), + function: None, + ret_pc: 0, + }); + + Ok(()) + } + + pub fn load( + &mut self, + module: &vihaco::module::Module, + ) -> eyre::Result<()> { + self.loader.module = module.clone(); + Ok(()) + } + + pub fn step_once(&mut self) -> eyre::Result { + let inst = self.peek_instruction()?.clone(); + let effects = self.execute_effects(inst)?; + self.continue_effects(effects) + } + + /// Program counter: the index of the next instruction to execute. + pub fn current_pc(&self) -> u32 { + self.loader.pc() + } + + /// The next instruction to execute, or `None` once execution has run off + /// the end of the code. Intended for debuggers/inspection. + pub fn current_instruction(&self) -> Option { + self.peek_instruction().ok().cloned() + } + + /// Append one REPL command's lowered VM ops and run just that block against + /// the persistent state, advancing the pc through it. NOTE: an out-of-range + /// qubit index *panics* in the tableau rather than erroring, so callers must + /// bounds-check qubit operands against `n_qubits` first. + pub fn execute_single_instruction(&mut self, instrs: &[PPVMInstruction]) -> eyre::Result<()> { + let start = self.loader.module.code.len() as u32; + self.loader.module.code.extend_from_slice(instrs); + *self.loader.pc_mut() = start; + for _ in 0..instrs.len() { + self.step_once()?; + } + Ok(()) + } + + /// Render the current circuit state (tableau / Pauli sum) for the REPL's + /// `show` command. Delegates to the circuit's size-specific tableau. + pub fn state_string(&self) -> String { + self.circuit.state_string() + } + + /// Build a fresh, initialized `n_qubits`-qubit device with no code. The + /// REPL's `device` command uses this to (re)create the machine. Errors if + /// `n_qubits` is zero (a device must have at least one qubit). + pub fn with_qubits(n_qubits: usize) -> eyre::Result { + let mut machine = Self::default(); + let mut module = vihaco::module::Module::< + PPVMInstruction, + Value, + vihaco::Type, + PPVMDeviceInfo, + >::default(); + module.extra.n_qubits = n_qubits; + machine.load(&module)?; + machine.init()?; + Ok(machine) + } + + /// Lower a single circuit instruction — qubit operands first, then float + /// params, per the push-in-order / pop-in-reverse convention — and execute + /// it against the persistent state. Qubit indices are bounds-checked against + /// the device size first, because the tableau panics (rather than erroring) + /// on an out-of-range qubit. + pub fn apply_circuit_instruction( + &mut self, + inst: CircuitInstruction, + qubits: &[usize], + params: &[f64], + ) -> eyre::Result<()> { + let n_qubits = self.loader.module.extra.n_qubits; + for &q in qubits { + if q >= n_qubits { + eyre::bail!("qubit {q} out of range for {n_qubits}-qubit device"); + } + } + + let mut instrs = Vec::with_capacity(qubits.len() + params.len() + 1); + for &q in qubits { + instrs.push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::U64(q as u64), + ))); + } + for &p in params { + instrs.push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::F64(p), + ))); + } + instrs.push(PPVMInstruction::Circuit(inst)); + self.execute_single_instruction(&instrs) + } + + fn execute_effects(&mut self, inst: Instruction) -> eyre::Result> { + log::debug!("exec inst: {:?}, stack: {:?}", inst, self.cpu.stack()); + match inst { + PPVMInstruction::Cpu(cpu_inst) => { + let msg = self.resolve_cpu(&cpu_inst)?; + self.cpu.set_current_pc(self.loader.pc()); + let stdout_effect = match (&cpu_inst, &msg) { + (vihaco_cpu::Instruction::Print, vihaco_cpu::CPUMessage::Print(text)) => { + Some(PPVMEffect::Stdout(StdoutEffect(text.clone()))) + } + _ => None, + }; + let outcome = vihaco::expect_exactly_one_effect( + vihaco::GeneratedComponent::execute_generated(&mut self.cpu, cpu_inst, msg)?, + )?; + // Advance past a breakpoint as well, so the debugger that paused + // on it doesn't re-hit the same instruction on the next step. + if matches!(outcome, StepOutcome::Continue | StepOutcome::Breakpoint) { + if let Some(target) = self.cpu.take_pending_pc() { + *self.loader.pc_mut() = target; + } else { + *self.loader.pc_mut() += 1; + } + } + let mut effects = Effects::one(PPVMEffect::Step(outcome)); + if let Some(stdout_effect) = stdout_effect { + effects = effects.append(stdout_effect); + } + Ok(effects) + } + PPVMInstruction::Circuit(inst) => { + let msg = self.resolve_circuit(&inst)?; + let circuit_effects = ::execute_generated( + &mut self.circuit, + inst, + msg, + )?; + *self.loader.pc_mut() += 1; + let mut effects = Effects::one(PPVMEffect::Step(StepOutcome::Continue)); + for outcome in circuit_effects { + effects = effects.append(PPVMEffect::from(outcome)); + } + Ok(effects) + } + } + } + + fn continue_effects(&mut self, effects: Effects) -> eyre::Result { + let mut step_outcome = None; + for effect in effects { + match effect { + PPVMEffect::Step(outcome) => { + if step_outcome.replace(outcome).is_some() { + return Err(eyre::eyre!( + "expected exactly one PPVM step effect, got multiple" + )); + } + } + effect => self.continue_observer_effect(effect)?, + } + } + + step_outcome.ok_or_else(|| eyre::eyre!("expected exactly one PPVM step effect, got 0")) + } + + fn continue_observer_effect(&mut self, effect: PPVMEffect) -> eyre::Result<()> { + match effect { + PPVMEffect::Stdout(effect) => { + let follow_ups = Observe::::observe(self, &effect)?; + self.continue_observer_effects(follow_ups) + } + PPVMEffect::Circuit(effect) => { + let follow_ups = Observe::::observe(self, &effect)?; + self.continue_observer_effects(follow_ups) + } + PPVMEffect::Measurement(effect) => { + let follow_ups = Observe::::observe(self, &effect)?; + // NOTE: push measurements to stack; two booleans: outcome, is_lost + for outcome in effect.measurement_results { + let m = Value::U32(outcome as u32); + self.cpu.stack_push(m); + } + self.continue_observer_effects(follow_ups) + } + PPVMEffect::Trace(effect) => { + let value = effect.value; + let follow_ups = Observe::::observe(self, &effect)?; + // Mirror the measurement wiring: append to the trace record + // (via the observer above) AND push the value onto the CPU + // stack so user bytecode can consume it. Plan Task 7. + self.cpu.stack_push(Value::F64(value)); + self.continue_observer_effects(follow_ups) + } + PPVMEffect::Step(_) => Err(eyre::eyre!( + "unexpected Step effect while continuing PPVM observer follow-ups" + )), + } + } + + fn continue_observer_effects(&mut self, effects: Effects) -> eyre::Result<()> { + for effect in effects { + self.continue_observer_effect(effect)?; + } + Ok(()) + } + + pub fn run(&mut self) -> eyre::Result { + self.run_with_seed(None) + } + + /// Like [`PPVM::run`], but seed the circuit RNG deterministically when + /// `seed` is `Some`, making the run reproducible. + pub fn run_with_seed(&mut self, seed: Option) -> eyre::Result { + match seed { + Some(seed) => self.init_with_seed(seed)?, + None => self.init()?, + } + + loop { + // Breakpoints only pause the interactive debugger; a batch run + // skips straight past them. + match self.step_once()? { + StepOutcome::Continue | StepOutcome::Breakpoint => continue, + action => return Ok(action), + } + } + } + + pub fn stdout(&self) -> &[u8] { + self.stdout.output() + } + + pub fn measurement_record(&self) -> Vec { + self.measurement_record.record.clone() + } + + /// Per-trace values collected by `Trace` instructions during the run. + /// Parallel to [`PPVM::measurement_record`]: one f64 per `Trace` executed, + /// in execution order. + pub fn trace_record(&self) -> Vec { + self.trace_record.record.clone() + } + + pub fn load_program(&mut self, program: &str) -> eyre::Result<()> { + let module = crate::compile_program(program)?; + self.load(&module)?; + Ok(()) + } + + /// Load from a file, auto-detecting the format: if it starts with the PPVM + /// magic it is loaded as `.ssb` bytecode, otherwise it is parsed as `.sst` + /// source text. A magic match commits to the bytecode path — a corrupt + /// `.ssb` errors rather than silently falling back to the text parser. + pub fn load_file(&mut self, path: &str) -> eyre::Result<()> { + let bytes = std::fs::read(path)?; + if crate::bytecode::is_bytecode(&bytes) { + self.load_bytecode(&bytes) + } else { + self.load_program(std::str::from_utf8(&bytes)?) + } + } + + /// Load a module from an in-memory `.ssb` byte stream. + pub fn load_bytecode(&mut self, bytes: &[u8]) -> eyre::Result<()> { + let module = crate::bytecode::module_from_bytes(bytes)?; + self.load(&module) + } + + /// Read a `.ssb` file and load the module it contains. + pub fn load_bytecode_file(&mut self, path: &str) -> eyre::Result<()> { + let bytes = std::fs::read(path)?; + self.load_bytecode(&bytes) + } + + pub fn run_program(&mut self, program: &str) -> eyre::Result<()> { + self.load_program(program)?; + self.run()?; + Ok(()) + } + + pub fn run_file(&mut self, path: &str) -> eyre::Result<()> { + self.load_file(path)?; + self.run()?; + Ok(()) + } +} + +impl vihaco::Reset for PPVM { + fn reset(&mut self) { + self.cpu.reset(); + self.circuit.reset(); + self.loader.pc = 0; + self.measurement_record.record.clear(); + self.trace_record.record.clear(); + } +} + +/// Parse the `device circuit.observable` header into Pauli-sum terms ready to +/// seed a `PauliSum` / `LossyPauliSum` state. Single-Pauli observables from +/// Phase 2 keep working as the degenerate one-term case; multi-term sums like +/// `"1.0*ZZ + 0.5*XX"` are handled by [`parse_pauli_sum_terms`]. +fn parse_observable_terms(info: &PPVMDeviceInfo) -> Result> { + let observable = info.observable.as_deref().ok_or_else(|| { + eyre!( + "the {:?} backend requires `device circuit.observable` to be set", + info.backend + ) + })?; + crate::observable::parse_pauli_sum_terms(observable, info.n_qubits) +} + +#[cfg(test)] +mod tests { + use vihaco::{Type, Value, module::Module}; + + use super::*; + + #[test] + fn test_run_ppvm() -> eyre::Result<()> { + let mut module: Module = Module::default(); + + module.extra.n_qubits = 2; + + /* + const.u64 0 + circuit.h + */ + let zero = PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(0))); + let one = PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(1))); + module.code.push(zero.clone()); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::H)); + + /* + const.u64 0 + circuit.t + */ + + module.code.push(zero.clone()); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::T)); + + /* + const.u64 0 + const.u64 1 + circuit.cnot + */ + module.code.push(zero.clone()); + module.code.push(one.clone()); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::CNOT)); + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + + for _ in 0..module.code.len() { + machine.step_once()?; + assert!(machine.cpu.stack().len() <= 2); + } + + let num_coefficients = match &machine.circuit { + Circuit::Tableau(TableauCircuit::Bits64(ex)) => { + println!("{}", ex.tab); + ex.tab.coefficients.len() + } + Circuit::Tableau(TableauCircuit::Bits128(ex)) => { + println!("{}", ex.tab); + ex.tab.coefficients.len() + } + Circuit::Tableau(TableauCircuit::Bits256(ex)) => { + println!("{}", ex.tab); + ex.tab.coefficients.len() + } + Circuit::Tableau(TableauCircuit::Bits512(ex)) => { + println!("{}", ex.tab); + ex.tab.coefficients.len() + } + Circuit::Tableau(TableauCircuit::Bits1024(ex)) => { + println!("{}", ex.tab); + ex.tab.coefficients.len() + } + Circuit::Tableau(TableauCircuit::Bits2048(ex)) => { + println!("{}", ex.tab); + ex.tab.coefficients.len() + } + Circuit::PauliSum(_) | Circuit::LossyPauliSum(_) => { + panic!("test expects the default Tableau backend, got a PauliSum variant"); + } + }; + + assert_eq!(num_coefficients, 2); + Ok(()) + } + + #[test] + fn test_device_decl() -> eyre::Result<()> { + // Equivalent .sst source: + // + // device circuit.n_qubits 5; + // device circuit.coefficient_threshold 1e-10; + // + // fn @main() { ...5-qubit GHZ + 5 measurements... } + + let mut module: Module = Module::default(); + + module.extra.n_qubits = 5; + module.extra.coefficient_threshold = 1e-10; + + // 5-qubit GHZ: H on q0, then CNOT(q_i, q_{i+1}) for i = 0..4. + /* + const.u64 0 + circuit.h + */ + module + .code + .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::U64(0), + ))); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::H)); + + for i in 0..4u64 { + /* + const.u64 i + const.u64 i+1 + circuit.cnot + */ + module + .code + .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::U64(i), + ))); + module + .code + .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::U64(i + 1), + ))); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::CNOT)); + } + + // Measure all 5 qubits. + for q in 0..5u64 { + /* + const.u64 q + circuit.measure + */ + module + .code + .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::U64(q), + ))); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::Measure)); + } + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + + for _ in 0..module.code.len() { + machine.step_once()?; + } + + assert_eq!(machine.measurement_record().len(), 5); + Ok(()) + } + + // ─── Incremental execution (REPL) ───────────────────────────────────── + + #[test] + fn execute_single_instruction_persists_state_across_calls() -> eyre::Result<()> { + use crate::measurements::MeasurementOutcome; + + // A 1-qubit device with no code; the REPL builds up instructions + // incrementally, one command at a time, rather than loading a program. + let mut module: Module = Module::default(); + module.extra.n_qubits = 1; + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + + // First command: X on q0 (|0> -> |1>). + let x = [ + PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(0))), + PPVMInstruction::Circuit(CircuitInstruction::X), + ]; + machine.execute_single_instruction(&x)?; + // No measurement yet. + assert!(machine.measurement_record().is_empty()); + + // Second command: measure q0. The X from the first command must persist, + // so the outcome is deterministically |1>. + let measure = [ + PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(0))), + PPVMInstruction::Circuit(CircuitInstruction::Measure), + ]; + machine.execute_single_instruction(&measure)?; + + let record = machine.measurement_record(); + assert_eq!(record.len(), 1); + assert_eq!(record[0].as_slice(), [MeasurementOutcome::One]); + Ok(()) + } + + #[test] + fn execute_single_instruction_propagates_engine_errors() -> eyre::Result<()> { + // The REPL relies on engine errors surfacing as `Err` (so it can print + // them and keep looping) rather than panicking. A circuit with no qubit + // operand on the stack is one such propagating error. + // + // NOTE: an out-of-range qubit index (>= n_qubits) currently *panics* in + // the tableau rather than erroring, so the REPL command layer must + // bounds-check qubit indices before calling `execute`. + let mut module: Module = Module::default(); + module.extra.n_qubits = 1; + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + + // `circuit.h` with nothing on the stack: `pop_qubit` fails. + let missing_operand = [PPVMInstruction::Circuit(CircuitInstruction::H)]; + assert!( + machine + .execute_single_instruction(&missing_operand) + .is_err() + ); + Ok(()) + } + + #[test] + fn state_string_renders_a_small_device() -> eyre::Result<()> { + let source = "device circuit.n_qubits 2;\nfn @main() { ret }\n"; + let mut machine = PPVM::default(); + machine.load_program(source)?; + machine.init()?; + + let rendered = machine.state_string(); + assert!( + !rendered.is_empty(), + "state_string should render the tableau" + ); + // PPVM delegates to the circuit. + assert_eq!(rendered, machine.circuit.state_string()); + Ok(()) + } + + #[test] + fn resolve_circuit_pops_operands_in_reverse_of_push_order() -> eyre::Result<()> { + // Convention: operands are pushed in argument order (q0, q1, then any + // floats) and popped in reverse. So every two-qubit circuit must read q0 as + // the first operand pushed, consistently, with or without trailing + // floats. (CNOT already obeyed this; the float-carrying arms did not.) + let mut module: Module = Module::default(); + module.extra.n_qubits = 8; + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + + // CNOT: push q0=2, q1=5. + machine.cpu.stack_push(Value::U32(2)); + machine.cpu.stack_push(Value::U32(5)); + assert_eq!( + machine.resolve_circuit(&CircuitInstruction::CNOT)?, + CircuitMessage::TwoQubit(2, 5) + ); + + // RXX: push q0=2, q1=5, theta — same qubit order as CNOT. + machine.cpu.stack_push(Value::U32(2)); + machine.cpu.stack_push(Value::U32(5)); + machine.cpu.stack_push(Value::F64(0.3)); + assert_eq!( + machine.resolve_circuit(&CircuitInstruction::RXX)?, + CircuitMessage::TwoQubitAndFloat(2, 5, 0.3) + ); + + // CorrelatedLoss: push q0=2, q1=5, p0, p1, p2. + machine.cpu.stack_push(Value::U32(2)); + machine.cpu.stack_push(Value::U32(5)); + machine.cpu.stack_push(Value::F64(0.1)); + machine.cpu.stack_push(Value::F64(0.2)); + machine.cpu.stack_push(Value::F64(0.3)); + assert_eq!( + machine.resolve_circuit(&CircuitInstruction::CorrelatedLoss)?, + CircuitMessage::TwoQubitAndFloatArr3(2, 5, [0.1, 0.2, 0.3]) + ); + Ok(()) + } + + #[test] + fn with_qubits_builds_an_initialized_device() -> eyre::Result<()> { + use crate::measurements::MeasurementOutcome; + + let mut m = PPVM::with_qubits(2)?; + // The device is ready to take instructions immediately. + m.apply_circuit_instruction(CircuitInstruction::X, &[0], &[])?; + m.apply_circuit_instruction(CircuitInstruction::Measure, &[0], &[])?; + let record = m.measurement_record(); + assert_eq!(record.len(), 1); + assert_eq!(record[0].as_slice(), [MeasurementOutcome::One]); + Ok(()) + } + + #[test] + fn with_qubits_zero_is_an_error() { + assert!(PPVM::with_qubits(0).is_err()); + } + + #[test] + fn apply_circuit_instruction_bounds_checks_qubits() -> eyre::Result<()> { + // q1 is out of range on a 1-qubit device: this must error rather than + // panic in the tableau. + let mut m = PPVM::with_qubits(1)?; + let err = m + .apply_circuit_instruction(CircuitInstruction::X, &[1], &[]) + .unwrap_err(); + assert!(err.to_string().contains("out of range"), "got: {err}"); + Ok(()) + } + + // ─── Parser-driven entry points ─────────────────────────────────────── + + #[test] + fn load_program_populates_device_info_and_code() -> eyre::Result<()> { + let source = "device circuit.n_qubits 2;\n\ + device circuit.coefficient_threshold 1e-8;\n\ + fn @main() {\n\ + const.u64 0\n\ + circuit.h\n\ + ret\n\ + }\n"; + let mut machine = PPVM::default(); + machine.load_program(source)?; + assert_eq!(machine.loader.module.extra.n_qubits, 2); + assert_eq!(machine.loader.module.extra.coefficient_threshold, 1e-8); + // const.u64 0 / circuit.h / ret = 3 + assert_eq!(machine.loader.module.code.len(), 3); + Ok(()) + } + + #[test] + fn run_program_executes_bell_circuit() -> eyre::Result<()> { + let source = "device circuit.n_qubits 2;\n\ + fn @main() {\n\ + const.u64 0\n\ + circuit.h\n\ + const.u64 0\n\ + const.u64 1\n\ + circuit.cnot\n\ + const.u64 0\n\ + circuit.measure\n\ + const.u64 1\n\ + circuit.measure\n\ + ret\n\ + }\n"; + let mut machine = PPVM::default(); + machine.run_program(source)?; + let record = machine.measurement_record(); + assert_eq!(record.len(), 2); + Ok(()) + } + + #[test] + fn reset_clears_the_measurement_record() -> eyre::Result<()> { + use vihaco::Reset; + + let source = "device circuit.n_qubits 2;\n\ + fn @main() {\n\ + const.u64 0\n\ + circuit.h\n\ + const.u64 0\n\ + const.u64 1\n\ + circuit.cnot\n\ + const.u64 0\n\ + circuit.measure\n\ + const.u64 1\n\ + circuit.measure\n\ + ret\n\ + }\n"; + let mut machine = PPVM::default(); + machine.run_program(source)?; + assert_eq!(machine.measurement_record().len(), 2); + + // Resetting the machine must discard the recorded measurements, so a + // subsequent run does not see stale results leaking in from before. + machine.reset(); + assert!( + machine.measurement_record().is_empty(), + "reset must clear the measurement record" + ); + + Ok(()) + } + + #[test] + fn init_fails_when_n_qubits_undeclared() -> eyre::Result<()> { + let source = "fn @main() { ret }\n"; + let mut machine = PPVM::default(); + machine.load_program(source)?; + let err = machine.init().unwrap_err(); + assert!(err.to_string().contains("circuit.n_qubits"), "err: {err}"); + Ok(()) + } + + #[test] + fn run_program_reports_parse_errors() { + let source = "device circuit.n_qubits 2;\n\ + fn @main() {\n\ + circuit.not_a_real_gate\n\ + ret\n\ + }\n"; + let mut machine = PPVM::default(); + let err = machine.run_program(source).unwrap_err(); + assert!( + err.to_string().contains("parsing failed") + || err.to_string().contains("unhandled raw form"), + "err: {err}" + ); + } + + // ─── Breakpoints ────────────────────────────────────────────────────── + + /// Bell circuit with a `breakpoint` between the two measurements. + const BREAKPOINT_PROGRAM: &str = "device circuit.n_qubits 2;\n\ + fn @main() {\n\ + const.u64 0\n\ + circuit.h\n\ + const.u64 0\n\ + const.u64 1\n\ + circuit.cnot\n\ + const.u64 0\n\ + circuit.measure\n\ + breakpoint\n\ + const.u64 1\n\ + circuit.measure\n\ + ret\n\ + }\n"; + + #[test] + fn run_ignores_breakpoints() -> eyre::Result<()> { + // A batch run must execute straight through the breakpoint and record + // both measurements, exactly as if it weren't there. + let mut machine = PPVM::default(); + machine.run_program(BREAKPOINT_PROGRAM)?; + assert_eq!(machine.measurement_record().len(), 2); + Ok(()) + } + + #[test] + fn step_once_advances_past_breakpoint() -> eyre::Result<()> { + let mut machine = PPVM::default(); + machine.load_program(BREAKPOINT_PROGRAM)?; + machine.init()?; + + // Step until the breakpoint pauses us. + let mut outcome = StepOutcome::Continue; + for _ in 0..machine.loader.module.code.len() { + outcome = machine.step_once()?; + if outcome == StepOutcome::Breakpoint { + break; + } + } + assert_eq!(outcome, StepOutcome::Breakpoint, "breakpoint should pause"); + let pc_at_break = machine.current_pc(); + + // Stepping again must make progress (advance the pc) rather than + // re-hitting the same breakpoint instruction. + let next = machine.step_once()?; + assert_ne!( + next, + StepOutcome::Breakpoint, + "must move past the breakpoint" + ); + assert!(machine.current_pc() > pc_at_break, "pc must advance"); + + Ok(()) + } + + #[test] + fn paulisum_truncate_runs_without_error() -> eyre::Result<()> { + // Smoke test: a `circuit.truncate` reaches the PauliSum executor's + // Truncate arm and calls `state.truncate()`. Task 8 makes the + // observable mandatory for PauliSum init, so seed `Z` here. + let mut module: Module = Module::default(); + module.extra.n_qubits = 1; + module.extra.backend = BackendKind::PauliSum; + module.extra.observable = Some("Z".to_string()); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::Truncate)); + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + machine.step_once()?; + Ok(()) + } + + #[test] + fn paulisum_trace_populates_trace_record() -> eyre::Result<()> { + // End-to-end Trace pipeline: with the observable `Z` seeded (Task 8), + // tracing the `Z0` pattern picks up that one term with coefficient + // 1.0, so the trace should be exactly 1.0. + let mut module: Module = Module::default(); + module.extra.n_qubits = 1; + module.extra.backend = BackendKind::PauliSum; + module.extra.observable = Some("Z".to_string()); + // `Z0` matches a Z on qubit 0; the parser requires position anchors. + module.strings.push("Z0".to_string()); + + module + .code + .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::String(0), + ))); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::Trace)); + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + for _ in 0..module.code.len() { + machine.step_once()?; + } + + assert_eq!(machine.trace_record(), vec![1.0]); + Ok(()) + } + + #[test] + fn paulisum_multi_term_observable_seeds_all_terms() -> eyre::Result<()> { + // Task 11: a sum-valued observable seeds every term. With + // `"ZZ + 0.5*XX"` the state holds `1.0 * ZZ + 0.5 * XX`; tracing + // `[XZ]0[XZ]1` matches both words and returns 1.0 + 0.5 = 1.5. + let mut module: Module = Module::default(); + module.extra.n_qubits = 2; + module.extra.backend = BackendKind::PauliSum; + module.extra.observable = Some("ZZ + 0.5*XX".to_string()); + module.strings.push("[XZ]0[XZ]1".to_string()); + + module + .code + .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::String(0), + ))); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::Trace)); + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + for _ in 0..module.code.len() { + machine.step_once()?; + } + + assert_eq!(machine.trace_record(), vec![1.5]); + Ok(()) + } + + #[test] + fn paulisum_init_rejects_missing_observable() { + // Task 8 requires `device circuit.observable` for PauliSum / Lossy. + let mut module: Module = Module::default(); + module.extra.n_qubits = 1; + module.extra.backend = BackendKind::PauliSum; + + let mut machine = PPVM::default(); + machine.load(&module).unwrap(); + let err = machine.init().unwrap_err(); + assert!( + err.to_string().contains("observable"), + "expected observable-related error, got: {err}" + ); + } + + #[test] + fn paulisum_init_rejects_mismatched_observable_length() { + let mut module: Module = Module::default(); + module.extra.n_qubits = 2; + module.extra.backend = BackendKind::PauliSum; + // Three letters but only two qubits — should error. + module.extra.observable = Some("ZZZ".to_string()); + + let mut machine = PPVM::default(); + machine.load(&module).unwrap(); + let err = machine.init().unwrap_err(); + assert!( + err.to_string().contains("invalid Pauli-sum"), + "expected parser rejection, got: {err}" + ); + } + + #[test] + fn tableau_truncate_is_silent_no_op() -> eyre::Result<()> { + // Task 9: `circuit.truncate` on the default Tableau backend should run + // without error — the tableau prunes via coefficient_threshold during + // every gate, so the explicit Truncate instruction has nothing to do. + let mut module: Module = Module::default(); + module.extra.n_qubits = 1; + // backend defaults to Tableau; no observable needed. + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::Truncate)); + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + machine.step_once()?; + // No observer effects emitted by Truncate. + assert!(machine.measurement_record().is_empty()); + assert!(machine.trace_record().is_empty()); + Ok(()) + } + + #[test] + fn tableau_trace_emits_expectation_on_zero_state() { + // Task 16: `circuit.trace` on the Tableau backend now computes + // Σ_{P matches pat} ⟨ψ|P|ψ⟩ via `GeneralizedTableau::trace`. On the + // freshly-initialized |0⟩ state, pattern `Z0` matches the single + // Pauli Z and ⟨0|Z|0⟩ = 1, so the trace_record gets one entry: 1.0. + let mut module: Module = Module::default(); + module.extra.n_qubits = 1; + module.strings.push("Z0".to_string()); + module + .code + .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::String(0), + ))); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::Trace)); + + let mut machine = PPVM::default(); + machine.load(&module).unwrap(); + machine.init().unwrap(); + machine.step_once().unwrap(); // const.string + machine.step_once().unwrap(); // circuit.trace + let trace = machine.trace_record(); + assert_eq!(trace.len(), 1); + assert!( + (trace[0] - 1.0).abs() < 1e-12, + "expected ⟨0|Z|0⟩ = 1.0, got {}", + trace[0] + ); + } + + #[test] + fn paulisum_reset_restores_seeded_observable() -> eyre::Result<()> { + use vihaco::Reset; + + // Seed the observable `Z` (PauliSum backend), then apply H(0), which + // conjugates Z -> X in the Heisenberg picture and changes the state. + // reset() must rebuild the state from the seeded observable. + let mut module: Module = Module::default(); + module.extra.n_qubits = 1; + module.extra.backend = BackendKind::PauliSum; + module.extra.observable = Some("Z".to_string()); + + module + .code + .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::U64(0), + ))); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::H)); + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + + let seeded = machine.state_string(); + for _ in 0..module.code.len() { + machine.step_once()?; + } + assert_ne!( + machine.state_string(), + seeded, + "H(0) should have changed the propagated observable" + ); + + machine.reset(); + assert_eq!( + machine.state_string(), + seeded, + "reset must rebuild the state from the seeded observable" + ); + Ok(()) + } + + #[test] + fn lossy_paulisum_reset_restores_seeded_observable() -> eyre::Result<()> { + use vihaco::Reset; + + // Same as `paulisum_reset_restores_seeded_observable`, but through the + // LossyPauliSum dispatch path. + let mut module: Module = Module::default(); + module.extra.n_qubits = 1; + module.extra.backend = BackendKind::LossyPauliSum; + module.extra.observable = Some("Z".to_string()); + + module + .code + .push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::U64(0), + ))); + module + .code + .push(PPVMInstruction::Circuit(CircuitInstruction::H)); + + let mut machine = PPVM::default(); + machine.load(&module)?; + machine.init()?; + + let seeded = machine.state_string(); + for _ in 0..module.code.len() { + machine.step_once()?; + } + assert_ne!( + machine.state_string(), + seeded, + "H(0) should have changed the propagated observable" + ); + + machine.reset(); + assert_eq!( + machine.state_string(), + seeded, + "reset must rebuild the state from the seeded observable" + ); + Ok(()) + } +} diff --git a/crates/ppvm-vihaco/src/lib.rs b/crates/ppvm-vihaco/src/lib.rs index 534f523c9..3bad3bdca 100644 --- a/crates/ppvm-vihaco/src/lib.rs +++ b/crates/ppvm-vihaco/src/lib.rs @@ -1,19 +1,118 @@ // 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 bytecode; pub mod component; +pub mod composite; pub mod device_info; pub mod measurements; +pub mod observable; +pub mod shots; +mod syntax; -/// Re-exported so consumers can name gates for the circuit component without -/// depending on the ISA crate directly. +/// Re-exported so consumers (e.g. the CLI REPL) can name gates for +/// [`composite::PPVM::apply_circuit_instruction`] without depending on the ISA +/// crate directly. pub use vihaco_circuit_isa::CircuitInstruction; +use chumsky::Parser; +use vihaco::syntax::{ParsedModule, Resolve}; +use vihaco::{Type, Value, module::Module}; +use vihaco_parser_core::Parse; + +use crate::composite::{PPVM, PPVMDeviceInfo, PPVMInstruction}; +use crate::syntax::{PPVMHeader, PPVMResolver}; + +/// A fully resolved PPVM module, ready to load into a [`PPVM`]. +pub type PPVMModule = Module; + +/// Read a file and produce a loadable module, auto-detecting the format: a +/// leading PPVM magic is parsed as `.ssb` bytecode, otherwise as `.sst` source. +/// Mirrors [`PPVM::load_file`] but returns the module so it can be compiled once +/// and run for many shots. +pub fn load_module_file(path: &str) -> eyre::Result { + let bytes = std::fs::read(path)?; + if bytecode::is_bytecode(&bytes) { + bytecode::module_from_bytes(&bytes) + } else { + compile_program(std::str::from_utf8(&bytes)?) + } +} + +pub fn run_file(path: &str) -> eyre::Result { + let mut machine = PPVM::default(); + machine.run_file(path)?; + Ok(machine) +} + +pub fn run_program(program: &str) -> eyre::Result { + let mut machine = PPVM::default(); + machine.run_program(program)?; + Ok(machine) +} + +/// Parse `.sst` source into the unresolved AST. +pub fn parse_program(source: &str) -> eyre::Result> { + ParsedModule::::parser() + .parse(source) + .into_result() + .map_err(|errs| eyre::eyre!("parsing failed: {errs:?}")) +} + +pub fn compile_program( + source: &str, +) -> eyre::Result> { + PPVMResolver::new().resolve_module(parse_program(source)?) +} + +/// Dump `.sst` source to a `.ssb` bytecode file. +pub fn dump_program(program: &str, output_path: &str) -> eyre::Result<()> { + let bytes = bytecode::compile_to_bytes(program)?; + std::fs::write(output_path, bytes)?; + Ok(()) +} + +/// Read a `.sst` file and dump it to a `.ssb` bytecode file. +pub fn dump_file(input_path: &str, output_path: &str) -> eyre::Result<()> { + let program = std::fs::read_to_string(input_path)?; + dump_program(&program, output_path) +} + pub mod prelude { pub use crate::component::Circuit; - pub use crate::device_info::{BackendKind, PPVMDeviceInfo}; + pub use crate::composite::PPVM; + pub use crate::syntax::{PPVMHeader, PPVMResolver}; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dump_program_writes_loadable_bytecode() { + let src = "device circuit.n_qubits 1;\n\ + fn @main() { const.u64 0\n circuit.measure\n ret }\n"; + let path = std::env::temp_dir().join("ppvm_dump_program_test.ssb"); + dump_program(src, path.to_str().unwrap()).unwrap(); + + let mut machine = PPVM::default(); + machine.load_bytecode_file(path.to_str().unwrap()).unwrap(); + machine.run().unwrap(); + + assert_eq!(machine.measurement_record().len(), 1); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn dump_file_reads_sst_and_writes_bytecode() { + let out = std::env::temp_dir().join("ppvm_dump_file_test.ssb"); + dump_file("tests/function_call.sst", out.to_str().unwrap()).unwrap(); + + let mut machine = PPVM::default(); + machine.load_bytecode_file(out.to_str().unwrap()).unwrap(); + machine.run().unwrap(); + + assert_eq!(machine.measurement_record().len(), 1); + let _ = std::fs::remove_file(&out); + } } diff --git a/crates/ppvm-vihaco/src/observable.rs b/crates/ppvm-vihaco/src/observable.rs new file mode 100644 index 000000000..f5bc92032 --- /dev/null +++ b/crates/ppvm-vihaco/src/observable.rs @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Parse the `device circuit.observable` header value into Pauli-sum terms. +//! +//! Lives in `ppvm-vihaco` rather than `ppvm-runtime` because it only parses +//! header syntax that `ppvm-vihaco` consumes; `ppvm-runtime` has no notion of +//! a textual observable. The unrelated `PauliPattern::parse` in +//! `ppvm-runtime` is a *matcher* grammar (alternation, star, positional +//! anchors) and shares only the `I/X/Y/Z` alphabet with this one. +//! +//! Grammar (informal): +//! ```text +//! sum = WS* term (WS* sign WS* term)* WS* +//! term = coefficient (WS* '*')? WS* pauli_word +//! | pauli_word +//! sign = '+' | '-' +//! coefficient +//! = digits ('.' digits?)? ([eE] [+-]? digits)? +//! | '.' digits ([eE] [+-]? digits)? +//! pauli_word +//! = [IXYZ]{n_qubits} +//! ``` +//! +//! - The first term may have a leading `+` or `-` (no sign means `+`). +//! - Absent coefficient defaults to `1.0`. +//! - The `*` is only legal *after* a coefficient — bare `*ZZ` is rejected. +//! - The word must be exactly `n_qubits` characters from `I/X/Y/Z`. +//! +//! Rejected at parse time: +//! - Empty or whitespace-only input. +//! - Bare coefficients with no Pauli word. +//! - Words shorter or longer than `n_qubits`. +//! - Invalid Pauli characters. +//! - Missing `+`/`-` between terms. + +use chumsky::error::Simple; +use chumsky::extra; +use chumsky::prelude::*; +use eyre::{Result, eyre}; + +type Err<'src> = extra::Err>; + +/// Parse a Pauli-sum string like `"1.0*ZZ + 0.5*XX - 0.3*YY"` into a list of +/// `(word_source, coefficient)` pairs. Callers convert the word source to +/// their preferred Pauli-word type via `PauliWord::from` / `LossyPauliWord::from`. +pub fn parse_pauli_sum_terms(input: &str, n_qubits: usize) -> Result> { + if n_qubits == 0 { + return Err(eyre!("n_qubits must be at least 1")); + } + if input.trim().is_empty() { + return Err(eyre!("empty Pauli-sum input")); + } + pauli_sum_parser(n_qubits) + .parse(input) + .into_result() + .map_err(|errs| { + let joined = errs + .into_iter() + .map(|e| e.to_string()) + .collect::>() + .join("; "); + eyre!("invalid Pauli-sum `{input}`: {joined}") + }) +} + +fn pauli_sum_parser<'src>( + n_qubits: usize, +) -> impl Parser<'src, &'src str, Vec<(String, f64)>, Err<'src>> { + // Numeric literal: digits[.digits?]?[exp]? or .digits[exp]? + let digits = text::digits(10).at_least(1); + let exp = one_of("eE") + .then(one_of("+-").or_not()) + .then(digits) + .ignored(); + let mantissa = choice(( + digits + .then(just('.').then(digits.or_not()).or_not()) + .ignored(), + just('.').then(digits).ignored(), + )); + let coefficient = mantissa.then(exp.or_not()).to_slice().map(|s: &str| { + s.parse::() + .expect("mantissa+exponent grammar validates parseability") + }); + + // Pauli word: exactly n_qubits chars from IXYZ. + let pauli_word = one_of("IXYZ") + .repeated() + .exactly(n_qubits) + .collect::(); + + // Term: coefficient [*] word | bare word. + let term_with_coeff = coefficient + .then_ignore(just('*').padded().or_not()) + .then(pauli_word) + .map(|(c, w)| (w, c)); + let term_bare = pauli_word.map(|w| (w, 1.0)); + let term = choice((term_with_coeff, term_bare)); + + // Sign factor: +1 / -1. + let sign = choice((just('+').to(1.0_f64), just('-').to(-1.0_f64))); + + // First term: optional leading sign. + let first = sign + .padded() + .or_not() + .then(term) + .map(|(s, (w, c))| (w, s.unwrap_or(1.0) * c)); + + // Subsequent terms: required + or - before the term. + let rest = sign.padded().then(term).map(|(s, (w, c))| (w, s * c)); + + let inner = first + .then(rest.repeated().collect::>()) + .map(|(first, mut rest)| { + rest.insert(0, first); + rest + }); + + inner.padded().then_ignore(end()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── Happy paths ──────────────────────────────────────────────────── + + #[test] + fn single_term_no_coefficient() { + let got = parse_pauli_sum_terms("ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 1.0)]); + } + + #[test] + fn single_term_with_explicit_star() { + let got = parse_pauli_sum_terms("0.5*ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 0.5)]); + } + + #[test] + fn single_term_without_star() { + let got = parse_pauli_sum_terms("0.5ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 0.5)]); + } + + #[test] + fn integer_coefficient() { + let got = parse_pauli_sum_terms("3XY", 2).unwrap(); + assert_eq!(got, vec![("XY".to_string(), 3.0)]); + } + + #[test] + fn leading_negative_no_coefficient() { + let got = parse_pauli_sum_terms("-ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), -1.0)]); + } + + #[test] + fn leading_negative_with_coefficient() { + let got = parse_pauli_sum_terms("-0.5*ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), -0.5)]); + } + + #[test] + fn leading_plus_no_coefficient() { + let got = parse_pauli_sum_terms("+ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 1.0)]); + } + + #[test] + fn multiple_terms_with_addition() { + let got = parse_pauli_sum_terms("1.0*ZZ + 0.5*XX", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 1.0), ("XX".to_string(), 0.5)]); + } + + #[test] + fn multiple_terms_with_subtraction() { + let got = parse_pauli_sum_terms("ZZ - XX", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 1.0), ("XX".to_string(), -1.0)]); + } + + #[test] + fn three_term_mixed() { + let got = parse_pauli_sum_terms("1.0*ZZ + 0.5*XX - 0.3*YY", 2).unwrap(); + assert_eq!( + got, + vec![ + ("ZZ".to_string(), 1.0), + ("XX".to_string(), 0.5), + ("YY".to_string(), -0.3), + ] + ); + } + + #[test] + fn scientific_notation_coefficient() { + let got = parse_pauli_sum_terms("1e-3*ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 1e-3)]); + } + + #[test] + fn unsigned_positive_exponent() { + let got = parse_pauli_sum_terms("1e3*ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 1000.0)]); + } + + #[test] + fn uppercase_exponent_with_explicit_sign() { + let got = parse_pauli_sum_terms("2.5E+2*ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 250.0)]); + } + + #[test] + fn coefficient_with_trailing_dot() { + let got = parse_pauli_sum_terms("1.*ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 1.0)]); + } + + #[test] + fn whitespace_tolerance_aggressive() { + let got = parse_pauli_sum_terms(" 1.0 * ZZ + 0.5 * XX ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 1.0), ("XX".to_string(), 0.5)]); + } + + #[test] + fn coefficient_starts_with_dot() { + let got = parse_pauli_sum_terms(".5*ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 0.5)]); + } + + #[test] + fn identity_in_word_is_valid() { + let got = parse_pauli_sum_terms("IZIZ", 4).unwrap(); + assert_eq!(got, vec![("IZIZ".to_string(), 1.0)]); + } + + #[test] + fn single_qubit_n_equals_one() { + let got = parse_pauli_sum_terms("0.5*X + Y - 0.25Z", 1).unwrap(); + assert_eq!( + got, + vec![ + ("X".to_string(), 0.5), + ("Y".to_string(), 1.0), + ("Z".to_string(), -0.25), + ] + ); + } + + #[test] + fn duplicate_word_emits_two_terms() { + // Coefficient collapse is the caller's job (via `PauliSum +=`); the + // parser stays a thin syntactic layer. + let got = parse_pauli_sum_terms("0.5*ZZ + 0.5*ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 0.5), ("ZZ".to_string(), 0.5)]); + } + + // ─── Error paths ──────────────────────────────────────────────────── + + #[test] + fn rejects_empty_input() { + let err = parse_pauli_sum_terms("", 2).unwrap_err(); + assert!(err.to_string().contains("empty"), "{err}"); + } + + #[test] + fn rejects_whitespace_only() { + let err = parse_pauli_sum_terms(" \t \n ", 2).unwrap_err(); + assert!(err.to_string().contains("empty"), "{err}"); + } + + #[test] + fn rejects_zero_qubits() { + let err = parse_pauli_sum_terms("ZZ", 0).unwrap_err(); + assert!(err.to_string().contains("n_qubits"), "{err}"); + } + + #[test] + fn rejects_bare_coefficient() { + let err = parse_pauli_sum_terms("0.5", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_bare_sign() { + let err = parse_pauli_sum_terms("+", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_short_word() { + let err = parse_pauli_sum_terms("Z", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_long_word() { + let err = parse_pauli_sum_terms("ZZZ", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_invalid_pauli_character() { + let err = parse_pauli_sum_terms("ZF", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_lowercase_pauli_character() { + let err = parse_pauli_sum_terms("zz", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_missing_separator_between_terms() { + let err = parse_pauli_sum_terms("ZZ XX", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_double_sign() { + let err = parse_pauli_sum_terms("++ZZ", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_star_without_coefficient() { + // Bare `*ZZ` is rejected — `*` is only legal after a coefficient. + let err = parse_pauli_sum_terms("*ZZ", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_trailing_garbage() { + let err = parse_pauli_sum_terms("ZZ garbage", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } + + #[test] + fn rejects_trailing_garbage_after_complete_sum() { + // Distinct from `rejects_missing_separator_between_terms`: here a + // complete two-term sum parses successfully and the failure is the + // `end()` check after the last term. + let err = parse_pauli_sum_terms("ZZ + XX trailing", 2).unwrap_err(); + assert!(err.to_string().contains("invalid Pauli-sum"), "{err}"); + } +} diff --git a/crates/ppvm-vihaco/src/shots.rs b/crates/ppvm-vihaco/src/shots.rs new file mode 100644 index 000000000..966edaa71 --- /dev/null +++ b/crates/ppvm-vihaco/src/shots.rs @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Running a compiled program for many shots, optionally across threads. +//! +//! Each shot runs on a fresh [`PPVM`] so shots are fully independent; the +//! module is compiled once and shared. With the `rayon` feature, [`run_shots`] +//! parallelizes across shots when the global pool (sized once by +//! [`set_global_threads`]) has more than one thread and there are enough shots +//! to amortize the overhead. + +use crate::PPVMModule; +use crate::composite::PPVM; +use crate::measurements::MeasurementResult; + +/// One shot's full output: the measurement record and the trace-instruction +/// record. Either may be empty depending on what the program emits. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ShotRecord { + pub measurements: Vec, + pub traces: Vec, +} + +/// Below this many shots, parallelism's overhead outweighs its benefit and we +/// always run serially. Provisional — tune with benchmarks. +pub const PARALLEL_SHOT_THRESHOLD: usize = 128; + +/// Per-shot seed derived from the base seed and the shot index, so every shot +/// gets a distinct RNG stream (a shared seed would make all shots identical). +/// Depends only on `(base, index)`, so serial and parallel runs are bit-for-bit +/// identical for a given seed regardless of thread count. +fn shot_seed(base: Option, index: usize) -> Option { + base.map(|b| b.wrapping_add(index as u64)) +} + +/// Run a single shot on a fresh machine and return both records. +fn run_one_shot(module: &PPVMModule, seed: Option) -> eyre::Result { + let mut machine = PPVM::default(); + machine.load(module)?; + machine.run_with_seed(seed)?; + Ok(ShotRecord { + measurements: machine.measurement_record(), + traces: machine.trace_record(), + }) +} + +/// Run `shots` shots serially. One entry per shot, in order. +pub fn run_shots_serial( + module: &PPVMModule, + shots: usize, + seed: Option, +) -> eyre::Result> { + (0..shots) + .map(|i| run_one_shot(module, shot_seed(seed, i))) + .collect() +} + +/// Run `shots` shots across the global rayon pool. One entry per shot, in order +/// (preserved by the indexed parallel iterator). The pool size is whatever +/// [`set_global_threads`] configured at startup; each shot runs on a worker +/// thread, so the intra-shot parallelism guard keeps a single shot serial and +/// the pool is never oversubscribed. +#[cfg(feature = "rayon")] +pub fn run_shots_parallel( + module: &PPVMModule, + shots: usize, + seed: Option, +) -> eyre::Result> { + use rayon::prelude::*; + + (0..shots) + .into_par_iter() + .map(|i| run_one_shot(module, shot_seed(seed, i))) + .collect() +} + +/// Decide whether to spread shots across the rayon pool. Worth it only with a +/// multi-thread pool and enough shots to amortize the overhead; a single-thread +/// pool always takes the serial path, keeping results deterministic. +#[cfg(feature = "rayon")] +fn should_parallelize(num_threads: usize, shots: usize) -> bool { + num_threads > 1 && shots >= PARALLEL_SHOT_THRESHOLD +} + +/// Run `shots` shots, choosing serial or parallel execution. Goes parallel only +/// when built with `rayon`, the global pool has more than one thread, and there +/// are enough shots to be worth it; otherwise runs serially. The pool size is +/// set once at startup by [`set_global_threads`]. +pub fn run_shots( + module: &PPVMModule, + shots: usize, + seed: Option, +) -> eyre::Result> { + #[cfg(feature = "rayon")] + if should_parallelize(rayon::current_num_threads(), shots) { + return run_shots_parallel(module, shots, seed); + } + + run_shots_serial(module, shots, seed) +} + +/// Configure the process-wide rayon thread pool. Call once, before any parallel +/// work runs. A count of `1` forces fully serial, deterministic execution — both +/// across shots and within a single machine's coefficient propagation. +#[cfg(feature = "rayon")] +pub fn set_global_threads(threads: usize) -> eyre::Result<()> { + rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build_global()?; + Ok(()) +} + +/// Without the `rayon` feature there is no pool to size; anything but a single +/// thread is meaningless, so reject it rather than silently run serially. +#[cfg(not(feature = "rayon"))] +pub fn set_global_threads(threads: usize) -> eyre::Result<()> { + if threads > 1 { + eyre::bail!("this build has no parallelism support; --threads must be 1"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile_program; + use crate::measurements::MeasurementOutcome; + + /// Measures q0 in |0>: every shot is deterministically `0`. + const DETERMINISTIC: &str = + "device circuit.n_qubits 1;\nfn @main() { const.u64 0\n circuit.measure\n ret }\n"; + + /// Prepares |+> with H, then measures q0: each shot is a random 0/1. + const RANDOM: &str = "device circuit.n_qubits 1;\nfn @main() { const.u64 0\n circuit.h\n const.u64 0\n circuit.measure\n ret }\n"; + + fn module(src: &str) -> PPVMModule { + compile_program(src).unwrap() + } + + #[test] + fn serial_runs_one_record_per_shot() { + let m = module(DETERMINISTIC); + let records = run_shots_serial(&m, 5, None).unwrap(); + assert_eq!(records.len(), 5); + for shot in &records { + // One measurement event, one qubit, deterministically |0>. + assert_eq!(shot.measurements.len(), 1); + assert_eq!(shot.measurements[0].as_slice(), [MeasurementOutcome::Zero]); + assert!(shot.traces.is_empty(), "Tableau backend emits no traces"); + } + } + + #[test] + fn dispatcher_runs_all_shots() { + let m = module(DETERMINISTIC); + // 10 shots is below the parallel threshold, so this takes the serial path. + let records = run_shots(&m, 10, None).unwrap(); + assert_eq!(records.len(), 10); + } + + #[test] + fn same_seed_is_reproducible() { + let m = module(RANDOM); + let a = run_shots_serial(&m, 20, Some(42)).unwrap(); + let b = run_shots_serial(&m, 20, Some(42)).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn per_shot_seeds_differ() { + // If every shot shared one seed, all 20 outcomes would be identical. + // Distinct per-shot seeds must produce a mix of 0s and 1s. + let m = module(RANDOM); + let records = run_shots_serial(&m, 20, Some(42)).unwrap(); + let first = &records[0]; + assert!( + records.iter().any(|r| r != first), + "expected varied outcomes across shots, got {records:?}" + ); + } + + #[cfg(feature = "rayon")] + #[test] + fn serial_and_parallel_match_for_same_seed() { + let m = module(RANDOM); + let serial = run_shots_serial(&m, 64, Some(7)).unwrap(); + let parallel = run_shots_parallel(&m, 64, Some(7)).unwrap(); + assert_eq!(serial, parallel); + } + + #[cfg(feature = "rayon")] + #[test] + fn parallelizes_only_with_multiple_threads_above_threshold() { + // A single-thread pool is always serial, no matter how many shots. + assert!(!should_parallelize(1, 100_000)); + // Multiple threads, but too few shots to amortize the overhead: serial. + assert!(!should_parallelize(8, PARALLEL_SHOT_THRESHOLD - 1)); + // Multiple threads at the threshold: parallel. + assert!(should_parallelize(8, PARALLEL_SHOT_THRESHOLD)); + // Multiple threads, plenty of shots: parallel. + assert!(should_parallelize(8, 100_000)); + } +} diff --git a/crates/ppvm-vihaco/src/syntax.rs b/crates/ppvm-vihaco/src/syntax.rs new file mode 100644 index 000000000..e11865068 --- /dev/null +++ b/crates/ppvm-vihaco/src/syntax.rs @@ -0,0 +1,724 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; + +use chumsky::{Parser, error::Simple, extra}; +use vihaco::{ + Type, Value, + module::Module, + syntax::{BodyItem, RawForm, RawOperand, Resolve}, +}; +use vihaco_circuit_isa::CircuitInstruction; +use vihaco_parser_core::Parse; + +use crate::composite::{BackendKind, PPVMDeviceInfo, PPVMInstruction}; + +#[derive(Debug, Clone, PartialEq, vihaco_parser::Parse)] +#[head = "device "] +pub enum PPVMHeader { + #[token = "circuit.n_qubits"] + #[delimiters(open = "", close = "", separator = "")] + NumQubits(usize), + + #[token = "circuit.coefficient_threshold"] + #[delimiters(open = "", close = "", separator = "")] + CoefficientThreshold(f64), + #[token = "circuit.backend"] + #[delimiters(open = "", close = "", separator = "")] + Backend(BackendKind), + + #[token = "circuit.observable"] + #[delimiters(open = "", close = "", separator = "")] + Observable(#[parse_with = "vihaco_parser_core::ident"] String), + + #[token = "circuit.max_pauli_weight"] + #[delimiters(open = "", close = "", separator = "")] + MaxPauliWeight(usize), +} + +#[derive(Debug, Default)] +pub struct PPVMResolver { + strings: Vec, +} + +impl PPVMResolver { + pub fn new() -> Self { + Self::default() + } + + fn apply_header(info: &mut PPVMDeviceInfo, header: PPVMHeader) -> eyre::Result<()> { + match header { + PPVMHeader::NumQubits(n) => { + info.n_qubits = n; + } + PPVMHeader::CoefficientThreshold(t) => { + info.coefficient_threshold = t; + } + PPVMHeader::Backend(b) => { + info.backend = b; + } + PPVMHeader::Observable(s) => { + info.observable = Some(s); + } + PPVMHeader::MaxPauliWeight(w) => { + info.max_pauli_weight = Some(w); + } + } + Ok(()) + } + + fn lower_raw(&mut self, raw: RawForm) -> eyre::Result> { + match raw.mnemonic.as_str() { + "ret" => { + let keep = match raw.operands.as_slice() { + [] => 0u32, + [RawOperand::UInt(n)] => u32::try_from(*n) + .map_err(|_| eyre::eyre!("`ret` keep count {n} does not fit in u32"))?, + other => { + return Err(eyre::eyre!( + "`ret` takes 0 or 1 unsigned int operands, got {other:?}" + )); + } + }; + Ok(vec![vihaco_cpu::Instruction::Return(keep).into()]) + } + "const.str" => { + let lit = match raw.operands.as_slice() { + [RawOperand::StringLit(s)] => s.clone(), + other => { + return Err(eyre::eyre!( + "`const.str` takes one string literal, got {other:?}" + )); + } + }; + let addr = u32::try_from(self.strings.len()).map_err(|_| { + eyre::eyre!("string table overflowed u32 at `const.str` lowering") + })?; + self.strings.push(lit); + Ok(vec![ + vihaco_cpu::Instruction::Const(vihaco::Value::String(addr)).into(), + ]) + } + other => Err(eyre::eyre!( + "PPVMResolver: unhandled raw form `{other}` (operands: {:?})", + raw.operands + )), + } + } +} + +impl Resolve for PPVMResolver { + type Module = Module; + fn resolve_module( + &mut self, + parsed: vihaco::syntax::ParsedModule, + ) -> eyre::Result { + let mut info = PPVMDeviceInfo::default(); + for header in parsed.headers { + Self::apply_header(&mut info, header)?; + } + + let mut code: Vec = Vec::new(); + let mut labels: HashMap = HashMap::new(); + let mut branch_patches: Vec<(usize, BranchPatch)> = Vec::new(); + let mut call_patches: Vec<(usize, CallPatch)> = Vec::new(); + for function in parsed.functions { + if labels + .insert(function.name.clone(), code.len() as u32) + .is_some() + { + return Err(eyre::eyre!("duplicate function name `@{}`", function.name)); + } + for item in function.body { + match item { + BodyItem::Direct(inst) => code.push(inst), + BodyItem::Raw(raw) => { + if let Some(name) = raw_as_label(&raw) { + if labels.insert(name.clone(), code.len() as u32).is_some() { + return Err(eyre::eyre!("duplicate label `@{name}`")); + } + continue; + } + if let Some(patch) = raw_as_branch(&raw) { + let idx = code.len(); + code.push(patch.placeholder()); + branch_patches.push((idx, patch)); + continue; + } + if let Some(patch) = raw_as_call(&raw)? { + let idx = code.len(); + code.push(patch.placeholder()); + call_patches.push((idx, patch)); + continue; + } + code.extend(self.lower_raw(raw)?); + } + } + } + } + for (idx, patch) in branch_patches { + patch.apply(&mut code, idx, &labels)?; + } + for (idx, patch) in call_patches { + patch.apply(&mut code, idx, &labels)?; + } + + let strings = std::mem::take(&mut self.strings); + let module = Module { + code, + strings, + extra: info, + ..Default::default() + }; + Ok(module) + } +} + +type Err<'src> = extra::Err>; + +impl<'src> Parse<'src> for PPVMInstruction { + fn parser() -> impl Parser<'src, &'src str, Self, Err<'src>> { + use chumsky::prelude::*; + + let cpu = ::parser().map(PPVMInstruction::Cpu); + + // Reuse the derived parser for all CircuitInstruction variants, + // gated behind the `circuit.` prefix (covers gates, noise channels, + // measure/reset, trace, and truncate — i.e. everything circuit-side). + let circuit = just("circuit") + .then(just('.')) + .ignore_then(::parser()) + .map(PPVMInstruction::Circuit); + + // Try `circuit.` first so CPU doesn't see "circuit" as an identifier. + choice((circuit, cpu)) + } +} + +// ---- Everything below is 1:1 copy from Acamar with Acamar -> PPVM renaming ---- + +/// A deferred branch whose target(s) couldn't be resolved at lowering time +/// because the label may appear later in the function body. Patched in a +/// second pass once all labels are known. +#[derive(Debug)] +enum BranchPatch { + /// `br @target` — fills the `u32` in `cpu::Instruction::Branch`. + Unconditional(String), + /// `br @t, @f` / `cond_br @t, @f` — fills both `u32`s in + /// `cpu::Instruction::ConditionalBranch`. + Conditional(String, String), +} + +/// `@name:` → `Some("name")`. Body parser already emits `@entry:` as a single +/// raw mnemonic with no operands, so the check is purely on the mnemonic +/// shape. +fn raw_as_label(raw: &RawForm) -> Option { + if !raw.operands.is_empty() { + return None; + } + let m = raw.mnemonic.as_str(); + let stripped = m.strip_prefix('@')?.strip_suffix(':')?; + if stripped.is_empty() { + return None; + } + Some(stripped.to_string()) +} + +/// `br @t` / `br @t, @f` / `cond_br @t, @f`. +fn raw_as_branch(raw: &RawForm) -> Option { + let symbols: Vec<&str> = raw + .operands + .iter() + .map(|op| match op { + RawOperand::Symbol(s) => Some(s.as_str()), + _ => None, + }) + .collect::>>()?; + + match (raw.mnemonic.as_str(), symbols.as_slice()) { + ("br", [t]) => Some(BranchPatch::Unconditional((*t).to_string())), + ("br", [t, f]) | ("cond_br", [t, f]) => { + Some(BranchPatch::Conditional((*t).to_string(), (*f).to_string())) + } + _ => None, + } +} + +impl BranchPatch { + fn placeholder(&self) -> PPVMInstruction { + match self { + BranchPatch::Unconditional(_) => vihaco_cpu::Instruction::Branch(u32::MAX).into(), + BranchPatch::Conditional(_, _) => { + vihaco_cpu::Instruction::ConditionalBranch(u32::MAX, u32::MAX).into() + } + } + } + + fn apply( + self, + code: &mut [PPVMInstruction], + idx: usize, + labels: &HashMap, + ) -> eyre::Result<()> { + let lookup = |name: &str| { + labels + .get(name) + .copied() + .ok_or_else(|| eyre::eyre!("undefined label `@{name}`")) + }; + let resolved = match self { + BranchPatch::Unconditional(t) => vihaco_cpu::Instruction::Branch(lookup(&t)?).into(), + BranchPatch::Conditional(t, f) => { + vihaco_cpu::Instruction::ConditionalBranch(lookup(&t)?, lookup(&f)?).into() + } + }; + code[idx] = resolved; + Ok(()) + } +} + +/// `call , @target` — symbolic target resolved in a second pass against +/// the same label table that holds branch targets and function entry points. +#[derive(Debug)] +struct CallPatch { + arity: u32, + target: String, +} + +/// `call , @target` → `Some(CallPatch)`. Returns `Ok(None)` for any +/// other mnemonic so the resolver can fall through to `lower_raw`. +fn raw_as_call(raw: &RawForm) -> eyre::Result> { + if raw.mnemonic != "call" { + return Ok(None); + } + match raw.operands.as_slice() { + [RawOperand::UInt(arity), RawOperand::Symbol(target)] => { + let arity = u32::try_from(*arity) + .map_err(|_| eyre::eyre!("`call` arity {arity} does not fit in u32"))?; + Ok(Some(CallPatch { + arity, + target: target.clone(), + })) + } + other => Err(eyre::eyre!( + "`call` expects `, @`, got operands {other:?}" + )), + } +} + +impl CallPatch { + fn placeholder(&self) -> PPVMInstruction { + vihaco_cpu::Instruction::Call(self.arity, u32::MAX).into() + } + + fn apply( + self, + code: &mut [PPVMInstruction], + idx: usize, + labels: &HashMap, + ) -> eyre::Result<()> { + let target = labels + .get(&self.target) + .copied() + .ok_or_else(|| eyre::eyre!("undefined function `@{}`", self.target))?; + code[idx] = vihaco_cpu::Instruction::Call(self.arity, target).into(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vihaco::syntax::ParsedModule; + + fn parse_module(source: &str) -> ParsedModule { + ParsedModule::::parser() + .parse(source) + .into_result() + .unwrap_or_else(|e| panic!("parse failed: {e:?}")) + } + + fn raw(mnemonic: &str, operands: Vec) -> RawForm { + RawForm { + mnemonic: mnemonic.to_string(), + operands, + } + } + + // ─── Header parsing ─────────────────────────────────────────────────── + + #[test] + fn header_parses_n_qubits() { + let got = ::parser() + .parse("device circuit.n_qubits 5") + .into_result() + .unwrap_or_else(|e| panic!("parse failed: {e:?}")); + assert_eq!(got, PPVMHeader::NumQubits(5)); + } + + #[test] + fn header_parses_coefficient_threshold() { + let got = ::parser() + .parse("device circuit.coefficient_threshold 1e-10") + .into_result() + .unwrap_or_else(|e| panic!("parse failed: {e:?}")); + assert_eq!(got, PPVMHeader::CoefficientThreshold(1e-10)); + } + + #[test] + fn header_n_qubits_rejects_extra_operand() { + // The variant has exactly one field, so the parser consumes one + // integer. Wrapped in a full module, a second integer must trip the + // module-level parser. + let result = ParsedModule::::parser() + .parse( + "device circuit.n_qubits 5 6;\n\ + fn @main() { ret }\n", + ) + .into_result(); + assert!(result.is_err(), "expected parse error, got {result:?}"); + } + + #[test] + fn header_coefficient_threshold_rejects_extra_operand() { + let result = ParsedModule::::parser() + .parse( + "device circuit.coefficient_threshold 1e-10 0.5;\n\ + fn @main() { ret }\n", + ) + .into_result(); + assert!(result.is_err(), "expected parse error, got {result:?}"); + } + + #[test] + fn header_parses_backend_tableau() { + let got = ::parser() + .parse("device circuit.backend tableau") + .into_result() + .unwrap_or_else(|e| panic!("parse failed: {e:?}")); + assert_eq!(got, PPVMHeader::Backend(BackendKind::Tableau)); + } + + #[test] + fn header_parses_backend_paulisum() { + let got = ::parser() + .parse("device circuit.backend paulisum") + .into_result() + .unwrap_or_else(|e| panic!("parse failed: {e:?}")); + assert_eq!(got, PPVMHeader::Backend(BackendKind::PauliSum)); + } + + #[test] + fn header_parses_backend_lossy_paulisum() { + let got = ::parser() + .parse("device circuit.backend lossy_paulisum") + .into_result() + .unwrap_or_else(|e| panic!("parse failed: {e:?}")); + assert_eq!(got, PPVMHeader::Backend(BackendKind::LossyPauliSum)); + } + + #[test] + fn header_parses_observable_single_pauli_word() { + let got = ::parser() + .parse("device circuit.observable ZZIIII") + .into_result() + .unwrap_or_else(|e| panic!("parse failed: {e:?}")); + assert_eq!(got, PPVMHeader::Observable("ZZIIII".to_string())); + } + + #[test] + fn header_parses_max_pauli_weight() { + let got = ::parser() + .parse("device circuit.max_pauli_weight 8") + .into_result() + .unwrap_or_else(|e| panic!("parse failed: {e:?}")); + assert_eq!(got, PPVMHeader::MaxPauliWeight(8)); + } + + #[test] + fn apply_header_sets_n_qubits() { + let mut info = PPVMDeviceInfo::default(); + PPVMResolver::apply_header(&mut info, PPVMHeader::NumQubits(7)).unwrap(); + assert_eq!(info.n_qubits, 7); + } + + #[test] + fn apply_header_sets_coefficient_threshold() { + let mut info = PPVMDeviceInfo::default(); + PPVMResolver::apply_header(&mut info, PPVMHeader::CoefficientThreshold(5e-6)).unwrap(); + assert_eq!(info.coefficient_threshold, 5e-6); + } + + #[test] + fn apply_header_sets_backend() { + let mut info = PPVMDeviceInfo::default(); + PPVMResolver::apply_header(&mut info, PPVMHeader::Backend(BackendKind::PauliSum)).unwrap(); + assert_eq!(info.backend, BackendKind::PauliSum); + } + + #[test] + fn apply_header_sets_observable() { + let mut info = PPVMDeviceInfo::default(); + PPVMResolver::apply_header(&mut info, PPVMHeader::Observable("ZZ".to_string())).unwrap(); + assert_eq!(info.observable.as_deref(), Some("ZZ")); + } + + #[test] + fn apply_header_sets_max_pauli_weight() { + let mut info = PPVMDeviceInfo::default(); + PPVMResolver::apply_header(&mut info, PPVMHeader::MaxPauliWeight(4)).unwrap(); + assert_eq!(info.max_pauli_weight, Some(4)); + } + + #[test] + fn device_info_defaults_match_tableau_no_observable_no_truncation() { + let info = PPVMDeviceInfo::default(); + assert_eq!(info.backend, BackendKind::Tableau); + assert_eq!(info.observable, None); + assert_eq!(info.max_pauli_weight, None); + } + + // ─── PPVMInstruction parser dispatch ────────────────────────────────── + + #[test] + fn ppvm_instruction_parses_cpu_const() { + let got = ::parser() + .parse("const.u64 7") + .into_result() + .unwrap(); + assert!(matches!( + got, + PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const(Value::U64(7))) + )); + } + + #[test] + fn ppvm_instruction_parses_gate_h() { + let got = ::parser() + .parse("circuit.h") + .into_result() + .unwrap(); + assert!(matches!( + got, + PPVMInstruction::Circuit(CircuitInstruction::H) + )); + } + + #[test] + fn ppvm_instruction_parses_gate_cnot() { + let got = ::parser() + .parse("circuit.cnot") + .into_result() + .unwrap(); + assert!(matches!( + got, + PPVMInstruction::Circuit(CircuitInstruction::CNOT) + )); + } + + #[test] + fn ppvm_instruction_parses_gate_measure() { + let got = ::parser() + .parse("circuit.measure") + .into_result() + .unwrap(); + assert!(matches!( + got, + PPVMInstruction::Circuit(CircuitInstruction::Measure) + )); + } + + #[test] + fn ppvm_instruction_parses_gate_rx() { + let got = ::parser() + .parse("circuit.rx") + .into_result() + .unwrap(); + assert!(matches!( + got, + PPVMInstruction::Circuit(CircuitInstruction::RX) + )); + } + + #[test] + fn ppvm_instruction_rejects_bare_circuit_token_without_circuit_prefix() { + // `h` on its own must not parse as Circuit(H) — only `circuit.h` does. + // Without `circuit `, the CPU parser is tried, which should reject + // `h` (not a CPU mnemonic). + let result = ::parser() + .parse("h") + .into_result(); + assert!(result.is_err(), "expected parse error, got {result:?}"); + } + + // ─── lower_raw ──────────────────────────────────────────────────────── + + #[test] + fn lower_raw_ret_emits_return_zero() { + let mut r = PPVMResolver::new(); + let out = r.lower_raw(raw("ret", vec![])).unwrap(); + assert_eq!(out.len(), 1); + assert!(matches!( + out[0], + PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(0)) + )); + } + + #[test] + fn lower_raw_ret_with_uint_operand_emits_return_n() { + let mut r = PPVMResolver::new(); + let out = r.lower_raw(raw("ret", vec![RawOperand::UInt(2)])).unwrap(); + assert_eq!(out.len(), 1); + assert!(matches!( + out[0], + PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(2)) + )); + } + + #[test] + fn lower_raw_ret_with_non_uint_operand_errors() { + let mut r = PPVMResolver::new(); + let err = r + .lower_raw(raw("ret", vec![RawOperand::Symbol("foo".into())])) + .unwrap_err(); + assert!( + err.to_string().contains("`ret` takes 0 or 1 unsigned int"), + "err: {err}" + ); + } + + #[test] + fn lower_raw_unknown_mnemonic_errors() { + let mut r = PPVMResolver::new(); + let err = r.lower_raw(raw("nope", vec![])).unwrap_err(); + assert!(err.to_string().contains("unhandled raw form"), "err: {err}"); + } + + // ─── End-to-end resolver behaviour ──────────────────────────────────── + + #[test] + fn resolver_populates_device_info_from_headers() { + let parsed = parse_module( + "device circuit.n_qubits 3;\n\ + device circuit.coefficient_threshold 1e-8;\n\ + fn @main() { ret }\n", + ); + let m = PPVMResolver::new().resolve_module(parsed).unwrap(); + assert_eq!(m.extra.n_qubits, 3); + assert_eq!(m.extra.coefficient_threshold, 1e-8); + } + + #[test] + fn resolver_populates_paulisum_headers() { + let parsed = parse_module( + "device circuit.n_qubits 4;\n\ + device circuit.backend paulisum;\n\ + device circuit.observable ZZII;\n\ + device circuit.max_pauli_weight 8;\n\ + fn @main() { ret }\n", + ); + let m = PPVMResolver::new().resolve_module(parsed).unwrap(); + assert_eq!(m.extra.n_qubits, 4); + assert_eq!(m.extra.backend, BackendKind::PauliSum); + assert_eq!(m.extra.observable.as_deref(), Some("ZZII")); + assert_eq!(m.extra.max_pauli_weight, Some(8)); + } + + #[test] + fn resolver_lowers_simple_bell_body() { + // Smoke test the whole pipeline on a tiny bell-like body. + let parsed = parse_module( + "device circuit.n_qubits 2;\n\ + fn @main() {\n\ + const.u64 0\n\ + circuit.h\n\ + const.u64 0\n\ + const.u64 1\n\ + circuit.cnot\n\ + ret\n\ + }\n", + ); + let m = PPVMResolver::new().resolve_module(parsed).unwrap(); + // const.u64 0 / circuit.h / const.u64 0 / const.u64 1 / circuit.cnot / ret + assert_eq!(m.code.len(), 6); + assert!(matches!( + m.code[1], + PPVMInstruction::Circuit(CircuitInstruction::H) + )); + assert!(matches!( + m.code[4], + PPVMInstruction::Circuit(CircuitInstruction::CNOT) + )); + assert!(matches!( + m.code[5], + PPVMInstruction::Cpu(vihaco_cpu::Instruction::Return(0)) + )); + } + + #[test] + fn resolver_resolves_forward_branch_targets() { + let parsed = parse_module( + "fn @main() {\n\ + @loop:\n\ + br @done\n\ + @done:\n\ + ret\n\ + }\n", + ); + let m = PPVMResolver::new().resolve_module(parsed).unwrap(); + assert!(matches!( + m.code[0], + PPVMInstruction::Cpu(vihaco_cpu::Instruction::Branch(1)) + )); + } + + #[test] + fn resolver_resolves_conditional_branch_with_two_targets() { + let parsed = parse_module( + "fn @main() {\n\ + @head:\n\ + br @head, @exit\n\ + @exit:\n\ + ret\n\ + }\n", + ); + let m = PPVMResolver::new().resolve_module(parsed).unwrap(); + assert!(matches!( + m.code[0], + PPVMInstruction::Cpu(vihaco_cpu::Instruction::ConditionalBranch(0, 1)) + )); + } + + #[test] + fn resolver_rejects_undefined_branch_target() { + let parsed = parse_module( + "fn @main() {\n\ + br @missing\n\ + ret\n\ + }\n", + ); + let err = PPVMResolver::new().resolve_module(parsed).unwrap_err(); + assert!( + err.to_string().contains("undefined label `@missing`"), + "err: {err}" + ); + } + + #[test] + fn resolver_rejects_duplicate_label() { + let parsed = parse_module( + "fn @main() {\n\ + @same:\n\ + ret\n\ + @same:\n\ + ret\n\ + }\n", + ); + let err = PPVMResolver::new().resolve_module(parsed).unwrap_err(); + assert!( + err.to_string().contains("duplicate label `@same`"), + "err: {err}" + ); + } +} diff --git a/crates/ppvm-vihaco/tests/bell.sst b/crates/ppvm-vihaco/tests/bell.sst new file mode 100644 index 000000000..8b23d1f37 --- /dev/null +++ b/crates/ppvm-vihaco/tests/bell.sst @@ -0,0 +1,18 @@ +device circuit.n_qubits 2; + +fn @main() { + const.u64 0 + circuit.h + + const.u64 0 + const.u64 1 + circuit.cnot + + const.u64 0 + circuit.measure + + const.u64 0 + circuit.measure + + ret +} diff --git a/crates/ppvm-vihaco/tests/branch_on_outcome.sst b/crates/ppvm-vihaco/tests/branch_on_outcome.sst new file mode 100644 index 000000000..d86de1896 --- /dev/null +++ b/crates/ppvm-vihaco/tests/branch_on_outcome.sst @@ -0,0 +1,29 @@ +device circuit.n_qubits 2; + +fn @main() { + const.u64 0 + circuit.h + + const.u64 0 + circuit.measure + + // Stack: [outcome]. No loss gate, so outcome is 0 or 1. Compare to 1 + // to derive a bool for cond_br. + const.u32 1 + eq.u32 + + cond_br @one, @zero + + @one: + const.u64 1 + circuit.x + br @measure_q1 + + @zero: + br @measure_q1 + + @measure_q1: + const.u64 1 + circuit.measure + ret +} diff --git a/crates/ppvm-vihaco/tests/branch_on_outcome_x.sst b/crates/ppvm-vihaco/tests/branch_on_outcome_x.sst new file mode 100644 index 000000000..501797c4f --- /dev/null +++ b/crates/ppvm-vihaco/tests/branch_on_outcome_x.sst @@ -0,0 +1,30 @@ +device circuit.n_qubits 2; + +fn @main() { + // X on q0 -> |1>, measure -> outcome is deterministically 1. + const.u64 0 + circuit.x + + const.u64 0 + circuit.measure + + // Stack: [outcome]. No loss gate, so outcome is 0 or 1. Compare to 1 + // to derive a bool for cond_br. + const.u32 1 + eq.u32 + + cond_br @one, @zero + + @one: + const.u64 1 + circuit.x + br @measure_q1 + + @zero: + br @measure_q1 + + @measure_q1: + const.u64 1 + circuit.measure + ret +} diff --git a/crates/ppvm-vihaco/tests/function_call.sst b/crates/ppvm-vihaco/tests/function_call.sst new file mode 100644 index 000000000..c001a5160 --- /dev/null +++ b/crates/ppvm-vihaco/tests/function_call.sst @@ -0,0 +1,19 @@ +device circuit.n_qubits 2; + +fn @main() { + // Jump into the helper, which finishes the program with `halt`. + // Using `halt` instead of `ret` from the callee avoids depending on + // vihaco-cpu restoring a return PC, which it doesn't track today. + call 0, @run_circuit + ret +} + +fn @run_circuit() { + const.u64 1 + circuit.h + + const.u64 1 + circuit.measure + + ret 1 +} diff --git a/crates/ppvm-vihaco/tests/function_call_branch_both.sst b/crates/ppvm-vihaco/tests/function_call_branch_both.sst new file mode 100644 index 000000000..46b538f12 --- /dev/null +++ b/crates/ppvm-vihaco/tests/function_call_branch_both.sst @@ -0,0 +1,63 @@ +device circuit.n_qubits 2; + +// Exercises a helper that returns the tri-state measurement outcome via +// `ret 1`, with main branching on loss first, then on the 0/1 outcome. +// +// Layout after `call 0, @measure_q0` returns: stack = [outcome] where +// outcome ∈ {0, 1, 2} (2 = Lost). +// +// Loss prob is 0.5, so: +// P(lost) = 0.5 → q1 flipped to |1> → m1 = 1 +// P(kept ∧ outcome = 1) = 0.25 → q1 flipped to |1> → m1 = 1 +// P(kept ∧ outcome = 0) = 0.25 → q1 stays in |0> → m1 = 0 +// → P(m1 = 1) = 0.75. +fn @main() { + const.u64 0 + circuit.h + + const.u64 0 + const.f64 0.5 + circuit.loss + + call 0, @measure_q0 + + // Stack: [outcome]. Branch on Lost (outcome == 2) first. + dup + const.u32 2 + eq.u32 + cond_br @lost, @kept + + @lost: + // is_lost = true: outcome is meaningless. Flip q1 to mark "lost" + // path. The leftover `outcome` value on the stack is harmless because + // we halt at @final without reading it. + const.u64 1 + circuit.x + br @final + + @kept: + // Stack: [outcome (0 or 1)]. Compare to 1 to derive a bool. + const.u32 1 + eq.u32 + cond_br @outcome_one, @outcome_zero + + @outcome_one: + const.u64 1 + circuit.x + br @final + + @outcome_zero: + br @final + + @final: + const.u64 1 + circuit.measure + halt +} + +fn @measure_q0() { + const.u64 0 + circuit.measure + // Stack: [outcome] + ret 1 +} diff --git a/crates/ppvm-vihaco/tests/function_call_ret.sst b/crates/ppvm-vihaco/tests/function_call_ret.sst new file mode 100644 index 000000000..ea7623adf --- /dev/null +++ b/crates/ppvm-vihaco/tests/function_call_ret.sst @@ -0,0 +1,42 @@ +device circuit.n_qubits 2; + +// TODO: aspirational — depends on `ret ` restoring the caller's PC and +// leaving the top `n` values on the caller's stack. The runtime doesn't do +// this today (Frame has no return_pc), so this fixture currently fails. + +fn @main() { + // Put q0 into |+>. + const.u64 0 + circuit.h + + // Measure q1 via a helper that returns the outcome on top of the stack. + call 0, @measure_q1 + + // Stack: [outcome]. Compare to 1 to derive a bool for cond_br. + const.u32 1 + eq.u32 + cond_br @one, @zero + + @one: + // outcome was 1: apply X to q0 as a correction. + const.u64 0 + circuit.x + br @done + + @zero: + br @done + + @done: + ret +} + +fn @measure_q1() -> u32 { + const.u64 1 + circuit.h + + const.u64 1 + circuit.measure + // Stack: [outcome] + + ret 1 +} diff --git a/crates/ppvm-vihaco/tests/hello_circuit.sst b/crates/ppvm-vihaco/tests/hello_circuit.sst new file mode 100644 index 000000000..21f8b619b --- /dev/null +++ b/crates/ppvm-vihaco/tests/hello_circuit.sst @@ -0,0 +1,16 @@ +device circuit.n_qubits 2; + +fn @main() { + const.u64 0 + circuit.h + + const.u64 0 + const.u64 1 + circuit.cnot + + const.u64 0 + const.f64 0.1 + circuit.rx + + ret +} diff --git a/crates/ppvm-vihaco/tests/lossy_paulisum_loss_trace.sst b/crates/ppvm-vihaco/tests/lossy_paulisum_loss_trace.sst new file mode 100644 index 000000000..3f42aef14 --- /dev/null +++ b/crates/ppvm-vihaco/tests/lossy_paulisum_loss_trace.sst @@ -0,0 +1,28 @@ +device circuit.n_qubits 2; +device circuit.backend lossy_paulisum; +device circuit.observable ZZ; + +// LossyPauliSum end-to-end with a single-qubit loss channel. The header +// seeds `ZZ`, the body Heisenberg-propagates ZZ backward through CNOT(0,1) +// to Z_1 (= IZ), then applies loss(q0, p=0.3). The pattern `Z?*` matches +// all Z/I-only Paulis on the surviving qubits, so the trace picks up the +// IZ coefficient that survived the loss channel. +// +// The Rust-side test cross-checks this against a pure-Rust LossyPauliSum +// reference with the same Config/strategy, so we don't have to commit to +// the exact loss-channel value — only that .sst and direct API agree. +fn @main() { + const.u64 0 + const.u64 1 + circuit.cnot + + const.u64 0 + const.f64 0.3 + circuit.loss + + circuit.truncate + + const.str "Z?*" + circuit.trace + ret +} diff --git a/crates/ppvm-vihaco/tests/paulisum_bell_trace.sst b/crates/ppvm-vihaco/tests/paulisum_bell_trace.sst new file mode 100644 index 000000000..e36fe339f --- /dev/null +++ b/crates/ppvm-vihaco/tests/paulisum_bell_trace.sst @@ -0,0 +1,18 @@ +device circuit.n_qubits 2; +device circuit.backend paulisum; +device circuit.observable ZZ; + +fn @main() { + // Textbook H(0); CNOT(0,1) — emit reversed for Heisenberg propagation. + const.u64 0 + const.u64 1 + circuit.cnot + + const.u64 0 + circuit.h + + // Trace against |00>: match Z-or-identity on every qubit. + const.str "Z?*" + circuit.trace + ret +} diff --git a/crates/ppvm-vihaco/tests/paulisum_ghz_xxx_trace.sst b/crates/ppvm-vihaco/tests/paulisum_ghz_xxx_trace.sst new file mode 100644 index 000000000..53cb37c41 --- /dev/null +++ b/crates/ppvm-vihaco/tests/paulisum_ghz_xxx_trace.sst @@ -0,0 +1,27 @@ +device circuit.n_qubits 3; +device circuit.backend paulisum; +device circuit.observable XXX; + +fn @main() { + // Heisenberg backward through GHZ prep: forward is H(0); CNOT(0,1); CNOT(1,2), + // so we apply CNOT(1,2); CNOT(0,1); H(0) here. + // + // XXX --CNOT(1,2)--> XXI (X_2 on target absorbs into X_1 on control) + // --CNOT(0,1)--> XII + // --H(0)------> ZII + // Trace against Z/I-only Paulis picks up the coefficient of ZII = 1.0. + const.u64 1 + const.u64 2 + circuit.cnot + + const.u64 0 + const.u64 1 + circuit.cnot + + const.u64 0 + circuit.h + + const.str "Z?*" + circuit.trace + ret +} diff --git a/crates/ppvm-vihaco/tests/paulisum_measure_error.sst b/crates/ppvm-vihaco/tests/paulisum_measure_error.sst new file mode 100644 index 000000000..9d5f88991 --- /dev/null +++ b/crates/ppvm-vihaco/tests/paulisum_measure_error.sst @@ -0,0 +1,9 @@ +device circuit.n_qubits 1; +device circuit.backend paulisum; +device circuit.observable Z; + +fn @main() { + const.u64 0 + circuit.measure + ret +} diff --git a/crates/ppvm-vihaco/tests/paulisum_multi_term_trace.sst b/crates/ppvm-vihaco/tests/paulisum_multi_term_trace.sst new file mode 100644 index 000000000..7de4f4f99 --- /dev/null +++ b/crates/ppvm-vihaco/tests/paulisum_multi_term_trace.sst @@ -0,0 +1,11 @@ +device circuit.n_qubits 2; +device circuit.backend paulisum; +device circuit.observable 1.0*ZZ+0.5*XX; + +fn @main() { + // No gates — the multi-term observable seeds the state directly. + // Pattern `[XZ]?*` matches both ZZ (coef 1.0) and XX (coef 0.5). + const.str "[XZ]?*" + circuit.trace + ret +} diff --git a/crates/ppvm-vihaco/tests/paulisum_ry_z_trace.sst b/crates/ppvm-vihaco/tests/paulisum_ry_z_trace.sst new file mode 100644 index 000000000..449d92a79 --- /dev/null +++ b/crates/ppvm-vihaco/tests/paulisum_ry_z_trace.sst @@ -0,0 +1,17 @@ +device circuit.n_qubits 1; +device circuit.backend paulisum; +device circuit.observable Z; + +fn @main() { + // PauliSum already runs in the Heisenberg picture: `circuit.ry` performs + // RY(θ)† Z RY(θ) = cos(θ)·Z + sin(θ)·X. A single gate means no + // reversal is needed beyond that. Trace against Z/I-only Paulis picks + // up the cos(θ) coefficient on Z; the sin(θ)·X term contributes 0. + const.u64 0 + const.f64 0.7 + circuit.ry + + const.str "Z?*" + circuit.trace + ret +} diff --git a/crates/ppvm-vihaco/tests/paulisum_trotter_truncate.sst b/crates/ppvm-vihaco/tests/paulisum_trotter_truncate.sst new file mode 100644 index 000000000..a94ee86a7 --- /dev/null +++ b/crates/ppvm-vihaco/tests/paulisum_trotter_truncate.sst @@ -0,0 +1,37 @@ +device circuit.n_qubits 2; +device circuit.backend paulisum; +device circuit.observable ZZ; +device circuit.coefficient_threshold 1e-6; + +fn @main() { + // Two Trotter layers of RXX(0.1) RZZ(0.05), with explicit truncate + // between layers. RXX branches Z->Y so the sum grows; truncate prunes + // small-coefficient terms via `coefficient_threshold`. + const.u64 0 + const.u64 1 + const.f64 0.1 + circuit.rxx + + const.u64 0 + const.u64 1 + const.f64 0.05 + circuit.rzz + + circuit.truncate + + const.u64 0 + const.u64 1 + const.f64 0.1 + circuit.rxx + + const.u64 0 + const.u64 1 + const.f64 0.05 + circuit.rzz + + circuit.truncate + + const.str "Z?*" + circuit.trace + ret +} diff --git a/crates/ppvm-vihaco/tests/rotxy.sst b/crates/ppvm-vihaco/tests/rotxy.sst new file mode 100644 index 000000000..99e6c50ed --- /dev/null +++ b/crates/ppvm-vihaco/tests/rotxy.sst @@ -0,0 +1,15 @@ +device circuit.n_qubits 1; + +fn @main() { + // R(axis_angle = π/2, θ = π) == RY(π), so |0> is sent to |1>. + // Stack order for `circuit.r`: qubit, then axis_angle, then theta. + const.u64 0 + const.f64 1.5707963267948966 + const.f64 3.141592653589793 + circuit.r + + const.u64 0 + circuit.measure + + ret +} diff --git a/crates/ppvm-vihaco/tests/sst_fixtures.rs b/crates/ppvm-vihaco/tests/sst_fixtures.rs new file mode 100644 index 000000000..a66d9445d --- /dev/null +++ b/crates/ppvm-vihaco/tests/sst_fixtures.rs @@ -0,0 +1,532 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end fixture coverage: parse + resolve + run each `.sst` file in +//! this directory via the public `PPVM` API. + +use ppvm_vihaco::composite::PPVM; +use ppvm_vihaco::measurements::MeasurementOutcome; + +/// Dump a fixture to a `.ssb` file, load it back, and run it. Exercises the +/// full bytecode round-trip through disk: `dump_file` → `load_bytecode_file`. +fn dump_load_run(sst_path: &str, ssb_name: &str) -> PPVM { + let out = std::env::temp_dir().join(ssb_name); + let out = out.to_str().expect("utf-8 temp path"); + ppvm_vihaco::dump_file(sst_path, out).unwrap_or_else(|e| panic!("dump {sst_path}: {e:?}")); + + let mut machine = PPVM::default(); + machine + .load_bytecode_file(out) + .unwrap_or_else(|e| panic!("load {out}: {e:?}")); + machine.run().unwrap_or_else(|e| panic!("run {out}: {e:?}")); + + let _ = std::fs::remove_file(out); + machine +} + +#[test] +fn bell_sst_runs_and_records_two_measurements() { + let mut machine = PPVM::default(); + machine + .run_file("tests/bell.sst") + .unwrap_or_else(|e| panic!("run bell.sst: {e:?}")); + assert_eq!(machine.measurement_record().len(), 2); +} + +#[test] +fn hello_circuit_sst_parses_and_runs() { + let mut machine = PPVM::default(); + machine + .run_file("tests/hello_circuit.sst") + .unwrap_or_else(|e| panic!("run hello_circuit.sst: {e:?}")); + // hello_circuit.sst applies H + CNOT + RX(0.1); no measurements. + assert_eq!(machine.measurement_record().len(), 0); +} + +#[test] +fn rotxy_sst_runs_and_flips_qubit() { + // `rotxy.sst` applies R(axis_angle=π/2, θ=π) = RY(π) to q0, deterministically + // sending |0> → |1>, then measures it. Exercises the `circuit.r` path end to + // end: parse → resolve (pop θ, axis_angle, qubit) → execute via `tab.r`. + let machine = + ppvm_vihaco::run_file("tests/rotxy.sst").unwrap_or_else(|e| panic!("run rotxy.sst: {e:?}")); + let record = machine.measurement_record(); + assert_eq!(record.len(), 1, "expected exactly one measurement"); + assert_eq!( + record[0].as_slice(), + &[MeasurementOutcome::One], + "R(π/2, π) = RY(π) must flip q0 to 1" + ); +} + +#[test] +fn dumped_rotxy_runs_and_flips_qubit() { + // Same program, but through the bytecode round-trip: confirms the `R` + // instruction survives dump → `.ssb` → load → execute. + let machine = dump_load_run("tests/rotxy.sst", "ppvm_dump_rotxy.ssb"); + let record = machine.measurement_record(); + assert_eq!(record.len(), 1); + assert_eq!(record[0].as_slice(), &[MeasurementOutcome::One]); +} + +#[test] +fn run_file_via_library_helper() { + let machine = + ppvm_vihaco::run_file("tests/bell.sst").unwrap_or_else(|e| panic!("run bell.sst: {e:?}")); + assert_eq!(machine.measurement_record().len(), 2); +} + +#[test] +fn function_call_jumps_into_callee_body() { + // `function_call.sst` has main `call` into `@run_circuit`, which puts q1 + // in |+>, measures it, and `halt`s. Verifies CallPatch resolves the + // symbolic target and op_call actually transfers control there. + let machine = ppvm_vihaco::run_file("tests/function_call.sst") + .unwrap_or_else(|e| panic!("run function_call.sst: {e:?}")); + let record = machine.measurement_record(); + assert_eq!(record.len(), 1, "expected exactly one measurement"); + assert_eq!(record[0].len(), 1); + assert!( + record[0][0] != MeasurementOutcome::Lost, + "measurement should not be lost" + ); +} + +#[test] +fn function_call_returns() { + let machine = ppvm_vihaco::run_file("tests/function_call_ret.sst") + .unwrap_or_else(|e| panic!("run function_call.sst: {e:?}")); + let record = machine.measurement_record(); + assert_eq!(record.len(), 1, "expected exactly one measurement"); + assert_eq!(record[0].len(), 1); + assert!( + record[0][0] != MeasurementOutcome::Lost, + "measurement should not be lost" + ); +} + +#[test] +fn branch_on_outcome_deterministic_x_path() { + // `branch_on_outcome_x.sst` applies X to q0 instead of H, so the outcome + // is deterministically 1. The cond_br must therefore take the @one path, + // which flips q1 before measuring it, yielding m1 = 1 as well. + let machine = ppvm_vihaco::run_file("tests/branch_on_outcome_x.sst") + .unwrap_or_else(|e| panic!("run branch_on_outcome_x.sst: {e:?}")); + let record = machine.measurement_record(); + assert_eq!(record.len(), 2, "expected exactly two measurements"); + assert_eq!( + record[0].as_slice(), + &[MeasurementOutcome::One], + "X-prepared q0 must measure 1" + ); + assert_eq!( + record[1].as_slice(), + &[MeasurementOutcome::One], + "branch must have flipped q1" + ); +} + +#[test] +fn branch_on_outcome_statistics_balanced_and_invariant_holds() { + // `branch_on_outcome.sst` puts q0 in |+>, so its measurement is a fair + // coin. The branch then flips q1 iff the outcome was 1, making m1 == m0 + // an invariant on every shot. Run many shots and check both properties. + const SHOTS: usize = 400; + let mut ones = 0usize; + for _ in 0..SHOTS { + let machine = ppvm_vihaco::run_file("tests/branch_on_outcome.sst") + .unwrap_or_else(|e| panic!("run branch_on_outcome.sst: {e:?}")); + let record = machine.measurement_record(); + assert_eq!(record.len(), 2); + let m0 = record[0][0]; + let m1 = record[1][0]; + assert_eq!(m0, m1, "branch must steer q1 to match q0 on every shot"); + assert!( + m0 != MeasurementOutcome::Lost, + "measurement should not be lost" + ); + if m0 == MeasurementOutcome::One { + ones += 1; + } + } + // Fair coin with SHOTS=400: mean=200, stddev=10. ±6σ window catches a + // truly broken RNG without flaking on a healthy one. + let lo = SHOTS / 2 - 60; + let hi = SHOTS / 2 + 60; + assert!( + (lo..=hi).contains(&ones), + "expected {lo}..={hi} ones out of {SHOTS}, got {ones}" + ); +} + +#[test] +fn function_call_branch_on_both_returned_values() { + // `function_call_branch_both.sst`: helper returns the tri-state outcome + // (0/1/Lost) via `ret 1`. Main first branches on is_lost, then on the + // 0/1 outcome, steering q1 to |1> on the lost path and on the + // kept-outcome=1 path, leaving q1 in |0> only on the kept-outcome=0 + // path. With loss prob 0.5 and a |+> prep: + // P(m1 = 1) = P(lost) + P(kept) · P(outcome = 1 | kept) + // = 0.5 + 0.5 · 0.5 = 0.75 + // P(m0 = lost) = 0.5 + const SHOTS: usize = 400; + let mut q0_lost = 0usize; + let mut q1_ones = 0usize; + for _ in 0..SHOTS { + let machine = ppvm_vihaco::run_file("tests/function_call_branch_both.sst") + .unwrap_or_else(|e| panic!("run function_call_branch_both.sst: {e:?}")); + let record = machine.measurement_record(); + assert_eq!(record.len(), 2, "expected exactly two measurements"); + assert_eq!(record[0].len(), 1); + assert_eq!(record[1].len(), 1); + if record[0][0] == MeasurementOutcome::Lost { + q0_lost += 1; + } + if record[1][0] == MeasurementOutcome::One { + q1_ones += 1; + } + } + // P(lost) = 0.5, SHOTS=400: mean=200, stddev=10. ±6σ window. + assert!( + (140..=260).contains(&q0_lost), + "expected ~200 lost shots, got {q0_lost}" + ); + // P(m1=1) = 0.75, SHOTS=400: mean=300, stddev≈8.66. ±6σ → ~248..352. + assert!( + (240..=360).contains(&q1_ones), + "expected ~300 q1=true shots, got {q1_ones}" + ); +} + +// ─── Task 12: PauliSum / LossyPauliSum end-to-end via .sst source ──────── + +#[test] +fn paulisum_bell_zz_trace_through_sst() { + // Bell-state ⟨ZZ⟩ via PauliSum. Textbook circuit H(0); CNOT(0,1) is + // emitted reversed for Heisenberg propagation: `circuit.cnot; circuit.h`. + // Conjugating ZZ by CNOT(0,1) gives Z_1 (= IZ); H on q0 leaves IZ + // untouched. Tracing against |00> matches IZ (pattern `Z?*`) and + // returns +1.0 — matching ⟨Φ+|ZZ|Φ+⟩ = 1. + let machine = ppvm_vihaco::run_file("tests/paulisum_bell_trace.sst") + .unwrap_or_else(|e| panic!("run paulisum_bell_trace.sst: {e:?}")); + let trace = machine.trace_record(); + assert_eq!(trace.len(), 1, "expected one trace emission"); + assert!( + (trace[0] - 1.0).abs() < 1e-12, + "expected ⟨ZZ⟩ = 1.0, got {}", + trace[0] + ); +} + +#[test] +fn paulisum_multi_term_observable_trace_through_sst() { + // Phase 3 wiring: a 2-term observable `1.0*ZZ+0.5*XX` parses on the + // header, seeds both terms, and an end-of-program trace picks up their + // contributions. With no gates applied, tracing `[XZ]?*` matches both + // ZZ and XX directly → coef sum = 1.0 + 0.5 = 1.5. + let machine = ppvm_vihaco::run_file("tests/paulisum_multi_term_trace.sst") + .unwrap_or_else(|e| panic!("run paulisum_multi_term_trace.sst: {e:?}")); + let trace = machine.trace_record(); + assert_eq!(trace.len(), 1); + assert!( + (trace[0] - 1.5).abs() < 1e-12, + "expected 1.5, got {}", + trace[0] + ); +} + +#[test] +fn paulisum_trotter_matches_pure_rust_reference() { + // Two Trotter layers of RXX(0.1) + RZZ(0.05), interleaved with explicit + // `circuit.truncate`. The .sst-driven path should agree bit-for-bit with a + // pure Rust PauliSum running the same gates: `indexmap::ByteFxHashF64` + // gives deterministic iteration order (Decision 7), so truncation order + // and float accumulation are stable across both paths. + + let machine = ppvm_vihaco::run_file("tests/paulisum_trotter_truncate.sst") + .unwrap_or_else(|e| panic!("run paulisum_trotter_truncate.sst: {e:?}")); + let through_sst = machine.trace_record(); + assert_eq!(through_sst.len(), 1, "expected one trace emission"); + + // Pure Rust reference: same N=8 / strategy / circuit order as the PauliSum + // Bits64 bucket in `ppvm_vihaco::component`. + use ppvm_pauli_sum::config::indexmap::ByteFxHashF64; + use ppvm_pauli_sum::prelude::*; + use ppvm_pauli_sum::strategy::{CoefficientThreshold, CombinedStrategy, MaxPauliWeight}; + type RefConfig = ByteFxHashF64<8, CombinedStrategy>; + + let mut state: PauliSum = PauliSum::builder() + .n_qubits(2) + .strategy(CombinedStrategy( + CoefficientThreshold(1e-6), + MaxPauliWeight(usize::MAX), + )) + .build(); + state += ("ZZ", 1.0); + state.rxx(0, 1, 0.1); + state.rzz(0, 1, 0.05); + state.truncate(); + state.rxx(0, 1, 0.1); + state.rzz(0, 1, 0.05); + state.truncate(); + let pat = PauliPattern::parse("Z?*").expect("parse pattern"); + let reference = state.trace(&pat); + + assert_eq!( + through_sst[0], reference, + ".sst-driven trace must match pure Rust reference bit-for-bit" + ); +} + +#[test] +fn lossy_paulisum_loss_trace_matches_pure_rust_reference() { + // End-to-end LossyPauliSum: header seeds ZZ, body applies CNOT(0,1) + // then loss(q0, 0.3), then traces. The .sst-driven trace must agree + // bit-for-bit with a pure-Rust reference using the same Config (a + // `LossyPauliSumConfig<8>` matching the Bits64 bucket) and operations. + let machine = ppvm_vihaco::run_file("tests/lossy_paulisum_loss_trace.sst") + .unwrap_or_else(|e| panic!("run lossy_paulisum_loss_trace.sst: {e:?}")); + let through_sst = machine.trace_record(); + assert_eq!(through_sst.len(), 1, "expected one trace emission"); + + use ppvm_pauli_sum::config::indexmap::ByteFxHashF64; + use ppvm_pauli_sum::prelude::*; + use ppvm_pauli_sum::strategy::{CoefficientThreshold, CombinedStrategy, MaxPauliWeight}; + type RefConfig = ByteFxHashF64< + 8, + CombinedStrategy, + LossyPauliWord<[u8; 8]>, + >; + + let mut state: PauliSum = PauliSum::builder() + .n_qubits(2) + .strategy(CombinedStrategy( + CoefficientThreshold(1e-10), + MaxPauliWeight(usize::MAX), + )) + .build(); + state += ("ZZ", 1.0); + state.cnot(0, 1); + state.loss_channel(0, 0.3); + state.truncate(); + let pat = PauliPattern::parse("Z?*").expect("parse pattern"); + let reference = state.trace(&pat); + + assert_eq!( + through_sst[0], reference, + ".sst-driven trace must match pure Rust reference bit-for-bit" + ); +} + +#[test] +fn paulisum_measure_returns_unsupported_error() { + // Per Decision 11, Measure on PauliSum hits the dispatch fallback with a + // clear "not supported on the PauliSum backend" error. + let mut machine = PPVM::default(); + machine + .load_file("tests/paulisum_measure_error.sst") + .unwrap_or_else(|e| panic!("load paulisum_measure_error.sst: {e:?}")); + let err = machine.run().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("not supported on the PauliSum backend"), + "expected PauliSum-rejection error, got: {msg}" + ); +} + +// ─── Task 16: Tableau-side Trace, cross-backend agreement ──────────────── + +#[test] +fn tableau_bell_zz_trace_through_sst() { + // Bell-state ⟨ZZ⟩ via the Tableau backend's `Trace` instruction: + // forward H(0); CNOT(0, 1) leaves |ψ⟩ = |Φ+⟩, and `Z0Z1` matches exactly + // the Pauli word ZZ, so `tab.trace(&pat) = ⟨Φ+|ZZ|Φ+⟩ = 1`. + let machine = ppvm_vihaco::run_file("tests/tableau_bell_trace.sst") + .unwrap_or_else(|e| panic!("run tableau_bell_trace.sst: {e:?}")); + let trace = machine.trace_record(); + assert_eq!(trace.len(), 1, "expected one trace emission"); + assert!( + (trace[0] - 1.0).abs() < 1e-12, + "expected ⟨ZZ⟩ = 1.0, got {}", + trace[0] + ); +} + +fn assert_cross_backend_agreement(tableau_sst: &str, paulisum_sst: &str) { + let tab = + ppvm_vihaco::run_file(tableau_sst).unwrap_or_else(|e| panic!("run {tableau_sst}: {e:?}")); + let ps = + ppvm_vihaco::run_file(paulisum_sst).unwrap_or_else(|e| panic!("run {paulisum_sst}: {e:?}")); + let tab_v = tab.trace_record(); + let ps_v = ps.trace_record(); + assert_eq!(tab_v.len(), 1, "{tableau_sst}: expected one trace emission"); + assert_eq!(ps_v.len(), 1, "{paulisum_sst}: expected one trace emission"); + assert!( + (tab_v[0] - ps_v[0]).abs() < 1e-12, + "tableau {} vs PauliSum {} (|Δ|={}) for {tableau_sst} ↔ {paulisum_sst}", + tab_v[0], + ps_v[0], + (tab_v[0] - ps_v[0]).abs() + ); +} + +#[test] +fn tableau_and_paulisum_agree_on_bell_zz_trace() { + // ⟨Φ+|ZZ|Φ+⟩ = 1. Tableau forward-evolves |0…0⟩ and matches `Z0Z1`; + // PauliSum Heisenberg-propagates ZZ backward through the reversed + // circuit, then sums Z/I-only coefficients. + assert_cross_backend_agreement( + "tests/tableau_bell_trace.sst", + "tests/paulisum_bell_trace.sst", + ); +} + +#[test] +fn tableau_and_paulisum_agree_on_ghz_xxx_trace() { + // ⟨GHZ|XXX|GHZ⟩ = 1. Exercises a 3-qubit Clifford chain (H + two + // CNOTs) and the non-trivial Heisenberg evolution XXX → ZII through + // the reversed circuit on the PauliSum side. + assert_cross_backend_agreement( + "tests/tableau_ghz_xxx_trace.sst", + "tests/paulisum_ghz_xxx_trace.sst", + ); +} + +#[test] +fn tableau_and_paulisum_agree_on_ry_z_trace() { + // ⟨RY(θ)|0⟩|Z|RY(θ)|0⟩⟩ = cos(θ). Exercises a non-Clifford rotation: + // the tableau opens a branch via `tab.ry`, while the PauliSum applies + // the Heisenberg dual RY(θ)†·Z·RY(θ) = cos(θ)·Z + sin(θ)·X in one step. + assert_cross_backend_agreement( + "tests/tableau_ry_z_trace.sst", + "tests/paulisum_ry_z_trace.sst", + ); +} + +// ─── Auto-detect via load_file: route by content, not extension ─────────── + +#[test] +fn is_bytecode_distinguishes_ssb_from_sst() { + let sst = std::fs::read("tests/bell.sst").expect("read bell.sst"); + assert!( + !ppvm_vihaco::bytecode::is_bytecode(&sst), + ".sst source must not be detected as bytecode" + ); + + let ssb = ppvm_vihaco::bytecode::compile_to_bytes( + &String::from_utf8(sst).expect("bell.sst is utf-8"), + ) + .expect("compile bell.sst"); + assert!( + ppvm_vihaco::bytecode::is_bytecode(&ssb), + "dumped .ssb must be detected as bytecode" + ); + + // Inputs shorter than the 4-byte magic are never bytecode. Note "PPVM" as + // text also fails: the magic is a little-endian u32, so its on-disk bytes + // are "MVPP", not "PPVM". + assert!(!ppvm_vihaco::bytecode::is_bytecode(b"PPV")); + assert!(!ppvm_vihaco::bytecode::is_bytecode(b"")); + assert!(!ppvm_vihaco::bytecode::is_bytecode(b"PPVM")); +} + +#[test] +fn load_file_auto_detects_bytecode_and_text() { + // Use the deterministic X-prepared fixture: q0 measures 1, the branch + // flips q1, so both routes must yield exactly [1], [1]. Any divergence — + // or a binary file mis-parsed as text — fails loudly. + let from_text = ppvm_vihaco::run_file("tests/branch_on_outcome_x.sst") + .unwrap_or_else(|e| panic!("run .sst via load_file: {e:?}")); + + // Dump the same fixture to a `.ssb` and run *that file* through the same + // run_file entry point. If load_file didn't sniff the magic it would try + // to parse the binary as text and error. + let out = std::env::temp_dir().join("ppvm_autodetect_branch_x.ssb"); + let out = out.to_str().expect("utf-8 temp path"); + ppvm_vihaco::dump_file("tests/branch_on_outcome_x.sst", out) + .unwrap_or_else(|e| panic!("dump: {e:?}")); + let from_binary = ppvm_vihaco::run_file(out).unwrap_or_else(|e| panic!("run .ssb: {e:?}")); + let _ = std::fs::remove_file(out); + + for (label, machine) in [("text", &from_text), ("binary", &from_binary)] { + let record = machine.measurement_record(); + assert_eq!(record.len(), 2, "{label}: expected two measurements"); + assert_eq!( + record[0].as_slice(), + &[MeasurementOutcome::One], + "{label}: X-prepared q0 must measure 1" + ); + assert_eq!( + record[1].as_slice(), + &[MeasurementOutcome::One], + "{label}: branch must flip q1" + ); + } +} + +// ─── Bytecode round-trip: dump → load → execute each fixture ────────────── + +#[test] +fn dumped_bell_records_two_measurements() { + let machine = dump_load_run("tests/bell.sst", "ppvm_dump_bell.ssb"); + assert_eq!(machine.measurement_record().len(), 2); +} + +#[test] +fn dumped_hello_circuit_runs_with_no_measurements() { + let machine = dump_load_run("tests/hello_circuit.sst", "ppvm_dump_hello_circuit.ssb"); + assert_eq!(machine.measurement_record().len(), 0); +} + +#[test] +fn dumped_function_call_executes_callee() { + let machine = dump_load_run("tests/function_call.sst", "ppvm_dump_function_call.ssb"); + let record = machine.measurement_record(); + assert_eq!(record.len(), 1); + assert_eq!(record[0].len(), 1); + assert!(record[0][0] != MeasurementOutcome::Lost); +} + +#[test] +fn dumped_function_call_ret_executes() { + let machine = dump_load_run( + "tests/function_call_ret.sst", + "ppvm_dump_function_call_ret.ssb", + ); + let record = machine.measurement_record(); + assert_eq!(record.len(), 1); + assert_eq!(record[0].len(), 1); +} + +#[test] +fn dumped_branch_on_outcome_x_is_deterministic() { + // X-prepared q0 measures 1, so the branch flips q1 → both outcomes are 1. + // Confirms branch targets survive the dump/load round-trip. + let machine = dump_load_run("tests/branch_on_outcome_x.sst", "ppvm_dump_branch_x.ssb"); + let record = machine.measurement_record(); + assert_eq!(record.len(), 2); + assert_eq!(record[0].as_slice(), &[MeasurementOutcome::One]); + assert_eq!(record[1].as_slice(), &[MeasurementOutcome::One]); +} + +#[test] +fn dumped_branch_on_outcome_preserves_invariant() { + // q0 in |+> is a fair coin, but the branch steers q1 to match q0 every + // shot — that invariant must hold after a round-trip. + let machine = dump_load_run("tests/branch_on_outcome.sst", "ppvm_dump_branch.ssb"); + let record = machine.measurement_record(); + assert_eq!(record.len(), 2); + assert_eq!(record[0][0], record[1][0]); +} + +#[test] +fn dumped_function_call_branch_both_runs() { + let machine = dump_load_run( + "tests/function_call_branch_both.sst", + "ppvm_dump_function_call_branch_both.ssb", + ); + let record = machine.measurement_record(); + assert_eq!(record.len(), 2); + assert_eq!(record[0].len(), 1); + assert_eq!(record[1].len(), 1); +} diff --git a/crates/ppvm-vihaco/tests/tableau_bell_trace.sst b/crates/ppvm-vihaco/tests/tableau_bell_trace.sst new file mode 100644 index 000000000..d188046f3 --- /dev/null +++ b/crates/ppvm-vihaco/tests/tableau_bell_trace.sst @@ -0,0 +1,18 @@ +device circuit.n_qubits 2; + +fn @main() { + // Forward Bell prep: H(0); CNOT(0, 1) → |Φ+⟩. + const.u64 0 + circuit.h + + const.u64 0 + const.u64 1 + circuit.cnot + + // Tableau-side trace: Σ_{P matches pat} ⟨ψ|P|ψ⟩. Positional `Z0Z1` matches + // exactly the ZZ word, so this returns ⟨Φ+|ZZ|Φ+⟩ = 1.0 — the same value + // the PauliSum backend produces by Heisenberg-propagating ZZ backward. + const.str "Z0Z1" + circuit.trace + ret +} diff --git a/crates/ppvm-vihaco/tests/tableau_ghz_xxx_trace.sst b/crates/ppvm-vihaco/tests/tableau_ghz_xxx_trace.sst new file mode 100644 index 000000000..5dd1c6054 --- /dev/null +++ b/crates/ppvm-vihaco/tests/tableau_ghz_xxx_trace.sst @@ -0,0 +1,20 @@ +device circuit.n_qubits 3; + +fn @main() { + // Forward GHZ prep: H(0); CNOT(0, 1); CNOT(1, 2) → (|000⟩+|111⟩)/√2. + const.u64 0 + circuit.h + + const.u64 0 + const.u64 1 + circuit.cnot + + const.u64 1 + const.u64 2 + circuit.cnot + + // `X{3}` matches exactly the Pauli word XXX. ⟨GHZ|XXX|GHZ⟩ = 1. + const.str "X{3}" + circuit.trace + ret +} diff --git a/crates/ppvm-vihaco/tests/tableau_ry_z_trace.sst b/crates/ppvm-vihaco/tests/tableau_ry_z_trace.sst new file mode 100644 index 000000000..b50d74cdf --- /dev/null +++ b/crates/ppvm-vihaco/tests/tableau_ry_z_trace.sst @@ -0,0 +1,13 @@ +device circuit.n_qubits 1; + +fn @main() { + // RY(θ)|0⟩ = cos(θ/2)|0⟩ + sin(θ/2)|1⟩, so ⟨ψ|Z|ψ⟩ = cos(θ). + // Hard-coded θ = 0.7 → cos(0.7) ≈ 0.7648421872844885. + const.u64 0 + const.f64 0.7 + circuit.ry + + const.str "Z{1}" + circuit.trace + ret +} From c9f875c4a38f7ae8aaf372e45fce23b0ec76e102 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 14:38:55 +0200 Subject: [PATCH 2/7] refactor(ppvm-vihaco): propagate Result from circuit constructors in init_inner The circuit constructors now return eyre::Result (merged from #173), so PPVM::init_inner propagates with `?`. It already returns eyre::Result<()> and reports device-header errors, so this just flows the construction error (e.g. n_qubits > MAX_QUBITS) into the same path. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-vihaco/src/composite.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs index f95fb924e..3f48d9610 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -287,15 +287,15 @@ impl PPVM { return Err(eyre::eyre!("device circuit.n_qubits must be declared")); } self.circuit = match (info.backend, seed) { - (BackendKind::Tableau, None) => Circuit::tableau(info), - (BackendKind::Tableau, Some(seed)) => Circuit::tableau_with_seed(info, seed), + (BackendKind::Tableau, None) => Circuit::tableau(info)?, + (BackendKind::Tableau, Some(seed)) => Circuit::tableau_with_seed(info, seed)?, (BackendKind::PauliSum, _) => { let terms = parse_observable_terms(info)?; - Circuit::paulisum(info, &terms) + Circuit::paulisum(info, &terms)? } (BackendKind::LossyPauliSum, _) => { let terms = parse_observable_terms(info)?; - Circuit::lossy_paulisum(info, &terms) + Circuit::lossy_paulisum(info, &terms)? } }; From a2ff2564edccfcb1f38ed7cc59b9aa6fad9c532f Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 14:41:23 +0200 Subject: [PATCH 3/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/ppvm-vihaco/src/shots.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ppvm-vihaco/src/shots.rs b/crates/ppvm-vihaco/src/shots.rs index 966edaa71..80945f9e1 100644 --- a/crates/ppvm-vihaco/src/shots.rs +++ b/crates/ppvm-vihaco/src/shots.rs @@ -4,8 +4,8 @@ //! Running a compiled program for many shots, optionally across threads. //! //! Each shot runs on a fresh [`PPVM`] so shots are fully independent; the -//! module is compiled once and shared. With the `rayon` feature, [`run_shots`] -//! parallelizes across shots when the global pool (sized once by +//! module is compiled once and cloned into each shot's machine. With the `rayon` feature, +//! [`run_shots`] parallelizes across shots when the global pool (sized once by //! [`set_global_threads`]) has more than one thread and there are enough shots //! to amortize the overhead. From 34805929dcaeab4639571d432316482f26e49d2b Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 14:41:56 +0200 Subject: [PATCH 4/7] Remove outdated comment --- crates/ppvm-vihaco/tests/function_call_ret.sst | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ppvm-vihaco/tests/function_call_ret.sst b/crates/ppvm-vihaco/tests/function_call_ret.sst index ea7623adf..15d995737 100644 --- a/crates/ppvm-vihaco/tests/function_call_ret.sst +++ b/crates/ppvm-vihaco/tests/function_call_ret.sst @@ -1,8 +1,5 @@ device circuit.n_qubits 2; -// TODO: aspirational — depends on `ret ` restoring the caller's PC and -// leaving the top `n` values on the caller's stack. The runtime doesn't do -// this today (Frame has no return_pc), so this fixture currently fails. fn @main() { // Put q0 into |+>. From 6fe312c74c98cce67c3c22eae4b39867891798c5 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 14:43:33 +0200 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/ppvm-vihaco/src/composite.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs index 3f48d9610..ed5ecb3db 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -482,7 +482,7 @@ impl PPVM { } PPVMEffect::Measurement(effect) => { let follow_ups = Observe::::observe(self, &effect)?; - // NOTE: push measurements to stack; two booleans: outcome, is_lost + // NOTE: push measurement outcomes onto the stack as u32 (0/1/2, where 2 = Lost). for outcome in effect.measurement_results { let m = Value::U32(outcome as u32); self.cpu.stack_push(m); From 2fb8fa4929f4ae2e8c5e3c337faa0588666308ba Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 15:28:27 +0200 Subject: [PATCH 6/7] fix(ppvm-vihaco): parse whitespace between coefficient and Pauli word The documented observable grammar allows `coefficient WS* pauli_word`, but the parser only tolerated whitespace around an explicit `*`, so "0.5 ZZ" failed to parse. Consume optional whitespace before the Pauli word to match the grammar. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-vihaco/src/observable.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ppvm-vihaco/src/observable.rs b/crates/ppvm-vihaco/src/observable.rs index f5bc92032..6b1d56520 100644 --- a/crates/ppvm-vihaco/src/observable.rs +++ b/crates/ppvm-vihaco/src/observable.rs @@ -90,9 +90,10 @@ fn pauli_sum_parser<'src>( .exactly(n_qubits) .collect::(); - // Term: coefficient [*] word | bare word. + // Term: coefficient (WS* '*')? WS* word | bare word. let term_with_coeff = coefficient .then_ignore(just('*').padded().or_not()) + .then_ignore(text::whitespace()) .then(pauli_word) .map(|(c, w)| (w, c)); let term_bare = pauli_word.map(|w| (w, 1.0)); @@ -145,6 +146,13 @@ mod tests { assert_eq!(got, vec![("ZZ".to_string(), 0.5)]); } + #[test] + fn single_term_space_between_coefficient_and_word() { + // Grammar allows `coefficient WS* pauli_word` (whitespace, no `*`). + let got = parse_pauli_sum_terms("0.5 ZZ", 2).unwrap(); + assert_eq!(got, vec![("ZZ".to_string(), 0.5)]); + } + #[test] fn integer_coefficient() { let got = parse_pauli_sum_terms("3XY", 2).unwrap(); From 0e7e532405c8778f359b23fe558ca38f583c53a6 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 15:28:52 +0200 Subject: [PATCH 7/7] refactor(ppvm-vihaco): make rayon a single opt-in parallelism feature Previously ppvm-tableau's rayon was force-enabled on every non-wasm build via a target-specific dependency, so downstream could not disable parallelism and the `rayon` feature only gated shot-level parallelism. Fold both into one off-by-default `rayon` feature (dep:rayon + ppvm-tableau/rayon) and drop the forced target override. Consumers opt in for parallelism (ppvm-cli already does); off by default keeps the default build wasm-safe with no CI changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-vihaco/Cargo.toml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/ppvm-vihaco/Cargo.toml b/crates/ppvm-vihaco/Cargo.toml index f1e479543..9abb13991 100644 --- a/crates/ppvm-vihaco/Cargo.toml +++ b/crates/ppvm-vihaco/Cargo.toml @@ -4,8 +4,10 @@ version = "0.1.0" edition = "2024" [features] -# Enables shot-level parallelism in `run_shots_parallel`/`run_shots`. -rayon = ["dep:rayon"] +# Parallelism via rayon: shot-level (`run_shots`) and tableau-internal. Off by +# default; enable with `features = ["rayon"]` (native only — wasm has no thread +# support, so keeping it off by default keeps the default build wasm-safe). +rayon = ["dep:rayon", "ppvm-tableau/rayon"] [dependencies] bitvec = "1.0.1" @@ -23,7 +25,3 @@ vihaco-parser = "0.1.1" vihaco-parser-core = "0.1.1" vihaco-circuit-isa = { version = "0.1.0", path = "../vihaco-circuit-isa" } ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } - -# enable rayon if not on wasm -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -ppvm-tableau = { version = "0.1.0", path = "../ppvm-tableau", features = ["rayon"] }