diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 631edd75b..c0ddc59f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,9 +70,11 @@ jobs: run: cargo test -p ppvm-stim --features rayon # Cross-compile the whole workspace for browser wasm so a wasm regression - # surfaces in CI. `ppvm-python-native` is a CPython extension (never a wasm - # target) and is excluded. Native-only acceleration deps (gxhash, dashmap → - # rayon, ahash) are pruned automatically by the `cfg(not(target_arch = + # surfaces in CI. `ppvm-python-native` (a CPython extension) and `ppvm-cli` + # (a terminal binary using clap and forcing the rayon feature) are never + # browser-wasm targets and are excluded; the reusable engine lives in the + # library crates, which stay covered. Native-only acceleration deps (gxhash, + # dashmap → rayon, ahash) are pruned automatically by the `cfg(not(target_arch = # "wasm32"))` dependency tables, and `rand`'s entropy uses the getrandom # `wasm_js` backend wired in `.cargo/config.toml` — so no extra flags here. wasm-build: @@ -89,7 +91,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Build workspace for wasm32-unknown-unknown - run: cargo build --target wasm32-unknown-unknown --workspace --exclude ppvm-python-native + run: cargo build --target wasm32-unknown-unknown --workspace --exclude ppvm-python-native --exclude ppvm-cli python-tests: name: Python tests diff --git a/Cargo.lock b/Cargo.lock index d8e9dab88..0041ea5e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,28 +337,43 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.48" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "clap_lex" -version = "0.7.5" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codespan" @@ -1146,6 +1161,15 @@ dependencies = [ "ppvm-tableau", ] +[[package]] +name = "ppvm-cli" +version = "0.1.0" +dependencies = [ + "clap", + "eyre", + "ppvm-vihaco", +] + [[package]] name = "ppvm-pauli-sum" version = "0.1.0" @@ -1321,9 +1345,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1423,9 +1447,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.41" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1721,9 +1745,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.106" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 722b49ef0..60e8ae5dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/ppvm-tableau-sum", "crates/vihaco-circuit-isa", "crates/ppvm-vihaco", + "crates/ppvm-cli", # 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-cli/Cargo.toml b/crates/ppvm-cli/Cargo.toml new file mode 100644 index 000000000..4ba3d8841 --- /dev/null +++ b/crates/ppvm-cli/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ppvm-cli" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.6.1", features = ["derive"] } +eyre = "0.6.12" +ppvm-vihaco = { version = "0.1.0", path = "../ppvm-vihaco", features = ["rayon"] } + +[[bin]] +name = "ppvm" +path = "src/main.rs" diff --git a/crates/ppvm-cli/README.md b/crates/ppvm-cli/README.md new file mode 100644 index 000000000..9a08f726c --- /dev/null +++ b/crates/ppvm-cli/README.md @@ -0,0 +1,137 @@ +# ppvm-cli + +Command-line front-end for the Pauli-propagation virtual machine. Parses, +dumps, runs, and steps through `.sst` programs (and their compiled `.ssb` +bytecode). + +## Install + +From this crate directory: + +```sh +cargo install --path crates/ppvm-cli +``` + +This builds the release binary and copies it to `~/.cargo/bin/ppvm`. As long as +`~/.cargo/bin` is on your `PATH`, you can then invoke `ppvm` from anywhere. + +During development you can skip the install and use `cargo run` instead — just +put CLI arguments after `--`: + +```sh +cargo run -p ppvm-cli -- run examples/ghz.sst +``` + +## Run + +`run` executes a program for one or more shots and prints the measurement +results. The example [`examples/ghz.sst`](examples/ghz.sst) prepares a 3-qubit +GHZ state and measures every qubit, so each shot reads `000` or `111`: + +```sh +$ ppvm run examples/ghz.sst +000 +``` + +Each shot is printed as a single flat bit string — `0`/`1`, with a lost qubit +shown as `2` — and shots are separated by newlines. Use `-s`/`--shots` to run +more than one: + +```sh +$ ppvm run examples/ghz.sst --shots 5 +000 +000 +111 +000 +111 +``` + +Other options: + +- `-t`/`--threads ` — run shots across `N` threads. More than one enables + parallel execution (defaults to 1). +- `--seed ` — seed the RNG for reproducible results. The same seed yields the + same shots regardless of the thread count. +- `-o`/`--output ` — write the results to a file (one shot per line) + instead of stdout. +- `-f debug` — print the raw record for every shot instead of bit strings. +- `-q`/`--quiet` — run without printing anything. + +```sh +$ ppvm run examples/ghz.sst --shots 2 -f debug +[[[Zero], [Zero], [Zero]], [[One], [One], [One]]] + +$ ppvm run examples/ghz.sst --shots 1000 --threads 8 -o results.txt +Results written to results.txt +``` + +## Dump + +`dump` compiles a `.sst` program to `.ssb` bytecode. With no `-o`, it writes +next to the input (`ghz.sst` → `ghz.ssb`): + +```sh +$ ppvm dump examples/ghz.sst +Bytecode written to examples/ghz.ssb +``` + +`run` auto-detects the format from the file's contents, so the bytecode runs the +same way as the source: + +```sh +$ ppvm run examples/ghz.ssb +000 +``` + +`dump` refuses to overwrite an existing file unless you pass `-f`/`--force`. + +## Debug + +`debug` steps through a program interactively. At each pause it prints the +program counter, the next instruction, and the measurements so far, then waits +for a command (type the letter and press Enter; a bare Enter steps): + +- `s` — step one instruction +- `c` — continue to the next breakpoint (or the end) +- `q` — quit + +By default it pauses at `breakpoint` instructions in the program. Add one +wherever you want execution to stop: + +``` +fn @main() { + const.u64 0 + circuit.h + + // execution pauses here + breakpoint + + const.u64 0 + circuit.measure + ret +} +``` + +```sh +$ printf 's\nc\n' | ppvm debug program.sst +-- breakpoint hit -- +pc=3 next: const.u64 0 +measurements: +> s step | c continue | q quit: pc=4 next: Measure +measurements: +> s step | c continue | q quit: Program finished. +Measurements: 0 +``` + +To step through a program that has no breakpoints, pass `-b`/`--break-at-start` +to pause before the very first instruction: + +```sh +$ ppvm debug examples/ghz.sst -b +pc=0 next: const.u64 0 +measurements: +> s step | c continue | q quit: +``` + +Batch `run` ignores `breakpoint` instructions entirely, so the same file still +runs straight through with `ppvm run`. diff --git a/crates/ppvm-cli/examples/bit_flip_correction.sst b/crates/ppvm-cli/examples/bit_flip_correction.sst new file mode 100644 index 000000000..a76d1e267 --- /dev/null +++ b/crates/ppvm-cli/examples/bit_flip_correction.sst @@ -0,0 +1,66 @@ +device circuit.n_qubits 5; + +// Data: q0, q1, q2. +// Syndrome ancillas: q3, q4. +fn @main() { + const.u64 1 + const.f64 0.25 + const.f64 0.0 + const.f64 0.0 + circuit.paulierror + + const.u64 0 + const.u64 3 + circuit.cnot + const.u64 1 + const.u64 3 + circuit.cnot + const.u64 3 + circuit.measure + + const.u64 1 + const.u64 4 + circuit.cnot + const.u64 2 + const.u64 4 + circuit.cnot + const.u64 4 + circuit.measure + + const.u32 1 + eq.u32 + cond_br @s12_one, @s12_zero + +@s12_one: + const.u32 1 + eq.u32 + cond_br @correct_q1, @correct_q2 + +@s12_zero: + const.u32 1 + eq.u32 + cond_br @correct_q0, @readout + +@correct_q0: + const.u64 0 + circuit.x + br @readout + +@correct_q1: + const.u64 1 + circuit.x + br @readout + +@correct_q2: + const.u64 2 + circuit.x + +@readout: + const.u64 0 + circuit.measure + const.u64 1 + circuit.measure + const.u64 2 + circuit.measure + ret +} diff --git a/crates/ppvm-cli/examples/ghz.sst b/crates/ppvm-cli/examples/ghz.sst new file mode 100644 index 000000000..aa88b8a65 --- /dev/null +++ b/crates/ppvm-cli/examples/ghz.sst @@ -0,0 +1,27 @@ +device circuit.n_qubits 3; + +// Prepare a 3-qubit GHZ state (|000> + |111>)/sqrt(2) and measure every qubit. +// The three outcomes are perfectly correlated, so each shot reads 0 0 0 or 1 1 1. +fn @main() { + const.u64 0 + circuit.h + + const.u64 0 + const.u64 1 + circuit.cnot + + const.u64 1 + const.u64 2 + circuit.cnot + + const.u64 0 + circuit.measure + + const.u64 1 + circuit.measure + + const.u64 2 + circuit.measure + + ret +} diff --git a/crates/ppvm-cli/examples/heisenberg_zz.sst b/crates/ppvm-cli/examples/heisenberg_zz.sst new file mode 100644 index 000000000..028e7e098 --- /dev/null +++ b/crates/ppvm-cli/examples/heisenberg_zz.sst @@ -0,0 +1,17 @@ +device circuit.n_qubits 2; +device circuit.backend paulisum; +device circuit.observable 1.0*ZZ+0.5*XX; +device circuit.coefficient_threshold 1e-10; + +// Heisenberg-picture run on a PauliSum target. The header observable +// `1.0*ZZ + 0.5*XX` seeds two terms; tracing `[XZ]?*` filters both ZZ +// (coef 1.0) and XX (coef 0.5), so the trace is their coefficient sum. +// +// No gates here, so the trace returns the seeded observable's coefficient +// sum directly: 1.0 + 0.5 = 1.5. Add `circuit.cnot; circuit.h; circuit.truncate` +// in textbook-reversed order to evolve before tracing. +fn @main() { + const.str "[XZ]?*" + circuit.trace + ret +} diff --git a/crates/ppvm-cli/examples/loop_feedforward.pseudo b/crates/ppvm-cli/examples/loop_feedforward.pseudo new file mode 100644 index 000000000..eeec2fce3 --- /dev/null +++ b/crates/ppvm-cli/examples/loop_feedforward.pseudo @@ -0,0 +1,16 @@ + def main(): + measurement_record = [] + + for _ in range(3): + circuit.h(q0) + + q0_outcome = circuit.measure(q0) + measurement_record.append(q0_outcome) + + if q0_outcome == 1: + circuit.x(q1) + + q1_outcome = circuit.measure(q1) + measurement_record.append(q1_outcome) + + return measurement_record diff --git a/crates/ppvm-cli/examples/loop_feedforward.sst b/crates/ppvm-cli/examples/loop_feedforward.sst new file mode 100644 index 000000000..f1a7f6d87 --- /dev/null +++ b/crates/ppvm-cli/examples/loop_feedforward.sst @@ -0,0 +1,44 @@ +device circuit.n_qubits 2; + +// Loop + feed-forward demo. +// Repeat a body 3 times (a counted loop); in each round flip a fair coin on +// q0 and, based on the measured outcome, conditionally toggle q1 (the +// feed-forward). q1 is flipped once per coin that came up 1, so the final q1 +// measurement equals the parity (XOR) of the three coin flips. Each shot +// prints 4 bits: q0_round1 q0_round2 q0_round3 q1. +fn @main() { + // Loop counter starts at 0 and lives on the bottom of the stack. + const.u64 0 + +@loop: + // Flip a fair coin: H then measure q0 (pushes outcome 0/1 as u32). + const.u64 0 + circuit.h + const.u64 0 + circuit.measure + + // Feed-forward: if the outcome was 1, apply X to q1. + const.u32 1 + eq.u32 + cond_br @flip, @next + +@flip: + const.u64 1 + circuit.x + br @next + +@next: + // counter += 1, then loop while counter < 3. + const.u64 1 + add.u64 + dup + const.u64 3 + lt.u64 + cond_br @loop, @done + +@done: + // Final readout of q1. + const.u64 1 + circuit.measure + ret +} diff --git a/crates/ppvm-cli/examples/simple_loop.sst b/crates/ppvm-cli/examples/simple_loop.sst new file mode 100644 index 000000000..3591973ac --- /dev/null +++ b/crates/ppvm-cli/examples/simple_loop.sst @@ -0,0 +1,33 @@ +device circuit.n_qubits 1; + +fn @main() { + const.u64 0 + +@loop: + const.u64 0 + circuit.h + const.u64 0 + circuit.measure + + // stop here to investigate + breakpoint + + const.u32 1 + eq.u32 + cond_br @flip, @next + +@flip: + const.u64 0 + circuit.x + +@next: + const.u64 1 + add.u64 + dup + const.u64 2 + lt.u64 + cond_br @loop, @done + +@done: + ret +} diff --git a/crates/ppvm-cli/src/commands.rs b/crates/ppvm-cli/src/commands.rs new file mode 100644 index 000000000..574d3c174 --- /dev/null +++ b/crates/ppvm-cli/src/commands.rs @@ -0,0 +1,580 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use eyre::{Result, WrapErr}; +use ppvm_vihaco::composite::{PPVM, StepOutcome}; +use ppvm_vihaco::measurements::MeasurementResult; +use ppvm_vihaco::shots::ShotRecord; +use std::io::{BufRead, Write}; +use std::path::Path; + +/// Output format for `parse`. +#[derive(Clone, Debug, clap::ValueEnum)] +pub enum Format { + Pretty, + Debug, + Json, +} + +/// Output format for the measurement record from `run`. +#[derive(Clone, Debug, clap::ValueEnum)] +pub enum MeasurementFormat { + /// One flat bit string per shot: `0`/`1`, lost qubit = `2`. + Bits, + /// Raw debug representation of all shots. + Debug, +} + +pub fn run( + file: &str, + shots: usize, + seed: Option, + output: Option<&str>, + quiet: bool, + format: MeasurementFormat, +) -> Result<()> { + // Compile once, then run every shot against the shared module. The thread + // pool is sized once in `main` via the top-level `--threads` flag. + let module = + ppvm_vihaco::load_module_file(file).wrap_err_with(|| format!("failed to load {file}"))?; + let records = ppvm_vihaco::shots::run_shots(&module, shots, seed) + .wrap_err_with(|| format!("failed to run {file}"))?; + if quiet { + return Ok(()); + } + + let text = match format { + MeasurementFormat::Bits => format_shot_records(&records), + MeasurementFormat::Debug => format!("{records:?}"), + }; + + match output { + Some(path) => { + std::fs::write(path, format!("{text}\n")) + .wrap_err_with(|| format!("failed to write {path}"))?; + eprintln!("Results written to {path}"); + } + None => println!("{text}"), + } + Ok(()) +} + +/// Render one shot per line. +fn format_shot_records(records: &[ShotRecord]) -> String { + records + .iter() + .map(format_shot_record) + .collect::>() + .join("\n") +} + +/// Render one shot. Layout: +/// - Tableau-style (only measurements): `bits` — same as the pre-trace format. +/// - PauliSum-style (only traces): comma-separated floats. +/// - Both present: `bits | t0,t1,...`. +/// - Both empty: empty string. +fn format_shot_record(record: &ShotRecord) -> String { + let bits = format_shot(&record.measurements); + let traces = format_traces(&record.traces); + match (bits.is_empty(), traces.is_empty()) { + (false, false) => format!("{bits} | {traces}"), + (false, true) => bits, + (true, false) => traces, + (true, true) => String::new(), + } +} + +/// Render a shot's full measurement record as one flat bit string, all events +/// and qubits concatenated: `Zero` → `0`, `One` → `1`, `Lost` → `2` (the +/// outcome's own enum value). An empty record renders as the empty string. +fn format_shot(record: &[MeasurementResult]) -> String { + record + .iter() + .flatten() + .map(|outcome| char::from(b'0' + *outcome as u8)) + .collect() +} + +/// Render trace values as comma-separated floats with default `f64` formatting +/// (`1.0` → `"1"`, `1.5` → `"1.5"`, etc.). Empty record renders as empty string. +fn format_traces(traces: &[f64]) -> String { + traces + .iter() + .map(|t| format!("{t}")) + .collect::>() + .join(",") +} + +pub fn parse(file: &str, format: Format) -> Result<()> { + let source = + std::fs::read_to_string(file).wrap_err_with(|| format!("failed to read {file}"))?; + let parsed = ppvm_vihaco::parse_program(&source)?; + + match format { + Format::Json => { + eprintln!("Warning: JSON format not yet supported for AST, using debug format"); + println!("{:#?}", parsed); + } + Format::Debug => { + println!("{:#?}", parsed); + } + Format::Pretty => { + println!("Module:"); + println!(" Headers: {}", parsed.headers.len()); + for (i, header) in parsed.headers.iter().enumerate() { + println!(" [{}] {:?}", i, header); + } + println!(" Functions: {}", parsed.functions.len()); + for (i, func) in parsed.functions.iter().enumerate() { + println!( + " [{}] {}({} params, {} body items)", + i, + func.name, + func.params.len(), + func.body.len() + ); + } + } + } + + Ok(()) +} + +pub fn dump(file: &str, output: Option<&str>, force: bool) -> Result<()> { + let output_file = match output { + Some(output_file_name) => output_file_name.to_string(), + None => Path::new(file) + .with_extension("ssb") + .to_string_lossy() + .into_owned(), + }; + + // Don't clobber an existing file unless asked to. + if !force && Path::new(&output_file).exists() { + return Err(eyre::eyre!( + "{output_file} already exists; pass --force to overwrite" + )); + } + + ppvm_vihaco::dump_file(file, &output_file) + .wrap_err_with(|| format!("failed to dump {file}"))?; + eprintln!("Bytecode written to {output_file}"); + Ok(()) +} + +/// A command entered at the debugger prompt. +enum DebugCommand { + Step, + Continue, + Quit, +} + +/// Step through a program interactively, pausing at `breakpoint` instructions. +/// With `break_at_start`, also pauses before the first instruction so any +/// program can be stepped from the beginning. +pub fn debug(file: &str, break_at_start: bool) -> Result<()> { + let stdin = std::io::stdin(); + let mut input = stdin.lock(); + let mut output = std::io::stdout(); + debug_loop(file, break_at_start, &mut input, &mut output) +} + +/// Core debugger loop, generic over its IO so it can be driven by tests. +fn debug_loop( + file: &str, + break_at_start: bool, + input: &mut impl BufRead, + output: &mut impl Write, +) -> Result<()> { + let mut machine = PPVM::default(); + machine + .load_file(file) + .wrap_err_with(|| format!("failed to load {file}"))?; + machine.init()?; + + let mut paused = break_at_start; + let mut ever_paused = paused; + + loop { + // Safety net: stop if execution has run off the end of the code. + if machine.current_instruction().is_none() { + writeln!(output, "Program counter past end of code.")?; + break; + } + + if paused { + print_location(&machine, output)?; + match read_command(input, output)? { + DebugCommand::Quit => { + writeln!(output, "Quit.")?; + return Ok(()); + } + DebugCommand::Continue => paused = false, + DebugCommand::Step => {} + } + } + + match machine.step_once()? { + StepOutcome::Continue => {} + StepOutcome::Breakpoint => { + paused = true; + ever_paused = true; + writeln!(output, "-- breakpoint hit --")?; + } + StepOutcome::Return | StepOutcome::Halt => { + writeln!(output, "Program finished.")?; + break; + } + } + } + + writeln!( + output, + "Measurements: {}", + format_shot(&machine.measurement_record()) + )?; + let traces = machine.trace_record(); + if !traces.is_empty() { + writeln!(output, "Traces: {}", format_traces(&traces))?; + } + if !ever_paused { + writeln!( + output, + "(no breakpoint was hit; pass --break-at-start to step from the beginning)" + )?; + } + Ok(()) +} + +/// Print the program counter, the next instruction, and measurements so far. +fn print_location(machine: &PPVM, output: &mut impl Write) -> Result<()> { + let pc = machine.current_pc(); + match machine.current_instruction() { + Some(inst) => writeln!(output, "pc={pc} next: {inst}")?, + None => writeln!(output, "pc={pc} (end of code)")?, + } + writeln!( + output, + "measurements: {}", + format_shot(&machine.measurement_record()) + )?; + let traces = machine.trace_record(); + if !traces.is_empty() { + writeln!(output, "traces: {}", format_traces(&traces))?; + } + Ok(()) +} + +/// Prompt for and read a debugger command. A bare Enter steps; EOF quits. +fn read_command(input: &mut impl BufRead, output: &mut impl Write) -> Result { + loop { + write!(output, "> s step | c continue | q quit: ")?; + output.flush()?; + + let mut line = String::new(); + if input.read_line(&mut line)? == 0 { + // EOF (e.g. stdin closed): treat as quit so we never spin. + return Ok(DebugCommand::Quit); + } + match line.trim() { + "" | "s" | "step" => return Ok(DebugCommand::Step), + "c" | "continue" => return Ok(DebugCommand::Continue), + "q" | "quit" => return Ok(DebugCommand::Quit), + other => writeln!(output, "unknown command: {other:?}")?, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ppvm_vihaco::measurements::MeasurementOutcome; + use std::fs; + + /// Minimal program that compiles and measures q0 in |0> (deterministic). + const PROGRAM: &str = + "device circuit.n_qubits 1;\nfn @main() { const.u64 0\n circuit.measure\n ret }\n"; + + fn row(outcomes: &[MeasurementOutcome]) -> MeasurementResult { + outcomes.iter().copied().collect() + } + + /// Write `contents` to a uniquely-named temp file and return its path. + fn temp_file(name: &str, contents: &str) -> String { + let path = std::env::temp_dir().join(name); + fs::write(&path, contents).unwrap(); + path.to_string_lossy().into_owned() + } + + // ─── format_shot ───────────────────────────────────────────────────── + + #[test] + fn format_shot_empty_record_is_empty() { + assert_eq!(format_shot(&[]), ""); + } + + #[test] + fn format_shot_concatenates_qubits_within_an_event() { + let record = vec![row(&[ + MeasurementOutcome::One, + MeasurementOutcome::Zero, + MeasurementOutcome::One, + ])]; + assert_eq!(format_shot(&record), "101"); + } + + #[test] + fn format_shot_flattens_events_with_no_separator() { + let record = vec![ + row(&[MeasurementOutcome::One]), + row(&[MeasurementOutcome::Zero]), + ]; + assert_eq!(format_shot(&record), "10"); + } + + #[test] + fn format_shot_renders_lost_qubit_as_two() { + let record = vec![ + row(&[MeasurementOutcome::One, MeasurementOutcome::Lost]), + row(&[MeasurementOutcome::Zero]), + ]; + assert_eq!(format_shot(&record), "120"); + } + + #[test] + fn format_shot_records_joins_shots_with_newlines() { + let shots = vec![ + ShotRecord { + measurements: vec![row(&[MeasurementOutcome::One])], + traces: vec![], + }, + ShotRecord { + measurements: vec![row(&[MeasurementOutcome::Zero])], + traces: vec![], + }, + ]; + assert_eq!(format_shot_records(&shots), "1\n0"); + } + + #[test] + fn format_shot_record_traces_only_shows_floats() { + let shot = ShotRecord { + measurements: vec![], + traces: vec![1.0, 0.5], + }; + assert_eq!(format_shot_record(&shot), "1,0.5"); + } + + #[test] + fn format_shot_record_both_present_joins_with_pipe() { + let shot = ShotRecord { + measurements: vec![row(&[MeasurementOutcome::One])], + traces: vec![0.25], + }; + assert_eq!(format_shot_record(&shot), "1 | 0.25"); + } + + #[test] + fn format_shot_record_both_empty_is_empty_string() { + assert_eq!(format_shot_record(&ShotRecord::default()), ""); + } + + // ─── run ─────────────────────────────────────────────────────────── + + #[test] + fn run_succeeds_on_valid_file() { + let src = temp_file("ppvm_cli_run_ok.sst", PROGRAM); + let res = run(&src, 3, None, None, true, MeasurementFormat::Bits); + let _ = fs::remove_file(&src); + assert!(res.is_ok(), "got: {res:?}"); + } + + #[test] + fn run_writes_one_line_per_shot_to_output_file() { + let src = temp_file("ppvm_cli_run_output.sst", PROGRAM); + let out = std::env::temp_dir().join("ppvm_cli_run_output.txt"); + let _ = fs::remove_file(&out); + + run(&src, 4, None, out.to_str(), false, MeasurementFormat::Bits).unwrap(); + let contents = fs::read_to_string(&out).unwrap(); + // Four deterministic shots of |0>, one per line. + assert_eq!(contents, "0\n0\n0\n0\n"); + + let _ = fs::remove_file(&src); + let _ = fs::remove_file(&out); + } + + #[test] + fn run_outputs_trace_record_for_paulisum_backend() { + // Single-qubit PauliSum with observable Z, no gates, trace `Z?*` → 1.0. + // The .sst-driven path should surface the trace value in `run`'s output. + const TRACE_PROGRAM: &str = "device circuit.n_qubits 1;\n\ + device circuit.backend paulisum;\n\ + device circuit.observable Z;\n\ + fn @main() { const.str \"Z?*\"\n circuit.trace\n ret }\n"; + let src = temp_file("ppvm_cli_run_trace.sst", TRACE_PROGRAM); + let out = std::env::temp_dir().join("ppvm_cli_run_trace.txt"); + let _ = fs::remove_file(&out); + + run(&src, 1, None, out.to_str(), false, MeasurementFormat::Bits).unwrap(); + let contents = fs::read_to_string(&out).unwrap(); + assert_eq!(contents, "1\n", "expected trace value 1.0 in output"); + + let _ = fs::remove_file(&src); + let _ = fs::remove_file(&out); + } + + #[test] + fn run_errors_with_context_on_missing_file() { + let err = run( + "/no/such/file.sst", + 1, + None, + None, + false, + MeasurementFormat::Bits, + ) + .unwrap_err(); + assert!(err.to_string().contains("failed to load"), "got: {err}"); + } + + // ─── parse ───────────────────────────────────────────────────────── + + #[test] + fn parse_succeeds_on_valid_file() { + let src = temp_file("ppvm_cli_parse_ok.sst", PROGRAM); + let res = parse(&src, Format::Debug); + let _ = fs::remove_file(&src); + assert!(res.is_ok(), "got: {res:?}"); + } + + #[test] + fn parse_errors_with_context_on_missing_file() { + let err = parse("/no/such/file.sst", Format::Pretty).unwrap_err(); + assert!(err.to_string().contains("failed to read"), "got: {err}"); + } + + // ─── dump ────────────────────────────────────────────────────────── + + #[test] + fn dump_writes_default_ssb_path_when_output_omitted() { + let src = temp_file("ppvm_cli_dump_default.sst", PROGRAM); + let expected = Path::new(&src).with_extension("ssb"); + let _ = fs::remove_file(&expected); // clear any leftover from a prior run + + dump(&src, None, false).unwrap(); + assert!(expected.exists(), "default .ssb should have been written"); + + let _ = fs::remove_file(&src); + let _ = fs::remove_file(&expected); + } + + #[test] + fn dump_writes_explicit_output_path() { + let src = temp_file("ppvm_cli_dump_explicit.sst", PROGRAM); + let out = std::env::temp_dir().join("ppvm_cli_dump_explicit_out.ssb"); + let _ = fs::remove_file(&out); + + dump(&src, Some(out.to_str().unwrap()), false).unwrap(); + assert!(out.exists()); + + let _ = fs::remove_file(&src); + let _ = fs::remove_file(&out); + } + + #[test] + fn dump_refuses_to_clobber_without_force() { + let src = temp_file("ppvm_cli_dump_clobber.sst", PROGRAM); + let out = std::env::temp_dir().join("ppvm_cli_dump_clobber_out.ssb"); + fs::write(&out, b"existing").unwrap(); + + let err = dump(&src, Some(out.to_str().unwrap()), false).unwrap_err(); + assert!(err.to_string().contains("already exists"), "got: {err}"); + // The existing file must be left untouched. + assert_eq!(fs::read(&out).unwrap(), b"existing"); + + let _ = fs::remove_file(&src); + let _ = fs::remove_file(&out); + } + + #[test] + fn dump_overwrites_existing_with_force() { + let src = temp_file("ppvm_cli_dump_force.sst", PROGRAM); + let out = std::env::temp_dir().join("ppvm_cli_dump_force_out.ssb"); + fs::write(&out, b"existing").unwrap(); + + dump(&src, Some(out.to_str().unwrap()), true).unwrap(); + // Replaced with real bytecode, not the placeholder. + assert_ne!(fs::read(&out).unwrap(), b"existing"); + + let _ = fs::remove_file(&src); + let _ = fs::remove_file(&out); + } + + // ─── debug ───────────────────────────────────────────────────────── + + /// Program with a `breakpoint` before measuring q0 in |0> (deterministic). + const BREAKPOINT_PROGRAM: &str = "device circuit.n_qubits 1;\nfn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + + /// Drive `debug_loop` with scripted input, returning the captured output. + fn run_debug(program: &str, name: &str, break_at_start: bool, script: &str) -> String { + let src = temp_file(name, program); + let mut input = script.as_bytes(); + let mut output: Vec = Vec::new(); + debug_loop(&src, break_at_start, &mut input, &mut output).unwrap(); + let _ = fs::remove_file(&src); + String::from_utf8(output).unwrap() + } + + #[test] + fn debug_break_at_start_steps_through_to_finish() { + // PROGRAM is const.u64 0 / circuit.measure / ret = 3 steps. + let out = run_debug(PROGRAM, "ppvm_cli_debug_step.sst", true, "s\ns\ns\n"); + assert!( + out.contains("next: Measure"), + "should display the gate: {out}" + ); + assert!(out.contains("Program finished."), "{out}"); + assert!( + out.contains("Measurements: 0"), + "q0 in |0> measures 0: {out}" + ); + } + + #[test] + fn debug_continue_runs_to_end() { + let out = run_debug(PROGRAM, "ppvm_cli_debug_continue.sst", true, "c\n"); + assert!(out.contains("Program finished."), "{out}"); + assert!(out.contains("Measurements: 0"), "{out}"); + } + + #[test] + fn debug_honors_authored_breakpoint() { + // Not breaking at start: must run until the `breakpoint` pauses it. + let out = run_debug(BREAKPOINT_PROGRAM, "ppvm_cli_debug_bp.sst", false, "c\n"); + assert!(out.contains("-- breakpoint hit --"), "{out}"); + assert!(out.contains("Program finished."), "{out}"); + // A breakpoint was hit, so no "use --break-at-start" hint. + assert!(!out.contains("no breakpoint was hit"), "{out}"); + } + + #[test] + fn debug_quit_stops_before_finishing() { + let out = run_debug(PROGRAM, "ppvm_cli_debug_quit.sst", true, "q\n"); + assert!(out.contains("Quit."), "{out}"); + assert!(!out.contains("Program finished."), "{out}"); + assert!( + !out.contains("Measurements:"), + "quit prints no record: {out}" + ); + } + + #[test] + fn debug_without_breakpoint_prints_hint() { + // No breakpoint, no break-at-start, empty input: runs straight through + // and tells the user how to step. + let out = run_debug(PROGRAM, "ppvm_cli_debug_hint.sst", false, ""); + assert!(out.contains("Program finished."), "{out}"); + assert!(out.contains("no breakpoint was hit"), "{out}"); + } +} diff --git a/crates/ppvm-cli/src/main.rs b/crates/ppvm-cli/src/main.rs new file mode 100644 index 000000000..cccf674dc --- /dev/null +++ b/crates/ppvm-cli/src/main.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use clap::{Parser, Subcommand}; +use eyre::Result; + +mod commands; + +#[derive(Parser)] +#[command(name = "ppvm")] +#[command(about = "Pauli propagation virtual machine", long_about = None)] +pub struct Cli { + /// Number of threads for all parallel work (1 = fully serial & deterministic) + #[arg(short, long, default_value_t = 1, value_parser = clap::builder::RangedU64ValueParser::::new().range(1..))] + threads: usize, + + /// Subcommand to run. + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Parse a .sst file and output the AST + Parse { + /// Input .sst file + #[arg(value_name = "FILE")] + file: String, + + /// Output format + #[arg(short, long, value_enum, default_value = "pretty")] + format: commands::Format, + }, + /// Compile a .sst file to bytecode + Dump { + /// Input .sst file + #[arg(value_name = "FILE")] + file: String, + + /// Output file (optional, defaults to .ssb) + #[arg(short, long)] + output: Option, + + /// Overwrite the output file if it already exists + #[arg(short, long)] + force: bool, + }, + + /// Run a .sst or .ssb program + Run { + /// Input file (.sst source or .ssb bytecode) + #[arg(value_name = "FILE")] + file: String, + + /// Number of shots to run + #[arg(short, long, default_value = "1")] + shots: usize, + + /// Seed the RNG for reproducible results + #[arg(long)] + seed: Option, + + /// Write results to a file instead of stdout + #[arg(short, long)] + output: Option, + + /// Suppress the measurement record + #[arg(short, long)] + quiet: bool, + + /// Measurement output format + #[arg(short, long, value_enum, default_value = "bits")] + format: commands::MeasurementFormat, + }, + + /// Step through a program interactively, pausing at `breakpoint` instructions + Debug { + /// Input file (.sst source or .ssb bytecode) + #[arg(value_name = "FILE")] + file: String, + + /// Pause before the first instruction so any program can be stepped + #[arg(short, long)] + break_at_start: bool, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + // Size the process-wide thread pool once; governs all parallelism (across + // shots and within a single machine). `--threads 1` is fully serial. + ppvm_vihaco::shots::set_global_threads(cli.threads)?; + + match cli.command { + Commands::Parse { file, format } => { + commands::parse(&file, format)?; + } + Commands::Dump { + file, + output, + force, + } => { + commands::dump(&file, output.as_deref(), force)?; + } + Commands::Run { + file, + shots, + seed, + output, + quiet, + format, + } => { + commands::run(&file, shots, seed, output.as_deref(), quiet, format)?; + } + Commands::Debug { + file, + break_at_start, + } => { + commands::debug(&file, break_at_start)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_zero_threads() { + // `--threads 0` must be rejected (0 would break pool sizing). The + // subcommand is valid, so a zero thread count is the only parse error. + assert!(Cli::try_parse_from(["ppvm", "--threads", "0", "parse", "f.sst"]).is_err()); + } + + #[test] + fn accepts_positive_and_default_threads() { + assert!(Cli::try_parse_from(["ppvm", "--threads", "1", "parse", "f.sst"]).is_ok()); + assert!(Cli::try_parse_from(["ppvm", "parse", "f.sst"]).is_ok()); + } +}