diff --git a/.github/workflows/wai-quantum-variational.yml b/.github/workflows/wai-quantum-variational.yml index f2d12a7d9..b7dfc62c8 100644 --- a/.github/workflows/wai-quantum-variational.yml +++ b/.github/workflows/wai-quantum-variational.yml @@ -108,6 +108,8 @@ jobs: engine::variational::tests::vqe_finds_the_ground_energy_diagonalisation_gives \ engine::variational::tests::qaoa_is_max_cut_and_finds_an_optimal_cut \ engine::variational::tests::the_answers_are_the_same_bytes_on_every_platform \ + engine::variational::tests::an_optimisation_stopped_anywhere_and_gone_on_with_is_the_same_bytes \ + engine::variational::tests::a_checkpoint_goes_on_only_from_its_own_run \ engine::algorithms::tests::phase_estimation_reads_the_phase_with_the_fejer_distribution \ engine::algorithms::tests::amplitude_estimation_reads_the_amplitude \ engine::algorithms::tests::the_adder_is_its_truth_table \ diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 7296c90ea..5f5703b57 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -180,6 +180,22 @@ and no `depends_on :macos`. It now has both, so the tap carries it as the releas invoice of version 6, since version 5 is now defined, and is refused `unknown_version` as before; every other vector keeps its outcome, and the new vectors are added. +### An optimisation stops and goes on to the same bytes + +A long optimisation ran to its end in one process or not at all, and one killed partway lost every step. + +- **wai-quantum:** `engine::variational::optimize_resumable` (and `call_optimize`, as `call` answers) goes on from a + checkpoint, `wai.quantum.optimize-checkpoint/1`. + - After every step, the caller gets the run as it stands, and may stop it there. + - The checkpoint holds the steps taken with their links and the optimiser's whole state, every number as the bits + of its f64. + - Gone on with from any step, a run ends with the same bytes as one that never stopped, for each optimiser. + - A checkpoint of another request, or one whose steps were changed, is refused (`J052`). +- **wai-quantum-cli:** `optimize --checkpoint FILE` keeps the state in FILE as the run goes, at most once a second and + whenever it stops, written whole or not at all. It goes on from FILE when FILE is there, and removes FILE when the + run ends. A run killed and started again with the same arguments ends with the same bytes, receipt and all. + `--slice N` takes at most N steps, then stops with the state kept (exit 2). + ### The receipt ledger bills what the work spends: bytes out, a package's work per run and its storage, every hop's energy, the output its hop delivered - **A job's output is the object its hop delivered** ([extensions/receipt-ledger.md](extensions/receipt-ledger.md) diff --git a/wai/wai-quantum-cli/README.md b/wai/wai-quantum-cli/README.md index f3c0ba301..0151681a1 100644 --- a/wai/wai-quantum-cli/README.md +++ b/wai/wai-quantum-cli/README.md @@ -319,7 +319,7 @@ wai-quantum build qpe --counting 5 --phase 0.3 > qpe.qasm # checked a wai-quantum run qpe.qasm --shots 1000 ``` -`--param NAME=VALUE` binds a program's parameter on any job (`run`, `plan`, `submit`, and fixes one in a sweep). `gradient` gives a Hamiltonian's expectation (`--term`, repeated, or `--hamiltonian FILE`) and its exact gradient by each parameter (`--gradient adjoint` or `parameter_shift`), each component with its rounding bound. `optimize` takes `--optimizer gradient_descent`, `adam` (the default) or `spsa` (`--seed`), `--steps` and `--step-size`, every step in the digest chain the receipt seals. `vqe` takes a program or `--layers L` for the built-in ansatz, and sets the energy beside the exact ground energy up to 8 qubits. `qaoa --graph FILE` solves Max-Cut and sets the cut beside the maximum. `build qpe | amplitude_estimation | adder | qsvt` writes the program to standard output and its check against the reference to standard error. Every answer is signed with your key (`--json` prints it sealed), and `verify` checks it and runs it again to the same bytes. Everything each computes is on [the variational page](../wai-quantum/docs/variational.md). +`--param NAME=VALUE` binds a program's parameter on any job (`run`, `plan`, `submit`, and fixes one in a sweep). `gradient` gives a Hamiltonian's expectation (`--term`, repeated, or `--hamiltonian FILE`) and its exact gradient by each parameter (`--gradient adjoint` or `parameter_shift`), each component with its rounding bound. `optimize` takes `--optimizer gradient_descent`, `adam` (the default) or `spsa` (`--seed`), `--steps` and `--step-size`, every step in the digest chain the receipt seals. With `--checkpoint FILE` it keeps its state in FILE as it goes, and a run killed and started again ends with the same bytes as one that never stopped; `--slice N` takes N steps at a time (exit 2 while steps remain). `vqe` takes a program or `--layers L` for the built-in ansatz, and sets the energy beside the exact ground energy up to 8 qubits. `qaoa --graph FILE` solves Max-Cut and sets the cut beside the maximum. `build qpe | amplitude_estimation | adder | qsvt` writes the program to standard output and its check against the reference to standard error. Every answer is signed with your key (`--json` prints it sealed), and `verify` checks it and runs it again to the same bytes. Everything each computes is on [the variational page](../wai-quantum/docs/variational.md). ## Compiling diff --git a/wai/wai-quantum-cli/docs/llms-full.txt b/wai/wai-quantum-cli/docs/llms-full.txt index 4ddbd24b1..96779ed3c 100644 --- a/wai/wai-quantum-cli/docs/llms-full.txt +++ b/wai/wai-quantum-cli/docs/llms-full.txt @@ -1605,7 +1605,8 @@ Commands: each (adjoint differentiation or the parameter-shift rule, in f64 with a stated rounding bound), signed optimize Minimise a Hamiltonian's expectation over a program's parameters: gradient descent, - Adam or SPSA from a seed, every step in a digest chain the receipt seals + Adam or SPSA from a seed, every step in a digest chain the receipt seals; with + --checkpoint it stops and goes on to the same bytes (exit 2 when --slice stops it) vqe The variational eigensolver: an ansatz optimised on a Hamiltonian, the energy found set beside the exact ground energy by diagonalisation qaoa Max-Cut on a graph by QAOA: the program and its cost built from the graph, optimised, @@ -2619,7 +2620,8 @@ Options: ```text Minimise a Hamiltonian's expectation over a program's parameters: gradient descent, Adam or SPSA -from a seed, every step in a digest chain the receipt seals +from a seed, every step in a digest chain the receipt seals; with --checkpoint it stops and goes on +to the same bytes (exit 2 when --slice stops it) Usage: wai-quantum optimize [OPTIONS] [FILE] @@ -2668,6 +2670,16 @@ Options: --seed SPSA's seed (default 1) + --checkpoint + Keep the run's state in FILE as it goes (at most once a second, and when it stops), and go + on from FILE when it is there: a run stopped, killed or by --slice, and started again with + the same arguments ends with the same bytes as a run that never stopped. FILE is removed + when the run ends + + --slice + With --checkpoint: take at most N steps now, then stop with the state kept (exit 2); the + same command again goes on + --json Print the answer sealed with this user's key, as JSON (`wai-quantum verify` checks it) diff --git a/wai/wai-quantum-cli/locales/zh-CN.json b/wai/wai-quantum-cli/locales/zh-CN.json index f5ae42012..7c49d45e9 100644 --- a/wai/wai-quantum-cli/locales/zh-CN.json +++ b/wai/wai-quantum-cli/locales/zh-CN.json @@ -49,7 +49,7 @@ "compile": { "source": "Compile a program: passes each checked where they act, Clifford+T within an ε budget (or the standard gates), and a signed certificate of every pass and the distance (`wai-quantum compile --help`)", "text": "编译程序:每一遍处理(pass)都在其作用之处经过检查,在 ε 预算内得到 Clifford+T(或标准门),并为每一遍处理和距离出具签名证书(`wai-quantum compile --help`)" }, "estimate": { "source": "What a fault-tolerant machine would need to run a program: its logical counts (T, Toffolis, rotations at their synthesised T counts, depth, qubits with ancillas), physical qubits, runtime and factories, and the frontier between qubits and time, each estimate signed (`wai-quantum estimate --help`)", "text": "容错机器运行某个程序需要什么:逻辑计数(T 门、Toffoli 门、按综合后 T 门数计的旋转、深度、含辅助量子比特的量子比特数)、物理量子比特、运行时间和工厂,以及量子比特与时间之间的权衡前沿,每份估算都附签名(`wai-quantum estimate --help`)" }, "gradient": { "source": "The expectation of a Hamiltonian at a program's parameters and its exact gradient by each (adjoint differentiation or the parameter-shift rule, in f64 with a stated rounding bound), signed", "text": "哈密顿量在程序参数处的期望值及其对每个参数的精确梯度(伴随微分或参数平移规则,以 f64 计算并给出舍入误差界),附签名" }, - "optimize": { "source": "Minimise a Hamiltonian's expectation over a program's parameters: gradient descent, Adam or SPSA from a seed, every step in a digest chain the receipt seals", "text": "在程序参数上最小化哈密顿量的期望值:梯度下降、Adam 或带种子的 SPSA,每一步都记入摘要链,由回执封存" }, + "optimize": { "source": "Minimise a Hamiltonian's expectation over a program's parameters: gradient descent, Adam or SPSA from a seed, every step in a digest chain the receipt seals; with --checkpoint it stops and goes on to the same bytes (exit 2 when --slice stops it)", "text": "在程序参数上最小化哈密顿量的期望值:梯度下降、Adam 或带种子的 SPSA,每一步都记入摘要链,由回执封存;用 --checkpoint 时可中途停下、之后接着运行,得到相同的字节(--slice 使其停下时退出码为 2)" }, "vqe": { "source": "The variational eigensolver: an ansatz optimised on a Hamiltonian, the energy found set beside the exact ground energy by diagonalisation", "text": "变分本征求解器:在哈密顿量上优化拟设(ansatz),并把找到的能量与对角化得到的精确基态能量并列" }, "qaoa": { "source": "Max-Cut on a graph by QAOA: the program and its cost built from the graph, optimised, and the cut set beside the maximum cut by brute force", "text": "用 QAOA 求图的最大割:由图构造程序及其代价函数,优化后把所得的割与穷举得到的最大割并列" }, "build": { "source": "A standard algorithm's program (qpe, amplitude_estimation, adder, qsvt) on standard output, checked by the equivalence ladder against a reference built from its definition", "text": "把标准算法的程序(qpe、amplitude_estimation、adder、qsvt)写到标准输出,并由等价性阶梯对照按其定义构造的参考加以检查" }, diff --git a/wai/wai-quantum-cli/src/main.rs b/wai/wai-quantum-cli/src/main.rs index 7e192499d..1e03e7ee4 100644 --- a/wai/wai-quantum-cli/src/main.rs +++ b/wai/wai-quantum-cli/src/main.rs @@ -264,7 +264,7 @@ enum Command { Estimate(estimate::Args), /// The expectation of a Hamiltonian at a program's parameters and its exact gradient by each (adjoint differentiation or the parameter-shift rule, in f64 with a stated rounding bound), signed Gradient(variational::Gradient), - /// Minimise a Hamiltonian's expectation over a program's parameters: gradient descent, Adam or SPSA from a seed, every step in a digest chain the receipt seals + /// Minimise a Hamiltonian's expectation over a program's parameters: gradient descent, Adam or SPSA from a seed, every step in a digest chain the receipt seals; with --checkpoint it stops and goes on to the same bytes (exit 2 when --slice stops it) Optimize(variational::Optimize), /// The variational eigensolver: an ansatz optimised on a Hamiltonian, the energy found set beside the exact ground energy by diagonalisation Vqe(variational::Vqe), diff --git a/wai/wai-quantum-cli/src/variational.rs b/wai/wai-quantum-cli/src/variational.rs index 7b09dede6..be1bf12da 100644 --- a/wai/wai-quantum-cli/src/variational.rs +++ b/wai/wai-quantum-cli/src/variational.rs @@ -11,6 +11,8 @@ //! wai-quantum gradient ansatz.qasm --param theta=0.3 --term "Z0 Z1" --term "0.5 X0" //! wai-quantum gradient ansatz.qasm --param theta=0.3 --term Z0 --gradient parameter_shift //! wai-quantum optimize ansatz.qasm --param theta=0.3 --term Z0 --optimizer adam --steps 200 +//! wai-quantum optimize ansatz.qasm --param theta=0.3 --term Z0 --steps 2000 --checkpoint run.json +//! wai-quantum optimize ansatz.qasm --param theta=0.3 --term Z0 --steps 2000 --checkpoint run.json --slice 100 //! wai-quantum vqe --term "X0 X1" --term "Y0 Y1" --term "Z0 Z1" --layers 3 --steps 400 //! wai-quantum qaoa --graph ring.json --layers 2 //! wai-quantum build qpe --counting 5 --phase 0.3 > qpe.qasm @@ -81,10 +83,69 @@ pub struct Optimize { pub program: Program, #[command(flatten)] pub optimiser: Optimiser, + /// Keep the run's state in FILE as it goes (at most once a second, and when it stops), and go on from FILE when it is there: a run stopped, killed or by --slice, and started again with the same arguments ends with the same bytes as a run that never stopped. FILE is removed when the run ends + #[arg(long, value_name = "FILE")] + pub checkpoint: Option, + /// With --checkpoint: take at most N steps now, then stop with the state kept (exit 2); the same command again goes on + #[arg(long, value_name = "N", requires = "checkpoint", value_parser = clap::value_parser!(u32).range(1..))] + pub slice: Option, #[command(flatten)] pub out: Out, } +/// How often a running optimisation writes its checkpoint, at most. +const CHECKPOINT_EVERY: std::time::Duration = std::time::Duration::from_secs(1); + +/// What an optimisation with a checkpoint did: ended, or stopped after a step. +enum Went { + Ended(Value), + Stopped { next: u32, steps: u32 }, +} + +/// `req` optimised with its state in `path`: gone on with from the +/// checkpoint there, if there is one, and kept there as it goes (at most once +/// a [`CHECKPOINT_EVERY`], and when it stops), `slice` steps at most. The +/// file is removed once the run ends. +fn optimize_kept(req: &Value, path: &std::path::Path, slice: Option) -> Result { + let from: Option = match std::fs::read(path) { + Ok(b) => Some(serde_json::from_slice(&b).map_err(|e| format!("{}: not a checkpoint: {e}", path.display()))?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(format!("{}: {e}", path.display())), + }; + if let Some(cp) = &from { + eprintln!("going on from step {} of {}, as {} keeps it", cp["next"], cp["steps"], path.display()); + } + let (mut taken, mut wrote, mut stopped) = (0u32, std::time::Instant::now(), None); + let done = variational::call_optimize(req, from.as_ref(), |p| { + taken += 1; + let stop = slice.is_some_and(|n| taken >= n); + if stop || wrote.elapsed() >= CHECKPOINT_EVERY { + keep(path, &p.checkpoint())?; + wrote = std::time::Instant::now(); + } + if stop { + stopped = Some((p.next(), p.steps())); + } + Ok(!stop) + })?; + match (done, stopped) { + (Some(r), _) => { + // The run is in its answer now; a checkpoint left would only go on to it again. + let _ = std::fs::remove_file(path); + Ok(Went::Ended(r)) + } + (None, Some((next, steps))) => Ok(Went::Stopped { next, steps }), + (None, None) => Err("the optimisation stopped with no step to go on from".into()), + } +} + +/// Writes `v` to `path` whole or not at all: a run killed while it writes +/// leaves the checkpoint it had. +fn keep(path: &std::path::Path, v: &Value) -> Result<(), String> { + let partial = std::path::PathBuf::from(format!("{}.partial", path.display())); + std::fs::write(&partial, v.to_string()).and_then(|()| std::fs::rename(&partial, path)).map_err(|e| format!("{}: {e}", path.display())) +} + #[derive(clap::Args, Clone)] pub struct Vqe { #[command(flatten)] @@ -337,7 +398,16 @@ fn out_of(cmd: &super::Command) -> &Out { pub fn main(cmd: &super::Command) { let r = (|| -> Result<(), String> { let (op, req) = request(cmd)?; - let result = variational::call(op, &req)?; + let result = match cmd { + super::Command::Optimize(Optimize { checkpoint: Some(path), slice, .. }) => match optimize_kept(&req, path, *slice)? { + Went::Ended(r) => r, + Went::Stopped { next, steps } => { + eprintln!("stopped after step {} of {steps}; {} keeps the state: the same command again goes on", next - 1, path.display()); + std::process::exit(2); + } + }, + _ => variational::call(op, &req)?, + }; let out = out_of(cmd); let k = super::load_key(&out.key); let body = variational::seal(&req, result, &k.key); diff --git a/wai/wai-quantum-cli/tests/optimize_resume.rs b/wai/wai-quantum-cli/tests/optimize_resume.rs new file mode 100644 index 000000000..6c2e216ed --- /dev/null +++ b/wai/wai-quantum-cli/tests/optimize_resume.rs @@ -0,0 +1,131 @@ +//! `wai-quantum optimize --checkpoint`: a run stopped and started again ends +//! with the same bytes as one that never stopped, whether it stops by +//! `--slice` or is killed while it runs. Nothing here leaves this machine. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_wai-quantum") +} + +/// A scratch directory of the test's own, and the program it optimises: +/// `n` qubits, a layer of `ry` on each, a chain of `cx`, and another layer. +fn scratch(tag: &str, n: usize) -> (PathBuf, PathBuf) { + let dir = std::env::temp_dir().join(format!("wai-q-opt-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut src = String::from("OPENQASM 3.0;\ninclude \"stdgates.inc\";\n"); + for k in 0..2 * n { + src += &format!("input float t{k};\n"); + } + src += &format!("qubit[{n}] q;\n"); + for k in 0..n { + src += &format!("ry(t{k}) q[{k}];\n"); + } + for k in 0..n - 1 { + src += &format!("cx q[{k}], q[{}];\n", k + 1); + } + for k in 0..n { + src += &format!("ry(t{}) q[{k}];\n", n + k); + } + let prog = dir.join("p.qasm"); + std::fs::write(&prog, src).unwrap(); + (dir, prog) +} + +/// The command's arguments: every parameter from 0.1 up, the Hamiltonian +/// Z0 Z1 and X on the last qubit, Adam, `steps` steps, signed with the demo +/// key so that the same answer is the same bytes. +fn args(prog: &Path, n: usize, steps: u32) -> Vec { + let mut a = vec!["optimize".to_string(), prog.display().to_string()]; + for k in 0..2 * n { + a.push("--param".into()); + a.push(format!("t{k}=0.{}", k % 9 + 1)); + } + for t in ["Z0 Z1".to_string(), format!("0.5 X{}", n - 1)] { + a.push("--term".into()); + a.push(t); + } + for x in ["--optimizer", "adam", "--steps", &steps.to_string(), "--json", "--demo-key"] { + a.push(x.into()); + } + a +} + +fn run(a: &[String], extra: &[&str]) -> Output { + Command::new(bin()).args(a).args(extra).env_remove("WAI_QUANTUM_KEY").output().unwrap() +} + +#[test] +fn a_run_taken_in_slices_ends_with_the_bytes_of_one_that_never_stopped() { + let (dir, prog) = scratch("slices", 3); + let a = args(&prog, 3, 40); + let whole = run(&a, &[]); + assert!(whole.status.success(), "{}", String::from_utf8_lossy(&whole.stderr)); + let cp = dir.join("run.json"); + let cps = cp.display().to_string(); + let mut slices = 0; + let last = loop { + slices += 1; + let o = run(&a, &["--checkpoint", &cps, "--slice", "7"]); + match o.status.code() { + Some(2) => { + let err = String::from_utf8_lossy(&o.stderr); + assert!(err.contains("the same command again goes on") && o.stdout.is_empty(), "{err}"); + assert!(cp.exists(), "a stopped run keeps its state"); + } + Some(0) => break o, + other => panic!("{other:?}: {}", String::from_utf8_lossy(&o.stderr)), + } + assert!(slices < 20, "the run never ended"); + }; + // 41 steps, seven a slice: five slices stop, the sixth ends. + assert_eq!(slices, 6); + assert_eq!(last.stdout, whole.stdout, "the same bytes as a run that never stopped"); + assert!(!cp.exists(), "the checkpoint is removed once the run ends"); + // A checkpoint of another request is refused, and left as it is. + let o = run(&a, &["--checkpoint", &cps, "--slice", "3"]); + assert_eq!(o.status.code(), Some(2)); + let other = args(&prog, 3, 41); + let o = run(&other, &["--checkpoint", &cps]); + assert_eq!(o.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&o.stderr).contains("it is of another request"), "{}", String::from_utf8_lossy(&o.stderr)); + assert!(cp.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_run_killed_while_it_runs_goes_on_to_the_bytes_of_one_that_never_stopped() { + // Big enough that a run outlasts its first checkpoint by seconds. + let (n, steps) = (16, 600); + let (dir, prog) = scratch("killed", n); + let a = args(&prog, n, steps); + let cp = dir.join("run.json"); + let cps = cp.display().to_string(); + let mut child = Command::new(bin()).args(&a).args(["--checkpoint", &cps]).env_remove("WAI_QUANTUM_KEY").stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + let t = Instant::now(); + // Killed once it has kept a state some steps in, as a crash or a power cut would stop it. + let at = loop { + if let Ok(text) = std::fs::read_to_string(&cp) + && let Ok(v) = serde_json::from_str::(&text) + && v["next"].as_u64().is_some_and(|k| k >= 2) + { + break v["next"].as_u64().unwrap(); + } + assert!(child.try_wait().unwrap().is_none(), "the run ended before it was killed: make it longer"); + assert!(t.elapsed() < Duration::from_secs(120), "no checkpoint was kept"); + std::thread::sleep(Duration::from_millis(20)); + }; + child.kill().unwrap(); + child.wait().unwrap(); + assert!(at < u64::from(steps), "killed partway"); + let resumed = run(&a, &["--checkpoint", &cps]); + assert!(resumed.status.success(), "{}", String::from_utf8_lossy(&resumed.stderr)); + assert!(String::from_utf8_lossy(&resumed.stderr).contains("going on from step"), "{}", String::from_utf8_lossy(&resumed.stderr)); + let whole = run(&a, &[]); + assert!(whole.status.success()); + assert_eq!(resumed.stdout, whole.stdout, "the same bytes as a run that never stopped"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/wai/wai-quantum/docs/variational.md b/wai/wai-quantum/docs/variational.md index f181c783b..5225b64c1 100644 --- a/wai/wai-quantum/docs/variational.md +++ b/wai/wai-quantum/docs/variational.md @@ -116,6 +116,31 @@ wai-quantum optimize ansatz.qasm --param t=0.4 --term Z1 --optimizer spsa --step wai-quantum verify opt.json ``` +An optimisation can stop between any two steps and go on later, to the same +bytes. Its checkpoint (`wai.quantum.optimize-checkpoint/1`) holds: + +- the request it is of, by the chain's `start` and the request's digest; +- the steps taken, each with its `digest`, and the last link; +- the optimiser's whole state: the point, Adam's moments and the powers of its + betas, SPSA's stream, the best point and the counts. Every number is the + hex of its f64's bits, so it reads back exactly. + +A checkpoint goes on only from its own request, and only if each step is still +the link its digest says. Any other is refused (`J052`). + +On the command line, `--checkpoint FILE` keeps the state in FILE as the run goes, +at most once a second and whenever it stops, and goes on from FILE when it is +there. A run killed and started again with the same arguments loses at most a +second of steps, and ends with the same bytes as a run that never stopped. The +file is removed when the run ends. `--slice N` takes at most N steps, then stops +with the state kept (exit 2), so a long optimisation can be run a slice at a +time. + +```sh +wai-quantum optimize ansatz.qasm --param t=0.4 --term Z1 --steps 2000 --checkpoint run.json +wai-quantum optimize ansatz.qasm --param t=0.4 --term Z1 --steps 2000 --checkpoint run.json --slice 100 +``` + ## The variational eigensolver `vqe` optimises an ansatz on a Hamiltonian: the request's program with its diff --git a/wai/wai-quantum/src/engine/variational.rs b/wai/wai-quantum/src/engine/variational.rs index 93200ccd4..20e626aff 100644 --- a/wai/wai-quantum/src/engine/variational.rs +++ b/wai/wai-quantum/src/engine/variational.rs @@ -49,6 +49,14 @@ //! from a seeded stream. The same request gives the same bytes everywhere, //! and a result carries no clock. //! +//! **Stopping and going on.** An optimisation can stop between any two steps +//! and go on later ([`optimize_resumable`]): its checkpoint holds the steps +//! taken, each with its link, and the optimiser's whole state (the point, +//! Adam's moments and the powers of its betas, SPSA's stream, the best point +//! and the counts), every number as the bits of its f64. Gone on with, it ends +//! with the same bytes as a run that never stopped. A checkpoint goes on only +//! from its own request, and only with its steps as they were. +//! //! Refusals end with their codes: `J050` for `params`, `J051` for a gradient //! (with `Q129` where the reader cannot follow a parameter), `J052` for an //! optimisation, a VQE or a QAOA run. @@ -856,9 +864,171 @@ pub fn optimize(request: &Value) -> Result { optimize_from(request, request) } +/// [`optimize`], stopped and gone on with: from `from`, a checkpoint an +/// earlier run of the same request gave, or from the start. After every +/// step but the last, `each` is given the run as it stands ([`Paused`]): +/// `Ok(true)` goes on, `Ok(false)` stops it there (`Ok(None)`), and an error +/// ends it. Gone on with from any of its checkpoints, a run ends with the +/// same bytes as one that never stopped, since the checkpoint holds the +/// optimiser's whole state as bits. +pub fn optimize_resumable(request: &Value, from: Option<&Value>, each: impl FnMut(&Paused) -> Result) -> Result, String> { + resume_from(request, request, from, each) +} + /// [`optimize`] on `request`, its chain starting from `origin`: the request /// as given, where a VQE or a QAOA run derives the optimisation's own from it. fn optimize_from(request: &Value, origin: &Value) -> Result { + Ok(resume_from(request, origin, None, |_| Ok(true))?.expect("a run nothing stops runs to its end")) +} + +/// The format of an optimisation's checkpoint ([`Paused::checkpoint`]). +pub const CHECKPOINT: &str = "wai.quantum.optimize-checkpoint/1"; + +/// An optimisation between two steps: all its next step needs. +struct State { + /// The step it goes on from. + next: u32, + theta: Vec, + /// Adam's moment estimates and the powers of its betas. + m: Vec, + w: Vec, + b1t: f64, + b2t: f64, + /// SPSA's stream. + rng: u64, + /// The steps taken, each with its link. + trace: Vec, + best: Option<(f64, Vec, u32)>, + evaluations: u64, + bound_max: f64, + chain: [u8; 32], +} + +/// An optimisation stopped between two steps, as [`optimize_resumable`] +/// gives it to its caller. +pub struct Paused<'a> { + state: &'a State, + request: &'a Value, + start: &'a [u8; 32], + steps: u32, +} + +fn bits(x: f64) -> Value { + json!(format!("{:016x}", x.to_bits())) +} + +fn bits_of(xs: &[f64]) -> Value { + Value::Array(xs.iter().map(|x| bits(*x)).collect()) +} + +fn unbits(v: &Value) -> Option { + v.as_str().filter(|t| t.len() == 16).and_then(|t| u64::from_str_radix(t, 16).ok()).map(f64::from_bits) +} + +fn unbits_of(v: &Value, n: usize) -> Option> { + v.as_array().filter(|a| a.len() == n)?.iter().map(unbits).collect() +} + +/// BLAKE3 of a request's canonical text, in hex. +fn request_digest(request: &Value) -> String { + hex(blake3::hash(&receipt::canonical(request)).as_bytes()) +} + +impl Paused<'_> { + /// The step the run goes on from: the steps taken so far. + pub fn next(&self) -> u32 { + self.state.next + } + + /// The steps the run takes after its first point. + pub fn steps(&self) -> u32 { + self.steps + } + + /// The run as it stands, to go on from ([`optimize_resumable`]): the + /// request it is of, by its chain's first link and its digest; the steps + /// taken, each with its link; and the optimiser's state, every number as + /// the bits of its f64, so that it reads back exactly. + pub fn checkpoint(&self) -> Value { + let st = self.state; + json!({ + "format": CHECKPOINT, + "start": hex(self.start), + "request": request_digest(self.request), + "next": st.next, + "steps": self.steps, + "trace": st.trace, + "chain": hex(&st.chain), + "state": { + "theta": bits_of(&st.theta), + "m": bits_of(&st.m), + "w": bits_of(&st.w), + "b1t": bits(st.b1t), + "b2t": bits(st.b2t), + "rng": format!("{:016x}", st.rng), + "evaluations": st.evaluations, + "bound_max": bits(st.bound_max), + "best": st.best.as_ref().map(|(v, p, s)| json!({ "value": bits(*v), "params": bits_of(p), "step": s })), + }, + }) + } +} + +/// The state `cp` holds, if it is a checkpoint of this request (its first +/// link `start`, `k` parameters, `steps` steps) whose steps hold their chain. +fn restore(cp: &Value, request: &Value, start: &[u8; 32], k: usize, steps: u32) -> Result { + let bad = |why: &str| oerr(format!("the checkpoint does not go on from here: {why}")); + if cp["format"] != CHECKPOINT { + return Err(bad(&format!("it is not {CHECKPOINT}"))); + } + if cp["start"].as_str() != Some(hex(start).as_str()) || cp["request"].as_str() != Some(request_digest(request).as_str()) || cp["steps"].as_u64() != Some(u64::from(steps)) { + return Err(bad("it is of another request")); + } + let next = cp["next"].as_u64().filter(|n| (1..=u64::from(steps)).contains(n)).ok_or_else(|| bad("its next step is not one of the request's"))? as u32; + let trace = cp["trace"].as_array().filter(|t| t.len() == next as usize).ok_or_else(|| bad("its steps are not the steps it says it took"))?.clone(); + // Each step's link, from the request: a checkpoint changed in any step is refused. + let mut chain = *start; + for (i, rec) in trace.iter().enumerate() { + let mut bare = rec.clone(); + let d = bare.as_object_mut().and_then(|o| o.remove("digest")); + chain = link(&chain, &bare); + if rec["step"].as_u64() != Some(i as u64) || d.as_ref().and_then(Value::as_str) != Some(hex(&chain).as_str()) { + return Err(bad(&format!("step {i} is not the link its digest says"))); + } + } + if cp["chain"].as_str() != Some(hex(&chain).as_str()) { + return Err(bad("its chain is not its steps' last link")); + } + let o = &cp["state"]; + let num = |key: &str| unbits(&o[key]).ok_or_else(|| bad(&format!("its {key} does not read"))); + let vec = |key: &str| unbits_of(&o[key], k).ok_or_else(|| bad(&format!("its {key} does not read as {k} numbers"))); + let best = match &o["best"] { + Value::Null => None, + b => Some((unbits(&b["value"]).ok_or_else(|| bad("its best value does not read"))?, unbits_of(&b["params"], k).ok_or_else(|| bad("its best point does not read"))?, b["step"].as_u64().filter(|s| *s < u64::from(next)).ok_or_else(|| bad("its best step is not one it took"))? as u32)), + }; + let theta = vec("theta")?; + if theta.iter().any(|x| !x.is_finite() || x.abs() > 1e6) { + return Err(bad("its parameters are outside the range a program reads")); + } + Ok(State { + next, + theta, + m: vec("m")?, + w: vec("w")?, + b1t: num("b1t")?, + b2t: num("b2t")?, + rng: o["rng"].as_str().filter(|t| t.len() == 16).and_then(|t| u64::from_str_radix(t, 16).ok()).ok_or_else(|| bad("its rng does not read"))?, + trace, + best, + evaluations: o["evaluations"].as_u64().ok_or_else(|| bad("its evaluations do not read"))?, + bound_max: num("bound_max")?, + chain, + }) +} + +/// [`optimize_from`], from `from` or the start, `each` given the run after +/// every step but the last ([`optimize_resumable`]). +fn resume_from(request: &Value, origin: &Value, from: Option<&Value>, mut each: impl FnMut(&Paused) -> Result) -> Result, String> { let s = Settings::read(request)?; let (v, p, point) = program_of(request, refusal::OPTIMIZE)?; let h = hamiltonian(request.get("hamiltonian"), v.qubits, refusal::OPTIMIZE)?; @@ -871,60 +1041,56 @@ fn optimize_from(request: &Value, origin: &Value) -> Result { return Err(oerr(format!("{} steps would apply about {work:.1e} gate amplitudes, more than one optimisation takes ({MAX_WORK:.0e}): take fewer steps", s.steps))); } let start = trace_start(origin); - let mut chain = start; - let mut theta = point; - let k = theta.len(); - let (mut m, mut w) = (vec![0.0; k], vec![0.0; k]); - let (mut b1t, mut b2t) = (1.0f64, 1.0f64); - let mut rng = s.seed; - let mut steps = Vec::with_capacity(s.steps as usize + 1); - let mut best: Option<(f64, Vec, u32)> = None; - let mut evaluations: u64 = 0; - let mut bound_max = 0.0f64; - for step in 0..=s.steps { + let k = point.len(); + let mut st = match from { + Some(cp) => restore(cp, request, &start, k, s.steps)?, + None => State { next: 0, theta: point, m: vec![0.0; k], w: vec![0.0; k], b1t: 1.0, b2t: 1.0, rng: s.seed, trace: Vec::with_capacity(s.steps as usize + 1), best: None, evaluations: 0, bound_max: 0.0, chain: start }, + }; + while st.next <= s.steps { + let step = st.next; let last = step == s.steps; - let here = theta.clone(); + let here = st.theta.clone(); let mut rec = json!({ "step": step, "params": v.named(&here) }); let value = if s.optimizer == "spsa" { - let e = energy_at(&v, &theta, &h)?; - evaluations += 1; + let e = energy_at(&v, &st.theta, &h)?; + st.evaluations += 1; if !last { let kk = f64::from(step); let ak = s.step_size / pow(kk + 1.0 + s.big_a, s.alpha); let ck = s.c / pow(kk + 1.0, s.gamma); - let delta: Vec = (0..k).map(|_| if splitmix64(&mut rng) >> 63 == 0 { 1.0 } else { -1.0 }).collect(); - let plus: Vec = theta.iter().zip(&delta).map(|(t, d)| t + ck * d).collect(); - let minus: Vec = theta.iter().zip(&delta).map(|(t, d)| t - ck * d).collect(); + let delta: Vec = (0..k).map(|_| if splitmix64(&mut st.rng) >> 63 == 0 { 1.0 } else { -1.0 }).collect(); + let plus: Vec = st.theta.iter().zip(&delta).map(|(t, d)| t + ck * d).collect(); + let minus: Vec = st.theta.iter().zip(&delta).map(|(t, d)| t - ck * d).collect(); let diff = (energy_at(&v, &plus, &h)? - energy_at(&v, &minus, &h)?) / (2.0 * ck); - evaluations += 2; + st.evaluations += 2; let est: Vec = delta.iter().map(|d| diff * d).collect(); rec["perturbation"] = json!(delta); rec["estimate"] = json!(est); rec["gains"] = json!({ "a": ak, "c": ck }); - for (t, e) in theta.iter_mut().zip(&est) { + for (t, e) in st.theta.iter_mut().zip(&est) { *t -= ak * e; } } e } else { - let pt = v.at(&theta, true)?; + let pt = v.at(&st.theta, true)?; let g = gradient_at(&v, &pt, &h, &s.gradient)?; - evaluations += g.evaluations; - bound_max = bound_max.max(g.value_bound); + st.evaluations += g.evaluations; + st.bound_max = st.bound_max.max(g.value_bound); if !last { rec["gradient"] = v.named(&g.gradient); if s.optimizer == "adam" { - b1t *= s.beta1; - b2t *= s.beta2; + st.b1t *= s.beta1; + st.b2t *= s.beta2; for j in 0..k { - m[j] = s.beta1 * m[j] + (1.0 - s.beta1) * g.gradient[j]; - w[j] = s.beta2 * w[j] + (1.0 - s.beta2) * g.gradient[j] * g.gradient[j]; - let mh = m[j] / (1.0 - b1t); - let wh = w[j] / (1.0 - b2t); - theta[j] -= s.step_size * mh / (wh.sqrt() + s.epsilon); + st.m[j] = s.beta1 * st.m[j] + (1.0 - s.beta1) * g.gradient[j]; + st.w[j] = s.beta2 * st.w[j] + (1.0 - s.beta2) * g.gradient[j] * g.gradient[j]; + let mh = st.m[j] / (1.0 - st.b1t); + let wh = st.w[j] / (1.0 - st.b2t); + st.theta[j] -= s.step_size * mh / (wh.sqrt() + s.epsilon); } } else { - for (t, d) in theta.iter_mut().zip(&g.gradient) { + for (t, d) in st.theta.iter_mut().zip(&g.gradient) { *t -= s.step_size * d; } } @@ -932,19 +1098,23 @@ fn optimize_from(request: &Value, origin: &Value) -> Result { g.value }; rec["value"] = json!(value); - if best.as_ref().is_none_or(|b| value < b.0) { - best = Some((value, here, step)); + if st.best.as_ref().is_none_or(|b| value < b.0) { + st.best = Some((value, here, step)); } - if theta.iter().any(|x| !x.is_finite() || x.abs() > 1e6) { + if st.theta.iter().any(|x| !x.is_finite() || x.abs() > 1e6) { return Err(oerr(format!("step {step}: the parameters left the range a program reads (|θ| ≤ 10^6); take a smaller step_size"))); } - chain = link(&chain, &rec); - rec["digest"] = json!(hex(&chain)); - steps.push(rec); + st.chain = link(&st.chain, &rec); + rec["digest"] = json!(hex(&st.chain)); + st.trace.push(rec); + st.next += 1; + if st.next <= s.steps && !each(&Paused { state: &st, request, start: &start, steps: s.steps })? { + return Ok(None); + } } - let (bv, bp, bs) = best.expect("at least one step"); + let (bv, bp, bs) = st.best.expect("at least one step"); let best_params = v.named(&bp); - let fin = steps.last().cloned().unwrap_or(Value::Null); + let fin = st.trace.last().cloned().unwrap_or(Value::Null); let mut out = json!({ "optimizer": s.json(), "qubits": v.qubits, @@ -952,17 +1122,17 @@ fn optimize_from(request: &Value, origin: &Value) -> Result { "parameters": v.names.iter().map(|(n, t)| json!({ "name": n, "type": t })).collect::>(), "terms": h.len(), "start": hex(&start), - "steps": steps, - "chain": hex(&chain), + "steps": st.trace, + "chain": hex(&st.chain), "best": { "step": bs, "params": best_params, "value": bv }, "final": { "step": fin["step"], "params": fin["params"], "value": fin["value"] }, - "evaluations": evaluations, - "precision": precision(if s.optimizer == "spsa" { value_rounding(v.qubits, &h, p.program.ops.iter().map(|o| gate_rounding(o.qubits.len())).sum()) } else { bound_max }, None), + "evaluations": st.evaluations, + "precision": precision(if s.optimizer == "spsa" { value_rounding(v.qubits, &h, p.program.ops.iter().map(|o| gate_rounding(o.qubits.len())).sum()) } else { st.bound_max }, None), }); if s.optimizer == "spsa" { out["note"] = json!("SPSA's estimates are of the gradient from two evaluations each; its values are exact for the circuit at each point, its path depends on the seed"); } - Ok(out) + Ok(Some(out)) } /// Whether a trace's chain holds: its first link from the request, each @@ -1331,10 +1501,8 @@ pub fn qaoa(request: &Value) -> Result { /// `qaoa`, and `build` (the algorithm builders, [`super::algorithms`]). The /// answer names the engine and its schema (`wai.quantum.algorithms/1`). pub fn call(op: &str, request: &Value) -> Result { - if let Some(at) = receipt::inexact_number(request) { - return Err(tag(refusal::LIMIT, format!("`{at}`: a whole number here must be within ±9007199254740991 (2^53 − 1), so that every JSON reader reads it alike and the receipt binds it"))); - } - let mut out = match op { + exact_numbers(request)?; + let out = match op { "gradient" => gradient(request), "optimize" => optimize(request), "vqe" => vqe(request), @@ -1342,9 +1510,29 @@ pub fn call(op: &str, request: &Value) -> Result { "build" => super::algorithms::build(request), other => Err(oerr(format!("no call {other}: gradient, optimize, vqe, qaoa or build"))), }?; + Ok(answered(op, out)) +} + +/// [`call`] for an optimisation stopped and gone on with +/// ([`optimize_resumable`]): the answer once the run ends, `None` while it +/// is stopped. +pub fn call_optimize(request: &Value, from: Option<&Value>, each: impl FnMut(&Paused) -> Result) -> Result, String> { + exact_numbers(request)?; + Ok(optimize_resumable(request, from, each)?.map(|out| answered("optimize", out))) +} + +fn exact_numbers(request: &Value) -> Result<(), String> { + match receipt::inexact_number(request) { + Some(at) => Err(tag(refusal::LIMIT, format!("`{at}`: a whole number here must be within ±9007199254740991 (2^53 − 1), so that every JSON reader reads it alike and the receipt binds it"))), + None => Ok(()), + } +} + +/// An answer as [`call`] gives it: its schema, and the call it answers. +fn answered(op: &str, mut out: Value) -> Value { super::schema::stamp_as(&mut out, super::schema::ALGORITHMS); out["call"] = json!(op); - Ok(out) + out } /// The five MCP tools both servers serve, in one wording: `sealer` names diff --git a/wai/wai-quantum/src/engine/variational/tests.rs b/wai/wai-quantum/src/engine/variational/tests.rs index a882ba2b9..4c0a368bf 100644 --- a/wai/wai-quantum/src/engine/variational/tests.rs +++ b/wai/wai-quantum/src/engine/variational/tests.rs @@ -373,3 +373,104 @@ fn the_answers_are_the_same_bytes_on_every_platform() { assert_eq!(one, want, "{op}: every digest now: {got:?}"); } } + +/// Stopped after any step and gone on with from its checkpoint, written out +/// as text and read back, a run ends with the same bytes as one that never +/// stopped: for each optimiser, and stopped after every step in turn. +#[test] +fn an_optimisation_stopped_anywhere_and_gone_on_with_is_the_same_bytes() { + let src = header(&["a", "b"], 2) + "ry(a) q[0];\ncx q[0], q[1];\nry(b) q[1];\n"; + for extra in [ + json!({ "optimizer": "gradient_descent", "steps": 12, "step_size": 0.3 }), + json!({ "optimizer": "adam", "steps": 12 }), + json!({ "optimizer": "spsa", "steps": 12, "seed": 5 }), + json!({ "optimizer": "adam", "steps": 6, "gradient": "parameter_shift" }), + ] { + let mut req = json!({ "qasm": src, "params": { "a": 0.2, "b": -0.4 }, "hamiltonian": ["Z1", "0.5 X0"] }); + for (k, v) in extra.as_object().unwrap() { + req[k] = v.clone(); + } + let whole = optimize(&req).unwrap().to_string(); + let steps = req["steps"].as_u64().unwrap() as u32; + // Stopped once, after each step that leaves one to take. + for at in 1..=steps { + let mut kept = None; + let stopped = optimize_resumable(&req, None, |p| { + if p.next() == at { + kept = Some(p.checkpoint().to_string()); + return Ok(false); + } + Ok(true) + }) + .unwrap(); + assert!(stopped.is_none(), "{extra}: stopped at {at}"); + let cp: Value = serde_json::from_str(&kept.unwrap()).unwrap(); + assert_eq!((cp["next"].as_u64(), cp["trace"].as_array().map(Vec::len)), (Some(u64::from(at)), Some(at as usize))); + let gone_on = optimize_resumable(&req, Some(&cp), |_| Ok(true)).unwrap().unwrap(); + assert_eq!(gone_on.to_string(), whole, "{extra}: stopped after step {}", at - 1); + } + // Stopped after every step, one step a slice. + let mut cp: Option = None; + let mut slices = 0; + let result = loop { + slices += 1; + let mut kept = None; + match optimize_resumable(&req, cp.as_ref(), |p| { + kept = Some(serde_json::from_str::(&p.checkpoint().to_string()).unwrap()); + Ok(false) + }) + .unwrap() + { + Some(r) => break r, + None => cp = kept, + } + }; + assert_eq!(slices, steps + 1, "{extra}"); + assert_eq!(result.to_string(), whole, "{extra}: one step a slice"); + } +} + +/// A checkpoint goes on only from where it was made: one of another +/// request, one whose steps were changed, and one that does not read are +/// each refused with the optimisation's code. +#[test] +fn a_checkpoint_goes_on_only_from_its_own_run() { + let req = json!({ "qasm": header(&["a"], 1) + "ry(a) q[0];\n", "params": { "a": 0.3 }, "hamiltonian": ["Z0"], "optimizer": "adam", "steps": 8 }); + let mut cp = Value::Null; + optimize_resumable(&req, None, |p| { + if p.next() == 4 { + cp = p.checkpoint(); + return Ok(false); + } + Ok(true) + }) + .unwrap(); + assert_eq!(cp["format"], CHECKPOINT); + let refused = |r: &Value, c: &Value| optimize_resumable(r, Some(c), |_| Ok(true)).unwrap_err(); + let mut other = req.clone(); + other["params"]["a"] = json!(0.31); + let mut longer = req.clone(); + longer["steps"] = json!(9); + for r in [&other, &longer] { + let e = refused(r, &cp); + assert!(e.contains("another request") && e.ends_with("[J052]"), "{e}"); + } + let edits: Vec<(&str, Box)> = vec![ + ("a step's value", Box::new(|c| c["trace"][2]["value"] = json!(-1.0))), + ("a step dropped", Box::new(|c| drop(c["trace"].as_array_mut().unwrap().pop()))), + ("the chain", Box::new(|c| c["chain"] = json!("00".repeat(32)))), + ("the next step", Box::new(|c| c["next"] = json!(9))), + ("the parameters", Box::new(|c| c["state"]["theta"] = json!(["zz"]))), + ("the stream", Box::new(|c| c["state"]["rng"] = json!(7))), + ("the format", Box::new(|c| c["format"] = json!("something else"))), + ("the parameters' range", Box::new(|c| c["state"]["theta"] = json!([format!("{:016x}", f64::NAN.to_bits())]))), + ]; + for (what, edit) in edits { + let mut c = cp.clone(); + edit(&mut c); + let e = refused(&req, &c); + assert!(e.starts_with("the checkpoint does not go on from here") && e.ends_with("[J052]"), "{what}: {e}"); + } + // Its own goes on, and ends as a run that never stopped. + assert_eq!(optimize_resumable(&req, Some(&cp), |_| Ok(true)).unwrap().unwrap(), optimize(&req).unwrap()); +}