From 6127458100732252cfade027be9577842e26a792 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 04:03:00 -0400 Subject: [PATCH 1/7] wai-quantum: one OpenQASM reader for the service, the CLI and the browser IDE; receipts signed with the signer's own key The OpenQASM reader moves from the service into the library as wai_quantum::qasm (feature quantum_qasm). The service, `wai-quantum run --qasm` and the browser IDE's import now read a program the same way and refuse the same programs: two registers, broadcast gates and comments read as the service reads them, where the IDE's line reader dropped what it did not know. - qasm::dyadic_circuit takes a program whose measurements all come at the end, and refuses one that measures and then acts, with the reason. - quantum_toolchain::from_qasm, the older line reader, refuses what it cannot take (more than one register, reset, gate definitions, if/while/for, a gate after a measurement, two statements on one line) and names the full reader, instead of dropping the line. - wai-web: the IDE binding builds its JSON with serde_json, so a quote or a control character in a program or an error no longer breaks the reply. set_signing_key, public_key_hex and key_is_demo let a page sign receipts with its own key; without one it signs with the published demo key and says so. - The CLI signs with --key FILE, $WAI_QUANTUM_KEY, or this user's key at $XDG_CONFIG_HOME/wai/quantum-key (~/.config/wai/quantum-key), made from the operating system's random source with mode 0600 on first use. --demo-key keeps the published key, with a note that such a receipt shows the result is unaltered, not who signed it. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum-service/Cargo.toml | 2 +- wai/wai-quantum-service/src/main.rs | 2 +- wai/wai-quantum/Cargo.toml | 5 +- wai/wai-quantum/src/lib.rs | 5 + .../src/qasm.rs | 36 ++-- wai/wai-quantum/src/quantum_toolchain.rs | 48 +++++ wai/wai-rs/Cargo.lock | 1 + wai/wai-rs/Cargo.toml | 8 + wai/wai-rs/src/bin/wai_quantum.rs | 71 +++++-- wai/wai-rs/src/lib.rs | 2 + wai/wai-web/Cargo.toml | 2 +- wai/wai-web/src/quantum_toolchain.rs | 185 +++++++++++++----- 12 files changed, 297 insertions(+), 70 deletions(-) rename wai/{wai-quantum-service => wai-quantum}/src/qasm.rs (98%) diff --git a/wai/wai-quantum-service/Cargo.toml b/wai/wai-quantum-service/Cargo.toml index 18b304ea2..14a92fb65 100644 --- a/wai/wai-quantum-service/Cargo.toml +++ b/wai/wai-quantum-service/Cargo.toml @@ -17,7 +17,7 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["float_roundtrip"] } blake3 = "1" getrandom = "0.4" -wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum", "quantum_toolchain", "quantum_receipt", "quantum_sv", "quantum_tn", "quantum_tableau", "quantum_cutrank", "quantum_mpsim", "quantum_spd", "quantum_noisy", "quantum_sv_metal"] } +wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum", "quantum_toolchain", "quantum_receipt", "quantum_sv", "quantum_tn", "quantum_tableau", "quantum_cutrank", "quantum_qasm", "quantum_mpsim", "quantum_spd", "quantum_noisy", "quantum_sv_metal"] } # proc_pid_rusage and waitid, for each job's energy (src/energy.rs). [target.'cfg(target_os = "macos")'.dependencies] diff --git a/wai/wai-quantum-service/src/main.rs b/wai/wai-quantum-service/src/main.rs index 42608b028..ff66b6b42 100644 --- a/wai/wai-quantum-service/src/main.rs +++ b/wai/wai-quantum-service/src/main.rs @@ -20,7 +20,7 @@ mod energy; mod http; mod jobs; mod mcp; -mod qasm; +use wai_quantum::qasm; mod receipt; mod run; diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index a86503a1b..7223ace40 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -80,6 +80,9 @@ quantum_tableau = [] # Exact amplitudes, probabilities and samples of a Clifford circuit measured in a # product basis, at a cost set by its cut-rank (src/quantum_cutrank.rs). quantum_cutrank = ["quantum_tableau", "quantum_sv"] +# OpenQASM 3 (and the OpenQASM 2 forms in common use) read to one program and +# lowered to each method's input (src/qasm.rs). +quantum_qasm = ["quantum", "quantum_sv", "quantum_spd", "quantum_tableau"] quantum_mpsim = ["quantum_sv", "quantum_mps"] quantum_pauli = ["quantum"] # Sparse Pauli dynamics at utility scale: arbitrary-angle Pauli rotations on up @@ -206,7 +209,7 @@ quantum_phasor_meter = ["_signing"] # Everything. full = [ - "quantum", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_tableau", "quantum_cutrank", "quantum_mpsim", "quantum_pauli", "quantum_spd", "quantum_spd_receipt", "quantum_bptn", "quantum_resource", "quantum_resource_receipt", "quantum_frame", "quantum_tn", "quantum_gbs", "quantum_sv", "quantum_arch", "quantum_ldpc", "quantum_qio", "quantum_uf", "quantum_noisy", "quantum_dmrg", "quantum_tdvp", "quantum_chem", "quantum_nqs", "quantum_qir", "quantum_info", "quantum_qudit", "quantum_comm", "quantum_source", "quantum_channel", + "quantum", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_tableau", "quantum_cutrank", "quantum_qasm", "quantum_mpsim", "quantum_pauli", "quantum_spd", "quantum_spd_receipt", "quantum_bptn", "quantum_resource", "quantum_resource_receipt", "quantum_frame", "quantum_tn", "quantum_gbs", "quantum_sv", "quantum_arch", "quantum_ldpc", "quantum_qio", "quantum_uf", "quantum_noisy", "quantum_dmrg", "quantum_tdvp", "quantum_chem", "quantum_nqs", "quantum_qir", "quantum_info", "quantum_qudit", "quantum_comm", "quantum_source", "quantum_channel", "quantum_receipt", "quantum_ops", "quantum_cal", "quantum_mitigate", "quantum_qec", "quantum_control", "quantum_noise", "quantum_compile", "quantum_atom", "quantum_qbom", "quantum_fabric", "quantum_vml", "quantum_phasor", "quantum_qfhrr", "quantum_kernel", "quantum_qdata", diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index 876f07274..f22defbd7 100644 --- a/wai/wai-quantum/src/lib.rs +++ b/wai/wai-quantum/src/lib.rs @@ -47,6 +47,11 @@ mod linalg; #[cfg(any(feature = "quantum_tn", feature = "quantum_sv"))] pub mod gates; +// OpenQASM 3 (and the OpenQASM 2 forms in common use): one front end for every +// surface, lowered to each method's input. +#[cfg(feature = "quantum_qasm")] +pub mod qasm; + #[cfg(any( feature = "quantum_spd", feature = "quantum_pauli", diff --git a/wai/wai-quantum-service/src/qasm.rs b/wai/wai-quantum/src/qasm.rs similarity index 98% rename from wai/wai-quantum-service/src/qasm.rs rename to wai/wai-quantum/src/qasm.rs index b79f1730c..37bfe5a3f 100644 --- a/wai/wai-quantum-service/src/qasm.rs +++ b/wai/wai-quantum/src/qasm.rs @@ -16,7 +16,7 @@ //! OpenQASM 2's `if(c==n) gate;`. A program that measures a qubit and then //! acts on it, resets one after its first gate, or uses `if` is dynamic: it //! is kept as steps ([`Instr`]) as well as gates, and runs by -//! [`crate::dynamic`]. Every other program is exactly what it was before. +//! the service's `dynamic` module. Every other program is exactly what it was before. //! //! Control modifiers, `ctrl @`, `ctrl(n) @`, `negctrl @` and `negctrl(n) @`, //! on `x y z h s sdg t tdg p` and on the controlled names `cx cy cz ch cp ccx @@ -35,10 +35,10 @@ //! lowering to the others. use std::collections::HashMap; -use wai_quantum::gates::{C, Gate as Matrix, complex}; -use wai_quantum::quantum::{BaseGate, Circuit as Dyadic, Gate as DyadicGate}; -use wai_quantum::quantum_spd::{Op as Rot, Pauli, RotCircuit}; -use wai_quantum::quantum_tableau::Clifford; +use crate::gates::{C, Gate as Matrix, complex}; +use crate::quantum::{BaseGate, Circuit as Dyadic, Gate as DyadicGate}; +use crate::quantum_spd::{Op as Rot, Pauli, RotCircuit}; +use crate::quantum_tableau::Clifford; /// The most qubits a program declares. pub const MAX_QUBITS: u32 = 65_536; @@ -1119,6 +1119,20 @@ impl Parser { } /// Parses an OpenQASM program. +/// A program whose measurements all come at the end, as the dyadic circuit: +/// the reader of the bit-exact state vector, the in-browser IDE and the CLI. +/// Every register, broadcast, gate definition and modifier [`parse`] reads is +/// read the same way here. A program that measures a qubit and then acts on +/// it, resets a qubit after its first gate, or acts on measured bits is +/// refused, never rewritten: its meaning needs the measurements. +pub fn dyadic_circuit(src: &str) -> Result { + let p = parse(src)?; + if p.dynamic.is_some() { + return Err("this program measures a qubit and then acts on it, resets a qubit after its first gate, or acts on measured bits; the exact state vector here takes programs whose measurements all come at the end".into()); + } + p.dyadic() +} + pub fn parse(src: &str) -> Result { let toks = lex(src)?; let mut p = Parser { @@ -1292,7 +1306,7 @@ fn lowering_phase(op: &Op, gates: &[Clifford]) -> C { let want = op.matrix().matrix; let mut best = (0.0f64, complex(1.0, 0.0)); for col in 0..d { - let mut sv = wai_quantum::quantum_sv::StateVector::zero(k).expect("at most three qubits"); + let mut sv = crate::quantum_sv::StateVector::zero(k).expect("at most three qubits"); sv.re[0] = 0.0; sv.re[col] = 1.0; for g in &local_gates { @@ -2119,8 +2133,8 @@ mod tests { Program { n_qubits: n, ops, measured: (0..n).map(|q| (q, q)).collect(), n_bits: n, dynamic: None } } - fn dense(p: &Program) -> wai_quantum::quantum_sv::StateVector { - let mut sv = wai_quantum::quantum_sv::StateVector::zero(p.n_qubits).unwrap(); + fn dense(p: &Program) -> crate::quantum_sv::StateVector { + let mut sv = crate::quantum_sv::StateVector::zero(p.n_qubits).unwrap(); sv.run(&p.matrices(), 1).unwrap(); sv } @@ -2276,7 +2290,7 @@ mod tests { #[test] fn the_clifford_lowering_gives_the_matrices_expectation_values() { - use wai_quantum::quantum_tableau::Tableau; + use crate::quantum_tableau::Tableau; for seed in 0..12 { let p = clifford_kinds(5, seed); let sv = dense(&p); @@ -2304,7 +2318,7 @@ mod tests { for seed in 0..6 { let p = every_kind(4, seed + 300); let want = dense(&p); - let mut got = wai_quantum::quantum_sv::StateVector::zero(p.n_qubits).unwrap(); + let mut got = crate::quantum_sv::StateVector::zero(p.n_qubits).unwrap(); let local = p.local_matrices(); assert!(local.iter().all(|g| g.qubits.len() <= 2)); got.run(&local, 1).unwrap(); @@ -2319,7 +2333,7 @@ mod tests { #[test] fn the_rotation_lowering_gives_the_matrices_expectation_values() { - use wai_quantum::quantum_spd::{SpdConfig, expectation}; + use crate::quantum_spd::{SpdConfig, expectation}; for seed in 0..6 { let p = every_kind(4, seed + 100); let sv = dense(&p); diff --git a/wai/wai-quantum/src/quantum_toolchain.rs b/wai/wai-quantum/src/quantum_toolchain.rs index 978c8021a..6064818ff 100644 --- a/wai/wai-quantum/src/quantum_toolchain.rs +++ b/wai/wai-quantum/src/quantum_toolchain.rs @@ -674,7 +674,15 @@ pub fn to_qasm(c: &Circuit) -> String { /// if its gateset is then changed. pub fn from_qasm(src: &str) -> Result { let mut n: Option = None; + let mut registers = 0; // first pass: qubit count + // Registers counted statement by statement, however lines are broken. + for stmt in src.lines().map(|l| l.split("//").next().unwrap_or("")).flat_map(|l| l.split(';')) { + let stmt = stmt.trim(); + if stmt.starts_with("qubit") || stmt.starts_with("qreg ") { + registers += 1; + } + } for raw in src.lines() { let line = strip(raw); if let Some(rest) = line.strip_prefix("qubit[") @@ -688,20 +696,42 @@ pub fn from_qasm(src: &str) -> Result { } } let n = n.ok_or("no qubit register declaration (qubit[n] q; or qreg q[n];)")?; + // What this reader does not take is refused, never dropped: the program + // would mean something else. `wai_quantum::qasm` reads all of it. + let fuller = "; the full reader (wai_quantum::qasm, the IDE's and the service's) takes it"; + if registers > 1 { + return Err(format!("{registers} qubit registers; this reader takes one{fuller}")); + } let mut c = Circuit::new(n); + let mut measured = false; for raw in src.lines() { let line = strip(raw); if line.is_empty() { continue; } + if line.contains(';') && !line.starts_with("OPENQASM") && !line.starts_with("include") { + return Err(format!("several statements on one line (`{line}`); this reader takes one a line{fuller}")); + } + let first: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + match first.as_str() { + "reset" => return Err(format!("`reset` changes the state; this reader does not take it{fuller}")), + "gate" => return Err(format!("gate definitions are not read here{fuller}")), + "if" | "while" | "for" => return Err(format!("`{first}` acts on measured bits; this reader does not take it{fuller}")), + _ => {} + } // Measurement is recorded, not simulated here, and the exporters disagree // about how to spell it: OpenQASM 2 writes `measure q[0] -> c[0];` while // Qiskit's OpenQASM 3 exporter writes `c[0] = measure q[0];`, whose first // token is the *classical* register. Skip either shape before parsing a // gate name out of the line. if line.contains("measure") { + measured = true; continue; } + let gate_line = !matches!(first.as_str(), "OPENQASM" | "include" | "qubit" | "qreg" | "creg" | "bit" | "barrier"); + if measured && gate_line { + return Err(format!("a gate after a measurement (`{line}`): this reader takes measurements only at the end{fuller}")); + } // Gate modifiers: `ctrl @`, `ctrl(n) @`, `negctrl @`, `negctrl(n) @`. if let Some((polarity, call)) = modifiers(&line)? { push_modified(&mut c, &polarity, &call)?; @@ -1322,6 +1352,24 @@ mod tests { assert_eq!(recommend_backend(&Circuit::qft(6)).0, Backend::Statevector); } + /// What this reader cannot take is refused, never dropped. + #[test] + fn what_the_reader_cannot_take_is_refused() { + for src in [ + "OPENQASM 3.0;\nqubit[1] a;\nqubit[1] b;\nx a[0];\nx b[0];\n", + "OPENQASM 3.0;\nqubit[1] q;\nx q[0];\nreset q[0];\n", + "OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nh q[0];\nc[0] = measure q[0];\nh q[0];\n", + "OPENQASM 3.0;\ngate flip a { x a; }\nqubit[1] q;\nflip q[0];\n", + "OPENQASM 3.0;\nqubit[2] q;\nh q[0]; cx q[0], q[1];\n", + ] { + let e = from_qasm(src).unwrap_err(); + assert!(e.contains("wai_quantum::qasm"), "{src}: {e}"); + } + // Measurements at the end are still read (and dropped, as they change nothing). + let c = from_qasm("OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nx q[0];\nc[0] = measure q[0];\n").unwrap(); + assert_eq!(c.ops.len(), 1); + } + #[test] fn qasm_round_trip() { // Grover's search past three qubits uses a Z with three or more diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 890a68780..eb21328ad 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4280,6 +4280,7 @@ dependencies = [ "dav1d", "ed25519-dalek", "flac-bound", + "getrandom 0.2.17", "image", "jpeg-encoder", "jpegxl-rs", diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 4f7b8b82a..cc0a07abf 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -90,6 +90,8 @@ c2pa = { version = "0.90", optional = true, default-features = false, features = # deterministic fixed-point simulation, replay-equivalence conformance. blake3 = { version = "1.8", optional = true } # state hashes, node minting, op/seal commitments; `hazmat` (1.8) for the HTTP head proof ed25519-dalek = { version = "2", optional = true } # op + seal signatures +# The CLI's own signing key, made from the operating system's random source. +getrandom = { version = "0.2", optional = true } num-bigint = { version = "0.4", optional = true } # oer: exact decimal arithmetic in the composition checks # The curve ed25519-dalek is built on, named directly only by `receipt_claims`: its # conformance corpus builds the signatures jwp-receipts §3.1 step 3 must refuse (keys of @@ -177,6 +179,8 @@ semantic = ["codecs", "dep:ort", "dep:ndarray"] # the dependency on its own, so the one verifier builds exactly when ed25519-dalek does # and asks for no feature its caller does not need. Every signing feature enables it. ed25519 = ["dep:ed25519-dalek"] +# The wai-quantum CLI's per-user signing key (src/bin/wai_quantum.rs). +cli_keys = ["dep:getrandom"] # Interactive worlds (extensions/interactive-worlds): media that consumes # input. Pure-Rust, no ML dep chain — the sim is closed-form fixed-point. world = ["dep:blake3", "ed25519"] @@ -444,6 +448,8 @@ quantum_atom = ["quantum_ops", "wai-quantum/quantum_atom"] # selection (Clifford → statevector → tensor-network cascade) + OpenQASM interop, # all over the byte-exact fixed-point simulator. Framework-neutral, no cloud. quantum_toolchain = ["quantum", "wai-quantum/quantum_toolchain"] +# OpenQASM 3 read one way on every surface (wai_quantum::qasm). +quantum_qasm = ["quantum", "wai-quantum/quantum_qasm"] # Matrix Product State backend (extensions/quantum-sim § MPS, `wai.quantum.mps`): # the tensor-network "classical referee" — evolve a bounded-bond MPS gate-by-gate # with a deterministic complex Jacobi SVD truncation, reproducible on every machine @@ -617,6 +623,8 @@ name = "wai-quantum" path = "src/bin/wai_quantum.rs" required-features = [ "quantum_toolchain", + "quantum_qasm", + "cli_keys", "quantum_receipt", "quantum_qir", "quantum_channel", diff --git a/wai/wai-rs/src/bin/wai_quantum.rs b/wai/wai-rs/src/bin/wai_quantum.rs index c00f05f71..730b26c09 100644 --- a/wai/wai-rs/src/bin/wai_quantum.rs +++ b/wai/wai-rs/src/bin/wai_quantum.rs @@ -149,7 +149,7 @@ fn usage() -> ! { wai-quantum run --qir [--top K] [--json] [--gateset v1|v2] (QIR on stdin)\n \ wai-quantum qasm [-n QUBITS] [-p PARAM] [--gateset v1|v2]\n \ wai-quantum qir [-n QUBITS] [-p PARAM] [--no-measure] [--gateset v1|v2]\n \ - wai-quantum receipt [-n QUBITS] [-p PARAM] [--shots N] [--seed S] [--gateset v1|v2]\n \ + wai-quantum receipt [-n QUBITS] [-p PARAM] [--shots N] [--seed S] [--gateset v1|v2] [--key FILE | --demo-key]\n \ wai-quantum verify (receipt JSON on stdin)\n \ --gateset v2 (the default) is wai.quantum.circuit2; v1 is the frozen wai.quantum.circuit.\n\n\ INFORMATION AND TRANSPORT:\n \ @@ -223,6 +223,61 @@ fn read_stdin() -> String { } /// Pull `--flag VALUE` out of the argument list. +/// The key a receipt is signed with: +/// - `--demo-key`: the published demo key, for receipts that repeat byte for +/// byte everywhere. Anyone can sign with it, so it proves nothing about who +/// signed, and a note on stderr says so; +/// - `--key FILE`, or `$WAI_QUANTUM_KEY` naming a file: that key; +/// - otherwise this user's key, `$XDG_CONFIG_HOME/wai/quantum-key` (or +/// `~/.config/wai/quantum-key`), made from the operating system's random +/// source on first use and readable by its owner only. +/// +/// A key file holds the 32-byte Ed25519 seed as 64 hex digits. +fn signing_key(args: &[String]) -> SigningKey { + if flag(args, "--demo-key") { + eprintln!("note: signed with the published demo key; the receipt shows the result is unaltered, not who signed it"); + return SigningKey::from_bytes(&[42u8; 32]); + } + let path = opt(args, "--key", "").or_else(|| std::env::var("WAI_QUANTUM_KEY").ok()).map(std::path::PathBuf::from).unwrap_or_else(|| { + let base = std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| std::path::Path::new(&h).join(".config"))).unwrap_or_else(|| die("no HOME to keep a key in: pass --key FILE")); + base.join("wai").join("quantum-key") + }); + let parse = |text: &str| -> Option<[u8; 32]> { + let t = text.trim(); + if t.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (i, b) in out.iter_mut().enumerate() { + *b = u8::from_str_radix(&t[2 * i..2 * i + 2], 16).ok()?; + } + Some(out) + }; + match std::fs::read_to_string(&path) { + Ok(text) => SigningKey::from_bytes(&parse(&text).unwrap_or_else(|| die(&format!("{}: a key file holds 64 hex digits", path.display())))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound && opt(args, "--key", "").is_none() && std::env::var_os("WAI_QUANTUM_KEY").is_none() => { + let mut seed = [0u8; 32]; + getrandom::getrandom(&mut seed).unwrap_or_else(|e| die(&format!("no random source for a new key: {e}"))); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap_or_else(|e| die(&format!("{}: {e}", dir.display()))); + } + let text: String = seed.iter().map(|b| format!("{b:02x}")).collect(); + let mut f = std::fs::OpenOptions::new(); + f.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + f.mode(0o600); + } + use std::io::Write as _; + f.open(&path).and_then(|mut file| writeln!(file, "{text}")).unwrap_or_else(|e| die(&format!("{}: {e}", path.display()))); + eprintln!("note: made a signing key at {}", path.display()); + SigningKey::from_bytes(&seed) + } + Err(e) => die(&format!("{}: {e}", path.display())), + } +} + fn opt(args: &[String], long: &str, short: &str) -> Option { args.iter() .position(|a| a == long || (!short.is_empty() && a == short)) @@ -259,7 +314,7 @@ fn gateset_from(args: &[String]) -> Gateset { fn circuit_from(args: &[String], algo: Option<&String>) -> Circuit { let gs = gateset_from(args); let mut c = if flag(args, "--qasm") { - qt::from_qasm(&read_stdin()).unwrap_or_else(|e| die(&format!("QASM parse: {e}"))) + wai::qasm::dyadic_circuit(&read_stdin()).unwrap_or_else(|e| die(&format!("QASM parse: {e}"))) } else if flag(args, "--qir") { wai::quantum_qir::from_qir(&read_stdin()).unwrap_or_else(|e| die(&format!("QIR parse: {e}"))) } else { @@ -509,9 +564,7 @@ fn main() { let shots: u64 = num(args, "--shots", "", 0); let seed: u64 = num(args, "--seed", "", 0); let measure = (shots > 0).then_some(Measure { seed, shots }); - // Deterministic demo key: the receipt is reproducible byte-for-byte, so - // native and cloud-WASM runs sign identically. Supply real keys in prod. - let key = SigningKey::from_bytes(&[42u8; 32]); + let key = signing_key(args); let r = QuantumReceipt::seal(&key, "did:key:wai-quantum-cli", &c, measure, 0, None) .unwrap_or_else(|e| die(&format!("seal: {e:?}"))); println!("{}", r.to_json()); @@ -633,9 +686,7 @@ fn main() { config: SpdConfig { threshold, max_weight: None }, }; if flag(args, "--receipt") { - // The same deterministic demo key as `receipt`: the run is - // bit-identical everywhere, so is its receipt. - let key = SigningKey::from_bytes(&[42u8; 32]); + let key = signing_key(args); let r = SpdReceipt::seal(&key, "did:key:wai-quantum-cli", &job, None, |run| { run(); 0 @@ -698,9 +749,7 @@ fn main() { die("--receipt seals one estimate: pick a --qubit"); } let job = ResourceJob { program: Program::Logical(counts), qubit: qubits[0].clone(), code, config: cfg }; - // The same deterministic demo key as `receipt`: the estimate is - // bit-identical everywhere, so is its receipt. - let key = SigningKey::from_bytes(&[42u8; 32]); + let key = signing_key(args); let r = ResourceReceipt::seal(&key, "did:key:wai-quantum-cli", &job, None, |run| { run(); 0 diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 0ddd880ed..5f8598aaf 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -775,6 +775,8 @@ pub use wai_quantum::quantum_atom; // byte-exact fixed-point simulator. Framework-neutral and offline. #[cfg(feature = "quantum_toolchain")] pub use wai_quantum::quantum_toolchain; +#[cfg(feature = "quantum_qasm")] +pub use wai_quantum::qasm; // Matrix Product State backend (`wai.quantum.mps`) — the tensor-network classical // referee: a bounded-bond MPS evolved with a deterministic complex Jacobi SVD diff --git a/wai/wai-web/Cargo.toml b/wai/wai-web/Cargo.toml index 30b4bdc43..23b58c8fc 100644 --- a/wai/wai-web/Cargo.toml +++ b/wai/wai-web/Cargo.toml @@ -19,7 +19,7 @@ js-sys = "0.3" # with the codec chain off. No second implementation: replay-equivalence # in the browser is the identical Rust that generated the conformance # corpus hashes. -wai = { path = "../wai-rs", default-features = false, features = ["world", "audio_scene", "haptic", "splat4d", "provenance", "score", "film", "neural_int", "generate", "video_int", "audio_int", "world_model", "world_int", "splat_int", "quantum", "quantum_ops", "quantum_cal", "quantum_mitigate", "quantum_qec", "quantum_control", "quantum_noise", "quantum_compile", "quantum_atom", "quantum_qbom", "quantum_receipt", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_pauli", "quantum_vml", "quantum_phasor", "quantum_qfhrr", "quantum_kernel", "quantum_phasor_meter", "quantum_qdata", "video_workflow", "staged", "stage_measure", "receipt_claims"] } +wai = { path = "../wai-rs", default-features = false, features = ["world", "audio_scene", "haptic", "splat4d", "provenance", "score", "film", "neural_int", "generate", "video_int", "audio_int", "world_model", "world_int", "splat_int", "quantum", "quantum_ops", "quantum_cal", "quantum_mitigate", "quantum_qec", "quantum_control", "quantum_noise", "quantum_compile", "quantum_atom", "quantum_qbom", "quantum_receipt", "quantum_toolchain", "quantum_qasm", "quantum_mps", "quantum_stabilizer", "quantum_pauli", "quantum_vml", "quantum_phasor", "quantum_qfhrr", "quantum_kernel", "quantum_phasor_meter", "quantum_qdata", "video_workflow", "staged", "stage_measure", "receipt_claims"] } ed25519-dalek = "2" # actor keys for browser-side op authoring (same version as wai-rs) blake3 = "1" # in-page receipt check: hash a decoded image against a pinned digest # Awaits the WebGPU member's futures behind the promises of src/quantum_webgpu.rs; diff --git a/wai/wai-web/src/quantum_toolchain.rs b/wai/wai-web/src/quantum_toolchain.rs index 8756a19b5..5de3a3d72 100644 --- a/wai/wai-web/src/quantum_toolchain.rs +++ b/wai/wai-web/src/quantum_toolchain.rs @@ -20,9 +20,17 @@ use wasm_bindgen::prelude::*; const SCALE: f64 = (1u64 << FRAC) as f64; -fn signer() -> SigningKey { +/// The key a receipt is signed with before the page gives the IDE its own +/// ([`QuantumIde::set_signing_key`]). It is public, so a receipt it signs +/// proves nothing about who made it; [`QuantumIde::key_is_demo`] says so. +fn demo_signer() -> SigningKey { SigningKey::from_bytes(&[0x51; 32]) } +/// `{"error": message}`, the message escaped as JSON requires. +fn error_json(message: impl std::fmt::Display) -> String { + serde_json::json!({ "error": message.to_string() }).to_string() +} + fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } @@ -98,6 +106,8 @@ pub struct QuantumIde { sv: Option, /// Whether the console note has been printed for the circuit as it stands. noted: bool, + /// The key receipts are signed with: this browser's, once the page sets it. + key: Option, } #[wasm_bindgen] @@ -105,7 +115,26 @@ impl QuantumIde { /// An empty circuit on `n_qubits` (1 to 12), in circuit2. #[wasm_bindgen(constructor)] pub fn new(n_qubits: u8) -> QuantumIde { - QuantumIde { circuit: Circuit::with_gateset(n_qubits.clamp(1, 12), Gateset::V2), sv: None, noted: false } + QuantumIde { circuit: Circuit::with_gateset(n_qubits.clamp(1, 12), Gateset::V2), sv: None, noted: false, key: None } + } + + // ---- signing key ---- + /// Sign receipts with the Ed25519 key whose 32-byte seed is `seed`: the + /// page makes one at random and keeps it in this browser. Refuses any + /// other length. + pub fn set_signing_key(&mut self, seed: &[u8]) -> Result<(), String> { + let bytes: [u8; 32] = seed.try_into().map_err(|_| format!("a signing key's seed is 32 bytes, not {}", seed.len()))?; + self.key = Some(SigningKey::from_bytes(&bytes)); + Ok(()) + } + /// The public key receipts are signed with, as hex. + pub fn public_key_hex(&self) -> String { + hex(self.signer().verifying_key().as_bytes()) + } + /// Whether receipts are still signed with the published demo key, which + /// proves nothing about who made them. + pub fn key_is_demo(&self) -> bool { + self.key.is_none() } // ---- gateset ---- @@ -180,10 +209,20 @@ impl QuantumIde { } } /// Replace the circuit from an OpenQASM source, built in the IDE's gateset. - /// Returns an error string on failure (unsupported gate, non-dyadic phase, - /// bad register, a gate that names one qubit twice). + /// The program is read as the job service reads it ([`wai::qasm`]): every + /// register, broadcast, gate definition and modifier. Returns an error + /// string on failure (an unsupported construct with its line, a + /// non-dyadic angle, more qubits than the page holds). A program that + /// measures in the middle, resets after a gate or acts on measured bits is + /// refused, never rewritten, with a pointer to the console that runs it. pub fn load_qasm(&mut self, src: &str) -> Result<(), String> { - let c = tc::from_qasm(src)?; + let c = wai::qasm::dyadic_circuit(src).map_err(|e| { + if e.starts_with("this program measures") { + format!("{e}. The console at /quantum-run/ runs it.") + } else { + e + } + })?; if c.n_qubits > 12 { return Err(format!("{} qubits exceeds the in-browser limit (12)", c.n_qubits)); } @@ -192,22 +231,13 @@ impl QuantumIde { } /// The op-log as JSON, for the editor to render. pub fn gates_json(&self) -> String { - let items: Vec = self + let items: Vec = self .circuit .ops .iter() - .map(|g| { - let ctrls: Vec = g.controls.iter().map(|c| c.to_string()).collect(); - format!( - "{{\"name\":\"{}\",\"controls\":[{}],\"target\":{},\"param\":{}}}", - name_of(g.base), - ctrls.join(","), - g.target, - g.param - ) - }) + .map(|g| serde_json::json!({ "name": name_of(g.base), "controls": g.controls, "target": g.target, "param": g.param })) .collect(); - format!("[{}]", items.join(",")) + serde_json::Value::Array(items).to_string() } // ---- run ---- @@ -314,13 +344,7 @@ impl QuantumIde { pub fn transpile_line(&self) -> String { let r = tc::route_line(&self.circuit); let eq = tc::routing_equivalent(&self.circuit, &r); - format!( - "{{\"source_2q\":{},\"nonlocal\":{},\"routed_gates\":{},\"swaps\":{},\"equivalent\":{eq}}}", - r.source_2q, - r.source_nonlocal, - r.circuit.ops.len(), - r.swaps - ) + serde_json::json!({ "source_2q": r.source_2q, "nonlocal": r.source_nonlocal, "routed_gates": r.circuit.ops.len(), "swaps": r.swaps, "equivalent": eq }).to_string() } /// Run the circuit through the tensor-network (MPS) backend at bond cap /// `chi_max` and report `{bond, retained, fidelity}` against the exact dense @@ -328,17 +352,14 @@ impl QuantumIde { pub fn run_mps(&self, chi_max: u32) -> String { let dense = match self.circuit.simulate() { Ok(d) => d, - Err(e) => return format!("{{\"error\":\"{e}\"}}"), + Err(e) => return error_json(e), }; match quantum_mps::run(&self.circuit, chi_max.max(1) as usize) { Ok((mps, r)) => { let f = quantum_mps::fidelity(&mps, &dense); - format!( - "{{\"bond\":{},\"retained\":{:.6},\"fidelity\":{:.6}}}", - r.max_bond, r.retained_weight, f - ) + serde_json::json!({ "bond": r.max_bond, "retained": r.retained_weight, "fidelity": f }).to_string() } - Err(e) => format!("{{\"error\":\"{e:?}\"}}"), + Err(e) => error_json(format!("{e:?}")), } } /// Sample the current circuit on the O(n²) stabilizer backend (if Clifford): @@ -347,9 +368,9 @@ impl QuantumIde { match quantum_stabilizer::sample(&self.circuit, seed as u64, shots.max(1) as u64) { Ok(v) => { let (o, c) = v.first().copied().unwrap_or((0, 0)); - format!("{{\"distinct\":{},\"top_index\":{o},\"top_count\":{c}}}", v.len()) + serde_json::json!({ "distinct": v.len(), "top_index": o, "top_count": c }).to_string() } - Err(e) => format!("{{\"error\":\"{}\"}}", e.0), + Err(e) => error_json(e.0), } } /// Scale demonstration: build an `n`-qubit GHZ and sample it on the stabilizer @@ -364,16 +385,16 @@ impl QuantumIde { c.cx(i - 1, i); } match quantum_stabilizer::sample(&c, seed as u64, shots.max(1) as u64) { - Ok(v) => format!("{{\"n\":{n},\"distinct\":{},\"gates\":{}}}", v.len(), c.ops.len()), - Err(e) => format!("{{\"error\":\"{}\"}}", e.0), + Ok(v) => serde_json::json!({ "n": n, "distinct": v.len(), "gates": c.ops.len() }).to_string(), + Err(e) => error_json(e.0), } } /// Compute ⟨Z_q⟩ on the current circuit via sparse-Pauli / Heisenberg /// propagation: `{value, peak}` (peak Pauli-sum size) or `{error}`. pub fn expect_z_pauli(&self, q: u8, threshold: f64) -> String { match quantum_pauli::expect_z(&self.circuit, q, threshold.max(0.0)) { - Ok((v, peak)) => format!("{{\"value\":{v:.6},\"peak\":{peak}}}"), - Err(e) => format!("{{\"error\":\"{}\"}}", e.0), + Ok((v, peak)) => serde_json::json!({ "value": v, "peak": peak }).to_string(), + Err(e) => error_json(e.0), } } /// Scale demonstration: ⟨Z_0⟩ on an `n`-qubit low-magic circuit — a single @@ -401,8 +422,8 @@ impl QuantumIde { } } match quantum_pauli::expect_z(&c, 0, threshold.max(0.0)) { - Ok((v, peak)) => format!("{{\"n\":{n},\"value\":{v:.6},\"peak\":{peak}}}"), - Err(e) => format!("{{\"error\":\"{}\"}}", e.0), + Ok((v, peak)) => serde_json::json!({ "n": n, "value": v, "peak": peak }).to_string(), + Err(e) => error_json(e.0), } } /// Seal a signed run receipt over the byte-exact reconstruction (and a shot @@ -445,9 +466,13 @@ impl QuantumIde { } } + fn signer(&self) -> SigningKey { + self.key.clone().unwrap_or_else(demo_signer) + } + fn seal(&self, seed: u64, shots: u64) -> Option { QuantumReceipt::seal( - &signer(), + &self.signer(), "did:key:quantum-ide", &self.circuit, Some(Measure { seed, shots }), @@ -461,11 +486,8 @@ impl QuantumIde { /// The algorithm library catalog as JSON `[{id,name,desc}]`, for the IDE dropdown. #[wasm_bindgen] pub fn ide_catalog() -> String { - let items: Vec = tc::catalog() - .into_iter() - .map(|(id, name, desc)| format!("{{\"id\":\"{id}\",\"name\":\"{name}\",\"desc\":\"{desc}\"}}")) - .collect(); - format!("[{}]", items.join(",")) + let items: Vec = tc::catalog().into_iter().map(|(id, name, desc)| serde_json::json!({ "id": id, "name": name, "desc": desc })).collect(); + serde_json::Value::Array(items).to_string() } #[cfg(test)] @@ -600,4 +622,79 @@ mod tests { assert!(ide.set_gateset(2)); assert_eq!(ide.note(), ""); } + + fn loaded(src: &str) -> QuantumIde { + let mut ide = QuantumIde::new(2); + ide.load_qasm(src).unwrap_or_else(|e| panic!("{src}: {e}")); + ide.run().unwrap(); + ide + } + + /// The outcome with probability 1, or None. + fn certain(ide: &QuantumIde) -> Option { + let p = ide.probabilities(); + p.iter().position(|&x| (x - 1.0).abs() < 1e-9) + } + + /// OpenQASM means what it says: registers keep their own qubits, broadcast, + /// comments, single-qubit declarations and gate definitions are read, and a + /// program whose meaning needs its measurements is refused, not rewritten. + #[test] + fn imported_programs_mean_what_they_say() { + // Two registers are two qubits: |11⟩. + let ide = loaded("OPENQASM 3.0; qubit[1] a; qubit[1] b; x a[0]; x b[0];"); + assert_eq!(ide.circuit.n_qubits, 2); + assert_eq!(certain(&ide), Some(3)); + // OpenQASM 2's registers too: three qubits, one of them flipped. + let ide = loaded("OPENQASM 2.0; include \"qelib1.inc\"; qreg a[2]; qreg b[1]; x b[0];"); + assert_eq!(ide.circuit.n_qubits, 3); + assert_eq!(certain(&ide).map(|i| i.count_ones()), Some(1)); + // Broadcast, a single-qubit declaration, a block comment, a definition. + let ide = loaded("OPENQASM 3.0; qubit[2] q; /* both */ h q;"); + assert!(ide.probabilities().iter().all(|&p| (p - 0.25).abs() < 1e-9), "{:?}", ide.probabilities()); + let ide = loaded("OPENQASM 3.0; qubit q; x q;"); + assert_eq!((ide.circuit.n_qubits, certain(&ide)), (1, Some(1))); + let ide = loaded("OPENQASM 3.0; gate flip a { x a; } qubit[1] q; flip q[0];"); + assert_eq!(certain(&ide), Some(1)); + // A final measurement leaves the state as it was. + let ide = loaded("OPENQASM 3.0; qubit[1] q; bit[1] c; x q[0]; c[0] = measure q[0];"); + assert_eq!(certain(&ide), Some(1)); + // A measurement acted on afterwards, or a reset after a gate, is refused. + let mut ide = QuantumIde::new(2); + for src in ["OPENQASM 3.0; qubit[1] q; bit[1] c; h q[0]; c[0] = measure q[0]; h q[0];", "OPENQASM 3.0; qubit[1] q; x q[0]; reset q[0];"] { + let e = ide.load_qasm(src).unwrap_err(); + assert!(e.contains("measures a qubit and then acts on it") && e.contains("/quantum-run/"), "{src}: {e}"); + } + // A missing semicolon is an error with its line, as in the service. + assert!(ide.load_qasm("OPENQASM 3.0;\nqubit[2] q\nh q[0];").is_err()); + } + + /// Every JSON the panels read parses, whatever an error message holds. + #[test] + fn the_panels_get_json_that_parses() { + let mut ide = QuantumIde::new(8); + assert!(ide.load_algorithm("grover", 8, 0)); + ide.run().unwrap(); + for text in [ide.run_mps(8), ide.transpile_line(), ide.sample_stabilizer(1.0, 16), ide.stabilizer_scale(20, 1.0, 16), ide.expect_z_pauli(0, 1e-6), ide.pauli_scale(8, 1e-4), ide.gates_json(), super::ide_catalog()] { + serde_json::from_str::(&text).unwrap_or_else(|e| panic!("{text}: {e}")); + } + assert_eq!(super::error_json("say \"no\" \\ here"), r#"{"error":"say \"no\" \\ here"}"#); + } + + /// Receipts are signed with the key the page gives, and say when they are + /// not. + #[test] + fn receipts_use_the_browsers_key() { + let mut ide = QuantumIde::new(2); + assert!(ide.load_algorithm("bell", 2, 0)); + ide.run().unwrap(); + assert!(ide.key_is_demo()); + let demo = ide.public_key_hex(); + ide.set_signing_key(&[7u8; 32]).unwrap(); + assert!(!ide.key_is_demo()); + assert_ne!(ide.public_key_hex(), demo); + assert!(ide.receipt_verifies(1.0, 64)); + assert!(ide.receipt_json(1.0, 64).contains(&ide.public_key_hex()) || !ide.receipt_json(1.0, 64).is_empty()); + assert!(ide.set_signing_key(&[1u8; 31]).is_err()); + } } -- 2.54.0 (Apple Git-157) From b695b0b6fcf458aa6b73bbd6c74c3c3e6bf1f90a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 04:28:01 -0400 Subject: [PATCH 2/7] wai-quantum CLI and IDE: a receipt names its signer by its key's did:key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI signed every receipt as "did:key:wai-quantum-cli" and the IDE as "did:key:quantum-ide", labels that name no key. Now that each signs with its own key, the receipt's signer_id is that key's Ed25519 did:key (wai::signer_id::did_key), so a verifier reads the signer from the id as jwp-receipts §7.1 describes. The IDE binding gains signer_did() for the page to show it. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/bin/wai_quantum.rs | 7 ++++--- wai/wai-web/src/quantum_toolchain.rs | 12 ++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/wai/wai-rs/src/bin/wai_quantum.rs b/wai/wai-rs/src/bin/wai_quantum.rs index 730b26c09..535f1574c 100644 --- a/wai/wai-rs/src/bin/wai_quantum.rs +++ b/wai/wai-rs/src/bin/wai_quantum.rs @@ -81,6 +81,7 @@ use wai::quantum_info::{chsh, DensityMatrix}; use wai::quantum_qudit::QuditState; use wai::quantum_energy::{EnergyClass, MaybeLabelled}; use wai::quantum_receipt::QuantumReceipt; +use wai::signer_id::did_key; use wai::quantum_resource::{self as qr, EstimateConfig, LogicalCounts, PhysicalQubit, QecModel}; use wai::quantum_resource_receipt::{Program, ResourceJob, ResourceReceipt}; use wai::quantum_spd_receipt::{SpdJob, SpdReceipt}; @@ -565,7 +566,7 @@ fn main() { let seed: u64 = num(args, "--seed", "", 0); let measure = (shots > 0).then_some(Measure { seed, shots }); let key = signing_key(args); - let r = QuantumReceipt::seal(&key, "did:key:wai-quantum-cli", &c, measure, 0, None) + let r = QuantumReceipt::seal(&key, did_key(key.verifying_key().as_bytes()), &c, measure, 0, None) .unwrap_or_else(|e| die(&format!("seal: {e:?}"))); println!("{}", r.to_json()); } @@ -687,7 +688,7 @@ fn main() { }; if flag(args, "--receipt") { let key = signing_key(args); - let r = SpdReceipt::seal(&key, "did:key:wai-quantum-cli", &job, None, |run| { + let r = SpdReceipt::seal(&key, did_key(key.verifying_key().as_bytes()), &job, None, |run| { run(); 0 }) @@ -750,7 +751,7 @@ fn main() { } let job = ResourceJob { program: Program::Logical(counts), qubit: qubits[0].clone(), code, config: cfg }; let key = signing_key(args); - let r = ResourceReceipt::seal(&key, "did:key:wai-quantum-cli", &job, None, |run| { + let r = ResourceReceipt::seal(&key, did_key(key.verifying_key().as_bytes()), &job, None, |run| { run(); 0 }) diff --git a/wai/wai-web/src/quantum_toolchain.rs b/wai/wai-web/src/quantum_toolchain.rs index 5de3a3d72..2663ec2e8 100644 --- a/wai/wai-web/src/quantum_toolchain.rs +++ b/wai/wai-web/src/quantum_toolchain.rs @@ -14,6 +14,7 @@ use wai::quantum::{circuit_hash, BaseGate, Circuit, Gate, Gateset, Measure, Stat use wai::quantum_mps; use wai::quantum_pauli; use wai::quantum_receipt::QuantumReceipt; +use wai::signer_id::did_key; use wai::quantum_stabilizer; use wai::quantum_toolchain as tc; use wasm_bindgen::prelude::*; @@ -131,6 +132,10 @@ impl QuantumIde { pub fn public_key_hex(&self) -> String { hex(self.signer().verifying_key().as_bytes()) } + /// The signer a receipt names: that public key as a `did:key`. + pub fn signer_did(&self) -> String { + did_key(self.signer().verifying_key().as_bytes()) + } /// Whether receipts are still signed with the published demo key, which /// proves nothing about who made them. pub fn key_is_demo(&self) -> bool { @@ -473,7 +478,7 @@ impl QuantumIde { fn seal(&self, seed: u64, shots: u64) -> Option { QuantumReceipt::seal( &self.signer(), - "did:key:quantum-ide", + did_key(self.signer().verifying_key().as_bytes()), &self.circuit, Some(Measure { seed, shots }), 0, @@ -694,7 +699,10 @@ mod tests { assert!(!ide.key_is_demo()); assert_ne!(ide.public_key_hex(), demo); assert!(ide.receipt_verifies(1.0, 64)); - assert!(ide.receipt_json(1.0, 64).contains(&ide.public_key_hex()) || !ide.receipt_json(1.0, 64).is_empty()); + let r: serde_json::Value = serde_json::from_str(&ide.receipt_json(1.0, 64)).unwrap(); + assert_eq!(r["signer_pubkey"], ide.public_key_hex()); + assert_eq!(r["signer_id"], ide.signer_did()); + assert!(ide.signer_did().starts_with("did:key:z6Mk")); assert!(ide.set_signing_key(&[1u8; 31]).is_err()); } } -- 2.54.0 (Apple Git-157) From d8b1d5487af01fa05a8790158a111a18fe09a545 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 04:55:28 -0400 Subject: [PATCH 3/7] wai-quantum 0.6.10: the changelog, the qasm module in the README, the version in every lockfile that pins it Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 28 +++++++++++++++++++++++++ wai/tools/quantum-meter/Cargo.lock | 2 +- wai/tools/quantum-wgsl-check/Cargo.lock | 2 +- wai/wai-pack/Cargo.lock | 2 +- wai/wai-quantum-qpu/Cargo.lock | 2 +- wai/wai-quantum-serve/Cargo.lock | 2 +- wai/wai-quantum-service/Cargo.lock | 2 +- wai/wai-quantum/Cargo.lock | 2 +- wai/wai-quantum/Cargo.toml | 2 +- wai/wai-quantum/README.md | 1 + wai/wai-rs/Cargo.lock | 2 +- wai/wai-web/Cargo.lock | 2 +- 12 files changed, 39 insertions(+), 10 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 2b36338ac..371ccabcf 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,34 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### wai-quantum 0.6.10: one OpenQASM reader for the service, the command line and the browser IDE + +Additive, apart from the old line reader's refusals below. Every pinned digest is unchanged. + +- **`wai_quantum::qasm`** (feature `quantum_qasm`) is the job service's OpenQASM reader, + moved into the library unchanged. The service, `wai-quantum run --qasm` and the browser + IDE's import now read a program the same way. Before, the IDE read two registers as + one, skipped a gate on a whole register, and read past what it did not know. +- **`qasm::dyadic_circuit`** gives the exact state vector's circuit for a program whose + measurements all come at the end, and refuses one that measures and then acts, resets + after a gate or uses `if`, with the reason. +- **`quantum_toolchain::from_qasm`**, the older line reader, refuses what it cannot take + instead of dropping the line: more than one register, `reset`, gate definitions, + `if`/`while`/`for`, a gate after a measurement, two statements on one line. Each + refusal names `wai_quantum::qasm`. +- **The IDE binding** (`wai-web`) builds its JSON with `serde_json`, so a quote or a + control character in a program or an error no longer breaks a reply. + `set_signing_key`, `public_key_hex`, `signer_did` and `key_is_demo` let a page sign + receipts with a key of its own; without one it signs with the published demo key and + says so. +- **The command line** signs with `--key FILE`, `$WAI_QUANTUM_KEY`, or this user's key at + `$XDG_CONFIG_HOME/wai/quantum-key` (or `~/.config/wai/quantum-key`). That key is made + from the operating system's random source on first use, with mode 0600. + `--demo-key` keeps the published key, with a note that its receipts show a result is + unaltered, not who signed it. +- **A receipt from the command line or the IDE names its signer by its key's `did:key`** + (`wai::signer_id::did_key`), where it used to carry a label naming no key. + ### wai-quantum 0.6.9: cut-rank amplitudes with the circuit's own global phase Additive. Every pinned digest is unchanged. diff --git a/wai/tools/quantum-meter/Cargo.lock b/wai/tools/quantum-meter/Cargo.lock index 985e84317..75358a064 100644 --- a/wai/tools/quantum-meter/Cargo.lock +++ b/wai/tools/quantum-meter/Cargo.lock @@ -1017,7 +1017,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/tools/quantum-wgsl-check/Cargo.lock b/wai/tools/quantum-wgsl-check/Cargo.lock index f4b0bcb14..07f8b0b28 100644 --- a/wai/tools/quantum-wgsl-check/Cargo.lock +++ b/wai/tools/quantum-wgsl-check/Cargo.lock @@ -599,7 +599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index 0572fe23e..0095c1963 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -713,7 +713,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-qpu/Cargo.lock b/wai/wai-quantum-qpu/Cargo.lock index 0b9cf7c2a..c83505a33 100644 --- a/wai/wai-quantum-qpu/Cargo.lock +++ b/wai/wai-quantum-qpu/Cargo.lock @@ -422,7 +422,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index f9a3beec5..5faa436cf 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index 50058c247..ef64ec4ee 100644 --- a/wai/wai-quantum-service/Cargo.lock +++ b/wai/wai-quantum-service/Cargo.lock @@ -857,7 +857,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index 47a7b5bcb..fe3c47e0c 100644 --- a/wai/wai-quantum/Cargo.lock +++ b/wai/wai-quantum/Cargo.lock @@ -1079,7 +1079,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" dependencies = [ "blake3", "block2", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 7223ace40..c9cd4a6b9 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/wai/wai-quantum/README.md b/wai/wai-quantum/README.md index dfba3c2ed..035bd2824 100644 --- a/wai/wai-quantum/README.md +++ b/wai/wai-quantum/README.md @@ -67,6 +67,7 @@ never creates information. | `quantum` | byte-exact statevector simulation (dyadic Clifford+T+P(k)), as `wai.quantum.circuit2` by default or as the frozen `wai.quantum.circuit` | | `quantum_toolchain` | algorithm library, backend recommendation, **OpenQASM 2/3 ingest of what the mainstream toolchains actually export** — both register and both measurement spellings, `rx`/`ry`/`rz`/`sx`/`u`/`u2`/`u3`/`crz`, and exact rational-multiple-of-pi angles (non-dyadic angles are refused, never rounded); **device-ready OpenQASM 2/3** (`device::device_qasm`): every gate lowered exactly to its dialect's standard library, multi-controlled gates included, every qubit measured into `c[i]`, and the service's counts read back onto the crate's basis; **native programs** (`native::compile`): a processor's own basis (`rz`/`sx`/`x`/`cz`; `prx`/`cz`; or `gpi`/`gpi2`/`zz` with angles in turns, written exactly; the Z rotations carried in the phases of the last two) on its own qubits, routed over its coupling map, and checked against the circuit by simulation before it is sent — Bloch / entanglement / purity analysis | +| `quantum_qasm` | **the OpenQASM reader the job service runs**, for every surface that takes a program: OpenQASM 3 and the OpenQASM 2 forms in common use, every register, a gate on a whole register, gate definitions with parameters, `ctrl`/`negctrl` modifiers, angle expressions, comments, and measurement in every spelling. A program that measures and then acts, resets after a gate or uses `if` is kept as dynamic steps; loops, `inv`, `pow` and anything else not listed are refused with their line. Lowered to each method's input exactly up to a global phase. `qasm::dyadic_circuit` gives the exact state vector's circuit (dyadic angles) for a program whose measurements all come at the end, and refuses any other with the reason | | `quantum_stabilizer` | stabilizer (CHP) tableau — `O(n²)`, scales far past statevector | | `quantum_mps` | tensor-network (matrix-product-state) backend | | `quantum_pauli` | sparse-Pauli / Heisenberg observable propagation over the dyadic gate set (up to 64 qubits) | diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index eb21328ad..4d07fe7e5 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4311,7 +4311,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 2bc7cea66..526a2a58b 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -1241,7 +1241,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.9" +version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", -- 2.54.0 (Apple Git-157) From 38af4dd66e06af24150a8e47009af7960b04bc11 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:28:45 -0400 Subject: [PATCH 4/7] wai-web wasm32 CI: the native unit tests are 35, with the IDE binding's import, JSON and signing-key tests Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-web-wasm.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index dfe99c844..067adf0a0 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -161,8 +161,10 @@ jobs: # both quantum corpora to their manifests' hashes and capability and refuses # every reject file with its listed class (quantum::tests), and the quantum IDE # builds its catalog circuits in its gateset to the same manifests, stores no - # parameter on a gate other than P, and shows no hash for an invalid circuit - # (quantum_toolchain::tests). The OTT demo seals a figure the page supplies as + # parameter on a gate other than P, and shows no hash for an invalid circuit; + # its import reads a program as the job service does and refuses one that + # measures and then acts, its replies stay valid JSON whatever an error says, + # and it signs with the page's own key once given one (quantum_toolchain::tests). The OTT demo seals a figure the page supplies as # an estimate, never a reading (video_ott::tests). The count check turns a test that # stopped being compiled into a failure instead of a silent "0 passed". - name: wai-web native unit tests @@ -171,7 +173,7 @@ jobs: run: | set -euo pipefail cargo test --locked --lib 2>&1 | tee "$RUNNER_TEMP/wai-web-native.log" - grep -q "test result: ok. 32 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" + grep -q "test result: ok. 35 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" - name: wasm32 int_synth decode equals the frozen native corpus shell: bash working-directory: wai/wai-web -- 2.54.0 (Apple Git-157) From bded5e9832305b1867aec33af8e3d2a059c3dadb Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 05:03:38 -0400 Subject: [PATCH 5/7] wai-quantum: the job service's engine in the library (feature quantum_engine) The service's planner (catalog), runner (run) and dynamic-circuit walk (dynamic) move unchanged into wai_quantum::engine, so every host that runs a job plans and answers it as the service does. The service keeps what is its own: HTTP, the job queue and its limits, energy metering, its signed receipts, MCP and its examples. - The GPU state vector is behind quantum_sv_metal: without it, the plan says the build has none, and the build still compiles for any target. - The feature reads JSON numbers correctly rounded (serde_json/float_roundtrip), as the service always has. - The test of the service's own examples stays with the service; the Grover test, which builds its program with the toolchain, runs where quantum_toolchain is on. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum-service/Cargo.lock | 1 + wai/wai-quantum-service/Cargo.toml | 2 +- wai/wai-quantum-service/src/discovery.rs | 26 +++++++ wai/wai-quantum-service/src/main.rs | 5 +- wai/wai-quantum/Cargo.lock | 1 + wai/wai-quantum/Cargo.toml | 9 ++- .../src => wai-quantum/src/engine}/catalog.rs | 64 +++++++++++------- .../src => wai-quantum/src/engine}/dynamic.rs | 6 +- wai/wai-quantum/src/engine/mod.rs | 21 ++++++ .../src => wai-quantum/src/engine}/run.rs | 67 +++++++------------ wai/wai-quantum/src/lib.rs | 4 ++ 11 files changed, 131 insertions(+), 75 deletions(-) rename wai/{wai-quantum-service/src => wai-quantum/src/engine}/catalog.rs (93%) rename wai/{wai-quantum-service/src => wai-quantum/src/engine}/dynamic.rs (99%) create mode 100644 wai/wai-quantum/src/engine/mod.rs rename wai/{wai-quantum-service/src => wai-quantum/src/engine}/run.rs (95%) diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index ef64ec4ee..96aebfd27 100644 --- a/wai/wai-quantum-service/Cargo.lock +++ b/wai/wai-quantum-service/Cargo.lock @@ -864,6 +864,7 @@ dependencies = [ "objc2", "objc2-foundation", "objc2-metal", + "serde", "serde_json", ] diff --git a/wai/wai-quantum-service/Cargo.toml b/wai/wai-quantum-service/Cargo.toml index 14a92fb65..c90482908 100644 --- a/wai/wai-quantum-service/Cargo.toml +++ b/wai/wai-quantum-service/Cargo.toml @@ -17,7 +17,7 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["float_roundtrip"] } blake3 = "1" getrandom = "0.4" -wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum", "quantum_toolchain", "quantum_receipt", "quantum_sv", "quantum_tn", "quantum_tableau", "quantum_cutrank", "quantum_qasm", "quantum_mpsim", "quantum_spd", "quantum_noisy", "quantum_sv_metal"] } +wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt", "quantum_sv_metal"] } # proc_pid_rusage and waitid, for each job's energy (src/energy.rs). [target.'cfg(target_os = "macos")'.dependencies] diff --git a/wai/wai-quantum-service/src/discovery.rs b/wai/wai-quantum-service/src/discovery.rs index bb40633c3..4639ae4db 100644 --- a/wai/wai-quantum-service/src/discovery.rs +++ b/wai/wai-quantum-service/src/discovery.rs @@ -199,6 +199,32 @@ pub fn index(base: &str, console: &str) -> Value { #[cfg(test)] mod tests { use super::*; + use wai_quantum::engine::{catalog::plan, run::run}; + + fn go(job: Value) -> Value { + let p = plan(&job, 8 << 30).unwrap(); + run(&job, &p).unwrap() + } + + /// The two worked examples that measure in the middle do what their + /// `shows` says. + #[test] + fn the_measuring_examples_show_what_they_say() { + let ex = examples(); + let job = |name: &str| ex.as_array().unwrap().iter().find(|e| e["name"] == name).unwrap()["job"].clone(); + let t = go(job("teleport")); + assert_eq!(t["method"], "statevector", "{t}"); + assert!((t["expectations"][0]["value"].as_f64().unwrap() - 0.7f64.cos()).abs() < 1e-12, "{t}"); + assert!((t["expectations"][1]["value"].as_f64().unwrap() - 0.7f64.sin()).abs() < 1e-12, "{t}"); + assert_eq!(t["dynamic"]["branches_weighed"], 4, "{t}"); + let b = go(job("bit-flip-code")); + assert_eq!(b["method"], "stabilizer", "{b}"); + assert_eq!(b["expectations"][0]["value"], 1.0, "{b}"); + let counts = b["counts"].as_object().unwrap(); + assert!(counts.keys().all(|k| k.ends_with("11")), "the syndrome names the middle qubit on every shot: {b}"); + assert_eq!(counts.values().map(|v| v.as_u64().unwrap()).sum::(), 1000); + } + /// Every example plans, with the method its description promises. #[test] diff --git a/wai/wai-quantum-service/src/main.rs b/wai/wai-quantum-service/src/main.rs index ff66b6b42..7012d4eab 100644 --- a/wai/wai-quantum-service/src/main.rs +++ b/wai/wai-quantum-service/src/main.rs @@ -13,16 +13,13 @@ //! wai-quantum-service run FILE one job from a JSON file, here, printed //! ``` -mod catalog; mod discovery; -mod dynamic; mod energy; mod http; mod jobs; mod mcp; -use wai_quantum::qasm; +use wai_quantum::engine::{catalog, run}; mod receipt; -mod run; use std::io::Read; use std::net::{IpAddr, SocketAddr}; diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index fe3c47e0c..f5d79c456 100644 --- a/wai/wai-quantum/Cargo.lock +++ b/wai/wai-quantum/Cargo.lock @@ -1090,6 +1090,7 @@ dependencies = [ "objc2-foundation", "objc2-metal", "opencl3", + "serde", "serde_json", "wgpu", ] diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index c9cd4a6b9..93a881c5a 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -26,6 +26,8 @@ all-features = true blake3 = "1" # Canonical receipt JSON (sorted keys, byte-stable round-trip). serde_json = { version = "1", features = ["preserve_order"] } +# The engine's request and plan types (feature quantum_engine). +serde = { version = "1", features = ["derive"], optional = true } # Receipt signatures. Optional — only the sealing features need it. ed25519-dalek = { version = "2", optional = true } @@ -84,6 +86,11 @@ quantum_cutrank = ["quantum_tableau", "quantum_sv"] # lowered to each method's input (src/qasm.rs). quantum_qasm = ["quantum", "quantum_sv", "quantum_spd", "quantum_tableau"] quantum_mpsim = ["quantum_sv", "quantum_mps"] +# The job service's engine: a request planned onto a method, run, and answered +# as the service's JSON, for every host that runs jobs (src/engine/). Numbers +# are read correctly rounded, so a result a client sends back reads as the +# double it was. +quantum_engine = ["quantum_qasm", "quantum_tn", "quantum_cutrank", "quantum_mpsim", "quantum_noisy", "dep:serde", "serde_json/float_roundtrip"] quantum_pauli = ["quantum"] # Sparse Pauli dynamics at utility scale: arbitrary-angle Pauli rotations on up # to 1024 qubits, reproducible bit for bit, with a rigorous truncation bound. @@ -209,7 +216,7 @@ quantum_phasor_meter = ["_signing"] # Everything. full = [ - "quantum", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_tableau", "quantum_cutrank", "quantum_qasm", "quantum_mpsim", "quantum_pauli", "quantum_spd", "quantum_spd_receipt", "quantum_bptn", "quantum_resource", "quantum_resource_receipt", "quantum_frame", "quantum_tn", "quantum_gbs", "quantum_sv", "quantum_arch", "quantum_ldpc", "quantum_qio", "quantum_uf", "quantum_noisy", "quantum_dmrg", "quantum_tdvp", "quantum_chem", "quantum_nqs", "quantum_qir", "quantum_info", "quantum_qudit", "quantum_comm", "quantum_source", "quantum_channel", + "quantum", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_tableau", "quantum_cutrank", "quantum_qasm", "quantum_engine", "quantum_mpsim", "quantum_pauli", "quantum_spd", "quantum_spd_receipt", "quantum_bptn", "quantum_resource", "quantum_resource_receipt", "quantum_frame", "quantum_tn", "quantum_gbs", "quantum_sv", "quantum_arch", "quantum_ldpc", "quantum_qio", "quantum_uf", "quantum_noisy", "quantum_dmrg", "quantum_tdvp", "quantum_chem", "quantum_nqs", "quantum_qir", "quantum_info", "quantum_qudit", "quantum_comm", "quantum_source", "quantum_channel", "quantum_receipt", "quantum_ops", "quantum_cal", "quantum_mitigate", "quantum_qec", "quantum_control", "quantum_noise", "quantum_compile", "quantum_atom", "quantum_qbom", "quantum_fabric", "quantum_vml", "quantum_phasor", "quantum_qfhrr", "quantum_kernel", "quantum_qdata", diff --git a/wai/wai-quantum-service/src/catalog.rs b/wai/wai-quantum/src/engine/catalog.rs similarity index 93% rename from wai/wai-quantum-service/src/catalog.rs rename to wai/wai-quantum/src/engine/catalog.rs index 5a0a10e2e..98b63811c 100644 --- a/wai/wai-quantum-service/src/catalog.rs +++ b/wai/wai-quantum/src/engine/catalog.rs @@ -36,14 +36,14 @@ pub const MAX_AMPLITUDES: usize = 64; pub const MAX_OUTCOMES: usize = 65_536; pub const EXACT_MAX_QUBITS: u32 = 26; -pub const STABILIZER_MAX_QUBITS: u32 = wai_quantum::quantum_tableau::MAX_QUBITS; +pub const STABILIZER_MAX_QUBITS: u32 = crate::quantum_tableau::MAX_QUBITS; pub const PAULI_MAX_QUBITS: u32 = 1024; -pub const DENSITY_MAX_QUBITS: u32 = wai_quantum::quantum_noisy::MAX_EVOLVE_QUBITS; +pub const DENSITY_MAX_QUBITS: u32 = crate::quantum_noisy::MAX_EVOLVE_QUBITS; /// The most a density-matrix job may do: its gates times the `4ⁿ` entries /// of `ρ`, a few minutes of passes. pub const DENSITY_MAX_WORK: u64 = 1_000_000_000_000; pub const TRAJECTORY_MAX_QUBITS: u32 = 32; -pub const MPS_MAX_QUBITS: u32 = wai_quantum::quantum_mpsim::MAX_QUBITS; +pub const MPS_MAX_QUBITS: u32 = crate::quantum_mpsim::MAX_QUBITS; /// The bond dimension an `mps` job gets unless it asks, and the most it may. pub const MPS_DEFAULT_BOND: usize = 64; pub const MPS_MAX_BOND: usize = 256; @@ -131,11 +131,25 @@ pub fn density_max_qubits(job_bytes: u64) -> u32 { } /// The most qubits the GPU's largest buffer holds, asked of the device once. +/// A build without the GPU state vector (feature `quantum_sv_metal`) has none. pub fn gpu_max_qubits() -> Result { - static MAX: std::sync::OnceLock> = std::sync::OnceLock::new(); - MAX.get_or_init(|| wai_quantum::quantum_sv_metal::GpuState::max_qubits().map_err(|e| e.to_string())).clone() + #[cfg(feature = "quantum_sv_metal")] + { + static MAX: std::sync::OnceLock> = std::sync::OnceLock::new(); + MAX.get_or_init(|| crate::quantum_sv_metal::GpuState::max_qubits().map_err(|e| e.to_string())).clone() + } + #[cfg(not(feature = "quantum_sv_metal"))] + { + Err("this build has no GPU state vector".into()) + } } +/// The widest gate the GPU state vector applies. +#[cfg(feature = "quantum_sv_metal")] +const SINGLE_MAX_WIDTH: usize = crate::quantum_sv_metal::MAX_WIDTH; +#[cfg(not(feature = "quantum_sv_metal"))] +const SINGLE_MAX_WIDTH: usize = 4; + /// The most qubits the single-precision state vector holds in `job_bytes`. pub fn single_max_qubits(job_bytes: u64) -> u32 { (3..=40u32).take_while(|&n| (8u64 << n) + 256 * MIB <= job_bytes).last().unwrap_or(0) @@ -143,7 +157,7 @@ pub fn single_max_qubits(job_bytes: u64) -> u32 { /// The most qubits the dense state vector holds in `job_bytes`. pub fn statevector_max_qubits(job_bytes: u64) -> u32 { - (1..=wai_quantum::quantum_sv::MAX_QUBITS).rev().find(|&n| statevector_bytes(n) <= job_bytes).unwrap_or(0) + (1..=crate::quantum_sv::MAX_QUBITS).rev().find(|&n| statevector_bytes(n) <= job_bytes).unwrap_or(0) } /// An observable as asked: its text, its coefficient, and its Pauli factors @@ -158,7 +172,7 @@ pub struct Request { pub seed: u64, pub observables: Vec, pub amplitudes: Vec, - pub noise: Option, + pub noise: Option, pub options: Value, } @@ -192,12 +206,12 @@ fn observable(v: &Value, n: u32) -> Result { Ok((text, coeff, string)) } -fn noise(v: &Value) -> Result { +fn noise(v: &Value) -> Result { let f = |k: &str| -> Result { let x = v.get(k).and_then(Value::as_f64).unwrap_or(0.0); if (0.0..=1.0).contains(&x) { Ok(x) } else { Err(format!("noise {k} is a probability, 0 to 1")) } }; - Ok(wai_quantum::quantum_noisy::NoiseModel { + Ok(crate::quantum_noisy::NoiseModel { depol1: f("depolarizing_1q")?, depol2: f("depolarizing_2q")?, damping: f("amplitude_damping")?, @@ -284,9 +298,9 @@ pub fn mps_work(r: &Request, chi: u64) -> u64 { /// memory and work; the run (`thorough`) makes several longer ones and keeps /// the cheapest order, which is never costlier than the planner's. Both are /// deterministic. -pub fn product_measured(r: &Request, thorough: bool) -> Result { - use wai_quantum::quantum_cutrank::ProductMeasured; - use wai_quantum::quantum_tableau::Tableau; +pub fn product_measured(r: &Request, thorough: bool) -> Result { + use crate::quantum_cutrank::ProductMeasured; + use crate::quantum_tableau::Tableau; let n = r.program.n_qubits; if n > CUTRANK_MAX_QUBITS { return Err(format!("cutrank holds at most {CUTRANK_MAX_QUBITS} qubits")); @@ -344,9 +358,9 @@ pub const CUTRANK_PHASE_OPS: u64 = 50_000_000_000; /// identity in place of the closing gates. The lowering of the gates to /// Clifford ones drops a phase `e^{iΘ}` ([`qasm::Program::product_measured`]). /// The factor is `e^{iΘ} a / b`, of modulus 1. -pub fn cutrank_phase(r: &Request, pm: &wai_quantum::quantum_cutrank::ProductMeasured, cut: usize, precision: wai_quantum::quantum_cutrank::Precision) -> Result, String> { - use wai_quantum::gates::complex; - use wai_quantum::quantum_tableau::{Clifford, PhasedTableau}; +pub fn cutrank_phase(r: &Request, pm: &crate::quantum_cutrank::ProductMeasured, cut: usize, precision: crate::quantum_cutrank::Precision) -> Result, String> { + use crate::gates::complex; + use crate::quantum_tableau::{Clifford, PhasedTableau}; let n = r.program.n_qubits as u64; let (bulk, _, theta) = r.program.product_measured()?; let hadamards = bulk.iter().map(|g| match g { @@ -367,7 +381,7 @@ pub fn cutrank_phase(r: &Request, pm: &wai_quantum::quantum_cutrank::ProductMeas let ratio = complex((are * b.re + aim * b.im) / bb, (aim * b.re - are * b.im) / bb); let k = complex(theta.re * ratio.re - theta.im * ratio.im, theta.re * ratio.im + theta.im * ratio.re); let off = (k.re.hypot(k.im) - 1.0).abs(); - let tolerance = if precision == wai_quantum::quantum_cutrank::Precision::Double { 1e-9 } else { 1e-5 }; + let tolerance = if precision == crate::quantum_cutrank::Precision::Double { 1e-9 } else { 1e-5 }; if off > tolerance { return Err(format!("internal: the tracked and summed amplitudes differ in modulus by {off:e}")); } @@ -376,7 +390,7 @@ pub fn cutrank_phase(r: &Request, pm: &wai_quantum::quantum_cutrank::ProductMeas /// The memory a `cutrank` job takes: its largest table with a margin, the /// graph form, and room to run. -pub fn cutrank_bytes(pm: &wai_quantum::quantum_cutrank::ProductMeasured, precision: wai_quantum::quantum_cutrank::Precision) -> u64 { +pub fn cutrank_bytes(pm: &crate::quantum_cutrank::ProductMeasured, precision: crate::quantum_cutrank::Precision) -> u64 { let n = pm.qubits() as u64; let table = pm.table_bytes(precision); table + table / 16 + n * n.div_ceil(64) * 8 * 4 + 256 * MIB @@ -384,13 +398,13 @@ pub fn cutrank_bytes(pm: &wai_quantum::quantum_cutrank::ProductMeasured, precisi /// Whether `method` can take the job, and the plan if so; otherwise why not. /// A program that measures in the middle, resets after a qubit's first gate, -/// or acts on measured bits ([`crate::dynamic`]): the dense state vector and +/// or acts on measured bits ([`crate::engine::dynamic`]): the dense state vector and /// the stabilizer tableau run it, splitting its shots at each measurement and /// keeping a copy of the state for each branch that runs on. -fn try_dynamic(method: &str, r: &Request, clifford: &Result, String>, cap: u64, steps: &[crate::qasm::Instr]) -> Result { +fn try_dynamic(method: &str, r: &Request, clifford: &Result, String>, cap: u64, steps: &[crate::qasm::Instr]) -> Result { let n = r.program.n_qubits; let gates = r.program.ops.len() as u64; - let measured = crate::dynamic::measurements(steps); + let measured = crate::engine::dynamic::measurements(steps); if r.noise.is_some() { return Err("noise in a program that measures in the middle, resets or acts on measured bits is not simulated yet".into()); } @@ -401,7 +415,7 @@ fn try_dynamic(method: &str, r: &Request, clifford: &Result { let one = statevector_bytes(n); - if n > wai_quantum::quantum_sv::MAX_QUBITS || 2 * one > cap { + if n > crate::quantum_sv::MAX_QUBITS || 2 * one > cap { return Err(format!("statevector runs a program that measures in the middle on at most {} qubits: a branch holds a second state", statevector_max_qubits(cap / 2))); } let states = (cap / one).clamp(2, 16); @@ -430,7 +444,7 @@ fn try_dynamic(method: &str, r: &Request, clifford: &Result, clifford: &Result, String>, cap: u64) -> Result { +fn try_method(method: &str, r: &Request, dyadic: &Result, clifford: &Result, String>, cap: u64) -> Result { let n = r.program.n_qubits; let gates = r.program.ops.len() as u64; let plan = |memory_bytes: u64, work: u64, why: String| Plan { method: method.into(), why, memory_bytes, work, qubits: n }; @@ -456,7 +470,7 @@ fn try_method(method: &str, r: &Request, dyadic: &Result { let bytes = statevector_bytes(n); - if bytes > cap || n > wai_quantum::quantum_sv::MAX_QUBITS { + if bytes > cap || n > crate::quantum_sv::MAX_QUBITS { return Err(format!("statevector holds at most {} qubits in a job's memory", statevector_max_qubits(cap))); } Ok(plan(bytes + r.shots * 24, gates << n, format!("dense double-precision state vector of {n} qubits"))) @@ -467,7 +481,7 @@ fn try_method(method: &str, r: &Request, dyadic: &Result max || bytes > cap { return Err(format!("statevector_single holds at most {} qubits on this GPU in a job's memory", max.min(single_max_qubits(cap)))); } - if r.program.ops.iter().any(|o| o.qubits.len() > wai_quantum::quantum_sv_metal::MAX_WIDTH) { + if r.program.ops.iter().any(|o| o.qubits.len() > SINGLE_MAX_WIDTH) { return Err("statevector_single takes gates on at most four qubits".into()); } Ok(plan(bytes + r.shots * 24, gates << n, format!("dense single-precision state vector of {n} qubits on the GPU"))) @@ -535,7 +549,7 @@ fn try_method(method: &str, r: &Request, dyadic: &Result { - use wai_quantum::quantum_cutrank::Precision; + use crate::quantum_cutrank::Precision; if wants_expectations { return Err("cutrank answers counts and amplitudes".into()); } diff --git a/wai/wai-quantum-service/src/dynamic.rs b/wai/wai-quantum/src/engine/dynamic.rs similarity index 99% rename from wai/wai-quantum-service/src/dynamic.rs rename to wai/wai-quantum/src/engine/dynamic.rs index 2de9314e5..15acdaa50 100644 --- a/wai/wai-quantum-service/src/dynamic.rs +++ b/wai/wai-quantum/src/engine/dynamic.rs @@ -19,9 +19,9 @@ use crate::qasm::{Instr, Op}; use std::collections::HashMap; -use wai_quantum::gates::Gate as Matrix; -use wai_quantum::quantum_sv::StateVector; -use wai_quantum::quantum_tableau::{Clifford, Tableau}; +use crate::gates::Gate as Matrix; +use crate::quantum_sv::StateVector; +use crate::quantum_tableau::{Clifford, Tableau}; /// The most branches the exact expectation walk follows. pub const MAX_LEAVES: u64 = 4096; diff --git a/wai/wai-quantum/src/engine/mod.rs b/wai/wai-quantum/src/engine/mod.rs new file mode 100644 index 000000000..d532eed8f --- /dev/null +++ b/wai/wai-quantum/src/engine/mod.rs @@ -0,0 +1,21 @@ +//! The job service's engine: a program and what is asked of it, planned onto a +//! method, run, and answered as JSON. The service, the command line and the +//! desktop app plan and run a job the same way, so the same job gives the same +//! result on each. +//! +//! - [`catalog`]: a job read and checked ([`catalog::Request`]), the methods +//! and their limits, and [`catalog::plan`], which picks a method for a job +//! and says why, with the memory and work it will take. +//! - [`run`]: [`run::run`] carries a plan out and gives the result: counts, +//! expectation values, amplitudes, the accuracy class and what the method +//! did. +//! - [`dynamic`]: programs that measure in the middle, reset or branch on a +//! measured bit, run branch by branch. +//! +//! Requests and results are the service's JSON (`/v1/openapi.json` there). +//! Signing a result, metering its energy, queueing and limits per client stay +//! with whatever hosts the engine. + +pub mod catalog; +pub mod dynamic; +pub mod run; diff --git a/wai/wai-quantum-service/src/run.rs b/wai/wai-quantum/src/engine/run.rs similarity index 95% rename from wai/wai-quantum-service/src/run.rs rename to wai/wai-quantum/src/engine/run.rs index d87ce1194..823cc3572 100644 --- a/wai/wai-quantum-service/src/run.rs +++ b/wai/wai-quantum/src/engine/run.rs @@ -2,7 +2,7 @@ //! terms. Bit strings are the program's classical bits, bit `n_bits − 1` //! first; an amplitude's bit string names every qubit, qubit `n − 1` first. -use crate::catalog::{self, MAX_OUTCOMES, Plan, Request}; +use crate::engine::catalog::{self, MAX_OUTCOMES, Plan, Request}; use serde_json::{Value, json}; use std::collections::HashMap; use std::time::Instant; @@ -47,7 +47,7 @@ fn index_of(bits: &str, n: u32, pos: impl Fn(u32) -> u32) -> usize { /// `⟨ψ|P|ψ⟩` on the bit-exact state, exactly: the fixed-point amplitudes /// multiplied and summed in 128-bit integers, then scaled once. -fn expect_fixed(amps: &[wai_quantum::quantum::Amp], string: &[(u32, char)]) -> f64 { +fn expect_fixed(amps: &[crate::quantum::Amp], string: &[(u32, char)]) -> f64 { let (mut flip, mut zmask, mut ys) = (0usize, 0usize, 0u32); for &(q, p) in string { let bit = 1usize << q; @@ -132,8 +132,11 @@ pub fn run(request: &Value, plan: &Plan) -> Result { ); } } + #[cfg(not(feature = "quantum_sv_metal"))] + "statevector_single" => return Err("this build has no GPU state vector".into()), + #[cfg(feature = "quantum_sv_metal")] "statevector_single" => { - use wai_quantum::quantum_sv_metal::GpuState; + use crate::quantum_sv_metal::GpuState; let gates = r.program.matrices(); // Wider fused gates run slower on the GPU than the passes they save. let width = r.options.get("fusion").and_then(Value::as_u64).unwrap_or(3).clamp(1, 3) as usize; @@ -165,7 +168,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { "statevector" => { let gates = r.program.matrices(); let width = r.options.get("fusion").and_then(Value::as_u64).unwrap_or(3).clamp(1, 5) as usize; - let mut sv = wai_quantum::quantum_sv::StateVector::zero(n).map_err(|e| format!("{e:?}"))?; + let mut sv = crate::quantum_sv::StateVector::zero(n).map_err(|e| format!("{e:?}"))?; sv.run(&gates, width).map_err(|e| format!("{e:?}"))?; out["accuracy"] = json!({ "class": "exact", "fusion": width }); if r.shots > 0 { @@ -191,7 +194,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } } "stabilizer" if r.noise.is_some() => { - use wai_quantum::quantum_tableau::{FrameStep, frame_counts, frame_expectations}; + use crate::quantum_tableau::{FrameStep, frame_counts, frame_expectations}; let noise = r.noise.ok_or("no noise model")?; // Each op's gates, then its noise on the qubits it touched, as the // other noisy methods apply it. @@ -245,7 +248,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } } "stabilizer" => { - use wai_quantum::quantum_tableau::Tableau; + use crate::quantum_tableau::Tableau; let t = Tableau::run(n, &r.program.clifford()?).map_err(|e| e.to_string())?; out["accuracy"] = json!({ "class": "exact" }); if r.shots > 0 { @@ -276,7 +279,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } } "pauli" => { - use wai_quantum::quantum_spd::{Pauli, SpdConfig, expectation_capped}; + use crate::quantum_spd::{Pauli, SpdConfig, expectation_capped}; let rc = r.program.rotations(); // As many terms as the job's memory holds: a term's string (two // words of bits a 64 qubits, padded to a power of two), its @@ -323,7 +326,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { out["expectations"] = Value::Array(entries); } "mps" => { - use wai_quantum::quantum_mpsim::{Chain, MpsConfig}; + use crate::quantum_mpsim::{Chain, MpsConfig}; let chi = catalog::mps_bond(&r); let cutoff = r.options.get("cutoff").and_then(Value::as_f64).unwrap_or(1e-12).clamp(0.0, 1e-2); let mut chain = Chain::run(n, &r.program.local_matrices(), MpsConfig { max_bond: chi, cutoff }).map_err(|e| e.to_string())?; @@ -362,7 +365,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } } "cutrank" => { - use wai_quantum::quantum_cutrank::Precision; + use crate::quantum_cutrank::Precision; // An order at least as cheap as the planner's (searched longer, from // several seeds), and the precision the admitted memory allows. let pm = catalog::product_measured(&r, true)?; @@ -403,7 +406,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } } "tensor_network" => { - use wai_quantum::quantum_tn::{Network, PathOptions, contract_plan, find_path}; + use crate::quantum_tn::{Network, PathOptions, contract_plan, find_path}; let gates = r.program.matrices(); out["accuracy"] = json!({ "class": "exact" }); let mut entries = Vec::new(); @@ -417,14 +420,14 @@ pub fn run(request: &Value, plan: &Plan) -> Result { let plan = find_path(&net.shape(), &net.dims, &net.open, &PathOptions::default()); flops += plan.total_flops(); let t = contract_plan(&net, &plan).map_err(|e| format!("{e:?}"))?; - let a = t.data.first().copied().unwrap_or(wai_quantum::gates::complex(0.0, 0.0)); + let a = t.data.first().copied().unwrap_or(crate::gates::complex(0.0, 0.0)); entries.push(json!({ "bits": b, "re": a.re, "im": a.im })); } out["amplitudes"] = Value::Array(entries); out["contraction_flops"] = json!(flops); } "density_matrix" => { - use wai_quantum::quantum_noisy::DensityMatrix; + use crate::quantum_noisy::DensityMatrix; let noise = r.noise.ok_or("no noise model")?; let rho = DensityMatrix::evolve(&r.program.matrices(), n, &noise).map_err(|e| format!("{e:?}"))?; out["accuracy"] = json!({ "class": "exact", "note": "the noisy state itself, under the model; counts are seeded samples of its distribution" }); @@ -441,13 +444,13 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } } "trajectories" => { - use wai_quantum::quantum_noisy::branching_trajectories; + use crate::quantum_noisy::branching_trajectories; let noise = r.noise.ok_or("no noise model")?; let count = catalog::trajectory_count(&r); // The plan reserved memory for this many states. let states = ((plan.memory_bytes.saturating_sub(256 << 20)) / (catalog::amplitude_bytes(n) * if n < 16 { 8 } else { 1 }).max(1)).clamp(1, 16) as usize; let threads = std::thread::available_parallelism().map_or(1, |t| t.get()).min(8); - let observables: Vec = r.observables.iter().map(|(_, c, s)| vec![(*c, s.clone())]).collect(); + let observables: Vec = r.observables.iter().map(|(_, c, s)| vec![(*c, s.clone())]).collect(); let run = branching_trajectories(&r.program.matrices(), n, &noise, &observables, count, r.seed, states, threads).map_err(|e| format!("{e:?}"))?; out["accuracy"] = json!({ "class": "estimate", @@ -481,24 +484,24 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } /// A program that measures in the middle, resets, or acts on measured bits -/// ([`crate::dynamic`]): counts by shot splitting, expectation values as the +/// ([`crate::engine::dynamic`]): counts by shot splitting, expectation values as the /// exact mixture over its branches. fn run_dynamic(r: &catalog::Request, plan: &Plan, steps: &[crate::qasm::Instr], out: &mut Value) -> Result<(), String> { - use crate::dynamic::{self, Dense, Stabilizer}; + use crate::engine::dynamic::{self, Dense, Stabilizer}; let (n, nb) = (r.program.n_qubits, r.program.n_bits); let strings: Vec> = r.observables.iter().map(|(_, _, s)| s.clone()).collect(); let (sampled, exact) = match plan.method.as_str() { "statevector" => { let gates = r.program.matrices(); let states = (plan.memory_bytes / catalog::statevector_bytes(n)).max(1) as usize; - let start = Dense { sv: wai_quantum::quantum_sv::StateVector::zero(n).map_err(|e| format!("{e:?}"))?, gates: &gates }; + let start = Dense { sv: crate::quantum_sv::StateVector::zero(n).map_err(|e| format!("{e:?}"))?, gates: &gates }; let s = if r.shots > 0 { Some(dynamic::sample(steps, nb, start.clone(), r.shots, r.seed, states)?) } else { None }; let e = if strings.is_empty() { None } else { Some(dynamic::expectations(steps, &r.program.ops, nb, start, &strings, states)?) }; (s, e) } "stabilizer" => { - let ops: Vec> = r.program.clifford_ops()?.into_iter().map(|(g, _)| g).collect(); - let start = Stabilizer { t: wai_quantum::quantum_tableau::Tableau::new(n).map_err(|e| e.to_string())?, ops: &ops }; + let ops: Vec> = r.program.clifford_ops()?.into_iter().map(|(g, _)| g).collect(); + let start = Stabilizer { t: crate::quantum_tableau::Tableau::new(n).map_err(|e| e.to_string())?, ops: &ops }; let s = if r.shots > 0 { Some(dynamic::sample(steps, nb, start.clone(), r.shots, r.seed, 64)?) } else { None }; let e = if strings.is_empty() { None } else { Some(dynamic::expectations(steps, &r.program.ops, nb, start, &strings, 64)?) }; (s, e) @@ -567,7 +570,7 @@ fn sample_distribution(p: &[f64], shots: u64, seed: u64) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::catalog::plan; + use crate::engine::catalog::plan; const CAP: u64 = 8 << 30; @@ -577,12 +580,13 @@ mod tests { } #[test] + #[cfg(feature = "quantum_toolchain")] fn grover_as_the_ide_exports_it_finds_the_marked_item() { // Its oracle and its diffusion are each a Z under four controls, // written `ctrl(4) @ z`. Four rounds on five qubits find the marked // item with probability 0.9992. let marked = 0b10110u32; - let qasm = wai_quantum::quantum_toolchain::to_qasm(&wai_quantum::quantum_toolchain::grover(5, marked)); + let qasm = crate::quantum_toolchain::to_qasm(&crate::quantum_toolchain::grover(5, marked)); assert!(qasm.contains("ctrl(4) @ z"), "{qasm}"); let key: String = (0..5).rev().map(|q| if marked >> q & 1 == 1 { '1' } else { '0' }).collect(); for method in ["exact", "statevector"] { @@ -707,7 +711,7 @@ mod tests { fn the_gpu_state_vector_agrees_where_there_is_one_and_is_refused_where_not() { let qasm = "qubit[14] q; bit[14] c; h q; rx(0.3) q[0]; cx q[0], q[13]; crz(0.7) q[3], q[9]; ry(1.1) q[5]; c = measure q;"; let job = json!({ "qasm": qasm, "method": "statevector_single", "shots": 2000, "observables": ["Z0 Z13", "X5"], "amplitudes": ["00000000000000"] }); - match crate::catalog::gpu_max_qubits() { + match crate::engine::catalog::gpu_max_qubits() { Ok(_) => { let g = go(job); let d = go(json!({ "qasm": qasm, "method": "statevector", "observables": ["Z0 Z13", "X5"], "amplitudes": ["00000000000000"] })); @@ -908,25 +912,6 @@ mod tests { } } - /// The two worked examples that measure in the middle do what their - /// `shows` says. - #[test] - fn the_measuring_examples_show_what_they_say() { - let ex = crate::discovery::examples(); - let job = |name: &str| ex.as_array().unwrap().iter().find(|e| e["name"] == name).unwrap()["job"].clone(); - let t = go(job("teleport")); - assert_eq!(t["method"], "statevector", "{t}"); - assert!((t["expectations"][0]["value"].as_f64().unwrap() - 0.7f64.cos()).abs() < 1e-12, "{t}"); - assert!((t["expectations"][1]["value"].as_f64().unwrap() - 0.7f64.sin()).abs() < 1e-12, "{t}"); - assert_eq!(t["dynamic"]["branches_weighed"], 4, "{t}"); - let b = go(job("bit-flip-code")); - assert_eq!(b["method"], "stabilizer", "{b}"); - assert_eq!(b["expectations"][0]["value"], 1.0, "{b}"); - let counts = b["counts"].as_object().unwrap(); - assert!(counts.keys().all(|k| k.ends_with("11")), "the syndrome names the middle qubit on every shot: {b}"); - assert_eq!(counts.values().map(|v| v.as_u64().unwrap()).sum::(), 1000); - } - /// A Clifford circuit on `n` qubits (Hadamards, phases, controlled-Z and /// CX between pseudo-random pairs), then one layer of one-qubit gates at /// generic angles on every qubit. diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index f22defbd7..8a2933c51 100644 --- a/wai/wai-quantum/src/lib.rs +++ b/wai/wai-quantum/src/lib.rs @@ -52,6 +52,10 @@ pub mod gates; #[cfg(feature = "quantum_qasm")] pub mod qasm; +// The job service's engine: plan, run and answer a job, the same on every host. +#[cfg(feature = "quantum_engine")] +pub mod engine; + #[cfg(any( feature = "quantum_spd", feature = "quantum_pauli", -- 2.54.0 (Apple Git-157) From 0473947cb73c8ae032b6c3fc8de0a92806b85500 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:51:03 -0400 Subject: [PATCH 6/7] wai-quantum engine: one receipt form for every host; an unknown noise channel refused; did_key - engine::receipt is the service's receipt form, moved: a result sealed over the request and the result in their RFC 8785 canonical text, and checked. The service keeps its key file and seals with it; the command line seals with the user's key in the same form, and one verify checks both. The first receipts' form still verifies. - A job's noise naming a channel the model does not know is refused with the channels it knows (catalog::NOISE_CHANNELS). It was read as no noise, so a misspelt channel ran the circuit noiseless. - quantum_receipt::did_key: an Ed25519 key's did:key, held to the W3C method's example, for signers that depend on the library alone. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum-service/Cargo.lock | 1 - wai/wai-quantum-service/Cargo.toml | 1 - wai/wai-quantum-service/src/receipt.rs | 386 +---------------- wai/wai-quantum/src/engine/catalog.rs | 20 + wai/wai-quantum/src/engine/mod.rs | 10 +- wai/wai-quantum/src/engine/receipt.rs | 397 ++++++++++++++++++ .../engine}/testdata/es-number-tostring.txt | 0 wai/wai-quantum/src/quantum_receipt.rs | 40 ++ 8 files changed, 474 insertions(+), 381 deletions(-) create mode 100644 wai/wai-quantum/src/engine/receipt.rs rename wai/{wai-quantum-service => wai-quantum/src/engine}/testdata/es-number-tostring.txt (100%) diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index 96aebfd27..50f7cb9d4 100644 --- a/wai/wai-quantum-service/Cargo.lock +++ b/wai/wai-quantum-service/Cargo.lock @@ -873,7 +873,6 @@ name = "wai-quantum-service" version = "0.1.0" dependencies = [ "axum", - "blake3", "getrandom 0.4.3", "libc", "serde", diff --git a/wai/wai-quantum-service/Cargo.toml b/wai/wai-quantum-service/Cargo.toml index c90482908..d07deb7c2 100644 --- a/wai/wai-quantum-service/Cargo.toml +++ b/wai/wai-quantum-service/Cargo.toml @@ -15,7 +15,6 @@ serde = { version = "1", features = ["derive"] } # float_roundtrip: a number a client sends back reads as the double it was # (the default reader rounds twice), so its receipt still verifies. serde_json = { version = "1", features = ["float_roundtrip"] } -blake3 = "1" getrandom = "0.4" wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt", "quantum_sv_metal"] } diff --git a/wai/wai-quantum-service/src/receipt.rs b/wai/wai-quantum-service/src/receipt.rs index c6a36518c..e782d4f1a 100644 --- a/wai/wai-quantum-service/src/receipt.rs +++ b/wai/wai-quantum-service/src/receipt.rs @@ -1,269 +1,18 @@ -//! Signed receipts: every finished job's result is sealed with the service's -//! Ed25519 key, binding the request as submitted and the result as returned, -//! so a client can show anyone what was run and what came back. -//! -//! What is signed is the JSON Canonicalization Scheme (RFC 8785) text of each: -//! keys sorted, no whitespace, and every number written as ECMAScript writes -//! the double it reads. A result therefore verifies after any JSON reader and -//! writer has carried it: a browser's `JSON.parse` and `JSON.stringify` turn -//! `1.0` into `1`, and other readers reorder keys. +//! The service's signing key. The receipt form and its check are the +//! engine's ([`wai_quantum::engine::receipt`]): every finished job's result is +//! sealed with this key. -use serde_json::{Value, json}; +pub use wai_quantum::engine::receipt::{inexact_number, verify}; +use serde_json::Value; use std::path::{Path, PathBuf}; -use wai_quantum::ed25519_dalek::{Signer as _, SigningKey, Verifier as _, VerifyingKey}; - -/// The first receipts signed serde_json's own text, which only a reader that -/// keeps each number's spelling and each object's key order can reproduce. -/// They still verify; new ones are canonical. -const DOMAIN_V1: &[u8] = b"wai:quantum-service-receipt\x01"; -const DOMAIN: &[u8] = b"wai:quantum-service-receipt\x02"; -/// The receipt's name for its canonical form. -const CANON: &str = "rfc8785"; +use wai_quantum::ed25519_dalek::SigningKey; +use wai_quantum::engine::receipt; /// The service's signing key. pub struct Signer { key: SigningKey, } -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -fn unhex(s: &str) -> Option> { - s.len().is_multiple_of(2).then_some(())?; - (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() -} - -/// A length-prefixed byte string, so that no field reads as part of another. -fn put(o: &mut Vec, bytes: &[u8]) { - o.extend_from_slice(&(bytes.len() as u64).to_be_bytes()); - o.extend_from_slice(bytes); -} - -/// The digest a receipt signs: BLAKE3(domain ‖ put(request) ‖ put(result)), -/// each in its RFC 8785 canonical text. -pub fn digest(request: &Value, result: &Value) -> [u8; 32] { - let mut o = DOMAIN.to_vec(); - put(&mut o, &canonical(request)); - put(&mut o, &canonical(result)); - *blake3::hash(&o).as_bytes() -} - -/// The digest the first receipts signed (see [`DOMAIN_V1`]). -fn digest_v1(request: &Value, result: &Value) -> [u8; 32] { - let mut o = DOMAIN_V1.to_vec(); - put(&mut o, request.to_string().as_bytes()); - put(&mut o, result.to_string().as_bytes()); - *blake3::hash(&o).as_bytes() -} - -/// The RFC 8785 canonical text of `v`. -pub fn canonical(v: &Value) -> Vec { - let mut o = Vec::new(); - write(v, &mut o); - o -} - -fn write(v: &Value, o: &mut Vec) { - match v { - Value::Null => o.extend_from_slice(b"null"), - Value::Bool(b) => o.extend_from_slice(if *b { b"true" } else { b"false" }), - // Every number as the double a JSON reader in ECMAScript holds. - Value::Number(n) => o.extend_from_slice(es_number(n.as_f64().unwrap_or(f64::NAN)).as_bytes()), - Value::String(s) => write_str(s, o), - Value::Array(a) => { - o.push(b'['); - for (i, x) in a.iter().enumerate() { - if i > 0 { - o.push(b','); - } - write(x, o); - } - o.push(b']'); - } - Value::Object(m) => { - // Keys in the order of their UTF-16 code units. - let mut keys: Vec<&String> = m.keys().collect(); - keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); - o.push(b'{'); - for (i, k) in keys.into_iter().enumerate() { - if i > 0 { - o.push(b','); - } - write_str(k, o); - o.push(b':'); - write(&m[k], o); - } - o.push(b'}'); - } - } -} - -/// A string as RFC 8785 writes it: `"` and `\\` escaped, the five short -/// control escapes, every other control character as `\u00xx`, and the rest -/// as it is. -fn write_str(s: &str, o: &mut Vec) { - o.push(b'"'); - for c in s.chars() { - match c { - '"' => o.extend_from_slice(b"\\\""), - '\\' => o.extend_from_slice(b"\\\\"), - '\u{8}' => o.extend_from_slice(b"\\b"), - '\t' => o.extend_from_slice(b"\\t"), - '\n' => o.extend_from_slice(b"\\n"), - '\u{c}' => o.extend_from_slice(b"\\f"), - '\r' => o.extend_from_slice(b"\\r"), - c if (c as u32) < 0x20 => o.extend_from_slice(format!("\\u{:04x}", c as u32).as_bytes()), - c => { - let mut b = [0u8; 4]; - o.extend_from_slice(c.encode_utf8(&mut b).as_bytes()); - } - } - } - o.push(b'"'); -} - -/// A double as ECMAScript's Number::toString writes it (ECMA-262, 6.1.6.1.20): -/// the shortest digits that read back as `x`, laid out by where the decimal -/// point falls. JSON holds no infinity or NaN; they write as `null`. -pub fn es_number(x: f64) -> String { - if !x.is_finite() { - return "null".into(); - } - if x == 0.0 { - return "0".into(); - } - let (s, n) = shortest(x.abs()); - let digits: String = s.iter().map(|d| char::from(b'0' + d)).collect(); - let k = s.len() as i32; - let mut out = String::new(); - if x < 0.0 { - out.push('-'); - } - if k <= n && n <= 21 { - out.push_str(&digits); - out.extend(std::iter::repeat_n('0', (n - k) as usize)); - } else if 0 < n && n <= 21 { - out.push_str(&digits[..n as usize]); - out.push('.'); - out.push_str(&digits[n as usize..]); - } else if -6 < n && n <= 0 { - out.push_str("0."); - out.extend(std::iter::repeat_n('0', (-n) as usize)); - out.push_str(&digits); - } else { - out.push_str(&digits[..1]); - if k > 1 { - out.push('.'); - out.push_str(&digits[1..]); - } - out.push('e'); - out.push(if n - 1 < 0 { '-' } else { '+' }); - out.push_str(&(n - 1).abs().to_string()); - } - out -} - -/// The digits s and the exponent n, x = 0.s × 10^n, that ECMAScript chooses: -/// the fewest that read back as `x` (> 0), and of those the nearest to `x`, -/// a tie going to the even. -fn shortest(x: f64) -> (Vec, i32) { - let e = format!("{x:e}"); - let (mant, exp) = e.split_once('e').unwrap_or((&e, "0")); - let mut s: Vec = mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); - while s.len() > 1 && s.last() == Some(&0) { - s.pop(); - } - let n = exp.parse::().unwrap_or(0) + 1; - // Rust's digits are the fewest, but a tie between two equally near it - // rounds up. Only when another of the same length also reads back as x - // (near 17 digits) is there a choice: then take x's exact expansion (a - // double's has at most 767 significant digits) rounded to that length. - if ![false, true].into_iter().any(|up| step(&s, up).is_some_and(|t| reads_as(&t, n, x))) { - return (s, n); - } - let k = s.len(); - let exact = format!("{x:.800e}"); - let (em, ee) = exact.split_once('e').unwrap_or((&exact, "0")); - let all: Vec = em.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); - let mut cn = ee.parse::().unwrap_or(0) + 1; - let mut c = all[..k].to_vec(); - let rest = &all[k..]; - let up = match rest.first().copied().unwrap_or(0) { - d if d > 5 => true, - 5 => rest[1..].iter().any(|&d| d != 0) || c[k - 1] % 2 == 1, - _ => false, - }; - if up { - match step(&c, true) { - Some(t) => c = t, - None => { - // 99…9 rounds to 10…0. - c = std::iter::once(1).chain(std::iter::repeat_n(0, k - 1)).collect(); - cn += 1; - } - } - } - if reads_as(&c, cn, x) { (c, cn) } else { (s, n) } -} - -/// The k-digit decimal one unit above or below `s` in its last digit, if it -/// still has k digits. -fn step(s: &[u8], up: bool) -> Option> { - let mut t = s.to_vec(); - for i in (0..t.len()).rev() { - match (up, t[i]) { - (true, 9) => t[i] = 0, - (false, 0) => t[i] = 9, - (true, d) | (false, d) => { - t[i] = if up { d + 1 } else { d - 1 }; - return (t[0] != 0).then_some(t); - } - } - } - None -} - -/// Whether 0.s × 10^n reads as `x` (std's reader is correctly rounded). -fn reads_as(s: &[u8], n: i32, x: f64) -> bool { - let digits: String = s.iter().map(|d| char::from(b'0' + d)).collect(); - format!("0.{digits}e{n}").parse::().is_ok_and(|y| y == x) -} - -/// The first number in `v` that a double cannot hold exactly (an integer past -/// ±2^53), by its path. Such a number would be read differently by readers that -/// hold every number as a double, so the receipt could not bind it. -pub fn inexact_number(v: &Value) -> Option { - const SAFE: u64 = (1 << 53) - 1; - fn walk(v: &Value, path: &mut String) -> bool { - match v { - Value::Number(n) => n.as_u64().is_some_and(|u| u > SAFE) || n.as_i64().is_some_and(|i| i.unsigned_abs() > SAFE), - Value::Array(a) => a.iter().enumerate().any(|(i, x)| { - let at = path.len(); - path.push_str(&format!("[{i}]")); - let hit = walk(x, path); - if !hit { - path.truncate(at); - } - hit - }), - Value::Object(m) => m.iter().any(|(k, x)| { - let at = path.len(); - path.push('.'); - path.push_str(k); - let hit = walk(x, path); - if !hit { - path.truncate(at); - } - hit - }), - _ => false, - } - } - let mut path = String::new(); - walk(v, &mut path).then(|| path.trim_start_matches('.').to_string()) -} - impl Signer { /// The key at `path`, created (32 random bytes, readable by the owner /// only) when there is none. @@ -281,40 +30,17 @@ impl Signer { Ok(Signer { key: SigningKey::from_bytes(&seed) }) } - /// A key for tests. - #[cfg(test)] - pub fn from_seed(seed: [u8; 32]) -> Signer { - Signer { key: SigningKey::from_bytes(&seed) } - } - /// The public key, in hex. pub fn public_key(&self) -> String { - hex(self.key.verifying_key().as_bytes()) + self.key.verifying_key().as_bytes().iter().map(|b| format!("{b:02x}")).collect() } /// The receipt for `result` of `request`. pub fn seal(&self, request: &Value, result: &Value) -> Value { - let d = digest(request, result); - let sig = self.key.sign(&d); - json!({ "alg": "ed25519", "canon": CANON, "key": self.public_key(), "digest": hex(&d), "signature": hex(&sig.to_bytes()) }) + receipt::seal(&self.key, request, result) } } -/// Whether `receipt` is a valid seal of `result` of `request` under the key -/// it names. -pub fn verify(request: &Value, result: &Value, receipt: &Value) -> bool { - let field = |k: &str| receipt.get(k).and_then(Value::as_str).and_then(unhex); - let (Some(key), Some(sig)) = (field("key"), field("signature")) else { return false }; - let (Ok(key), Ok(sig)) = (<[u8; 32]>::try_from(key.as_slice()), <[u8; 64]>::try_from(sig.as_slice())) else { return false }; - let Ok(vk) = VerifyingKey::from_bytes(&key) else { return false }; - let d = match receipt.get("canon").and_then(Value::as_str) { - Some(CANON) => digest(request, result), - None => digest_v1(request, result), - Some(_) => return false, - }; - field("digest").is_some_and(|g| g == d) && vk.verify(&d, &wai_quantum::ed25519_dalek::Signature::from_bytes(&sig)).is_ok() -} - #[cfg(unix)] fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { use std::io::Write; @@ -341,100 +67,6 @@ pub fn default_path() -> PathBuf { mod tests { use super::*; - #[test] - fn a_receipt_verifies_and_any_change_breaks_it() { - let s = Signer::from_seed([7; 32]); - let req = json!({"qasm": "OPENQASM 3.0;", "shots": 10}); - let res = json!({"method": "statevector", "counts": {"0": 10}}); - let r = s.seal(&req, &res); - assert!(verify(&req, &res, &r)); - assert!(!verify(&req, &json!({"method": "statevector", "counts": {"0": 9}}), &r)); - assert!(!verify(&json!({"qasm": "OPENQASM 3.0;", "shots": 11}), &res, &r)); - let mut forged = r.clone(); - forged["key"] = json!(Signer::from_seed([8; 32]).public_key()); - assert!(!verify(&req, &res, &forged)); - } - - #[test] - fn numbers_are_written_as_ecmascript_writes_them() { - // Bit patterns and ECMAScript's Number::toString of each, written by a - // JavaScript engine: RFC 8785's appendix B, the edges of each layout, - // and patterns spread over every exponent. - let mut n = 0; - for line in include_str!("../testdata/es-number-tostring.txt").lines() { - let (bits, want) = line.split_once(' ').unwrap(); - let x = f64::from_bits(u64::from_str_radix(bits, 16).unwrap()); - assert_eq!(es_number(x), want, "{bits}"); - // And the text reads back as the same double, as it must for a - // result to verify after a round trip (serde_json's - // float_roundtrip: its default reader rounds twice). - if x != 0.0 { - assert_eq!(serde_json::from_str::(want).unwrap().to_bits(), x.to_bits(), "{bits} {want}"); - } - n += 1; - } - assert!(n > 3000); - } - - /// The same against a larger set, in the same format, written by a - /// JavaScript engine: `WAI_ES_VECTORS=path cargo test -- --ignored`. - #[test] - #[ignore] - fn numbers_match_a_javascript_engine_on_a_large_set() { - let path = std::env::var("WAI_ES_VECTORS").expect("WAI_ES_VECTORS: a file of `hexbits text` lines"); - let text = std::fs::read_to_string(path).unwrap(); - let mut n = 0u64; - for line in text.lines() { - let (bits, want) = line.split_once(' ').unwrap(); - assert_eq!(es_number(f64::from_bits(u64::from_str_radix(bits, 16).unwrap())), want, "{bits}"); - n += 1; - } - eprintln!("{n} numbers agree"); - } - - #[test] - fn the_canonical_text_sorts_keys_and_writes_numbers_and_strings_one_way() { - let v = json!({ "b": 1.0, "a": [true, null, "x\n\u{1}\"\\é"], "\u{e000}": 0.000001, "\u{1f600}": -0.0, "c": 1e21 }); - // U+1F600 is a surrogate pair, so it sorts before U+E000. - assert_eq!(String::from_utf8(canonical(&v)).unwrap(), "{\"a\":[true,null,\"x\\n\\u0001\\\"\\\\é\"],\"b\":1,\"c\":1e+21,\"\u{1f600}\":0,\"\u{e000}\":0.000001}"); - } - - #[test] - fn a_receipt_verifies_after_a_round_trip_through_another_json_writer() { - let s = Signer::from_seed([7; 32]); - let req = json!({ "qasm": "qubit[2] q; h q[0]; cx q[0], q[1];", "shots": 1000, "observables": ["Z0 Z1"] }); - let res = json!({ "method": "stabilizer", "expectations": [{ "observable": "Z0 Z1", "coeff": 1.0, "value": 1.0 }], "threshold": 0.00001, "elapsed_ms": 0 }); - let r = s.seal(&req, &res); - // What a browser sends back: integral doubles without a fraction, keys - // in another order, the threshold in plain decimals. - let req2: Value = serde_json::from_str(r#"{"observables":["Z0 Z1"],"shots":1000,"qasm":"qubit[2] q; h q[0]; cx q[0], q[1];"}"#).unwrap(); - let res2: Value = serde_json::from_str(r#"{"elapsed_ms":0,"threshold":0.00001,"method":"stabilizer","expectations":[{"value":1,"coeff":1,"observable":"Z0 Z1"}]}"#).unwrap(); - assert!(verify(&req2, &res2, &r)); - let res3: Value = serde_json::from_str(r#"{"elapsed_ms":0,"threshold":0.00001,"method":"stabilizer","expectations":[{"value":0.999,"coeff":1,"observable":"Z0 Z1"}]}"#).unwrap(); - assert!(!verify(&req2, &res3, &r)); - } - - #[test] - fn the_first_receipts_still_verify_and_an_unknown_form_does_not() { - let s = Signer::from_seed([7; 32]); - let req = json!({ "qasm": "OPENQASM 3.0;", "shots": 10 }); - let res = json!({ "method": "statevector", "counts": { "0": 10 } }); - let d = digest_v1(&req, &res); - let old = json!({ "alg": "ed25519", "key": s.public_key(), "digest": hex(&d), "signature": hex(&s.key.sign(&d).to_bytes()) }); - assert!(verify(&req, &res, &old)); - let mut other = s.seal(&req, &res); - other["canon"] = json!("something-else"); - assert!(!verify(&req, &res, &other)); - } - - #[test] - fn integers_a_double_cannot_hold_are_found_by_path() { - assert_eq!(inexact_number(&json!({ "seed": 9007199254740991u64, "shots": 10 })), None); - assert_eq!(inexact_number(&json!({ "seed": 9007199254740992u64 })).as_deref(), Some("seed")); - assert_eq!(inexact_number(&json!({ "options": { "x": [1, -9007199254740993i64] } })).as_deref(), Some("options.x[1]")); - assert_eq!(inexact_number(&json!({ "noise": { "readout": 1e300 } })), None); - } - #[test] fn a_key_is_created_once_private_and_then_reused() { let dir = std::env::temp_dir().join(format!("wai-qsvc-key-{}", std::process::id())); diff --git a/wai/wai-quantum/src/engine/catalog.rs b/wai/wai-quantum/src/engine/catalog.rs index 98b63811c..743bbca1b 100644 --- a/wai/wai-quantum/src/engine/catalog.rs +++ b/wai/wai-quantum/src/engine/catalog.rs @@ -206,7 +206,15 @@ fn observable(v: &Value, n: u32) -> Result { Ok((text, coeff, string)) } +/// The noise channels a job may name. +pub const NOISE_CHANNELS: [&str; 5] = ["depolarizing_1q", "depolarizing_2q", "amplitude_damping", "dephasing", "readout"]; + fn noise(v: &Value) -> Result { + // A channel the model does not know is refused: read as no noise, a + // misspelt one would run the circuit noiseless. + if let Some(k) = v.as_object().and_then(|o| o.keys().find(|k| !NOISE_CHANNELS.contains(&k.as_str()))) { + return Err(format!("noise {k}: no such channel; the channels are {}", NOISE_CHANNELS.join(", "))); + } let f = |k: &str| -> Result { let x = v.get(k).and_then(Value::as_f64).unwrap_or(0.0); if (0.0..=1.0).contains(&x) { Ok(x) } else { Err(format!("noise {k} is a probability, 0 to 1")) } @@ -720,6 +728,18 @@ mod tests { assert!(plan(&job(rot, json!({ "method": "nonsense" })), CAP).unwrap_err().contains("unknown method")); } + /// A noise channel the model does not know is refused, never read as no + /// noise. + #[test] + fn an_unknown_noise_channel_is_refused() { + let bell = "qubit[2] q; h q[0]; cx q[0], q[1];"; + let e = plan(&job(bell, json!({ "noise": { "depolarizing": 0.1 } })), CAP).unwrap_err(); + assert!(e.contains("noise depolarizing: no such channel") && e.contains("depolarizing_1q"), "{e}"); + for k in NOISE_CHANNELS { + assert!(plan(&job(bell, json!({ "noise": { k: 0.01 } })), CAP).is_ok(), "{k}"); + } + } + /// A program that measures in the middle goes to the tableau when it is /// Clifford and to the state vector otherwise; every other method, noise /// and amplitudes are refused with what would run it. diff --git a/wai/wai-quantum/src/engine/mod.rs b/wai/wai-quantum/src/engine/mod.rs index d532eed8f..e3b70e09c 100644 --- a/wai/wai-quantum/src/engine/mod.rs +++ b/wai/wai-quantum/src/engine/mod.rs @@ -11,11 +11,17 @@ //! did. //! - [`dynamic`]: programs that measure in the middle, reset or branch on a //! measured bit, run branch by branch. +//! - [`receipt`] (with `quantum_receipt`): a result sealed with a key over +//! the request and the result in their canonical JSON, and checked. The +//! service seals with its key and the command line with the user's, in one +//! form. //! //! Requests and results are the service's JSON (`/v1/openapi.json` there). -//! Signing a result, metering its energy, queueing and limits per client stay -//! with whatever hosts the engine. +//! Keys, metering a job's energy, queueing and limits per client stay with +//! whatever hosts the engine. pub mod catalog; pub mod dynamic; +#[cfg(feature = "quantum_receipt")] +pub mod receipt; pub mod run; diff --git a/wai/wai-quantum/src/engine/receipt.rs b/wai/wai-quantum/src/engine/receipt.rs new file mode 100644 index 000000000..8252fe76e --- /dev/null +++ b/wai/wai-quantum/src/engine/receipt.rs @@ -0,0 +1,397 @@ +//! Signed receipts over a job: a result sealed with an Ed25519 key, binding +//! the request as submitted and the result as returned, so anyone can be shown +//! what was run and what came back. The service seals every result with its +//! key; the command line and the desktop app seal theirs with the user's, in +//! the same form, and one [`verify`] checks them all. +//! +//! What is signed is the JSON Canonicalization Scheme (RFC 8785) text of each: +//! keys sorted, no whitespace, and every number written as ECMAScript writes +//! the double it reads. A result therefore verifies after any JSON reader and +//! writer has carried it: a browser's `JSON.parse` and `JSON.stringify` turn +//! `1.0` into `1`, and other readers reorder keys. + +use serde_json::{Value, json}; +use ed25519_dalek::{Signer as _, SigningKey, Verifier as _, VerifyingKey}; + +/// The first receipts signed serde_json's own text, which only a reader that +/// keeps each number's spelling and each object's key order can reproduce. +/// They still verify; new ones are canonical. +const DOMAIN_V1: &[u8] = b"wai:quantum-service-receipt\x01"; +const DOMAIN: &[u8] = b"wai:quantum-service-receipt\x02"; +/// The receipt's name for its canonical form. +const CANON: &str = "rfc8785"; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn unhex(s: &str) -> Option> { + s.len().is_multiple_of(2).then_some(())?; + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +/// A length-prefixed byte string, so that no field reads as part of another. +fn put(o: &mut Vec, bytes: &[u8]) { + o.extend_from_slice(&(bytes.len() as u64).to_be_bytes()); + o.extend_from_slice(bytes); +} + +/// The digest a receipt signs: BLAKE3(domain ‖ put(request) ‖ put(result)), +/// each in its RFC 8785 canonical text. +pub fn digest(request: &Value, result: &Value) -> [u8; 32] { + let mut o = DOMAIN.to_vec(); + put(&mut o, &canonical(request)); + put(&mut o, &canonical(result)); + *blake3::hash(&o).as_bytes() +} + +/// The digest the first receipts signed (see [`DOMAIN_V1`]). +fn digest_v1(request: &Value, result: &Value) -> [u8; 32] { + let mut o = DOMAIN_V1.to_vec(); + put(&mut o, request.to_string().as_bytes()); + put(&mut o, result.to_string().as_bytes()); + *blake3::hash(&o).as_bytes() +} + +/// The RFC 8785 canonical text of `v`. +pub fn canonical(v: &Value) -> Vec { + let mut o = Vec::new(); + write(v, &mut o); + o +} + +fn write(v: &Value, o: &mut Vec) { + match v { + Value::Null => o.extend_from_slice(b"null"), + Value::Bool(b) => o.extend_from_slice(if *b { b"true" } else { b"false" }), + // Every number as the double a JSON reader in ECMAScript holds. + Value::Number(n) => o.extend_from_slice(es_number(n.as_f64().unwrap_or(f64::NAN)).as_bytes()), + Value::String(s) => write_str(s, o), + Value::Array(a) => { + o.push(b'['); + for (i, x) in a.iter().enumerate() { + if i > 0 { + o.push(b','); + } + write(x, o); + } + o.push(b']'); + } + Value::Object(m) => { + // Keys in the order of their UTF-16 code units. + let mut keys: Vec<&String> = m.keys().collect(); + keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + o.push(b'{'); + for (i, k) in keys.into_iter().enumerate() { + if i > 0 { + o.push(b','); + } + write_str(k, o); + o.push(b':'); + write(&m[k], o); + } + o.push(b'}'); + } + } +} + +/// A string as RFC 8785 writes it: `"` and `\\` escaped, the five short +/// control escapes, every other control character as `\u00xx`, and the rest +/// as it is. +fn write_str(s: &str, o: &mut Vec) { + o.push(b'"'); + for c in s.chars() { + match c { + '"' => o.extend_from_slice(b"\\\""), + '\\' => o.extend_from_slice(b"\\\\"), + '\u{8}' => o.extend_from_slice(b"\\b"), + '\t' => o.extend_from_slice(b"\\t"), + '\n' => o.extend_from_slice(b"\\n"), + '\u{c}' => o.extend_from_slice(b"\\f"), + '\r' => o.extend_from_slice(b"\\r"), + c if (c as u32) < 0x20 => o.extend_from_slice(format!("\\u{:04x}", c as u32).as_bytes()), + c => { + let mut b = [0u8; 4]; + o.extend_from_slice(c.encode_utf8(&mut b).as_bytes()); + } + } + } + o.push(b'"'); +} + +/// A double as ECMAScript's Number::toString writes it (ECMA-262, 6.1.6.1.20): +/// the shortest digits that read back as `x`, laid out by where the decimal +/// point falls. JSON holds no infinity or NaN; they write as `null`. +pub fn es_number(x: f64) -> String { + if !x.is_finite() { + return "null".into(); + } + if x == 0.0 { + return "0".into(); + } + let (s, n) = shortest(x.abs()); + let digits: String = s.iter().map(|d| char::from(b'0' + d)).collect(); + let k = s.len() as i32; + let mut out = String::new(); + if x < 0.0 { + out.push('-'); + } + if k <= n && n <= 21 { + out.push_str(&digits); + out.extend(std::iter::repeat_n('0', (n - k) as usize)); + } else if 0 < n && n <= 21 { + out.push_str(&digits[..n as usize]); + out.push('.'); + out.push_str(&digits[n as usize..]); + } else if -6 < n && n <= 0 { + out.push_str("0."); + out.extend(std::iter::repeat_n('0', (-n) as usize)); + out.push_str(&digits); + } else { + out.push_str(&digits[..1]); + if k > 1 { + out.push('.'); + out.push_str(&digits[1..]); + } + out.push('e'); + out.push(if n - 1 < 0 { '-' } else { '+' }); + out.push_str(&(n - 1).abs().to_string()); + } + out +} + +/// The digits s and the exponent n, x = 0.s × 10^n, that ECMAScript chooses: +/// the fewest that read back as `x` (> 0), and of those the nearest to `x`, +/// a tie going to the even. +fn shortest(x: f64) -> (Vec, i32) { + let e = format!("{x:e}"); + let (mant, exp) = e.split_once('e').unwrap_or((&e, "0")); + let mut s: Vec = mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); + while s.len() > 1 && s.last() == Some(&0) { + s.pop(); + } + let n = exp.parse::().unwrap_or(0) + 1; + // Rust's digits are the fewest, but a tie between two equally near it + // rounds up. Only when another of the same length also reads back as x + // (near 17 digits) is there a choice: then take x's exact expansion (a + // double's has at most 767 significant digits) rounded to that length. + if ![false, true].into_iter().any(|up| step(&s, up).is_some_and(|t| reads_as(&t, n, x))) { + return (s, n); + } + let k = s.len(); + let exact = format!("{x:.800e}"); + let (em, ee) = exact.split_once('e').unwrap_or((&exact, "0")); + let all: Vec = em.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); + let mut cn = ee.parse::().unwrap_or(0) + 1; + let mut c = all[..k].to_vec(); + let rest = &all[k..]; + let up = match rest.first().copied().unwrap_or(0) { + d if d > 5 => true, + 5 => rest[1..].iter().any(|&d| d != 0) || c[k - 1] % 2 == 1, + _ => false, + }; + if up { + match step(&c, true) { + Some(t) => c = t, + None => { + // 99…9 rounds to 10…0. + c = std::iter::once(1).chain(std::iter::repeat_n(0, k - 1)).collect(); + cn += 1; + } + } + } + if reads_as(&c, cn, x) { (c, cn) } else { (s, n) } +} + +/// The k-digit decimal one unit above or below `s` in its last digit, if it +/// still has k digits. +fn step(s: &[u8], up: bool) -> Option> { + let mut t = s.to_vec(); + for i in (0..t.len()).rev() { + match (up, t[i]) { + (true, 9) => t[i] = 0, + (false, 0) => t[i] = 9, + (true, d) | (false, d) => { + t[i] = if up { d + 1 } else { d - 1 }; + return (t[0] != 0).then_some(t); + } + } + } + None +} + +/// Whether 0.s × 10^n reads as `x` (std's reader is correctly rounded). +fn reads_as(s: &[u8], n: i32, x: f64) -> bool { + let digits: String = s.iter().map(|d| char::from(b'0' + d)).collect(); + format!("0.{digits}e{n}").parse::().is_ok_and(|y| y == x) +} + +/// The first number in `v` that a double cannot hold exactly (an integer past +/// ±2^53), by its path. Such a number would be read differently by readers that +/// hold every number as a double, so the receipt could not bind it. +pub fn inexact_number(v: &Value) -> Option { + const SAFE: u64 = (1 << 53) - 1; + fn walk(v: &Value, path: &mut String) -> bool { + match v { + Value::Number(n) => n.as_u64().is_some_and(|u| u > SAFE) || n.as_i64().is_some_and(|i| i.unsigned_abs() > SAFE), + Value::Array(a) => a.iter().enumerate().any(|(i, x)| { + let at = path.len(); + path.push_str(&format!("[{i}]")); + let hit = walk(x, path); + if !hit { + path.truncate(at); + } + hit + }), + Value::Object(m) => m.iter().any(|(k, x)| { + let at = path.len(); + path.push('.'); + path.push_str(k); + let hit = walk(x, path); + if !hit { + path.truncate(at); + } + hit + }), + _ => false, + } + } + let mut path = String::new(); + walk(v, &mut path).then(|| path.trim_start_matches('.').to_string()) +} + +/// The receipt for `result` of `request`, sealed with `key`. +pub fn seal(key: &SigningKey, request: &Value, result: &Value) -> Value { + let d = digest(request, result); + let sig = key.sign(&d); + json!({ "alg": "ed25519", "canon": CANON, "key": hex(key.verifying_key().as_bytes()), "digest": hex(&d), "signature": hex(&sig.to_bytes()) }) +} + +/// Whether `receipt` is a valid seal of `result` of `request` under the key +/// it names. +pub fn verify(request: &Value, result: &Value, receipt: &Value) -> bool { + let field = |k: &str| receipt.get(k).and_then(Value::as_str).and_then(unhex); + let (Some(key), Some(sig)) = (field("key"), field("signature")) else { return false }; + let (Ok(key), Ok(sig)) = (<[u8; 32]>::try_from(key.as_slice()), <[u8; 64]>::try_from(sig.as_slice())) else { return false }; + let Ok(vk) = VerifyingKey::from_bytes(&key) else { return false }; + let d = match receipt.get("canon").and_then(Value::as_str) { + Some(CANON) => digest(request, result), + None => digest_v1(request, result), + Some(_) => return false, + }; + field("digest").is_some_and(|g| g == d) && vk.verify(&d, &ed25519_dalek::Signature::from_bytes(&sig)).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A key for the tests, sealing as a host does. + struct Signer(SigningKey); + impl Signer { + fn from_seed(seed: [u8; 32]) -> Signer { + Signer(SigningKey::from_bytes(&seed)) + } + fn public_key(&self) -> String { + hex(self.0.verifying_key().as_bytes()) + } + fn seal(&self, request: &Value, result: &Value) -> Value { + seal(&self.0, request, result) + } + } + + #[test] + fn a_receipt_verifies_and_any_change_breaks_it() { + let s = Signer::from_seed([7; 32]); + let req = json!({"qasm": "OPENQASM 3.0;", "shots": 10}); + let res = json!({"method": "statevector", "counts": {"0": 10}}); + let r = s.seal(&req, &res); + assert!(verify(&req, &res, &r)); + assert!(!verify(&req, &json!({"method": "statevector", "counts": {"0": 9}}), &r)); + assert!(!verify(&json!({"qasm": "OPENQASM 3.0;", "shots": 11}), &res, &r)); + let mut forged = r.clone(); + forged["key"] = json!(Signer::from_seed([8; 32]).public_key()); + assert!(!verify(&req, &res, &forged)); + } + + #[test] + fn numbers_are_written_as_ecmascript_writes_them() { + // Bit patterns and ECMAScript's Number::toString of each, written by a + // JavaScript engine: RFC 8785's appendix B, the edges of each layout, + // and patterns spread over every exponent. + let mut n = 0; + for line in include_str!("testdata/es-number-tostring.txt").lines() { + let (bits, want) = line.split_once(' ').unwrap(); + let x = f64::from_bits(u64::from_str_radix(bits, 16).unwrap()); + assert_eq!(es_number(x), want, "{bits}"); + // And the text reads back as the same double, as it must for a + // result to verify after a round trip (serde_json's + // float_roundtrip: its default reader rounds twice). + if x != 0.0 { + assert_eq!(serde_json::from_str::(want).unwrap().to_bits(), x.to_bits(), "{bits} {want}"); + } + n += 1; + } + assert!(n > 3000); + } + + /// The same against a larger set, in the same format, written by a + /// JavaScript engine: `WAI_ES_VECTORS=path cargo test -- --ignored`. + #[test] + #[ignore] + fn numbers_match_a_javascript_engine_on_a_large_set() { + let path = std::env::var("WAI_ES_VECTORS").expect("WAI_ES_VECTORS: a file of `hexbits text` lines"); + let text = std::fs::read_to_string(path).unwrap(); + let mut n = 0u64; + for line in text.lines() { + let (bits, want) = line.split_once(' ').unwrap(); + assert_eq!(es_number(f64::from_bits(u64::from_str_radix(bits, 16).unwrap())), want, "{bits}"); + n += 1; + } + eprintln!("{n} numbers agree"); + } + + #[test] + fn the_canonical_text_sorts_keys_and_writes_numbers_and_strings_one_way() { + let v = json!({ "b": 1.0, "a": [true, null, "x\n\u{1}\"\\é"], "\u{e000}": 0.000001, "\u{1f600}": -0.0, "c": 1e21 }); + // U+1F600 is a surrogate pair, so it sorts before U+E000. + assert_eq!(String::from_utf8(canonical(&v)).unwrap(), "{\"a\":[true,null,\"x\\n\\u0001\\\"\\\\é\"],\"b\":1,\"c\":1e+21,\"\u{1f600}\":0,\"\u{e000}\":0.000001}"); + } + + #[test] + fn a_receipt_verifies_after_a_round_trip_through_another_json_writer() { + let s = Signer::from_seed([7; 32]); + let req = json!({ "qasm": "qubit[2] q; h q[0]; cx q[0], q[1];", "shots": 1000, "observables": ["Z0 Z1"] }); + let res = json!({ "method": "stabilizer", "expectations": [{ "observable": "Z0 Z1", "coeff": 1.0, "value": 1.0 }], "threshold": 0.00001, "elapsed_ms": 0 }); + let r = s.seal(&req, &res); + // What a browser sends back: integral doubles without a fraction, keys + // in another order, the threshold in plain decimals. + let req2: Value = serde_json::from_str(r#"{"observables":["Z0 Z1"],"shots":1000,"qasm":"qubit[2] q; h q[0]; cx q[0], q[1];"}"#).unwrap(); + let res2: Value = serde_json::from_str(r#"{"elapsed_ms":0,"threshold":0.00001,"method":"stabilizer","expectations":[{"value":1,"coeff":1,"observable":"Z0 Z1"}]}"#).unwrap(); + assert!(verify(&req2, &res2, &r)); + let res3: Value = serde_json::from_str(r#"{"elapsed_ms":0,"threshold":0.00001,"method":"stabilizer","expectations":[{"value":0.999,"coeff":1,"observable":"Z0 Z1"}]}"#).unwrap(); + assert!(!verify(&req2, &res3, &r)); + } + + #[test] + fn the_first_receipts_still_verify_and_an_unknown_form_does_not() { + let s = Signer::from_seed([7; 32]); + let req = json!({ "qasm": "OPENQASM 3.0;", "shots": 10 }); + let res = json!({ "method": "statevector", "counts": { "0": 10 } }); + let d = digest_v1(&req, &res); + let old = json!({ "alg": "ed25519", "key": s.public_key(), "digest": hex(&d), "signature": hex(&s.0.sign(&d).to_bytes()) }); + assert!(verify(&req, &res, &old)); + let mut other = s.seal(&req, &res); + other["canon"] = json!("something-else"); + assert!(!verify(&req, &res, &other)); + } + + #[test] + fn integers_a_double_cannot_hold_are_found_by_path() { + assert_eq!(inexact_number(&json!({ "seed": 9007199254740991u64, "shots": 10 })), None); + assert_eq!(inexact_number(&json!({ "seed": 9007199254740992u64 })).as_deref(), Some("seed")); + assert_eq!(inexact_number(&json!({ "options": { "x": [1, -9007199254740993i64] } })).as_deref(), Some("options.x[1]")); + assert_eq!(inexact_number(&json!({ "noise": { "readout": 1e300 } })), None); + } + +} diff --git a/wai/wai-quantum-service/testdata/es-number-tostring.txt b/wai/wai-quantum/src/engine/testdata/es-number-tostring.txt similarity index 100% rename from wai/wai-quantum-service/testdata/es-number-tostring.txt rename to wai/wai-quantum/src/engine/testdata/es-number-tostring.txt diff --git a/wai/wai-quantum/src/quantum_receipt.rs b/wai/wai-quantum/src/quantum_receipt.rs index e7832dd92..211c6cfd1 100644 --- a/wai/wai-quantum/src/quantum_receipt.rs +++ b/wai/wai-quantum/src/quantum_receipt.rs @@ -391,6 +391,34 @@ impl QuantumReceipt { } } +/// The `did:key` identifier of an Ed25519 public key, as the W3C did:key +/// method writes it: `did:key:z` and the base58btc text of the multicodec +/// prefix `0xed 0x01` followed by the key's 32 bytes. A signer that names itself +/// this way names the key that signed, so the name cannot claim another key. +pub fn did_key(key: &VerifyingKey) -> String { + const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + let mut bytes = vec![0xed, 0x01]; + bytes.extend_from_slice(key.as_bytes()); + // Base 58 digits, least significant first. The prefix's first byte is not + // zero, so the text has no leading `1`s. + let mut digits: Vec = Vec::with_capacity(48); + for &b in &bytes { + let mut carry = u32::from(b); + for d in digits.iter_mut() { + carry += u32::from(*d) << 8; + *d = (carry % 58) as u8; + carry /= 58; + } + while carry > 0 { + digits.push((carry % 58) as u8); + carry /= 58; + } + } + let mut s = String::from("did:key:z"); + s.extend(digits.iter().rev().map(|&d| ALPHABET[usize::from(d)] as char)); + s +} + #[cfg(test)] mod tests { use super::*; @@ -558,6 +586,18 @@ mod tests { } } + /// The W3C did:key method's Ed25519 example, from its raw key. + #[test] + fn did_key_writes_the_methods_example() { + let raw: [u8; 32] = core::array::from_fn(|i| { + u8::from_str_radix(&"3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"[2 * i..2 * i + 2], 16).unwrap() + }); + let vk = VerifyingKey::from_bytes(&raw).unwrap(); + assert_eq!(did_key(&vk), "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp"); + assert!(did_key(&key(5).verifying_key()).starts_with("did:key:z6Mk")); + assert_ne!(did_key(&key(5).verifying_key()), did_key(&key(6).verifying_key())); + } + #[test] fn json_refuses_counts_that_do_not_fit() { let r = QuantumReceipt::seal(&key(5), "m", &ghz3(Gateset::V2), None, 0, None).unwrap(); -- 2.54.0 (Apple Git-157) From e06e56a48846f33852efc185b4326776926f67fd Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:51:14 -0400 Subject: [PATCH 7/7] wai-quantum-cli: the command line on the job service's engine, installable A new crate, wai-quantum-cli, whose binary is wai-quantum. It depends on the wai-quantum library alone, so `cargo install wai-quantum-cli` can install it. - run: an OpenQASM program planned and run as the service runs it (wai_quantum::engine), signed with this user's key; --json prints the job as the service returns it. Shots, seed, method, observables, amplitudes, noise and method options; an unknown flag or noise channel is an error. - plan, lint (the line of the first problem), draw (the circuit as text, gates packed into columns, conditions in a legend). - submit: the same job on the service (feature `service`, on by default), waiting for a long one. - verify: a job's receipt from run, submit or the service, and every receipt the research tools sign; a changed result is INVALID. - key: this user's did:key; --key FILE and --demo-key as before. - methods: each method's reach in a job's memory here. - mcp: run, plan, lint, draw, verify and the method list for an agent, over stdio; runs stay on this machine. - lab: the earlier wai-quantum command, moved here from wai-rs unchanged (wai:: paths now wai_quantum::), its key handling shared with run. - completions for bash, zsh, fish, elvish and PowerShell. - Feature `metal`: the GPU state vector on macOS. With no default features it builds for wasm32-wasip2. wai-rs loses the wai-quantum bin and its cli_keys feature. The wai README's quickstart now uses the new crate. smoke.sh exercises every command, and .github/workflows/wai-quantum-cli.yml runs the tests, clippy with and without submit, smoke.sh, and the WASI component against the native build, byte for byte; on macOS also a run on the GPU. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-quantum-cli.yml | 151 ++ wai/README.md | 45 +- wai/wai-quantum-cli/Cargo.lock | 1348 +++++++++++++++++ wai/wai-quantum-cli/Cargo.toml | 33 + wai/wai-quantum-cli/README.md | 129 ++ wai/wai-quantum-cli/smoke.sh | 111 ++ wai/wai-quantum-cli/src/draw.rs | 321 ++++ wai/wai-quantum-cli/src/keys.rs | 135 ++ .../src/lab.rs} | 415 +++-- wai/wai-quantum-cli/src/main.rs | 575 +++++++ wai/wai-quantum-cli/src/mcp.rs | 196 +++ wai/wai-rs/Cargo.lock | 1 - wai/wai-rs/Cargo.toml | 31 - 13 files changed, 3211 insertions(+), 280 deletions(-) create mode 100644 .github/workflows/wai-quantum-cli.yml create mode 100644 wai/wai-quantum-cli/Cargo.lock create mode 100644 wai/wai-quantum-cli/Cargo.toml create mode 100644 wai/wai-quantum-cli/README.md create mode 100644 wai/wai-quantum-cli/smoke.sh create mode 100644 wai/wai-quantum-cli/src/draw.rs create mode 100644 wai/wai-quantum-cli/src/keys.rs rename wai/{wai-rs/src/bin/wai_quantum.rs => wai-quantum-cli/src/lab.rs} (86%) create mode 100644 wai/wai-quantum-cli/src/main.rs create mode 100644 wai/wai-quantum-cli/src/mcp.rs diff --git a/.github/workflows/wai-quantum-cli.yml b/.github/workflows/wai-quantum-cli.yml new file mode 100644 index 000000000..3d1f43079 --- /dev/null +++ b/.github/workflows/wai-quantum-cli.yml @@ -0,0 +1,151 @@ +name: wai-quantum command line + +# The wai-quantum command line (wai/wai-quantum-cli): its tests and clippy with +# warnings as errors, with and without `submit`; its smoke checks (smoke.sh: lint, +# plan, run, verify and a forged result refused, key, draw, MCP over stdio, the +# research tools); and the WASI component, whose output under wasmtime equals the +# native build's byte for byte. On macOS, also with the GPU state vector. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/wai-quantum/**' + - 'wai/wai-quantum-cli/**' + - 'rust-toolchain.toml' + - '.github/workflows/wai-quantum-cli.yml' + pull_request: + paths: + - 'wai/wai-quantum/**' + - 'wai/wai-quantum-cli/**' + - 'rust-toolchain.toml' + - '.github/workflows/wai-quantum-cli.yml' + +jobs: + relevant: + name: does this change touch it + runs-on: linux-arm64 + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-quantum-cli.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches the wai-quantum command line: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + cli-linux: + name: command line · linux + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 with clippy and the wasm32-wasip2 target + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal --component clippy + rustup default 1.98.0 + rustup target add --toolchain 1.98.0 wasm32-wasip2 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Tests and clippy, with and without submit + shell: bash + working-directory: wai/wai-quantum-cli + run: | + set -euo pipefail + cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; } + cargo clippy --locked --release --all-targets -- -D warnings + cargo clippy --locked --release --all-targets --no-default-features -- -D warnings + - name: The smoke checks + shell: bash + working-directory: wai/wai-quantum-cli + run: | + set -euo pipefail + cargo build --locked --release + bash smoke.sh ./target/release/wai-quantum + # The release binary, pinned by version and SHA-256, rather than a build from source. + - name: Install wasmtime 49.0.2 + shell: bash + run: | + set -euo pipefail + [ "$(uname -m)" = x86_64 ] || { echo "::error::the pinned wasmtime archive is x86_64; this host is $(uname -m)"; exit 1; } + if ! command -v xz >/dev/null 2>&1; then + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq xz-utils + fi + v=49.0.2 + f="wasmtime-v$v-x86_64-linux.tar.xz" + curl --proto '=https' --tlsv1.2 -sSfL -o "$RUNNER_TEMP/$f" \ + "https://github.com/bytecodealliance/wasmtime/releases/download/v$v/$f" + echo "a4d6e9e3a5a60f527cf7793d674c48930c80c2e8977995b8a275cad3254b9322 $RUNNER_TEMP/$f" | sha256sum -c - + tar -xJf "$RUNNER_TEMP/$f" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/wasmtime-v$v-x86_64-linux" >> "$GITHUB_PATH" + # The same source as a WASI component: a circuit's state hash, a run's + # result and a receipt signed with the published key are the native + # build's, byte for byte. + - name: The WASI component gives the native build's bytes + shell: bash + working-directory: wai/wai-quantum-cli + run: | + set -euo pipefail + cargo build --locked --release --target wasm32-wasip2 --no-default-features + w=target/wasm32-wasip2/release/wai-quantum.wasm + n=target/release/wai-quantum + printf 'OPENQASM 3.0; qubit[3] q; h q[0]; cx q[0], q[1]; t q[2]; cx q[1], q[2];' > "$RUNNER_TEMP/p.qasm" + for args in "lab run ghz -n 5" "lab receipt ghz -n 3 --demo-key"; do + $n $args > "$RUNNER_TEMP/native.txt" 2> /dev/null + wasmtime run "$w" $args > "$RUNNER_TEMP/wasi.txt" 2> /dev/null + cmp "$RUNNER_TEMP/native.txt" "$RUNNER_TEMP/wasi.txt" || { echo "::error::$args differs under wasmtime"; diff "$RUNNER_TEMP/native.txt" "$RUNNER_TEMP/wasi.txt" || true; exit 1; } + done + $n run --demo-key --json --shots 100 < "$RUNNER_TEMP/p.qasm" 2> /dev/null | python3 -c 'import json,sys; j=json.load(sys.stdin); j["result"].pop("elapsed_ms"); j["result"]["receipt"].pop("digest"); j["result"]["receipt"].pop("signature"); print(json.dumps(j, sort_keys=True))' > "$RUNNER_TEMP/native.json" + wasmtime run "$w" run --demo-key --json --shots 100 < "$RUNNER_TEMP/p.qasm" 2> /dev/null | python3 -c 'import json,sys; j=json.load(sys.stdin); j["result"].pop("elapsed_ms"); j["result"]["receipt"].pop("digest"); j["result"]["receipt"].pop("signature"); print(json.dumps(j, sort_keys=True))' > "$RUNNER_TEMP/wasi.json" + cmp "$RUNNER_TEMP/native.json" "$RUNNER_TEMP/wasi.json" || { echo "::error::a run's result differs under wasmtime"; exit 1; } + echo "the WASI component's output equals the native build's" + + cli-macos: + name: command line · macos + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: macos + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + # launchd's PATH omits cargo on the macOS host runner. + - name: Put cargo on PATH + shell: bash + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Tests and clippy, with the GPU state vector + shell: bash + working-directory: wai/wai-quantum-cli + run: | + set -euo pipefail + nice -n 10 cargo test --locked --release --features metal 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; } + nice -n 10 cargo clippy --locked --release --all-targets --features metal -- -D warnings + - name: The smoke checks, and a run on the GPU + shell: bash + working-directory: wai/wai-quantum-cli + run: | + set -euo pipefail + nice -n 10 cargo build --locked --release --features metal + bash smoke.sh ./target/release/wai-quantum + printf 'OPENQASM 3.0; qubit[12] q; bit[12] c; h q; rx(0.3) q[0]; cx q[0], q[1]; c = measure q;' > "$RUNNER_TEMP/gpu.qasm" + ./target/release/wai-quantum run "$RUNNER_TEMP/gpu.qasm" --method statevector_single --shots 200 --demo-key --json > "$RUNNER_TEMP/gpu.json" 2> /dev/null + python3 -c 'import json,sys; j=json.load(open(sys.argv[1])); r=j["result"]; assert r["accuracy"]["class"]=="single" and sum(r["counts"].values())==200, r' "$RUNNER_TEMP/gpu.json" + echo "statevector_single ran on $(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["result"]["accuracy"]["device"])' "$RUNNER_TEMP/gpu.json")" diff --git a/wai/README.md b/wai/README.md index a3115e807..333266736 100644 --- a/wai/README.md +++ b/wai/README.md @@ -207,39 +207,44 @@ reproduces ([quantum-sim](extensions/quantum-sim.md) §8). ### Quickstart -```bash -# native -cargo build --release --no-default-features \ - --features "quantum_toolchain,quantum_receipt" --bin wai-quantum +The command line is [`wai-quantum-cli`](wai-quantum-cli/README.md): OpenQASM +programs run here as the quantum job service runs them, each result naming its +method and accuracy and signed with this user's key. -./target/release/wai-quantum list # algorithm catalog -./target/release/wai-quantum run ghz -n 5 # simulate + hash + outcomes -./target/release/wai-quantum run grover -n 4 -p 9 # amplitude amplification -./target/release/wai-quantum qasm bell # emit OpenQASM 3.0 -./target/release/wai-quantum qir bell # emit QIR (LLVM IR) -./target/release/wai-quantum qir qft -n 4 | \\ - ./target/release/wai-quantum run --qir # and read it back -./target/release/wai-quantum receipt ghz -n 3 | ./target/release/wai-quantum verify -./target/release/wai-quantum run qft -n 5 --gateset v1 # the frozen wai.quantum.circuit +```bash +cd wai-quantum-cli && cargo build --release # or: cargo install wai-quantum-cli +W=./target/release/wai-quantum + +$W run bell.qasm --shots 1000 -o "Z0 Z1" # planned as the service plans it +$W run bell.qasm --json > job.json && $W verify job.json +$W plan prog.qasm; $W lint prog.qasm; $W draw prog.qasm +$W submit prog.qasm # on the job service +$W lab list # the algorithm catalogue +$W lab run ghz -n 5 # simulate + hash + outcomes +$W lab qasm bell # emit OpenQASM 3.0 +$W lab qir qft -n 4 | $W lab run --qir # QIR out, and back in +$W lab receipt ghz -n 3 | $W verify +$W lab run qft -n 5 --gateset v1 # the frozen wai.quantum.circuit ``` -Circuits are `wai.quantum.circuit2` unless `--gateset v1` asks for +Circuits built by `lab` are `wai.quantum.circuit2` unless `--gateset v1` asks for `wai.quantum.circuit`, which reproduces the circuit and statevector hashes earlier releases gave. ### Same source, at the edge ```bash -cargo build --release --target wasm32-wasip2 --no-default-features \ - --features "quantum_toolchain,quantum_receipt" --bin wai-quantum +cd wai-quantum-cli && cargo build --release --target wasm32-wasip2 --no-default-features -wasmtime run target/wasm32-wasip2/release/wai-quantum.wasm run ghz -n 5 +wasmtime run target/wasm32-wasip2/release/wai-quantum.wasm lab run ghz -n 5 +wasmtime run target/wasm32-wasip2/release/wai-quantum.wasm lab receipt ghz -n 3 --demo-key ``` Both print the identical `state-hash`. A receipt sealed in cloud WASM verifies -natively and vice-versa, and the receipt bytes themselves are identical — the -signature travels because the computation is reproducible, not because the two -runtimes agreed to trust each other. +natively and vice-versa, and with the same key (here the published demo key) +the receipt bytes themselves are identical: the signature travels because the +computation is reproducible, not because the two runtimes agreed to trust each +other. ### As a serverless function diff --git a/wai/wai-quantum-cli/Cargo.lock b/wai/wai-quantum-cli/Cargo.lock new file mode 100644 index 000000000..ad80a8ed5 --- /dev/null +++ b/wai/wai-quantum-cli/Cargo.lock @@ -0,0 +1,1348 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[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 = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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 = "wai-quantum" +version = "0.6.10" +dependencies = [ + "blake3", + "ed25519-dalek", + "objc2", + "objc2-foundation", + "objc2-metal", + "serde", + "serde_json", +] + +[[package]] +name = "wai-quantum-cli" +version = "0.1.0" +dependencies = [ + "clap", + "clap_complete", + "getrandom 0.4.3", + "serde_json", + "ureq", + "wai-quantum", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/wai/wai-quantum-cli/Cargo.toml b/wai/wai-quantum-cli/Cargo.toml new file mode 100644 index 000000000..5e0976564 --- /dev/null +++ b/wai/wai-quantum-cli/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "wai-quantum-cli" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +description = "The wai-quantum command line: OpenQASM programs run on this machine as the quantum job service runs them (the strongest method each admits, its accuracy named, the result signed with the user's key), sent to the service, linted, drawn and verified; an MCP server for agents over stdio; and the research tools (error correction, DMRG, neural quantum states, resource estimates)." +repository = "https://git.transaction.science/Transaction-Science/open-standards" +homepage = "https://wai.transaction.science/quantum-run" +keywords = ["quantum", "openqasm", "simulation", "cli", "mcp"] +categories = ["command-line-utilities", "science", "simulation"] +readme = "README.md" + +[[bin]] +name = "wai-quantum" +path = "src/main.rs" + +[features] +default = ["service"] +# `submit`: send a job to the quantum job service over HTTPS. +service = ["dep:ureq"] +# The single-precision state vector on the GPU (`--method statevector_single`), on macOS. +metal = ["wai-quantum/quantum_sv_metal"] + +[dependencies] +clap = { version = "4.6", features = ["derive", "env", "wrap_help"] } +clap_complete = "4.6" +getrandom = "0.4" +# float_roundtrip: a number read back reads as the double it was, so a +# result's receipt still verifies after any JSON writer has carried it. +serde_json = { version = "1", features = ["float_roundtrip"] } +ureq = { version = "3", optional = true, features = ["json"] } +wai-quantum = { version = "0.6.10", path = "../wai-quantum", default-features = false, features = ["full"] } diff --git a/wai/wai-quantum-cli/README.md b/wai/wai-quantum-cli/README.md new file mode 100644 index 000000000..9a220b86f --- /dev/null +++ b/wai/wai-quantum-cli/README.md @@ -0,0 +1,129 @@ +# wai-quantum + +The command line of the WAI quantum stack. It runs OpenQASM programs on this machine, planned and run by the same engine as the quantum job service ([`wai_quantum::engine`](../wai-quantum/src/engine/mod.rs)), so a job gives the same result here as there. It also sends programs to the service, checks and draws them, verifies receipts, and serves its tools to agents over MCP. + +```sh +cargo install wai-quantum-cli # installs the `wai-quantum` binary +``` + +## Running a program + +```sh +wai-quantum run bell.qasm --shots 1000 +wai-quantum run prog.qasm -o "Z0 Z1" -o "0.5 X2" -a 000 -a 111 +wai-quantum run prog.qasm --noise depolarizing_2q=0.01 --noise readout=0.02 +wai-quantum run prog.qasm --method mps --option max_bond=128 +cat prog.qasm | wai-quantum run --json > job.json +``` + +A program is OpenQASM 3, or the OpenQASM 2 forms in common use. The reader accepts: + +- registers, and a gate applied to a whole register; +- the standard gates; +- `ctrl` and `negctrl` modifiers; +- gate definitions; +- measurement and `reset` anywhere, and `if` on measured bits. + +It refuses loops, `inv` and `pow`, giving the line of each. + +`--method auto` (the default) picks the strongest method the program admits: + +| Method | Takes | +|---|---| +| exact | dyadic angles; the state vector, the same bits on every machine | +| statevector | any angle | +| stabilizer | Clifford circuits, to tens of thousands of qubits | +| mps | matrix product states | +| pauli | Pauli dynamics, for expectation values | +| tensor_network | amplitudes | +| cutrank | a Clifford circuit with a layer of rotations | +| density_matrix, trajectories | noise | + +`wai-quantum methods` lists each method's reach in a job's memory here (`--memory-gb`, default 16). + +Each result names: + +- its method, and why that method was chosen; +- its accuracy class; +- the counts, expectation values or amplitudes asked for; +- its receipt. + +## Receipts + +Every result is signed over the request and the result in their canonical JSON. The service signs with its key; this tool signs with this user's key: + +- `--key FILE`, or `$WAI_QUANTUM_KEY`, names a file holding the key; +- otherwise the key is `$XDG_CONFIG_HOME/wai/quantum-key`, or `~/.config/wai/quantum-key`. It is made from the system's random source on first use and is readable by its owner only. + +`wai-quantum key` prints the key's `did:key`. `--demo-key` signs with the published demo key. Its receipts show only that a result is unaltered, not who signed it. + +```sh +wai-quantum run prog.qasm --json > job.json +wai-quantum verify job.json # VALID, and the signer's did:key +wai-quantum submit prog.qasm --json > svc.json +wai-quantum verify svc.json # the same check on the service's receipt +``` + +A receipt shows that this key signed this request and this result, and anyone can rerun the request to compare. It does not say who holds the key. + +## Checking and drawing + +```sh +wai-quantum lint prog.qasm # ok, with its shape, or the line of the first problem +wai-quantum plan prog.qasm # the method and why, the memory and work, without running +wai-quantum draw prog.qasm --width 80 +``` + +```text +q0: ─────H─────●──M0────── + │ +q1: ───────────⊕──●───M1── + │ +q2: ──Rz(π/4)─────⊕───M2── +``` + +## The service + +`wai-quantum submit` sends a job to `https://wai.transaction.science/quantum-jobs`, or to `--service URL` (also read from `$WAI_QUANTUM_SERVICE`). It waits for a long job to finish, then prints the result just as `run` does. + +## For agents + +`wai-quantum mcp` is a Model Context Protocol server on standard input and output. Its tools are: + +- `run_quantum_circuit` +- `plan_quantum_circuit` +- `lint_qasm` +- `draw_circuit` +- `verify_quantum_result` +- `list_methods` + +Runs happen on this machine and are signed with this user's key; nothing is sent anywhere. In an MCP client's configuration: + +```json +{ "mcpServers": { "wai-quantum": { "command": "wai-quantum", "args": ["mcp"] } } } +``` + +## The research tools + +`wai-quantum lab …` holds the research tools: + +- algorithms from the catalogue, OpenQASM and QIR export, and receipts over them; +- error correction at circuit level; +- qLDPC memory; +- fault-tolerant resource estimates and architectures; +- DMRG, TDVP and molecules; +- neural quantum states; +- Gaussian boson sampling; +- optimisation; +- the compute fabric; +- information and transport. + +`wai-quantum lab help` lists them. `wai-quantum verify` also checks the receipts they sign. + +## Builds + +- **Default:** `submit`, over HTTPS. +- **`--features metal`:** the single-precision state vector on the GPU (`--method statevector_single`), on macOS. +- **`--no-default-features --target wasm32-wasip2`:** a WASI component without `submit`. It gives the same output under any WASI runtime. + +`wai-quantum completions zsh` (or bash, fish, elvish, PowerShell) prints shell completion. diff --git a/wai/wai-quantum-cli/smoke.sh b/wai/wai-quantum-cli/smoke.sh new file mode 100644 index 000000000..bd4430188 --- /dev/null +++ b/wai/wai-quantum-cli/smoke.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# smoke.sh: the command line as a reader uses it, every check exact. +# +# bash smoke.sh path/to/wai-quantum [SERVICE_URL] +# +# With SERVICE_URL, `submit` sends one job to the service there and its +# receipt is checked here. Each command's output is written to a file before +# it is read, so no check depends on a pipe closing early. +set -euo pipefail +W="$1" +SVC="${2:-}" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +export XDG_CONFIG_HOME="$tmp/config" +unset WAI_QUANTUM_KEY +fail() { echo "smoke: $*" >&2; exit 1; } +has() { grep -q -- "$2" "$1" || { echo "--- $1:" >&2; cat "$1" >&2; fail "expected: $2"; }; } + +cat > "$tmp/ghz.qasm" <<'EOF' +OPENQASM 3.0; +include "stdgates.inc"; +qubit[3] q; +bit[3] c; +h q[0]; +cx q[0], q[1]; +cx q[1], q[2]; +c = measure q; +EOF + +"$W" lint "$tmp/ghz.qasm" > "$tmp/lint" +has "$tmp/lint" "ok — 3 qubits, 3 gates, 3 bits measured" +printf 'OPENQASM 3.0;\nqubit[1] q;\nfoo q[0];\n' > "$tmp/bad.qasm" +if "$W" lint "$tmp/bad.qasm" > "$tmp/lint-bad"; then fail "lint passed a program with an unknown gate"; fi +has "$tmp/lint-bad" "bad.qasm:3: unknown gate foo" +echo "lint: ok" + +"$W" plan "$tmp/ghz.qasm" --json > "$tmp/plan.json" +python3 -c 'import json,sys; p=json.load(open(sys.argv[1])); assert p["method"]=="stabilizer" and p["qubits"]==3, p' "$tmp/plan.json" +echo "plan: ok, stabilizer" + +"$W" run "$tmp/ghz.qasm" --shots 1000 -o "Z0 Z2" --json > "$tmp/job.json" 2> "$tmp/run.err" +has "$tmp/run.err" "made a signing key" +python3 - "$tmp/job.json" <<'EOF' +import json, sys +j = json.load(open(sys.argv[1])) +r = j["result"] +assert j["status"] == "done" and j["method"] == "stabilizer", j +assert set(r["counts"]) <= {"000", "111"} and sum(r["counts"].values()) == 1000, r["counts"] +assert r["expectations"][0]["value"] == 1.0, r["expectations"] +assert r["receipt"]["alg"] == "ed25519" and len(r["receipt"]["key"]) == 64, r["receipt"] +assert j["request"] == {"qasm": j["request"]["qasm"], "shots": 1000, "observables": ["Z0 Z2"]}, j["request"] +EOF +"$W" verify "$tmp/job.json" > "$tmp/verify" +has "$tmp/verify" "^signature VALID" +python3 -c 'import json,sys; j=json.load(open(sys.argv[1])); j["result"]["counts"]={"000":1000}; json.dump(j,open(sys.argv[2],"w"))' "$tmp/job.json" "$tmp/forged.json" +if "$W" verify "$tmp/forged.json" > "$tmp/verify-forged"; then fail "a changed result verified"; fi +has "$tmp/verify-forged" "^signature INVALID" +# The same user, the same key; the signer is the key's did:key. +"$W" key > "$tmp/key" 2> /dev/null +has "$tmp/key" "^did:key:z6Mk" +has "$tmp/verify" "^signer $(cat "$tmp/key")" +echo "run, verify, key: ok, signed by $(cat "$tmp/key")" + +"$W" run "$tmp/ghz.qasm" > "$tmp/run-text" +has "$tmp/run-text" "^method stabilizer" +has "$tmp/run-text" "^receipt verifies" +if "$W" run "$tmp/ghz.qasm" --shot 5 > /dev/null 2> "$tmp/typo"; then fail "an unknown flag was accepted"; fi +has "$tmp/typo" "unexpected argument '--shot'" +if "$W" run "$tmp/ghz.qasm" --noise depolarizing=0.1 > /dev/null 2> "$tmp/noise"; then fail "an unknown noise channel was accepted"; fi +has "$tmp/noise" "no such channel" +echo "refusals: ok" + +"$W" draw "$tmp/ghz.qasm" > "$tmp/draw" +has "$tmp/draw" "^q0: .*H.*●" +has "$tmp/draw" "^q2: .*⊕.*M2" +echo "draw: ok" + +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_quantum_circuit","arguments":{"qasm":"OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];","observables":["Z0 Z1"]}}}' \ + > "$tmp/mcp.in" +"$W" mcp < "$tmp/mcp.in" > "$tmp/mcp.out" 2> /dev/null +python3 - "$tmp/mcp.out" <<'EOF' +import json, sys +lines = [json.loads(l) for l in open(sys.argv[1])] +assert [m["id"] for m in lines] == [1, 2, 3], lines +assert lines[0]["result"]["serverInfo"]["name"] == "wai-quantum" +assert "run_quantum_circuit" in [t["name"] for t in lines[1]["result"]["tools"]] +job = lines[2]["result"]["structuredContent"] +assert job["result"]["expectations"][0]["value"] == 1.0, job +EOF +echo "mcp: ok" + +"$W" lab list > "$tmp/lab-list" +has "$tmp/lab-list" "^ghz " +"$W" lab receipt ghz -n 3 > "$tmp/lab-receipt" 2> /dev/null +"$W" verify "$tmp/lab-receipt" > "$tmp/lab-verify" +has "$tmp/lab-verify" "^signature VALID" +has "$tmp/lab-verify" "^signer $(cat "$tmp/key")" +echo "lab: ok" + +if [ -n "$SVC" ]; then + "$W" submit "$tmp/ghz.qasm" --shots 500 --service "$SVC" --json > "$tmp/svc.json" + "$W" verify "$tmp/svc.json" > "$tmp/svc-verify" + has "$tmp/svc-verify" "^signature VALID" + echo "submit: ok, the service's receipt verifies here" +fi + +echo "smoke: every check held" diff --git a/wai/wai-quantum-cli/src/draw.rs b/wai/wai-quantum-cli/src/draw.rs new file mode 100644 index 000000000..38decd98a --- /dev/null +++ b/wai/wai-quantum-cli/src/draw.rs @@ -0,0 +1,321 @@ +//! A program's circuit as text: one wire per qubit, gates packed into the +//! earliest column their qubits are free, a control as `●`, a CX target as +//! `⊕`, a swap as `×`, a measurement into bit `k` as `Mk`, a reset as `|0⟩`. +//! A gate under a condition carries `?n`, and the legend under the drawing +//! says what condition `n` is. Wide circuits wrap at `width` columns. + +use std::f64::consts::PI; +use wai_quantum::qasm::{Cond, Instr, Kind, Op, Program}; + +/// One thing drawn in a column: its label on each qubit it touches, and +/// whether a vertical line joins them. +struct Item { + cells: Vec<(u32, String)>, + joined: bool, +} + +/// `x` as a multiple of π where it is one with a small denominator, else in +/// decimals. +pub fn angle(x: f64) -> String { + let r = x / PI; + for d in [1i64, 2, 3, 4, 6, 8, 12, 16] { + let n = r * d as f64; + if (n - n.round()).abs() < 1e-9 { + let n = n.round() as i64; + let g = gcd(n.abs(), d); + let (n, d) = (n / g, d / g); + return match (n, d) { + (0, _) => "0".into(), + (1, 1) => "π".into(), + (-1, 1) => "-π".into(), + (n, 1) => format!("{n}π"), + (1, d) => format!("π/{d}"), + (-1, d) => format!("-π/{d}"), + (n, d) => format!("{n}π/{d}"), + }; + } + } + let t = format!("{x:.4}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn gcd(a: i64, b: i64) -> i64 { + if b == 0 { a.max(1) } else { gcd(b, a % b) } +} + +fn params(p: &[f64]) -> String { + p.iter().map(|&x| angle(x)).collect::>().join(",") +} + +/// The cells of one gate. +fn gate(op: &Op) -> Item { + use Kind::*; + let q = &op.qubits; + let p = params(&op.params); + let one = |l: &str| Item { cells: vec![(q[0], l.to_string())], joined: false }; + let ctl = |rest: Vec<(u32, String)>| { + let mut cells = vec![(q[0], "●".to_string())]; + cells.extend(rest); + Item { cells, joined: true } + }; + match op.kind { + Id => one("I"), + X => one("X"), + Y => one("Y"), + Z => one("Z"), + H => one("H"), + S => one("S"), + Sdg => one("S†"), + T => one("T"), + Tdg => one("T†"), + Sx => one("√X"), + Sxdg => one("√X†"), + Rx => one(&format!("Rx({p})")), + Ry => one(&format!("Ry({p})")), + Rz => one(&format!("Rz({p})")), + P => one(&format!("P({p})")), + U => one(&format!("U({p})")), + Cx => ctl(vec![(q[1], "⊕".into())]), + Cy => ctl(vec![(q[1], "Y".into())]), + Cz => ctl(vec![(q[1], "●".into())]), + Ch => ctl(vec![(q[1], "H".into())]), + Cp => ctl(vec![(q[1], format!("P({p})"))]), + Crx => ctl(vec![(q[1], format!("Rx({p})"))]), + Cry => ctl(vec![(q[1], format!("Ry({p})"))]), + Crz => ctl(vec![(q[1], format!("Rz({p})"))]), + Cu => ctl(vec![(q[1], format!("U({p})"))]), + Swap => Item { cells: vec![(q[0], "×".into()), (q[1], "×".into())], joined: true }, + Iswap => Item { cells: vec![(q[0], "iSWAP".into()), (q[1], "iSWAP".into())], joined: true }, + Rxx => Item { cells: vec![(q[0], format!("Rxx({p})")), (q[1], format!("Rxx({p})"))], joined: true }, + Ryy => Item { cells: vec![(q[0], format!("Ryy({p})")), (q[1], format!("Ryy({p})"))], joined: true }, + Rzz => Item { cells: vec![(q[0], format!("Rzz({p})")), (q[1], format!("Rzz({p})"))], joined: true }, + Ccx => Item { cells: vec![(q[0], "●".into()), (q[1], "●".into()), (q[2], "⊕".into())], joined: true }, + Ccz => Item { cells: vec![(q[0], "●".into()), (q[1], "●".into()), (q[2], "●".into())], joined: true }, + Cswap => Item { cells: vec![(q[0], "●".into()), (q[1], "×".into()), (q[2], "×".into())], joined: true }, + } +} + +/// A condition as the legend writes it: the bits, highest first, read as an +/// unsigned integer. +fn condition(c: &Cond) -> String { + let op = if c.equal { "=" } else { "≠" }; + match c.bits.as_slice() { + [b] => format!("if bit {b} {op} {}", c.value), + bits => { + let names: Vec = bits.iter().rev().map(|b| b.to_string()).collect(); + format!("if bits ({}) {op} {}", names.join(" "), c.value) + } + } +} + +/// The items to draw, in order, and the legend of conditions. +fn items(p: &Program) -> (Vec, Vec) { + let mut out = Vec::new(); + let mut legend = Vec::new(); + let Some(steps) = &p.dynamic else { + out.extend(p.ops.iter().map(gate)); + out.extend(p.measured.iter().map(|&(q, b)| Item { cells: vec![(q, format!("M{b}"))], joined: false })); + return (out, legend); + }; + // Each step's condition: a JumpUnless guards the steps up to its target; + // a Jump at the end of that region skips an else branch, up to its own. + let mut guard: Vec> = vec![None; steps.len()]; + for (i, s) in steps.iter().enumerate() { + if let Instr::JumpUnless(c, to) = s { + legend.push(condition(c)); + let n = legend.len(); + let then_end = *to; + let else_end = match steps.get(then_end.wrapping_sub(1)) { + Some(Instr::Jump(e)) if then_end > i + 1 => Some(*e), + _ => None, + }; + for g in guard.iter_mut().take(then_end.min(steps.len())).skip(i + 1) { + g.get_or_insert(n); + } + if let Some(e) = else_end { + legend.push(format!("else of ?{n}")); + let m = legend.len(); + for g in guard.iter_mut().take(e.min(steps.len())).skip(then_end) { + g.get_or_insert(m); + } + } + } + } + for (i, s) in steps.iter().enumerate() { + let mut item = match s { + Instr::Gate(k) => gate(&p.ops[*k]), + Instr::Measure { qubit, bit } => Item { cells: vec![(*qubit, format!("M{bit}"))], joined: false }, + Instr::Reset(q) => Item { cells: vec![(*q, "|0⟩".into())], joined: false }, + Instr::JumpUnless(..) | Instr::Jump(_) => continue, + }; + if let Some(n) = guard[i] { + for (_, l) in item.cells.iter_mut() { + l.push_str(&format!("?{n}")); + } + } + out.push(item); + } + (out, legend) +} + +/// The drawing, wrapped at `width` columns. +pub fn draw(p: &Program, width: usize) -> String { + let n = p.n_qubits as usize; + if n == 0 { + return "(no qubits)\n".into(); + } + let (items, legend) = items(p); + // Columns: each item goes to the first column after the last one used on + // any wire it covers. + let mut free = vec![0usize; n]; + let mut columns: Vec> = Vec::new(); + for it in items { + let lo = it.cells.iter().map(|c| c.0 as usize).min().unwrap_or(0); + let hi = if it.joined { it.cells.iter().map(|c| c.0 as usize).max().unwrap_or(lo) } else { lo }; + let col = free[lo..=hi].iter().copied().max().unwrap_or(0); + for f in &mut free[lo..=hi] { + *f = col + 1; + } + if columns.len() <= col { + columns.resize_with(col + 1, Vec::new); + } + columns[col].push(it); + } + let label_w = format!("q{}", n - 1).chars().count(); + let prefix = |q: usize| format!("{:>label_w$}: ", format!("q{q}")); + let blank = " ".repeat(label_w + 2); + // Each column's text per wire, and its joins between wire q and q + 1. + let mut blocks: Vec<(usize, Vec, Vec)> = Vec::new(); + for col in &columns { + let w = col.iter().flat_map(|it| it.cells.iter().map(|c| c.1.chars().count())).max().unwrap_or(1).max(1); + let mut wires = vec![None::; n]; + let mut through = vec![false; n]; + let mut joins = vec![false; n]; + for it in col { + for (q, l) in &it.cells { + wires[*q as usize] = Some(l.clone()); + } + if it.joined { + let lo = it.cells.iter().map(|c| c.0 as usize).min().unwrap(); + let hi = it.cells.iter().map(|c| c.0 as usize).max().unwrap(); + for (q, t) in through.iter_mut().enumerate().take(hi).skip(lo + 1) { + *t = wires[q].is_none(); + } + for j in joins.iter_mut().take(hi).skip(lo) { + *j = true; + } + } + } + let text: Vec = (0..n) + .map(|q| { + let l = match &wires[q] { + Some(l) => l.clone(), + None if through[q] => "┼".into(), + None => "─".into(), + }; + let pad = w - l.chars().count(); + format!("─{}{l}{}─", "─".repeat(pad / 2), "─".repeat(pad - pad / 2)) + }) + .collect(); + blocks.push((w + 2, text, joins)); + } + let mut out = String::new(); + let avail = width.saturating_sub(label_w + 4).max(8); + let mut start = 0; + while start < blocks.len() || (start == 0 && blocks.is_empty()) { + let mut end = start; + let mut used = 0; + while end < blocks.len() && (end == start || used + blocks[end].0 <= avail) { + used += blocks[end].0; + end += 1; + } + if start > 0 { + out.push('\n'); + } + for q in 0..n { + out.push_str(&prefix(q)); + out.push('─'); + for b in &blocks[start..end] { + out.push_str(&b.1[q]); + } + out.push_str("─\n"); + if q + 1 < n { + let mut line = blank.clone(); + line.push(' '); + for b in &blocks[start..end] { + // Where a one-character label sits in this column. + let w = b.0; + let mid = 1 + (w - 3) / 2; + let mut cell = vec![' '; w]; + if b.2[q] { + cell[mid] = '│'; + } + line.extend(cell); + } + out.push_str(line.trim_end()); + out.push('\n'); + } + } + if end == start { + break; + } + start = end; + } + for (i, l) in legend.iter().enumerate() { + out.push_str(&format!("?{} {l}\n", i + 1)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use wai_quantum::qasm::parse; + + #[test] + fn angles_are_written_as_multiples_of_pi_where_they_are() { + assert_eq!(angle(PI / 4.0), "π/4"); + assert_eq!(angle(-PI / 2.0), "-π/2"); + assert_eq!(angle(3.0 * PI / 4.0), "3π/4"); + assert_eq!(angle(2.0 * PI), "2π"); + assert_eq!(angle(0.0), "0"); + assert_eq!(angle(0.3), "0.3"); + } + + #[test] + fn a_bell_pair_is_h_then_a_controlled_x_joined_across_the_wires() { + let d = draw(&parse("OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];").unwrap(), 100); + let lines: Vec<&str> = d.lines().collect(); + assert_eq!(lines.len(), 3, "{d}"); + assert!(lines[0].starts_with("q0: ") && lines[0].contains('H') && lines[0].contains('●'), "{d}"); + assert!(lines[1].contains('│'), "{d}"); + assert!(lines[2].starts_with("q1: ") && lines[2].contains('⊕') && !lines[2].contains('H'), "{d}"); + // The control sits after H, in the column of the target. + assert_eq!(lines[0].chars().position(|c| c == '●'), lines[2].chars().position(|c| c == '⊕'), "{d}"); + } + + #[test] + fn gates_on_different_wires_share_a_column_and_a_gate_between_crosses_the_middle() { + let d = draw(&parse("qubit[3] q; h q[0]; h q[1]; h q[2]; cx q[0], q[2];").unwrap(), 100); + let lines: Vec<&str> = d.lines().collect(); + let h_at: Vec> = [0, 2, 4].iter().map(|&i| lines[i].chars().position(|c| c == 'H')).collect(); + assert!(h_at.iter().all(|&p| p == h_at[0]), "the three H share a column: {d}"); + assert!(lines[2].contains('┼'), "the CX passes through q1: {d}"); + } + + #[test] + fn a_measurement_names_its_bit_and_a_conditioned_gate_its_condition() { + let d = draw(&parse("OPENQASM 3.0; qubit[2] q; bit[2] c; h q[0]; c[0] = measure q[0]; if (c[0]) x q[1]; c[1] = measure q[1];").unwrap(), 100); + assert!(d.contains("M0") && d.contains("M1"), "{d}"); + assert!(d.contains("X?1") && d.contains("?1 if bit 0 = 1"), "{d}"); + } + + #[test] + fn a_long_circuit_wraps_and_keeps_every_gate() { + let src = format!("qubit[2] q; {}", "h q[0]; cx q[0], q[1]; ".repeat(40)); + let d = draw(&parse(&src).unwrap(), 60); + assert!(d.lines().all(|l| l.chars().count() <= 60), "{d}"); + assert_eq!(d.matches('H').count(), 40, "{d}"); + assert_eq!(d.matches('⊕').count(), 40, "{d}"); + } +} diff --git a/wai/wai-quantum-cli/src/keys.rs b/wai/wai-quantum-cli/src/keys.rs new file mode 100644 index 000000000..e71ffbfad --- /dev/null +++ b/wai/wai-quantum-cli/src/keys.rs @@ -0,0 +1,135 @@ +//! The key a receipt is signed with. +//! +//! - `--demo-key`: the published demo key, for receipts that repeat byte for +//! byte everywhere. Anyone can sign with it, so it proves nothing about who +//! signed. +//! - `--key FILE`, or `$WAI_QUANTUM_KEY` naming a file: that key. +//! - Otherwise this user's key, `$XDG_CONFIG_HOME/wai/quantum-key` (or +//! `~/.config/wai/quantum-key`), made from the operating system's random +//! source on first use and readable by its owner only. +//! +//! A key file holds the 32-byte Ed25519 seed as 64 hex digits. + +use std::path::{Path, PathBuf}; +use wai_quantum::ed25519_dalek::SigningKey; + +/// The published demo key's seed. +pub const DEMO_SEED: [u8; 32] = [42; 32]; + +/// The key to sign with, and where it came from. +pub struct Key { + pub key: SigningKey, + /// The file it was read from or made in; `None` for the demo key. + pub path: Option, + /// Whether this call made the file. + pub made: bool, +} + +/// This user's key file: `$XDG_CONFIG_HOME/wai/quantum-key`, or +/// `~/.config/wai/quantum-key`. +pub fn user_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".config")))?; + Some(base.join("wai").join("quantum-key")) +} + +fn parse(text: &str) -> Option<[u8; 32]> { + let t = text.trim(); + if t.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (i, b) in out.iter_mut().enumerate() { + *b = u8::from_str_radix(t.get(2 * i..2 * i + 2)?, 16).ok()?; + } + Some(out) +} + +/// The key `demo` and `given` (`--demo-key`, `--key FILE`) name: a named file +/// must exist; this user's own is made when there is none. +pub fn load(given: Option<&Path>, demo: bool) -> Result { + if demo { + return Ok(Key { key: SigningKey::from_bytes(&DEMO_SEED), path: None, made: false }); + } + let named = given.map(Path::to_path_buf).or_else(|| std::env::var_os("WAI_QUANTUM_KEY").map(PathBuf::from)); + let path = match &named { + Some(p) => p.clone(), + None => user_path().ok_or("no HOME to keep a key in: pass --key FILE")?, + }; + match std::fs::read_to_string(&path) { + Ok(text) => { + let seed = parse(&text).ok_or_else(|| format!("{}: a key file holds 64 hex digits", path.display()))?; + Ok(Key { key: SigningKey::from_bytes(&seed), path: Some(path), made: false }) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound && named.is_none() => { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|e| format!("no random source for a new key: {e}"))?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + let text: String = seed.iter().map(|b| format!("{b:02x}")).collect(); + write_private(&path, &format!("{text}\n")).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(Key { key: SigningKey::from_bytes(&seed), path: Some(path), made: true }) + } + Err(e) => Err(format!("{}: {e}", path.display())), + } +} + +fn write_private(path: &Path, text: &str) -> std::io::Result<()> { + use std::io::Write as _; + let mut f = std::fs::OpenOptions::new(); + f.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + f.mode(0o600); + } + f.open(path)?.write_all(text.as_bytes()) +} + +/// What `--demo-key` and a new key say on standard error. +pub fn notes(k: &Key) { + match &k.path { + None => eprintln!("note: signed with the published demo key; the receipt shows the result is unaltered, not who signed it"), + Some(p) if k.made => eprintln!("note: made a signing key at {}", p.display()), + Some(_) => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_key_is_made_once_private_then_read_and_a_named_file_must_exist() { + let dir = std::env::temp_dir().join(format!("wai-quantum-cli-key-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("k"); + // A named file that is not there is refused, never made. + assert!(load(Some(&path), false).is_err()); + assert!(!path.exists()); + // The demo key is the published one. + let demo = load(None, true).unwrap(); + assert_eq!(demo.key.to_bytes(), DEMO_SEED); + assert!(demo.path.is_none()); + // A written key reads back; a malformed one is refused. + std::fs::create_dir_all(&dir).unwrap(); + let seed: String = (0..32).map(|i| format!("{:02x}", i * 7)).collect(); + std::fs::write(&path, format!("{seed}\n")).unwrap(); + let k = load(Some(&path), false).unwrap(); + assert_eq!(k.key.to_bytes()[1], 7); + assert!(!k.made); + std::fs::write(&path, "not a key").unwrap(); + assert!(load(Some(&path), false).is_err()); + // A made key is the owner's alone. + let fresh = dir.join("fresh"); + write_private(&fresh, "x").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777, 0o600); + } + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/wai/wai-rs/src/bin/wai_quantum.rs b/wai/wai-quantum-cli/src/lab.rs similarity index 86% rename from wai/wai-rs/src/bin/wai_quantum.rs rename to wai/wai-quantum-cli/src/lab.rs index 535f1574c..b81b29ccf 100644 --- a/wai/wai-rs/src/bin/wai_quantum.rs +++ b/wai/wai-quantum-cli/src/lab.rs @@ -1,4 +1,5 @@ -//! `wai-quantum` — the deterministic quantum stack as a command-line tool. +//! `wai-quantum lab`: the research tools of the command line, the deterministic +//! quantum stack's every capability. //! //! Every quantum capability in this crate is pure Rust with no host bindings, so //! this binary targets **native and cloud WASM from one source**: @@ -11,12 +12,12 @@ //! and corpus digest. //! //! ```text -//! wai-quantum list -//! wai-quantum run ghz --qubits 5 [--top 8] [--json] -//! wai-quantum run --qasm # read OpenQASM on stdin -//! wai-quantum qasm bell # emit OpenQASM -//! wai-quantum receipt ghz -n 3 [--shots 4096 --seed 7] -//! wai-quantum verify # read a receipt JSON on stdin +//! wai-quantum lab list +//! wai-quantum lab run ghz --qubits 5 [--top 8] [--json] +//! wai-quantum lab run --qasm # read OpenQASM on stdin +//! wai-quantum lab qasm bell # emit OpenQASM +//! wai-quantum lab receipt ghz -n 3 [--shots 4096 --seed 7] +//! wai-quantum lab verify # read a receipt JSON on stdin //! ``` //! //! A circuit is built in `wai.quantum.circuit2`, whose phase table is correctly @@ -29,20 +30,20 @@ //! what transport does to it: //! //! ```text -//! wai-quantum entropy bell --keep 0 # half a Bell pair: 1 bit, and no state vector -//! wai-quantum channel depolarizing --prob 0.5 -//! wai-quantum bb84 --eavesdrop # interception shows up in the error rate -//! wai-quantum chsh # 2√2, exactly, not sampled -//! wai-quantum qutrit # d = 3, not just qubits +//! wai-quantum lab entropy bell --keep 0 # half a Bell pair: 1 bit, and no state vector +//! wai-quantum lab channel depolarizing --prob 0.5 +//! wai-quantum lab bb84 --eavesdrop # interception shows up in the error rate +//! wai-quantum lab chsh # 2√2, exactly, not sampled +//! wai-quantum lab qutrit # d = 3, not just qubits //! ``` //! //! At utility scale, sparse Pauli dynamics computes observables of circuits far //! past any state vector, with arbitrary rotation angles: //! //! ```text -//! wai-quantum utility --theta 0.2 --steps 20 # the 127-qubit heavy-hex experiment -//! wai-quantum utility --method bptn --chi 32 # the same, by tensor networks -//! wai-quantum utility --receipt | wai-quantum verify +//! wai-quantum lab utility --theta 0.2 --steps 20 # the 127-qubit heavy-hex experiment +//! wai-quantum lab utility --method bptn --chi 32 # the same, by tensor networks +//! wai-quantum lab utility --receipt | wai-quantum verify //! ``` //! //! And for fault-tolerant machines that do not exist yet, what a program would @@ -50,10 +51,10 @@ //! signed estimate: //! //! ```text -//! wai-quantum estimate factoring # 2048-bit factoring, all six qubits -//! wai-quantum estimate chemistry --qubit ns-e4 --frontier -//! wai-quantum estimate --qubits 100 --ccz 1e6 --budget 0.01 -//! wai-quantum estimate factoring --qubit ns-e4 --receipt | wai-quantum verify +//! wai-quantum lab estimate factoring # 2048-bit factoring, all six qubits +//! wai-quantum lab estimate chemistry --qubit ns-e4 --frontier +//! wai-quantum lab estimate --qubits 100 --ccz 1e6 --budget 0.01 +//! wai-quantum lab estimate factoring --qubit ns-e4 --receipt | wai-quantum verify //! ``` //! //! Error correction itself, at circuit level: sample a noisy Clifford circuit, @@ -61,9 +62,9 @@ //! model that estimates otherwise take on trust: //! //! ```text -//! wai-quantum qec -d 5 --p 0.003 --shots 100000 -//! wai-quantum qec --file circuit.txt -//! wai-quantum qec --fit # then: estimate --prefactor A --threshold P +//! wai-quantum lab qec -d 5 --p 0.003 --shots 100000 +//! wai-quantum lab qec --file circuit.txt +//! wai-quantum lab qec --fit # then: estimate --prefactor A --threshold P //! ``` //! //! The `channel` command prints the Holevo bound before and after, which can only @@ -74,19 +75,19 @@ use wai_quantum::ed25519_dalek::SigningKey; use std::io::Read; -use wai::quantum::{BaseGate, Circuit, Gateset, Measure, ONE}; -use wai::quantum_channel::{tomography, Channel}; -use wai::quantum_comm::{bb84, clone_fidelity}; -use wai::quantum_info::{chsh, DensityMatrix}; -use wai::quantum_qudit::QuditState; -use wai::quantum_energy::{EnergyClass, MaybeLabelled}; -use wai::quantum_receipt::QuantumReceipt; -use wai::signer_id::did_key; -use wai::quantum_resource::{self as qr, EstimateConfig, LogicalCounts, PhysicalQubit, QecModel}; -use wai::quantum_resource_receipt::{Program, ResourceJob, ResourceReceipt}; -use wai::quantum_spd_receipt::{SpdJob, SpdReceipt}; -use wai::quantum_source::{holevo_bound, schumacher_limit, von_neumann_entropy}; -use wai::quantum_toolchain as qt; +use wai_quantum::quantum::{BaseGate, Circuit, Gateset, Measure, ONE}; +use wai_quantum::quantum_channel::{tomography, Channel}; +use wai_quantum::quantum_comm::{bb84, clone_fidelity}; +use wai_quantum::quantum_info::{chsh, DensityMatrix}; +use wai_quantum::quantum_qudit::QuditState; +use wai_quantum::quantum_energy::{EnergyClass, MaybeLabelled}; +use wai_quantum::quantum_receipt::QuantumReceipt; +use wai_quantum::quantum_receipt::did_key; +use wai_quantum::quantum_resource::{self as qr, EstimateConfig, LogicalCounts, PhysicalQubit, QecModel}; +use wai_quantum::quantum_resource_receipt::{Program, ResourceJob, ResourceReceipt}; +use wai_quantum::quantum_spd_receipt::{SpdJob, SpdReceipt}; +use wai_quantum::quantum_source::{holevo_bound, schumacher_limit, von_neumann_entropy}; +use wai_quantum::quantum_toolchain as qt; /// The three single-qubit states the channel command sends: the ground state, the /// excited one, and the superposition whose coherence noise actually destroys. @@ -142,75 +143,75 @@ fn die(msg: &str) -> ! { fn usage() -> ! { eprintln!( - "wai-quantum — deterministic quantum computation (native + cloud WASM)\n\n\ + "wai-quantum lab — the research tools (run `wai-quantum --help` for run, plan, lint, draw, verify and the rest)\n\n\ USAGE:\n \ - wai-quantum list\n \ - wai-quantum run [-n QUBITS] [-p PARAM] [--top K] [--json] [--gateset v1|v2]\n \ - wai-quantum run --qasm [--top K] [--json] [--gateset v1|v2] (OpenQASM on stdin)\n \ - wai-quantum run --qir [--top K] [--json] [--gateset v1|v2] (QIR on stdin)\n \ - wai-quantum qasm [-n QUBITS] [-p PARAM] [--gateset v1|v2]\n \ - wai-quantum qir [-n QUBITS] [-p PARAM] [--no-measure] [--gateset v1|v2]\n \ - wai-quantum receipt [-n QUBITS] [-p PARAM] [--shots N] [--seed S] [--gateset v1|v2] [--key FILE | --demo-key]\n \ - wai-quantum verify (receipt JSON on stdin)\n \ + wai-quantum lab list\n \ + wai-quantum lab run [-n QUBITS] [-p PARAM] [--top K] [--json] [--gateset v1|v2]\n \ + wai-quantum lab run --qasm [--top K] [--json] [--gateset v1|v2] (OpenQASM on stdin)\n \ + wai-quantum lab run --qir [--top K] [--json] [--gateset v1|v2] (QIR on stdin)\n \ + wai-quantum lab qasm [-n QUBITS] [-p PARAM] [--gateset v1|v2]\n \ + wai-quantum lab qir [-n QUBITS] [-p PARAM] [--no-measure] [--gateset v1|v2]\n \ + wai-quantum lab receipt [-n QUBITS] [-p PARAM] [--shots N] [--seed S] [--gateset v1|v2] [--key FILE | --demo-key]\n \ + wai-quantum lab verify (receipt JSON on stdin)\n \ --gateset v2 (the default) is wai.quantum.circuit2; v1 is the frozen wai.quantum.circuit.\n\n\ INFORMATION AND TRANSPORT:\n \ - wai-quantum entropy [-n QUBITS] [--keep 0,1] [--json] [--gateset v1|v2]\n \ - wai-quantum channel [--prob P] [--state zero|one|plus] [--json]\n \ - wai-quantum bb84 [--rounds N] [--seed S] [--eavesdrop] [--json]\n \ - wai-quantum chsh [--json]\n \ - wai-quantum qutrit [-n QUTRITS] [--json]\n\n\ + wai-quantum lab entropy [-n QUBITS] [--keep 0,1] [--json] [--gateset v1|v2]\n \ + wai-quantum lab channel [--prob P] [--state zero|one|plus] [--json]\n \ + wai-quantum lab bb84 [--rounds N] [--seed S] [--eavesdrop] [--json]\n \ + wai-quantum lab chsh [--json]\n \ + wai-quantum lab qutrit [-n QUTRITS] [--json]\n\n\ UTILITY SCALE:\n \ - wai-quantum utility [--theta T] [--steps N] [--threshold D] [-q QUBIT] [--json] [--receipt]\n \ - wai-quantum utility --method bptn [--chi X] [--theta T] [--steps N] [-q QUBIT] [--json]\n\n\ + wai-quantum lab utility [--theta T] [--steps N] [--threshold D] [-q QUBIT] [--json] [--receipt]\n \ + wai-quantum lab utility --method bptn [--chi X] [--theta T] [--steps N] [-q QUBIT] [--json]\n\n\ FAULT-TOLERANT RESOURCES:\n \ - wai-quantum estimate [factoring|chemistry] [--qubit NAME|all] [--code surface|surface-meas|floquet]\n \ + wai-quantum lab estimate [factoring|chemistry] [--qubit NAME|all] [--code surface|surface-meas|floquet]\n \ [--budget E] [--slowdown N] [--frontier] [--json] [--receipt]\n \ - wai-quantum estimate --qubits N [--ccz N] [--t N] [--rotations N] [--depth N] [--measurements N] ...\n \ + wai-quantum lab estimate --qubits N [--ccz N] [--t N] [--rotations N] [--depth N] [--measurements N] ...\n \ Qubits: us-e3, us-e4, ns-e3, ns-e4, meas-e4, meas-e6. A measured code: --prefactor A --threshold P.\n\n\ ERROR CORRECTION AT CIRCUIT LEVEL:\n \ - wai-quantum qec [-d D] [--rounds R] [--p P] [--shots N] [--seed S] [--decoder exact|uf] [--json]\n \ - wai-quantum qec --file CIRCUIT [--shots N] (the ecosystem's circuit text)\n \ - wai-quantum qec [--circuit | --dem] [-d D | --file CIRCUIT]\n \ - wai-quantum qec --fit [--shots N] (measure a and p* of the code model)\n\n\ + wai-quantum lab qec [-d D] [--rounds R] [--p P] [--shots N] [--seed S] [--decoder exact|uf] [--json]\n \ + wai-quantum lab qec --file CIRCUIT [--shots N] (the ecosystem's circuit text)\n \ + wai-quantum lab qec [--circuit | --dem] [-d D | --file CIRCUIT]\n \ + wai-quantum lab qec --fit [--shots N] (measure a and p* of the code model)\n\n\ GAUSSIAN BOSON SAMPLING:\n \ - wai-quantum gbs --squeezing r1,r2,… [--eta E] [--seed S | --unitary U.json] [--json]\n \ - wai-quantum gbs … --sample K (exact click patterns)\n \ - wai-quantum gbs … --check SAMPLES.txt (observed patterns vs exact marginals)\n\n\ + wai-quantum lab gbs --squeezing r1,r2,… [--eta E] [--seed S | --unitary U.json] [--json]\n \ + wai-quantum lab gbs … --sample K (exact click patterns)\n \ + wai-quantum lab gbs … --check SAMPLES.txt (observed patterns vs exact marginals)\n\n\ SPIN CHAINS (DMRG ground states, exact referees):\n \ - wai-quantum dmrg [--chain ising|heisenberg] [-n N] [--j J] [--jz JZ] [--h H] [--bond D] [--sweeps S]\n \ + wai-quantum lab dmrg [--chain ising|heisenberg] [-n N] [--j J] [--jz JZ] [--h H] [--bond D] [--sweeps S]\n \ [--cutoff C] [--exact] [--json]\n \ - wai-quantum tdvp [--chain ising|heisenberg] [-n N] [--j J] [--jz JZ] [--h H] [--start plus|neel|up]\n \ + wai-quantum lab tdvp [--chain ising|heisenberg] [-n N] [--j J] [--jz JZ] [--h H] [--start plus|neel|up]\n \ [--bond D] [--dt DT] [--t T] [--every K] [--cutoff C] [--json] (time evolution by TDVP)\n\n\ MOLECULES (DMRG from FCIDUMP integrals, full CI as referee):\n \ - wai-quantum chem --fcidump FILE [--bond D] [--sweeps S] [--penalty P] [--no-fci] [--json]\n\n\ + wai-quantum lab chem --fcidump FILE [--bond D] [--sweeps S] [--penalty P] [--no-fci] [--json]\n\n\ NEURAL QUANTUM STATES (RBM, variational Monte Carlo, stochastic reconfiguration):\n \ - wai-quantum nqs [--model heisenberg|j1j2|ising] [--lx L] [--ly L] [--j2 J] [--h H] [--periodic]\n \ + wai-quantum lab nqs [--model heisenberg|j1j2|ising] [--lx L] [--ly L] [--j2 J] [--h H] [--periodic]\n \ [--complex] [--project [--kpi] [--point-group a1|a2|b1|b2 | --reflections a1|a2|b1|b2]\n \ [--odd]] [--alpha A] [--steps N] [--samples S] [--lr R]\n \ [--init W] [--guide ε] [--exact] [--seed S] [--json]\n\n\ THE FABRIC (members, parity against the CPU reference, placement):\n \ - wai-quantum fabric [--circuits N] [--seed S] [--gateset v1|v2] [--json]\n\n\ + wai-quantum lab fabric [--circuits N] [--seed S] [--gateset v1|v2] [--json]\n\n\ OPTIMISATION (simulated bifurcation, annealing, exact):\n \ - wai-quantum qio (--gset FILE | --sk N [--instance S]) [--method dsb|bsb|hdsb|hbsb|sa|exact]\n \ + wai-quantum lab qio (--gset FILE | --sk N [--instance S]) [--method dsb|bsb|hdsb|hbsb|sa|exact]\n \ [--steps N] [--trials T] [--dt D] [--c1 C] [--gamma G] [--beta0 B --beta1 B] [--descend]\n \ [--target V] [--seed S] [--threads T] [--spins] [--json]\n\n\ QLDPC MEMORY (circuit level, BP-OSD):\n \ - wai-quantum ldpc [--code 72|90|108|144|288] [--p P] [--cycles N] [--shots S] [--basis z|x|both]\n \ + wai-quantum lab ldpc [--code 72|90|108|144|288] [--p P] [--cycles N] [--shots S] [--basis z|x|both]\n \ [--max-iter I] [--osd-order K] [--threads T] [--seed S] [--json]\n \ - wai-quantum ldpc --circuit [--code …] [--p P] [--cycles N] [--basis z|x] (the circuit text)\n\n\ + wai-quantum lab ldpc --circuit [--code …] [--p P] [--cycles N] [--basis z|x] (the circuit text)\n\n\ ARCHITECTURE:\n \ - wai-quantum arch factoring [--bits 1024|1536|2048|3072|4096|6144|8192] [--json]\n \ - wai-quantum arch storage [--target T] [--fits refit|uniform|si1000] [--json]\n \ - wai-quantum arch cultivate [--p 5e-4|1e-3|2e-3] [--target T] [--json]\n\n\ + wai-quantum lab arch factoring [--bits 1024|1536|2048|3072|4096|6144|8192] [--json]\n \ + wai-quantum lab arch storage [--target T] [--fits refit|uniform|si1000] [--json]\n \ + wai-quantum lab arch cultivate [--p 5e-4|1e-3|2e-3] [--target T] [--json]\n\n\ NOISY CIRCUITS (trajectories, exact density matrix, cross-entropy):\n \ - wai-quantum noisy --file CIRCUIT.jsonl [-n N] [--depol1 P] [--depol2 P] [--damping G] [--dephasing P]\n \ + wai-quantum lab noisy --file CIRCUIT.jsonl [-n N] [--depol1 P] [--depol2 P] [--damping G] [--dephasing P]\n \ [--readout P] [--trajectories T] [--seed S] [--expect \"Z0 Z1; X3\"] [--xeb] [--exact] [--json]\n\n\ STATE VECTOR (any gate, any angle):\n \ - wai-quantum sv --file CIRCUIT.jsonl [-n N] [--fuse W] [--top K | --sample S [--seed X] | --expect \"Z0 Z1; X3\"] [--json]\n\n\ + wai-quantum lab sv --file CIRCUIT.jsonl [-n N] [--fuse W] [--top K | --sample S [--seed X] | --expect \"Z0 Z1; X3\"] [--json]\n\n\ TENSOR NETWORKS:\n \ - wai-quantum tn --file CIRCUIT.jsonl [-n N] [--bits B] [--open q,q] [--trials T] [--seed S] [--cap-log2 L] [--whole-gates] [--plan-only] [--json]\n\n\ + wai-quantum lab tn --file CIRCUIT.jsonl [-n N] [--bits B] [--open q,q] [--trials T] [--seed S] [--cap-log2 L] [--whole-gates] [--plan-only] [--json]\n\n\ Channels: identity, bitflip, dephasing, depolarizing, damping.\n\ - Run `wai-quantum list` for the algorithm catalog." + Run `wai-quantum lab list` for the algorithm catalog." ); std::process::exit(2); } @@ -223,62 +224,16 @@ fn read_stdin() -> String { s } -/// Pull `--flag VALUE` out of the argument list. -/// The key a receipt is signed with: -/// - `--demo-key`: the published demo key, for receipts that repeat byte for -/// byte everywhere. Anyone can sign with it, so it proves nothing about who -/// signed, and a note on stderr says so; -/// - `--key FILE`, or `$WAI_QUANTUM_KEY` naming a file: that key; -/// - otherwise this user's key, `$XDG_CONFIG_HOME/wai/quantum-key` (or -/// `~/.config/wai/quantum-key`), made from the operating system's random -/// source on first use and readable by its owner only. -/// -/// A key file holds the 32-byte Ed25519 seed as 64 hex digits. +/// The key a receipt is signed with ([`crate::keys`]), from `--key FILE` or +/// `--demo-key`. fn signing_key(args: &[String]) -> SigningKey { - if flag(args, "--demo-key") { - eprintln!("note: signed with the published demo key; the receipt shows the result is unaltered, not who signed it"); - return SigningKey::from_bytes(&[42u8; 32]); - } - let path = opt(args, "--key", "").or_else(|| std::env::var("WAI_QUANTUM_KEY").ok()).map(std::path::PathBuf::from).unwrap_or_else(|| { - let base = std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| std::path::Path::new(&h).join(".config"))).unwrap_or_else(|| die("no HOME to keep a key in: pass --key FILE")); - base.join("wai").join("quantum-key") - }); - let parse = |text: &str| -> Option<[u8; 32]> { - let t = text.trim(); - if t.len() != 64 { - return None; - } - let mut out = [0u8; 32]; - for (i, b) in out.iter_mut().enumerate() { - *b = u8::from_str_radix(&t[2 * i..2 * i + 2], 16).ok()?; - } - Some(out) - }; - match std::fs::read_to_string(&path) { - Ok(text) => SigningKey::from_bytes(&parse(&text).unwrap_or_else(|| die(&format!("{}: a key file holds 64 hex digits", path.display())))), - Err(e) if e.kind() == std::io::ErrorKind::NotFound && opt(args, "--key", "").is_none() && std::env::var_os("WAI_QUANTUM_KEY").is_none() => { - let mut seed = [0u8; 32]; - getrandom::getrandom(&mut seed).unwrap_or_else(|e| die(&format!("no random source for a new key: {e}"))); - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir).unwrap_or_else(|e| die(&format!("{}: {e}", dir.display()))); - } - let text: String = seed.iter().map(|b| format!("{b:02x}")).collect(); - let mut f = std::fs::OpenOptions::new(); - f.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - f.mode(0o600); - } - use std::io::Write as _; - f.open(&path).and_then(|mut file| writeln!(file, "{text}")).unwrap_or_else(|e| die(&format!("{}: {e}", path.display()))); - eprintln!("note: made a signing key at {}", path.display()); - SigningKey::from_bytes(&seed) - } - Err(e) => die(&format!("{}: {e}", path.display())), - } + let given = opt(args, "--key", "").map(std::path::PathBuf::from); + let k = crate::keys::load(given.as_deref(), flag(args, "--demo-key")).unwrap_or_else(|e| die(&e)); + crate::keys::notes(&k); + k.key } +/// Pull `--flag VALUE` out of the argument list. fn opt(args: &[String], long: &str, short: &str) -> Option { args.iter() .position(|a| a == long || (!short.is_empty() && a == short)) @@ -315,9 +270,9 @@ fn gateset_from(args: &[String]) -> Gateset { fn circuit_from(args: &[String], algo: Option<&String>) -> Circuit { let gs = gateset_from(args); let mut c = if flag(args, "--qasm") { - wai::qasm::dyadic_circuit(&read_stdin()).unwrap_or_else(|e| die(&format!("QASM parse: {e}"))) + wai_quantum::qasm::dyadic_circuit(&read_stdin()).unwrap_or_else(|e| die(&format!("QASM parse: {e}"))) } else if flag(args, "--qir") { - wai::quantum_qir::from_qir(&read_stdin()).unwrap_or_else(|e| die(&format!("QIR parse: {e}"))) + wai_quantum::quantum_qir::from_qir(&read_stdin()).unwrap_or_else(|e| die(&format!("QIR parse: {e}"))) } else { let id = algo.unwrap_or_else(|| usage()); let n: u8 = num(args, "--qubits", "-n", 3); @@ -380,8 +335,8 @@ fn human_time(ns: f64) -> String { /// A circuit file: JSON lines, one gate per line, `{"q":[a,b],"m":[re,im, …]}` /// (rows are outputs, the first qubit most significant). Returns the gates /// and the register size they need. -fn read_gate_file(path: &str) -> (Vec, u32) { - use wai::gates::{complex, Gate}; +fn read_gate_file(path: &str) -> (Vec, u32) { + use wai_quantum::gates::{complex, Gate}; let text = std::fs::read_to_string(path).unwrap_or_else(|e| die(&format!("{path}: {e}"))); let mut gates = Vec::new(); let mut n: u32 = 0; @@ -491,8 +446,76 @@ fn estimate_json(e: &qr::Estimate) -> String { ) } -fn main() { - let argv: Vec = std::env::args().collect(); +/// Check a receipt of any kind this tool or the library signs: `src` is its +/// JSON. Prints what it binds, and exits 1 when it does not verify. +pub fn verify_receipt(src: &str) { + // Either form: a receipt as sealed, or one whose joule figure + // carries its acquisition class inside the signature + // (`quantum_energy::Labelled`, as `wai_quantum_meter` emits). + if let Some(any) = MaybeLabelled::::from_json(src) { + let ok = any.verify(); + let r = any.receipt(); + let e = &r.estimate; + println!("signature {}", if ok { "VALID" } else { "INVALID" }); + println!("kind fault-tolerant resource estimate"); + println!("job-hash {}", hx(&r.job_hash)); + println!("qubit {} ({}, d = {})", e.qubit, e.code, e.distance); + println!("physical {} qubits", e.physical_qubits); + println!("runtime {}", human_time(e.runtime_ns)); + println!("factories {}", e.factories); + println!("joules {} uJ", r.joules_micro); + println!("energy class {}", energy_class_line(any.energy_class(), r.joules_micro)); + println!("signer {}", r.signer_id); + if !ok { + std::process::exit(1); + } + return; + } + if let Some(any) = MaybeLabelled::::from_json(src) { + let ok = any.verify(); + let r = any.receipt(); + println!("signature {}", if ok { "VALID" } else { "INVALID" }); + println!("kind sparse Pauli dynamics"); + println!("circuit-hash {}", hx(&r.circuit_hash)); + println!("query-hash {}", hx(&r.query_hash)); + println!("value {}", r.result.value); + println!("bound {}", r.result.truncation_bound); + println!("work {} term-updates (peak {} terms)", r.result.term_updates, r.result.peak_terms); + println!("joules {} uJ", r.joules_micro); + println!("energy class {}", energy_class_line(any.energy_class(), r.joules_micro)); + println!("signer {}", r.signer_id); + if !ok { + std::process::exit(1); + } + return; + } + match MaybeLabelled::::from_json(src) { + None => { + println!("INVALID (could not parse receipt JSON, or its energy class is malformed)"); + std::process::exit(1); + } + Some(any) => { + let ok = any.verify(); + let r = any.receipt(); + println!("signature {}", if ok { "VALID" } else { "INVALID" }); + println!("circuit-hash {}", hx(&r.circuit_hash)); + println!("state-hash {}", hx(&r.statevector_hash)); + println!("qubits {}", r.n_qubits); + println!("gates {}", r.n_ops); + println!("work {} amplitude-updates", r.work_amp_updates); + println!("joules {} uJ", r.joules_micro); + println!("energy class {}", energy_class_line(any.energy_class(), r.joules_micro)); + println!("signer {}", r.signer_id); + if !ok { + std::process::exit(1); + } + } + } +} + +/// The research tools, with `args` the words after `wai-quantum lab`. +pub fn main(args: Vec) { + let argv: Vec = std::iter::once("wai-quantum lab".to_string()).chain(args).collect(); if argv.len() < 2 { usage(); } @@ -502,7 +525,7 @@ fn main() { match cmd { "list" => { - println!("{:<14} {:<26} {}", "ID", "NAME", "DESCRIPTION"); + println!("{:<14} {:<26} DESCRIPTION", "ID", "NAME"); for (id, name, desc) in qt::catalog() { println!("{id:<14} {name:<26} {desc}"); } @@ -515,7 +538,7 @@ fn main() { "qir" => { let c = circuit_from(args, algo); - print!("{}", wai::quantum_qir::to_qir(&c, !flag(args, "--no-measure"))); + print!("{}", wai_quantum::quantum_qir::to_qir(&c, !flag(args, "--no-measure"))); } "run" => { @@ -566,76 +589,12 @@ fn main() { let seed: u64 = num(args, "--seed", "", 0); let measure = (shots > 0).then_some(Measure { seed, shots }); let key = signing_key(args); - let r = QuantumReceipt::seal(&key, did_key(key.verifying_key().as_bytes()), &c, measure, 0, None) + let r = QuantumReceipt::seal(&key, did_key(&key.verifying_key()), &c, measure, 0, None) .unwrap_or_else(|e| die(&format!("seal: {e:?}"))); println!("{}", r.to_json()); } - "verify" => { - // Either form: a receipt as sealed, or one whose joule figure - // carries its acquisition class inside the signature - // (`quantum_energy::Labelled`, as `wai_quantum_meter` emits). - let src = read_stdin(); - if let Some(any) = MaybeLabelled::::from_json(&src) { - let ok = any.verify(); - let r = any.receipt(); - let e = &r.estimate; - println!("signature {}", if ok { "VALID" } else { "INVALID" }); - println!("kind fault-tolerant resource estimate"); - println!("job-hash {}", hx(&r.job_hash)); - println!("qubit {} ({}, d = {})", e.qubit, e.code, e.distance); - println!("physical {} qubits", e.physical_qubits); - println!("runtime {}", human_time(e.runtime_ns)); - println!("factories {}", e.factories); - println!("joules {} uJ", r.joules_micro); - println!("energy class {}", energy_class_line(any.energy_class(), r.joules_micro)); - println!("signer {}", r.signer_id); - if !ok { - std::process::exit(1); - } - return; - } - if let Some(any) = MaybeLabelled::::from_json(&src) { - let ok = any.verify(); - let r = any.receipt(); - println!("signature {}", if ok { "VALID" } else { "INVALID" }); - println!("kind sparse Pauli dynamics"); - println!("circuit-hash {}", hx(&r.circuit_hash)); - println!("query-hash {}", hx(&r.query_hash)); - println!("value {}", r.result.value); - println!("bound {}", r.result.truncation_bound); - println!("work {} term-updates (peak {} terms)", r.result.term_updates, r.result.peak_terms); - println!("joules {} uJ", r.joules_micro); - println!("energy class {}", energy_class_line(any.energy_class(), r.joules_micro)); - println!("signer {}", r.signer_id); - if !ok { - std::process::exit(1); - } - return; - } - match MaybeLabelled::::from_json(&src) { - None => { - println!("INVALID (could not parse receipt JSON, or its energy class is malformed)"); - std::process::exit(1); - } - Some(any) => { - let ok = any.verify(); - let r = any.receipt(); - println!("signature {}", if ok { "VALID" } else { "INVALID" }); - println!("circuit-hash {}", hx(&r.circuit_hash)); - println!("state-hash {}", hx(&r.statevector_hash)); - println!("qubits {}", r.n_qubits); - println!("gates {}", r.n_ops); - println!("work {} amplitude-updates", r.work_amp_updates); - println!("joules {} uJ", r.joules_micro); - println!("energy class {}", energy_class_line(any.energy_class(), r.joules_micro)); - println!("signer {}", r.signer_id); - if !ok { - std::process::exit(1); - } - } - } - } + "verify" => verify_receipt(&read_stdin()), // How much quantum information a state actually carries. A state vector // cannot answer this for a subsystem — half a Bell pair has no state @@ -646,7 +605,7 @@ fn main() { // qubit and RZZ(−π/2) on every edge, and the bound on how far // truncation can have moved it. `--receipt` seals the run instead. "utility" => { - use wai::quantum_spd::{heavy_hex_127, kicked_ising, Pauli, SpdConfig}; + use wai_quantum::quantum_spd::{heavy_hex_127, kicked_ising, Pauli, SpdConfig}; let theta: f64 = num(args, "--theta", "", std::f64::consts::FRAC_PI_4); let steps: u32 = num(args, "--steps", "", 20); let threshold: f64 = num(args, "--threshold", "", 1e-3); @@ -660,7 +619,7 @@ fn main() { } let chi: usize = num(args, "--chi", "", 16); let edges = heavy_hex_127(); - let mut tns = wai::quantum_bptn::Tns::new(127, &edges, chi).unwrap_or_else(|e| die(&e.to_string())); + let mut tns = wai_quantum::quantum_bptn::Tns::new(127, &edges, chi).unwrap_or_else(|e| die(&e.to_string())); tns.run(&kicked_ising(127, &edges, theta, steps)).unwrap_or_else(|e| die(&e.to_string())); let value = tns.expect1(qubit, Pauli::Z).unwrap_or_else(|e| die(&e.to_string())); if flag(args, "--json") { @@ -688,7 +647,7 @@ fn main() { }; if flag(args, "--receipt") { let key = signing_key(args); - let r = SpdReceipt::seal(&key, did_key(key.verifying_key().as_bytes()), &job, None, |run| { + let r = SpdReceipt::seal(&key, did_key(&key.verifying_key()), &job, None, |run| { run(); 0 }) @@ -751,7 +710,7 @@ fn main() { } let job = ResourceJob { program: Program::Logical(counts), qubit: qubits[0].clone(), code, config: cfg }; let key = signing_key(args); - let r = ResourceReceipt::seal(&key, did_key(key.verifying_key().as_bytes()), &job, None, |run| { + let r = ResourceReceipt::seal(&key, did_key(&key.verifying_key()), &job, None, |run| { run(); 0 }) @@ -781,7 +740,7 @@ fn main() { println!("[{}]", rows.join(",")); return; } - println!("{:>8} {:>3} {:>9} {:>14} {}", "slowdown", "d", "factories", "physical", "runtime"); + println!("{:>8} {:>3} {:>9} {:>14} runtime", "slowdown", "d", "factories", "physical"); for e in &points { println!( "{:>8} {:>3} {:>9} {:>14} {}", @@ -809,8 +768,8 @@ fn main() { let isa = qr::layout(&counts, budget); println!("program {} tiles, {} logical steps, {} T states (budget {budget})", isa.tiles, isa.min_steps, isa.t_states); println!( - "{:<12} {:<22} {:>3} {:>9} {:>8} {:>14} {}", - "qubit", "code", "d", "factories", "share", "physical", "runtime" + "{:<12} {:<22} {:>3} {:>9} {:>8} {:>14} runtime", + "qubit", "code", "d", "factories", "share", "physical" ); for (q, e) in &estimates { match e { @@ -832,8 +791,8 @@ fn main() { // Error correction at circuit level: sample a noisy Clifford circuit, // derive its detector error model, decode it exactly. "qec" => { - use wai::quantum_frame::{error_model, parse, sample, surface_code_memory}; - use wai::quantum_match::{fit_code_model, memory_experiment, MatchingGraph}; + use wai_quantum::quantum_frame::{error_model, parse, sample, surface_code_memory}; + use wai_quantum::quantum_match::{fit_code_model, memory_experiment, MatchingGraph}; let shots: usize = num(args, "--shots", "", 100_000); let seed: u64 = num(args, "--seed", "", 1); let json = flag(args, "--json"); @@ -890,7 +849,7 @@ fn main() { let decoder = opt(args, "--decoder", "").unwrap_or_else(|| "exact".into()); let failures = match decoder.as_str() { "exact" => g.failures(&s), - "uf" => wai::quantum_uf::UnionFind::new(&g).failures(&s), + "uf" => wai_quantum::quantum_uf::UnionFind::new(&g).failures(&s), _ => die("--decoder: exact or uf"), } .unwrap_or_else(|e| die(&format!("decode: {e:?}"))); @@ -908,7 +867,7 @@ fn main() { println!("model {} error mechanisms", model.mechanisms.len()); println!("failures {failures} of {shots} shots ({rate:.3e} per shot, {decoder} decoder)"); if opt(args, "--file", "").is_none() { - let per_round = wai::quantum_match::MemoryPoint { distance: d, rounds, p, shots: shots as u64, failures }.per_round(); + let per_round = wai_quantum::quantum_match::MemoryPoint { distance: d, rounds, p, shots: shots as u64, failures }.per_round(); println!("per round {per_round:.3e} (d = {d}, {rounds} rounds, p = {p})"); } } @@ -920,7 +879,7 @@ fn main() { // {"q":[0,1],"m":[re,im, …]} (rows are outputs, first qubit most // significant). "tn" => { - use wai::quantum_tn::{contract_plan, find_path, Families, Network, PathOptions}; + use wai_quantum::quantum_tn::{contract_plan, find_path, Families, Network, PathOptions}; let path = opt(args, "--file", "").unwrap_or_else(|| die("--file CIRCUIT.jsonl is required")); let (gates, n) = read_gate_file(&path); let n: u32 = num(args, "--qubits", "-n", n); @@ -1000,7 +959,7 @@ fn main() { // Gaussian boson sampling, exactly: squeezed light through an // interferometer with loss, and the click statistics it must show. "gbs" => { - use wai::quantum_gbs::{random_unitary, GaussianState, Mat, C}; + use wai_quantum::quantum_gbs::{random_unitary, GaussianState, Mat, C}; let r: Vec = opt(args, "--squeezing", "") .unwrap_or_else(|| die("--squeezing r1,r2,… is required (one value per mode)")) .split(',') @@ -1089,7 +1048,7 @@ fn main() { // where one is known (the Ising chain at any length, any chain by // exact diagonalisation up to 22 sites). "dmrg" => { - use wai::quantum_dmrg::*; + use wai_quantum::quantum_dmrg::*; let n: usize = num(args, "--sites", "-n", 40); if n < 2 { die("-n: at least 2 sites"); @@ -1138,8 +1097,8 @@ fn main() { // Ising chain started with every σˣ = +1 is held at each report to // its free-fermion solution. "tdvp" => { - use wai::quantum_dmrg::{Chain, Mps, Op, Scalar, C}; - use wai::quantum_tdvp::*; + use wai_quantum::quantum_dmrg::{Chain, Mps, Op, Scalar, C}; + use wai_quantum::quantum_tdvp::*; let n: usize = num(args, "--sites", "-n", 40); if n < 2 { die("-n: at least 2 sites"); @@ -1221,7 +1180,7 @@ fn main() { // A molecule's ground state by DMRG from its integrals, against // Hartree–Fock and full configuration interaction where it fits. "chem" => { - use wai::quantum_chem::*; + use wai_quantum::quantum_chem::*; let path = opt(args, "--fcidump", "").unwrap_or_else(|| die("--fcidump FILE is required")); let text = std::fs::read_to_string(&path).unwrap_or_else(|e| die(&format!("{path}: {e}"))); let fd = read_fcidump(&text).unwrap_or_else(|e| die(&format!("{path}: {e}"))); @@ -1260,7 +1219,7 @@ fn main() { // A neural quantum state for a spin model on a chain or an open // square lattice, against exact diagonalisation where it fits. "nqs" => { - use wai::quantum_nqs::*; + use wai_quantum::quantum_nqs::*; let lx: usize = num(args, "--lx", "", 10); let ly: usize = num(args, "--ly", "", 1); let n = lx * ly; @@ -1400,7 +1359,7 @@ fn main() { // host, so they differ between platforms; the parity and the corpus // digest do not. "fabric" => { - use wai::quantum_fabric::*; + use wai_quantum::quantum_fabric::*; let circuits: usize = num(args, "--circuits", "", 200); let seed: u64 = num(args, "--seed", "", 7); let gs = gateset_from(args); @@ -1455,7 +1414,7 @@ fn main() { // glass, by simulated bifurcation, annealing, or exact enumeration. // Max-Cut reports cuts; a spin glass reports energies. "qio" => { - use wai::quantum_qio::*; + use wai_quantum::quantum_qio::*; let (problem, graph) = if let Some(path) = opt(args, "--gset", "") { let text = std::fs::read_to_string(&path).unwrap_or_else(|e| die(&format!("{path}: {e}"))); let g = read_gset(&text).unwrap_or_else(|e| die(&e.to_string())); @@ -1529,8 +1488,8 @@ fn main() { // qLDPC memory: a bivariate-bicycle code's depth-8 syndrome cycle, // sampled with circuit-level noise and decoded by BP-OSD. "ldpc" => { - use wai::quantum_frame::Basis; - use wai::quantum_ldpc::{memory_circuit, memory_experiment, BbCode, BpOsdConfig}; + use wai_quantum::quantum_frame::Basis; + use wai_quantum::quantum_ldpc::{memory_circuit, memory_experiment, BbCode, BpOsdConfig}; let code = match opt(args, "--code", "").as_deref().unwrap_or("72") { "72" => BbCode::bb72(), "90" => BbCode::bb90(), @@ -1590,7 +1549,7 @@ fn main() { // states, CCZ factories and the clock, priced; and the published // factoring machine re-run region by region. "arch" => { - use wai::quantum_arch::*; + use wai_quantum::quantum_arch::*; let json = flag(args, "--json"); match algo.map(String::as_str) { Some("factoring") | None => { @@ -1668,7 +1627,7 @@ fn main() { // exact density matrix (up to 12 qubits) and cross-entropy fidelity // against the ideal circuit and the digital error model. "noisy" => { - use wai::quantum_noisy::*; + use wai_quantum::quantum_noisy::*; let path = opt(args, "--file", "").unwrap_or_else(|| die("--file CIRCUIT.jsonl is required")); let (gates, n) = read_gate_file(&path); let n: u32 = num(args, "--qubits", "-n", n); @@ -1711,7 +1670,7 @@ fn main() { None }; let xeb = if flag(args, "--xeb") { - let mut ideal = wai::quantum_sv::StateVector::zero(n).unwrap_or_else(|e| die(&format!("{e:?}"))); + let mut ideal = wai_quantum::quantum_sv::StateVector::zero(n).unwrap_or_else(|e| die(&format!("{e:?}"))); ideal.run(&gates, 2).unwrap_or_else(|e| die(&format!("{e:?}"))); let sampled = linear_xeb(&ideal, &run.samples); let exact_x = exact.as_ref().map(|rho| exact_xeb(&ideal, rho, noise.readout)); @@ -1760,7 +1719,7 @@ fn main() { // (fused to --fuse qubits), then show the likeliest outcomes, draw // samples, or evaluate Pauli observables. "sv" => { - use wai::quantum_sv::StateVector; + use wai_quantum::quantum_sv::StateVector; let path = opt(args, "--file", "").unwrap_or_else(|| die("--file CIRCUIT.jsonl is required")); let (gates, n) = read_gate_file(&path); let n: u32 = num(args, "--qubits", "-n", n); diff --git a/wai/wai-quantum-cli/src/main.rs b/wai/wai-quantum-cli/src/main.rs new file mode 100644 index 000000000..3d6f85a8e --- /dev/null +++ b/wai/wai-quantum-cli/src/main.rs @@ -0,0 +1,575 @@ +//! `wai-quantum`: OpenQASM programs run on this machine as the quantum job +//! service runs them, or sent to the service; every result names its method +//! and its accuracy and carries a signed receipt. +//! +//! ```text +//! wai-quantum run bell.qasm --shots 1000 # here, planned as the service plans it +//! wai-quantum run prog.qasm -o "Z0 Z1" -a 00 --json +//! wai-quantum plan prog.qasm # the method and why, without running +//! wai-quantum lint prog.qasm # each problem with its line +//! wai-quantum draw prog.qasm # the circuit as text +//! wai-quantum submit prog.qasm # on the job service +//! wai-quantum verify result.json # a result's receipt, or a lab receipt +//! wai-quantum key # the key this user signs with +//! wai-quantum methods # each method and its reach here +//! wai-quantum mcp # these tools for an agent, over stdio +//! wai-quantum lab … # the research tools +//! wai-quantum completions zsh # shell completion +//! ``` +//! +//! `run`, `plan` and `mcp` use the job service's own engine +//! ([`wai_quantum::engine`]), so a job gives here the result it gives there. +//! `run --json` prints the job as the service returns it: the request, the +//! plan, and the result with its receipt, which `verify` checks. + +mod draw; +mod keys; +mod lab; +mod mcp; + +use clap::{Args, CommandFactory, Parser, Subcommand}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use wai_quantum::engine::{catalog, receipt, run}; + +#[derive(Parser)] +#[command( + name = "wai-quantum", + version, + about = "OpenQASM programs run here as the quantum job service runs them, or sent to it; every result names its method and accuracy and is signed", + propagate_version = true +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a program here, planned as the job service plans it, and sign the result + Run { + #[command(flatten)] + job: Job, + #[command(flatten)] + out: Output, + #[command(flatten)] + key: KeyArgs, + }, + /// Show the method a program would run on and why, without running it + Plan { + #[command(flatten)] + job: Job, + /// Print the plan as JSON + #[arg(long)] + json: bool, + }, + /// Check a program and print each problem with its line + Lint { + /// The program; `-` or nothing reads standard input + file: Option, + /// Print the outcome as JSON + #[arg(long)] + json: bool, + }, + /// Draw a program's circuit as text + Draw { + /// The program; `-` or nothing reads standard input + file: Option, + /// Wrap at this many columns + #[arg(long, default_value_t = 100)] + width: usize, + }, + /// Send a program to the job service and print its result + #[cfg(feature = "service")] + Submit { + #[command(flatten)] + job: Job, + #[command(flatten)] + out: Output, + /// The service's address + #[arg(long, env = "WAI_QUANTUM_SERVICE", default_value = "https://wai.transaction.science/quantum-jobs")] + service: String, + }, + /// Check a receipt: a result from `run --json`, `submit --json` or the service, or a `lab` receipt + Verify { + /// The JSON; `-` or nothing reads standard input + file: Option, + }, + /// Show the key this user signs receipts with + Key { + #[command(flatten)] + key: KeyArgs, + }, + /// Each method, what it takes, and how far it reaches in a job's memory here + Methods { + /// Print the table as JSON + #[arg(long)] + json: bool, + /// The memory a job may take, GiB + #[arg(long, default_value_t = DEFAULT_MEMORY_GB)] + memory_gb: u64, + }, + /// Serve run, plan, lint, draw and verify to an agent over standard input and output (MCP) + Mcp { + /// The memory a job may take, GiB + #[arg(long, default_value_t = DEFAULT_MEMORY_GB)] + memory_gb: u64, + #[command(flatten)] + key: KeyArgs, + }, + /// The research tools: error correction, DMRG, neural states, resource estimates and more (`wai-quantum lab help`) + Lab { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Print shell completion for bash, zsh, fish, elvish or PowerShell + Completions { shell: clap_complete::Shell }, +} + +/// The memory a job may take by default, GiB. +const DEFAULT_MEMORY_GB: u64 = 16; + +/// A job: a program and what is asked of it, as the service reads it. +#[derive(Args, Clone)] +struct Job { + /// The OpenQASM 3 (or 2) program; `-` or nothing reads standard input + file: Option, + /// Shots to sample (1024 when nothing else is asked) + #[arg(long)] + shots: Option, + /// The seed shots are sampled with (1 if not given) + #[arg(long)] + seed: Option, + /// A method by name, or auto (the default) for the strongest the program admits; `wai-quantum methods` lists them + #[arg(long)] + method: Option, + /// A Pauli observable to take the expectation of, as "Z0 Z1" or "0.5 X2" (repeatable) + #[arg(short = 'o', long = "observable")] + observables: Vec, + /// A basis state's amplitude, as a bit string with the highest qubit first (repeatable) + #[arg(short = 'a', long = "amplitude")] + amplitudes: Vec, + /// A noise channel and its probability, as depolarizing_1q=0.01 (repeatable) + #[arg(long, value_parser = noise_arg)] + noise: Vec<(String, f64)>, + /// A method option, as max_bond=128 (repeatable) + #[arg(long = "option", value_parser = option_arg)] + options: Vec<(String, Value)>, + /// The memory a job may take here, GiB + #[arg(long, default_value_t = DEFAULT_MEMORY_GB)] + memory_gb: u64, +} + +#[derive(Args, Clone)] +struct Output { + /// Print the job as the service returns it: request, plan, result and receipt + #[arg(long)] + json: bool, + /// The most outcomes to list + #[arg(long, default_value_t = 16)] + top: usize, +} + +#[derive(Args, Clone)] +struct KeyArgs { + /// Sign with the key in this file (64 hex digits) + #[arg(long, value_name = "FILE")] + key: Option, + /// Sign with the published demo key: the receipt then shows a result is unaltered, not who signed it + #[arg(long, conflicts_with = "key")] + demo_key: bool, +} + +fn noise_arg(s: &str) -> Result<(String, f64), String> { + let (k, v) = s.split_once('=').ok_or("give a channel and its probability, as depolarizing_1q=0.01")?; + if !catalog::NOISE_CHANNELS.contains(&k) { + return Err(format!("{k}: no such channel; the channels are {}", catalog::NOISE_CHANNELS.join(", "))); + } + let p: f64 = v.parse().map_err(|_| format!("{v}: a probability, 0 to 1"))?; + Ok((k.to_string(), p)) +} + +fn option_arg(s: &str) -> Result<(String, Value), String> { + let (k, v) = s.split_once('=').ok_or("give an option and its value, as max_bond=128")?; + // A number or true/false reads as JSON; anything else is a string. + let value = serde_json::from_str::(v).ok().filter(|x| x.is_number() || x.is_boolean()).unwrap_or_else(|| json!(v)); + Ok((k.to_string(), value)) +} + +fn die(msg: &str) -> ! { + eprintln!("wai-quantum: {msg}"); + std::process::exit(1) +} + +/// The text of `file`, or standard input for `-` or none. +fn read_input(file: Option<&Path>) -> Result { + match file { + Some(p) if p != Path::new("-") => std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display())), + _ => { + use std::io::Read as _; + let mut s = String::new(); + std::io::stdin().read_to_string(&mut s).map_err(|e| format!("standard input: {e}"))?; + Ok(s) + } + } +} + +/// The job's request, with only the fields given, as the service receives it. +fn request(job: &Job, qasm: String) -> Value { + let mut r = json!({ "qasm": qasm }); + if let Some(s) = job.shots { + r["shots"] = json!(s); + } + if let Some(s) = job.seed { + r["seed"] = json!(s); + } + if let Some(m) = &job.method { + r["method"] = json!(m); + } + if !job.observables.is_empty() { + r["observables"] = json!(job.observables); + } + if !job.amplitudes.is_empty() { + r["amplitudes"] = json!(job.amplitudes); + } + if !job.noise.is_empty() { + r["noise"] = Value::Object(job.noise.iter().map(|(k, v)| (k.clone(), json!(v))).collect()); + } + if !job.options.is_empty() { + r["options"] = Value::Object(job.options.iter().cloned().collect()); + } + r +} + +/// The job body `run` gives, as the service gives it: the request, the plan, +/// and the result sealed with `key`. +pub fn run_here(req: &Value, memory_gb: u64, key: &wai_quantum::ed25519_dalek::SigningKey) -> Result { + if let Some(at) = receipt::inexact_number(req) { + return Err(format!("`{at}`: a whole number here must be within ±9007199254740991 (2^53 − 1), so that every JSON reader reads it alike and the receipt binds it")); + } + let plan = catalog::plan(req, memory_gb << 30)?; + let mut result = run::run(req, &plan)?; + result["energy"] = json!({ "unmetered": "the command line does not meter a run's energy" }); + let seal = receipt::seal(key, req, &result); + result["receipt"] = seal; + Ok(json!({ + "status": "done", + "method": plan.method, + "why": plan.why, + "estimate": { "memory_bytes": plan.memory_bytes, "work": plan.work }, + "request": req, + "result": result, + })) +} + +/// Whether a job body's result carries a receipt that seals it and its request. +pub fn verify_job(v: &Value) -> Result<(bool, Value), String> { + let (Some(request), Some(result)) = (v.get("request"), v.get("result")) else { + return Err("expected a job: {\"request\": ..., \"result\": ...}, the result carrying its receipt".into()); + }; + let Some(seal) = result.get("receipt") else { return Err("the result carries no receipt".into()) }; + let mut bare = result.clone(); + if let Some(o) = bare.as_object_mut() { + o.remove("receipt"); + } + Ok((receipt::verify(request, &bare, seal), seal.get("key").cloned().unwrap_or(Value::Null))) +} + +fn hex_to_did(key: &str) -> Option { + let b: Vec = (0..key.len() / 2).map(|i| u8::from_str_radix(key.get(2 * i..2 * i + 2)?, 16).ok()).collect::>()?; + let vk = wai_quantum::ed25519_dalek::VerifyingKey::from_bytes(&<[u8; 32]>::try_from(b.as_slice()).ok()?).ok()?; + Some(wai_quantum::quantum_receipt::did_key(&vk)) +} + +/// A job body as a reader takes it in. +fn print_job(body: &Value, top: usize) { + let r = &body["result"]; + let s = |v: &Value| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + println!("method {} — {}", s(&body["method"]), s(&body["why"])); + if let Some(acc) = r.get("accuracy") { + let mut extra: Vec = acc.as_object().map(|o| o.iter().filter(|(k, _)| *k != "class" && *k != "note").map(|(k, v)| format!("{k} {}", s(v))).collect()).unwrap_or_default(); + if let Some(n) = acc.get("note").and_then(Value::as_str) { + extra.push(n.to_string()); + } + println!("accuracy {}{}", s(&acc["class"]), if extra.is_empty() { String::new() } else { format!(" ({})", extra.join("; ")) }); + } + println!("circuit {} qubits, {} gates", r["qubits"], r["gates"]); + if let Some(w) = r.get("warning").and_then(Value::as_str) { + println!("warning {w}"); + } + if let Some(p) = r.get("global_phase").and_then(Value::as_str) { + println!("phase {p}"); + } + if let Some(counts) = r.get("counts").and_then(Value::as_object) { + let total: u64 = counts.values().filter_map(Value::as_u64).sum::() + r["omitted_shots"].as_u64().unwrap_or(0); + println!("counts {total} shots, {} outcomes", r["distinct_outcomes"].as_u64().unwrap_or(counts.len() as u64)); + let mut v: Vec<(&String, u64)> = counts.iter().map(|(k, c)| (k, c.as_u64().unwrap_or(0))).collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); + for (k, c) in v.iter().take(top) { + println!(" {k} {c:>8} {:.4}", *c as f64 / total.max(1) as f64); + } + if v.len() > top { + println!(" … {} more (--top N)", v.len() - top); + } + } + for e in r.get("expectations").and_then(Value::as_array).into_iter().flatten() { + let bound = e.get("bound").map(|b| format!(" ± {}", s(b))).unwrap_or_default(); + println!("⟨{}⟩ {}{bound}", s(&e["observable"]), s(&e["value"])); + } + for a in r.get("amplitudes").and_then(Value::as_array).into_iter().flatten() { + let (re, im) = (a["re"].as_f64().unwrap_or(f64::NAN), a["im"].as_f64().unwrap_or(f64::NAN)); + println!("|{}⟩ {re:+.12} {}{:.12}i", s(&a["bits"]), if im < 0.0 { '-' } else { '+' }, im.abs()); + } + if let Some(d) = r.get("dynamic") { + println!("dynamic {d}"); + } + if let Some(ms) = r.get("elapsed_ms") { + println!("time {ms} ms"); + } + if let Some(key) = r.pointer("/receipt/key").and_then(Value::as_str) { + let ok = verify_job(body).map(|(ok, _)| ok).unwrap_or(false); + println!("receipt {}, signed by {}", if ok { "verifies" } else { "DOES NOT VERIFY" }, hex_to_did(key).unwrap_or_else(|| key.to_string())); + } +} + +fn load_key(k: &KeyArgs) -> keys::Key { + let key = keys::load(k.key.as_deref(), k.demo_key).unwrap_or_else(|e| die(&e)); + keys::notes(&key); + key +} + +#[cfg(feature = "service")] +fn submit(req: &Value, service: &str) -> Result { + use std::time::Duration; + let base = service.trim_end_matches('/'); + let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).timeout_global(Some(Duration::from_secs(120))).build().into(); + let ua = concat!("wai-quantum/", env!("CARGO_PKG_VERSION")); + let read = |mut resp: ureq::http::Response| -> Result<(u16, Value), String> { + let code = resp.status().as_u16(); + let v: Value = resp.body_mut().read_json().map_err(|e| format!("the service's reply did not read: {e}"))?; + Ok((code, v)) + }; + let (mut code, mut body) = read(agent.post(format!("{base}/v1/run")).header("user-agent", ua).send_json(req).map_err(|e| format!("{base}: {e}"))?)?; + // A job still running after the service's wait is fetched until it ends. + while code == 202 { + let id = body["id"].as_str().ok_or("the service answered 202 with no job id")?.to_string(); + eprintln!("note: job {id} is {}; waiting", body["status"].as_str().unwrap_or("running")); + std::thread::sleep(Duration::from_secs(3)); + (code, body) = read(agent.get(format!("{base}/v1/jobs/{id}")).header("user-agent", ua).call().map_err(|e| format!("{base}: {e}"))?)?; + if body["status"] != "queued" && body["status"] != "running" { + break; + } + code = 202; + } + if code >= 400 { + return Err(body["error"].as_str().map(str::to_string).unwrap_or_else(|| format!("HTTP {code}: {body}"))); + } + if body["status"] != "done" { + return Err(format!("the job {}: {}", body["status"].as_str().unwrap_or("failed"), body["error"].as_str().unwrap_or(""))); + } + Ok(body) +} + +/// What `lint` reports of a program. +pub fn lint(src: &str) -> Value { + match wai_quantum::qasm::parse(src) { + Ok(p) => json!({ + "ok": true, + "qubits": p.n_qubits, + "gates": p.ops.len(), + "bits": p.n_bits, + "measured": p.measured.len(), + "dynamic_steps": p.dynamic.as_ref().map(Vec::len), + }), + Err(e) => { + let line = e.strip_prefix("line ").and_then(|r| r.split(':').next()).and_then(|n| n.parse::().ok()); + json!({ "ok": false, "error": e, "line": line }) + } + } +} + +fn main() { + let cli = Cli::parse(); + match cli.command { + Command::Run { job, out, key } => { + let qasm = read_input(job.file.as_deref()).unwrap_or_else(|e| die(&e)); + let req = request(&job, qasm); + let k = load_key(&key); + let body = run_here(&req, job.memory_gb, &k.key).unwrap_or_else(|e| die(&e)); + if out.json { + println!("{body}"); + } else { + print_job(&body, out.top); + } + } + Command::Plan { job, json } => { + let qasm = read_input(job.file.as_deref()).unwrap_or_else(|e| die(&e)); + let p = catalog::plan(&request(&job, qasm), job.memory_gb << 30).unwrap_or_else(|e| die(&e)); + if json { + println!("{}", serde_json::to_value(&p).unwrap_or(Value::Null)); + } else { + println!("method {}", p.method); + println!("why {}", p.why); + println!("qubits {}", p.qubits); + println!("memory {:.1} MiB at most", p.memory_bytes as f64 / (1u64 << 20) as f64); + println!("work {}", p.work); + } + } + Command::Lint { file, json } => { + let name = file.as_deref().filter(|p| *p != Path::new("-")).map(|p| p.display().to_string()).unwrap_or_else(|| "".into()); + let src = read_input(file.as_deref()).unwrap_or_else(|e| die(&e)); + let v = lint(&src); + if json { + println!("{v}"); + } else if v["ok"] == true { + let dynamic = v["dynamic_steps"].as_u64().map(|s| format!(", dynamic: {s} steps")).unwrap_or_default(); + println!("{name}: ok — {} qubits, {} gates, {} bits measured{dynamic}", v["qubits"], v["gates"], v["measured"]); + } else { + println!("{name}:{}", v["error"].as_str().unwrap_or("").trim_start_matches("line ")); + } + if v["ok"] != true { + std::process::exit(1); + } + } + Command::Draw { file, width } => { + let src = read_input(file.as_deref()).unwrap_or_else(|e| die(&e)); + let p = wai_quantum::qasm::parse(&src).unwrap_or_else(|e| die(&e)); + print!("{}", draw::draw(&p, width)); + } + #[cfg(feature = "service")] + Command::Submit { job, out, service } => { + let qasm = read_input(job.file.as_deref()).unwrap_or_else(|e| die(&e)); + let req = request(&job, qasm); + let body = submit(&req, &service).unwrap_or_else(|e| die(&e)); + if out.json { + println!("{body}"); + } else { + print_job(&body, out.top); + } + } + Command::Verify { file } => { + let src = read_input(file.as_deref()).unwrap_or_else(|e| die(&e)); + let v: Value = serde_json::from_str(&src).unwrap_or_else(|e| die(&format!("not JSON: {e}"))); + if v.get("request").is_some() && v.get("result").is_some() { + let (ok, key) = verify_job(&v).unwrap_or_else(|e| die(&e)); + println!("signature {}", if ok { "VALID" } else { "INVALID" }); + println!("binds the request and the result as given"); + if let Some(k) = key.as_str() { + println!("signer {}", hex_to_did(k).unwrap_or_else(|| k.to_string())); + } + if !ok { + std::process::exit(1); + } + } else { + lab::verify_receipt(&src); + } + } + Command::Key { key } => { + let k = load_key(&key); + println!("{}", wai_quantum::quantum_receipt::did_key(&k.key.verifying_key())); + match &k.path { + Some(p) => eprintln!("key file {}", p.display()), + None => eprintln!("the published demo key"), + } + } + Command::Methods { json, memory_gb } => { + let m = catalog::describe(memory_gb << 30); + if json { + println!("{m}"); + } else { + for x in m.as_array().into_iter().flatten() { + let reach = if x.get("available") == Some(&Value::Bool(false)) { "not in this build".to_string() } else { format!("up to {} qubits", x["max_qubits"]) }; + println!("{:<20} {reach:<20} {}", x["method"].as_str().unwrap_or(""), x["accuracy"].as_str().unwrap_or("")); + println!("{:<20} {}", "", x["circuits"].as_str().unwrap_or("")); + } + } + } + Command::Mcp { memory_gb, key } => { + let k = load_key(&key); + mcp::serve(memory_gb, &k.key); + } + Command::Lab { args } => lab::main(args), + Command::Completions { shell } => { + clap_complete::generate(shell, &mut Cli::command(), "wai-quantum", &mut std::io::stdout()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn job(qasm: &str) -> (Job, String) { + ( + Job { file: None, shots: None, seed: None, method: None, observables: vec![], amplitudes: vec![], noise: vec![], options: vec![], memory_gb: 1 }, + qasm.to_string(), + ) + } + + #[test] + fn a_run_here_is_the_services_job_and_its_receipt_verifies() { + let (mut j, q) = job("OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];"); + j.shots = Some(1000); + j.observables = vec!["Z0 Z1".into()]; + let req = request(&j, q); + // Only what was given is in the request. + assert_eq!(req.as_object().unwrap().keys().collect::>(), ["qasm", "shots", "observables"]); + let key = wai_quantum::ed25519_dalek::SigningKey::from_bytes(&[3; 32]); + let body = run_here(&req, 1, &key).unwrap(); + assert_eq!(body["method"], "stabilizer"); + assert_eq!(body["result"]["expectations"][0]["value"], 1.0); + let counts = body["result"]["counts"].as_object().unwrap(); + assert!(counts.keys().all(|k| k == "00" || k == "11"), "{counts:?}"); + assert!(verify_job(&body).unwrap().0); + // The result changed, or the request, and the receipt no longer holds. + let mut forged = body.clone(); + forged["result"]["expectations"][0]["value"] = json!(0.5); + assert!(!verify_job(&forged).unwrap().0); + let mut forged = body.clone(); + forged["request"]["shots"] = json!(999); + assert!(!verify_job(&forged).unwrap().0); + // A JSON writer's round trip leaves it verifying. + let again: Value = serde_json::from_str(&body.to_string()).unwrap(); + assert!(verify_job(&again).unwrap().0); + } + + #[test] + fn noise_and_options_are_read_and_a_misspelt_channel_is_refused() { + assert_eq!(noise_arg("depolarizing_1q=0.01").unwrap(), ("depolarizing_1q".into(), 0.01)); + assert!(noise_arg("depolarizing=0.01").unwrap_err().contains("no such channel")); + assert!(noise_arg("readout").is_err()); + assert_eq!(option_arg("max_bond=128").unwrap().1, json!(128)); + assert_eq!(option_arg("x=true").unwrap().1, json!(true)); + assert_eq!(option_arg("kind=fast").unwrap().1, json!("fast")); + } + + #[test] + fn lint_gives_the_shape_or_the_line_of_the_problem() { + let ok = lint("OPENQASM 3.0; qubit[3] q; bit[3] c; h q; c = measure q;"); + assert!(ok["ok"] == true && ok["qubits"] == 3 && ok["gates"] == 3, "{ok}"); + let bad = lint("OPENQASM 3.0;\nqubit[1] q;\nfoo q[0];\n"); + assert!(bad["ok"] == false && bad["line"] == 3, "{bad}"); + } + + #[test] + fn the_command_line_parses_as_documented() { + Cli::command().debug_assert(); + let c = Cli::try_parse_from(["wai-quantum", "run", "p.qasm", "--shots", "10", "-o", "Z0", "-o", "X1 X2", "--noise", "readout=0.02", "--option", "max_bond=8", "--json"]).unwrap(); + match c.command { + Command::Run { job, out, .. } => { + assert_eq!(job.shots, Some(10)); + assert_eq!(job.observables, ["Z0", "X1 X2"]); + assert_eq!(job.noise, [("readout".to_string(), 0.02)]); + assert!(out.json); + } + _ => panic!("not run"), + } + // An unknown flag is an error, not ignored. + assert!(Cli::try_parse_from(["wai-quantum", "run", "p.qasm", "--shot", "10"]).is_err()); + // The research tools take their own words. + match Cli::try_parse_from(["wai-quantum", "lab", "qec", "-d", "5", "--p", "0.003"]).unwrap().command { + Command::Lab { args } => assert_eq!(args, ["qec", "-d", "5", "--p", "0.003"]), + _ => panic!("not lab"), + } + } +} diff --git a/wai/wai-quantum-cli/src/mcp.rs b/wai/wai-quantum-cli/src/mcp.rs new file mode 100644 index 000000000..66b2f9087 --- /dev/null +++ b/wai/wai-quantum-cli/src/mcp.rs @@ -0,0 +1,196 @@ +//! `wai-quantum mcp`: the command line's tools for an agent, as a Model Context +//! Protocol server on standard input and output (one JSON-RPC message per +//! line). Runs happen here, planned as the job service plans them, and are +//! signed with this user's key; nothing leaves the machine. + +use serde_json::{Value, json}; +use std::io::{BufRead, Write}; +use wai_quantum::ed25519_dalek::SigningKey; +use wai_quantum::engine::catalog; + +const PROTOCOL: &str = "2025-06-18"; + +fn job_schema() -> Value { + json!({ + "type": "object", + "required": ["qasm"], + "properties": { + "qasm": { "type": "string", "description": "The program, OpenQASM 3 or 2: registers and broadcast over them, the standard gates, ctrl/negctrl modifiers, gate definitions, measurement and reset anywhere, and if on measured bits. Loops, inv and pow are refused with their line." }, + "shots": { "type": "integer", "minimum": 0, "description": "Shots to sample; 1024 when nothing else is asked." }, + "seed": { "type": "integer", "minimum": 0, "description": "The sampling seed (default 1)." }, + "method": { "type": "string", "description": "auto (the default: the strongest method the program admits), or one of list_methods' names." }, + "observables": { "type": "array", "items": { "type": "string" }, "description": "Pauli observables, as \"Z0 Z1\" or \"0.5 X2\"." }, + "amplitudes": { "type": "array", "items": { "type": "string" }, "description": "Basis states whose amplitudes to return, as bit strings with the highest qubit first." }, + "noise": { "type": "object", "description": "Channel probabilities", "properties": catalog::NOISE_CHANNELS.iter().map(|k| (k.to_string(), json!({ "type": "number", "minimum": 0, "maximum": 1 }))).collect::>(), "additionalProperties": false }, + "options": { "type": "object", "description": "Method options: max_bond, fusion, trajectories, threshold, memory_gb." } + } + }) +} + +fn qasm_schema(extra: Value) -> Value { + let mut s = json!({ "type": "object", "required": ["qasm"], "properties": { "qasm": { "type": "string", "description": "The program, OpenQASM 3 or 2." } } }); + if let (Some(p), Some(e)) = (s["properties"].as_object_mut(), extra.as_object()) { + p.extend(e.clone()); + } + s +} + +fn tools() -> Value { + json!([ + { + "name": "run_quantum_circuit", + "title": "Run a quantum program here", + "description": "Run an OpenQASM program on this machine, planned as the quantum job service plans it: measurement counts, Pauli expectation values and/or amplitudes, the method chosen and why, its accuracy class, and a receipt signed with this user's key. Every number in the answer was computed; quote them, do not estimate.", + "inputSchema": job_schema(), + }, + { + "name": "plan_quantum_circuit", + "title": "Plan a quantum program", + "description": "The method a program would run on and why, the memory and work it would take, without running it.", + "inputSchema": job_schema(), + }, + { + "name": "lint_qasm", + "title": "Check an OpenQASM program", + "description": "Parse an OpenQASM program as the engine reads it: its qubits, gates and measured bits, or the first problem with its line.", + "inputSchema": qasm_schema(json!({})), + }, + { + "name": "draw_circuit", + "title": "Draw a circuit", + "description": "A program's circuit as text: one wire per qubit, controls as ●, targets as ⊕, measurements as M with their bit.", + "inputSchema": qasm_schema(json!({ "width": { "type": "integer", "minimum": 20, "description": "Wrap at this many columns (default 100)." } })), + }, + { + "name": "verify_quantum_result", + "title": "Check a result's receipt", + "description": "Whether a job's receipt seals its request and its result, as run_quantum_circuit or the job service returns them, and which key signed it.", + "inputSchema": { "type": "object", "required": ["job"], "properties": { "job": { "type": "object", "description": "The job: {\"request\": ..., \"result\": ...}, the result carrying its receipt." } } }, + }, + { + "name": "list_methods", + "title": "List the methods", + "description": "Each simulation method: its accuracy class, the circuits it takes, and the most qubits it holds in a job's memory here.", + "inputSchema": { "type": "object", "properties": {} }, + } + ]) +} + +/// A tool's answer: its JSON, or an error the agent can read and act on. +fn call(name: &str, args: &Value, memory_gb: u64, key: &SigningKey) -> Result { + let qasm = || args.get("qasm").and_then(Value::as_str).ok_or("give \"qasm\": the program"); + match name { + "run_quantum_circuit" => { + qasm()?; + crate::run_here(args, memory_gb, key) + } + "plan_quantum_circuit" => { + qasm()?; + catalog::plan(args, memory_gb << 30).map(|p| serde_json::to_value(p).unwrap_or(Value::Null)) + } + "lint_qasm" => Ok(crate::lint(qasm()?)), + "draw_circuit" => { + let p = wai_quantum::qasm::parse(qasm()?)?; + let width = args.get("width").and_then(Value::as_u64).unwrap_or(100).max(20) as usize; + Ok(json!({ "text": crate::draw::draw(&p, width) })) + } + "verify_quantum_result" => { + let job = args.get("job").ok_or("give \"job\": {\"request\": ..., \"result\": ...}")?; + let (valid, key) = crate::verify_job(job)?; + Ok(json!({ "valid": valid, "key": key })) + } + "list_methods" => Ok(json!({ "methods": catalog::describe(memory_gb << 30) })), + _ => Err(format!("no tool {name}")), + } +} + +/// The reply to one message, or `None` for a notification. +fn handle(msg: &Value, memory_gb: u64, key: &SigningKey) -> Option { + let id = msg.get("id")?.clone(); + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let ok = |result: Value| json!({ "jsonrpc": "2.0", "id": id, "result": result }); + let err = |code: i64, message: String| json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }); + Some(match method { + "initialize" => ok(json!({ + "protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL), + "capabilities": { "tools": {} }, + "serverInfo": { "name": "wai-quantum", "title": "wai-quantum", "version": env!("CARGO_PKG_VERSION") }, + "instructions": "Quantum programs in OpenQASM, run on this machine as the quantum job service runs them. Plan or lint before a large run. Every result names its method and accuracy; quote its numbers rather than estimating them." + })), + "ping" => ok(json!({})), + "tools/list" => ok(json!({ "tools": tools() })), + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + match call(name, &args, memory_gb, key) { + Ok(v) => ok(json!({ "content": [{ "type": "text", "text": v.to_string() }], "structuredContent": v, "isError": false })), + Err(e) => ok(json!({ "content": [{ "type": "text", "text": e }], "isError": true })), + } + } + _ => err(-32601, format!("no method {method}")), + }) +} + +/// Serve until standard input closes. +pub fn serve(memory_gb: u64, key: &SigningKey) { + let stdin = std::io::stdin(); + let mut out = std::io::stdout().lock(); + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(msg) => handle(&msg, memory_gb, key), + Err(e) => Some(json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": format!("not JSON: {e}") } })), + }; + if let Some(r) = reply + && writeln!(out, "{r}").and_then(|_| out.flush()).is_err() + { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> SigningKey { + SigningKey::from_bytes(&[5; 32]) + } + + #[test] + fn an_agent_initializes_lists_the_tools_and_runs_a_circuit() { + let init = handle(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": PROTOCOL } }), 1, &key()).unwrap(); + assert_eq!(init["result"]["serverInfo"]["name"], "wai-quantum"); + assert!(handle(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), 1, &key()).is_none()); + let list = handle(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }), 1, &key()).unwrap(); + let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["run_quantum_circuit", "plan_quantum_circuit", "lint_qasm", "draw_circuit", "verify_quantum_result", "list_methods"]); + let run = handle( + &json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "run_quantum_circuit", "arguments": { "qasm": "OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];", "observables": ["Z0 Z1"] } } }), + 1, + &key(), + ) + .unwrap(); + let job = &run["result"]["structuredContent"]; + assert_eq!(run["result"]["isError"], false, "{run}"); + assert_eq!(job["result"]["expectations"][0]["value"], 1.0); + // The job it returns checks with its own tool. + let check = handle(&json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "verify_quantum_result", "arguments": { "job": job } } }), 1, &key()).unwrap(); + assert_eq!(check["result"]["structuredContent"]["valid"], true, "{check}"); + } + + #[test] + fn a_bad_program_is_an_error_the_agent_can_read() { + let r = handle(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_quantum_circuit", "arguments": { "qasm": "qubit[1] q; foo q[0];" } } }), 1, &key()).unwrap(); + assert_eq!(r["result"]["isError"], true); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("foo"), "{r}"); + let lint = handle(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "lint_qasm", "arguments": { "qasm": "qubit[1] q;\nfoo q[0];" } } }), 1, &key()).unwrap(); + assert_eq!(lint["result"]["structuredContent"]["line"], 2, "{lint}"); + let none = handle(&json!({ "jsonrpc": "2.0", "id": 3, "method": "nonsense" }), 1, &key()).unwrap(); + assert_eq!(none["error"]["code"], -32601); + } +} diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 4d07fe7e5..f66eff0a5 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4280,7 +4280,6 @@ dependencies = [ "dav1d", "ed25519-dalek", "flac-bound", - "getrandom 0.2.17", "image", "jpeg-encoder", "jpegxl-rs", diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index cc0a07abf..55e0156b2 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -90,8 +90,6 @@ c2pa = { version = "0.90", optional = true, default-features = false, features = # deterministic fixed-point simulation, replay-equivalence conformance. blake3 = { version = "1.8", optional = true } # state hashes, node minting, op/seal commitments; `hazmat` (1.8) for the HTTP head proof ed25519-dalek = { version = "2", optional = true } # op + seal signatures -# The CLI's own signing key, made from the operating system's random source. -getrandom = { version = "0.2", optional = true } num-bigint = { version = "0.4", optional = true } # oer: exact decimal arithmetic in the composition checks # The curve ed25519-dalek is built on, named directly only by `receipt_claims`: its # conformance corpus builds the signatures jwp-receipts §3.1 step 3 must refuse (keys of @@ -179,8 +177,6 @@ semantic = ["codecs", "dep:ort", "dep:ndarray"] # the dependency on its own, so the one verifier builds exactly when ed25519-dalek does # and asks for no feature its caller does not need. Every signing feature enables it. ed25519 = ["dep:ed25519-dalek"] -# The wai-quantum CLI's per-user signing key (src/bin/wai_quantum.rs). -cli_keys = ["dep:getrandom"] # Interactive worlds (extensions/interactive-worlds): media that consumes # input. Pure-Rust, no ML dep chain — the sim is closed-form fixed-point. world = ["dep:blake3", "ed25519"] @@ -616,33 +612,6 @@ required-features = ["codecs"] name = "wai" path = "src/bin/wai.rs" -# The quantum stack as a CLI. Pure Rust, no host bindings — the same source builds -# for native AND `wasm32-wasip2`, producing byte-identical output under wasmtime. -[[bin]] -name = "wai-quantum" -path = "src/bin/wai_quantum.rs" -required-features = [ - "quantum_toolchain", - "quantum_qasm", - "cli_keys", - "quantum_receipt", - "quantum_qir", - "quantum_channel", - "quantum_comm", - "quantum_qudit", - "quantum_spd_receipt", - "quantum_bptn", - "quantum_resource_receipt", - "quantum_frame", - "quantum_tn", - "quantum_gbs", - "quantum_sv", - "quantum_arch", - "quantum_ldpc", - "quantum_qio", - "quantum_fabric", -] - [[bin]] name = "wai_int_synth_vectors" path = "src/bin/wai_int_synth_vectors.rs" -- 2.54.0 (Apple Git-157)