From 44f87fc907c1fec90bfee9d0bdcde83062d71ac9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 04:03:00 -0400 Subject: [PATCH 01/19] 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 (cherry picked from commit 593f80f102d7225e970fa6d4652bfda77d97a93f) --- 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 a56552f96..c674c29ab 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"] @@ -460,6 +464,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 @@ -633,6 +639,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 a9caedfc5..33446b0b6 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -792,6 +792,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 e13677ed9009a0650bcd761498ff8a0683444e63 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 04:28:01 -0400 Subject: [PATCH 02/19] 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 (cherry picked from commit ef1e3b54e2e598395c9dc5a192b5864a08f2830c) --- 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 aee35300f3659f28847a9fe1739a1ff004c1dabc Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 04:55:28 -0400 Subject: [PATCH 03/19] 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 (cherry picked from commit d6540016f6fd37cedf49da773d832079f072fc23) --- 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 506898792..4e5a034db 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. + ### Live L10b: end-to-end encrypted rooms in the browser Additive. No record, code point, reason or corpus vector changes. 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 a9f8b64b2c6f76a142c9e5d985d46af15f057956 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:28:45 -0400 Subject: [PATCH 04/19] 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 (cherry picked from commit 04da8bfb54c461d02eb020b4298c43327844ab68) --- .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 c9f69527a373a9d73fd87a9c2f7d0835119f6648 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 05:03:38 -0400 Subject: [PATCH 05/19] 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 (cherry picked from commit 225e62eadd7d2fb58cd0a54e0eaff74650608925) --- 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 3f3bea1268f5835efc70d13a7d436f606d93bfba Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:51:03 -0400 Subject: [PATCH 06/19] 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 (cherry picked from commit bc5bd68b5273c1b1b9be8e894732b2f2b9ca7327) --- 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 605ea19aa2f14876b080cdfd966194b6666e748a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:51:14 -0400 Subject: [PATCH 07/19] 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 (cherry picked from commit d2459835d8c489ffaf4d6c59b54220ddc7439819) --- .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 c674c29ab..07a1b441e 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"] @@ -632,33 +628,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) From 7acd4e223aea7bb893628f04a92ab2371ef30d17 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 09:03:01 -0400 Subject: [PATCH 08/19] wai-quantum engine in a page: one job function for every host, and the engine in the browser - engine::job: a job planned, run, sealed with the host's key and returned in the service's form (run), its check (verify), a key's did:key (did_of) and a program's lint. The command line now calls these; the browser binding calls the same. - engine::draw: the circuit drawer, moved from the command line. - The engine runs on wasm32-unknown-unknown: a result there carries no elapsed_ms (a tab has no clock std reads), and the cut-rank order search runs its seeds in turn where there are no threads, finding the same orders. - A program that measures in the middle on the state vector is admitted with the job's memory margin counted once, as trajectories count it, not once per state: two 256 MiB margins had left no room in 512 MiB, and the service refused a qubit it could hold. - wai-web, feature quantum_engine (off by default, for the IDE's bundle): QuantumEngine (run, plan, methods, signer_did) signing with the page's key, and engine_lint, engine_draw and engine_verify. Tested natively and in wasm32 under Node, where a job runs and verifies, a changed result is refused, and every method a tab holds runs; wai-web-wasm.yml runs both with their counts. - wai-rs: feature quantum_engine re-exports the engine as wai::engine. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit aa516abdb1af26150e79ba2d071ceb3ae7214301) --- .github/workflows/wai-web-wasm.yml | 22 ++ wai/wai-quantum-cli/src/main.rs | 86 ++----- wai/wai-quantum-cli/src/mcp.rs | 10 +- wai/wai-quantum/README.md | 1 + wai/wai-quantum/src/engine/catalog.rs | 40 ++-- .../src => wai-quantum/src/engine}/draw.rs | 4 +- wai/wai-quantum/src/engine/job.rs | 112 +++++++++ wai/wai-quantum/src/engine/mod.rs | 6 + wai/wai-quantum/src/engine/run.rs | 35 ++- wai/wai-rs/Cargo.lock | 1 + wai/wai-rs/Cargo.toml | 3 + wai/wai-rs/src/lib.rs | 2 + wai/wai-web/Cargo.lock | 1 + wai/wai-web/Cargo.toml | 5 + wai/wai-web/src/lib.rs | 5 + wai/wai-web/src/quantum_engine.rs | 216 ++++++++++++++++++ 16 files changed, 451 insertions(+), 98 deletions(-) rename wai/{wai-quantum-cli/src => wai-quantum/src/engine}/draw.rs (99%) create mode 100644 wai/wai-quantum/src/engine/job.rs create mode 100644 wai/wai-web/src/quantum_engine.rs diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 067adf0a0..5b2260d8b 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -149,6 +149,28 @@ jobs: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \ cargo test --locked --target wasm32-unknown-unknown 2>&1 | tee "$RUNNER_TEMP/wai-web-wasm.log" grep -q "test result: ok. 4 passed; 0 failed" "$RUNNER_TEMP/wai-web-wasm.log" + # The job service's engine in a page (src/quantum_engine.rs, feature + # quantum_engine, which only the IDE's bundle carries): a job planned, run and + # signed in wasm32 under Node, its receipt checked there and a changed result + # refused, and every method a tab holds run with no clock and no threads. The + # count is the four tests above and its two. + - name: The job engine in a page (wasm32 tests under Node) + shell: bash + working-directory: wai/wai-web + run: | + set -euo pipefail + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \ + cargo test --locked --target wasm32-unknown-unknown --features quantum_engine 2>&1 | tee "$RUNNER_TEMP/wai-web-engine-wasm.log" + grep -q "test result: ok. 6 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine-wasm.log" + # Its native half: the service's form and receipt, any angle on the state + # vector, what does not run saying why, and plan, lint, draw and methods as JSON. + - name: The job engine in a page (native unit tests) + shell: bash + working-directory: wai/wai-web + run: | + set -euo pipefail + cargo test --locked --lib --features quantum_engine quantum_engine:: 2>&1 | tee "$RUNNER_TEMP/wai-web-engine.log" + grep -q "test result: ok. 4 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine.log" # The crate's native unit tests, run on the host: the pure-Rust halves of the # bindings, which wasm_tests does not run. They decode the committed integer # payloads to their sidecars' SHA-256 (int_payload::tests) and check that the diff --git a/wai/wai-quantum-cli/src/main.rs b/wai/wai-quantum-cli/src/main.rs index 3d6f85a8e..4fd94ade8 100644 --- a/wai/wai-quantum-cli/src/main.rs +++ b/wai/wai-quantum-cli/src/main.rs @@ -22,7 +22,6 @@ //! `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; @@ -30,7 +29,7 @@ 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}; +use wai_quantum::engine::{catalog, draw, job}; #[derive(Parser)] #[command( @@ -241,44 +240,13 @@ fn request(job: &Job, qasm: String) -> Value { r } +/// What a result says of its energy: the command line does not meter a run. +const UNMETERED: &str = "the command line does not meter a run's energy"; + /// The job body `run` gives, as the service gives it: the request, the plan, -/// and the result sealed with `key`. +/// and the result sealed with `key` ([`job::run`]). 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)) + job::run(req, memory_gb << 30, key, json!({ "unmetered": UNMETERED })) } /// A job body as a reader takes it in. @@ -327,8 +295,8 @@ fn print_job(body: &Value, top: usize) { 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())); + let ok = job::verify(body).map(|(ok, _)| ok).unwrap_or(false); + println!("receipt {}, signed by {}", if ok { "verifies" } else { "DOES NOT VERIFY" }, job::did_of(key).unwrap_or_else(|| key.to_string())); } } @@ -370,23 +338,6 @@ fn submit(req: &Value, service: &str) -> Result { 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(); @@ -418,7 +369,7 @@ fn main() { 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); + let v = job::lint(&src); if json { println!("{v}"); } else if v["ok"] == true { @@ -451,11 +402,11 @@ fn main() { 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)); + let (ok, key) = job::verify(&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())); + println!("signer {}", job::did_of(k).unwrap_or_else(|| k.to_string())); } if !ok { std::process::exit(1); @@ -520,17 +471,17 @@ mod tests { 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); + assert!(job::verify(&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); + assert!(!job::verify(&forged).unwrap().0); let mut forged = body.clone(); forged["request"]["shots"] = json!(999); - assert!(!verify_job(&forged).unwrap().0); + assert!(!job::verify(&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); + assert!(job::verify(&again).unwrap().0); } #[test] @@ -543,13 +494,6 @@ mod tests { 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() { diff --git a/wai/wai-quantum-cli/src/mcp.rs b/wai/wai-quantum-cli/src/mcp.rs index 66b2f9087..9d8f140c1 100644 --- a/wai/wai-quantum-cli/src/mcp.rs +++ b/wai/wai-quantum-cli/src/mcp.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use std::io::{BufRead, Write}; use wai_quantum::ed25519_dalek::SigningKey; -use wai_quantum::engine::catalog; +use wai_quantum::engine::{catalog, draw, job}; const PROTOCOL: &str = "2025-06-18"; @@ -88,15 +88,15 @@ fn call(name: &str, args: &Value, memory_gb: u64, key: &SigningKey) -> Result Ok(crate::lint(qasm()?)), + "lint_qasm" => Ok(job::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) })) + Ok(json!({ "text": 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)?; + let given = args.get("job").ok_or("give \"job\": {\"request\": ..., \"result\": ...}")?; + let (valid, key) = job::verify(given)?; Ok(json!({ "valid": valid, "key": key })) } "list_methods" => Ok(json!({ "methods": catalog::describe(memory_gb << 30) })), diff --git a/wai/wai-quantum/README.md b/wai/wai-quantum/README.md index 035bd2824..1d7a1da4f 100644 --- a/wai/wai-quantum/README.md +++ b/wai/wai-quantum/README.md @@ -68,6 +68,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_engine` | **the quantum job service's engine, for every host**: a job (an OpenQASM program and what is asked of it: shots, Pauli expectation values, amplitudes, noise) planned onto the strongest method it admits, with the reason and the memory and work it will take; run; answered in the service's JSON with its accuracy class; and sealed over the request and the result in their canonical JSON (`engine::job`, `engine::receipt`), so a result from the service, the command line or a browser tab checks the same way. Also a program's circuit drawn as text (`engine::draw`). It builds for `wasm32-unknown-unknown`, where it runs on one thread and claims no time | | `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-quantum/src/engine/catalog.rs b/wai/wai-quantum/src/engine/catalog.rs index 743bbca1b..8a4c10152 100644 --- a/wai/wai-quantum/src/engine/catalog.rs +++ b/wai/wai-quantum/src/engine/catalog.rs @@ -334,15 +334,21 @@ pub fn product_measured(r: &Request, thorough: bool) -> Result> = std::thread::scope(|sc| { - let handles: Vec<_> = (1..=CUTRANK_RUN_SEEDS) - .map(|seed| { - let pm = ± - sc.spawn(move || pm.anneal_order(m, seed)) - }) - .collect(); - handles.into_iter().map(|h| h.join().expect("a search")).collect() - }); + // One thread per seed, where there are threads; a browser tab has one, so + // there the seeds run in turn and find the same orders. + let found: Vec> = if cfg!(target_arch = "wasm32") { + (1..=CUTRANK_RUN_SEEDS).map(|seed| pm.anneal_order(m, seed)).collect() + } else { + std::thread::scope(|sc| { + let handles: Vec<_> = (1..=CUTRANK_RUN_SEEDS) + .map(|seed| { + let pm = ± + sc.spawn(move || pm.anneal_order(m, seed)) + }) + .collect(); + handles.into_iter().map(|h| h.join().expect("a search")).collect() + }) + }; let mut best = quick; for o in &found { let cand = pm.clone().with_order(o).map_err(|e| e.to_string())?; @@ -422,12 +428,16 @@ fn try_dynamic(method: &str, r: &Request, clifford: &Result { - let one = statevector_bytes(n); - 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))); + // At least two states (a branch's copy beside the state it left), + // at most sixteen, and the job's margin once, as for trajectories. + let fits = |m: u32| 2 * amplitude_bytes(m) + 256 * MIB <= cap; + if n > crate::quantum_sv::MAX_QUBITS || !fits(n) { + let most = (1..=crate::quantum_sv::MAX_QUBITS).rev().find(|&m| fits(m)).unwrap_or(0); + return Err(format!("statevector runs a program that measures in the middle on at most {most} qubits: a branch holds a second state")); } - let states = (cap / one).clamp(2, 16); - Ok(plan(states * one + r.shots * 24, (gates << n).saturating_mul(r.shots.clamp(1, 64)), format!("dense double-precision state vector of {n} qubits, its shots split at each of {measured} mid-circuit measurements and resets"))) + let one = amplitude_bytes(n); + let states = (cap.saturating_sub(256 * MIB) / one).clamp(2, 16); + Ok(plan(states * one + 256 * MIB + r.shots * 24, (gates << n).saturating_mul(r.shots.clamp(1, 64)), format!("dense double-precision state vector of {n} qubits, its shots split at each of {measured} mid-circuit measurements and resets"))) } "stabilizer" => { if let Err(e) = clifford { @@ -752,7 +762,7 @@ mod tests { assert!(p.why.contains("split at each of 2 mid-circuit measurements"), "{}", p.why); let p = plan(&job(rotated, json!({ "observables": ["Z1"] })), CAP).unwrap(); assert_eq!(p.method, "statevector"); - assert!(p.memory_bytes >= 2 * statevector_bytes(2), "room for a branch's copy"); + assert!(p.memory_bytes >= 2 * amplitude_bytes(2) + 256 * MIB, "room for a branch's copy"); for (extra, needle) in [ (json!({ "method": "mps" }), "statevector and stabilizer do"), (json!({ "method": "exact" }), "statevector and stabilizer do"), diff --git a/wai/wai-quantum-cli/src/draw.rs b/wai/wai-quantum/src/engine/draw.rs similarity index 99% rename from wai/wai-quantum-cli/src/draw.rs rename to wai/wai-quantum/src/engine/draw.rs index 38decd98a..0e46061fd 100644 --- a/wai/wai-quantum-cli/src/draw.rs +++ b/wai/wai-quantum/src/engine/draw.rs @@ -5,7 +5,7 @@ //! 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}; +use crate::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. @@ -270,7 +270,7 @@ pub fn draw(p: &Program, width: usize) -> String { #[cfg(test)] mod tests { use super::*; - use wai_quantum::qasm::parse; + use crate::qasm::parse; #[test] fn angles_are_written_as_multiples_of_pi_where_they_are() { diff --git a/wai/wai-quantum/src/engine/job.rs b/wai/wai-quantum/src/engine/job.rs new file mode 100644 index 000000000..82b7b5ced --- /dev/null +++ b/wai/wai-quantum/src/engine/job.rs @@ -0,0 +1,112 @@ +//! A job end to end, as every host gives it: planned, run, sealed with the +//! host's key, and returned in the service's form, the request, the plan +//! and the result with its receipt. The service, the command line, the +//! desktop app and a browser tab each call [`run`] with their own key and +//! their own word on energy, and [`verify`] checks any of their answers. + +use super::{catalog, receipt}; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde_json::{Value, json}; + +/// The job body for `request`: planned within `job_bytes`, run, with +/// `energy` (the host's figure, or why it has none) inside the result, and +/// the result sealed with `key`. +pub fn run(request: &Value, job_bytes: u64, key: &SigningKey, energy: Value) -> Result { + if let Some(at) = receipt::inexact_number(request) { + 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(request, job_bytes)?; + let mut result = super::run::run(request, &plan)?; + result["energy"] = energy; + let seal = receipt::seal(key, request, &result); + result["receipt"] = seal; + Ok(json!({ + "status": "done", + "method": plan.method, + "why": plan.why, + "estimate": { "memory_bytes": plan.memory_bytes, "work": plan.work }, + "request": request, + "result": result, + })) +} + +/// Whether a job body's result carries a receipt that seals that result and +/// its request, and the key the receipt names. +pub fn verify(job: &Value) -> Result<(bool, Value), String> { + let (Some(request), Some(result)) = (job.get("request"), job.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))) +} + +/// The `did:key` of a key given as 64 hex digits, as a receipt names it. +pub fn did_of(key_hex: &str) -> Option { + if key_hex.len() != 64 { + return None; + } + let mut b = [0u8; 32]; + for (i, x) in b.iter_mut().enumerate() { + *x = u8::from_str_radix(key_hex.get(2 * i..2 * i + 2)?, 16).ok()?; + } + Some(crate::quantum_receipt::did_key(&VerifyingKey::from_bytes(&b).ok()?)) +} + +/// A program read as the engine reads it: `{"ok": true}` with its qubits, +/// gates, bits and measurements (and its steps, when it is dynamic), or +/// `{"ok": false}` with the problem and its line. +pub fn lint(src: &str) -> Value { + match crate::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 }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_job_is_the_services_form_and_its_receipt_holds_only_as_given() { + let req = json!({ "qasm": "OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];", "shots": 1000, "observables": ["Z0 Z1"] }); + let key = SigningKey::from_bytes(&[3; 32]); + let body = run(&req, 1 << 30, &key, json!({ "unmetered": "a test" })).unwrap(); + assert_eq!(body["method"], "stabilizer"); + assert_eq!(body["result"]["expectations"][0]["value"], 1.0); + assert_eq!(body["result"]["energy"]["unmetered"], "a test"); + assert!(body["result"]["counts"].as_object().unwrap().keys().all(|k| k == "00" || k == "11")); + let (ok, k) = verify(&body).unwrap(); + assert!(ok); + assert_eq!(did_of(k.as_str().unwrap()).unwrap(), crate::quantum_receipt::did_key(&key.verifying_key())); + for (path, v) in [("/result/expectations/0/value", json!(0.5)), ("/request/shots", json!(999)), ("/result/energy/unmetered", json!("other"))] { + let mut forged = body.clone(); + *forged.pointer_mut(path).unwrap() = v; + assert!(!verify(&forged).unwrap().0, "{path}"); + } + let again: Value = serde_json::from_str(&body.to_string()).unwrap(); + assert!(verify(&again).unwrap().0); + assert!(verify(&json!({ "request": {} })).is_err()); + } + + #[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["dynamic_steps"].is_null(), "{ok}"); + let bad = lint("OPENQASM 3.0;\nqubit[1] q;\nfoo q[0];\n"); + assert!(bad["ok"] == false && bad["line"] == 3, "{bad}"); + } +} diff --git a/wai/wai-quantum/src/engine/mod.rs b/wai/wai-quantum/src/engine/mod.rs index e3b70e09c..c4dea9716 100644 --- a/wai/wai-quantum/src/engine/mod.rs +++ b/wai/wai-quantum/src/engine/mod.rs @@ -15,13 +15,19 @@ //! 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. +//! - [`job`] (with `quantum_receipt`): a job end to end, planned, run and +//! sealed, in the service's form; its check; and a program's lint. +//! - [`draw`]: a program's circuit as text. //! //! Requests and results are the service's JSON (`/v1/openapi.json` there). //! Keys, metering a job's energy, queueing and limits per client stay with //! whatever hosts the engine. pub mod catalog; +pub mod draw; pub mod dynamic; #[cfg(feature = "quantum_receipt")] +pub mod job; +#[cfg(feature = "quantum_receipt")] pub mod receipt; pub mod run; diff --git a/wai/wai-quantum/src/engine/run.rs b/wai/wai-quantum/src/engine/run.rs index 823cc3572..08549f738 100644 --- a/wai/wai-quantum/src/engine/run.rs +++ b/wai/wai-quantum/src/engine/run.rs @@ -5,7 +5,6 @@ use crate::engine::catalog::{self, MAX_OUTCOMES, Plan, Request}; use serde_json::{Value, json}; use std::collections::HashMap; -use std::time::Instant; fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() @@ -85,10 +84,35 @@ fn expectation_entries(r: &Request, value: impl Fn(&[(u32, char)]) -> f64) -> Va Value::Array(r.observables.iter().map(|(text, coeff, string)| json!({ "observable": text, "coeff": coeff, "value": coeff * value(string) })).collect()) } +/// When a run started, on a target with a clock std can read. A browser tab +/// (wasm32-unknown-unknown) has none, so a result there carries no +/// `elapsed_ms`; its host can time the call itself. +#[derive(Clone, Copy)] +struct Started(Option); + +impl Started { + fn now() -> Started { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + Started(None) + } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + Started(Some(std::time::Instant::now())) + } + } + + fn stamp(self, out: &mut Value) { + if let Some(t) = self.0 { + out["elapsed_ms"] = json!(t.elapsed().as_millis() as u64); + } + } +} + /// Runs `plan` for the job `request`: the result, or why it failed. pub fn run(request: &Value, plan: &Plan) -> Result { let r = catalog::read(request)?; - let started = Instant::now(); + let started = Started::now(); let n = r.program.n_qubits; let mut out = json!({ "method": plan.method, @@ -97,7 +121,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { }); if let Some(steps) = &r.program.dynamic { run_dynamic(&r, plan, steps, &mut out)?; - out["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); + started.stamp(&mut out); return Ok(out); } match plan.method.as_str() { @@ -479,7 +503,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } other => return Err(format!("unknown method {other}")), } - out["elapsed_ms"] = json!(started.elapsed().as_millis() as u64); + started.stamp(&mut out); Ok(out) } @@ -493,7 +517,8 @@ fn run_dynamic(r: &catalog::Request, plan: &Plan, steps: &[crate::qasm::Instr], 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; + // The states the plan admitted: its memory less the job's margin and the counts. + let states = (plan.memory_bytes.saturating_sub((256 << 20) + r.shots * 24) / catalog::amplitude_bytes(n)).max(1) as usize; 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)?) }; diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index f66eff0a5..356826bcc 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4314,6 +4314,7 @@ version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", + "serde", "serde_json", "wgpu", ] diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 07a1b441e..2f54cb110 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -462,6 +462,9 @@ quantum_atom = ["quantum_ops", "wai-quantum/quantum_atom"] 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"] +# The job service's engine (wai_quantum::engine): plan, run and sign a job as the +# service does, re-exported as wai::engine for the browser binding. +quantum_engine = ["quantum_qasm", "quantum_receipt", "wai-quantum/quantum_engine"] # 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 diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 33446b0b6..189e83f18 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -794,6 +794,8 @@ pub use wai_quantum::quantum_atom; pub use wai_quantum::quantum_toolchain; #[cfg(feature = "quantum_qasm")] pub use wai_quantum::qasm; +#[cfg(feature = "quantum_engine")] +pub use wai_quantum::engine; // 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.lock b/wai/wai-web/Cargo.lock index 526a2a58b..4924e7a2a 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -1245,6 +1245,7 @@ version = "0.6.10" dependencies = [ "blake3", "ed25519-dalek", + "serde", "serde_json", "wgpu", ] diff --git a/wai/wai-web/Cargo.toml b/wai/wai-web/Cargo.toml index 23b58c8fc..27d6dc846 100644 --- a/wai/wai-web/Cargo.toml +++ b/wai/wai-web/Cargo.toml @@ -33,6 +33,11 @@ wasm-bindgen-futures = { version = "0.4.77", optional = true } # --out-name (demo/quantum_webgpu.html); off by default, so the main bundle is # unchanged. quantum_webgpu = ["wai/quantum_fabric", "wai/quantum_fabric_webgpu", "dep:wasm-bindgen-futures"] +# The job service's engine in a page (src/quantum_engine.rs): a job planned, run +# and signed in the tab as the service runs it, its receipt checked there. Built +# into the IDE's bundle (scripts/wasm-pkgs.tsv on the site), which the console +# also loads; off by default, so the main bundle is unchanged. +quantum_engine = ["wai/quantum_engine"] [dev-dependencies] # Pinned to the wasm-bindgen release Cargo.lock holds, so the test runner and the diff --git a/wai/wai-web/src/lib.rs b/wai/wai-web/src/lib.rs index 1ceb23f63..fada30dd4 100644 --- a/wai/wai-web/src/lib.rs +++ b/wai/wai-web/src/lib.rs @@ -160,6 +160,11 @@ pub mod quantum_qbom; #[cfg(all(feature = "quantum_webgpu", target_arch = "wasm32"))] mod quantum_webgpu; +// The job service's engine in the page: plan, run and sign a job in the tab, and +// check any job's receipt there, the service's included. The IDE's bundle only. +#[cfg(feature = "quantum_engine")] +pub mod quantum_engine; + // Staged delivery (extensions/staged-delivery.md): wai::staged::StageSink, the sink a // native player runs, bound for JS — bases added, refinement layers offered, holds // expired and bases removed in any order, every unit's stage, producer, tier, output diff --git a/wai/wai-web/src/quantum_engine.rs b/wai/wai-web/src/quantum_engine.rs new file mode 100644 index 000000000..ab3d04ee6 --- /dev/null +++ b/wai/wai-web/src/quantum_engine.rs @@ -0,0 +1,216 @@ +//! The quantum job service's engine in a page ([`wai::engine`]): a job +//! planned, run and sealed in the tab exactly as the service plans, runs and +//! seals it, with the page's own key; the receipt of any job, the service's +//! included, checked in the tab; a program linted and drawn. Requests and +//! answers are the service's JSON, so a page shows a job run here and one run +//! there with the same code. + +use ed25519_dalek::SigningKey; +use serde_json::{Value, json}; +use wai::engine::{catalog, draw, job}; +use wasm_bindgen::prelude::*; + +/// What a result run in a tab says of its energy. +const UNMETERED: &str = "a browser tab does not meter a run's energy"; + +fn error_json(e: impl std::fmt::Display) -> String { + json!({ "error": e.to_string() }).to_string() +} + +fn read(request: &str) -> Result { + serde_json::from_str(request).map_err(|e| format!("the job is not JSON: {e}")) +} + +/// The engine, with the key it signs with and the memory a job may take. +#[wasm_bindgen] +pub struct QuantumEngine { + key: SigningKey, + job_bytes: u64, +} + +#[wasm_bindgen] +impl QuantumEngine { + /// An engine that signs with the Ed25519 key whose 32-byte seed is + /// `seed` (the page makes one at random and keeps it in this browser), + /// and admits a job of at most `memory_mb` MiB. + #[wasm_bindgen(constructor)] + pub fn new(seed: &[u8], memory_mb: u32) -> Result { + let bytes: [u8; 32] = seed.try_into().map_err(|_| format!("a signing key's seed is 32 bytes, not {}", seed.len()))?; + Ok(QuantumEngine { key: SigningKey::from_bytes(&bytes), job_bytes: u64::from(memory_mb.max(1)) << 20 }) + } + + /// The signer the receipts name: the key as a `did:key`. + pub fn signer_did(&self) -> String { + wai::quantum_receipt::did_key(&self.key.verifying_key()) + } + + /// Run a job (the service's request JSON): the job as the service + /// returns it, the request, the plan and the result with its receipt; or + /// `{"error"}` with why it did not run. + pub fn run(&self, request: &str) -> String { + match read(request).and_then(|req| job::run(&req, self.job_bytes, &self.key, json!({ "unmetered": UNMETERED }))) { + Ok(body) => body.to_string(), + Err(e) => error_json(e), + } + } + + /// The method a job would run on and why, its memory and work, without + /// running it; or `{"error"}`. + pub fn plan(&self, request: &str) -> String { + match read(request).and_then(|req| catalog::plan(&req, self.job_bytes)) { + Ok(p) => serde_json::to_value(p).map(|v| v.to_string()).unwrap_or_else(error_json), + Err(e) => error_json(e), + } + } + + /// Each method, its accuracy, what it takes, and the most qubits it holds + /// in a job's memory here. + pub fn methods(&self) -> String { + json!({ "methods": catalog::describe(self.job_bytes) }).to_string() + } +} + +/// A program read as the engine reads it: `{"ok": true, ...}` with its shape, +/// or `{"ok": false, "error", "line"}`. +#[wasm_bindgen] +pub fn engine_lint(src: &str) -> String { + job::lint(src).to_string() +} + +/// A program's circuit as text, wrapped at `width` columns: `{"text"}`, or +/// `{"error"}`. +#[wasm_bindgen] +pub fn engine_draw(src: &str, width: u32) -> String { + match wai::qasm::parse(src) { + Ok(p) => json!({ "text": draw::draw(&p, width.max(20) as usize) }).to_string(), + Err(e) => error_json(e), + } +} + +/// Whether a job's receipt seals its request and its result, checked here: +/// `{"valid", "key", "signer"}` (the signer as a `did:key`), or `{"error"}`. +#[wasm_bindgen] +pub fn engine_verify(job_json: &str) -> String { + let v: Value = match serde_json::from_str(job_json) { + Ok(v) => v, + Err(e) => return error_json(format!("not JSON: {e}")), + }; + match job::verify(&v) { + Ok((valid, key)) => { + let signer = key.as_str().and_then(job::did_of); + json!({ "valid": valid, "key": key, "signer": signer }).to_string() + } + Err(e) => error_json(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bell(extra: &str) -> String { + format!("{{\"qasm\": \"OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];\"{extra}}}") + } + + /// A job run in the tab is the service's form, signed with the page's key, + /// and its receipt checks there; a changed result does not. + #[test] + fn a_job_runs_signs_and_verifies_in_the_page() { + let e = QuantumEngine::new(&[9u8; 32], 512).unwrap(); + let body: Value = serde_json::from_str(&e.run(&bell(", \"shots\": 500, \"observables\": [\"Z0 Z1\"]"))).unwrap(); + assert_eq!(body["method"], "stabilizer", "{body}"); + assert_eq!(body["result"]["expectations"][0]["value"], 1.0); + assert_eq!(body["result"]["energy"]["unmetered"], UNMETERED); + let v: Value = serde_json::from_str(&engine_verify(&body.to_string())).unwrap(); + assert_eq!(v["valid"], true, "{v}"); + assert_eq!(v["signer"], e.signer_did()); + let mut forged = body.clone(); + forged["result"]["expectations"][0]["value"] = json!(-1.0); + let v: Value = serde_json::from_str(&engine_verify(&forged.to_string())).unwrap(); + assert_eq!(v["valid"], false, "{v}"); + } + + /// Any angle runs, on the double-precision state vector. + #[test] + fn a_rotation_runs_on_the_state_vector() { + let e = QuantumEngine::new(&[9u8; 32], 512).unwrap(); + let req = r#"{"qasm": "OPENQASM 3.0; qubit[1] q; rx(0.3) q[0];", "observables": ["Z0"]}"#; + let body: Value = serde_json::from_str(&e.run(req)).unwrap(); + assert_eq!(body["method"], "statevector", "{body}"); + assert!((body["result"]["expectations"][0]["value"].as_f64().unwrap() - 0.3f64.cos()).abs() < 1e-12, "{body}"); + } + + /// What the page cannot run says why: a job too large for the tab's + /// memory, a program the reader refuses, a request that is not JSON. + #[test] + fn what_does_not_run_says_why() { + let e = QuantumEngine::new(&[9u8; 32], 64).unwrap(); + let wide = r#"{"qasm": "OPENQASM 3.0; qubit[30] q; rx(0.3) q; cx q[0], q[29];", "amplitudes": ["000000000000000000000000000000"], "method": "statevector"}"#; + let r: Value = serde_json::from_str(&e.run(wide)).unwrap(); + assert!(r["error"].as_str().unwrap().contains("statevector holds at most"), "{r}"); + let r: Value = serde_json::from_str(&e.run(r#"{"qasm": "qubit[1] q; foo q[0];"}"#)).unwrap(); + assert!(r["error"].as_str().unwrap().contains("foo"), "{r}"); + let r: Value = serde_json::from_str(&e.run("not json")).unwrap(); + assert!(r["error"].as_str().unwrap().starts_with("the job is not JSON"), "{r}"); + assert!(QuantumEngine::new(&[1u8; 31], 64).is_err()); + } + + #[test] + fn plan_lint_draw_and_methods_answer_as_json() { + let e = QuantumEngine::new(&[9u8; 32], 512).unwrap(); + let p: Value = serde_json::from_str(&e.plan(&bell(""))).unwrap(); + assert_eq!(p["method"], "stabilizer", "{p}"); + let l: Value = serde_json::from_str(&engine_lint("qubit[1] q;\nfoo q[0];")).unwrap(); + assert_eq!(l["line"], 2, "{l}"); + let d: Value = serde_json::from_str(&engine_draw("qubit[2] q; h q[0]; cx q[0], q[1];", 80)).unwrap(); + assert!(d["text"].as_str().unwrap().contains('⊕'), "{d}"); + let m: Value = serde_json::from_str(&e.methods()).unwrap(); + assert!(m["methods"].as_array().unwrap().iter().any(|x| x["method"] == "cutrank"), "{m}"); + } +} + +/// The engine compiled to wasm32 and run under Node, as a page runs it: no +/// clock std can read and no threads, on every method a tab holds. +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + use super::*; + use wasm_bindgen_test::*; + + fn run(e: &QuantumEngine, req: Value) -> Value { + serde_json::from_str(&e.run(&req.to_string())).unwrap() + } + + #[wasm_bindgen_test] + fn a_job_runs_and_verifies_in_wasm() { + let e = QuantumEngine::new(&[9u8; 32], 512).unwrap(); + let body = run(&e, json!({ "qasm": "OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];", "shots": 200 })); + assert_eq!(body["method"], "stabilizer", "{body}"); + // A tab has no clock std reads, so no time is claimed. + assert!(body["result"].get("elapsed_ms").is_none(), "{body}"); + let v: Value = serde_json::from_str(&engine_verify(&body.to_string())).unwrap(); + assert_eq!(v["valid"], true, "{v}"); + } + + #[wasm_bindgen_test] + fn every_method_a_tab_holds_runs_in_wasm() { + let e = QuantumEngine::new(&[9u8; 32], 512).unwrap(); + let ring = "OPENQASM 3.0; qubit[12] q; bit[12] c; h q; cz q[0], q[1]; cz q[1], q[2]; cz q[2], q[3]; cz q[3], q[4]; cz q[4], q[5]; cz q[5], q[6]; cz q[6], q[7]; cz q[7], q[8]; cz q[8], q[9]; cz q[9], q[10]; cz q[10], q[11]; t q; h q; c = measure q;"; + let jobs = [ + ("exact", json!({ "qasm": "OPENQASM 3.0; qubit[3] q; h q[0]; t q[0]; cx q[0], q[1];", "shots": 100 })), + ("statevector", json!({ "qasm": "OPENQASM 3.0; qubit[3] q; rx(0.3) q[0]; cx q[0], q[1];", "observables": ["Z0"] })), + ("stabilizer", json!({ "qasm": "OPENQASM 3.0; qubit[40] q; h q[0]; cx q[0], q[39];", "shots": 100 })), + ("mps", json!({ "qasm": "OPENQASM 3.0; qubit[30] q; rx(0.3) q; cx q[0], q[1];", "shots": 50, "method": "mps" })), + ("pauli", json!({ "qasm": "OPENQASM 3.0; qubit[30] q; rx(0.3) q; cx q[0], q[1];", "observables": ["Z0 Z1"], "method": "pauli" })), + ("tensor_network", json!({ "qasm": "OPENQASM 3.0; qubit[30] q; rx(0.3) q; cx q[0], q[1];", "amplitudes": ["0".repeat(30)], "method": "tensor_network" })), + ("cutrank", json!({ "qasm": ring, "shots": 20, "method": "cutrank" })), + ("density_matrix", json!({ "qasm": "OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];", "shots": 100, "noise": { "depolarizing_2q": 0.02 } })), + ("trajectories", json!({ "qasm": "OPENQASM 3.0; qubit[3] q; h q[0]; cx q[0], q[1];", "shots": 100, "noise": { "depolarizing_1q": 0.01 }, "method": "trajectories" })), + ("statevector", json!({ "qasm": "OPENQASM 3.0; qubit[2] q; bit[2] c; rx(0.4) q[0]; c[0] = measure q[0]; if (c[0]) x q[1]; c[1] = measure q[1];", "shots": 100 })), + ]; + for (want, req) in jobs { + let body = run(&e, req.clone()); + assert_eq!(body["method"], want, "{req}: {body}"); + assert_eq!(body["status"], "done", "{req}: {body}"); + } + } +} -- 2.54.0 (Apple Git-157) From 73c6d04a557a4e8a32b35d07e9a8717cffccfae1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 09:14:53 -0400 Subject: [PATCH 09/19] wai-quantum 0.6.11, wai-quantum-cli 0.1.0: the changelog, the version in every lockfile that pins it Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 5e1bcc0b68d2520aeff1543b1009133966588251) --- wai/CHANGELOG.md | 29 +++++++++++++++++++++++++ wai/tools/quantum-meter/Cargo.lock | 2 +- wai/tools/quantum-wgsl-check/Cargo.lock | 2 +- wai/wai-pack/Cargo.lock | 2 +- wai/wai-quantum-cli/Cargo.lock | 2 +- wai/wai-quantum-cli/Cargo.toml | 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-rs/Cargo.lock | 2 +- wai/wai-web/Cargo.lock | 2 +- 13 files changed, 41 insertions(+), 12 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 4e5a034db..e94b3d0ec 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,35 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### wai-quantum 0.6.11 and wai-quantum-cli 0.1.0: one job engine for the service, the command line and a browser tab + +Additive, apart from two refusals and an admission noted below. Every pinned digest is unchanged. + +- **`wai_quantum::engine`** (feature `quantum_engine`) is the job service's engine, moved into the library. + - `catalog` plans a job onto the strongest method it admits, and says why. + - `run` carries the plan out. + - `dynamic` runs programs that measure in the middle. + - `receipt` is the service's receipt form: a result sealed over the request and the result in their RFC 8785 canonical text. + - `job` takes a job end to end and checks it. + - `draw` writes a program's circuit as text. + + The service imports all of these. A job now gives the same result and the same receipt form whether it runs on the service, on the command line or in a browser tab. +- **The engine builds for `wasm32-unknown-unknown`.** There a result carries no `elapsed_ms`, since a tab has no clock std can read. The multi-seed cut-rank order search runs its seeds one after another and finds the same orders. +- **A noise channel the model does not know is refused** (`catalog::NOISE_CHANNELS`). It was read as no noise, so a misspelt channel ran the circuit noiseless. +- **The job's memory margin is counted once for a program that measures in the middle on the state vector,** as it is for trajectories. It was counted once per state, and the service refused a qubit it could hold. +- **`quantum_receipt::did_key`** gives an Ed25519 key's `did:key`, held to the W3C method's example. +- **`wai-quantum-cli` 0.1.0** is a new crate whose binary is `wai-quantum`. Its commands: + - `run`: planned and run as the service runs it, signed with the user's key; + - `plan`, `lint` and `draw`; + - `submit` to the service; + - `verify`, for any receipt this tool, the service or `lab` signs; + - `key`, `methods` and `completions`; + - `mcp`, which serves these tools to an agent over stdio; + - `lab`, which holds the earlier command's research tools. + + It depends on the library alone. The `wai-quantum` bin and the `cli_keys` feature leave wai-rs. +- **wai-web, feature `quantum_engine`:** `QuantumEngine` (run, plan, methods and signer), `engine_lint`, `engine_draw` and `engine_verify`, for a page. Tested in wasm32 under Node on every method a tab holds. + ### 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. diff --git a/wai/tools/quantum-meter/Cargo.lock b/wai/tools/quantum-meter/Cargo.lock index 75358a064..4300fb6a2 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.10" +version = "0.6.11" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/tools/quantum-wgsl-check/Cargo.lock b/wai/tools/quantum-wgsl-check/Cargo.lock index 07f8b0b28..6140a68a4 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.10" +version = "0.6.11" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index 0095c1963..c491d5a53 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.10" +version = "0.6.11" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-cli/Cargo.lock b/wai/wai-quantum-cli/Cargo.lock index ad80a8ed5..deeb58e91 100644 --- a/wai/wai-quantum-cli/Cargo.lock +++ b/wai/wai-quantum-cli/Cargo.lock @@ -1120,7 +1120,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.10" +version = "0.6.11" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-cli/Cargo.toml b/wai/wai-quantum-cli/Cargo.toml index 5e0976564..f48d550f9 100644 --- a/wai/wai-quantum-cli/Cargo.toml +++ b/wai/wai-quantum-cli/Cargo.toml @@ -30,4 +30,4 @@ getrandom = "0.4" # 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"] } +wai-quantum = { version = "0.6.11", path = "../wai-quantum", default-features = false, features = ["full"] } diff --git a/wai/wai-quantum-qpu/Cargo.lock b/wai/wai-quantum-qpu/Cargo.lock index c83505a33..ccc628b7d 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.10" +version = "0.6.11" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index 5faa436cf..71dcfd7fa 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.10" +version = "0.6.11" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index 50f7cb9d4..2d240a0a6 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.10" +version = "0.6.11" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index f5d79c456..81954092f 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.10" +version = "0.6.11" dependencies = [ "blake3", "block2", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 93a881c5a..0652599d6 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.6.10" +version = "0.6.11" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 356826bcc..701ee84c4 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4310,7 +4310,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.10" +version = "0.6.11" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 4924e7a2a..e900293eb 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.10" +version = "0.6.11" dependencies = [ "blake3", "ed25519-dalek", -- 2.54.0 (Apple Git-157) From b3ac90034c967fefb116142624d590522a828f38 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 09:37:22 -0400 Subject: [PATCH 10/19] Quantum Studio: one interface for the browser and the desktop app, on the job service's engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wai/quantum-studio is an editor for OpenQASM programs with three places to run them: this browser (the engine in WebAssembly, in a worker), this computer (the engine built natively, in the desktop app), and the job service. Each returns the same job, and the Studio shows it the same way and checks its receipt where it runs. The same request and seed give the same counts in every place. - The editor highlights OpenQASM, completes the standard gates and the program's own registers, marks the engine's diagnostics on their lines, and draws the circuit and shows the plan as the program is typed. Ctrl/⌘+Enter runs; Escape cancels. - A result shows its method and why, its accuracy class, counts, expectation values, amplitudes with probability and phase, and a receipt naming whose key signed it, checked on this device, which can be downloaded. The program and the history of runs are kept on the device; a link carries the program in its fragment. - It takes the host's design tokens (tokens.css when it stands alone) and follows the light and dark schemes. - test/studio.test.mjs runs it in a browser: lint, draw and plan; a run checked here; an error on its line; any angle; the service giving the counts run here, shot for shot; Ctrl+Enter; the program and the history surviving a reload; no sideways scroll at a phone's width. wai/wai-quantum-desktop is the app: the Studio's build around the engine natively. Each job runs in a process of its own (the binary as `worker`), so Cancel ends it and a large one cannot take the app with it. The worker plans and runs, and the app seals the answer with this user's key, the command line's. Jobs and programs are saved under ~/Documents/Quantum Studio/. `check` runs a Bell pair through the worker, sealed and verified, with no window. engine::job splits into plan_and_run and seal (run is both), for hosts that run a job away from their key. wai-quantum-cli gains a library target for its key module, shared with the app. .github/workflows/wai-quantum-studio.yml builds the engine's browser package, builds the Studio and runs its test, and on macOS runs the app's tests, clippy and check. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 547d6db753c0d2ce14e25d607fdcf1ebe42c108a) --- .github/workflows/wai-quantum-studio.yml | 137 + wai/quantum-studio/.gitignore | 3 + wai/quantum-studio/README.md | 61 + wai/quantum-studio/build.mjs | 22 + wai/quantum-studio/package-lock.json | 694 +++ wai/quantum-studio/package.json | 26 + wai/quantum-studio/src/engine.js | 130 + wai/quantum-studio/src/examples.js | 58 + wai/quantum-studio/src/history.js | 53 + wai/quantum-studio/src/index.html | 22 + wai/quantum-studio/src/main.js | 412 ++ wai/quantum-studio/src/qasm-lang.js | 83 + wai/quantum-studio/src/studio.css | 101 + wai/quantum-studio/src/tokens.css | 178 + wai/quantum-studio/src/worker.js | 47 + wai/quantum-studio/test/studio.test.mjs | 148 + wai/wai-quantum-cli/Cargo.toml | 4 + wai/wai-quantum-cli/src/lab.rs | 4 +- wai/wai-quantum-cli/src/lib.rs | 4 + wai/wai-quantum-cli/src/main.rs | 2 +- wai/wai-quantum-desktop/.gitignore | 1 + wai/wai-quantum-desktop/Cargo.lock | 4691 +++++++++++++++++ wai/wai-quantum-desktop/Cargo.toml | 29 + wai/wai-quantum-desktop/README.md | 22 + wai/wai-quantum-desktop/build.rs | 3 + .../capabilities/default.json | 6 + wai/wai-quantum-desktop/icons/128x128.png | Bin 0 -> 3251 bytes wai/wai-quantum-desktop/icons/128x128@2x.png | Bin 0 -> 8233 bytes wai/wai-quantum-desktop/icons/32x32.png | Bin 0 -> 713 bytes wai/wai-quantum-desktop/icons/icon.png | Bin 0 -> 19047 bytes wai/wai-quantum-desktop/src/main.rs | 267 + wai/wai-quantum-desktop/tauri.conf.json | 33 + wai/wai-quantum/src/engine/job.rs | 20 +- 33 files changed, 7255 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/wai-quantum-studio.yml create mode 100644 wai/quantum-studio/.gitignore create mode 100644 wai/quantum-studio/README.md create mode 100644 wai/quantum-studio/build.mjs create mode 100644 wai/quantum-studio/package-lock.json create mode 100644 wai/quantum-studio/package.json create mode 100644 wai/quantum-studio/src/engine.js create mode 100644 wai/quantum-studio/src/examples.js create mode 100644 wai/quantum-studio/src/history.js create mode 100644 wai/quantum-studio/src/index.html create mode 100644 wai/quantum-studio/src/main.js create mode 100644 wai/quantum-studio/src/qasm-lang.js create mode 100644 wai/quantum-studio/src/studio.css create mode 100644 wai/quantum-studio/src/tokens.css create mode 100644 wai/quantum-studio/src/worker.js create mode 100644 wai/quantum-studio/test/studio.test.mjs create mode 100644 wai/wai-quantum-cli/src/lib.rs create mode 100644 wai/wai-quantum-desktop/.gitignore create mode 100644 wai/wai-quantum-desktop/Cargo.lock create mode 100644 wai/wai-quantum-desktop/Cargo.toml create mode 100644 wai/wai-quantum-desktop/README.md create mode 100644 wai/wai-quantum-desktop/build.rs create mode 100644 wai/wai-quantum-desktop/capabilities/default.json create mode 100644 wai/wai-quantum-desktop/icons/128x128.png create mode 100644 wai/wai-quantum-desktop/icons/128x128@2x.png create mode 100644 wai/wai-quantum-desktop/icons/32x32.png create mode 100644 wai/wai-quantum-desktop/icons/icon.png create mode 100644 wai/wai-quantum-desktop/src/main.rs create mode 100644 wai/wai-quantum-desktop/tauri.conf.json diff --git a/.github/workflows/wai-quantum-studio.yml b/.github/workflows/wai-quantum-studio.yml new file mode 100644 index 000000000..3b801bf68 --- /dev/null +++ b/.github/workflows/wai-quantum-studio.yml @@ -0,0 +1,137 @@ +name: wai-quantum studio and desktop + +# Quantum Studio (wai/quantum-studio), the interface for the browser and the +# desktop, and the desktop app around it (wai/wai-quantum-desktop): +# - the Studio built against the engine's browser package (wai-web, feature +# quantum_engine) and run in a browser by test/studio.test.mjs: linted, drawn and +# planned as it is typed, run here with its receipt checked here, an error marked +# on its line, any angle on the state vector, Ctrl+Enter, and the program and the +# history kept across a reload; nothing scrolls sideways at a phone's width; +# - the desktop app's tests and clippy with warnings as errors, and `check`: a job +# run in the app's worker process, sealed in the app with a key that never leaves +# it, and verified, with no window. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/quantum-studio/**' + - 'wai/wai-quantum-desktop/**' + - 'wai/wai-quantum/**' + - 'wai/wai-quantum-cli/**' + - 'wai/wai-web/**' + - 'wai/wai-rs/**' + - 'rust-toolchain.toml' + - '.github/workflows/wai-quantum-studio.yml' + pull_request: + paths: + - 'wai/quantum-studio/**' + - 'wai/wai-quantum-desktop/**' + - 'wai/wai-quantum/**' + - 'wai/wai-quantum-cli/**' + - 'wai/wai-web/**' + - 'wai/wai-rs/**' + - 'rust-toolchain.toml' + - '.github/workflows/wai-quantum-studio.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-studio.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches the studio or the desktop app: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + studio: + name: studio · browser + 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 the wasm32 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 + rustup default 1.98.0 + rustup target add wasm32-unknown-unknown + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # Pinned to the wasm-bindgen version in wai-web/Cargo.lock. + - name: wasm-bindgen CLI 0.2.127 + shell: bash + run: cargo install --locked wasm-bindgen-cli --version 0.2.127 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: The engine's browser package + shell: bash + working-directory: wai/wai-web + run: | + set -euo pipefail + cargo build --locked --release --target wasm32-unknown-unknown --features quantum_engine + wasm-bindgen --target web --out-dir "$RUNNER_TEMP/pkg" --out-name wai_quantum_ide \ + target/wasm32-unknown-unknown/release/wai_web.wasm + - name: Build the Studio and run it in a browser + shell: bash + working-directory: wai/quantum-studio + env: + WAI_E2E_CHANNEL: '' + STUDIO_SKIP_SERVICE: '1' + run: | + set -euo pipefail + npm ci --no-audit --no-fund + npx playwright install --with-deps chromium + node build.mjs --pkg "$RUNNER_TEMP/pkg" + node test/studio.test.mjs + + desktop: + name: desktop app · macos + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: macos + timeout-minutes: 90 + steps: + - uses: actions/checkout@v5 + # launchd's PATH omits cargo and node on the macOS host runner. + - name: Put cargo and node on PATH + shell: bash + run: | + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + echo "/opt/homebrew/bin" >> "$GITHUB_PATH" + # The app embeds the Studio's build, so it is built first. + - name: Build the Studio + shell: bash + working-directory: wai/quantum-studio + run: | + set -euo pipefail + npm ci --no-audit --no-fund + node build.mjs + - name: Tests, clippy, and a job through the worker with no window + shell: bash + working-directory: wai/wai-quantum-desktop + run: | + set -euo pipefail + nice -n 10 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; } + nice -n 10 cargo clippy --locked --release --all-targets -- -D warnings + nice -n 10 cargo build --locked --release + ./target/release/wai-quantum-desktop check diff --git a/wai/quantum-studio/.gitignore b/wai/quantum-studio/.gitignore new file mode 100644 index 000000000..75a0c418b --- /dev/null +++ b/wai/quantum-studio/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +test/screenshots/ diff --git a/wai/quantum-studio/README.md b/wai/quantum-studio/README.md new file mode 100644 index 000000000..a39ef3879 --- /dev/null +++ b/wai/quantum-studio/README.md @@ -0,0 +1,61 @@ +# Quantum Studio + +An editor for OpenQASM programs, with three places to run them: + +- **this browser**, through the quantum job service's engine compiled to WebAssembly, in a worker; +- **this computer**, through the same engine built natively, in the [desktop app](../wai-quantum-desktop/); +- **the job service**, over HTTP. + +Every place returns the same job: the request, the method and why it was chosen, the result, and its receipt. The Studio shows them the same way and checks every receipt on the machine it runs on. The same request with the same seed gives the same counts in each place. + +The editor: + +- highlights OpenQASM; +- completes the standard gates and the program's own registers; +- marks the engine's diagnostics on their lines; +- draws the circuit and shows the plan as you type. + +Ctrl+Enter (⌘+Enter on macOS) runs the program, and Escape cancels a run. The program and the history of runs are kept on the device. "Copy link" puts the program in a URL fragment, which no server sees. + +## Hosting it + +Build it with `npm ci && node build.mjs`. That writes `dist/`: + +| File | What it is | +|---|---| +| `studio.js` | the interface, bundled | +| `worker.js` | the engine's worker | +| `studio.css` | the Studio's styles | +| `tokens.css` | the site's design tokens, for when it stands alone | +| `index.html` | the page that stands alone | + +A host mounts the Studio on an element: + +```html +
+ +``` + +`data-engine-pkg` names the engine's browser package: wai-web built with the feature `quantum_engine`. `node build.mjs --pkg DIR` copies such a package into `dist/pkg/` for `index.html`. On the desktop, `window.__TAURI__` is present and the Studio uses the native engine instead. + +## Testing it + +```sh +node build.mjs --pkg PATH/TO/PKG && node test/studio.test.mjs +``` + +The test runs the Studio in the installed browser: + +- lint, draw and plan as the program is typed; +- a run in the browser, with its receipt checked there; +- an error marked on its line; +- a rotation run on the state vector; +- the same job on the live service giving the counts run here, shot for shot; +- Ctrl+Enter; +- the program and the history surviving a reload; +- the layout at a phone's width. + +`STUDIO_SKIP_SERVICE=1` leaves the service step out. It saves screenshots of both colour schemes in `test/screenshots/`. diff --git a/wai/quantum-studio/build.mjs b/wai/quantum-studio/build.mjs new file mode 100644 index 000000000..e5dd90937 --- /dev/null +++ b/wai/quantum-studio/build.mjs @@ -0,0 +1,22 @@ +// Build the Studio into dist/: studio.js (the bundled interface), worker.js +// (the engine's worker), studio.css, tokens.css and index.html. The engine +// package itself is not bundled: a host serves it and names it in the mount +// element's data-engine-pkg. +// +// node build.mjs [--pkg DIR] (--pkg copies an engine package into dist/pkg/) +import { build } from 'esbuild'; +import { copyFileSync, cpSync, mkdirSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const dist = join(here, 'dist'); +rmSync(dist, { recursive: true, force: true }); +mkdirSync(dist, { recursive: true }); +const common = { bundle: true, format: 'esm', target: ['es2022'], minify: true, legalComments: 'eof', logLevel: 'warning' }; +await build({ ...common, entryPoints: [join(here, 'src/main.js')], outfile: join(dist, 'studio.js') }); +await build({ ...common, entryPoints: [join(here, 'src/worker.js')], outfile: join(dist, 'worker.js') }); +for (const f of ['studio.css', 'tokens.css', 'index.html']) copyFileSync(join(here, 'src', f), join(dist, f)); +const at = process.argv.indexOf('--pkg'); +if (at > 0) cpSync(process.argv[at + 1], join(dist, 'pkg'), { recursive: true }); +console.log(`built ${dist}`); diff --git a/wai/quantum-studio/package-lock.json b/wai/quantum-studio/package-lock.json new file mode 100644 index 000000000..0b453354b --- /dev/null +++ b/wai/quantum-studio/package-lock.json @@ -0,0 +1,694 @@ +{ + "name": "wai-quantum-studio", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wai-quantum-studio", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@codemirror/autocomplete": "6.20.4", + "@codemirror/commands": "6.11.1", + "@codemirror/language": "6.13.1", + "@codemirror/lint": "6.9.7", + "@codemirror/search": "6.7.2", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.15", + "@lezer/highlight": "1.2.5" + }, + "devDependencies": { + "esbuild": "0.27.4", + "playwright": "1.60.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.4", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz", + "integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz", + "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/streamparser": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/streamparser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz", + "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==", + "license": "MIT" + }, + "node_modules/@codemirror/view": { + "version": "6.43.15", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz", + "integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz", + "integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/wai/quantum-studio/package.json b/wai/quantum-studio/package.json new file mode 100644 index 000000000..8cc6ffbe2 --- /dev/null +++ b/wai/quantum-studio/package.json @@ -0,0 +1,26 @@ +{ + "name": "wai-quantum-studio", + "version": "0.1.0", + "private": true, + "description": "Quantum Studio: an editor for OpenQASM programs, run in this browser, on the desktop or on the quantum job service by the same engine, every result naming its method and accuracy and its receipt checked where it is shown. One interface for the browser and the desktop app.", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "node build.mjs", + "test": "node test/studio.test.mjs" + }, + "dependencies": { + "@codemirror/autocomplete": "6.20.4", + "@codemirror/commands": "6.11.1", + "@codemirror/language": "6.13.1", + "@codemirror/lint": "6.9.7", + "@codemirror/search": "6.7.2", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.15", + "@lezer/highlight": "1.2.5" + }, + "devDependencies": { + "esbuild": "0.27.4", + "playwright": "1.60.0" + } +} diff --git a/wai/quantum-studio/src/engine.js b/wai/quantum-studio/src/engine.js new file mode 100644 index 000000000..8c9a0ca41 --- /dev/null +++ b/wai/quantum-studio/src/engine.js @@ -0,0 +1,130 @@ +// Where a job runs. Three engines, one interface, one engine underneath: +// +// tab the job service's engine compiled for the browser, in a worker +// service the job service itself, over HTTP +// desktop the same engine built natively, in the desktop app +// +// Each takes the service's request JSON and gives the service's job JSON +// (request, plan and result with its receipt), so the Studio shows a job the +// same way wherever it ran. Lint, draw and verify run locally: in the tab, or +// natively on the desktop. + +const KEY_ITEM = 'wai.quantum-ide.signing-key'; // one key per browser, shared with the IDE and the console + +function browserSeed() { + try { + const h = localStorage.getItem(KEY_ITEM); + if (h && /^[0-9a-f]{64}$/.test(h)) return Uint8Array.from(h.match(/../g), (b) => parseInt(b, 16)); + } catch (e) {} + const seed = crypto.getRandomValues(new Uint8Array(32)); + try { localStorage.setItem(KEY_ITEM, Array.from(seed, (b) => b.toString(16).padStart(2, '0')).join('')); } catch (e) {} + return seed; +} + +export class TabEngine { + constructor(pkgUrl, workerUrl) { + this.pkgUrl = pkgUrl; + this.workerUrl = workerUrl; + this.memoryMb = navigator.deviceMemory && navigator.deviceMemory < 4 ? 512 : 1024; + this.where = 'this browser'; + this.worker = null; + this.waiting = new Map(); + this.next = 1; + } + call(op, data = {}) { + if (!this.worker) { + this.worker = new Worker(this.workerUrl, { type: 'module' }); + this.worker.onmessage = (ev) => { + const w = this.waiting.get(ev.data.id); + if (!w) return; + this.waiting.delete(ev.data.id); + if (ev.data.ok) w.resolve(ev.data.value); else w.reject(new Error(ev.data.error)); + }; + this.worker.onerror = (ev) => { + for (const w of this.waiting.values()) w.reject(new Error(ev.message || 'the engine did not load in this browser')); + this.waiting.clear(); + this.worker = null; + }; + this.seed = browserSeed(); + } + const id = this.next++; + return new Promise((resolve, reject) => { + this.waiting.set(id, { resolve, reject }); + this.worker.postMessage({ id, op, pkg: this.pkgUrl, seed: Array.from(this.seed), memoryMb: this.memoryMb, ...data }); + }); + } + run(request) { return this.call('run', { request }); } + plan(request) { return this.call('plan', { request }); } + methods() { return this.call('methods'); } + lint(qasm) { return this.call('lint', { qasm }); } + draw(qasm, width) { return this.call('draw', { qasm, width }); } + verify(job) { return this.call('verify', { job }); } + async signer() { return (await this.call('signer')).did; } + cancel() { + this.worker?.terminate(); + this.worker = null; + for (const w of this.waiting.values()) w.reject(new Error('cancelled')); + this.waiting.clear(); + } +} + +export class ServiceEngine { + constructor(base) { + this.base = base.replace(/\/+$/, ''); + this.where = 'the service'; + this.current = null; + this.stopped = false; + } + async json(res) { + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || `the service answered ${res.status}`); + return body; + } + async run(request, onProgress) { + this.stopped = false; + const sub = await this.json(await fetch(`${this.base}/v1/jobs`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request) })); + this.current = sub.id; + let wait = 300; + for (;;) { + if (this.stopped) throw new Error('cancelled'); + const job = await this.json(await fetch(`${this.base}/v1/jobs/${encodeURIComponent(sub.id)}`)); + onProgress?.(job); + if (job.status === 'done') { job.request ??= request; this.current = null; return job; } + if (job.status !== 'queued' && job.status !== 'running') { this.current = null; throw new Error(job.error || `the job ${job.status}`); } + await new Promise((r) => setTimeout(r, wait)); + wait = Math.min(2000, wait * 1.5); + } + } + async methods() { return this.json(await fetch(`${this.base}/v1/methods`)); } + async describe() { return this.json(await fetch(`${this.base}/v1`)); } + cancel() { + this.stopped = true; + if (this.current) fetch(`${this.base}/v1/jobs/${encodeURIComponent(this.current)}`, { method: 'DELETE' }).catch(() => {}); + } +} + +export class DesktopEngine { + constructor() { + this.where = 'this computer'; + this.invoke = window.__TAURI__.core.invoke; + } + // The native engine answers with JSON text, as the tab's does, so both read alike. + async call(cmd, args) { + const v = JSON.parse(await this.invoke(cmd, args)); + if (v && v.error) throw new Error(v.error); + return v; + } + run(request) { return this.call('run_job', { request: JSON.stringify(request) }); } + plan(request) { return this.call('plan_job', { request: JSON.stringify(request) }); } + methods() { return this.call('methods', {}); } + // A lint names a problem as {ok: false, error}, an answer and not a failure. + async lint(qasm) { return JSON.parse(await this.invoke('lint', { qasm })); } + draw(qasm, width) { return this.call('draw', { qasm, width }); } + verify(job) { return this.call('verify', { job: JSON.stringify(job) }); } + async signer() { return (await this.call('signer', {})).did; } + saveJob(name, job) { return this.call('save_job', { name, job: JSON.stringify(job, null, 2) }); } + saveProgram(name, text) { return this.call('save_program', { name, text }); } + cancel() { this.call('cancel', {}).catch(() => {}); } +} + +export const onDesktop = () => typeof window !== 'undefined' && !!window.__TAURI__?.core?.invoke; diff --git a/wai/quantum-studio/src/examples.js b/wai/quantum-studio/src/examples.js new file mode 100644 index 000000000..2da08e704 --- /dev/null +++ b/wai/quantum-studio/src/examples.js @@ -0,0 +1,58 @@ +// Worked examples, each a whole job and what it shows. +const H = 'OPENQASM 3.0;\ninclude "stdgates.inc";\n'; + +export const EXAMPLES = [ + { + id: 'bell', + title: 'Bell pair', + shows: 'Two qubits entangled: only 00 and 11 come up, and ⟨Z0 Z1⟩ is exactly 1.', + job: { qasm: `${H}qubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;\n`, shots: 1000, observables: ['Z0 Z1', 'X0 X1'] }, + }, + { + id: 'ghz', + title: 'GHZ state, 40 qubits', + shows: 'A Clifford circuit past any state vector, run exactly on the stabilizer tableau: all zeros or all ones.', + job: { qasm: `${H}qubit[40] q;\nbit[40] c;\nh q[0];\n${Array.from({ length: 39 }, (_, i) => `cx q[${i}], q[${i + 1}];`).join('\n')}\nc = measure q;\n`, shots: 1000 }, + }, + { + id: 'rotation', + title: 'A rotation', + shows: 'Any angle runs on the double-precision state vector: ⟨Z0⟩ = cos 0.3 and ⟨Y0⟩ = −sin 0.3.', + job: { qasm: `${H}qubit[1] q;\nrx(0.3) q[0];\n`, observables: ['Z0', 'Y0'], amplitudes: ['0', '1'] }, + }, + { + id: 'qft', + title: 'Quantum Fourier transform', + shows: 'The Fourier transform of a basis state on three qubits: every outcome equally likely, the information in the amplitudes\' phases.', + job: { + qasm: `${H}qubit[3] q;\nx q[2];\nh q[0];\ncp(pi/2) q[1], q[0];\ncp(pi/4) q[2], q[0];\nh q[1];\ncp(pi/2) q[2], q[1];\nh q[2];\nswap q[0], q[2];\n`, + amplitudes: ['000', '001', '010', '011'], + shots: 2000, + }, + }, + { + id: 'teleport', + title: 'Teleportation', + shows: 'A state moved from qubit 0 to qubit 2 by measurement and correction: ⟨Z2⟩ = cos 0.7, ⟨X2⟩ = sin 0.7.', + job: { + qasm: `${H}qubit[3] q;\nbit[3] c;\nry(0.7) q[0];\nh q[1];\ncx q[1], q[2];\ncx q[0], q[1];\nh q[0];\nc[0] = measure q[0];\nc[1] = measure q[1];\nif (c[1]) x q[2];\nif (c[0]) z q[2];\nc[2] = measure q[2];\n`, + observables: ['Z2', 'X2'], + shots: 2000, + }, + }, + { + id: 'grover', + title: 'Grover search, 3 qubits', + shows: 'Two rounds of amplitude amplification find the marked item 101 with probability 0.945.', + job: { + qasm: `${H}qubit[3] q;\nbit[3] c;\nh q;\n${'x q[1];\nh q[2];\nccx q[0], q[1], q[2];\nh q[2];\nx q[1];\nh q;\nx q;\nh q[2];\nccx q[0], q[1], q[2];\nh q[2];\nx q;\nh q;\n'.repeat(2)}c = measure q;\n`, + shots: 2000, + }, + }, + { + id: 'noisy-bell', + title: 'A noisy Bell pair', + shows: 'The same pair on a device whose gates go wrong at random: 01 and 10 appear, and ⟨Z0 Z1⟩ falls below 1.', + job: { qasm: `${H}qubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;\n`, shots: 2000, observables: ['Z0 Z1'], noise: { depolarizing_1q: 0.01, depolarizing_2q: 0.03, readout: 0.02 } }, + }, +]; diff --git a/wai/quantum-studio/src/history.js b/wai/quantum-studio/src/history.js new file mode 100644 index 000000000..0a1875b00 --- /dev/null +++ b/wai/quantum-studio/src/history.js @@ -0,0 +1,53 @@ +// The jobs run in the Studio, kept in this browser (or the desktop app's own +// store), newest first. Where storage is off, the history lasts the visit. +const DB = 'wai-quantum-studio'; +const STORE = 'jobs'; +const MAX = 200; +let memory = []; + +function open() { + return new Promise((resolve, reject) => { + const r = indexedDB.open(DB, 1); + r.onupgradeneeded = () => r.result.createObjectStore(STORE, { keyPath: 'key' }); + r.onsuccess = () => resolve(r.result); + r.onerror = () => reject(r.error); + }); +} + +function tx(db, mode, f) { + return new Promise((resolve, reject) => { + const t = db.transaction(STORE, mode); + const out = f(t.objectStore(STORE)); + t.oncomplete = () => resolve(out?.result ?? out); + t.onerror = () => reject(t.error); + }); +} + +// `entry`: {key, saved (ms), where, title, job}. +export async function save(entry) { + memory = [entry, ...memory.filter((e) => e.key !== entry.key)].slice(0, MAX); + try { + const db = await open(); + await tx(db, 'readwrite', (s) => s.put(entry)); + const all = await list(); + if (all.length > MAX) await tx(db, 'readwrite', (s) => all.slice(MAX).forEach((e) => s.delete(e.key))); + } catch (e) {} +} + +export async function list() { + try { + const db = await open(); + const all = await tx(db, 'readonly', (s) => s.getAll()); + return all.sort((a, b) => b.saved - a.saved); + } catch (e) { + return memory; + } +} + +export async function clear() { + memory = []; + try { + const db = await open(); + await tx(db, 'readwrite', (s) => s.clear()); + } catch (e) {} +} diff --git a/wai/quantum-studio/src/index.html b/wai/quantum-studio/src/index.html new file mode 100644 index 000000000..e5977c176 --- /dev/null +++ b/wai/quantum-studio/src/index.html @@ -0,0 +1,22 @@ + + + + + + Quantum Studio + + + + + + + +
+ + + diff --git a/wai/quantum-studio/src/main.js b/wai/quantum-studio/src/main.js new file mode 100644 index 000000000..b89eef0dd --- /dev/null +++ b/wai/quantum-studio/src/main.js @@ -0,0 +1,412 @@ +// Quantum Studio: an editor for OpenQASM programs, run on this device or on +// the quantum job service by the same engine. Every result names its method, +// why, and how accurate it is, and carries a receipt checked here. One +// interface for the browser and the desktop app. +// +// The host gives a mount element: +//
+// and, on the desktop, window.__TAURI__ (the native engine). +import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection } from '@codemirror/view'; +import { EditorState, Compartment } from '@codemirror/state'; +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; +import { syntaxHighlighting, HighlightStyle, bracketMatching, indentOnInput } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; +import { linter, lintGutter, forceLinting } from '@codemirror/lint'; +import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { openqasm, complete } from './qasm-lang.js'; +import { TabEngine, ServiceEngine, DesktopEngine, onDesktop } from './engine.js'; +import { EXAMPLES } from './examples.js'; +import * as History from './history.js'; + +const METHOD_NAMES = { + exact: 'Exact state vector', statevector: 'State vector, double precision', statevector_single: 'State vector on the GPU, single precision', + stabilizer: 'Stabilizer tableau', pauli: 'Sparse Pauli dynamics', mps: 'Matrix product state', tensor_network: 'Tensor network', + cutrank: 'Cut-rank sum', density_matrix: 'Density matrix', trajectories: 'Trajectories', +}; +const ACCURACY = { + 'bit-exact': 'The same bits on every machine.', + exact: 'No approximation beyond double-precision rounding.', + single: 'No approximation beyond single-precision rounding, about 10⁻⁷ per gate.', + bounded: 'An approximation, each value with a proven bound on its error.', + estimate: 'A statistical estimate, each value with its standard error.', + heuristic: 'An approximation, with the standard estimate of its error but no bound.', +}; +const NOISE = [['depolarizing_1q', 'one-qubit depolarizing'], ['depolarizing_2q', 'two-qubit depolarizing'], ['amplitude_damping', 'amplitude damping'], ['dephasing', 'dephasing'], ['readout', 'readout flips']]; +const DRAFT = 'wai.quantum-studio.draft'; + +const el = (tag, attrs = {}, ...kids) => { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') e.className = v; + else if (k === 'text') e.textContent = v; + else if (k.startsWith('on')) e.addEventListener(k.slice(2), v); + else if (v === true) e.setAttribute(k, ''); + else if (v !== false && v != null) e.setAttribute(k, v); + } + for (const k of kids.flat()) if (k != null) e.append(k); + return e; +}; +const ms = (t) => (t < 1 ? 'under 1 ms' : t < 10000 ? `${Math.round(t)} ms` : `${(t / 1000).toFixed(1)} s`); +const compact = (b) => (b.length <= 24 ? b : `${b.slice(0, 8)}…${b.slice(-8)} (${b.length} bits)`); +const split = (s) => s.split(/[,\n]/).map((x) => x.trim()).filter(Boolean); + +function mount(root) { + const desktop = onDesktop(); + const local = desktop ? new DesktopEngine() : new TabEngine(root.dataset.enginePkg, root.dataset.worker || new URL('worker.js', import.meta.url).href); + const service = root.dataset.service ? new ServiceEngine(root.dataset.service) : null; + let where = 'local'; + let current = null; // {job, where, took} + let running = null; + let serviceKey = null; + let mySigner = null; + + // ---- the frame ---- + const status = el('span', { class: 'qs-status', role: 'status', 'aria-live': 'polite' }); + const runBtn = el('button', { class: 'qs-btn qs-primary', id: 'qs-run', type: 'button', title: 'Run (Ctrl or ⌘ + Enter)', text: '▶ Run' }); + const cancelBtn = el('button', { class: 'qs-btn', id: 'qs-cancel', type: 'button', hidden: true, text: 'Cancel' }); + const whereLocal = el('button', { class: 'qs-seg on', type: 'button', 'aria-pressed': 'true', 'data-where': 'local', text: desktop ? 'This computer' : 'This browser' }); + const whereService = el('button', { class: 'qs-seg', type: 'button', 'aria-pressed': 'false', 'data-where': 'service', text: 'The service', disabled: !service }); + const examples = el('select', { class: 'qs-select', id: 'qs-example', 'aria-label': 'Load an example' }, + el('option', { value: '', text: 'Examples…' }), ...EXAMPLES.map((e) => el('option', { value: e.id, text: e.title }))); + const header = el('header', { class: 'qs-top' }, + el('div', { class: 'qs-brand' }, el('span', { class: 'qs-mark', 'aria-hidden': 'true', text: '⟨ψ⟩' }), el('h1', { text: 'Quantum Studio' })), + examples, + el('div', { class: 'qs-where', role: 'group', 'aria-label': 'Run on' }, whereLocal, whereService), + runBtn, cancelBtn); + + // ---- the program ---- + const lintBadge = el('span', { class: 'qs-badge', id: 'qs-lint', text: '…' }); + const fileIn = el('input', { type: 'file', accept: '.qasm,.txt,text/plain', hidden: true, 'aria-hidden': 'true' }); + const shows = el('p', { class: 'qs-shows', id: 'qs-shows' }); + const editorHost = el('div', { class: 'qs-editor', id: 'qs-editor' }); + const shareBtn = el('button', { class: 'qs-btn qs-small', type: 'button', text: 'Copy link', title: 'A link with the program in its fragment, which no server sees' }); + const openBtn = el('button', { class: 'qs-btn qs-small', type: 'button', text: 'Open' }); + const saveBtn = el('button', { class: 'qs-btn qs-small', type: 'button', text: 'Save' }); + + const field = (id, label, attrs = {}) => { + const input = el(attrs.tag || 'input', { class: 'qs-input', id, ...attrs, tag: null }); + return [input, el('label', { class: 'qs-field', for: id }, el('span', { text: label }), input)]; + }; + const [shots, shotsF] = field('qs-shots', 'Shots', { type: 'number', min: '0', step: '1', placeholder: '1024', inputmode: 'numeric' }); + const [seed, seedF] = field('qs-seed', 'Seed', { type: 'number', min: '0', step: '1', placeholder: '1', inputmode: 'numeric' }); + const method = el('select', { class: 'qs-input', id: 'qs-method' }, el('option', { value: 'auto', text: 'auto', title: 'The strongest method the program admits' })); + const methodF = el('label', { class: 'qs-field', for: 'qs-method' }, el('span', { text: 'Method' }), method); + const [obs, obsF] = field('qs-obs', 'Observables', { placeholder: 'Z0 Z1, 0.5 X2', autocomplete: 'off', spellcheck: 'false' }); + const [amps, ampsF] = field('qs-amps', 'Amplitudes', { placeholder: '000, 111', autocomplete: 'off', spellcheck: 'false' }); + const noiseInputs = NOISE.map(([k, label]) => field(`qs-noise-${k}`, label, { type: 'number', min: '0', max: '1', step: '0.001', placeholder: '0', 'data-noise': k })); + const jobBox = el('div', { class: 'qs-job' }, + el('div', { class: 'qs-row' }, shotsF, seedF, methodF), + el('div', { class: 'qs-row' }, obsF, ampsF), + el('details', { class: 'qs-noise' }, el('summary', { text: 'Noise' }), el('div', { class: 'qs-row' }, ...noiseInputs.map((n) => n[1])))); + const program = el('section', { class: 'qs-pane qs-program', 'aria-labelledby': 'qs-program-h' }, + el('div', { class: 'qs-head' }, el('h2', { id: 'qs-program-h', text: 'Program' }), lintBadge, el('span', { class: 'qs-grow' }), openBtn, saveBtn, shareBtn, fileIn), + shows, editorHost, jobBox); + + // ---- the circuit ---- + const circuit = el('pre', { class: 'qs-circuit', id: 'qs-circuit', tabindex: '0', 'aria-label': 'The circuit, drawn as text' }); + const circuitPane = el('section', { class: 'qs-pane qs-circ', 'aria-labelledby': 'qs-circuit-h' }, + el('div', { class: 'qs-head' }, el('h2', { id: 'qs-circuit-h', text: 'Circuit' }), el('span', { class: 'qs-grow' }), el('span', { class: 'qs-note', id: 'qs-plan' })), circuit); + + // ---- the result ---- + const state = el('span', { class: 'qs-badge', id: 'qs-state', text: 'ready' }); + const resultBody = el('div', { class: 'qs-result', id: 'qs-result' }, el('p', { class: 'qs-empty', text: 'Run the program to see its result here. Counts are keyed by the classical bits, highest bit first.' })); + const resultPane = el('section', { class: 'qs-pane qs-res', 'aria-labelledby': 'qs-result-h' }, + el('div', { class: 'qs-head' }, el('h2', { id: 'qs-result-h', text: 'Result' }), state), resultBody); + + // ---- the history ---- + const historyList = el('ol', { class: 'qs-history', id: 'qs-history' }); + const historyPane = el('section', { class: 'qs-pane qs-hist', 'aria-labelledby': 'qs-history-h' }, + el('div', { class: 'qs-head' }, el('h2', { id: 'qs-history-h', text: 'History' }), el('span', { class: 'qs-grow' }), + el('button', { class: 'qs-btn qs-small', type: 'button', text: 'Clear', onclick: async () => { await History.clear(); showHistory(); } })), + historyList); + + const footer = el('footer', { class: 'qs-foot' }, el('span', { class: 'qs-signer', id: 'qs-signer' }), status); + root.classList.add('qs'); + root.replaceChildren(header, el('div', { class: 'qs-grid' }, program, el('div', { class: 'qs-side' }, circuitPane, resultPane, historyPane)), footer); + + // ---- the editor ---- + const style = HighlightStyle.define([ + { tag: t.keyword, color: 'var(--qs-kw)' }, + { tag: t.typeName, color: 'var(--qs-type)' }, + { tag: t.function(t.variableName), color: 'var(--qs-gate)', fontWeight: '600' }, + { tag: t.number, color: 'var(--qs-num)' }, + { tag: t.atom, color: 'var(--qs-num)' }, + { tag: t.standard(t.variableName), color: 'var(--qs-type)' }, + { tag: t.string, color: 'var(--qs-str)' }, + { tag: t.comment, color: 'var(--text-muted)', fontStyle: 'italic' }, + { tag: t.operator, color: 'var(--text-secondary)' }, + ]); + const lintAt = async (view) => { + const text = view.state.doc.toString(); + let v; + try { v = await local.lint(text); } catch (e) { lintBadge.textContent = 'engine not loaded'; lintBadge.className = 'qs-badge bad'; return []; } + if (v.ok) { + lintBadge.textContent = `${v.qubits} qubits · ${v.gates} gates${v.dynamic_steps != null ? ' · measures in the middle' : ''}`; + lintBadge.className = 'qs-badge ok'; + return []; + } + lintBadge.textContent = 'problem'; lintBadge.className = 'qs-badge bad'; + const doc = view.state.doc; + const line = v.line && v.line <= doc.lines ? doc.line(v.line) : doc.line(doc.lines); + return [{ from: line.from, to: Math.max(line.to, line.from), severity: 'error', message: v.error }]; + }; + const theme = EditorView.theme({ + '&': { height: '100%', fontSize: '13px', backgroundColor: 'var(--bg-base)', color: 'var(--text-primary)' }, + '.cm-content': { fontFamily: 'var(--font-mono)', caretColor: 'var(--accent)' }, + '.cm-gutters': { backgroundColor: 'var(--bg-surface)', color: 'var(--text-muted)', borderRight: '1px solid var(--border)' }, + '.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--accent) 6%, transparent)' }, + '.cm-activeLineGutter': { backgroundColor: 'color-mix(in srgb, var(--accent) 10%, transparent)' }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--accent) 25%, transparent) !important' }, + '.cm-tooltip': { backgroundColor: 'var(--bg-elevated)', border: '1px solid var(--border-subtle)', color: 'var(--text-primary)' }, + }); + const view = new EditorView({ + parent: editorHost, + state: EditorState.create({ + doc: '', + extensions: [ + lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), history(), drawSelection(), indentOnInput(), bracketMatching(), closeBrackets(), + highlightSelectionMatches(), openqasm, syntaxHighlighting(style), autocompletion({ override: [complete] }), + linter(lintAt, { delay: 300 }), lintGutter(), theme, + EditorView.contentAttributes.of({ 'aria-label': 'The OpenQASM program' }), + keymap.of([{ key: 'Mod-Enter', run: () => { run(); return true; } }, { key: 'Escape', run: () => { if (running) { cancel(); return true; } return false; } }, + ...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...completionKeymap, indentWithTab]), + EditorView.updateListener.of((u) => { if (u.docChanged) edited(); }), + ], + }), + }); + const text = () => view.state.doc.toString(); + const setText = (s) => view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: s } }); + + // ---- the job ---- + function request() { + const job = { qasm: text() }; + const n = parseInt(shots.value, 10); if (Number.isFinite(n) && n > 0) job.shots = n; + const s = parseInt(seed.value, 10); if (Number.isFinite(s) && s >= 0 && seed.value !== '') job.seed = s; + const o = split(obs.value); if (o.length) job.observables = o; + const a = split(amps.value); if (a.length) job.amplitudes = a; + const noise = {}; + for (const [input] of noiseInputs) { const v = parseFloat(input.value); if (Number.isFinite(v) && v > 0) noise[input.dataset.noise] = v; } + if (Object.keys(noise).length) job.noise = noise; + if (method.value !== 'auto') job.method = method.value; + return job; + } + function fill(job) { + setText(job.qasm || ''); + shots.value = job.shots ?? ''; seed.value = job.seed ?? ''; + obs.value = (job.observables || []).join(', '); amps.value = (job.amplitudes || []).join(', '); + for (const [input] of noiseInputs) input.value = job.noise?.[input.dataset.noise] ?? ''; + method.value = [...method.options].some((o) => o.value === job.method) ? job.method : 'auto'; + if (job.noise && Object.keys(job.noise).length) jobBox.querySelector('details').open = true; + } + + // ---- drawing and planning, as the program changes ---- + let drawTimer = null; + function edited() { + shows.textContent = ''; + clearTimeout(drawTimer); + drawTimer = setTimeout(async () => { + const src = text(); + try { localStorage.setItem(DRAFT, src); } catch (e) {} + try { + const d = await local.draw(src, Math.max(40, Math.floor(circuit.clientWidth / 7.8) || 80)); + circuit.textContent = d.text ?? ''; + circuit.classList.toggle('stale', !!d.error); + if (d.error) circuit.textContent = d.error; + } catch (e) { circuit.textContent = e.message; circuit.classList.add('stale'); } + plan(); + }, 250); + } + async function plan() { + const p = document.getElementById('qs-plan'); + if (where !== 'local') { p.textContent = ''; return; } + try { + const pl = await local.plan(request()); + p.textContent = `${METHOD_NAMES[pl.method] || pl.method} · ${(pl.memory_bytes / 2 ** 20).toFixed(0)} MiB at most`; + p.title = pl.why; + } catch (e) { p.textContent = e.message.length > 90 ? e.message.slice(0, 90) + '…' : e.message; p.title = e.message; } + } + + // ---- running ---- + function setStatus(s, cls = '') { status.textContent = s; status.className = 'qs-status ' + cls; } + function busy(on) { runBtn.disabled = on; cancelBtn.hidden = !on; } + async function run() { + if (running) return; + const job = request(); + const eng = where === 'service' ? service : local; + running = eng; busy(true); + state.textContent = 'running'; state.className = 'qs-badge run'; + setStatus(`Running on ${eng.where}…`); + const t0 = performance.now(); + try { + const body = await eng.run(job, (j) => setStatus(`On the service: ${j.status}…`)); + const took = performance.now() - t0; + current = { job: body, where: eng.where, took }; + await show(current); + setStatus(`Done on ${eng.where} in ${ms(took)}.`, 'ok'); + await History.save({ key: body.result?.receipt?.digest || String(Date.now()), saved: Date.now(), where: eng.where, title: firstLine(job.qasm), job: body }); + showHistory(); + } catch (e) { + state.textContent = e.message === 'cancelled' ? 'cancelled' : 'refused'; state.className = 'qs-badge bad'; + setStatus(e.message === 'cancelled' ? 'Cancelled.' : e.message, 'bad'); + } finally { running = null; busy(false); } + } + function cancel() { running?.cancel(); } + const firstLine = (q) => (q.split('\n').find((l) => /\b(h|x|cx|rx|ry|rz|ccx|measure)\b/.test(l)) || q.split('\n')[0] || '').trim().slice(0, 60); + + // ---- a result ---- + async function show({ job, where: ran, took }) { + const r = job.result || {}; + state.textContent = job.status || 'done'; state.className = 'qs-badge ok'; + const acc = r.accuracy || {}; + const extra = []; + if (acc.fidelity_estimate !== undefined) extra.push(`fidelity estimate ${Number(acc.fidelity_estimate).toFixed(4)}`); + if (acc.discarded_weight !== undefined) extra.push(`discarded weight ${Number(acc.discarded_weight).toExponential(2)}`); + if (acc.histories !== undefined) extra.push(`${acc.histories} error histories`); + if (acc.device) extra.push(`on ${acc.device}`); + if (acc.max_cut !== undefined) extra.push(`the widest cut ${acc.max_cut} bits`); + if (r.global_phase) extra.push(`global phase ${r.global_phase}`); + const dy = r.dynamic; + if (dy) { + const parts = [`${dy.measurements_and_resets} measurements and resets`]; + if (dy.branches_run !== undefined) parts.push(`the shots split into ${dy.branches_run} branches`); + if (dy.branches_weighed !== undefined) parts.push(`the expectation values weigh ${dy.branches_weighed} branches exactly`); + extra.push(parts.join('; ')); + } + const blocks = [ + el('div', { class: 'qs-block' }, el('div', { class: 'qs-k', text: 'Method' }), + el('div', {}, el('b', { class: 'qs-method', text: METHOD_NAMES[job.method] || job.method || '' }), el('span', { class: 'qs-why', text: ' ' + (job.why || '') }))), + el('div', { class: 'qs-block' }, el('div', { class: 'qs-k', text: 'Accuracy' }), + el('div', {}, el('span', { class: 'qs-badge ok', text: acc.class || '?' }), el('span', { class: 'qs-why', text: ' ' + (ACCURACY[acc.class] || '') + (extra.length ? ' ' + extra.join(', ') + '.' : '') })), + r.warning ? el('p', { class: 'qs-warn', text: r.warning }) : null), + ]; + if (r.counts) { + const entries = Object.entries(r.counts).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)); + const total = entries.reduce((s, [, n]) => s + n, 0) + (r.omitted_shots || 0); + const max = entries.length ? entries[0][1] : 1; + blocks.push(el('div', { class: 'qs-block' }, el('div', { class: 'qs-k', text: `Counts · ${total} shots · highest bit first` }), + el('div', { class: 'qs-bars' }, ...entries.slice(0, 32).map(([k, n]) => el('div', { class: 'qs-bar' }, + el('span', { class: 'qs-key', title: k, text: compact(k) }), + el('span', { class: 'qs-track' }, el('span', { class: 'qs-fill', style: `width:${(100 * n) / max}%` })), + el('span', { class: 'qs-n', text: `${n} · ${((100 * n) / total).toFixed(1)}%` })))), + entries.length > 32 ? el('p', { class: 'qs-note', text: `${entries.length - 32} more outcomes.` }) : null)); + } + if (r.expectations) { + blocks.push(el('div', { class: 'qs-block' }, el('div', { class: 'qs-k', text: 'Expectation values' }), + el('table', { class: 'qs-table' }, el('tbody', {}, ...r.expectations.map((e) => el('tr', {}, + el('th', { scope: 'row', text: `⟨${e.observable}⟩` }), el('td', { class: 'num', text: Number(e.value).toFixed(6) }), + el('td', { class: 'qs-note', text: e.stderr !== undefined ? `± ${Number(e.stderr).toExponential(1)}` : e.bound !== undefined ? `error ≤ ${Number(e.bound).toExponential(1)}` : '' }))))))); + } + if (r.amplitudes) { + blocks.push(el('div', { class: 'qs-block' }, el('div', { class: 'qs-k', text: 'Amplitudes' }), + el('table', { class: 'qs-table' }, el('thead', {}, el('tr', {}, el('th', { text: 'state' }), el('th', { text: 'amplitude' }), el('th', { text: 'probability' }), el('th', { text: 'phase' }))), + el('tbody', {}, ...r.amplitudes.map((a) => { + const p = a.re * a.re + a.im * a.im; + const ph = Math.atan2(a.im, a.re) / Math.PI; + return el('tr', {}, el('th', { scope: 'row', text: `|${compact(a.bits)}⟩` }), + el('td', { class: 'num', text: `${Number(a.re).toFixed(6)} ${a.im < 0 ? '−' : '+'} ${Math.abs(a.im).toFixed(6)}i` }), + el('td', { class: 'num', text: p.toFixed(6) }), el('td', { class: 'num', text: p < 1e-24 ? '—' : `${ph.toFixed(4)}π` })); + }))))); + } + // The receipt, checked here. + const rc = r.receipt || {}; + const check = el('span', { class: 'qs-badge', id: 'qs-verified', text: 'checking…' }); + const en = r.energy || {}; + const energy = en.joules_micro ? `${en.joules_micro < 1e3 ? en.joules_micro + ' µJ' : en.joules_micro < 1e6 ? (en.joules_micro / 1e3).toFixed(1) + ' mJ' : (en.joules_micro / 1e6).toFixed(2) + ' J'}` : en.unmetered ? `unmetered: ${en.unmetered}` : '—'; + const dl = el('button', { class: 'qs-btn qs-small', type: 'button', text: 'Download job', onclick: () => saveJob(job) }); + blocks.push(el('div', { class: 'qs-block' }, el('div', { class: 'qs-k', text: 'Receipt' }), + el('table', { class: 'qs-table' }, el('tbody', {}, + el('tr', {}, el('th', { scope: 'row', text: 'ran on' }), el('td', { text: ran })), + el('tr', {}, el('th', { scope: 'row', text: 'signed by' }), el('td', { class: 'mono', id: 'qs-signed-by', text: (rc.key || '').slice(0, 16) + '…' })), + el('tr', {}, el('th', { scope: 'row', text: 'check' }), el('td', {}, check)), + el('tr', {}, el('th', { scope: 'row', text: 'ran in' }), el('td', { text: r.elapsed_ms != null ? ms(r.elapsed_ms) : `${ms(took)}, timed by this page` })), + el('tr', {}, el('th', { scope: 'row', text: 'energy' }), el('td', { text: energy })))), + el('p', { class: 'qs-note', text: 'A receipt shows that this key signed this request and this result; anyone can run the request again to compare. It does not say who holds the key.' }), + el('div', { class: 'qs-actions' }, dl))); + resultBody.replaceChildren(...blocks); + try { + const v = await local.verify({ request: job.request, result: job.result }); + const whose = v.key === serviceKey ? 'the service’s key' : v.signer && v.signer === mySigner ? (desktop ? 'this computer’s key' : 'this browser’s key') : 'the key it names'; + document.getElementById('qs-signed-by').textContent = `${whose} ${v.signer ? v.signer.slice(0, 28) + '…' : ''}`; + check.textContent = v.valid ? `✓ verifies, checked on ${desktop ? 'this computer' : 'this device'}` : '✗ does not verify'; + check.className = 'qs-badge ' + (v.valid ? 'ok' : 'bad'); + } catch (e) { check.textContent = `not checked: ${e.message}`; check.className = 'qs-badge bad'; } + } + + // ---- files ---- + function download(name, text, type) { + const a = el('a', { href: URL.createObjectURL(new Blob([text], { type })), download: name }); + document.body.append(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 0); + } + async function saveJob(job) { + const name = `wai-quantum-job-${job.id || (job.result?.receipt?.digest || 'here').slice(0, 12)}.json`; + if (desktop) { const r = await local.saveJob(name, job); setStatus(`Saved ${r.path}.`, 'ok'); } + else { download(name, JSON.stringify(job, null, 2) + '\n', 'application/json'); setStatus(`Saved ${name}. wai-quantum verify checks it.`, 'ok'); } + } + saveBtn.addEventListener('click', async () => { + const name = 'program.qasm'; + if (desktop) { const r = await local.saveProgram(name, text()); setStatus(`Saved ${r.path}.`, 'ok'); } + else download(name, text(), 'text/plain'); + }); + openBtn.addEventListener('click', () => fileIn.click()); + fileIn.addEventListener('change', async () => { + const f = fileIn.files?.[0]; if (!f) return; + setText(await f.text()); shows.textContent = `Opened ${f.name}.`; fileIn.value = ''; + }); + shareBtn.addEventListener('click', () => { + const u = new URL(location.href); u.hash = 'qasm=' + encodeURIComponent(text()); u.search = ''; + navigator.clipboard?.writeText(u.toString()).then(() => setStatus('Copied a link with the program in its fragment, which no server sees.', 'ok'), () => setStatus(u.toString())); + }); + + // ---- history ---- + async function showHistory() { + const all = await History.list(); + historyList.replaceChildren(...(all.length ? all.slice(0, 50).map((h) => el('li', {}, + el('button', { type: 'button', class: 'qs-hitem', onclick: () => { fill(h.job.request || {}); current = { job: h.job, where: h.where, took: 0 }; show(current); setStatus(`From the history: run on ${h.where}, ${new Date(h.saved).toLocaleString()}.`); } }, + el('span', { class: 'qs-htitle', text: h.title || '(a program)' }), + el('span', { class: 'qs-note', text: `${METHOD_NAMES[h.job.method] || h.job.method} · ${h.where} · ${new Date(h.saved).toLocaleTimeString()}` })))) : [el('li', { class: 'qs-note', text: 'Jobs you run appear here, kept on this device.' })])); + } + + // ---- where ---- + for (const b of [whereLocal, whereService]) b.addEventListener('click', () => { + where = b.dataset.where; + for (const x of [whereLocal, whereService]) { x.classList.toggle('on', x === b); x.setAttribute('aria-pressed', String(x === b)); } + loadMethods(); plan(); + }); + async function loadMethods() { + const keep = method.value; + try { + const m = (await (where === 'service' ? service : local).methods()).methods || []; + method.replaceChildren(el('option', { value: 'auto', text: 'auto', title: 'The strongest method the program admits' }), + ...m.filter((x) => x.available !== false).map((x) => el('option', { value: x.method, text: `${METHOD_NAMES[x.method] || x.method} (to ${x.max_qubits} qubits)` }))); + method.value = [...method.options].some((o) => o.value === keep) ? keep : 'auto'; + } catch (e) {} + } + examples.addEventListener('change', () => { + const e = EXAMPLES.find((x) => x.id === examples.value); if (!e) return; + fill(e.job); shows.textContent = e.shows; examples.value = ''; + }); + [shots, seed, method, obs, amps, ...noiseInputs.map((n) => n[0])].forEach((i) => i.addEventListener('input', () => { clearTimeout(drawTimer); drawTimer = setTimeout(plan, 250); })); + runBtn.addEventListener('click', run); + cancelBtn.addEventListener('click', cancel); + + // ---- start ---- + const handed = new URLSearchParams(location.hash.slice(1)).get('qasm'); + let draft = null; + try { draft = localStorage.getItem(DRAFT); } catch (e) {} + if (handed) { fill({ qasm: handed }); shows.textContent = 'The program from the link.'; } + else if (draft) setText(draft); + else { fill(EXAMPLES[0].job); shows.textContent = EXAMPLES[0].shows; } + loadMethods(); + showHistory(); + local.signer().then((d) => { mySigner = d; document.getElementById('qs-signer').textContent = `Signs as ${d.slice(0, 32)}… (${desktop ? 'this computer’s key' : 'a key made for this browser'})`; }).catch(() => {}); + service?.describe().then((d) => { serviceKey = d.receipt_key || null; }).catch(() => { whereService.disabled = true; whereService.title = 'The service did not answer'; }); + edited(); + forceLinting(view); + return { view, run, fill, request }; +} + +const root = document.getElementById('studio'); +if (root) window.quantumStudio = mount(root); diff --git a/wai/quantum-studio/src/qasm-lang.js b/wai/quantum-studio/src/qasm-lang.js new file mode 100644 index 000000000..05a436455 --- /dev/null +++ b/wai/quantum-studio/src/qasm-lang.js @@ -0,0 +1,83 @@ +// OpenQASM for the editor: highlighting, the gates and keywords to complete, +// and the registers a program declares. The engine's reader decides what a +// program means; this only colours and suggests. +import { StreamLanguage } from '@codemirror/language'; + +export const KEYWORDS = [ + 'OPENQASM', 'include', 'qubit', 'bit', 'qreg', 'creg', 'gate', 'measure', 'reset', 'barrier', + 'if', 'else', 'for', 'while', 'in', 'let', 'const', 'input', 'output', 'def', 'return', + 'ctrl', 'negctrl', 'inv', 'pow', 'box', 'delay', 'opaque', 'U', 'CX', +]; +const TYPES = new Set(['qubit', 'bit', 'qreg', 'creg', 'int', 'uint', 'float', 'angle', 'bool', 'complex']); +export const CONSTANTS = ['pi', 'tau', 'euler', 'π', 'τ']; +export const FUNCTIONS = ['sin', 'cos', 'tan', 'exp', 'ln', 'sqrt', 'arcsin', 'arccos', 'arctan']; +// The standard gates, with their parameters, as the engine reads them. +export const GATES = [ + ['id', 0, 1], ['x', 0, 1], ['y', 0, 1], ['z', 0, 1], ['h', 0, 1], ['s', 0, 1], ['sdg', 0, 1], ['t', 0, 1], ['tdg', 0, 1], + ['sx', 0, 1], ['sxdg', 0, 1], ['rx', 1, 1], ['ry', 1, 1], ['rz', 1, 1], ['p', 1, 1], ['u', 3, 1], ['u1', 1, 1], ['u2', 2, 1], ['u3', 3, 1], + ['cx', 0, 2], ['cy', 0, 2], ['cz', 0, 2], ['ch', 0, 2], ['cp', 1, 2], ['crx', 1, 2], ['cry', 1, 2], ['crz', 1, 2], ['cu', 4, 2], + ['swap', 0, 2], ['iswap', 0, 2], ['rxx', 1, 2], ['ryy', 1, 2], ['rzz', 1, 2], ['ccx', 0, 3], ['ccz', 0, 3], ['cswap', 0, 3], +]; +const GATE_NAMES = new Set(GATES.map((g) => g[0]).concat(['cnot', 'toffoli', 'fredkin', 'phase', 'cphase', 'cu1', 'cu3'])); +const KEYWORD_SET = new Set(KEYWORDS); + +export const openqasm = StreamLanguage.define({ + name: 'openqasm', + startState: () => ({ inComment: false }), + token(stream, state) { + if (state.inComment) { + if (stream.match(/^.*?\*\//)) state.inComment = false; + else stream.skipToEnd(); + return 'comment'; + } + if (stream.eatSpace()) return null; + if (stream.match('//')) { stream.skipToEnd(); return 'comment'; } + if (stream.match('/*')) { state.inComment = true; return 'comment'; } + if (stream.match(/^"[^"]*"?/)) return 'string'; + if (stream.match(/^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?(ns|us|µs|ms|s|dt|im)?/)) return 'number'; + if (stream.match(/^[A-Za-z_$πτ][\w$]*/u)) { + const w = stream.current(); + if (TYPES.has(w)) return 'typeName'; + if (KEYWORD_SET.has(w)) return 'keyword'; + if (CONSTANTS.includes(w)) return 'atom'; + if (FUNCTIONS.includes(w)) return 'builtin'; + if (GATE_NAMES.has(w)) return 'variableName.function'; + return 'variableName'; + } + if (stream.match(/^(->|==|!=|<=|>=|&&|\|\||<<|>>|\*\*|[+\-*/%^=<>!~&|@])/)) return 'operator'; + stream.next(); + return null; + }, + languageData: { commentTokens: { line: '//', block: { open: '/*', close: '*/' } } }, +}); + +// The registers a program declares, for completion. +export function registers(text) { + const out = []; + const re = /\b(qubit|bit)\s*(\[\s*\d+\s*\])?\s+([A-Za-z_]\w*)|\b(qreg|creg)\s+([A-Za-z_]\w*)/g; + for (let m; (m = re.exec(text)); ) out.push({ name: m[3] || m[5], kind: m[1] === 'qubit' || m[4] === 'qreg' ? 'qubit' : 'bit' }); + return out; +} + +// Completions at the cursor: gates with their parameters, keywords, constants +// and the program's registers. +export function complete(context) { + const word = context.matchBefore(/[\w$]*/); + if (!word || (word.from === word.to && !context.explicit)) return null; + const regs = registers(context.state.doc.toString()); + return { + from: word.from, + options: [ + ...GATES.map(([name, params, qubits]) => ({ + label: name, + type: 'function', + detail: `${params ? `${params} angle${params > 1 ? 's' : ''}, ` : ''}${qubits} qubit${qubits > 1 ? 's' : ''}`, + apply: params ? `${name}(${Array(params).fill('').join(', ')})` : name, + })), + ...KEYWORDS.map((k) => ({ label: k, type: 'keyword' })), + ...CONSTANTS.slice(0, 3).map((c) => ({ label: c, type: 'constant' })), + ...regs.map((r) => ({ label: r.name, type: 'variable', detail: r.kind === 'qubit' ? 'qubit register' : 'bit register' })), + ], + validFor: /^[\w$]*$/, + }; +} diff --git a/wai/quantum-studio/src/studio.css b/wai/quantum-studio/src/studio.css new file mode 100644 index 000000000..a746645e3 --- /dev/null +++ b/wai/quantum-studio/src/studio.css @@ -0,0 +1,101 @@ +/* Quantum Studio. Every colour and face comes from the host's design tokens + (tokens.css standing alone; the site's own inside the site), so it follows + the host's theme. Syntax colours are the Studio's, taken from the palette. */ +.qs { + --qs-kw: var(--accent-text, #d4a574); + --qs-type: var(--accent-text-hover, #e0bd95); + --qs-gate: var(--info-text, #7aa0ff); + --qs-num: var(--warn-text, #f0a020); + --qs-str: var(--std-text, #10b981); + --qs-gap: 12px; + --qs-radius: 8px; + display: flex; + flex-direction: column; + gap: var(--qs-gap); + min-height: min(100vh, 1100px); + color: var(--text-primary); + background: var(--bg-base); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 14px; +} +.qs *, .qs *::before, .qs *::after { box-sizing: border-box; } +.qs h1, .qs h2 { margin: 0; text-wrap: balance; } +.qs-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--qs-radius); background: var(--bg-surface); } +.qs-brand { display: flex; align-items: baseline; gap: 8px; margin-right: auto; } +.qs-brand h1 { font-size: 15px; font-weight: 650; letter-spacing: 0.01em; } +.qs-mark { font-family: var(--font-mono, ui-monospace, monospace); color: var(--accent-text); font-size: 14px; } +.qs-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: var(--qs-gap); flex: 1; min-height: 0; } +.qs-side { display: flex; flex-direction: column; gap: var(--qs-gap); min-width: 0; } +.qs-pane { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--border); border-radius: var(--qs-radius); background: var(--bg-surface); overflow: hidden; } +.qs-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); } +.qs-head h2 { font-size: 11px; font-family: var(--font-mono, ui-monospace, monospace); font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-secondary); } +.qs-grow { flex: 1; } +.qs-program { min-height: 520px; } +.qs-editor { flex: 1; min-height: 260px; overflow: auto; } +.qs-editor .cm-editor { height: 100%; } +.qs-editor .cm-editor.cm-focused { outline: 2px solid var(--focus-ring, var(--accent)); outline-offset: -2px; } +.qs-shows { margin: 0; padding: 0 12px; font-size: 12px; color: var(--text-secondary); line-height: 1.45; } +.qs-shows:not(:empty) { padding: 8px 12px; border-bottom: 1px solid var(--border); } +.qs-job { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); } +.qs-row { display: flex; flex-wrap: wrap; gap: 8px; } +.qs-field { display: flex; flex-direction: column; gap: 3px; flex: 1 1 120px; min-width: 0; font-size: 10px; font-family: var(--font-mono, ui-monospace, monospace); letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); } +.qs-input, .qs-select { width: 100%; min-height: 32px; padding: 5px 8px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--bg-base); color: var(--text-primary); font: 12px var(--font-mono, ui-monospace, monospace); letter-spacing: 0; text-transform: none; } +.qs-select { width: auto; } +.qs-noise summary { cursor: pointer; font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); padding: 4px 0; } +.qs-noise .qs-row { padding-top: 6px; } +.qs-btn { min-height: 32px; padding: 5px 12px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--bg-base); color: var(--text-primary); font: 12px var(--font-mono, ui-monospace, monospace); cursor: pointer; white-space: nowrap; } +.qs-btn:hover:not(:disabled) { border-color: var(--accent); } +.qs-btn:disabled { opacity: 0.5; cursor: default; } +.qs-btn.qs-small { min-height: 26px; padding: 2px 9px; font-size: 11px; } +.qs-primary { background: var(--accent-fill, var(--accent)); border-color: var(--accent-fill, var(--accent)); color: var(--on-accent, #0c0a09); font-weight: 650; } +.qs-primary:hover:not(:disabled) { background: var(--accent-fill-hover, var(--accent)); } +.qs-where { display: inline-flex; border: 1px solid var(--border-subtle); border-radius: 6px; overflow: hidden; } +.qs-seg { min-height: 32px; padding: 5px 11px; border: 0; background: var(--bg-base); color: var(--text-secondary); font: 12px var(--font-mono, ui-monospace, monospace); cursor: pointer; } +.qs-seg + .qs-seg { border-left: 1px solid var(--border-subtle); } +.qs-seg.on { background: color-mix(in srgb, var(--accent) 18%, var(--bg-base)); color: var(--text-primary); } +.qs-seg:disabled { opacity: 0.45; cursor: default; } +.qs :focus-visible { outline: 2px solid var(--focus-ring, var(--accent)); outline-offset: 2px; } +.qs-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-subtle); font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } +.qs-badge.ok { color: var(--ok-text); border-color: color-mix(in srgb, var(--ok-text) 40%, transparent); } +.qs-badge.bad { color: var(--bad-text); border-color: color-mix(in srgb, var(--bad-text) 40%, transparent); } +.qs-badge.run { color: var(--info-text); border-color: color-mix(in srgb, var(--info-text) 40%, transparent); } +.qs-circuit { margin: 0; padding: 10px 12px; min-height: 120px; max-height: 320px; overflow: auto; font: 12px/1.35 var(--font-mono, ui-monospace, monospace); color: var(--text-primary); background: var(--bg-base); white-space: pre; } +.qs-circuit.stale { color: var(--bad-text); white-space: pre-wrap; } +.qs-note { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono, ui-monospace, monospace); overflow-wrap: anywhere; } +.qs-result { padding: 10px 12px; display: flex; flex-direction: column; gap: 12px; overflow: auto; } +.qs-empty { margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; } +.qs-block { display: flex; flex-direction: column; gap: 5px; } +.qs-k { font: 10px var(--font-mono, ui-monospace, monospace); letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-muted); } +.qs-method { font-weight: 650; } +.qs-why { color: var(--text-secondary); font-size: 12px; line-height: 1.5; } +.qs-warn { margin: 4px 0 0; color: var(--warn-text); font-size: 12px; } +.qs-bars { display: flex; flex-direction: column; gap: 3px; } +.qs-bar { display: grid; grid-template-columns: minmax(3ch, max-content) 1fr auto; gap: 8px; align-items: center; font: 12px var(--font-mono, ui-monospace, monospace); font-variant-numeric: tabular-nums; } +.qs-track { height: 10px; border-radius: 3px; background: color-mix(in srgb, var(--text-muted) 15%, transparent); overflow: hidden; } +.qs-fill { display: block; height: 100%; background: var(--accent); border-radius: 3px; } +.qs-n { color: var(--text-secondary); } +.qs-table { width: 100%; border-collapse: collapse; font: 12px var(--font-mono, ui-monospace, monospace); font-variant-numeric: tabular-nums; } +.qs-table th, .qs-table td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--border); vertical-align: top; overflow-wrap: anywhere; } +.qs-table th { color: var(--text-secondary); font-weight: 500; } +.qs-table thead th { color: var(--text-muted); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; } +.qs-table .num { text-align: right; } +.qs-table .mono { color: var(--text-primary); } +.qs-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.qs-hist { max-height: 260px; } +.qs-history { list-style: none; margin: 0; padding: 4px; overflow: auto; } +.qs-hitem { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 6px 8px; border: 0; border-radius: 6px; background: transparent; color: var(--text-primary); text-align: left; cursor: pointer; font: inherit; } +.qs-hitem:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); } +.qs-htitle { font: 12px var(--font-mono, ui-monospace, monospace); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.qs-history > li.qs-note { padding: 8px; } +.qs-foot { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 4px 2px; font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-muted); } +.qs-status { margin-left: auto; color: var(--text-secondary); overflow-wrap: anywhere; } +.qs-status.ok { color: var(--ok-text); } +.qs-status.bad { color: var(--bad-text); } +@media (max-width: 900px) { + .qs-grid { grid-template-columns: minmax(0, 1fr); } + .qs-program { min-height: 0; } + .qs-editor { min-height: 240px; max-height: 60vh; } + .qs-brand { margin-right: 0; flex-basis: 100%; } + .qs-status { margin-left: 0; } +} +@media (prefers-reduced-motion: reduce) { .qs * { transition: none !important; } } diff --git a/wai/quantum-studio/src/tokens.css b/wai/quantum-studio/src/tokens.css new file mode 100644 index 000000000..9c50d878f --- /dev/null +++ b/wai/quantum-studio/src/tokens.css @@ -0,0 +1,178 @@ +/* The site's design tokens (wai-transaction-science-web src/styles/global.css), so the Studio + looks the same standing alone (the desktop app) as inside the site. Copied, not edited. */ +/* -- Design Tokens -- */ +:root { + /* Neutrals — warm-tinted blacks (shared with transaction.science) */ + --stone-950: #0c0a09; + --stone-900: #1c1917; + --stone-800: #292524; + --stone-700: #44403c; + --stone-600: #57534e; + --stone-500: #78716c; + --stone-400: #a8a29e; + --stone-300: #d6d3d1; + --stone-200: #e7e5e4; + --stone-100: #f5f5f4; + --stone-50: #fafaf9; + + /* Brand — WAI warm amber/tan. + --accent is decorative: borders, glows, the focus ring and selection. + Accent-coloured TEXT uses --accent-text; a button or tile uses --accent-fill with + --on-accent on it. The hues are the brand's; each theme picks the step that keeps + text at WCAG 2.2 AA (4.5:1) on the base and surface backgrounds. */ + --accent: #d4a574; + --accent-light: #e0bd95; + --accent-dark: #a87f4f; + --accent-text: #d4a574; + --accent-text-hover: #e0bd95; + --accent-text-elevated: #d4a574; + --accent-fill: #d4a574; + --accent-fill-hover: #e0bd95; + --on-accent: #0c0a09; + /* The keyboard focus ring. It must reach 3:1 against every background it can sit on + (base, surface and the elevated theme toggle): in the light theme --accent is 2.88:1 + on --bg-elevated, so the ring takes the darker step, which is 5.0:1 there. */ + --focus-ring: #d4a574; + + /* Status text on the demonstration pages: a verdict that holds, one that fails, a + warning, an informational value. Dark values are the pages' own; the light theme + takes a darker step of the same hue so the text stays at 4.5:1 or more. */ + --ok-text: #00e5a0; + --std-text: #10b981; + --bad-text: #ff6b6b; + --error-text: #ef4444; + --warn-text: #f0a020; + --info-text: #7aa0ff; + + /* Sibling platform colors */ + --veritas-color: #0d9488; + --terra-color: #3b82f6; + --insights-color: #f59e0b; + --tradingos-color: #06b6d4; + --legalos-color: #8b5cf6; + --compliance-color: #f43f5e; + --settlement-color: #10b981; + + /* Type — both faces are served from this site (above); a system face stands in only + until one loads, or where it cannot */ + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + + /* Semantic — dark by default */ + --text-primary: var(--stone-100); + --text-secondary: var(--stone-400); + --text-muted: #948d88; + --bg-base: var(--stone-950); + --bg-surface: var(--stone-900); + --bg-elevated: var(--stone-800); + --border: var(--stone-800); + --border-subtle: var(--stone-700); + --nav-bg: rgba(12, 10, 9, 0.8); + --selection-color: white; + /* Headline gradient: light to mid stone on the dark page, dark to mid on the light + one, so both ends of a headline stay readable (7:1 or more at the faint end). */ + --gradient-text-from: var(--stone-100); + --gradient-text-to: var(--stone-400); +} + +/* -- Dark theme (explicit) -- */ +[data-theme="dark"] { + --text-primary: var(--stone-100); + --text-secondary: var(--stone-400); + --text-muted: #948d88; + --bg-base: var(--stone-950); + --bg-surface: var(--stone-900); + --bg-elevated: var(--stone-800); + --border: var(--stone-800); + --border-subtle: var(--stone-700); + --nav-bg: rgba(12, 10, 9, 0.8); + --selection-color: white; + --accent: #d4a574; + --accent-light: #e0bd95; + --accent-dark: #a87f4f; + --accent-text: #d4a574; + --accent-text-hover: #e0bd95; + --accent-text-elevated: #d4a574; + --accent-fill: #d4a574; + --accent-fill-hover: #e0bd95; + --on-accent: #0c0a09; + --focus-ring: #d4a574; + --ok-text: #00e5a0; + --std-text: #10b981; + --bad-text: #ff6b6b; + --error-text: #ef4444; + --warn-text: #f0a020; + --info-text: #7aa0ff; +} + +/* -- Light theme -- */ +[data-theme="light"] { + --text-primary: var(--stone-900); + --text-secondary: var(--stone-600); + --text-muted: #6b645f; + --bg-base: var(--stone-50); + --bg-surface: var(--stone-100); + --bg-elevated: var(--stone-200); + --border: var(--stone-200); + --border-subtle: var(--stone-300); + --accent: #a87f4f; + /* In light mode the "light" step is the emphasis step used for code and hover + text, so it is the darker one: #d4a574 on the light surface is 2:1. */ + --accent-light: #7a5a37; + --accent-dark: #8a6740; + --accent-text: #8a6740; + --accent-text-hover: #7a5a37; + /* accent text on --bg-elevated: #8a6740 is 4.08:1 there, one step darker is 5.2:1 */ + --accent-text-elevated: #7a5a37; + --accent-fill: #8a6740; + --accent-fill-hover: #7a5a37; + --on-accent: #ffffff; + --focus-ring: #7a5a37; + --ok-text: #047857; + --std-text: #047857; + --bad-text: #b91c1c; + --error-text: #b91c1c; + --warn-text: #b45309; + --info-text: #1d4ed8; + --nav-bg: rgba(250, 250, 249, 0.8); + --selection-color: var(--stone-900); + --gradient-text-from: var(--stone-900); + --gradient-text-to: var(--stone-600); +} + +/* -- Auto theme -- */ +[data-theme="auto"] {} + +@media (prefers-color-scheme: light) { + [data-theme="auto"] { + --text-primary: var(--stone-900); + --text-secondary: var(--stone-600); + --text-muted: #6b645f; + --bg-base: var(--stone-50); + --bg-surface: var(--stone-100); + --bg-elevated: var(--stone-200); + --border: var(--stone-200); + --border-subtle: var(--stone-300); + --accent: #a87f4f; + --accent-light: #7a5a37; + --accent-dark: #8a6740; + --accent-text: #8a6740; + --accent-text-hover: #7a5a37; + --accent-text-elevated: #7a5a37; + --accent-fill: #8a6740; + --accent-fill-hover: #7a5a37; + --on-accent: #ffffff; + --focus-ring: #7a5a37; + --ok-text: #047857; + --std-text: #047857; + --bad-text: #b91c1c; + --error-text: #b91c1c; + --warn-text: #b45309; + --info-text: #1d4ed8; + --nav-bg: rgba(250, 250, 249, 0.8); + --selection-color: var(--stone-900); + --gradient-text-from: var(--stone-900); + --gradient-text-to: var(--stone-600); + } +} + diff --git a/wai/quantum-studio/src/worker.js b/wai/quantum-studio/src/worker.js new file mode 100644 index 000000000..05ddc2262 --- /dev/null +++ b/wai/quantum-studio/src/worker.js @@ -0,0 +1,47 @@ +// The job service's engine, compiled for the browser, off the page's thread. +// The page posts {id, op, pkg, seed, memoryMb, ...}; the first message loads +// the engine package from `pkg`. Answers are {id, ok, value} or {id, ok: false, +// error}. The page ends a long job by ending this worker. +let loaded = null; +let mod = null; +let engine = null; +let engineKey = null; + +async function load(pkg) { + loaded ??= import(pkg).then(async (m) => { await m.default(); mod = m; }); + await loaded; +} + +function engineFor(seed, memoryMb) { + const key = seed.join(',') + '/' + memoryMb; + if (!engine || engineKey !== key) { + engine?.free?.(); + engine = new mod.QuantumEngine(new Uint8Array(seed), memoryMb); + engineKey = key; + } + return engine; +} + +self.onmessage = async (ev) => { + const { id, op, pkg, seed, memoryMb } = ev.data; + try { + await load(pkg); + const e = () => engineFor(seed, memoryMb); + let text; + switch (op) { + case 'run': text = e().run(JSON.stringify(ev.data.request)); break; + case 'plan': text = e().plan(JSON.stringify(ev.data.request)); break; + case 'methods': text = e().methods(); break; + case 'signer': text = JSON.stringify({ did: e().signer_did() }); break; + case 'lint': text = mod.engine_lint(ev.data.qasm); break; + case 'draw': text = mod.engine_draw(ev.data.qasm, ev.data.width ?? 100); break; + case 'verify': text = mod.engine_verify(JSON.stringify(ev.data.job)); break; + default: throw new Error(`no operation ${op}`); + } + const value = JSON.parse(text); + if (value && value.error && op !== 'lint') self.postMessage({ id, ok: false, error: value.error }); + else self.postMessage({ id, ok: true, value }); + } catch (err) { + self.postMessage({ id, ok: false, error: String(err && err.message ? err.message : err) }); + } +}; diff --git a/wai/quantum-studio/test/studio.test.mjs b/wai/quantum-studio/test/studio.test.mjs new file mode 100644 index 000000000..f3cbf7fa0 --- /dev/null +++ b/wai/quantum-studio/test/studio.test.mjs @@ -0,0 +1,148 @@ +// The Studio in the installed browser, as a reader uses it: +// +// node build.mjs --pkg PATH/TO/ENGINE/PKG && node test/studio.test.mjs +// +// Serves dist/ (with the engine package in dist/pkg/); the page's index.html names the +// live job service, https://wai.transaction.science/quantum-jobs, which the service checks use. +// - the first example loads; the program is linted, drawn and planned as it is typed; +// - it runs in this browser: the stabilizer tableau, only 00 and 11, ⟨Z0 Z1⟩ = 1, the +// receipt checked here and signed by this browser's key; +// - an error in the program is marked on its line; +// - a rotation runs on the state vector, ⟨Z0⟩ = cos 0.3; +// - the same Bell job on the service gives the counts run here, shot for shot, and its +// receipt checks here as the service's (left out with STUDIO_SKIP_SERVICE=1); +// - Ctrl+Enter in the editor runs; the program and the history survive a reload; +// - no console error and no failed request; at a phone's width nothing scrolls sideways. +// Exit 0 iff every check passes. +import { createServer } from 'node:http'; +import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, extname, join, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const { chromium } = (await import(process.env.WAI_E2E_DRIVER ?? 'playwright')).default; +const DIST = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist'); +const SHOTS = resolve(dirname(fileURLToPath(import.meta.url)), 'screenshots'); +const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.wasm': 'application/wasm', '.json': 'application/json' }; +const server = createServer((req, res) => { + let file = normalize(join(DIST, decodeURIComponent(new URL(req.url, 'http://x').pathname))); + if (!file.startsWith(DIST)) { res.writeHead(403).end(); return; } + if (existsSync(file) && statSync(file).isDirectory()) file = join(file, 'index.html'); + if (!existsSync(file)) { res.writeHead(404).end('not found'); return; } + res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' }); + res.end(readFileSync(file)); +}); +await new Promise((r) => server.listen(0, '127.0.0.1', r)); +const ORIGIN = `http://127.0.0.1:${server.address().port}`; +let fails = 0; +const check = (ok, what) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails += 1; }; +const LONG = 120_000; +const browser = await chromium.launch({ channel: process.env.WAI_E2E_CHANNEL ?? 'chrome', headless: true }); + +async function open(viewport = { width: 1400, height: 950 }) { + const ctx = await browser.newContext({ viewport, colorScheme: 'dark' }); + const page = await ctx.newPage(); + const seen = { errors: [], failed: [] }; + page.on('console', (m) => { if (m.type() === 'error') seen.errors.push(m.text()); }); + page.on('pageerror', (e) => seen.errors.push(String(e))); + page.on('requestfailed', (r) => seen.failed.push(`${r.url()} (${r.failure()?.errorText})`)); + page.on('response', (r) => { if (r.status() >= 400) seen.failed.push(`${r.url()} (${r.status()})`); }); + await page.goto(ORIGIN + '/', { waitUntil: 'domcontentloaded' }); + return { ctx, page, seen }; +} +const done = (page) => page.waitForFunction(() => !/^Running|^On the service/.test(document.querySelector('.qs-status')?.textContent ?? '') && document.querySelector('#qs-run') && !document.querySelector('#qs-run').disabled, null, { timeout: LONG }); +const verified = (page) => page.waitForFunction(() => /verifies|does not verify|not checked/.test(document.querySelector('#qs-verified')?.textContent ?? ''), null, { timeout: LONG }); +const counts = (page) => page.$$eval('.qs-bar', (bs) => bs.map((b) => [b.querySelector('.qs-key').textContent, b.querySelector('.qs-n').textContent])); + +const { ctx, page, seen } = await open(); +await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG }); +check((await page.textContent('#qs-lint')) === '2 qubits · 2 gates', `the first example is linted as the engine reads it (${await page.textContent('#qs-lint')})`); +await page.waitForFunction(() => document.querySelector('#qs-circuit')?.textContent.includes('⊕'), null, { timeout: LONG }); +const drawing = await page.textContent('#qs-circuit'); +check(/q0: .*H.*●/.test(drawing) && /q1: .*⊕/.test(drawing), 'the circuit is drawn as the program is typed'); +await page.waitForFunction(() => /Stabilizer tableau/.test(document.querySelector('#qs-plan')?.textContent ?? ''), null, { timeout: LONG }); +check(true, `the plan is shown before running (${await page.textContent('#qs-plan')})`); + +await page.click('#qs-run'); +await done(page); +await verified(page); +check((await page.textContent('.qs-method')) === 'Stabilizer tableau', 'run in this browser: the stabilizer tableau'); +const here = await counts(page); +check(here.length === 2 && here.every(([k]) => k === '00' || k === '11'), `only 00 and 11 (${JSON.stringify(here)})`); +const zz = await page.$$eval('.qs-table tbody tr', (rs) => rs.map((r) => r.textContent)); +check(zz.some((t) => t.startsWith('⟨Z0 Z1⟩') && t.includes('1.000000')), `⟨Z0 Z1⟩ = 1 (${zz.slice(0, 2).join(' | ')})`); +check((await page.textContent('#qs-verified')).startsWith('✓ verifies'), `the receipt verifies, checked here (${await page.textContent('#qs-verified')})`); +check((await page.textContent('#qs-signed-by')).startsWith('this browser’s key did:key:z6Mk'), `signed by this browser's key (${await page.textContent('#qs-signed-by')})`); +check((await page.$$('.qs-hitem')).length === 1, 'the run is in the history'); +// What it looks like, for a reader of the change: the dark scheme and the light. +mkdirSync(SHOTS, { recursive: true }); +await page.screenshot({ path: join(SHOTS, 'studio-dark.png'), fullPage: true }); +await page.emulateMedia({ colorScheme: 'light' }); +await page.screenshot({ path: join(SHOTS, 'studio-light.png'), fullPage: true }); +await page.emulateMedia({ colorScheme: 'dark' }); + +// An error is marked on its line. +await page.click('.qs-editor .cm-content'); +await page.keyboard.press('ControlOrMeta+End'); +await page.keyboard.type('\nfoo q[0];'); +await page.waitForFunction(() => document.querySelector('#qs-lint')?.textContent === 'problem', null, { timeout: LONG }); +await page.waitForSelector('.cm-lintRange-error', { timeout: LONG }); +check(true, 'an unknown gate is marked in the editor'); +for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace'); +await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG }); + +// A rotation: any angle, on the state vector. +await page.selectOption('#qs-example', 'rotation'); +await page.waitForFunction(() => /State vector/.test(document.querySelector('#qs-plan')?.textContent ?? ''), null, { timeout: LONG }); +await page.click('#qs-run'); +await done(page); +const z = await page.$$eval('.qs-table tbody tr', (rs) => rs.map((r) => r.textContent)); +check((await page.textContent('.qs-method')) === 'State vector, double precision' && z.some((t) => t.startsWith('⟨Z0⟩') && t.includes(Math.cos(0.3).toFixed(6))), `rx(0.3) on the state vector, ⟨Z0⟩ = cos 0.3 (${z[0]})`); + +// The same Bell job on the service, and here: the same counts. +// (STUDIO_SKIP_SERVICE=1 leaves it out, where a build may not reach the service.) +if (process.env.STUDIO_SKIP_SERVICE) { + console.log('SKIP the service checks (STUDIO_SKIP_SERVICE)'); +} else { + await page.selectOption('#qs-example', 'bell'); + await page.click('#qs-run'); await done(page); await verified(page); + const local = await counts(page); + await page.click('.qs-seg[data-where=service]'); + await page.click('#qs-run'); await done(page); await verified(page); + const remote = await counts(page); + check(JSON.stringify(remote) === JSON.stringify(local), `the service gives the counts run here, shot for shot (${JSON.stringify(remote)})`); + check((await page.textContent('#qs-signed-by')).startsWith('the service’s key') && (await page.textContent('#qs-verified')).startsWith('✓ verifies'), `the service's receipt checks here, as the service's (${await page.textContent('#qs-signed-by')})`); + await page.click('.qs-seg[data-where=local]'); + +} + +// Ctrl+Enter runs; the program and the history survive a reload. (A job run again +// unchanged gives the same signed result, which the history keeps once; so this one +// asks for other shots.) +const before = (await page.$$('.qs-hitem')).length; +await page.fill('#qs-shots', '321'); +await page.click('.qs-editor .cm-content'); +await page.keyboard.press('ControlOrMeta+Enter'); +await done(page); +check((await page.$$('.qs-hitem')).length === before + 1, 'Ctrl+Enter in the editor runs the program'); +const program = await page.evaluate(() => window.quantumStudio.view.state.doc.toString()); +await page.reload({ waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG }); +check((await page.evaluate(() => window.quantumStudio.view.state.doc.toString())) === program, 'the program survives a reload'); +await page.waitForFunction((n) => document.querySelectorAll('.qs-hitem').length >= n, before + 1, { timeout: LONG }); +check(true, 'the history survives a reload'); +check(seen.errors.length === 0, `no console error${seen.errors.length ? ': ' + seen.errors.join(' | ') : ''}`); +check(seen.failed.length === 0, `no failed request${seen.failed.length ? ': ' + seen.failed.join(' | ') : ''}`); +await ctx.close(); + +{ + const { ctx, page } = await open({ width: 390, height: 844 }); + await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG }); + const over = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + check(over <= 0, `at 390 px nothing scrolls sideways (overflow ${over} px)`); + await ctx.close(); +} + +await browser.close(); +server.close(); +console.log(`\n${fails === 0 ? 'every check passed' : `${fails} check(s) failed`}`); +process.exit(fails === 0 ? 0 : 1); diff --git a/wai/wai-quantum-cli/Cargo.toml b/wai/wai-quantum-cli/Cargo.toml index f48d550f9..074a2b131 100644 --- a/wai/wai-quantum-cli/Cargo.toml +++ b/wai/wai-quantum-cli/Cargo.toml @@ -11,6 +11,10 @@ keywords = ["quantum", "openqasm", "simulation", "cli", "mcp"] categories = ["command-line-utilities", "science", "simulation"] readme = "README.md" +[lib] +name = "wai_quantum_cli" +path = "src/lib.rs" + [[bin]] name = "wai-quantum" path = "src/main.rs" diff --git a/wai/wai-quantum-cli/src/lab.rs b/wai/wai-quantum-cli/src/lab.rs index b81b29ccf..184ed5a68 100644 --- a/wai/wai-quantum-cli/src/lab.rs +++ b/wai/wai-quantum-cli/src/lab.rs @@ -228,8 +228,8 @@ fn read_stdin() -> String { /// `--demo-key`. fn signing_key(args: &[String]) -> SigningKey { 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); + let k = wai_quantum_cli::keys::load(given.as_deref(), flag(args, "--demo-key")).unwrap_or_else(|e| die(&e)); + wai_quantum_cli::keys::notes(&k); k.key } diff --git a/wai/wai-quantum-cli/src/lib.rs b/wai/wai-quantum-cli/src/lib.rs new file mode 100644 index 000000000..78aadd4b4 --- /dev/null +++ b/wai/wai-quantum-cli/src/lib.rs @@ -0,0 +1,4 @@ +//! The command line's shared pieces, for other hosts of the engine on a +//! user's machine (the desktop app): the key a receipt is signed with. + +pub mod keys; diff --git a/wai/wai-quantum-cli/src/main.rs b/wai/wai-quantum-cli/src/main.rs index 4fd94ade8..a440c10c0 100644 --- a/wai/wai-quantum-cli/src/main.rs +++ b/wai/wai-quantum-cli/src/main.rs @@ -22,7 +22,6 @@ //! `run --json` prints the job as the service returns it: the request, the //! plan, and the result with its receipt, which `verify` checks. -mod keys; mod lab; mod mcp; @@ -30,6 +29,7 @@ use clap::{Args, CommandFactory, Parser, Subcommand}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use wai_quantum::engine::{catalog, draw, job}; +use wai_quantum_cli::keys; #[derive(Parser)] #[command( diff --git a/wai/wai-quantum-desktop/.gitignore b/wai/wai-quantum-desktop/.gitignore new file mode 100644 index 000000000..e8e450bed --- /dev/null +++ b/wai/wai-quantum-desktop/.gitignore @@ -0,0 +1 @@ +gen/ diff --git a/wai/wai-quantum-desktop/Cargo.lock b/wai/wai-quantum-desktop/Cargo.lock new file mode 100644 index 000000000..00e582c57 --- /dev/null +++ b/wai/wai-quantum-desktop/Cargo.lock @@ -0,0 +1,4691 @@ +# 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 = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[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 = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[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 = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[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 = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.8+spec-1.1.0", +] + +[[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 = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[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 0.5.0", + "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 = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[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 = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[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 = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[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 = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + +[[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" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[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 = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[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 = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[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 = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.8+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[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 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[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 = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[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 = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[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 = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[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 = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "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 = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[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 = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[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 = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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 = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[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 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[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-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[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_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap 2.14.2", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[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 = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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 = "static_vcruntime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[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 = "swift-rs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[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 = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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 = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "static_vcruntime", + "strsim", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f" +dependencies = [ + "base64 0.23.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.18.1", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[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 = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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 = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[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 = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" +dependencies = [ + "icu_properties", + "regex", + "serde", + "url", +] + +[[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 = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "wai-quantum" +version = "0.6.11" +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", + "wai-quantum", +] + +[[package]] +name = "wai-quantum-desktop" +version = "0.1.0" +dependencies = [ + "serde_json", + "tauri", + "tauri-build", + "wai-quantum", + "wai-quantum-cli", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[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-desktop/Cargo.toml b/wai/wai-quantum-desktop/Cargo.toml new file mode 100644 index 000000000..42264825d --- /dev/null +++ b/wai/wai-quantum-desktop/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "wai-quantum-desktop" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +description = "Quantum Studio on the desktop: the Studio's interface (wai/quantum-studio) around the quantum job service's engine built natively, each job in a process of its own, signed with the user's key, with the service one switch away." +repository = "https://git.transaction.science/Transaction-Science/open-standards" +publish = false + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +serde_json = { version = "1", features = ["float_roundtrip"] } +wai-quantum = { version = "0.6.11", path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt"] } +wai-quantum-cli = { version = "0.1.0", path = "../wai-quantum-cli", default-features = false } + +[features] +default = ["custom-protocol"] +# The interface served from the bundled build (../quantum-studio/dist), as a release does. +custom-protocol = ["tauri/custom-protocol"] +# The single-precision state vector on the GPU, on macOS. +metal = ["wai-quantum/quantum_sv_metal"] + +[profile.release] +codegen-units = 1 +lto = true diff --git a/wai/wai-quantum-desktop/README.md b/wai/wai-quantum-desktop/README.md new file mode 100644 index 000000000..297021c88 --- /dev/null +++ b/wai/wai-quantum-desktop/README.md @@ -0,0 +1,22 @@ +# Quantum Studio for the desktop + +[Quantum Studio](../quantum-studio/) as an app. The interface is the browser's. The engine underneath is the quantum job service's engine ([`wai_quantum::engine`](../wai-quantum/src/engine/mod.rs)), built natively: it uses every core, holds as much memory as the machine has, and works offline. + +- **Each job runs in a process of its own,** this binary run as `worker`, so a large job cannot take the app's memory with it, and Cancel ends it. +- **The worker plans and runs; the app seals.** It signs the answer with this user's key, the command line's (`~/.config/wai/quantum-key`), which never leaves the app. +- **Saved jobs and programs** go to `~/Documents/Quantum Studio/`. `wai-quantum verify` checks a saved job. +- **The job service is one switch away,** as in the browser. +- **Memory:** a job may take `$WAI_QUANTUM_MEMORY_GB` (16 by default). The plan says when a job needs more. + +## Building + +The app embeds the Studio's build, so build the Studio first: + +```sh +(cd ../quantum-studio && npm ci && node build.mjs) +cargo build --release # add --features metal for the GPU state vector on macOS +./target/release/wai-quantum-desktop # the app +./target/release/wai-quantum-desktop check # a job through the worker, sealed and verified, with no window +``` + +It draws with the operating system's own web view, so the app carries no browser of its own. Installers for macOS, Windows and Linux come from `cargo tauri build`. diff --git a/wai/wai-quantum-desktop/build.rs b/wai/wai-quantum-desktop/build.rs new file mode 100644 index 000000000..d860e1e6a --- /dev/null +++ b/wai/wai-quantum-desktop/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/wai/wai-quantum-desktop/capabilities/default.json b/wai/wai-quantum-desktop/capabilities/default.json new file mode 100644 index 000000000..d2afe49d5 --- /dev/null +++ b/wai/wai-quantum-desktop/capabilities/default.json @@ -0,0 +1,6 @@ +{ + "identifier": "default", + "description": "The Studio's window: the core bridge, by which it calls the app's own commands (run, plan, lint, draw, verify, save).", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/wai/wai-quantum-desktop/icons/128x128.png b/wai/wai-quantum-desktop/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..565aba41632a9d58891e76cdb0982fdd0b6cbf09 GIT binary patch literal 3251 zcmV;k3{3NhP)=@fI4zEsX^I}5mf_rCMkz#mT2gRckqYLoJr7_LD!}wqSJl0dw6!dd+Z&%do#21 z;gR5OcV3>~vpe(q&2Q#HIjg@E@&I8CqZ+0)+|)3yVL`*92BMG@iw0No26xj2mtik# z_4Yw8X z+~Kxi8&3r=rHJMpQ(g#Qm4;bGt}vK2Z03pp@-^I2qzZ>yhV51)02DB%$P^ZH%ZXsQ z*S>o4<#-dUN=E={%$I9Ukdgq@Y(F60ft{bP2$K^LCjwAG9=U@>Nd*v7BuS5$odB{_ zY(Fq-2U%7Eh%0gegSeFd)RJznmXb{Zhz6nKr3LFqeZ?arQddTvXsRIlTB>y5Z+wRD za7{o&5Tyk$Bb%XN*m(aA@_PJ9^5HkXLVoquaq`(aY}A3eBuOs|B@#G=?8Nq(N6F++JNfwXh-XH(1L{QGXoJ8a z2#1V}Q=u9@&{0dif2Plop+0?cj9j1WBd;9aLrx7mPKNe2knYxM(z&gY?Ap3X2mZ!q z_zu_LTHJ$soz#K0piQP>sG^B<3|u0Ip8td#IrD4MePWih?1_`y>};O691R(1$3hq$ z2KwmLBT0>MyF)s42HjU5B8l9MyU5@WCYmDDu% z@WSbI$jCkC+VDNCHTtB?j`$zHd{}>So5IDOw*_^eF4Sq&mYKIdu{A!8>a7QP;Pz(7 z*nq&L;o+8DIo)Mz_-{?@)nDP6Y9H!G8Uhi%JZ9$tZz4d#?0stD@j%K>;3nAXNYi%l8Z~N3^a>kS4S;Vh=k&*!RAN^j& zx1oi2A`lTdE0^wrbB8kes&|6?{F0Q65A?-vWW3!Wo@V$~Pdz4FB6ujdYuBbMfUaXd zPy*0jo&7s3-8tT$dKQOY{AEf4Xz4tq1c1;gYkbTBO7g{vy*KX~cWnIUXZ}N87~UUj z0azIyvuTlI>?0TE90}n4Ywzj(1qXo?fYobXl=0pU6*W;u#=m~;N2Y#*i$Dp$YQlG- zcf0T_Y(r;1TQYL&%`eC^FMLWSE-z^R|Cfxt{1>uspT#ftO6iZ|k|3_NpSw4>*%X0PJf{hDl$=mJXBg2TuQDMdh)RLcfLr z&jo;F4t8NjbmOmG^JOwL^_wN*f9;Kblj5@46_*QcXs*DW@B&~nd1QF?cMh5pKek4n zb1lJy<_g>iEdVy)gY|sjUB1Y$r)&D)$j<IpWfIg1Wp?>a{oM09>1<=bs~t$52mo8AWSRM|U9(zq`F^`({1b2d zixih`@g?P)<`A3;BLI5A2OB8-G}yYF)udp3j=r0>qId(TE_;|fwyBV8ttukhHkas6 z(&M$IWM^Hez9k&M-}nsQ;Tl|vdvI^hbMKg4zV^3|udkyzgj*APE(&Sd=f*$?$PP#5Y%-Dm^af;ORTZuXUp z4tlXG*>wT1X$j{e5`V%@?O|dW1KNT%p>1fRlfIzO=sP$_))sI`bL#dFszY)*Y%e20^f}NfXkPiO@`0??!L?S`aen0 zqwcK8p-<==`Z%0K5a0-0*#(8%&LDPG060{MKA8ib!IkQdZn5m_GrQ~br=)QGS}uJ4 zy~A&tcki}8>s>0D)@Sq`99VG!&cGe6rB5mW$J`2lUiy9SY`=c=3fr;cnj6(#U#^d# zJOz9jT92BHj}&g-@(MLBz{zn7Hx?X%ORtuQT@?U*6x;R5ep5%r4mA04f=61hg_6x= z;>SyCP3iwF>vvF2i z$bvd44CD_-+69PY`RQ1v2=q_K+GP+2!vTC_8G=RV1MBB!`nAuU0PH4)qR$Jg#`{c8__!88!bN1`1c?PC zk=_PqE4#3h@bfd_>MbXLMV=ajK#0|NnaC&LIjIC~W7qTPd5zye9tT*202X*8lU+Mt zJq4bGfVNo8@G}wHPZ0|cz&x){6jsFS-Uf+m<4g_>sGH5+WuJP#R`t@vJOpr)_c0ZT z5o~4=4Q!ymgv*Z;NKgmrqBm`@KwJGt=5PTwA%JO7CRdm}vx%lw?jv^8Vq3Kv!vxAe zSt!%WonsR?d79xnz%&FfDw=zRG--CPD*)iOB1u}@X6+A|#i&|zb>cECq$KbEc?S0aGK z0#aNpC1-3&H%>3?WI_NmqDGdv lwG6K24eq85F2i2f>VIBeV-)Qe-DLm(002ovPDHLkV1n@=Hs}BV literal 0 HcmV?d00001 diff --git a/wai/wai-quantum-desktop/icons/128x128@2x.png b/wai/wai-quantum-desktop/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5a9b89c60e2c03fa57ef247def9bd6c452480ff4 GIT binary patch literal 8233 zcmYj%byQSc+xHm;2Ly)BK~g0Iqy|Bd9=Zf?O1eQpLXc*V7Nn(PKvIwf2azu6Zlt@r zdU+1i|_uBiceb#mT>V&;km4o8J@BjdSD##-=004x!1p&Aa%thb1z!Cr$ z4ipfQ|F}=@rhC1n(9Y<*;3tz%a!l_#Xq|4`@3R|$G1M$Cmt+M)jZ(}Z-xopL9d{}1SQn7JZKxoo`uL} zQLv;u!7RSbrk1>(pwwGhJe$%NIWk z4(tQ1K0jK`pQI1XFzoPAz6TEAys{g#;gJHxAbClL&syO~oGt+&d&fj6CeXTr{B|uI zsSuSwqo2nn|H=7fJoup%&<@dN2c(osf}NQUAUuB;usG79AJ!6g$4J1MP|fSb=2AJ{ z^xa>Zs3rWHaf)@Mn3n=x?^)Xe;9_|xQ&R~!uzTBrffhYe$$n8+RyfAkO}b zx{4ogSWpO^(+?d7KGVFC!jg1?Lz;Z9A3lU=KNdjpLB2#ws8fc44$#k~KDArqL!x_( zNl4|L)2W+mAfKZpoTvK%N4J+z8!S(fW}&O-T+jzT`9SOx)nrfvkHi(fefN`?MA0|J zI^a_f%xJ# zc1=P)k#Koh1B%H5nk9?MYkIur_;8|z&A><&s*$2{bpMSHa8QwN1QIF=G{bn$%=U;(R zW~0@^QFIiKKQ^M45C~Wzdc!Kt@>YxTbEOw8IPSm=Rm{fUtqQ%6bwz_7Sx7A5drk}; z)90}Iz`4L}KskzKsei~3jP6Ia;SiLzZ>Qfahu5`78O=OzI5#aqJ6Z<_2~09Zo{xr^ z85Fh0^lL6noqc7w5WEY%Z?!tm^~RZRf8SO#0F;MWljk_*vKd`R6nJgL7wrFH8+ZH7 z^lXBG@lz=gt!t|XkhysE0>jh<`yHFSwHX{UY|r?iu;kIkUAIa~L2zZ4&5_TB;tn{wuB_ z8trm6wCEBWZ8x<-&%#YRk%7V;?=;cX&VEA)S(+J2gbNuDLMBW-kFzAI8!oX4xA7K#Q1II z-f4yn2T_=cO-eVAprfrN0;9Vm5D=~A`j6S7-!1PlzF05^jF>P0ypnbuhD5#><7HM| z;?sB+EXxinVgw76;Kej+&vT}&j6915!l})X3eVAqvB85(_jf>DW44F3BUL(ov88BVwf`S#`jC&9k1B>WlH$e6ZYT3gXq(jL zuR5q)Q#V+m+bkO&?bx*=%guX$76{5 zmlWO1Ch*qZ(i#9YcYPOgyI>#I@Y`T1zOqKruC*t()kPslc+Xtz{!{Gf-u^4|(fT;DaMV*6(sI&%AuLEVZKm{3w9zfd z=4issv%S(*8tfMGQ%VUAd~vLB71Tau=UYiMVpq8v57_teB^vw`2UfHJU`S~ZbcPXw zaHO|+@(uMw)I?Z=YF>!69vld2b~12dJu_xyV98*X_SGAhB)B|nKlMdHJu=nn3VC73 zym0Tb5Qd+J8s>jWvsF@VzSPZy-HXypUrer9pQEbj!DfI^?nKGMPUx#KMp2*0jqA0> zw=DPliT7GLc{x|dil)|Ws&a!cs08m$5l@+&GUml8xN(+MO>BxoNl)G=2N|*C{qsQi<=!=4^8s z`DniQ&ohmH1plHVrbg4#5Ubr9(veyUR4&MzwuDo!`SdQC&VM6o%-30~mFY6ts7Gl! z0xtQc;%n|yO3jOEI8c{yk7-%T5{)4@c;8p0eXsqpH9b&;jFGt3HUI{u*?q3m(tEK_ zQv27%%a!k%1@E-YS2wC3(Wb;!W1$paNUX}cGdoT*tShmV^IzV7O7rA;oF(-nQ|SuQ zL4VvXWZv7$zT=A<9AIjs(97+bFFUrk?}6BN$zp$)RnXx8m+2jL{;2n%KS~wQ-tpa? z=Zf>|oVb6hocjHoKEh4!^LCBKYs@T>p+wu;Tl%Bx2Jb)bZR+o?si|RcHm`ri1;?Fi z26`yZQty=$+#KAouw-Ta;7>LCtzGk*I;ld+R!cZg)^%Dyo)&DatIOs(x7Lltl5JGU zhp(*|ci(mU*#8-K70U>7l_Jcgd&G^V^LS$u5)+N_s1pB(1+f z7A)5_-Z{6p-KnG=CH+atuqzSPOA;ON#^LH4fQzJRQ4El|_E3ff9|dLCewVN_td_4D zrwlv$mAmG7MhA@wH`rE^Q9I@v`e{=8GGc6A!$H-a4m*`Yb_#wac*7tTFt8+Y@!S{O z7&PDZei??QED5vyNbsA>=Stjggzk@L`8I*_ggIOhAB^1iRI@_0A699lOyQx+k8s+v zQ?C{3RAzgkof>0pAiK2tB9?@|_n*~d|MlFi`(Au;o*v|XI8t|LMRqTnh$(7Wwa!f` zC`%WHR3dLt}wa_-Hy+}>g?uPh&xN8#nh%_KxZP3LKU z{(u^9?6=R2YZCgEwnRKM4WMSy3Z*d(q5l51vBIn!_3x5@JnxC@f1KUkX~eOHT~_9Y z4+kYLg$+N*yN+VH{M>ZZ+vCUScXdvPcH?Rz<(07`F>8bIp4ZJ~iB4Gq(546PBF^=7 z-xO7_vF@pzzFIbTq|$WCanyId%*c8b*yZT4F~XrvQIc;kO?CV1{z) z;BzRWEMb-A<@9ytOMaR7mA)5ILGO$z|G8Z8_H+rFikYC@YTM=$IaEY+P7g)?&d<b=VH_oB`jr-)&KgVZzM#b4h|5l z-!)GE96#O8dV{yrT*v0wbW7qKRC`l3B9C~aAC|A5K6LZLkWZVF`w!oI6u$FtZ@P7v zSzqyHm#J*Gc?@6sb$^vT_l-tH`TBbqgVSfnhE4< z@idSNZF0C6_(A4-yxvP*q_oz#`*7a=33f(}d~8arhpDb_0$WliwH}U^(Jxpqp_Si8o}Gn``ZbUK#EtynNPJqEtfgg;_R~!Zdi2DL6Ej$~Ev; z2m}>mqh6D-bCwqvYy;5m+>rGhFa&D8|3`@B7kWy+)5H06)g!x@M{mWlk0*6mGUX(e z*UNv>Wh=qr=wtm!BJj%EM6wkTynFu{occYtwyQ^HC7@^I-ygv^XmM}o6z7}*C<*mZ% z6Z*SO^tNi49j**MFy7{_i}R(TQEkZj&S=TIa7*6$^3r8E+ks_EdG)Gwc%^jCQMtBo zq<~|TX6yO*RY)-9R27~%D_&89W>N*j_}n4r@_@{TsJ`C5dPw``?26Q-~YhPnWPsfI+a~G%W6xR z+G;ziYIhSoU&%UY}SBwp8wsp9Y|kEq35)ah`Yij!tO{BpbQIi#d_5W9+G(-sSk%Ow@3 z<>}LI5+VR)jKFBR*pmBB0{;B3fpq#G#2UFWdb@imyi{*PFpV+Xk10-jO=eyONT{~y8cDUld%P#+!bkai=R%^srRA;i-wDagYV_DG(#I0}aE?U)qH5_G5Pz2~Iol zvqH=pC*M<6Rvyvh6Aw=BJTe=cA~f7C;En}0vTclpzAcc6W=D~;a-rwUg^D)pBN zL}M5@tJZo8IlAtg>2qBoH{7wCyOvI6YGkEm%`a6CX30|F_$xI62SOetD1X1$D`#aF z(hj~^@w(C$WUxqM#-6Sk-ZhaSDpel_%=3Xvm>p`;lc=p^2t;~FVSGK|`(rn<-TQ$tD`Qw01+fDbMhtgMmXSrb zA|F~}D|-`yotcT5(s0`fn*tr5@lb(DTG}P_36*gZ!7kFoQfMZ5@5KaTgbezrPTE2q z3h@-pUO?oi=eQ&|OF1j&&Vzf1Pfw72;R(<~WoLus0&L(kp&}oMU+3Nzq#2&3VFm0NR-Z#l@jc<&>e>bTqXxyN zL>R_ZVJj7`B^(s28JRc?aojVmv@nhm+0p4z&1BJ{G2M9Q{>qXUX}_(A@X^g+Ge}}z zqi*cWIi?x~ZDF~7`sMU3GK<2RWya)|KJkN&G{*m8leExza8^%7M{;kNwYYM+iZ3&d zJtFiizr`DCG;uwwqQx#IRUqIS0sZOl-4dsZ{$~)-05L1H@3-pmR8v%Md{WwrHb$wal8Z69KoRj;;;+teXg5fC1 z7Cj6}ne9_wxn1-L)5FzcGg3IA&x7C3Zazt|dA@BYnE8z3RsIYy{hZoF=^rYAr^js- zH=J)yUeXl$XVivPWJR!XVAM87|I7T8lYK=$%DV+~-IGm5NhZsVP%!8!Y8b%x8_L@G z(4}WTTyH%wghjy1>(G31QKL~3UP|FEY!Xa^9sHy@F2}P=-v!rp^OJ5iBWs`c)dHRS z_=$OCSQ=@Fs-ix4qD8>Te)hkP#lL>he*n{@>Z|6W5~q4dfzl}Z*7&ZwM1a;+pqu6Z z_bPNjLhq{ua$bD-wtOs^|BdpYF{hv*SD=nNh6YSlmiJn};3lgZPfVeEyYXn7hqS!j zq42WG9xOX@#X;@YdcVkj!PyY7za)5|ccF4qv$ZNmmsRrGsz9Xl6a;;{Nj9AeYQqh7 zF;^+>&Q3$m4V)Cl_2#C#%eB98nE7S88pm9~cvb)&eXxb?N~Xpo;l*b!g&_`97{AJl zW<^@B=&R0iVZXBvE<$)@syr$mtjyTsMxPQZwUL+0M3C50BzQ@CY5fYcbp6A{MPO`= znj2eea<8GpkTZe#sG@|Gr2Xt4Md4CwubWp0w~r;1iD}z#%cXjW>)gf1{=)78ENy`2 zpz}n5>Ot&Lr$6}V8W@(?Tf6!-+y(Ks;sJ)8HhHslUY|QG*Xy0H$kM!~jDKh$O{1NTl>k>aVTJfT8N zyn6x@K05II{dhENV&r#k+Qz5I-**Ylyk&*@1fTjSzS+TUY43KEyqO3~|#YUv3-?k7wx)HuL^)%lAnVVFed$ef?GW}>sT z8NkJXpBouYr_ZKLN`w+frp(fWA#wJ5D~6DZw<_eUVfce7#pg!(Vqc!5V{wZJP%qHM zV?8y*`qBk2*kW(;=K-etnI!iKo_Kz^EA#~983~#k?3?7)uhBoK4nK(SS}U*D-cQpa zcNShFO*lV)bq7^F9m)8PgM@SgJ2iN%2fY_i>4#zUWN*bz@~_5?{P}<>RY{;njs5Ks z%V4~+%lG{oGB$9Np?!sY(&Fg%jCR=%XoZAU( zt&msU3X2l6u?5_`%CmkZ)SboZ_y?Pd-hbb}X3zmU4&w=pSEn$u@PeQZPB%;ILB?ZK ztW+Oy913}c%(}R%aG=8gIX#K{_7vskWW<9ivbKzS2^zrDS}yok4n@%E4mmsw+gdh{ z-TFE1V-*F-0?xWAe{sJ`?d?by={=#PHH&rS9hNPyu)Ds}SN$dcN-pSxUBx^?T16RO z0qFpCwD5#qswWXoDK!2nxlqCd0|Pas@mjN;R3BXi0A_>j`|`d5QaEJ}A5J$=M%LrS zI-N^#vD=!;=Elb~7_2NZs5MCVQ3dbq(sqGG9S*joyr{2;Fa-_fhfNM+j4H@3hImb1IDmhpm-f)V6dX3T zgEJ_b-Oj>J@?g|MNplyzN{l&$v1Fhs1Z(?bzl+hnBkb!&$5s{Uzmh4UBIjw z@>C8if56mIo*sm*Pr64D?zkN(oMee%iXtwkER zIjF?-U@W|5%yY9kbxPtE2BSMA{y0@s4kF~@mhC(`xL*)U*FFA4=ETa7h{geSSPs>x zBwtlMCY%if%^EXiduWJK4((JPmO?NtU>M>XnFn#WeCpL=<<%!QeeFlJATYW`0zstu zxmqMezXM|!4C(7eJ32`xtDjjf2Pffd#v5j$vB1=(jI+s-LVTkX)u^G9_BftD(xLyF z&?@|X1Z_$DSNdkSkih{7^n^?O#~!>x1?-VGKN9E}{;$6+WRPw90Hbc45$&2+CjZYy zmlb$xJjU15|3YW$d+P_Y|2XK6dg6S&_r**V?Q2!J2pGTkA3UdFG|aahO|K!uL=MUL zCW^=WK6G!}s-1|?RI_i2)h-XcxEbB_%@)~hcmT)L=7Y$c6C2Cp(^h^F1EuH#MD2}6 zst|_vzjU(1WC^t#k*Sr$hyNnkf4i;&Y=%0N#Pn;QZoFYYni`BBxlJH{BaHu8*PI+* z=h^l0L``bta+w^x*5IPTm%{3eh*n})Q{&X7kV zXMNh9Ye(>grSYf(ov|LrNpz;jl>0kpaz`-)|j4wsS=2`iW`v2y0vDZy{6N{{0)2o=sKzY*s%7j11W2KFl#0)0PtlJY2PB9BR6|Km5UHKz)PT^R z1+pa$9J8)P9VqasQ<1TaDkEIL?=KHfgN-}1)3sj%DpNxeL+38*;mGnK5FrWa(%;uc zaj7r2Yd{fwWDDj@QnQ&Wm6N;Pr!7E-C4uua57VB?zE4}Y{%JRWMutWUaqp-P0UZgX zF5)05W}^Y41y|pWR-ol0<Z5# z9iw@CQCIAbx&Uink(Kz6nlH9MU$sW4oK>BG@kT-;O~3nFm%?Bz?XYJFUTywoP=tSF zj|fzjjhvK6R%+T;CpYa)AJC#8u<-(I>6nSdoZ=*&^LH;g;cGAdS14mz2*Gz~Burlr zt>RD8j;0%48;aIq7-stUffpGj&tD+0AxO=KHg#-W$ZO^b5YbOU=)Xm}u;vt|GJP?4 kXj3Ad(+oE_-na)n#fmbc*BIfz`~mMlP4@6wM(f|Me literal 0 HcmV?d00001 diff --git a/wai/wai-quantum-desktop/icons/32x32.png b/wai/wai-quantum-desktop/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..4ce7da2d80e931e1bcb28fe68d8318b2a38b0e9b GIT binary patch literal 713 zcmV;)0yh1LP)ZE)6C+K3TOJ-vG6#LKcDXk{QfwFU$4so99#308Oe=T>JK>iie937^^Nx{|-$S?ys}gg>jO*iBw0|`3(nV z=Ko34qB-rH#u?rVr#f8cbs+yj7@wM3#`g~kf`eSj4p?DVF&^#C2;=FIm*ShcB<2XjeYmb zUn~+r|A`2OjF4U(>_jP(K>1=1CWd{m23Tipp;GEaw>+iCyyZ+kDw3%oaB}| zP`xpT`lma1P&kdOk+-9V4myUuF#QALua*(qr#GxommK6GC%L5#%sHRGn(DzF7 zp3c`q=%d|PM>%qllia>KFpzsLAd^#aKs5zU@RS18A|yEGoG(S_PhO=-*9@|HhLTK+WqFCM2Yd!DZYYYXtix^tQ=00000NkvXXu0mjfuR2jH literal 0 HcmV?d00001 diff --git a/wai/wai-quantum-desktop/icons/icon.png b/wai/wai-quantum-desktop/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c3330bf4a3f1cf3ba6365d28ea411c03b5be8a00 GIT binary patch literal 19047 zcmZ_0bzD?m7e0Cj5k&+60ck}+L_lKb5|NM+7`l{ZDCvg5Ae2y%P63Ibq#IPabLf(W zp*x4W`M&XcfA@awU*epdXRp2YdY<*H6Z}$HmiRi&bqIop<>jQ+AP5)yi3<@DfPePf z2TvgA1*g39Q@H!s`lOe$hFRREm+Z01KK10q;4*hu6HK3sNkEE;N=}OK{=`!z|2Oiu z0rB@qcq-DZt>tJ_7UHR-iy^tZzn z!Z=&*w&=7xZ^;Rp7M^nQo;hmVmTjTBi3eHjJV#Obd_^g3{y?B}vd3KbH9D%)O7re% zV6u*HNs|8A=aBu9HtNv3oiJzHl1V`Z-I*VNuX}N75XGyPZtLZe^Fi`omMmQooNN9b zg#?p8J|~Yg*i|wR77Q3dVo7}pL1DTc;VBCqJfy{DCITUgYAzb;g{1!^3$4`NgyQgB*RXiqsm zA3?}(M6vD-uUKkFrCJ8=3qvU+Dw#vqX^`)a6k1ftXrW#eS#|5H#F8PlPTtZyco4yI z+8~V-9w~2W*s%O72q{~TIYdE&3_5jcX}JFw`ei|qIYdo^Bt$r$CEdRQ;m;ptmj)0p z-NXKI2x!EI-p?x{*1zM(r5%U4$;m*HtyA)OPx&CLH=ptGq4d$GG{`Me5za5p5HhIZ zn{K=%ggZEM^w|^-lI{%5hJ7Spf==b1K$4B?nK)ZLrO({;xgaL-?#v-h8i+(j$oDx9 z4rIKjl?@9a`1J?g!YFqSl2kmuTN>2|-T0-}LUo@6Vp^<|%d_Hx&VPP+j|1KBl%YYg zk3aniL1YLp&CP=^bP)3WxGdh5%T>ASkYs?MB;K!C5-8kpUI_ z&}bOR9Z3kv^^Vb1Fd2sQjkh3ZYhG=F2Knv=4FuH*Qb!ZK5`T9ELKZB_*5VuywTRrH z0Ka>(mNv_j;RrS`;XtH8ysEYk%^fzGdV3R+yiXA@3W+NH!hwR`XEq=p6d^wZeNnqDgZIW?5)UF}qk3xxAqaWE zk8pBRye(WD0*L9WAt4GUM)3`(RMH0QV(Hpr@Z5E3lYl8O1s5VE67uDT0I_a)=sF&c@BH939H&5H%Ttg%itzRF4J_E>-7nr3*{j|Y*{|NG+0)&N+Z)@X zxx>z+mc(d@w?+S!;C+F&cUg6eE1$9x)kyKFW*k*`u7G{g`ks#}$w$K+!(zkgsdrPK zrXr?jO>U$&xe@e3C1g*S1W!Kt=uF-(9yrGL@wRwZ>F%EBftffMdAw7$$FOg>pR_-9 z8?WhY0x$vL&%mTFaFQvO!uZ(DBjfajySSI^^vrxc5DK$YC;n=R6tqopg=(Fx^h_fi zP%Q`6Z}BJriQ|fR;n=lkhTh23HxRJ!FHaj`3;6iM_=O$Lkhn_Tx|S`pdX2Kzugb}A zxKd@)z|K%)^7kw~X_F?mX6|h#M-uAKCs;lxob2IDyW3nrlEf(8x~{RIP+#|K0?WtR zW=WIZ8+bD)hIJLZElz*jI=j`zLUUR5Jdee0O3LY={t?R%KF#^ZTg01&c{mT5+cqu(CWyMOPOSIqes5Z zwZ5ST1(F^}wl+4nIj2sBZyL3{jeSF75(*0>yZcPc>j%M%5AJ)#Cypab#dfM6ymC2+ zOw9X7*sp#g>bzzeba;w@b_j3s{H?-%=NN8TwKTx@b1rmzdwS*b-5pPBNDxaE%;&x3 zvVmuvBvuh{g+wczPBs6b#h=c;TRLf-6=yu^+M*31$#9FEPZ@Cu+;g$@gi_Y_#J}Wm z-`!_p?{u}tzF7S$Dk0HlXDB#`=$@+TpCs{nH7Dw%?vyt=_yk?j$u2-#O5Rn2+Ri9@ z+ylQI?lQ;C?e>k0M&ysrV8KE6Cu})39;@gl>!)=gbD=5mOhQ3IT|%$^S-nENdj09r zHL)nU*X!I(+xDnZ91^H%V7){@awdmT!38lvuvAV!3u% zY^8QZY_+zzko%<_TML1A&|m_8@wQj}LCNyr58nYDUe1DV$Kn1*k4qQ*&=AEfibf60 zAGVF*zHPy}vaW=&fo0#sx{8dE^Aj=k7Nt!C?w5}#$eUPhnq=v#cN?sq-&QfS9NM@r zVrdE_E1iA~U2YD$y!!kQYx z%Q?KJmxXY}w6Qfds>le>+BDgvIJB41b)W4`tL9mQ`J4HRuX+e(gmB+x4X96ZGK39T z`Phq3t@!%*nD|7pam2hnWrF|okaG%jE-W?IKGB~T|(Dr%n{X0^0xERMh z9?68FF<0c1sIhiQw;QXr*p*V>tjT{k8JcWG;T7uNd~Z_VcHPOm&tiAW#irb2>|{yT zXVIsOQyZg@_X|l<7;pPdXKZ}5tZ|>;%5U09u`Q=ZBc8W4XlO&fG(YN`aGyKFaoXDU z3mu%Dw%tH^wG!_w4r~y-diY!N8%0=~qXhbgSKmbwb9~qDvMc6x4?g)$Sa$Nc(ln&( z&X3I1f0_r?RwrB?acl3O2vXikGCdO3QdT(?I&X2~gmtX4a5G%{UN{&-@P&OT$z7=b zQ~jFozCBC%&$s%&79I^kk6B5B{~^ynkH+*v9~0=cHr|= z7j|Q@k~e2~I?#~wEyDDU_YTZmh4~Mx^o|&p^*goB?sx`EnruFB+weL|HXeZFy51@` zGl;ol;|NMKeZCnVt&zEa@twS%vAa5&NwR=+e4Qe26T4qoABLXgqL;lhw)Y1pvn4ZG* z{SEcyAPJj?Zr`3wPaGV&f$J{a8Nob9Fj4c{mDYMlbkR21afD+0u3flZU_1@U{eEv1!Ycj}Z|j;r(nyAw zcFn`j#ZSy7B{u$)6vYuZEiaAtB$O9tCiffxVda=+hW|c#Em~~QV`L%1_$LbHZF-N= zPp1ZZtC_+Nj<S8VqX)TU|u})31Td)VtFz z_H{&aty8lTUmEGG)OlQVjA@X>ZZ&N&e|n)`y5MTB6VqOJL9(KwudZ10#r3vJUa%E3 zwt4Q=Ou+~J91ty^I1`?!K6CMf3T5E%?y=7?1g|>&`k5*|#Y6g!*Ls?#5M7v@YR0r* zGYNvFy5v7!CvH0?nCYG7&Pc)e^$Y)ac}jqBlP{hogUmv@KmFqa(EQLewmgK*66%$x z!YQa%c~R`(x#_6>RU%HCxoGD)JJ|q~JLc=peT5jxw_BX|c3SzacDjNEKSg(2Qha z^>i(6nKUXt8z&F8gqjy4d6H6aBsYO~a;0di-n*u2uqQ??vPk^V3JHkvRDE&bD4~3(}f2#TrS?J4Z5FpcE_YjQeGJ3V1l^FUYiZ?7TQ4{ zUsRkC&_Mc&N8JR|RM=rsV*PM=9e^A!>SlPT)MKX1vha#q4^W zBvhlWgghXID*pDg?;e)K_2V+kzf-KG*eaG2fc$pn8_qqcsZ8YYpt2L<)=@pJxP;uh zjg)t+mS>)3tZ!V9jW%7(R%V`vMQhMftJ6SlT2<;rD{7M}?xn$OJVlE>UFinqV#EQfT&RmTt-pRKVn>y^Bj&0Y$Ob%xVtu7^K5x?%Vf=GFJS(oC= zyKN8iNS*T%l=Z)CvncD(#eS$?Pr5*uKOQ>{9nPv;9j?(LR^@oT1}cy@RWf(|2;?t3 z!V{xvz&@1{QTDc^5lP}U8_4%|4^LJ?B7zaug(UHi z)b7B?t2?-QHnlT+@IF&zO^j+if%|y|;vm$#uZrBfYf8a<*XQDuYrICVO6oAEN-cYe7}9eD^z!amRegKwnA=>8%0LvHF}`Mq?{sB(Ro zI12QfMhZMr#-uob9i*Nmtl8}2)$wr_3{LE|whVo`7(1_5#9)T?7oXu+LS(rfc0z$? z&{Lb^@zby8Lp7Uzi{h{+lbcVS#tKeSbe|c5M(&|lLwW0cQ$Nl5;3Rh$c=QfvT?`b| zv+-;`cN!0;`L3H5_skcR>naNH9<$aC5$JD=TY?yn=!X^wtaPz5N{FhhEGyyo-6l(RUclBLMz=%lI-E| zTUYTs*)!6u6}{!~(spL*9t|X|2yg=zxzq5f@a(m{i5-FZhJjg$`UR|X(=*tgaIl)s zefywaS0!pXPWLO-y=a0T^ggrmtsFgG_A8&>Dc__D4*7)mFzg8mhJk|CvQx9Q%znr2 z@ohZ}%A)>BdRRLwEr}i$8`wr`6xmvlrI}^!j%_adU{7ZSy~@kDKO9{!w`!qy|DORW z1=iRe>y1P%A7z;zsd~X1S_NnzZ9%{4!)mO(RpRr<{tPc3(SkDLJDWWBlu`7Cm~aHO zkSrb&XRIc7YpZ*o94lXV_bic`ieDwv-_|jP36-?5r+zS3j^GwW{NaRSN1VzszEHC%5x*1oi*fcl;mMl{p*IV#Z&BW&+TdN z=?n9sfI{v4beg$R_A-w|2q4V_>T6OoQV=SQPxz(UWI*NZ+xaBjcGDnK${! zWtpDK?t6~y3_Ei?AXtDBSXoh`KY*kL_5csxaaGLS3Moqr#j|529BPbN!)l-~7x zJG9rYKT9YGu-QS`f<0Dqc9|K?qRjULN0L#q^U*%v(=b<#yJ5)cg@V3;*?Q+)k6CI6 z{g4Z$4ErkB54}lKNV1Gndys#yq%Y|J(#Ma&sccw19fkaRU2o69qJiCut zyyB>3%N*vpm_q3&0S9K_=svVCIn>fx7lJ@m+~rstcIF1+l}8U_?}OTxOg+FcGr9W&2K* zyNp{k3!792dRT_@O>=$mOp9hSStNqRAsr{E_V2p_-h#x9pGkz72YiQNE&7QcT zP8LMm(lWGPVEr~iXJKG9p4Ia<1l)KKWtB=}u6F?!3BF4#Y15UE&_jL!sQ~-m-dpig zdmzx85i!hq1r>0)657Hg3!a$9OE4IGm*-Tb2;=5HzEi$xH%pC!GAna=3HwIf4>fkT zzJAvkZy}OL?kB6x;UL}I&J~AnO+LO@D?eMWm8N5sD(x)0i3&hP{nMyUdDfo+Eu-Vn zhG1qMw~pz&0CYRhtVUEu@ zD5S(2Ljum>oSA-|Z`oy|L(kE0V>vuhkJ1H?q`KAixkU-n*{J&G0v@_&OSz(Qbj+MR zb6rSO-sL*=*N7f19d-X`B)wCyP%yT3jI{v1$Z9Rut(KUVp>sHZ6b;@7JXRjLHz4_p zW8{Zx0&nsNF$Yv6hkR@fd-C8ot#f``Xk&NK`h}yCfk$1ex~e6mA_vbW1WU}x9r8pv zHg0zF=(vnZGxPnkw#9%C>JzqV!UE^bCyxe(o}g_EHP?M7t6V%nN6S*1o%0jys_4eG zy56i?u4=~bEL?12o&H0Ao$5PUh7(WwU(-u zwN{{3wpOdwqSmeH)jHN_%>LMG-sF;q@&0%XEjWIXHTL3$6?Ctoqp!Z+-ISL*t9bJ*O+&J|`&M4Ywc;zRn=;GE5LxZK5 z*zJP}Y)NJTHnZDosxcHnFN-$--M{1nbIDI%jpG_q-%Sf7;lCoJlTq@jBs+<(yE94c z73DBR{O#fwlTF?cyH?uCUv(PA9%Bv4fiHGAv2WsbgO8GiJT$af&g92Glp-u@-q|^8 zXmm&(t$n$k@>~o$JyRDkk2gM;6ipPQTX*bq{#m_O?5!DW-9ZsE6h?Yn$Y85C6j^@?F~&B$tFmfqfy}Js51_v85tyN_?E} zb+$aeJ-Pw2G>8~-S-qU{aD@(%d28h&>0)MO;9?_B#M@5^@CQMT&o6nrn{3hF)H^vY z+&v^eBRDRNoPvv_M)0Qt19wTqLDCZEj?jk7?|?hxUBpBg6~iMsubAtOHpLd#b1w84 z6*@4QfH(VQ-7d6{jPatX&A*kOV6bW@V$!D=RZCK1cCm7?$7+7q9Hm`rwD8kWM<{PX zGmLk*K7FiZK+Os=R`L37OMaZxMa0W#`0T2|lCM-8zg2?QaG71_?3kz?HveSBZ%HgT z=$)g1P{G77U9UcMoPYE!_^Ayh1HoxcLM(azl5kFJ6&8H3vdz|dXTz>Tm#20lx4Q0a z^%}2Z$Fz939+s0uSUiqAjy8@tjy;Z7zJLhjQ|6)$i!Kc$U?Kx#wOwQ*VB2CVXoE@; zQ8iD-J9B6@T~`d&B3s5Dm~XcEZC5PVyEZo|ImtT7ImtUcveLV)aSF$?MP&YK3}e}P zsBG>r;GxK7_WhdDvw0xmbXh6#O5dM+ATYFhWuT%ql&WI`7Yudt6_Ul9t#;( z-O;k!{N0ba6)-H7ZrPZK^g81qtM_BOb&?sBvB%xUJOw2G8>9HoCOv#;yyUj(hJh`Y zF2igc!|x_7)@jx*ZqULGCw9NKaQKb4eW2*#?t*m*b=6i3lb|KXPqN24Xr%EzYtl0? zIAlAaP2AFw)SdaZof~aW`w<^^H1hXXx9)I+StqDCR^3q~%BIwsydswNxQAnYOgY2k z{Uq;>EQx@L%nk0e0Aikot1TDf->V)}i*jH^?1hv#4g42V7E>2Z?QYQGUiLQnJ8i%U z>R;cRS{S;PoAx3Ni!B0sHj=zqFd3J%DtU{ia%A`C;)+MP zD@;>pEcGNbyma1KLj9|y1N6Zn{c6uO$D?$qG)+5)*Yu*4PMf(4FA1af4XfTRGvQkdkPC8D)R}KFwZMj9OKD%(Q6I`&;INouuth1W>a61{ zsu&Wc;tvC=hEw7b-$uVT&a{PBC1q8t%J8ojBrSWdw!QWzG4N0-&TOj3Hn-f{7XK#F zes|%kUXCQ4YEYAuK2DdtIi4qPvYx*%nt_+ z{i+6iwHS5IUh|8=skE;K?Yt)zg1^=J3s_U2Gx6QB^2=A_P;T zru5QQu0{TXp|Y9=vH4UVl->kb0-XozfHbEav!>&9%*y8IaUz1RDJv+@hwuPqpCX*r zO6DfL^wjf-kKOeLz}0qltWjx(hdt7YEN(Ygcr&3~3D0}k^>Uq%-3}GZUkcuZhb-Rb zDB6Wo&vPS^yL|^dVncj4ugI$C47`IoYn9&={Y&m6+NE7H4v?JJabj?2sH1LW9-UTx z%R+V}>MrWRQ;Ct*4j)Lh?l-uMvy+|8M~%w+LuH=Uu&h5AzI!+Embg!WiASg&WTC?L zre116-G_&QQsuDZ(&(%f4LBVfjhrN%o;sy6{}Gr$Wn`6QHRInRLQ9W(qI5gy(s2mU zd6-3QRvY;`ztf9S30HrmjV^%nv!Bg0juR5O+f7d8)u*UQfmk9tGy2z(KA zW&E+;dAsXQSK`B|lpFv&=a#%Rl}hq5QBCtVi6yhTC(GJ*Zdm)330Q>}-#i=X<6~A! zCe!TZbo%t{Z_MMJ`zB>)V#~k>vdNz_+#bJe!D*#zW%qn#Tnx>CWr`h&+9Op)cp7m3No$r`I0O-g?%=cy~&1}=GmQ*j5nEy}6a8wmpw z19Tx$950pBY$xXrmn8-_)l~hX(eqYDMoc?hOe+W`1*7O&TEezCzNyeEf58nKzv#-i-BWjW%Ofo zzW*A-OWTsX98_C~7_sC-*Rnw!R>ae8bU`Qyt#_h`hjY7zZW*?d%_*6a|6}|?Z{XMi zFEGLCcY*=i9HG2{uxME30AMW|;2i<`b2>Dr&@vYdSnPHNPSD;pBo|}-2{E#&jOsxB z(D`LB1Dmd9OxMxus=yDWguhnX_EY;38=n}LGDbR7iHGv26(cdLUpRgQ-U>6Z*~a7$JH9lwwN3Q@0=_iX%_G$^2p)iCRO#GL?^-C;LLPl zr4rcroy_4k*IAZ!5x76=H)n7WE7-h6AcZDHe@#i29`RvQgY}oyq4;_2_#ppP%LMbp z6wkT}1_H8q)Bf05Uk2EX3O#I9(jfpBQpeu2)Fvpl9Na1Sl(8%tD?KBP_&e-0NJ)Ad zqgRm$m(kI=Xd7NEe& zUvw{#l%7;&mZ97q&-u@kpV7NFG(^>3bj*h&O45-lm$vp4E5QF<=_#lr(BrE6U@4+p zz(c}tAl7uEE4;VtEKh7>Wx#JCm$;F;!Ih-sxR)?Xb8Ju>ZQkrp!1QFS9O3n@9mG&X zMO_M-#kwQ7CABj;wdcUz4H#(TewMA7Z9Y0Af_^bBJU%{(n}V~|RPxnfDA>atW1Q3W zq&gKj!VJF*6xz06N5SCAgP14A%+zn@xg1iu_ODsNR9ukUGd7 z>0!(2XKXu+Mnz|XxO&LmAYu@sl89u)0}C9WW(rGkv5k-M8rtJJF6S)x`(0EnzUm}i z3~Z7_!C6}P0DSyJ9S^*%%O1eKAZ+C9Xo2dt&u!0NoOxDhrS~1lyD*fWoqNp&v8#n* zR5;*WCE2Cb?vI}kOU~cK5gzqL2kt4dwcl-)+Z51uJCnsU)nopWD0au(n+?jDeziJS zl_TaX2a9MxT0-|08oPhgG2)hhhjXFaB3+l*s&%g{3j|fmC~Ng1|&Q!n2T7F zsatPDrS{F{m|=u|`1JjJxl`7nG1^O(^Eut?=dhpUV_9)l>ed=*!R(;0O~g*1Ti&tE z+n!|Mq?mi>df1{}#ie%_#(-`;o3amL7FFe_sS!t+kNJ+=%K%1HvDeFj$=}<_G`yuE z{TsDT1zL_*p!++zpdw#-UZw$ybMJ@nXZLya^1kjA>S=mMj_jh2=EH+*j;$-2zy;(q zYTJ0LPStiTKLQoYU#ba<(*_m(xZ1BWSJO_W+hv)>Gu|s&C@_rz_w`B!Bf0F6GB9H` z*g)CTB`AhAbm@%~Ka;FmKEVj3;C>m~Im`f_W>s8@s!Hc9oIMY2n>`>w!xKoW6g=d2x>-9I~=3_&8XarzgW^&{`(sIci)z9I^ zL6$bhIoA6nM_h&%4;`du;Jmh&JOm7Yi>>lElndIdru*rIjif8}A(4sp!i?V2;4Dw9 zX9*+xaM||)Tmy`gtRJiTOLXGumywP&!&^00M z=z{dmiyVFo$vXm0E55Tqy;=Vn6u?_EIk6GDdE`ZwYlOx@C99sxMq)O{)p9p(Jwhvv zd*tj+e)n8w$RH30x<(r7O8!c<&1ROs z4mTYK8i&5fN8^}tPRe%v;+Ld>oadOB8V$}v!wp=vho|pq$_d=WO$9J43|B_2?%MLY zBN7NQLa6W3Fjfd&2gHk%J2mN7SCbS<@4gEbmLM_)0^o4RRI7Q=J%RShhjs*C0c~Ad zH_d;RnM#XH7OJ?8e(~=)1DeR&BK~vunjK%YRu3Ogg9I}}dr$w@N+fH1&2u!Q#9ceC z3uK!aqTP+JLDBzjqnqu27N7fd2ZW}L9+Y)v&t$!TmNVi;ie-Sg59*>VXXWuogFq0y z^hG=4ROzC18^>YM|60D73zYROs_l`EHsEkcFucOVoHdlaXpqhF?@<{m;*k4;(^^h?+6Pa zQzd-vM4jm0qjI=BkOG<_hrHAeb7Myf8mFsj0s^n%$WfsA&}G;`$;-2}+8=KiXbnZu z|Ew6=lJACv?w&-RO-A)Rx`ZzGHXvgKm}037Q>jX6e_1#G7RMuE$(I?CBNAjsTSHBF zarl$wS`?&SP+{8teby36163HNQMA%47H#_3mL1{V3`1(a%v0+s%5y_Smbri}&z*up z_zw?8w%gXBj91bJNEo;xb%P)jM2BWUKP+(Bknh;lgvZ~x3SMBXyJ@Y4qqtuddbe@8 ztS#15qsinB31pS2ThbQzp*|k*Ln&0l)?5@!#>I8|#WV?CQRxqdxLDPwmI{b45fP5~ zJnhnTK*|K}n-2s`ae+9AmI7T)9X`pAi;63U9>?f5I{n1Ke*u#zm4UGa>1NPm>$csM z1n$bvYm~-rr{NGz z=q;7$Jy^!NIC+Zcr8t<&&qU;%et+h9wg@0f2s5%EizSO+fBmkQuWA>@wx${|K%QmnpL~4DlI)xp_g6e7?KDRKihT|u%95o_xm9U2nYzQn)@q?8 z*?GWuVgoK^*rv_T7yZ%U97LL}!DO2W(hB;MYHd~)Tvd@#FU6G|Hms96zH4}Em9tgkF&mdb{cTA zs(X?l&B7qlTFQ-4aFDNjLJa4z+ujpLoFJ&{d6#@wSIW)h6)yBc^doexe;o;0X1q!` z*eli&`m+bHLoV6=U{bTTP2bA2`t{8C1;DUbd(Fx8kwUL~^#}d~7`9Au3jYl{xB%#I z*(lS{Y&&TW=AW)x)Z_6<9R(nU?2051`v|aGM8}Tph^h;+d=ka+PN_^6`ce5qh%)jx ze=Ln=et6+2S|&aYrK^|(V8^Ov$2n?tNwpU6eXgGxfU4>X+YPZEdP@VrCNUBHKjWXySK9WZ-J0~1B9l;1iSt#fg+0{y>Hna3yG5=E1?r6y?FvA0BGo{Gu{eCGJA z3?iy`S|`u=;pDcMYy`KE3?69}(2o|q7-5_&=Pwx+G@pNYh^eBbR1ALpd&NWWi!!ku zps;FlD%>aJ>Nq4Hi=)~9jgm(KH{5*%!`0d0yl{~}AL9|JN*o%*ESr{)vCF-bJ=dGA z)Z-t_mAgsKBqe^ zh-@(rS)qO?Jyf}}NNFOnc))8CF_HD%ZX1!L#Q^Xu!3(b-``iM@ITUSHh7yeh%VL;w zGM94xAH)|oM_HkCp#Ebk|7Ifa!=s?qNMISUkwcJ0nakQPS7_;$G%4eTKHLswf9nGyyhJeN1{}#6 z#*~^=vARnUecw{)+`@MO{nNGed-%&H_xHm77^^kD02+nRY#<}cEmWSxoyH6tCJ5zL z(k;*~z=>=TSqQFUWx$+e9&(m%7%C9S$X=-TZ~e~bKYpK%C-KMk#p}W4!fadR8_uMsO%8k{b%+G9dP80Xy1w1RNB|Mjz76XhZ6q?|L8X>o>oAe8ZsVyJ#vCzcl$4tJV zYE(bG#JI&KZ#DuDDYobEbu%C=NY{{^ANc?@^zx$ zpTBgAu?Ljq`TNW!hxPs28;MI*=6SNU`EMw@n`0lCTTR|~yNlkp@e!``9m98XFkn*u zG?CfZg?ab?D`C|uc=|uWQX%y^bCT1QggEOsCwX^l!20n;>c1#V*MbDg@)t9gnk+Cq z%vh1vH>=lW)hW5I;%zR^kKj^F{m}^tm1#oh;Bbj7Nh`TzYS)vp`@6@U;gJ%{R!3>O z6|OEuFeJZC_-j|5uksa>pz=w6MCQs-ZK#XE>I{3=?XKAL;VWqI@v7{&j>}rtR{Q_U zQhRD19dR>HoTE=Z>bRLZcD?bv5Ol#Lzu<0e~4dz9Qj(cY7DqO~1 z%z(1mMT&7mFq~R^_w`=aOQNrO&_&i$KdC6-NcI1O1>TG>-3+FA_&?R38`RrM z)$h%OHuf&r6YoqeGK~*LF2z5Gkx#V5O;x0S^df%KWWC#dYvH4#Ol2CK8t1mvlo86K zY+tSPGT?E4QBI)`CS^=LrP=g{TW5o{q9Z&Q{g-!5O>WWPfM&cS+)MN3L#nT3O`Pp; zj19*0TlmXNoYps|!##z{Ywv7{f?hYqy?>kV_%KV&wj)mL$BcDL_iCIys|8(pV)Ls3 zvld?KTt0Ieklg@m8Q*#-O zL+ZEEp&ZKgRZAl;qfTSCm?d4-qwPe6;Je61wb0_ca1f6K)96QZ?BjeYKX#V^N>oO!?_d!-4S1yHvfgv$J?lP2T{FL?GD9Z^^pzdWus>pHSJC z4NZU6}3rrN3*3L7T5 zUSYbnBQ|xGsrv$WYQ|>sKc6mk6(@>r;Y!Nv?xgUcU!v6jU?mj^EYt77dkdNJagktE ziT`P0e!=pU6_EbX;WS1eVxnnU`|3f%2W!7!Lv0tGi5!ijk~Y}^qeBvC<;!rMwXndX z!qu9Tsvtq!AbK<>7x4BY1VIpZ44nJ1K+mYH^7c~X!;BnJ{oly?L5(Pr+}=a@EFlvo zytrmil;P|d2$`9Em{KMXEvroetr4RibHP+#m#y7z2HJommiI}SU|iCPh{$N&Ox=H? z{bq&>?kW+pt^8qE2}pfV8it5O!~=(P9JvIbqS)ntAo26Vr~fDw?)L-nUJ^@7U;cx} zTau>PG9K9us4th%aJ%ahNa>>vKby20S5!KPRWT7u<|3@&<=(XMI zseBdxGqs`0`M(tzzcJgtCLZE3$qNWNtpNf>=C}=y)?JMWIkJ1wD1nv&Z~C+95Esg{ zA8I`!;r&Mk!ZfEZTGKtyINAqSpuC&Be+-1ASYVNZ;cNHg!QY!NZ>EaWapA;)n#e<$K=GQZ5D9 zNl48fC>GA62WfSov;4kLRpCa zDxL)=&W9`cu~mB$3Xpuy8OyhT|7w^V>UXbi2ko@e5p@m&*U^ITJ0V*2xZR@{8@KU{qcU%s8Y)G(W$9>8{uSnN7#9F4D=GIjY#Ac7EI5M+RXkd#~0q&(sAFjIbh zm-`A&&|;0z{J7~1lGs8Z@ z`=Uw+GzxW@)vvxq+_-E~ntpdBLy|@0SK!;EFoyv~VkmVpRz%M`ehIx-R;Bu!5VTRANI8>GH=JP>8!GJvL0uXrsA9{=(fR z1hErC#^z1AYSY(oqVbTOCu2tNXaD?&P;Y=65#b6D;I`HORe_8A^P`=XLSn%Oe~!13 zj^!u82m%MtsnmqSoTw2Ie&7|HzL}-F5~;dR^)GeIf#sY#r7z#uJo#+sOTRg;1bbTn zUf_2Cl($M>E&shI`ka(7dNIL>_Gzi^Gy7C$ zvykf1K&C;(r)TGTTL2vLW~P1~!NuvP8_?g~*Q@anB}gEE?wDQb#mP^#j&R-%t$Y6R5RBnCvIq^Y4{pjdb&$8GE4_)R{Pc#dx+@D2s%bAUA>M z_sZ`=WQ`MZ?nXdxe~~Pu`}UY(2Z$onB~0#t$Gr)?6NK~Q0t{<-G|-K2)8S+rD^Wu_ zjnr=}Iq_jV?&$zCzp@33w>wn1?oW{kvHbmN^n&xIHVz54+l@Sr;d2gwDF<~WNDn`^n2R`!na;v^JOMQkna1fA02 z7qb>06%r6b-fp?p@~@gOT`eM&=E2g_>S=T&2&|KsJsjDD)`Fcs#_RG7C2Bak0EAmb+oxW(lBY?i6fD{KPp<%tanaTAP z88Pn6n%|0FcM^9!oh1fJ%MT2!o_xPfajV37D`Vp0Nk7G#Mdm#1x!NVfus|e1gXAK_ zuL1ERO836gE^-5NS@P&Bq)P2w)A-&xihRRC0^VC6Xpj`tmyXtVs)=ZZnm)0T2f%FSKL7oO*M4O|dc-3O$ZB5EeP#(9 z{BOz>o=@W&zXL6&iWiH0F@Y|I&yB)2G&XoC<o&HXhTCul!{%ntG zwqgmlze1*9lEZ!Tnq9<8rE0{B{K)NGm`V~wd63N<#ivkV*IPCW!EK}e>2T`FaHkO1fJ zz91ccx?h@fPzEv`DdtT#CjWYFe)Ko~9`lgjf%@4(oMTvJAT|3cNbc*{zVq~HLSBpp z0_1hleR@8>6Mvm?y!_9akw8nX&XZA}>juh0pBqCy{gZDTOq-u|kgIy0X z%f;pSd;q3bc+Kp)oMZ%KZ@BCQj0Ky6aEH2Hlm*cBR&B?`h_q9L(*N~6&!=6JzRN4)=uFn*LTK4E@mzH&F;H#7IF)++Kzyp?`Of)PYB;TQ?H%xb&&o$|Vs{rn~Kty#t-z3`rezrFk6 z&VF4v!Hg>Il|3b|rTsyH^+3M|-%n@e9C972MS!U@)Zpu*)6T6xayLb7rhbjjq8e<>@;=Z}Ky12L%s5Mk1;;b3$9_}w@= z=BpmEv`5|gRD%}sNJ8RZdxefYgF1mUCdR2ekEdR=^urY_&&Ocnf9&~ACEn5E@zl^s z9SszAg#eci=t6Zg1=`kZvYjLr3<3P80qIh+RG$CUno1Tju_7f)Z>HlM-NeD`cY3JW z5E8uMvT>P_5Aw<;Icyz-tF-@}i~Mx+`DSo~8?xlM8$|Fp@t|81K2Ja@KS167FyTIB zqBW4jKWSpiHBmiOYll6F&8y>DJ&8RTJ)u7_I7vL2^d$D=@Ko}&od_jqoWoq?z@QvL zI#MH`Vx5pZUu8dFdoHw*xC`=RCmI|;peUv?@&o6&{kgz-EH(_Aj}67<`4SZ38{0-I z;l0V5GkJRzl6>rwpxbeE0%~Q(CxwvOp7SrP&(Ji;N4SLGnTCfIG*A2%{Fz(hP`Y0W zzV0F7558=mId*Od)pd0tA*gY6vEKf)4{Cjl3qE4;m~NwIY-$>OL;?Aqk0>}MU4Bo& zYU#8(pClc8a^cl~KDn@i`}@k}hZv|^qIFFN=?foKS9uNw!lCruu<4g4&_L(h&o^fjDV9m zt~qcJK$;nfGpt=Hr^7Khw+iWzr4Kn@{YVCd7pi@0qd=Q;Rnui3h=)Z zm!Kj>EuDZ6R5ZWh@nIYCZfMl5=o3X9m=OPN}z{lk`@ro0lLYxyB zP?WOq?bWOnMOYt+b|I7r{^%-%)jM9#$#Rp!{c0m!$`NEC_`GIPTLu@Q34 zuWMVpQjAaYi3BjeinWo-gj6Exl5Stq5E`?TRDSo|35*VK*yaAXev$+FL9l?>X4Wu= zw~X;kzNG+^;k~;POWuwUppCTv-I1jqBELe$MVst(`Ee_lEibr*``V7eu!qLQJ__LT zt!oDSv(tJ>6!M=Q+WYW|j;-hJma$HCmeHAr#Qg_8ooy9x^)aS_5>L6O+h;3xKlQ{qIYG z*p4*wa~(!_6#{yp7@tJeR`dN0=p(%g^8J=DK(dag`vdE2rUcS+WWDG?n>I}x3k^n;4UyT>FArGeE zN%|d@0|{6G)p#EIQF`^1W-T-f`22zfT>5@zntKaLc`WB>*bNJio6;qU9)!wFOBn6d z9RSxagtvN@oj+VX4OuT1&83zYP;?Qw*Kv ze>dP&2aK28IWVJgwi9Fl|Hl%;wDPR+qPsuLNSu!#AV=AGLC|XD&_heOOxC6TZS%p0 z6sVn-t4xWTJ4lIPBX|c9J;>SNBBDo5Gcu_Bn1Pj~oIZ6iiI~iDjTn70vU7^;t#iU$ zemjq5+055(LVVVTf*j#(v@voax4z5@s)*S)Vr83bZf7#|)KXMSLr2 z!jeA2IT2fwbFW}14kLo{Z>w&xMP0tvWyKiNb6~;avR@#LSD)FpV!l~MXBNrpRQcLw z$;C{Se?f(;5M$a>Wn%8_z+UHT&WQ{=Mmw*-n5$jCGH0uCVZ;ap(Dbui3%(hu^6&pr t714fQGBG9gGJZVPT`q2ITnrg}OBdt@G*sm!2+@dy(359^+dhgd{1^Nf>remy literal 0 HcmV?d00001 diff --git a/wai/wai-quantum-desktop/src/main.rs b/wai/wai-quantum-desktop/src/main.rs new file mode 100644 index 000000000..d5f0312e7 --- /dev/null +++ b/wai/wai-quantum-desktop/src/main.rs @@ -0,0 +1,267 @@ +//! Quantum Studio on the desktop: the Studio's interface (wai/quantum-studio) +//! around the quantum job service's engine, built natively. +//! +//! - A job runs in a process of its own (this binary run as `worker`), so a +//! large one cannot take the app's memory with it, and Cancel ends it. The +//! worker plans and runs; the app seals the answer with this user's key +//! (the command line's, `~/.config/wai/quantum-key`), which never leaves it. +//! - Lint, draw, plan, the methods and a receipt's check answer at once, here. +//! - A job and a program are saved under `~/Documents/Quantum Studio/`. +//! - The service is one switch away in the interface, as in the browser. +//! +//! ```text +//! wai-quantum-desktop the app +//! wai-quantum-desktop worker one job: {"request", "job_bytes"} on stdin, {"plan", "result"} out +//! wai-quantum-desktop check a Bell pair through the worker, sealed and checked; no window +//! ``` +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use serde_json::{Value, json}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use wai_quantum::ed25519_dalek::SigningKey; +use wai_quantum::engine::{catalog, draw, job}; + +/// What a result says of its energy here. +const UNMETERED: &str = "the desktop app does not meter a run's energy"; + +/// The memory a job may take: `$WAI_QUANTUM_MEMORY_GB`, or 16 GiB. +fn job_bytes() -> u64 { + std::env::var("WAI_QUANTUM_MEMORY_GB").ok().and_then(|v| v.parse::().ok()).unwrap_or(16).max(1) << 30 +} + +fn error_json(e: impl std::fmt::Display) -> String { + json!({ "error": e.to_string() }).to_string() +} + +/// The app's state: the key it seals with, the memory a job may take, and +/// the job running now. +struct Studio { + key: SigningKey, + job_bytes: u64, + running: Arc>>, + cancelled: Arc>, +} + +/// One job in its own process: plan and run, no key. +fn worker() { + let mut input = String::new(); + let out = match std::io::stdin().read_to_string(&mut input).map_err(|e| e.to_string()).and_then(|_| serde_json::from_str::(&input).map_err(|e| e.to_string())) { + Ok(v) => match job::plan_and_run(&v["request"], v["job_bytes"].as_u64().unwrap_or(1 << 30)) { + Ok((plan, result)) => json!({ "plan": plan, "result": result }).to_string(), + Err(e) => error_json(e), + }, + Err(e) => error_json(format!("the job did not arrive: {e}")), + }; + println!("{out}"); +} + +/// Run `request` in a worker process (`exe worker`), keeping its handle in +/// `running` so it can be ended, and seal the answer with `key`. +fn run_in_worker(exe: &Path, request: &Value, job_bytes: u64, key: &SigningKey, running: &Mutex>, cancelled: &Mutex) -> Result { + *cancelled.lock().unwrap() = false; + let mut child = Command::new(exe).arg("worker").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(|e| format!("the job's process did not start: {e}"))?; + let input = json!({ "request": request, "job_bytes": job_bytes }).to_string(); + child.stdin.take().expect("piped").write_all(input.as_bytes()).map_err(|e| format!("the job did not reach its process: {e}"))?; + let mut stdout = child.stdout.take().expect("piped"); + *running.lock().unwrap() = Some(child); + let mut out = String::new(); + let read = stdout.read_to_string(&mut out); + let status = running.lock().unwrap().take().map(|mut c| c.wait()); + if *cancelled.lock().unwrap() { + return Err("cancelled".into()); + } + read.map_err(|e| format!("the job's answer did not arrive: {e}"))?; + let v: Value = serde_json::from_str(out.trim()).map_err(|_| match status { + Some(Ok(s)) => format!("the job's process ended without an answer ({s}); a job too large for this computer's memory ends this way"), + _ => "the job's process ended without an answer".to_string(), + })?; + if let Some(e) = v.get("error").and_then(Value::as_str) { + return Err(e.to_string()); + } + let plan: catalog::Plan = serde_json::from_value(v["plan"].clone()).map_err(|e| format!("the job's plan did not read: {e}"))?; + Ok(job::seal(request, &plan, v["result"].clone(), key, json!({ "unmetered": UNMETERED }))) +} + +fn read_request(request: &str) -> Result { + serde_json::from_str(request).map_err(|e| format!("the job is not JSON: {e}")) +} + +#[tauri::command] +async fn run_job(studio: tauri::State<'_, Studio>, request: String) -> Result { + let req = match read_request(&request) { + Ok(r) => r, + Err(e) => return Ok(error_json(e)), + }; + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let (key, bytes, running, cancelled) = (studio.key.clone(), studio.job_bytes, studio.running.clone(), studio.cancelled.clone()); + // The wait for the worker goes to a thread of its own, off the interface's. + let out = tauri::async_runtime::spawn_blocking(move || run_in_worker(&exe, &req, bytes, &key, &running, &cancelled)).await.map_err(|e| e.to_string())?; + Ok(match out { + Ok(body) => body.to_string(), + Err(e) => error_json(e), + }) +} + +#[tauri::command] +fn cancel(studio: tauri::State<'_, Studio>) -> String { + *studio.cancelled.lock().unwrap() = true; + if let Some(c) = studio.running.lock().unwrap().as_mut() { + let _ = c.kill(); + } + json!({ "ok": true }).to_string() +} + +#[tauri::command] +fn plan_job(studio: tauri::State<'_, Studio>, request: String) -> String { + match read_request(&request).and_then(|r| catalog::plan(&r, studio.job_bytes)) { + Ok(p) => serde_json::to_value(p).map(|v| v.to_string()).unwrap_or_else(error_json), + Err(e) => error_json(e), + } +} + +#[tauri::command] +fn methods(studio: tauri::State<'_, Studio>) -> String { + json!({ "methods": catalog::describe(studio.job_bytes) }).to_string() +} + +#[tauri::command] +fn lint(qasm: String) -> String { + job::lint(&qasm).to_string() +} + +#[tauri::command] +fn draw(qasm: String, width: u32) -> String { + match wai_quantum::qasm::parse(&qasm) { + Ok(p) => json!({ "text": draw::draw(&p, width.max(20) as usize) }).to_string(), + Err(e) => error_json(e), + } +} + +#[tauri::command] +fn verify(job: String) -> String { + match serde_json::from_str::(&job).map_err(|e| format!("not JSON: {e}")).and_then(|v| job::verify(&v)) { + Ok((valid, key)) => json!({ "valid": valid, "signer": key.as_str().and_then(job::did_of), "key": key }).to_string(), + Err(e) => error_json(e), + } +} + +#[tauri::command] +fn signer(studio: tauri::State<'_, Studio>) -> String { + json!({ "did": wai_quantum::quantum_receipt::did_key(&studio.key.verifying_key()) }).to_string() +} + +/// Where the Studio keeps what it saves: `~/Documents/Quantum Studio/`. +fn save_dir() -> Result { + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).ok_or("no home folder to save in")?; + let dir = Path::new(&home).join("Documents").join("Quantum Studio"); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + Ok(dir) +} + +/// Write `text` as `name` in `dir`, beside anything there: `name`, then +/// `stem-2.ext`, `stem-3.ext`, …. +fn save_as(dir: &Path, name: &str, text: &str) -> Result { + let name = Path::new(name).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("untitled"); + let (stem, ext) = name.rsplit_once('.').map(|(s, e)| (s.to_string(), format!(".{e}"))).unwrap_or((name.to_string(), String::new())); + let mut path = dir.join(name); + let mut n = 2; + while path.exists() { + path = dir.join(format!("{stem}-{n}{ext}")); + n += 1; + } + std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(path) +} + +#[tauri::command] +fn save_job(name: String, job: String) -> String { + match save_dir().and_then(|d| save_as(&d, &name, &job)) { + Ok(p) => json!({ "path": p.display().to_string() }).to_string(), + Err(e) => error_json(e), + } +} + +#[tauri::command] +fn save_program(name: String, text: String) -> String { + match save_dir().and_then(|d| save_as(&d, &name, &text)) { + Ok(p) => json!({ "path": p.display().to_string() }).to_string(), + Err(e) => error_json(e), + } +} + +/// A Bell pair through the worker, sealed and checked, with no window: what +/// a build is held to where there is no screen. +fn check() -> Result { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let key = SigningKey::from_bytes(&[11; 32]); + let req = json!({ "qasm": "OPENQASM 3.0; qubit[2] q; bit[2] c; h q[0]; cx q[0], q[1]; c = measure q;", "shots": 1000, "observables": ["Z0 Z1"] }); + let body = run_in_worker(&exe, &req, 1 << 30, &key, &Mutex::new(None), &Mutex::new(false))?; + let (ok, _) = job::verify(&body)?; + let counts = body["result"]["counts"].as_object().ok_or("no counts")?; + if !ok || body["method"] != "stabilizer" || body["result"]["expectations"][0]["value"] != 1.0 || !counts.keys().all(|k| k == "00" || k == "11") { + return Err(format!("the Bell pair came back wrong: {body}")); + } + Ok(format!("check: a Bell pair ran in the worker on the stabilizer tableau, only 00 and 11, ⟨Z0 Z1⟩ = 1, and its receipt verifies ({} ms)", body["result"]["elapsed_ms"])) +} + +fn main() { + match std::env::args().nth(1).as_deref() { + Some("worker") => return worker(), + Some("check") => { + match check() { + Ok(s) => println!("{s}"), + Err(e) => { + eprintln!("check failed: {e}"); + std::process::exit(1); + } + } + return; + } + _ => {} + } + let key = match wai_quantum_cli::keys::load(None, false) { + Ok(k) => k.key, + Err(e) => { + eprintln!("wai-quantum-desktop: no signing key: {e}"); + std::process::exit(1); + } + }; + tauri::Builder::default() + .manage(Studio { key, job_bytes: job_bytes(), running: Arc::new(Mutex::new(None)), cancelled: Arc::new(Mutex::new(false)) }) + .invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, draw, verify, signer, save_job, save_program]) + .run(tauri::generate_context!()) + .expect("the window opens"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saving_never_overwrites() { + let dir = std::env::temp_dir().join(format!("wai-quantum-desktop-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let a = save_as(&dir, "job.json", "1").unwrap(); + let b = save_as(&dir, "job.json", "2").unwrap(); + let c = save_as(&dir, "../escape.json", "3").unwrap(); + assert_eq!(a.file_name().unwrap(), "job.json"); + assert_eq!(b.file_name().unwrap(), "job-2.json"); + assert_eq!(c.parent().unwrap(), dir, "a name cannot leave the folder"); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "1"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn lint_draw_and_verify_answer_as_json() { + let l: Value = serde_json::from_str(&lint("qubit[1] q;\nfoo q[0];".into())).unwrap(); + assert_eq!(l["line"], 2); + let d: Value = serde_json::from_str(&draw("qubit[2] q; h q[0]; cx q[0], q[1];".into(), 80)).unwrap(); + assert!(d["text"].as_str().unwrap().contains('⊕')); + let v: Value = serde_json::from_str(&verify("{}".into())).unwrap(); + assert!(v["error"].is_string()); + } +} diff --git a/wai/wai-quantum-desktop/tauri.conf.json b/wai/wai-quantum-desktop/tauri.conf.json new file mode 100644 index 000000000..e29577b81 --- /dev/null +++ b/wai/wai-quantum-desktop/tauri.conf.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Quantum Studio", + "version": "0.1.0", + "identifier": "science.transaction.quantum-studio", + "build": { + "frontendDist": "../quantum-studio/dist" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "label": "main", + "title": "Quantum Studio", + "width": 1440, + "height": 960, + "minWidth": 720, + "minHeight": 560 + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://wai.transaction.science; worker-src 'self' blob:" + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.png"], + "category": "DeveloperTool", + "shortDescription": "OpenQASM programs run on this computer or the quantum job service, every result named and signed", + "copyright": "Transaction Science" + } +} diff --git a/wai/wai-quantum/src/engine/job.rs b/wai/wai-quantum/src/engine/job.rs index 82b7b5ced..f26191bd6 100644 --- a/wai/wai-quantum/src/engine/job.rs +++ b/wai/wai-quantum/src/engine/job.rs @@ -12,22 +12,36 @@ use serde_json::{Value, json}; /// `energy` (the host's figure, or why it has none) inside the result, and /// the result sealed with `key`. pub fn run(request: &Value, job_bytes: u64, key: &SigningKey, energy: Value) -> Result { + let (plan, result) = plan_and_run(request, job_bytes)?; + Ok(seal(request, &plan, result, key, energy)) +} + +/// `request` planned within `job_bytes` and run: the plan and the result, +/// not yet sealed. A host that runs a job in a process of its own, away from +/// its key, gives these back to be sealed ([`seal`]). +pub fn plan_and_run(request: &Value, job_bytes: u64) -> Result<(catalog::Plan, Value), String> { if let Some(at) = receipt::inexact_number(request) { 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(request, job_bytes)?; - let mut result = super::run::run(request, &plan)?; + let result = super::run::run(request, &plan)?; + Ok((plan, result)) +} + +/// The job body for `result` of `request` under `plan`, in the service's +/// form: `energy` inside the result, and the result sealed with `key`. +pub fn seal(request: &Value, plan: &catalog::Plan, mut result: Value, key: &SigningKey, energy: Value) -> Value { result["energy"] = energy; let seal = receipt::seal(key, request, &result); result["receipt"] = seal; - Ok(json!({ + json!({ "status": "done", "method": plan.method, "why": plan.why, "estimate": { "memory_bytes": plan.memory_bytes, "work": plan.work }, "request": request, "result": result, - })) + }) } /// Whether a job body's result carries a receipt that seals that result and -- 2.54.0 (Apple Git-157) From 0300d45b7fadf1fe81f49a116b1f800abf0f4ee7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 13:38:39 -0400 Subject: [PATCH 11/19] Quantum Studio: an assistant on a local model, with the engine as its tools and every program checked before it is offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Assistant pane talks to a model on this machine: any server with the chat-completions interface and tool calls (the JouleClaw runtime, a llama.cpp server). Its address goes under "Model…", and nothing leaves the machine unless that address points elsewhere. In the desktop app the app itself makes the call (`chat` and `models` commands, http and https only). - The model's tools are the Studio's engine: read the program, lint, draw, plan and run. Its first turn must call one, so every answer starts from the engine. - Every program it proposes is read by the engine here. One that does not parse goes back once with the engine's reason. One that parses is run with the settings the model ran it with, so the counts it quoted are the counts shown, and then offered with Apply. A program the model ran through a tool but did not show is offered the same way. - A figure in its prose that no tool returned is marked "Not from a run". LaTeX is shown as Unicode, and answers are plain text with bold and code built as nodes, never HTML. - test/studio.test.mjs drives it against a scripted model server: the tool call run on the engine, a broken proposal sent back with its reason, the corrected one checked and run, the tools and the grounding rule in every call, and Apply. - test/assistant-live.mjs drives it against a real server. Against a local 35B mixture-of-experts model (about 3B parameters active per token), every answer began with an engine call. The GHZ program it wrote was checked and run, with the counts it quoted equal to the counts shown, and a "50/50" it gave itself was marked as not from a run. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 82269e7f4d7ae97bfdfab524ae211ceecd12bd6c) --- wai/CHANGELOG.md | 9 + wai/quantum-studio/README.md | 14 ++ wai/quantum-studio/src/assistant.js | 232 +++++++++++++++++++++ wai/quantum-studio/src/main.js | 101 ++++++++- wai/quantum-studio/src/studio.css | 18 ++ wai/quantum-studio/test/assistant-live.mjs | 57 +++++ wai/quantum-studio/test/studio.test.mjs | 48 +++++ wai/wai-quantum-desktop/Cargo.lock | 226 +++++++++++++++++++- wai/wai-quantum-desktop/Cargo.toml | 2 + wai/wai-quantum-desktop/src/main.rs | 50 ++++- 10 files changed, 747 insertions(+), 10 deletions(-) create mode 100644 wai/quantum-studio/src/assistant.js create mode 100644 wai/quantum-studio/test/assistant-live.mjs diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index e94b3d0ec..3f43756cf 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -33,6 +33,15 @@ Additive, apart from two refusals and an admission noted below. Every pinned dig - `lab`, which holds the earlier command's research tools. It depends on the library alone. The `wai-quantum` bin and the `cli_keys` feature leave wai-rs. +- **Quantum Studio** (`wai/quantum-studio`) is one interface for the browser and the desktop app (`wai/wai-quantum-desktop`). Its editor: + - highlights and completes OpenQASM; + - marks the engine's diagnostics on their lines; + - draws the circuit and shows the plan as the program is typed. + + It runs a job in the browser, on the desktop's native engine, or on the service, and checks each receipt where it runs. The desktop app runs each job in a process of its own and seals it with the user's key. + + Its assistant uses a local model with the engine as its tools. It checks and runs every program the model proposes before offering it, and marks any figure the engine did not compute. +- **`engine::job`** also splits into `plan_and_run` and `seal`, for hosts that run a job away from their key. - **wai-web, feature `quantum_engine`:** `QuantumEngine` (run, plan, methods and signer), `engine_lint`, `engine_draw` and `engine_verify`, for a page. Tested in wasm32 under Node on every method a tab holds. ### wai-quantum 0.6.10: one OpenQASM reader for the service, the command line and the browser IDE diff --git a/wai/quantum-studio/README.md b/wai/quantum-studio/README.md index a39ef3879..7330a0bd8 100644 --- a/wai/quantum-studio/README.md +++ b/wai/quantum-studio/README.md @@ -17,6 +17,19 @@ The editor: Ctrl+Enter (⌘+Enter on macOS) runs the program, and Escape cancels a run. The program and the history of runs are kept on the device. "Copy link" puts the program in a URL fragment, which no server sees. +## The assistant + +The Assistant pane talks to a language model on this machine: any server with the chat-completions interface and tool calls, such as the JouleClaw runtime or a llama.cpp server. The address goes under "Model…" and is `http://127.0.0.1:8080/v1` by default. Nothing leaves the machine unless the address points elsewhere. In the desktop app the app makes the call itself, so no browser origin rule stands in the way. + +The engine is the model's tool, not an afterthought: + +- **Its tools are the Studio's engine:** read the program, lint, draw, plan and run. Its first turn must call one of them, so every answer starts from the engine. +- **Every program it proposes is checked before it is offered.** The engine reads it here. One that does not parse goes back to the model once, with the engine's reason, to be corrected. One that parses is run, with the settings the model used, so the counts it quoted are the counts shown. Then "Apply" puts it in the editor. +- **It may state only figures the engine returned.** A figure in its prose that no tool returned is marked "Not from a run". +- **Answers are plain text.** LaTeX a model writes anyway is shown as Unicode. + +`node test/assistant-live.mjs URL` drives the assistant against a real model server and prints what it did. It is not part of CI, which has no model. + ## Hosting it Build it with `npm ci && node build.mjs`. That writes `dist/`: @@ -56,6 +69,7 @@ The test runs the Studio in the installed browser: - the same job on the live service giving the counts run here, shot for shot; - Ctrl+Enter; - the program and the history surviving a reload; +- the assistant, against a scripted model server: a tool call run on the engine, a proposal the engine cannot read sent back with its reason, the corrected one checked and run before it is offered, and Apply; - the layout at a phone's width. `STUDIO_SKIP_SERVICE=1` leaves the service step out. It saves screenshots of both colour schemes in `test/screenshots/`. diff --git a/wai/quantum-studio/src/assistant.js b/wai/quantum-studio/src/assistant.js new file mode 100644 index 000000000..2e2129bd8 --- /dev/null +++ b/wai/quantum-studio/src/assistant.js @@ -0,0 +1,232 @@ +// The Studio's assistant: a language model on this machine, with the engine +// as its tools. The model is any server with the chat-completions interface, +// tool calls included: the JouleClaw runtime, a llama.cpp server, others. By default +// it is local, and nothing leaves the machine. +// +// Tools first: the model asks the engine (lint, draw, plan, run) and may state +// only numbers the engine returned. Every program it proposes is checked by the +// engine before the Studio offers it: read as the engine reads it, and run on +// this device. A proposal that does not parse goes back to the model once, with +// the engine's reason, to be corrected. + +const SYSTEM = `You are the assistant in Quantum Studio, an editor for OpenQASM programs. +Tools run the Studio's own engine: +- get_program: the program in the editor, the job's settings and the last result. +- lint_qasm: read a program as the engine reads it (its qubits and gates, or the first problem and its line). +- draw_circuit: the circuit as text. +- plan_job: the method that would run a job, why, and the memory it needs. +- run_job: run a job on this device; counts, expectation values, amplitudes and the accuracy of each. +Use them. Never state a probability, count, amplitude, expectation value or cost that a tool did not return; quote the tool's figure. +The engine reads OpenQASM 3 and the OpenQASM 2 forms in common use: registers, the standard gates, gate definitions, ctrl and negctrl modifiers, measurement and reset anywhere, and if on measured bits. It refuses loops, inv and pow. +When you write a program, run it with run_job and also show the whole program in one \`\`\`qasm block; the Studio checks it and runs it before offering it. +For a state, a probability or an amplitude, run the program and quote the result rather than working it out yourself. +Write plain text: no Markdown headings, no LaTeX. Write states as |00⟩ and roots as √2. Qubit 0 is the rightmost bit of a basis state. +Answer briefly.`; + +export const TOOLS = [ + { type: 'function', function: { name: 'get_program', description: 'The program in the editor, the job settings, and a summary of the last result.', parameters: { type: 'object', properties: {} } } }, + { type: 'function', function: { name: 'lint_qasm', description: 'Read an OpenQASM program as the engine reads it: its qubits, gates and measurements, or the first problem with its line.', parameters: { type: 'object', required: ['qasm'], properties: { qasm: { type: 'string' } } } } }, + { type: 'function', function: { name: 'draw_circuit', description: 'A program\'s circuit drawn as text.', parameters: { type: 'object', required: ['qasm'], properties: { qasm: { type: 'string' } } } } }, + { + type: 'function', + function: { + name: 'plan_job', + description: 'The method a job would run on and why, with the memory it needs, without running it.', + parameters: { type: 'object', required: ['qasm'], properties: { qasm: { type: 'string' }, shots: { type: 'integer' }, observables: { type: 'array', items: { type: 'string' } }, method: { type: 'string' } } }, + }, + }, + { + type: 'function', + function: { + name: 'run_job', + description: 'Run a job on this device: measurement counts, Pauli expectation values (as "Z0 Z1") and/or amplitudes (bit strings, highest qubit first), with the method used and its accuracy.', + parameters: { + type: 'object', + required: ['qasm'], + properties: { qasm: { type: 'string' }, shots: { type: 'integer' }, observables: { type: 'array', items: { type: 'string' } }, amplitudes: { type: 'array', items: { type: 'string' } }, method: { type: 'string' } }, + }, + }, + }, +]; + +const clip = (s, n = 6000) => (s.length > n ? s.slice(0, n) + `… (${s.length - n} more characters)` : s); + +// A result as a model reads it: what was computed, not the receipt. +export function summary(job) { + const r = job?.result || {}; + const counts = r.counts ? Object.entries(r.counts).sort((a, b) => b[1] - a[1]).slice(0, 16) : undefined; + return { + method: job?.method, why: job?.why, accuracy: r.accuracy, qubits: r.qubits, gates: r.gates, + counts: counts && Object.fromEntries(counts), distinct_outcomes: r.distinct_outcomes, + expectations: r.expectations, amplitudes: r.amplitudes?.slice(0, 16), dynamic: r.dynamic, warning: r.warning, + }; +} + +// The ```qasm (or ```openqasm) blocks of an answer. +export function proposals(text) { + const out = []; + const re = /```(?:qasm|openqasm|openqasm3)?\s*\n([\s\S]*?)```/gi; + for (let m; (m = re.exec(text)); ) if (/\b(qubit|qreg|OPENQASM)\b/.test(m[1])) out.push(m[1].trim() + '\n'); + return out; +} + +// A model behind a chat-completions endpoint, called from this page. +export class HttpModel { + constructor(endpoint, name) { this.endpoint = endpoint.replace(/\/+$/, ''); this.name = name; } + async chat(body) { + const res = await fetch(`${this.endpoint}/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const v = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(v.error?.message || v.error || `the model answered ${res.status}`); + return v; + } + async models() { + const res = await fetch(`${this.endpoint}/models`); + if (!res.ok) throw new Error(`the model server answered ${res.status}`); + return (await res.json()).data?.map((m) => m.id) ?? []; + } +} + +// The same, called by the desktop app (no browser origin rules in the way). +export class DesktopModel { + constructor(endpoint, name) { this.endpoint = endpoint.replace(/\/+$/, ''); this.name = name; this.invoke = window.__TAURI__.core.invoke; } + async chat(body) { + const v = JSON.parse(await this.invoke('chat', { endpoint: this.endpoint, body: JSON.stringify(body) })); + if (v.error && !v.choices) throw new Error(v.error.message || v.error); + return v; + } + async models() { + const v = JSON.parse(await this.invoke('models', { endpoint: this.endpoint })); + if (v.error && !v.data) throw new Error(v.error.message || v.error); + return v.data?.map((m) => m.id) ?? []; + } +} + +export class Assistant { + // engine: the Studio's local engine; context(): {qasm, job, last} as the editor holds them. + constructor({ engine, model, context }) { + this.engine = engine; + this.model = model; + this.context = context; + this.history = []; + } + + async tool(name, args) { + const ctx = this.context(); + const job = (a) => { + const j = { qasm: a.qasm }; + for (const k of ['shots', 'observables', 'amplitudes', 'method']) if (a[k] != null && !(Array.isArray(a[k]) && !a[k].length)) j[k] = a[k]; + if (!j.shots && !j.observables && !j.amplitudes) j.shots = 1000; + return j; + }; + switch (name) { + case 'get_program': return { qasm: ctx.qasm, job: ctx.job, last_result: ctx.last ? summary(ctx.last) : null }; + case 'lint_qasm': return this.engine.lint(args.qasm ?? ctx.qasm); + case 'draw_circuit': return { text: (await this.engine.draw(args.qasm ?? ctx.qasm, 80)).text }; + case 'plan_job': return this.engine.plan(job({ ...args, qasm: args.qasm ?? ctx.qasm })); + case 'run_job': return summary(await this.engine.run(job({ ...args, qasm: args.qasm ?? ctx.qasm }))); + default: throw new Error(`no tool ${name}`); + } + } + + // Ask; `on(event)` hears each step: {type: 'tool', name, args, result} and + // finally {type: 'answer', text, proposals: [{qasm, lint, job}]}. + async ask(text, on = () => {}) { + const ctx = this.context(); + const used = []; // programs the model passed to the tools, other than the editor's + const ranWith = new Map(); // program → the settings the model ran it with + const heard = [ctx.qasm, JSON.stringify(ctx.job ?? {}), JSON.stringify(ctx.last ? summary(ctx.last) : {})]; // what the engine and the editor told it + const messages = [ + { role: 'system', content: SYSTEM }, + ...this.history, + { role: 'user', content: `${text}\n\n(The program in the editor:)\n\`\`\`qasm\n${ctx.qasm}\n\`\`\`` }, + ]; + let retried = false; + for (let round = 0; round < 8; round++) { + // The first turn must ask the engine something, so every answer starts from it. + const res = await this.model.chat({ model: this.model.name || undefined, messages, tools: TOOLS, tool_choice: round === 0 ? 'required' : 'auto', temperature: 0.2 }); + const msg = res.choices?.[0]?.message; + if (!msg) throw new Error('the model gave no answer'); + messages.push({ role: 'assistant', content: msg.content ?? '', ...(msg.tool_calls?.length ? { tool_calls: msg.tool_calls } : {}) }); + if (msg.tool_calls?.length) { + for (const call of msg.tool_calls) { + let args = {}; + try { args = JSON.parse(call.function?.arguments || '{}'); } catch (e) {} + let result; + try { result = await this.tool(call.function?.name, args); } catch (e) { result = { error: e.message }; } + if (typeof args.qasm === 'string' && args.qasm.trim() && args.qasm.trim() !== ctx.qasm.trim()) used.push(args.qasm.trim() + '\n'); + if (call.function?.name === 'run_job') { + const eff = pick(args, ['shots', 'observables', 'amplitudes', 'method']); + if (!eff.shots && !eff.observables && !eff.amplitudes) eff.shots = 1000; // as the tool ran it + ranWith.set((args.qasm ?? ctx.qasm).trim(), eff); + } + on({ type: 'tool', name: call.function?.name, args, result }); + messages.push({ role: 'tool', tool_call_id: call.id, content: clip(JSON.stringify(result)) }); + heard.push(JSON.stringify(args), JSON.stringify(result)); // what it asked, and what it was told + } + continue; + } + const answer = unlatex((msg.content || '').replace(/[\s\S]*?<\/think>\s*/g, '').trim()); + // Check each proposal as the engine reads it; a broken one goes back once. + // The answer's programs, and the last one the model ran or checked through a tool + // when the answer shows none: either way, read by the engine here. + const written = proposals(answer); + const shown = written.length ? written : used.slice(-1); + const checked = []; + for (const qasm of shown) checked.push({ qasm, lint: await this.engine.lint(qasm) }); + const broken = checked.find((p) => !p.lint.ok); + if (broken && !retried) { + retried = true; + on({ type: 'retry', error: broken.lint.error }); + messages.push({ role: 'user', content: `The engine could not read the program you proposed: ${broken.lint.error}. Give the corrected whole program in one \`\`\`qasm block.` }); + continue; + } + for (const p of checked) { + if (!p.lint.ok) continue; + // Run as the model ran it, so the counts it quoted are the counts shown; else + // with the editor's settings. + const theirs = ranWith.get(p.qasm.trim()); + const settings = theirs ?? { ...(ctx.job?.shots ? {} : { shots: 1000 }), ...pick(ctx.job, ['shots', 'observables']) }; + try { p.job = await this.engine.run({ qasm: p.qasm, ...settings }); } catch (e) { p.error = e.message; } + } + this.history.push({ role: 'user', content: text }, { role: 'assistant', content: answer }); + this.history = this.history.slice(-12); + on({ type: 'answer', text: answer, proposals: checked, usage: res.usage, unsourced: unsourced(answer, heard.join(' ')) }); + return; + } + throw new Error('the model kept calling tools without answering'); + } +} + +// The figures in an answer's prose that no tool returned and the program does not +// hold: a decimal, a percentage, or a whole number of two digits or more. +export function unsourced(answer, heard) { + const prose = answer.replace(/```[\s\S]*?```/g, ' '); + const figures = prose.match(/(? ({ Phi: 'Φ', Psi: 'Ψ', psi: 'ψ', phi: 'φ', theta: 'θ', pi: 'π', alpha: 'α', beta: 'β', gamma: 'γ', otimes: '⊗', cdot: '·', pm: '±' })[w]) + .replace(/\^\{?\+\}?/g, '⁺').replace(/\^\{?-\}?/g, '⁻') + .replace(/\\(left|right)/g, ''); +} + +function pick(o, keys) { + const out = {}; + for (const k of keys) if (o && o[k] != null && !(Array.isArray(o[k]) && !o[k].length)) out[k] = o[k]; + return out; +} diff --git a/wai/quantum-studio/src/main.js b/wai/quantum-studio/src/main.js index b89eef0dd..2ef9ed0ec 100644 --- a/wai/quantum-studio/src/main.js +++ b/wai/quantum-studio/src/main.js @@ -19,6 +19,7 @@ import { openqasm, complete } from './qasm-lang.js'; import { TabEngine, ServiceEngine, DesktopEngine, onDesktop } from './engine.js'; import { EXAMPLES } from './examples.js'; import * as History from './history.js'; +import { Assistant, HttpModel, DesktopModel } from './assistant.js'; const METHOD_NAMES = { exact: 'Exact state vector', statevector: 'State vector, double precision', statevector_single: 'State vector on the GPU, single precision', @@ -122,9 +123,38 @@ function mount(root) { el('button', { class: 'qs-btn qs-small', type: 'button', text: 'Clear', onclick: async () => { await History.clear(); showHistory(); } })), historyList); + // ---- the assistant ---- + const AI_ITEM = 'wai.quantum-studio.assistant'; + const aiConf = { endpoint: 'http://127.0.0.1:8080/v1', model: '' }; + try { Object.assign(aiConf, JSON.parse(localStorage.getItem(AI_ITEM) || '{}')); } catch (e) {} + const [aiEndpoint, aiEndpointF] = field('qs-ai-endpoint', 'Model server', { value: aiConf.endpoint, spellcheck: 'false', autocomplete: 'off' }); + const [aiModel, aiModelF] = field('qs-ai-model', 'Model', { value: aiConf.model, placeholder: 'the server’s own', spellcheck: 'false', autocomplete: 'off' }); + const aiState = el('span', { class: 'qs-badge', id: 'qs-ai-state', text: 'not connected' }); + const aiConnect = el('button', { class: 'qs-btn qs-small', id: 'qs-ai-connect', type: 'button', text: 'Connect' }); + const aiSettings = el('div', { class: 'qs-ai-settings', id: 'qs-ai-settings', hidden: true }, + el('div', { class: 'qs-row' }, aiEndpointF, aiModelF), + el('div', { class: 'qs-actions' }, aiConnect), + el('p', { class: 'qs-note', text: 'A model on this machine by default: any server with the chat-completions interface and tool calls. Nothing leaves this machine unless the address points elsewhere.' })); + const aiLog = el('ol', { class: 'qs-ai-log', id: 'qs-ai-log', 'aria-live': 'polite' }, + el('li', { class: 'qs-note', text: 'Ask about the program. The assistant uses the engine to check what it says: every number comes from a run, and every program it proposes is read and run here before it is offered.' })); + const aiInput = el('textarea', { class: 'qs-input qs-ai-input', id: 'qs-ai-input', rows: '2', placeholder: 'Ask about this program…', 'aria-label': 'Ask the assistant' }); + const aiSend = el('button', { class: 'qs-btn qs-primary', id: 'qs-ai-send', type: 'button', text: 'Ask' }); + const QUICK = [ + ['Explain this circuit', 'Explain what this program does, step by step, and what its result shows.'], + ['Why this method?', 'Why does the engine pick this method for this job, and what would make it pick another?'], + ['Fix the problem', 'The program has a problem. Find it with the tools and give the corrected whole program.'], + ]; + const aiPane = el('section', { class: 'qs-pane qs-ai', 'aria-labelledby': 'qs-ai-h' }, + el('div', { class: 'qs-head' }, el('h2', { id: 'qs-ai-h', text: 'Assistant' }), aiState, el('span', { class: 'qs-grow' }), + el('button', { class: 'qs-btn qs-small', type: 'button', id: 'qs-ai-gear', text: 'Model…', 'aria-controls': 'qs-ai-settings', onclick: () => { aiSettings.hidden = !aiSettings.hidden; } })), + aiSettings, + el('div', { class: 'qs-ai-quick' }, ...QUICK.map(([label, q]) => el('button', { class: 'qs-btn qs-small', type: 'button', text: label, onclick: () => ask(q, label) }))), + aiLog, + el('div', { class: 'qs-ai-ask' }, aiInput, aiSend)); + const footer = el('footer', { class: 'qs-foot' }, el('span', { class: 'qs-signer', id: 'qs-signer' }), status); root.classList.add('qs'); - root.replaceChildren(header, el('div', { class: 'qs-grid' }, program, el('div', { class: 'qs-side' }, circuitPane, resultPane, historyPane)), footer); + root.replaceChildren(header, el('div', { class: 'qs-grid' }, program, el('div', { class: 'qs-side' }, circuitPane, resultPane, aiPane, historyPane)), footer); // ---- the editor ---- const style = HighlightStyle.define([ @@ -392,6 +422,75 @@ function mount(root) { runBtn.addEventListener('click', run); cancelBtn.addEventListener('click', cancel); + // ---- asking ---- + const model = () => { + const endpoint = aiEndpoint.value.trim(), name = aiModel.value.trim(); + try { localStorage.setItem(AI_ITEM, JSON.stringify({ endpoint, model: name })); } catch (e) {} + return desktop ? new DesktopModel(endpoint, name) : new HttpModel(endpoint, name); + }; + let assistant = null; + const assistantFor = () => { + const m = model(); + if (!assistant || assistant.model.endpoint !== m.endpoint || assistant.model.name !== m.name) { + assistant = new Assistant({ engine: local, model: m, context: () => ({ qasm: text(), job: request(), last: current?.job }) }); + } + return assistant; + }; + const say = (cls, ...kids) => { const li = el('li', { class: cls }, ...kids); aiLog.append(li); li.scrollIntoView({ block: 'nearest' }); return li; }; + // An answer's text, its code in blocks. + // **bold** and `code` within a line, built as nodes, never as HTML. + const inline = (line) => line.split(/(\*\*[^*]+\*\*|`[^`]+`)/).filter(Boolean).map((t) => + t.startsWith('**') && t.endsWith('**') ? el('strong', { text: t.slice(2, -2) }) : t.startsWith('`') && t.endsWith('`') ? el('code', { text: t.slice(1, -1) }) : document.createTextNode(t.replace(/\$([^$]+)\$/g, '$1'))); + const prose = (t) => t.split(/```[a-z0-9]*\n?/i).map((part, i) => (i % 2 ? el('pre', { class: 'qs-ai-code', text: part.replace(/\n$/, '') }) : part.trim() ? el('p', { class: 'qs-ai-text' }, ...part.trim().split('\n').flatMap((l, j) => [j ? el('br') : null, ...inline(l)])) : null)); + async function ask(q, label = q) { + if (aiSend.disabled) return; + aiSend.disabled = true; + say('qs-ai-user', el('p', { text: label })); + const thinking = say('qs-ai-tool', el('span', { class: 'qs-note', text: 'thinking…' })); + try { + await assistantFor().ask(q, (ev) => { + if (ev.type === 'tool') { + const res = JSON.stringify(ev.result); + aiLog.insertBefore(el('li', { class: 'qs-ai-tool' }, el('details', {}, el('summary', { text: `used ${ev.name}` }), el('pre', { class: 'qs-ai-code', text: res.length > 2000 ? res.slice(0, 2000) + '…' : res }))), thinking); + } else if (ev.type === 'retry') { + aiLog.insertBefore(el('li', { class: 'qs-ai-tool' }, el('span', { class: 'qs-note', text: `The engine could not read the proposed program (${ev.error}); asked for a correction.` })), thinking); + } else if (ev.type === 'answer') { + thinking.remove(); + const li = say('qs-ai-answer', ...prose(ev.text)); + for (const p of ev.proposals) { + const box = el('div', { class: 'qs-ai-proposal' }); + if (!p.lint.ok) box.append(el('span', { class: 'qs-badge bad', text: 'does not parse' }), el('span', { class: 'qs-note', text: ' ' + p.lint.error })); + else { + const r = p.job?.result; + const counts = r?.counts ? Object.entries(r.counts).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, n]) => `${compact(k)}: ${n}`).join(', ') : ''; + box.append(el('span', { class: 'qs-badge ok', text: `checked: ${p.lint.qubits} qubits, ${p.lint.gates} gates` }), + el('span', { class: 'qs-note', text: p.job ? ` ran here on ${METHOD_NAMES[p.job.method] || p.job.method}${counts ? '; ' + counts : ''}` : p.error ? ` did not run: ${p.error}` : '' }), + el('button', { class: 'qs-btn qs-small qs-ai-apply', type: 'button', text: 'Apply', onclick: () => { setText(p.qasm); shows.textContent = 'The assistant’s program, checked and run before it was offered.'; setStatus('Applied the assistant’s program.', 'ok'); } })); + } + li.append(box); + } + if (ev.unsourced?.length) li.append(el('p', { class: 'qs-ai-unsourced', text: `Not from a run: ${ev.unsourced.join(', ')}. The model gave these itself; the engine did not compute them.` })); + if (ev.usage?.total_tokens) li.append(el('p', { class: 'qs-note', text: `${ev.usage.prompt_tokens ?? '?'} tokens in, ${ev.usage.completion_tokens ?? '?'} out` })); + } + }); + } catch (e) { + thinking.remove(); + say('qs-ai-error', el('p', { text: `The assistant could not answer: ${e.message}. Check the model server under “Model…”.` })); + aiState.textContent = 'not connected'; aiState.className = 'qs-badge bad'; + } finally { aiSend.disabled = false; } + } + aiSend.addEventListener('click', () => { const q = aiInput.value.trim(); if (q) { aiInput.value = ''; ask(q); } }); + aiInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); aiSend.click(); } }); + aiConnect.addEventListener('click', async () => { + aiState.textContent = 'connecting…'; aiState.className = 'qs-badge run'; + try { + const ids = await model().models(); + const name = ids[0] ? ids[0].split(/[\\/]/).pop().replace(/\.gguf$/i, '') : ''; + aiState.textContent = `connected${name ? ' · ' + name : ''}`; aiState.className = 'qs-badge ok'; aiState.title = ids[0] || ''; + if (!aiModel.value && ids[0]) aiModel.value = ids[0]; + } catch (e) { aiState.textContent = 'not connected'; aiState.className = 'qs-badge bad'; aiState.title = e.message; } + }); + // ---- start ---- const handed = new URLSearchParams(location.hash.slice(1)).get('qasm'); let draft = null; diff --git a/wai/quantum-studio/src/studio.css b/wai/quantum-studio/src/studio.css index a746645e3..a491272b7 100644 --- a/wai/quantum-studio/src/studio.css +++ b/wai/quantum-studio/src/studio.css @@ -91,6 +91,24 @@ .qs-status { margin-left: auto; color: var(--text-secondary); overflow-wrap: anywhere; } .qs-status.ok { color: var(--ok-text); } .qs-status.bad { color: var(--bad-text); } +.qs-ai { min-height: 200px; } +.qs-ai-settings { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); } +.qs-ai-settings[hidden] { display: none; } +.qs-ai-quick { display: flex; gap: 6px; flex-wrap: wrap; padding: 8px 12px 0; } +.qs-ai-log { list-style: none; margin: 0; padding: 8px 12px; display: flex; flex-direction: column; gap: 8px; max-height: 420px; overflow: auto; } +.qs-ai-log > li { font-size: 13px; line-height: 1.5; } +.qs-ai-log p { margin: 0; } +.qs-ai-user { align-self: flex-end; max-width: 85%; padding: 6px 10px; border-radius: 8px; background: color-mix(in srgb, var(--accent) 14%, var(--bg-base)); } +.qs-ai-answer { display: flex; flex-direction: column; gap: 6px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-base); } +.qs-ai-text { white-space: pre-wrap; } +.qs-ai-tool { font-size: 12px; color: var(--text-secondary); } +.qs-ai-tool summary { cursor: pointer; font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; color: var(--text-muted); } +.qs-ai-error { color: var(--bad-text); } +.qs-ai-unsourced { font-size: 12px; color: var(--warn-text); } +.qs-ai-code { margin: 0; padding: 8px 10px; border-radius: 6px; background: var(--bg-surface); border: 1px solid var(--border); font: 12px/1.4 var(--font-mono, ui-monospace, monospace); overflow: auto; white-space: pre; } +.qs-ai-proposal { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 4px; } +.qs-ai-ask { display: flex; gap: 8px; align-items: flex-end; padding: 8px 12px 12px; border-top: 1px solid var(--border); } +.qs-ai-input { min-height: 40px; resize: vertical; font-family: var(--font-sans, system-ui, sans-serif); font-size: 13px; } @media (max-width: 900px) { .qs-grid { grid-template-columns: minmax(0, 1fr); } .qs-program { min-height: 0; } diff --git a/wai/quantum-studio/test/assistant-live.mjs b/wai/quantum-studio/test/assistant-live.mjs new file mode 100644 index 000000000..bac797215 --- /dev/null +++ b/wai/quantum-studio/test/assistant-live.mjs @@ -0,0 +1,57 @@ +// The assistant against a real model server, as a reader uses it: not part of CI, which +// has no model. Prints what the model did and how long it took. +// +// node build.mjs --pkg PATH/TO/PKG && node test/assistant-live.mjs http://127.0.0.1:8080/v1 +// +// Asks it to explain the Bell example, then to write a three-qubit GHZ program; each +// program it proposes is checked and run by the engine before it is shown, as always. +import { createServer } from 'node:http'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, extname, join, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const endpoint = process.argv[2] ?? 'http://127.0.0.1:8080/v1'; +const { chromium } = (await import(process.env.WAI_E2E_DRIVER ?? 'playwright')).default; +const DIST = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist'); +const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.wasm': 'application/wasm' }; +const server = createServer((req, res) => { + let file = normalize(join(DIST, decodeURIComponent(new URL(req.url, 'http://x').pathname))); + if (!file.startsWith(DIST)) { res.writeHead(403).end(); return; } + if (existsSync(file) && statSync(file).isDirectory()) file = join(file, 'index.html'); + if (!existsSync(file)) { res.writeHead(404).end(); return; } + res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' }); + res.end(readFileSync(file)); +}); +await new Promise((r) => server.listen(0, '127.0.0.1', r)); +const browser = await chromium.launch({ channel: process.env.WAI_E2E_CHANNEL ?? 'chrome', headless: true }); +const page = await (await browser.newContext({ viewport: { width: 1400, height: 1000 } })).newPage(); +await page.goto(`http://127.0.0.1:${server.address().port}/`, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: 120_000 }); +await page.selectOption('#qs-example', 'bell'); +await page.click('#qs-ai-gear'); +await page.fill('#qs-ai-endpoint', endpoint); +await page.click('#qs-ai-connect'); +await page.waitForFunction(() => !/connecting/.test(document.querySelector('#qs-ai-state')?.textContent ?? ''), null, { timeout: 60_000 }); +console.log('model:', await page.textContent('#qs-ai-state')); + +async function ask(q) { + const before = await page.$$eval('.qs-ai-answer, .qs-ai-error', (a) => a.length); + const t0 = Date.now(); + await page.fill('#qs-ai-input', q); + await page.click('#qs-ai-send'); + await page.waitForFunction((n) => document.querySelectorAll('.qs-ai-answer, .qs-ai-error').length > n, before, { timeout: 600_000 }); + const items = await page.$$eval('#qs-ai-log > li', (ls) => ls.map((l) => `[${l.className}] ${l.textContent.replace(/\s+/g, ' ').slice(0, 900)}`)); + console.log(`\n=== ${q} (${((Date.now() - t0) / 1000).toFixed(1)} s)`); + for (const i of items.slice(-8)) console.log(i); +} +await ask('Explain this circuit.'); +await ask('Write a three-qubit GHZ program, measured, and tell me its counts.'); +const proposals = await page.$$eval('.qs-ai-proposal', (p) => p.map((x) => x.textContent)); +console.log('\nproposals checked:', proposals.length, proposals.join(' | ')); +const answers = await page.$$eval('.qs-ai-answer', (a) => a.map((x) => x.innerText)); +for (const a of answers) console.log('\n--- answer as shown ---\n' + a); +const { mkdirSync } = await import('node:fs'); +mkdirSync(join(dirname(fileURLToPath(import.meta.url)), 'screenshots'), { recursive: true }); +await page.locator('.qs-ai').screenshot({ path: join(dirname(fileURLToPath(import.meta.url)), 'screenshots', 'assistant-live.png') }); +await browser.close(); +server.close(); diff --git a/wai/quantum-studio/test/studio.test.mjs b/wai/quantum-studio/test/studio.test.mjs index f3cbf7fa0..dc882fd57 100644 --- a/wai/quantum-studio/test/studio.test.mjs +++ b/wai/quantum-studio/test/studio.test.mjs @@ -12,6 +12,9 @@ // - the same Bell job on the service gives the counts run here, shot for shot, and its // receipt checks here as the service's (left out with STUDIO_SKIP_SERVICE=1); // - Ctrl+Enter in the editor runs; the program and the history survive a reload; +// - the assistant, against a scripted model server: a tool call run on the engine, a +// proposal the engine cannot read sent back with its reason, the corrected one checked +// and run before it is offered, and Apply; // - no console error and no failed request; at a phone's width nothing scrolls sideways. // Exit 0 iff every check passes. import { createServer } from 'node:http'; @@ -130,6 +133,51 @@ await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint' check((await page.evaluate(() => window.quantumStudio.view.state.doc.toString())) === program, 'the program survives a reload'); await page.waitForFunction((n) => document.querySelectorAll('.qs-hitem').length >= n, before + 1, { timeout: LONG }); check(true, 'the history survives a reload'); +// The assistant, against a scripted model server: it calls a tool, proposes a program +// the engine cannot read, gets the engine's reason back, and proposes a corrected one, +// which the Studio checks and runs before offering it. +{ + const asked = []; + const GOOD = 'OPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;\n'; + const model = createServer((req, res) => { + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'GET, POST, OPTIONS' }; + if (req.method === 'OPTIONS') { res.writeHead(204, cors).end(); return; } + if (req.url.endsWith('/models')) { res.writeHead(200, { ...cors, 'content-type': 'application/json' }).end(JSON.stringify({ data: [{ id: 'scripted' }] })); return; } + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + const r = JSON.parse(body); + asked.push(r); + const msgs = r.messages; + const told = msgs.some((m) => m.role === 'user' && /could not read the program you proposed/.test(m.content)); + const toolDone = msgs.some((m) => m.role === 'tool'); + let message; + if (!toolDone) message = { role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'lint_qasm', arguments: JSON.stringify({ qasm: GOOD }) } }] }; + else if (!told) message = { role: 'assistant', content: 'It entangles two qubits. A variant:\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nfoo q[0];\n```' }; + else message = { role: 'assistant', content: 'Corrected:\n```qasm\n' + GOOD + '```' }; + res.writeHead(200, { ...cors, 'content-type': 'application/json' }).end(JSON.stringify({ choices: [{ message, finish_reason: message.tool_calls ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } })); + }); + }); + await new Promise((r) => model.listen(0, '127.0.0.1', r)); + await page.click('#qs-ai-gear'); + await page.fill('#qs-ai-endpoint', `http://127.0.0.1:${model.address().port}/v1`); + await page.click('#qs-ai-connect'); + await page.waitForFunction(() => /connected · scripted/.test(document.querySelector('#qs-ai-state')?.textContent ?? ''), null, { timeout: LONG }); + check(true, 'the assistant connects to a model server and reads its models'); + await page.click('.qs-ai-quick button >> text=Explain this circuit'); + await page.waitForSelector('.qs-ai-apply', { timeout: LONG }); + const log = await page.$$eval('#qs-ai-log > li', (ls) => ls.map((l) => l.textContent)); + check(log.some((t) => t.includes('used lint_qasm')), 'the model\'s tool call ran on the engine, and the log shows it'); + check(log.some((t) => /could not read the proposed program \(line 3: unknown gate foo\)/.test(t)), `a proposal the engine cannot read goes back with the engine's reason (${log.find((t) => /could not read/.test(t)) ?? 'none'})`); + const proposal = await page.textContent('.qs-ai-proposal'); + check(/checked: 2 qubits, 2 gates/.test(proposal) && /ran here on Stabilizer tableau/.test(proposal), `the corrected proposal is checked and run before it is offered (${proposal})`); + check(asked.length === 3 && asked.every((r) => r.tools?.some((t) => t.function.name === 'run_job') && r.messages[0].role === 'system' && /Never state a probability/.test(r.messages[0].content)), 'every call to the model carries the engine\'s tools and the rule that numbers come from them'); + const toolMsg = asked[1].messages.find((m) => m.role === 'tool'); + check(toolMsg && JSON.parse(toolMsg.content).qubits === 2, 'the tool\'s answer went back to the model'); + await page.click('.qs-ai-apply'); + check((await page.evaluate(() => window.quantumStudio.view.state.doc.toString())) === GOOD, 'Apply puts the checked program in the editor'); + model.close(); +} check(seen.errors.length === 0, `no console error${seen.errors.length ? ': ' + seen.errors.join(' | ') : ''}`); check(seen.failed.length === 0, `no failed request${seen.failed.length ? ': ' + seen.failed.join(' | ') : ''}`); await ctx.close(); diff --git a/wai/wai-quantum-desktop/Cargo.lock b/wai/wai-quantum-desktop/Cargo.lock index 00e582c57..e48194ff5 100644 --- a/wai/wai-quantum-desktop/Cargo.lock +++ b/wai/wai-quantum-desktop/Cargo.lock @@ -487,10 +487,29 @@ 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 2.14.2", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -832,6 +851,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.28.0" @@ -1962,6 +1990,12 @@ 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 = "lock_api" version = "0.4.14" @@ -2772,6 +2806,20 @@ dependencies = [ "web-sys", ] +[[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-hash" version = "2.1.3" @@ -2800,6 +2848,41 @@ dependencies = [ "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 = "rustversion" version = "1.0.23" @@ -3920,6 +4003,44 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[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 0.23.1", + "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 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -3945,6 +4066,12 @@ dependencies = [ "url", ] +[[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" @@ -4032,6 +4159,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "ureq", "wai-quantum", "wai-quantum-cli", ] @@ -4215,6 +4343,15 @@ dependencies = [ "system-deps", ] +[[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 = "webview2-com" version = "0.39.1" @@ -4403,7 +4540,16 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -4421,13 +4567,29 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -4454,42 +4616,90 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.5.40" diff --git a/wai/wai-quantum-desktop/Cargo.toml b/wai/wai-quantum-desktop/Cargo.toml index 42264825d..0cb519b4b 100644 --- a/wai/wai-quantum-desktop/Cargo.toml +++ b/wai/wai-quantum-desktop/Cargo.toml @@ -14,6 +14,8 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } serde_json = { version = "1", features = ["float_roundtrip"] } +# The assistant's model server, called by the app (a local one by default). +ureq = { version = "3", features = ["json"] } wai-quantum = { version = "0.6.11", path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt"] } wai-quantum-cli = { version = "0.1.0", path = "../wai-quantum-cli", default-features = false } diff --git a/wai/wai-quantum-desktop/src/main.rs b/wai/wai-quantum-desktop/src/main.rs index d5f0312e7..b3d4e44f0 100644 --- a/wai/wai-quantum-desktop/src/main.rs +++ b/wai/wai-quantum-desktop/src/main.rs @@ -153,6 +153,47 @@ fn signer(studio: tauri::State<'_, Studio>) -> String { json!({ "did": wai_quantum::quantum_receipt::did_key(&studio.key.verifying_key()) }).to_string() } +/// A model server's address: http or https only. +fn model_url(endpoint: &str, path: &str) -> Result { + let e = endpoint.trim().trim_end_matches('/'); + if !(e.starts_with("http://") || e.starts_with("https://")) { + return Err(format!("{e}: a model server's address starts with http:// or https://")); + } + Ok(format!("{e}{path}")) +} + +fn model_agent() -> ureq::Agent { + ureq::Agent::config_builder().http_status_as_error(false).timeout_global(Some(std::time::Duration::from_secs(600))).build().into() +} + +/// The assistant's call to its model: a chat-completions request, sent by +/// the app, whose calls no page origin rule stands in front of. The answer is +/// passed back as the server gave it. +#[tauri::command] +async fn chat(endpoint: String, body: String) -> Result { + let out = tauri::async_runtime::spawn_blocking(move || -> Result { + let url = model_url(&endpoint, "/chat/completions")?; + let mut r = model_agent().post(&url).header("content-type", "application/json").send(body).map_err(|e| format!("{url}: {e}"))?; + r.body_mut().read_to_string().map_err(|e| format!("{url}: {e}")) + }) + .await + .map_err(|e| e.to_string())?; + Ok(out.unwrap_or_else(error_json)) +} + +/// The models a server offers. +#[tauri::command] +async fn models(endpoint: String) -> Result { + let out = tauri::async_runtime::spawn_blocking(move || -> Result { + let url = model_url(&endpoint, "/models")?; + let mut r = model_agent().get(&url).call().map_err(|e| format!("{url}: {e}"))?; + r.body_mut().read_to_string().map_err(|e| format!("{url}: {e}")) + }) + .await + .map_err(|e| e.to_string())?; + Ok(out.unwrap_or_else(error_json)) +} + /// Where the Studio keeps what it saves: `~/Documents/Quantum Studio/`. fn save_dir() -> Result { let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).ok_or("no home folder to save in")?; @@ -231,7 +272,7 @@ fn main() { }; tauri::Builder::default() .manage(Studio { key, job_bytes: job_bytes(), running: Arc::new(Mutex::new(None)), cancelled: Arc::new(Mutex::new(false)) }) - .invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, draw, verify, signer, save_job, save_program]) + .invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, draw, verify, signer, save_job, save_program, chat, models]) .run(tauri::generate_context!()) .expect("the window opens"); } @@ -255,6 +296,13 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_model_server_is_reached_by_http_only() { + assert_eq!(model_url("http://127.0.0.1:8080/v1/", "/models").unwrap(), "http://127.0.0.1:8080/v1/models"); + assert!(model_url("file:///etc/passwd", "/models").is_err()); + assert!(model_url("127.0.0.1:8080", "/models").is_err()); + } + #[test] fn lint_draw_and_verify_answer_as_json() { let l: Value = serde_json::from_str(&lint("qubit[1] q;\nfoo q[0];".into())).unwrap(); -- 2.54.0 (Apple Git-157) From 1f7947e35ce51302ca4feccca909be6b70860274 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 13:28:24 -0400 Subject: [PATCH 12/19] Quantum Studio: data-embedded, for a host page with a heading of its own Co-Authored-By: Claude Opus 5.5 (cherry picked from commit f1c3ccb7b0e0732b6ecd493a75545b99e2820a7e) --- wai/quantum-studio/README.md | 2 +- wai/quantum-studio/src/main.js | 4 +++- wai/quantum-studio/src/studio.css | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/wai/quantum-studio/README.md b/wai/quantum-studio/README.md index 7330a0bd8..3c75ff33e 100644 --- a/wai/quantum-studio/README.md +++ b/wai/quantum-studio/README.md @@ -52,7 +52,7 @@ A host mounts the Studio on an element: ``` -`data-engine-pkg` names the engine's browser package: wai-web built with the feature `quantum_engine`. `node build.mjs --pkg DIR` copies such a package into `dist/pkg/` for `index.html`. On the desktop, `window.__TAURI__` is present and the Studio uses the native engine instead. +`data-embedded` tells the Studio the host page has its own heading, so the Studio's name is not a second `h1`. `data-engine-pkg` names the engine's browser package: wai-web built with the feature `quantum_engine`. `node build.mjs --pkg DIR` copies such a package into `dist/pkg/` for `index.html`. On the desktop, `window.__TAURI__` is present and the Studio uses the native engine instead. ## Testing it diff --git a/wai/quantum-studio/src/main.js b/wai/quantum-studio/src/main.js index 2ef9ed0ec..d81955d60 100644 --- a/wai/quantum-studio/src/main.js +++ b/wai/quantum-studio/src/main.js @@ -55,6 +55,8 @@ const split = (s) => s.split(/[,\n]/).map((x) => x.trim()).filter(Boolean); function mount(root) { const desktop = onDesktop(); + // A host page with a heading of its own mounts the Studio with data-embedded, and its name is not a second h1. + const embedded = 'embedded' in root.dataset; const local = desktop ? new DesktopEngine() : new TabEngine(root.dataset.enginePkg, root.dataset.worker || new URL('worker.js', import.meta.url).href); const service = root.dataset.service ? new ServiceEngine(root.dataset.service) : null; let where = 'local'; @@ -72,7 +74,7 @@ function mount(root) { const examples = el('select', { class: 'qs-select', id: 'qs-example', 'aria-label': 'Load an example' }, el('option', { value: '', text: 'Examples…' }), ...EXAMPLES.map((e) => el('option', { value: e.id, text: e.title }))); const header = el('header', { class: 'qs-top' }, - el('div', { class: 'qs-brand' }, el('span', { class: 'qs-mark', 'aria-hidden': 'true', text: '⟨ψ⟩' }), el('h1', { text: 'Quantum Studio' })), + el('div', { class: 'qs-brand' }, el('span', { class: 'qs-mark', 'aria-hidden': 'true', text: '⟨ψ⟩' }), el(embedded ? 'span' : 'h1', { class: 'qs-title', text: 'Quantum Studio' })), examples, el('div', { class: 'qs-where', role: 'group', 'aria-label': 'Run on' }, whereLocal, whereService), runBtn, cancelBtn); diff --git a/wai/quantum-studio/src/studio.css b/wai/quantum-studio/src/studio.css index a491272b7..bc8f814ef 100644 --- a/wai/quantum-studio/src/studio.css +++ b/wai/quantum-studio/src/studio.css @@ -22,7 +22,7 @@ .qs h1, .qs h2 { margin: 0; text-wrap: balance; } .qs-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--qs-radius); background: var(--bg-surface); } .qs-brand { display: flex; align-items: baseline; gap: 8px; margin-right: auto; } -.qs-brand h1 { font-size: 15px; font-weight: 650; letter-spacing: 0.01em; } +.qs-brand .qs-title { margin: 0; font-size: 15px; font-weight: 650; letter-spacing: 0.01em; } .qs-mark { font-family: var(--font-mono, ui-monospace, monospace); color: var(--accent-text); font-size: 14px; } .qs-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: var(--qs-gap); flex: 1; min-height: 0; } .qs-side { display: flex; flex-direction: column; gap: var(--qs-gap); min-width: 0; } -- 2.54.0 (Apple Git-157) From 06941cf250ce1d55d361e73f9528f501ff859940 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 13:53:56 -0400 Subject: [PATCH 13/19] wai-quantum-cli: release builds for macOS, Linux and Windows, with a Homebrew formula and a Scoop manifest A tag wai-quantum-cli-v (the crate's version, checked) builds the wai-quantum binary on the project's own runners and attaches each archive, with its SHA-256, to the forge release of that tag: - macOS: aarch64-apple-darwin and x86_64-apple-darwin, with the GPU state vector; - Linux: x86_64- and aarch64-unknown-linux-musl, static, checked as such, so any distribution runs them; - Windows: x86_64-pc-windows-msvc, as a zip. Each binary runs once before it is packaged (--version, and a did:key from `key --demo-key`). A last job writes the Homebrew formula and the Scoop manifest from the attached SHA-256 files, leaving out a platform whose build attached nothing, and attaches them too. release/package.sh makes the archive (the binary, README and LICENSE; the same bytes for the same inputs where GNU tar is there) and its .sha256. release/publish.sh creates the release if no build has yet, replaces an asset of the same name, and uploads over the private network, not the public tunnel. release/formulae.py writes the formula and the manifest. The README says how to install it each way. Checked here: package.sh on the macOS binary (the archive verifies against its .sha256, unpacks, and runs); formulae.py against a stand-in release with three of the five platforms attached (the formula holds those three, Ruby reads it, and the manifest is the Windows one). The workflow itself runs on its first tag. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 66fd49edc0a5069eb1e60ad4a7f46337fc6465a5) --- .github/workflows/wai-quantum-cli-release.yml | 213 ++++++++++++++++++ wai/wai-quantum-cli/README.md | 19 +- wai/wai-quantum-cli/release/formulae.py | 93 ++++++++ wai/wai-quantum-cli/release/package.sh | 30 +++ wai/wai-quantum-cli/release/publish.sh | 34 +++ 5 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/wai-quantum-cli-release.yml create mode 100644 wai/wai-quantum-cli/release/formulae.py create mode 100755 wai/wai-quantum-cli/release/package.sh create mode 100755 wai/wai-quantum-cli/release/publish.sh diff --git a/.github/workflows/wai-quantum-cli-release.yml b/.github/workflows/wai-quantum-cli-release.yml new file mode 100644 index 000000000..7dc22ac7d --- /dev/null +++ b/.github/workflows/wai-quantum-cli-release.yml @@ -0,0 +1,213 @@ +name: wai-quantum command line release + +# On a tag wai-quantum-cli-v (the version wai/wai-quantum-cli/Cargo.toml +# states), builds the wai-quantum binary on each runner and attaches it to the forge +# release of that tag, each archive with its SHA-256: +# macOS aarch64-apple-darwin and x86_64-apple-darwin, with the GPU state vector; +# Linux x86_64- and aarch64-unknown-linux-musl, static, so any distribution runs it; +# Windows x86_64-pc-windows-msvc, as a zip. +# Each binary is run once before it is packaged (`--version`, and `key --demo-key` for +# a did:key). A last job writes the Homebrew formula and the Scoop manifest for the +# archives just uploaded and attaches them too. +# +# Uploads go to the forge directly over the private network (FORGE_API), never through +# the public tunnel. A runner that is down leaves its platform's archive missing: the +# release lists what each build attached. + +on: + push: + tags: ['wai-quantum-cli-v*'] + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + FORGE_API: http://100.64.224.0:3400/api/v1 + FORGE_REPO: Transaction-Science/open-standards + PUBLIC: https://git.transaction.science/Transaction-Science/open-standards + +jobs: + version: + name: the tag is the crate's version + runs-on: linux + outputs: + version: ${{ steps.v.outputs.version }} + steps: + - uses: actions/checkout@v5 + - id: v + shell: bash + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + v="${tag#wai-quantum-cli-v}" + crate="$(sed -n 's/^version = "\(.*\)"$/\1/p' wai/wai-quantum-cli/Cargo.toml | head -1)" + [ "$v" = "$crate" ] || { echo "::error::the tag says $v, wai-quantum-cli/Cargo.toml says $crate"; exit 1; } + echo "version=$v" >> "$GITHUB_OUTPUT" + + macos: + name: macOS · ${{ matrix.triple }} + needs: version + runs-on: macos + timeout-minutes: 90 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + triple: [aarch64-apple-darwin, x86_64-apple-darwin] + 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: Build, check and package + shell: bash + working-directory: wai/wai-quantum-cli + run: | + set -euo pipefail + rustup target add "${{ matrix.triple }}" + nice -n 10 cargo build --locked --release --features metal --target "${{ matrix.triple }}" + bin="target/${{ matrix.triple }}/release/wai-quantum" + if [ "${{ matrix.triple }}" = "$(rustc -vV | sed -n 's/^host: //p')" ]; then + "$bin" --version + "$bin" key --demo-key | grep -q '^did:key:z6Mk' + fi + bash release/package.sh "$bin" "${{ needs.version.outputs.version }}" "${{ matrix.triple }}" "$RUNNER_TEMP/dist" + - name: Attach to the release + shell: bash + working-directory: wai/wai-quantum-cli + env: + FORGE_TOKEN: ${{ github.token }} + run: bash release/publish.sh "$GITHUB_REF_NAME" "wai-quantum ${{ needs.version.outputs.version }}" "$RUNNER_TEMP"/dist/* + + linux: + name: Linux · ${{ matrix.triple }} + needs: version + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - runner: linux + triple: x86_64-unknown-linux-musl + - runner: linux-arm64 + triple: aarch64-unknown-linux-musl + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0, the musl target and musl-gcc + 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 + rustup default 1.98.0 + rustup target add "${{ matrix.triple }}" + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq musl-tools + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Build, check and package + shell: bash + working-directory: wai/wai-quantum-cli + run: | + set -euo pipefail + t="${{ matrix.triple }}" + T="$(echo "$t" | tr 'a-z-' 'A-Z_')" + export "CC_${t//-/_}=musl-gcc" "CARGO_TARGET_${T}_LINKER=musl-gcc" + cargo build --locked --release --target "$t" + bin="target/$t/release/wai-quantum" + if command -v file >/dev/null 2>&1; then + file "$bin" | grep -q -E 'statically linked|static-pie linked' || { echo "::error::$bin is not static"; file "$bin"; exit 1; } + else + echo "note: no file(1) here to confirm the binary is static" + fi + "$bin" --version + "$bin" key --demo-key | grep -q '^did:key:z6Mk' + bash release/package.sh "$bin" "${{ needs.version.outputs.version }}" "$t" "$RUNNER_TEMP/dist" + - name: Attach to the release + shell: bash + working-directory: wai/wai-quantum-cli + env: + FORGE_TOKEN: ${{ github.token }} + run: bash release/publish.sh "$GITHUB_REF_NAME" "wai-quantum ${{ needs.version.outputs.version }}" "$RUNNER_TEMP"/dist/* + + windows: + name: Windows · x86_64-pc-windows-msvc + needs: version + runs-on: windows + timeout-minutes: 90 + env: + CI: "" + steps: + - uses: actions/checkout@v5 + - name: Build, check and package + shell: pwsh + working-directory: wai/wai-quantum-cli + run: | + $ErrorActionPreference = 'Stop' + Remove-Item Env:CI -ErrorAction SilentlyContinue + cargo build --locked --release --target x86_64-pc-windows-msvc + if ($LASTEXITCODE -ne 0) { throw "the build failed" } + $bin = "target\x86_64-pc-windows-msvc\release\wai-quantum.exe" + & $bin --version + $did = & $bin key --demo-key 2>$null + if ($did -notmatch '^did:key:z6Mk') { throw "no did:key from the binary: $did" } + $v = "${{ needs.version.outputs.version }}" + $name = "wai-quantum-$v-x86_64-pc-windows-msvc" + $stage = Join-Path $env:RUNNER_TEMP $name + $dist = Join-Path $env:RUNNER_TEMP 'dist' + Remove-Item $stage, $dist -Recurse -Force -ErrorAction Ignore + New-Item -ItemType Directory -Force -Path $stage, $dist | Out-Null + Copy-Item $bin, README.md, ..\LICENSE $stage + Compress-Archive -Path $stage -DestinationPath (Join-Path $dist "$name.zip") -Force + $h = (Get-FileHash (Join-Path $dist "$name.zip") -Algorithm SHA256).Hash.ToLower() + Set-Content -NoNewline -Path (Join-Path $dist "$name.zip.sha256") -Value "$h $name.zip`n" + - name: Attach to the release + shell: pwsh + env: + FORGE_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $H = @{ Authorization = "token $env:FORGE_TOKEN" } + $base = "$env:FORGE_API/repos/$env:FORGE_REPO/releases" + $tag = $env:GITHUB_REF_NAME + $rel = $null + for ($i = 0; $i -lt 6 -and -not $rel; $i++) { + try { $rel = Invoke-RestMethod -Headers $H -Uri "$base/tags/$tag" } catch { + try { Invoke-RestMethod -Headers $H -Method Post -Uri $base -ContentType 'application/json' -Body (@{ tag_name = $tag; name = "wai-quantum ${{ needs.version.outputs.version }}" } | ConvertTo-Json) | Out-Null } catch {} + Start-Sleep -Seconds 3 + } + } + if (-not $rel) { throw "no release for $tag" } + foreach ($f in Get-ChildItem (Join-Path $env:RUNNER_TEMP 'dist')) { + foreach ($a in (Invoke-RestMethod -Headers $H -Uri "$base/$($rel.id)/assets" | Where-Object { $_.name -eq $f.Name })) { + Invoke-RestMethod -Headers $H -Method Delete -Uri "$base/$($rel.id)/assets/$($a.id)" | Out-Null + } + curl.exe -fsS -H "Authorization: token $env:FORGE_TOKEN" -X POST "$base/$($rel.id)/assets?name=$($f.Name)" -F "attachment=@$($f.FullName);type=application/octet-stream" -o NUL -w "uploaded $($f.Name): http %{http_code}`n" + if ($LASTEXITCODE -ne 0) { throw "the upload of $($f.Name) failed" } + } + + # The package managers' entries for the archives just attached, from their own + # SHA-256 files: a Homebrew formula (macOS and Linux) and a Scoop manifest (Windows). + # A platform whose build did not attach an archive is left out, and the log says so. + formulae: + name: Homebrew formula and Scoop manifest + needs: [version, macos, linux, windows] + if: always() && needs.version.result == 'success' + runs-on: linux + steps: + - uses: actions/checkout@v5 + - name: Write and attach them + shell: bash + working-directory: wai/wai-quantum-cli + env: + FORGE_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + v="${{ needs.version.outputs.version }}" + out="$RUNNER_TEMP/formulae"; mkdir -p "$out" + python3 release/formulae.py "$v" "$GITHUB_REF_NAME" "$FORGE_API" "$FORGE_REPO" "$PUBLIC" "$out" + bash release/publish.sh "$GITHUB_REF_NAME" "wai-quantum $v" "$out"/* diff --git a/wai/wai-quantum-cli/README.md b/wai/wai-quantum-cli/README.md index 9a220b86f..4ac2b2ffc 100644 --- a/wai/wai-quantum-cli/README.md +++ b/wai/wai-quantum-cli/README.md @@ -2,9 +2,22 @@ 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 -``` +## Installing it + +| Where | How | +|---|---| +| Any platform with Rust | `cargo install wai-quantum-cli` (installs the `wai-quantum` binary) | +| macOS or Linux, with Homebrew | `brew tap transaction-science/tap https://git.transaction.science/Transaction-Science/homebrew-tap` then `brew install wai-quantum` | +| Windows, with Scoop | `scoop bucket add transaction-science https://git.transaction.science/Transaction-Science/scoop-bucket` then `scoop install wai-quantum` | +| Anywhere | a prebuilt archive from the [releases](https://git.transaction.science/Transaction-Science/open-standards/releases) tagged `wai-quantum-cli-v…` | + +The prebuilt archives are: + +- macOS for Apple silicon and for Intel, with the GPU state vector; +- Linux for x86_64 and arm64, linked statically, so any distribution runs them; +- Windows for x86_64. + +Each archive has a `.sha256` file beside it (`shasum -a 256 -c`). A tag `wai-quantum-cli-v` builds them on the project's own runners ([`release/`](release/), `.github/workflows/wai-quantum-cli-release.yml`), and writes the Homebrew formula and the Scoop manifest for them. ## Running a program diff --git a/wai/wai-quantum-cli/release/formulae.py b/wai/wai-quantum-cli/release/formulae.py new file mode 100644 index 000000000..e8ebaca7a --- /dev/null +++ b/wai/wai-quantum-cli/release/formulae.py @@ -0,0 +1,93 @@ +"""The package managers' entries for a wai-quantum release, from the SHA-256 files +attached to it: + + python3 release/formulae.py + +Writes /wai-quantum.rb (a Homebrew formula for macOS and Linux) and +/wai-quantum.json (a Scoop manifest for Windows). A platform whose archive +is not attached is left out, and stderr says so; with none at all, it fails. +""" + +import json +import os +import pathlib +import sys +import urllib.request + +version, tag, api, repo, public, out = sys.argv[1:7] +token = os.environ.get("FORGE_TOKEN", "") +headers = {"Authorization": f"token {token}"} if token else {} + + +def get(url): + with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60) as r: + return r.read() + + +release = json.loads(get(f"{api}/repos/{repo}/releases/tags/{tag}")) +assets = {a["name"]: a for a in release.get("assets", [])} + + +def digest(archive): + """The SHA-256 its .sha256 file states, or None when either is missing.""" + if archive not in assets or archive + ".sha256" not in assets: + print(f"note: {archive} is not attached; left out", file=sys.stderr) + return None + return get(assets[archive + ".sha256"]["browser_download_url"]).decode().split()[0] + + +def url(archive): + return f"{public}/releases/download/{tag}/{archive}" + + +targets = { + ("macos", "arm"): "aarch64-apple-darwin", + ("macos", "intel"): "x86_64-apple-darwin", + ("linux", "arm"): "aarch64-unknown-linux-musl", + ("linux", "intel"): "x86_64-unknown-linux-musl", +} +blocks = {} +for (os_name, cpu), triple in targets.items(): + archive = f"wai-quantum-{version}-{triple}.tar.gz" + sha = digest(archive) + if sha: + blocks.setdefault(os_name, []).append(f' on_{cpu} do\n url "{url(archive)}"\n sha256 "{sha}"\n end\n') +win = f"wai-quantum-{version}-x86_64-pc-windows-msvc" +win_sha = digest(win + ".zip") +if not blocks and not win_sha: + sys.exit("no archive is attached to the release") + +formula = f'''class WaiQuantum < Formula + desc "OpenQASM programs run as the WAI quantum job service runs them, signed" + homepage "https://wai.transaction.science/quantum-studio/" + version "{version}" + license "Apache-2.0" +''' +for os_name in ("macos", "linux"): + if os_name in blocks: + formula += f" on_{os_name} do\n" + "".join(blocks[os_name]) + " end\n" +formula += ''' + def install + bin.install "wai-quantum" + generate_completions_from_executable(bin/"wai-quantum", "completions") + end + + test do + assert_match "did:key:z6Mk", shell_output("#{bin}/wai-quantum key --demo-key") + end +end +''' +pathlib.Path(out, "wai-quantum.rb").write_text(formula) + +sha = win_sha +if sha: + manifest = { + "version": version, + "description": "OpenQASM programs run as the WAI quantum job service runs them, signed", + "homepage": "https://wai.transaction.science/quantum-studio/", + "license": "Apache-2.0", + "architecture": {"64bit": {"url": url(win + ".zip"), "hash": sha, "extract_dir": win}}, + "bin": "wai-quantum.exe", + } + pathlib.Path(out, "wai-quantum.json").write_text(json.dumps(manifest, indent=2) + "\n") +print(f"wrote the formula{' and the manifest' if sha else ''} for wai-quantum {version}") diff --git a/wai/wai-quantum-cli/release/package.sh b/wai/wai-quantum-cli/release/package.sh new file mode 100755 index 000000000..95c2ec3ec --- /dev/null +++ b/wai/wai-quantum-cli/release/package.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Package a built wai-quantum binary for release: +# +# bash release/package.sh +# +# Writes /wai-quantum--.tar.gz holding wai-quantum (or +# wai-quantum.exe), README.md and LICENSE, and beside it the archive's SHA-256 in +# .sha256 in the form `shasum -a 256 -c` reads. Run from wai/wai-quantum-cli. +set -euo pipefail +if [ $# -ne 4 ]; then + echo "usage: bash release/package.sh " >&2 + exit 2 +fi +BIN="$1"; VERSION="$2"; TRIPLE="$3"; OUT="$4" +NAME="wai-quantum-$VERSION-$TRIPLE" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +mkdir -p "$STAGE/$NAME" "$OUT" +case "$BIN" in + *.exe) cp "$BIN" "$STAGE/$NAME/wai-quantum.exe" ;; + *) cp "$BIN" "$STAGE/$NAME/wai-quantum"; chmod 755 "$STAGE/$NAME/wai-quantum" ;; +esac +cp README.md "$STAGE/$NAME/README.md" +cp ../LICENSE "$STAGE/$NAME/LICENSE" +# The same bytes for the same inputs: sorted names, fixed owner and times. +tar --sort=name --owner=0 --group=0 --numeric-owner --mtime='2026-01-01 00:00Z' -C "$STAGE" -cf - "$NAME" 2>/dev/null \ + | gzip -n -9 > "$OUT/$NAME.tar.gz" \ + || tar -C "$STAGE" -cf - "$NAME" | gzip -n -9 > "$OUT/$NAME.tar.gz" +( cd "$OUT" && { command -v sha256sum >/dev/null && sha256sum "$NAME.tar.gz" || shasum -a 256 "$NAME.tar.gz"; } > "$NAME.tar.gz.sha256" ) +echo "packaged $OUT/$NAME.tar.gz ($(cut -d' ' -f1 "$OUT/$NAME.tar.gz.sha256"))" diff --git a/wai/wai-quantum-cli/release/publish.sh b/wai/wai-quantum-cli/release/publish.sh new file mode 100755 index 000000000..69099b058 --- /dev/null +++ b/wai/wai-quantum-cli/release/publish.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Attach files to the forge release for a tag, creating the release if there is +# none, and replacing an asset of the same name: +# +# FORGE_API=http://host:3400/api/v1 FORGE_REPO=owner/repo FORGE_TOKEN=... \ +# bash release/publish.sh <file>... +# +# Uploads go to FORGE_API directly (over the private network), never through the +# public tunnel, whose request size cap a large asset would hit. +set -euo pipefail +if [ $# -lt 3 ]; then + echo "usage: bash release/publish.sh <tag> <title> <file>..." >&2 + exit 2 +fi +TAG="$1"; TITLE="$2"; shift 2 +: "${FORGE_API:?}" "${FORGE_REPO:?}" "${FORGE_TOKEN:?}" +AUTH="Authorization: token $FORGE_TOKEN" +BASE="$FORGE_API/repos/$FORGE_REPO/releases" +release_id() { curl -fsS -H "$AUTH" "$BASE/tags/$TAG" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("id", ""))' 2>/dev/null || true; } +ID="$(release_id)" +if [ -z "$ID" ]; then + BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[2], "body": "The wai-quantum command line for macOS, Linux and Windows. Each archive holds the binary, its README and its licence; each .sha256 file checks its archive (`shasum -a 256 -c`)."}))' "$TAG" "$TITLE")" + curl -fsS -H "$AUTH" -H 'content-type: application/json' -X POST "$BASE" -d "$BODY" > /dev/null 2>&1 || true + # Builds run side by side; one of them created it if this did not. + for _ in 1 2 3 4 5 6; do ID="$(release_id)"; [ -n "$ID" ] && break; sleep 3; done +fi +[ -n "$ID" ] || { echo "::error::no release for $TAG"; exit 1; } +for f in "$@"; do + name="$(basename "$f")" + for old in $(curl -fsS -H "$AUTH" "$BASE/$ID/assets" | NAME="$name" python3 -c 'import json,os,sys; print(" ".join(str(a["id"]) for a in json.load(sys.stdin) if a["name"] == os.environ["NAME"]))'); do + curl -fsS -H "$AUTH" -X DELETE "$BASE/$ID/assets/$old" > /dev/null + done + curl -fsS -H "$AUTH" -X POST "$BASE/$ID/assets?name=$name" -F "attachment=@$f;type=application/octet-stream" -o /dev/null -w "uploaded $name: http %{http_code}\n" +done -- 2.54.0 (Apple Git-157) From b8773cf47f8ee58986b834a46f117eb6685bcdf0 Mon Sep 17 00:00:00 2001 From: Transaction Science <hello@transaction.science> Date: Tue, 20 Oct 2026 14:02:27 -0400 Subject: [PATCH 14/19] Quantum Studio: the circuit as a diagram, from the engine's layout - engine::draw::layout: the drawing as data. It gives the qubits, the columns (each item's cells as [qubit, label], and whether a line joins them) and the legend of conditions, packed as the text drawing packs them. The packing moves into one function, which both forms use. - wai-web's engine binding gains engine_layout, and the desktop app gains `layout`. - The Studio draws the circuit as SVG from it: - wires, gate boxes, a control's dot joined to its target's ring, swaps; - measurements as meters with their bit, resets; - a gate under a condition with a dashed outline and the condition's number. Colours come from the page's tokens, each item has a title naming its gates and qubits, and the SVG has a text alternative. "Text" shows the text drawing to copy. Past 400 columns the diagram stops and says so. - Tests: the layout of a circuit with a shared column, a joined CX and a conditioned gate; the binding's layout. The Studio test adds the diagram's glyphs and text alternative, and the Text toggle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit c2e89a9fd9bffaa2b29621bbb3f63ddb9bdc20b5) --- wai/quantum-studio/src/circuit-svg.js | 84 +++++++++++++++++++++++++ wai/quantum-studio/src/engine.js | 2 + wai/quantum-studio/src/main.js | 29 +++++++-- wai/quantum-studio/src/studio.css | 18 ++++++ wai/quantum-studio/src/worker.js | 1 + wai/quantum-studio/test/studio.test.mjs | 5 ++ wai/wai-quantum-desktop/src/main.rs | 10 ++- wai/wai-quantum/src/engine/draw.rs | 52 ++++++++++++--- wai/wai-web/src/quantum_engine.rs | 12 ++++ 9 files changed, 199 insertions(+), 14 deletions(-) create mode 100644 wai/quantum-studio/src/circuit-svg.js diff --git a/wai/quantum-studio/src/circuit-svg.js b/wai/quantum-studio/src/circuit-svg.js new file mode 100644 index 000000000..5cfca134e --- /dev/null +++ b/wai/quantum-studio/src/circuit-svg.js @@ -0,0 +1,84 @@ +// A circuit drawn as SVG from the engine's layout (engine::draw::layout): one wire per +// qubit, gates packed into columns, a control as a dot, a CX target as ⊕, a swap as ×, +// a measurement as a meter with its bit, a reset as |0⟩, and a gate under a condition +// with a dashed outline and the condition's number. Colours come from the page's +// tokens, through classes in studio.css. +const NS = 'http://www.w3.org/2000/svg'; +const ROW = 40; // between wires +const LEFT = 40; // the qubits' names +const PAD = 14; +const MAX_COLUMNS = 400; + +function node(tag, attrs = {}, ...kids) { + const e = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) if (v != null) e.setAttribute(k, String(v)); + for (const k of kids) if (k != null) e.append(k); + return e; +} +const text = (x, y, s, cls) => node('text', { x, y, class: cls, 'text-anchor': 'middle', 'dominant-baseline': 'central' }, s); + +// A label's glyph, its condition (the n of a trailing ?n) and its width. +function parse(label) { + const m = /^(.*)\?(\d+)$/.exec(label); + const base = m ? m[1] : label; + const cond = m ? m[2] : null; + const glyph = base === '●' ? 'control' : base === '⊕' ? 'target' : base === '×' ? 'swap' : /^M\d+$/.test(base) ? 'measure' : base === '|0⟩' ? 'reset' : 'gate'; + const width = glyph === 'gate' || glyph === 'reset' ? Math.max(28, 7.4 * [...base].length + 14) : 28; + return { base, cond, glyph, width }; +} + +function cell(x, y, p) { + const g = node('g', { class: `qc-${p.glyph}${p.cond ? ' qc-cond' : ''}` }); + switch (p.glyph) { + case 'control': g.append(node('circle', { cx: x, cy: y, r: 5, class: 'qc-dot' })); break; + case 'target': + g.append(node('circle', { cx: x, cy: y, r: 10, class: 'qc-ring' }), node('line', { x1: x - 10, y1: y, x2: x + 10, y2: y, class: 'qc-stroke' }), node('line', { x1: x, y1: y - 10, x2: x, y2: y + 10, class: 'qc-stroke' })); + break; + case 'swap': + g.append(node('line', { x1: x - 6, y1: y - 6, x2: x + 6, y2: y + 6, class: 'qc-stroke' }), node('line', { x1: x - 6, y1: y + 6, x2: x + 6, y2: y - 6, class: 'qc-stroke' })); + break; + case 'measure': { + const bit = p.base.slice(1); + g.append(node('rect', { x: x - 14, y: y - 12, width: 28, height: 24, rx: 4, class: 'qc-box' }), + node('path', { d: `M ${x - 8} ${y + 5} A 9 9 0 0 1 ${x + 8} ${y + 5}`, class: 'qc-meter' }), + node('line', { x1: x, y1: y + 5, x2: x + 6, y2: y - 5, class: 'qc-meter' }), + text(x + 13, y + 15, bit, 'qc-sub')); + break; + } + default: { + const w = p.width; + g.append(node('rect', { x: x - w / 2, y: y - 13, width: w, height: 26, rx: 5, class: 'qc-box' }), text(x, y, p.base, 'qc-label')); + } + } + if (p.cond) g.append(text(x + p.width / 2 + 2, y - 15, p.cond, 'qc-sub')); + return g; +} + +// The SVG for `layout`, and how much of it was drawn. +export function circuitSvg(layout) { + const n = layout.qubits; + const cols = layout.columns.slice(0, MAX_COLUMNS); + const widths = cols.map((col) => Math.max(36, ...col.flatMap((it) => it.cells.map(([, l]) => parse(l).width + 12)))); + const W = LEFT + PAD + widths.reduce((a, b) => a + b, 0) + PAD; + const H = n * ROW + 10; + const y = (q) => 25 + q * ROW; + const gates = layout.columns.reduce((s, c) => s + c.length, 0); + const svg = node('svg', { xmlns: NS, width: W, height: H, viewBox: `0 0 ${W} ${H}`, class: 'qc', role: 'img', 'aria-label': `The circuit: ${n} qubits, ${gates} operations in ${layout.columns.length} columns` }); + for (let q = 0; q < n; q++) { + svg.append(text(LEFT - 14, y(q), `q${q}`, 'qc-name'), node('line', { x1: LEFT, y1: y(q), x2: W - PAD, y2: y(q), class: 'qc-wire' })); + } + let x = LEFT + PAD; + cols.forEach((col, ci) => { + const cx = x + widths[ci] / 2; + for (const it of col) { + const qs = it.cells.map(([q]) => q); + const g = node('g', { class: 'qc-item' }); + g.append(node('title', {}, it.cells.map(([q, l]) => `${l} on q${q}`).join(', '))); + if (it.joined && qs.length > 1) g.append(node('line', { x1: cx, y1: y(Math.min(...qs)), x2: cx, y2: y(Math.max(...qs)), class: 'qc-join' })); + for (const [q, l] of it.cells) g.append(cell(cx, y(q), parse(l))); + svg.append(g); + } + x += widths[ci]; + }); + return { svg, drawn: cols.length, total: layout.columns.length }; +} diff --git a/wai/quantum-studio/src/engine.js b/wai/quantum-studio/src/engine.js index 8c9a0ca41..2453a3388 100644 --- a/wai/quantum-studio/src/engine.js +++ b/wai/quantum-studio/src/engine.js @@ -58,6 +58,7 @@ export class TabEngine { methods() { return this.call('methods'); } lint(qasm) { return this.call('lint', { qasm }); } draw(qasm, width) { return this.call('draw', { qasm, width }); } + layout(qasm) { return this.call('layout', { qasm }); } verify(job) { return this.call('verify', { job }); } async signer() { return (await this.call('signer')).did; } cancel() { @@ -120,6 +121,7 @@ export class DesktopEngine { // A lint names a problem as {ok: false, error}, an answer and not a failure. async lint(qasm) { return JSON.parse(await this.invoke('lint', { qasm })); } draw(qasm, width) { return this.call('draw', { qasm, width }); } + layout(qasm) { return this.call('layout', { qasm }); } verify(job) { return this.call('verify', { job: JSON.stringify(job) }); } async signer() { return (await this.call('signer', {})).did; } saveJob(name, job) { return this.call('save_job', { name, job: JSON.stringify(job, null, 2) }); } diff --git a/wai/quantum-studio/src/main.js b/wai/quantum-studio/src/main.js index d81955d60..82520f0c7 100644 --- a/wai/quantum-studio/src/main.js +++ b/wai/quantum-studio/src/main.js @@ -19,6 +19,7 @@ import { openqasm, complete } from './qasm-lang.js'; import { TabEngine, ServiceEngine, DesktopEngine, onDesktop } from './engine.js'; import { EXAMPLES } from './examples.js'; import * as History from './history.js'; +import { circuitSvg } from './circuit-svg.js'; import { Assistant, HttpModel, DesktopModel } from './assistant.js'; const METHOD_NAMES = { @@ -108,9 +109,18 @@ function mount(root) { shows, editorHost, jobBox); // ---- the circuit ---- - const circuit = el('pre', { class: 'qs-circuit', id: 'qs-circuit', tabindex: '0', 'aria-label': 'The circuit, drawn as text' }); + const circuit = el('pre', { class: 'qs-circuit', id: 'qs-circuit', tabindex: '0', 'aria-label': 'The circuit, drawn as text', hidden: true }); + const diagram = el('div', { class: 'qs-diagram', id: 'qs-diagram', tabindex: '0' }); + const legend = el('p', { class: 'qs-note qs-legend', id: 'qs-legend' }); + const asText = el('button', { class: 'qs-btn qs-small', id: 'qs-astext', type: 'button', 'aria-pressed': 'false', text: 'Text', title: 'The circuit as text, to copy' }); + asText.addEventListener('click', () => { + const on = circuit.hidden; + circuit.hidden = !on; diagram.hidden = on; + asText.setAttribute('aria-pressed', String(on)); asText.textContent = on ? 'Diagram' : 'Text'; + }); const circuitPane = el('section', { class: 'qs-pane qs-circ', 'aria-labelledby': 'qs-circuit-h' }, - el('div', { class: 'qs-head' }, el('h2', { id: 'qs-circuit-h', text: 'Circuit' }), el('span', { class: 'qs-grow' }), el('span', { class: 'qs-note', id: 'qs-plan' })), circuit); + el('div', { class: 'qs-head' }, el('h2', { id: 'qs-circuit-h', text: 'Circuit' }), el('span', { class: 'qs-grow' }), el('span', { class: 'qs-note', id: 'qs-plan' }), asText), + diagram, circuit, legend); // ---- the result ---- const state = el('span', { class: 'qs-badge', id: 'qs-state', text: 'ready' }); @@ -242,11 +252,18 @@ function mount(root) { const src = text(); try { localStorage.setItem(DRAFT, src); } catch (e) {} try { - const d = await local.draw(src, Math.max(40, Math.floor(circuit.clientWidth / 7.8) || 80)); + const [d, l] = await Promise.all([local.draw(src, Math.max(40, Math.floor((circuit.clientWidth || diagram.clientWidth) / 7.8) || 80)), local.layout(src)]); circuit.textContent = d.text ?? ''; - circuit.classList.toggle('stale', !!d.error); - if (d.error) circuit.textContent = d.error; - } catch (e) { circuit.textContent = e.message; circuit.classList.add('stale'); } + circuit.classList.remove('stale'); + const { svg, drawn, total } = circuitSvg(l); + diagram.replaceChildren(svg); + diagram.classList.remove('stale'); + legend.textContent = [...l.legend.map((c, i) => `${i + 1}: ${c}`), drawn < total ? `The first ${drawn} of ${total} columns are drawn; “Text” shows them all.` : ''].filter(Boolean).join(' · '); + } catch (e) { + circuit.textContent = e.message; circuit.classList.add('stale'); + diagram.replaceChildren(el('p', { class: 'qs-note', text: e.message })); diagram.classList.add('stale'); + legend.textContent = ''; + } plan(); }, 250); } diff --git a/wai/quantum-studio/src/studio.css b/wai/quantum-studio/src/studio.css index bc8f814ef..32dd7e8b5 100644 --- a/wai/quantum-studio/src/studio.css +++ b/wai/quantum-studio/src/studio.css @@ -61,6 +61,24 @@ .qs-badge.run { color: var(--info-text); border-color: color-mix(in srgb, var(--info-text) 40%, transparent); } .qs-circuit { margin: 0; padding: 10px 12px; min-height: 120px; max-height: 320px; overflow: auto; font: 12px/1.35 var(--font-mono, ui-monospace, monospace); color: var(--text-primary); background: var(--bg-base); white-space: pre; } .qs-circuit.stale { color: var(--bad-text); white-space: pre-wrap; } +.qs-circuit[hidden], .qs-diagram[hidden] { display: none; } +.qs-diagram { min-height: 120px; max-height: 360px; overflow: auto; padding: 6px 4px; background: var(--bg-base); } +.qs-diagram.stale .qs-note { color: var(--bad-text); padding: 6px 8px; } +.qs-legend { margin: 0; padding: 0 12px 8px; } +.qs-legend:empty { display: none; } +.qc { display: block; font-family: var(--font-mono, ui-monospace, monospace); } +.qc-wire { stroke: var(--border-subtle); stroke-width: 1.5; } +.qc-name { fill: var(--text-muted); font-size: 11px; } +.qc-join { stroke: var(--text-secondary); stroke-width: 1.5; } +.qc-box { fill: var(--bg-surface); stroke: var(--accent); stroke-width: 1.3; } +.qc-cond .qc-box { stroke-dasharray: 3 2; } +.qc-label { fill: var(--text-primary); font-size: 12px; } +.qc-sub { fill: var(--text-muted); font-size: 9px; } +.qc-dot { fill: var(--text-primary); } +.qc-ring { fill: var(--bg-surface); stroke: var(--text-primary); stroke-width: 1.5; } +.qc-stroke { stroke: var(--text-primary); stroke-width: 1.5; } +.qc-meter { fill: none; stroke: var(--info-text, var(--accent)); stroke-width: 1.4; } +.qc-item:hover .qc-box, .qc-item:hover .qc-ring { stroke-width: 2.2; } .qs-note { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono, ui-monospace, monospace); overflow-wrap: anywhere; } .qs-result { padding: 10px 12px; display: flex; flex-direction: column; gap: 12px; overflow: auto; } .qs-empty { margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; } diff --git a/wai/quantum-studio/src/worker.js b/wai/quantum-studio/src/worker.js index 05ddc2262..e97401d0d 100644 --- a/wai/quantum-studio/src/worker.js +++ b/wai/quantum-studio/src/worker.js @@ -35,6 +35,7 @@ self.onmessage = async (ev) => { case 'signer': text = JSON.stringify({ did: e().signer_did() }); break; case 'lint': text = mod.engine_lint(ev.data.qasm); break; case 'draw': text = mod.engine_draw(ev.data.qasm, ev.data.width ?? 100); break; + case 'layout': text = mod.engine_layout(ev.data.qasm); break; case 'verify': text = mod.engine_verify(JSON.stringify(ev.data.job)); break; default: throw new Error(`no operation ${op}`); } diff --git a/wai/quantum-studio/test/studio.test.mjs b/wai/quantum-studio/test/studio.test.mjs index dc882fd57..53440d824 100644 --- a/wai/quantum-studio/test/studio.test.mjs +++ b/wai/quantum-studio/test/studio.test.mjs @@ -62,6 +62,11 @@ check((await page.textContent('#qs-lint')) === '2 qubits · 2 gates', `the first await page.waitForFunction(() => document.querySelector('#qs-circuit')?.textContent.includes('⊕'), null, { timeout: LONG }); const drawing = await page.textContent('#qs-circuit'); check(/q0: .*H.*●/.test(drawing) && /q1: .*⊕/.test(drawing), 'the circuit is drawn as the program is typed'); +const svg = await page.$eval('#qs-diagram svg', (g) => ({ label: g.getAttribute('aria-label'), rings: g.querySelectorAll('.qc-ring').length, dots: g.querySelectorAll('.qc-dot').length, joins: g.querySelectorAll('.qc-join').length, meters: g.querySelectorAll('.qc-measure').length })); +check(svg.rings === 1 && svg.dots === 1 && svg.joins === 1 && svg.meters === 2 && /2 qubits/.test(svg.label), `the circuit is drawn as a diagram with a text alternative (${JSON.stringify(svg)})`); +await page.click('#qs-astext'); +check(await page.isVisible('#qs-circuit') && await page.isHidden('#qs-diagram'), '“Text” shows the circuit as text to copy'); +await page.click('#qs-astext'); await page.waitForFunction(() => /Stabilizer tableau/.test(document.querySelector('#qs-plan')?.textContent ?? ''), null, { timeout: LONG }); check(true, `the plan is shown before running (${await page.textContent('#qs-plan')})`); diff --git a/wai/wai-quantum-desktop/src/main.rs b/wai/wai-quantum-desktop/src/main.rs index b3d4e44f0..112d53f02 100644 --- a/wai/wai-quantum-desktop/src/main.rs +++ b/wai/wai-quantum-desktop/src/main.rs @@ -140,6 +140,14 @@ fn draw(qasm: String, width: u32) -> String { } } +#[tauri::command] +fn layout(qasm: String) -> String { + match wai_quantum::qasm::parse(&qasm) { + Ok(p) => draw::layout(&p).to_string(), + Err(e) => error_json(e), + } +} + #[tauri::command] fn verify(job: String) -> String { match serde_json::from_str::<Value>(&job).map_err(|e| format!("not JSON: {e}")).and_then(|v| job::verify(&v)) { @@ -272,7 +280,7 @@ fn main() { }; tauri::Builder::default() .manage(Studio { key, job_bytes: job_bytes(), running: Arc::new(Mutex::new(None)), cancelled: Arc::new(Mutex::new(false)) }) - .invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, draw, verify, signer, save_job, save_program, chat, models]) + .invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, draw, layout, verify, signer, save_job, save_program, chat, models]) .run(tauri::generate_context!()) .expect("the window opens"); } diff --git a/wai/wai-quantum/src/engine/draw.rs b/wai/wai-quantum/src/engine/draw.rs index 0e46061fd..5ea272971 100644 --- a/wai/wai-quantum/src/engine/draw.rs +++ b/wai/wai-quantum/src/engine/draw.rs @@ -158,15 +158,11 @@ fn items(p: &Program) -> (Vec<Item>, Vec<String>) { (out, legend) } -/// The drawing, wrapped at `width` columns. -pub fn draw(p: &Program, width: usize) -> String { +/// The items packed into columns: each goes to the first column after the +/// last one used on any wire it covers. With the legend of conditions. +fn columns(p: &Program) -> (Vec<Vec<Item>>, Vec<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<Item>> = Vec::new(); for it in items { @@ -181,6 +177,34 @@ pub fn draw(p: &Program, width: usize) -> String { } columns[col].push(it); } + (columns, legend) +} + +/// The same drawing as data, for a page to draw: the qubits, the columns +/// (each a list of items, each its cells as `[qubit, label]` and whether a +/// line joins them) and the legend of conditions. A label is what the text +/// drawing shows: `●` a control, `⊕` a CX target, `×` a swap, `Mk` a +/// measurement into bit k, `|0⟩` a reset, any other the gate; `?n` after it +/// marks condition n. +pub fn layout(p: &Program) -> serde_json::Value { + let (columns, legend) = columns(p); + serde_json::json!({ + "qubits": p.n_qubits, + "columns": columns.iter().map(|col| col.iter().map(|it| serde_json::json!({ + "cells": it.cells.iter().map(|(q, l)| serde_json::json!([q, l])).collect::<Vec<_>>(), + "joined": it.joined, + })).collect::<Vec<_>>()).collect::<Vec<_>>(), + "legend": 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 (columns, legend) = columns(p); 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); @@ -310,6 +334,20 @@ mod tests { assert!(d.contains("X?1") && d.contains("?1 if bit 0 = 1"), "{d}"); } + #[test] + fn the_layout_is_the_drawing_as_data() { + let l = layout(&parse("OPENQASM 3.0; qubit[3] q; bit[3] c; h q[0]; h q[2]; cx q[0], q[2]; c[0] = measure q[0]; if (c[0]) x q[1];").unwrap()); + assert_eq!(l["qubits"], 3); + let cols = l["columns"].as_array().unwrap(); + // The two H share the first column; the CX joins q0 to q2 in the second. + assert_eq!(cols[0].as_array().unwrap().len(), 2, "{l}"); + let cx = &cols[1][0]; + assert_eq!(cx["joined"], true); + assert_eq!(cx["cells"], serde_json::json!([[0, "●"], [2, "⊕"]])); + assert!(l.to_string().contains("\"X?1\""), "{l}"); + assert_eq!(l["legend"][0], "if bit 0 = 1"); + } + #[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)); diff --git a/wai/wai-web/src/quantum_engine.rs b/wai/wai-web/src/quantum_engine.rs index ab3d04ee6..04a84152c 100644 --- a/wai/wai-web/src/quantum_engine.rs +++ b/wai/wai-web/src/quantum_engine.rs @@ -87,6 +87,16 @@ pub fn engine_draw(src: &str, width: u32) -> String { } } +/// A program's circuit as data for a page to draw: `{"qubits", "columns", +/// "legend"}` ([`draw::layout`]), or `{"error"}`. +#[wasm_bindgen] +pub fn engine_layout(src: &str) -> String { + match wai::qasm::parse(src) { + Ok(p) => draw::layout(&p).to_string(), + Err(e) => error_json(e), + } +} + /// Whether a job's receipt seals its request and its result, checked here: /// `{"valid", "key", "signer"}` (the signer as a `did:key`), or `{"error"}`. #[wasm_bindgen] @@ -164,6 +174,8 @@ mod tests { assert_eq!(l["line"], 2, "{l}"); let d: Value = serde_json::from_str(&engine_draw("qubit[2] q; h q[0]; cx q[0], q[1];", 80)).unwrap(); assert!(d["text"].as_str().unwrap().contains('⊕'), "{d}"); + let l: Value = serde_json::from_str(&engine_layout("qubit[2] q; h q[0]; cx q[0], q[1];")).unwrap(); + assert_eq!(l["columns"][1][0]["cells"], serde_json::json!([[0, "●"], [1, "⊕"]]), "{l}"); let m: Value = serde_json::from_str(&e.methods()).unwrap(); assert!(m["methods"].as_array().unwrap().iter().any(|x| x["method"] == "cutrank"), "{m}"); } -- 2.54.0 (Apple Git-157) From caa6088bd760a0c65fa85cf25c824e9d2a5fb95e Mon Sep 17 00:00:00 2001 From: Transaction Science <hello@transaction.science> Date: Tue, 20 Oct 2026 14:25:01 -0400 Subject: [PATCH 15/19] wai-quantum tn: path search from independent restarts; the engine runs eight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_path_restarts runs find_path from several seeds side by side and keeps the cheapest plan. The first restart uses the given seed, so the plan is never costlier than one search. Each restart's seed depends only on its number, so the plan does not depend on the thread count. In a browser tab the restarts run in turn. On large networks the search's cost varies widely with its seed. More trials in one search barely move it; independent restarts do. The other restarts' seeds come from a generator of their own: seeds a multiple of the search generator's step apart would start each search on another's stream a few steps on, and the restarts would repeat one another, which a first version did. The engine's tensor_network method runs 8 restarts natively and 1 in a tab, with a job option path_restarts (1–64) to set it. examples/tn_cost takes RESTARTS. The 61-qubit Nighthawk logical circuits, split and reduced, 1024 trials. log10 complex multiply-adds for one amplitude: depth 1 search 8 restarts 24 restarts (wall time) 8 5.33 5.33 5.26 (4 s) 12 8.17 8.10 8.09 (7 s) 16 11.24 11.11 11.11 (19 s) 20 14.35 14.01 13.82 (33 s) Test: restarts never cost more than one search, repeat their plan, and contract to the dense state vector's amplitude. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 260a3ee996d6a4aabbba4930925bbce1fdf03cd7) --- wai/wai-quantum/examples/tn_cost.rs | 9 ++-- wai/wai-quantum/src/engine/catalog.rs | 2 +- wai/wai-quantum/src/engine/run.rs | 7 ++- wai/wai-quantum/src/quantum_tn.rs | 65 +++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/wai/wai-quantum/examples/tn_cost.rs b/wai/wai-quantum/examples/tn_cost.rs index 12b898c5a..83ca5ae43 100644 --- a/wai/wai-quantum/examples/tn_cost.rs +++ b/wai/wai-quantum/examples/tn_cost.rs @@ -2,7 +2,7 @@ //! the hyper-optimised path search finds it. //! //! ```text -//! cargo run --release --example tn_cost --features quantum_tn -- CIRCUIT.qasm TRIALS SEED [LOG2_MAX_SIZE] +//! cargo run --release --example tn_cost --features quantum_tn -- CIRCUIT.qasm TRIALS SEED [LOG2_MAX_SIZE|-] [RESTARTS] //! ``` //! //! `CIRCUIT.qasm`: OpenQASM 3 with `rz`, `rx`, `ry`, `h`, `x`, `sx`, `cz`, @@ -14,14 +14,15 @@ use std::time::Instant; use wai_quantum::gates::Gate; -use wai_quantum::quantum_tn::{Network, PathOptions, find_path}; +use wai_quantum::quantum_tn::{Network, PathOptions, find_path_restarts}; fn main() { let args: Vec<String> = std::env::args().collect(); let src = std::fs::read_to_string(&args[1]).expect("the circuit reads"); let trials: usize = args[2].parse().unwrap(); let seed: u64 = args[3].parse().unwrap(); - let max_size = args.get(4).map(|s| s.parse::<f64>().unwrap().exp2()); + let max_size = args.get(4).filter(|s| *s != "-").map(|s| s.parse::<f64>().unwrap().exp2()); + let restarts: usize = args.get(5).map(|s| s.parse().unwrap()).unwrap_or(1); let mut n = 0u32; let mut gates = Vec::new(); for line in src.lines().map(str::trim) { @@ -63,7 +64,7 @@ fn main() { net.reduce(); } let t = Instant::now(); - let plan = find_path(&net.shape(), &net.dims, &net.open, &PathOptions { trials, seed, max_size, ..PathOptions::default() }); + let plan = find_path_restarts(&net.shape(), &net.dims, &net.open, &PathOptions { trials, seed, max_size, ..PathOptions::default() }, restarts); println!( "{n} qubits, {} gates: log10 flops {:.3}, log2 largest {:.1}, slices {:.3e}, searched in {:?}", gates.len(), diff --git a/wai/wai-quantum/src/engine/catalog.rs b/wai/wai-quantum/src/engine/catalog.rs index 8a4c10152..1fe487ba1 100644 --- a/wai/wai-quantum/src/engine/catalog.rs +++ b/wai/wai-quantum/src/engine/catalog.rs @@ -106,7 +106,7 @@ pub fn describe(job_bytes: u64) -> Value { { "method": "stabilizer", "accuracy": "exact; with Pauli noise, counts are noisy samples and expectations estimates", "circuits": "Clifford (rotations by whole quarter turns, controlled phases by whole half turns), optionally with depolarizing, dephasing and readout noise", "max_qubits": STABILIZER_MAX_QUBITS, "answers": ["counts", "expectations"], "options": { "trajectories": "with noise: the shots expectation values are estimated from (default: the shots, at least 10000)" } }, { "method": "pauli", "accuracy": "bounded", "circuits": "any", "max_qubits": PAULI_MAX_QUBITS, "answers": ["expectations"], "options": { "threshold": "coefficients below it are dropped (default 1e-5); the bound covers what was dropped", "max_weight": "Pauli strings heavier than this are dropped", "ladder": "also report the value at up to three thresholds a decade apart above it, to show it settling (default true)", "max_terms": "the most Pauli terms to hold (default: what the job's memory holds); past it the threshold rises, and the bound covers what drops" } }, { "method": "mps", "accuracy": "exact when nothing is truncated, otherwise heuristic", "circuits": "any; best on low-entanglement circuits", "max_qubits": MPS_MAX_QUBITS, "answers": ["counts", "expectations", "amplitudes"], "options": { "max_bond": "the largest bond dimension kept (default 64, at most 256)", "cutoff": "singular values whose squared share is below it are dropped (default 1e-12)" } }, - { "method": "tensor_network", "accuracy": "exact", "circuits": "any", "max_qubits": qasm::MAX_QUBITS, "answers": ["amplitudes"] }, + { "method": "tensor_network", "accuracy": "exact", "circuits": "any", "max_qubits": qasm::MAX_QUBITS, "answers": ["amplitudes"], "options": { "path_restarts": "independent contraction-path searches, the cheapest kept (default 8; 1 in a browser tab)" } }, { "method": "cutrank", "accuracy": "exact; single when only a single-precision table fits", "circuits": "a Clifford circuit, then one layer of one-qubit gates at any angles: each qubit's gates from its first non-Clifford one on act on that qubit alone; the cost is set by the state's cut-rank along an order of the qubits, not by their number", "max_qubits": CUTRANK_MAX_QUBITS, "answers": ["counts", "amplitudes"] }, { "method": "density_matrix", "accuracy": "exact", "circuits": "any, with noise", "max_qubits": density_max_qubits(job_bytes), "answers": ["counts", "expectations"] }, { "method": "trajectories", "accuracy": "estimate", "circuits": "any, with noise", "max_qubits": TRAJECTORY_MAX_QUBITS.min(statevector_max_qubits(job_bytes)), "answers": ["counts", "expectations"], "options": { "trajectories": "how many (default: the shots, or 1000 for expectations alone); shots that take the same errors share one state" } }, diff --git a/wai/wai-quantum/src/engine/run.rs b/wai/wai-quantum/src/engine/run.rs index 08549f738..8e9760323 100644 --- a/wai/wai-quantum/src/engine/run.rs +++ b/wai/wai-quantum/src/engine/run.rs @@ -430,7 +430,10 @@ pub fn run(request: &Value, plan: &Plan) -> Result<Value, String> { } } "tensor_network" => { - use crate::quantum_tn::{Network, PathOptions, contract_plan, find_path}; + use crate::quantum_tn::{Network, PathOptions, contract_plan, find_path_restarts}; + // Independent path searches, side by side: the search's cost varies with its + // seed, and the cheapest of a few is much cheaper than one. A tab has one thread. + let restarts = r.options.get("path_restarts").and_then(Value::as_u64).map(|k| k.clamp(1, 64) as usize).unwrap_or(if cfg!(target_arch = "wasm32") { 1 } else { 8 }); let gates = r.program.matrices(); out["accuracy"] = json!({ "class": "exact" }); let mut entries = Vec::new(); @@ -441,7 +444,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result<Value, String> { // Controlled and diagonal gates split at their block-diagonal qubits. let mut net = Network::amplitude_split(n, &gates, &bits, &[]).map_err(|e| format!("{e:?}"))?; net.reduce(); - let plan = find_path(&net.shape(), &net.dims, &net.open, &PathOptions::default()); + let plan = find_path_restarts(&net.shape(), &net.dims, &net.open, &PathOptions::default(), restarts); flops += plan.total_flops(); let t = contract_plan(&net, &plan).map_err(|e| format!("{e:?}"))?; let a = t.data.first().copied().unwrap_or(crate::gates::complex(0.0, 0.0)); diff --git a/wai/wai-quantum/src/quantum_tn.rs b/wai/wai-quantum/src/quantum_tn.rs index 41902b492..72ecf4872 100644 --- a/wai/wai-quantum/src/quantum_tn.rs +++ b/wai/wai-quantum/src/quantum_tn.rs @@ -1304,6 +1304,48 @@ pub fn find_path(shape: &[Vec<u32>], dims: &[usize], open: &[u32], opts: &PathOp best } +/// [`find_path`] from `restarts` independent seeds, run side by side where +/// there are threads, keeping the cheapest plan (the earliest restart's on a +/// tie). The first restart uses `opts.seed` itself, so the plan is never +/// costlier than [`find_path`] with the same options; each restart's seed +/// depends only on its number, so the plan does not depend on how many threads +/// run them. The search's cost varies widely with its seed on large networks, +/// so a few restarts lower the cost more than more trials in one search. +pub fn find_path_restarts(shape: &[Vec<u32>], dims: &[usize], open: &[u32], opts: &PathOptions, restarts: usize) -> Plan { + let r = restarts.max(1); + // The other restarts' seeds are outputs of a generator of their own. Seeds a + // multiple of the search generator's step apart would start each search + // on the previous one's stream, a few steps on, and the searches would + // repeat one another. + let mut seeds = vec![opts.seed]; + let mut g = Rng::new(opts.seed ^ 0xd1b5_4a32_d192_ed03); + seeds.extend((1..r).map(|_| g.next())); + let run = |k: usize| find_path(shape, dims, open, &PathOptions { seed: seeds[k], ..*opts }); + let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |t| t.get()).min(r) }; + let plans: Vec<Plan> = if threads <= 1 { + (0..r).map(run).collect() + } else { + let mut out: Vec<Option<Plan>> = vec![None; r]; + std::thread::scope(|sc| { + let run = &run; + let handles: Vec<_> = (0..threads).map(|t| sc.spawn(move || (t..r).step_by(threads).map(|k| (k, run(k))).collect::<Vec<_>>())).collect(); + for h in handles { + for (k, plan) in h.join().expect("a search") { + out[k] = Some(plan); + } + } + }); + out.into_iter().map(|p| p.expect("every restart ran")).collect() + }; + let mut best = 0; + for (k, plan) in plans.iter().enumerate() { + if plan.total_flops() < plans[best].total_flops() { + best = k; + } + } + plans.into_iter().nth(best).expect("one restart at least") +} + /// One round of the hyper-optimiser, on dimensions that may already have /// indices fixed (dimension one). fn search(shape: &[Vec<u32>], dims: &[usize], open: &[u32], opts: &PathOptions) -> Plan { @@ -1930,6 +1972,29 @@ pub(crate) mod tests { assert_eq!(plan, again); } + /// Restarts never cost more than one search with the same options, do not + /// depend on the thread count, and contract to the same amplitude. + #[test] + fn restarts_never_cost_more_and_give_the_amplitude() { + let (w, h) = (3, 3); + let n = w * h; + let gates = grid_circuit(w, h, 8, 22); + let psi = dense(n, &gates); + let bits: Vec<u8> = (0..n).map(|q| (q % 2) as u8).collect(); + let x = bits.iter().fold(0usize, |a, &b| (a << 1) | b as usize); + let mut net = Network::amplitude(n, &gates, &bits, &[]).unwrap(); + net.simplify(); + let shape = net.shape(); + let opts = PathOptions { trials: 8, seed: 11, max_size: None, reconfigure: 4, families: Families::Both }; + let one = find_path(&shape, &net.dims, &net.open, &opts); + let four = find_path_restarts(&shape, &net.dims, &net.open, &opts, 4); + assert!(four.total_flops() <= one.total_flops()); + assert_eq!(find_path_restarts(&shape, &net.dims, &net.open, &opts, 4), four, "the same plan every time"); + assert_eq!(find_path_restarts(&shape, &net.dims, &net.open, &opts, 1), one); + let a = contract_plan(&net, &four).unwrap().data[0]; + assert!(close(a, psi[x]), "{a:?} against {:?}", psi[x]); + } + #[test] fn slices_fit_the_cap_and_sum_to_the_amplitude() { let (w, h) = (4, 3); -- 2.54.0 (Apple Git-157) From 9d2260596bb3a7a17782405e9ef4eb2f016fff0e Mon Sep 17 00:00:00 2001 From: Transaction Science <hello@transaction.science> Date: Tue, 20 Oct 2026 14:28:20 -0400 Subject: [PATCH 16/19] wai-quantum-cli release workflow: a concurrency group per tag The workflow-hygiene check requires every workflow to have one. A re-pushed tag now cancels the run before it, and its own run replaces any asset the cancelled run had attached. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit ed32a94ed194b5c14a5a1a5b7ebc10a91381d4bc) --- .github/workflows/wai-quantum-cli-release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/wai-quantum-cli-release.yml b/.github/workflows/wai-quantum-cli-release.yml index 7dc22ac7d..017bae10c 100644 --- a/.github/workflows/wai-quantum-cli-release.yml +++ b/.github/workflows/wai-quantum-cli-release.yml @@ -14,6 +14,10 @@ name: wai-quantum command line release # the public tunnel. A runner that is down leaves its platform's archive missing: the # release lists what each build attached. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: push: tags: ['wai-quantum-cli-v*'] -- 2.54.0 (Apple Git-157) From a84f5d704f8ac4d9c79f5b13c164f3de8e3290a3 Mon Sep 17 00:00:00 2001 From: Transaction Science <hello@transaction.science> Date: Tue, 20 Oct 2026 15:33:30 -0400 Subject: [PATCH 17/19] wai-quantum 0.6.12: cheaper contraction trees, from V-cycles and tree annealing Each bisection now runs two V-cycles after the first projection. The graph is coarsened again with every match inside one side, so the split carries to the coarse graph with the same cut. It is refined there and on the way back down, which never raises the cut. The best few trees are annealed after reconfiguration (quantum_tn::anneal). Random rotations at each node are accepted on the change in log total cost at a falling temperature; the cheapest tree seen is kept, then reconfigured again. A rotation keeps the node's index set, so it changes the cost of two contractions only. On the 61-qubit Nighthawk logical circuits (split, reduced, 1024 trials, 8 restarts), the median of 4 seeds, in log10 complex multiply-adds for one amplitude: depth before after 8 5.30 5.29 12 8.10 8.09 16 11.12 10.97 20 14.02 13.68 At depth 20 the search takes about 1.7 times as long. Also tried, and left out: 24 initial partitions per bisection (no gain at depth 16 at equal time), a coarsening rating that keeps coarse nodes balanced (mixed), more refinement passes (no change), and 200 annealing sweeps (no better than 50). The tensor-network plan pinned in tests/bit_identity.rs changes, by design. The native and WASI builds compute the same new digest. A test checks that annealing never costs more, keeps every tensor contracted once and after it exists, repeats its tree for a seed, and gives the dense amplitude. The changelog also records what 0.6.11 gained after its entry was written: the circuit's layout as data, path-search restarts and the command line's release builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 5c253c907402ce4a8e6140a676e3e58d86e01398) --- wai/CHANGELOG.md | 33 ++++ wai/tools/quantum-meter/Cargo.lock | 2 +- wai/tools/quantum-wgsl-check/Cargo.lock | 2 +- wai/wai-pack/Cargo.lock | 2 +- wai/wai-quantum-cli/Cargo.lock | 2 +- wai/wai-quantum-desktop/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/src/quantum_tn.rs | 226 ++++++++++++++++++++++-- wai/wai-quantum/tests/bit_identity.rs | 2 +- wai/wai-rs/Cargo.lock | 2 +- wai/wai-web/Cargo.lock | 2 +- 15 files changed, 257 insertions(+), 28 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 3f43756cf..72b89fe90 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,28 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### wai-quantum 0.6.12: cheaper contraction trees, from V-cycles and tree annealing + +One pinned digest changes, by design: the tensor-network plan in `tests/bit_identity.rs`, because the search now finds a cheaper plan. The native and WASI builds compute the same new digest. Every other pin is unchanged. + +- **Each bisection runs two V-cycles.** After the split is projected onto the full graph: + - the graph is coarsened again, matching nodes only within a side, so the split carries to the coarse graph with the same cut; + - the split is refined there and on the way back down, which never raises the cut. +- **The best few trees are annealed after reconfiguration** (`quantum_tn::anneal`): + - random rotations at each node are accepted on the change in log total cost, at a falling temperature; + - the cheapest tree seen is kept and reconfigured again; + - a rotation changes the cost of two contractions only, so each move costs two set sizes. +- **On the 61-qubit Nighthawk logical circuits** (split and reduced, 1024 trials, 8 restarts, the median of 4 seeds). The figures are log10 complex multiply-adds for one amplitude: + + | depth | 0.6.11 | 0.6.12 | + |---|---|---| + | 8 | 5.30 | 5.29 | + | 12 | 8.10 | 8.09 | + | 16 | 11.12 | 10.97 | + | 20 | 14.02 | 13.68 | + + The search takes about 1.7 times as long at depth 20. + ### wai-quantum 0.6.11 and wai-quantum-cli 0.1.0: one job engine for the service, the command line and a browser tab Additive, apart from two refusals and an admission noted below. Every pinned digest is unchanged. @@ -42,6 +64,17 @@ Additive, apart from two refusals and an admission noted below. Every pinned dig Its assistant uses a local model with the engine as its tools. It checks and runs every program the model proposes before offering it, and marks any figure the engine did not compute. - **`engine::job`** also splits into `plan_and_run` and `seal`, for hosts that run a job away from their key. +- **`engine::draw::layout`** gives a circuit's layout as data: its columns, and each gate's cells. The text drawing and Quantum Studio's diagram are both drawn from it. wai-web exports it as `engine_layout`, and the desktop app as `layout`. +- **Path search from independent restarts** (`quantum_tn::find_path_restarts`): + - independent searches run side by side, and the cheapest plan is kept; + - the plan is never costlier than one search, and is the same whatever the thread count; + - the engine's `tensor_network` method runs 8 restarts natively and 1 in a tab, and the job option `path_restarts` (1–64) sets the count. +- **Release builds of `wai-quantum`.** A tag `wai-quantum-cli-v<version>` builds the binary on the project's runners and attaches each archive, with its SHA-256, to the forge release: + - macOS, for Apple silicon and for Intel; + - Linux, linked statically; + - Windows. + + The same workflow writes a Homebrew formula and a Scoop manifest for the archives. - **wai-web, feature `quantum_engine`:** `QuantumEngine` (run, plan, methods and signer), `engine_lint`, `engine_draw` and `engine_verify`, for a page. Tested in wasm32 under Node on every method a tab holds. ### wai-quantum 0.6.10: one OpenQASM reader for the service, the command line and the browser IDE diff --git a/wai/tools/quantum-meter/Cargo.lock b/wai/tools/quantum-meter/Cargo.lock index 4300fb6a2..4f55bb65b 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.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/tools/quantum-wgsl-check/Cargo.lock b/wai/tools/quantum-wgsl-check/Cargo.lock index 6140a68a4..cfa6c68ef 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.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index c491d5a53..ca0b3d0cb 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.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-cli/Cargo.lock b/wai/wai-quantum-cli/Cargo.lock index deeb58e91..10d2bcfb7 100644 --- a/wai/wai-quantum-cli/Cargo.lock +++ b/wai/wai-quantum-cli/Cargo.lock @@ -1120,7 +1120,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-desktop/Cargo.lock b/wai/wai-quantum-desktop/Cargo.lock index e48194ff5..083c54b56 100644 --- a/wai/wai-quantum-desktop/Cargo.lock +++ b/wai/wai-quantum-desktop/Cargo.lock @@ -4130,7 +4130,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-qpu/Cargo.lock b/wai/wai-quantum-qpu/Cargo.lock index ccc628b7d..863807efa 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.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index 71dcfd7fa..ad6e2c829 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.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index 2d240a0a6..e41084596 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.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index 81954092f..c6baa28c5 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.11" +version = "0.6.12" dependencies = [ "blake3", "block2", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 0652599d6..9d4e283b0 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.6.11" +version = "0.6.12" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/wai/wai-quantum/src/quantum_tn.rs b/wai/wai-quantum/src/quantum_tn.rs index 72ecf4872..f78fc04bb 100644 --- a/wai/wai-quantum/src/quantum_tn.rs +++ b/wai/wai-quantum/src/quantum_tn.rs @@ -689,6 +689,13 @@ fn greedy_steps( members[0] } +/// V-cycles per bisection after the first projection. +const V_CYCLES: usize = 2; +/// Sweeps of tree annealing per reconfigured candidate, and its starting +/// temperature (on the change in log total cost). +const ANNEAL_SWEEPS: usize = 50; +const ANNEAL_T0: f64 = 0.05; + /// Parameters of one recursive-bipartition tree. #[derive(Clone, Copy, Debug, PartialEq)] struct PartitionParams { @@ -735,6 +742,7 @@ fn partition_rec( } /// A weighted graph for partitioning: node weights and merged edge weights. +#[derive(Clone)] struct WGraph { nw: Vec<f64>, adj: Vec<Vec<(usize, f64)>>, @@ -766,7 +774,10 @@ fn merged(adj: Vec<Vec<(usize, f64)>>) -> Vec<Vec<(usize, f64)>> { /// One level of coarsening by heavy-edge matching: nodes in random order, /// each unmatched node joins its unmatched neighbour with the heaviest edge. /// Returns the coarse graph and each fine node's coarse node. -fn coarsen(g: &WGraph, rng: &mut Rng) -> (WGraph, Vec<usize>) { +/// +/// With `within`, a node is matched only to a neighbour on its own side, so a +/// split of the fine graph is a split of the coarse one with the same cut. +fn coarsen(g: &WGraph, within: Option<&[bool]>, rng: &mut Rng) -> (WGraph, Vec<usize>) { let n = g.n(); let mut order: Vec<usize> = (0..n).collect(); for i in (1..n).rev() { @@ -779,6 +790,9 @@ fn coarsen(g: &WGraph, rng: &mut Rng) -> (WGraph, Vec<usize>) { } let mut best: Option<(f64, usize)> = None; for &(u, w) in &g.adj[v] { + if within.is_some_and(|s| s[u] != s[v]) { + continue; + } if u != v && mate[u] == usize::MAX && best.is_none_or(|(bw, _)| w > bw) { best = Some((w, u)); } @@ -915,7 +929,9 @@ fn grow(g: &WGraph, target: f64, rng: &mut Rng) -> Vec<bool> { /// Split `ids` in two with a small weighted cut, multilevel: coarsen by /// heavy-edge matching, partition the coarsest graph several ways (grown /// breadth-first, refined), keep the best, then project it back up, -/// refining at every level. The two sides weigh within the imbalance of half. +/// refining at every level. Then V-cycles: coarsen again keeping each side +/// apart, refine at the coarsest level and on the way down, which never +/// raises the cut. The two sides weigh within the imbalance of half. fn bisect(ids: &[usize], live: &[Option<Vec<u32>>], dims: &[usize], p: &PartitionParams, rng: &mut Rng) -> (Vec<usize>, Vec<usize>) { let n = ids.len(); let mut by_index: BTreeMap<u32, Vec<usize>> = BTreeMap::new(); @@ -932,10 +948,11 @@ fn bisect(ids: &[usize], live: &[Option<Vec<u32>>], dims: &[usize], p: &Partitio adj[ks[1]].push((ks[0], w)); } } + let passes = p.passes; let mut levels = vec![WGraph { nw: vec![1.0; n], adj: merged(adj) }]; let mut maps: Vec<Vec<usize>> = Vec::new(); while levels.last().unwrap().n() > 24 { - let (coarse, map) = coarsen(levels.last().unwrap(), rng); + let (coarse, map) = coarsen(levels.last().unwrap(), None, rng); if coarse.n() as f64 > 0.95 * levels.last().unwrap().n() as f64 { break; } @@ -951,7 +968,7 @@ fn bisect(ids: &[usize], live: &[Option<Vec<u32>>], dims: &[usize], p: &Partitio for _ in 0..6 { let target = lo + (hi - lo) * rng.unit(); let mut s = grow(coarsest, target, rng); - refine(coarsest, &mut s, lo, hi, p.passes); + refine(coarsest, &mut s, lo, hi, passes); let w: f64 = (0..coarsest.n()).filter(|&v| s[v]).map(|v| coarsest.nw[v]).sum(); let c: f64 = (0..coarsest.n()) .flat_map(|v| coarsest.adj[v].iter().filter(move |&&(u, _)| v < u).map(move |&(u, w)| (v, u, w))) @@ -969,9 +986,40 @@ fn bisect(ids: &[usize], live: &[Option<Vec<u32>>], dims: &[usize], p: &Partitio for lvl in (0..maps.len()).rev() { let map = &maps[lvl]; let mut fine: Vec<bool> = map.iter().map(|&c| side[c]).collect(); - refine(&levels[lvl], &mut fine, lo, hi, p.passes); + refine(&levels[lvl], &mut fine, lo, hi, passes); side = fine; } + // V-cycles: coarsen again with every match inside one side, so the split + // carries to the coarse graph unchanged, refine there and on the way down. + for _ in 0..V_CYCLES { + if side.len() != n { + break; + } + let mut lv = vec![levels[0].clone()]; + let mut sides = vec![side.clone()]; + let mut mp: Vec<Vec<usize>> = Vec::new(); + while lv.last().unwrap().n() > 24 { + let (coarse, map) = coarsen(lv.last().unwrap(), Some(sides.last().unwrap()), rng); + if coarse.n() as f64 > 0.95 * lv.last().unwrap().n() as f64 { + break; + } + let mut cs = vec![false; coarse.n()]; + for (v, &c) in map.iter().enumerate() { + cs[c] = sides.last().unwrap()[v]; + } + lv.push(coarse); + sides.push(cs); + mp.push(map); + } + let mut s = sides.pop().unwrap(); + refine(lv.last().unwrap(), &mut s, lo, hi, passes); + for lvl in (0..mp.len()).rev() { + let mut fine: Vec<bool> = mp[lvl].iter().map(|&c| s[c]).collect(); + refine(&lv[lvl], &mut fine, lo, hi, passes); + s = fine; + } + side = s; + } // A degenerate split (everything on one side) falls back to halves. if side.iter().all(|&x| x) || side.iter().all(|&x| !x) { side = (0..n).map(|k| k < n / 2).collect(); @@ -981,6 +1029,110 @@ fn bisect(ids: &[usize], live: &[Option<Vec<u32>>], dims: &[usize], p: &Partitio (left, right) } +// --------------------------------------------------------------------------- +// Tree annealing +// --------------------------------------------------------------------------- + +/// Simulated annealing over tree rotations: at a node x = (A, B) with A = (A1, A2), +/// the rotation makes x = (A1, (A2, B)) or x = (A2, (A1, B)) (and the same with B's +/// children). x keeps its index set, so a rotation changes the cost of two +/// contractions only. Moves are accepted on the change in log total cost at a +/// temperature that falls geometrically from `t0` to 1e-3 over `sweeps` sweeps; the +/// cheapest tree seen is returned, so the path is never costlier. +pub fn anneal(shape: &[Vec<u32>], dims: &[usize], open: &[u32], path: &Path, sweeps: usize, t0: f64, seed: u64) -> Path { + let n = shape.len(); + let m = n + path.steps.len(); + if path.steps.len() < 3 || sweeps == 0 { + return path.clone(); + } + let mut left = vec![usize::MAX; m]; + let mut right = vec![usize::MAX; m]; + let mut parent = vec![usize::MAX; m]; + let mut set: Vec<Vec<u32>> = shape.to_vec(); + set.resize(m, Vec::new()); + let mut cost = vec![0.0f64; m]; + for (s, &(a, b)) in path.steps.iter().enumerate() { + let x = n + s; + left[x] = a; + right[x] = b; + parent[a] = x; + parent[b] = x; + cost[x] = set_size(&union(&set[a], &set[b]), dims); + set[x] = sym_diff(&set[a], &set[b], open); + } + // A rotation keeps every node's place under its parent, so the root stays the root. + let root = (n..m).find(|&x| parent[x] == usize::MAX).unwrap_or(m - 1); + let mut total: f64 = cost[n..].iter().sum(); + let mut best_total = total; + let mut best = (left.clone(), right.clone()); + let mut rng = Rng::new(seed); + let internal: Vec<usize> = (n..m).collect(); + let steps_total = sweeps * internal.len(); + let t_end: f64 = 1e-3; + let ratio = crate::repro::exp(ln(t_end / t0) / steps_total.max(1) as f64); + let mut t = t0; + for _ in 0..steps_total { + t *= ratio; + let x = internal[rng.below(internal.len())]; + // Pick the child to open (A) and the other child (B), and which of A's + // children stays with x (keep) and which joins B (moved). + let (a_is_left, keep_left) = (rng.next() & 1 == 0, rng.next() & 1 == 0); + let (a, b) = if a_is_left { (left[x], right[x]) } else { (right[x], left[x]) }; + if a < n { + continue; + } + let (keep, moved) = if keep_left { (left[a], right[a]) } else { (right[a], left[a]) }; + let new_set = sym_diff(&set[moved], &set[b], open); + let new_cost_a = set_size(&union(&set[moved], &set[b]), dims); + let new_cost_x = set_size(&union(&set[keep], &new_set), dims); + let delta = new_cost_a + new_cost_x - cost[a] - cost[x]; + let new_total = total + delta; + let accept = new_total < total || { + let d = ln(new_total.max(1.0)) - ln(total.max(1.0)); + rng.unit() < crate::repro::exp(-d / t) + }; + if !accept { + continue; + } + // Node a becomes (moved, b); x becomes (keep, a). + left[a] = moved; + right[a] = b; + parent[moved] = a; + parent[b] = a; + set[a] = new_set; + cost[a] = new_cost_a; + left[x] = keep; + right[x] = a; + parent[keep] = x; + parent[a] = x; + cost[x] = new_cost_x; + total = new_total; + if total < best_total * (1.0 - 1e-12) { + best_total = total; + best = (left.clone(), right.clone()); + } + } + // The best tree back to steps, children before parents. + let (left, right) = best; + let mut id: Vec<usize> = (0..n).chain(core::iter::repeat_n(usize::MAX, m - n)).collect(); + let mut steps = Vec::with_capacity(path.steps.len()); + let mut stack = vec![(root, false)]; + while let Some((x, done)) = stack.pop() { + if x < n { + continue; + } + if done { + steps.push((id[left[x]], id[right[x]])); + id[x] = n + steps.len() - 1; + } else { + stack.push((x, true)); + stack.push((right[x], false)); + stack.push((left[x], false)); + } + } + Path { steps } +} + // --------------------------------------------------------------------------- // Subtree reconfiguration // --------------------------------------------------------------------------- @@ -1273,7 +1425,7 @@ impl Plan { /// Seeded hyper-optimisation: `trials` trees from both families with /// randomised parameters, each sliced to the memory cap and scored by total -/// operations. The best is then reconfigured and re-sliced. +/// operations. The best few are then reconfigured, annealed and re-sliced. pub fn find_path(shape: &[Vec<u32>], dims: &[usize], open: &[u32], opts: &PathOptions) -> Plan { let mut best = search(shape, dims, open, opts); // With slices, search again on the network with some of the first @@ -1429,18 +1581,28 @@ fn search(shape: &[Vec<u32>], dims: &[usize], open: &[u32], opts: &PathOptions) // Reconfigure each of the best few to convergence; keep the best result. // With slicing, each round reconfigures the per-slice tree (the sliced // indices fixed) and then slices afresh, so the tree adapts to its slices. + let polish = |mut cur: Plan| -> Plan { + for _ in 0..8 { + let d = sliced_dims(dims, &cur.sliced); + let plan = score(&reconfigure(shape, &d, open, &cur.path, opts.reconfigure)); + if plan.total_flops() < cur.total_flops() * (1.0 - 1e-9) { + cur = plan; + } else { + break; + } + } + cur + }; let mut best = top[0].clone(); if opts.reconfigure >= 3 { for cand in top { - let mut cur = cand; - for _ in 0..8 { - let d = sliced_dims(dims, &cur.sliced); - let plan = score(&reconfigure(shape, &d, open, &cur.path, opts.reconfigure)); - if plan.total_flops() < cur.total_flops() * (1.0 - 1e-9) { - cur = plan; - } else { - break; - } + let mut cur = polish(cand); + // Then anneal it, which can leave the local minimum reconfiguration + // stops in, and reconfigure the result. + let d = sliced_dims(dims, &cur.sliced); + let annealed = polish(score(&anneal(shape, &d, open, &cur.path, ANNEAL_SWEEPS, ANNEAL_T0, rng.next()))); + if annealed.total_flops() < cur.total_flops() { + cur = annealed; } if cur.total_flops() < best.total_flops() { best = cur; @@ -1995,6 +2157,40 @@ pub(crate) mod tests { assert!(close(a, psi[x]), "{a:?} against {:?}", psi[x]); } + #[test] + fn annealing_never_costs_more_and_keeps_a_valid_tree() { + let (w, h) = (4, 3); + let n = w * h; + let gates = grid_circuit(w, h, 10, 7); + let psi = dense(n, &gates); + let bits: Vec<u8> = (0..n).map(|q| (q % 2) as u8).collect(); + let x = bits.iter().fold(0usize, |a, &b| (a << 1) | b as usize); + let mut net = Network::amplitude(n, &gates, &bits, &[]).unwrap(); + net.simplify(); + let shape = net.shape(); + let t = shape.len(); + // A plain greedy tree has room to improve. + let start = greedy_path(&shape, &net.dims, &net.open, 1.0, 0.0, 5); + let before = path_cost(&shape, &net.dims, &net.open, &start).flops; + for (sweeps, t0) in [(1, 0.05), (50, 0.05), (50, 1.0)] { + let path = anneal(&shape, &net.dims, &net.open, &start, sweeps, t0, 9); + // Every tensor, input or intermediate, is contracted exactly once, + // and only after it exists. + assert_eq!(path.steps.len(), start.steps.len()); + let mut used = vec![false; t + path.steps.len()]; + for (s, &(a, b)) in path.steps.iter().enumerate() { + for id in [a, b] { + assert!(id < t + s && !used[id], "step {s} takes {id}"); + used[id] = true; + } + } + assert!(path_cost(&shape, &net.dims, &net.open, &path).flops <= before); + assert_eq!(anneal(&shape, &net.dims, &net.open, &start, sweeps, t0, 9), path, "the same tree every time"); + let a = contract_path(&net, &path).unwrap().data[0]; + assert!(close(a, psi[x]), "{a:?} against {:?}", psi[x]); + } + } + #[test] fn slices_fit_the_cap_and_sum_to_the_amplitude() { let (w, h) = (4, 3); diff --git a/wai/wai-quantum/tests/bit_identity.rs b/wai/wai-quantum/tests/bit_identity.rs index a3ffd2579..c295de9bc 100644 --- a/wai/wai-quantum/tests/bit_identity.rs +++ b/wai/wai-quantum/tests/bit_identity.rs @@ -238,7 +238,7 @@ fn tensor_network_plan_and_amplitude() { let steps: Vec<f64> = plan.path.steps.iter().flat_map(|&(a, b)| [a as f64, b as f64]).collect(); let mut v = vec![amp.re, amp.im, plan.per_slice.flops, plan.per_slice.max_size, plan.slices]; v.extend(steps); - assert_eq!(digest(&v), 0x8954_0c3a_d1fc_a04d); + assert_eq!(digest(&v), 0xf39b_7370_3ae9_ea0c); } /// A 24 × 24 hafnian and a 14-click torontonian (both summed in fixed chunks, diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 701ee84c4..d55e04408 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4310,7 +4310,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index e900293eb..a872ec65d 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.11" +version = "0.6.12" dependencies = [ "blake3", "ed25519-dalek", -- 2.54.0 (Apple Git-157) From c6002701b08e00eb0cdcad8fcefaece58f860a72 Mon Sep 17 00:00:00 2001 From: Transaction Science <hello@transaction.science> Date: Tue, 20 Oct 2026 16:45:53 -0400 Subject: [PATCH 18/19] wai-quantum: the cut-rank table on the GPU, and the engine's cutrank_single method quantum_cutrank_metal (feature quantum_cutrank_metal, macOS) runs the cut-rank walk's passes on the platform's GPU: - each shot's table is 2^cut single-precision entries in one shared buffer, at most 2^31 a table, so every index fits in 32 bits; - each pass is one dispatch over (entry, shot), in the walk's order and with its arithmetic, done in f32; the passes are the same for every shot, and only each shot's weights (set by the outcomes it drew) differ, read from a small per-shot array; an amplitude is every pass encoded into one command buffer; - a sample's sums are reduced per threadgroup on the device (a SIMD-group sum, then the groups in order) and added on the host in f64, in order, for each shot; the outcome is drawn as the host's walk draws it (outcome_norm and draw, now shared by both); - a batch of shots is sampled side by side, so a qubit is one command buffer for the batch: the previous qubit's passes for each shot's outcome, then this qubit's sums; - each shot draws from a stream of its own, so a seed's samples are the same at any batch size. The kernels are compiled in the platform's safe math mode, so a run is the same bits on one device. Tests hold it to the double-precision walk on states of 10 to 34 qubits: amplitudes within 1e-5 of each amplitude's size and the same bits run to run; samples whose probabilities are their own and whose counts follow them; and the same samples one at a time and seven side by side. Without a device the tests skip; WAI_QUANTUM_CUTRANK_METAL_EXPECT makes that a failure, and the service workflow's macOS job sets it and checks both GPU tests by name. The engine's cutrank_single method runs a cutrank job on that table where the build has the feature and the host a GPU; auto never picks it. It samples up to 32 shots a batch, as many as 2 GiB beside its first table holds within the job's memory, and the plan counts them. Its amplitudes carry the circuit's global phase, found on the same table (catalog::cutrank_phase_with takes the amplitude function). The job service, the command line's metal feature and the desktop app's metal feature build it in. The service's schemas list it, and it marks the method's energy figure partial, since the counter does not read the GPU. examples/cutrank_gpu times one state's amplitudes and samples on the host's tables and on the GPU, and each kernel's pass alone. On a shared desktop machine, each device at its quietest (either side ran up to five times slower under other work), seconds for one amplitude of a random graph state: widest cut host, double host, single GPU 23 bits 0.027 0.024 0.005 25 bits 0.057 0.045 0.019 27 bits 0.18 0.17 0.046 29 bits 1.03 0.54 0.34 and for 64 samples, 16 a batch, the host at about half its quietest speed: widest cut host, single GPU, one at a time GPU, 16 a batch 16 bits 0.52 0.62 0.036 20 bits 1.97 0.73 0.067 23 bits 3.87 0.91 0.34 25 bits 7.48 1.82 1.20 A pass runs at about 550 GB/s on an idle device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit f7c7160a9ab5a6255c28d34af57ee2fbc017b055) --- .github/workflows/wai-quantum-service.yml | 15 + wai/CHANGELOG.md | 36 +- wai/quantum-studio/src/main.js | 2 +- wai/wai-quantum-cli/Cargo.toml | 2 +- wai/wai-quantum-desktop/Cargo.toml | 2 +- wai/wai-quantum-service/Cargo.toml | 2 +- wai/wai-quantum-service/README.md | 3 +- wai/wai-quantum-service/src/discovery.rs | 2 +- wai/wai-quantum-service/src/energy.rs | 3 +- wai/wai-quantum-service/src/jobs.rs | 2 +- wai/wai-quantum-service/src/mcp.rs | 2 +- wai/wai-quantum/Cargo.toml | 6 + wai/wai-quantum/examples/cutrank_gpu.rs | 103 +++ wai/wai-quantum/src/engine/catalog.rs | 64 +- wai/wai-quantum/src/engine/run.rs | 75 ++ wai/wai-quantum/src/lib.rs | 2 + wai/wai-quantum/src/quantum_cutrank.rs | 136 ++- wai/wai-quantum/src/quantum_cutrank_metal.rs | 842 +++++++++++++++++++ 18 files changed, 1270 insertions(+), 29 deletions(-) create mode 100644 wai/wai-quantum/examples/cutrank_gpu.rs create mode 100644 wai/wai-quantum/src/quantum_cutrank_metal.rs diff --git a/.github/workflows/wai-quantum-service.yml b/.github/workflows/wai-quantum-service.yml index 541116696..3aaa942c6 100644 --- a/.github/workflows/wai-quantum-service.yml +++ b/.github/workflows/wai-quantum-service.yml @@ -112,6 +112,21 @@ jobs: set -euo pipefail nice -n 10 cargo test --locked --release --features quantum_sv_metal --lib quantum_sv_metal 2>&1 | tee "$RUNNER_TEMP/gpu.log" grep -qE '^test result: ok\. 4 passed; 0 failed' "$RUNNER_TEMP/gpu.log" || { echo "::error::not every GPU test ran and passed"; exit 1; } + # The cut-rank table on this host's GPU, against the double-precision + # walk; the GPU is declared, so its absence fails, and each GPU test is + # checked by name. + - name: The GPU cut-rank table + shell: bash + working-directory: wai/wai-quantum + env: + WAI_QUANTUM_CUTRANK_METAL_EXPECT: "1" + run: | + set -euo pipefail + nice -n 10 cargo test --locked --release --features quantum_cutrank_metal --lib quantum_cutrank 2>&1 | tee "$RUNNER_TEMP/cutrank-gpu.log" + for t in the_gpu_table_agrees gpu_samples_follow_the_probabilities; do + grep -q "^test quantum_cutrank::tests::$t ... ok$" "$RUNNER_TEMP/cutrank-gpu.log" \ + || { echo "::error::quantum_cutrank::tests::$t did not run and pass"; exit 1; } + done - name: The smoke checks over HTTP shell: bash working-directory: wai/wai-quantum-service diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 72b89fe90..c45d5e701 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,7 +6,7 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current -### wai-quantum 0.6.12: cheaper contraction trees, from V-cycles and tree annealing +### wai-quantum 0.6.12: cheaper contraction trees, and the cut-rank table on the GPU One pinned digest changes, by design: the tensor-network plan in `tests/bit_identity.rs`, because the search now finds a cheaper plan. The native and WASI builds compute the same new digest. Every other pin is unchanged. @@ -27,6 +27,40 @@ One pinned digest changes, by design: the tensor-network plan in `tests/bit_iden | 20 | 14.02 | 13.68 | The search takes about 1.7 times as long at depth 20. +- **`quantum_cutrank_metal`** (feature `quantum_cutrank_metal`, macOS) runs the cut-rank table on the platform's GPU: + - one shared buffer of `2^cut` single-precision entries, at most `2³¹`; + - one dispatch per pass of the walk, in its order and with its arithmetic, done in `f32`, so an amplitude is one command buffer; + - a sample's sums reduced per threadgroup on the device and added on the host in `f64`, in order, with the outcome drawn as the host's walk draws it; + - a batch of shots sampled side by side, each shot's table at its own offset and its weights in a small per-shot array, so a qubit is one command buffer for the whole batch; + - each shot drawing from a stream of its own, so a seed's samples are the same at any batch size. + + It is reproducible on one device, and is checked against the double-precision walk on states of 10 to 34 qubits: amplitudes within `10⁻⁵` of each amplitude's size, and samples whose probabilities are their own and whose counts follow them. +- **The engine's `cutrank_single` method** runs a `cutrank` job on that table, where the build has the feature and the host has a GPU. `auto` never picks it. + - Its amplitudes carry the circuit's global phase, which is found on the same table. + - The job service, the command line's `metal` feature and the desktop app's `metal` feature build it in. + - The service marks a `cutrank_single` job's energy figure partial, as it does `statevector_single`'s, because the counter does not read the GPU. + - `examples/cutrank_gpu` times it against the host's tables. +- **Speed.** One amplitude of a random graph state on a shared desktop machine, each device timed at its quietest. Either side ran up to five times slower while other work held it. + + | widest cut | host, double | host, single | GPU | + |---|---|---|---| + | 23 bits | 0.027 s | 0.024 s | 0.005 s | + | 25 bits | 0.057 s | 0.045 s | 0.019 s | + | 27 bits | 0.18 s | 0.17 s | 0.046 s | + | 29 bits | 1.03 s | 0.54 s | 0.34 s | + + A pass of the table runs at about 550 GB/s on an idle device. + + Sampling 64 shots of the same states, 16 a batch, with the host at about half its quietest speed: + + | widest cut | host, single | GPU, one at a time | GPU, 16 a batch | + |---|---|---|---| + | 16 bits | 0.52 s | 0.62 s | 0.036 s | + | 20 bits | 1.97 s | 0.73 s | 0.067 s | + | 23 bits | 3.87 s | 0.91 s | 0.34 s | + | 25 bits | 7.48 s | 1.82 s | 1.20 s | + + The engine samples up to 32 shots a batch, as many as 2 GiB beside the job's first table holds. ### wai-quantum 0.6.11 and wai-quantum-cli 0.1.0: one job engine for the service, the command line and a browser tab diff --git a/wai/quantum-studio/src/main.js b/wai/quantum-studio/src/main.js index 82520f0c7..46d474da6 100644 --- a/wai/quantum-studio/src/main.js +++ b/wai/quantum-studio/src/main.js @@ -25,7 +25,7 @@ import { Assistant, HttpModel, DesktopModel } from './assistant.js'; const METHOD_NAMES = { exact: 'Exact state vector', statevector: 'State vector, double precision', statevector_single: 'State vector on the GPU, single precision', stabilizer: 'Stabilizer tableau', pauli: 'Sparse Pauli dynamics', mps: 'Matrix product state', tensor_network: 'Tensor network', - cutrank: 'Cut-rank sum', density_matrix: 'Density matrix', trajectories: 'Trajectories', + cutrank: 'Cut-rank sum', cutrank_single: 'Cut-rank sum on the GPU, single precision', density_matrix: 'Density matrix', trajectories: 'Trajectories', }; const ACCURACY = { 'bit-exact': 'The same bits on every machine.', diff --git a/wai/wai-quantum-cli/Cargo.toml b/wai/wai-quantum-cli/Cargo.toml index 074a2b131..a7e4f2591 100644 --- a/wai/wai-quantum-cli/Cargo.toml +++ b/wai/wai-quantum-cli/Cargo.toml @@ -24,7 +24,7 @@ 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"] +metal = ["wai-quantum/quantum_sv_metal", "wai-quantum/quantum_cutrank_metal"] [dependencies] clap = { version = "4.6", features = ["derive", "env", "wrap_help"] } diff --git a/wai/wai-quantum-desktop/Cargo.toml b/wai/wai-quantum-desktop/Cargo.toml index 0cb519b4b..66ebe07f0 100644 --- a/wai/wai-quantum-desktop/Cargo.toml +++ b/wai/wai-quantum-desktop/Cargo.toml @@ -24,7 +24,7 @@ default = ["custom-protocol"] # The interface served from the bundled build (../quantum-studio/dist), as a release does. custom-protocol = ["tauri/custom-protocol"] # The single-precision state vector on the GPU, on macOS. -metal = ["wai-quantum/quantum_sv_metal"] +metal = ["wai-quantum/quantum_sv_metal", "wai-quantum/quantum_cutrank_metal"] [profile.release] codegen-units = 1 diff --git a/wai/wai-quantum-service/Cargo.toml b/wai/wai-quantum-service/Cargo.toml index d07deb7c2..bae2aa7d9 100644 --- a/wai/wai-quantum-service/Cargo.toml +++ b/wai/wai-quantum-service/Cargo.toml @@ -16,7 +16,7 @@ serde = { version = "1", features = ["derive"] } # (the default reader rounds twice), so its receipt still verifies. serde_json = { version = "1", features = ["float_roundtrip"] } getrandom = "0.4" -wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt", "quantum_sv_metal"] } +wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt", "quantum_sv_metal", "quantum_cutrank_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/README.md b/wai/wai-quantum-service/README.md index 839c1a298..b6517c3b6 100644 --- a/wai/wai-quantum-service/README.md +++ b/wai/wai-quantum-service/README.md @@ -42,6 +42,7 @@ The program may use the standard gates, user `gate` definitions, and the control | `mps` | any circuit, up to 4096 qubits, at bond dimension up to 256; best when entanglement stays low | counts, expectations, amplitudes | exact when nothing is truncated, otherwise heuristic | | `tensor_network` | any circuit | amplitudes | exact | | `cutrank` | a Clifford circuit, then one layer of one-qubit gates at any angles, up to 4096 qubits, as wide as the state's cut-rank allows | counts, amplitudes | exact; single when only a single-precision table fits | +| `cutrank_single` | as `cutrank`, with the table on the host's GPU, where it has one; only when asked for | counts, amplitudes | single | | `density_matrix` | noisy circuits, up to 16 qubits within a job's memory | counts, expectations | exact | | `trajectories` | noisy circuits, up to 32 qubits | counts, expectations | estimate | @@ -138,7 +139,7 @@ The fields follow WAI's energy-measurement extension (§2): - `window_us` is the worker's lifetime. - `energy_counter_filtering` is `Unknown`, because the platform does not document whether the count is filtered. -A `statevector_single` job runs on the GPU, which this counter does not read, so its figure is `partial` and says so in `excludes`. So is a figure that includes CPU energy system services billed to the worker, since such credits land late. +A `statevector_single` or `cutrank_single` job runs on the GPU, which this counter does not read, so its figure is `partial` and says so in `excludes`. So is a figure that includes CPU energy system services billed to the worker, since such credits land late. Where the service cannot read the count (off macOS), `energy` is `{"unmetered": "<why>"}`. It is never a figure of zero. `wai-quantum-service run FILE` runs a job in its own process and reports no energy. diff --git a/wai/wai-quantum-service/src/discovery.rs b/wai/wai-quantum-service/src/discovery.rs index 4639ae4db..5145412af 100644 --- a/wai/wai-quantum-service/src/discovery.rs +++ b/wai/wai-quantum-service/src/discovery.rs @@ -108,7 +108,7 @@ pub fn openapi(server: &str) -> Value { "required": ["qasm"], "properties": { "qasm": { "type": "string", "description": "The program: OpenQASM 3, or OpenQASM 2. Registers, standard gates (h, x, y, z, s, sdg, t, tdg, sx, rx, ry, rz, p, u, cx, cy, cz, ch, swap, iswap, cp, crx, cry, crz, ccx, ccz, cswap, rxx, ryy, rzz), the control modifiers ctrl @, ctrl(n) @ and negctrl @ on x y z h s sdg t tdg p and the controlled names, user gate definitions, measurement anywhere, reset anywhere, and `if (c[0]) …` / `if (c == 3) { … } else { … }` on measured bits (OpenQASM 2's `if(c==n) gate;` too). A program that measures in the middle, resets after a gate or acts on measured bits runs on statevector or stabilizer: counts split their shots at each measurement, expectation values are the exact mixture over the branches, of the state before the final measurements. Loops, inv @ and pow @ are refused, with the line." }, - "method": { "type": "string", "enum": ["auto", "exact", "statevector", "statevector_single", "stabilizer", "pauli", "mps", "tensor_network", "cutrank", "density_matrix", "trajectories"], "default": "auto" }, + "method": { "type": "string", "enum": ["auto", "exact", "statevector", "statevector_single", "stabilizer", "pauli", "mps", "tensor_network", "cutrank", "cutrank_single", "density_matrix", "trajectories"], "default": "auto" }, "shots": { "type": "integer", "minimum": 0, "maximum": 1000000, "description": "Measurement shots (default 1024 when nothing else is asked for)." }, "seed": { "type": "integer", "default": 1, "description": "The sampling seed: the same job and seed give the same counts." }, "observables": { "type": "array", "items": pauli }, diff --git a/wai/wai-quantum-service/src/energy.rs b/wai/wai-quantum-service/src/energy.rs index c93bcc6b7..dc982d2c0 100644 --- a/wai/wai-quantum-service/src/energy.rs +++ b/wai/wai-quantum-service/src/energy.rs @@ -13,7 +13,8 @@ //! start to its exit (reading the job, running the plan, writing the result), //! and the CPU energy system services billed to it for work it asked of them //! (`ri_billed_energy`, less any `ri_serviced_energy`). Not the GPU: a -//! `statevector_single` job runs there, so its figure is partial and says so. +//! `statevector_single` or `cutrank_single` job runs there, so its figure is +//! partial and says so. //! Not the memory, not the host's idle draw, not the service's own process. A //! billed credit lands late and carries no GPU energy, so a figure with one is //! partial too. diff --git a/wai/wai-quantum-service/src/jobs.rs b/wai/wai-quantum-service/src/jobs.rs index 6611bcac5..259582c95 100644 --- a/wai/wai-quantum-service/src/jobs.rs +++ b/wai/wai-quantum-service/src/jobs.rs @@ -500,7 +500,7 @@ impl Service { Some(e) => (Err(e.to_string()), peak), None => { // Inside the result, so the receipt seals the figure and its class. - v["energy"] = energy::figure(reading.as_ref(), plan.method == "statevector_single", energy::UNMETERED_HERE); + v["energy"] = energy::figure(reading.as_ref(), matches!(plan.method.as_str(), "statevector_single" | "cutrank_single"), energy::UNMETERED_HERE); (Ok(v), peak) } }, diff --git a/wai/wai-quantum-service/src/mcp.rs b/wai/wai-quantum-service/src/mcp.rs index 71976a925..c06eea7ab 100644 --- a/wai/wai-quantum-service/src/mcp.rs +++ b/wai/wai-quantum-service/src/mcp.rs @@ -31,7 +31,7 @@ fn tools() -> Value { "noise": { "type": "object", "description": "Optional noise, each a probability: depolarizing_1q, depolarizing_2q, amplitude_damping, dephasing (after each gate on its qubits), readout (each measured bit).", "properties": { "depolarizing_1q": { "type": "number" }, "depolarizing_2q": { "type": "number" }, "amplitude_damping": { "type": "number" }, "dephasing": { "type": "number" }, "readout": { "type": "number" } } }, - "method": { "type": "string", "enum": ["auto", "exact", "statevector", "statevector_single", "stabilizer", "pauli", "mps", "tensor_network", "cutrank", "density_matrix", "trajectories"], "description": "Leave as auto unless you need a particular method." }, + "method": { "type": "string", "enum": ["auto", "exact", "statevector", "statevector_single", "stabilizer", "pauli", "mps", "tensor_network", "cutrank", "cutrank_single", "density_matrix", "trajectories"], "description": "Leave as auto unless you need a particular method." }, "seed": { "type": "integer", "description": "Sampling seed (default 1); the same job and seed give the same counts." }, "options": { "type": "object", "description": "Advanced, per method: max_bond (mps), threshold and max_terms (pauli), fusion (statevector), trajectories." } }); diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 9d4e283b0..c7c8844cf 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -193,6 +193,8 @@ quantum_fabric_npu = ["quantum_fabric", "dep:objc2", "dep:block2", "dep:objc2-fo quantum_fabric_metal = ["quantum_fabric", "dep:objc2", "dep:objc2-foundation", "dep:objc2-metal"] # The dense state vector in single precision on the platform's GPU (macOS). quantum_sv_metal = ["quantum_sv", "dep:objc2", "dep:objc2-foundation", "dep:objc2-metal"] +# The cut-rank table in single precision on the platform's GPU (macOS). +quantum_cutrank_metal = ["quantum_cutrank", "dep:objc2", "dep:objc2-foundation", "dep:objc2-metal"] # The npu-cores member: the gate's arithmetic on a neural processor's compute core, programmed directly (Linux): the program built by the tile array's open toolchain, the host side compiled against the device runtime, both at run time. quantum_fabric_npu_cores = ["quantum_fabric"] # The tensor-engine member: the gate's products as exact bf16 limb convolutions on a tensor processor's matrix engine, through its kernel toolchain's Python run as a helper. @@ -327,6 +329,10 @@ required-features = ["quantum_sv"] name = "cutrank_qasm" required-features = ["quantum_cutrank"] +[[example]] +name = "cutrank_gpu" +required-features = ["quantum_cutrank_metal"] + [[example]] name = "npu_onnx" required-features = ["quantum_fabric"] diff --git a/wai/wai-quantum/examples/cutrank_gpu.rs b/wai/wai-quantum/examples/cutrank_gpu.rs new file mode 100644 index 000000000..ddf092d36 --- /dev/null +++ b/wai/wai-quantum/examples/cutrank_gpu.rs @@ -0,0 +1,103 @@ +//! One state's amplitudes by the cut-rank table on the host, in double and in +//! single precision, and on the platform's GPU, timed and compared. +//! +//! ```text +//! cargo run --release --example cutrank_gpu --features quantum_cutrank_metal -- QUBITS SEED [AMPLITUDES] [SHOTS] +//! ``` +//! +//! The state is a random graph state on `QUBITS` qubits (a Hadamard on each, +//! then a CZ on each pair with probability ½), whose cut-rank is about half +//! the qubits on each side of the middle for any order, then a closing layer +//! of random single-qubit gates, summed along an order annealed for less work. Prints the widest cut, the seconds for each +//! amplitude on each, the largest difference of the GPU's amplitudes from the +//! double-precision ones (relative to each amplitude), and, with SHOTS, the +//! seconds for that many samples on each. + +use std::time::Instant; +use wai_quantum::gates::complex; +use wai_quantum::quantum_cutrank::{Precision, ProductMeasured, Unitary}; +use wai_quantum::quantum_cutrank_metal::GpuCutRank; +use wai_quantum::quantum_tableau::{Clifford, Tableau}; + +fn next(s: &mut u64) -> u64 { + *s = s.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = *s; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +fn main() { + let args: Vec<String> = std::env::args().collect(); + if args[1] == "passes" { + // cutrank_gpu passes LOG_LEN REPS: each kernel's pass alone. + let l: u32 = args[2].parse().expect("LOG_LEN"); + let reps: usize = args.get(3).map_or(10, |a| a.parse().expect("REPS")); + let mut gpu = GpuCutRank::new(l as usize).expect("a GPU table"); + for (name, t) in gpu.time_passes(l, reps).expect("the passes run") { + println!("{name:14} {:8.3} ms {:6.2} G entries/s", t * 1e3, (1u64 << l) as f64 / t / 1e9); + } + return; + } + let n: usize = args[1].parse().expect("QUBITS"); + let seed: u64 = args[2].parse().expect("SEED"); + let amps: usize = args.get(3).map_or(3, |a| a.parse().expect("AMPLITUDES")); + let shots: usize = args.get(4).map_or(0, |a| a.parse().expect("SHOTS")); + let mut s = seed; + let mut ops: Vec<Clifford> = (0..n as u32).map(Clifford::H).collect(); + for a in 0..n as u32 { + for b in a + 1..n as u32 { + if next(&mut s) & 1 == 1 { + ops.push(Clifford::CZ(a, b)); + } + } + } + let last: Vec<Unitary> = (0..n) + .map(|_| { + let mut ang = || (next(&mut s) >> 11) as f64 / (1u64 << 53) as f64 * std::f64::consts::TAU; + let (t, p, l) = (ang(), ang(), ang()); + let (c, sn) = ((t / 2.0).cos(), (t / 2.0).sin()); + let e = |x: f64| complex(x.cos(), x.sin()); + let sc = |z: wai_quantum::gates::C, k: f64| complex(z.re * k, z.im * k); + [complex(c, 0.0), sc(e(l), -sn), sc(e(p), sn), sc(e(p + l), c)] + }) + .collect(); + let pm = ProductMeasured::new(&Tableau::run(n as u32, &ops).unwrap(), &last).unwrap(); + let order = pm.anneal_order(20_000, seed); + let pm = pm.with_order(&order).unwrap(); + let cut = pm.max_cut(); + println!("{n} qubits, widest cut {cut} bits, work 2^{:.1}", pm.work().log2()); + let mut gpu = GpuCutRank::new(cut).expect("a GPU table"); + println!("device: {}", gpu.device()); + let mut worst: f64 = 0.0; + let (mut td, mut ts, mut tg) = (0.0, 0.0, 0.0); + for _ in 0..amps { + let x: Vec<bool> = (0..n).map(|_| next(&mut s) & 1 == 1).collect(); + let t0 = Instant::now(); + let a = pm.amplitude(&x, 34).unwrap(); + td += t0.elapsed().as_secs_f64(); + let t0 = Instant::now(); + let _ = pm.amplitude_in(&x, 34, Precision::Single).unwrap(); + ts += t0.elapsed().as_secs_f64(); + let t0 = Instant::now(); + let b = gpu.amplitude(&pm, &x).unwrap(); + tg += t0.elapsed().as_secs_f64(); + worst = worst.max((a.re - b.re).hypot(a.im - b.im) / a.re.hypot(a.im).max(1e-300)); + } + let k = amps.max(1) as f64; + println!("seconds an amplitude: host double {:.3}, host single {:.3}, GPU {:.3}", td / k, ts / k, tg / k); + println!("largest relative difference of the GPU's amplitudes: {worst:.2e}"); + if shots > 0 { + let t0 = Instant::now(); + let _ = pm.sample_in(shots, seed, 34, Precision::Single).unwrap(); + let th = t0.elapsed().as_secs_f64(); + let t0 = Instant::now(); + let _ = gpu.sample(&pm, shots, seed).unwrap(); + let tg = t0.elapsed().as_secs_f64(); + let mut batched = GpuCutRank::with_batch(cut, 16).expect("a batch of GPU tables"); + let t0 = Instant::now(); + let _ = batched.sample(&pm, shots, seed).unwrap(); + let tb = t0.elapsed().as_secs_f64(); + println!("seconds for {shots} samples: host single {th:.3}, GPU {tg:.3}, GPU 16 at a time {tb:.3}"); + } +} diff --git a/wai/wai-quantum/src/engine/catalog.rs b/wai/wai-quantum/src/engine/catalog.rs index 1fe487ba1..d2c4e518f 100644 --- a/wai/wai-quantum/src/engine/catalog.rs +++ b/wai/wai-quantum/src/engine/catalog.rs @@ -15,6 +15,7 @@ //! | `mps` | any, ≤ 4096 qubits, best on low-entanglement circuits | counts, expectations, amplitudes | exact, or heuristic with its fidelity estimate | //! | `tensor_network` | any | amplitudes | exact | //! | `cutrank` | Clifford, then one layer of one-qubit gates at any angles, ≤ 4096 qubits, as wide as its cut-rank allows | counts, amplitudes | exact, or single | +//! | `cutrank_single` | as `cutrank`, the table on a GPU (never picked by `auto`) | counts, amplitudes | single | //! | `density_matrix` | any, noisy, ≤ 16 qubits in a job's memory | counts, expectations | exact | //! | `trajectories` | any, noisy, ≤ 32 qubits | counts, expectations | estimate | //! @@ -108,6 +109,7 @@ pub fn describe(job_bytes: u64) -> Value { { "method": "mps", "accuracy": "exact when nothing is truncated, otherwise heuristic", "circuits": "any; best on low-entanglement circuits", "max_qubits": MPS_MAX_QUBITS, "answers": ["counts", "expectations", "amplitudes"], "options": { "max_bond": "the largest bond dimension kept (default 64, at most 256)", "cutoff": "singular values whose squared share is below it are dropped (default 1e-12)" } }, { "method": "tensor_network", "accuracy": "exact", "circuits": "any", "max_qubits": qasm::MAX_QUBITS, "answers": ["amplitudes"], "options": { "path_restarts": "independent contraction-path searches, the cheapest kept (default 8; 1 in a browser tab)" } }, { "method": "cutrank", "accuracy": "exact; single when only a single-precision table fits", "circuits": "a Clifford circuit, then one layer of one-qubit gates at any angles: each qubit's gates from its first non-Clifford one on act on that qubit alone; the cost is set by the state's cut-rank along an order of the qubits, not by their number", "max_qubits": CUTRANK_MAX_QUBITS, "answers": ["counts", "amplitudes"] }, + { "method": "cutrank_single", "accuracy": "single", "circuits": "as for cutrank; the table on a GPU, its arithmetic in single precision", "max_qubits": CUTRANK_MAX_QUBITS, "max_cut": gpu_cutrank_max_cut().unwrap_or(0), "available": gpu_cutrank_max_cut().is_ok(), "answers": ["counts", "amplitudes"] }, { "method": "density_matrix", "accuracy": "exact", "circuits": "any, with noise", "max_qubits": density_max_qubits(job_bytes), "answers": ["counts", "expectations"] }, { "method": "trajectories", "accuracy": "estimate", "circuits": "any, with noise", "max_qubits": TRAJECTORY_MAX_QUBITS.min(statevector_max_qubits(job_bytes)), "answers": ["counts", "expectations"], "options": { "trajectories": "how many (default: the shots, or 1000 for expectations alone); shots that take the same errors share one state" } }, ]) @@ -144,6 +146,30 @@ pub fn gpu_max_qubits() -> Result<u32, String> { } } +/// The widest cut the GPU's cut-rank table takes, asked of the device once. +/// A build without it (feature `quantum_cutrank_metal`) has none. +pub fn gpu_cutrank_max_cut() -> Result<usize, String> { + #[cfg(feature = "quantum_cutrank_metal")] + { + static MAX: std::sync::OnceLock<Result<usize, String>> = std::sync::OnceLock::new(); + MAX.get_or_init(|| crate::quantum_cutrank_metal::GpuCutRank::device_max_cut().map_err(|e| e.to_string())).clone() + } + #[cfg(not(feature = "quantum_cutrank_metal"))] + { + Err("this build has no GPU cut-rank table".into()) + } +} + +/// The shots a `cutrank_single` job samples side by side on the GPU: as many +/// tables beside its first as 2 GiB holds, at most 32, never more than its +/// shots, and within the job's memory `cap` beside `base` (the job's memory +/// with one table). +pub fn cutrank_gpu_batch(pm: &crate::quantum_cutrank::ProductMeasured, shots: u64, base: u64, cap: u64) -> usize { + let table = pm.table_bytes(crate::quantum_cutrank::Precision::Single).max(1); + let room = cap.saturating_sub(base).min(2 << 30) / table; + (1 + room).min(32).min(shots.max(1)) as usize +} + /// The widest gate the GPU state vector applies. #[cfg(feature = "quantum_sv_metal")] const SINGLE_MAX_WIDTH: usize = crate::quantum_sv_metal::MAX_WIDTH; @@ -373,6 +399,16 @@ pub const CUTRANK_PHASE_OPS: u64 = 50_000_000_000; /// 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: &crate::quantum_cutrank::ProductMeasured, cut: usize, precision: crate::quantum_cutrank::Precision) -> Result<Option<crate::gates::C>, String> { + let single = precision == crate::quantum_cutrank::Precision::Single; + cutrank_phase_with(r, pm, single, &mut |p, x| p.amplitude_in(x, cut, precision).map_err(|e| e.to_string())) +} + +/// An amplitude of a state at a bit string, however it is summed. +pub type AmplitudeOf<'a> = dyn FnMut(&crate::quantum_cutrank::ProductMeasured, &[bool]) -> Result<crate::gates::C, String> + 'a; + +/// [`cutrank_phase`] with the Clifford part's amplitude taken by `amplitude` +/// (the GPU's table, say), held to single-precision rounding when `single`. +pub fn cutrank_phase_with(r: &Request, pm: &crate::quantum_cutrank::ProductMeasured, single: bool, amplitude: &mut AmplitudeOf<'_>) -> Result<Option<crate::gates::C>, String> { use crate::gates::complex; use crate::quantum_tableau::{Clifford, PhasedTableau}; let n = r.program.n_qubits as u64; @@ -389,13 +425,13 @@ pub fn cutrank_phase(r: &Request, pm: &crate::quantum_cutrank::ProductMeasured, let (bits, (are, aim)) = tracked.reference(); let (o, one) = (complex(0.0, 0.0), complex(1.0, 0.0)); let bulk_only = pm.with_last(&vec![[one, o, o, one]; n as usize]).map_err(|e| e.to_string())?; - let b = bulk_only.amplitude_in(&bits, cut, precision).map_err(|e| e.to_string())?; + let b = amplitude(&bulk_only, &bits)?; let bb = b.re * b.re + b.im * b.im; // a / b, then times e^{iΘ}. 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 == crate::quantum_cutrank::Precision::Double { 1e-9 } else { 1e-5 }; + let tolerance = if single { 1e-5 } else { 1e-9 }; if off > tolerance { return Err(format!("internal: the tracked and summed amplitudes differ in modulus by {off:e}")); } @@ -587,6 +623,30 @@ fn try_method(method: &str, r: &Request, dyadic: &Result<crate::quantum::Circuit let held = if precision == Precision::Double { "double" } else { "single" }; Ok(plan(memory, work, format!("Clifford circuit then one layer of one-qubit gates on {n} qubits: summed exactly by cut-rank, the widest cut {cut} bits, the table in {held} precision"))) } + "cutrank_single" => { + use crate::quantum_cutrank::Precision; + let max = gpu_cutrank_max_cut().map_err(|e| format!("cutrank_single needs a GPU: {e}"))?; + if wants_expectations { + return Err("cutrank_single answers counts and amplitudes".into()); + } + let pm = product_measured(r, false)?; + let cut = pm.max_cut(); + let one = cutrank_bytes(&pm, Precision::Single); + let batch = cutrank_gpu_batch(&pm, r.shots, one, cap) as u64; + let memory = one + (batch - 1) * pm.table_bytes(Precision::Single); + if cut > max { + return Err(format!("the widest cut along the best order found is {cut} bits; this GPU's table holds at most {max}")); + } + if memory > cap { + return Err(format!("the widest cut along the best order found is {cut} bits: a table of 2^{cut} entries, more than a job's memory")); + } + let per = pm.work().ceil() as u64; + let work = per.saturating_mul(r.amplitudes.len() as u64 + 3 * r.shots); + if work > CUTRANK_MAX_WORK { + return Err(format!("{work} table entries touched is more than one job may take ({CUTRANK_MAX_WORK}); ask for fewer shots or amplitudes")); + } + Ok(plan(memory, work, format!("Clifford circuit then one layer of one-qubit gates on {n} qubits: summed by cut-rank on the GPU in single precision, the widest cut {cut} bits"))) + } "tensor_network" => { if !wants_amplitudes || counts || wants_expectations { return Err("tensor_network answers amplitudes only".into()); diff --git a/wai/wai-quantum/src/engine/run.rs b/wai/wai-quantum/src/engine/run.rs index 8e9760323..0e1ae86cb 100644 --- a/wai/wai-quantum/src/engine/run.rs +++ b/wai/wai-quantum/src/engine/run.rs @@ -388,6 +388,49 @@ pub fn run(request: &Value, plan: &Plan) -> Result<Value, String> { out["amplitudes"] = Value::Array(entries); } } + #[cfg(not(feature = "quantum_cutrank_metal"))] + "cutrank_single" => return Err("this build has no GPU cut-rank table".into()), + #[cfg(feature = "quantum_cutrank_metal")] + "cutrank_single" => { + use crate::quantum_cutrank_metal::GpuCutRank; + let mut pm = catalog::product_measured(&r, true)?; + let cut = pm.max_cut(); + // The batch the plan's memory holds, the first table aside. + let one = catalog::cutrank_bytes(&pm, crate::quantum_cutrank::Precision::Single); + let batch = catalog::cutrank_gpu_batch(&pm, r.shots, one, plan.memory_bytes); + let mut gpu = GpuCutRank::with_batch(cut, batch).map_err(|e| e.to_string())?; + out["accuracy"] = json!({ + "class": "single", + "max_cut": cut, + "order_work": pm.work(), + "device": gpu.device(), + "batch": gpu.batch(), + "note": "the state's sum taken qubit by qubit along an order of the qubits, in a table of 2^max_cut entries on the GPU, its arithmetic in single precision (about 1e-6 relative); counts are samples drawn from the table's own probabilities, seeded, each shot from a stream of its own, a batch of shots side by side; amplitudes carry the circuit's own global phase, found by tracking one amplitude of the Clifford part exactly through the circuit" + }); + if r.shots > 0 { + let mut map: HashMap<String, u64> = HashMap::new(); + for (bits, _) in gpu.sample(&pm, r.shots as usize, r.seed).map_err(|e| e.to_string())? { + *map.entry(r.program.bitstring(|q| bits[q as usize])).or_insert(0) += 1; + } + merge(&mut out, counts(map)); + } + if !r.amplitudes.is_empty() { + let fixed = catalog::cutrank_phase_with(&r, &pm, true, &mut |p, x| gpu.amplitude(p, x).map_err(|e| e.to_string()))?; + if let Some(kappa) = fixed { + pm.scale(kappa); + } + let mut entries = Vec::new(); + for b in &r.amplitudes { + let x: Vec<bool> = (0..n).map(|q| b.as_bytes()[(n - 1 - q) as usize] == b'1').collect(); + let a = gpu.amplitude(&pm, &x).map_err(|e| e.to_string())?; + entries.push(json!({ "bits": b, "re": a.re, "im": a.im })); + } + out["amplitudes"] = Value::Array(entries); + if fixed.is_none() { + out["global_phase"] = json!("not fixed: tracking it exactly would take more than one job may; the amplitudes share one unknown phase, and their magnitudes and relative phases hold to single precision"); + } + } + } "cutrank" => { use crate::quantum_cutrank::Precision; // An order at least as cheap as the planner's (searched longer, from @@ -759,6 +802,38 @@ mod tests { } } + #[test] + fn the_gpu_cut_rank_table_agrees_where_there_is_one_and_is_refused_where_not() { + // A Clifford bulk on 24 qubits, then a closing layer at any angles. + let mut q = String::from("qubit[24] q; bit[24] c; h q;"); + for a in 0..24 { + q.push_str(&format!(" cz q[{a}], q[{}]; cx q[{a}], q[{}];", (a + 5) % 24, (a + 11) % 24)); + } + q.push_str(" rx(0.3) q; ry(1.1) q[3]; rz(0.7) q[9]; c = measure q;"); + let bits = ["000000000000000000000000", "101100111000101011110001"]; + let job = json!({ "qasm": q, "method": "cutrank_single", "shots": 300, "seed": 3, "amplitudes": bits }); + match crate::engine::catalog::gpu_cutrank_max_cut() { + Ok(_) => { + let g = go(job); + let d = go(json!({ "qasm": q, "method": "cutrank", "amplitudes": bits })); + assert_eq!(g["method"], "cutrank_single", "{g}"); + assert_eq!(g["accuracy"]["class"], "single"); + assert_eq!(g["accuracy"]["max_cut"], d["accuracy"]["max_cut"]); + for k in 0..2 { + let a = (g["amplitudes"][k]["re"].as_f64().unwrap(), g["amplitudes"][k]["im"].as_f64().unwrap()); + let b = (d["amplitudes"][k]["re"].as_f64().unwrap(), d["amplitudes"][k]["im"].as_f64().unwrap()); + let scale = b.0.hypot(b.1).max(1e-300); + assert!((a.0 - b.0).hypot(a.1 - b.1) / scale < 1e-5, "{a:?} vs {b:?}: the same amplitude, global phase and all"); + } + assert_eq!(g["counts"].as_object().unwrap().values().map(|v| v.as_u64().unwrap()).sum::<u64>(), 300); + } + Err(_) => { + let e = plan(&job, CAP).unwrap_err(); + assert!(e.contains("needs a GPU"), "{e}"); + } + } + } + #[test] fn a_pauli_sum_held_to_a_term_cap_keeps_its_bound() { let qasm = "qubit[8] q; rx(0.7) q; rzz(0.4) q[0], q[1]; rzz(0.4) q[2], q[3]; rzz(0.4) q[4], q[5]; rzz(0.4) q[6], q[7]; ry(0.9) q; rzz(0.3) q[1], q[2]; rzz(0.3) q[3], q[4]; rzz(0.3) q[5], q[6]; rx(1.1) q; rzz(0.5) q[0], q[7]; ry(0.4) q;"; diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index 8a2933c51..82d1328e5 100644 --- a/wai/wai-quantum/src/lib.rs +++ b/wai/wai-quantum/src/lib.rs @@ -262,6 +262,8 @@ pub mod quantum_stabilizer; pub mod quantum_tableau; #[cfg(feature = "quantum_cutrank")] pub mod quantum_cutrank; +#[cfg(feature = "quantum_cutrank_metal")] +pub mod quantum_cutrank_metal; #[cfg(feature = "quantum_sv_metal")] pub mod quantum_sv_metal; diff --git a/wai/wai-quantum/src/quantum_cutrank.rs b/wai/wai-quantum/src/quantum_cutrank.rs index 7a2c651fd..b0013f691 100644 --- a/wai/wai-quantum/src/quantum_cutrank.rs +++ b/wai/wai-quantum/src/quantum_cutrank.rs @@ -396,6 +396,13 @@ impl ProductMeasured { (out, walk.finish()) } + /// The walk's steps and each qubit's weights, for another walker of the + /// same table ([`crate::quantum_cutrank_metal`]). + #[cfg_attr(not(feature = "quantum_cutrank_metal"), allow(dead_code))] + pub(crate) fn walk_plan(&self) -> (&[Step], &[Unitary]) { + (&self.steps, &self.w) + } + fn check(&self, max_cut: usize) -> Result<(), CutRankError> { let cut = self.max_cut(); if cut > max_cut { @@ -410,7 +417,22 @@ fn pivot(v: &[u64]) -> usize { w * 64 + v[w].trailing_zeros() as usize } -fn splitmix(s: &mut u64) -> u64 { +/// The probability of outcome `b` of a step, from the sums over its table: +/// `Σ|w0·A + w1·B|² = |w0|²Σ|A|² + |w1|²Σ|B|² + 2·Re(w0·conj(w1)·ΣA·conj(B))`, +/// with `sa = Σ|A|²`, `sb = Σ|B|²` and `x = ΣA·conj(B)`. +pub(crate) fn outcome_norm(w: &Unitary, b: bool, sa: f64, sb: f64, x: (f64, f64)) -> f64 { + let (w0, w1) = (w[2 * usize::from(b)], w[2 * usize::from(b) + 1]); + let (cr, ci) = (w0.re * w1.re + w0.im * w1.im, w0.im * w1.re - w0.re * w1.im); + ((w0.re * w0.re + w0.im * w0.im) * sa + (w1.re * w1.re + w1.im * w1.im) * sb + 2.0 * (cr * x.0 - ci * x.1)).max(0.0) +} + +/// An outcome drawn from the stream with probabilities in the ratio `p0 : p1`. +pub(crate) fn draw(rng: &mut u64, p0: f64, p1: f64) -> bool { + let u = ((splitmix(rng) >> 11) as f64 + 0.5) / 9_007_199_254_740_992.0; + u * (p0 + p1) >= p0 +} + +pub(crate) fn splitmix(s: &mut u64) -> u64 { *s = s.wrapping_add(0x9e37_79b9_7f4a_7c15); let mut z = *s; z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); @@ -563,17 +585,17 @@ impl<T: Elem> Shared<T> { /// One qubit's step, the same for every bit string: fixed by the graph and /// the order alone. #[derive(Clone, Debug, PartialEq, Eq)] -struct Step { - v: usize, +pub(crate) struct Step { + pub(crate) v: usize, /// Coordinates swapped first, so the kernel's last coordinate is the top. - swap: Option<(usize, usize)>, + pub(crate) swap: Option<(usize, usize)>, /// The signs `(−1)^{z·ℓ(c)}`, in the coordinates after the swap. - ell: usize, + pub(crate) ell: usize, /// Merge along the kernel (its coordinates below the top), dropping the top. - kernel: Option<usize>, + pub(crate) kernel: Option<usize>, /// The edges ahead: `None` a new coordinate (the table doubles), else /// their coordinates. - alpha: Option<usize>, + pub(crate) alpha: Option<usize>, } /// The steps along `order` (see [`Walk`]). @@ -829,16 +851,7 @@ impl<T: Elem> Walk<T> { self.swap_coords(a, b); } let ws = |b: bool| (w[2 * usize::from(b)], w[2 * usize::from(b) + 1]); - // Σ|w0·A + w1·B|² = |w0|²Σ|A|² + |w1|²Σ|B|² + 2·Re(w0·conj(w1)·ΣA·conj(B)). - let norm = |b: bool, sa: f64, sb: f64, x: (f64, f64)| { - let (w0, w1) = ws(b); - let (cr, ci) = (w0.re * w1.re + w0.im * w1.im, w0.im * w1.re - w0.re * w1.im); - ((w0.re * w0.re + w0.im * w0.im) * sa + (w1.re * w1.re + w1.im * w1.im) * sb + 2.0 * (cr * x.0 - ci * x.1)).max(0.0) - }; - let draw = |rng: &mut u64, p0: f64, p1: f64| { - let u = ((splitmix(rng) >> 11) as f64 + 0.5) / 9_007_199_254_740_992.0; - u * (p0 + p1) >= p0 - }; + let norm = |b: bool, sa: f64, sb: f64, x: (f64, f64)| outcome_norm(w, b, sa, sb, x); let one = complex(1.0, 0.0); match (st.kernel, st.alpha) { (Some(k), None) => match rng { @@ -1150,6 +1163,95 @@ mod tests { assert_eq!(Precision::Single.entry_bytes() * 2, Precision::Double.entry_bytes()); } + /// A GPU table, or `None` where the platform has no device: the tests then + /// have nothing to check, and where one is declared + /// (`WAI_QUANTUM_CUTRANK_METAL_EXPECT`), its absence is a failure. + #[cfg(all(feature = "quantum_cutrank_metal", target_os = "macos"))] + fn gpu_table(max_cut: usize) -> Option<crate::quantum_cutrank_metal::GpuCutRank> { + use crate::quantum_cutrank_metal::{GpuCutRank, GpuError}; + match GpuCutRank::new(max_cut) { + Ok(g) => Some(g), + Err(GpuError::Unavailable(why)) => { + assert!(std::env::var_os("WAI_QUANTUM_CUTRANK_METAL_EXPECT").is_none(), "a Metal device was declared but none was found: {why}"); + eprintln!("no Metal device: skipped"); + None + } + Err(e) => panic!("the GPU table: {e}"), + } + } + + /// The GPU table against the double-precision walk: amplitudes within + /// single-precision rounding, the same bits run to run. + #[cfg(all(feature = "quantum_cutrank_metal", target_os = "macos"))] + #[test] + fn the_gpu_table_agrees() { + let mut s = 41u64; + let mut compared = 0; + for seed in 0..9u64 { + let n = 10 + 3 * seed as usize; + let ops = random_clifford(n as u32, 4 * n, seed); + let last: Vec<Unitary> = (0..n).map(|_| random_unitary(&mut s)).collect(); + let pm = ProductMeasured::new(&Tableau::run(n as u32, &ops).unwrap(), &last).unwrap(); + let Some(mut gpu) = gpu_table(pm.max_cut()) else { return }; + for _ in 0..3 { + let x: Vec<bool> = (0..n).map(|_| next(&mut s) & 1 == 1).collect(); + let (a, b) = (pm.amplitude(&x, 30).unwrap(), gpu.amplitude(&pm, &x).unwrap()); + let scale = a.re.hypot(a.im).max(1e-300); + assert!((a.re - b.re).hypot(a.im - b.im) / scale < 1e-5, "seed {seed}, {n} qubits, cut {}: {a:?} vs {b:?}", pm.max_cut()); + let again = gpu.amplitude(&pm, &x).unwrap(); + assert_eq!((b.re.to_bits(), b.im.to_bits()), (again.re.to_bits(), again.im.to_bits()), "seed {seed}: the same bits again"); + compared += 1; + } + } + assert!(compared >= 27, "only {compared} comparisons ran"); + } + + /// GPU samples: each one's probability is its own, the counts follow the + /// probabilities, and the stream is the seed's. + #[cfg(all(feature = "quantum_cutrank_metal", target_os = "macos"))] + #[test] + fn gpu_samples_follow_the_probabilities() { + use crate::quantum_cutrank_metal::GpuCutRank; + let n = 6usize; + let mut s = 99u64; + let ops = random_clifford(n as u32, 40, 7); + let last: Vec<Unitary> = (0..n).map(|_| random_unitary(&mut s)).collect(); + let pm = ProductMeasured::new(&Tableau::run(n as u32, &ops).unwrap(), &last).unwrap(); + let sv = dense(n as u32, &ops, &last); + let Some(mut gpu) = gpu_table(pm.max_cut()) else { return }; + let shots = 4_000; + let samples = gpu.sample(&pm, shots, 5).unwrap(); + let mut counts = vec![0usize; 1 << n]; + for (x, p) in &samples { + let idx = x.iter().enumerate().fold(0usize, |i, (q, &b)| i | (usize::from(b) << (n - 1 - q))); + counts[idx] += 1; + let want = sv.amplitude(idx); + let want = want.re * want.re + want.im * want.im; + assert!((p - want).abs() < 1e-5 * want.max(1e-3), "a sample's probability is its own: {p} vs {want}"); + } + for (i, &c) in counts.iter().enumerate() { + let a = sv.amplitude(i); + let p = a.re * a.re + a.im * a.im; + let sd = (shots as f64 * p * (1.0 - p)).sqrt().max(1.0); + assert!((c as f64 - shots as f64 * p).abs() < 5.0 * sd, "{i:06b}: {c} vs {:.1}", shots as f64 * p); + } + assert_eq!(gpu.sample(&pm, 50, 5).unwrap(), gpu.sample(&pm, 50, 5).unwrap()); + // A batch changes no bit: the same samples one at a time and seven side by side. + let mut seven = GpuCutRank::with_batch(pm.max_cut(), 7).unwrap(); + assert_eq!(seven.batch(), 7); + assert_eq!(gpu.sample(&pm, 20, 9).unwrap(), seven.sample(&pm, 20, 9).unwrap()); + // A wider state: each sample's probability against the double walk's. + let n = 28usize; + let ops = random_clifford(n as u32, 4 * n, 3); + let last: Vec<Unitary> = (0..n).map(|_| random_unitary(&mut s)).collect(); + let pm = ProductMeasured::new(&Tableau::run(n as u32, &ops).unwrap(), &last).unwrap(); + let mut gpu = GpuCutRank::new(pm.max_cut()).unwrap(); + for (x, p) in gpu.sample(&pm, 40, 11).unwrap() { + let want = pm.probability(&x, 30).unwrap(); + assert!((p - want).abs() < 1e-4 * want, "cut {}: {p} vs {want}", pm.max_cut()); + } + } + #[test] fn samples_follow_the_probabilities() { let n = 6usize; diff --git a/wai/wai-quantum/src/quantum_cutrank_metal.rs b/wai/wai-quantum/src/quantum_cutrank_metal.rs new file mode 100644 index 000000000..1126abd28 --- /dev/null +++ b/wai/wai-quantum/src/quantum_cutrank_metal.rs @@ -0,0 +1,842 @@ +//! The cut-rank table on the platform's GPU, in single precision — +//! `wai.quantum.cutrank.metal`. +//! +//! [`crate::quantum_cutrank`] sums a stabilizer state measured in a product +//! basis one qubit at a time, over a table of `2^{cut-rank}` complex numbers; +//! each qubit is a few passes over the table. This module runs those passes on +//! a GPU that shares the host's memory: +//! +//! - **Layout.** One shared buffer holding a table of `2^{max cut}` complex +//! values, two `f32`s each (the bytes a table takes on the host in single +//! precision), for each shot of a batch. +//! - **Passes.** One dispatch per pass, over (entry, shot): a thread per entry +//! (or per pair or quartet of entries a pass touches together), in the CPU +//! walk's order and with its arithmetic, done in `f32`. The passes are the +//! same for every shot; only each shot's weights, set by the outcomes it has +//! drawn, differ. An amplitude is every pass of every qubit encoded into one +//! command buffer, which the device runs in order. +//! - **Reductions.** A sample's conditional probabilities need sums over each +//! shot's table: each threadgroup sums its entries (a SIMD-group sum, then the +//! groups in order) into one partial sum, and the host adds a shot's partial +//! sums in `f64`, in order. The outcome is then drawn as the CPU walk draws it. +//! - **Batches.** A batch of shots is sampled side by side, so a qubit is one +//! command buffer for the whole batch: the previous qubit's passes for each +//! shot's outcome, then this qubit's sums. +//! - **Arithmetic.** The kernels are compiled with the platform's safe math +//! mode, so the device does not reassociate or approximate, and a run is the +//! same bits on the same device and operating system. +//! +//! # Checked +//! +//! - **Against the double-precision walk** on random Clifford circuits with a +//! closing layer at any angles, 10 to 34 qubits: every amplitude within +//! `10⁻⁵` of the amplitude's own size, and samples whose probabilities are +//! the double walk's within the same bound and whose counts follow them. +//! - **Batches change no bit:** a seed's samples are the same at any batch +//! size, since each shot draws from a stream of its own. +//! - **Speed** on a desktop machine's GPU, one amplitude of a random graph +//! state, each device timed at its quietest (the machine is shared, and +//! either side ran up to five times slower under other work): at a 23-bit +//! cut 0.005 s against 0.024 s for the host's single-precision table, at 25 +//! bits 0.019 s against 0.045 s, at 27 bits 0.046 s against 0.17 s, at 29 +//! bits 0.34 s against 0.54 s (1.03 s in double precision). A pass of the +//! table runs at about 550 GB/s on an idle device. +//! - **Sampling,** 64 shots of the same states, 16 a batch, the host at about +//! half its quietest speed: at a 16-bit cut 0.036 s against 0.52 s for the +//! host's single-precision sampler, at 20 bits 0.067 s against 1.97 s, at +//! 23 bits 0.34 s against 3.87 s, at 25 bits 1.20 s against 7.48 s. One shot +//! at a time, a narrow table's round trips cost more than its passes. +//! +//! # Honest boundaries +//! +//! - **Single-precision arithmetic.** The host's single-precision tables +//! store `f32` and compute in `f64`; this one computes in `f32` too. The +//! double-precision walk is the reference; this one is checked against it. +//! - **Its own sample streams.** Each shot draws from a stream of its own, so +//! a seed's samples here are not the host walk's samples for that seed; both +//! follow the same probabilities. +//! - **Not bit-identical across devices.** The result is reproducible on one +//! device and operating system; another GPU or compiler may round +//! differently. +//! - **At most `2³¹` entries** (16 GB) a table, within the device's largest +//! buffer. +//! - **macOS only.** Elsewhere [`GpuCutRank::new`] reports that no device is +//! available. + +use crate::gates::{C, complex}; +use crate::quantum_cutrank::{ProductMeasured, Step, Unitary, draw, outcome_norm, splitmix}; + +/// The widest cut the device's table takes, whatever its buffers: `2³¹` +/// entries keep every index in 32 bits. +pub const MAX_CUT: usize = 31; + +/// The most shots a batch samples side by side. +pub const MAX_BATCH: usize = 64; + +/// Why the GPU table could not run. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GpuError { + /// No Metal device, or not on macOS. + Unavailable(String), + /// A cut wider than this device's table holds. + TooWide { cut: usize, max: usize }, + /// A bit string is not one bit per qubit. + Bits, + /// The device refused the kernel or a command. + Device(String), +} + +impl std::fmt::Display for GpuError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GpuError::Unavailable(why) => write!(f, "no GPU cut-rank table here: {why}"), + GpuError::TooWide { cut, max } => write!(f, "a cut of {cut} bits; this device's table holds at most {max}"), + GpuError::Bits => write!(f, "a bit string is not one bit per qubit"), + GpuError::Device(why) => write!(f, "the device: {why}"), + } + } +} + +impl std::error::Error for GpuError {} + +/// The kernels. Each reads one `Args` and each shot's weights `(w0, w1)` as a +/// `float4`; `len` is the number of threads' worth of a table the kernel's +/// pass is over (see each kernel), and shot `y`'s table starts `y << shift` +/// entries in. +pub const SOURCE: &str = r#" +#include <metal_stdlib> +using namespace metal; + +struct Args { + uint len; + uint a; + uint b; + uint ell; + uint shift; +}; + +static inline float sgn(uint c, uint l) { return (popcount(c & l) & 1u) ? -1.0f : 1.0f; } +static inline uint insert_zero(uint i, uint p) { uint low = i & ((1u << p) - 1u); return ((i >> p) << (p + 1u)) | low; } +static inline float2 cm(float2 w, float2 v) { return float2(w.x * v.x - w.y * v.y, w.x * v.y + w.y * v.x); } +static inline float m2(float2 v) { return v.x * v.x + v.y * v.y; } + +#define SHOT device float2* t = t0 + (ulong(p.y) << g.shift); float2 w0 = wts[p.y].xy; float2 w1 = wts[p.y].zw; (void)w0; (void)w1; +#define PLAIN device float2* t0 [[buffer(0)]], constant Args& g [[buffer(1)]], constant float4* wts [[buffer(3)]], uint2 p [[thread_position_in_grid]] +#define SUMS device float2* t0 [[buffer(0)]], constant Args& g [[buffer(1)]], device float4* part [[buffer(2)]], constant float4* wts [[buffer(3)]], \ + threadgroup float4* tg [[threadgroup(0)]], uint2 p [[thread_position_in_grid]], uint2 lid [[thread_position_in_threadgroup]], \ + uint2 size [[threads_per_threadgroup]], uint2 group [[threadgroup_position_in_grid]], uint2 groups [[threadgroups_per_grid]], uint lanes [[threads_per_simdgroup]] + +// A threadgroup's sum, written by its first thread to its shot's row: each +// SIMD group sums its lanes (in the hardware's fixed order), then the first +// thread adds the SIMD groups' sums in order. +static inline void reduce(float4 v, threadgroup float4* tg, uint2 lid, uint2 size, uint2 group, uint2 groups, uint lanes, device float4* part) { + float4 s = simd_sum(v); + if (lid.x % lanes == 0u) tg[lid.x / lanes] = s; + threadgroup_barrier(mem_flags::mem_threadgroup); + if (lid.x == 0u) { + float4 t = tg[0]; + for (uint k = 1u; k < (size.x + lanes - 1u) / lanes; k++) t += tg[k]; + part[group.y * groups.x + group.x] = t; + } +} + +// Swap coordinates a < b: len/4 threads. +kernel void swap_coords(PLAIN) { + SHOT + uint base = insert_zero(insert_zero(p.x, g.a), g.b); + uint i = base | (1u << g.a), j = base | (1u << g.b); + float2 x = t[i]; + t[i] = t[j]; + t[j] = x; +} + +// Merge along a kernel of the top coordinate alone: len = top threads. +template <bool R> +kernel void merge0(SUMS) { + SHOT + uint top = g.len, c = p.x; + float2 x = t[c], y = t[c | top]; + float sx = sgn(c, g.ell), sy = sgn(c | top, g.ell); + float2 m0 = float2(x.x + y.x, x.y + y.y); + float2 m1 = float2(sx * x.x + sy * y.x, sx * x.y + sy * y.y); + t[c] = cm(w0, m0); + t[c | top] = cm(w1, m1); + if (R) reduce(float4(m2(m0), m2(m1), 0.0f, 0.0f), tg, lid, size, group, groups, lanes, part); +} + +// Merge along a kernel a with its highest bit b: len = top, top/2 threads. +template <bool R> +kernel void merge1(SUMS) { + SHOT + uint top = g.len; + uint c = insert_zero(p.x, g.b); + uint c2 = c ^ g.a; + float2 x = t[c], y = t[c2], xx = t[c | top], yy = t[c2 | top]; + float2 m0c = float2(x.x + yy.x, x.y + yy.y); + float2 m0c2 = float2(y.x + xx.x, y.y + xx.y); + float s1 = sgn(c, g.ell), s2 = sgn(c2 | top, g.ell), s3 = sgn(c2, g.ell), s4 = sgn(c | top, g.ell); + float2 m1c = float2(s1 * x.x + s2 * yy.x, s1 * x.y + s2 * yy.y); + float2 m1c2 = float2(s3 * y.x + s4 * xx.x, s3 * y.y + s4 * xx.y); + t[c] = cm(w0, m0c); + t[c2] = cm(w0, m0c2); + t[c | top] = cm(w1, m1c); + t[c2 | top] = cm(w1, m1c2); + if (R) reduce(float4(m2(m0c) + m2(m0c2), m2(m1c) + m2(m1c2), 0.0f, 0.0f), tg, lid, size, group, groups, lanes, part); +} + +#define SUMS_TYPES device float2*, constant Args&, device float4*, constant float4*, threadgroup float4*, uint2, uint2, uint2, uint2, uint2, uint +template [[host_name("merge0")]] kernel void merge0<false>(SUMS_TYPES); +template [[host_name("merge0_sums")]] kernel void merge0<true>(SUMS_TYPES); +template [[host_name("merge1")]] kernel void merge1<false>(SUMS_TYPES); +template [[host_name("merge1_sums")]] kernel void merge1<true>(SUMS_TYPES); + +// Σ lower[c]·conj(upper[c ⊕ a]): len = half threads. +kernel void cross_halves(SUMS) { + SHOT + uint c = p.x; + float2 x = t[c], y = t[(c ^ g.a) | g.len]; + reduce(float4(x.x * y.x + x.y * y.y, x.y * y.x - x.x * y.y, 0.0f, 0.0f), tg, lid, size, group, groups, lanes, part); +} + +// lower[c] ← w0·lower[c] + w1·upper[c ⊕ a]: len = half threads. +kernel void fold(PLAIN) { + SHOT + uint c = p.x; + float2 a = cm(w0, t[c]), b = cm(w1, t[(c ^ g.a) | g.len]); + t[c] = float2(a.x + b.x, a.y + b.y); +} + +// The table doubles: len threads. +kernel void dbl(PLAIN) { + SHOT + uint c = p.x; + float2 v = t[c]; + float sv = sgn(c, g.ell); + t[c | g.len] = cm(w1, float2(sv * v.x, sv * v.y)); + t[c] = cm(w0, v); +} + +// T[c] ← w0·T[c] + w1·s(c)·T[c]: len threads. +kernel void xor0(PLAIN) { + SHOT + uint c = p.x; + float2 v = t[c]; + float sv = sgn(c, g.ell); + float2 a = cm(w0, v), b = cm(w1, float2(sv * v.x, sv * v.y)); + t[c] = float2(a.x + b.x, a.y + b.y); +} + +// T[c] ← w0·T[c] + w1·s(c ⊕ a)·T[c ⊕ a], a's highest bit b: len/2 threads. +kernel void xor1(PLAIN) { + SHOT + uint c = insert_zero(p.x, g.b); + uint d = c ^ g.a; + float2 x = t[c], y = t[d]; + float sx = sgn(c, g.ell), sy = sgn(d, g.ell); + float2 a = cm(w0, x), b = cm(w1, float2(sy * y.x, sy * y.y)); + float2 e = cm(w0, y), f = cm(w1, float2(sx * x.x, sx * x.y)); + t[c] = float2(a.x + b.x, a.y + b.y); + t[d] = float2(e.x + f.x, e.y + f.y); +} + +// Σ|T|² and, with a ≠ 0 flagged in b, Σ T[c]·conj(s(c⊕a)·T[c⊕a]): len threads. +kernel void norms(SUMS) { + SHOT + uint c = p.x; + float2 x = t[c]; + float4 v = float4(m2(x), 0.0f, 0.0f, 0.0f); + if (g.b != 0u) { + uint d = c ^ g.a; + float2 y = t[d]; + float sy = sgn(d, g.ell); + v.y = sy * (x.x * y.x + x.y * y.y); + v.z = sy * (x.y * y.x - x.x * y.y); + } + reduce(v, tg, lid, size, group, groups, lanes, part); +} + +// The halves scaled once a merge's outcome is known: len = half threads. +kernel void scale_halves(PLAIN) { + SHOT + uint c = p.x; + t[c] = cm(w0, t[c]); + t[c | g.len] = cm(w1, t[c | g.len]); +} +"#; + +/// A kernel's arguments, laid out as the source's `Args`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Args { + len: u32, + a: u32, + b: u32, + ell: u32, + shift: u32, +} + +/// The kernels, by the index `sys` keeps their pipelines at. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Kernel { + Swap, + Merge0, + Merge0Sums, + Merge1, + Merge1Sums, + CrossHalves, + Fold, + Double, + Xor0, + Xor1, + Norms, + ScaleHalves, +} + +impl Kernel { + pub(crate) const ALL: [Kernel; 12] = [ + Kernel::Swap, + Kernel::Merge0, + Kernel::Merge0Sums, + Kernel::Merge1, + Kernel::Merge1Sums, + Kernel::CrossHalves, + Kernel::Fold, + Kernel::Double, + Kernel::Xor0, + Kernel::Xor1, + Kernel::Norms, + Kernel::ScaleHalves, + ]; + + pub(crate) fn name(self) -> &'static str { + match self { + Kernel::Swap => "swap_coords", + Kernel::Merge0 => "merge0", + Kernel::Merge0Sums => "merge0_sums", + Kernel::Merge1 => "merge1", + Kernel::Merge1Sums => "merge1_sums", + Kernel::CrossHalves => "cross_halves", + Kernel::Fold => "fold", + Kernel::Double => "dbl", + Kernel::Xor0 => "xor0", + Kernel::Xor1 => "xor1", + Kernel::Norms => "norms", + Kernel::ScaleHalves => "scale_halves", + } + } + + /// Whether the kernel writes a partial sum per threadgroup. + pub(crate) fn sums(self) -> bool { + matches!(self, Kernel::Merge0Sums | Kernel::Merge1Sums | Kernel::CrossHalves | Kernel::Norms) + } +} + +/// One dispatch: a kernel, its arguments, its threads a shot, each shot's +/// weights `[w0.re, w0.im, w1.re, w1.im]` (one entry a shot of the batch the +/// dispatch covers), and for a kernel that sums, the region of the +/// partial-sum buffer it writes (0 or 1). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Op { + pub(crate) kernel: Kernel, + pub(crate) args: Args, + pub(crate) threads: u64, + pub(crate) weights: Vec<[f32; 4]>, + pub(crate) region: usize, +} + +fn w4(w0: C, w1: C) -> [f32; 4] { + [w0.re as f32, w0.im as f32, w1.re as f32, w1.im as f32] +} + +/// The highest set bit of a nonzero mask. +fn high(m: usize) -> u32 { + usize::BITS - 1 - m.leading_zeros() +} + +/// The cut-rank table on the GPU, sized for one state's widest cut and a batch +/// of shots. +pub struct GpuCutRank { + dev: sys::Device, + max_cut: usize, + batch: usize, +} + +impl std::fmt::Debug for GpuCutRank { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GpuCutRank").field("device", &self.dev.name()).field("max_cut", &self.max_cut).field("batch", &self.batch).finish() + } +} + +impl GpuCutRank { + /// The widest cut this platform's GPU takes: the device's largest buffer, + /// at most [`MAX_CUT`]. + pub fn device_max_cut() -> Result<usize, GpuError> { + sys::max_cut() + } + + /// A table for cuts of at most `max_cut` bits, one shot at a time. + pub fn new(max_cut: usize) -> Result<GpuCutRank, GpuError> { + GpuCutRank::with_batch(max_cut, 1) + } + + /// A table for cuts of at most `max_cut` bits, sampling `batch` shots side + /// by side (1 to [`MAX_BATCH`]; the tables take `batch · 8 · 2^max_cut` + /// bytes, and the device's largest buffer bounds them). + pub fn with_batch(max_cut: usize, batch: usize) -> Result<GpuCutRank, GpuError> { + let max = sys::max_cut()?; + if max_cut > max { + return Err(GpuError::TooWide { cut: max_cut, max }); + } + let batch = batch.clamp(1, MAX_BATCH); + Ok(GpuCutRank { dev: sys::Device::new(max_cut, batch)?, max_cut, batch }) + } + + /// The device's name. + pub fn device(&self) -> String { + self.dev.name() + } + + /// The shots a batch samples side by side. + pub fn batch(&self) -> usize { + self.batch + } + + fn fits(&self, pm: &ProductMeasured) -> Result<(), GpuError> { + let cut = pm.max_cut(); + if cut > self.max_cut { + return Err(GpuError::TooWide { cut, max: self.max_cut }); + } + Ok(()) + } + + /// `⟨x|ψ⟩` up to the state's global phase, as + /// [`ProductMeasured::amplitude`] gives it, in single precision. + pub fn amplitude(&mut self, pm: &ProductMeasured, x: &[bool]) -> Result<C, GpuError> { + if x.len() != pm.qubits() { + return Err(GpuError::Bits); + } + self.fits(pm)?; + let (steps, w) = pm.walk_plan(); + let shift = self.max_cut as u32; + let mut ops = Vec::new(); + let mut len = 1usize; + for st in steps { + let (w0, w1) = (w[st.v][2 * usize::from(x[st.v])], w[st.v][2 * usize::from(x[st.v]) + 1]); + len = passes(st, len, shift, w4(w0, w1), &mut ops); + } + debug_assert_eq!(len, 1); + self.dev.reset(1); + self.dev.run(&ops)?; + let v = self.dev.entry(0, 0); + Ok(complex(v[0] as f64, v[1] as f64)) + } + + /// `shots` samples, each with its probability, from a seeded stream: + /// qubit by qubit along the order, each outcome from its conditional + /// probability, as [`ProductMeasured::sample`] draws them. Shot `s` draws + /// from a stream of its own, the `s`-th output of a generator seeded from + /// `seed`, so the samples do not depend on the batch size. A batch of + /// shots is sampled side by side, one command buffer a qubit. + pub fn sample(&mut self, pm: &ProductMeasured, shots: usize, seed: u64) -> Result<Vec<(Vec<bool>, f64)>, GpuError> { + self.fits(pm)?; + let (steps, w) = pm.walk_plan(); + let shift = self.max_cut as u32; + let mut streams = seed ^ 0x5851_f42d_4c95_7f2d; + let mut out = Vec::with_capacity(shots); + let mut done = 0; + while done < shots { + let b = self.batch.min(shots - done); + let mut rngs: Vec<u64> = (0..b).map(|_| splitmix(&mut streams)).collect(); + let mut bits = vec![vec![false; pm.qubits()]; b]; + self.dev.reset(b); + let mut len = 1usize; + let mut pending = Vec::new(); + for st in steps { + let outcome = self.sample_step(st, &w[st.v], len, shift, &mut rngs, &mut pending)?; + for (s, &o) in outcome.0.iter().enumerate() { + bits[s][st.v] = o; + } + len = outcome.1; + } + self.dev.run(&pending)?; + for (s, x) in bits.into_iter().enumerate() { + let v = self.dev.entry(s, 0); + out.push((x, v[0] as f64 * v[0] as f64 + v[1] as f64 * v[1] as f64)); + } + done += b; + } + Ok(out) + } + + /// One sampling step for a batch: `pending` (the previous step's passes for + /// each shot's outcome) and the sums this step's outcomes need, in one + /// command buffer; each shot's draw; then this step's passes for the + /// outcomes drawn, left in `pending`. Returns the outcomes and the tables' + /// new length. + fn sample_step(&mut self, st: &Step, w: &Unitary, len: usize, shift: u32, rngs: &mut [u64], pending: &mut Vec<Op>) -> Result<(Vec<bool>, usize), GpuError> { + let b = rngs.len(); + let one = complex(1.0, 0.0); + let ones = vec![w4(one, one); b]; + let mut ops = std::mem::take(pending); + if let Some((a, c)) = st.swap { + ops.push(swap_op(len, a, c, shift, ones.clone())); + } + let ws = |bit: bool| w4(w[2 * usize::from(bit)], w[2 * usize::from(bit) + 1]); + let half = len >> 1; + let base = Args { len: half as u32, shift, ..Args::default() }; + match (st.kernel, st.alpha) { + (Some(k), alpha) => { + ops.push(merge_op(half, k, st.ell, shift, ones.clone(), true)); + if let Some(al) = alpha { + ops.push(Op { kernel: Kernel::CrossHalves, args: Args { a: al as u32, ..base }, threads: half as u64, weights: ones, region: 1 }); + } + let sums = self.dev.sums(&ops, b)?; + let mut outcomes = Vec::with_capacity(b); + let mut weights = Vec::with_capacity(b); + for (s, rng) in rngs.iter_mut().enumerate() { + let [sa, sb, _, _] = sums[0][s]; + let x = if alpha.is_some() { (sums[1][s][0], sums[1][s][1]) } else { (0.0, 0.0) }; + let o = draw(rng, outcome_norm(w, false, sa, sb, x), outcome_norm(w, true, sa, sb, x)); + outcomes.push(o); + weights.push(ws(o)); + } + Ok(match alpha { + None => { + pending.push(Op { kernel: Kernel::ScaleHalves, args: base, threads: half as u64, weights, region: 0 }); + (outcomes, len) + } + Some(al) => { + pending.push(Op { kernel: Kernel::Fold, args: Args { a: al as u32, ..base }, threads: half as u64, weights, region: 0 }); + (outcomes, half) + } + }) + } + (None, alpha) => { + let al = alpha.unwrap_or(0); + let args = Args { len: len as u32, a: al as u32, b: u32::from(alpha.is_some()), ell: st.ell as u32, shift }; + ops.push(Op { kernel: Kernel::Norms, args, threads: len as u64, weights: ones, region: 0 }); + let sums = self.dev.sums(&ops, b)?; + let mut outcomes = Vec::with_capacity(b); + let mut weights = Vec::with_capacity(b); + for (s, rng) in rngs.iter_mut().enumerate() { + let [n, r, i, _] = sums[0][s]; + let x = if alpha.is_some() { (r, i) } else { (0.0, 0.0) }; + let o = draw(rng, outcome_norm(w, false, n, n, x), outcome_norm(w, true, n, n, x)); + outcomes.push(o); + weights.push(ws(o)); + } + Ok(match alpha { + None => { + pending.push(double_op(len, st.ell, shift, weights)); + (outcomes, 2 * len) + } + Some(al) => { + pending.push(xor_op(len, al, st.ell, shift, weights)); + (outcomes, len) + } + }) + } + } + } + + /// Seconds a pass of each kernel takes over a table of `2^log_len` + /// entries, the mean of `reps` passes in one command buffer, by kernel + /// name. For measuring the kernels, not for computing. + #[doc(hidden)] + pub fn time_passes(&mut self, log_len: u32, reps: usize) -> Result<Vec<(&'static str, f64)>, GpuError> { + let l = log_len as usize; + if l > self.max_cut || l < 4 { + return Err(GpuError::TooWide { cut: l, max: self.max_cut }); + } + let (len, half, shift) = (1usize << l, 1usize << (l - 1), self.max_cut as u32); + let w = complex(0.6, 0.8); + let ww = vec![w4(w, w)]; + let base = Args { len: half as u32, a: 3, shift, ..Args::default() }; + let cases = [ + swap_op(len, 0, l - 1, shift, ww.clone()), + merge_op(half, 0, 5, shift, ww.clone(), false), + merge_op(half, 3, 5, shift, ww.clone(), false), + merge_op(half, 3, 5, shift, ww.clone(), true), + Op { kernel: Kernel::Fold, args: base, threads: half as u64, weights: ww.clone(), region: 0 }, + double_op(half, 5, shift, ww.clone()), + xor_op(len, 0, 5, shift, ww.clone()), + xor_op(len, 5, 5, shift, ww.clone()), + Op { kernel: Kernel::Norms, args: Args { len: len as u32, a: 5, b: 1, ell: 5, shift }, threads: len as u64, weights: ww, region: 0 }, + ]; + let mut out = Vec::new(); + for op in cases { + let name = op.kernel.name(); + let ops = vec![op; reps.max(1)]; + let t0 = std::time::Instant::now(); + self.dev.run(&ops)?; + out.push((name, t0.elapsed().as_secs_f64() / reps.max(1) as f64)); + } + Ok(out) + } +} + +fn swap_op(len: usize, a: usize, b: usize, shift: u32, weights: Vec<[f32; 4]>) -> Op { + let (lo, hi) = (a.min(b) as u32, a.max(b) as u32); + Op { kernel: Kernel::Swap, args: Args { len: len as u32, a: lo, b: hi, shift, ..Args::default() }, threads: (len >> 2) as u64, weights, region: 0 } +} + +fn merge_op(top: usize, kappa_low: usize, ell: usize, shift: u32, weights: Vec<[f32; 4]>, sums: bool) -> Op { + let base = Args { len: top as u32, ell: ell as u32, shift, ..Args::default() }; + if kappa_low == 0 { + Op { kernel: if sums { Kernel::Merge0Sums } else { Kernel::Merge0 }, args: base, threads: top as u64, weights, region: 0 } + } else { + Op { kernel: if sums { Kernel::Merge1Sums } else { Kernel::Merge1 }, args: Args { a: kappa_low as u32, b: high(kappa_low), ..base }, threads: (top >> 1) as u64, weights, region: 0 } + } +} + +fn double_op(len: usize, ell: usize, shift: u32, weights: Vec<[f32; 4]>) -> Op { + Op { kernel: Kernel::Double, args: Args { len: len as u32, ell: ell as u32, shift, ..Args::default() }, threads: len as u64, weights, region: 0 } +} + +fn xor_op(len: usize, al: usize, ell: usize, shift: u32, weights: Vec<[f32; 4]>) -> Op { + let base = Args { len: len as u32, ell: ell as u32, shift, ..Args::default() }; + if al == 0 { + Op { kernel: Kernel::Xor0, args: base, threads: len as u64, weights, region: 0 } + } else { + Op { kernel: Kernel::Xor1, args: Args { a: al as u32, b: high(al), ..base }, threads: (len >> 1) as u64, weights, region: 0 } + } +} + +/// One amplitude step's passes for weights `w`, appended to `ops`, as the CPU +/// walk makes them; returns the table's new length. +fn passes(st: &Step, len: usize, shift: u32, w: [f32; 4], ops: &mut Vec<Op>) -> usize { + let one = complex(1.0, 0.0); + let ones = vec![w4(one, one)]; + if let Some((a, b)) = st.swap { + ops.push(swap_op(len, a, b, shift, ones.clone())); + } + match (st.kernel, st.alpha) { + (Some(k), None) => { + ops.push(merge_op(len >> 1, k, st.ell, shift, vec![w], false)); + len + } + (Some(k), Some(al)) => { + let half = len >> 1; + ops.push(merge_op(half, k, st.ell, shift, ones, false)); + ops.push(Op { kernel: Kernel::Fold, args: Args { len: half as u32, a: al as u32, shift, ..Args::default() }, threads: half as u64, weights: vec![w], region: 0 }); + half + } + (None, None) => { + ops.push(double_op(len, st.ell, shift, vec![w])); + 2 * len + } + (None, Some(al)) => { + ops.push(xor_op(len, al, st.ell, shift, vec![w])); + len + } + } +} + +/// Threads a threadgroup: at most 256, and a power of two dividing `threads`. +fn group(threads: u64, limit: u64) -> u64 { + let mut tg = limit.min(256).min(threads.max(1)); + while tg & (tg - 1) != 0 { + tg &= tg - 1; + } + tg +} + +#[cfg(target_os = "macos")] +mod sys { + use super::{GpuError, Kernel, MAX_CUT, Op, SOURCE, group}; + use objc2::rc::{Retained, autoreleasepool}; + use objc2::runtime::ProtocolObject; + use objc2_foundation::{NSError, NSString}; + use objc2_metal::{ + MTLBuffer, MTLCommandBuffer, MTLCommandBufferStatus, MTLCommandEncoder, MTLCommandQueue, MTLCompileOptions, MTLComputeCommandEncoder, MTLComputePipelineState, + MTLCreateSystemDefaultDevice, MTLDevice, MTLLibrary, MTLMathMode, MTLResourceOptions, MTLSize, + }; + use std::ptr::NonNull; + + type Pipeline = Retained<ProtocolObject<dyn MTLComputePipelineState>>; + + pub struct Device { + device: Retained<ProtocolObject<dyn MTLDevice>>, + queue: Retained<ProtocolObject<dyn MTLCommandQueue>>, + pipes: Vec<Pipeline>, + /// Each shot's table, `2^max_cut` entries apart. + table: Retained<ProtocolObject<dyn MTLBuffer>>, + max_cut: usize, + /// Two regions of partial sums, `region` bytes each: a row of the + /// widest pass's threadgroups for each shot of a batch. + part: Retained<ProtocolObject<dyn MTLBuffer>>, + region: usize, + } + + fn ns(e: &NSError) -> String { + e.localizedDescription().to_string() + } + + fn size(w: u64, h: u64) -> MTLSize { + MTLSize { width: w as usize, height: h as usize, depth: 1 } + } + + pub fn max_cut() -> Result<usize, GpuError> { + let device = MTLCreateSystemDefaultDevice().ok_or_else(|| GpuError::Unavailable("the platform has no default Metal device".into()))?; + // 8 bytes an entry. + let entries = (device.maxBufferLength() as u64 / 8).max(1); + Ok((63 - entries.leading_zeros() as usize).min(MAX_CUT)) + } + + impl Device { + pub fn new(max_cut: usize, batch: usize) -> Result<Device, GpuError> { + autoreleasepool(|_| { + let device = MTLCreateSystemDefaultDevice().ok_or_else(|| GpuError::Unavailable("the platform has no default Metal device".into()))?; + let bytes = (8usize << max_cut) * batch; + if bytes as u64 > device.maxBufferLength() as u64 { + return Err(GpuError::Device(format!("{batch} tables of 2^{max_cut} entries are more than the device's largest buffer"))); + } + let queue = device.newCommandQueue().ok_or_else(|| GpuError::Device("no command queue".into()))?; + let options = MTLCompileOptions::new(); + options.setMathMode(MTLMathMode::Safe); + let library = device.newLibraryWithSource_options_error(&NSString::from_str(SOURCE), Some(&options)).map_err(|e| GpuError::Device(ns(&e)))?; + let mut pipes = Vec::with_capacity(Kernel::ALL.len()); + for k in Kernel::ALL { + let f = library.newFunctionWithName(&NSString::from_str(k.name())).ok_or_else(|| GpuError::Device(format!("no entry point {}", k.name())))?; + pipes.push(device.newComputePipelineStateWithFunction_error(&f).map_err(|e| GpuError::Device(ns(&e)))?); + } + let table = device.newBufferWithLength_options(bytes, MTLResourceOptions::StorageModeShared).ok_or_else(|| GpuError::Device(format!("a table of {bytes} bytes")))?; + // One partial sum (four f32) per threadgroup of the widest pass + // that sums, for each shot; a narrower pass has no more. + let widest = 1u64 << max_cut; + let tg = Kernel::ALL + .iter() + .zip(&pipes) + .filter(|(k, _)| k.sums()) + .map(|(_, p)| group(widest, p.maxTotalThreadsPerThreadgroup() as u64)) + .min() + .unwrap_or(1); + let region = 16 * (widest / tg).max(1) as usize * batch; + let part = device.newBufferWithLength_options(2 * region, MTLResourceOptions::StorageModeShared).ok_or_else(|| GpuError::Device("a partial-sum buffer".into()))?; + Ok(Device { device, queue, pipes, table, max_cut, part, region }) + }) + } + + pub fn name(&self) -> String { + self.device.name().to_string() + } + + /// The first `shots` tables back to the one entry 1. + pub fn reset(&self, shots: usize) { + for s in 0..shots { + // SAFETY: each shot's table starts `2^max_cut` entries after the + // last, inside the buffer, and no command buffer is in flight + // between the methods of `&mut GpuCutRank`. + unsafe { *self.table.contents().as_ptr().cast::<[f32; 2]>().add(s << self.max_cut) = [1.0, 0.0] }; + } + } + + pub fn entry(&self, shot: usize, i: usize) -> [f32; 2] { + // SAFETY: as for `reset`; callers read inside a shot's table. + unsafe { *self.table.contents().as_ptr().cast::<[f32; 2]>().add((shot << self.max_cut) + i) } + } + + fn pipe(&self, k: Kernel) -> &Pipeline { + &self.pipes[Kernel::ALL.iter().position(|&x| x == k).expect("every kernel has a pipeline")] + } + + fn encode(&self, enc: &ProtocolObject<dyn MTLComputeCommandEncoder>, op: &Op) { + if op.threads == 0 || op.weights.is_empty() { + return; + } + let pipe = self.pipe(op.kernel); + enc.setComputePipelineState(pipe); + let tg = group(op.threads, pipe.maxTotalThreadsPerThreadgroup() as u64); + let shots = op.weights.len() as u64; + // SAFETY: the tables at 0, the arguments at 1 (laid out as the + // source's `Args`), the partial sums at 2, each shot's weights at 3 + // (one float4 a shot, at most 64 of them, within the 4 KiB inline + // limit), each sized for what the kernel reads and writes; + // threadgroup memory for `tg` float4s. + unsafe { + enc.setBuffer_offset_atIndex(Some(&self.table), 0, 0); + enc.setBytes_length_atIndex(NonNull::from(&op.args).cast(), std::mem::size_of_val(&op.args), 1); + enc.setBytes_length_atIndex(NonNull::from(op.weights.as_slice()).cast(), std::mem::size_of_val(op.weights.as_slice()), 3); + if op.kernel.sums() { + enc.setBuffer_offset_atIndex(Some(&self.part), op.region * self.region, 2); + enc.setThreadgroupMemoryLength_atIndex((tg * 16) as usize, 0); + } + } + enc.dispatchThreadgroups_threadsPerThreadgroup(size(op.threads / tg, shots), size(tg, 1)); + } + + /// Run `ops` in order in one command buffer. + pub fn run(&self, ops: &[Op]) -> Result<(), GpuError> { + if ops.is_empty() { + return Ok(()); + } + autoreleasepool(|_| { + let cb = self.queue.commandBuffer().ok_or_else(|| GpuError::Device("no command buffer".into()))?; + let enc = cb.computeCommandEncoder().ok_or_else(|| GpuError::Device("no compute encoder".into()))?; + for op in ops { + self.encode(&enc, op); + } + enc.endEncoding(); + cb.commit(); + cb.waitUntilCompleted(); + if cb.status() != MTLCommandBufferStatus::Completed { + let why = cb.error().map(|e| ns(&e)).unwrap_or_else(|| format!("status {:?}", cb.status())); + return Err(GpuError::Device(format!("the command buffer did not complete: {why}"))); + } + Ok(()) + }) + } + + /// Run `ops` and, for each op that writes partial sums (each to a + /// region of its own), each of the `shots` shots' partial sums added in + /// `f64`, in order. + pub fn sums(&self, ops: &[Op], shots: usize) -> Result<Vec<Vec<[f64; 4]>>, GpuError> { + self.run(ops)?; + Ok(ops + .iter() + .filter(|op| op.kernel.sums()) + .map(|op| { + let groups = (op.threads / group(op.threads, self.pipe(op.kernel).maxTotalThreadsPerThreadgroup() as u64)) as usize; + // SAFETY: the command buffer that wrote the partial sums has + // completed, and each region holds a row of the widest + // pass's threadgroups for each shot. + let parts = unsafe { std::slice::from_raw_parts(self.part.contents().as_ptr().cast::<u8>().add(op.region * self.region).cast::<[f32; 4]>(), groups * shots) }; + parts + .chunks(groups) + .map(|row| row.iter().fold([0.0; 4], |a, p| [a[0] + p[0] as f64, a[1] + p[1] as f64, a[2] + p[2] as f64, a[3] + p[3] as f64])) + .collect() + }) + .collect()) + } + } +} + +#[cfg(not(target_os = "macos"))] +mod sys { + use super::{GpuError, Op}; + + pub struct Device; + + pub fn max_cut() -> Result<usize, GpuError> { + Err(GpuError::Unavailable("the GPU cut-rank table runs on macOS".into())) + } + + impl Device { + pub fn new(_: usize, _: usize) -> Result<Device, GpuError> { + Err(GpuError::Unavailable("the GPU cut-rank table runs on macOS".into())) + } + pub fn name(&self) -> String { + String::new() + } + pub fn reset(&self, _: usize) {} + pub fn entry(&self, _: usize, _: usize) -> [f32; 2] { + [0.0; 2] + } + pub fn run(&self, _: &[Op]) -> Result<(), GpuError> { + Err(GpuError::Unavailable("the GPU cut-rank table runs on macOS".into())) + } + pub fn sums(&self, _: &[Op], _: usize) -> Result<Vec<Vec<[f64; 4]>>, GpuError> { + Err(GpuError::Unavailable("the GPU cut-rank table runs on macOS".into())) + } + } +} -- 2.54.0 (Apple Git-157) From 95b3bc5f3ed690efb94dbc73d23122b3967459c8 Mon Sep 17 00:00:00 2001 From: Transaction Science <hello@transaction.science> Date: Tue, 20 Oct 2026 17:34:52 -0400 Subject: [PATCH 19/19] wai-quantum-cli and the desktop app require wai-quantum 0.6.12 Their metal features turn on quantum_cutrank_metal, which 0.6.12 adds. 0.6.11 was not published on its own; 0.6.12 carries it, and the changelog says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- wai/CHANGELOG.md | 2 ++ wai/wai-quantum-cli/Cargo.toml | 2 +- wai/wai-quantum-desktop/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index c45d5e701..e7c07ec03 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -8,6 +8,8 @@ wire format may change and such changes are recorded here. ### wai-quantum 0.6.12: cheaper contraction trees, and the cut-rank table on the GPU +0.6.11 was not published on its own: this release carries it to the package registry, and `wai-quantum-cli` 0.1.0 requires it, since the command line's `metal` feature turns on `quantum_cutrank_metal`. + One pinned digest changes, by design: the tensor-network plan in `tests/bit_identity.rs`, because the search now finds a cheaper plan. The native and WASI builds compute the same new digest. Every other pin is unchanged. - **Each bisection runs two V-cycles.** After the split is projected onto the full graph: diff --git a/wai/wai-quantum-cli/Cargo.toml b/wai/wai-quantum-cli/Cargo.toml index a7e4f2591..19a7c2834 100644 --- a/wai/wai-quantum-cli/Cargo.toml +++ b/wai/wai-quantum-cli/Cargo.toml @@ -34,4 +34,4 @@ getrandom = "0.4" # 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.11", path = "../wai-quantum", default-features = false, features = ["full"] } +wai-quantum = { version = "0.6.12", path = "../wai-quantum", default-features = false, features = ["full"] } diff --git a/wai/wai-quantum-desktop/Cargo.toml b/wai/wai-quantum-desktop/Cargo.toml index 66ebe07f0..198c4f423 100644 --- a/wai/wai-quantum-desktop/Cargo.toml +++ b/wai/wai-quantum-desktop/Cargo.toml @@ -16,7 +16,7 @@ tauri = { version = "2", features = [] } serde_json = { version = "1", features = ["float_roundtrip"] } # The assistant's model server, called by the app (a local one by default). ureq = { version = "3", features = ["json"] } -wai-quantum = { version = "0.6.11", path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt"] } +wai-quantum = { version = "0.6.12", path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt"] } wai-quantum-cli = { version = "0.1.0", path = "../wai-quantum-cli", default-features = false } [features] -- 2.54.0 (Apple Git-157)