From 5496c23d3f047abbe672a3a673561ca8c83ee25d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 25 Oct 2026 14:48:07 -0400 Subject: [PATCH 1/6] wai-quantum: the specification-and-evaluator workbench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A person writes what a family of circuits must do and what it costs; proposers write generators; the engine checks each one at every size and an archive keeps the cheapest verified generator of every kind. The largest unbuilt feature of the co-design research: the evaluator is the engine, never a model's judgement. - engine::discover: - Spec: the task (equivalent, or prepares: the same state from |0…0⟩, the new equiv::same_state), the reference generator, the sizes, the gates and connectivity allowed, the cost (weights on the critique's measures, summed over the sizes), two axes and a budget. - Evaluator: verified only when it reads, keeps the rules and passes the check at every size, stopping at the first failure; the weakest check is named. - Archive: MAP-Elites over the two axes, with lineage, proposer and note; bound to the specification's digest (budget left out); each kept entry sealed with the searcher's key, and Archive::verify checks every seal binds its entry. - mutate and search: the built-in, seeded, blind baseline. The clock and the energy reading are passed in, so the module builds wherever the engine does. - engine::energy: the job service's reader moved into the engine, with `own()` for a running process. The service keeps only waitid(WNOWAIT); the FFI is declared directly, so wai-quantum gains no dependency and no lockfile changes. - critique::measures is public. - CLI: `wai-quantum discover new|search|eval|submit|show|verify`, with specifications that may name their reference as @file.qasm, and two shipped specifications (discover/ghz-line.json, discover/ghz-all.json). - MCP: discover_evaluate, discover_submit, discover_archive; and every tool that takes a program takes a generator with `inputs`. The program description no longer says loops are refused. Measured on the build host, ghz-all.json: 1,500 blind edits in 4.6 s and 24 J. 139 were verified and 29 kept, none cheaper than the reference; the doubling tree costs 25 against its 49. All 29 seals held. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 39 + wai/wai-quantum-cli/README.md | 27 +- wai/wai-quantum-cli/discover/README.md | 20 + wai/wai-quantum-cli/discover/ghz-all.json | 11 + wai/wai-quantum-cli/discover/ghz-chain.qasm | 9 + wai/wai-quantum-cli/discover/ghz-line.json | 11 + wai/wai-quantum-cli/src/discover.rs | 351 ++++++++ wai/wai-quantum-cli/src/main.rs | 9 + wai/wai-quantum-cli/src/mcp.rs | 114 ++- wai/wai-quantum-service/src/energy.rs | 286 +------ wai/wai-quantum/README.md | 2 +- wai/wai-quantum/src/engine/critique.rs | 2 +- wai/wai-quantum/src/engine/discover.rs | 840 ++++++++++++++++++++ wai/wai-quantum/src/engine/energy.rs | 353 ++++++++ wai/wai-quantum/src/engine/equiv.rs | 24 + wai/wai-quantum/src/engine/mod.rs | 10 + 16 files changed, 1823 insertions(+), 285 deletions(-) create mode 100644 wai/wai-quantum-cli/discover/README.md create mode 100644 wai/wai-quantum-cli/discover/ghz-all.json create mode 100644 wai/wai-quantum-cli/discover/ghz-chain.qasm create mode 100644 wai/wai-quantum-cli/discover/ghz-line.json create mode 100644 wai/wai-quantum-cli/src/discover.rs create mode 100644 wai/wai-quantum/src/engine/discover.rs create mode 100644 wai/wai-quantum/src/engine/energy.rs diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 558295845..8c43f5eba 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -1116,6 +1116,45 @@ wire format may change and such changes are recorded here. - **`tools/ops-gpu-bench`** times each capability's decode with its graphs on the CPU and on each adapter, and reads the energy counters the host has. - **CI:** `wai-ops-gpu.yml` runs the Metal and browser legs on the `macos` runner, and requires on a runner with no GPU that the device tests skip without `WAI_OPS_GPU_REQUIRE` and fail with it. +### wai-quantum: the specification-and-evaluator workbench + +- **A specification** (`engine::discover::Spec`, a JSON file for the command line): + - the task: the same unitary as a reference generator (`equivalent`), or the same state from + |0…0⟩ (`prepares`, a new check, `equiv::same_state`); + - the sizes to check at; + - the gates allowed, and the connectivity (`all`, or neighbours on a `line`); + - the cost: weights on the critique's measures, summed over the sizes; + - the archive's two axes, and a budget in evaluations, seconds and joules. +- **The evaluator is the engine.** A candidate is verified only when it reads, keeps the rules + and passes the check at every size; the first size that fails decides, and the larger ones are + not run. The weakest check it passed is named: `tableau`, `exact` and `state` decide it; + `random` is a check with no false "different". +- **The archive** keeps the cheapest verified generator in every cell of the two axes + (MAP-Elites). Each entry has its parent, its proposer and a note. A generator seen before is + not evaluated again. + - The reference is the first entry, so an improvement means cheaper than what the person + brought. + - An archive is bound to its specification's digest, the budget left out. + - Each entry kept is sealed with the searcher's key, over the specification's digest, the + source, the proposer and every size's verdict and measures. `Archive::verify` checks every + seal, and that it binds its entry. +- **Proposers stand outside it.** The built-in one (`mutate`, `search`) makes blind, seeded, + statement-level edits: a baseline that replays from its seed. A person or an agent submits + through the same `Archive::submit`. +- **The command line:** `wai-quantum discover new | search | eval | submit | show | verify`. + A specification's reference may be `@file.qasm` beside it. `search` reports evaluations, + seconds and, on macOS, the joules its own process spent. Two specifications ship: + `discover/ghz-line.json` and `discover/ghz-all.json`. +- **For agents:** `wai-quantum mcp` adds `discover_evaluate`, `discover_submit` and + `discover_archive`, so an outside agent proposes into the same archive and is named as the + proposer. Every tool that takes a program also takes a generator, with `inputs`. +- **One energy reader** (`engine::energy`), moved from the job service: the figure a worker's + result carries, unchanged, and a running process's own count (`energy::own`, a lower bound by + its threads still on a core). The service keeps only its wait-without-reaping. +- Measured on the build host, `ghz-all.json`: 1,500 blind edits in 4.6 s and 24 J. 139 were + verified and 29 kept, and none beat the reference. The doubling tree costs 25 against the + reference's 49. Every one of the 29 seals held. + ### wai-quantum: OpenQASM 3 generators — one program for a family of circuits - **The reader takes generators** (`qasm::parse_with`, `qasm::inputs`). `input int n;` and `input diff --git a/wai/wai-quantum-cli/README.md b/wai/wai-quantum-cli/README.md index 616a6409f..cbdf93839 100644 --- a/wai/wai-quantum-cli/README.md +++ b/wai/wai-quantum-cli/README.md @@ -151,14 +151,39 @@ A cheap route proves a design classically easy. None being cheap proves nothing - `classical_shortcuts` - `draw_circuit` - `verify_quantum_result` +- `discover_evaluate`, `discover_submit` and `discover_archive` (the workbench, below) - `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: +A tool that takes a program also takes a generator, with its inputs' values in `inputs`. 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 workbench + +`wai-quantum discover` searches for cheaper circuits that provably do the same job. A specification (JSON) says: +- what a family of circuits must do: the same unitary as a reference generator (`equivalent`), or the same state from |0…0⟩ (`prepares`); +- the sizes to check it at; +- the gates and connectivity a candidate may use; +- what it costs: weights on the engine's measures (depth, two-qubit gates, T count …), summed over the sizes; +- the two measures the archive is laid out on. + +Proposers write generators: you, an agent through `wai-quantum mcp`, or the built-in search. The engine checks each one at every size, never a model's judgement. The archive keeps the cheapest verified generator in every cell of the two axes, with its parent, proposer and note, so different ideas mature side by side. + +```sh +cd discover # the shipped specifications +wai-quantum discover new ghz-all.json # the archive, seeded with the reference +wai-quantum discover search ghz-all.json # the built-in search: blind, seeded edits +wai-quantum discover submit ghz-all.json mine.qasm --note "doubling layers" +wai-quantum discover show ghz-all.json # what was spent, the best, the front +wai-quantum discover verify ghz-all.json # every sealed verdict +``` + +Each kept generator is sealed with your key. Its receipt binds the specification, the source, its proposer and the engine's verdict at every size. A search reports what it spent: evaluations, seconds and, on macOS, the joules its own process used as the kernel counts them. So the result is a cost per verified improvement, not just a best circuit. + +The built-in search is the baseline any proposer should beat. On `ghz-all.json`, 1,500 blind edits took 4.6 s and 24 J: 139 were verified and 29 kept, and none beat the reference. The doubling tree, which a person or a model writes in a few lines, costs 25 against the reference's 49. + ## The research tools `wai-quantum lab …` holds the research tools: diff --git a/wai/wai-quantum-cli/discover/README.md b/wai/wai-quantum-cli/discover/README.md new file mode 100644 index 000000000..7b82e3bba --- /dev/null +++ b/wai/wai-quantum-cli/discover/README.md @@ -0,0 +1,20 @@ +# Workbench specifications + +Each specification says what a family of circuits must do, what a candidate may use, and what it costs. `wai-quantum discover` keeps an archive beside it of the cheapest verified generator of every kind. + +| Specification | Task | What beats the reference | +|---|---|---| +| `ghz-line.json` | the GHZ state on a line of qubits | starting from the middle and spreading both ways halves the depth | +| `ghz-all.json` | the GHZ state with every pair connected | doubling the qubits that hold the state each layer brings the depth to about log₂ n | + +Both start from `ghz-chain.qasm`, the plain chain of CX gates: depth n. + +```sh +wai-quantum discover new ghz-all.json # the archive, seeded with the reference +wai-quantum discover search ghz-all.json # the built-in, blind search +wai-quantum discover submit ghz-all.json mine.qasm --note "doubling layers" +wai-quantum discover show ghz-all.json # what was spent, the best, the front +wai-quantum discover verify ghz-all.json # every sealed verdict +``` + +An agent proposes through `wai-quantum mcp` (`discover_archive`, `discover_evaluate`, `discover_submit`), working on the same archive. diff --git a/wai/wai-quantum-cli/discover/ghz-all.json b/wai/wai-quantum-cli/discover/ghz-all.json new file mode 100644 index 000000000..5d8b1dd1a --- /dev/null +++ b/wai/wai-quantum-cli/discover/ghz-all.json @@ -0,0 +1,11 @@ +{ + "name": "ghz-all", + "task": "prepares", + "reference": "@ghz-chain.qasm", + "inputs": { "n": [3, 5, 8, 13, 16] }, + "gates": ["h", "cx"], + "connectivity": "all", + "cost": { "depth": 1.0, "two_qubit": 0.1 }, + "axes": ["two_qubit", "depth"], + "budget": { "evaluations": 2000, "seconds": 600 } +} diff --git a/wai/wai-quantum-cli/discover/ghz-chain.qasm b/wai/wai-quantum-cli/discover/ghz-chain.qasm new file mode 100644 index 000000000..f78304207 --- /dev/null +++ b/wai/wai-quantum-cli/discover/ghz-chain.qasm @@ -0,0 +1,9 @@ +OPENQASM 3.0; +// A GHZ state the plain way: a Hadamard, then a chain of CX down the line. +// Depth n, n - 1 two-qubit gates. +input uint n; +qubit[n] q; +h q[0]; +for uint i in [0:n-2] { + cx q[i], q[i + 1]; +} diff --git a/wai/wai-quantum-cli/discover/ghz-line.json b/wai/wai-quantum-cli/discover/ghz-line.json new file mode 100644 index 000000000..19fcb0f6f --- /dev/null +++ b/wai/wai-quantum-cli/discover/ghz-line.json @@ -0,0 +1,11 @@ +{ + "name": "ghz-line", + "task": "prepares", + "reference": "@ghz-chain.qasm", + "inputs": { "n": [3, 4, 5, 6, 8] }, + "gates": ["h", "cx"], + "connectivity": "line", + "cost": { "depth": 1.0, "two_qubit": 0.1 }, + "axes": ["two_qubit", "depth"], + "budget": { "evaluations": 2000, "seconds": 600 } +} diff --git a/wai/wai-quantum-cli/src/discover.rs b/wai/wai-quantum-cli/src/discover.rs new file mode 100644 index 000000000..4a8d14adc --- /dev/null +++ b/wai/wai-quantum-cli/src/discover.rs @@ -0,0 +1,351 @@ +//! `wai-quantum discover`: the specification-and-evaluator workbench on this +//! machine ([`wai_quantum::engine::discover`]). +//! +//! A specification is a JSON file: the task (`equivalent` or `prepares`), the +//! reference generator (inline, or `"@file.qasm"` beside the specification), +//! the sizes to check, the gates and connectivity allowed, the cost, the +//! archive's two axes and the budget. The archive is a JSON file beside it +//! (`spec.json` → `spec.archive.json`), so a person, the built-in search and +//! an agent (through `wai-quantum mcp`) all work on the same one. +//! +//! Every generator kept is sealed with this user's key: its receipt binds the +//! specification, the source, who proposed it, and the engine's verdict at +//! every size, and `discover verify` checks them all. + +use clap::{Args, Subcommand}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::time::Instant; +use wai_quantum::engine::discover::{Archive, Evaluation, Evaluator, SearchOptions, Spec, search}; +use wai_quantum::engine::energy; + +#[derive(Args)] +pub struct Files { + /// The specification (JSON) + spec: PathBuf, + /// The archive (default: beside the specification, as NAME.archive.json) + #[arg(long)] + archive: Option, +} + +#[derive(Subcommand)] +pub enum Cmd { + /// Start an archive for a specification, seeded with its reference + New { + #[command(flatten)] + files: Files, + #[command(flatten)] + key: super::KeyArgs, + }, + /// Search with the built-in proposer (blind, seeded edits) until the budget is spent + Search { + #[command(flatten)] + files: Files, + /// The search's seed: the same seed on the same archive searches the same way + #[arg(long, default_value_t = 1)] + seed: u64, + /// Edits made to a parent before it is evaluated, at most + #[arg(long, default_value_t = 2)] + max_edits: usize, + /// Stop after this many evaluations (overrides the specification's budget) + #[arg(long)] + evaluations: Option, + /// Stop after this many seconds (overrides the specification's budget) + #[arg(long)] + seconds: Option, + /// Stop after this many joules, where this machine counts them (overrides the specification's budget) + #[arg(long)] + joules: Option, + #[command(flatten)] + key: super::KeyArgs, + /// Print the archive's summary as JSON + #[arg(long)] + json: bool, + }, + /// Judge one generator against a specification, without an archive + Eval { + /// The specification (JSON) + spec: PathBuf, + /// The generator; `-` reads standard input + candidate: PathBuf, + /// Print the evaluation as JSON + #[arg(long)] + json: bool, + }, + /// Judge a generator and keep it in the archive if it is the cheapest of its cell + Submit { + #[command(flatten)] + files: Files, + /// The generator; `-` reads standard input + candidate: PathBuf, + /// The id of the archive entry it came from + #[arg(long)] + parent: Option, + /// Who wrote it + #[arg(long, default_value = "person")] + proposer: String, + /// What it tries, in a sentence + #[arg(long)] + note: Option, + #[command(flatten)] + key: super::KeyArgs, + /// Print the outcome as JSON + #[arg(long)] + json: bool, + }, + /// The archive: what was spent, the reference, the best, the front and every cell + Show { + #[command(flatten)] + files: Files, + /// Print one entry's generator, by id + #[arg(long)] + source: Option, + /// Print the summary as JSON + #[arg(long)] + json: bool, + }, + /// Check every sealed verdict in the archive + Verify { + #[command(flatten)] + files: Files, + }, +} + +/// A specification read from `path`: a reference given as `"@file"` is read +/// from that file, beside the specification. +pub fn read_spec(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut v: Value = serde_json::from_str(&text).map_err(|e| format!("{}: not JSON: {e}", path.display()))?; + if let Some(r) = v["reference"].as_str().and_then(|r| r.strip_prefix('@')) { + let file = path.parent().unwrap_or(Path::new(".")).join(r); + v["reference"] = json!(std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?); + } + Spec::from_json(&v).map_err(|e| format!("{}: {e}", path.display())) +} + +/// The archive beside `spec`, unless one is named. +pub fn archive_path(spec: &Path, given: Option<&Path>) -> PathBuf { + given.map(Path::to_path_buf).unwrap_or_else(|| spec.with_extension("archive.json")) +} + +/// The archive at `path`, which must have been built for `ev`'s specification. +pub fn load(ev: &Evaluator, path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}; start one with `wai-quantum discover new`", path.display()))?; + let a: Archive = serde_json::from_str(&text).map_err(|e| format!("{}: not an archive: {e}", path.display()))?; + if !a.is_for(&ev.spec) { + return Err(format!("{} was built for another specification (or this one changed); start a new archive", path.display())); + } + Ok(a) +} + +/// Writes the archive, whole, through a temporary file beside it. +pub fn save(a: &Archive, path: &Path) -> Result<(), String> { + let tmp = path.with_extension("json.tmp"); + let text = serde_json::to_string_pretty(a).expect("an archive is JSON"); + std::fs::write(&tmp, text).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| format!("{}: {e}", path.display())) +} + +fn axes_text(spec: &Spec, axes: &Value) -> String { + format!("{} {}, {} {}", spec.axes[0], axes[0], spec.axes[1], axes[1]) +} + +fn evaluation_text(spec: &Spec, e: &Evaluation) -> String { + let sizes: Vec = e.sizes.iter().map(|s| s["inputs"].as_object().map(|o| o.iter().map(|(k, v)| format!("{k} = {v}")).collect::>().join(", ")).unwrap_or_default()).collect(); + match (&e.failure, e.cost, e.axes) { + (None, Some(cost), Some(axes)) => format!("verified ({}) at {}: cost {cost}, {}", e.strength.as_deref().unwrap_or(""), sizes.join("; "), axes_text(spec, &json!(axes))), + (Some(why), _, _) => format!("not verified: {why}"), + _ => "not verified".into(), + } +} + +fn summary_text(spec: &Spec, s: &Value, path: &Path) -> String { + let st = &s["stats"]; + let mut out = vec![format!( + "{}: {} evaluated, {} verified, {} kept, {} improvements, {} seen before", + spec.name, st["evaluations"], st["verified"], st["kept"], st["improvements"], st["duplicates"] + )]; + let secs = st["seconds"].as_f64().unwrap_or(0.0); + match st["joules"].as_f64() { + Some(j) => { + let per = s["joules_per_improvement"].as_f64().map(|p| format!(", {p:.2} J per improvement")).unwrap_or_default(); + out.push(format!("time {secs:.1} s, energy {j:.2} J (the searches' own process, as the kernel counts it){per}")); + } + None => out.push(format!("time {secs:.1} s; energy unmetered on this machine")), + } + let line = |label: &str, e: &Value| format!("{label} {}: cost {}, {}, by {}", e["id"].as_str().unwrap_or(""), e["cost"], axes_text(spec, &e["axes"]), e["proposer"].as_str().unwrap_or("")); + out.push(line("reference", &s["reference"])); + out.push(format!("{}, checked by {}", line("best", &s["best"]), s["best"]["strength"].as_str().unwrap_or(""))); + let front: Vec = s["front"].as_array().into_iter().flatten().map(|e| format!("{} ({}, {})", e["id"].as_str().unwrap_or(""), e["axes"][0], e["axes"][1])).collect(); + out.push(format!("front ({} against {}): {}", spec.axes[0], spec.axes[1], front.join(", "))); + out.push(format!("archive: {}", path.display())); + out.join("\n") +} + +fn key(k: &super::KeyArgs) -> wai_quantum::ed25519_dalek::SigningKey { + wai_quantum_cli::keys::load(k.key.as_deref(), k.demo_key).unwrap_or_else(|e| super::die(&e)).key +} + +pub fn main(cmd: Cmd) { + let r = match cmd { + Cmd::New { files, key: k } => new(&files, &k), + Cmd::Search { files, seed, max_edits, evaluations, seconds, joules, key: k, json } => run_search(&files, seed, max_edits, (evaluations, seconds, joules), &k, json), + Cmd::Eval { spec, candidate, json } => eval(&spec, &candidate, json), + Cmd::Submit { files, candidate, parent, proposer, note, key: k, json } => submit(&files, &candidate, parent.as_deref(), &proposer, note.as_deref(), &k, json), + Cmd::Show { files, source, json } => show(&files, source.as_deref(), json), + Cmd::Verify { files } => verify(&files), + }; + r.unwrap_or_else(|e| super::die(&e)); +} + +fn new(files: &Files, k: &super::KeyArgs) -> Result<(), String> { + let ev = Evaluator::new(read_spec(&files.spec)?)?; + let path = archive_path(&files.spec, files.archive.as_deref()); + if path.exists() { + return Err(format!("{} exists; give another with --archive, or remove it", path.display())); + } + // The reference's own verdict is sealed too: the baseline every + // improvement is measured against. + let a = Archive::new_sealed(&ev, Some(&key(k)))?; + save(&a, &path)?; + println!("{}", summary_text(&ev.spec, &a.summary(&ev.spec), &path)); + Ok(()) +} + +fn run_search(files: &Files, seed: u64, max_edits: usize, (evaluations, seconds, joules): (Option, Option, Option), k: &super::KeyArgs, json: bool) -> Result<(), String> { + let mut spec = read_spec(&files.spec)?; + // The overrides steer this search only: the archive is bound to the + // specification with its budget left out. + spec.budget.evaluations = evaluations.or(spec.budget.evaluations); + spec.budget.seconds = seconds.or(spec.budget.seconds); + spec.budget.joules = joules.or(spec.budget.joules); + let ev = Evaluator::new(spec)?; + let path = archive_path(&files.spec, files.archive.as_deref()); + let mut a = load(&ev, &path)?; + let opts = SearchOptions { seed, max_edits, key: Some(key(k)) }; + let (t0, e0) = (Instant::now(), energy::own()); + search( + &ev, + &mut a, + &opts, + &|| t0.elapsed().as_secs_f64(), + &|| energy::own().zip(e0).map(|(now, then)| now.joules() - then.joules()), + &mut |e| eprintln!("kept {} ({}, from {}): cost {}, {}", e.id, e.proposer, e.parent.as_deref().unwrap_or("-"), e.cost, axes_text(&ev.spec, &json!(e.axes))), + ); + save(&a, &path)?; + let s = a.summary(&ev.spec); + println!("{}", if json { s.to_string() } else { summary_text(&ev.spec, &s, &path) }); + Ok(()) +} + +fn eval(spec: &Path, candidate: &Path, json: bool) -> Result<(), String> { + let ev = Evaluator::new(read_spec(spec)?)?; + let e = ev.evaluate(&super::read_text(Some(candidate))?); + println!("{}", if json { serde_json::to_string(&e).expect("an evaluation is JSON") } else { evaluation_text(&ev.spec, &e) }); + if !e.verified { + std::process::exit(1); + } + Ok(()) +} + +fn submit(files: &Files, candidate: &Path, parent: Option<&str>, proposer: &str, note: Option<&str>, k: &super::KeyArgs, json: bool) -> Result<(), String> { + let ev = Evaluator::new(read_spec(&files.spec)?)?; + let path = archive_path(&files.spec, files.archive.as_deref()); + let mut a = load(&ev, &path)?; + let out = a.submit_sealed(&ev, &super::read_text(Some(candidate))?, parent, proposer, note, Some(&key(k))); + save(&a, &path)?; + if json { + println!("{}", serde_json::to_string(&out).expect("an outcome is JSON")); + return Ok(()); + } + let Some(e) = out.evaluation.as_ref() else { + println!("seen before: the archive has evaluated this generator already"); + return Ok(()); + }; + let fate = match (&out.kept, out.improvement) { + (Some(id), true) => format!("kept as {id}: cheaper than every generator before it"), + (Some(id), false) => format!("kept as {id}: the best of its cell"), + (None, _) if e.verified => "not kept: its cell already holds one as cheap".into(), + _ => "not kept".into(), + }; + println!("{}\n{fate}", evaluation_text(&ev.spec, e)); + Ok(()) +} + +fn show(files: &Files, source: Option<&str>, json: bool) -> Result<(), String> { + let ev = Evaluator::new(read_spec(&files.spec)?)?; + let path = archive_path(&files.spec, files.archive.as_deref()); + let a = load(&ev, &path)?; + if let Some(id) = source { + let e = a.entries.iter().find(|e| e.id == id).ok_or_else(|| format!("no entry {id} in {}", path.display()))?; + print!("{}", e.source); + return Ok(()); + } + let s = a.summary(&ev.spec); + println!("{}", if json { s.to_string() } else { summary_text(&ev.spec, &s, &path) }); + Ok(()) +} + +fn verify(files: &Files) -> Result<(), String> { + let ev = Evaluator::new(read_spec(&files.spec)?)?; + let path = archive_path(&files.spec, files.archive.as_deref()); + let a = load(&ev, &path)?; + let checks = a.verify(); + let bad: Vec<&str> = checks.iter().filter(|(_, ok)| !ok).map(|(id, _)| id.as_str()).collect(); + let unsealed = a.entries.len() - checks.len(); + let note = if unsealed > 0 { format!("; {unsealed} entries are unsealed") } else { String::new() }; + println!("{} of {} sealed verdicts hold{note}", checks.len() - bad.len(), checks.len()); + if !bad.is_empty() { + return Err(format!("these do not hold: {}", bad.join(", "))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TREE: &str = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint k in [0:n] {\n for uint i in [0:2**k - 1] {\n if (2**k < n && i + 2**k < n) {\n cx q[i], q[i + 2**k];\n }\n }\n}\n"; + + fn place() -> PathBuf { + let dir = std::env::temp_dir().join(format!("wai-discover-{}-{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + std::fs::create_dir_all(&dir).unwrap(); + let shipped = Path::new(env!("CARGO_MANIFEST_DIR")).join("discover"); + std::fs::copy(shipped.join("ghz-chain.qasm"), dir.join("ghz-chain.qasm")).unwrap(); + let mut spec: Value = serde_json::from_str(&std::fs::read_to_string(shipped.join("ghz-all.json")).unwrap()).unwrap(); + spec["inputs"] = json!({ "n": [3, 5, 8] }); + std::fs::write(dir.join("ghz.json"), spec.to_string()).unwrap(); + std::fs::write(dir.join("tree.qasm"), TREE).unwrap(); + dir + } + + #[test] + fn a_person_and_the_search_share_one_sealed_archive() { + let dir = place(); + let files = Files { spec: dir.join("ghz.json"), archive: None }; + let demo = super::super::KeyArgs { key: None, demo_key: true }; + new(&files, &demo).unwrap(); + let path = archive_path(&files.spec, None); + assert!(path.ends_with("ghz.archive.json")); + // A second `new` refuses to overwrite it. + assert!(new(&files, &demo).unwrap_err().contains("exists")); + // A person's generator: the doubling tree, kept as an improvement. + submit(&files, &dir.join("tree.qasm"), Some("e0"), "person", Some("doubling layers"), &demo, false).unwrap(); + // The budget given on the command line steers the search and the + // archive still matches its specification. + run_search(&files, 3, 2, (Some(15), None, None), &demo, false).unwrap(); + let ev = Evaluator::new(read_spec(&files.spec).unwrap()).unwrap(); + let a = load(&ev, &path).unwrap(); + assert_eq!(a.entries[1].note.as_deref(), Some("doubling layers")); + assert!(a.best().unwrap().cost <= a.entries[1].cost); + assert_eq!(a.stats.evaluations, 1 + 1 + 15); + // Every entry is sealed, and every seal holds. + assert!(a.entries.iter().all(|e| e.sealed.is_some())); + verify(&files).unwrap(); + // An archive edited by hand no longer verifies. + let mut forged = a.clone(); + forged.entries[1].source.push_str("x q[0];\n"); + save(&forged, &path).unwrap(); + assert!(verify(&files).unwrap_err().contains("e1")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/wai/wai-quantum-cli/src/main.rs b/wai/wai-quantum-cli/src/main.rs index e94ff8c34..b573bf4ba 100644 --- a/wai/wai-quantum-cli/src/main.rs +++ b/wai/wai-quantum-cli/src/main.rs @@ -14,6 +14,8 @@ //! wai-quantum shortcuts prog.qasm # each exact classical route and its cost here //! wai-quantum run ghz.qasm --input n=40 # a generator, read at n = 40 //! wai-quantum instance qft.qasm --input n=5 # a generator written out as plain OpenQASM +//! wai-quantum discover new ghz.json # the workbench: an archive for a specification +//! wai-quantum discover search ghz.json # cheaper generators, each checked at every size //! 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 @@ -33,6 +35,7 @@ //! circuits. Every command reads one circuit of the family, at the values //! given with `--input`, and `instance` prints that circuit. +mod discover; mod lab; mod mcp; @@ -173,6 +176,11 @@ enum Command { #[command(flatten)] key: KeyArgs, }, + /// The workbench: a specification, its archive, and searches for cheaper generators (`wai-quantum discover --help`) + Discover { + #[command(subcommand)] + cmd: discover::Cmd, + }, /// 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)] @@ -629,6 +637,7 @@ fn main() { let k = load_key(&key); mcp::serve(memory_gb, &k.key); } + Command::Discover { cmd } => discover::main(cmd), Command::Lab { args } => lab::main(args), Command::Completions { shell } => { clap_complete::generate(shell, &mut Cli::command(), "wai-quantum", &mut std::io::stdout()); diff --git a/wai/wai-quantum-cli/src/mcp.rs b/wai/wai-quantum-cli/src/mcp.rs index 5a0bddee1..8b9e07dd1 100644 --- a/wai/wai-quantum-cli/src/mcp.rs +++ b/wai/wai-quantum-cli/src/mcp.rs @@ -15,7 +15,8 @@ fn job_schema() -> Value { "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." }, + "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, if on measured bits, and generators (integer input, const and for loops, one program for a family of circuits; give the inputs' values in \"inputs\"). while, inv and pow are refused with their line." }, + "inputs": inputs_schema(), "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." }, @@ -27,8 +28,12 @@ fn job_schema() -> Value { }) } +fn inputs_schema() -> Value { + json!({ "type": "object", "additionalProperties": { "type": "integer" }, "description": "Values for a generator's integer inputs, as {\"n\": 5}: the program is read as its family's circuit at those values." }) +} + 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." } } }); + let mut s = json!({ "type": "object", "required": ["qasm"], "properties": { "qasm": { "type": "string", "description": "The program, OpenQASM 3 or 2, or a generator (integer input, const, for loops) with its values in \"inputs\"." }, "inputs": inputs_schema() } }); if let (Some(p), Some(e)) = (s["properties"].as_object_mut(), extra.as_object()) { p.extend(e.clone()); } @@ -65,7 +70,7 @@ fn tools() -> Value { "name": "check_equivalent", "title": "Check two programs are the same circuit", "description": "Whether two programs are the same circuit up to a global phase, and how the engine knows: equal tableaus for Clifford circuits at any size, every basis state to 8 qubits, random product states to 22 qubits (evidence, not proof), or unchecked with the reason.", - "inputSchema": { "type": "object", "required": ["a", "b"], "properties": { "a": { "type": "string", "description": "The first program." }, "b": { "type": "string", "description": "The second program." } } }, + "inputSchema": { "type": "object", "required": ["a", "b"], "properties": { "a": { "type": "string", "description": "The first program." }, "b": { "type": "string", "description": "The second program." }, "inputs": inputs_schema() } }, }, { "name": "improve_qasm", @@ -91,6 +96,24 @@ fn tools() -> Value { "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": "discover_evaluate", + "title": "Judge a generator against a workbench specification", + "description": "The engine's verdict on a generator (an OpenQASM program with integer inputs) against a specification: whether it reads, keeps the specification's gate and connectivity rules, and matches the reference (the same unitary, or the same state from |0…0⟩) at every size; the weakest check it passed (tableau, exact and state decide it; random is evidence); its measures at each size; its cost; and its two axes. Write generators freely: the engine, not you, decides whether one is right.", + "inputSchema": { "type": "object", "required": ["spec", "qasm"], "properties": { "spec": { "description": "The specification: its JSON, or the path of its file." }, "qasm": { "type": "string", "description": "The generator." } } }, + }, + { + "name": "discover_submit", + "title": "Propose a generator to a workbench archive", + "description": "Judge a generator against the specification in a file and keep it in the archive beside it (spec.json → spec.archive.json) if it is verified and the cheapest in its cell of the two axes. A kept generator is sealed with this user's key, with you named as its proposer. Say which entry it came from (parent) and what it tries (note), so its lineage can be read.", + "inputSchema": { "type": "object", "required": ["spec", "qasm"], "properties": { "spec": { "type": "string", "description": "The specification's path." }, "archive": { "type": "string", "description": "The archive's path, if not beside the specification." }, "qasm": { "type": "string", "description": "The generator." }, "parent": { "type": "string", "description": "The id of the entry it came from." }, "note": { "type": "string", "description": "What it tries, in a sentence." }, "proposer": { "type": "string", "description": "Who wrote it (default \"agent\")." } } }, + }, + { + "name": "discover_archive", + "title": "Read a workbench archive", + "description": "An archive's summary: what the searches spent (evaluations, seconds, joules) and found (verified, kept, improvements), the reference, the best, the front of the two axes, and the best of every cell, with its proposer, parent and note. With sources, each cell's generator too, to propose from.", + "inputSchema": { "type": "object", "required": ["spec"], "properties": { "spec": { "type": "string", "description": "The specification's path." }, "archive": { "type": "string", "description": "The archive's path, if not beside the specification." }, "sources": { "type": "boolean", "description": "Include every cell's generator." } } }, + }, { "name": "list_methods", "title": "List the methods", @@ -102,6 +125,10 @@ fn tools() -> Value { /// 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 { + if name.starts_with("discover_") { + return discover(name, args, key); + } + let args = &at_inputs(args)?; let qasm = || args.get("qasm").and_then(Value::as_str).ok_or("give \"qasm\": the program"); match name { "run_quantum_circuit" => { @@ -135,6 +162,68 @@ fn call(name: &str, args: &Value, memory_gb: u64, key: &SigningKey) -> Result Result { + let inputs: wai_quantum::qasm::Inputs = match args.get("inputs") { + None | Some(Value::Null) => Default::default(), + Some(Value::Object(m)) => m.iter().map(|(k, v)| v.as_i64().map(|n| (k.clone(), n)).ok_or(format!("inputs: {k} takes a whole number"))).collect::>()?, + Some(_) => return Err("give \"inputs\" as an object of whole numbers, as {\"n\": 5}".into()), + }; + let mut out = args.clone(); + if let Some(o) = out.as_object_mut() { + o.remove("inputs"); + for k in ["qasm", "a", "b"] { + if let Some(text) = o.get(k).and_then(Value::as_str) { + let read = crate::at_inputs(text.to_string(), &inputs)?; + o.insert(k.to_string(), json!(read)); + } + } + } + Ok(out) +} + +/// The workbench's tools ([`crate::discover`]). +fn discover(name: &str, args: &Value, key: &SigningKey) -> Result { + use std::path::Path; + use wai_quantum::engine::discover::{Evaluator, Spec}; + let text = |k: &str| args.get(k).and_then(Value::as_str); + let spec_path = || text("spec").map(Path::new).ok_or("give \"spec\": the specification's path"); + let qasm = || text("qasm").ok_or("give \"qasm\": the generator"); + match name { + "discover_evaluate" => { + let spec = match args.get("spec") { + Some(Value::String(p)) => crate::discover::read_spec(Path::new(p))?, + Some(v @ Value::Object(_)) => Spec::from_json(v)?, + _ => return Err("give \"spec\": the specification's JSON or its path".into()), + }; + Ok(serde_json::to_value(Evaluator::new(spec)?.evaluate(qasm()?)).unwrap_or(Value::Null)) + } + "discover_submit" => { + let path = spec_path()?; + let ev = Evaluator::new(crate::discover::read_spec(path)?)?; + let archive = crate::discover::archive_path(path, text("archive").map(Path::new)); + let mut a = crate::discover::load(&ev, &archive)?; + let out = a.submit_sealed(&ev, qasm()?, text("parent"), text("proposer").unwrap_or("agent"), text("note"), Some(key)); + crate::discover::save(&a, &archive)?; + Ok(json!({ "outcome": out, "best": a.best().map(|e| json!({ "id": e.id, "cost": e.cost, "axes": e.axes })) })) + } + "discover_archive" => { + let path = spec_path()?; + let ev = Evaluator::new(crate::discover::read_spec(path)?)?; + let archive = crate::discover::archive_path(path, text("archive").map(Path::new)); + let a = crate::discover::load(&ev, &archive)?; + let mut s = a.summary(&ev.spec); + if args.get("sources").and_then(Value::as_bool) == Some(true) { + s["sources"] = a.elites().iter().map(|e| (e.id.clone(), json!(e.source))).collect::>().into(); + } + Ok(s) + } + _ => 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(); @@ -199,7 +288,7 @@ mod tests { 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", "critique_qasm", "check_equivalent", "improve_qasm", "classical_shortcuts", "draw_circuit", "verify_quantum_result", "list_methods"]); + assert_eq!(names, ["run_quantum_circuit", "plan_quantum_circuit", "lint_qasm", "critique_qasm", "check_equivalent", "improve_qasm", "classical_shortcuts", "draw_circuit", "verify_quantum_result", "discover_evaluate", "discover_submit", "discover_archive", "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, @@ -241,4 +330,21 @@ mod tests { let s = tool("classical_shortcuts", json!({ "qasm": draft })); assert!(s["structuredContent"]["summary"].as_str().unwrap().starts_with("Classically easy"), "{s}"); } + #[test] + fn the_workbench_and_generators_answer_through_the_tools() { + let spec = json!({ + "name": "ghz", "task": "prepares", + "reference": "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [0:n-2] { cx q[i], q[i + 1]; }\n", + "inputs": { "n": [3, 5] }, "cost": { "depth": 1.0 } + }); + let star = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [1:n-1] { cx q[0], q[i]; }\n"; + let v = call("discover_evaluate", &json!({ "spec": spec, "qasm": star }), 1, &key()).unwrap(); + assert_eq!(v["verified"], true, "{v}"); + assert_eq!(v["strength"], "state"); + // A tool that takes a program takes a generator with its inputs. + let lint = call("lint_qasm", &json!({ "qasm": star, "inputs": { "n": 4 } }), 1, &key()).unwrap(); + assert_eq!((lint["qubits"].as_u64(), lint["gates"].as_u64()), (Some(4), Some(4)), "{lint}"); + assert!(call("lint_qasm", &json!({ "qasm": star }), 1, &key()).unwrap_err().contains("give n a value")); + } + } diff --git a/wai/wai-quantum-service/src/energy.rs b/wai/wai-quantum-service/src/energy.rs index dc982d2c0..69acdb1c3 100644 --- a/wai/wai-quantum-service/src/energy.rs +++ b/wai/wai-quantum-service/src/energy.rs @@ -1,98 +1,17 @@ //! A job's energy: what the CPU cores spent running its worker process, as the //! kernel counts it, read once the worker has exited and before it is reaped. //! -//! On macOS, `proc_pid_rusage(pid, RUSAGE_INFO_V6)` gives `ri_energy_nj`, the -//! energy in nanojoules the kernel attributes to a process's own threads from -//! the cores' own energy accounting. A thread's energy is added to its process -//! when the thread leaves a core, so a running process's count trails its work. -//! An exited process has left every core, so its count is complete, and it -//! stays readable until its parent reaps it. The service therefore waits for -//! the worker with `waitid(WNOWAIT)`, reads it, and only then reaps it. +//! The reading and the figure a result carries are the engine's +//! ([`wai_quantum::engine::energy`], which says what the figure covers and how +//! its uncertainty is declared). What stays here is the service's part: a +//! running process's count trails its work, and an exited one's is complete +//! only until it is reaped, so the service waits for the worker with +//! `waitid(WNOWAIT)` ([`exited_unreaped`]), reads it, and only then reaps it. //! -//! What the figure covers: the CPU energy of the worker's own threads from its -//! 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` 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. -//! -//! The class is `OnChipCounter` (WAI energy-measurement §2): the silicon's own -//! accounting, with no calibration behind it. Its declared uncertainty is the -//! part that can be evaluated (§2.3). The reading is complete, so that part is -//! the rounding of nanojoules to whole microjoules, `0.5/√3 µJ`, and the -//! declaration meets the floor `joules_micro × relative_ppm ≥ 288 676`. The -//! counter's own systematic error is not evaluated, and the class says so. -//! Apple does not document whether the counter is filtered, so the filtering -//! state is `Unknown` (§2.4). -//! -//! Elsewhere the service has no per-process energy counter, and a job says it +//! Off macOS the service has no per-process energy counter, and a job says it //! is unmetered. It never says zero. -use serde_json::{Value, json}; - -/// The least `joules_micro × relative_ppm` a hardware class may declare -/// (energy-measurement §2.3): `⌈10⁶ × 0.5/√3⌉`. -pub const RESOLUTION_FLOOR_UJ_PPM: u64 = 288_676; - -/// One read of an exited worker. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Reading { - /// `ri_energy_nj`: the CPU energy of the worker's own threads. - pub energy_nj: u64, - /// `ri_billed_energy`: CPU energy system services spent for it. - pub billed_nj: u64, - /// `ri_serviced_energy`: CPU energy it spent for other processes. - pub serviced_nj: u64, - /// Its own CPU time, nanoseconds. - pub cpu_ns: u64, - /// From its start to its exit, nanoseconds. - pub window_ns: u64, -} - -/// The figure a result carries, inside its signed body: a reading, or why -/// there is none. `gpu` says the job's method ran on the GPU, which the -/// counter does not read. -pub fn figure(reading: Option<&Reading>, gpu: bool, unmetered_why: &str) -> Value { - let Some(r) = reading else { - return json!({ "unmetered": unmetered_why }); - }; - if r.energy_nj == 0 { - // A process that ran and reads no energy means the counter is not live - // here; that is no reading, not a reading of zero. - return json!({ "unmetered": "the worker's energy counter did not advance: it is not live on this host" }); - } - let total_nj = r.energy_nj.saturating_add(r.billed_nj).saturating_sub(r.serviced_nj); - let joules_micro = total_nj.saturating_add(500) / 1000; - if joules_micro == 0 { - return json!({ "unmetered": "the worker spent under half a microjoule, below what the figure resolves" }); - } - let relative_ppm = RESOLUTION_FLOOR_UJ_PPM.div_ceil(joules_micro).max(1); - let mut excludes = vec!["memory", "the host's idle draw"]; - if gpu { - excludes.insert(0, "the GPU, which this method runs on"); - } - let delegated = r.billed_nj > 0; - if delegated { - excludes.push("any part of the system services' work not yet billed to the worker, and their GPU work"); - } - json!({ - "joules_micro": joules_micro, - "energy_provenance": "OnChipCounter", - "energy_uncertainty": { - "relative_ppm": relative_ppm, - "window_us": r.window_ns.div_ceil(1000).max(1), - }, - "energy_counter_filtering": "Unknown", - "coverage": if gpu || delegated { "partial" } else { "whole" }, - "covers": "the CPU energy of the job's worker process, from its start to its exit, with the CPU energy system services billed to it", - "excludes": excludes, - "cpu_ms": r.cpu_ns / 1_000_000, - "source": "proc_pid_rusage ri_energy_nj + ri_billed_energy − ri_serviced_energy, read after the worker exited and before it was reaped", - }) -} +pub use wai_quantum::engine::energy::{figure, read}; /// Why a job on this host carries no reading. pub const UNMETERED_HERE: &str = if cfg!(target_os = "macos") { @@ -133,199 +52,10 @@ pub fn exited_unreaped(_pid: u32) -> Option { None } -/// The exited, unreaped worker's energy, CPU time and lifetime. -#[cfg(target_os = "macos")] -pub fn read(pid: u32) -> Option { - let info = sys::rusage(pid as libc::pid_t)?; - if info.ri_proc_exit_abstime == 0 { - return None; - } - let ns = |ticks: u64| sys::ticks_to_ns(ticks); - Some(Reading { - energy_nj: info.ri_energy_nj, - billed_nj: info.ri_billed_energy, - serviced_nj: info.ri_serviced_energy, - cpu_ns: ns(info.ri_user_time.saturating_add(info.ri_system_time)), - window_ns: ns(info.ri_proc_exit_abstime.saturating_sub(info.ri_proc_start_abstime)), - }) -} - -#[cfg(not(target_os = "macos"))] -pub fn read(_pid: u32) -> Option { - None -} - -#[cfg(target_os = "macos")] -mod sys { - /// `RUSAGE_INFO_V6` in ``. - const RUSAGE_INFO_V6: libc::c_int = 6; - - /// `struct rusage_info_v6` from the macOS SDK's ``, field - /// for field (464 bytes; the offsets read are pinned by a test). Times are - /// in mach ticks. - #[repr(C)] - #[derive(Clone, Copy)] - pub(super) struct RusageInfoV6 { - pub ri_uuid: [u8; 16], - pub ri_user_time: u64, - pub ri_system_time: u64, - pub ri_pkg_idle_wkups: u64, - pub ri_interrupt_wkups: u64, - pub ri_pageins: u64, - pub ri_wired_size: u64, - pub ri_resident_size: u64, - pub ri_phys_footprint: u64, - pub ri_proc_start_abstime: u64, - pub ri_proc_exit_abstime: u64, - pub ri_child_user_time: u64, - pub ri_child_system_time: u64, - pub ri_child_pkg_idle_wkups: u64, - pub ri_child_interrupt_wkups: u64, - pub ri_child_pageins: u64, - pub ri_child_elapsed_abstime: u64, - pub ri_diskio_bytesread: u64, - pub ri_diskio_byteswritten: u64, - pub ri_cpu_time_qos_default: u64, - pub ri_cpu_time_qos_maintenance: u64, - pub ri_cpu_time_qos_background: u64, - pub ri_cpu_time_qos_utility: u64, - pub ri_cpu_time_qos_legacy: u64, - pub ri_cpu_time_qos_user_initiated: u64, - pub ri_cpu_time_qos_user_interactive: u64, - pub ri_billed_system_time: u64, - pub ri_serviced_system_time: u64, - pub ri_logical_writes: u64, - pub ri_lifetime_max_phys_footprint: u64, - pub ri_instructions: u64, - pub ri_cycles: u64, - pub ri_billed_energy: u64, - pub ri_serviced_energy: u64, - pub ri_interval_max_phys_footprint: u64, - pub ri_runnable_time: u64, - pub ri_flags: u64, - pub ri_user_ptime: u64, - pub ri_system_ptime: u64, - pub ri_pinstructions: u64, - pub ri_pcycles: u64, - pub ri_energy_nj: u64, - pub ri_penergy_nj: u64, - pub ri_secure_time_in_system: u64, - pub ri_secure_ptime_in_system: u64, - pub ri_neural_footprint: u64, - pub ri_lifetime_max_neural_footprint: u64, - pub ri_interval_max_neural_footprint: u64, - pub ri_conclave_footprint: u64, - pub ri_page_wait_time_mach: u64, - pub ri_page_cache_hits: u64, - pub ri_reserved: [u64; 6], - } - - pub(super) fn rusage(pid: libc::pid_t) -> Option { - // SAFETY: an all-zero struct of plain integers is a valid value. - let mut info: RusageInfoV6 = unsafe { std::mem::zeroed() }; - // SAFETY: `info` is a writable `rusage_info_v6`, the struct the V6 - // flavor fills, and outlives the call. - let rc = unsafe { libc::proc_pid_rusage(pid, RUSAGE_INFO_V6, &mut info as *mut RusageInfoV6 as *mut libc::rusage_info_t) }; - (rc == 0).then_some(info) - } - - #[repr(C)] - struct Timebase { - numer: u32, - denom: u32, - } - - unsafe extern "C" { - // `libc` marks it deprecated in favour of the `mach2` crate; bound here. - fn mach_timebase_info(info: *mut Timebase) -> libc::c_int; - } - - /// Mach ticks to nanoseconds. - pub(super) fn ticks_to_ns(ticks: u64) -> u64 { - static TB: std::sync::OnceLock<(u32, u32)> = std::sync::OnceLock::new(); - let (numer, denom) = *TB.get_or_init(|| { - let mut tb = Timebase { numer: 0, denom: 0 }; - // SAFETY: `tb` is writable for the call. - let rc = unsafe { mach_timebase_info(&mut tb) }; - if rc == 0 && tb.denom != 0 { (tb.numer, tb.denom) } else { (1, 1) } - }); - (ticks as u128 * numer as u128 / denom as u128) as u64 - } -} - #[cfg(test)] mod tests { use super::*; - fn reading(energy_nj: u64) -> Reading { - Reading { energy_nj, billed_nj: 0, serviced_nj: 0, cpu_ns: 250_000_000, window_ns: 300_000_000 } - } - - #[test] - fn a_figure_is_a_counter_reading_with_its_declared_uncertainty() { - let f = figure(Some(&reading(812_345_678)), false, UNMETERED_HERE); - assert_eq!(f["joules_micro"], 812_346); - assert_eq!(f["energy_provenance"], "OnChipCounter"); - assert_eq!(f["energy_counter_filtering"], "Unknown"); - assert_eq!(f["energy_uncertainty"]["window_us"], 300_000); - assert_eq!(f["coverage"], "whole"); - assert_eq!(f["cpu_ms"], 250); - let ppm = f["energy_uncertainty"]["relative_ppm"].as_u64().unwrap(); - assert_eq!(ppm, 1); - assert!(f.get("unmetered").is_none()); - } - - #[test] - fn the_declaration_meets_the_resolution_floor_at_every_size() { - for nj in [500, 999, 1_000, 1_499, 1_500, 7_777, 288_675, 288_676_000, 288_677_000, 10_000_000_000_000] { - let f = figure(Some(&reading(nj)), false, UNMETERED_HERE); - let uj = f["joules_micro"].as_u64().unwrap(); - let ppm = f["energy_uncertainty"]["relative_ppm"].as_u64().unwrap(); - assert!(uj * ppm >= RESOLUTION_FLOOR_UJ_PPM, "{nj} nJ: {uj} µJ × {ppm} ppm"); - // and declares no more than the rounding: one ppm less breaks the floor - assert!(ppm == 1 || uj * (ppm - 1) < RESOLUTION_FLOOR_UJ_PPM, "{nj} nJ: {ppm} ppm overstates"); - } - } - - #[test] - fn no_reading_is_never_zero_joules() { - for f in [ - figure(None, false, "why"), - figure(Some(&reading(0)), false, UNMETERED_HERE), - figure(Some(&reading(499)), false, UNMETERED_HERE), - ] { - assert!(f.get("joules_micro").is_none() && f.get("energy_provenance").is_none(), "{f}"); - assert!(f["unmetered"].as_str().is_some_and(|s| !s.is_empty())); - } - } - - #[test] - fn a_gpu_method_or_billed_work_makes_the_figure_partial() { - let f = figure(Some(&reading(5_000_000)), true, UNMETERED_HERE); - assert_eq!(f["coverage"], "partial"); - assert!(f["excludes"][0].as_str().unwrap().contains("GPU")); - let billed = Reading { billed_nj: 1_000_000, ..reading(5_000_000) }; - let f = figure(Some(&billed), false, UNMETERED_HERE); - assert_eq!(f["coverage"], "partial"); - assert_eq!(f["joules_micro"], 6_000); - let serviced = Reading { serviced_nj: 1_000_000, ..reading(5_000_000) }; - assert_eq!(figure(Some(&serviced), false, UNMETERED_HERE)["joules_micro"], 4_000); - } - - #[cfg(target_os = "macos")] - #[test] - fn the_layout_is_the_sdks() { - use std::mem::{offset_of, size_of}; - assert_eq!(size_of::(), 464); - assert_eq!(offset_of!(sys::RusageInfoV6, ri_user_time), 16); - assert_eq!(offset_of!(sys::RusageInfoV6, ri_system_time), 24); - assert_eq!(offset_of!(sys::RusageInfoV6, ri_proc_start_abstime), 80); - assert_eq!(offset_of!(sys::RusageInfoV6, ri_proc_exit_abstime), 88); - assert_eq!(offset_of!(sys::RusageInfoV6, ri_billed_energy), 264); - assert_eq!(offset_of!(sys::RusageInfoV6, ri_serviced_energy), 272); - assert_eq!(offset_of!(sys::RusageInfoV6, ri_energy_nj), 336); - } - /// A child that burns CPU and exits reads a complete, non-zero count while /// it is unreaped, over a window that covers its run; once reaped, it is gone. #[cfg(target_os = "macos")] diff --git a/wai/wai-quantum/README.md b/wai/wai-quantum/README.md index 0edfc93dc..4677934bc 100644 --- a/wai/wai-quantum/README.md +++ b/wai/wai-quantum/README.md @@ -68,7 +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. Generators: integer `input`s, `const`s and `for` loops (ranges, steps, sets, nested), with integer arithmetic in indices and sizes and `if` on integers decided on reading, so one program describes a family of circuits; `qasm::parse_with` reads it at given inputs and `qasm::instantiate` writes that circuit out as plain OpenQASM. `while`, `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_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`); the specification-and-evaluator workbench (`engine::discover`: a specification, the engine's verdict on a generator at every size, and an archive of the cheapest verified generator in every cell of two measures, sealed); and the CPU energy the kernel counts for one process (`engine::energy`). 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/critique.rs b/wai/wai-quantum/src/engine/critique.rs index 720543661..8c097374c 100644 --- a/wai/wai-quantum/src/engine/critique.rs +++ b/wai/wai-quantum/src/engine/critique.rs @@ -106,7 +106,7 @@ fn smell_json(s: &Smell) -> Value { } /// The program's measures, without its smells. -fn measures(p: &Program) -> Value { +pub fn measures(p: &Program) -> Value { let n = p.n_qubits as usize; let mut by = Map::new(); let (mut one, mut two, mut three, mut t, mut off) = (0u64, 0u64, 0u64, 0u64, 0u64); diff --git a/wai/wai-quantum/src/engine/discover.rs b/wai/wai-quantum/src/engine/discover.rs new file mode 100644 index 000000000..9b9e46965 --- /dev/null +++ b/wai/wai-quantum/src/engine/discover.rs @@ -0,0 +1,840 @@ +//! The specification-and-evaluator workbench: a person writes what a family of +//! circuits must do and what it costs; proposers write generators (OpenQASM 3 +//! programs with integer `input`s, [`crate::qasm::parse_with`]); the engine +//! checks every one at every size and keeps the cheapest of every kind. +//! +//! - **The specification** ([`Spec`]) names the task, the reference generator +//! the candidates must match (the same unitary, `equivalent`, or the same +//! state from `|0…0⟩`, `prepares`), the sizes to check, the gates and the +//! connectivity allowed, the cost (weights on the critique's measures, +//! summed over the sizes), the archive's two axes, and the budget. +//! - **The evaluator** ([`Evaluator`]) is the engine, never a model's +//! judgement. A candidate is verified only when it reads at every size, +//! keeps the rules and passes the check at every size, and the weakest check +//! it passed is named: `tableau` and `exact` decide the unitary up to +//! rounding, `state` decides the prepared state, and `random` is a check +//! with no false "different" and a vanishing chance of a false "equivalent". +//! - **The archive** ([`Archive`]) keeps, in every cell of the two axes, the +//! cheapest verified generator (MAP-Elites), with its parent, its proposer +//! and a note. Families of ideas so mature side by side, and a lineage may +//! get worse before it gets better. The reference is its first entry, so +//! an improvement is measured against what the person brought. +//! - **Proposers** stand outside it and submit through [`Archive::submit`]: +//! [`mutate`] and [`search`] are the built-in, seeded baseline (blind +//! statement-level edits, no model), and a model or an outside agent +//! submits the same way. +//! +//! Nothing here reads a clock or an energy counter: [`search`] is given both, +//! so the module runs wherever the engine does. + +use crate::engine::{critique, equiv, receipt}; +use ed25519_dalek::SigningKey; +use crate::qasm::{self, Inputs, Program}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; + +/// The measures a cost or an axis may name: the critique's +/// ([`critique::measures`]). +pub const MEASURES: &[&str] = &["qubits", "gates", "one_qubit", "two_qubit", "three_qubit", "t_count", "non_clifford", "depth", "two_qubit_depth"]; + +/// The most sizes a specification may check. +pub const MAX_INSTANCES: usize = 64; + +/// What a candidate must do at every size. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Task { + /// The reference's unitary, up to a global phase. + Equivalent, + /// The reference's state from `|0…0⟩`, up to a global phase. + Prepares, +} + +/// Which pairs of qubits a gate may act on. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Connectivity { + /// Any. + #[default] + All, + /// Neighbours on a line: a gate's qubits are consecutive. + Line, +} + +/// When a search stops: whichever comes first. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +pub struct Budget { + /// Candidates evaluated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evaluations: Option, + /// Wall-clock seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seconds: Option, + /// Joules spent, where the platform counts them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub joules: Option, +} + +fn default_axes() -> [String; 2] { + ["two_qubit".into(), "depth".into()] +} + +/// What a family of circuits must do, what it may use, and what it costs. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Spec { + pub name: String, + pub task: Task, + /// The generator every candidate is checked against. + pub reference: String, + /// The values of each input to check; every combination is a size. + pub inputs: BTreeMap>, + /// The gates a candidate may use, by name (`h`, `cx`, …); none means any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gates: Option>, + #[serde(default)] + pub connectivity: Connectivity, + /// Weights on [`MEASURES`], summed over every size checked. + pub cost: BTreeMap, + /// The archive's two axes, measured at the largest size. + #[serde(default = "default_axes")] + pub axes: [String; 2], + #[serde(default)] + pub budget: Budget, +} + +impl Spec { + /// A specification from its JSON, checked. + pub fn from_json(v: &Value) -> Result { + let spec: Spec = serde_json::from_value(v.clone()).map_err(|e| format!("the specification: {e}"))?; + spec.check()?; + Ok(spec) + } + + /// Why the specification cannot be used, if it cannot. + pub fn check(&self) -> Result<(), String> { + if self.cost.is_empty() { + return Err("the specification gives no cost: weigh at least one measure".into()); + } + for (m, w) in &self.cost { + if !MEASURES.contains(&m.as_str()) { + return Err(format!("the cost names `{m}`, which is not a measure; the measures are {}", MEASURES.join(", "))); + } + if !w.is_finite() || *w < 0.0 { + return Err(format!("the cost's weight on `{m}` is {w}; a weight is a finite number, 0 or more")); + } + } + for a in &self.axes { + if !MEASURES.contains(&a.as_str()) { + return Err(format!("the axis `{a}` is not a measure; the measures are {}", MEASURES.join(", "))); + } + } + let declared: BTreeSet = qasm::inputs(&self.reference).map_err(|e| format!("the reference: {e}"))?.into_iter().collect(); + let given: BTreeSet = self.inputs.keys().cloned().collect(); + if declared != given { + return Err(format!("the reference takes inputs {declared:?}, and the specification gives values for {given:?}")); + } + let count: usize = self.inputs.values().map(Vec::len).product(); + if count == 0 { + return Err("the specification checks no size: give each input at least one value".into()); + } + if count > MAX_INSTANCES { + return Err(format!("the specification checks {count} sizes; at most {MAX_INSTANCES}")); + } + Ok(()) + } + + /// Every set of inputs checked, each input's values in ascending order, so + /// the last set is the largest. + pub fn instances(&self) -> Vec { + let mut out = vec![Inputs::new()]; + for (name, values) in &self.inputs { + let mut sorted = values.clone(); + sorted.sort_unstable(); + sorted.dedup(); + out = out + .into_iter() + .flat_map(|base| { + sorted.iter().map(move |v| { + let mut b = base.clone(); + b.insert(name.clone(), *v); + b + }) + }) + .collect(); + } + out + } + + /// The specification's digest: BLAKE3 of its canonical JSON, in hex, with + /// the budget left out. An archive is bound to what it is an archive of + /// (the task, the reference, the sizes, the rules, the cost and the axes), + /// and any search may spend what it likes on it. + pub fn digest(&self) -> String { + let v = serde_json::to_value(Spec { budget: Budget::default(), ..self.clone() }).expect("a specification is JSON"); + hex(blake3::hash(&crate::engine::receipt::canonical(&v)).as_bytes()) + } +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn describe(inputs: &Inputs) -> String { + inputs.iter().map(|(k, v)| format!("{k} = {v}")).collect::>().join(", ") +} + +/// How strong a passed check is: a proof up to rounding, or a check. +fn rank(how: &str) -> u8 { + match how { + "tableau" | "exact" | "state" => 2, + "random" => 1, + _ => 0, + } +} + +/// The verdict on one candidate. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct Evaluation { + /// Whether it reads, keeps the rules and passes the check at every size. + pub verified: bool, + /// The weakest check it passed, when verified. + #[serde(skip_serializing_if = "Option::is_none")] + pub strength: Option, + /// Why it is not verified: the first size that failed, and how. + #[serde(skip_serializing_if = "Option::is_none")] + pub failure: Option, + /// Each size checked: its inputs, the check's verdict and how it was + /// decided, and the candidate's measures there. + pub sizes: Vec, + /// The cost, when every size read. + #[serde(skip_serializing_if = "Option::is_none")] + pub cost: Option, + /// The two axes at the largest size, when every size read. + #[serde(skip_serializing_if = "Option::is_none")] + pub axes: Option<[u64; 2]>, +} + +impl Evaluation { + fn failed(why: String, sizes: Vec) -> Evaluation { + Evaluation { verified: false, strength: None, failure: Some(why), sizes, cost: None, axes: None } + } +} + +/// A specification ready to judge candidates: the reference read once at +/// every size. +pub struct Evaluator { + pub spec: Spec, + sizes: Vec<(Inputs, Program)>, + allowed: Option>, +} + +impl Evaluator { + pub fn new(spec: Spec) -> Result { + spec.check()?; + let sizes = spec + .instances() + .into_iter() + .map(|ins| { + let p = qasm::parse_with(&spec.reference, &ins).map_err(|e| format!("the reference at {}: {e}", describe(&ins)))?; + if p.dynamic.is_some() { + return Err(format!("the reference at {} measures in the middle or acts on what it measured; the workbench compares circuits whose measurements all come at the end", describe(&ins))); + } + Ok((ins, p)) + }) + .collect::, String>>()?; + let allowed = spec.gates.as_ref().map(|g| g.iter().map(|s| s.to_ascii_lowercase()).collect()); + Ok(Evaluator { spec, sizes, allowed }) + } + + /// The rules a candidate breaks at one size, if any. + fn rules(&self, p: &Program) -> Option { + if let Some(allowed) = &self.allowed { + for op in &p.ops { + let name = critique::kind_name(op.kind); + if !allowed.contains(name) { + return Some(format!("it uses `{name}`, which the specification does not allow (allowed: {})", allowed.iter().cloned().collect::>().join(", "))); + } + } + } + if self.spec.connectivity == Connectivity::Line { + for op in &p.ops { + if op.qubits.len() >= 2 { + let (lo, hi) = (op.qubits.iter().min().unwrap(), op.qubits.iter().max().unwrap()); + if (hi - lo) as usize != op.qubits.len() - 1 { + return Some(format!("`{}` acts on qubits {:?}, which are not neighbours on the line", critique::kind_name(op.kind), op.qubits)); + } + } + } + } + None + } + + /// The engine's verdict on `candidate`. + pub fn evaluate(&self, candidate: &str) -> Evaluation { + let declared: BTreeSet = match qasm::inputs(candidate) { + Ok(v) => v.into_iter().collect(), + Err(e) => return Evaluation::failed(format!("it does not read: {e}"), Vec::new()), + }; + let given: BTreeSet = self.spec.inputs.keys().cloned().collect(); + if declared != given { + return Evaluation::failed(format!("it takes inputs {declared:?}, and the specification gives values for {given:?}"), Vec::new()); + } + let mut sizes = Vec::new(); + let mut measures = Vec::new(); + let mut weakest: Option = None; + for (ins, reference) in &self.sizes { + let p = match qasm::parse_with(candidate, ins) { + Ok(p) => p, + Err(e) => return Evaluation::failed(format!("at {}: it does not read: {e}", describe(ins)), sizes), + }; + if let Some(why) = self.rules(&p) { + return Evaluation::failed(format!("at {}: {why}", describe(ins)), sizes); + } + let verdict = match self.spec.task { + Task::Equivalent => equiv::equivalent_programs(&p, reference), + Task::Prepares => equiv::same_state(&p, reference), + }; + let m = critique::measures(&p); + let how = verdict["how"].as_str().unwrap_or("none").to_string(); + let v = verdict["verdict"].as_str().unwrap_or("unchecked").to_string(); + sizes.push(json!({ "inputs": ins, "verdict": v, "how": how, "measures": m.clone() })); + // The first size that fails decides: the larger ones are not run. + if v != "equivalent" { + return Evaluation::failed(format!("at {}: {v} ({how}): {}", describe(ins), verdict["why"].as_str().unwrap_or("")), sizes); + } + if weakest.as_deref().is_none_or(|w| rank(&how) < rank(w)) { + weakest = Some(how); + } + measures.push(m); + } + let cost: f64 = measures.iter().map(|m| self.spec.cost.iter().map(|(k, w)| w * m[k.as_str()].as_f64().unwrap_or(0.0)).sum::()).sum(); + let last = measures.last().expect("a specification checks at least one size"); + let axes = [last[self.spec.axes[0].as_str()].as_u64().unwrap_or(0), last[self.spec.axes[1].as_str()].as_u64().unwrap_or(0)]; + Evaluation { verified: true, strength: weakest, failure: None, sizes, cost: Some(cost), axes: Some(axes) } + } +} + +/// One generator the archive keeps. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Entry { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, + /// Who wrote it: `reference`, `mutate`, a model, an agent. + pub proposer: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + pub source: String, + pub cost: f64, + pub axes: [u64; 2], + pub strength: String, + /// The evaluation, in the archive's count, that produced it. + pub at: u64, + /// The engine's verdict sealed with the key of whoever ran the search: + /// `request` (the specification's digest, the source, its proposer and + /// parent), `result` (the cost, the axes, the strength, and every size's + /// verdict and measures) and `receipt`, as [`receipt::seal`] makes it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sealed: Option, +} + +/// What a search has spent and found. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +pub struct Stats { + /// Candidates evaluated. + pub evaluations: u64, + /// Of those, verified. + pub verified: u64, + /// Of those, kept as the best of a cell. + pub kept: u64, + /// Of those, cheaper than every generator kept before. + pub improvements: u64, + /// Candidates not evaluated because the archive had seen them. + pub duplicates: u64, + /// Wall-clock seconds the searches took. + pub seconds: f64, + /// Joules they spent, where the platform counts them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub joules: Option, +} + +/// What became of one submission. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct Outcome { + /// The archive had seen this generator, so it was not evaluated again. + pub duplicate: bool, + /// Kept as the best of its cell, with its id. + #[serde(skip_serializing_if = "Option::is_none")] + pub kept: Option, + /// Cheaper than every generator kept before it. + pub improvement: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub evaluation: Option, +} + +/// The cheapest verified generator in every cell of the two axes, and every +/// generator that was ever the best of its cell, with its lineage. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Archive { + /// The digest of the specification it was built for ([`Spec::digest`]). + pub spec: String, + /// Every generator kept, in the order kept. + pub entries: Vec, + /// Each cell's best: `"a,b"` to its index in `entries`. + pub cells: BTreeMap, + /// The digests of every generator submitted, so none is evaluated twice. + #[serde(default)] + pub seen: BTreeSet, + pub stats: Stats, +} + +/// A generator's digest for the archive's memory: BLAKE3 of its text with its +/// whitespace collapsed. +fn source_digest(src: &str) -> String { + let collapsed: String = src.split_whitespace().collect::>().join(" "); + hex(blake3::hash(collapsed.as_bytes()).as_bytes()) +} + +fn cell(axes: [u64; 2]) -> String { + format!("{},{}", axes[0], axes[1]) +} + +impl Archive { + /// An archive for `ev`'s specification, seeded with its reference. + pub fn new(ev: &Evaluator) -> Result { + Archive::new_sealed(ev, None) + } + + /// [`Archive::new`], with the reference's verdict sealed with `key`. + pub fn new_sealed(ev: &Evaluator, key: Option<&SigningKey>) -> Result { + let mut a = Archive { spec: ev.spec.digest(), entries: Vec::new(), cells: BTreeMap::new(), seen: BTreeSet::new(), stats: Stats::default() }; + let out = a.submit_sealed(ev, &ev.spec.reference.clone(), None, "reference", Some("the reference the specification brought"), key); + if out.kept.is_none() { + let why = out.evaluation.and_then(|e| e.failure).unwrap_or_else(|| "it was not kept".into()); + return Err(format!("the reference does not pass its own specification: {why}")); + } + Ok(a) + } + + /// Whether this archive was built for `spec`. + pub fn is_for(&self, spec: &Spec) -> bool { + self.spec == spec.digest() + } + + /// The best of every cell, in cell order. + pub fn elites(&self) -> Vec<&Entry> { + self.cells.values().map(|&i| &self.entries[i]).collect() + } + + /// The cheapest generator kept. + pub fn best(&self) -> Option<&Entry> { + self.elites().into_iter().min_by(|a, b| a.cost.total_cmp(&b.cost)) + } + + /// The elites no other elite beats on both axes, lower being better, + /// ordered by the first axis. + pub fn front(&self) -> Vec<&Entry> { + let elites = self.elites(); + let mut front: Vec<&Entry> = elites + .iter() + .filter(|e| !elites.iter().any(|o| o.axes[0] <= e.axes[0] && o.axes[1] <= e.axes[1] && o.axes != e.axes)) + .copied() + .collect(); + front.sort_by_key(|e| e.axes); + front + } + + /// Evaluates `source` and keeps it if it is verified and the cheapest in + /// its cell. `parent` is the id it came from, `proposer` who wrote it. + pub fn submit(&mut self, ev: &Evaluator, source: &str, parent: Option<&str>, proposer: &str, note: Option<&str>) -> Outcome { + self.submit_sealed(ev, source, parent, proposer, note, None) + } + + /// [`Archive::submit`], sealing a kept generator's verdict with `key`. + pub fn submit_sealed(&mut self, ev: &Evaluator, source: &str, parent: Option<&str>, proposer: &str, note: Option<&str>, key: Option<&SigningKey>) -> Outcome { + if !self.seen.insert(source_digest(source)) { + self.stats.duplicates += 1; + return Outcome { duplicate: true, kept: None, improvement: false, evaluation: None }; + } + let e = ev.evaluate(source); + self.stats.evaluations += 1; + let (true, Some(cost), Some(axes)) = (e.verified, e.cost, e.axes) else { + return Outcome { duplicate: false, kept: None, improvement: false, evaluation: Some(e) }; + }; + self.stats.verified += 1; + let slot = cell(axes); + if self.cells.get(&slot).is_some_and(|&i| self.entries[i].cost <= cost) { + return Outcome { duplicate: false, kept: None, improvement: false, evaluation: Some(e) }; + } + let improvement = self.best().is_some_and(|b| cost < b.cost); + let id = format!("e{}", self.entries.len()); + let sealed = key.map(|k| { + let request = json!({ "workbench": self.spec, "source": source, "proposer": proposer, "parent": parent }); + let result = json!({ "cost": cost, "axes": axes, "strength": e.strength, "sizes": e.sizes }); + let r = receipt::seal(k, &request, &result); + json!({ "request": request, "result": result, "receipt": r }) + }); + self.entries.push(Entry { + id: id.clone(), + parent: parent.map(str::to_string), + proposer: proposer.to_string(), + note: note.map(str::to_string), + source: source.to_string(), + cost, + axes, + strength: e.strength.clone().unwrap_or_default(), + at: self.stats.evaluations, + sealed, + }); + self.cells.insert(slot, self.entries.len() - 1); + self.stats.kept += 1; + if improvement { + self.stats.improvements += 1; + } + Outcome { duplicate: false, kept: Some(id), improvement, evaluation: Some(e) } + } + + /// Each sealed entry's id and whether its receipt holds and binds this + /// entry: the signature over its request and result, the archive's + /// specification, its source and its cost. + pub fn verify(&self) -> Vec<(String, bool)> { + self.entries + .iter() + .filter_map(|e| { + let s = e.sealed.as_ref()?; + let (req, res) = (&s["request"], &s["result"]); + let binds = req["workbench"] == self.spec.as_str() && req["source"] == e.source.as_str() && res["cost"].as_f64() == Some(e.cost); + Some((e.id.clone(), binds && receipt::verify(req, res, &s["receipt"]))) + }) + .collect() + } + + /// The archive as a person or an agent reads it: what was spent, the + /// reference, the best, the front and every cell. + pub fn summary(&self, spec: &Spec) -> Value { + let brief = |e: &Entry| json!({ "id": e.id, "cost": e.cost, "axes": e.axes, "strength": e.strength, "proposer": e.proposer, "parent": e.parent, "note": e.note }); + let reference = self.entries.first().map(brief); + let per_improvement = match (self.stats.joules, self.stats.improvements) { + (Some(j), n) if n > 0 => json!(j / n as f64), + _ => Value::Null, + }; + json!({ + "spec": spec.name, + "task": spec.task, + "axes": spec.axes, + "stats": self.stats, + "joules_per_improvement": per_improvement, + "reference": reference, + "best": self.best().map(|e| json!({ "id": e.id, "cost": e.cost, "axes": e.axes, "strength": e.strength, "proposer": e.proposer, "source": e.source })), + "front": self.front().into_iter().map(brief).collect::>(), + "cells": self.elites().into_iter().map(brief).collect::>(), + }) + } +} + +/// A seeded source of 64-bit values (SplitMix64), so a search replays. +#[derive(Clone, Debug)] +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Rng { + Rng(seed) + } + + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } + + /// A value in `0..n`, for `n > 0`. + pub fn below(&mut self, n: usize) -> usize { + (self.next_u64() % n as u64) as usize + } +} + +/// Gates by how many qubits they take, for renaming one into another. +const ONE: &[&str] = &["h", "x", "y", "z", "s", "sdg", "t", "tdg", "sx", "sxdg", "id"]; +const TWO: &[&str] = &["cx", "cy", "cz", "ch", "swap"]; +const THREE: &[&str] = &["ccx", "ccz", "cswap"]; + +/// Whether a line of a generator is a gate statement an edit may touch. +fn editable(line: &str) -> bool { + let t = line.trim(); + let first = t.split(|c: char| !(c.is_alphanumeric() || c == '_')).next().unwrap_or(""); + t.ends_with(';') + && !t.contains("measure") + && !t.contains('=') + && !matches!(first, "OPENQASM" | "include" | "input" | "const" | "qubit" | "bit" | "qreg" | "creg" | "gate" | "barrier" | "reset" | "if" | "for" | "else") +} + +/// `source` with one blind, statement-level edit, or none when it has no +/// statement to edit: delete, duplicate or swap a gate statement; rename a +/// gate into another of the same width in `gates` (any, when empty); move an +/// index by one; or swap a two-qubit gate's qubits. The baseline proposer: +/// most edits break the circuit, and the evaluator says so. +pub fn mutate(source: &str, gates: &[String], rng: &mut Rng) -> Option { + let mut lines: Vec = source.lines().map(str::to_string).collect(); + let stmts: Vec = (0..lines.len()).filter(|&i| editable(&lines[i])).collect(); + if stmts.is_empty() { + return None; + } + let at = stmts[rng.below(stmts.len())]; + let line = lines[at].clone(); + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + let body = line.trim(); + let (head, rest) = body.split_once(char::is_whitespace).unwrap_or((body, "")); + let operands: Vec<&str> = rest.trim_end_matches(';').split(',').map(str::trim).collect(); + match rng.below(6) { + 0 => { + lines.remove(at); + } + 1 => lines.insert(at + 1, line.clone()), + 2 => { + let next = stmts.iter().copied().find(|&j| j == at + 1)?; + lines.swap(at, next); + } + 3 => { + if head.contains('(') { + return None; + } + let family = match operands.len() { + 1 => ONE, + 2 => TWO, + 3 => THREE, + _ => return None, + }; + let choices: Vec<&str> = family.iter().copied().filter(|g| *g != head && (gates.is_empty() || gates.iter().any(|a| a == g))).collect(); + if choices.is_empty() { + return None; + } + lines[at] = format!("{indent}{} {rest}", choices[rng.below(choices.len())]); + } + 4 => { + let k = rng.below(operands.len()); + let (open, close) = (operands[k].find('[')?, operands[k].rfind(']')?); + let shift = if rng.below(2) == 0 { "+ 1" } else { "- 1" }; + let moved = format!("{}[{} {shift}]", &operands[k][..open], &operands[k][open + 1..close]); + let mut ops: Vec = operands.iter().map(|s| s.to_string()).collect(); + ops[k] = moved; + lines[at] = format!("{indent}{head} {};", ops.join(", ")); + } + _ => { + if operands.len() != 2 { + return None; + } + lines[at] = format!("{indent}{head} {}, {};", operands[1], operands[0]); + } + } + let mut out = lines.join("\n"); + if source.ends_with('\n') { + out.push('\n'); + } + (out != source).then_some(out) +} + +/// How the built-in proposer searches. +#[derive(Clone)] +pub struct SearchOptions { + pub seed: u64, + /// Edits applied to a parent before it is submitted, at most. + pub max_edits: usize, + /// The key each kept generator's verdict is sealed with, if any. + pub key: Option, +} + +impl Default for SearchOptions { + fn default() -> SearchOptions { + SearchOptions { seed: 1, max_edits: 2, key: None } + } +} + +/// Runs the built-in proposer until the budget is spent: each step picks an +/// elite at random (MAP-Elites selection), applies up to `max_edits` of +/// [`mutate`]'s edits, and submits the result. `seconds` and `joules` read the +/// wall-clock time and the energy spent since the search began (joules where +/// the platform counts them); `on_kept` hears of every generator kept. A +/// budget with no limit at all stops after 1,000 evaluations. +pub fn search(ev: &Evaluator, archive: &mut Archive, opts: &SearchOptions, seconds: &dyn Fn() -> f64, joules: &dyn Fn() -> Option, on_kept: &mut dyn FnMut(&Entry)) { + let b = &ev.spec.budget; + let max_evals = b.evaluations.unwrap_or(if b.seconds.is_none() && b.joules.is_none() { 1_000 } else { u64::MAX }); + let (start_evals, start_seconds, start_joules) = (archive.stats.evaluations, archive.stats.seconds, archive.stats.joules); + let gates: Vec = ev.spec.gates.clone().unwrap_or_default(); + let mut rng = Rng::new(opts.seed); + // Blind edits often give a generator seen before; this bounds the tries. + let mut tries = 0u64; + loop { + let spent = archive.stats.evaluations - start_evals; + let (s, j) = (seconds(), joules()); + archive.stats.seconds = start_seconds + s; + if let Some(j) = j { + archive.stats.joules = Some(start_joules.unwrap_or(0.0) + j); + } + if spent >= max_evals || b.seconds.is_some_and(|limit| s >= limit) || b.joules.zip(j).is_some_and(|(limit, j)| j >= limit) || tries > max_evals.saturating_mul(20).max(1_000) { + return; + } + tries += 1; + let elites: Vec<(String, String)> = archive.elites().into_iter().map(|e| (e.id.clone(), e.source.clone())).collect(); + let (parent, mut src) = elites[rng.below(elites.len())].clone(); + let edits = 1 + rng.below(opts.max_edits.max(1)); + for _ in 0..edits { + if let Some(next) = mutate(&src, &gates, &mut rng) { + src = next; + } + } + let out = archive.submit_sealed(ev, &src, Some(&parent), "mutate", None, opts.key.as_ref()); + if let Some(id) = &out.kept + && let Some(e) = archive.entries.iter().find(|e| &e.id == id) + { + on_kept(e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A GHZ state the plain way: a chain of CX, depth n. + const CHAIN: &str = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [0:n-2] {\n cx q[i], q[i + 1];\n}\n"; + /// The same state in about log2(n) layers: each layer doubles the + /// qubits that hold it. + const TREE: &str = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint k in [0:n] {\n for uint i in [0:2**k - 1] {\n if (2**k < n && i + 2**k < n) {\n cx q[i], q[i + 2**k];\n }\n }\n}\n"; + + fn spec(task: Task) -> Spec { + Spec::from_json(&json!({ + "name": "ghz", + "task": task, + "reference": CHAIN, + "inputs": { "n": [3, 5, 8] }, + "cost": { "depth": 1.0, "two_qubit": 0.1 }, + "axes": ["two_qubit", "depth"], + "budget": { "evaluations": 40 }, + })) + .unwrap() + } + + #[test] + fn the_same_state_in_fewer_layers_is_verified_and_cheaper() { + let ev = Evaluator::new(spec(Task::Prepares)).unwrap(); + let chain = ev.evaluate(CHAIN); + let tree = ev.evaluate(TREE); + assert!(chain.verified && tree.verified, "{tree:?}"); + assert_eq!(tree.strength.as_deref(), Some("state")); + // At n = 8 the chain is 8 layers deep and the tree 4, with the same seven CX. + assert_eq!(chain.axes, Some([7, 8])); + assert_eq!(tree.axes, Some([7, 4])); + assert!(tree.cost.unwrap() < chain.cost.unwrap()); + // As a unitary the tree is a different circuit: it acts otherwise on + // other inputs, so the stricter task refuses it, saying where. + let strict = Evaluator::new(spec(Task::Equivalent)).unwrap().evaluate(TREE); + assert!(!strict.verified); + assert!(strict.failure.as_deref().unwrap().starts_with("at n = 3: different (tableau)"), "{strict:?}"); + // It stopped there: the larger sizes were not run. + assert_eq!(strict.sizes.len(), 1); + } + + #[test] + fn rules_and_inputs_are_held_to() { + let mut s = spec(Task::Prepares); + s.connectivity = Connectivity::Line; + let ev = Evaluator::new(s).unwrap(); + let tree = ev.evaluate(TREE); + assert!(tree.failure.as_deref().unwrap().contains("not neighbours"), "{tree:?}"); + assert!(ev.evaluate(CHAIN).verified); + let mut s = spec(Task::Prepares); + s.gates = Some(vec!["h".into(), "cz".into()]); + let only_cz = Evaluator::new(s).unwrap(); + assert!(only_cz.evaluate(CHAIN).failure.unwrap().contains("does not allow")); + let fixed = "OPENQASM 3.0;\nqubit[3] q;\nh q[0];\ncx q[0], q[1];\ncx q[1], q[2];\n"; + assert!(ev.evaluate(fixed).failure.unwrap().contains("takes inputs")); + assert!(ev.evaluate("OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[n];\n").failure.unwrap().contains("does not read")); + } + + #[test] + fn a_specification_says_what_it_lacks() { + let bad = |patch: Value| { + let mut v = serde_json::to_value(spec(Task::Prepares)).unwrap(); + for (k, x) in patch.as_object().unwrap() { + v[k] = x.clone(); + } + Spec::from_json(&v).unwrap_err() + }; + assert!(bad(json!({ "cost": { "beauty": 1.0 } })).contains("not a measure")); + assert!(bad(json!({ "cost": {} })).contains("no cost")); + assert!(bad(json!({ "axes": ["depth", "joy"] })).contains("not a measure")); + assert!(bad(json!({ "inputs": { "m": [2] } })).contains("takes inputs")); + assert!(bad(json!({ "inputs": { "n": (0..65).collect::>() } })).contains("at most 64")); + assert!(bad(json!({ "task": "guesses" })).contains("the specification")); + } + + #[test] + fn the_archive_keeps_the_cheapest_of_each_cell_and_counts_improvements() { + let ev = Evaluator::new(spec(Task::Prepares)).unwrap(); + let mut a = Archive::new(&ev).unwrap(); + assert_eq!(a.entries[0].proposer, "reference"); + assert!(a.is_for(&ev.spec)); + // A search may spend any budget on it: the budget is not part of what + // the archive is an archive of. + let mut longer = ev.spec.clone(); + longer.budget.evaluations = Some(1_000_000); + assert!(a.is_for(&longer)); + // The tree is cheaper, in a cell of its own: kept, an improvement. + let out = a.submit(&ev, TREE, Some("e0"), "agent:test", Some("doubling layers")); + assert_eq!(out.kept.as_deref(), Some("e1")); + assert!(out.improvement); + // The same generator again is not evaluated twice. + let again = a.submit(&ev, &format!("{TREE}\n\n"), None, "agent:test", None); + assert!(again.duplicate && again.evaluation.is_none()); + // Costlier at n = 3 alone, it lands in the chain's cell at n = 8 and + // is not kept, since the cost is summed over every size. + let padded = CHAIN.replace("h q[0];", "h q[0];\nif (n == 3) { x q[1]; x q[1]; }"); + let out = a.submit(&ev, &padded, None, "agent:test", None); + let e = out.evaluation.unwrap(); + assert!(e.verified && e.axes == Some([7, 8]) && out.kept.is_none(), "{e:?}"); + // Neither is a broken one. + let broken = a.submit(&ev, &CHAIN.replace("cx", "cz"), None, "agent:test", None); + assert!(broken.kept.is_none() && !broken.evaluation.unwrap().verified); + assert_eq!(a.best().unwrap().id, "e1"); + assert_eq!(a.front().iter().map(|e| e.id.as_str()).collect::>(), ["e1"]); + assert_eq!((a.stats.evaluations, a.stats.verified, a.stats.kept, a.stats.improvements, a.stats.duplicates), (4, 3, 2, 1, 1)); + let s = a.summary(&ev.spec); + assert_eq!(s["best"]["id"], "e1"); + assert_eq!(s["reference"]["cost"], a.entries[0].cost); + // A sealed verdict holds, binds its entry, and fails once altered. + let key = SigningKey::from_bytes(&[7; 32]); + let out = a.submit_sealed(&ev, &TREE.replace("h q[0];", "h q[0];\nz q[0];\nz q[0];"), Some("e1"), "agent:test", None, Some(&key)); + let id = out.kept.expect("a new cell"); + assert_eq!(a.verify(), vec![(id.clone(), true)]); + let mut forged = a.clone(); + forged.entries.last_mut().unwrap().cost -= 1.0; + assert_eq!(forged.verify(), vec![(id, false)]); + // An archive round-trips through JSON. + let back: Archive = serde_json::from_value(serde_json::to_value(&a).unwrap()).unwrap(); + assert_eq!(back, a); + } + + #[test] + fn the_baseline_search_replays_and_keeps_only_verified_generators() { + let ev = Evaluator::new(spec(Task::Prepares)).unwrap(); + let run = |seed: u64| { + let mut a = Archive::new(&ev).unwrap(); + let mut kept = Vec::new(); + search(&ev, &mut a, &SearchOptions { seed, max_edits: 2, key: None }, &|| 0.0, &|| None, &mut |e| kept.push(e.id.clone())); + (a, kept) + }; + let (a, kept) = run(7); + assert_eq!(a.stats.evaluations, 40 + 1, "the reference and forty candidates"); + assert!(a.elites().iter().all(|e| ev.evaluate(&e.source).verified)); + assert_eq!(kept.len() as u64 + 1, a.stats.kept); + // The same seed searches the same way. + assert_eq!(run(7).0, a); + // An edit changes the text, and leaves a program with no gate alone. + let mut rng = Rng::new(3); + assert_ne!(mutate(CHAIN, &[], &mut rng).as_deref(), Some(CHAIN)); + assert_eq!(mutate("OPENQASM 3.0;\nqubit q;\n", &[], &mut rng), None); + } +} diff --git a/wai/wai-quantum/src/engine/energy.rs b/wai/wai-quantum/src/engine/energy.rs new file mode 100644 index 000000000..cd16d0c73 --- /dev/null +++ b/wai/wai-quantum/src/engine/energy.rs @@ -0,0 +1,353 @@ +//! What the CPU cores spent, as the kernel counts it for one process: the +//! job service reads each worker once it has exited ([`read`], then +//! [`figure`] for the result's signed body), and the workbench's search reads +//! its own process while it runs ([`own`]). +//! +//! On macOS, `proc_pid_rusage(pid, RUSAGE_INFO_V6)` gives `ri_energy_nj`, the +//! energy in nanojoules the kernel attributes to a process's own threads from +//! the cores' own energy accounting. A thread's energy is added to its process +//! when the thread leaves a core, so a running process's count trails its +//! work by the time slices its threads are still running: [`own`] is a lower +//! bound by that much. An exited process has left every core, so its count is +//! complete, and it stays readable until its parent reaps it; the service +//! waits for its worker without reaping it, reads it, and only then reaps it. +//! +//! What a worker's figure covers: the CPU energy of its own threads from its +//! 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` 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. +//! +//! The class is `OnChipCounter` (WAI energy-measurement §2): the silicon's own +//! accounting, with no calibration behind it. Its declared uncertainty is the +//! part that can be evaluated (§2.3). The reading is complete, so that part is +//! the rounding of nanojoules to whole microjoules, `0.5/√3 µJ`, and the +//! declaration meets the floor `joules_micro × relative_ppm ≥ 288 676`. The +//! counter's own systematic error is not evaluated, and the class says so. +//! Apple does not document whether the counter is filtered, so the filtering +//! state is `Unknown` (§2.4). +//! +//! Elsewhere there is no per-process energy counter to read, and a figure +//! says it is unmetered. It never says zero. + +use serde_json::{Value, json}; + +/// The least `joules_micro × relative_ppm` a hardware class may declare +/// (energy-measurement §2.3): `⌈10⁶ × 0.5/√3⌉`. +pub const RESOLUTION_FLOOR_UJ_PPM: u64 = 288_676; + +/// One read of a process. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reading { + /// `ri_energy_nj`: the CPU energy of the process's own threads. + pub energy_nj: u64, + /// `ri_billed_energy`: CPU energy system services spent for it. + pub billed_nj: u64, + /// `ri_serviced_energy`: CPU energy it spent for other processes. + pub serviced_nj: u64, + /// Its own CPU time, nanoseconds. + pub cpu_ns: u64, + /// From its start to its exit (or to the read, for [`own`]), nanoseconds. + pub window_ns: u64, +} + +impl Reading { + /// The energy the process is charged with: its own threads', plus what + /// system services billed to it, less what it did for others, in joules. + pub fn joules(&self) -> f64 { + self.energy_nj.saturating_add(self.billed_nj).saturating_sub(self.serviced_nj) as f64 / 1e9 + } +} + +/// The figure a worker's result carries, inside its signed body: a reading, +/// or why there is none. `gpu` says the job's method ran on the GPU, which the +/// counter does not read. +pub fn figure(reading: Option<&Reading>, gpu: bool, unmetered_why: &str) -> Value { + let Some(r) = reading else { + return json!({ "unmetered": unmetered_why }); + }; + if r.energy_nj == 0 { + // A process that ran and reads no energy means the counter is not live + // here; that is no reading, not a reading of zero. + return json!({ "unmetered": "the worker's energy counter did not advance: it is not live on this host" }); + } + let total_nj = r.energy_nj.saturating_add(r.billed_nj).saturating_sub(r.serviced_nj); + let joules_micro = total_nj.saturating_add(500) / 1000; + if joules_micro == 0 { + return json!({ "unmetered": "the worker spent under half a microjoule, below what the figure resolves" }); + } + let relative_ppm = RESOLUTION_FLOOR_UJ_PPM.div_ceil(joules_micro).max(1); + let mut excludes = vec!["memory", "the host's idle draw"]; + if gpu { + excludes.insert(0, "the GPU, which this method runs on"); + } + let delegated = r.billed_nj > 0; + if delegated { + excludes.push("any part of the system services' work not yet billed to the worker, and their GPU work"); + } + json!({ + "joules_micro": joules_micro, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": relative_ppm, + "window_us": r.window_ns.div_ceil(1000).max(1), + }, + "energy_counter_filtering": "Unknown", + "coverage": if gpu || delegated { "partial" } else { "whole" }, + "covers": "the CPU energy of the job's worker process, from its start to its exit, with the CPU energy system services billed to it", + "excludes": excludes, + "cpu_ms": r.cpu_ns / 1_000_000, + "source": "proc_pid_rusage ri_energy_nj + ri_billed_energy − ri_serviced_energy, read after the worker exited and before it was reaped", + }) +} + +/// An exited, unreaped child's energy, CPU time and lifetime; none for a +/// process still running, a reaped one, or off macOS. +#[cfg(target_os = "macos")] +pub fn read(pid: u32) -> Option { + let info = sys::rusage(pid)?; + if info.ri_proc_exit_abstime == 0 { + return None; + } + let ns = sys::ticks_to_ns; + Some(Reading { + energy_nj: info.ri_energy_nj, + billed_nj: info.ri_billed_energy, + serviced_nj: info.ri_serviced_energy, + cpu_ns: ns(info.ri_user_time.saturating_add(info.ri_system_time)), + window_ns: ns(info.ri_proc_exit_abstime.saturating_sub(info.ri_proc_start_abstime)), + }) +} + +#[cfg(not(target_os = "macos"))] +pub fn read(_pid: u32) -> Option { + None +} + +/// This process's energy so far, while it runs: a lower bound by its threads' +/// time slices still on a core. None off macOS, or where the counter is not +/// live. +#[cfg(target_os = "macos")] +pub fn own() -> Option { + let info = sys::rusage(std::process::id())?; + if info.ri_energy_nj == 0 { + return None; + } + let ns = sys::ticks_to_ns; + Some(Reading { + energy_nj: info.ri_energy_nj, + billed_nj: info.ri_billed_energy, + serviced_nj: info.ri_serviced_energy, + cpu_ns: ns(info.ri_user_time.saturating_add(info.ri_system_time)), + window_ns: ns(sys::now_ticks().saturating_sub(info.ri_proc_start_abstime)), + }) +} + +#[cfg(not(target_os = "macos"))] +pub fn own() -> Option { + None +} + +#[cfg(target_os = "macos")] +mod sys { + use std::ffi::{c_int, c_void}; + + /// `RUSAGE_INFO_V6` in ``. + const RUSAGE_INFO_V6: c_int = 6; + + /// `struct rusage_info_v6` from the macOS SDK's ``, field + /// for field (464 bytes; the offsets read are pinned by a test). Times are + /// in mach ticks. + #[repr(C)] + #[derive(Clone, Copy)] + pub(super) struct RusageInfoV6 { + pub ri_uuid: [u8; 16], + pub ri_user_time: u64, + pub ri_system_time: u64, + pub ri_pkg_idle_wkups: u64, + pub ri_interrupt_wkups: u64, + pub ri_pageins: u64, + pub ri_wired_size: u64, + pub ri_resident_size: u64, + pub ri_phys_footprint: u64, + pub ri_proc_start_abstime: u64, + pub ri_proc_exit_abstime: u64, + pub ri_child_user_time: u64, + pub ri_child_system_time: u64, + pub ri_child_pkg_idle_wkups: u64, + pub ri_child_interrupt_wkups: u64, + pub ri_child_pageins: u64, + pub ri_child_elapsed_abstime: u64, + pub ri_diskio_bytesread: u64, + pub ri_diskio_byteswritten: u64, + pub ri_cpu_time_qos_default: u64, + pub ri_cpu_time_qos_maintenance: u64, + pub ri_cpu_time_qos_background: u64, + pub ri_cpu_time_qos_utility: u64, + pub ri_cpu_time_qos_legacy: u64, + pub ri_cpu_time_qos_user_initiated: u64, + pub ri_cpu_time_qos_user_interactive: u64, + pub ri_billed_system_time: u64, + pub ri_serviced_system_time: u64, + pub ri_logical_writes: u64, + pub ri_lifetime_max_phys_footprint: u64, + pub ri_instructions: u64, + pub ri_cycles: u64, + pub ri_billed_energy: u64, + pub ri_serviced_energy: u64, + pub ri_interval_max_phys_footprint: u64, + pub ri_runnable_time: u64, + pub ri_flags: u64, + pub ri_user_ptime: u64, + pub ri_system_ptime: u64, + pub ri_pinstructions: u64, + pub ri_pcycles: u64, + pub ri_energy_nj: u64, + pub ri_penergy_nj: u64, + pub ri_secure_time_in_system: u64, + pub ri_secure_ptime_in_system: u64, + pub ri_neural_footprint: u64, + pub ri_lifetime_max_neural_footprint: u64, + pub ri_interval_max_neural_footprint: u64, + pub ri_conclave_footprint: u64, + pub ri_page_wait_time_mach: u64, + pub ri_page_cache_hits: u64, + pub ri_reserved: [u64; 6], + } + + #[repr(C)] + struct Timebase { + numer: u32, + denom: u32, + } + + unsafe extern "C" { + // ``; `rusage_info_t` is a pointer to the flavor's struct. + fn proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut c_void) -> c_int; + // ``. + fn mach_timebase_info(info: *mut Timebase) -> c_int; + fn mach_absolute_time() -> u64; + } + + pub(super) fn rusage(pid: u32) -> Option { + let pid = c_int::try_from(pid).ok()?; + // SAFETY: an all-zero struct of plain integers is a valid value. + let mut info: RusageInfoV6 = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is a writable `rusage_info_v6`, the struct the V6 + // flavor fills, and outlives the call. + let rc = unsafe { proc_pid_rusage(pid, RUSAGE_INFO_V6, (&mut info as *mut RusageInfoV6).cast()) }; + (rc == 0).then_some(info) + } + + /// The mach clock now, in ticks. + pub(super) fn now_ticks() -> u64 { + // SAFETY: no arguments, no state. + unsafe { mach_absolute_time() } + } + + /// Mach ticks to nanoseconds. + pub(super) fn ticks_to_ns(ticks: u64) -> u64 { + static TB: std::sync::OnceLock<(u32, u32)> = std::sync::OnceLock::new(); + let (numer, denom) = *TB.get_or_init(|| { + let mut tb = Timebase { numer: 0, denom: 0 }; + // SAFETY: `tb` is writable for the call. + let rc = unsafe { mach_timebase_info(&mut tb) }; + if rc == 0 && tb.denom != 0 { (tb.numer, tb.denom) } else { (1, 1) } + }); + (ticks as u128 * numer as u128 / denom as u128) as u64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const WHY: &str = "this host has no per-process energy counter"; + + fn reading(energy_nj: u64) -> Reading { + Reading { energy_nj, billed_nj: 0, serviced_nj: 0, cpu_ns: 250_000_000, window_ns: 300_000_000 } + } + + #[test] + fn a_figure_is_a_counter_reading_with_its_declared_uncertainty() { + let f = figure(Some(&reading(812_345_678)), false, WHY); + assert_eq!(f["joules_micro"], 812_346); + assert_eq!(f["energy_provenance"], "OnChipCounter"); + assert_eq!(f["energy_counter_filtering"], "Unknown"); + assert_eq!(f["energy_uncertainty"]["window_us"], 300_000); + assert_eq!(f["coverage"], "whole"); + assert_eq!(f["cpu_ms"], 250); + let ppm = f["energy_uncertainty"]["relative_ppm"].as_u64().unwrap(); + assert_eq!(ppm, 1); + assert!(f.get("unmetered").is_none()); + } + + #[test] + fn the_declaration_meets_the_resolution_floor_at_every_size() { + for nj in [500, 999, 1_000, 1_499, 1_500, 7_777, 288_675, 288_676_000, 288_677_000, 10_000_000_000_000] { + let f = figure(Some(&reading(nj)), false, WHY); + let uj = f["joules_micro"].as_u64().unwrap(); + let ppm = f["energy_uncertainty"]["relative_ppm"].as_u64().unwrap(); + assert!(uj * ppm >= RESOLUTION_FLOOR_UJ_PPM, "{nj} nJ: {uj} µJ × {ppm} ppm"); + // and declares no more than the rounding: one ppm less breaks the floor + assert!(ppm == 1 || uj * (ppm - 1) < RESOLUTION_FLOOR_UJ_PPM, "{nj} nJ: {ppm} ppm overstates"); + } + } + + #[test] + fn no_reading_is_never_zero_joules() { + for f in [figure(None, false, "why"), figure(Some(&reading(0)), false, WHY), figure(Some(&reading(499)), false, WHY)] { + assert!(f.get("joules_micro").is_none() && f.get("energy_provenance").is_none(), "{f}"); + assert!(f["unmetered"].as_str().is_some_and(|s| !s.is_empty())); + } + } + + #[test] + fn a_gpu_method_or_billed_work_makes_the_figure_partial() { + let f = figure(Some(&reading(5_000_000)), true, WHY); + assert_eq!(f["coverage"], "partial"); + assert!(f["excludes"][0].as_str().unwrap().contains("GPU")); + let billed = Reading { billed_nj: 1_000_000, ..reading(5_000_000) }; + let f = figure(Some(&billed), false, WHY); + assert_eq!(f["coverage"], "partial"); + assert_eq!(f["joules_micro"], 6_000); + let serviced = Reading { serviced_nj: 1_000_000, ..reading(5_000_000) }; + assert_eq!(figure(Some(&serviced), false, WHY)["joules_micro"], 4_000); + assert!((serviced.joules() - 0.004).abs() < 1e-12); + } + + #[cfg(target_os = "macos")] + #[test] + fn the_layout_is_the_sdks() { + use std::mem::{offset_of, size_of}; + assert_eq!(size_of::(), 464); + assert_eq!(offset_of!(sys::RusageInfoV6, ri_user_time), 16); + assert_eq!(offset_of!(sys::RusageInfoV6, ri_system_time), 24); + assert_eq!(offset_of!(sys::RusageInfoV6, ri_proc_start_abstime), 80); + assert_eq!(offset_of!(sys::RusageInfoV6, ri_proc_exit_abstime), 88); + assert_eq!(offset_of!(sys::RusageInfoV6, ri_billed_energy), 264); + assert_eq!(offset_of!(sys::RusageInfoV6, ri_serviced_energy), 272); + assert_eq!(offset_of!(sys::RusageInfoV6, ri_energy_nj), 336); + } + + /// This process reads its own count while it runs, and the count grows + /// with the work it does. + #[cfg(target_os = "macos")] + #[test] + fn a_running_process_reads_its_own_energy_and_it_grows() { + let Some(before) = own() else { return }; + let mut x = 0u64; + for i in 0..50_000_000u64 { + x = x.wrapping_mul(6364136223846793005).wrapping_add(i); + } + std::hint::black_box(x); + // The count trails the threads still on a core; yield so it lands. + std::thread::sleep(std::time::Duration::from_millis(20)); + let after = own().expect("the counter read once reads again"); + assert!(after.energy_nj > before.energy_nj, "{before:?} {after:?}"); + assert!(after.cpu_ns > before.cpu_ns && after.window_ns >= before.window_ns); + } +} diff --git a/wai/wai-quantum/src/engine/equiv.rs b/wai/wai-quantum/src/engine/equiv.rs index 1d76fe081..b383fb83c 100644 --- a/wai/wai-quantum/src/engine/equiv.rs +++ b/wai/wai-quantum/src/engine/equiv.rs @@ -86,6 +86,30 @@ pub fn equivalent_programs(a: &Program, b: &Program) -> Value { json!({ "verdict": "unchecked", "how": "none", "why": format!("{n} qubits and not both Clifford: wider than this check runs ({RANDOM_MAX_QUBITS} qubits)") }) } +/// Whether `a` and `b` prepare the same state from `|0…0⟩`, up to a global +/// phase, as JSON in the form of [`equivalent_programs`]: a weaker question +/// than the same unitary, and the one a state-preparation circuit answers. +/// `how` is `state`: both run from `|0…0⟩` on the state vector, which one input +/// fixes completely, so the check is exact up to rounding, at most +/// [`RANDOM_MAX_QUBITS`] qubits. +pub fn same_state(a: &Program, b: &Program) -> Value { + if a.dynamic.is_some() || b.dynamic.is_some() { + return json!({ "verdict": "unchecked", "how": "none", "why": "a program measures in the middle, resets after a gate or acts on what it measured; this check compares the states of programs whose measurements all come at the end" }); + } + if a.n_qubits != b.n_qubits { + return json!({ "verdict": "different", "how": "shape", "why": format!("one has {} qubits and the other {}", a.n_qubits, b.n_qubits) }); + } + let n = a.n_qubits; + if n > RANDOM_MAX_QUBITS { + return json!({ "verdict": "unchecked", "how": "none", "why": format!("{n} qubits: wider than this check runs ({RANDOM_MAX_QUBITS} qubits)") }); + } + let v = compare(n, &a.matrices(), &b.matrices(), &[Vec::new()], "state", |_| "|0…0⟩".to_string()); + if v["verdict"] == "equivalent" { + return json!({ "verdict": "equivalent", "how": "state", "why": "from |0…0⟩ both give the same state up to a global phase", "global_phase": v["global_phase"] }); + } + v +} + /// Run each input preparation followed by `a`, and by `b`, and compare. fn compare(n: u32, a: &[Gate], b: &[Gate], inputs: &[Vec], how: &str, name: impl Fn(usize) -> String) -> Value { let mut phase: Option = None; diff --git a/wai/wai-quantum/src/engine/mod.rs b/wai/wai-quantum/src/engine/mod.rs index 058063363..9c1994870 100644 --- a/wai/wai-quantum/src/engine/mod.rs +++ b/wai/wai-quantum/src/engine/mod.rs @@ -28,6 +28,13 @@ //! circuit, each with its cost on it, and the cheapest for each question. A //! cheap route proves a design classically easy; none being cheap proves //! nothing. +//! - [`energy`]: what the CPU cores spent for one process, as the kernel counts +//! it, and the figure a result carries: the service reads each exited +//! worker, the workbench its own process while it searches. +//! - [`discover`] (with `quantum_receipt`): the specification-and-evaluator +//! workbench. A person writes what a family of circuits must do and what it +//! costs, proposers write generators, the engine checks each at every size, +//! and an archive keeps the cheapest verified generator of every kind. //! //! 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 @@ -37,6 +44,9 @@ pub mod catalog; pub mod critique; pub mod draw; pub mod dynamic; +#[cfg(feature = "quantum_receipt")] +pub mod discover; +pub mod energy; pub mod equiv; #[cfg(feature = "quantum_receipt")] pub mod job; -- 2.54.0 (Apple Git-157) From 256aa21ab4efada7360b014176df5b2f76722d54 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 25 Oct 2026 21:15:28 -0400 Subject: [PATCH 2/6] wai-quantum-service: the energy test module is macOS-only, so Linux builds it with no unused import Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum-service/src/energy.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wai/wai-quantum-service/src/energy.rs b/wai/wai-quantum-service/src/energy.rs index 69acdb1c3..e3bd559a3 100644 --- a/wai/wai-quantum-service/src/energy.rs +++ b/wai/wai-quantum-service/src/energy.rs @@ -52,13 +52,13 @@ pub fn exited_unreaped(_pid: u32) -> Option { None } -#[cfg(test)] +// The one test reads the kernel's counter, which only macOS has. +#[cfg(all(test, target_os = "macos"))] mod tests { use super::*; /// A child that burns CPU and exits reads a complete, non-zero count while /// it is unreaped, over a window that covers its run; once reaped, it is gone. - #[cfg(target_os = "macos")] #[test] fn an_exited_worker_reads_its_whole_energy_before_it_is_reaped() { let mut child = std::process::Command::new("/bin/sh") -- 2.54.0 (Apple Git-157) From 52a0fcab68fc2b7b12164f9e011eaec3e0c98cd2 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 25 Oct 2026 21:15:00 -0400 Subject: [PATCH 3/6] Quantum Studio: the Workbench, and generators in the editor The workbench from the last change, in the Studio: a family of circuits searched for its cheapest members, every proposal judged by the engine. And the editor reads a generator at its inputs' values, which the Workbench needs and everything else gains from. - Generators in the editor (generators.js): - a field for each declared input under the editor, kept by name; - the lint and the critique read the generator at those values and keep its own lines (job::lint_with, critique::critique_with; a smell in a generator offers no Fix); - the diagram, the plan, a run here or on the service, the lens and the alternatives take the circuit it writes out (qasm::instantiate, in the page and in the desktop app). A new value re-lints through the linter's needsRefresh. - The Workbench pane (workbench.js): - a form for the specification, the archive's elites on the two axes with the cheapest marked, and an entry's cost, check, seal and lineage, with "Open in editor"; - three proposers, each named on what it found: the reader, the blind search (in steps of 25, so its progress shows) and the model; - the archive kept in the browser, back with the page, and downloaded beside its specification as `wai-quantum discover verify` reads them. - The model as a proposer (Assistant.propose): it is told the specification and shown the cheapest found, with the costs the engine measured. The assistant's brief now says while, inv and pow are refused, not loops. - Bindings: discover_start, discover_submit, discover_search, discover_evaluate and instantiate in wai-web and the desktop app, which also counts a search's seconds and joules. - Tests: - the engine: a generator critiqued at its inputs, with its own lines and no Fix; - wai-web: the workbench run and sealed in the page; - the Studio, in a browser: a generator linted, drawn and run at its value; the Workbench started from it, a hundred blind edits judged, the reader's log-depth tree kept as the cheapest, opened, downloaded and back after a reload; a scripted model's generators judged at every size, one kept, a wrong one and a forbidden gate left out with their reasons. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 30 +++ wai/quantum-studio/README.md | 22 ++ wai/quantum-studio/src/assistant.js | 32 ++- wai/quantum-studio/src/engine.js | 19 +- wai/quantum-studio/src/generators.js | 70 ++++++ wai/quantum-studio/src/main.js | 33 ++- wai/quantum-studio/src/studio.css | 36 +++ wai/quantum-studio/src/workbench.js | 312 ++++++++++++++++++++++++ wai/quantum-studio/src/worker.js | 9 +- wai/quantum-studio/test/studio.test.mjs | 95 +++++++- wai/wai-quantum-desktop/src/main.rs | 102 +++++++- wai/wai-quantum/src/engine/critique.rs | 42 ++++ wai/wai-quantum/src/engine/job.rs | 8 +- wai/wai-web/src/quantum_engine.rs | 134 ++++++++++ 14 files changed, 920 insertions(+), 24 deletions(-) create mode 100644 wai/quantum-studio/src/generators.js create mode 100644 wai/quantum-studio/src/workbench.js diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 8c43f5eba..c4dc00c35 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -1116,6 +1116,36 @@ wire format may change and such changes are recorded here. - **`tools/ops-gpu-bench`** times each capability's decode with its graphs on the CPU and on each adapter, and reads the energy counters the host has. - **CI:** `wai-ops-gpu.yml` runs the Metal and browser legs on the `macos` runner, and requires on a runner with no GPU that the device tests skip without `WAI_OPS_GPU_REQUIRE` and fail with it. +### Quantum Studio: the Workbench, and generators in the editor + +- **Generators in the editor.** A program with integer inputs shows a field for each under the + editor. The lint and the critique read the generator at those values, keeping its own lines + (`job::lint_with`, `critique::critique_with`; a smell in a generator offers no Fix). The diagram, + the plan, a run here or on the service, the lens and the alternatives take the circuit it writes + out (`qasm::instantiate`, in the page and in the desktop app). +- **The Workbench pane** puts `engine::discover` in the Studio. The program in the editor is the + reference. A form gives the specification: the task, the sizes, the gates, the connectivity, the + cost's weights and the map's axes. The archive's elites are plotted on the two axes, the + cheapest marked. An entry shows its cost, how it was checked, its seal and its lineage, and opens + in the editor. +- **Three proposers, each named on what it found:** the reader's program, the built-in blind + search (in steps of 25, so its progress shows), and the model. The model is a proposer + (`Assistant.propose`): it is told the specification and shown the cheapest generators found, + with the costs the engine measured. The engine judges what it writes, as it judges everything + else. +- **Sealed where it runs:** in a tab with the browser's key, in the desktop app with the app's, + which also counts a search's seconds and joules. The archive is kept in the browser and comes + back with the page. It downloads beside its specification, as `wai-quantum discover verify` reads + them. +- **The assistant's brief** no longer says loops are refused: `while`, `inv` and `pow` are, and + a generator is read at the inputs bar's values. +- The Studio's browser test adds: + - a generator linted, drawn and run at its value; + - the Workbench started from it, with a hundred blind edits judged; + - the reader's log-depth tree kept as the cheapest, opened in the editor, and back after a reload; + - a scripted model's generators judged at every size: one kept, while a wrong one and one using + a gate the specification forbids are left out with their reasons. + ### wai-quantum: the specification-and-evaluator workbench - **A specification** (`engine::discover::Spec`, a JSON file for the command line): diff --git a/wai/quantum-studio/README.md b/wai/quantum-studio/README.md index 3c75ff33e..ce301ad29 100644 --- a/wai/quantum-studio/README.md +++ b/wai/quantum-studio/README.md @@ -17,6 +17,28 @@ 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. +## Generators + +A program that declares integer inputs (`input uint n;`) is a generator: its `for` loops and its `if`s on integers write out one circuit for each value. The Studio shows a field for each input under the editor and reads the program at those values. The lint and the critique read the generator itself, so a problem or a smell keeps the generator's own line. The diagram, the plan, a run (here or on the service), the lens and the alternatives take the circuit it writes out. The values are kept in this browser, by name. + +## The Workbench + +The Workbench searches a family of circuits for its cheapest members, and the engine judges every proposal. The program in the editor is the reference. The specification says: + +- what each member must do: prepare the same state from |0…0⟩, or be the same circuit; +- the sizes to check at (`n = 3, 5, 8`); +- the gates allowed, and whether a gate may join any pair or only neighbours on a line; +- the cost, as weights on the critique's measures summed over the sizes; +- the two measures the map plots. + +"Start" checks the reference at every size and seeds an archive with it. The archive keeps the cheapest verified generator in each cell of the map. Three proposers write into it, and each is named on what it found: + +- **the reader:** "Submit the editor's program"; +- **the built-in, blind search:** "Search ×100", seeded edits of what the archive holds. It is the baseline an idea has to beat; +- **the model**, when one is connected: "Ask the model ×3". It is told the specification and shown the cheapest found, with the costs the engine measured. + +A proposal is verified only when it reads, keeps the rules and passes the check at every size. Each kept entry is sealed with this browser's key (the desktop app's, in the app). Clicking a point shows its cost, how it was checked, its seal and where it came from; "Open in editor" puts it in the editor. The archive is kept in this browser and comes back with the page. "Download" saves the specification and its archive side by side (`NAME.json` and `NAME.archive.json`), so `wai-quantum discover verify NAME.json` checks every seal. In the desktop app a search also counts its seconds and the joules its process spent. + ## 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. diff --git a/wai/quantum-studio/src/assistant.js b/wai/quantum-studio/src/assistant.js index 9b34dfc1a..f993b1f74 100644 --- a/wai/quantum-studio/src/assistant.js +++ b/wai/quantum-studio/src/assistant.js @@ -25,7 +25,7 @@ Tools run the Studio's own engine: - 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. +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 while, inv and pow. A program that declares integer inputs (input uint n;) is a generator: for loops over integer ranges and if on integer conditions write out one circuit for each value, and the tools read it at the values in the Studio's inputs bar. 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. @@ -133,8 +133,8 @@ export class Assistant { // Candidates for Alternatives, from one call without tools and outside the // conversation: [{idea, qasm}]. The engine checks each; nothing here does. - async muse(prompt, temperature) { - const res = await this.model.chat({ model: this.model.name || undefined, temperature, messages: [{ role: 'system', content: MUSE }, { role: 'user', content: prompt }] }); + async muse(prompt, temperature, brief = MUSE) { + const res = await this.model.chat({ model: this.model.name || undefined, temperature, messages: [{ role: 'system', content: brief }, { role: 'user', content: prompt }] }); return ideas(unlatex((res.choices?.[0]?.message?.content || '').replace(/[\s\S]*?<\/think>\s*/g, ''))); } improve(qasm, k = 3) { @@ -143,6 +143,13 @@ export class Assistant { explore(qasm, aim, k = 4) { return this.muse(`Imagine ${k} different programs in the spirit of this one${aim ? `, toward this aim: ${aim}` : ''}. Make them differ from each other in kind, not only in their angles: another structure, another entangler, another symmetry, another use of the qubits. Before each, one line that starts "Idea:" and says what it tries. Each program whole, in its own \`\`\`qasm block.\n\n\`\`\`qasm\n${qasm}\n\`\`\``, 0.9); } + // Generators for the Workbench, from its specification and the cheapest it + // has found (`shown`, entries of the archive, cheapest first). The archive + // judges each; the model sees costs the engine measured, never its own. + propose(spec, shown, k = 3) { + const found = shown.map((e, i) => `${i ? 'Also found' : 'The cheapest found'}: cost ${Math.round(e.cost * 1000) / 1000}${e.proposer === 'reference' ? ', the reference' : e.note ? `, from the idea "${e.note}"` : ''}.\n\`\`\`qasm\n${e.source.trim()}\n\`\`\``).join('\n\n'); + return this.muse(`Write ${k} generators that meet this specification at a lower cost than any found so far. ${specText(spec)}\n\n${found}\n\nMake the ideas differ from each other in kind: another structure, another order of the gates, another way to share the work between the qubits. Before each, one line that starts "Idea:" and says what it tries. Each generator whole, with its input declarations, in its own \`\`\`qasm block.`, 0.9, PROPOSE); + } async tool(name, args) { const ctx = this.context(); @@ -235,7 +242,24 @@ export class Assistant { } // The muse's brief: candidates, not answers, and no figures. -const MUSE = `You propose OpenQASM 3 programs for Quantum Studio's Alternatives. The Studio's engine reads, measures and compares every program you write; you do not judge them. Write each program whole, in its own \`\`\`qasm block, each after one line that starts "Idea:". State no counts, probabilities or costs. Use only what the engine reads: registers, the standard gates, gate definitions, ctrl and negctrl modifiers, measurement and reset, and if on measured bits; no loops, inv or pow.`; +const MUSE = `You propose OpenQASM 3 programs for Quantum Studio's Alternatives. The Studio's engine reads, measures and compares every program you write; you do not judge them. Write each program whole, in its own \`\`\`qasm block, each after one line that starts "Idea:". State no counts, probabilities or costs. Use only what the engine reads: registers, the standard gates, gate definitions, ctrl and negctrl modifiers, measurement and reset, and if on measured bits; no while, inv or pow, and loops only in a program that declares an integer input.`; + +// The proposer's brief for the Workbench: generators, judged at every size. +const PROPOSE = `You propose OpenQASM 3 generators for Quantum Studio's Workbench: programs with integer inputs (input uint n;) that write out one circuit for each value. The Studio's engine checks every generator you write at every size of the specification and keeps the cheapest; you do not judge them. Write each generator whole, in its own \`\`\`qasm block, each after one line that starts "Idea:". State no counts or costs. Use what the engine reads: integer inputs, registers sized by an input (qubit[n] q;), for loops over inclusive integer ranges (for uint i in [0:n-2] { … }), if on integer conditions (< <= == != && || % **), and the standard gates; no while, inv or pow.`; + +const TASKS = { prepares: 'prepare the same state from |0…0⟩ as the reference, up to a global phase', equivalent: 'be the same circuit as the reference (the same unitary up to a global phase)' }; + +// What a specification asks, in the words a proposer reads. +export function specText(spec) { + const sizes = Object.entries(spec.inputs ?? {}).map(([n, vs]) => `${n} = ${vs.join(', ')}`).join('; ') || 'one size'; + const cost = Object.entries(spec.cost ?? {}).map(([k, w]) => `${w} × ${k.replace(/_/g, ' ')}`).join(' + '); + return [ + `Every member of the family must ${TASKS[spec.task] ?? spec.task}, at every size: ${sizes}.`, + spec.gates?.length ? `It may use only these gates: ${spec.gates.join(', ')}.` : 'It may use any standard gate.', + spec.connectivity === 'line' ? 'A two-qubit gate may join only neighbours on a line: q[i] and q[i + 1].' : 'A gate may join any qubits.', + `The cost, summed over the sizes: ${cost}.`, + ].join(' '); +} // The ideas in a muse's answer: each \`\`\`qasm block with the "Idea:" line // before it. diff --git a/wai/quantum-studio/src/engine.js b/wai/quantum-studio/src/engine.js index 24bb295b0..d7997c318 100644 --- a/wai/quantum-studio/src/engine.js +++ b/wai/quantum-studio/src/engine.js @@ -7,7 +7,8 @@ // 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, verify, the critique, the equivalence -// check and the variants run locally: in the tab, or natively on the desktop. +// check, the variants and the workbench run locally: in the tab, or natively on +// the desktop, which also reads the seconds and joules a search takes. const KEY_ITEM = 'wai.quantum-ide.signing-key'; // one key per browser, shared with the IDE and the console @@ -64,6 +65,15 @@ export class TabEngine { equivalent(a, b) { return this.call('equivalent', { a, b }); } variants(qasm) { return this.call('variants', { qasm }); } shortcuts(qasm, thorough = false) { return this.call('shortcuts', { qasm, thorough }); } + instantiate(qasm, inputs) { return this.call('instantiate', { qasm, inputs }); } + lintWith(qasm, inputs) { return this.call('lint_with', { qasm, inputs }); } + critiqueWith(qasm, inputs) { return this.call('critique_with', { qasm, inputs }); } + // The workbench: each answer is {archive, summary, …}, the archive passed back whole. + discoverEvaluate(spec, source) { return this.call('discover_evaluate', { spec, source }); } + discoverStart(spec) { return this.call('discover_start', { spec }); } + discoverSubmit(spec, archive, source, parent, proposer, note) { return this.call('discover_submit', { spec, archive, source, parent, proposer, note }); } + // The search's seed goes as `from`: `seed` is the signing key's, on every message. + discoverSearch(spec, archive, seed, evaluations) { return this.call('discover_search', { spec, archive, from: seed, evaluations }); } async signer() { return (await this.call('signer')).did; } cancel() { this.worker?.terminate(); @@ -132,6 +142,13 @@ export class DesktopEngine { equivalent(a, b) { return this.call('equivalent', { a, b }); } variants(qasm) { return this.call('variants', { qasm }); } shortcuts(qasm, thorough = false) { return this.call('shortcuts', { qasm, thorough }); } + instantiate(qasm, inputs) { return this.call('instantiate', { qasm, inputs: JSON.stringify(inputs ?? {}) }); } + async lintWith(qasm, inputs) { return JSON.parse(await this.invoke('lint', { qasm, inputs: JSON.stringify(inputs ?? {}) })); } + async critiqueWith(qasm, inputs) { return JSON.parse(await this.invoke('critique', { qasm, inputs: JSON.stringify(inputs ?? {}) })); } + discoverEvaluate(spec, source) { return this.call('discover_evaluate', { spec: JSON.stringify(spec), source }); } + discoverStart(spec) { return this.call('discover_start', { spec: JSON.stringify(spec) }); } + discoverSubmit(spec, archive, source, parent, proposer, note) { return this.call('discover_submit', { spec: JSON.stringify(spec), archive: JSON.stringify(archive), source, parent: parent ?? null, proposer, note: note ?? null }); } + discoverSearch(spec, archive, seed, evaluations) { return this.call('discover_search', { spec: JSON.stringify(spec), archive: JSON.stringify(archive), seed, evaluations }); } 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 }); } diff --git a/wai/quantum-studio/src/generators.js b/wai/quantum-studio/src/generators.js new file mode 100644 index 000000000..9a6f12690 --- /dev/null +++ b/wai/quantum-studio/src/generators.js @@ -0,0 +1,70 @@ +// Generators in the editor: a program with integer inputs (`input uint n;`) +// describes a family of circuits, and the Studio reads it at the values the +// inputs bar holds. The lint and the critique read the generator itself, so a +// problem or a smell keeps the generator's own line; everything else (the +// diagram, the plan, a run, the lens, the alternatives) takes the circuit the +// generator writes out at those values. +import { el } from './dom.js'; + +const INPUT_RE = /^\s*input\s+u?int(?:\s*\[\s*\d+\s*\])?\s+([A-Za-z_]\w*)\s*;/gm; +const VALUES = 'wai.quantum-studio.inputs'; + +// The names a program declares as integer inputs, in order. +export function declared(qasm) { + return typeof qasm === 'string' ? [...qasm.matchAll(INPUT_RE)].map((m) => m[1]) : []; +} + +// `engine` reading generators at `values()`: `local` (the tab's or the +// desktop's engine) writes a generator out, and lints and critiques it. +export function withInputs(engine, values, local = engine) { + const isGen = (q) => declared(q).length > 0; + const inst = async (q) => (isGen(q) ? (await local.instantiate(q, values())).text : q); + const over = { + lint: (q) => (isGen(q) ? local.lintWith(q, values()) : engine.lint(q)), + critique: (q) => (isGen(q) ? local.critiqueWith(q, values()) : engine.critique(q)), + equivalent: async (a, b) => engine.equivalent(await inst(a), await inst(b)), + run: async (req, ...rest) => engine.run({ ...req, qasm: await inst(req.qasm) }, ...rest), + plan: async (req, ...rest) => engine.plan({ ...req, qasm: await inst(req.qasm) }, ...rest), + instance: inst, + }; + for (const m of ['draw', 'layout', 'variants', 'shortcuts']) over[m] = async (q, ...rest) => engine[m](await inst(q), ...rest); + return new Proxy(engine, { + get(target, prop) { + if (Object.hasOwn(over, prop) && (prop === 'instance' || typeof target[prop] === 'function')) return over[prop]; + const v = target[prop]; + return typeof v === 'function' ? v.bind(target) : v; + }, + }); +} + +// The bar under the editor that holds a generator's inputs: one field for +// each name the program declares, hidden when it declares none. The values +// are kept in this browser, by name. +export function inputsBar(onChange) { + let saved = {}; + try { saved = JSON.parse(localStorage.getItem(VALUES) || '{}') || {}; } catch (e) {} + const fields = el('span', { class: 'qs-inputs-fields' }); + const node = el('div', { class: 'qs-inputs', id: 'qs-inputs', hidden: true, role: 'group', 'aria-label': 'The generator’s inputs' }, + el('span', { class: 'qs-inputs-label', text: 'A generator, read at' }), fields, + el('span', { class: 'qs-note', text: 'one circuit of its family; the Workbench checks it at every size.' })); + let names = []; + const values = () => Object.fromEntries(names.map((n) => [n, Number.isFinite(saved[n]) ? saved[n] : 4])); + function sync(qasm) { + const now = declared(qasm); + if (now.join(',') === names.join(',')) return; + names = now; + node.hidden = names.length === 0; + fields.replaceChildren(...names.map((n) => { + const input = el('input', { class: 'qs-input qs-small', type: 'number', min: '0', max: '4096', step: '1', id: `qs-input-${n}`, value: String(values()[n]), 'aria-label': `the value of ${n}` }); + input.addEventListener('change', () => { + const v = Math.max(0, Math.min(4096, Math.round(Number(input.value) || 0))); + input.value = String(v); + saved[n] = v; + try { localStorage.setItem(VALUES, JSON.stringify(saved)); } catch (e) {} + onChange(); + }); + return el('label', { class: 'qs-inputs-one' }, el('span', { text: `${n} =` }), input); + })); + } + return { node, values, sync }; +} diff --git a/wai/quantum-studio/src/main.js b/wai/quantum-studio/src/main.js index 4c643c8de..9216154ff 100644 --- a/wai/quantum-studio/src/main.js +++ b/wai/quantum-studio/src/main.js @@ -13,7 +13,7 @@ // data-service="…/quantum-jobs"> // 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 { EditorState, Compartment, StateEffect } 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'; @@ -29,6 +29,8 @@ import { Assistant, HttpModel, DesktopModel, STANCES } from './assistant.js'; import { el, sameness } from './dom.js'; import { critiquePanel } from './critique-view.js'; import { galleryPane } from './gallery.js'; +import { workbenchPane } from './workbench.js'; +import { declared, inputsBar, withInputs } from './generators.js'; import { shortcutsPanel } from './shortcuts-view.js'; import * as Predict from './predict.js'; @@ -56,8 +58,13 @@ 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; + const native = desktop ? new DesktopEngine() : new TabEngine(root.dataset.enginePkg, root.dataset.worker || new URL('worker.js', import.meta.url).href); + // A generator (a program with integer inputs) is read at the inputs bar's + // values: the engine here writes it out, for a run here or on the service. + const inputsChanged = StateEffect.define(); + const inputs = inputsBar(() => { view.dispatch({ effects: inputsChanged.of(null) }); forceLinting(view); edited(); }); + const local = withInputs(native, inputs.values); + const service = root.dataset.service ? withInputs(new ServiceEngine(root.dataset.service), inputs.values, native) : null; let where = 'local'; let current = null; // {job, where, took} let running = null; @@ -108,7 +115,7 @@ function mount(root) { 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); + shows, editorHost, inputs.node, jobBox); // ---- the circuit ---- const circuit = el('pre', { class: 'qs-circuit', id: 'qs-circuit', tabindex: '0', 'aria-label': 'The circuit, drawn as text', hidden: true }); @@ -150,6 +157,15 @@ function mount(root) { apply: (qasm, note) => { setText(qasm); shows.textContent = note; setStatus('Applied the alternative. Undo brings the draft back.', 'ok'); }, }); + // ---- the workbench ---- + const workbench = workbenchPane({ + engine: native, + draft: () => text(), + model: () => (aiConnected ? assistantFor() : null), + apply: (qasm, note) => { setText(qasm); shows.textContent = note; setStatus('Opened the generator. Undo brings the draft back.', 'ok'); }, + save: async (name, body) => (desktop ? (await local.saveProgram(name, body)).path : (download(name, body, 'application/json'), name)), + }); + // ---- 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.' })); @@ -214,7 +230,7 @@ function mount(root) { 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, gallery.node, resultPane, aiPane, historyPane)), footer); + root.replaceChildren(header, el('div', { class: 'qs-grid' }, program, el('div', { class: 'qs-side' }, circuitPane, gallery.node, workbench.node, resultPane, aiPane, historyPane)), footer); // ---- the editor ---- const style = HighlightStyle.define([ @@ -233,7 +249,8 @@ function mount(root) { 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' : ''}`; + const at = declared(text).length ? ` at ${Object.entries(inputs.values()).map(([n, x]) => `${n} = ${x}`).join(', ')}` : ''; + lintBadge.textContent = `${v.qubits} qubits · ${v.gates} gates${at}${v.dynamic_steps != null ? ' · measures in the middle' : ''}`; lintBadge.className = 'qs-badge ok'; badSince = null; clearTimeout(stuckTimer); if (!refusals) stuck.hidden = true; @@ -261,7 +278,8 @@ function mount(root) { extensions: [ lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), history(), drawSelection(), indentOnInput(), bracketMatching(), closeBrackets(), highlightSelectionMatches(), openqasm, syntaxHighlighting(style), autocompletion({ override: [complete] }), - linter(lintAt, { delay: 300 }), lintGutter(), theme, + // A new value for a generator's input re-reads the program, as an edit does. + linter(lintAt, { delay: 300, needsRefresh: (u) => u.transactions.some((tr) => tr.effects.some((e) => e.is(inputsChanged))) }), 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]), @@ -298,6 +316,7 @@ function mount(root) { let drawTimer = null; function edited() { shows.textContent = ''; + inputs.sync(text()); clearTimeout(drawTimer); drawTimer = setTimeout(async () => { const src = text(); diff --git a/wai/quantum-studio/src/studio.css b/wai/quantum-studio/src/studio.css index 7242c6229..ea24a33c6 100644 --- a/wai/quantum-studio/src/studio.css +++ b/wai/quantum-studio/src/studio.css @@ -181,6 +181,42 @@ .qs-cand-facts { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .qs-cand-src summary { cursor: pointer; font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-muted); } .qs-cand-src pre { margin-top: 4px; max-height: 220px; } +/* A generator's inputs, under the editor. */ +.qs-inputs { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 6px 12px; border-top: 1px solid var(--border); font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); } +.qs-inputs[hidden] { display: none; } +.qs-inputs-fields { display: inline-flex; flex-wrap: wrap; gap: 8px; } +.qs-inputs-one { display: inline-flex; align-items: center; gap: 4px; color: var(--text-primary); } +.qs-inputs-one .qs-input { width: 72px; min-height: 26px; } +.qs-inputs .qs-note { margin: 0; } +/* The Workbench: the specification, the archive's map, an entry, the log. */ +.qs-wb-spec { padding: 8px 12px 0; } +.qs-wb-spec summary { cursor: pointer; font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); } +.qs-wb-form { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; } +.qs-wb-form > label { display: grid; grid-template-columns: 9.5em minmax(0, 1fr); align-items: center; gap: 8px; font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); } +.qs-wb-form > label:last-child { grid-template-columns: 9.5em auto auto auto; justify-content: start; } +.qs-wb-cost { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); } +.qs-wb-cost > span { flex-basis: 100%; } +.qs-wb-cost label { display: inline-flex; align-items: center; gap: 4px; } +.qs-wb-w { width: 64px; min-height: 26px; } +.qs-wb-acts { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px 0; } +.qs-wb-acts[hidden] { display: none; } +.qs-wb > .qs-note { padding: 6px 12px 0; margin: 0; font-variant-numeric: tabular-nums; } +.qs-wb-mapbox { padding: 6px 12px 0; } +.qs-wb-mapbox:empty, .qs-wb-entry:empty, .qs-wb-log:empty { display: none; } +.qs-atlas .qs-wb-best .qs-dot { fill: var(--ok-text); } +.qs-atlas .qs-wb-model .qs-dot { fill: var(--info-text); } +.qs-atlas .qs-wb-mutate .qs-dot { fill: var(--text-muted); } +.qs-atlas .qs-sel .qs-dot, .qs-atlas .qs-sel .qs-ring { stroke: var(--text-primary); stroke-width: 2.5; } +.qs-wb-entry { display: flex; flex-direction: column; gap: 6px; margin: 8px 12px 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-base); } +.qs-wb-entry details summary { cursor: pointer; font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-muted); } +.qs-wb-entry pre { margin-top: 4px; max-height: 260px; } +.qs-wb-note { margin: 0; font-size: 12px; color: var(--text-secondary); } +.qs-wb-chain { display: flex; flex-wrap: wrap; gap: 4px; } +.qs-wb-chain .qs-chip { border: 0; cursor: pointer; } +.qs-wb-chain .qs-chip:hover, .qs-wb-chain .qs-chip:focus-visible { color: var(--text-primary); outline: 1px solid var(--accent); } +.qs-wb-log { margin: 8px 12px 12px; padding: 0 0 0 18px; display: flex; flex-direction: column; gap: 3px; font: 11px/1.4 var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); } +.qs-wb-log li.ok { color: var(--ok-text); } +.qs-wb-log li.bad { color: var(--bad-text); } /* Predict first. */ .qs-check { display: inline-flex; align-items: center; gap: 6px; font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); cursor: pointer; } .qs-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; } diff --git a/wai/quantum-studio/src/workbench.js b/wai/quantum-studio/src/workbench.js new file mode 100644 index 000000000..8d86f9702 --- /dev/null +++ b/wai/quantum-studio/src/workbench.js @@ -0,0 +1,312 @@ +// The Workbench: a family of circuits searched for its cheapest members, every +// proposal judged by the engine (engine::discover). +// +// The reader writes a generator in the editor (a program with integer inputs) +// and says what each member of its family must do (the same state from +// |0…0⟩, or the same circuit), at which sizes, with which gates and +// connectivity, and what it costs. The engine checks every proposal at every +// size, never a model's judgement, and the archive keeps the cheapest verified +// generator in every cell of two measures, with who proposed it, what it came +// from and what it tries. Proposers are the reader, the built-in blind search +// (the baseline any idea should beat) and the model, when one is connected; +// each is named on what it found. Nothing changes the editor until the reader +// opens an entry. +import { el } from './dom.js'; +import { declared } from './generators.js'; + +const SAVED = 'wai.quantum-studio.workbench'; +// A cost as the Studio shows it: a sum of weighted measures, to three places. +const num = (x) => String(Math.round(x * 1000) / 1000); +const NS = 'http://www.w3.org/2000/svg'; +const svg = (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(typeof k === 'string' ? document.createTextNode(k) : k); + return e; +}; + +// The measures a cost or an axis may name, with the words the Studio shows. +export const MEASURES = { + depth: 'depth', + two_qubit: 'two-qubit gates', + t_count: 'T gates', + gates: 'gates', + two_qubit_depth: 'two-qubit depth', + one_qubit: 'one-qubit gates', + three_qubit: 'three-qubit gates', + non_clifford: 'non-Clifford gates', + qubits: 'qubits', +}; + +// The sizes a reader writes ("n = 3, 5, 8", or "3, 5, 8" for a generator with +// one input, "n = 3, 5; m = 2" for two) as the specification's inputs, or why +// they cannot be read. +export function parseSizes(text, names) { + if (!names.length) return { inputs: {} }; + const out = {}; + const parts = text.split(';').map((p) => p.trim()).filter(Boolean); + for (const part of parts) { + const m = part.match(/^(?:([A-Za-z_]\w*)\s*=\s*)?(.+)$/); + const name = m[1] ?? (names.length === 1 ? names[0] : null); + if (!name) return { error: `name each input: ${names.map((n) => `${n} = 3, 5`).join('; ')}` }; + if (!names.includes(name)) return { error: `the program takes no input ${name}; it takes ${names.join(', ')}` }; + const values = m[2].split(/[\s,]+/).filter(Boolean).map(Number); + if (!values.length || values.some((v) => !Number.isInteger(v) || v < 0)) return { error: `${name}: whole numbers, 0 or more, as ${name} = 3, 5, 8` }; + out[name] = [...new Set(values)].sort((a, b) => a - b); + } + const missing = names.filter((n) => !out[n]); + if (missing.length) return { error: `give values for ${missing.join(', ')}` }; + return { inputs: out }; +} + +// The specification the form describes, for the program `reference`. +export function specOf({ reference, task, sizes, gates, connectivity, weights, axes }) { + const names = declared(reference); + const s = parseSizes(sizes, names); + if (s.error) return { error: s.error }; + const cost = Object.fromEntries(Object.entries(weights).filter(([, w]) => Number(w) > 0).map(([k, w]) => [k, Number(w)])); + if (!Object.keys(cost).length) return { error: 'give a weight above 0 to at least one measure of the cost' }; + const allowed = gates.split(/[\s,]+/).map((g) => g.trim().toLowerCase()).filter(Boolean); + const spec = { name: 'studio', task, reference, inputs: s.inputs, connectivity, cost, axes }; + if (allowed.length) spec.gates = allowed; + return { spec }; +} + +// The archive's elites on the two axes: a ring for the reference, a dot for +// each other cell's best, the cheapest marked. A click on a dot picks it. +export function archiveMap(summary, axes, selected, onPick) { + const W = 320, H = 190, L = 34, B = 26, T = 22, R = 12; + const cells = summary.cells ?? []; + const xs = cells.map((c) => c.axes[0]), ys = cells.map((c) => c.axes[1]); + const xmax = Math.max(1, ...xs) * 1.15, ymax = Math.max(1, ...ys) * 1.15; + const sx = (v) => L + (v / xmax) * (W - L - R), sy = (v) => H - B - (v / ymax) * (H - B - T); + const ticks = (max) => { const step = Math.max(1, Math.ceil(max / 4)); const out = []; for (let v = 0; v <= max; v += step) out.push(v); return out; }; + const g = svg('svg', { class: 'qs-atlas qs-wb-map', id: 'qs-wb-map', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': `The archive: ${cells.length} cells by ${MEASURES[axes[0]] ?? axes[0]} and ${MEASURES[axes[1]] ?? axes[1]}` }); + for (const v of ticks(xmax)) g.append(svg('line', { x1: sx(v), y1: T, x2: sx(v), y2: H - B, class: 'qs-gridline' }), svg('text', { x: sx(v), y: H - B + 13, class: 'qs-tick', 'text-anchor': 'middle' }, String(v))); + for (const v of ticks(ymax)) g.append(svg('line', { x1: L, y1: sy(v), x2: W - R, y2: sy(v), class: 'qs-gridline' }), svg('text', { x: L - 6, y: sy(v) + 3, class: 'qs-tick', 'text-anchor': 'end' }, String(v))); + g.append(svg('text', { x: W - R, y: H - 2, class: 'qs-axis', 'text-anchor': 'end' }, `${MEASURES[axes[0]] ?? axes[0]} →`), + svg('text', { x: L - 6, y: 10, class: 'qs-axis', 'text-anchor': 'start' }, `↑ ${MEASURES[axes[1]] ?? axes[1]}`)); + const best = summary.best?.id; + const kindOf = (c) => (c.proposer === 'reference' ? 'ref' : c.id === best ? 'best' : c.proposer.startsWith('model') ? 'model' : c.proposer === 'mutate' ? 'mutate' : 'person'); + // Drawn so the reference and then the best lie on top where points crowd. + const rank = { mutate: 0, model: 1, person: 1, ref: 2, best: 3 }; + for (const c of [...cells].sort((a, b) => rank[kindOf(a)] - rank[kindOf(b)])) { + const kind = kindOf(c); + const p = svg('g', { class: `qs-pt qs-wb-${kind}${c.id === selected ? ' qs-sel' : ''}`, tabindex: '0', role: 'button', 'data-id': c.id, 'aria-label': `${c.id}, by ${c.proposer}: cost ${num(c.cost)}, ${c.axes[0]} and ${c.axes[1]}` }, + svg('title', {}, `${c.id} (${c.proposer}): cost ${num(c.cost)}`), + svg('circle', { cx: sx(c.axes[0]), cy: sy(c.axes[1]), r: 8, class: 'qs-hit' }), + svg('circle', { cx: sx(c.axes[0]), cy: sy(c.axes[1]), r: kind === 'ref' ? 6 : 4.5, class: kind === 'ref' ? 'qs-ring' : 'qs-dot' })); + p.addEventListener('click', () => onPick(c.id)); + p.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPick(c.id); } }); + g.append(p); + } + return g; +} + +// One line for an outcome of a submission. +export function outcomeText(o, who) { + if (o.duplicate) return `${who}: seen before, not evaluated again`; + const e = o.evaluation; + if (!e?.verified) return `${who}: not verified: ${e?.failure ?? 'it did not read'}`; + if (o.kept && o.improvement) return `${who}: kept as ${o.kept}, cheaper than every generator before it (cost ${num(e.cost)})`; + if (o.kept) return `${who}: kept as ${o.kept}, the best of its cell (cost ${num(e.cost)})`; + return `${who}: verified (cost ${num(e.cost)}), but its cell already holds one as cheap`; +} + +// `save(name, text)` writes a file where the reader keeps them and gives back +// where it went. +export function workbenchPane({ engine, draft, model, apply, save }) { + const startBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-start', type: 'button', text: 'Start', title: 'A new archive, seeded with the program in the editor as the reference' }); + const task = el('select', { class: 'qs-input qs-small', id: 'qs-wb-task', 'aria-label': 'What each member must do' }, + el('option', { value: 'prepares', text: 'prepare the same state from |0…0⟩' }), el('option', { value: 'equivalent', text: 'be the same circuit (unitary)' })); + const sizes = el('input', { class: 'qs-input', id: 'qs-wb-sizes', value: 'n = 3, 5, 8', autocomplete: 'off', 'aria-label': 'The sizes to check at' }); + const gates = el('input', { class: 'qs-input', id: 'qs-wb-gates', placeholder: 'any', value: 'h, cx', autocomplete: 'off', 'aria-label': 'The gates a proposal may use' }); + const conn = el('select', { class: 'qs-input qs-small', id: 'qs-wb-conn', 'aria-label': 'Which qubits a gate may join' }, + el('option', { value: 'all', text: 'any pair' }), el('option', { value: 'line', text: 'neighbours on a line' })); + const weight = (k, v) => el('input', { class: 'qs-input qs-small qs-wb-w', type: 'number', min: '0', step: '0.1', value: String(v), id: `qs-wb-w-${k}`, 'aria-label': `the cost's weight on ${MEASURES[k]}` }); + const weights = { depth: weight('depth', 1), two_qubit: weight('two_qubit', 0.1), t_count: weight('t_count', 0), gates: weight('gates', 0) }; + const axis = (id, chosen) => el('select', { class: 'qs-input qs-small', id, 'aria-label': id.endsWith('x') ? 'The map’s first axis' : 'The map’s second axis' }, + ...Object.entries(MEASURES).map(([k, w]) => el('option', { value: k, text: w, selected: k === chosen }))); + const ax = axis('qs-wb-ax', 'two_qubit'), ay = axis('qs-wb-ay', 'depth'); + const form = el('details', { class: 'qs-wb-spec', id: 'qs-wb-spec', open: true }, el('summary', { text: 'The specification' }), + el('div', { class: 'qs-wb-form' }, + el('label', {}, el('span', { text: 'Each member must' }), task), + el('label', {}, el('span', { text: 'checked at' }), sizes), + el('label', {}, el('span', { text: 'using' }), gates), + el('label', {}, el('span', { text: 'on' }), conn), + el('div', { class: 'qs-wb-cost' }, el('span', { text: 'Cost, summed over the sizes:' }), + ...Object.entries(weights).map(([k, i]) => el('label', {}, i, el('span', { text: `× ${MEASURES[k]}` })))), + el('label', {}, el('span', { text: 'Map' }), ax, el('span', { text: 'against' }), ay))); + const searchBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-search', type: 'button', text: 'Search ×100', title: 'The built-in, blind search: a hundred seeded edits of the archive’s best, each checked by the engine' }); + const askBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-ask', type: 'button', text: 'Ask the model ×3', title: 'Three generators from the model, from the archive’s best; the engine judges each' }); + const submitBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-submit', type: 'button', text: 'Submit the editor’s program', title: 'The program in the editor, judged and kept if it is the best of its cell' }); + const saveBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-save', type: 'button', text: 'Download', title: 'The specification and its archive, every kept generator with its sealed verdict, for wai-quantum discover verify' }); + const acts = el('div', { class: 'qs-wb-acts', hidden: true }, searchBtn, askBtn, submitBtn, saveBtn); + const stats = el('p', { class: 'qs-note', id: 'qs-wb-stats', text: 'Write a generator in the editor (a program with input uint n;), say what each member of its family must do and what it costs, and start. A program with no inputs works too: one circuit, one size.' }); + const mapBox = el('div', { class: 'qs-wb-mapbox', id: 'qs-wb-mapbox' }); + const entry = el('div', { class: 'qs-wb-entry', id: 'qs-wb-entry' }); + const log = el('ol', { class: 'qs-wb-log', id: 'qs-wb-log', 'aria-live': 'polite' }); + const node = el('section', { class: 'qs-pane qs-wb', 'aria-labelledby': 'qs-wb-h' }, + el('div', { class: 'qs-head' }, el('h2', { id: 'qs-wb-h', text: 'Workbench' }), el('span', { class: 'qs-grow' }), startBtn), + form, acts, stats, mapBox, entry, log); + + let spec = null, archive = null, summary = null, selected = null, openedFrom = null, busy = false; + const say = (text, cls = '') => { log.prepend(el('li', { class: cls, text })); while (log.children.length > 12) log.lastChild.remove(); }; + const persist = () => { try { localStorage.setItem(SAVED, JSON.stringify({ spec, archive })); } catch (e) {} }; + const setBusy = (b) => { busy = b; for (const x of [startBtn, searchBtn, askBtn, submitBtn]) x.disabled = b; }; + const entryById = (id) => archive?.entries.find((e) => e.id === id); + + function render() { + if (!summary) return; + const st = summary.stats; + const energy = st.joules != null ? `, ${st.joules.toFixed(2)} J` : ''; + stats.textContent = `${st.evaluations} evaluated, ${st.verified} verified, ${st.kept} kept, ${st.improvements} improvement${st.improvements === 1 ? '' : 's'}${energy}. ` + + `Best ${summary.best.id}: cost ${num(summary.best.cost)} against the reference’s ${num(summary.reference.cost)}, by ${summary.best.proposer}, checked by ${summary.best.strength}.`; + mapBox.replaceChildren(archiveMap(summary, spec.axes, selected, pick)); + acts.hidden = false; + if (selected) pick(selected); + } + + // The form, showing the specification an archive was built for. + function fill(sp) { + task.value = sp.task; + sizes.value = Object.entries(sp.inputs ?? {}).map(([n, vs]) => `${n} = ${vs.join(', ')}`).join('; '); + gates.value = (sp.gates ?? []).join(', '); + conn.value = sp.connectivity ?? 'all'; + for (const [k, i] of Object.entries(weights)) i.value = String(sp.cost?.[k] ?? 0); + [ax.value, ay.value] = sp.axes; + } + + function lineage(id) { + const out = []; + for (let e = entryById(id); e && out.length < 20; e = e.parent ? entryById(e.parent) : null) out.push(e); + return out; + } + + function pick(id) { + const e = entryById(id); + if (!e) return; + selected = id; + for (const p of mapBox.querySelectorAll('.qs-pt')) p.classList.toggle('qs-sel', p.dataset.id === id); + const open = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-open', type: 'button', text: 'Open in editor' }); + open.addEventListener('click', () => { openedFrom = id; apply(e.source, `Opened ${id} from the Workbench (${e.proposer}).`); }); + const chain = lineage(id).map((x, i) => el('button', { class: 'qs-chip', type: 'button', text: `${i ? '← ' : ''}${x.id} ${x.proposer}`, title: x.note ?? '', onclick: () => pick(x.id) })); + entry.replaceChildren(...[ + el('div', { class: 'qs-cand-head' }, el('span', { class: 'qs-cand-title', text: `${e.id} · cost ${num(e.cost)} · ${MEASURES[spec.axes[0]]} ${e.axes[0]}, ${MEASURES[spec.axes[1]]} ${e.axes[1]}` }), + el('span', { class: 'qs-badge ok', text: `checked by ${e.strength}${e.sealed ? ', sealed' : ''}` }), el('span', { class: 'qs-grow' }), open), + e.note ? el('p', { class: 'qs-wb-note', text: e.note }) : null, + el('div', { class: 'qs-wb-chain', 'aria-label': 'Where it came from' }, ...chain), + el('details', {}, el('summary', { text: 'Its generator' }), el('pre', { class: 'qs-ai-code', text: e.source })), + ].filter(Boolean)); + } + + async function start() { + if (busy) return; + const reference = draft(); + const made = specOf({ reference, task: task.value, sizes: sizes.value, gates: gates.value, connectivity: conn.value, weights: Object.fromEntries(Object.entries(weights).map(([k, i]) => [k, i.value])), axes: [ax.value, ay.value] }); + if (made.error) { stats.textContent = made.error; return; } + setBusy(true); + stats.textContent = 'The engine is checking the reference at every size…'; + try { + const r = await engine.discoverStart(made.spec); + ({ archive, summary } = r); + spec = made.spec; + selected = null; openedFrom = null; + log.replaceChildren(); + say(`Started: the reference is ${summary.reference.id}, cost ${num(summary.reference.cost)}.`); + form.open = false; + persist(); + render(); + } catch (e) { stats.textContent = e.message.charAt(0).toUpperCase() + e.message.slice(1); } + finally { setBusy(false); } + } + + async function submit(source, parent, proposer, note) { + const r = await engine.discoverSubmit(spec, archive, source, parent, proposer, note); + ({ archive, summary } = r); + return r.outcome; + } + + async function searchMore() { + if (busy || !spec) return; + setBusy(true); + const total = 100, chunk = 25; + const before = summary.stats.kept; + try { + for (let done = 0; done < total; done += chunk) { + stats.textContent = `The blind search: ${done} of ${total} edits judged…`; + // A fresh seed for every step, so a search taken up again does not replay. + const r = await engine.discoverSearch(spec, archive, archive.stats.evaluations + archive.stats.duplicates + 1, chunk); + ({ archive, summary } = r); + } + say(`The blind search: ${total} edits judged, ${summary.stats.kept - before} kept.`); + persist(); + } catch (e) { say(`The search stopped: ${e.message}`, 'bad'); } + finally { setBusy(false); render(); } + } + + async function ask() { + if (busy || !spec) return; + const m = model(); + if (!m) { say('Ask the model needs one: connect it under “Model…” in the Assistant.', 'bad'); return; } + setBusy(true); + stats.textContent = 'The model is proposing; the engine will judge each…'; + try { + const shown = Object.values(archive.cells).map((i) => archive.entries[i]).sort((a, b) => a.cost - b.cost).slice(0, 3); + const ideas = await m.propose(spec, shown, 3); + if (!ideas.length) say('The model wrote no generator in a ```qasm block.', 'bad'); + for (const p of ideas) { + const o = await submit(p.qasm, shown[0]?.id ?? null, `model: ${m.model?.name || 'the model'}`, p.idea || null); + say(outcomeText(o, p.idea || 'an idea'), o.kept ? 'ok' : o.evaluation?.verified ? '' : 'bad'); + } + persist(); + } catch (e) { say(`The model did not answer: ${e.message}`, 'bad'); } + finally { setBusy(false); render(); } + } + + async function submitDraft() { + if (busy || !spec) return; + setBusy(true); + try { + const src = draft(); + const parent = openedFrom && entryById(openedFrom)?.source !== src ? openedFrom : null; + const o = await submit(src, parent, 'person', null); + say(outcomeText(o, 'Your program'), o.kept ? 'ok' : o.evaluation?.verified ? '' : 'bad'); + persist(); + } catch (e) { say(`It could not be judged: ${e.message}`, 'bad'); } + finally { setBusy(false); render(); } + } + + // The specification and its archive, as the command line keeps them: + // `wai-quantum discover verify NAME.json` reads NAME.archive.json beside it. + async function download() { + if (!archive) return; + const stem = `workbench-${archive.spec.slice(0, 8)}-${archive.stats.evaluations}`; + try { + const where = await save(`${stem}.json`, JSON.stringify(spec, null, 2) + '\n'); + await save(`${stem}.archive.json`, JSON.stringify(archive, null, 2) + '\n'); + say(`Saved ${where} and its archive; wai-quantum discover verify ${stem}.json checks every seal.`, 'ok'); + } catch (e) { say(`It could not be saved: ${e.message}`, 'bad'); } + } + + // The last archive comes back with the page, if the engine still reads it + // for its specification (a search of no evaluations changes nothing). + try { + const was = JSON.parse(localStorage.getItem(SAVED) || 'null'); + if (was?.spec && was?.archive) { + engine.discoverSearch(was.spec, was.archive, 1, 0).then((r) => { + if (spec) return; + spec = was.spec; ({ archive, summary } = r); fill(spec); form.open = false; + say(`Taken up again: ${summary.stats.evaluations} evaluated so far.`); + render(); + }).catch(() => {}); + } + } catch (e) {} + + startBtn.addEventListener('click', start); + searchBtn.addEventListener('click', searchMore); + askBtn.addEventListener('click', ask); + submitBtn.addEventListener('click', submitDraft); + saveBtn.addEventListener('click', download); + return { node, start, searchMore, ask, submitDraft }; +} diff --git a/wai/quantum-studio/src/worker.js b/wai/quantum-studio/src/worker.js index 67513a698..da5231a54 100644 --- a/wai/quantum-studio/src/worker.js +++ b/wai/quantum-studio/src/worker.js @@ -41,11 +41,18 @@ self.onmessage = async (ev) => { case 'equivalent': text = mod.engine_equivalent(ev.data.a, ev.data.b); break; case 'variants': text = mod.engine_variants(ev.data.qasm); break; case 'shortcuts': text = mod.engine_shortcuts(ev.data.qasm, !!ev.data.thorough); break; + case 'lint_with': text = mod.engine_lint_with(ev.data.qasm, JSON.stringify(ev.data.inputs ?? {})); break; + case 'critique_with': text = mod.engine_critique_with(ev.data.qasm, JSON.stringify(ev.data.inputs ?? {})); break; + case 'instantiate': text = mod.engine_instantiate(ev.data.qasm, JSON.stringify(ev.data.inputs ?? {})); break; + case 'discover_evaluate': text = mod.engine_discover_evaluate(JSON.stringify(ev.data.spec), ev.data.source); break; + case 'discover_start': text = e().discover_start(JSON.stringify(ev.data.spec)); break; + case 'discover_submit': text = e().discover_submit(JSON.stringify(ev.data.spec), JSON.stringify(ev.data.archive), ev.data.source, ev.data.parent ?? undefined, ev.data.proposer, ev.data.note ?? undefined); break; + case 'discover_search': text = e().discover_search(JSON.stringify(ev.data.spec), JSON.stringify(ev.data.archive), ev.data.from >>> 0, ev.data.evaluations >>> 0); break; default: throw new Error(`no operation ${op}`); } const value = JSON.parse(text); // A lint or a critique of a program that does not read is an answer that names the problem. - if (value && value.error && op !== 'lint' && op !== 'critique') self.postMessage({ id, ok: false, error: value.error }); + if (value && value.error && !['lint', 'critique', 'lint_with', 'critique_with'].includes(op)) 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 index 5b128e079..d3e66311b 100644 --- a/wai/quantum-studio/test/studio.test.mjs +++ b/wai/quantum-studio/test/studio.test.mjs @@ -29,6 +29,12 @@ // and never asking the model itself; Improve and Explore with the model, each candidate // read, measured and compared by the engine, a rewrite that is not the same circuit // left out with its reason; +// - generators: a program with an integer input shows it under the editor, is linted and +// drawn at its value, and runs there; the Workbench starts from it, judges a hundred +// blind edits, keeps the reader's log-depth tree as the cheapest, opens it in the +// editor, and comes back with the page; with the model, its generators are judged at +// every size, a wrong one and one with a gate the specification forbids left out, and +// it is told the specification and shown the cheapest found; // - 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'; @@ -256,6 +262,74 @@ const chip = (k) => page.$eval(`#qs-measures [data-k=${k}]`, (c) => c.textConten check(/you said00 in 50%/.test(pred) && /the run00 in \d/.test(pred) && /(came out on top|Another outcome)/.test(pred) && /first prediction/.test(pred), `the prediction is set against the counts (${pred})`); await page.uncheck('#qs-predict-first'); } +// Generators: a program with integer inputs, read at the inputs bar's values. The +// Workbench checks a family at every size and keeps the cheapest of each cell. +const CHAIN = 'OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [0:n-2] {\n cx q[i], q[i + 1];\n}\n'; +const TREE = 'OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint k in [0:n] {\n for uint i in [0:2**k - 1] {\n if (2**k < n && i + 2**k < n) {\n cx q[i], q[i + 2**k];\n }\n }\n}\n'; +const lintSays = (re) => page.waitForFunction((re) => new RegExp(re).test(document.querySelector('#qs-lint')?.textContent ?? ''), re.source, { timeout: LONG }); +const wbLog = () => page.$$eval('#qs-wb-log li', (ls) => ls.map((l) => l.textContent)); +const wbIdle = () => page.waitForFunction(() => !document.querySelector('#qs-wb-start').disabled, null, { timeout: LONG }); +{ + await setProgram(CHAIN.replace('qubit[n] q;\n', 'qubit[n] q;\nbit[n] c;\n') + 'c = measure q;\n'); + await page.waitForSelector('#qs-inputs:not([hidden]) #qs-input-n', { timeout: LONG }); + await lintSays(/4 qubits · 4 gates at n = 4/); + check(true, 'a generator shows its input under the editor, and the lint reads it at n = 4'); + await page.fill('#qs-input-n', '3'); + await page.dispatchEvent('#qs-input-n', 'change'); + await lintSays(/3 qubits · 3 gates at n = 3/); + await page.waitForFunction(() => /^The circuit: 3 qubits/.test(document.querySelector('#qs-diagram svg')?.getAttribute('aria-label') ?? ''), null, { timeout: LONG }); + check(true, 'a new value re-reads it: linted and drawn at n = 3'); + await page.click('#qs-run'); + await done(page); + const got = await counts(page); + check(got.length === 2 && got.every(([k]) => k === '000' || k === '111'), `it runs at its value: a GHZ state on 3 qubits (${JSON.stringify(got)})`); + await page.fill('#qs-input-n', '4'); + await page.dispatchEvent('#qs-input-n', 'change'); + + await setProgram(CHAIN); + await page.click('#qs-wb-start'); + await page.waitForFunction(() => /^1 evaluated/.test(document.querySelector('#qs-wb-stats')?.textContent ?? ''), null, { timeout: LONG }); + const st = await page.textContent('#qs-wb-stats'); + check(/Best e0: cost 17\.3 against the reference’s 17\.3, by reference/.test(st) && (await page.$$('#qs-wb-map .qs-ring')).length === 1, + `Start checks the reference at n = 3, 5 and 8 and seeds the archive with it (${st})`); + await page.click('#qs-wb-search'); + await page.waitForFunction(() => /The blind search: 100 edits judged/.test(document.querySelector('#qs-wb-log')?.textContent ?? ''), null, { timeout: LONG }); + await wbIdle(); + const after = await page.textContent('#qs-wb-stats'); + check(/^101 evaluated/.test(after), `the blind search judges a hundred edits, each by the engine (${after})`); + await setProgram(TREE); + await page.click('#qs-wb-submit'); + await page.waitForFunction(() => /^Your program: /.test(document.querySelector('#qs-wb-log li')?.textContent ?? ''), null, { timeout: LONG }); + await wbIdle(); + const said = (await wbLog())[0]; + check(/^Your program: kept as e\d+, cheaper than every generator before it \(cost 12\.3\)/.test(said) && /by person/.test(await page.textContent('#qs-wb-stats')), + `the reader's log-depth tree is kept as the cheapest, and named as theirs (${said})`); + await page.click('#qs-wb-map .qs-wb-best'); + await page.waitForSelector('#qs-wb-open', { timeout: LONG }); + const card = await page.textContent('#qs-wb-entry'); + check(/cost 12\.3/.test(card) && /checked by \w+, sealed/.test(card) && /person/.test(card) && !/null/.test(card), `an entry shows its cost, how it was checked, its seal and who wrote it (${card.slice(0, 140)})`); + await setProgram(CHAIN); + await page.click('#qs-wb-open'); + check((await doc()) === TREE, 'Open in editor puts the kept generator in the editor'); + await page.click('#qs-wb-ask'); + check(/needs one/.test((await wbLog())[0]), 'Ask the model, with none connected, says it needs one'); + const files = []; + page.on('download', (d) => files.push(d)); + await page.click('#qs-wb-save'); + await page.waitForFunction(() => /^Saved workbench-/.test(document.querySelector('#qs-wb-log li')?.textContent ?? ''), null, { timeout: LONG }); + await page.waitForTimeout(500); + const read = async (d) => JSON.parse(readFileSync(await d.path(), 'utf8')); + const [specFile, archiveFile] = [files.find((d) => !/archive/.test(d.suggestedFilename())), files.find((d) => /\.archive\.json$/.test(d.suggestedFilename()))]; + const savedSpec = specFile && await read(specFile), savedArchive = archiveFile && await read(archiveFile); + check(savedSpec?.reference === CHAIN && savedArchive?.spec?.length === 64 && savedArchive.entries.length >= 2 && savedArchive.entries.every((e) => e.sealed?.receipt) + && archiveFile.suggestedFilename() === specFile.suggestedFilename().replace(/\.json$/, '.archive.json'), + `Download saves the specification and its archive side by side, every entry sealed, as the command line reads them (${files.map((d) => d.suggestedFilename()).join(', ')})`); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => /Taken up again: 102 evaluated/.test(document.querySelector('#qs-wb-log')?.textContent ?? ''), null, { timeout: LONG }); + check(/by person/.test(await page.textContent('#qs-wb-stats')) && (await page.inputValue('#qs-wb-sizes')) === 'n = 3, 5, 8', 'the archive and its specification come back with the page'); + await lintSays(/qubits/); +} + // 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. @@ -275,7 +349,9 @@ const chip = (k) => page.$eval(`#qs-measures [data-k=${k}]`, (c) => c.textConten // Alternatives' calls carry no tools: candidates, one of them wrong on purpose. if (!r.tools) { const want = msgs[1].content; - const content = /^Write \d+ programs that are the same circuit/.test(want) + const content = /^Write \d+ generators/.test(want) + ? `Idea: double the qubits that hold the state each layer.\n\`\`\`qasm\n${TREE}\`\`\`\nIdea: forget the Hadamard.\n\`\`\`qasm\n${CHAIN.replace('h q[0];\n', '')}\`\`\`\nIdea: use CZ.\n\`\`\`qasm\n${CHAIN.replace('cx q[i], q[i + 1];', 'h q[i + 1];\n cz q[i], q[i + 1];\n h q[i + 1];')}\`\`\`` + : /^Write \d+ programs that are the same circuit/.test(want) ? 'Idea: the two Hadamards cancel, and the X moves past the CNOT.\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\ncx q[0], q[1];\nx q[1];\nc = measure q;\n```\nIdea: drop the X.\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\ncx q[0], q[1];\nc = measure q;\n```' : 'Idea: a GHZ state on three qubits.\n```qasm\nOPENQASM 3.0;\nqubit[3] q;\nbit[3] c;\nh q[0];\ncx q[0], q[1];\ncx q[1], q[2];\nc = measure q;\n```\n**Idea:** entangle with CZ instead.\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\nh q[1];\ncz q[0], q[1];\nh q[1];\nc = measure q;\n```'; res.writeHead(200, { ...cors, 'content-type': 'application/json' }).end(JSON.stringify({ choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }] })); @@ -339,6 +415,23 @@ const chip = (k) => page.$eval(`#qs-measures [data-k=${k}]`, (c) => c.textConten check(ideas.length === 2 && ideas.some((t) => /3 qubits/.test(t) && /a different circuit/.test(t)) && ideas.some((t) => /entangle with CZ instead/.test(t)) && muse.some((m) => /toward this aim: another entangler/.test(m)), `Explore: the model's ideas, each read and measured by the engine (${ideas.map((t) => t.slice(0, 60)).join(' | ')})`); check((await doc()) === DRAFT, 'neither changes the program until one is applied'); + // The Workbench asks the model for generators, and the engine judges each at every size. + await setProgram(CHAIN); + await page.click('#qs-wb-start'); + await page.waitForFunction(() => /^1 evaluated/.test(document.querySelector('#qs-wb-stats')?.textContent ?? ''), null, { timeout: LONG }); + await page.click('#qs-wb-ask'); + await page.waitForFunction(() => /^use CZ: /.test(document.querySelector('#qs-wb-log li')?.textContent ?? ''), null, { timeout: LONG }); + await wbIdle(); + const wlog = await wbLog(); + check(wlog.some((t) => /^double the qubits that hold the state each layer: kept as e\d+, cheaper than every generator before it/.test(t)) + && wlog.some((t) => /^forget the Hadamard: not verified: at n = 3: different/.test(t)) + && wlog.some((t) => /^use CZ: not verified: at n = 3: it uses `cz`, which the specification does not allow/.test(t)), + `the model's generators, each judged at every size: one kept, a wrong one and a forbidden gate left out (${wlog.slice(0, 3).join(' | ')})`); + const brief = asked.filter((r) => !r.tools).map((r) => r.messages[1].content).find((m) => /^Write 3 generators/.test(m)) ?? ''; + check(/prepare the same state from \|0…0⟩/.test(brief) && /n = 3, 5, 8/.test(brief) && /only these gates: h, cx/.test(brief) && brief.includes('cx q[i], q[i + 1];') && /The cheapest found: cost 17\.3, the reference\./.test(brief), + 'the model is told the specification and shown the cheapest found, with the cost the engine measured'); + check(/by model: scripted/.test(await page.textContent('#qs-wb-stats')), 'the best is the model’s, and named as its'); + await setProgram(DRAFT); // The candidates come back within the editor's redraw delay: wait for the draft's diagram. await page.waitForFunction(() => /4 gates/.test(document.querySelector('#qs-lint')?.textContent ?? '') && document.querySelectorAll('#qs-diagram .qc-flag').length === 2, null, { timeout: LONG }); check(!/null/.test(await page.textContent('#qs-alt-body')) && !/null/.test(await page.textContent('#qs-measures')), 'no empty part is written out as “null”'); diff --git a/wai/wai-quantum-desktop/src/main.rs b/wai/wai-quantum-desktop/src/main.rs index 54d7e6e2d..b090da11f 100644 --- a/wai/wai-quantum-desktop/src/main.rs +++ b/wai/wai-quantum-desktop/src/main.rs @@ -25,7 +25,8 @@ 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, critique, draw, equiv, job, shortcuts}; +use wai_quantum::engine::discover::{Archive, Budget, Evaluator, SearchOptions, Spec, search}; +use wai_quantum::engine::{catalog, critique, draw, energy, equiv, job, shortcuts}; /// What a result says of its energy here. const UNMETERED: &str = "the desktop app does not meter a run's energy"; @@ -130,14 +131,19 @@ 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() +/// A generator's inputs, when the page gives them. +fn inputs_of(inputs: Option) -> Result { + inputs.map(|t| serde_json::from_str(&t).map_err(|e| format!("inputs: {e}"))).unwrap_or_else(|| Ok(Default::default())) } #[tauri::command] -fn critique(qasm: String) -> String { - critique::critique(&qasm).to_string() +fn lint(qasm: String, inputs: Option) -> String { + inputs_of(inputs).map(|ins| job::lint_with(&qasm, &ins).to_string()).unwrap_or_else(|e| json!({ "ok": false, "error": e }).to_string()) +} + +#[tauri::command] +fn critique(qasm: String, inputs: Option) -> String { + inputs_of(inputs).map(|ins| critique::critique_with(&qasm, &ins).to_string()).unwrap_or_else(|e| json!({ "ok": false, "error": e }).to_string()) } #[tauri::command] @@ -156,6 +162,81 @@ async fn shortcuts(qasm: String, thorough: bool) -> String { tauri::async_runtime::spawn_blocking(move || shortcuts::shortcuts(&qasm, thorough).to_string()).await.unwrap_or_else(|e| error_json(e.to_string())) } +/// A workbench specification from its JSON, ready to judge candidates. +fn evaluator(spec: &str) -> Result { + let v: Value = serde_json::from_str(spec).map_err(|e| format!("the specification is not JSON: {e}"))?; + Evaluator::new(Spec::from_json(&v)?) +} + +/// An archive from its JSON, which must have been built for `ev`'s specification. +fn read_archive(ev: &Evaluator, text: &str) -> Result { + let a: Archive = serde_json::from_str(text).map_err(|e| format!("the archive is not one: {e}"))?; + if !a.is_for(&ev.spec) { + return Err("this archive was built for another specification (or this one changed); start a new one".into()); + } + Ok(a) +} + +/// The workbench's answer: the archive, its summary, and what else was asked. +fn workbench(ev: &Evaluator, a: &Archive, extra: Value) -> String { + let mut v = json!({ "archive": a, "summary": a.summary(&ev.spec) }); + if let (Some(o), Some(x)) = (v.as_object_mut(), extra.as_object()) { + o.extend(x.clone()); + } + v.to_string() +} + +/// An archive for a specification, seeded with its reference and sealed with +/// the app's key. +#[tauri::command] +fn discover_start(studio: tauri::State<'_, Studio>, spec: String) -> String { + evaluator(&spec).and_then(|ev| Archive::new_sealed(&ev, Some(&studio.key)).map(|a| workbench(&ev, &a, json!({})))).unwrap_or_else(error_json) +} + +fn submit_in(key: &SigningKey, spec: &str, archive: &str, source: &str, parent: Option<&str>, proposer: &str, note: Option<&str>) -> Result { + let ev = evaluator(spec)?; + let mut a = read_archive(&ev, archive)?; + let out = a.submit_sealed(&ev, source, parent, proposer, note, Some(key)); + Ok(workbench(&ev, &a, json!({ "outcome": out }))) +} + +/// A generator judged, and kept if it is the cheapest of its cell. +#[tauri::command] +fn discover_submit(studio: tauri::State<'_, Studio>, spec: String, archive: String, source: String, parent: Option, proposer: String, note: Option) -> String { + submit_in(&studio.key, &spec, &archive, &source, parent.as_deref(), &proposer, note.as_deref()).unwrap_or_else(error_json) +} + +fn search_in(key: SigningKey, spec: &str, archive: &str, seed: u64, evaluations: u64) -> Result { + let mut ev = evaluator(spec)?; + ev.spec.budget = Budget { evaluations: Some(evaluations), seconds: None, joules: None }; + let mut a = read_archive(&ev, archive)?; + let opts = SearchOptions { seed, max_edits: 2, key: Some(key) }; + let (t0, e0) = (std::time::Instant::now(), energy::own()); + let mut kept = Vec::new(); + search(&ev, &mut a, &opts, &|| t0.elapsed().as_secs_f64(), &|| energy::own().zip(e0).map(|(now, then)| now.joules() - then.joules()), &mut |e| kept.push(e.id.clone())); + Ok(workbench(&ev, &a, json!({ "kept": kept }))) +} + +/// More candidates from the built-in search, off the window's thread; this +/// computer reads the seconds and, on macOS, the joules they took. +#[tauri::command] +async fn discover_search(studio: tauri::State<'_, Studio>, spec: String, archive: String, seed: u64, evaluations: u64) -> Result { + let key = studio.key.clone(); + Ok(tauri::async_runtime::spawn_blocking(move || search_in(key, &spec, &archive, seed, evaluations).unwrap_or_else(error_json)).await.unwrap_or_else(|e| error_json(e.to_string()))) +} + +/// The engine's verdict on one generator against a specification. +#[tauri::command] +fn discover_evaluate(spec: String, source: String) -> String { + evaluator(&spec).map(|ev| serde_json::to_string(&ev.evaluate(&source)).unwrap_or_else(error_json)).unwrap_or_else(error_json) +} + +/// A generator written out at its inputs as plain OpenQASM. +#[tauri::command] +fn instantiate(qasm: String, inputs: String) -> String { + inputs_of(Some(inputs)).and_then(|ins| wai_quantum::qasm::instantiate(&qasm, &ins)).map(|text| json!({ "text": text }).to_string()).unwrap_or_else(error_json) +} + #[tauri::command] fn draw(qasm: String, width: u32) -> String { match wai_quantum::qasm::parse(&qasm) { @@ -304,7 +385,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, critique, equivalent, variants, shortcuts, draw, layout, verify, signer, save_job, save_program, chat, models]) + .invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, critique, equivalent, variants, shortcuts, draw, layout, verify, signer, save_job, save_program, chat, models, discover_start, discover_submit, discover_search, discover_evaluate, instantiate]) .run(tauri::generate_context!()) .expect("the window opens"); } @@ -337,8 +418,11 @@ mod tests { #[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(); + let l: Value = serde_json::from_str(&lint("qubit[1] q;\nfoo q[0];".into(), None)).unwrap(); assert_eq!(l["line"], 2); + let family = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [0:n-2] { cx q[i], q[i + 1]; }\n"; + let g: Value = serde_json::from_str(&lint(family.into(), Some("{\"n\": 3}".into()))).unwrap(); + assert_eq!((g["qubits"].as_u64(), g["gates"].as_u64()), (Some(3), Some(3)), "{g}"); 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(); @@ -347,7 +431,7 @@ mod tests { #[test] fn critique_equivalence_and_variants_answer_as_json() { - let c: Value = serde_json::from_str(&critique("qubit[1] q;\nh q[0];\nh q[0];\n".into())).unwrap(); + let c: Value = serde_json::from_str(&critique("qubit[1] q;\nh q[0];\nh q[0];\n".into(), None)).unwrap(); assert_eq!((c["ok"].as_bool(), c["smells"][0]["kind"].as_str()), (Some(true), Some("cancels")), "{c}"); let e: Value = serde_json::from_str(&equivalent("qubit[1] q; h q[0]; h q[0];".into(), "qubit[1] q;".into())).unwrap(); assert_eq!(e["verdict"], "equivalent", "{e}"); diff --git a/wai/wai-quantum/src/engine/critique.rs b/wai/wai-quantum/src/engine/critique.rs index 8c097374c..a64260b56 100644 --- a/wai/wai-quantum/src/engine/critique.rs +++ b/wai/wai-quantum/src/engine/critique.rs @@ -101,6 +101,35 @@ pub fn critique(src: &str) -> Value { } } +/// [`critique`] for a generator read at `inputs`: its measures there, and its +/// smells with the generator's own lines. None is offered as a fix, since a +/// smell that a loop makes may span two of its passes, and an edit to the +/// loop's body changes every pass. +pub fn critique_with(src: &str, inputs: &qasm::Inputs) -> Value { + if inputs.is_empty() { + return critique(src); + } + match qasm::parse_lines_with(src, inputs) { + Ok((p, lines)) => { + let mut v = measures(&p); + v["smells"] = smells(src, &p, &lines) + .iter() + .map(|s| { + let mut j = smell_json(s); + j["fixable"] = json!(false); + j + }) + .collect(); + v["inputs"] = json!(inputs); + v + } + 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 smell_json(s: &Smell) -> Value { json!({ "kind": s.kind, "message": s.message, "lines": s.lines, "ops": s.ops, "qubits": s.qubits, "fixable": s.fix.is_some() }) } @@ -557,4 +586,17 @@ mod tests { // Nothing to fix: no candidates. assert_eq!(variants("qubit[2] q; h q[0]; cx q[0], q[1];")["candidates"], json!([])); } + + #[test] + fn a_generator_is_critiqued_at_its_inputs_with_its_own_lines_and_no_fix() { + let family = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nfor uint i in [0:n-1] {\n h q[i];\n h q[i];\n}\n"; + let ins: qasm::Inputs = [("n".to_string(), 3)].into_iter().collect(); + let v = critique_with(family, &ins); + assert_eq!((v["qubits"].as_u64(), v["gates"].as_u64()), (Some(3), Some(6)), "{v}"); + let smell = v["smells"].as_array().unwrap().iter().find(|s| s["kind"] == "cancels").expect("the cancelling pair is named"); + assert_eq!(smell["lines"], json!([5, 6])); + assert_eq!(smell["fixable"], false); + assert!(critique_with(family, &qasm::Inputs::new())["error"].as_str().unwrap().contains("give n a value")); + assert_eq!(crate::engine::job::lint_with(family, &ins)["gates"], 6); + } } diff --git a/wai/wai-quantum/src/engine/job.rs b/wai/wai-quantum/src/engine/job.rs index f26191bd6..853f6cb82 100644 --- a/wai/wai-quantum/src/engine/job.rs +++ b/wai/wai-quantum/src/engine/job.rs @@ -74,7 +74,13 @@ pub fn did_of(key_hex: &str) -> Option { /// 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) { + lint_with(src, &crate::qasm::Inputs::new()) +} + +/// [`lint`] for a generator read at `inputs`: a problem keeps the generator's +/// line. +pub fn lint_with(src: &str, inputs: &crate::qasm::Inputs) -> Value { + match crate::qasm::parse_with(src, inputs) { Ok(p) => json!({ "ok": true, "qubits": p.n_qubits, diff --git a/wai/wai-web/src/quantum_engine.rs b/wai/wai-web/src/quantum_engine.rs index 54e553da4..43a133011 100644 --- a/wai/wai-web/src/quantum_engine.rs +++ b/wai/wai-web/src/quantum_engine.rs @@ -7,6 +7,7 @@ use ed25519_dalek::SigningKey; use serde_json::{Value, json}; +use wai::engine::discover::{Archive, Budget, Evaluator, SearchOptions, Spec, search}; use wai::engine::{catalog, critique, draw, equiv, job, shortcuts}; use wasm_bindgen::prelude::*; @@ -144,10 +145,143 @@ pub fn engine_verify(job_json: &str) -> String { } } +/// A workbench specification from its JSON, ready to judge candidates. +fn evaluator(spec: &str) -> Result { + let v: Value = serde_json::from_str(spec).map_err(|e| format!("the specification is not JSON: {e}"))?; + Evaluator::new(Spec::from_json(&v)?) +} + +/// An archive from its JSON, which must have been built for `ev`'s specification. +fn archive(ev: &Evaluator, text: &str) -> Result { + let a: Archive = serde_json::from_str(text).map_err(|e| format!("the archive is not one: {e}"))?; + if !a.is_for(&ev.spec) { + return Err("this archive was built for another specification (or this one changed); start a new one".into()); + } + Ok(a) +} + +/// The workbench's answer: the archive, its summary, and what else was asked. +fn workbench(ev: &Evaluator, a: &Archive, extra: Value) -> String { + let mut v = json!({ "archive": a, "summary": a.summary(&ev.spec) }); + if let (Some(o), Some(x)) = (v.as_object_mut(), extra.as_object()) { + o.extend(x.clone()); + } + v.to_string() +} + +/// The workbench in a page ([`wai::engine::discover`]): an archive for a +/// specification, generators judged by the engine and kept when they are the +/// cheapest of their cell, each sealed with the page's key. +#[wasm_bindgen] +impl QuantumEngine { + /// An archive for `spec` (its JSON), seeded with the reference: + /// `{archive, summary}`, or `{error}`. + pub fn discover_start(&self, spec: &str) -> String { + evaluator(spec) + .and_then(|ev| Archive::new_sealed(&ev, Some(&self.key)).map(|a| workbench(&ev, &a, json!({})))) + .unwrap_or_else(error_json) + } + + /// `source` judged and kept in the archive if it is the cheapest of its + /// cell: `{archive, summary, outcome}`, or `{error}`. + pub fn discover_submit(&self, spec: &str, archive_json: &str, source: &str, parent: Option, proposer: &str, note: Option) -> String { + submit_in(&self.key, spec, archive_json, source, parent.as_deref(), proposer, note.as_deref()).unwrap_or_else(error_json) + } + + /// `evaluations` more candidates from the built-in search, from `seed`: + /// `{archive, summary, kept}` (the ids kept), or `{error}`. A tab claims no + /// time and meters no energy, so the budget is evaluations alone; the + /// Studio calls it in small steps to show progress. + pub fn discover_search(&self, spec: &str, archive_json: &str, seed: u32, evaluations: u32) -> String { + search_in(&self.key, spec, archive_json, seed, evaluations).unwrap_or_else(error_json) + } +} + +fn submit_in(key: &SigningKey, spec: &str, archive_json: &str, source: &str, parent: Option<&str>, proposer: &str, note: Option<&str>) -> Result { + let ev = evaluator(spec)?; + let mut a = archive(&ev, archive_json)?; + let out = a.submit_sealed(&ev, source, parent, proposer, note, Some(key)); + Ok(workbench(&ev, &a, json!({ "outcome": out }))) +} + +fn search_in(key: &SigningKey, spec: &str, archive_json: &str, seed: u32, evaluations: u32) -> Result { + let mut ev = evaluator(spec)?; + ev.spec.budget = Budget { evaluations: Some(u64::from(evaluations)), seconds: None, joules: None }; + let mut a = archive(&ev, archive_json)?; + let opts = SearchOptions { seed: u64::from(seed), max_edits: 2, key: Some(key.clone()) }; + let mut kept = Vec::new(); + search(&ev, &mut a, &opts, &|| 0.0, &|| None, &mut |e| kept.push(e.id.clone())); + Ok(workbench(&ev, &a, json!({ "kept": kept }))) +} + +/// The engine's verdict on one generator against a workbench specification, +/// without an archive ([`Evaluator::evaluate`]), or `{error}`. +#[wasm_bindgen] +pub fn engine_discover_evaluate(spec: &str, source: &str) -> String { + evaluator(spec).map(|ev| serde_json::to_string(&ev.evaluate(source)).unwrap_or_else(error_json)).unwrap_or_else(error_json) +} + +fn inputs_of(inputs: &str) -> Result { + serde_json::from_str(inputs).map_err(|e| format!("inputs: {e}")) +} + +/// A generator linted at `inputs` (JSON, as `{"n": 5}`), its problems on the +/// generator's own lines ([`job::lint_with`]). +#[wasm_bindgen] +pub fn engine_lint_with(src: &str, inputs: &str) -> String { + inputs_of(inputs).map(|ins| job::lint_with(src, &ins).to_string()).unwrap_or_else(|e| json!({ "ok": false, "error": e }).to_string()) +} + +/// A generator critiqued at `inputs`: its measures there and its smells on the +/// generator's own lines, none offered as a fix ([`critique::critique_with`]). +#[wasm_bindgen] +pub fn engine_critique_with(src: &str, inputs: &str) -> String { + inputs_of(inputs).map(|ins| critique::critique_with(src, &ins).to_string()).unwrap_or_else(|e| json!({ "ok": false, "error": e }).to_string()) +} + +/// A generator written out at `inputs` (JSON, as `{"n": 5}`) as plain +/// OpenQASM: `{text}`, or `{error}`. +#[wasm_bindgen] +pub fn engine_instantiate(src: &str, inputs: &str) -> String { + inputs_of(inputs).and_then(|ins| wai::qasm::instantiate(src, &ins)).map(|text| json!({ "text": text }).to_string()).unwrap_or_else(error_json) +} + #[cfg(test)] mod tests { use super::*; + const CHAIN: &str = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [0:n-2] { cx q[i], q[i + 1]; }\n"; + const STAR: &str = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [1:n-1] { cx q[0], q[i]; }\n"; + + /// The workbench in the page: an archive started, a generator judged and + /// kept, a search, every seal holding, and an archive refused for another + /// specification. + #[test] + fn the_workbench_runs_and_seals_in_the_page() { + let e = QuantumEngine::new(&[9u8; 32], 512).unwrap(); + let spec = json!({ "name": "ghz", "task": "prepares", "reference": CHAIN, "inputs": { "n": [3, 5] }, "cost": { "depth": 1.0 }, "axes": ["two_qubit", "depth"] }).to_string(); + let started: Value = serde_json::from_str(&e.discover_start(&spec)).unwrap(); + assert_eq!(started["summary"]["stats"]["kept"], 1, "{started}"); + let a = started["archive"].to_string(); + let sub: Value = serde_json::from_str(&e.discover_submit(&spec, &a, STAR, Some("e0".into()), "person", None)).unwrap(); + assert_eq!(sub["outcome"]["evaluation"]["verified"], true, "{sub}"); + let searched: Value = serde_json::from_str(&e.discover_search(&spec, &sub["archive"].to_string(), 5, 10)).unwrap(); + assert_eq!(searched["summary"]["stats"]["evaluations"], 2 + 10); + let back: Archive = serde_json::from_value(searched["archive"].clone()).unwrap(); + assert!(back.verify().iter().all(|(_, ok)| *ok)); + let other = spec.replace("\"depth\":1.0", "\"depth\":2.0"); + assert_ne!(other, spec); + assert!(e.discover_submit(&other, &a, STAR, None, "person", None).contains("another specification")); + let v: Value = serde_json::from_str(&engine_discover_evaluate(&spec, STAR)).unwrap(); + assert_eq!(v["strength"], "state"); + let lint: Value = serde_json::from_str(&engine_lint_with(CHAIN, "{\"n\": 4}")).unwrap(); + assert_eq!((lint["qubits"].as_u64(), lint["gates"].as_u64()), (Some(4), Some(4)), "{lint}"); + let crit: Value = serde_json::from_str(&engine_critique_with(CHAIN, "{\"n\": 4}")).unwrap(); + assert_eq!(crit["depth"], 4); + let inst: Value = serde_json::from_str(&engine_instantiate(CHAIN, "{\"n\": 3}")).unwrap(); + assert!(inst["text"].as_str().unwrap().contains("cx q[1], q[2];"), "{inst}"); + } + fn bell(extra: &str) -> String { format!("{{\"qasm\": \"OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];\"{extra}}}") } -- 2.54.0 (Apple Git-157) From cbb56effc602e24204763e87289034c630c9e971 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 26 Oct 2026 02:00:38 -0400 Subject: [PATCH 4/6] wai-web wasm32: the engine's native tests count the workbench's The step pins the count of quantum_engine:: tests so a test that stops being compiled fails it. The workbench test in the page makes it 7. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-web-wasm.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 44e58e8f2..418044984 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -166,14 +166,15 @@ jobs: grep -q "test result: ok. 8 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, plan, lint, draw and methods as JSON, - # critique, equivalence and variants as JSON, and the classical shortcuts. + # critique, equivalence and variants as JSON, the classical shortcuts, and the + # workbench run and sealed in the page. - 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. 6 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine.log" + grep -q "test result: ok. 7 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 -- 2.54.0 (Apple Git-157) From a1bf417590045ac94d6efc359c492a70c17e63c2 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 26 Oct 2026 00:57:15 -0400 Subject: [PATCH 5/6] wai-quantum: a program's history, attributed, and the Studio's Versions Feature 6 of the co-design report: every version of a program, who made each change and how, and whose hand each line and gate is in, sealed into the run's receipt. - engine::versions: - versions in a chain, each naming its parents (none, one, or two for a merge), who made it (the person, the assistant, an outside agent, the engine, or where the program came from) and how (an edit, a proposal reviewed change by change, a merge); - a version's digest covers its parents' digests, and the recording host's key seals it; verify checks every digest, seal and review; - changes and apply: a line diff (Myers), so a proposal is taken change by change, and the version keeps the whole proposal; - blame, authorship and gate_hands: a line keeps its author until changed; a model's or an agent's line the person edits is in both hands; an undo keeps the earlier authors; a merge keeps each side's; gates follow their lines, at a generator's inputs; - attest and check_job: a job's request carries the account, the receipt binds it, and it is recomputed from the history; - call: the history by name with JSON arguments, for the page and the desktop app. - The command line: history start | commit | changes | propose | merge | log | blame | verify; run and submit --history; verify --history. - MCP: history_commit, history_changes, history_blame and history_log. An agent's edit is recorded as an outside agent's, under the name its client gave in initialize, whatever it says of itself. - The Studio's Versions pane: - what the reader types is theirs, recorded when they pause; - every proposal (the assistant's program, an alternative, a generator from the Workbench) is offered change by change while the editor waits; a fix is the engine's; - lines of work in lanes: an older version opened and edited starts its own, and "Bring in" merges one change by change; - "Who wrote it" marks lines in the gutter and gates on the diagram; - the run's receipt binds the account, which the result recomputes; - kept in the browser, downloaded for the command line; - window.quantumStudio.propose for an embedding page. - Fixes found by the Studio's test: a run shows it is running before the account is made, and the draft is kept as the page goes. - CI: wai-web's native engine tests count 8, the history's included. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-web-wasm.yml | 7 +- wai/CHANGELOG.md | 32 + wai/quantum-studio/README.md | 19 + wai/quantum-studio/src/engine.js | 4 + wai/quantum-studio/src/gallery.js | 6 +- wai/quantum-studio/src/main.js | 152 +++- wai/quantum-studio/src/studio.css | 70 ++ wai/quantum-studio/src/versions.js | 284 ++++++ wai/quantum-studio/src/workbench.js | 10 +- wai/quantum-studio/src/worker.js | 1 + wai/quantum-studio/test/studio.test.mjs | 102 ++- wai/wai-quantum-cli/README.md | 25 + wai/wai-quantum-cli/src/history.rs | 385 +++++++++ wai/wai-quantum-cli/src/main.rs | 51 +- wai/wai-quantum-cli/src/mcp.rs | 151 +++- wai/wai-quantum-desktop/src/main.rs | 32 +- wai/wai-quantum/README.md | 2 +- wai/wai-quantum/src/engine/mod.rs | 7 + wai/wai-quantum/src/engine/versions.rs | 1055 +++++++++++++++++++++++ wai/wai-web/src/quantum_engine.rs | 34 +- 20 files changed, 2372 insertions(+), 57 deletions(-) create mode 100644 wai/quantum-studio/src/versions.js create mode 100644 wai/wai-quantum-cli/src/history.rs create mode 100644 wai/wai-quantum/src/engine/versions.rs diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 418044984..6a53f035e 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -166,15 +166,16 @@ jobs: grep -q "test result: ok. 8 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, plan, lint, draw and methods as JSON, - # critique, equivalence and variants as JSON, the classical shortcuts, and the - # workbench run and sealed in the page. + # critique, equivalence and variants as JSON, the classical shortcuts, the + # workbench run and sealed in the page, and a program's history recorded and + # blamed there. - 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. 7 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine.log" + grep -q "test result: ok. 8 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/CHANGELOG.md b/wai/CHANGELOG.md index c4dc00c35..0448566c0 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -1116,6 +1116,38 @@ wire format may change and such changes are recorded here. - **`tools/ops-gpu-bench`** times each capability's decode with its graphs on the CPU and on each adapter, and reads the energy counters the host has. - **CI:** `wai-ops-gpu.yml` runs the Metal and browser legs on the `macos` runner, and requires on a runner with no GPU that the device tests skip without `WAI_OPS_GPU_REQUIRE` and fail with it. +### wai-quantum: a program's history, attributed, and the Studio's Versions + +- **The history** (`engine::versions`): every version of a program, in a chain. + - Each version names its parents: none for the first, one for a change, two for a merge. + - Each names who made it: the person, the assistant (a model), an outside agent, the engine, or where the program came from. + - Each names how it was made: an edit, a proposal reviewed change by change, or a merge. + - A version's digest covers its parents' digests, its source, who made it and how, and the recording host's key seals it. `History::verify` checks every digest and seal, and that each review gives its version's source. +- **A proposal reviewed change by change** (`changes`, `apply`, `History::propose`): + - the changes are a line-level diff, a longest common subsequence by Myers' algorithm; + - only the changes accepted reach the program; + - the version keeps the whole proposal and which changes were taken, so the rejected ones stay readable. +- **Whose hand** (`History::blame`, `authorship`, `gate_hands`): + - a line keeps its author until someone changes it; + - a model's or an agent's line that the person then edits is in both hands (`assistant_then_person`, `agent_then_person`); + - an edit back to an earlier program (an undo) keeps that program's authors, and a merge keeps each line's author from either side; + - the gates follow their lines, so a generator's are counted at given inputs, each by the line in the loop that makes it. +- **On the receipt:** `History::attest` gives a version's digest and the gates and lines in each hand. A job's request carries it as `history`, so the receipt binds it and a changed count fails the signature. `History::check_job` recomputes the account from the history, and checks that the job ran that version's program. +- **The command line:** + - `wai-quantum history start | commit | changes | propose | merge | log | blame | verify`, with the history in a file beside the program; + - `run --history` and `submit --history`; + - `verify --history`. +- **For agents:** `wai-quantum mcp` adds `history_commit`, `history_changes`, `history_blame` and `history_log`. An agent's edit is recorded as an outside agent's, under the name its client gave in `initialize`, whatever it says of itself. +- **The Studio's Versions pane:** + - **Recording:** what the reader types is recorded as theirs once they pause. Every proposal (the assistant's program, an alternative, a generator from the Workbench) is offered change by change, and the editor waits until the reader chooses. + - **Lines of work:** drawn in lanes. An older version opened and edited starts a line of its own, and "Bring in" merges another line change by change. + - **"Who wrote it"** marks each line in the gutter and each gate on the diagram. + - **On the receipt:** a run's receipt binds the account, and the result shows it, recomputed here. The versions are kept in the browser and download as `program.qasm` and `program.history.json` for the command line. + - **Hosts:** `window.quantumStudio.propose` lets an embedding page offer a program as someone's proposal. The page and the desktop app call the engine through one `history` binding. +- **Fixes in the Studio:** + - a run is shown as running before the history's account is made, so a quick check after it does not read the last result; + - the draft is kept as the page goes, not only after a redraw's pause. + ### Quantum Studio: the Workbench, and generators in the editor - **Generators in the editor.** A program with integer inputs shows a field for each under the diff --git a/wai/quantum-studio/README.md b/wai/quantum-studio/README.md index ce301ad29..ae14d7e48 100644 --- a/wai/quantum-studio/README.md +++ b/wai/quantum-studio/README.md @@ -39,6 +39,25 @@ The Workbench searches a family of circuits for its cheapest members, and the en A proposal is verified only when it reads, keeps the rules and passes the check at every size. Each kept entry is sealed with this browser's key (the desktop app's, in the app). Clicking a point shows its cost, how it was checked, its seal and where it came from; "Open in editor" puts it in the editor. The archive is kept in this browser and comes back with the page. "Download" saves the specification and its archive side by side (`NAME.json` and `NAME.archive.json`), so `wai-quantum discover verify NAME.json` checks every seal. In the desktop app a search also counts its seconds and the joules its process spent. +## Versions + +The Versions pane records each change to the program and who made it: + +- what the reader types is theirs, recorded once they pause, before a run, and before anything else changes the program; +- a proposal is its proposer's: the assistant's program, an alternative (the engine's or the model's), a generator opened from the Workbench; +- a fix is the engine's; +- an example, a file, a link or an earlier run is where the program came from. + +**A proposal is taken change by change.** Its changes are listed with their lines, each with a box, and the program waits until the reader chooses. Only the changes taken reach the program. The version keeps the whole proposal and which changes were taken, so a rejected one stays readable. + +**Lines of work.** The versions are drawn as lines of work. "Open" puts an older version in the editor, and what is changed from it starts a line of its own. "Bring in" shows another line's head change by change against the program, and the changes taken are recorded as a merge of both. + +**Whose hand.** "Who wrote it" marks each line in the editor's gutter and each gate on the diagram: the reader, the assistant, an agent, the engine, where it came from, or a model's line the reader then edited, which is in both hands. The pane shows each contributor's gates and lines. + +**On the receipt.** A run's request carries the account of the version in the editor: its digest, and the gates and lines in each hand. The receipt binds it, and the result shows "written by" and the account recomputed here from the versions. The versions are kept in this browser, each sealed with its key (the desktop app's, in the app). "Download" saves `program.qasm` and `program.history.json`, which `wai-quantum history verify program.qasm` checks. + +An embedding page can offer a program as someone's proposal with `window.quantumStudio.propose(qasm, {kind, name}, note)`; it resolves once the reader has chosen. + ## 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. diff --git a/wai/quantum-studio/src/engine.js b/wai/quantum-studio/src/engine.js index d7997c318..bd6e4c21e 100644 --- a/wai/quantum-studio/src/engine.js +++ b/wai/quantum-studio/src/engine.js @@ -74,6 +74,9 @@ export class TabEngine { discoverSubmit(spec, archive, source, parent, proposer, note) { return this.call('discover_submit', { spec, archive, source, parent, proposer, note }); } // The search's seed goes as `from`: `seed` is the signing key's, on every message. discoverSearch(spec, archive, seed, evaluations) { return this.call('discover_search', { spec, archive, from: seed, evaluations }); } + // The program's history: `name` is the call (start, commit, propose, merge, + // changes, apply, blame, attest, log, check_job), its arguments in `args`. + history(name, args) { return this.call('history', { name, args }); } async signer() { return (await this.call('signer')).did; } cancel() { this.worker?.terminate(); @@ -149,6 +152,7 @@ export class DesktopEngine { discoverStart(spec) { return this.call('discover_start', { spec: JSON.stringify(spec) }); } discoverSubmit(spec, archive, source, parent, proposer, note) { return this.call('discover_submit', { spec: JSON.stringify(spec), archive: JSON.stringify(archive), source, parent: parent ?? null, proposer, note: note ?? null }); } discoverSearch(spec, archive, seed, evaluations) { return this.call('discover_search', { spec: JSON.stringify(spec), archive: JSON.stringify(archive), seed, evaluations }); } + history(name, args) { return this.call('history', { name, args: JSON.stringify(args) }); } 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 }); } diff --git a/wai/quantum-studio/src/gallery.js b/wai/quantum-studio/src/gallery.js index 58b398201..42262881e 100644 --- a/wai/quantum-studio/src/gallery.js +++ b/wai/quantum-studio/src/gallery.js @@ -70,7 +70,8 @@ export function atlas(draft, cands, onPick) { } // The pane. `draft()` is the editor's program; `model()` the assistant or null -// when no model is connected; `apply(qasm, note)` puts a program in the editor. +// when no model is connected; `apply(qasm, note, by)` offers a program to the +// editor as `by`'s proposal: the engine's, or the model's. export function galleryPane({ engine, draft, model, apply }) { const intent = el('input', { class: 'qs-input', id: 'qs-explore-aim', placeholder: 'Toward… fewer qubits, another entangler, more symmetry', autocomplete: 'off', 'aria-label': 'What Explore should aim for' }); const improveBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-improve', type: 'button', text: 'Improve', title: 'The same circuit, cheaper: each checked equivalent to the draft' }); @@ -108,7 +109,8 @@ export function galleryPane({ engine, draft, model, apply }) { c.changes?.length ? el('p', { class: 'qs-note', text: c.changes.join('; ') }) : null, el('details', { class: 'qs-cand-src' }, el('summary', { text: 'Program' }), el('pre', { class: 'qs-ai-code', text: c.qasm })), el('div', { class: 'qs-actions' }, - el('button', { class: 'qs-btn qs-small qs-apply', type: 'button', text: 'Apply', onclick: () => apply(c.qasm, `${mode === 'improve' ? 'An improvement' : 'An exploration'} from ${c.source === 'engine' ? 'the engine' : 'the model'}, applied: ${c.idea || c.label}. ${s.text[0].toUpperCase() + s.text.slice(1)}.`) }))); + el('button', { class: 'qs-btn qs-small qs-apply', type: 'button', text: 'Apply', onclick: () => apply(c.qasm, `${mode === 'improve' ? 'An improvement' : 'An exploration'} from ${c.source === 'engine' ? 'the engine' : 'the model'}: ${c.idea || c.label}. ${s.text[0].toUpperCase() + s.text.slice(1)}.`, + c.source === 'engine' ? { kind: 'engine', name: 'Alternatives' } : { kind: 'assistant', name: model()?.model?.name || undefined }) }))); return card; }); const pick = (i, go) => { diff --git a/wai/quantum-studio/src/main.js b/wai/quantum-studio/src/main.js index 9216154ff..3f10476f2 100644 --- a/wai/quantum-studio/src/main.js +++ b/wai/quantum-studio/src/main.js @@ -6,14 +6,16 @@ // The engine is the microscope and the judge: it critiques the draft as it is // written, measures and checks every alternative, and answers every run. The // assistant, a model on this machine, is an untrusted muse that speaks only when -// asked. The reader is the author: nothing changes the program until they apply it. +// asked. The reader is the author: nothing changes the program until they apply it, +// and a proposal is taken change by change. Every version is recorded with who +// made it, and a run's receipt binds whose hand each gate is in. // // 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, StateEffect } from '@codemirror/state'; +import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, gutter, GutterMarker } from '@codemirror/view'; +import { EditorState, Compartment, StateEffect, StateField, RangeSet } 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'; @@ -30,6 +32,7 @@ import { el, sameness } from './dom.js'; import { critiquePanel } from './critique-view.js'; import { galleryPane } from './gallery.js'; import { workbenchPane } from './workbench.js'; +import { versionsPane, HANDS, whoName } from './versions.js'; import { declared, inputsBar, withInputs } from './generators.js'; import { shortcutsPanel } from './shortcuts-view.js'; import * as Predict from './predict.js'; @@ -54,6 +57,23 @@ const ms = (t) => (t < 1 ? 'under 1 ms' : t < 10000 ? `${Math.round(t)} ms` : `$ 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); +// Whose hand a line is in, in the editor's gutter. +class HandMark extends GutterMarker { + constructor(hand, title) { super(); this.hand = hand; this.title = title; } + eq(o) { return o.hand === this.hand && o.title === this.title; } + toDOM() { const d = document.createElement('span'); d.className = `qs-hand qs-hand-${this.hand}`; d.title = this.title; return d; } +} +const setHands = StateEffect.define(); +const handsField = StateField.define({ + create: () => RangeSet.empty, + update(set, tr) { + set = set.map(tr.changes); + for (const e of tr.effects) if (e.is(setHands)) set = e.value; + return set; + }, +}); +const handGutter = gutter({ class: 'qs-hand-gutter', markers: (v) => v.state.field(handsField), initialSpacer: () => new HandMark('spacer', '') }); + 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. @@ -93,6 +113,7 @@ function mount(root) { 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 reviewBox = el('div', { class: 'qs-review', id: 'qs-review', hidden: true, role: 'region', 'aria-label': 'A proposal to review' }); 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' }); @@ -115,7 +136,7 @@ function mount(root) { 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, inputs.node, jobBox); + shows, reviewBox, editorHost, inputs.node, jobBox); // ---- the circuit ---- const circuit = el('pre', { class: 'qs-circuit', id: 'qs-circuit', tabindex: '0', 'aria-label': 'The circuit, drawn as text', hidden: true }); @@ -154,7 +175,7 @@ function mount(root) { engine: local, draft: () => text(), model: () => (aiConnected ? assistantFor() : null), - apply: (qasm, note) => { setText(qasm); shows.textContent = note; setStatus('Applied the alternative. Undo brings the draft back.', 'ok'); }, + apply: (qasm, note, by) => offer(qasm, by, note), }); // ---- the workbench ---- @@ -162,8 +183,8 @@ function mount(root) { engine: native, draft: () => text(), model: () => (aiConnected ? assistantFor() : null), - apply: (qasm, note) => { setText(qasm); shows.textContent = note; setStatus('Opened the generator. Undo brings the draft back.', 'ok'); }, - save: async (name, body) => (desktop ? (await local.saveProgram(name, body)).path : (download(name, body, 'application/json'), name)), + apply: (qasm, note, by) => offer(qasm, by, note), + save: (name, body) => saveFile(name, body, 'application/json'), }); // ---- the result ---- @@ -230,7 +251,22 @@ function mount(root) { 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, gallery.node, workbench.node, resultPane, aiPane, historyPane)), footer); + // ---- the versions ---- + const editable = new Compartment(); + const handsCol = new Compartment(); + const versions = versionsPane({ + engine: local, + text: () => text(), + setText: (s) => setText(s), + inputs: () => (declared(text()).length ? inputs.values() : {}), + lock: (on) => { view.dispatch({ effects: editable.reconfigure(EditorState.readOnly.of(on)) }); editorHost.classList.toggle('qs-locked', on); }, + reviewHost: reviewBox, + onHands: (b) => showHands(b), + save: (name, body, type) => saveFile(name, body, type), + status: (s, cls) => setStatus(s, cls), + }); + + root.replaceChildren(header, el('div', { class: 'qs-grid' }, program, el('div', { class: 'qs-side' }, circuitPane, gallery.node, workbench.node, resultPane, versions.node, aiPane, historyPane)), footer); // ---- the editor ---- const style = HighlightStyle.define([ @@ -276,6 +312,7 @@ function mount(root) { state: EditorState.create({ doc: '', extensions: [ + handsField, handsCol.of([]), editable.of(EditorState.readOnly.of(false)), lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), history(), drawSelection(), indentOnInput(), bracketMatching(), closeBrackets(), highlightSelectionMatches(), openqasm, syntaxHighlighting(style), autocompletion({ override: [complete] }), // A new value for a generator's input re-reads the program, as an edit does. @@ -283,7 +320,7 @@ function mount(root) { 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(); }), + EditorView.updateListener.of((u) => { if (u.docChanged) { edited(); versions.edited(); } }), ], }), }); @@ -331,6 +368,7 @@ function mount(root) { 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(' · '); critique.update(c); + paintHands(); // The smells, on the diagram: each gate in one is flagged, its tooltip saying why. const smells = c.ok ? c.smells : []; mark(diagram, 'qc-flag', smells.flatMap((s) => s.ops), smells.flatMap((s) => s.qubits && !s.ops.length ? s.qubits : [])); @@ -373,6 +411,8 @@ function mount(root) { running = eng; busy(true); state.textContent = 'running'; state.className = 'qs-badge run'; setStatus(`Running on ${eng.where}…`); + // The history's account of the program: the receipt binds whose hand each gate is in. + try { job.history = await versions.attest(); } catch (e) {} const t0 = performance.now(); try { const body = await eng.run(job, (j) => setStatus(`On the service: ${j.status}…`)); @@ -450,6 +490,7 @@ function mount(root) { } // The receipt, checked here. const rc = r.receipt || {}; + const account = el('span', { class: 'qs-badge', id: 'qs-account', text: 'checking…' }); 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}` : '—'; @@ -460,7 +501,9 @@ function mount(root) { 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('tr', {}, el('th', { scope: 'row', text: 'energy' }), el('td', { text: energy })), + job.request?.history ? el('tr', {}, el('th', { scope: 'row', text: 'written by' }), el('td', { id: 'qs-written-by', text: writtenBy(job.request.history) })) : null, + job.request?.history ? el('tr', {}, el('th', { scope: 'row', text: 'account' }), el('td', {}, account)) : null)), 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.filter(Boolean)); @@ -471,6 +514,59 @@ function mount(root) { 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'; } + if (job.request?.history) { + try { + const a = await versions.checkJob(job); + account.textContent = a.ok ? `✓ recomputed from ${a.version} in Versions` : `✗ ${a.problems.join('; ')}`; + account.className = 'qs-badge ' + (a.ok ? 'ok' : 'bad'); + } catch (e) { account.textContent = `not in this ${desktop ? 'computer’s' : 'browser’s'} versions`; account.className = 'qs-badge'; account.title = e.message; } + } + } + + // A history's account as a line: each hand's gates. + function writtenBy(h) { + const parts = (h.contributors || []).filter((c) => c.gates).map((c) => `${c.gates} by ${HANDS[c.hand] || c.hand}${c.name ? ` (${c.name})` : ''}`); + return `${parts.join(', ') || 'no gates'} · of ${h.of?.gates ?? 0} gates, ${h.version}`; + } + + // ---- whose hand: the gutter and the diagram ---- + let hands = null; + function showHands(b) { + hands = b || null; + if (!hands || hands.source !== text()) { + view.dispatch({ effects: [handsCol.reconfigure(hands ? [handGutter] : []), setHands.of(RangeSet.empty)] }); + paintHands(); + return; + } + const doc = view.state.doc; + const marks = hands.lines.flatMap((l, i) => (i < doc.lines && doc.line(i + 1).length ? [new HandMark(l.hand, `${HANDS[l.hand]} · ${l.version}${l.from ? ` (from ${l.from})` : ''}`).range(doc.line(i + 1).from)] : [])); + view.dispatch({ effects: [handsCol.reconfigure([handGutter]), setHands.of(RangeSet.of(marks))] }); + paintHands(); + } + // Each gate drawn marked with its hand, while the diagram is of the version blamed. + function paintHands() { + const by = hands && hands.source === text() ? hands.gates || [] : []; + diagram.classList.toggle('qs-by-hand', by.length > 0); + for (const h of Object.keys(HANDS)) mark(diagram, `qc-by-${h}`, by.flatMap((x, i) => (x === h ? [i] : []))); + } + + // ---- a proposal, reviewed change by change ---- + async function offer(qasm, by, note, opts = {}) { + if (versions.busy()) { setStatus('Choose on the proposal under review first.', 'bad'); return null; } + const r = await versions.review(qasm, by || { kind: 'person' }, note, opts); + if (r) { + shows.textContent = note || ''; + setStatus(r.accepted ? `Took ${r.accepted} of ${r.offered} change${r.offered === 1 ? '' : 's'} from ${whoName(by)}, as ${r.version}.` : `Rejected the proposal from ${whoName(by)}; ${r.version} in Versions keeps it.`, 'ok'); + } + return r; + } + // A program from elsewhere, recorded as where it came from. + async function from(where, f) { + if (versions.busy()) { setStatus('Choose on the proposal under review first.', 'bad'); return false; } + await versions.record(); + f(); + await versions.record({ kind: 'origin', name: where }); + return true; } // ---- a smell fixed: the engine's rewrite, checked the same circuit ---- @@ -482,13 +578,19 @@ function mount(root) { if (!c) { setStatus('The engine has no rewrite for that one now; the program may have changed.', 'bad'); return; } if (text() !== src) return; // edited meanwhile: the rewrite is of an older draft const same = sameness(c.equivalence); - setText(c.qasm); + const r = await offer(c.qasm, { kind: 'engine', name: 'a fix' }, `Fixed: ${s.message}`, { all: true }); + if (!r) return; shows.textContent = `Fixed: ${s.message}. The engine checked the result: ${same.why || same.text}.`; - setStatus('Fixed. Undo brings the lines back.', 'ok'); + setStatus(`Fixed, as ${r.version}. Undo brings the lines back.`, 'ok'); } catch (e) { setStatus(e.message, 'bad'); } } // ---- files ---- + async function saveFile(name, body, type) { + if (desktop) return (await local.saveProgram(name, body)).path; + download(name, body, type); + return name; + } 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(); @@ -507,7 +609,9 @@ function mount(root) { 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 = ''; + const body = await f.text(); + if (await from(`the file ${f.name}`, () => setText(body))) shows.textContent = `Opened ${f.name}.`; + fileIn.value = ''; }); shareBtn.addEventListener('click', () => { const u = new URL(location.href); u.hash = 'qasm=' + encodeURIComponent(text()); u.search = ''; @@ -518,7 +622,10 @@ function mount(root) { 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('button', { type: 'button', class: 'qs-hitem', onclick: async () => { + if (!(await from('an earlier run', () => fill(h.job.request || {})))) return; + 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.' })])); } @@ -538,9 +645,10 @@ function mount(root) { method.value = [...method.options].some((o) => o.value === keep) ? keep : 'auto'; } catch (e) {} } - examples.addEventListener('change', () => { + examples.addEventListener('change', async () => { const e = EXAMPLES.find((x) => x.id === examples.value); if (!e) return; - fill(e.job); shows.textContent = e.shows; examples.value = ''; + examples.value = ''; + if (await from(`the example ${e.title}`, () => fill(e.job))) shows.textContent = e.shows; }); [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); @@ -591,7 +699,7 @@ function mount(root) { 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'); } })); + el('button', { class: 'qs-btn qs-small qs-ai-apply', type: 'button', text: 'Apply', onclick: () => offer(p.qasm, { kind: 'assistant', name: assistant?.model?.name || undefined }, 'The assistant’s program, checked and run before it was offered.') })); } li.append(box); } @@ -626,13 +734,21 @@ function mount(root) { 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; } + // The draft is kept as the page goes, not only once a redraw has waited. + addEventListener('pagehide', () => { try { localStorage.setItem(DRAFT, text()); } catch (e) {} }); + // The versions this browser kept go on if the editor holds their program; a + // draft typed since is the reader's, and anything else starts where it came from. + versions.begin(handed ? { kind: 'origin', name: 'a link' } : draft ? { kind: 'person' } : { kind: 'origin', name: `the example ${EXAMPLES[0].title}` }, + draft && !handed ? { kind: 'origin', name: 'the draft kept here' } : null).catch((e) => setStatus(`Versions: ${e.message}`, 'bad')); 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, gallery }; + // `propose(qasm, by, note)`: a program offered as `by`'s proposal, taken + // change by change; it resolves once the reader has chosen. + return { view, run, fill, request, gallery, versions, propose: (qasm, by, note) => offer(qasm, by, note) }; } const root = document.getElementById('studio'); diff --git a/wai/quantum-studio/src/studio.css b/wai/quantum-studio/src/studio.css index ea24a33c6..7e624e6c4 100644 --- a/wai/quantum-studio/src/studio.css +++ b/wai/quantum-studio/src/studio.css @@ -7,6 +7,12 @@ --qs-gate: var(--info-text, #7aa0ff); --qs-num: var(--warn-text, #f0a020); --qs-str: var(--std-text, #10b981); + /* Whose hand: the reader, a model, an outside agent, the engine, the start. */ + --qs-hand-person: var(--accent-text, #d4a574); + --qs-hand-assistant: var(--info-text, #7aa0ff); + --qs-hand-agent: var(--std-text, #10b981); + --qs-hand-engine: var(--text-secondary, #9aa0a6); + --qs-hand-origin: var(--border-subtle, #5a5f66); --qs-gap: 12px; --qs-radius: 8px; display: flex; @@ -217,6 +223,70 @@ .qs-wb-log { margin: 8px 12px 12px; padding: 0 0 0 18px; display: flex; flex-direction: column; gap: 3px; font: 11px/1.4 var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); } .qs-wb-log li.ok { color: var(--ok-text); } .qs-wb-log li.bad { color: var(--bad-text); } +/* Versions: who made each change, and whose hand each line and gate is in. */ +.qs-hand-person { --qs-hand: var(--qs-hand-person); } +.qs-hand-assistant { --qs-hand: var(--qs-hand-assistant); } +.qs-hand-agent { --qs-hand: var(--qs-hand-agent); } +.qs-hand-engine { --qs-hand: var(--qs-hand-engine); } +.qs-hand-origin { --qs-hand: var(--qs-hand-origin); } +.qs-hand-assistant_then_person { --qs-hand: var(--qs-hand-person); --qs-hand-was: var(--qs-hand-assistant); } +.qs-hand-agent_then_person { --qs-hand: var(--qs-hand-person); --qs-hand-was: var(--qs-hand-agent); } +.qs-hand-gutter .cm-gutterElement { padding: 0 3px 0 2px; display: flex; align-items: stretch; } +.qs-hand { display: block; width: 4px; min-height: 1.2em; border-radius: 2px; background: var(--qs-hand); } +.qs-hand.qs-hand-assistant_then_person, .qs-hand.qs-hand-agent_then_person { background: linear-gradient(var(--qs-hand-was) 0 50%, var(--qs-hand) 50% 100%); } +.qs-by-hand .qc-item .qc-box, .qs-by-hand .qc-item .qc-ring { stroke-width: 2.2; } +.qs-by-hand .qc-by-person .qc-box, .qs-by-hand .qc-by-person .qc-ring { stroke: var(--qs-hand-person); } +.qs-by-hand .qc-by-assistant .qc-box, .qs-by-hand .qc-by-assistant .qc-ring { stroke: var(--qs-hand-assistant); } +.qs-by-hand .qc-by-agent .qc-box, .qs-by-hand .qc-by-agent .qc-ring { stroke: var(--qs-hand-agent); } +.qs-by-hand .qc-by-engine .qc-box, .qs-by-hand .qc-by-engine .qc-ring { stroke: var(--qs-hand-engine); } +.qs-by-hand .qc-by-origin .qc-box, .qs-by-hand .qc-by-origin .qc-ring { stroke: var(--qs-hand-origin); } +.qs-by-hand .qc-by-assistant_then_person .qc-box, .qs-by-hand .qc-by-assistant_then_person .qc-ring { stroke: var(--qs-hand-person); stroke-dasharray: 3 2; } +.qs-by-hand .qc-by-agent_then_person .qc-box, .qs-by-hand .qc-by-agent_then_person .qc-ring { stroke: var(--qs-hand-person); stroke-dasharray: 3 2; } +.qs-vs { max-height: 520px; } +.qs-vs-intro { margin: 0; padding: 6px 12px 0; } +.qs-vs-share { padding: 6px 12px 0; display: flex; flex-direction: column; gap: 4px; } +.qs-vs-share:empty { display: none; } +.qs-vs-sum { margin: 0; font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); font-variant-numeric: tabular-nums; } +.qs-vs-bar { display: flex; gap: 2px; height: 8px; border-radius: 4px; overflow: hidden; } +.qs-vs-seg { display: block; min-width: 3px; background: var(--qs-hand); } +.qs-vs-seg.qs-hand-assistant_then_person, .qs-vs-seg.qs-hand-agent_then_person { background: repeating-linear-gradient(135deg, var(--qs-hand) 0 4px, var(--qs-hand-was) 4px 8px); } +.qs-vs-who { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px 14px; font-size: 12px; color: var(--text-secondary); font-variant-numeric: tabular-nums; } +.qs-vs-who li { display: inline-flex; align-items: center; gap: 6px; } +.qs-vs-key { width: 9px; height: 9px; border-radius: 2px; background: var(--qs-hand); } +.qs-vs-key.qs-hand-assistant_then_person, .qs-vs-key.qs-hand-agent_then_person { background: linear-gradient(135deg, var(--qs-hand-was) 0 50%, var(--qs-hand) 50% 100%); } +.qs-vs-lines { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 0 6px; margin: 8px 12px 0; overflow: auto; max-height: 240px; } +.qs-vs-svg { display: block; } +.qs-vs-edge { fill: none; stroke: var(--border-subtle); stroke-width: 1.6; } +.qs-vs-dot { fill: var(--bg-surface); stroke-width: 2.2; } +.qs-vs-dot.qs-by-person { stroke: var(--qs-hand-person); } +.qs-vs-dot.qs-by-assistant { stroke: var(--qs-hand-assistant); } +.qs-vs-dot.qs-by-agent { stroke: var(--qs-hand-agent); } +.qs-vs-dot.qs-by-engine { stroke: var(--qs-hand-engine); } +.qs-vs-dot.qs-by-origin { stroke: var(--qs-hand-origin); } +.qs-vs-dot.qs-vs-here { fill: currentColor; color: var(--accent); } +.qs-vs-list { list-style: none; margin: 0; padding: 0; } +.qs-vs-list li { height: 30px; } +.qs-vs-item { display: flex; align-items: baseline; gap: 8px; width: 100%; height: 30px; padding: 0 6px; border: 0; border-radius: 6px; background: none; color: var(--text-primary); text-align: left; cursor: pointer; font: inherit; white-space: nowrap; overflow: hidden; } +.qs-vs-item > span { line-height: 30px; overflow: hidden; text-overflow: ellipsis; } +.qs-vs-item:hover, .qs-vs-list li.on .qs-vs-item { background: var(--bg-base); } +.qs-vs-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.qs-vs-id { font: 600 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); } +.qs-vs-list li.here .qs-vs-id { color: var(--accent-text); } +.qs-vs-by { font-size: 13px; } +.qs-vs-detail { margin: 8px 12px 12px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-base); display: flex; flex-direction: column; gap: 6px; } +.qs-vs-detail:empty { display: none; } +.qs-vs-detail p { margin: 0; } +.qs-vs-what { font-size: 13px; } +.qs-review { margin: 0 12px 8px; padding: 8px 10px; border: 1px solid var(--qs-hand-assistant); border-radius: 8px; background: color-mix(in srgb, var(--qs-hand-assistant) 7%, var(--bg-base)); display: flex; flex-direction: column; gap: 8px; } +.qs-review[hidden] { display: none; } +.qs-review-head, .qs-review-note { margin: 0; } +.qs-review-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: 300px; overflow: auto; } +.qs-review-list li { display: flex; flex-direction: column; gap: 3px; } +.qs-diff { margin: 0; padding: 6px 8px; border-radius: 6px; background: var(--bg-surface); font: 12px/1.45 var(--font-mono, ui-monospace, monospace); white-space: pre-wrap; overflow-x: auto; } +.qs-diff .qs-del { color: var(--bad-text); } +.qs-diff .qs-add { color: var(--ok-text); } +.qs-editor.qs-locked { opacity: 0.72; } + /* Predict first. */ .qs-check { display: inline-flex; align-items: center; gap: 6px; font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); cursor: pointer; } .qs-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; } diff --git a/wai/quantum-studio/src/versions.js b/wai/quantum-studio/src/versions.js new file mode 100644 index 000000000..d56cdc5e3 --- /dev/null +++ b/wai/quantum-studio/src/versions.js @@ -0,0 +1,284 @@ +// The program's history, attributed (the engine's versions): every version, +// who made it and how, and whose hand each line and gate is in. The Studio +// records each change as it sees it made: what is typed is the reader's, a +// proposal applied is its proposer's, and a fix is the engine's. A proposal is +// reviewed change by change before it touches the program, and a version +// opened from the past and edited starts a line of work of its own. The +// history stays in this browser; a run's request carries its account, which +// the receipt binds. +import { el } from './dom.js'; + +const SAVED = 'wai.quantum-studio.versions'; +const IDLE_MS = 4000; +const ROW = 30, LANE = 14; +const SVG = 'http://www.w3.org/2000/svg'; + +// Whose hand, as the reader is told. +export const HANDS = { + person: 'you', assistant: 'the assistant', agent: 'an agent', engine: 'the engine', origin: 'where it came from', + assistant_then_person: 'the assistant, then you', agent_then_person: 'an agent, then you', +}; +const KINDS = { person: 'you', assistant: 'the assistant', agent: 'an agent', engine: 'the engine', origin: 'the start' }; +const HOW = { start: 'started', edit: 'edited', proposal: 'proposed', merge: 'merged' }; +export const whoName = (by) => `${KINDS[by?.kind] || by?.kind || '?'}${by?.name ? ` (${by.name})` : ''}`; +const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`; + +const svg = (tag, attrs = {}) => { + const e = document.createElementNS(SVG, tag); + for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); + return e; +}; + +// Each version's lane: a version continues its first parent's lane unless +// another child already has, and then opens one of its own. +function lanes(versions) { + const lane = new Map(), continued = new Set(); + let next = 0; + for (const v of versions) { + const p = v.parents?.[0]; + if (p != null && !continued.has(p) && lane.has(p)) { lane.set(v.id, lane.get(p)); continued.add(p); } + else lane.set(v.id, next++); + } + return { lane, count: Math.max(next, 1) }; +} + +// The pane. `engine.history(name, args)` calls the engine; `text()` and +// `setText(s)` read and write the editor; `inputs()` gives a generator's +// values (or {} for a program with none); `lock(on)` makes the editor wait +// while a proposal is reviewed in `reviewHost`; `onHands(blame)` hears whose +// hand each line and gate is in; `save(name, body)` keeps a file. +export function versionsPane({ engine, text, setText, inputs, lock, reviewHost, onHands, save, status }) { + let history = null; + let current = null; + let selected = null; + let timer = null; + let queue = Promise.resolve(); + let reviewing = false; + let showing = false; + + const call = (name, args) => engine.history(name, args); + const source = (id) => history?.versions.find((v) => v.id === id)?.source; + const keep = () => { try { localStorage.setItem(SAVED, JSON.stringify({ history, current })); } catch (e) {} }; + // One change at a time, in the order made. + const serial = (f) => (queue = queue.then(f, f)); + + const handsBox = el('input', { type: 'checkbox', id: 'qs-vs-hands' }); + const handsLabel = el('label', { class: 'qs-check', for: 'qs-vs-hands', title: 'Mark each line and gate with whose hand it is in' }, handsBox, el('span', { text: 'Who wrote it' })); + const download = el('button', { class: 'qs-btn qs-small', id: 'qs-vs-download', type: 'button', text: 'Download', title: 'The program and its history, as wai-quantum history verify reads them' }); + const share = el('div', { class: 'qs-vs-share', id: 'qs-vs-share' }); + const graph = el('div', { class: 'qs-vs-graph' }); + const list = el('ol', { class: 'qs-vs-list', id: 'qs-vs-list' }); + const detail = el('div', { class: 'qs-vs-detail', id: 'qs-vs-detail' }); + const node = el('section', { class: 'qs-pane qs-vs', 'aria-labelledby': 'qs-vs-h' }, + el('div', { class: 'qs-head' }, el('h2', { id: 'qs-vs-h', text: 'Versions' }), el('span', { class: 'qs-grow' }), handsLabel, download), + el('p', { class: 'qs-note qs-vs-intro', text: 'Each change, who made it and how. What you type is yours; a proposal you apply is its proposer’s, and only the changes you take reach the program.' }), + share, el('div', { class: 'qs-vs-lines' }, graph, list), detail); + + // ---- recording ---- + // The program as the editor holds it, recorded as `by`'s edit if it changed. + function record(by = { kind: 'person' }, note) { + clearTimeout(timer); + return serial(async () => { + if (!history || reviewing) return current; + const src = text(); + if (src === source(current)) return current; + const r = await call('commit', { history, parent: current, source: src, by, note, at: Date.now() }); + history = r.history; current = r.version; selected = null; keep(); + await render(); + return current; + }); + } + + // The editor changed: the reader's edit is recorded once they pause. + function edited() { + if (!history || reviewing) return; + clearTimeout(timer); + if (text() !== source(current)) timer = setTimeout(() => record(), IDLE_MS); + } + + // The history this browser kept goes on, a program that differs from its + // current version recorded as `changed`'s; with none kept, a history starts + // with the program as `fresh`'s (or `changed`'s). + async function begin(changed, fresh = null) { + let was = null; + try { was = JSON.parse(localStorage.getItem(SAVED) || 'null'); } catch (e) {} + await serial(async () => { + if (was?.history?.versions?.length && was.history.versions.some((v) => v.id === was.current)) { + history = was.history; current = was.current; + const log = await call('log', { history }).catch(() => null); + if (!log?.check?.ok) { history = null; current = null; } + } + // A program that is a version's already is that version, opened. + const same = history && text() !== source(current) ? [...history.versions].reverse().find((v) => v.source === text()) : null; + if (same) current = same.id; + else if (history && text() !== source(current)) { + const r = await call('commit', { history, parent: current, source: text(), by: changed, at: Date.now() }); + history = r.history; current = r.version; + } else if (!history) { + const r = await call('start', { source: text(), by: fresh || changed, at: Date.now() }); + history = r.history; current = r.version; + } + keep(); + }); + await render(); + } + + // ---- reviewing ---- + // A proposal (or, with `merge`, another line of work) shown change by + // change; only the changes taken reach the program. `by` is its proposer. + async function review(proposed, by, note, { merge = null, all = false } = {}) { + if (reviewing) return null; + await record(); + const base = source(current); + const { changes } = await call('changes', { base, proposed }); + if (!changes.length) { status?.(merge ? `${merge} holds nothing the program does not.` : 'The proposal is the program as it is.'); return null; } + const finish = async (accepted) => { + await serial(async () => { + if (merge) { + const { source: merged } = await call('apply', { base, proposed, accepted }); + const r = await call('merge', { history, a: current, b: merge, source: merged, by: { kind: 'person' }, note: note || `${merge} brought in`, at: Date.now() }); + history = r.history; current = r.version; + } else { + const r = await call('propose', { history, parent: current, proposed, accepted, by, note, at: Date.now() }); + history = r.history; current = r.version; + } + selected = null; keep(); + }); + reviewing = false; lock(false); reviewHost.replaceChildren(); reviewHost.hidden = true; + setText(source(current)); + await render(); + return { offered: changes.length, accepted: accepted.length, version: current }; + }; + if (all) return finish(changes.map((_, i) => i)); + reviewing = true; lock(true); + return new Promise((resolve) => { + const boxes = changes.map((c, i) => el('input', { type: 'checkbox', id: `qs-review-${i}`, checked: true })); + const take = el('button', { class: 'qs-btn qs-primary qs-small', id: 'qs-review-apply', type: 'button' }); + const count = () => { const n = boxes.filter((b) => b.checked).length; take.textContent = n === changes.length ? `Take all ${changes.length}` : `Take ${n} of ${changes.length}`; take.disabled = false; }; + boxes.forEach((b) => b.addEventListener('change', count)); count(); + const who = merge ? `${merge} (${whoName(history.versions.find((v) => v.id === merge)?.by)})` : whoName(by); + reviewHost.replaceChildren( + el('p', { class: 'qs-review-head' }, el('b', { text: merge ? `Bring in ${merge}` : `A proposal from ${who}` }), + el('span', { class: 'qs-note', text: ` · ${plural(changes.length, 'change')}. Take the ones you want; the program waits until you choose.` })), + note ? el('p', { class: 'qs-note qs-review-note', text: note }) : null, + el('ol', { class: 'qs-review-list' }, ...changes.map((c, i) => el('li', {}, + el('label', { class: 'qs-check', for: `qs-review-${i}` }, boxes[i], el('span', { text: `at line ${c.line}` })), + el('pre', { class: 'qs-diff' }, ...c.removed.map((l) => el('span', { class: 'qs-del', text: `− ${l}\n` })), ...c.added.map((l) => el('span', { class: 'qs-add', text: `+ ${l}\n` })))))), + el('div', { class: 'qs-actions' }, take, + el('button', { class: 'qs-btn qs-small', id: 'qs-review-reject', type: 'button', text: merge ? 'Not now' : 'Reject all', onclick: async () => { + if (merge) { reviewing = false; lock(false); reviewHost.replaceChildren(); reviewHost.hidden = true; resolve(null); return; } + resolve(await finish([])); + } }))); + reviewHost.hidden = false; + take.addEventListener('click', async () => { take.disabled = true; resolve(await finish(boxes.flatMap((b, i) => (b.checked ? [i] : [])))); }); + }); + } + + // ---- the account a run carries ---- + async function attest() { + await record(); + return queue.then(() => call('attest', { history, version: current, inputs: inputs() })); + } + const checkJob = (job) => call('check_job', { history, job }); + + // ---- showing ---- + async function blame() { + if (!history) return null; + const b = await call('blame', { history, version: current, inputs: inputs() }); + b.source = source(current); + return b; + } + + function shareOf(a) { + if (!a) return [el('p', { class: 'qs-note', text: 'The program does not read here, so its gates are not counted; its lines are marked.' })]; + const total = a.of.gates; + const bar = el('div', { class: 'qs-vs-bar', role: 'img', 'aria-label': a.contributors.map((c) => `${c.gates} of ${total} gates by ${HANDS[c.hand]}`).join(', ') }, + ...a.contributors.filter((c) => c.gates).map((c) => el('span', { class: `qs-vs-seg qs-hand-${c.hand}`, style: `flex-grow:${c.gates}`, title: `${HANDS[c.hand]}${c.name ? ` (${c.name})` : ''}: ${plural(c.gates, 'gate')}` }))); + return [ + el('p', { class: 'qs-vs-sum', id: 'qs-vs-sum', text: `${current}: ${plural(total, 'gate')}, ${plural(a.of.lines, 'line')}` }), + total ? bar : null, + el('ul', { class: 'qs-vs-who' }, ...a.contributors.map((c) => el('li', {}, + el('span', { class: `qs-vs-key qs-hand-${c.hand}`, 'aria-hidden': 'true' }), + el('span', { text: `${HANDS[c.hand]}${c.name ? ` (${c.name})` : ''}: ${plural(c.gates, 'gate')}, ${plural(c.lines, 'line')}` })))), + ]; + } + + function drawGraph(versions, { lane, count }) { + const order = [...versions].reverse(); + const row = new Map(order.map((v, i) => [v.id, i])); + const x = (id) => LANE / 2 + LANE * lane.get(id); + const y = (id) => ROW / 2 + ROW * row.get(id); + const g = svg('svg', { width: LANE * count, height: ROW * order.length, 'aria-hidden': 'true', class: 'qs-vs-svg' }); + for (const v of order) for (const p of v.parents || []) { + if (!row.has(p)) continue; + const [x1, y1, x2, y2] = [x(v.id), y(v.id), x(p), y(p)]; + g.append(svg('path', { class: 'qs-vs-edge', d: x1 === x2 ? `M${x1} ${y1}V${y2}` : `M${x1} ${y1}C${x1} ${y1 + ROW * 0.8} ${x2} ${y2 - ROW * 0.8} ${x2} ${y2}` })); + } + for (const v of order) g.append(svg('circle', { class: `qs-vs-dot qs-by-${v.by.kind}${v.id === current ? ' qs-vs-here' : ''}`, cx: x(v.id), cy: y(v.id), r: v.id === current ? 5 : 4 })); + return g; + } + + function showDetail(v, log) { + selected = v.id; + for (const li of list.children) li.classList.toggle('on', li.dataset.id === v.id); + const e = log.find((x) => x.id === v.id) || {}; + const parent = v.parents?.[0]; + const heads = log.filter((x) => x.head).map((x) => x.id); + const actions = []; + if (v.id !== current) actions.push(el('button', { class: 'qs-btn qs-small', id: 'qs-vs-open', type: 'button', text: `Open ${v.id}`, title: 'Put this version in the editor; what you change from it is a line of work of its own', onclick: () => openVersion(v.id) })); + if (heads.includes(v.id) && v.id !== current) actions.push(el('button', { class: 'qs-btn qs-small', id: 'qs-vs-merge', type: 'button', text: `Bring ${v.id} in`, title: 'Review its changes against the program and take the ones you want; recorded as a merge', onclick: () => review(v.source, { kind: 'person' }, null, { merge: v.id }) })); + const r = v.review; + detail.replaceChildren( + el('p', { class: 'qs-vs-what' }, el('b', { text: v.id }), el('span', { text: ` ${HOW[v.how] || v.how} by ${whoName(v.by)}${v.parents?.length ? ` from ${v.parents.join(' and ')}` : ''}, ${new Date(v.at).toLocaleString()}` })), + v.note ? el('p', { class: 'qs-note', text: v.note }) : null, + r ? el('p', { class: 'qs-note', id: 'qs-vs-review', text: `${r.accepted.length} of ${plural(r.offered, 'change')} taken${r.accepted.length < r.offered ? '; the rest were rejected, and the whole proposal is kept below' : ''}.` }) : null, + r && r.accepted.length < r.offered ? el('details', {}, el('summary', { text: 'The whole proposal' }), el('pre', { class: 'qs-ai-code', text: r.proposed })) : null, + parent ? el('p', { class: 'qs-note', text: `+${e.added ?? 0} −${e.removed ?? 0} against ${parent}${e.sealed ? ' · sealed' : ''}` }) : null, + actions.length ? el('div', { class: 'qs-actions' }, ...actions) : null); + } + + async function openVersion(id) { + await record(); + current = id; keep(); + setText(source(id)); + status?.(`Opened ${id}. What you change from here is a line of work of its own.`); + await render(); + } + + async function render() { + if (!history) return; + const log = (await call('log', { history })).versions; + const ls = lanes(history.versions); + graph.replaceChildren(drawGraph(history.versions, ls)); + list.replaceChildren(...[...history.versions].reverse().map((v) => { + const e = log.find((x) => x.id === v.id) || {}; + const tags = [v.id === current ? 'in the editor' : null, e.head && v.id !== current ? 'a head' : null].filter(Boolean); + return el('li', { 'data-id': v.id, class: v.id === current ? 'here' : '' }, + el('button', { type: 'button', class: 'qs-vs-item', onclick: () => showDetail(v, log) }, + el('span', { class: 'qs-vs-id', text: v.id }), + el('span', { class: 'qs-vs-by', text: `${HOW[v.how] || v.how} by ${whoName(v.by)}` }), + el('span', { class: 'qs-note', text: `${e.review ? `${e.review.accepted} of ${e.review.offered} taken · ` : ''}+${e.added ?? 0} −${e.removed ?? 0}${tags.length ? ` · ${tags.join(', ')}` : ''}` }))); + })); + const b = await blame().catch(() => null); + share.replaceChildren(...shareOf(b?.authorship)); + if (showing) onHands(b); + const v = history.versions.find((x) => x.id === (selected && source(selected) != null ? selected : current)); + if (v) showDetail(v, log); + } + + handsBox.addEventListener('change', async () => { + showing = handsBox.checked; + onHands(showing ? await blame().catch(() => null) : null); + }); + download.addEventListener('click', async () => { + await record(); + await save('program.qasm', source(current), 'text/plain'); + await save('program.history.json', JSON.stringify(history, null, 2) + '\n', 'application/json'); + status?.('Saved program.qasm and program.history.json; wai-quantum history verify program.qasm checks every version.', 'ok'); + }); + + return { + node, begin, edited, record, review, attest, checkJob, + current: () => current, history: () => history, showing: () => showing, busy: () => reviewing, blame, + }; +} diff --git a/wai/quantum-studio/src/workbench.js b/wai/quantum-studio/src/workbench.js index 8d86f9702..0bd3b3598 100644 --- a/wai/quantum-studio/src/workbench.js +++ b/wai/quantum-studio/src/workbench.js @@ -113,8 +113,14 @@ export function outcomeText(o, who) { return `${who}: verified (cost ${num(e.cost)}), but its cell already holds one as cheap`; } +// Who a proposer is, as the program's history records it. +const byOf = (p) => (p === 'mutate' ? { kind: 'engine', name: 'the blind search' } + : p === 'person' || p === 'reference' ? { kind: 'person' } + : p.startsWith('model') ? { kind: 'assistant', name: p.replace(/^model:?\s*/, '') || undefined } : { kind: 'agent', name: p }); + // `save(name, text)` writes a file where the reader keeps them and gives back -// where it went. +// where it went. `apply(source, note, by)` offers a generator to the editor as +// `by`'s proposal. export function workbenchPane({ engine, draft, model, apply, save }) { const startBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-start', type: 'button', text: 'Start', title: 'A new archive, seeded with the program in the editor as the reference' }); const task = el('select', { class: 'qs-input qs-small', id: 'qs-wb-task', 'aria-label': 'What each member must do' }, @@ -189,7 +195,7 @@ export function workbenchPane({ engine, draft, model, apply, save }) { selected = id; for (const p of mapBox.querySelectorAll('.qs-pt')) p.classList.toggle('qs-sel', p.dataset.id === id); const open = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-open', type: 'button', text: 'Open in editor' }); - open.addEventListener('click', () => { openedFrom = id; apply(e.source, `Opened ${id} from the Workbench (${e.proposer}).`); }); + open.addEventListener('click', () => { openedFrom = id; apply(e.source, `${id} from the Workbench (${e.proposer}).`, byOf(e.proposer)); }); const chain = lineage(id).map((x, i) => el('button', { class: 'qs-chip', type: 'button', text: `${i ? '← ' : ''}${x.id} ${x.proposer}`, title: x.note ?? '', onclick: () => pick(x.id) })); entry.replaceChildren(...[ el('div', { class: 'qs-cand-head' }, el('span', { class: 'qs-cand-title', text: `${e.id} · cost ${num(e.cost)} · ${MEASURES[spec.axes[0]]} ${e.axes[0]}, ${MEASURES[spec.axes[1]]} ${e.axes[1]}` }), diff --git a/wai/quantum-studio/src/worker.js b/wai/quantum-studio/src/worker.js index da5231a54..cf0f2571e 100644 --- a/wai/quantum-studio/src/worker.js +++ b/wai/quantum-studio/src/worker.js @@ -48,6 +48,7 @@ self.onmessage = async (ev) => { case 'discover_start': text = e().discover_start(JSON.stringify(ev.data.spec)); break; case 'discover_submit': text = e().discover_submit(JSON.stringify(ev.data.spec), JSON.stringify(ev.data.archive), ev.data.source, ev.data.parent ?? undefined, ev.data.proposer, ev.data.note ?? undefined); break; case 'discover_search': text = e().discover_search(JSON.stringify(ev.data.spec), JSON.stringify(ev.data.archive), ev.data.from >>> 0, ev.data.evaluations >>> 0); break; + case 'history': text = e().history(ev.data.name, JSON.stringify(ev.data.args ?? {})); break; default: throw new Error(`no operation ${op}`); } const value = JSON.parse(text); diff --git a/wai/quantum-studio/test/studio.test.mjs b/wai/quantum-studio/test/studio.test.mjs index d3e66311b..a69a0bd7b 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; +// - Versions: each change recorded with who made it; a proposal taken change by +// change; whose hand each line and gate is in; the receipt binding it; a line +// of work from an older version merged back; kept and downloaded; // - the critique: its measures as the program changes and their change since the last // run; a smell named with its lines, flagged on the diagram, shown, and fixed by the // engine's rewrite, checked the same circuit; @@ -182,7 +185,7 @@ const chip = (k) => page.$eval(`#qs-measures [data-k=${k}]`, (c) => c.textConten check(sel[0] && sel[1], 'Show selects its lines in the editor'); await page.click('#qs-run'); await done(page); await page.click('.qs-smell[data-kind=cancels] .qs-fix'); - await page.waitForFunction(() => !/x q\[1\];/.test(window.quantumStudio.view.state.doc.toString()), null, { timeout: LONG }); + await page.waitForFunction(() => !/x q\[1\];/.test(window.quantumStudio.view.state.doc.toString()) && /checked the result/.test(document.querySelector('#qs-shows')?.textContent ?? ''), null, { timeout: LONG }); const shows = await page.textContent('#qs-shows'); check(/^Fixed: x on qubit 1/.test(shows) && /same tableau/.test(shows), `Fix applies the engine's rewrite, checked the same circuit (${shows})`); await page.waitForFunction(() => /−2/.test(document.querySelector('#qs-measures [data-k=gates]')?.textContent ?? ''), null, { timeout: LONG }); @@ -208,12 +211,96 @@ const chip = (k) => page.$eval(`#qs-measures [data-k=${k}]`, (c) => c.textConten check(/Connect a model/.test(await page.textContent('#qs-alt-body')), 'without a model, Improve says the engine’s are the only ones'); check((await doc()) === MERGE, 'nothing changes the program until an alternative is applied'); await page.click('.qs-cand .qs-apply'); - check(/rz\(0\.25 \+ 0\.5\) q\[0\];/.test(await doc()), 'Apply puts the alternative in the editor'); + await page.waitForSelector('#qs-review:not([hidden]) #qs-review-apply', { timeout: LONG }); + check((await doc()) === MERGE && /A proposal from the engine \(Alternatives\)/.test(await page.textContent('#qs-review')), 'Apply offers the alternative as the engine’s proposal, to take change by change'); + await page.click('#qs-review-apply'); + await page.waitForFunction(() => /rz\(0\.25 \+ 0\.5\) q\[0\];/.test(window.quantumStudio.view.state.doc.toString()), null, { timeout: LONG }); + check(true, 'taking it puts the alternative in the editor'); await page.click('#qs-explore'); await page.waitForSelector('#qs-alt-nomodel', { timeout: LONG }); check(true, 'Explore without a model says it needs one'); } +// Versions: each change and who made it; a proposal taken change by change; +// whose hand each line and gate is in; the run's receipt binding the count; a +// line of work from an older version, brought back in; and all of it kept. +{ + const here = () => page.textContent('#qs-vs-list li.here'); + const record = () => page.evaluate(() => window.quantumStudio.versions.record()); + const BASE = 'OPENQASM 3.0;\nqubit[3] q;\nbit[3] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;\n'; + await setProgram(BASE); + await record(); + check(/edited by you/.test(await here()), `what the reader types is recorded as theirs (${await here()})`); + const mine = await page.$eval('#qs-vs-list li.here', (l) => l.dataset.id); + const PROPOSED = 'OPENQASM 3.0;\nqubit[3] q;\nbit[3] c;\nh q[1];\ncx q[0], q[1];\ncx q[1], q[2];\nc = measure q;\n'; + await page.evaluate((q) => { window.quantumStudio.propose(q, { kind: 'assistant', name: 'scripted' }, 'entangle the third qubit'); }, PROPOSED); + await page.waitForSelector('#qs-review:not([hidden]) #qs-review-1', { timeout: LONG }); + const offered = await page.$$eval('.qs-review-list > li', (ls) => ls.map((l) => l.textContent)); + check(offered.length === 2 && /h q\[0\]/.test(offered[0]) && /\+ cx q\[1\], q\[2\]/.test(offered[1]) && await page.evaluate(() => window.quantumStudio.view.state.readOnly), + `a proposal is shown change by change, and the program waits (${offered.map((t) => t.replace(/\s+/g, ' ')).join(' | ')})`); + await page.uncheck('#qs-review-0'); + check((await page.textContent('#qs-review-apply')) === 'Take 1 of 2', 'the reader takes the changes they want'); + await page.click('#qs-review-apply'); + const TAKEN = BASE.replace('cx q[0], q[1];\n', 'cx q[0], q[1];\ncx q[1], q[2];\n'); + await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q && !window.quantumStudio.view.state.readOnly, TAKEN, { timeout: LONG }); + check(/proposed by the assistant \(scripted\)/.test(await here()) && /1 of 2 taken/.test(await here()), `only the change taken reaches the program, recorded as the assistant's (${await here()})`); + check(/1 of 2 changes taken; the rest were rejected/.test(await page.textContent('#qs-vs-detail')), 'the version keeps what was rejected'); + await page.check('#qs-vs-hands'); + await page.waitForFunction(() => document.querySelectorAll('.qs-hand-gutter .qs-hand:not(.qs-hand-spacer)').length === 7, null, { timeout: LONG }); + const gutter = await page.$$eval('.qs-hand-gutter .qs-hand:not(.qs-hand-spacer)', (ms) => ms.map((m) => m.className.replace('qs-hand qs-hand-', ''))); + await page.waitForFunction(() => document.querySelectorAll('#qs-diagram .qc-item.qc-by-assistant').length === 1, null, { timeout: LONG }); + check(gutter[5] === 'assistant' && gutter.filter((h) => h === 'assistant').length === 1 && gutter.slice(1, 3).every((h) => h === 'person'), + `“Who wrote it” marks each line in the gutter and each gate on the diagram (${gutter.join(', ')})`); + // The reader edits the model's line: in both hands. + await setProgram(TAKEN.replace('cx q[1], q[2];', 'cx q[0], q[2];')); + await record(); + await page.waitForFunction(() => document.querySelectorAll('.qs-hand-gutter .qs-hand-assistant_then_person').length === 1, null, { timeout: LONG }); + check(/the assistant, then you \(scripted\): 1 gate, 1 line/.test(await page.textContent('#qs-vs-share')), `a model's line the reader edits is in both hands (${await page.textContent('#qs-vs-share')})`); + await page.click('#qs-run'); await done(page); + await page.waitForFunction(() => /recomputed/.test(document.querySelector('#qs-account')?.textContent ?? ''), null, { timeout: LONG }); + const wrote = await page.textContent('#qs-written-by'); + check(/1 by the assistant, then you \(scripted\)/.test(wrote) && /of 3 gates/.test(wrote) && (await page.textContent('#qs-verified')).startsWith('✓ verifies'), + `the run's receipt binds whose hand each gate is in, recomputed here (${wrote}; ${await page.textContent('#qs-account')})`); + // A line of work from an older version, then brought back in. + await page.click(`#qs-vs-list li[data-id=${mine}] .qs-vs-item`); + await page.click('#qs-vs-open'); + await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, BASE, { timeout: LONG }); + await setProgram(BASE.replace('h q[0];\n', 'h q[0];\nx q[2];\n')); + await record(); + const heads = await page.$$eval('#qs-vs-list li', (ls) => ls.filter((l) => /a head|in the editor/.test(l.textContent)).length); + check(heads === 2 && (await page.$$('.qs-vs-svg .qs-vs-edge')).length >= 4, `an edit from an older version is a line of work of its own (${heads} heads)`); + const other = await page.$eval('#qs-vs-list li:not(.here)', (l) => l.dataset.id); + const otherHead = await page.$$eval('#qs-vs-list li', (ls) => ls.find((l) => /a head/.test(l.textContent))?.dataset.id); + await page.click(`#qs-vs-list li[data-id=${otherHead}] .qs-vs-item`); + await page.click('#qs-vs-merge'); + await page.waitForSelector('#qs-review:not([hidden]) #qs-review-apply', { timeout: LONG }); + const merging = await page.$$eval('.qs-review-list > li', (ls) => ls.map((l) => l.textContent)); + // Keep this line's x q[2]; take the other's edited CX. + for (const [i, t] of merging.entries()) if (/− x q\[2\]/.test(t)) await page.uncheck(`#qs-review-${i}`); + await page.click('#qs-review-apply'); + await page.waitForFunction(() => /merged by you/.test(document.querySelector('#qs-vs-list li.here')?.textContent ?? ''), null, { timeout: LONG }); + const merged = await doc(); + check(/x q\[2\];/.test(merged) && /cx q\[0\], q\[2\];/.test(merged) && /from v\d+ and v\d+/.test(await page.textContent('#qs-vs-detail')), + `bringing a line of work back in is a merge of both (${other}, ${otherHead})`); + check(/the assistant, then you \(scripted\): 1 gate/.test(await page.textContent('#qs-vs-share')), 'the merge keeps each line’s author'); + const count = (await page.$$('#qs-vs-list li')).length; + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#qs-vs-list li.here', { timeout: LONG }); + const back = (await page.$$('#qs-vs-list li')).length; + check(back === count && /merged by you/.test(await here()) && (await doc()) === merged, `the versions come back with the page (${back} of ${count}; ${await here()})`); + const files = []; + page.on('download', (d) => files.push(d)); + await page.click('#qs-vs-download'); + await page.waitForFunction(() => /program\.history\.json/.test(document.querySelector('.qs-status')?.textContent ?? ''), null, { timeout: LONG }); + await page.waitForTimeout(500); + const hist = files.find((d) => d.suggestedFilename() === 'program.history.json'); + const saved = hist && JSON.parse(readFileSync(await hist.path(), 'utf8')); + const prog = files.find((d) => d.suggestedFilename() === 'program.qasm'); + check(saved?.versions?.length === count && saved.versions.every((v) => v.seal?.key?.length === 64 && v.digest.length === 64) && prog && readFileSync(await prog.path(), 'utf8') === merged, + `Download saves the program and its history, every version sealed, as wai-quantum history verify reads them (${files.map((d) => d.suggestedFilename()).join(', ')})`); + await page.uncheck('#qs-vs-hands'); +} + // The classical-shortcut lens. { let RING = 'OPENQASM 3.0;\nqubit[16] q;\nbit[16] c;\nh q;\n'; @@ -310,7 +397,10 @@ const wbIdle = () => page.waitForFunction(() => !document.querySelector('#qs-wb- check(/cost 12\.3/.test(card) && /checked by \w+, sealed/.test(card) && /person/.test(card) && !/null/.test(card), `an entry shows its cost, how it was checked, its seal and who wrote it (${card.slice(0, 140)})`); await setProgram(CHAIN); await page.click('#qs-wb-open'); - check((await doc()) === TREE, 'Open in editor puts the kept generator in the editor'); + await page.waitForSelector('#qs-review:not([hidden]) #qs-review-apply', { timeout: LONG }); + await page.click('#qs-review-apply'); + await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, TREE, { timeout: LONG }); + check(/proposed by you/.test(await page.textContent('#qs-vs-list li.here')), 'Open in editor offers the kept generator, recorded as its proposer’s, the reader’s'); await page.click('#qs-wb-ask'); check(/needs one/.test((await wbLog())[0]), 'Ask the model, with none connected, says it needs one'); const files = []; @@ -383,7 +473,11 @@ const wbIdle = () => page.waitForFunction(() => !document.querySelector('#qs-wb- 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'); + await page.waitForSelector('#qs-review:not([hidden]) #qs-review-apply', { timeout: LONG }); + check(/A proposal from the assistant \(scripted\)/.test(await page.textContent('#qs-review')), 'Apply offers the checked program as the assistant’s proposal, named'); + await page.click('#qs-review-apply'); + await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, GOOD, { timeout: LONG }); + check(true, 'taken, it is the program in the editor'); check((await page.inputValue('#qs-ai-stance')) === 'questions' && (await page.inputValue('#qs-ai-initiative')) === 'asked' && /ask one short question/.test(asked[0].messages[0].content), 'by default the assistant asks questions first and speaks only when asked'); const quick = await page.$$eval('.qs-ai-quick button', (bs) => bs.map((b) => b.textContent)); diff --git a/wai/wai-quantum-cli/README.md b/wai/wai-quantum-cli/README.md index cbdf93839..baac791e9 100644 --- a/wai/wai-quantum-cli/README.md +++ b/wai/wai-quantum-cli/README.md @@ -152,6 +152,7 @@ A cheap route proves a design classically easy. None being cheap proves nothing - `draw_circuit` - `verify_quantum_result` - `discover_evaluate`, `discover_submit` and `discover_archive` (the workbench, below) +- `history_commit`, `history_changes`, `history_blame` and `history_log` (a program's history, below) - `list_methods` A tool that takes a program also takes a generator, with its inputs' values in `inputs`. Runs happen on this machine and are signed with this user's key; nothing is sent anywhere. In an MCP client's configuration: @@ -184,6 +185,30 @@ Each kept generator is sealed with your key. Its receipt binds the specification The built-in search is the baseline any proposer should beat. On `ghz-all.json`, 1,500 blind edits took 4.6 s and 24 J: 139 were verified and 29 kept, and none beat the reference. The doubling tree, which a person or a model writes in a few lines, costs 25 against the reference's 49. +## A program's history + +`wai-quantum history` keeps a program's versions in a file beside it (`prog.qasm` → `prog.history.json`): who made each change, how, and whose hand each line and gate is in. + +```sh +wai-quantum history start prog.qasm # the program as it is: the first version +wai-quantum history commit prog.qasm --note "a third qubit" +wai-quantum history changes prog.qasm theirs.qasm # what another program would change, numbered +wai-quantum history propose prog.qasm theirs.qasm --accept 1 --name qwen +wai-quantum history log prog.qasm # every version: who, how, when, what it changed +wai-quantum history blame prog.qasm --input n=5 # each line's author, and the gates in each hand +wai-quantum history merge prog.qasm v4 v6 # the file as two lines of work brought together +wai-quantum run prog.qasm --history prog.history.json --json > job.json +wai-quantum verify job.json --history prog.history.json +``` + +- **Who:** each version is made by the person, the assistant (a model), an outside agent, the engine (a rewrite or the blind search), or where the program came from before the history began. `--by` and `--name` say which. +- **A proposal is reviewed change by change.** `propose` keeps only the changes accepted and writes the result to the program. The version keeps the whole proposal and which changes were taken, so the rejected ones stay readable. +- **Whose hand:** a line keeps its author until someone changes it. A model's or an agent's line that the person then edits is in both hands. An edit back to an earlier program (an undo) keeps that program's authors. The gates follow their lines, and a generator's are counted at `--input`. +- **The chain:** each version's digest covers its parents' digests, its source, who made it and how, and each is sealed with this user's key. `history verify` checks every digest, seal and review. +- **On the receipt:** `run --history` (and `submit --history`) puts the head's account in the job's request: its digest, and the gates and lines in each hand. The receipt binds it, so a changed count fails the signature. `verify --history` recomputes the account from the history, and checks that the job ran that version's program. + +Through `wai-quantum mcp`, an agent's edits (`history_commit`) are recorded as an outside agent's, under the name its client gave when it connected. An agent cannot record a change as the person's. + ## The research tools `wai-quantum lab …` holds the research tools: diff --git a/wai/wai-quantum-cli/src/history.rs b/wai/wai-quantum-cli/src/history.rs new file mode 100644 index 000000000..9071f4bf4 --- /dev/null +++ b/wai/wai-quantum-cli/src/history.rs @@ -0,0 +1,385 @@ +//! `wai-quantum history`: a program's history, attributed +//! ([`wai_quantum::engine::versions`]), in a JSON file beside it +//! (`prog.qasm` → `prog.history.json`). +//! +//! The program file is the working copy. `commit` records it as it now is, +//! `propose` records another program as a proposal and keeps the changes +//! accepted, writing the result back, and `merge` records the file as two +//! versions brought together. Every version is sealed with this user's key. +//! `run --history` puts the head's account (its digest, and the gates and +//! lines in each hand) in the job's request, so the receipt binds it, and +//! `verify --history` recomputes it. + +use clap::{Args, Subcommand}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use wai_quantum::engine::versions::{By, Hand, History, Kind, changes}; + +#[derive(Args)] +pub struct Files { + /// The program, the history's working copy + program: PathBuf, + /// The history (default: beside the program, as NAME.history.json) + #[arg(long)] + history: Option, +} + +#[derive(Args, Clone)] +pub struct Who { + /// Who made it: person, assistant, agent, engine or origin + #[arg(long, default_value = "person", value_parser = Kind::parse)] + by: Kind, + /// Which one: a model's or an agent's name, the rewrite, where the program came from + #[arg(long)] + name: Option, + /// What it does or why, in a sentence + #[arg(long)] + note: Option, +} + +#[derive(Subcommand)] +pub enum Cmd { + /// Start a history with the program as its first version + Start { + #[command(flatten)] + files: Files, + #[command(flatten)] + who: Who, + #[command(flatten)] + key: super::KeyArgs, + }, + /// Record the program as it now is: an edit from the head, or from --parent + Commit { + #[command(flatten)] + files: Files, + /// The version it was changed from (default: the head) + #[arg(long)] + parent: Option, + #[command(flatten)] + who: Who, + #[command(flatten)] + key: super::KeyArgs, + }, + /// The changes another program would make to the head (or --parent), numbered for propose + Changes { + #[command(flatten)] + files: Files, + /// The proposed program + proposed: PathBuf, + #[arg(long)] + parent: Option, + /// Print the changes as JSON + #[arg(long)] + json: bool, + }, + /// Record another program as a proposal, keep the changes accepted, and write the result to the program + Propose { + #[command(flatten)] + files: Files, + /// The proposed program + proposed: PathBuf, + /// The changes accepted: their numbers from `changes` (as 0,2), all, or none + #[arg(long)] + accept: String, + #[arg(long)] + parent: Option, + /// Who proposed it (default: assistant) + #[arg(long, default_value = "assistant", value_parser = Kind::parse)] + by: Kind, + #[arg(long)] + name: Option, + #[arg(long)] + note: Option, + #[command(flatten)] + key: super::KeyArgs, + }, + /// Record the program as versions A and B brought together + Merge { + #[command(flatten)] + files: Files, + a: String, + b: String, + #[command(flatten)] + who: Who, + #[command(flatten)] + key: super::KeyArgs, + }, + /// Every version: who made it, how, when, and what it changed + Log { + #[command(flatten)] + files: Files, + #[arg(long)] + json: bool, + }, + /// Each line of a version (default: the head) and whose hand it is in, and its gates' and lines' counts + Blame { + #[command(flatten)] + files: Files, + /// The version (default: the head) + #[arg(value_name = "VERSION")] + id: Option, + #[arg(long)] + json: bool, + }, + /// Check the chain, every seal and every review; with --job, that a job's receipt carries this history's account + Verify { + #[command(flatten)] + files: Files, + /// A job from `run --json --history` or the service + #[arg(long)] + job: Option, + }, +} + +/// The history beside `program`, or the one given. +pub fn path(program: &Path, given: Option<&Path>) -> PathBuf { + given.map(Path::to_path_buf).unwrap_or_else(|| { + let stem = program.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "program".into()); + program.with_file_name(format!("{stem}.history.json")) + }) +} + +pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e} (wai-quantum history start makes one)", path.display()))?; + let v: Value = serde_json::from_str(&text).map_err(|e| format!("{}: not JSON: {e}", path.display()))?; + History::from_json(&v).map_err(|e| format!("{}: {e}", path.display())) +} + +fn save(h: &History, path: &Path) -> Result<(), String> { + let text = serde_json::to_string_pretty(h).map_err(|e| e.to_string())? + "\n"; + std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display())) +} + +fn now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) +} + +fn key(k: &super::KeyArgs) -> wai_quantum::ed25519_dalek::SigningKey { + wai_quantum_cli::keys::load(k.key.as_deref(), k.demo_key).unwrap_or_else(|e| super::die(&e)).key +} + +fn read(p: &Path) -> Result { + super::read_text(Some(p)) +} + +/// The version a command starts from: the one named, or the head. +fn from(h: &History, parent: Option<&str>) -> Result { + match parent { + Some(p) => h.get(p).map(|v| v.id.clone()).ok_or_else(|| format!("{p}: no such version")), + None => Ok(h.head()?.id.clone()), + } +} + +/// The changes named in `--accept`, out of `offered`. +fn accepted(spec: &str, offered: usize) -> Result, String> { + match spec.trim() { + "all" => Ok((0..offered).collect()), + "none" | "" => Ok(Vec::new()), + s => s.split(',').map(|x| x.trim().parse::().map_err(|_| format!("{x}: a change's number, as 0,2, or all, or none"))).collect(), + } +} + +/// A hand as a reader says it. +pub fn hand_name(h: Hand) -> &'static str { + match h { + Hand::Person => "the person", + Hand::Assistant => "the assistant", + Hand::Agent => "an agent", + Hand::Engine => "the engine", + Hand::Origin => "where it came from", + Hand::AssistantThenPerson => "the assistant, then the person", + Hand::AgentThenPerson => "an agent, then the person", + } +} + +fn who(by: &By) -> String { + let kind = serde_json::to_value(by.kind).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + match &by.name { + Some(n) => format!("{kind} ({n})"), + None => kind, + } +} + +/// The head's account for a job's request: the version whose program is +/// `text`, read at `inputs`. +pub fn attest(h: &History, text: &str, inputs: &wai_quantum::qasm::Inputs) -> Result { + let v = h.heads().into_iter().rev().find(|v| v.source == text).or_else(|| h.versions.iter().rev().find(|v| v.source == text)); + let v = v.ok_or("the program is not a version in its history: wai-quantum history commit records it")?; + h.attest(&v.id, inputs) +} + +pub fn main(cmd: Cmd) { + let r = match cmd { + Cmd::Start { files, who, key: k } => (|| { + let p = path(&files.program, files.history.as_deref()); + if p.exists() { + return Err(format!("{}: a history is there already", p.display())); + } + let h = History::start(&read(&files.program)?, By::new(who.by, who.name.as_deref()), who.note.as_deref(), now(), Some(&key(&k)))?; + save(&h, &p)?; + println!("v0 {} {}", who_of(&h, "v0"), p.display()); + Ok(()) + })(), + Cmd::Commit { files, parent, who, key: k } => (|| { + let p = path(&files.program, files.history.as_deref()); + let mut h = load(&p)?; + let parent = from(&h, parent.as_deref())?; + let id = h.commit(&parent, &read(&files.program)?, By::new(who.by, who.name.as_deref()), who.note.as_deref(), now(), Some(&key(&k)))?.id.clone(); + save(&h, &p)?; + println!("{id} from {parent} {}", who_of(&h, &id)); + Ok(()) + })(), + Cmd::Changes { files, proposed, parent, json } => (|| { + let h = load(&path(&files.program, files.history.as_deref()))?; + let base = h.get(&from(&h, parent.as_deref())?).map(|v| v.source.clone()).unwrap_or_default(); + let ch = changes(&base, &read(&proposed)?); + if json { + println!("{}", json!(ch)); + return Ok(()); + } + if ch.is_empty() { + println!("no changes"); + } + { + for (n, c) in ch.iter().enumerate() { + println!("change {n}, at line {}:", c.line); + for l in &c.removed { + println!(" - {l}"); + } + for l in &c.added { + println!(" + {l}"); + } + } + } + Ok(()) + })(), + Cmd::Propose { files, proposed, accept, parent, by, name, note, key: k } => (|| { + let p = path(&files.program, files.history.as_deref()); + let mut h = load(&p)?; + let parent = from(&h, parent.as_deref())?; + let text = read(&proposed)?; + let offered = changes(&h.get(&parent).map(|v| v.source.clone()).unwrap_or_default(), &text).len(); + let acc = accepted(&accept, offered)?; + let v = h.propose(&parent, &text, &acc, By::new(by, name.as_deref()), note.as_deref(), now(), Some(&key(&k)))?; + let (id, source) = (v.id.clone(), v.source.clone()); + save(&h, &p)?; + std::fs::write(&files.program, &source).map_err(|e| format!("{}: {e}", files.program.display()))?; + println!("{id} from {parent} {}: {} of {offered} changes accepted; {} written", who_of(&h, &id), acc.len(), files.program.display()); + Ok(()) + })(), + Cmd::Merge { files, a, b, who, key: k } => (|| { + let p = path(&files.program, files.history.as_deref()); + let mut h = load(&p)?; + let id = h.merge(&a, &b, &read(&files.program)?, By::new(who.by, who.name.as_deref()), who.note.as_deref(), now(), Some(&key(&k)))?.id.clone(); + save(&h, &p)?; + println!("{id} merges {a} and {b} {}", who_of(&h, &id)); + Ok(()) + })(), + Cmd::Log { files, json } => (|| { + let h = load(&path(&files.program, files.history.as_deref()))?; + let log = h.log(); + if json { + println!("{}", json!(log)); + return Ok(()); + } + for (e, v) in log.iter().zip(&h.versions) { + let how = e["how"].as_str().unwrap_or(""); + let from = if v.parents.is_empty() { String::new() } else { format!(" from {}", v.parents.join(" and ")) }; + let review = e.get("review").map(|r| format!(", {} of {} changes accepted", r["accepted"], r["offered"])).unwrap_or_default(); + let head = if e["head"] == true { " (head)" } else { "" }; + println!("{:<5} {how}{from} by {}: +{} −{}{review}{head}", v.id, who(&v.by), e["added"], e["removed"]); + if let Some(n) = &v.note { + println!(" {n}"); + } + } + Ok(()) + })(), + Cmd::Blame { files, id, json } => (|| { + let h = load(&path(&files.program, files.history.as_deref()))?; + let id = from(&h, id.as_deref())?; + let inputs = super::INPUTS.get().cloned().unwrap_or_default(); + let blame = h.blame(&id)?; + let a = h.authorship(&id, &inputs)?; + if json { + println!("{}", json!({ "version": id, "lines": blame, "authorship": a })); + return Ok(()); + } + let source = h.get(&id).map(|v| v.source.clone()).unwrap_or_default(); + for (l, text) in blame.iter().zip(source.split('\n')) { + if l.version == "v0" && text.is_empty() { + continue; + } + println!("{:<5} {:<28} {text}", l.version, hand_name(l.hand)); + } + println!(); + println!("{id}: {}, {}", count(a.of.gates, "gate"), count(a.of.lines, "line")); + for c in &a.contributors { + let name = c.name.as_deref().map(|n| format!(" ({n})")).unwrap_or_default(); + println!(" {}{name}: {}, {}", hand_name(c.hand), count(c.gates, "gate"), count(c.lines, "line")); + } + Ok(()) + })(), + Cmd::Verify { files, job } => (|| { + let h = load(&path(&files.program, files.history.as_deref()))?; + let c = h.verify(); + println!("history {} versions, {} sealed: {}", c.versions, c.sealed, if c.ok { "every digest, seal and review holds" } else { "DOES NOT HOLD" }); + for p in &c.problems { + println!(" {p}"); + } + let mut ok = c.ok; + if let Some(j) = job { + let v: Value = serde_json::from_str(&read(&j)?).map_err(|e| format!("{}: not JSON: {e}", j.display()))?; + let (sealed, _) = wai_quantum::engine::job::verify(&v)?; + let r = h.check_job(&v)?; + println!("job {}; its receipt {}", if r["ok"] == true { format!("ran {} and carries its account", r["version"].as_str().unwrap_or("")) } else { "does NOT carry this history's account".into() }, if sealed { "verifies" } else { "DOES NOT VERIFY" }); + for p in r["problems"].as_array().into_iter().flatten() { + println!(" {}", p.as_str().unwrap_or("")); + } + ok &= sealed && r["ok"] == true; + } + if !ok { + std::process::exit(1); + } + Ok(()) + })(), + }; + r.unwrap_or_else(|e: String| super::die(&e)); +} + +fn who_of(h: &History, id: &str) -> String { + h.get(id).map(|v| format!("{} by {}", format!("{:?}", v.how).to_lowercase(), who(&v.by))).unwrap_or_default() +} + +fn count(n: u64, what: &str) -> String { + format!("{n} {what}{}", if n == 1 { "" } else { "s" }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_history_sits_beside_its_program() { + assert_eq!(path(Path::new("dir/ghz.qasm"), None), PathBuf::from("dir/ghz.history.json")); + assert_eq!(path(Path::new("ghz.qasm"), Some(Path::new("h.json"))), PathBuf::from("h.json")); + } + + #[test] + fn accepted_changes_read_as_numbers_all_or_none() { + assert_eq!(accepted("0, 2", 3).unwrap(), [0, 2]); + assert_eq!(accepted("all", 3).unwrap(), [0, 1, 2]); + assert!(accepted("none", 3).unwrap().is_empty()); + assert!(accepted("two", 3).is_err()); + } + + #[test] + fn a_run_s_account_is_the_version_whose_program_it_is() { + let bell = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n"; + let mut h = History::start(bell, By::new(Kind::Person, None), None, 1, None).unwrap(); + h.commit("v0", &format!("{bell}x q[0];\n"), By::new(Kind::Agent, Some("a")), None, 2, None).unwrap(); + let inputs = Default::default(); + assert_eq!(attest(&h, bell, &inputs).unwrap()["version"], "v0"); + assert_eq!(attest(&h, &format!("{bell}x q[0];\n"), &inputs).unwrap()["gates"]["agent"], 1); + assert!(attest(&h, "OPENQASM 3.0;\n", &inputs).unwrap_err().contains("history commit")); + } +} diff --git a/wai/wai-quantum-cli/src/main.rs b/wai/wai-quantum-cli/src/main.rs index b573bf4ba..97ba096f7 100644 --- a/wai/wai-quantum-cli/src/main.rs +++ b/wai/wai-quantum-cli/src/main.rs @@ -16,6 +16,8 @@ //! wai-quantum instance qft.qasm --input n=5 # a generator written out as plain OpenQASM //! wai-quantum discover new ghz.json # the workbench: an archive for a specification //! wai-quantum discover search ghz.json # cheaper generators, each checked at every size +//! wai-quantum history blame prog.qasm # whose hand each line and gate is in +//! wai-quantum run prog.qasm --history prog.history.json # the receipt binds who wrote it //! 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 @@ -36,6 +38,7 @@ //! given with `--input`, and `instance` prints that circuit. mod discover; +mod history; mod lab; mod mcp; @@ -153,6 +156,9 @@ enum Command { Verify { /// The JSON; `-` or nothing reads standard input file: Option, + /// Also check that the job carries this history's account of its program (`run --history`) + #[arg(long, value_name = "FILE")] + history: Option, }, /// Show the key this user signs receipts with Key { @@ -181,6 +187,11 @@ enum Command { #[command(subcommand)] cmd: discover::Cmd, }, + /// A program's history, attributed: its versions, who made each change and how, and whose hand each line and gate is in (`wai-quantum history --help`) + History { + #[command(subcommand)] + cmd: history::Cmd, + }, /// 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)] @@ -222,6 +233,9 @@ struct Job { /// The memory a job may take here, GiB #[arg(long, default_value_t = DEFAULT_MEMORY_GB)] memory_gb: u64, + /// The program's history (`wai-quantum history`): the request carries its account of the program (the version's digest and whose hand its gates and lines are in), and the receipt binds it + #[arg(long, value_name = "FILE")] + history: Option, } #[derive(Args, Clone)] @@ -303,6 +317,18 @@ fn read_text(file: Option<&Path>) -> Result { } } +/// The job's program as one circuit, and its request: with `--history`, the +/// request carries the history's account of the version the file holds. +fn job_request(job: &Job) -> Result { + let text = read_text(job.file.as_deref())?; + let inputs = INPUTS.get().cloned().unwrap_or_default(); + let mut req = request(job, at_inputs(text.clone(), &inputs)?); + if let Some(p) = &job.history { + req["history"] = history::attest(&history::load(p)?, &text, &inputs)?; + } + Ok(req) +} + /// 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 }); @@ -471,8 +497,7 @@ fn main() { let _ = INPUTS.set(cli.inputs.iter().cloned().collect()); 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 req = job_request(&job).unwrap_or_else(|e| die(&e)); let k = load_key(&key); let body = run_here(&req, job.memory_gb, &k.key).unwrap_or_else(|e| die(&e)); if out.json { @@ -587,8 +612,7 @@ fn main() { } #[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 req = job_request(&job).unwrap_or_else(|e| die(&e)); let body = submit(&req, &service).unwrap_or_else(|e| die(&e)); if out.json { println!("{body}"); @@ -596,16 +620,26 @@ fn main() { print_job(&body, out.top); } } - Command::Verify { file } => { - let src = read_input(file.as_deref()).unwrap_or_else(|e| die(&e)); + Command::Verify { file, history: hist } => { + let src = read_text(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) = job::verify(&v).unwrap_or_else(|e| die(&e)); + let (mut 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 {}", job::did_of(k).unwrap_or_else(|| k.to_string())); } + if let Some(c) = v["request"].get("history") { + let hands: Vec = c["gates"].as_object().into_iter().flatten().map(|(h, n)| format!("{n} {}", h.replace('_', " "))).collect(); + println!("history {}: {} of {} gates", c["version"].as_str().unwrap_or("?"), hands.join(", "), c["of"]["gates"]); + } + if let Some(p) = hist { + let h = history::load(&p).unwrap_or_else(|e| die(&e)); + let r = h.check_job(&v).unwrap_or_else(|e| die(&e)); + println!("account {}", if r["ok"] == true { "the history's own, recomputed from it".to_string() } else { format!("NOT the history's: {}", r["problems"]) }); + ok &= r["ok"] == true; + } if !ok { std::process::exit(1); } @@ -638,6 +672,7 @@ fn main() { mcp::serve(memory_gb, &k.key); } Command::Discover { cmd } => discover::main(cmd), + Command::History { cmd } => history::main(cmd), Command::Lab { args } => lab::main(args), Command::Completions { shell } => { clap_complete::generate(shell, &mut Cli::command(), "wai-quantum", &mut std::io::stdout()); @@ -671,7 +706,7 @@ mod tests { 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 }, + Job { file: None, shots: None, seed: None, method: None, observables: vec![], amplitudes: vec![], noise: vec![], options: vec![], memory_gb: 1, history: None }, qasm.to_string(), ) } diff --git a/wai/wai-quantum-cli/src/mcp.rs b/wai/wai-quantum-cli/src/mcp.rs index 8b9e07dd1..2b425946e 100644 --- a/wai/wai-quantum-cli/src/mcp.rs +++ b/wai/wai-quantum-cli/src/mcp.rs @@ -2,6 +2,10 @@ //! 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. +//! +//! An agent's edits to a program's history (`history_commit`) are recorded as +//! an outside agent's, under the name it gave when it connected: an agent +//! cannot record a change as the person's. use serde_json::{Value, json}; use std::io::{BufRead, Write}; @@ -114,6 +118,30 @@ fn tools() -> Value { "description": "An archive's summary: what the searches spent (evaluations, seconds, joules) and found (verified, kept, improvements), the reference, the best, the front of the two axes, and the best of every cell, with its proposer, parent and note. With sources, each cell's generator too, to propose from.", "inputSchema": { "type": "object", "required": ["spec"], "properties": { "spec": { "type": "string", "description": "The specification's path." }, "archive": { "type": "string", "description": "The archive's path, if not beside the specification." }, "sources": { "type": "boolean", "description": "Include every cell's generator." } } }, }, + { + "name": "history_commit", + "title": "Record your edit in a program's history", + "description": "Record your change to a program as an edit in its history (prog.qasm → prog.history.json), sealed with this user's key. It is recorded as an outside agent's, under the name you gave when you connected, so the person can see which lines and gates are yours and accept or undo them. Give the program as you leave it in qasm (the file is written), or leave qasm out to record the file as it now is. With no history yet, the file as it was starts one.", + "inputSchema": { "type": "object", "required": ["program"], "properties": { "program": { "type": "string", "description": "The program file's path." }, "qasm": { "type": "string", "description": "The program as you leave it; the file is written with it." }, "parent": { "type": "string", "description": "The version you changed (default: the head)." }, "note": { "type": "string", "description": "What the change does, in a sentence." }, "history": { "type": "string", "description": "The history's path, if not beside the program." } } }, + }, + { + "name": "history_changes", + "title": "The changes a program would make", + "description": "The changes, line by line and numbered, that a program would make to the head of a program's history (or another version): what a person reviews one by one.", + "inputSchema": { "type": "object", "required": ["program", "qasm"], "properties": { "program": { "type": "string", "description": "The program file's path." }, "qasm": { "type": "string", "description": "The proposed program." }, "parent": { "type": "string", "description": "The version to compare with (default: the head)." }, "history": { "type": "string", "description": "The history's path, if not beside the program." } } }, + }, + { + "name": "history_blame", + "title": "Whose hand each line and gate is in", + "description": "Each line of a version (default: the head) and whose hand it is in: the person, the assistant, an agent, the engine, where the program came from, or a model's or an agent's line the person then edited; and the gates (at the inputs given) and lines in each hand, with each contributor's share.", + "inputSchema": { "type": "object", "required": ["program"], "properties": { "program": { "type": "string", "description": "The program file's path." }, "version": { "type": "string", "description": "The version (default: the head)." }, "inputs": inputs_schema(), "history": { "type": "string", "description": "The history's path, if not beside the program." } } }, + }, + { + "name": "history_log", + "title": "A program's history", + "description": "Every version of a program: who made it and how (an edit, a proposal reviewed change by change, a merge), when, what it changed, which versions are heads, and whether the chain, every seal and every review hold.", + "inputSchema": { "type": "object", "required": ["program"], "properties": { "program": { "type": "string", "description": "The program file's path." }, "history": { "type": "string", "description": "The history's path, if not beside the program." } } }, + }, { "name": "list_methods", "title": "List the methods", @@ -123,11 +151,21 @@ fn tools() -> Value { ]) } +/// What the server knows of the agent it serves: the name it gave in +/// `initialize`. +#[derive(Default)] +pub struct Client { + name: Option, +} + /// 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 { +fn call(name: &str, args: &Value, memory_gb: u64, key: &SigningKey, client: &Client) -> Result { if name.starts_with("discover_") { return discover(name, args, key); } + if name.starts_with("history_") { + return history(name, args, key, client); + } let args = &at_inputs(args)?; let qasm = || args.get("qasm").and_then(Value::as_str).ok_or("give \"qasm\": the program"); match name { @@ -224,11 +262,63 @@ fn discover(name: &str, args: &Value, key: &SigningKey) -> Result } } +/// A program's history ([`crate::history`]): an agent's edits recorded as an +/// outside agent's, named as it named itself. +fn history(name: &str, args: &Value, key: &SigningKey, client: &Client) -> Result { + use std::path::Path; + use wai_quantum::engine::versions::{By, History, Kind, changes}; + let text = |k: &str| args.get(k).and_then(Value::as_str); + let program = Path::new(text("program").ok_or("give \"program\": the program file's path")?); + let path = crate::history::path(program, text("history").map(Path::new)); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); + let head = |h: &History, given: Option<&str>| -> Result { Ok(match given { Some(v) => v.to_string(), None => h.head()?.id.clone() }) }; + match name { + "history_commit" => { + let me = By::new(Kind::Agent, Some(client.name.as_deref().unwrap_or("an agent"))); + let on_disk = std::fs::read_to_string(program).map_err(|e| format!("{}: {e}", program.display()))?; + let mut h = if path.exists() { + crate::history::load(&path)? + } else { + let file = program.file_name().map(|f| f.to_string_lossy().into_owned()); + History::start(&on_disk, By::new(Kind::Origin, file.as_deref()), Some("the file as it was"), now, Some(key))? + }; + let source = text("qasm").map(str::to_string).unwrap_or(on_disk); + let parent = head(&h, text("parent"))?; + let id = h.commit(&parent, &source, me, text("note"), now, Some(key))?.id.clone(); + std::fs::write(program, &source).map_err(|e| format!("{}: {e}", program.display()))?; + std::fs::write(&path, serde_json::to_string_pretty(&h).map_err(|e| e.to_string())? + "\n").map_err(|e| format!("{}: {e}", path.display()))?; + Ok(json!({ "version": id, "parent": parent, "by": h.get(&id).map(|v| json!(v.by)), "history": path.display().to_string() })) + } + "history_changes" => { + let h = crate::history::load(&path)?; + let base = h.get(&head(&h, text("parent"))?).map(|v| v.source.clone()).ok_or("no such version")?; + Ok(json!({ "changes": changes(&base, text("qasm").ok_or("give \"qasm\": the proposed program")?) })) + } + "history_blame" => { + let h = crate::history::load(&path)?; + let id = head(&h, text("version"))?; + let inputs: wai_quantum::qasm::Inputs = match args.get("inputs") { + None | Some(Value::Null) => Default::default(), + Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("inputs: {e}"))?, + }; + Ok(json!({ "version": id, "lines": h.blame(&id)?, "authorship": h.authorship(&id, &inputs)? })) + } + "history_log" => { + let h = crate::history::load(&path)?; + Ok(json!({ "versions": h.log(), "check": h.verify() })) + } + _ => 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 { +fn handle(msg: &Value, memory_gb: u64, key: &SigningKey, client: &mut Client) -> 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!({})); + if method == "initialize" { + client.name = params.pointer("/clientInfo/name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(|n| n.chars().take(80).collect()); + } 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 { @@ -243,7 +333,7 @@ fn handle(msg: &Value, memory_gb: u64, key: &SigningKey) -> Option { "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) { + match call(name, &args, memory_gb, key, client) { 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 })), } @@ -256,13 +346,14 @@ fn handle(msg: &Value, memory_gb: u64, key: &SigningKey) -> Option { pub fn serve(memory_gb: u64, key: &SigningKey) { let stdin = std::io::stdin(); let mut out = std::io::stdout().lock(); + let mut client = Client::default(); 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), + Ok(msg) => handle(&msg, memory_gb, key, &mut client), Err(e) => Some(json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": format!("not JSON: {e}") } })), }; if let Some(r) = reply @@ -283,40 +374,41 @@ mod tests { #[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(); + let init = handle(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": PROTOCOL } }), 1, &key(), &mut Client::default()).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(); + assert!(handle(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), 1, &key(), &mut Client::default()).is_none()); + let list = handle(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }), 1, &key(), &mut Client::default()).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", "critique_qasm", "check_equivalent", "improve_qasm", "classical_shortcuts", "draw_circuit", "verify_quantum_result", "discover_evaluate", "discover_submit", "discover_archive", "list_methods"]); + assert_eq!(names, ["run_quantum_circuit", "plan_quantum_circuit", "lint_qasm", "critique_qasm", "check_equivalent", "improve_qasm", "classical_shortcuts", "draw_circuit", "verify_quantum_result", "discover_evaluate", "discover_submit", "discover_archive", "history_commit", "history_changes", "history_blame", "history_log", "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(), + &mut Client::default(), ) .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(); + let check = handle(&json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "verify_quantum_result", "arguments": { "job": job } } }), 1, &key(), &mut Client::default()).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(); + 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(), &mut Client::default()).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(); + 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(), &mut Client::default()).unwrap(); assert_eq!(lint["result"]["structuredContent"]["line"], 2, "{lint}"); - let none = handle(&json!({ "jsonrpc": "2.0", "id": 3, "method": "nonsense" }), 1, &key()).unwrap(); + let none = handle(&json!({ "jsonrpc": "2.0", "id": 3, "method": "nonsense" }), 1, &key(), &mut Client::default()).unwrap(); assert_eq!(none["error"]["code"], -32601); } #[test] fn an_agent_critiques_compares_and_improves_a_program() { - let tool = |name: &str, args: Value| handle(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": args } }), 1, &key()).unwrap()["result"].clone(); + let tool = |name: &str, args: Value| handle(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": args } }), 1, &key(), &mut Client::default()).unwrap()["result"].clone(); let draft = "qubit[2] q;\nh q[0];\nh q[0];\ncx q[0], q[1];\n"; let c = tool("critique_qasm", json!({ "qasm": draft })); assert_eq!(c["structuredContent"]["smells"][0]["kind"], "cancels", "{c}"); @@ -338,13 +430,40 @@ mod tests { "inputs": { "n": [3, 5] }, "cost": { "depth": 1.0 } }); let star = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [1:n-1] { cx q[0], q[i]; }\n"; - let v = call("discover_evaluate", &json!({ "spec": spec, "qasm": star }), 1, &key()).unwrap(); + let v = call("discover_evaluate", &json!({ "spec": spec, "qasm": star }), 1, &key(), &Client::default()).unwrap(); assert_eq!(v["verified"], true, "{v}"); assert_eq!(v["strength"], "state"); // A tool that takes a program takes a generator with its inputs. - let lint = call("lint_qasm", &json!({ "qasm": star, "inputs": { "n": 4 } }), 1, &key()).unwrap(); + let lint = call("lint_qasm", &json!({ "qasm": star, "inputs": { "n": 4 } }), 1, &key(), &Client::default()).unwrap(); assert_eq!((lint["qubits"].as_u64(), lint["gates"].as_u64()), (Some(4), Some(4)), "{lint}"); - assert!(call("lint_qasm", &json!({ "qasm": star }), 1, &key()).unwrap_err().contains("give n a value")); + assert!(call("lint_qasm", &json!({ "qasm": star }), 1, &key(), &Client::default()).unwrap_err().contains("give n a value")); + } + + #[test] + fn an_agent_s_edit_is_recorded_as_its_own_under_its_name() { + let dir = std::env::temp_dir().join(format!("wai-quantum-mcp-history-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let prog = dir.join("bell.qasm"); + std::fs::write(&prog, "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n").unwrap(); + let mut client = Client::default(); + let hello = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": PROTOCOL, "clientInfo": { "name": "test-agent", "version": "1" } } }); + handle(&hello, 1, &key(), &mut client).unwrap(); + let tool = |client: &mut Client, name: &str, args: Value| handle(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": name, "arguments": args } }), 1, &key(), client).unwrap()["result"]["structuredContent"].clone(); + let p = prog.to_string_lossy().to_string(); + // It names itself the person; the server records it as the agent it is. + let edit = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\nx q[1];\n"; + let c = tool(&mut client, "history_commit", json!({ "program": p, "qasm": edit, "note": "flip the target", "by": "person" })); + assert_eq!(c["version"], "v1", "{c}"); + assert_eq!(c["by"], json!({ "kind": "agent", "name": "test-agent" })); + assert_eq!(std::fs::read_to_string(&prog).unwrap(), edit); + let b = tool(&mut client, "history_blame", json!({ "program": p })); + assert_eq!(b["authorship"]["gates"], json!({ "origin": 2, "agent": 1 }), "{b}"); + let ch = tool(&mut client, "history_changes", json!({ "program": p, "qasm": "OPENQASM 3.0;\nqubit[2] q;\nh q[1];\ncx q[0], q[1];\nx q[1];\n" })); + assert_eq!(ch["changes"].as_array().map(Vec::len), Some(1), "{ch}"); + let log = tool(&mut client, "history_log", json!({ "program": p })); + assert_eq!(log["check"]["ok"], true, "{log}"); + assert_eq!(log["versions"][0]["by"]["kind"], "origin"); + std::fs::remove_dir_all(&dir).ok(); } } diff --git a/wai/wai-quantum-desktop/src/main.rs b/wai/wai-quantum-desktop/src/main.rs index b090da11f..caf403b60 100644 --- a/wai/wai-quantum-desktop/src/main.rs +++ b/wai/wai-quantum-desktop/src/main.rs @@ -26,7 +26,7 @@ use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; use wai_quantum::ed25519_dalek::SigningKey; use wai_quantum::engine::discover::{Archive, Budget, Evaluator, SearchOptions, Spec, search}; -use wai_quantum::engine::{catalog, critique, draw, energy, equiv, job, shortcuts}; +use wai_quantum::engine::{catalog, critique, draw, energy, equiv, job, shortcuts, versions}; /// What a result says of its energy here. const UNMETERED: &str = "the desktop app does not meter a run's energy"; @@ -193,6 +193,22 @@ fn discover_start(studio: tauri::State<'_, Studio>, spec: String) -> String { evaluator(&spec).and_then(|ev| Archive::new_sealed(&ev, Some(&studio.key)).map(|a| workbench(&ev, &a, json!({})))).unwrap_or_else(error_json) } +/// `name` on the program's history ([`versions::call`]), its arguments as +/// JSON, each version recorded sealed with the app's key: the answer, or +/// `{error}`. +#[tauri::command] +fn history(studio: tauri::State<'_, Studio>, name: String, args: String) -> String { + history_in(&studio.key, &name, &args) +} + +fn history_in(key: &SigningKey, name: &str, args: &str) -> String { + serde_json::from_str::(args) + .map_err(|e| format!("{name}: the arguments are not JSON: {e}")) + .and_then(|a| versions::call(name, &a, Some(key))) + .map(|v| v.to_string()) + .unwrap_or_else(error_json) +} + fn submit_in(key: &SigningKey, spec: &str, archive: &str, source: &str, parent: Option<&str>, proposer: &str, note: Option<&str>) -> Result { let ev = evaluator(spec)?; let mut a = read_archive(&ev, archive)?; @@ -385,7 +401,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, critique, equivalent, variants, shortcuts, draw, layout, verify, signer, save_job, save_program, chat, models, discover_start, discover_submit, discover_search, discover_evaluate, instantiate]) + .invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, critique, equivalent, variants, shortcuts, draw, layout, verify, signer, save_job, save_program, chat, models, discover_start, discover_submit, discover_search, discover_evaluate, instantiate, history]) .run(tauri::generate_context!()) .expect("the window opens"); } @@ -440,4 +456,16 @@ mod tests { let s: Value = serde_json::from_str(&tauri::async_runtime::block_on(shortcuts("qubit[2] q; h q[0]; cx q[0], q[1];".into(), false))).unwrap(); assert!(s["summary"].as_str().unwrap().starts_with("Classically easy"), "{s}"); } + + #[test] + fn the_history_records_with_the_app_s_key_and_reads_back() { + let key = SigningKey::from_bytes(&[3; 32]); + let bell = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n"; + let r: Value = serde_json::from_str(&history_in(&key, "start", &json!({ "source": bell, "by": { "kind": "person" }, "at": 1 }).to_string())).unwrap(); + assert_eq!(r["version"], "v0", "{r}"); + let r: Value = serde_json::from_str(&history_in(&key, "commit", &json!({ "history": r["history"], "parent": "v0", "source": format!("{bell}x q[1];\n"), "by": { "kind": "assistant", "name": "m" }, "at": 2 }).to_string())).unwrap(); + let log: Value = serde_json::from_str(&history_in(&key, "log", &json!({ "history": r["history"] }).to_string())).unwrap(); + assert_eq!((log["check"]["ok"].as_bool(), log["check"]["sealed"].as_u64()), (Some(true), Some(2)), "{log}"); + assert!(history_in(&key, "start", "not json").contains("not JSON")); + } } diff --git a/wai/wai-quantum/README.md b/wai/wai-quantum/README.md index 4677934bc..2fc9a840a 100644 --- a/wai/wai-quantum/README.md +++ b/wai/wai-quantum/README.md @@ -68,7 +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. Generators: integer `input`s, `const`s and `for` loops (ranges, steps, sets, nested), with integer arithmetic in indices and sizes and `if` on integers decided on reading, so one program describes a family of circuits; `qasm::parse_with` reads it at given inputs and `qasm::instantiate` writes that circuit out as plain OpenQASM. `while`, `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`); the specification-and-evaluator workbench (`engine::discover`: a specification, the engine's verdict on a generator at every size, and an archive of the cheapest verified generator in every cell of two measures, sealed); and the CPU energy the kernel counts for one process (`engine::energy`). It builds for `wasm32-unknown-unknown`, where it runs on one thread and claims no time | +| `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`); the specification-and-evaluator workbench (`engine::discover`: a specification, the engine's verdict on a generator at every size, and an archive of the cheapest verified generator in every cell of two measures, sealed); a program's history, attributed (`engine::versions`: versions in a sealed chain, each with who made it and how, proposals reviewed change by change, and each line and gate in the hand that wrote it, which a job's receipt can bind); and the CPU energy the kernel counts for one process (`engine::energy`). 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/mod.rs b/wai/wai-quantum/src/engine/mod.rs index 9c1994870..64ebb45aa 100644 --- a/wai/wai-quantum/src/engine/mod.rs +++ b/wai/wai-quantum/src/engine/mod.rs @@ -35,6 +35,11 @@ //! workbench. A person writes what a family of circuits must do and what it //! costs, proposers write generators, the engine checks each at every size, //! and an archive keeps the cheapest verified generator of every kind. +//! - [`versions`] (with `quantum_receipt`): a program's history, attributed. +//! Every version names its parents, who made it (the person, a model, an +//! outside agent, the engine) and how (an edit, a proposal reviewed change +//! by change, a merge), in a sealed chain. Each line and each gate is in the +//! hand that wrote it, and a job's receipt can bind the count. //! //! 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 @@ -54,3 +59,5 @@ pub mod job; pub mod receipt; pub mod run; pub mod shortcuts; +#[cfg(feature = "quantum_receipt")] +pub mod versions; diff --git a/wai/wai-quantum/src/engine/versions.rs b/wai/wai-quantum/src/engine/versions.rs new file mode 100644 index 000000000..453ff4750 --- /dev/null +++ b/wai/wai-quantum/src/engine/versions.rs @@ -0,0 +1,1055 @@ +//! A program's history, attributed: every version, who made each change and +//! how, and whose hand each line and each gate is in. +//! +//! - **Versions** ([`Version`]) form a chain. Each names its parents: none +//! for the first, one for a change, two for a merge. A history branches +//! wherever a change is made from an older version, and two lines of work +//! come together where a person merges them. A version's digest covers its +//! parents' digests, its source, who made it and how, so the digest of the +//! last stands for the whole line of work before it. Each version is sealed +//! with the key of the host that recorded it. +//! - **Who** ([`By`]): the person; the assistant, a model the person asked; +//! an outside agent, through the MCP server or the command line; the +//! engine, whose rewrites the person applied; or where the program came +//! from before the history began (a file, an example, a link). +//! - **How** ([`How`]): the first version; an edit, made directly; a +//! proposal, reviewed change by change ([`Review`] keeps the whole proposal +//! and the changes the person accepted, so the rejected ones stay readable); +//! or a merge. +//! - **Whose hand** ([`Hand`]): [`History::blame`] follows each line through +//! the versions, as a line-level diff matches them. A line keeps its author +//! until someone changes it. A model's or an agent's line that the person +//! then edits is in both hands (`assistant_then_person`, +//! `agent_then_person`). The gates follow their lines +//! ([`crate::qasm::parse_lines_with`]): a generator's gates are counted at +//! given inputs, each by the line in the loop that makes it. +//! - **On a receipt**: [`History::attest`] gives a version's digest and the +//! gates and lines in each hand. A host puts it in a job's request as +//! `history`, so the receipt binds it, and [`History::check_job`] +//! recomputes it from the history and checks that the job ran that +//! version's program. +//! +//! The hands are what the hosts recorded: a Studio records a typed edit as +//! the person's and an applied proposal as its proposer's. Nothing here can +//! tell who pressed the keys, and nothing reads a clock: each change is given +//! its time. + +use crate::engine::receipt; +use crate::qasm::{self, Inputs}; +use ed25519_dalek::SigningKey; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +const DOMAIN: &[u8] = b"wai:quantum-history-version\x01"; + +/// The most versions a history may hold. +pub const MAX_VERSIONS: usize = 100_000; + +/// The most edits between two versions, past the lines they share at their +/// start and end, that the diff follows line by line; beyond, the lines +/// between are one change. +const MAX_EDITS: usize = 2_000; + +/// A time, in milliseconds, must be a whole number JSON carries exactly. +const SAFE: u64 = (1 << 53) - 1; + +/// Who made a change. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + /// The person working on the program. + Person, + /// A model the person asked: the Studio's assistant, a model proposing in + /// the workbench. + Assistant, + /// An outside agent, through the MCP server or the command line. + Agent, + /// The engine: a rewrite it made and checked, or its blind search. + Engine, + /// Where the program came from before the history: a file, an example, a + /// link. + Origin, +} + +impl Kind { + /// The kind a word names. + pub fn parse(s: &str) -> Result { + Ok(match s { + "person" => Kind::Person, + "assistant" => Kind::Assistant, + "agent" => Kind::Agent, + "engine" => Kind::Engine, + "origin" => Kind::Origin, + _ => return Err(format!("{s}: who is one of person, assistant, agent, engine and origin")), + }) + } +} + +/// Who made a change, and which one of them. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct By { + pub kind: Kind, + /// Which one: the model's or the agent's name, the rewrite, the file or + /// the example. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl By { + pub fn new(kind: Kind, name: Option<&str>) -> By { + By { kind, name: name.filter(|n| !n.is_empty()).map(str::to_string) } + } +} + +/// How a version was made. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum How { + /// The first version. + Start, + /// An edit, made directly. + Edit, + /// A proposal, reviewed change by change. + Proposal, + /// Two versions brought together. + Merge, +} + +/// What became of a proposal. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Review { + /// The program as it was proposed. + pub proposed: String, + /// The changes it made to its parent ([`changes`]). + pub offered: usize, + /// The ones the person accepted, by index, in order; the rest were + /// rejected. + pub accepted: Vec, +} + +/// One version of the program. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Version { + /// `v0`, `v1`, … in the order recorded. + pub id: String, + /// The versions it came from, by id. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parents: Vec, + pub source: String, + pub by: By, + pub how: How, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option, + /// When it was recorded, in milliseconds since 1970, by the recording + /// host's clock. + pub at: u64, + /// BLAKE3 over its parents' digests, its source, who, how, its note, its + /// review and its time. + pub digest: String, + /// The digest sealed with the recording host's key ([`receipt::seal`] + /// over `{"history_version": digest}`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seal: Option, +} + +/// Whose hand a line, or a gate, is in. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "snake_case")] +pub enum Hand { + Person, + Assistant, + Agent, + Engine, + Origin, + /// A model's line that the person then edited. + AssistantThenPerson, + /// An agent's line that the person then edited. + AgentThenPerson, +} + +impl Hand { + fn of(kind: Kind) -> Hand { + match kind { + Kind::Person => Hand::Person, + Kind::Assistant => Hand::Assistant, + Kind::Agent => Hand::Agent, + Kind::Engine => Hand::Engine, + Kind::Origin => Hand::Origin, + } + } +} + +/// A line's author, as [`History::blame`] gives it. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct Line { + /// The version that last wrote it. + pub version: String, + pub hand: Hand, + /// For a line in two hands, the version of the model or the agent whose + /// line the person edited. + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option, +} + +/// One contributor's share. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Contributor { + pub kind: Kind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub hand: Hand, + pub gates: u64, + pub lines: u64, +} + +/// Whose hand a version's gates and lines are in. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +pub struct Authorship { + /// Gates in each hand. + pub gates: BTreeMap, + /// Lines in each hand, blank lines and comments left out. + pub lines: BTreeMap, + /// Every gate and every counted line. + pub of: Of, + /// Each one who wrote any of them, the most gates first. + pub contributors: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +pub struct Of { + pub gates: u64, + pub lines: u64, +} + +/// One change a proposal makes to its parent: lines removed and lines added +/// in their place. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Change { + /// The parent's line it starts at, from 1; an insertion goes before it. + pub line: usize, + pub removed: Vec, + pub added: Vec, +} + +/// What [`History::verify`] found. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct Check { + /// Every version is in order, its digest recomputes, its seal holds and + /// its review gives its source. + pub ok: bool, + pub versions: usize, + /// Versions with a seal, all of which hold when `ok`. + pub sealed: usize, + pub problems: Vec, +} + +/// A version's line and its author, as the blame works with them: the +/// version index that wrote it, its hand, and for a line in two hands, the +/// model's or the agent's version. +#[derive(Clone, Copy, Debug)] +struct Cell { + by: usize, + hand: Hand, + from: Option, +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn lines(s: &str) -> Vec<&str> { + s.split('\n').collect() +} + +/// The lines of `a` and `b` that match, as `(index in a, index in b)` in +/// increasing order: the lines the two share at their start and end, and a +/// longest common subsequence of those between (Myers' algorithm), or none +/// of those when they differ by more than [`MAX_EDITS`]. +fn matches(a: &[&str], b: &[&str]) -> Vec<(usize, usize)> { + let mut pre = 0; + while pre < a.len() && pre < b.len() && a[pre] == b[pre] { + pre += 1; + } + let mut suf = 0; + while suf < a.len() - pre && suf < b.len() - pre && a[a.len() - 1 - suf] == b[b.len() - 1 - suf] { + suf += 1; + } + let mut out: Vec<(usize, usize)> = (0..pre).map(|i| (i, i)).collect(); + let mid = myers(&a[pre..a.len() - suf], &b[pre..b.len() - suf]); + out.extend(mid.into_iter().map(|(i, j)| (i + pre, j + pre))); + out.extend((0..suf).map(|k| (a.len() - suf + k, b.len() - suf + k))); + out +} + +/// A longest common subsequence of `a` and `b` by Myers' greedy algorithm, +/// keeping each step's frontier to walk back; none past [`MAX_EDITS`]. +fn myers(a: &[&str], b: &[&str]) -> Vec<(usize, usize)> { + let (n, m) = (a.len() as isize, b.len() as isize); + if n == 0 || m == 0 { + return Vec::new(); + } + let max = (n + m) as usize; + let at = |k: isize| (k + max as isize) as usize; + let mut v = vec![0isize; 2 * max + 2]; + // trace[d]: the frontier as step d began, for diagonals -d..=d. + let mut trace: Vec> = Vec::new(); + for d in 0..=(max.min(MAX_EDITS) as isize) { + trace.push(v[at(-d)..=at(d)].to_vec()); + for k in (-d..=d).step_by(2) { + let mut x = if k == -d || (k != d && v[at(k - 1)] < v[at(k + 1)]) { v[at(k + 1)] } else { v[at(k - 1)] + 1 }; + let mut y = x - k; + while x < n && y < m && a[x as usize] == b[y as usize] { + x += 1; + y += 1; + } + v[at(k)] = x; + if x >= n && y >= m { + return walk_back(&trace, n, m); + } + } + } + Vec::new() +} + +fn walk_back(trace: &[Vec], n: isize, m: isize) -> Vec<(usize, usize)> { + let (mut x, mut y) = (n, m); + let mut out = Vec::new(); + for d in (1..trace.len() as isize).rev() { + let vd = &trace[d as usize]; + let get = |k: isize| vd[(k + d) as usize]; + let k = x - y; + let prev = if k == -d || (k != d && get(k - 1) < get(k + 1)) { k + 1 } else { k - 1 }; + let (px, py) = (get(prev), get(prev) - prev); + // The edit took (px, py) to (sx, sy); the snake ran from there to (x, y). + let (sx, sy) = if prev == k + 1 { (px, py + 1) } else { (px + 1, py) }; + while x > sx && y > sy { + x -= 1; + y -= 1; + out.push((x as usize, y as usize)); + } + x = px; + y = py; + } + while x > 0 && y > 0 { + x -= 1; + y -= 1; + out.push((x as usize, y as usize)); + } + out.reverse(); + out +} + +/// The gaps between `matches` of `a` (length `na`) and `b` (length `nb`): +/// each `(a_from, a_to, b_from, b_to)`. +fn gaps(matches: &[(usize, usize)], na: usize, nb: usize) -> Vec<(usize, usize, usize, usize)> { + let mut out = Vec::new(); + let (mut i, mut j) = (0, 0); + for &(x, y) in matches.iter().chain(std::iter::once(&(na, nb))) { + if x > i || y > j { + out.push((i, x, j, y)); + } + i = x + 1; + j = y + 1; + } + out +} + +/// The changes that make `proposed` from `base`, line by line, in order. +pub fn changes(base: &str, proposed: &str) -> Vec { + let (a, b) = (lines(base), lines(proposed)); + gaps(&matches(&a, &b), a.len(), b.len()) + .into_iter() + .map(|(i0, i1, j0, j1)| Change { + line: i0 + 1, + removed: a[i0..i1].iter().map(|s| s.to_string()).collect(), + added: b[j0..j1].iter().map(|s| s.to_string()).collect(), + }) + .collect() +} + +/// `base` with the `accepted` ones of [`changes`]`(base, proposed)` made and +/// the rest not: all of them give `proposed`, none gives `base`. +pub fn apply(base: &str, proposed: &str, accepted: &[usize]) -> Result { + let (a, b) = (lines(base), lines(proposed)); + let hs = gaps(&matches(&a, &b), a.len(), b.len()); + if let Some(&k) = accepted.iter().find(|&&k| k >= hs.len()) { + return Err(format!("change {k}: the proposal makes {} change{}", hs.len(), if hs.len() == 1 { "" } else { "s" })); + } + let mut out: Vec<&str> = Vec::new(); + let mut i = 0; + for (n, &(i0, i1, j0, j1)) in hs.iter().enumerate() { + out.extend(&a[i..i0]); + out.extend(if accepted.contains(&n) { &b[j0..j1] } else { &a[i0..i1] }); + i = i1; + } + out.extend(&a[i..]); + Ok(out.join("\n")) +} + +/// A line's words: names, numbers and gate names, for telling an edited +/// line from a new one. +fn words(s: &str) -> BTreeSet<&str> { + s.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.')).filter(|w| !w.is_empty()).collect() +} + +/// Whether `b` reads as an edit of `a`: they share at least half their +/// words. +fn alike(a: &str, b: &str) -> bool { + let (x, y) = (words(a), words(b)); + if x.is_empty() || y.is_empty() { + return false; + } + 2 * x.intersection(&y).count() >= x.union(&y).count() +} + +/// The hand a line is in after `kind` writes it, `old` being the line it +/// edited. +fn hand_after(kind: Kind, old: Option) -> (Hand, Option) { + match (kind, old) { + (Kind::Person, Some(c)) => match c.hand { + Hand::Assistant | Hand::AssistantThenPerson => (Hand::AssistantThenPerson, Some(c.from.unwrap_or(c.by))), + Hand::Agent | Hand::AgentThenPerson => (Hand::AgentThenPerson, Some(c.from.unwrap_or(c.by))), + _ => (Hand::Person, None), + }, + (k, _) => (Hand::of(k), None), + } +} + +/// Whether a line counts in the authorship: not blank, not only a comment. +fn counted(line: &str) -> bool { + let t = line.trim(); + !t.is_empty() && !t.starts_with("//") +} + +fn version_digest(parents: &[&str], source: &str, by: &By, how: How, note: Option<&str>, review: Option<&Review>, at: u64) -> String { + let body = json!({ "parents": parents, "source": source, "by": by, "how": how, "note": note, "review": review, "at": at }); + let mut h = blake3::Hasher::new(); + h.update(DOMAIN); + h.update(&receipt::canonical(&body)); + hex(h.finalize().as_bytes()) +} + +fn seal_request(digest: &str) -> Value { + json!({ "history_version": digest }) +} + +/// A program's versions, from the first. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +pub struct History { + pub versions: Vec, +} + +impl History { + /// A history whose first version is `source`, made by `by`. + pub fn start(source: &str, by: By, note: Option<&str>, at: u64, key: Option<&SigningKey>) -> Result { + let mut h = History::default(); + h.push(Vec::new(), source.to_string(), by, How::Start, note, None, at, key)?; + Ok(h) + } + + /// A history read from its JSON. [`History::verify`] checks it. + pub fn from_json(v: &Value) -> Result { + serde_json::from_value(v.clone()).map_err(|e| format!("not a history: {e}")) + } + + fn positions(&self) -> HashMap<&str, usize> { + self.versions.iter().enumerate().map(|(i, v)| (v.id.as_str(), i)).collect() + } + + fn index(&self, id: &str) -> Result { + self.versions.iter().position(|v| v.id == id).ok_or_else(|| format!("{id}: no such version")) + } + + pub fn get(&self, id: &str) -> Option<&Version> { + self.versions.iter().find(|v| v.id == id) + } + + /// The version whose digest is `digest`. + pub fn by_digest(&self, digest: &str) -> Option<&Version> { + self.versions.iter().find(|v| v.digest == digest) + } + + /// The versions no other version came from: where each line of work + /// stands, oldest first. + pub fn heads(&self) -> Vec<&Version> { + let parents: BTreeSet<&str> = self.versions.iter().flat_map(|v| v.parents.iter().map(String::as_str)).collect(); + self.versions.iter().filter(|v| !parents.contains(v.id.as_str())).collect() + } + + /// The one head, when the history has one. + pub fn head(&self) -> Result<&Version, String> { + match self.heads().as_slice() { + [one] => Ok(one), + [] => Err("the history is empty".into()), + many => Err(format!("the history has {} heads ({}): name the version", many.len(), many.iter().map(|v| v.id.as_str()).collect::>().join(", "))), + } + } + + #[allow(clippy::too_many_arguments)] + fn push(&mut self, parents: Vec, source: String, by: By, how: How, note: Option<&str>, review: Option, at: u64, key: Option<&SigningKey>) -> Result<&Version, String> { + if self.versions.len() >= MAX_VERSIONS { + return Err(format!("a history holds at most {MAX_VERSIONS} versions")); + } + if at > SAFE { + return Err(format!("{at}: a time is in milliseconds, at most {SAFE}")); + } + let note = note.filter(|n| !n.is_empty()); + let digests: Vec<&str> = parents.iter().map(|&p| self.versions[p].digest.as_str()).collect(); + let digest = version_digest(&digests, &source, &by, how, note, review.as_ref(), at); + let seal = key.map(|k| receipt::seal(k, &seal_request(&digest), &json!({}))); + let parents = parents.iter().map(|&p| self.versions[p].id.clone()).collect(); + self.versions.push(Version { id: format!("v{}", self.versions.len()), parents, source, by, how, note: note.map(str::to_string), review, at, digest, seal }); + Ok(self.versions.last().expect("just pushed")) + } + + /// An edit made from `parent`, giving `source`. + pub fn commit(&mut self, parent: &str, source: &str, by: By, note: Option<&str>, at: u64, key: Option<&SigningKey>) -> Result<&Version, String> { + let p = self.index(parent)?; + if self.versions[p].source == source { + return Err(format!("nothing changed from {parent}")); + } + self.push(vec![p], source.to_string(), by, How::Edit, note, None, at, key) + } + + /// A proposal from `by` to change `parent` into `proposed`, reviewed: the + /// person accepted the changes `accepted` ([`changes`] from `parent`'s + /// source) and rejected the rest. A proposal rejected whole is recorded + /// too, its source its parent's. + #[allow(clippy::too_many_arguments)] + pub fn propose(&mut self, parent: &str, proposed: &str, accepted: &[usize], by: By, note: Option<&str>, at: u64, key: Option<&SigningKey>) -> Result<&Version, String> { + let p = self.index(parent)?; + let base = self.versions[p].source.clone(); + let offered = changes(&base, proposed).len(); + if offered == 0 { + return Err(format!("the proposal is {parent} as it is")); + } + let source = apply(&base, proposed, accepted)?; + let mut accepted = accepted.to_vec(); + accepted.sort_unstable(); + accepted.dedup(); + let review = Review { proposed: proposed.to_string(), offered, accepted }; + self.push(vec![p], source, by, How::Proposal, note, Some(review), at, key) + } + + /// `a` and `b` brought together by `by` as `source`. + #[allow(clippy::too_many_arguments)] + pub fn merge(&mut self, a: &str, b: &str, source: &str, by: By, note: Option<&str>, at: u64, key: Option<&SigningKey>) -> Result<&Version, String> { + let (pa, pb) = (self.index(a)?, self.index(b)?); + if pa == pb { + return Err(format!("{a} cannot be merged with itself")); + } + self.push(vec![pa, pb], source.to_string(), by, How::Merge, note, None, at, key) + } + + /// Every version in order, its digest, its seal and, for a proposal, + /// that its review gives its source. + pub fn verify(&self) -> Check { + let mut problems = Vec::new(); + let mut sealed = 0; + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + for (i, v) in self.versions.iter().enumerate() { + if v.id != format!("v{i}") { + problems.push(format!("version {i} is named {}, not v{i}", v.id)); + } + let parents: Option> = v.parents.iter().map(|p| seen.get(p.as_str()).copied()).collect(); + let Some(parents) = parents else { + problems.push(format!("{}: a parent is not an earlier version", v.id)); + seen.insert(v.id.as_str(), i); + continue; + }; + let shape = match v.how { + How::Start => parents.is_empty() && i == 0, + How::Edit | How::Proposal => parents.len() == 1, + How::Merge => parents.len() == 2 && parents[0] != parents[1], + }; + if !shape || (i == 0 && v.how != How::Start) { + problems.push(format!("{}: a {:?} with {} parent{}", v.id, v.how, parents.len(), if parents.len() == 1 { "" } else { "s" }).to_lowercase()); + } + let digests: Vec<&str> = parents.iter().map(|&p| self.versions[p].digest.as_str()).collect(); + if version_digest(&digests, &v.source, &v.by, v.how, v.note.as_deref(), v.review.as_ref(), v.at) != v.digest { + problems.push(format!("{}: its digest does not recompute", v.id)); + } + if let Some(s) = &v.seal { + sealed += 1; + if !receipt::verify(&seal_request(&v.digest), &json!({}), s) { + problems.push(format!("{}: its seal does not hold", v.id)); + } + } + if let (Some(r), [p]) = (&v.review, parents.as_slice()) { + let base = &self.versions[*p].source; + if changes(base, &r.proposed).len() != r.offered || apply(base, &r.proposed, &r.accepted).as_deref() != Ok(v.source.as_str()) { + problems.push(format!("{}: its review does not give its source", v.id)); + } + } else if v.review.is_some() != (v.how == How::Proposal) { + problems.push(format!("{}: only a proposal has a review", v.id)); + } + seen.insert(v.id.as_str(), i); + } + Check { ok: problems.is_empty(), versions: self.versions.len(), sealed, problems } + } + + /// Each line's author in every version up to `upto`, by index. + fn cells(&self, upto: usize) -> Result>, String> { + let at = self.positions(); + let mut all: Vec> = Vec::with_capacity(upto + 1); + // The last version so far with each program: an edit back to one (an + // undo, a version reopened and saved) keeps that version's authors. + let mut same: HashMap<&str, usize> = HashMap::new(); + for (i, v) in self.versions.iter().enumerate().take(upto + 1) { + if let (How::Edit, Some(&j)) = (v.how, same.get(v.source.as_str())) { + all.push(all[j].clone()); + same.insert(v.source.as_str(), i); + continue; + } + let new = lines(&v.source); + let parents: Vec = v.parents.iter().map(|p| at.get(p.as_str()).copied().filter(|&p| p < i).ok_or_else(|| format!("{}: a parent is not an earlier version", v.id))).collect::>()?; + let mut out: Vec> = vec![None; new.len()]; + let mut first = None; + for &p in &parents { + let old = lines(&self.versions[p].source); + let m = matches(&old, &new); + for &(x, y) in &m { + out[y].get_or_insert(all[p][x]); + } + first.get_or_insert((p, old, m)); + } + if let (Some((p, old, m)), 1) = (first, parents.len()) { + // An edited line: the first line it replaced that it reads like. + for (i0, i1, j0, j1) in gaps(&m, old.len(), new.len()) { + let mut next = i0; + for (j, slot) in out.iter_mut().enumerate().take(j1).skip(j0) { + let was = (next..i1).find(|&x| alike(old[x], new[j])); + if let Some(x) = was { + next = x + 1; + } + let (hand, from) = hand_after(v.by.kind, was.map(|x| all[p][x])); + *slot = Some(Cell { by: i, hand, from }); + } + } + } + all.push(out.into_iter().map(|c| c.unwrap_or(Cell { by: i, hand: Hand::of(v.by.kind), from: None })).collect()); + same.insert(v.source.as_str(), i); + } + Ok(all) + } + + /// Each line of version `id` and its author. + pub fn blame(&self, id: &str) -> Result, String> { + let i = self.index(id)?; + let cells = self.cells(i)?; + Ok(cells[i].iter().map(|c| Line { version: self.versions[c.by].id.clone(), hand: c.hand, from: c.from.map(|f| self.versions[f].id.clone()) }).collect()) + } + + /// Whose hand each of version `id`'s gates, read at `inputs`, is in, in + /// the program's order (as [`crate::engine::draw::layout`] numbers them). + pub fn gate_hands(&self, id: &str, inputs: &Inputs) -> Result, String> { + let i = self.index(id)?; + let cells = self.cells(i)?; + let (_, op_lines) = qasm::parse_lines_with(&self.versions[i].source, inputs).map_err(|e| format!("{id}: {e}"))?; + Ok(op_lines.iter().map(|&l| l.checked_sub(1).and_then(|k| cells[i].get(k)).map_or(Hand::Person, |c| c.hand)).collect()) + } + + /// Whose hand version `id`'s gates (read at `inputs`) and lines are in. + pub fn authorship(&self, id: &str, inputs: &Inputs) -> Result { + let i = self.index(id)?; + let cells = self.cells(i)?; + let v = &self.versions[i]; + let (_, op_lines) = qasm::parse_lines_with(&v.source, inputs).map_err(|e| format!("{id}: {e}"))?; + let text = lines(&v.source); + let mut a = Authorship::default(); + // (hand, the named version) → gates, lines + let mut by: BTreeMap<(Hand, usize), (u64, u64)> = BTreeMap::new(); + let named = |c: &Cell| c.from.unwrap_or(c.by); + for l in op_lines { + let Some(c) = l.checked_sub(1).and_then(|k| cells[i].get(k)) else { continue }; + *a.gates.entry(c.hand).or_default() += 1; + by.entry((c.hand, named(c))).or_default().0 += 1; + a.of.gates += 1; + } + for (k, c) in cells[i].iter().enumerate() { + if counted(text[k]) { + *a.lines.entry(c.hand).or_default() += 1; + by.entry((c.hand, named(c))).or_default().1 += 1; + a.of.lines += 1; + } + } + // One contributor per hand and name: the versions a model or a person + // wrote under one name count together. + let mut merged: BTreeMap<(Hand, Kind, Option), (u64, u64)> = BTreeMap::new(); + for ((hand, at), (g, l)) in by { + let who = &self.versions[at].by; + let e = merged.entry((hand, who.kind, who.name.clone())).or_default(); + e.0 += g; + e.1 += l; + } + a.contributors = merged.into_iter().map(|((hand, kind, name), (gates, lines))| Contributor { kind, name, hand, gates, lines }).collect(); + a.contributors.sort_by(|x, y| y.gates.cmp(&x.gates).then(y.lines.cmp(&x.lines))); + Ok(a) + } + + /// The versions version `index` comes from, itself included. + fn ancestry(&self, index: usize) -> BTreeSet { + let at = self.positions(); + let mut seen = BTreeSet::new(); + let mut todo = vec![index]; + while let Some(i) = todo.pop() { + if seen.insert(i) { + todo.extend(self.versions[i].parents.iter().filter_map(|p| at.get(p.as_str()).copied())); + } + } + seen + } + + /// What a job's request carries as `history`, for version `id` read at + /// `inputs`: the version's digest, the versions in its line of work, and + /// its gates and lines in each hand. The receipt then binds it. + pub fn attest(&self, id: &str, inputs: &Inputs) -> Result { + let i = self.index(id)?; + let a = self.authorship(id, inputs)?; + let mut v = json!({ + "head": self.versions[i].digest, + "version": id, + "versions": self.ancestry(i).len(), + "gates": a.gates, + "lines": a.lines, + "of": a.of, + "contributors": a.contributors, + }); + if !inputs.is_empty() { + v["inputs"] = json!(inputs); + } + Ok(v) + } + + /// Whether a job (or its request) carries this history's account of the + /// program it ran: the version its `history` names is here, the program is + /// that version's (written out at the inputs it names), the counts are + /// what [`History::attest`] gives, and the history verifies. + pub fn check_job(&self, job: &Value) -> Result { + let request = job.get("request").unwrap_or(job); + let claim = request.get("history").ok_or("the request carries no history")?; + let head = claim.get("head").and_then(Value::as_str).ok_or("the request's history names no head")?; + let v = self.by_digest(head).ok_or_else(|| format!("no version here has the digest {head}"))?; + let inputs: Inputs = match claim.get("inputs") { + None => Inputs::new(), + Some(x) => serde_json::from_value(x.clone()).map_err(|e| format!("the request's history: inputs: {e}"))?, + }; + let mut problems = self.verify().problems; + if &self.attest(&v.id, &inputs)? != claim { + problems.push(format!("the request's account of {} is not the history's", v.id)); + } + let program = if inputs.is_empty() { v.source.clone() } else { qasm::instantiate(&v.source, &inputs)? }; + if request.get("qasm").and_then(Value::as_str) != Some(program.as_str()) { + problems.push(format!("the job did not run {}'s program", v.id)); + } + Ok(json!({ "ok": problems.is_empty(), "version": v.id, "problems": problems })) + } + + /// Each version as a person reads the history: who, how, when, what it + /// changed against its first parent, and a proposal's review. + pub fn log(&self) -> Vec { + let heads: BTreeSet<&str> = self.heads().iter().map(|v| v.id.as_str()).collect(); + self.versions + .iter() + .map(|v| { + let base = v.parents.first().and_then(|p| self.get(p)).map(|p| p.source.as_str()).unwrap_or(""); + let ch = changes(base, &v.source); + let mut e = json!({ + "id": v.id, "parents": v.parents, "by": v.by, "how": v.how, "at": v.at, + "added": ch.iter().map(|c| c.added.len()).sum::(), + "removed": ch.iter().map(|c| c.removed.len()).sum::(), + "head": heads.contains(v.id.as_str()), + "sealed": v.seal.is_some(), + }); + if let Some(n) = &v.note { + e["note"] = json!(n); + } + if let Some(r) = &v.review { + e["review"] = json!({ "offered": r.offered, "accepted": r.accepted.len() }); + } + e + }) + .collect() + } +} + +/// The history as a page or an app calls it, by name with JSON arguments, +/// the caller keeping the history as JSON. `start`, `commit`, `propose` and +/// `merge` record a version sealed with `key` and give `{history, version}`; +/// `changes` and `apply` compare two programs; `blame`, `attest`, `log` and +/// `check_job` read a history. +/// +/// Arguments: `history`; `source`, `parent`, `proposed` and `accepted` (the +/// changes' numbers), `a` and `b` (a merge's versions), `base` (for `changes` +/// and `apply`); `by` (`{"kind", "name"}`), `note` and `at` (milliseconds); +/// `version` and `inputs` (for `blame` and `attest`); and `job`. +pub fn call(op: &str, args: &Value, key: Option<&SigningKey>) -> Result { + let s = |k: &str| args.get(k).and_then(Value::as_str); + let need = |k: &str| s(k).ok_or_else(|| format!("{op}: give \"{k}\"")); + let by = || -> Result { serde_json::from_value(args.get("by").cloned().unwrap_or(Value::Null)).map_err(|e| format!("{op}: by: {e}")) }; + let at = args.get("at").and_then(Value::as_u64).unwrap_or(0); + let history = || History::from_json(args.get("history").ok_or_else(|| format!("{op}: give \"history\""))?); + let inputs = || -> Result { + match args.get("inputs") { + None | Some(Value::Null) => Ok(Inputs::new()), + Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("{op}: inputs: {e}")), + } + }; + let accepted = || -> Result, String> { serde_json::from_value(args.get("accepted").cloned().unwrap_or(json!([]))).map_err(|e| format!("{op}: accepted: {e}")) }; + let recorded = |h: &History, id: String| json!({ "history": h, "version": id }); + match op { + "start" => { + let h = History::start(need("source")?, by()?, s("note"), at, key)?; + Ok(recorded(&h, "v0".into())) + } + "commit" => { + let mut h = history()?; + let id = h.commit(need("parent")?, need("source")?, by()?, s("note"), at, key)?.id.clone(); + Ok(recorded(&h, id)) + } + "propose" => { + let mut h = history()?; + let id = h.propose(need("parent")?, need("proposed")?, &accepted()?, by()?, s("note"), at, key)?.id.clone(); + Ok(recorded(&h, id)) + } + "merge" => { + let mut h = history()?; + let id = h.merge(need("a")?, need("b")?, need("source")?, by()?, s("note"), at, key)?.id.clone(); + Ok(recorded(&h, id)) + } + "changes" => Ok(json!({ "changes": changes(need("base")?, need("proposed")?) })), + "apply" => Ok(json!({ "source": apply(need("base")?, need("proposed")?, &accepted()?)? })), + "blame" => { + let (h, id, ins) = (history()?, need("version")?, inputs()?); + // A draft that does not read still has its lines' authors. + let (authorship, gates) = (h.authorship(id, &ins), h.gate_hands(id, &ins)); + Ok(json!({ "version": id, "lines": h.blame(id)?, "authorship": authorship.as_ref().ok(), "gates": gates.as_ref().ok(), "error": authorship.err() })) + } + "attest" => history()?.attest(need("version")?, &inputs()?), + "log" => { + let h = history()?; + Ok(json!({ "versions": h.log(), "check": h.verify() })) + } + "check_job" => history()?.check_job(args.get("job").ok_or("check_job: give \"job\"")?), + _ => Err(format!("{op}: no such call on a history")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> SigningKey { + SigningKey::from_bytes(&[7; 32]) + } + + #[test] + fn a_host_records_and_reads_a_history_by_name() { + let k = key(); + let me = json!({ "kind": "person" }); + let r = call("start", &json!({ "source": BELL, "by": { "kind": "origin", "name": "example" }, "at": 1 }), Some(&k)).unwrap(); + let proposed = format!("{BELL}x q[1];\nz q[0];\n"); + let ch = call("changes", &json!({ "base": BELL, "proposed": proposed }), None).unwrap(); + assert_eq!(ch["changes"].as_array().unwrap().len(), 1, "{ch}"); + let r = call("propose", &json!({ "history": r["history"], "parent": "v0", "proposed": proposed, "accepted": [0], "by": { "kind": "assistant", "name": "m" }, "at": 2 }), Some(&k)).unwrap(); + assert_eq!(r["version"], "v1"); + let r = call("commit", &json!({ "history": r["history"], "parent": "v1", "source": format!("{BELL}x q[1];\nz q[1];\n"), "by": me, "at": 3 }), Some(&k)).unwrap(); + let b = call("blame", &json!({ "history": r["history"], "version": "v2" }), None).unwrap(); + assert_eq!(b["gates"], json!(["origin", "origin", "assistant", "assistant_then_person"]), "{b}"); + let log = call("log", &json!({ "history": r["history"] }), None).unwrap(); + assert_eq!((log["check"]["ok"].as_bool(), log["check"]["sealed"].as_u64()), (Some(true), Some(3)), "{log}"); + let att = call("attest", &json!({ "history": r["history"], "version": "v2" }), None).unwrap(); + assert_eq!(att["of"]["gates"], 4); + // A draft that does not read keeps its lines' authors. + let r = call("commit", &json!({ "history": r["history"], "parent": "v2", "source": format!("{BELL}x q[\n"), "by": me, "at": 4 }), Some(&k)).unwrap(); + let b = call("blame", &json!({ "history": r["history"], "version": "v3" }), None).unwrap(); + assert!(b["authorship"].is_null() && b["error"].is_string() && b["lines"].as_array().unwrap().len() == 6, "{b}"); + assert!(call("nonsense", &json!({}), None).unwrap_err().contains("no such call")); + } + + fn person() -> By { + By::new(Kind::Person, None) + } + + /// The length of a longest common subsequence, by the table. + fn lcs(a: &[&str], b: &[&str]) -> usize { + let mut t = vec![vec![0usize; b.len() + 1]; a.len() + 1]; + for i in 0..a.len() { + for j in 0..b.len() { + t[i + 1][j + 1] = if a[i] == b[j] { t[i][j] + 1 } else { t[i][j + 1].max(t[i + 1][j]) }; + } + } + t[a.len()][b.len()] + } + + #[test] + fn the_diff_matches_a_longest_common_subsequence_of_lines() { + let mut s = 0x9e37_79b9_7f4a_7c15u64; + let mut next = |n: u64| { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + s % n + }; + let alphabet = ["h q[0];", "x q[1];", "cx q[0], q[1];", "", "rz(0.5) q[2];"]; + for _ in 0..400 { + let la = next(12) as usize; + let lb = next(12) as usize; + let a: Vec<&str> = (0..la).map(|_| alphabet[next(5) as usize]).collect(); + let b: Vec<&str> = (0..lb).map(|_| alphabet[next(5) as usize]).collect(); + let m = matches(&a, &b); + assert_eq!(m.len(), lcs(&a, &b), "{a:?} / {b:?}"); + assert!(m.windows(2).all(|w| w[0].0 < w[1].0 && w[0].1 < w[1].1)); + assert!(m.iter().all(|&(i, j)| a[i] == b[j])); + } + } + + const BELL: &str = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n"; + + #[test] + fn a_proposal_is_reviewed_change_by_change() { + let proposed = "OPENQASM 3.0;\nqubit[3] q;\nh q[0];\ncx q[0], q[1];\ncx q[1], q[2];\n"; + let ch = changes(BELL, proposed); + assert_eq!(ch.len(), 2, "{ch:?}"); + assert_eq!((ch[0].line, ch[0].removed.as_slice(), ch[0].added.as_slice()), (2, &["qubit[2] q;".to_string()][..], &["qubit[3] q;".to_string()][..])); + assert_eq!(apply(BELL, proposed, &[0, 1]).unwrap(), proposed); + assert_eq!(apply(BELL, proposed, &[]).unwrap(), BELL); + assert_eq!(apply(BELL, proposed, &[0]).unwrap(), "OPENQASM 3.0;\nqubit[3] q;\nh q[0];\ncx q[0], q[1];\n"); + assert!(apply(BELL, proposed, &[2]).unwrap_err().contains("2 changes")); + } + + #[test] + fn each_line_and_gate_is_in_the_hand_that_wrote_it() { + let k = key(); + let mut h = History::start(BELL, By::new(Kind::Origin, Some("example: Bell pair")), None, 1, Some(&k)).unwrap(); + // The person adds a qubit and a gate. + let mine = "OPENQASM 3.0;\nqubit[3] q;\nh q[0];\ncx q[0], q[1];\nx q[2];\n"; + h.commit("v0", mine, person(), None, 2, Some(&k)).unwrap(); + // The model proposes two changes; the person takes one. + let theirs = "OPENQASM 3.0;\nqubit[3] q;\nh q[0];\ncx q[0], q[1];\ncx q[1], q[2];\nh q[2];\n"; + assert_eq!(changes(mine, theirs).len(), 1); + let theirs2 = "OPENQASM 3.0;\nqubit[3] q;\nh q[1];\ncx q[0], q[1];\ncx q[1], q[2];\n"; + let offered = changes(mine, theirs2); + assert_eq!(offered.len(), 2, "{offered:?}"); + h.propose("v1", theirs2, &[1], By::new(Kind::Assistant, Some("qwen")), Some("entangle the third qubit"), 3, Some(&k)).unwrap(); + assert_eq!(h.versions[2].source, "OPENQASM 3.0;\nqubit[3] q;\nh q[0];\ncx q[0], q[1];\ncx q[1], q[2];\n"); + // The person edits the model's line. + let edited = "OPENQASM 3.0;\nqubit[3] q;\nh q[0];\ncx q[0], q[1];\ncx q[0], q[2];\n"; + h.commit("v2", edited, person(), None, 4, Some(&k)).unwrap(); + let blame = h.blame("v3").unwrap(); + let hands: Vec = blame.iter().map(|l| l.hand).collect(); + assert_eq!(hands, [Hand::Origin, Hand::Person, Hand::Origin, Hand::Origin, Hand::AssistantThenPerson, Hand::Origin]); + assert_eq!(blame[4].from.as_deref(), Some("v2")); + let a = h.authorship("v3", &Inputs::new()).unwrap(); + assert_eq!(a.gates, BTreeMap::from([(Hand::Origin, 2), (Hand::AssistantThenPerson, 1)])); + assert_eq!(a.lines, BTreeMap::from([(Hand::Origin, 3), (Hand::Person, 1), (Hand::AssistantThenPerson, 1)])); + assert_eq!((a.of.gates, a.of.lines), (3, 5)); + let model = a.contributors.iter().find(|c| c.hand == Hand::AssistantThenPerson).unwrap(); + assert_eq!((model.kind, model.name.as_deref(), model.gates), (Kind::Assistant, Some("qwen"), 1)); + let check = h.verify(); + assert!(check.ok && check.sealed == 4, "{check:?}"); + } + + #[test] + fn a_line_the_person_rewrites_from_scratch_is_theirs_alone() { + let mut h = History::start(BELL, By::new(Kind::Agent, Some("an agent")), None, 1, None).unwrap(); + h.commit("v0", "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\nmeasure q;\n", person(), None, 2, None).unwrap(); + let hands: Vec = h.blame("v1").unwrap().iter().map(|l| l.hand).collect(); + assert_eq!(hands[3], Hand::Person, "{hands:?}"); + h.commit("v1", "OPENQASM 3.0;\nqubit[2] q;\nh q[1];\nmeasure q;\n", person(), None, 3, None).unwrap(); + assert_eq!(h.blame("v2").unwrap()[2].hand, Hand::AgentThenPerson); + } + + #[test] + fn a_generator_s_gates_are_counted_at_its_inputs_by_their_line_in_the_loop() { + let chain = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [0:n-2] {\n cx q[i], q[i + 1];\n}\n"; + let mut h = History::start(chain, person(), None, 1, None).unwrap(); + let proposed = chain.replace(" cx q[i], q[i + 1];", " cx q[i], q[i + 1];\n rz(0.1) q[i];"); + h.propose("v0", &proposed, &[0], By::new(Kind::Assistant, Some("m")), None, 2, None).unwrap(); + let at5 = Inputs::from([("n".to_string(), 5)]); + let a = h.authorship("v1", &at5).unwrap(); + assert_eq!(a.gates, BTreeMap::from([(Hand::Person, 5), (Hand::Assistant, 4)])); + let att = h.attest("v1", &at5).unwrap(); + assert_eq!(att["inputs"]["n"], 5); + assert_eq!(att["of"]["gates"], 9); + } + + #[test] + fn an_edit_back_to_an_earlier_program_keeps_its_authors() { + let mut h = History::start(BELL, By::new(Kind::Origin, None), None, 1, None).unwrap(); + let proposed = BELL.replace("h q[0];", "h q[1];"); + h.propose("v0", &proposed, &[0], By::new(Kind::Assistant, None), None, 2, None).unwrap(); + // Undo: the person's editor gives back the program as it was. + h.commit("v1", BELL, person(), None, 3, None).unwrap(); + assert!(h.blame("v2").unwrap().iter().all(|l| l.hand == Hand::Origin && l.version == "v0"), "{:?}", h.blame("v2")); + } + + #[test] + fn a_merge_keeps_each_branch_s_authors() { + let mut h = History::start(BELL, person(), None, 1, None).unwrap(); + let left = format!("{BELL}x q[0];\n"); + let right = BELL.replace("h q[0];\n", "h q[0];\nz q[1];\n"); + h.propose("v0", &left, &[0], By::new(Kind::Assistant, Some("m")), None, 2, None).unwrap(); + h.commit("v0", &right, By::new(Kind::Agent, Some("a")), None, 3, None).unwrap(); + assert_eq!(h.heads().len(), 2); + assert!(h.head().unwrap_err().contains("2 heads")); + let merged = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\nz q[1];\ncx q[0], q[1];\nx q[0];\n"; + h.merge("v1", "v2", merged, person(), Some("both"), 4, None).unwrap(); + let hands: Vec = h.blame("v3").unwrap().iter().map(|l| l.hand).collect(); + assert_eq!(hands, [Hand::Person, Hand::Person, Hand::Person, Hand::Agent, Hand::Person, Hand::Assistant, Hand::Person]); + assert_eq!(h.head().unwrap().id, "v3"); + assert!(h.verify().ok); + } + + #[test] + fn a_change_to_any_version_breaks_the_chain() { + let k = key(); + let mut h = History::start(BELL, person(), None, 1, Some(&k)).unwrap(); + h.propose("v0", &format!("{BELL}x q[1];\n"), &[], By::new(Kind::Assistant, None), None, 2, Some(&k)).unwrap(); + assert_eq!(h.versions[1].source, BELL, "rejected whole, the source is its parent's"); + assert!(h.verify().ok); + let mut t = h.clone(); + t.versions[0].source.push_str("x q[0];\n"); + assert!(t.verify().problems.iter().any(|p| p.contains("v0: its digest")), "{:?}", t.verify()); + let mut t = h.clone(); + t.versions[1].review.as_mut().unwrap().accepted = vec![0]; + t.versions[1].digest = version_digest(&[&t.versions[0].digest], &t.versions[1].source, &t.versions[1].by, How::Proposal, None, t.versions[1].review.as_ref(), 2); + let found = t.verify().problems; + assert!(found.iter().any(|p| p.contains("seal")) && found.iter().any(|p| p.contains("review")), "{found:?}"); + let mut t = h.clone(); + t.versions[1].by = By::new(Kind::Person, None); + assert!(!t.verify().ok); + } + + #[test] + fn a_job_carries_its_history_and_the_receipt_binds_it() { + let k = key(); + let mut h = History::start(BELL, person(), None, 1, Some(&k)).unwrap(); + h.propose("v0", &format!("{BELL}x q[1];\n"), &[0], By::new(Kind::Assistant, Some("m")), None, 2, Some(&k)).unwrap(); + let mut request = json!({ "qasm": h.versions[1].source, "shots": 16 }); + request["history"] = h.attest("v1", &Inputs::new()).unwrap(); + let body = crate::engine::job::run(&request, 1 << 26, &k, json!({ "unmetered": "test" })).unwrap(); + assert!(crate::engine::job::verify(&body).unwrap().0); + assert_eq!(h.check_job(&body).unwrap()["ok"], true); + let mut forged = body.clone(); + forged["request"]["history"]["gates"] = json!({ "person": 3 }); + let c = h.check_job(&forged).unwrap(); + assert_eq!(c["ok"], false); + assert!(!crate::engine::job::verify(&forged).unwrap().0, "the receipt binds the history"); + let mut other = body.clone(); + other["request"]["qasm"] = json!(BELL); + assert!(h.check_job(&other).unwrap()["problems"].to_string().contains("did not run v1")); + } + + #[test] + fn a_generator_s_job_ran_its_instance() { + let chain = "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\nfor uint i in [0:n-2] {\n cx q[i], q[i + 1];\n}\n"; + let h = History::start(chain, person(), None, 1, None).unwrap(); + let at3 = Inputs::from([("n".to_string(), 3)]); + let request = json!({ "qasm": qasm::instantiate(chain, &at3).unwrap(), "history": h.attest("v0", &at3).unwrap() }); + assert_eq!(h.check_job(&request).unwrap()["ok"], true); + } +} diff --git a/wai/wai-web/src/quantum_engine.rs b/wai/wai-web/src/quantum_engine.rs index 43a133011..7c9442295 100644 --- a/wai/wai-web/src/quantum_engine.rs +++ b/wai/wai-web/src/quantum_engine.rs @@ -8,7 +8,7 @@ use ed25519_dalek::SigningKey; use serde_json::{Value, json}; use wai::engine::discover::{Archive, Budget, Evaluator, SearchOptions, Spec, search}; -use wai::engine::{catalog, critique, draw, equiv, job, shortcuts}; +use wai::engine::{catalog, critique, draw, equiv, job, shortcuts, versions}; use wasm_bindgen::prelude::*; /// What a result run in a tab says of its energy. @@ -197,6 +197,22 @@ impl QuantumEngine { } } +/// A program's history in a page ([`wai::engine::versions`]), kept by the +/// page as JSON: who made each version and how, whose hand each line and gate +/// is in, and each version recorded sealed with the page's key. +#[wasm_bindgen] +impl QuantumEngine { + /// `op` on a history ([`versions::call`]), its arguments as JSON: the + /// answer, or `{error}`. + pub fn history(&self, op: &str, args: &str) -> String { + serde_json::from_str::(args) + .map_err(|e| format!("{op}: the arguments are not JSON: {e}")) + .and_then(|a| versions::call(op, &a, Some(&self.key))) + .map(|v| v.to_string()) + .unwrap_or_else(error_json) + } +} + fn submit_in(key: &SigningKey, spec: &str, archive_json: &str, source: &str, parent: Option<&str>, proposer: &str, note: Option<&str>) -> Result { let ev = evaluator(spec)?; let mut a = archive(&ev, archive_json)?; @@ -282,6 +298,22 @@ mod tests { assert!(inst["text"].as_str().unwrap().contains("cx q[1], q[2];"), "{inst}"); } + /// The history in the page: recorded and sealed with the page's key, and + /// the gates in each hand. + #[test] + fn the_history_records_and_blames_in_the_page() { + let e = QuantumEngine::new(&[9; 32], 64).unwrap(); + let bell = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n"; + let r: Value = serde_json::from_str(&e.history("start", &json!({ "source": bell, "by": { "kind": "origin", "name": "example" }, "at": 1 }).to_string())).unwrap(); + let proposed = format!("{bell}x q[1];\n"); + let r: Value = serde_json::from_str(&e.history("propose", &json!({ "history": r["history"], "parent": "v0", "proposed": proposed, "accepted": [0], "by": { "kind": "assistant", "name": "m" }, "at": 2 }).to_string())).unwrap(); + let b: Value = serde_json::from_str(&e.history("blame", &json!({ "history": r["history"], "version": "v1" }).to_string())).unwrap(); + assert_eq!(b["gates"], json!(["origin", "origin", "assistant"]), "{b}"); + let seal = &r["history"]["versions"][1]["seal"]; + assert_eq!(seal["key"].as_str().map(str::len), Some(64), "{seal}"); + assert!(e.history("nonsense", "{}").contains("no such call")); + } + fn bell(extra: &str) -> String { format!("{{\"qasm\": \"OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];\"{extra}}}") } -- 2.54.0 (Apple Git-157) From cebfe2801a90f91c0633de8fa360cd2fde1f7e8c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 25 Oct 2026 23:41:01 -0400 Subject: [PATCH 6/6] wai-quantum 0.6.15 and wai-quantum-cli 0.1.3: generators, the workbench, and a program's history This carries OpenQASM 3 generators, the specification-and-evaluator workbench, the Studio's Workbench pane and a program's attributed history to the package registry. - wai-quantum 0.6.15: qasm::parse_with, qasm::inputs and qasm::instantiate; engine::discover, with equiv::same_state for the prepares task; engine::energy, moved from the job service; job::lint_with and critique::critique_with, which read a generator at its inputs; and engine::versions. - wai-quantum-cli 0.1.3 requires it: --input on every command, instance, discover new | search | eval | submit | show | verify, history start | commit | changes | propose | merge | log | blame | verify, --history on run, submit and verify, and the MCP's discover and history tools. - The desktop app requires 0.6.15 too, for discover, instantiate and the history. - Every lockfile that holds wai-quantum or wai-quantum-cli moves with them, so a --locked build still reads its own. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-room/Cargo.lock | 2 +- wai/CHANGELOG.md | 17 +++++++++++++++++ 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 | 4 ++-- wai/wai-quantum-cli/Cargo.toml | 4 ++-- wai/wai-quantum-desktop/Cargo.lock | 4 ++-- wai/wai-quantum-desktop/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 +- 16 files changed, 35 insertions(+), 18 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock index 2cf03d627..af0d1a600 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock @@ -3436,7 +3436,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 0448566c0..60d8a9419 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -1116,6 +1116,23 @@ wire format may change and such changes are recorded here. - **`tools/ops-gpu-bench`** times each capability's decode with its graphs on the CPU and on each adapter, and reads the energy counters the host has. - **CI:** `wai-ops-gpu.yml` runs the Metal and browser legs on the `macos` runner, and requires on a runner with no GPU that the device tests skip without `WAI_OPS_GPU_REQUIRE` and fail with it. +### wai-quantum 0.6.15 and wai-quantum-cli 0.1.3: generators, the specification-and-evaluator workbench, and a program's history, attributed + +This release carries the four entries below it to the package registry, and `wai-quantum-cli` +0.1.3 requires it. + +- **wai-quantum 0.6.15:** + - OpenQASM 3 generators (`qasm::parse_with`, `qasm::inputs`, `qasm::instantiate`); + - the workbench (`engine::discover`, and `equiv::same_state` for the `prepares` task); + - the energy reader moved into the engine (`engine::energy`); + - a generator linted and critiqued at its inputs (`job::lint_with`, `critique::critique_with`); + - a program's history, attributed (`engine::versions`). +- **wai-quantum-cli 0.1.3:** + - `--input NAME=VALUE` on every command, and `instance`; + - `discover new | search | eval | submit | show | verify`, with two specifications in `discover/`; + - `history start | commit | changes | propose | merge | log | blame | verify`, and `--history` on `run`, `submit` and `verify`; + - the MCP tools `discover_evaluate`, `discover_submit`, `discover_archive`, `history_commit`, `history_changes`, `history_blame` and `history_log`. + ### wai-quantum: a program's history, attributed, and the Studio's Versions - **The history** (`engine::versions`): every version of a program, in a chain. diff --git a/wai/tools/quantum-meter/Cargo.lock b/wai/tools/quantum-meter/Cargo.lock index a994924b7..9c231fd93 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.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/tools/quantum-wgsl-check/Cargo.lock b/wai/tools/quantum-wgsl-check/Cargo.lock index c4fb684b4..7fb5b720e 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.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index aea581f98..3a5138ba6 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.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-cli/Cargo.lock b/wai/wai-quantum-cli/Cargo.lock index 00a59a28b..d26cd149d 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.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", @@ -1133,7 +1133,7 @@ dependencies = [ [[package]] name = "wai-quantum-cli" -version = "0.1.2" +version = "0.1.3" dependencies = [ "clap", "clap_complete", diff --git a/wai/wai-quantum-cli/Cargo.toml b/wai/wai-quantum-cli/Cargo.toml index 82e9f0267..8d9d4bf5e 100644 --- a/wai/wai-quantum-cli/Cargo.toml +++ b/wai/wai-quantum-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum-cli" -version = "0.1.2" +version = "0.1.3" edition = "2024" rust-version = "1.97" license = "Apache-2.0" @@ -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.14", path = "../wai-quantum", default-features = false, features = ["full"] } +wai-quantum = { version = "0.6.15", path = "../wai-quantum", default-features = false, features = ["full"] } diff --git a/wai/wai-quantum-desktop/Cargo.lock b/wai/wai-quantum-desktop/Cargo.lock index ef406f540..a6565f61c 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.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", @@ -4143,7 +4143,7 @@ dependencies = [ [[package]] name = "wai-quantum-cli" -version = "0.1.2" +version = "0.1.3" dependencies = [ "clap", "clap_complete", diff --git a/wai/wai-quantum-desktop/Cargo.toml b/wai/wai-quantum-desktop/Cargo.toml index c5c925d36..e73eb0857 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.14", path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt"] } +wai-quantum = { version = "0.6.15", 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] diff --git a/wai/wai-quantum-qpu/Cargo.lock b/wai/wai-quantum-qpu/Cargo.lock index deea629b5..be543ddf3 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.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index 772d80764..499b4913f 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.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index 0476358dd..6464b3468 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.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index 8ee579165..8d89999a8 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.14" +version = "0.6.15" dependencies = [ "blake3", "block2", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 67a0540c6..989a62769 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.6.14" +version = "0.6.15" 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 e1b7d4d31..e685bb579 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4319,7 +4319,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 9f1ff5566..d36c8f31c 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.14" +version = "0.6.15" dependencies = [ "blake3", "ed25519-dalek", -- 2.54.0 (Apple Git-157)