diff --git a/Cargo.lock b/Cargo.lock index 6370265da..d8e9dab88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,12 +45,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -126,6 +170,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -230,6 +280,20 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "chumsky" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14377e276b2c8300513dff55ba4cc4142b44e5d6de6d00eb5b2307d650bb4ec1" +dependencies = [ + "hashbrown 0.15.5", + "regex-automata 0.3.9", + "serde", + "stacker", + "unicode-ident", + "unicode-segmentation", +] + [[package]] name = "chumsky" version = "0.12.0" @@ -296,6 +360,42 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "codespan" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583f52b0658b321b25fd6b209b6c76cf058f433071297de64e5980c3d9aad937" +dependencies = [ + "codespan-reporting", + "serde", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "console" version = "0.15.11" @@ -308,6 +408,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -466,6 +575,37 @@ dependencies = [ "rayon", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "either" version = "1.15.0" @@ -478,6 +618,29 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -494,6 +657,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -621,6 +794,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.11.4" @@ -654,6 +833,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -678,6 +863,31 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -727,9 +937,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" @@ -834,6 +1044,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -903,6 +1119,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1063,6 +1288,27 @@ dependencies = [ "rayon", ] +[[package]] +name = "ppvm-vihaco" +version = "0.1.0" +dependencies = [ + "bitvec", + "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", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1090,12 +1336,12 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.11.1", "num-traits", "rand 0.9.4", "rand_chacha", "rand_xorshift", - "regex-syntax 0.8.6", + "regex-syntax 0.8.11", "rusty-fork", "tempfile", "unarray", @@ -1277,19 +1523,19 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] name = "regex" -version = "1.11.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.11", - "regex-syntax 0.8.6", + "regex-automata 0.4.14", + "regex-syntax 0.8.11", ] [[package]] @@ -1305,13 +1551,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.6", + "regex-syntax 0.8.11", ] [[package]] @@ -1322,9 +1568,9 @@ checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" @@ -1332,7 +1578,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1463,7 +1709,7 @@ name = "stim-parser" version = "0.1.0" dependencies = [ "ariadne", - "chumsky", + "chumsky 0.12.0", "proptest", ] @@ -1509,6 +1755,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1575,12 +1830,98 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vihaco" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "690c9db1827ed6e392340d3d9c65408213c7bb4c030b5e8991b03459e5c7bb17" +dependencies = [ + "byteorder", + "chumsky 0.10.1", + "codespan", + "colored", + "env_logger", + "eyre", + "log", + "smallvec", + "vihaco-derive", + "vihaco-parser", + "vihaco-parser-core", +] + +[[package]] +name = "vihaco-circuit-isa" +version = "0.1.0" +dependencies = [ + "chumsky 0.10.1", + "eyre", + "smallvec", + "vihaco", + "vihaco-parser", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f11ac1123518d4bec265847a1a12e406c1b54f4ef2478db87f4e6986e26b918" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "vihaco-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b0c3b54b4caeaaeeda2953fa273155887e89ebdb0ce80b65d23c435b1e70a5" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "vihaco-parser-core", +] + +[[package]] +name = "vihaco-parser-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea97e3bc67e4b5a97e21f2a4f64e4a87749038850968021ec903cc595353d20" +dependencies = [ + "chumsky 0.10.1", +] + [[package]] name = "virtue" version = "0.0.18" @@ -1720,7 +2061,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -1919,7 +2260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index ecb991f2c..722b49ef0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ members = [ "crates/ppvm-tableau", "crates/ppvm-stim", "crates/stim-parser", "crates/ppvm-tableau-sum", + "crates/vihaco-circuit-isa", + "crates/ppvm-vihaco", # Runnable copies of the Rust code blocks in skills/ppvm-usage/SKILL.md. # Built by `cargo build --workspace --all-targets` in CI so the skill # can't silently drift away from the public API. diff --git a/crates/ppvm-vihaco/Cargo.toml b/crates/ppvm-vihaco/Cargo.toml new file mode 100644 index 000000000..9abb13991 --- /dev/null +++ b/crates/ppvm-vihaco/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ppvm-vihaco" +version = "0.1.0" +edition = "2024" + +[features] +# 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" +bnum = { version = "0.13.0", features = ["num-traits"] } +chumsky = "0.10.0" +eyre = "0.6.12" +log = "0.4.29" +num = "0.4.3" +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" } 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/component.rs b/crates/ppvm-vihaco/src/component.rs new file mode 100644 index 000000000..7ed668233 --- /dev/null +++ b/crates/ppvm-vihaco/src/component.rs @@ -0,0 +1,1030 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::device_info::PPVMDeviceInfo; +use crate::measurements::{ + CircuitOutcomeEffect, MeasurementEffect, MeasurementOutcome, TraceEffect, +}; +use bitvec::view::BitView; +use bnum::types::{U256, U512, U1024, U2048}; +use eyre::{Result, eyre}; +use num::PrimInt; +use num::complex::Complex64; +use ppvm_pauli_sum::config::fx64hash::Byte8F64; +use ppvm_pauli_sum::config::indexmap::ByteFxHashF64; +use ppvm_pauli_sum::strategy::{CoefficientThreshold, CombinedStrategy, MaxPauliWeight}; +use ppvm_tableau::prelude::*; +use vihaco::{Effects, component, observe}; +use vihaco_circuit_isa::{CircuitEffect, CircuitInstruction, CircuitMessage}; + +/// Largest qubit count any backend can simulate. The widest size bucket is +/// backed by 2048-bit integers (`U2048` / `[u8; 256]`), so every constructor +/// rejects `n_qubits > MAX_QUBITS` rather than panicking. +pub const MAX_QUBITS: usize = 2048; + +/// Truncation strategy used by every `PauliSum` / `LossyPauliSum` size bucket. +/// Coefficient-threshold pruning is always on; the Pauli-weight cap is set per +/// run from the header (defaults to `usize::MAX` = no cap). +type PauliSumStrategy = CombinedStrategy; + +/// `PauliSum`'s `T` for the lossless backend: `[u8; N]` storage, fx hash, +/// f64 coefficients, the strategy above. +type PauliSumConfig = ByteFxHashF64; + +/// Same as `PauliSumConfig` but with `LossyPauliWord` as the word type, so the +/// loss-channel methods are dispatchable on the resulting `PauliSum`. +/// `LossyPauliWord`'s second type parameter (hasher) defaults to +/// `fxhash::FxBuildHasher`, matching `ByteFxHashF64`'s internal hasher. +type LossyPauliSumConfig = + ByteFxHashF64>; + +/// Build a `PauliSumStrategy` value from a `PPVMDeviceInfo`. Pulled out so the +/// six size-bucket constructors don't each repeat the strategy spelling. +fn paulisum_strategy(info: &PPVMDeviceInfo) -> PauliSumStrategy { + CombinedStrategy( + CoefficientThreshold(info.coefficient_threshold), + MaxPauliWeight(info.max_pauli_weight.unwrap_or(usize::MAX)), + ) +} + +macro_rules! batch_for { + ($tab:expr, $method:ident, $addrs:expr) => { + for addr in $addrs { $tab.$method(*addr); } + }; + ($tab:expr, $method:ident, $addrs:expr, $($arg:expr),+) => { + for addr in $addrs { $tab.$method(*addr, $($arg),+); } + }; +} + +/// Two-qubit sibling of [`batch_for!`]: drives a method that takes two qubit +/// addresses (plus optional extra args) over a slice of `(usize, usize)` pairs. +macro_rules! batch_pairs_for { + ($state:expr, $method:ident, $pairs:expr) => { + for &(a, b) in $pairs { $state.$method(a, b); } + }; + ($state:expr, $method:ident, $pairs:expr, $($arg:expr),+) => { + for &(a, b) in $pairs { $state.$method(a, b, $($arg),+); } + }; +} + +pub struct CircuitExecutor, I: TableauIndex, C: SparseVector> { + pub tab: GeneralizedTableau, +} + +#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +impl CircuitExecutor +where + T: Config, + <::Storage as BitView>::Store: PrimInt, + I: TableauIndex + Send + Sync + std::fmt::Debug, + C: SparseVector + std::fmt::Debug, +{ + fn execute( + &mut self, + inst: CircuitInstruction, + msg: CircuitMessage, + ) -> Result> { + self.execute_instruction(&inst, &msg) + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + use CircuitInstruction::*; + use CircuitMessage::*; + + match (inst, msg) { + // Single-qubit Clifford + (X, &Qubit(addr)) => self.tab.x(addr), + (Y, &Qubit(addr)) => self.tab.y(addr), + (Z, &Qubit(addr)) => self.tab.z(addr), + (H, &Qubit(addr)) => self.tab.h(addr), + (S, &Qubit(addr)) => self.tab.s(addr), + (SAdj, &Qubit(addr)) => self.tab.s_dag(addr), + (SqrtX, &Qubit(addr)) => self.tab.sqrt_x(addr), + (SqrtY, &Qubit(addr)) => self.tab.sqrt_y(addr), + (SqrtXAdj, &Qubit(addr)) => self.tab.sqrt_x_dag(addr), + (SqrtYAdj, &Qubit(addr)) => self.tab.sqrt_y_dag(addr), + + // Controlled gates + (CNOT, &TwoQubit(addr0, addr1)) => self.tab.cnot(addr0, addr1), + (CZ, &TwoQubit(addr0, addr1)) => self.tab.cz(addr0, addr1), + + // T gate + (T, &Qubit(addr)) => self.tab.t(addr), + (TAdj, &Qubit(addr)) => self.tab.t_dag(addr), + + // Single-qubit rotations + (RX, &QubitAndFloat(addr, angle)) => self.tab.rx(addr, angle), + (RY, &QubitAndFloat(addr, angle)) => self.tab.ry(addr, angle), + (RZ, &QubitAndFloat(addr, angle)) => self.tab.rz(addr, angle), + + // Two-qubit rotations + (RXX, &TwoQubitAndFloat(addr0, addr1, angle)) => self.tab.rxx(addr0, addr1, angle), + (RYY, &TwoQubitAndFloat(addr0, addr1, angle)) => self.tab.ryy(addr0, addr1, angle), + (RZZ, &TwoQubitAndFloat(addr0, addr1, angle)) => self.tab.rzz(addr0, addr1, angle), + + // U3 + (U3, &QubitU3(addr, theta, phi, lam)) => self.tab.u3(addr, theta, phi, lam), + + // RXY: rotation about an axis in the x/y plane + (R, &QubitAndTwoFloats(addr, axis_angle, theta)) => self.tab.r(addr, axis_angle, theta), + + // Measure & Reset + (Measure, &Qubit(addr)) => { + let outcome: MeasurementOutcome = self.tab.measure(addr).into(); + return Ok(Effects::one(CircuitOutcomeEffect::Measurement( + MeasurementEffect { + measurement_results: smallvec::smallvec![outcome], + }, + ))); + } + (Reset, &Qubit(addr)) => self.tab.reset(addr), + + // Noise + (Depolarize, &QubitAndFloat(addr, p)) => self.tab.depolarize1(addr, p), + (Depolarize2, &TwoQubitAndFloat(addr0, addr1, p)) => { + self.tab.depolarize2(addr0, addr1, p) + } + (PauliError, QubitAndFloatArr3(addr0, ps)) => self.tab.pauli_error(*addr0, *ps), + (TwoQubitPauliError, TwoQubitAndFloatArr15(addr0, addr1, ps)) => { + self.tab.two_qubit_pauli_error(*addr0, *addr1, *ps) + } + + // Loss + (Loss, &QubitAndFloat(addr, p)) => self.tab.loss_channel(addr, p), + (CorrelatedLoss, TwoQubitAndFloatArr3(addr0, addr1, ps)) => { + self.tab.correlated_loss_channel(*addr0, *addr1, *ps) + } + + /* BATCH OPERATIONS START HERE */ + // Batch: dedicated batch methods + (SqrtX, QubitBatch(addrs)) => self.tab.sqrt_x_many(addrs), + (SqrtY, QubitBatch(addrs)) => self.tab.sqrt_y_many(addrs), + (SqrtXAdj, QubitBatch(addrs)) => self.tab.sqrt_x_dag_many(addrs), + (SqrtYAdj, QubitBatch(addrs)) => self.tab.sqrt_y_dag_many(addrs), + (H, QubitBatch(addrs)) => self.tab.h_many(addrs), + (CZ, TwoQubitBatch(pairs)) => self.tab.cz_many(pairs), + + // TODO: replace things below by actual batched methods once they are available + // Batch: single-qubit for loops + (X, QubitBatch(addrs)) => batch_for!(self.tab, x, addrs), + (Y, QubitBatch(addrs)) => batch_for!(self.tab, y, addrs), + (Z, QubitBatch(addrs)) => batch_for!(self.tab, z, addrs), + (S, QubitBatch(addrs)) => batch_for!(self.tab, s, addrs), + (SAdj, QubitBatch(addrs)) => batch_for!(self.tab, s_dag, addrs), + (T, QubitBatch(addrs)) => batch_for!(self.tab, t, addrs), + (TAdj, QubitBatch(addrs)) => batch_for!(self.tab, t_dag, addrs), + (Reset, QubitBatch(addrs)) => batch_for!(self.tab, reset, addrs), + (RX, QubitBatchAndFloat(addrs, angle)) => batch_for!(self.tab, rx, addrs, *angle), + (RY, QubitBatchAndFloat(addrs, angle)) => batch_for!(self.tab, ry, addrs, *angle), + (RZ, QubitBatchAndFloat(addrs, angle)) => batch_for!(self.tab, rz, addrs, *angle), + (Depolarize, QubitBatchAndFloat(addrs, p)) => { + batch_for!(self.tab, depolarize1, addrs, *p) + } + (Loss, QubitBatchAndFloat(addrs, p)) => batch_for!(self.tab, loss_channel, addrs, *p), + (PauliError, QubitBatchAndFloatArr3(addrs, ps)) => { + batch_for!(self.tab, pauli_error, addrs, *ps) + } + (U3, QubitBatchU3(addrs, theta, phi, lam)) => { + batch_for!(self.tab, u3, addrs, *theta, *phi, *lam) + } + + // Batch: two-qubit for loops + (CNOT, TwoQubitBatch(pairs)) => { + for &(a, b) in pairs { + self.tab.cnot(a, b); + } + } + (RXX, TwoQubitBatchAndFloat(pairs, angle)) => { + for &(a, b) in pairs { + self.tab.rxx(a, b, *angle); + } + } + (RYY, TwoQubitBatchAndFloat(pairs, angle)) => { + for &(a, b) in pairs { + self.tab.ryy(a, b, *angle); + } + } + (RZZ, TwoQubitBatchAndFloat(pairs, angle)) => { + for &(a, b) in pairs { + self.tab.rzz(a, b, *angle); + } + } + (Depolarize2, TwoQubitBatchAndFloat(pairs, p)) => { + for &(a, b) in pairs { + self.tab.depolarize2(a, b, *p); + } + } + (TwoQubitPauliError, TwoQubitBatchAndFloatArr15(pairs, ps)) => { + for &(a, b) in pairs { + self.tab.two_qubit_pauli_error(a, b, *ps); + } + } + (CorrelatedLoss, TwoQubitBatchAndFloatArr3(pairs, ps)) => { + for &(a, b) in pairs { + self.tab.correlated_loss_channel(a, b, *ps); + } + } + + // Batch: measure (emits per qubit) + (Measure, QubitBatch(addrs)) => { + let outcomes = addrs.iter().map(|&addr| self.tab.measure(addr).into()); + return Ok(Effects::one(CircuitOutcomeEffect::Measurement( + MeasurementEffect { + measurement_results: outcomes.collect(), + }, + ))); + } + + // Truncate is a silent no-op on the Tableau backend — the tableau's + // gate methods already prune via the configured coefficient + // threshold, so there's nothing extra to do here. + (Truncate, None) => {} + + // Trace: parse the resolved pattern and compute Σ_{P matches} ⟨ψ|P|ψ⟩ + // on the tableau state. Asymmetric with the PauliSum semantics by + // design (Decision 9): on the tableau this is a sum of expectations, + // not a coefficient filter. + (Trace, PauliPatternStr(s)) => { + let pat = PauliPattern::parse(s) + .map_err(|e| eyre!("invalid Pauli pattern `{s}`: {e:?}"))?; + let value = self.tab.trace(&pat); + return Ok(Effects::one(CircuitOutcomeEffect::Trace(TraceEffect { + value, + }))); + } + + // Fallback + (inst, msg) => { + return Err(eyre!( + "Invalid circuit instruction arguments {:?} for instruction {:?}", + msg, + inst + )); + } + }; + + Ok(Effects::None) + } +} + +impl vihaco::Reset for CircuitExecutor +where + T: Config, + <::Storage as BitView>::Store: PrimInt, + I: TableauIndex + Send + Sync + std::fmt::Debug, + C: SparseVector + std::fmt::Debug, +{ + fn reset(&mut self) { + self.tab.reset_all(); + } +} + +/// Shared dispatch body for `PauliSumExecutor` and `LossyPauliSumExecutor`. +/// Every non-loss `CircuitInstruction` lands here. `LossyPauliSumExecutor` +/// matches `Loss` / `CorrelatedLoss` (single + batched) before invoking this +/// macro and never reaches the loss-rejection arm below. +/// +/// `$self` is passed as an `ident` (typically `self`) so the macro's +/// expansion shares hygiene with the surrounding method's `self` parameter. +/// `$inst` / `$msg` are passed the same way; `$backend` is the human-readable +/// backend name baked into error messages. +macro_rules! dispatch_common_paulisum { + ($self:ident, $inst:ident, $msg:ident, $backend:literal) => {{ + use CircuitInstruction::*; + use CircuitMessage::*; + match ($inst, $msg) { + // Single-qubit Clifford + (X, &Qubit(addr)) => $self.state.x(addr), + (Y, &Qubit(addr)) => $self.state.y(addr), + (Z, &Qubit(addr)) => $self.state.z(addr), + (H, &Qubit(addr)) => $self.state.h(addr), + (S, &Qubit(addr)) => $self.state.s(addr), + (SAdj, &Qubit(addr)) => $self.state.s_dag(addr), + (SqrtX, &Qubit(addr)) => $self.state.sqrt_x(addr), + (SqrtY, &Qubit(addr)) => $self.state.sqrt_y(addr), + (SqrtXAdj, &Qubit(addr)) => $self.state.sqrt_x_dag(addr), + (SqrtYAdj, &Qubit(addr)) => $self.state.sqrt_y_dag(addr), + + // Controlled gates + (CNOT, &TwoQubit(addr0, addr1)) => $self.state.cnot(addr0, addr1), + (CZ, &TwoQubit(addr0, addr1)) => $self.state.cz(addr0, addr1), + + // Single-qubit rotations + (RX, &QubitAndFloat(addr, angle)) => $self.state.rx(addr, angle), + (RY, &QubitAndFloat(addr, angle)) => $self.state.ry(addr, angle), + (RZ, &QubitAndFloat(addr, angle)) => $self.state.rz(addr, angle), + + // Two-qubit rotations + (RXX, &TwoQubitAndFloat(addr0, addr1, angle)) => { + $self.state.rxx(addr0, addr1, angle) + } + (RYY, &TwoQubitAndFloat(addr0, addr1, angle)) => { + $self.state.ryy(addr0, addr1, angle) + } + (RZZ, &TwoQubitAndFloat(addr0, addr1, angle)) => { + $self.state.rzz(addr0, addr1, angle) + } + + // RXY: rotation about an axis in the x/y plane + (R, &QubitAndTwoFloats(addr, axis_angle, theta)) => { + $self.state.r(addr, axis_angle, theta) + } + + // Noise + (Depolarize, &QubitAndFloat(addr, p)) => $self.state.depolarize1(addr, p), + (Depolarize2, &TwoQubitAndFloat(addr0, addr1, p)) => { + $self.state.depolarize2(addr0, addr1, p) + } + (PauliError, QubitAndFloatArr3(addr0, ps)) => { + $self.state.pauli_error(*addr0, *ps) + } + (TwoQubitPauliError, TwoQubitAndFloatArr15(addr0, addr1, ps)) => { + $self.state.two_qubit_pauli_error(*addr0, *addr1, *ps) + } + + // Truncate: pruning per the configured strategy. + (Truncate, None) => $self.state.truncate(), + + // Batched arms: simple for-loop dispatch (no dedicated batch + // methods on PauliSum, unlike GeneralizedTableau). + (X, QubitBatch(addrs)) => batch_for!($self.state, x, addrs), + (Y, QubitBatch(addrs)) => batch_for!($self.state, y, addrs), + (Z, QubitBatch(addrs)) => batch_for!($self.state, z, addrs), + (H, QubitBatch(addrs)) => batch_for!($self.state, h, addrs), + (S, QubitBatch(addrs)) => batch_for!($self.state, s, addrs), + (SAdj, QubitBatch(addrs)) => batch_for!($self.state, s_dag, addrs), + (SqrtX, QubitBatch(addrs)) => batch_for!($self.state, sqrt_x, addrs), + (SqrtY, QubitBatch(addrs)) => batch_for!($self.state, sqrt_y, addrs), + (SqrtXAdj, QubitBatch(addrs)) => batch_for!($self.state, sqrt_x_dag, addrs), + (SqrtYAdj, QubitBatch(addrs)) => batch_for!($self.state, sqrt_y_dag, addrs), + (RX, QubitBatchAndFloat(addrs, angle)) => { + batch_for!($self.state, rx, addrs, *angle) + } + (RY, QubitBatchAndFloat(addrs, angle)) => { + batch_for!($self.state, ry, addrs, *angle) + } + (RZ, QubitBatchAndFloat(addrs, angle)) => { + batch_for!($self.state, rz, addrs, *angle) + } + (Depolarize, QubitBatchAndFloat(addrs, p)) => { + batch_for!($self.state, depolarize1, addrs, *p) + } + (PauliError, QubitBatchAndFloatArr3(addrs, ps)) => { + batch_for!($self.state, pauli_error, addrs, *ps) + } + (CNOT, TwoQubitBatch(pairs)) => batch_pairs_for!($self.state, cnot, pairs), + (CZ, TwoQubitBatch(pairs)) => batch_pairs_for!($self.state, cz, pairs), + (RXX, TwoQubitBatchAndFloat(pairs, angle)) => { + batch_pairs_for!($self.state, rxx, pairs, *angle) + } + (RYY, TwoQubitBatchAndFloat(pairs, angle)) => { + batch_pairs_for!($self.state, ryy, pairs, *angle) + } + (RZZ, TwoQubitBatchAndFloat(pairs, angle)) => { + batch_pairs_for!($self.state, rzz, pairs, *angle) + } + (Depolarize2, TwoQubitBatchAndFloat(pairs, p)) => { + batch_pairs_for!($self.state, depolarize2, pairs, *p) + } + (TwoQubitPauliError, TwoQubitBatchAndFloatArr15(pairs, ps)) => { + batch_pairs_for!($self.state, two_qubit_pauli_error, pairs, *ps) + } + + // Not supported on either backend (Decision 11 + Gate Support + // Matrix). Loss / CorrelatedLoss handling differs by backend + // and lives in the caller's impl block, not this macro. + (Measure | Reset, _) => { + return Err(eyre!("{} is not supported on the {} backend", $inst, $backend)); + } + + // T / T_dag / U3 are listed as supported on PauliSum in the + // plan's Gate Support Matrix, but ppvm-runtime does not yet + // implement TGate or U3Gate for PauliSum (only for + // GeneralizedTableau). + (T | TAdj | U3, _) => { + return Err(eyre!( + "{} on {} requires upstream ppvm-runtime support that is not yet implemented", + $inst, + $backend + )); + } + + // Trace: parse the resolved pattern string and compute the + // trace. Per plan Decision 9, parsing happens on every + // execution; no module-load caching. + (Trace, PauliPatternStr(s)) => { + let pat = PauliPattern::parse(s) + .map_err(|e| eyre!("invalid Pauli pattern `{}`: {:?}", s, e))?; + let value = $self.state.trace(&pat); + return Ok(Effects::one(CircuitOutcomeEffect::Trace(TraceEffect { + value, + }))); + } + + // Fallback (mismatched shapes, etc.) + (inst, msg) => { + return Err(eyre!( + "Invalid circuit instruction arguments {:?} for instruction {:?} on the {} backend", + msg, + inst, + $backend + )); + } + }; + Ok(Effects::None) + }}; +} + +/// PauliSum-backed executor (Heisenberg picture). Holds a `PauliSum` and +/// answers the same `CircuitInstruction` vocabulary as `CircuitExecutor`, +/// but without measurement / reset / loss support. +pub struct PauliSumExecutor> { + pub state: PauliSum, + /// Snapshot of the seeded observable, restored by `reset`. + initial: PauliSum, +} + +#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +impl PauliSumExecutor +where + T: Config, + for<'a> PauliSum: Trace<'a, PauliPattern, Output = f64>, +{ + fn execute( + &mut self, + inst: CircuitInstruction, + msg: CircuitMessage, + ) -> Result> { + self.execute_instruction(&inst, &msg) + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + use CircuitInstruction::*; + + if matches!(inst, Loss | CorrelatedLoss) { + return Err(eyre!( + "{inst} is not supported on the PauliSum backend; use the LossyPauliSum backend instead" + )); + } + + dispatch_common_paulisum!(self, inst, msg, "PauliSum") + } +} + +impl vihaco::Reset for PauliSumExecutor +where + T: Config, + PauliSum: Clone, +{ + fn reset(&mut self) { + self.state = self.initial.clone(); + } +} + +/// LossyPauliSum-backed executor. Same dispatch as `PauliSumExecutor` plus +/// `Loss` / `CorrelatedLoss` channels. The concrete `T` used by the +/// enclosing `Circuit::LossyPauliSum` variant is a `Config` whose +/// `PauliWordType` is `LossyPauliWord` (see `LossyPauliSumConfig`). +pub struct LossyPauliSumExecutor> { + pub state: PauliSum, + /// Snapshot of the seeded observable, restored by `reset`. + initial: PauliSum, +} + +#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +impl LossyPauliSumExecutor +where + T: Config, + for<'a> PauliSum: Trace<'a, PauliPattern, Output = f64>, +{ + fn execute( + &mut self, + inst: CircuitInstruction, + msg: CircuitMessage, + ) -> Result> { + self.execute_instruction(&inst, &msg) + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + use CircuitInstruction::*; + use CircuitMessage::*; + + // Loss / CorrelatedLoss are the only instructions that differ from + // PauliSum; handle them here then delegate everything else to the + // shared dispatch. + match (inst, msg) { + (Loss, &QubitAndFloat(addr, p)) => { + self.state.loss_channel(addr, p); + return Ok(Effects::None); + } + (CorrelatedLoss, TwoQubitAndFloatArr3(addr0, addr1, ps)) => { + self.state.correlated_loss_channel(*addr0, *addr1, *ps); + return Ok(Effects::None); + } + (Loss, QubitBatchAndFloat(addrs, p)) => { + batch_for!(self.state, loss_channel, addrs, *p); + return Ok(Effects::None); + } + (CorrelatedLoss, TwoQubitBatchAndFloatArr3(pairs, ps)) => { + batch_pairs_for!(self.state, correlated_loss_channel, pairs, *ps); + return Ok(Effects::None); + } + _ => {} + } + + dispatch_common_paulisum!(self, inst, msg, "LossyPauliSum") + } +} + +impl vihaco::Reset for LossyPauliSumExecutor +where + T: Config, + PauliSum: Clone, +{ + fn reset(&mut self) { + self.state = self.initial.clone(); + } +} + +/// Tableau-backed inner enum (Schrödinger picture). Carries the six +/// size-bucketed `CircuitExecutor` variants; bucket is picked from `n_qubits`. +pub enum TableauCircuit { + Bits64(CircuitExecutor, usize, Vec<(Complex64, usize)>>), + Bits128(CircuitExecutor, u128, Vec<(Complex64, u128)>>), + Bits256(CircuitExecutor, U256, Vec<(Complex64, U256)>>), + Bits512(CircuitExecutor, U512, Vec<(Complex64, U512)>>), + Bits1024(CircuitExecutor, U1024, Vec<(Complex64, U1024)>>), + Bits2048(CircuitExecutor, U2048, Vec<(Complex64, U2048)>>), +} + +impl TableauCircuit { + pub fn new(n_qubits: usize, coefficient_threshold: f64) -> Result { + if n_qubits <= 64 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Ok(Self::Bits64(CircuitExecutor { tab })) + } else if n_qubits <= 128 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Ok(Self::Bits128(CircuitExecutor { tab })) + } else if n_qubits <= 256 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Ok(Self::Bits256(CircuitExecutor { tab })) + } else if n_qubits <= 512 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Ok(Self::Bits512(CircuitExecutor { tab })) + } else if n_qubits <= 1024 { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Ok(Self::Bits1024(CircuitExecutor { tab })) + } else if n_qubits <= MAX_QUBITS { + let tab = GeneralizedTableau::new(n_qubits, coefficient_threshold); + Ok(Self::Bits2048(CircuitExecutor { tab })) + } else { + Err(eyre!( + "cannot simulate {n_qubits} qubits: maximum is {MAX_QUBITS}" + )) + } + } + + /// Same as [`TableauCircuit::new`], but seed the RNG deterministically so a + /// shot is reproducible. + pub fn new_with_seed(n_qubits: usize, coefficient_threshold: f64, seed: u64) -> Result { + macro_rules! seeded { + ($variant:ident) => {{ + let tab = GeneralizedTableau::new_with_seed(n_qubits, coefficient_threshold, seed); + Ok(Self::$variant(CircuitExecutor { tab })) + }}; + } + if n_qubits <= 64 { + seeded!(Bits64) + } else if n_qubits <= 128 { + seeded!(Bits128) + } else if n_qubits <= 256 { + seeded!(Bits256) + } else if n_qubits <= 512 { + seeded!(Bits512) + } else if n_qubits <= 1024 { + seeded!(Bits1024) + } else if n_qubits <= MAX_QUBITS { + seeded!(Bits2048) + } else { + Err(eyre!( + "cannot simulate {n_qubits} qubits: maximum is {MAX_QUBITS}" + )) + } + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + match self { + Self::Bits64(ex) => ex.execute_instruction(inst, msg), + Self::Bits128(ex) => ex.execute_instruction(inst, msg), + Self::Bits256(ex) => ex.execute_instruction(inst, msg), + Self::Bits512(ex) => ex.execute_instruction(inst, msg), + Self::Bits1024(ex) => ex.execute_instruction(inst, msg), + Self::Bits2048(ex) => ex.execute_instruction(inst, msg), + } + } + + pub fn state_string(&self) -> String { + match self { + Self::Bits64(ex) => ex.tab.to_string(), + Self::Bits128(ex) => ex.tab.to_string(), + Self::Bits256(ex) => ex.tab.to_string(), + Self::Bits512(ex) => ex.tab.to_string(), + Self::Bits1024(ex) => ex.tab.to_string(), + Self::Bits2048(ex) => ex.tab.to_string(), + } + } +} + +impl vihaco::Reset for TableauCircuit { + fn reset(&mut self) { + match self { + Self::Bits64(ex) => ex.reset(), + Self::Bits128(ex) => ex.reset(), + Self::Bits256(ex) => ex.reset(), + Self::Bits512(ex) => ex.reset(), + Self::Bits1024(ex) => ex.reset(), + Self::Bits2048(ex) => ex.reset(), + }; + } +} + +/// PauliSum-backed inner enum (Heisenberg picture). Per Decision 7 of the plan, +/// the size buckets carry `[u8; N]`-storage `ByteFxHashF64` configs (N = 8, 16, +/// …, 256) rather than the tableau's `[u64; N]` configs; bucket labels match +/// the semantic qubit count (`Bits64` = 64 qubits) so the outer enum's dispatch +/// is uniform across backends. +pub enum PauliSumCircuit { + Bits64(PauliSumExecutor>), + Bits128(PauliSumExecutor>), + Bits256(PauliSumExecutor>), + Bits512(PauliSumExecutor>), + Bits1024(PauliSumExecutor>), + Bits2048(PauliSumExecutor>), +} + +impl PauliSumCircuit { + /// Build a PauliSum-backed circuit, seeding the state with every term: + /// `for (word, coef) in terms { state += (word, coef); }`. Words must + /// already be validated against `info.n_qubits` by the caller. + pub fn new(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Result { + macro_rules! build { + ($variant:ident, $N:literal) => {{ + let mut state = PauliSum::>::builder() + .n_qubits(info.n_qubits) + .strategy(paulisum_strategy(info)) + .build(); + for (word, coef) in terms { + state += (word.as_str(), *coef); + } + let initial = state.clone(); + Ok(Self::$variant(PauliSumExecutor { state, initial })) + }}; + } + if info.n_qubits <= 64 { + build!(Bits64, 8) + } else if info.n_qubits <= 128 { + build!(Bits128, 16) + } else if info.n_qubits <= 256 { + build!(Bits256, 32) + } else if info.n_qubits <= 512 { + build!(Bits512, 64) + } else if info.n_qubits <= 1024 { + build!(Bits1024, 128) + } else if info.n_qubits <= MAX_QUBITS { + build!(Bits2048, 256) + } else { + Err(eyre!( + "cannot simulate {} qubits: maximum is {MAX_QUBITS}", + info.n_qubits + )) + } + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + match self { + Self::Bits64(ex) => ex.execute_instruction(inst, msg), + Self::Bits128(ex) => ex.execute_instruction(inst, msg), + Self::Bits256(ex) => ex.execute_instruction(inst, msg), + Self::Bits512(ex) => ex.execute_instruction(inst, msg), + Self::Bits1024(ex) => ex.execute_instruction(inst, msg), + Self::Bits2048(ex) => ex.execute_instruction(inst, msg), + } + } + + pub fn state_string(&self) -> String { + match self { + Self::Bits64(ex) => ex.state.to_string(), + Self::Bits128(ex) => ex.state.to_string(), + Self::Bits256(ex) => ex.state.to_string(), + Self::Bits512(ex) => ex.state.to_string(), + Self::Bits1024(ex) => ex.state.to_string(), + Self::Bits2048(ex) => ex.state.to_string(), + } + } +} + +impl vihaco::Reset for PauliSumCircuit { + fn reset(&mut self) { + match self { + Self::Bits64(ex) => ex.reset(), + Self::Bits128(ex) => ex.reset(), + Self::Bits256(ex) => ex.reset(), + Self::Bits512(ex) => ex.reset(), + Self::Bits1024(ex) => ex.reset(), + Self::Bits2048(ex) => ex.reset(), + }; + } +} + +/// LossyPauliSum-backed inner enum. Identical shape to [`PauliSumCircuit`] +/// but with `LossyPauliWord`-keyed configs so loss-channel methods dispatch. +pub enum LossyPauliSumCircuit { + Bits64(LossyPauliSumExecutor>), + Bits128(LossyPauliSumExecutor>), + Bits256(LossyPauliSumExecutor>), + Bits512(LossyPauliSumExecutor>), + Bits1024(LossyPauliSumExecutor>), + Bits2048(LossyPauliSumExecutor>), +} + +impl LossyPauliSumCircuit { + /// Build a LossyPauliSum-backed circuit, seeding every term via + /// `state += (word, coef)`. Words must already be validated against + /// `info.n_qubits` by the caller. + pub fn new(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Result { + macro_rules! build { + ($variant:ident, $N:literal) => {{ + let mut state = PauliSum::>::builder() + .n_qubits(info.n_qubits) + .strategy(paulisum_strategy(info)) + .build(); + for (word, coef) in terms { + state += (word.as_str(), *coef); + } + let initial = state.clone(); + Ok(Self::$variant(LossyPauliSumExecutor { state, initial })) + }}; + } + if info.n_qubits <= 64 { + build!(Bits64, 8) + } else if info.n_qubits <= 128 { + build!(Bits128, 16) + } else if info.n_qubits <= 256 { + build!(Bits256, 32) + } else if info.n_qubits <= 512 { + build!(Bits512, 64) + } else if info.n_qubits <= 1024 { + build!(Bits1024, 128) + } else if info.n_qubits <= MAX_QUBITS { + build!(Bits2048, 256) + } else { + Err(eyre!( + "cannot simulate {} qubits: maximum is {MAX_QUBITS}", + info.n_qubits + )) + } + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + match self { + Self::Bits64(ex) => ex.execute_instruction(inst, msg), + Self::Bits128(ex) => ex.execute_instruction(inst, msg), + Self::Bits256(ex) => ex.execute_instruction(inst, msg), + Self::Bits512(ex) => ex.execute_instruction(inst, msg), + Self::Bits1024(ex) => ex.execute_instruction(inst, msg), + Self::Bits2048(ex) => ex.execute_instruction(inst, msg), + } + } + + pub fn state_string(&self) -> String { + match self { + Self::Bits64(ex) => ex.state.to_string(), + Self::Bits128(ex) => ex.state.to_string(), + Self::Bits256(ex) => ex.state.to_string(), + Self::Bits512(ex) => ex.state.to_string(), + Self::Bits1024(ex) => ex.state.to_string(), + Self::Bits2048(ex) => ex.state.to_string(), + } + } +} + +impl vihaco::Reset for LossyPauliSumCircuit { + fn reset(&mut self) { + match self { + Self::Bits64(ex) => ex.reset(), + Self::Bits128(ex) => ex.reset(), + Self::Bits256(ex) => ex.reset(), + Self::Bits512(ex) => ex.reset(), + Self::Bits1024(ex) => ex.reset(), + Self::Bits2048(ex) => ex.reset(), + }; + } +} + +/// Outer `Circuit` enum: backend selector. Picks one of the three inner enums +/// based on `info.backend` at construction time; from there, every per-step +/// call routes outer → inner → executor. +pub enum Circuit { + Tableau(TableauCircuit), + PauliSum(PauliSumCircuit), + LossyPauliSum(LossyPauliSumCircuit), +} + +#[component(instruction = CircuitInstruction, message = CircuitMessage, effect = CircuitOutcomeEffect)] +impl Circuit { + /// Build a Tableau-backed circuit. Tableau init only needs `n_qubits` and + /// `coefficient_threshold` from `info`; no observable required. + pub fn tableau(info: &PPVMDeviceInfo) -> Result { + Ok(Self::Tableau(TableauCircuit::new( + info.n_qubits, + info.coefficient_threshold, + )?)) + } + + /// Same as [`Circuit::tableau`], but seed the tableau RNG deterministically + /// so a shot is reproducible. + pub fn tableau_with_seed(info: &PPVMDeviceInfo, seed: u64) -> Result { + Ok(Self::Tableau(TableauCircuit::new_with_seed( + info.n_qubits, + info.coefficient_threshold, + seed, + )?)) + } + + /// Build a PauliSum-backed circuit, seeding the state with every term in + /// `terms`. Each `(word, coef)` is added via `state += (word, coef)`; the + /// caller is responsible for having parsed/validated the words against + /// `info.n_qubits` (see `parse_observable_terms` in `composite.rs`). + pub fn paulisum(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Result { + Ok(Self::PauliSum(PauliSumCircuit::new(info, terms)?)) + } + + /// Build a LossyPauliSum-backed circuit. Same contract as + /// [`Circuit::paulisum`]. + pub fn lossy_paulisum(info: &PPVMDeviceInfo, terms: &[(String, f64)]) -> Result { + Ok(Self::LossyPauliSum(LossyPauliSumCircuit::new(info, terms)?)) + } + + fn execute( + &mut self, + inst: CircuitInstruction, + msg: CircuitMessage, + ) -> Result> { + self.execute_instruction(&inst, &msg) + } + + fn execute_instruction( + &mut self, + inst: &CircuitInstruction, + msg: &CircuitMessage, + ) -> Result> { + match self { + Self::Tableau(c) => c.execute_instruction(inst, msg), + Self::PauliSum(c) => c.execute_instruction(inst, msg), + Self::LossyPauliSum(c) => c.execute_instruction(inst, msg), + } + } + + /// Render the current state. Used by the REPL's `show` command. + pub fn state_string(&self) -> String { + match self { + Self::Tableau(c) => c.state_string(), + Self::PauliSum(c) => c.state_string(), + Self::LossyPauliSum(c) => c.state_string(), + } + } +} + +#[observe(CircuitEffect, effect=CircuitOutcomeEffect)] +impl Circuit { + fn observe_circuit_effect( + &mut self, + effect: &CircuitEffect, + ) -> Result> { + self.execute_instruction(&effect.inst, &effect.msg) + } +} + +impl vihaco::Reset for Circuit { + fn reset(&mut self) { + match self { + Self::Tableau(c) => c.reset(), + Self::PauliSum(c) => c.reset(), + Self::LossyPauliSum(c) => c.reset(), + }; + } +} + +impl Default for Circuit { + fn default() -> Self { + // Default backend is Tableau with 0 qubits, which always fits the + // smallest bucket, so construction here is infallible. + Self::tableau(&PPVMDeviceInfo::default()).expect("0-qubit tableau is always constructible") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info(n_qubits: usize) -> PPVMDeviceInfo { + PPVMDeviceInfo { + n_qubits, + ..Default::default() + } + } + + /// Dispatch a single-qubit `Measure` and read the outcome out of the + /// returned effect. + fn measure(circuit: &mut Circuit, addr: usize) -> MeasurementOutcome { + let effects = circuit + .execute_instruction(&CircuitInstruction::Measure, &CircuitMessage::Qubit(addr)) + .unwrap(); + match effects.into_iter().next() { + Some(CircuitOutcomeEffect::Measurement(m)) => m.measurement_results[0], + other => panic!("expected a measurement effect, got {other:?}"), + } + } + + /// Smoke test: a single-qubit gate dispatches to the tableau backend and + /// flips the qubit. + #[test] + fn tableau_backend_x_flips_qubit() { + let mut circuit = Circuit::tableau(&info(1)).unwrap(); + circuit + .execute_instruction(&CircuitInstruction::X, &CircuitMessage::Qubit(0)) + .unwrap(); + assert_eq!(measure(&mut circuit, 0), MeasurementOutcome::One); + } + + /// Smoke test: a two-qubit gate dispatches correctly — `X(0); CNOT(0, 1)` + /// leaves both qubits in |1⟩. + #[test] + fn tableau_backend_cnot_propagates_flip() { + let mut circuit = Circuit::tableau(&info(2)).unwrap(); + circuit + .execute_instruction(&CircuitInstruction::X, &CircuitMessage::Qubit(0)) + .unwrap(); + circuit + .execute_instruction(&CircuitInstruction::CNOT, &CircuitMessage::TwoQubit(0, 1)) + .unwrap(); + assert_eq!(measure(&mut circuit, 0), MeasurementOutcome::One); + assert_eq!(measure(&mut circuit, 1), MeasurementOutcome::One); + } + + // ─── Construction rejects more qubits than the backend ceiling ──────── + // + // The widest executor bucket is 2048 qubits (U2048 / `[u8; 256]`); beyond + // that there is no backing width, so the constructors return an error + // rather than panicking — a panic would tear down the TUI that drives this. + + #[test] + fn tableau_rejects_more_than_2048_qubits() { + assert!(Circuit::tableau(&info(MAX_QUBITS + 1)).is_err()); + } + + #[test] + fn tableau_with_seed_rejects_more_than_2048_qubits() { + assert!(Circuit::tableau_with_seed(&info(MAX_QUBITS + 1), 0).is_err()); + } + + #[test] + fn paulisum_rejects_more_than_2048_qubits() { + assert!(Circuit::paulisum(&info(MAX_QUBITS + 1), &[]).is_err()); + } + + #[test] + fn lossy_paulisum_rejects_more_than_2048_qubits() { + assert!(Circuit::lossy_paulisum(&info(MAX_QUBITS + 1), &[]).is_err()); + } + + #[test] + fn constructs_at_the_2048_qubit_boundary() { + // 2048 is the last valid bucket; it must still succeed. + assert!(Circuit::tableau(&info(MAX_QUBITS)).is_ok()); + } +} diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs new file mode 100644 index 000000000..019417009 --- /dev/null +++ b/crates/ppvm-vihaco/src/composite.rs @@ -0,0 +1,1349 @@ +// 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_u64()?; + Ok(CircuitMessage::Qubit(q)) + } + CNOT | CZ => { + let q1 = self.pop_u64()?; + let q0 = self.pop_u64()?; + Ok(CircuitMessage::TwoQubit(q0, q1)) + } + RX | RY | RZ | Depolarize | Loss => { + let theta = self.pop_f64()?; + let q = self.pop_u64()?; + Ok(CircuitMessage::QubitAndFloat(q, theta)) + } + RXX | RYY | RZZ | Depolarize2 => { + let theta = self.pop_f64()?; + let q1 = self.pop_u64()?; + let q0 = self.pop_u64()?; + Ok(CircuitMessage::TwoQubitAndFloat(q0, q1, theta)) + } + R => { + let theta = self.pop_f64()?; + let axis_angle = self.pop_f64()?; + let q = self.pop_u64()?; + 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_u64()?; + 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_u64()?; + 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_u64()?; + let q0 = self.pop_u64()?; + 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_u64()?; + let q0 = self.pop_u64()?; + Ok(CircuitMessage::TwoQubitAndFloatArr15(q0, q1, ps)) + } + Trace => { + let s = self.pop_string()?; + Ok(CircuitMessage::PauliPatternStr(s)) + } + Truncate => Ok(CircuitMessage::None), + } + } + + fn pop_u64(&mut self) -> eyre::Result { + match self.cpu.stack_pop()? { + vihaco::Value::U64(v) => usize::try_from(v).map_err(Into::into), + v => Err(eyre::eyre!("Expected u64, 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 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); + } + 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_u64` 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::U64(2)); + machine.cpu.stack_push(Value::U64(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::U64(2)); + machine.cpu.stack_push(Value::U64(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::U64(2)); + machine.cpu.stack_push(Value::U64(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/device_info.rs b/crates/ppvm-vihaco/src/device_info.rs new file mode 100644 index 000000000..77e60ac79 --- /dev/null +++ b/crates/ppvm-vihaco/src/device_info.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Device configuration for the circuit component. Split into its own module +//! so [`crate::component`] can depend on [`PPVMDeviceInfo`] without pulling in +//! the composite machine (which is added in a later PR). + +pub const PPVM_MAGIC: u32 = 0x5050564D; + +/// Which execution backend the circuit runs on. Selected via the +/// `device circuit.backend` header; defaults to `Tableau` so existing +/// programs that don't declare a backend keep working. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, vihaco_parser::Parse)] +pub enum BackendKind { + #[default] + Tableau, + PauliSum, + #[token = "lossy_paulisum"] + LossyPauliSum, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PPVMDeviceInfo { + pub magic: u32, + pub n_qubits: usize, + pub coefficient_threshold: f64, + pub backend: BackendKind, + pub observable: Option, + pub max_pauli_weight: Option, +} + +impl Default for PPVMDeviceInfo { + fn default() -> Self { + Self { + magic: PPVM_MAGIC, + n_qubits: 0, + coefficient_threshold: 1e-10, + backend: BackendKind::default(), + observable: None, + max_pauli_weight: None, + } + } +} diff --git a/crates/ppvm-vihaco/src/lib.rs b/crates/ppvm-vihaco/src/lib.rs new file mode 100644 index 000000000..3bad3bdca --- /dev/null +++ b/crates/ppvm-vihaco/src/lib.rs @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +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 (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::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/measurements.rs b/crates/ppvm-vihaco/src/measurements.rs new file mode 100644 index 000000000..1881c0d51 --- /dev/null +++ b/crates/ppvm-vihaco/src/measurements.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use eyre::Result; +use smallvec::SmallVec; +use vihaco::{Effects, observe}; + +/// Measurement results are represented as an integer enum: +/// 0: state |0> +/// 1: state |1> +/// 2: qubit has been lost prior to measurement +/// In bytecode, this is represented as a u32 integer, which is simpler than +/// e.g. two boolean values and matches semantics elsewhere +#[repr(u8)] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum MeasurementOutcome { + Zero = 0, + One = 1, + Lost = 2, +} + +pub type MeasurementResult = SmallVec<[MeasurementOutcome; 8]>; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct MeasurementEffect { + pub measurement_results: MeasurementResult, +} + +impl From> for MeasurementOutcome { + fn from(m: Option) -> Self { + match m { + Some(false) => Self::Zero, + Some(true) => Self::One, + None => Self::Lost, + } + } +} + +#[derive(Debug, Default)] +pub struct MeasurementObserver { + pub record: Vec, +} + +#[observe(MeasurementEffect)] +impl MeasurementObserver { + fn observe_measurement_effect(&mut self, effect: &MeasurementEffect) -> Result> { + self.record.push(effect.measurement_results.clone()); + Ok(Effects::none()) + } +} + +/// Per-step trace value emitted by the `Trace` instruction on the PauliSum and +/// LossyPauliSum backends. The plan's Decision 5 keeps trace and measurement +/// records as two parallel streams; this effect feeds the trace stream. +#[derive(Debug, Clone, PartialEq)] +pub struct TraceEffect { + pub value: f64, +} + +#[derive(Debug, Default)] +pub struct TraceObserver { + pub record: Vec, +} + +#[observe(TraceEffect)] +impl TraceObserver { + fn observe_trace_effect(&mut self, effect: &TraceEffect) -> Result> { + self.record.push(effect.value); + Ok(Effects::none()) + } +} + +/// Union of the two effect types a circuit instruction can produce. The plan's +/// structural note (Task 6) calls for broadening the `#[component(..., effect = +/// ...)]` annotation on `Circuit` and the executors to a union so a single +/// `Trace` (or `Measure`) instruction can fan out to the right observer. +#[derive(Debug, Clone, PartialEq)] +pub enum CircuitOutcomeEffect { + Measurement(MeasurementEffect), + Trace(TraceEffect), +} + +impl From for CircuitOutcomeEffect { + fn from(value: MeasurementEffect) -> Self { + Self::Measurement(value) + } +} + +impl From for CircuitOutcomeEffect { + fn from(value: TraceEffect) -> Self { + Self::Trace(value) + } +} diff --git a/crates/ppvm-vihaco/src/observable.rs b/crates/ppvm-vihaco/src/observable.rs new file mode 100644 index 000000000..6b1d56520 --- /dev/null +++ b/crates/ppvm-vihaco/src/observable.rs @@ -0,0 +1,357 @@ +// 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 (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)); + 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 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(); + 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..80945f9e1 --- /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 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. + +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..15d995737 --- /dev/null +++ b/crates/ppvm-vihaco/tests/function_call_ret.sst @@ -0,0 +1,39 @@ +device circuit.n_qubits 2; + + +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 +} diff --git a/crates/vihaco-circuit-isa/Cargo.toml b/crates/vihaco-circuit-isa/Cargo.toml new file mode 100644 index 000000000..6c5e01887 --- /dev/null +++ b/crates/vihaco-circuit-isa/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "vihaco-circuit-isa" +version = "0.1.0" +edition = "2024" + +[dependencies] +chumsky = "0.10" +eyre = "0.6.12" +smallvec = "1.15.1" +vihaco = "0.1.1" +vihaco-parser = "0.1.1" +vihaco-parser-core = "0.1.1" + +[package.metadata.cargo-machete] +ignored = ["eyre"] # transitive dependency in vihaco, somehow not handled correctly by machete diff --git a/crates/vihaco-circuit-isa/src/lib.rs b/crates/vihaco-circuit-isa/src/lib.rs new file mode 100644 index 000000000..2a1b3283f --- /dev/null +++ b/crates/vihaco-circuit-isa/src/lib.rs @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use smallvec::SmallVec; +use vihaco::Instruction; +use vihaco::Message; +use vihaco_parser::Parse; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Instruction, Parse)] +pub enum CircuitInstruction { + // NOTE: longer tokens need to go first + TwoQubitPauliError, // needs to go before T + Truncate, // needs to go before T + Trace, // needs to go before T + + // Single-Qubit Clifford gates + X, + Y, + Z, + H, + + #[token = "sqrt_x_adj"] + SqrtXAdj, + + #[token = "sqrt_x"] + SqrtX, + + #[token = "sqrt_y_adj"] + SqrtYAdj, + + #[token = "sqrt_y"] + SqrtY, + + #[token = "s_adj"] + SAdj, + S, + + // Controlled gates + CNOT, + CZ, + + // T gate + TAdj, + T, + + // Two-qubit rotations + RXX, + RYY, + RZZ, + + // Single-qubit rotations + RX, + RY, + RZ, + + // U3 + U3, + + // Measurement & Reset + Measure, + Reset, + + // RXY + R, + + // Loss + Loss, + CorrelatedLoss, + + // Noise + PauliError, + Depolarize2, + Depolarize, +} + +impl std::fmt::Display for CircuitInstruction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use CircuitInstruction::*; + match self { + TwoQubitPauliError => write!(f, "TwoQubitPauliError"), + Truncate => write!(f, "Truncate"), + Trace => write!(f, "Trace"), + + X => write!(f, "X"), + Y => write!(f, "Y"), + Z => write!(f, "Z"), + H => write!(f, "H"), + + SqrtXAdj => write!(f, "SqrtXAdj"), + + SqrtX => write!(f, "SqrtX"), + + SqrtYAdj => write!(f, "SqrtYAdj"), + + SqrtY => write!(f, "SqrtY"), + + SAdj => write!(f, "SAdj"), + S => write!(f, "S"), + + CNOT => write!(f, "CNOT"), + CZ => write!(f, "CZ"), + + TAdj => write!(f, "TAdj"), + T => write!(f, "T"), + + RXX => write!(f, "RXX"), + RYY => write!(f, "RYY"), + RZZ => write!(f, "RZZ"), + + RX => write!(f, "RX"), + RY => write!(f, "RY"), + RZ => write!(f, "RZ"), + + U3 => write!(f, "U3"), + + Measure => write!(f, "Measure"), + Reset => write!(f, "Reset"), + + R => write!(f, "R"), + + Loss => write!(f, "Loss"), + CorrelatedLoss => write!(f, "CorrelatedLoss"), + + PauliError => write!(f, "PauliError"), + Depolarize2 => write!(f, "Depolarize2"), + Depolarize => write!(f, "Depolarize"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Message)] +pub enum CircuitMessage { + None, // Truncate (no operand) + Qubit(usize), // X, Y, Z, ... + QubitAndFloat(usize, f64), // RX, depolarize, ... + QubitAndTwoFloats(usize, f64, f64), // R + TwoQubit(usize, usize), // CX, CZ + TwoQubitAndFloat(usize, usize, f64), // RXX, ... + QubitU3(usize, f64, f64, f64), // U3 + QubitAndFloatArr3(usize, [f64; 3]), // PauliError + TwoQubitAndFloatArr3(usize, usize, [f64; 3]), // Correlated loss + TwoQubitAndFloatArr15(usize, usize, [f64; 15]), // TwoQubitPauliError + + PauliPatternStr(String), // Trace (resolved Pauli-pattern source) + + // batched instructions + QubitBatch(SmallVec<[usize; 8]>), // X, Y, Z, ... + QubitBatchAndFloat(SmallVec<[usize; 8]>, f64), // RX, depolarize, ... + TwoQubitBatch(SmallVec<[(usize, usize); 8]>), // CX, CZ + TwoQubitBatchAndFloat(SmallVec<[(usize, usize); 8]>, f64), // RXX, ... + QubitBatchU3(SmallVec<[usize; 8]>, f64, f64, f64), // U3 + QubitBatchAndFloatArr3(SmallVec<[usize; 8]>, [f64; 3]), // PauliError + TwoQubitBatchAndFloatArr3(SmallVec<[(usize, usize); 8]>, [f64; 3]), // Correlated loss + TwoQubitBatchAndFloatArr15(SmallVec<[(usize, usize); 8]>, [f64; 15]), // TwoQubitPauliError +} + +#[derive(Debug, Clone)] +pub struct CircuitEffect { + pub inst: CircuitInstruction, + pub msg: CircuitMessage, +} + +#[cfg(test)] +mod tests { + use super::CircuitInstruction::*; + use super::*; + + use chumsky::Parser as _; + use vihaco::instruction::{FromBytes, OpCode, WriteBytes}; + use vihaco_parser_core::Parse as _; + + /// Every variant, in declaration order. Anything iterating over the full + /// instruction set (round-trips, opcode uniqueness) goes through this so a + /// newly added variant is automatically covered. + const ALL: &[CircuitInstruction] = &[ + TwoQubitPauliError, + Truncate, + Trace, + X, + Y, + Z, + H, + SqrtXAdj, + SqrtX, + SqrtYAdj, + SqrtY, + SAdj, + S, + CNOT, + CZ, + TAdj, + T, + RXX, + RYY, + RZZ, + RX, + RY, + RZ, + U3, + Measure, + Reset, + R, + Loss, + CorrelatedLoss, + PauliError, + Depolarize2, + Depolarize, + ]; + + fn parse(src: &str) -> CircuitInstruction { + CircuitInstruction::parser() + .parse(src) + .into_result() + .unwrap_or_else(|e| panic!("parse of `{src}` failed: {e:?}")) + } + + // ─── Parse: tokens are the lowercased variant name ──────────────────── + + #[test] + fn parses_simple_lowercase_tokens() { + assert_eq!(parse("x"), X); + assert_eq!(parse("y"), Y); + assert_eq!(parse("z"), Z); + assert_eq!(parse("h"), H); + assert_eq!(parse("cnot"), CNOT); + assert_eq!(parse("cz"), CZ); + assert_eq!(parse("u3"), U3); + assert_eq!(parse("measure"), Measure); + assert_eq!(parse("reset"), Reset); + assert_eq!(parse("r"), R); + assert_eq!(parse("rx"), RX); + assert_eq!(parse("ry"), RY); + assert_eq!(parse("rz"), RZ); + assert_eq!(parse("rxx"), RXX); + assert_eq!(parse("depolarize2"), Depolarize2); + assert_eq!(parse("depolarize"), Depolarize); + } + + // ─── Parse: prefix-sensitive disambiguation ─────────────────────────── + // + // These pairs share a prefix, so the declaration order in the enum is + // load-bearing: the longer token must win. These tests pin that contract. + + #[test] + fn parses_t_family_without_prefix_collision() { + // `t` is a prefix of `tadj`, `trace`, `truncate`, and `twoqubitpaulierror`. + assert_eq!(parse("t"), T); + assert_eq!(parse("tadj"), TAdj); + assert_eq!(parse("trace"), Trace); + assert_eq!(parse("truncate"), Truncate); + assert_eq!(parse("twoqubitpaulierror"), TwoQubitPauliError); + } + + #[test] + fn parses_s_family_without_prefix_collision() { + // `s` is a prefix of `s_adj`, `sqrt_x`, `sqrt_y`, etc. + assert_eq!(parse("s"), S); + assert_eq!(parse("s_adj"), SAdj); + assert_eq!(parse("sqrt_x"), SqrtX); + assert_eq!(parse("sqrt_x_adj"), SqrtXAdj); + assert_eq!(parse("sqrt_y"), SqrtY); + assert_eq!(parse("sqrt_y_adj"), SqrtYAdj); + } + + #[test] + fn rejects_unknown_token() { + assert!(CircuitInstruction::parser().parse("nope").has_errors()); + } + + #[test] + fn rejects_pascal_case_token() { + // The parse token is lowercase; the Display form must not parse back. + assert!(CircuitInstruction::parser().parse("CNOT").has_errors()); + } + + // ─── Display: PascalCase variant names ──────────────────────────────── + + #[test] + fn display_uses_pascal_case_names() { + assert_eq!(H.to_string(), "H"); + assert_eq!(CNOT.to_string(), "CNOT"); + assert_eq!(TwoQubitPauliError.to_string(), "TwoQubitPauliError"); + assert_eq!(Trace.to_string(), "Trace"); + assert_eq!(Truncate.to_string(), "Truncate"); + // Custom-token variants display their Rust name, not the parse token. + assert_eq!(SqrtXAdj.to_string(), "SqrtXAdj"); + assert_eq!(SAdj.to_string(), "SAdj"); + } + + // ─── Instruction codec (derived OpCode / WriteBytes / FromBytes) ────── + + #[test] + fn opcodes_are_unit_width() { + // All variants are field-less, so each encodes to a single byte. + assert_eq!(CircuitInstruction::width(), 1); + } + + #[test] + fn opcodes_are_unique() { + let mut seen = std::collections::HashSet::new(); + for inst in ALL { + assert!( + seen.insert(inst.opcode()), + "duplicate opcode {} for {inst:?}", + inst.opcode() + ); + } + assert_eq!(seen.len(), ALL.len()); + } + + #[test] + fn opcodes_match_declaration_order() { + // Opcodes default to the variant index, which is the on-disk contract + // for bytecode. Reordering variants silently breaks old bytecode, so + // pin the assignment here. + for (index, inst) in ALL.iter().enumerate() { + assert_eq!(inst.opcode() as usize, index, "{inst:?}"); + } + } + + #[test] + fn write_then_read_round_trips_every_variant() { + for inst in ALL { + let mut buf = Vec::new(); + inst.write_bytes(&mut buf).unwrap(); + assert_eq!(buf, [inst.opcode()], "{inst:?} should encode to one byte"); + + let mut cursor = std::io::Cursor::new(buf); + let back = CircuitInstruction::from_bytes(&mut cursor).unwrap(); + assert_eq!(back, *inst); + } + } + + #[test] + fn from_bytes_rejects_unknown_opcode() { + let mut cursor = std::io::Cursor::new([0xFFu8]); + let err = CircuitInstruction::from_bytes(&mut cursor).unwrap_err(); + assert!(err.to_string().contains("invalid opcode"), "err: {err}"); + } +}