wai-quantum-service: POST /v1/write and write_circuit, a program written for a task on the service's own model (stacked on #646) #648

Closed
dcharlot wants to merge 15 commits from wai/quantum-service-write into main
30 changed files with 12903 additions and 7905 deletions

View file

@ -5,8 +5,9 @@ name: wai-quantum service
# an instance started here, on Linux and on the macOS runner (the host the
# service is deployed to). Its tests also hold the command line's snapshot of
# its MCP tools (wai/wai-quantum-cli/docs/service-mcp.json) to the live list,
# and one (tests/restart.rs) kills the service mid-job and starts it again on
# its store. Each instance here keeps its job store in the runner's own
# one (tests/restart.rs) kills the service mid-job and starts it again on its
# store, and one (tests/write.rs) writes a program for a task with a scripted
# writer process, sandboxed on the macOS runner as it is in production. Each instance here keeps its job store in the runner's own
# scratch directory.
concurrency:
@ -19,6 +20,11 @@ on:
paths:
- 'wai/wai-quantum/**'
- 'wai/wai-quantum-service/**'
# The service writes programs with the writer's loop, its model on the
# loader below, both path dependencies (src/writer.rs).
- 'wai/wai-quantum-writer/**'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/**'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-core/**'
# The command line's agent docs list this service's tools from a snapshot
# there, which this service's tests hold to its live list.
- 'wai/wai-quantum-cli/docs/**'
@ -28,6 +34,11 @@ on:
paths:
- 'wai/wai-quantum/**'
- 'wai/wai-quantum-service/**'
# The service writes programs with the writer's loop, its model on the
# loader below, both path dependencies (src/writer.rs).
- 'wai/wai-quantum-writer/**'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/**'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-core/**'
# The command line's agent docs list this service's tools from a snapshot
# there, which this service's tests hold to its live list.
- 'wai/wai-quantum-cli/docs/**'

View file

@ -0,0 +1,44 @@
### The job service writes a program for a task, on its own model
`POST /v1/write` and the MCP tool `write_circuit`: a service started with a model (`WAI_QSVC_MODEL`) writes an OpenQASM
program for a task, an exercise in the course's form, with `wai-quantum-writer`'s loop, the engine checking each attempt.
- **The result** is the transcript (`wai.quantum.write/1`), sealed with the service's receipt as every result is, the
writer's energy inside it, so `POST /v1/verify` checks it. Each attempt's timing is beside it, outside the signed body,
and each attempt is an `attempt` event on `GET /v1/jobs/{id}/events` as it lands.
- **The writer process** (`wai-quantum-service writer`) starts with the service and serves every write, so the model
loads once; one that ends is started again, and a write it was running fails, saying so. It reads every byte of the
model against its manifest as it starts, and loads exactly the files the manifest lists, so a transcript's model
digest names the weights that ran (`"checked": "blake3"`). On macOS it runs sandboxed as a worker does, reading its
model. Its resident memory is held to `WAI_QSVC_WRITER_GB` (96), a write to `WAI_QSVC_WRITE_SECS` (3600), and the
engine's checks in it to one job's memory, which the transcript records (`check_memory_bytes`).
- **Admission.** Writes run one at a time, in the queue's order, beside the circuit jobs and holding none of their
memory budget; each kind fills the queue up to `WAI_QSVC_MAX_QUEUE` alone, so a backlog of writes refuses no circuit
job. A client holds one queued or running write, checked and taken under one lock, so writes sent at once admit one.
A write cancelled while the writer starts is never sent to it, and one cancelled while it runs stops the writer.
`Idempotency-Key` works as for a job, and a write the service was running when it stopped runs again once, to the
same transcript.
- **What a task may hold.** A task is held to the course's form before it is queued (`check_task`, through
`engine::course::exercise_form`): every bound whole numbers, every constraint one the course names, a job of shots,
seed and noise alone, at most 16 cases and 8 intents in one place, every case giving the same inputs or none. Its
canonical form is at most 64 KiB, what the model is first told of it at most 8 KiB, and a program an intent compares
with is held to the equivalence bounds a job's intent is (`J031`). The cheap checks come first, then the client's
window (a write refused after it still counts), then the programs, each read once and all within one budget
(`wai_quantum::qasm::within_steps`, 1 000 000 statements), as blocking work off the runtime's workers.
- **The queue and its lock.** A write cancelled while queued holds no place in it, and no lock is held across the store's
writes: a client's write is reserved in memory while it is admitted, and a job's record is kept before it is queued.
- **The same transcript, in the same arithmetic.** The task reaches the model in its canonical form, so the same model,
task, options and engine give the same transcript on a host whose arithmetic the transcript's model names
(architecture, operating system, maths library, matmul kernel).
- **Refusals:** `J070` (no model here, `503`), `J071` (a task that does not read), `J072` (options out of range).
- **Discovery:** `GET /v1` and `quantum_service_info` give `write`: the model's name, licence and digest once it is
loaded, or that there is none. The OpenAPI document has the route, and `smoke.sh` writes a Bell pair's program with
`SMOKE_WRITE=1`.
- **wai-quantum-writer:** the scripted model is public (`scripted::Scripted`, read from a JSON script), `write_with`
hands on each attempt as it lands, and `check_task` holds a task to having something to check, and its canonical form
to the course's form: a case without an intent, in a task with none, or a task a check would read as asking nothing,
is refused before the model is asked.
- **wai-quantum:** the refusal codes `J070` to `J072`; a course check's `lesson` may be null, as `check_exercise`
gives it.
- **The verdict** `get_quantum_job` and `tasks/result` state for a write is the last program's, checked against the
task's constraints as well as its intents: a program whose intent holds and whose constraint does not is
`holds: false`, saying which. A write with no attempt yet has no verdict.

View file

@ -969,6 +969,16 @@ Parameters:
- `x` (number): qsvt: the signal.
- `matrix` (array): qsvt: [[a, b], [b, c]], real and symmetric, its eigenvalues within [-1, 1].
### `write_circuit`: Write a quantum program for a task
Have the service's own model write an OpenQASM 3 program for a task, the engine checking each attempt against what the task asks exactly as it checks a lesson's answer, and what went wrong going back to the model for another attempt, up to `rounds` repairs. The task is an exercise in the course's form (wai.quantum.exercise/1): a title and task in words, an optional starter program, and what the program must do, as an `intent` (an exact distribution, a reference program it must equal or whose state it must prepare) or `cases` that each have one, with optional constraints (gates allowed, counts, T count, smells), at most 16 cases and 8 intents in one place, its canonical form at most 64 KiB. The answer is a job whose result is the transcript (wai.quantum.write/1): the model by name, licence and the digest of its files, every byte checked, and the arithmetic it ran in, the prompt, each attempt's program and the engine's check, and whether one passed and at what level, signed (verify_quantum_receipt checks it with the job's request). Decoding is greedy, so the same task gives the same transcript in the same arithmetic (architecture, operating system, maths library, matmul kernel). Writes take minutes and run one at a time; a client holds one queued or running write. Call it as a task, or it waits 50 s and answers with status running and an id for get_quantum_job. A service without a model refuses it (J070); quantum_service_info says under `write` whether this one has one. Each call is a new write unless it gives idempotency_key.
Parameters:
- `task` (object, required): The exercise: {"title", "task", "starter", "intent" or "cases", "constraints", "job"}, as the course's exercises (wai.quantum.exercise/1); title and task may be plain strings. It must give an intent, or cases that each have one (J071).
- `rounds` (integer): Repairs after the first attempt, each with the engine's account of the last (J072 outside 0 to 4).
- `max_new` (integer): The most tokens one reply may take, its thinking included.
- `idempotency_key` (string): A key of your making that names this run, so that a call sent again is not a second job: the same key with the same arguments answers with the job it names, for as long as the job is kept, rather than running another; the same key with other arguments is refused (J034). Make it unguessable (a UUID). A job the service interrupted is run again under its key. Without a key, every call is a new job.
## Resources, on both servers
### `wai-quantum://docs/language`: the OpenQASM the engine reads

View file

@ -129,6 +129,7 @@ Runs on this machine, signed with the user's key; nothing leaves the machine.
- `vqe_ground_state`: Find a Hamiltonian's ground energy by the variational eigensolver
- `qaoa_maxcut`: Solve Max-Cut on a graph by QAOA
- `build_algorithm`: Write the program for a standard algorithm, checked against its reference
- `write_circuit`: Write a quantum program for a task
## Resources and prompts, on both servers

File diff suppressed because it is too large Load diff

View file

@ -91,6 +91,7 @@ Generated by `wai-quantum docs skill` from the servers' own lists; `wai-quantum
- `vqe_ground_state`: Find a Hamiltonian's ground energy by the variational eigensolver
- `qaoa_maxcut`: Solve Max-Cut on a graph by QAOA
- `build_algorithm`: Write the program for a standard algorithm, checked against its reference
- `write_circuit`: Write a quantum program for a task
## Resources, on both servers

View file

@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@ -72,6 +81,21 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.13.2"
@ -152,6 +176,31 @@ dependencies = [
"libc",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -233,6 +282,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "equivalent"
version = "1.0.3"
@ -246,7 +301,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "fancy-regex"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
dependencies = [
"bit-set",
"regex-automata",
"regex-syntax",
]
[[package]]
@ -433,6 +499,27 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jouleclaw-core"
version = "0.1.0"
[[package]]
name = "jouleclaw-loader-gguf"
version = "0.1.0"
dependencies = [
"fancy-regex",
"jouleclaw-core",
"libc",
"memmap2",
"quick-xml",
"rayon",
"regex",
"serde",
"serde_json",
"thiserror",
"windows-sys 0.59.0",
]
[[package]]
name = "libc"
version = "0.2.191"
@ -457,6 +544,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
dependencies = [
"libc",
]
[[package]]
name = "mime"
version = "0.3.17"
@ -471,7 +567,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@ -547,6 +643,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
@ -571,6 +676,55 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc_version"
version = "0.4.1"
@ -715,7 +869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@ -762,6 +916,26 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "tokio"
version = "1.54.1"
@ -775,7 +949,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@ -881,6 +1055,18 @@ dependencies = [
"serde_json",
"tokio",
"wai-quantum",
"wai-quantum-writer",
]
[[package]]
name = "wai-quantum-writer"
version = "0.1.0"
dependencies = [
"blake3",
"jouleclaw-loader-gguf",
"memmap2",
"serde_json",
"wai-quantum",
]
[[package]]
@ -895,6 +1081,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@ -904,6 +1099,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zeroize"
version = "1.9.1"

View file

@ -20,6 +20,15 @@ blake3 = "=1.8.7"
# A job's status as server-sent events (src/http.rs): a stream built from its changes.
futures-util = { version = "=0.3.34", default-features = false }
wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt", "quantum_qec", "quantum_noise", "quantum_fabric", "quantum_sv_metal", "quantum_cutrank_metal"] }
# A program written for a task (src/writer.rs): the loop, its transcript and the
# scripted model; with `coder`, the model the writer process runs.
wai-quantum-writer = { path = "../wai-quantum-writer", default-features = false }
[features]
default = ["coder"]
# The writer process's model (North Mini Code 1.0, on the pure-Rust inference
# runtime). Without it, the writer runs only a script (WAI_QSVC_WRITER_SCRIPT).
coder = ["wai-quantum-writer/coder"]
# proc_pid_rusage and waitid, for each job's energy (src/energy.rs).
[target.'cfg(target_os = "macos")'.dependencies]

View file

@ -4,6 +4,8 @@ The WAI quantum stack as a public job service. Send an OpenQASM program; get bac
No account and no key. The public instance is at **https://wai.transaction.science/quantum-jobs/v1**. It keeps every job across its own restarts for a day, runs sets of programs and sweeps of one program's inputs as one job, and answers a dry run with its plan before anything runs. You can run the same service yourself: [docs/self-host.md](docs/self-host.md).
A service started with a model also writes programs: send it a task, what a program must do, and its own model writes an OpenQASM program for it, the engine checking each attempt ([below](#writing-a-program-for-a-task)).
The service runs jobs on its own engine only. It never routes a job to anyone's quantum hardware or provider and never takes a credential: a job that names a `provider` is refused (`400`), with where such a run goes instead (the command line's `run --provider`, or the desktop app, which hold the person's own credentials; see `wai-quantum-qpu/bring-your-own-quantum.md`).
```sh runnable needs=service
@ -72,6 +74,31 @@ curl -s https://wai.transaction.science/quantum-jobs/v1/run -H 'content-type: ap
| `accepted` | whether the service would take the job now, by every limit; if not, `status` and `refusal` (its message and code) say why |
| `items` | for a set: each item's plan and estimates |
## Writing a program for a task
A service started with a model (`WAI_QSVC_MODEL`, the model's directory) also writes programs. `POST /v1/write` takes a task, and the service's own model writes an OpenQASM 3 program for it. The engine checks each attempt against the task exactly as it checks a lesson's answer in the course. What went wrong goes back to the model, in the engine's words, for the next attempt, until a program passes or the rounds run out. The model is North Mini Code 1.0 (Apache-2.0), on WAI's own pure-Rust inference runtime ([wai-quantum-writer](../wai-quantum-writer/src/lib.rs)).
```json
{ "task": { "title": "A Bell pair", "task": "Entangle two qubits so that their measured bits always agree.", "intent": { "distribution": { "00": 0.5, "11": 0.5 } } }, "rounds": 4, "max_new": 3000 }
```
| field | meaning |
|---|---|
| `task` | an exercise in the course's form (`wai.quantum.exercise/1`): `title` and `task` in words (strings, or one per locale), an optional `starter` program, and what the program must do, as an `intent` (a job's intent) or as `cases` that each have one (a case without one takes the task's); optionally `constraints` (`allow`, `counts`, `t_count`, `two_qubit`, `no_smells`, `estimate`) and the `job` the check runs it as (`shots`, `seed`, `noise`) |
| `rounds` | repairs after the first attempt: 0 to 4 (default 4) |
| `max_new` | the most tokens one reply may take, its thinking included: 1 to 4000 (default 3000) |
- **The answer** is `202` and the job, at once, since a write takes minutes. Its result is the transcript (`wai.quantum.write/1`). It names the task and its digest; the model by its name, licence and the digest of its files, how far its files were checked (`"checked": "blake3"`: the writer reads every byte against the model's manifest when it starts, and loads exactly the files the manifest lists), and the arithmetic it ran in (architecture, operating system, maths library, matmul kernel); the prompt by its version and digests; the decoding (greedy, with its thinking cap), the memory each check's run was planned in (`check_memory_bytes`) and the engine's version. It holds each attempt with its program, the program's digest and the engine's check, and says whether one passed and at what level.
- **Sealed as every result is.** The service's receipt seals the transcript with the job's `request`, the writer's energy inside it, so `POST /v1/verify` checks it. How long each reply and each check took is the job's `timing`, beside the result and outside the signed body.
- **Each attempt as it lands** is an `attempt` event (`{"id", "attempt", "timing"}`) on `GET /v1/jobs/{id}/events`. A running write's job also gives `progress`: how many attempts the engine has checked, and the latest.
- **The same task gives the same transcript, in the same arithmetic.** Decoding is greedy, and the task reaches the model in its canonical form (the form its digest is taken over), so the same model files, task, options and engine give the same bytes on a host whose arithmetic is the same: the transcript's model names it. On another architecture or maths library the logits can differ in their low bits, and a near tie can go the other way. A write that was running when the service stopped is run again, once, as a job is, and gives the same transcript.
- **One write at a time.** The model runs in one writer process (the binary's `writer` mode), started with the service and kept for every write, so the model loads once. Writes wait in the queue in their order, beside the circuit jobs, and neither waits for the other: each kind fills the queue up to `WAI_QSVC_MAX_QUEUE` alone. A client holds one queued or running write, however many it sends at once: a second is refused (`429`, `J030`).
- **Its limits.** A write may run for `WAI_QSVC_WRITE_SECS` (an hour by default). The writer's resident memory, its model's mapped pages among it, is held to `WAI_QSVC_WRITER_GB` (96 GiB). The engine's checks inside it plan within one job's memory (`WAI_QSVC_JOB_GB`), so the check refuses a program larger than a job may be, as a job's plan does. A writer that passes its memory or its time, or whose write is cancelled, is stopped: the write fails saying which, and the next write starts the writer again. A write cancelled while the writer starts is never sent to it.
- **What a task may hold.** A task is held to the course's form before it is queued: every bound whole numbers, every constraint one the course names, a job of `shots`, `seed` and `noise` alone, at most 16 cases and 8 intents in one place. Its canonical form is at most 64 KiB, what the model is first told of it at most 8 KiB, and a program an intent compares with is held to the equivalence bounds a job's intent is (`422`, `J031`). What costs nothing to read (the shape, the options, the sizes, the number of cases and intents) is refused first; then the write counts against its client's window, whatever comes of it; only then are its programs read, each once and all within one budget of 1 000 000 statements, as work that holds up no other request. Every case gives the same inputs, or none does.
- **Refusals.** A service with no model answers `503` (`J070`). `GET /v1` says under `write` whether this one has a model, and names it (name, licence, digest) once it is loaded. A task without an intent, or with a case that has none and no task's intent to take, or not in the course's form or past its sizes, is `400` (`J071`). Options out of range, or a member a write does not take, are `400` (`J072`). An `Idempotency-Key` works as it does for a job.
The MCP tool `write_circuit` takes the same and runs as a task ([For agents](#for-agents)). `WAI_QSVC_WRITER_SCRIPT` names a script the writer runs in the model's place: a JSON list of replies, each a string, or null for a reply that never left its thinking. The service's tests write with one. `smoke.sh` writes a Bell pair's program when `SMOKE_WRITE=1`, against a service with a model.
## Methods
| method | takes | answers | accuracy |
@ -143,7 +170,7 @@ Counts are always seeded samples.
| `POST /v1/run` | submit a job and wait up to 25 s: the finished job, or `202` and the job if it is still running |
| `POST /v1/jobs` | submit a job or a set: `202` and the job, with its `id`, once it is in the store; with `dry_run`, `200` and the plan |
| `GET /v1/jobs/{id}` | the job: its status, the request as submitted, its result when it is done, and the digests it is kept under; `?wait=S` (at most 25) holds the answer until its status changes |
| `GET /v1/jobs/{id}/events` | the job's status as server-sent events: a `status` event at each change, then one `job` event with the whole job when it ends |
| `GET /v1/jobs/{id}/events` | the job's status as server-sent events: a `status` event at each change, for a write an `attempt` event for each attempt as it lands, then one `job` event with the whole job when it ends |
| `DELETE /v1/jobs/{id}` | cancel a queued or running job |
| `POST /v1/verify` | `{"request": ..., "result": ...}`: whether the result's receipt seals that request and that result |
| `POST /v1/critique` | `{"qasm"}`: the program's measures (gates by kind, two-qubit gates, depth, T count, whether it is Clifford, a modelled fidelity) and its smells, each with its lines |
@ -169,13 +196,14 @@ The three error-correction calls answer in a job's form, sealed with the service
| `POST /v1/vqe` | `{"hamiltonian", "qasm" and "params" or "ansatz": {"layers"}, …}`: the variational eigensolver, beside the exact ground energy by diagonalisation |
| `POST /v1/qaoa` | `{"graph": {"nodes", "edges"}, "layers"?, …}`: Max-Cut by QAOA, beside the maximum cut by brute force |
| `POST /v1/build` | `{"algorithm": "qpe" \| "amplitude_estimation" \| "adder" \| "qsvt", …}`: a standard algorithm's program, how to read it, and the equivalence ladder's verdict against a reference built from its definition |
| `POST /v1/write` | `{"task", "rounds"?, "max_new"?}`: a program written for the task by the service's model, each attempt checked by the engine; `202` and the job, its result the signed transcript ([above](#writing-a-program-for-a-task)) |
| `POST /mcp` | the Model Context Protocol endpoint (streamable HTTP, answered as JSON, no key): the tools, resources and prompts below |
The three error-correction calls and the five of the variational and algorithm library answer in a job's form, sealed with the service's key, so `POST /v1/verify` checks them ([the error-correction page](../wai-quantum/docs/qec.md), [the variational page](../wai-quantum/docs/variational.md)). A job's `params` binds its program's parameters (`input float theta;`, `input angle phi;`) exactly. A job may also give `mitigate` beside `noise` and `observables` (`"zne"`, `"pea"`, `"pec"`, `"cdr"`, `"vd"`, `"readout"`, or `{"method", ...}` with its options): its result then carries each value raw and mitigated with its error bar, the method and its assumptions, inside the receipt.
A job may give a `target`: a built-in target's name, or a target document (`wai.quantum.target/1`, its schema in `GET /v1/openapi.json`). The program is compiled to the target's gates, placed on the qubits and routes most likely to succeed, and simulated under the noise its calibration gives. The result's `target` names the layout, its expected success, the noise and the target's hash, inside the receipt. The service reads the document it is given and fetches nothing.
Every refusal says why and ends with its code, which the body also carries as `code` ([the job layer's codes](../wai-quantum/docs/job-refusals.md), beside the reader's): `400` for a program or job that does not read (a set that does not read: `J007`), `422` for one larger than a job may be (`J031`), `429` past the per-client limits (`J030`), `503` when the queue is full (`J032`), on a private deployment `401` without one of its API keys (`J033`), and an `Idempotency-Key` that does not read (`400`) or was sent before with another request (`422`): `J034`. `GET /v1` states the limits.
Every refusal says why and ends with its code, which the body also carries as `code` ([the job layer's codes](../wai-quantum/docs/job-refusals.md), beside the reader's): `400` for a program or job that does not read (a set that does not read: `J007`), `422` for one larger than a job may be (`J031`), `429` past the per-client limits (`J030`), `503` when the queue is full (`J032`), on a private deployment `401` without one of its API keys (`J033`), an `Idempotency-Key` that does not read (`400`) or was sent before with another request (`422`): `J034`; and for a write, `503` on a service with no model (`J070`), `400` for a task that does not read (`J071`) or options out of range (`J072`). `GET /v1` states the limits.
## Status and limits
@ -185,14 +213,14 @@ Every answer carries the client's submission window in the rate-limit fields of
## Sending a job again
A connection can fail after the service took a job and before its answer came back. To send the job again safely, give `POST /v1/jobs` or `POST /v1/run` an `Idempotency-Key` (the same working group's field): 1 to 255 visible ASCII characters, with no quote or backslash, as a structured field's string or bare. Make it unguessable, a UUID or 128 random bits, since it is kept per client and a client is an address.
A connection can fail after the service took a job and before its answer came back. To send the job again safely, give `POST /v1/jobs`, `POST /v1/run` or `POST /v1/write` an `Idempotency-Key` (the same working group's field): 1 to 255 visible ASCII characters, with no quote or backslash, as a structured field's string or bare. Make it unguessable, a UUID or 128 random bits, since it is kept per client and a client is an address.
- Sent again with the same key and the same request, the job is answered with the job the key names, before any limit, and counts as no submission.
- The same key with another request is refused, `422` and `J034`.
- A key names its job for as long as the job is kept, whatever its status, with one exception: a job the service interrupted. That job's key runs the request again.
- The record keeps a digest of the key with the client's name, never the key.
The MCP tools that run (`run_quantum_circuit`, `run_quantum_set`, `run_on_target`) take the same key as `idempotency_key`. Without one, each call is a new job, which is why their `idempotentHint` is false. The command line (`wai-quantum submit`) and the Python client send each job under a key of their own. Where no answer comes back, they send it again under that key, twice at most, and only to a service whose `GET /v1` says it keeps keys (`idempotency`).
The MCP tools that run (`run_quantum_circuit`, `run_quantum_set`, `run_on_target`, `write_circuit`) take the same key as `idempotency_key`. Without one, each call is a new job, which is why their `idempotentHint` is false. The command line (`wai-quantum submit`) and the Python client send each job under a key of their own. Where no answer comes back, they send it again under that key, twice at most, and only to a service whose `GET /v1` says it keeps keys (`idempotency`).
## Kept across restarts
@ -218,14 +246,14 @@ The six checks answer at once, in the service's own process. Each counts against
## For agents
`POST /mcp` serves the service to an agent as Model Context Protocol tools: `run_quantum_circuit`, `run_quantum_set` (a set or a sweep), `dry_run_quantum_job` (the plan, running nothing), `get_quantum_job`, `verify_quantum_receipt`, `critique_qasm`, `check_equivalent`, `convert_program`, `improve_qasm`, `classical_shortcuts`, `compile_qasm`, `estimate_resources`, `repair_qasm`, `quantum_service_info`, `qasm_diagnostics`, `list_components`, `list_targets`, `show_target`, `run_on_target`, `quantum_circuit_examples`, `qec_code`, `qec_decode`, `qec_threshold`, `pulse_schedule`, `pulse_check`, `gradient_qasm`, `optimize_qasm`, `vqe_ground_state`, `qaoa_maxcut` and `build_algorithm`. A run it makes asks for its verdict, so the request its receipt seals is the job's own `request`; every answer about a program carries its verdict, every refusal its codes, and every tool a versioned output schema. `verify_quantum_receipt` re-runs an estimate. It also serves the engine's resources and prompts, the same as the command line's own server (`wai-quantum mcp`):
`POST /mcp` serves the service to an agent as Model Context Protocol tools: `run_quantum_circuit`, `run_quantum_set` (a set or a sweep), `dry_run_quantum_job` (the plan, running nothing), `get_quantum_job`, `verify_quantum_receipt`, `critique_qasm`, `check_equivalent`, `convert_program`, `improve_qasm`, `classical_shortcuts`, `compile_qasm`, `estimate_resources`, `repair_qasm`, `quantum_service_info`, `qasm_diagnostics`, `list_components`, `list_targets`, `show_target`, `run_on_target`, `quantum_circuit_examples`, `qec_code`, `qec_decode`, `qec_threshold`, `pulse_schedule`, `pulse_check`, `gradient_qasm`, `optimize_qasm`, `vqe_ground_state`, `qaoa_maxcut`, `build_algorithm` and `write_circuit` (a program written for a task, on a service with a model). A run it makes asks for its verdict, so the request its receipt seals is the job's own `request`; every answer about a program carries its verdict, every refusal its codes, and every tool a versioned output schema. `verify_quantum_receipt` re-runs an estimate. It also serves the engine's resources and prompts, the same as the command line's own server (`wai-quantum mcp`):
- resources: the language the engine reads (`wai-quantum://docs/language`), its diagnostics, the methods with their accuracy classes (their reach in a job's memory here), and worked examples, each with what it must do;
- prompts: writing and verifying a circuit for an intent, explaining a result and its receipt, and repairing a program from its diagnostics. Each names this server's own tools.
A client whose `initialize` says its host draws interactive views (the extension `io.modelcontextprotocol/ui`, version 2026-01-26) gets the engine's answers as views, as from the command line's server: each tool that has one names its `ui://wai-quantum/*` resource, the six views are listed and read, and each answer carries what its view draws in its `_meta`. The service keeps no session, so the client's word is carried in the session id it is given at `initialize` (`wq-views-` and 32 random hex digits, kept nowhere), which a client sends back with every request (`Mcp-Session-Id`); a client that draws no views gets no session id and none of this. For a job still running when the run's wait ends, the result view follows it with `poll_quantum_job`, which the host keeps from the model (`visibility: ["app"]`); a model reads a job with `get_quantum_job`.
A client that speaks MCP 2025-11-25 may call the three runs as tasks (`tools/call` with `task`): `initialize` declares `tasks` (`cancel`, and `requests.tools.call`), and each run's `execution.taskSupport` is `optional`.
A client that speaks MCP 2025-11-25 may call the three runs and `write_circuit` as tasks (`tools/call` with `task`): `initialize` declares `tasks` (`cancel`, and `requests.tools.call`), and each one's `execution.taskSupport` is `optional`. A write's task says, while it works, how many attempts the engine has checked, and its result states the verdict of the last program it wrote.
- **The task is the job.** Its `taskId` is the job's id. `tools/call` answers at once with the task, `working`. A program that does not read makes no task: the answer is an error (`-32602`) whose `data` is the refusal, with its codes and verdict.
- **Statuses.** `tasks/get` gives the job as a task: `working` while it is queued or running, `completed` once done, `cancelled`, or `failed` for a job that failed or that the service interrupted, with why. Its `ttl` is the service's retention, which a job outlasts from its creation, since it is kept that long after it ends.
@ -280,6 +308,8 @@ A `statevector_single` or `cutrank_single` job runs on the GPU, which this count
On Linux there is no per-process count. The service reads the processor packages' energy counters instead, through the kernel's power-capping interface, before the worker starts and after it exits. They count whole packages, so the figure is the packages' energy over the job's window, every process on the host included. It says so: `attribution` is `none` (the macOS figure's is `process`), and `concurrent_jobs` counts the other jobs that ran in the window. The counter is used only when every package zone reads and is live (each package advancing in 80% of fifteen 20 ms intervals at start). Its declared uncertainty covers the two whole-microjoule reads and the counter's update edges, measured then, which also bound the hardware unit the interface hides.
A write's figure is the writer process's. The writer runs on between writes, so the service reads its count before the write is sent and again after its last line comes back, over the write's window. The second read trails by the time the writer's threads are still on a core, which the kernel adds only when they leave it, so the figure is a lower bound by that much: its `coverage` is `partial`, and `excludes` says why. On Linux a write's figure is the packages' over its window, as a job's is.
Where the service cannot read a count (another system, a zone its user may not read, a counter that is not live, as in most containers), `energy` is `{"unmetered": "<why>"}`. It is never a figure of zero. `GET /v1` says how this host meters (`energy`). A set's figure covers every item together; each item's own says so. `wai-quantum-service run FILE` runs a job in its own process and reports no energy.
## Versions
@ -300,7 +330,7 @@ Each job runs in a child process of the same binary (`worker`), at a lower sched
Jobs are kept in `WAI_QSVC_STORE` (by default `~/.local/share/wai-quantum-service/store`; `memory` keeps them only while the process runs). [docs/self-host.md](docs/self-host.md) covers running it yourself: the OCI image ([`deploy/Containerfile`](deploy/Containerfile)), the configuration, the store, the keys, API keys for a private deployment, and a reverse proxy in front. [docs/scheduler.md](docs/scheduler.md) covers handing the service to an on-premises batch scheduler's jobs.
On macOS the worker runs in a sandbox (`sandbox-exec`): no network, no file writes, and no program but itself. It reads its job on stdin and writes its result on stdout, so it needs none of them, and a flaw in parsing a submitted circuit then reaches neither the host's files, nor the network, nor a shell. `WAI_QSVC_SANDBOX=0` turns the sandbox off, for debugging.
On macOS the worker runs in a sandbox (`sandbox-exec`): no network, no file writes, and no program but itself. It reads its job on stdin and writes its result on stdout, so it needs none of them, and a flaw in parsing a submitted circuit then reaches neither the host's files, nor the network, nor a shell. The writer runs in the same sandbox, its model's directory read. `WAI_QSVC_SANDBOX=0` turns the sandbox off, for debugging.
Limits are counted per client. A client is an IPv4 address, or the /64 an IPv6 address is in: one subscriber is usually given a /64 whole, so counting single addresses would hand out fresh limits to anyone who rotates through theirs. Behind the front proxy, the client is the first `X-Forwarded-For` address, which the proxy sets from the edge network's own client-address header rather than passing on what the request carried.
@ -317,13 +347,17 @@ These variables set the limits:
| `WAI_QSVC_CLIENT_GB` | the job size | memory one client's queued and running jobs may hold together |
| `WAI_QSVC_CLIENT_BURST` | 30 | submissions one client may make per window |
| `WAI_QSVC_CLIENT_WINDOW_SECS` | 600 | the window |
| `WAI_QSVC_MAX_QUEUE` | 256 | jobs waiting at once |
| `WAI_QSVC_MAX_QUEUE` | 256 | circuit jobs waiting at once, and writes waiting at once, each counted alone |
| `WAI_QSVC_RETAIN_SECS` | 86400 | how long finished jobs are kept, across restarts |
| `WAI_QSVC_STORE` | `~/.local/share/wai-quantum-service/store` | the job store's directory, or `memory` |
| `WAI_QSVC_API_KEYS` | unset | a file of API keys' digests: set, every request but the open ones needs a key (private deployments; the public service takes none) |
| `WAI_QSVC_ENERGY` | 1 | 0 meters no job's energy |
| `WAI_QSVC_TRUSTED_PROXIES` | `127.0.0.1,::1` | peers whose `X-Forwarded-For` names the client |
| `WAI_QSVC_SANDBOX` | 1 | run each worker sandboxed (macOS); 0 runs it unsandboxed |
| `WAI_QSVC_SANDBOX` | 1 | run each worker, and the writer, sandboxed (macOS); 0 runs them unsandboxed |
| `WAI_QSVC_MODEL` | unset | the model's directory (its files and `wai-model.json`): set, the service writes programs for tasks (`POST /v1/write`); unset, it refuses them (`J070`) |
| `WAI_QSVC_WRITER_GB` | 96 | the memory the writer process may hold, its model's mapped pages among it |
| `WAI_QSVC_WRITE_SECS` | 3600 | the longest a write may run |
| `WAI_QSVC_WRITER_SCRIPT` | unset | a script the writer runs in the model's place (tests) |
| `WAI_QSVC_KEY` | `~/.config/wai-quantum-service/signing.key` | the signing key, created on first start; its key set beside it, `<key>.keys.json` |
`wai-quantum-service run job.json` runs one job or set in the current process and prints its plan and its result. `wai-quantum-service rotate-key` retires the receipt key and makes a new one. `wai-quantum-service api-key NAME` makes an API key and the line for the keys file.

View file

@ -1,9 +1,11 @@
# The WAI quantum job service as an OCI image, for running it yourself.
#
# Build from the repository's wai/ directory, with the ignore file beside
# this one keeping the context to the two crates the service is built from:
# Build from the repository's root, with the ignore file beside this one
# keeping the context to the crates the service is built from: the engine, the
# service, the writer (src/writer.rs), and the two crates of the writer's model
# runtime, with the manifest of their workspace, which they inherit from:
#
# docker build -f wai-quantum-service/deploy/Containerfile -t wai-quantum-service wai/
# docker build -f wai/wai-quantum-service/deploy/Containerfile -t wai-quantum-service .
# (or: podman build -f ... the same)
#
# Two stages. The first builds the service with the repository's pinned
@ -19,9 +21,13 @@ ENV CARGO_INCREMENTAL=0 \
SOURCE_DATE_EPOCH=0 \
RUSTFLAGS="--remap-path-prefix=/src=/build --remap-path-prefix=/usr/local/cargo=/cargo -C strip=symbols"
WORKDIR /src
COPY wai-quantum ./wai-quantum
COPY wai-quantum-service ./wai-quantum-service
WORKDIR /src/wai-quantum-service
COPY wai/wai-quantum ./wai/wai-quantum
COPY wai/wai-quantum-writer ./wai/wai-quantum-writer
COPY wai/wai-quantum-service ./wai/wai-quantum-service
COPY jouleclaw/jouleclaw-rs/Cargo.toml ./jouleclaw/jouleclaw-rs/Cargo.toml
COPY jouleclaw/jouleclaw-rs/crates/jouleclaw-core ./jouleclaw/jouleclaw-rs/crates/jouleclaw-core
COPY jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf ./jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf
WORKDIR /src/wai/wai-quantum-service
# JOBS bounds the build's parallel jobs on a shared host (--build-arg JOBS=8).
ARG JOBS
RUN cargo build --locked --release ${JOBS:+--jobs "$JOBS"} && cp target/release/wai-quantum-service /wai-quantum-service

View file

@ -1,7 +1,13 @@
# The image's build context is the repository's wai/ directory; only the two
# crates the service is built from go into it, without their build output.
# The image's build context is the repository's root; only the crates the
# service is built from go into it (the engine, the service, the writer, and
# the writer's model runtime with its workspace's manifest), without their
# build output.
*
!wai-quantum/
!wai-quantum-service/
!wai/wai-quantum/
!wai/wai-quantum-service/
!wai/wai-quantum-writer/
!jouleclaw/jouleclaw-rs/Cargo.toml
!jouleclaw/jouleclaw-rs/crates/jouleclaw-core/
!jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/
**/target/
wai-quantum-service/deploy/
wai/wai-quantum-service/deploy/

View file

@ -12,7 +12,7 @@ ENGINE=${ENGINE:-docker}
JOBS=${JOBS:-8}
PORT=${PORT:-18743}
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
wai=$(cd "$here/../.." && pwd)
root=$(cd "$here/../../.." && pwd)
tag="wai-quantum-service-check:$$"
name="wai-quantum-service-check-$$"
volume="wai-quantum-service-check-$$"
@ -22,7 +22,7 @@ cleanup() {
"$ENGINE" image rm -f "$tag" >/dev/null 2>&1 || true
}
trap cleanup EXIT
"$ENGINE" build --build-arg JOBS="$JOBS" -f "$here/Containerfile" -t "$tag" "$wai"
"$ENGINE" build --build-arg JOBS="$JOBS" -f "$here/Containerfile" -t "$tag" "$root"
"$ENGINE" run -d --name "$name" -p "127.0.0.1:$PORT:8241" -v "$volume:/var/lib/wai-quantum-service" \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges "$tag" >/dev/null
for _ in $(seq 1 100); do curl -s -o /dev/null "http://127.0.0.1:$PORT/v1/health" && break; sleep 0.2; done

View file

@ -13,10 +13,10 @@ cargo build --locked --release
bash smoke.sh http://127.0.0.1:8241 # its promises, checked over HTTP
```
Or as an OCI image, built from the repository's `wai/` directory ([`deploy/Containerfile`](../deploy/Containerfile)):
Or as an OCI image, built from the repository's root ([`deploy/Containerfile`](../deploy/Containerfile)), whose ignore file keeps the context to the crates the service is built from:
```sh norun=server
docker build -f wai/wai-quantum-service/deploy/Containerfile -t wai-quantum-service wai/
docker build -f wai/wai-quantum-service/deploy/Containerfile -t wai-quantum-service .
docker run -d --name wai-quantum -p 127.0.0.1:8241:8241 \
-v wai-quantum:/var/lib/wai-quantum-service \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
@ -51,8 +51,11 @@ Everything is set by environment variables.
| `WAI_QSVC_CLIENT_GB` | the job size | the memory one client's jobs may hold together |
| `WAI_QSVC_CLIENT_BURST` | 30 | submissions one client may make in a window |
| `WAI_QSVC_CLIENT_WINDOW_SECS` | 600 | the window |
| `WAI_QSVC_MAX_QUEUE` | 256 | jobs waiting at once |
| `WAI_QSVC_SANDBOX` | 1 | 0 runs the macOS worker unsandboxed, for debugging |
| `WAI_QSVC_MAX_QUEUE` | 256 | circuit jobs waiting at once, and writes waiting at once, each counted alone |
| `WAI_QSVC_SANDBOX` | 1 | 0 runs the macOS worker and writer unsandboxed, for debugging |
| `WAI_QSVC_MODEL` | unset (no writes) | the model's directory, its files and `wai-model.json` (`wai-quantum-writer manifest DIR` writes it): set it and the service writes programs for tasks (`POST /v1/write`). The directory must hold exactly the model files the manifest lists, and the writer reads every byte against it each time it starts, before it loads the model |
| `WAI_QSVC_WRITER_GB` | 96 | the memory the writer process may hold, its model's mapped pages among it |
| `WAI_QSVC_WRITE_SECS` | 3600 | the longest one write may run |
Size the memory to the host: `WAI_QSVC_BUDGET_GB` below the host's memory, `WAI_QSVC_JOB_GB` at or below it. `GET /v1` states every limit in force, and a dry run (`"dry_run": true`) states the ones a job meets.

View file

@ -33,6 +33,456 @@
}
]
},
"job_or_write": {
"description": "A job, or a write's job (get_quantum_job reads either by its id).",
"anyOf": [
{
"$ref": "#/$defs/job"
},
{
"$ref": "#/$defs/write"
}
]
},
"write": {
"type": "object",
"description": "A write's job (POST /v1/write, write_circuit): queued; running, with the attempts checked so far; done, its result the transcript, sealed, with its timing beside it; or failed, cancelled or interrupted, saying why.",
"required": [
"id",
"status",
"method",
"why",
"estimate",
"request",
"submitted_ms",
"digests"
],
"properties": {
"id": {
"type": "string"
},
"status": {
"enum": [
"queued",
"running",
"done",
"failed",
"cancelled",
"interrupted"
]
},
"method": {
"const": "write"
},
"why": {
"type": "string"
},
"estimate": {
"type": "object",
"required": [
"memory_bytes",
"work"
],
"properties": {
"memory_bytes": {
"type": "integer",
"minimum": 0,
"description": "What it holds of the jobs' memory budget: none, since the writer's memory is its own, held to its own cap."
},
"work": {
"type": "integer",
"minimum": 0,
"description": "The most tokens its replies may take."
}
}
},
"request": {
"$ref": "#/$defs/write_request"
},
"submitted_ms": {
"type": "integer"
},
"started_ms": {
"type": "integer"
},
"finished_ms": {
"type": "integer"
},
"peak_rss_bytes": {
"type": "integer",
"description": "The most memory the writer process held while it wrote, its model's mapped pages among it."
},
"interruptions": {
"type": "array",
"items": {
"type": "integer"
},
"description": "Each time the service stopped while the write ran, in milliseconds since 1970; it is run again, once, from its stored request, and gives the same transcript."
},
"error": {
"type": "string"
},
"progress": {
"type": "object",
"description": "While it runs: how many attempts the engine has checked, and the latest.",
"required": [
"attempts",
"latest"
],
"properties": {
"attempts": {
"type": "integer",
"minimum": 1
},
"latest": {
"$ref": "#/$defs/write_attempt"
}
}
},
"timing": {
"type": "array",
"description": "How long each attempt's reply and its check took, in seconds: beside the result and outside what its receipt seals, since time is the one thing running it again does not reproduce.",
"items": {
"type": "object",
"required": [
"reply_seconds",
"check_seconds"
],
"properties": {
"reply_seconds": {
"type": "number",
"minimum": 0
},
"check_seconds": {
"type": "number",
"minimum": 0
}
}
}
},
"result": {
"$ref": "#/$defs/transcript"
},
"digests": {
"type": "object",
"description": "The BLAKE3 digests the store keeps the write's objects under: its request, its result and its receipt.",
"properties": {
"algorithm": {
"const": "blake3"
},
"request": {
"type": [
"string",
"null"
]
},
"programs": {
"type": "array",
"items": {
"type": "string"
}
},
"result": {
"type": "string"
},
"receipt": {
"type": "string"
}
}
}
}
},
"write_request": {
"type": "object",
"description": "What a write asks: its task, and how many repairs and tokens a reply may take; kept as its canonical text (RFC 8785), which its receipt binds.",
"required": [
"task"
],
"additionalProperties": false,
"properties": {
"task": {
"type": "object",
"description": "The task: an exercise in the course's form (wai.quantum.exercise/1), with an intent, or cases that each have one.",
"additionalProperties": true
},
"rounds": {
"type": "integer",
"minimum": 0,
"maximum": 4,
"description": "Repairs after the first attempt (default 4)."
},
"max_new": {
"type": "integer",
"minimum": 1,
"maximum": 4000,
"description": "The most tokens one reply may take, its thinking included (default 3000)."
}
}
},
"transcript": {
"type": "object",
"description": "A write's result: its transcript (wai.quantum.write/1), with the energy the writer spent on it and the service's receipt, which seals the rest with the write's request. The transcript names the task and its digest, the model by its name, licence and the digest of its files with how far they were checked and the arithmetic it ran in, the prompt by its version and digests, the decoding with its thinking cap, the memory each check's run was planned in, the engine, and each attempt with the engine's check; the same model, task, options and engine give the same transcript in the same arithmetic, so anyone who holds the model can write it again on a host whose architecture, operating system, maths library and matmul kernel are the model's here, and compare.",
"required": [
"schema",
"task",
"task_digest",
"model",
"prompt",
"decoding",
"engine",
"passed",
"passed_round",
"level",
"program",
"program_digest",
"attempts"
],
"properties": {
"schema": {
"const": "wai.quantum.write/1"
},
"task": {
"type": "object",
"description": "The task, as the model was given it: its canonical form (RFC 8785), read back.",
"additionalProperties": true
},
"task_digest": {
"type": "string",
"description": "BLAKE3 over the task's canonical text (RFC 8785)."
},
"model": {
"type": "object",
"required": [
"name",
"licence",
"digest"
],
"properties": {
"name": {
"type": "string"
},
"licence": {
"type": "string"
},
"digest": {
"type": "string",
"description": "BLAKE3 over the canonical text of the model's file manifest (wai.quantum.model/1: each file's name, size and digest); for a scripted model, over its script."
},
"runtime": {
"type": "string"
},
"checked": {
"enum": [
"sizes",
"blake3"
],
"description": "How far the model's files were checked against its manifest before they were loaded: their sizes, or every byte (BLAKE3). The model loads exactly the files the manifest lists either way. A scripted model has none."
},
"arch": {
"type": "string",
"description": "The architecture the model ran on (Rust's target_arch)."
},
"os": {
"type": "string",
"description": "The operating system it ran on, whose maths library its exponentials come from."
},
"libm": {
"type": "string",
"description": "The platform's maths library (glibc's version, or macOS's release and build), whose exponentials and sines shape the logits: part of the arithmetic a replay must match."
},
"matmul": {
"type": "string",
"description": "The BF16 matmul kernel its projections took there, which fixes the arithmetic of every logit; greedy decoding gives the same tokens again only in the same."
}
}
},
"prompt": {
"type": "object",
"required": [
"version",
"system_digest",
"first_digest"
],
"properties": {
"version": {
"type": "string"
},
"system_digest": {
"type": "string"
},
"first_digest": {
"type": "string"
}
}
},
"decoding": {
"type": "object",
"required": [
"method",
"max_new",
"rounds"
],
"properties": {
"method": {
"const": "greedy"
},
"max_new": {
"type": "integer",
"minimum": 1
},
"rounds": {
"type": "integer",
"minimum": 0
},
"think_max": {
"type": "integer",
"minimum": 0,
"description": "The most tokens of a reply that may be thinking: a reply still thinking there has its thinking closed for it and answers in the tokens left."
}
}
},
"engine": {
"type": "string",
"description": "The version of the engine (wai-quantum) that checked each attempt."
},
"check_memory_bytes": {
"type": "integer",
"minimum": 0,
"description": "The memory each check's run was planned in: it decides which programs a check runs, and by which method, so two hosts with other limits can check alike only with the same."
},
"passed": {
"type": "boolean"
},
"passed_round": {
"type": [
"integer",
"null"
],
"description": "The round whose program passed; null when none did."
},
"level": {
"description": "The level the engine checked the program to; null where there is none.",
"anyOf": [
{
"type": "null"
},
{
"enum": [
"refused",
"parsed",
"executed",
"evidence",
"proof"
]
}
]
},
"program": {
"type": [
"string",
"null"
],
"description": "The last attempt's program."
},
"program_digest": {
"type": [
"string",
"null"
]
},
"attempts": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/write_attempt"
}
},
"energy": {
"$ref": "https://git.transaction.science/Transaction-Science/open-standards/raw/branch/main/wai/wai-quantum/schemas/job-result.v1.json#/$defs/energy"
},
"receipt": {
"$ref": "https://git.transaction.science/Transaction-Science/open-standards/raw/branch/main/wai/wai-quantum/schemas/receipt.v2.json"
}
}
},
"write_attempt": {
"type": "object",
"description": "One attempt of a write: the program the model's reply gave, its digest, and the engine's check of it against the task (wai.quantum.course-check/1, its lesson null), whose what_went_wrong went back to the model.",
"required": [
"round",
"answered",
"program",
"program_digest",
"passed",
"level",
"check",
"reply_tokens",
"thinking_tokens"
],
"properties": {
"round": {
"type": "integer",
"minimum": 0
},
"answered": {
"type": "boolean",
"description": "Whether the reply gave a program: false for one that ended inside its thinking, of which the engine had nothing to check."
},
"program": {
"type": [
"string",
"null"
]
},
"program_digest": {
"type": [
"string",
"null"
],
"description": "BLAKE3 over the program's text."
},
"passed": {
"type": "boolean"
},
"level": {
"description": "The level the engine checked the program to; null where there is none.",
"anyOf": [
{
"type": "null"
},
{
"enum": [
"refused",
"parsed",
"executed",
"evidence",
"proof"
]
}
]
},
"check": {
"type": [
"object",
"null"
],
"description": "The engine's check of the program against the task (wai.quantum.course-check/1, its lesson null), whose what_went_wrong went back to the model; null when the reply gave no program."
},
"reply_tokens": {
"type": "integer",
"minimum": 0
},
"thinking_tokens": {
"type": "integer",
"minimum": 0,
"description": "The reply's tokens that were thinking, counted on the tokens it gave: up to and including the one that ended its thinking, all of a reply whose thinking did not end."
},
"thinking_closed": {
"type": "boolean",
"description": "Whether the reply reached the thinking cap still thinking, and had its thinking closed for it."
}
}
},
"verify": {
"description": "Whether a result's receipt seals exactly that request and that result, and the key it names; for an estimate, also every estimate's receipt and the request re-run here; for a set, also every item's receipt, the root over them and each item's path.",
"anyOf": [
@ -178,6 +628,11 @@
"description": "How the service meters a job's energy here, or why it does not.",
"additionalProperties": true
},
"write": {
"type": "object",
"description": "Whether the service writes programs for tasks (write_circuit, POST /v1/write): with a model, the model as a transcript names it once it is loaded, where the writer stands and a write's limits; without one, why.",
"additionalProperties": true
},
"receipt_keys": {
"type": "string",
"description": "Where the service publishes every key it has signed receipts with (the receipt key set, wai.quantum.receipt-keys/1)."

View file

@ -567,4 +567,57 @@ py 'import json; print(json.dumps({"jsonrpc":"2.0","id":7,"method":"tools/call",
pace 2
mcp "@$tmp/mcp-dry.json" | py 'import json,sys; r=json.load(sys.stdin)["result"]; sc=r["structuredContent"]; assert not r["isError"] and sc["accepted"] is False and sc["refusal"]["code"]=="J011" and sc["verdict"]["level"]=="parsed" and sc["verdict"]["refusal"]["code"]=="J011", r; print("mcp dry run refused: ok,", r["content"][0]["text"][:60]+"…")'
# Writing a program for a task. GET /v1 says under `write` whether this service
# has a model. Without one, a write is refused (503, J070), before it counts as
# a submission. With one, SMOKE_WRITE=1 has it write a Bell pair's program,
# which takes minutes with the real model: the write is followed to its end,
# its transcript passes, its receipt verifies and a changed transcript does
# not, and its events give each attempt before the job. Without SMOKE_WRITE=1
# the model is only named.
export SMOKE_WRITE="${SMOKE_WRITE:-0}"
curl -sS --max-time 30 "$U/v1" > "$tmp/write-describe.json"
avail=$(py 'import json,sys; print("1" if json.load(open(sys.argv[1]))["write"]["available"] else "0")' "$tmp/write-describe.json")
py 'import json; print(json.dumps({"task": {"schema": "wai.quantum.exercise/1", "title": "A Bell pair", "task": "Entangle two qubits so that their measured bits always agree.", "starter": ["OPENQASM 3.0;", "include \"stdgates.inc\";", "qubit[2] q;", "bit[2] c;", "c = measure q;"], "intent": {"distribution": {"00": 0.5, "11": 0.5}}}}))' > "$tmp/write.json"
if [ "$avail" = 0 ]; then
[ "$SMOKE_WRITE" != 1 ] || { echo "FAIL: SMOKE_WRITE=1, but this service has no model to write with" >&2; exit 1; }
code=$(curl -sS --max-time 30 -o "$tmp/write.out" -w '%{http_code}' -X POST "$U/v1/write" -H 'content-type: application/json' --data-binary "@$tmp/write.json")
py 'import json,sys; d=json.load(open(sys.argv[1])); assert sys.argv[2]=="503" and d["code"]=="J070", (sys.argv[2], d); print("write: this service has no model, and a write is refused (503, J070)")' "$tmp/write.out" "$code"
elif [ "$SMOKE_WRITE" != 1 ]; then
py 'import json,sys; w=json.load(open(sys.argv[1]))["write"]; m=w.get("model") or {}; print("write: this service writes with", m.get("name", "a model still loading"), m.get("licence", ""), (m.get("digest") or "")[:16]+"…;", "SMOKE_WRITE=1 runs one")' "$tmp/write-describe.json"
else
pace
code=$(curl -sS --max-time 60 -o "$tmp/write.out" -w '%{http_code}' -X POST "$U/v1/write" -H 'content-type: application/json' --data-binary "@$tmp/write.json")
[ "$code" = 202 ] || { echo "FAIL: POST /v1/write answered $code: $(cat "$tmp/write.out")" >&2; exit 1; }
wid=$(py 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/write.out")
# The events, followed in the background from now to the write's end.
curl -sS -N --max-time 7200 "$U/v1/jobs/$wid/events" > "$tmp/write-events.txt" &
epid=$!
secs=$(py 'import json,sys; print(json.load(open(sys.argv[1]))["write"]["limits"]["write_seconds"])' "$tmp/write-describe.json")
t0=$(date +%s)
while :; do
curl -sS --max-time 60 "$U/v1/jobs/$wid?wait=25" > "$tmp/write.job"
case "$(py 'import json,sys; print(json.load(open(sys.argv[1]))["status"])' "$tmp/write.job")" in
queued|running) [ $(( $(date +%s) - t0 )) -lt $(( secs * 2 + 600 )) ] || { echo "FAIL: the write $wid did not end" >&2; exit 1; } ;;
*) break ;;
esac
done
wait "$epid" || true
py 'import json,sys
j=json.load(open(sys.argv[1])); w=json.load(open(sys.argv[2]))["write"]
assert j["status"]=="done", j
t=j["result"]
assert t["schema"]=="wai.quantum.write/1" and t["passed"] is True and t["program"] and t["model"]["digest"]==w["model"]["digest"], t
assert len(j["timing"])==len(t["attempts"]) and "timing" not in t and "energy" in t and "receipt" in t, j
json.dump({"request": j["request"], "result": t}, open(sys.argv[3],"w"))
json.dump({"request": j["request"], "result": dict(t, passed_round=99)}, open(sys.argv[4],"w"))
print("write", j["id"]+": ok,", t["model"]["name"], "passed after", t["passed_round"], "repair(s) at the level", t["level"]+",", len(t["attempts"]), "attempt(s) in", round(sum(x["reply_seconds"]+x["check_seconds"] for x in j["timing"])), "s")' "$tmp/write.job" "$tmp/write-describe.json" "$tmp/vwrite.json" "$tmp/twrite.json"
post /v1/verify "$tmp/vwrite.json" | py 'import json,sys; d=json.load(sys.stdin); assert d["valid"] is True, d; print("write receipt: verifies")'
post /v1/verify "$tmp/twrite.json" | py 'import json,sys; d=json.load(sys.stdin); assert d["valid"] is False, d; print("write receipt: a changed transcript does not verify")'
py 'import json,sys
t=open(sys.argv[1]).read(); j=json.load(open(sys.argv[2]))
kinds=[l[len("event: "):] for l in t.splitlines() if l.startswith("event: ")]
assert kinds.count("attempt")==len(j["result"]["attempts"]) and kinds[-1]=="job", kinds
print("write events: ok,", kinds.count("attempt"), "attempt event(s), then the job")' "$tmp/write-events.txt" "$tmp/write.job"
fi
echo "smoke: every check held at $U"

View file

@ -207,8 +207,10 @@ pub fn openapi(server: &str) -> Value {
"interruptions": { "type": "array", "items": { "type": "integer" }, "description": "Each time the service stopped while the job ran, in milliseconds since 1970. A job that was running when the service stopped is queued again, once, in its order, to run from its stored request." },
"method": { "type": "string" },
"why": { "type": "string", "description": "Why this method: what it is, and what the circuit is." },
"request": { "$ref": "#/components/schemas/JobRequest", "description": "The request as submitted: the receipt is checked against it." },
"result": { "type": "object", "description": "counts (highest classical bit first), expectations, amplitudes, accuracy, elapsed_ms, energy and a receipt. energy is the CPU energy of the job's process: joules_micro with its class (energy_provenance), declared uncertainty (energy_uncertainty: relative_ppm, window_us) and counter filtering state, its coverage and what it excludes; or unmetered, saying why. It is inside the signed body." },
"request": { "anyOf": [{ "$ref": "#/components/schemas/JobRequest" }, { "$ref": "#/components/schemas/WriteRequest" }], "description": "The request as submitted (a write's in canonical form): the receipt is checked against it." },
"timing": { "type": "array", "description": "A write's: how long each attempt's reply and check took (reply_seconds, check_seconds), beside its result and outside what the receipt seals." },
"progress": { "type": "object", "description": "A write while it runs: how many attempts have been checked, and the latest." },
"result": { "type": "object", "description": "A write's result is its transcript (wai.quantum.write/1) with energy and a receipt. A job's: counts (highest classical bit first), expectations, amplitudes, accuracy, elapsed_ms, energy and a receipt. energy is the CPU energy of the job's process: joules_micro with its class (energy_provenance), declared uncertainty (energy_uncertainty: relative_ppm, window_us) and counter filtering state, its coverage and what it excludes; or unmetered, saying why. It is inside the signed body." },
"error": { "type": "string" },
"digests": { "type": "object", "description": "The BLAKE3 digests the store keeps the job's objects under: request (its RFC 8785 text), programs (each program's text), result (the result less its receipt, RFC 8785) and receipt." },
"submitted_ms": { "type": "integer" }, "started_ms": { "type": "integer" }, "finished_ms": { "type": "integer" }
@ -341,12 +343,44 @@ pub fn openapi(server: &str) -> Value {
"/mcp": { "post": { "summary": "The Model Context Protocol endpoint (streamable HTTP, JSON responses): tools to run circuits and sets, plan a job without running it, read jobs, verify receipts, list examples, and critique, compare, improve, price, compile, estimate and repair programs", "responses": { "200": { "description": "A JSON-RPC response" } } } }
}
});
for (path, item) in qec_paths().into_iter().chain(pulse_paths()).chain(variational_paths()) {
for (path, item) in qec_paths().into_iter().chain(pulse_paths()).chain(variational_paths()).chain(write_paths()) {
doc["paths"][path] = item;
}
doc["components"]["schemas"]["WriteRequest"] = write_request();
doc
}
/// What `POST /v1/write` takes ([`crate::writer`]).
fn write_request() -> Value {
json!({
"type": "object",
"required": ["task"],
"additionalProperties": false,
"properties": {
"task": { "type": "object", "description": "The task, an exercise in the course's form (wai.quantum.exercise/1): title and task (strings, or one per locale), an optional starter program, what the program must do as an intent (one or a list: distribution, equivalent, prepares or sampled) or cases that each have one (a case without one takes the task's), constraints (allow, counts, t_count, two_qubit, no_smells, estimate) and the job the check runs it as (shots, seed, noise). Refused without an intent or cases that each have one (J071)." },
"rounds": { "type": "integer", "minimum": 0, "maximum": crate::writer::MOST_ROUNDS, "default": crate::writer::ROUNDS, "description": "Repairs after the first attempt, each with the engine's account of the last." },
"max_new": { "type": "integer", "minimum": 1, "maximum": crate::writer::MOST_NEW, "default": crate::writer::MAX_NEW, "description": "The most tokens one reply may take, its thinking included." }
}
})
}
/// The write's path, apart, so the document's macro stays within its depth.
fn write_paths() -> Vec<(&'static str, Value)> {
vec![("/v1/write", json!({ "post": {
"summary": "A program written for a task by the service's own model: the model writes, the engine checks each attempt against the task as it checks a lesson's answer, and what went wrong goes back for another attempt. Answered at once with the job; its result is the transcript (wai.quantum.write/1), naming the model by the digest of its files and each attempt by its program's digest and the engine's check, sealed with the service's key (POST /v1/verify checks it with the job's request); its timing is beside it, outside the signed body; each attempt is also a server-sent `attempt` event on GET /v1/jobs/{id}/events as it lands. Decoding is greedy: the same model, task and options give the same transcript in the same arithmetic, which the transcript's model names (architecture, operating system, maths library, matmul kernel). The task is held to the course's form and sized before it is queued (J071). Writes run one at a time; a client holds one queued or running write.",
"parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WriteRequest" } } } },
"responses": {
"202": { "description": "Accepted and kept in the store before this answer: the job, queued; sent again with its Idempotency-Key, the key's job", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
"400": { "description": "A task that does not read, is not in the course's form, is past its sizes, or whose programs read past the request's budget (J071); options out of range or a member the write does not take (J072); or an Idempotency-Key that does not read (J034)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "A private deployment's key missing or not one it holds (J033)" },
"422": { "description": "A program an intent compares with past the equivalence bounds a check here takes (J031), or an Idempotency-Key sent before with another request (J034)" },
"429": { "description": "A write of this client's already queued or running, or past the per-client limits (J030, with Retry-After)" },
"503": { "description": "No model on this service (J070; GET /v1 says so under `write`), or the queue is full (J032)" }
}
} }))]
}
/// The pulse calls' paths.
fn pulse_paths() -> Vec<(&'static str, Value)> {
let responses = |ok: &str| json!({ "200": { "description": ok }, "400": { "description": "A body that does not read" }, "422": { "description": "A program the pulse reader refuses (its Q code), a request the engine refuses (J040, J041), or a check larger than the service takes (J031, naming the local command)" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running" } });
@ -497,9 +531,11 @@ mod tests {
let d = openapi("https://example.org/q");
for p in ["/v1", "/v1/methods", "/v1/examples", "/v1/targets", "/v1/run", "/v1/jobs", "/v1/jobs/{id}", "/v1/jobs/{id}/events", "/.well-known/wai-quantum-receipt-keys", "/v1/verify", "/v1/compile", "/v1/estimate", "/v1/convert", "/v1/qec/code", "/v1/qec/decode", "/v1/qec/threshold", "/v1/gradient", "/v1/optimize", "/v1/vqe", "/v1/qaoa", "/v1/build", "/v1/pulse/schedule", "/v1/pulse/check", "/mcp"] {
for p in ["/v1", "/v1/methods", "/v1/examples", "/v1/targets", "/v1/run", "/v1/jobs", "/v1/jobs/{id}", "/v1/jobs/{id}/events", "/.well-known/wai-quantum-receipt-keys", "/v1/verify", "/v1/compile", "/v1/estimate", "/v1/convert", "/v1/qec/code", "/v1/qec/decode", "/v1/qec/threshold", "/v1/gradient", "/v1/optimize", "/v1/vqe", "/v1/qaoa", "/v1/build", "/v1/pulse/schedule", "/v1/pulse/check", "/v1/write", "/mcp"] {
assert!(d["paths"].get(p).is_some(), "{p}");
}
assert_eq!(d["openapi"], "3.1.0");
assert_eq!(d["paths"]["/v1/write"]["post"]["requestBody"]["content"]["application/json"]["schema"]["$ref"], "#/components/schemas/WriteRequest");
assert_eq!(d["components"]["schemas"]["WriteRequest"]["required"], json!(["task"]));
}
}

View file

@ -8,7 +8,8 @@
//! with "dry_run": true, the plan, running nothing
//! GET /v1/jobs/{id} the job: status, and its result when done;
//! ?wait=S holds the answer until its status changes
//! GET /v1/jobs/{id}/events its status as server-sent events, to the end
//! GET /v1/jobs/{id}/events its status as server-sent events, to the end;
//! a write's attempts too, each as it lands
//! DELETE /v1/jobs/{id} cancel a queued or running job
//! POST /v1/run submit and wait (up to 25 s): the finished job,
//! or 202 and the job if it is still running
@ -51,6 +52,10 @@
//! POST /v1/qaoa Max-Cut on a graph by QAOA, beside the maximum cut
//! POST /v1/build a standard algorithm's program, checked against
//! its reference; each answer sealed
//! POST /v1/write a program written for a task by the service's own
//! model, each attempt checked by the engine: 202 and
//! the job, its result the signed transcript
//! ([`crate::writer`])
//! GET /v1/examples ready-to-run example jobs
//! GET /v1/targets the built-in device targets (a job may also give its own)
//! GET /v1/openapi.json this API as an OpenAPI 3.1 document
@ -66,7 +71,7 @@
//! group's fields), and a refusal past it `Retry-After`. Where the service
//! takes API keys (`auth`), every request but the open ones carries one.
//!
//! `POST /v1/jobs` and `POST /v1/run` take an `Idempotency-Key` (the same
//! `POST /v1/jobs`, `POST /v1/run` and `POST /v1/write` take an `Idempotency-Key` (the same
//! working group's field): a job sent again with its key is answered with
//! the key's job, before any limit, and counts as no submission, so a client
//! that lost an answer can send the job again safely. The same key with
@ -210,7 +215,7 @@ async fn describe(State(app): State<Shared>) -> Response {
"worker_sandbox": c.sandbox && cfg!(target_os = "macos"),
"submissions_per_window": c.per_client_burst,
"window_seconds": c.per_client_window.as_secs(),
"queue": c.max_queue,
"queue": { "per_kind": c.max_queue, "kinds": ["circuit jobs", "writes"], "about": "each kind of job fills the queue up to per_kind alone" },
"result_retention_seconds": c.retain.as_secs(),
"set_items": sets::MAX_ITEMS,
},
@ -223,12 +228,13 @@ async fn describe(State(app): State<Shared>) -> Response {
"restored": { "finished": app.service.restored.finished, "interrupted": app.service.restored.interrupted, "requeued": app.service.restored.requeued, "rerun": app.service.restored.rerun, "unreadable": app.service.restored.unreadable },
},
"energy": crate::energy::meter().describe(),
"write": app.service.write_info(),
"receipt_key": app.signer.public_key(),
"receipt_keys": format!("{}/.well-known/wai-quantum-receipt-keys", app.base),
"api_keys": app.keys.is_some(),
// A client that lost an answer sends the job again under its key only where this says the key is kept.
"idempotency": { "header": "Idempotency-Key", "argument": "idempotency_key", "key_bytes": crate::jobs::KEY_MAX, "on": ["POST /v1/jobs", "POST /v1/run", "run_quantum_circuit", "run_quantum_set", "run_on_target"], "kept": "while its job is kept, whatever its status, but for a job the service interrupted, which the key runs again" },
"endpoints": ["GET /v1", "GET /v1/health", "GET /v1/methods", "GET /v1/examples", "GET /v1/targets", "GET /v1/openapi.json", "GET /.well-known/wai-quantum-receipt-keys", "POST /v1/jobs", "GET /v1/jobs/{id}", "GET /v1/jobs/{id}/events", "DELETE /v1/jobs/{id}", "POST /v1/run", "POST /v1/verify", "POST /v1/critique", "POST /v1/equivalent", "POST /v1/improve", "POST /v1/shortcuts", "POST /v1/compile", "POST /v1/estimate", "POST /v1/repair", "POST /v1/convert", "POST /v1/qec/code", "POST /v1/qec/decode", "POST /v1/qec/threshold", "POST /v1/gradient", "POST /v1/optimize", "POST /v1/vqe", "POST /v1/qaoa", "POST /v1/build", "POST /mcp"],
"idempotency": { "header": "Idempotency-Key", "argument": "idempotency_key", "key_bytes": crate::jobs::KEY_MAX, "on": ["POST /v1/jobs", "POST /v1/run", "POST /v1/write", "run_quantum_circuit", "run_quantum_set", "run_on_target", "write_circuit"], "kept": "while its job is kept, whatever its status, but for a job the service interrupted, which the key runs again" },
"endpoints": ["GET /v1", "GET /v1/health", "GET /v1/methods", "GET /v1/examples", "GET /v1/targets", "GET /v1/openapi.json", "GET /.well-known/wai-quantum-receipt-keys", "POST /v1/jobs", "GET /v1/jobs/{id}", "GET /v1/jobs/{id}/events", "DELETE /v1/jobs/{id}", "POST /v1/run", "POST /v1/verify", "POST /v1/critique", "POST /v1/equivalent", "POST /v1/improve", "POST /v1/shortcuts", "POST /v1/compile", "POST /v1/estimate", "POST /v1/repair", "POST /v1/convert", "POST /v1/qec/code", "POST /v1/qec/decode", "POST /v1/qec/threshold", "POST /v1/gradient", "POST /v1/optimize", "POST /v1/vqe", "POST /v1/qaoa", "POST /v1/build", "POST /v1/write", "POST /mcp"],
"mcp": format!("{}/mcp", app.base),
"console": app.console,
}),
@ -302,6 +308,51 @@ pub(crate) fn admit(app: &App, who: &str, request: Value, key: Option<Key>) -> R
app.service.submit_work(who, request, plan, work, key).map_err(refused)
}
/// Admits a write for `who` ([`crate::writer`]): its job's id, or the
/// refusal's status and reason. Cheapest first:
/// - a service with no model refuses it (503, `J070`);
/// - its shape, options and sizes are read, with no program in it read
/// ([`crate::writer::read_shape`]: `J071`, `J072`);
/// - a request sent again with its key is the key's job, and counts as nothing;
/// - a client holding a write is refused (429, `J030`);
/// - the submission is counted against the client's window, whatever comes of
/// it from here;
/// - only then are its programs read, each once, within one budget
/// ([`crate::writer::read_programs`]).
///
/// The request is kept in canonical form (RFC 8785), the form the store keeps,
/// so a write run again after a restart hands the model the same text. The
/// reading runs as blocking work ([`blocking`]), off the runtime's workers.
pub(crate) fn admit_write(app: &App, who: &str, request: Value, key: Option<Key>) -> Result<String, (u16, String)> {
if !app.service.writes() {
return Err((503, tag(refusal::WRITE_NO_MODEL, "this service has no model to write with (GET /v1 says so under `write`); write the program where the model is, with `wai-quantum-writer write --model DIR TASK.json`")));
}
let asked = crate::writer::read_shape(&request)?;
let request: Value = serde_json::from_slice(&wai_quantum::engine::receipt::canonical(&request)).map_err(|e| (400, tag(refusal::WRITE_TASK, format!("the write does not read back from its canonical form: {e}"))))?;
let refused = |Refused(code, why)| (code, why);
if let Some(k) = key
&& let Some(id) = app.service.named(who, k, &request).map_err(refused)?
{
return Ok(id);
}
if let Some(r) = app.service.holds_write(who) {
return Err(refused(r));
}
app.service.count(who).map_err(refused)?;
blocking(|| crate::writer::read_programs(&request))?;
blocking(|| app.service.submit_counted(who, request, crate::writer::plan(asked), Work::Write, key)).map_err(refused)
}
/// `f`, as work that blocks: on the service's runtime, its worker hands its
/// other tasks on while `f` runs, so reading a request's programs, or the
/// store's writes, hold up no other request.
pub(crate) fn blocking<T>(f: impl FnOnce() -> T) -> T {
match tokio::runtime::Handle::try_current() {
Ok(h) if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => tokio::task::block_in_place(f),
_ => f(),
}
}
/// The `Idempotency-Key` a request gives, if it gives one: a structured
/// field's string (`"…"`), or the same characters bare ([`check_key`]).
fn idempotency_key(headers: &HeaderMap) -> Result<Option<String>, (u16, String)> {
@ -354,7 +405,8 @@ pub(crate) fn dry_run(app: &App, who: &str, request: &Value) -> Result<Value, (u
};
let estimate = |p: &catalog::Plan| app.service.estimate(p);
let (time, energy, items) = match &work {
Work::One => {
// A dry run plans a job or a set; a write is never planned here.
Work::One | Work::Write => {
let (t, e) = estimate(&plan);
(t, e, None)
}
@ -465,6 +517,24 @@ async fn run(State(app): State<Shared>, ConnectInfo(peer): ConnectInfo<SocketAdd
}
}
/// `POST /v1/write`: a program written for a task, answered at once with the
/// job (202), which runs for minutes; its attempts follow on its events.
async fn write(State(app): State<Shared>, ConnectInfo(peer): ConnectInfo<SocketAddr>, headers: HeaderMap, body: Result<Json<Value>, axum::extract::rejection::JsonRejection>) -> Response {
let Json(request) = match body {
Ok(b) => b,
Err(e) => return error(400, &tag(refusal::WRITE_TASK, format!("the body is not a JSON write: {e}"))),
};
let who = client(&app, peer, &headers);
let key = match idempotency_key(&headers) {
Ok(k) => k,
Err((code, why)) => return error(code, &why),
};
match admit_write(&app, &who, request, key.as_deref().map(Key)) {
Ok(id) => reply(202, app.service.get(&id).unwrap_or(json!({ "id": id }))),
Err((code, why)) => error(code, &why),
}
}
/// The longest `GET /v1/jobs/{id}?wait=S` holds its answer.
pub const POLL_WAIT: Duration = Duration::from_secs(25);
@ -496,8 +566,10 @@ async fn job(State(app): State<Shared>, Path(id): Path<String>, Query(poll): Que
}
/// A job's status as server-sent events: `status` events (`{"id", "status"}`)
/// as it changes, then one `job` event with the whole report when it ends,
/// and the stream closes. A comment every 15 s keeps an idle stream open.
/// as it changes; for a write, an `attempt` event (`{"id", "attempt",
/// "timing"}`) for each attempt as it lands, those before the stream began
/// first; then one `job` event with the whole report when it ends, and the
/// stream closes. A comment every 15 s keeps an idle stream open.
async fn events(State(app): State<Shared>, Path(id): Path<String>) -> Response {
if app.service.status(&id).is_none() {
return error(404, &format!("no such job (finished jobs are kept for {} s)", app.service.config.retain.as_secs()));
@ -507,9 +579,11 @@ async fn events(State(app): State<Shared>, Path(id): Path<String>) -> Response {
id: String,
rx: tokio::sync::broadcast::Receiver<String>,
last: Option<String>,
/// The write's attempts sent so far.
sent: usize,
ended: bool,
}
let start = Follow { rx: app.service.subscribe(), app, id, last: None, ended: false };
let start = Follow { rx: app.service.subscribe(), app, id, last: None, sent: 0, ended: false };
let stream = futures_util::stream::unfold(start, |mut f| async move {
if f.ended {
return None;
@ -517,16 +591,22 @@ async fn events(State(app): State<Shared>, Path(id): Path<String>) -> Response {
loop {
let job = f.app.service.get(&f.id)?;
let status = job["status"].as_str().unwrap_or("").to_string();
if f.last.as_deref() != Some(status.as_str()) {
let active = matches!(status.as_str(), "queued" | "running");
if active && f.last.as_deref() != Some(status.as_str()) {
f.last = Some(status.clone());
let ev = if matches!(status.as_str(), "queued" | "running") {
Event::default().event("status").data(json!({ "id": f.id, "status": status }).to_string())
} else {
f.ended = true;
Event::default().event("job").data(job.to_string())
};
let ev = Event::default().event("status").data(json!({ "id": f.id, "status": status }).to_string());
return Some((Ok::<Event, std::convert::Infallible>(ev), f));
}
// A write's attempts, each once, before its end.
if let Some(a) = f.app.service.attempts(&f.id).get(f.sent).cloned() {
f.sent += 1;
let ev = Event::default().event("attempt").data(json!({ "id": f.id, "attempt": a["attempt"], "timing": a["timing"] }).to_string());
return Some((Ok(ev), f));
}
if !active {
f.ended = true;
return Some((Ok(Event::default().event("job").data(job.to_string())), f));
}
loop {
match f.rx.recv().await {
Ok(changed) if changed == f.id => break,
@ -883,6 +963,7 @@ pub fn router(app: Shared) -> Router {
.route("/v1/vqe", post(|State(a): State<Shared>, ConnectInfo(p): ConnectInfo<SocketAddr>, h: HeaderMap, b| variational_route(a, p, h, b, "vqe")))
.route("/v1/qaoa", post(|State(a): State<Shared>, ConnectInfo(p): ConnectInfo<SocketAddr>, h: HeaderMap, b| variational_route(a, p, h, b, "qaoa")))
.route("/v1/build", post(|State(a): State<Shared>, ConnectInfo(p): ConnectInfo<SocketAddr>, h: HeaderMap, b| variational_route(a, p, h, b, "build")))
.route("/v1/write", post(write))
.route("/v1/repair", post(|State(a): State<Shared>, ConnectInfo(p): ConnectInfo<SocketAddr>, h: HeaderMap, b| analysis(a, p, h, b, crate::analysis::Check::Repair)))
.route("/v1/convert", post(|State(a): State<Shared>, ConnectInfo(p): ConnectInfo<SocketAddr>, h: HeaderMap, b| analysis(a, p, h, b, crate::analysis::Check::Convert)))
.route("/v1/examples", get(examples))

File diff suppressed because it is too large Load diff

View file

@ -6,11 +6,17 @@
//! accurate it is (bit-exact, exact, bounded or estimated), and is sealed with
//! the service's key ([`receipt`]). Jobs run one per child process, inside a
//! memory budget, at a lower priority than the host's other work ([`jobs`]).
//! Where it has a model (`WAI_QSVC_MODEL`), it also writes a program for a
//! task, each attempt checked by the engine, in one writer process kept for
//! every write ([`writer`]).
//!
//! ```text
//! wai-quantum-service serve the HTTP API (WAI_QSVC_ADDR, 127.0.0.1:8241)
//! wai-quantum-service worker one job: {"request", "plan"} (or a set's
//! {"set": [...]}) on stdin, the result on stdout
//! wai-quantum-service writer [--model DIR | --script FILE]
//! the writer: a write's request a line on stdin,
//! its attempts and transcript a line each on stdout
//! wai-quantum-service run FILE one job or set from a JSON file, here, printed
//! wai-quantum-service rotate-key retire the receipt key and make a new one
//! wai-quantum-service api-key NAME a new API key for a private deployment
@ -33,6 +39,7 @@ mod mcp;
use wai_quantum::engine::{catalog, run, sets};
mod receipt;
mod store;
mod writer;
use std::io::Read;
use std::net::{IpAddr, SocketAddr};
@ -42,6 +49,7 @@ fn main() {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("worker") => worker(),
Some("writer") => writer::main(&args[2..]),
Some("run") => match args.get(2) {
Some(path) => run_file(path),
None => usage(),
@ -63,14 +71,14 @@ fn main() {
}
fn usage() {
eprintln!("usage: wai-quantum-service [serve | worker | run FILE | rotate-key | api-key NAME]");
eprintln!("usage: wai-quantum-service [serve | worker | writer [--model DIR | --script FILE] | run FILE | rotate-key | api-key NAME]");
std::process::exit(2);
}
/// A worker stops itself once the service that started it has gone, so a
/// service stopped mid-job leaves no job running behind it: it is then
/// another process's child, and nobody reads what it would write.
fn watch_parent() {
/// A worker, or the writer, stops itself once the service that started it
/// has gone, so a service stopped mid-job leaves no job running behind it: it
/// is then another process's child, and nobody reads what it would write.
pub(crate) fn watch_parent() {
#[cfg(unix)]
{
let parent = std::os::unix::process::parent_id();
@ -188,6 +196,15 @@ fn serve() {
let signer = Arc::new(signer);
service.seal_with(Arc::clone(&signer));
tokio::spawn(Arc::clone(&service).schedule());
// Writes, where there is a model: their own scheduler, and the writer
// process, started now so its model loads once.
match &service.config.writer {
Some(source) => {
eprintln!("writes: the writer runs {}; one write at a time, up to {} s each, the writer held to {} GiB", match source { writer::Source::Model(d) => format!("the model at {}", d.display()), writer::Source::Script(f) => format!("the script at {} (a test service)", f.display()) }, service.config.write_timeout.as_secs(), service.config.writer_mib >> 10);
tokio::spawn(Arc::clone(&service).schedule_writes());
}
None => eprintln!("writes: none (no WAI_QSVC_MODEL); POST /v1/write is refused (J070)"),
}
// Where the service is seen from outside, and its page for people.
let base = std::env::var("WAI_QSVC_PUBLIC_BASE").unwrap_or_else(|_| "https://wai.transaction.science/quantum-jobs".into());
let console = std::env::var("WAI_QSVC_CONSOLE").unwrap_or_else(|_| "https://wai.transaction.science/quantum-run/".into());

View file

@ -5,8 +5,9 @@
//! engine's checks (critique, equivalence, improvement, classical shortcuts),
//! compile a program with a certificate the service seals, estimate what a
//! fault-tolerant machine needs to run one (each estimate sealed), repair a
//! program on the lines its diagnostics flag, describe the service, and list
//! worked examples. Every answer about a program carries its verdict
//! program on the lines its diagnostics flag, have the service's own model
//! write a program for a task (`write_circuit`, [`crate::writer`]), describe
//! the service, and list worked examples. Every answer about a program carries its verdict
//! ([`wai_quantum::engine::verdict`]): a run's is the one its receipt seals.
//! It also serves the
//! engine's resources (the language it reads, its diagnostics, the methods,
@ -58,7 +59,9 @@ pub fn schemas() -> &'static Registry {
/// The schema each tool's answer follows, by its place.
fn output_of(tool: &str) -> Option<&'static str> {
Some(match tool {
"run_quantum_circuit" | "get_quantum_job" | "run_quantum_set" => "service-tools.v1.json#/$defs/job",
"run_quantum_circuit" | "run_quantum_set" => "service-tools.v1.json#/$defs/job",
"get_quantum_job" => "service-tools.v1.json#/$defs/job_or_write",
"write_circuit" => "service-tools.v1.json#/$defs/write",
"dry_run_quantum_job" => "service-tools.v1.json#/$defs/dry_run",
"verify_quantum_receipt" => "service-tools.v1.json#/$defs/verify",
"critique_qasm" => "service-tools.v1.json#/$defs/critique",
@ -115,7 +118,7 @@ pub fn versions() -> Value {
/// How long a `run_quantum_circuit` call waits before handing back the job id.
pub const CALL_WAIT: Duration = Duration::from_secs(50);
const INSTRUCTIONS: &str = "Run quantum programs written in OpenQASM 3 (or 2). Call run_quantum_circuit with the program and what you want back: shots for measurement counts, observables (Pauli strings such as \"Z0 Z1\") for expectation values, amplitudes (bit strings, qubit n-1 first) for amplitudes, and `intent` for what the program must do. The service picks the strongest method the circuit allows and says which and why; every result names its accuracy, states its verdict (how far the engine checked: proof, evidence, executed, parsed or refused) and carries a signature verify_quantum_receipt can check. Counts are keyed by the classical bits, the highest bit first. If a call answers with status \"running\", call get_quantum_job with its id. A client that speaks tasks (MCP 2025-11-25) may call the three runs as tasks: the task is the job, its id the job's, and tasks/get, tasks/result and tasks/cancel follow it. run_quantum_set runs several programs, or one over a grid of its inputs, as one set with one receipt; dry_run_quantum_job says what a job or a set would take (method, memory, time, energy, bounds) and whether the service would take it, running nothing. quantum_circuit_examples gives ready-to-run programs.";
const INSTRUCTIONS: &str = "Run quantum programs written in OpenQASM 3 (or 2). Call run_quantum_circuit with the program and what you want back: shots for measurement counts, observables (Pauli strings such as \"Z0 Z1\") for expectation values, amplitudes (bit strings, qubit n-1 first) for amplitudes, and `intent` for what the program must do. The service picks the strongest method the circuit allows and says which and why; every result names its accuracy, states its verdict (how far the engine checked: proof, evidence, executed, parsed or refused) and carries a signature verify_quantum_receipt can check. Counts are keyed by the classical bits, the highest bit first. If a call answers with status \"running\", call get_quantum_job with its id. A client that speaks tasks (MCP 2025-11-25) may call the three runs as tasks: the task is the job, its id the job's, and tasks/get, tasks/result and tasks/cancel follow it. run_quantum_set runs several programs, or one over a grid of its inputs, as one set with one receipt; dry_run_quantum_job says what a job or a set would take (method, memory, time, energy, bounds) and whether the service would take it, running nothing. quantum_circuit_examples gives ready-to-run programs. write_circuit has the service's own model write a program for a task (an exercise: what the program must do, as an intent or cases), the engine checking each attempt and telling the model what went wrong; it takes minutes, so call it as a task or follow its job with get_quantum_job, and its result is the transcript, signed.";
/// What `initialize` tells an agent: how to use the service, then the
/// resources, the prompts and the rules both servers give.
@ -409,10 +412,32 @@ fn tool_list() -> Value {
a.extend(wai_quantum::engine::qec::tools("the service's key").as_array().cloned().unwrap_or_default());
a.extend(wai_quantum::engine::pulse::tools("the service's key").as_array().cloned().unwrap_or_default());
a.extend(wai_quantum::engine::variational::tools("the service's key").as_array().cloned().unwrap_or_default());
a.push(write_tool(key));
}
list
}
/// `write_circuit`: a program written for a task by the service's model.
fn write_tool(key: Value) -> Value {
json!({
"name": "write_circuit",
"title": "Write a quantum program for a task",
"description": format!("Have the service's own model write an OpenQASM 3 program for a task, the engine checking each attempt against what the task asks exactly as it checks a lesson's answer, and what went wrong going back to the model for another attempt, up to `rounds` repairs. The task is an exercise in the course's form (wai.quantum.exercise/1): a title and task in words, an optional starter program, and what the program must do, as an `intent` (an exact distribution, a reference program it must equal or whose state it must prepare) or `cases` that each have one, with optional constraints (gates allowed, counts, T count, smells), at most 16 cases and 8 intents in one place, its canonical form at most 64 KiB. The answer is a job whose result is the transcript (wai.quantum.write/1): the model by name, licence and the digest of its files, every byte checked, and the arithmetic it ran in, the prompt, each attempt's program and the engine's check, and whether one passed and at what level, signed (verify_quantum_receipt checks it with the job's request). Decoding is greedy, so the same task gives the same transcript in the same arithmetic (architecture, operating system, maths library, matmul kernel). Writes take minutes and run one at a time; a client holds one queued or running write. Call it as a task, or it waits {} s and answers with status running and an id for get_quantum_job. A service without a model refuses it (J070); quantum_service_info says under `write` whether this one has one. Each call is a new write unless it gives idempotency_key.", CALL_WAIT.as_secs()),
"inputSchema": {
"type": "object",
"required": ["task"],
"properties": {
"task": { "type": "object", "description": "The exercise: {\"title\", \"task\", \"starter\", \"intent\" or \"cases\", \"constraints\", \"job\"}, as the course's exercises (wai.quantum.exercise/1); title and task may be plain strings. It must give an intent, or cases that each have one (J071)." },
"rounds": { "type": "integer", "minimum": 0, "maximum": crate::writer::MOST_ROUNDS, "default": crate::writer::ROUNDS, "description": "Repairs after the first attempt, each with the engine's account of the last (J072 outside 0 to 4)." },
"max_new": { "type": "integer", "minimum": 1, "maximum": crate::writer::MOST_NEW, "default": crate::writer::MAX_NEW, "description": "The most tokens one reply may take, its thinking included." },
"idempotency_key": key
}
},
"annotations": { "readOnlyHint": false, "idempotentHint": false, "openWorldHint": false },
"execution": { "taskSupport": "optional" }
})
}
/// A job as a model reads it: the engine's, which the command line's server
/// writes too.
pub use wai_quantum::engine::views::job_text as summary;
@ -460,8 +485,8 @@ fn tool_result(text: String, structured: Value, is_error: bool) -> Value {
json!({ "content": [{ "type": "text", "text": text }], "structuredContent": structured, "isError": is_error })
}
/// The tools that run as tasks (MCP 2025-11-25): the runs, each a job.
pub const TASK_TOOLS: [&str; 3] = ["run_quantum_circuit", "run_quantum_set", "run_on_target"];
/// The tools that run as tasks (MCP 2025-11-25): the runs and the write, each a job.
pub const TASK_TOOLS: [&str; 4] = ["run_quantum_circuit", "run_quantum_set", "run_on_target", "write_circuit"];
/// The first protocol version with tasks.
const TASKS_FROM: &str = "2025-11-25";
@ -497,7 +522,10 @@ fn task_of(job: &Value, ttl_ms: u64) -> Value {
let created = job["submitted_ms"].as_u64().unwrap_or(0);
let updated = [job["started_ms"].as_u64(), job["finished_ms"].as_u64(), job["interruptions"].as_array().and_then(|a| a.last()).and_then(Value::as_u64)].into_iter().flatten().fold(created, u64::max);
let (state, message) = match status {
"queued" | "running" => ("working", json!(status)),
"queued" | "running" => ("working", match job.pointer("/progress/attempts").and_then(Value::as_u64) {
Some(n) => json!(format!("{status}: {n} attempt(s) checked")),
None => json!(status),
}),
"done" => ("completed", json!("done")),
"cancelled" => ("cancelled", json!("cancelled")),
_ => ("failed", job.get("error").cloned().unwrap_or_else(|| json!(status))),
@ -522,6 +550,9 @@ fn no_task(id: &Value, app: &App) -> Value {
/// A run's job admitted as a task: its id, or the refusal as data.
fn admit_task(app: &App, who: &str, name: &str, args: Value) -> Result<String, String> {
let (args, key) = take_key(args)?;
if name == "write_circuit" {
return crate::http::admit_write(app, who, args, key.as_deref().map(crate::jobs::Key)).map_err(|(_, why)| why);
}
if let Some(r) = run_refusal(name, &args) {
return Err(r["structuredContent"]["error"].as_str().unwrap_or("refused").to_string());
}
@ -573,6 +604,7 @@ async fn tool_answer(app: &App, who: &str, name: &str, args: Value, base: &str,
"run_quantum_set" if sc.is_object() && sc.get("error").is_none() => set_verdict(sc),
"run_quantum_set" => sc["error"].as_str().map(|e| verdict::of_refusal(&args, e)),
"get_quantum_job" if sc["method"] == "set" => set_verdict(sc),
"get_quantum_job" if sc["method"] == crate::writer::METHOD => Some(crate::writer::verdict(sc)),
"dry_run_quantum_job" => dry_run_verdict(sc, &args),
_ => None,
};
@ -598,7 +630,7 @@ async fn tool_answer(app: &App, who: &str, name: &str, args: Value, base: &str,
answer["structuredContent"] = r;
None
}
_ if name == "get_quantum_job" && sc["method"] == "set" => None,
_ if name == "get_quantum_job" && (sc["method"] == "set" || sc["method"] == crate::writer::METHOD) => None,
_ => verdict::for_tool(name, &args, sc),
};
if let Some(v) = v {
@ -739,7 +771,7 @@ fn take_key(mut args: Value) -> Result<(Value, Option<String>), String> {
async fn call(app: &App, who: &str, name: &str, args: Value, base: &str) -> Value {
use wai_quantum::engine::targets::{import, job::target_of, library};
let (args, key) = match name {
"run_quantum_circuit" | "run_on_target" | "run_quantum_set" => match take_key(args) {
"run_quantum_circuit" | "run_on_target" | "run_quantum_set" | "write_circuit" => match take_key(args) {
Ok(a) => a,
Err(why) => return tool_result(format!("refused: {why}"), json!({ "error": why }), true),
},
@ -783,7 +815,18 @@ async fn call(app: &App, who: &str, name: &str, args: Value, base: &str) -> Valu
let failed = job["status"] == "failed";
tool_result(summary(&job), job, failed)
}
"write_circuit" => {
let id = match crate::http::admit_write(app, who, args, key.as_deref().map(crate::jobs::Key)) {
Ok(id) => id,
Err((_, why)) => return tool_result(format!("refused: {why}"), json!({ "error": why }), true),
};
app.service.wait(&id, CALL_WAIT).await;
let job = app.service.get(&id).unwrap_or(json!({ "id": id }));
let failed = matches!(job["status"].as_str(), Some("failed" | "interrupted"));
tool_result(crate::writer::summary(&job), job, failed)
}
"get_quantum_job" | "poll_quantum_job" => match args["id"].as_str().and_then(|i| app.service.get(i)) {
Some(job) if job["method"] == crate::writer::METHOD => tool_result(crate::writer::summary(&job), job, false),
Some(job) => tool_result(summary(&job), job, false),
None => tool_result("no such job (results are kept for a day)".into(), json!({ "error": "no such job" }), true),
},
@ -849,6 +892,7 @@ async fn call(app: &App, who: &str, name: &str, args: Value, base: &str) -> Valu
"sets": { "most_items": wai_quantum::engine::sets::MAX_ITEMS },
"store": { "kind": app.service.store().kind(), "durable": app.service.store().durable(), "retention_seconds": c.retain.as_secs() },
"energy": crate::energy::meter().describe(),
"write": app.service.write_info(),
"receipt_keys": format!("{base}/.well-known/wai-quantum-receipt-keys"),
"api": discovery::index(base, &app.console),
"versions": versions(),
@ -1024,7 +1068,7 @@ mod tests {
#[test]
fn the_tools_are_these() {
let names: Vec<String> = tools().as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect();
assert_eq!(names, ["run_quantum_circuit", "run_quantum_set", "dry_run_quantum_job", "get_quantum_job", "verify_quantum_receipt", "critique_qasm", "check_equivalent", "convert_program", "improve_qasm", "classical_shortcuts", "compile_qasm", "estimate_resources", "repair_qasm", "quantum_service_info", "qasm_diagnostics", "list_components", "list_targets", "show_target", "run_on_target", "quantum_circuit_examples", "qec_code", "qec_decode", "qec_threshold", "pulse_schedule", "pulse_check", "gradient_qasm", "optimize_qasm", "vqe_ground_state", "qaoa_maxcut", "build_algorithm"]);
assert_eq!(names, ["run_quantum_circuit", "run_quantum_set", "dry_run_quantum_job", "get_quantum_job", "verify_quantum_receipt", "critique_qasm", "check_equivalent", "convert_program", "improve_qasm", "classical_shortcuts", "compile_qasm", "estimate_resources", "repair_qasm", "quantum_service_info", "qasm_diagnostics", "list_components", "list_targets", "show_target", "run_on_target", "quantum_circuit_examples", "qec_code", "qec_decode", "qec_threshold", "pulse_schedule", "pulse_check", "gradient_qasm", "optimize_qasm", "vqe_ground_state", "qaoa_maxcut", "build_algorithm", "write_circuit"]);
let run = &tools()[0]["inputSchema"]["properties"]["compile"]["properties"];
assert!(run.get("epsilon").is_some() && run.get("basis").is_some() && run.get("target").is_none(), "the compile's gate set is its basis; target is a device: {run}");
}
@ -1039,7 +1083,7 @@ mod tests {
}
let all = tools();
let names: Vec<&str> = all.as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert_eq!(names, ["run_quantum_circuit", "run_quantum_set", "dry_run_quantum_job", "get_quantum_job", "verify_quantum_receipt", "critique_qasm", "check_equivalent", "convert_program", "improve_qasm", "classical_shortcuts", "compile_qasm", "estimate_resources", "repair_qasm", "quantum_service_info", "qasm_diagnostics", "list_components", "list_targets", "show_target", "run_on_target", "quantum_circuit_examples", "qec_code", "qec_decode", "qec_threshold", "pulse_schedule", "pulse_check", "gradient_qasm", "optimize_qasm", "vqe_ground_state", "qaoa_maxcut", "build_algorithm"]);
assert_eq!(names, ["run_quantum_circuit", "run_quantum_set", "dry_run_quantum_job", "get_quantum_job", "verify_quantum_receipt", "critique_qasm", "check_equivalent", "convert_program", "improve_qasm", "classical_shortcuts", "compile_qasm", "estimate_resources", "repair_qasm", "quantum_service_info", "qasm_diagnostics", "list_components", "list_targets", "show_target", "run_on_target", "quantum_circuit_examples", "qec_code", "qec_decode", "qec_threshold", "pulse_schedule", "pulse_check", "gradient_qasm", "optimize_qasm", "vqe_ground_state", "qaoa_maxcut", "build_algorithm", "write_circuit"]);
// Each declares its output, versioned; a tool that reads a program requires its verdict.
for t in all.as_array().unwrap() {
let (name, o) = (t["name"].as_str().unwrap(), &t["outputSchema"]);
@ -1222,6 +1266,8 @@ mod tests {
("build_algorithm", json!({ "algorithm": "adder", "bits": 2, "a": 1, "b": 2 }), false),
("build_algorithm", json!({ "algorithm": "qpe", "counting": 30, "phase": 0.1 }), true),
("run_quantum_circuit", json!({ "qasm": "OPENQASM 3.0;\ninput float t;\nqubit q;\nrx(t) q;\n", "params": { "t": 0.4 }, "observables": ["Z0"] }), false),
// A write, on a service with no model: refused with its code.
("write_circuit", json!({ "task": { "intent": { "distribution": { "00": 0.5, "11": 0.5 } } } }), true),
] {
let r = rpc("tools/call", json!({ "name": name, "arguments": args }));
assert_eq!(r["result"]["isError"], refused, "{name}: {r}");
@ -1478,7 +1524,7 @@ mod tests {
// a plan takes none.
let all = tools();
let tool = |n: &str| all.as_array().unwrap().iter().find(|t| t["name"] == n).unwrap().clone();
for n in ["run_quantum_circuit", "run_quantum_set", "run_on_target"] {
for n in ["run_quantum_circuit", "run_quantum_set", "run_on_target", "write_circuit"] {
let t = tool(n);
assert_eq!(t["annotations"]["idempotentHint"], false, "{n}");
assert_eq!(t["inputSchema"]["properties"]["idempotency_key"]["maxLength"], crate::jobs::KEY_MAX, "{n}");

View file

@ -0,0 +1,692 @@
//! A program written for a task: `POST /v1/write` and the MCP tool
//! `write_circuit`.
//!
//! A write asks the service's own model for an OpenQASM program that does what
//! a task asks. A task is an exercise in the course's form
//! (`wai.quantum.exercise/1`). The model writes; the engine checks the program
//! against the task exactly as it checks a lesson's answer; and what went wrong
//! goes back for another attempt. That is the loop of [`wai_quantum_writer`]. The
//! answer is its transcript (`wai.quantum.write/1`), sealed with the service's
//! key as every result is, so `POST /v1/verify` checks it.
//!
//! - **One model, one write at a time.** The model runs in one writer process
//! (this binary's `writer` mode). The service starts it when it starts and
//! keeps it for every write after, so the checkpoint is mapped once. Writes
//! wait in the service's queue in their order, beside its circuit jobs, and
//! neither waits for the other ([`crate::jobs`]). A client holds at most one
//! queued or running write.
//! - **The writer process.** It reads one request a line on stdin. It answers on
//! stdout, one JSON object a line. Its first line is `{"ready": {"model": …}}`
//! once its model is loaded, or `{"error": …}`, after which it ends. Then, for
//! each request, it writes a line for each attempt as it lands
//! (`{"attempt": …, "timing": …}`), and a last line with the transcript
//! (`{"transcript": …, "timing": […]}`) or `{"error": …}`.
//! - **Its limits.** On macOS it runs sandboxed, as a worker does: no network,
//! no file writes, no further processes, its model read ([`profile`]). Its
//! resident memory is watched under its own cap, `WAI_QSVC_WRITER_GB`, since
//! the model's mapped pages count in it. A write has its own time limit,
//! `WAI_QSVC_WRITE_SECS`. The engine's checks inside it plan within the
//! per-job memory cap, so the check refuses a program larger than a job may
//! be, as a job's plan does. A writer that passes its cap or its time, is
//! cancelled, or ends on its own is stopped; its write fails saying which, and
//! the next write starts another.
//! - **Its model.** The writer reads every byte of the model against its
//! manifest before it loads it, and loads exactly the files the manifest
//! lists, so the digest a transcript names is the weights that ran
//! (`"checked": "blake3"` in its model).
//! - **What a write takes.** A task is held to the course's form before it is
//! queued (`wai_quantum_writer::check_task`): at most 16 cases and 8 intents
//! in one place, every bound whole numbers, a job of shots, seed and noise.
//! Its canonical form is at most [`MOST_TASK_BYTES`], what the model is first
//! told at most [`MOST_PROMPT_BYTES`], and a program an intent compares with
//! is within the bounds an equivalence check here takes.
//! - **Determinism.** Decoding is greedy, and the task reaches the model in its
//! canonical form (RFC 8785, the form the store keeps and its digest is taken
//! over). So the same model, task, options and engine give the same
//! transcript in the same arithmetic, which the transcript's model names (its
//! architecture, operating system, maths library and matmul kernel): a write the service
//! runs again after a restart gives the bytes it would have given, and a
//! replay elsewhere gives them where that arithmetic is the same.
//! - **Testing.** `WAI_QSVC_WRITER_SCRIPT` names a script the writer runs in
//! place of the model ([`wai_quantum_writer::scripted`]): a JSON list of
//! replies, each a string, or null for a reply that never left its thinking.
use crate::catalog::Plan;
use serde_json::{Value, json};
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
use wai_quantum::engine::energy::{PackageReading, Reading};
use wai_quantum::engine::refusal::{self, tag};
use wai_quantum_writer::{Model, Options, scripted::Scripted, write_with};
/// A write's method, as its plan and its job name it.
pub const METHOD: &str = "write";
/// The most rounds of repair a write may ask for, and the rounds it gets when it asks none.
pub const MOST_ROUNDS: u64 = 4;
pub const ROUNDS: u64 = 4;
/// The most tokens one reply may take, and what a reply gets when a write asks none.
pub const MOST_NEW: u64 = 4000;
pub const MAX_NEW: u64 = 3000;
/// The most bytes a write's task may take in its canonical form: what the store
/// keeps of it, and what every attempt's check carries.
pub const MOST_TASK_BYTES: usize = 64 << 10;
/// The most bytes of what the model is first told of a task: the prompt it reads
/// before it writes anything, which an anonymous caller would otherwise size.
pub const MOST_PROMPT_BYTES: usize = 8 << 10;
/// What a write's plan says of it.
const WHY: &str = "the service's model writes a program for the task, and the engine checks each attempt against it as it checks a lesson's answer; writes run one at a time in the writer process, whose memory is its own and held to its own cap, so a write holds none of the jobs' memory budget; the work is the most tokens its replies may take";
/// What the writer runs: the model, or a script in its place.
#[derive(Clone, Debug)]
pub enum Source {
/// A model's directory (its files and `wai-model.json`).
Model(PathBuf),
/// A script of replies ([`wai_quantum_writer::scripted`]), for tests.
Script(PathBuf),
}
impl Source {
/// `WAI_QSVC_WRITER_SCRIPT`, a script, for tests; else `WAI_QSVC_MODEL`, a
/// model's directory; else none, and the service writes no programs.
pub fn from_env() -> Option<Source> {
let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
var("WAI_QSVC_WRITER_SCRIPT").map(Source::Script).or_else(|| var("WAI_QSVC_MODEL").map(Source::Model))
}
/// The writer's arguments for it.
fn args(&self) -> [std::ffi::OsString; 2] {
match self {
Source::Model(d) => ["--model".into(), d.clone().into_os_string()],
Source::Script(f) => ["--script".into(), f.clone().into_os_string()],
}
}
/// What the writer reads beside its own binary: the model's directory, or the script.
fn reads(&self) -> &Path {
match self {
Source::Model(d) | Source::Script(d) => d,
}
}
/// Whether it is a script: a test service's.
pub fn scripted(&self) -> bool {
matches!(self, Source::Script(_))
}
}
/// What a write asks for beside its task.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Asked {
pub rounds: u64,
pub max_new: u64,
}
/// The statements reading a write's programs may take together: every program
/// an intent compares with, each read once, within one budget
/// ([`wai_quantum::qasm::within_steps`]). A course's reference reads in a few
/// hundred.
pub const READ_STEPS: u64 = 1_000_000;
/// A write's request read: `{"task": <exercise>, "rounds", "max_new"}`, its task
/// held to [`wai_quantum_writer::check_task`] (`J071`) and its options to their
/// ranges (`J072`); or the refusal's status and reason. [`read_shape`], then
/// [`read_programs`]: what admission does with a client's submission counted
/// between them.
pub fn read(request: &Value) -> Result<Asked, (u16, String)> {
let asked = read_shape(request)?;
read_programs(request)?;
Ok(asked)
}
/// What a write asks beside its task: `rounds` and `max_new`, in their ranges.
pub fn options(request: &Value) -> Result<Asked, (u16, String)> {
let num = |k: &str, default: u64, lo: u64, hi: u64| -> Result<u64, (u16, String)> {
match request.get(k) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_u64().filter(|n| (lo..=hi).contains(n)).ok_or_else(|| (400, tag(refusal::WRITE_OPTIONS, format!("`{k}` is a whole number from {lo} to {hi}, not {v}")))),
}
};
Ok(Asked { rounds: num("rounds", ROUNDS, 0, MOST_ROUNDS)?, max_new: num("max_new", MAX_NEW, 1, MOST_NEW)? })
}
/// How many intents `v` gives: a list's length, or one.
fn intents_in(v: &Value) -> usize {
match v {
Value::Null => 0,
Value::Array(a) => a.len(),
_ => 1,
}
}
/// A write's request read as far as costs nothing: its shape and options, its
/// numbers exact, its task's canonical size, and how many cases and intents it
/// gives. Nothing in it is read as a program, so a refusal here costs the
/// service no reading: admission refuses what this refuses before it counts the
/// client's submission.
pub fn read_shape(request: &Value) -> Result<Asked, (u16, String)> {
let Some(o) = request.as_object() else {
return Err((400, tag(refusal::WRITE_TASK, "a write is a JSON object: {\"task\": <an exercise>, \"rounds\": 0 to 4, \"max_new\": 1 to 4000}")));
};
if let Some(k) = o.keys().find(|k| !["task", "rounds", "max_new"].contains(&k.as_str())) {
return Err((400, tag(refusal::WRITE_OPTIONS, format!("`{k}`: a write takes task, rounds and max_new, and nothing else"))));
}
let task = o.get("task").ok_or_else(|| (400, tag(refusal::WRITE_TASK, "a write gives its task: {\"task\": <an exercise>}")))?;
if !task.is_object() {
return Err((400, tag(refusal::WRITE_TASK, "a write's task is a JSON object: an exercise in the course's form (wai.quantum.exercise/1)")));
}
if let Some(at) = crate::receipt::inexact_number(task) {
return Err((400, tag(refusal::WRITE_TASK, format!("the task's `{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 bytes = wai_quantum::engine::receipt::canonical(task).len();
if bytes > MOST_TASK_BYTES {
return Err((400, tag(refusal::WRITE_TASK, format!("the task is {bytes} bytes in its canonical form, and a write's may be at most {MOST_TASK_BYTES}"))));
}
use wai_quantum::engine::course::{MOST_CASES, MOST_INTENTS};
let cases = task.get("cases").and_then(Value::as_array).map_or(0, Vec::len);
if cases > MOST_CASES {
return Err((400, tag(refusal::WRITE_TASK, format!("the task has {cases} cases, and an exercise may have at most {MOST_CASES}"))));
}
let most = std::iter::once(&task["intent"]).chain(task["cases"].as_array().into_iter().flatten().map(|c| &c["intent"])).map(intents_in).max().unwrap_or(0);
if most > MOST_INTENTS {
return Err((400, tag(refusal::WRITE_TASK, format!("the task gives {most} intents in one place, and one may give at most {MOST_INTENTS}"))));
}
options(request)
}
/// The rest of a write's reading: its task held to the course's form
/// ([`wai_quantum_writer::check_task`]), what the model would first be told of
/// it within [`MOST_PROMPT_BYTES`], its job probed as a job is, and each program
/// an intent compares with within an equivalence check's bounds here. Every
/// program in it is read once, all of them within one budget of
/// [`READ_STEPS`] statements, so a request's reading is bounded however many
/// programs it carries.
pub fn read_programs(request: &Value) -> Result<(), (u16, String)> {
let task = &request["task"];
wai_quantum::qasm::within_steps(READ_STEPS, || {
wai_quantum_writer::check_task(task).map_err(|e| (400, tag(refusal::WRITE_TASK, e)))?;
let canonical = wai_quantum_writer::canonical_task(task).map_err(|e| (400, tag(refusal::WRITE_TASK, e)))?;
let told = wai_quantum_writer::prompt(&canonical).len();
if told > MOST_PROMPT_BYTES {
return Err((400, tag(refusal::WRITE_TASK, format!("what the model would first be told of this task is {told} bytes, and it may be at most {MOST_PROMPT_BYTES}: shorten its title, its text or its starter"))));
}
// The job each check runs a program as, read as a job is, on a program of one
// qubit: its shots within the engine's limit and every noise channel one it
// names, so a job no check could run is refused here, not in every round.
if let Some(job) = canonical.get("job").filter(|j| j.is_object()) {
let mut probe = job.clone();
probe["qasm"] = json!("OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nc = measure q;\n");
wai_quantum::engine::catalog::read(&probe).map_err(|e| (400, tag(refusal::WRITE_TASK, format!("the task's job: {e}"))))?;
}
// A program an intent compares with is held to an equivalence check's bounds
// here, as a job's intent is, checked against itself (read once: the readings
// above answer for it).
let intents = std::iter::once(&canonical["intent"]).chain(canonical["cases"].as_array().into_iter().flatten().map(|c| &c["intent"]));
for i in intents.filter(|i| !i.is_null()) {
for one in wai_quantum::engine::verdict::intents(i).unwrap_or_default() {
if let Some(r) = ["equivalent", "prepares"].iter().find_map(|k| one.get(*k).and_then(Value::as_str)) {
crate::analysis::intent_bounds(&json!({ "qasm": r, "intent": one }))?;
}
}
}
Ok(())
})
}
/// A write's plan: its method, why, no memory of the jobs' budget, and the most
/// tokens its replies may take as its work.
pub fn plan(a: Asked) -> Plan {
Plan { method: METHOD.into(), why: WHY.into(), memory_bytes: 0, work: (a.rounds + 1) * a.max_new, qubits: 0, target: None }
}
/// How long a reply and its check took, as a job's `timing` gives each attempt.
fn timing(reply: f64, check: f64) -> Value {
json!({ "reply_seconds": reply, "check_seconds": check })
}
fn flag(args: &[String], name: &str) -> Option<PathBuf> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(PathBuf::from)
}
/// The model `source` names, loaded.
fn open(source: Option<Source>) -> Result<Box<dyn Model>, String> {
match source {
None => Err("the writer has no model: give --model DIR or --script FILE (or WAI_QSVC_MODEL, WAI_QSVC_WRITER_SCRIPT)".into()),
Some(Source::Script(f)) => {
let text = std::fs::read_to_string(&f).map_err(|e| format!("the script {}: {e}", f.display()))?;
let v: Value = serde_json::from_str(&text).map_err(|e| format!("the script {}: {e}", f.display()))?;
Ok(Box::new(Scripted::from_json(&v).map_err(|e| format!("the script {}: {e}", f.display()))?))
}
// Every byte read against its manifest, once, as the writer starts.
#[cfg(feature = "coder")]
Some(Source::Model(d)) => Ok(Box::new(wai_quantum_writer::coder::CodeModel::open_verified(&d)?)),
#[cfg(not(feature = "coder"))]
Some(Source::Model(d)) => Err(format!("this build has no model runtime (it was built without the `coder` feature), so it cannot run the model at {}", d.display())),
}
}
/// One line of the writer's answer, written and flushed at once.
fn say(out: &mut impl Write, v: &Value) {
let _ = writeln!(out, "{v}");
let _ = out.flush();
}
/// `wai-quantum-service writer [--model DIR | --script FILE]`: the writer
/// process. It loads its model once, says so, and then writes a program for
/// each request it reads, a line each, until its input ends or its service has
/// gone.
pub fn main(args: &[String]) {
crate::watch_parent();
let source = flag(args, "--script").map(Source::Script).or_else(|| flag(args, "--model").map(Source::Model)).or_else(Source::from_env);
let stdout = std::io::stdout();
let mut model = match open(source) {
Ok(m) => m,
Err(e) => {
say(&mut stdout.lock(), &json!({ "error": e }));
std::process::exit(1)
}
};
say(&mut stdout.lock(), &json!({ "ready": { "model": model.identity() } }));
for line in std::io::stdin().lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let answer = match serde_json::from_str::<Value>(&line) {
Err(e) => json!({ "error": format!("the writer's request is not JSON: {e}") }),
Ok(req) => {
let num = |k: &str, d: u64| req.get(k).and_then(Value::as_u64).unwrap_or(d);
let opts = Options { rounds: num("rounds", ROUNDS) as usize, max_new: num("max_new", MAX_NEW) as usize, think_max: None, job_bytes: num("job_bytes", 1 << 30) };
let mut out = stdout.lock();
let written = write_with(model.as_mut(), &req["task"], &opts, &mut |a, (r, c)| say(&mut out, &json!({ "attempt": a, "timing": timing(r, c) })));
match written {
Ok(w) => json!({ "transcript": w.transcript, "timing": w.timing.iter().map(|(r, c)| timing(*r, *c)).collect::<Vec<_>>() }),
Err(e) => json!({ "error": e }),
}
}
};
say(&mut stdout.lock(), &answer);
}
}
/// The sandbox the writer runs in on macOS: a worker's ([`crate::jobs::worker_profile`]),
/// no network, no file writes and no program but itself, with the files it
/// reads (`reads`, the model's directory or the script) named as read. It reads
/// its requests on stdin and answers on stdout, so it needs nothing more.
pub fn profile(exe: &Path, reads: &Path) -> String {
let reads = std::fs::canonicalize(reads).unwrap_or_else(|_| reads.to_path_buf());
let quoted = reads.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\"");
let rule = if reads.is_dir() { "subpath" } else { "literal" };
format!("{}(allow file-read* ({rule} \"{quoted}\"))", crate::jobs::worker_profile(exe))
}
/// The writer process, seen from the service: its pipes, its model, and the
/// last line it wrote on standard error, which says why it ended when it ends.
pub struct Proc {
child: tokio::process::Child,
stdin: tokio::process::ChildStdin,
lines: tokio::io::Lines<tokio::io::BufReader<tokio::process::ChildStdout>>,
pub pid: u32,
/// The model it loaded, as a transcript names it.
pub model: Value,
last_err: Arc<Mutex<String>>,
}
impl Proc {
/// Starts the writer for `source`, at a lower priority and, with `sandbox`,
/// sandboxed on macOS, and waits up to `wait` for its model to load.
pub async fn start(source: &Source, sandbox: bool, wait: Duration) -> Result<Proc, String> {
let exe = std::env::current_exe().map_err(|e| format!("the service could not find itself: {e}"))?;
let mut cmd = crate::jobs::low_priority(&exe, sandbox, |e| profile(e, source.reads()));
cmd.arg("writer").args(source.args());
cmd.stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| format!("the writer did not start: {e}"))?;
let pid = child.id().unwrap_or(0);
let stdin = child.stdin.take().expect("piped");
let lines = tokio::io::BufReader::new(child.stdout.take().expect("piped")).lines();
let last_err = Arc::new(Mutex::new(String::new()));
let mut errs = tokio::io::BufReader::new(child.stderr.take().expect("piped")).lines();
let keep = Arc::clone(&last_err);
tokio::spawn(async move {
while let Ok(Some(l)) = errs.next_line().await {
eprintln!("writer: {l}");
*keep.lock().unwrap() = l;
}
});
let mut p = Proc { child, stdin, lines, pid, model: Value::Null, last_err };
let first = match tokio::time::timeout(wait, p.lines.next_line()).await {
Err(_) => {
p.stop().await;
return Err(format!("the writer did not load its model within {} s", wait.as_secs()));
}
Ok(Ok(Some(l))) => l,
Ok(_) => return Err(format!("the writer ended before it loaded its model ({})", p.ended().await)),
};
match serde_json::from_str::<Value>(&first) {
Ok(v) if v.pointer("/ready/model").is_some_and(Value::is_object) => {
p.model = v["ready"]["model"].clone();
Ok(p)
}
Ok(v) => {
let why = v["error"].as_str().map(str::to_string).unwrap_or_else(|| format!("it said {v}"));
p.stop().await;
Err(format!("the writer did not load its model: {why}"))
}
Err(e) => {
p.stop().await;
Err(format!("the writer's first line is not JSON: {e}"))
}
}
}
/// Sends a request.
pub async fn send(&mut self, v: &Value) -> Result<(), String> {
let line = format!("{v}\n");
self.stdin.write_all(line.as_bytes()).await.map_err(|e| format!("the writer did not take the write: {e}"))?;
self.stdin.flush().await.map_err(|e| format!("the writer did not take the write: {e}"))
}
/// The next line it writes, within `d`: `Ok(None)` when none came; an
/// error once its output has ended or does not read.
pub async fn next(&mut self, d: Duration) -> Result<Option<Value>, String> {
match tokio::time::timeout(d, self.lines.next_line()).await {
Err(_) => Ok(None),
Ok(Ok(Some(l))) => serde_json::from_str(&l).map(Some).map_err(|e| format!("the writer wrote a line that is not JSON: {e}")),
Ok(Ok(None)) => Err("its output ended".into()),
Ok(Err(e)) => Err(format!("its output could not be read: {e}")),
}
}
/// Whether it still runs.
pub fn alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
/// Why it ended: its status, and the last line it wrote on standard error.
pub async fn ended(&mut self) -> String {
let status = match tokio::time::timeout(Duration::from_secs(5), self.child.wait()).await {
Ok(Ok(s)) => s.to_string(),
_ => "no status".into(),
};
let last = self.last_err.lock().unwrap().clone();
if last.is_empty() { status } else { format!("{status}: {last}") }
}
/// Stops it, and waits for it to end.
pub async fn stop(&mut self) {
let _ = self.child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(10), self.child.wait()).await;
}
}
/// What a write's energy is read from on macOS: the kernel's count for the
/// writer process, which runs before the write and after it.
pub fn running(pid: u32) -> Option<Reading> {
wai_quantum::engine::energy::running(pid)
}
/// A write's energy on macOS: the writer process's count read before the write
/// was sent and again after its last line came back, over the write's window.
/// The process runs on, so the second read trails by its threads' time still on
/// a core, which the kernel adds only when they leave it: the figure is a lower
/// bound by that much, and says so (`partial`).
pub fn figure(before: Option<&Reading>, after: Option<&Reading>, window_ns: u64) -> Value {
let (Some(b), Some(a)) = (before, after) else {
return json!({ "unmetered": "the writer's energy could not be read at the write's start or its end" });
};
let r = Reading {
energy_nj: a.energy_nj.saturating_sub(b.energy_nj),
billed_nj: a.billed_nj.saturating_sub(b.billed_nj),
serviced_nj: a.serviced_nj.saturating_sub(b.serviced_nj),
cpu_ns: a.cpu_ns.saturating_sub(b.cpu_ns),
window_ns,
};
let mut f = crate::energy::figure(Some(&r), false, crate::energy::UNMETERED_HERE);
if f.get("joules_micro").is_some() {
f["coverage"] = json!("partial");
f["covers"] = json!("the CPU energy of the writer process's threads over the write, the model's reasoning and the engine's checks of each attempt, with the CPU energy system services billed to it in that time");
f["source"] = json!("proc_pid_rusage ri_energy_nj + ri_billed_energy − ri_serviced_energy of the writer, which runs on between writes, read before the write was sent and after its last line came back");
if let Some(x) = f["excludes"].as_array_mut() {
// What the engine says of a worker's process is said here of the writer's.
for e in x.iter_mut() {
if let Some(t) = e.as_str().map(|t| t.replace("worker", "writer")) {
*e = json!(t);
}
}
x.push(json!("the energy of the writer's threads still on a core when the write's end was read, which the kernel adds when they leave it (the figure is a lower bound by that)"));
}
} else if let Some(why) = f["unmetered"].as_str().map(|w| w.replace("worker", "writer")) {
f["unmetered"] = json!(why);
}
f
}
/// A write's energy on Linux: the processor packages' over its window, with no
/// attribution to it, as a job's is. The words say what the window is: the
/// writer runs on between writes, so its start and exit, which bound a worker's
/// window, are not in it.
pub fn package_figure(reading: Option<&PackageReading>) -> Value {
let mut f = crate::energy::package_figure(reading, "the processor packages' counter did not read at the write's start or its end");
if f.get("joules_micro").is_some() {
f["covers"] = json!("the energy of the host's processor packages over the write's window, from just before it was sent to the writer to just after its last line came back: every process on the host in that window, the writer among them, and the packages' idle draw");
f["source"] = json!("powercap energy_uj of every package zone, read before the write was sent to the writer and after its last line came back, each wrap corrected at the zone's max_energy_range_uj");
} else if let Some(why) = f["unmetered"].as_str().map(|w| w.replace("the job's window", "the write's window")) {
f["unmetered"] = json!(why);
}
f
}
/// A write's verdict as an answer about a program states one: of the last
/// program it wrote, as the engine checked it against the task, the task's
/// constraints with its intents (the weakest of its cases' levels; holding
/// only if every case and every constraint does), or, for a write that failed,
/// why. A write with no attempt yet has none: nothing has been read or refused.
pub fn verdict(job: &Value) -> Value {
let last = job.pointer("/result/attempts").and_then(Value::as_array).and_then(|a| a.last()).or_else(|| job.pointer("/progress/latest"));
let none = |why: String| wai_quantum::engine::verdict::of_refusal(&json!({}), &why);
let Some(a) = last else {
return match job["error"].as_str() {
Some(e) => none(e.to_string()),
None => Value::Null,
};
};
let cases: Vec<&Value> = a.pointer("/check/cases").and_then(Value::as_array).into_iter().flatten().filter_map(|c| c.get("verdict")).collect();
let round = a["round"].as_u64().unwrap_or(0);
let Some(weakest) = cases.iter().min_by_key(|v| v["rank"].as_u64().unwrap_or(0)) else {
return none(format!("the model's reply at round {round} ended before it wrote a program, so the engine checked nothing"));
};
let broken: Vec<&str> = a.pointer("/check/constraints").and_then(Value::as_array).into_iter().flatten().filter(|c| c["holds"] == false).filter_map(|c| c["text"].as_str()).collect();
let mut v = (*weakest).clone();
v["holds"] = if !broken.is_empty() || cases.iter().any(|c| c["holds"] == false) {
json!(false)
} else if cases.iter().all(|c| c["holds"] == true) {
json!(true)
} else {
Value::Null
};
let of = if cases.len() > 1 { format!("the weakest of its {} cases: ", cases.len()) } else { String::new() };
let mut statement = format!("the program written at round {round}, checked against the task, {of}{}", weakest["statement"].as_str().unwrap_or(""));
if !broken.is_empty() {
statement += &format!("; it does not keep the task's constraints: {}", broken.join("; "));
}
v["statement"] = json!(statement);
v
}
/// A write's job as a model reads it.
pub fn summary(job: &Value) -> String {
let mut s = vec![format!("status: {} · write {}", job["status"].as_str().unwrap_or("?"), job["id"].as_str().unwrap_or("?"))];
if let Some(e) = job["error"].as_str() {
s.push(format!("error: {e}"));
}
let r = &job["result"];
if r.is_object() {
let m = &r["model"];
s.push(format!("model: {} ({}), digest {}", m["name"].as_str().unwrap_or("?"), m["licence"].as_str().unwrap_or("?"), m["digest"].as_str().unwrap_or("?")));
let attempts = r["attempts"].as_array().map_or(0, Vec::len);
s.push(match r["passed_round"].as_u64() {
Some(n) => format!("passed after {n} repair(s), checked to the level {}", r["level"].as_str().unwrap_or("?")),
None => format!("no program passed in {attempts} attempt(s)"),
});
for a in r["attempts"].as_array().into_iter().flatten() {
let how = match (a["answered"].as_bool(), a["passed"].as_bool()) {
(Some(false), _) => "no program (the reply ended in its thinking)".to_string(),
(_, Some(true)) => format!("passed ({})", a["level"].as_str().unwrap_or("?")),
_ => format!("did not pass: {}", a.pointer("/check/what_went_wrong/0/text").and_then(Value::as_str).unwrap_or("")),
};
s.push(format!(" round {}: {how}", a["round"]));
}
if let Some(p) = r["program"].as_str() {
s.push(format!("program:\n{p}"));
}
s.push("the transcript is sealed: verify_quantum_receipt checks it with the job's request".into());
} else if let Some(n) = job.pointer("/progress/attempts").and_then(Value::as_u64) {
s.push(format!("{n} attempt(s) checked so far"));
}
s.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn bell() -> Value {
json!({ "title": "A Bell pair", "intent": { "distribution": { "00": 0.5, "11": 0.5 } } })
}
#[test]
fn a_write_reads_its_task_and_options_or_says_which_does_not() {
assert_eq!(read(&json!({ "task": bell() })).unwrap(), Asked { rounds: 4, max_new: 3000 });
assert_eq!(read(&json!({ "task": bell(), "rounds": 0, "max_new": 1 })).unwrap(), Asked { rounds: 0, max_new: 1 });
assert_eq!(read(&json!({ "task": bell(), "rounds": null })).unwrap().rounds, 4);
for (req, code) in [
(json!([]), "J071"),
(json!({}), "J071"),
(json!({ "task": { "task": "anything" } }), "J071"),
(json!({ "task": { "cases": [{ "name": "a", "inputs": {} }] } }), "J071"),
(json!({ "task": { "intent": { "distribution": { "0": 1.0 } }, "job": { "shots": 9007199254740993u64 } } }), "J071"),
(json!({ "task": bell(), "rounds": 5 }), "J072"),
(json!({ "task": bell(), "rounds": -1 }), "J072"),
(json!({ "task": bell(), "rounds": 1.5 }), "J072"),
(json!({ "task": bell(), "max_new": 0 }), "J072"),
(json!({ "task": bell(), "max_new": 4001 }), "J072"),
(json!({ "task": bell(), "qasm": "OPENQASM 3.0;" }), "J072"),
] {
let (status, why) = read(&req).unwrap_err();
assert!(status == 400 && why.ends_with(&format!("[{code}]")), "{req}: {status} {why}");
}
// A task is held to the course's form, and sized, before it is queued.
let with = |k: &str, v: Value| {
let mut t = bell();
t[k] = v;
json!({ "task": t })
};
let mut wide = String::from("OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[22] q;\nh q;\n");
for _ in 0..200 {
wide += "rz(0.3) q;\n";
}
for (req, status, needle) in [
(with("constraints", json!({ "t_count": 0 })), 400, "t_count"),
(with("constraints", json!({ "depth": { "max": 3 } })), 400, "not a constraint"),
(with("job", json!({ "repair": true })), 400, "job takes"),
(with("job", json!(1)), 400, "job is an object"),
(with("cases", json!(vec![json!({ "inputs": {} }); 17])), 400, "at most 16"),
(with("intent", json!(vec![json!({ "distribution": { "00": 1.0 } }); 9])), 400, "at most 8"),
(with("task", json!("x".repeat(70_000))), 400, "canonical form"),
(with("task", json!("x".repeat(9_000))), 400, "first be told"),
(with("intent", json!({ "equivalent": wide })), 422, "[J031]"),
(with("job", json!({ "shots": 2_000_000 })), 400, "the task's job"),
(with("job", json!({ "noise": { "no_such_channel": 0.1 } })), 400, "the task's job"),
] {
let (code, why) = read(&req).unwrap_err();
assert!(code == status && why.contains(needle), "{}: {code} {why}", req.to_string().chars().take(160).collect::<String>());
}
// Every program in a write is read once, all within one budget: the same
// long-running reference eight times reads once; eight different ones run
// past the budget together.
let looping = |n: u64| format!("OPENQASM 3.0;\nqubit[1] q;\nint a = 0;\nfor int i in [0:{n}] {{ a += 1; }}\n");
let same = json!({ "task": { "intent": vec![json!({ "equivalent": looping(200_000) }); 8] } });
read(&same).unwrap_or_else(|e| panic!("one program, eight times: {e:?}"));
let different = json!({ "task": { "intent": (0..8).map(|k| json!({ "equivalent": looping(200_000 + k) })).collect::<Vec<_>>() } });
let (code, why) = read(&different).unwrap_err();
assert!(code == 400 && why.contains("budget"), "{code} {why}");
let p = plan(Asked { rounds: 2, max_new: 100 });
assert_eq!((p.method.as_str(), p.memory_bytes, p.work), (METHOD, 0, 300));
}
/// A write's energy is the writer's count over the write, a lower bound
/// that says so; without both reads it is unmetered, and says why.
#[test]
fn a_write_s_energy_is_the_writer_s_over_the_write_and_a_lower_bound() {
let at = |nj: u64, cpu: u64| Reading { energy_nj: nj, billed_nj: 0, serviced_nj: 0, cpu_ns: cpu, window_ns: 0 };
let f = figure(Some(&at(1_000_000, 5)), Some(&at(9_000_000, 2_000_005)), 3_000_000_000);
assert_eq!((f["joules_micro"].as_u64(), f["coverage"].as_str(), f["cpu_ms"].as_u64()), (Some(8000), Some("partial"), Some(2)), "{f}");
assert_eq!(f["energy_uncertainty"]["window_us"], 3_000_000);
assert!(f["covers"].as_str().unwrap().contains("writer") && f["excludes"].to_string().contains("lower bound"), "{f}");
assert!(figure(None, Some(&at(1, 1)), 1)["unmetered"].is_string());
let still = figure(Some(&at(5, 1)), Some(&at(5, 1)), 1);
assert!(still["unmetered"].as_str().unwrap().contains("writer's"), "{still}");
// What the kernel billed to it is said of the writer too.
let billed = |b: u64| Reading { billed_nj: b, ..at(b + 9_000_000, 5) };
let f = figure(Some(&billed(0)), Some(&billed(4_000_000)), 1000);
assert!(f["excludes"].to_string().contains("billed to the writer") && !f.to_string().contains("worker"), "{f}");
}
/// On Linux a write's figure is the packages', and says its window is the
/// write's: the writer neither starts nor exits in it.
#[test]
fn a_write_s_package_figure_says_what_its_window_is() {
let r = PackageReading { energy_uj: 5_000_000, window_ns: 2_000_000_000, packages: 1, update_ns: 1_000_000, step_uj: 100, concurrent: 2 };
let f = package_figure(Some(&r));
assert_eq!((f["joules_micro"].as_u64(), f["attribution"].as_str(), f["concurrent_jobs"].as_u64()), (Some(5_000_000), Some("none"), Some(2)), "{f}");
let text = f.to_string();
assert!(!text.contains("worker") && !text.contains("exited") && !text.contains("the job's window"), "{f}");
assert!(f["covers"].as_str().unwrap().contains("the write's window") && f["source"].as_str().unwrap().contains("its last line came back"), "{f}");
assert!(package_figure(None)["unmetered"].as_str().unwrap().contains("the write's start"));
let idle = package_figure(Some(&PackageReading { energy_uj: 0, ..r }));
assert!(idle["unmetered"].as_str().unwrap().contains("the write's window"), "{idle}");
}
#[test]
fn a_write_s_verdict_is_its_last_program_s() {
// Queued, or running before its first attempt: nothing read, nothing refused.
assert!(verdict(&json!({ "status": "queued" })).is_null());
assert!(verdict(&json!({ "status": "running" })).is_null());
let failed = verdict(&json!({ "status": "failed", "error": "the writer stopped" }));
assert_eq!(failed["level"], "refused", "{failed}");
let case = |level: &str, rank: u64, holds: bool| json!({ "verdict": { "level": level, "rank": rank, "holds": holds, "statement": format!("{level} statement") } });
let job = json!({ "status": "done", "result": { "attempts": [{ "round": 1, "check": { "cases": [case("proof", 4, true), case("evidence", 3, true)] } }] } });
let v = verdict(&job);
assert_eq!((v["level"].as_str(), v["holds"].as_bool()), (Some("evidence"), Some(true)), "{v}");
assert!(v["statement"].as_str().unwrap().starts_with("the program written at round 1, checked against the task, the weakest of its 2 cases"), "{v}");
let unanswered = json!({ "status": "done", "result": { "attempts": [{ "round": 0, "check": null }] } });
assert_eq!(verdict(&unanswered)["level"], "refused");
// Its intent held, its constraint did not: the program does not do what the task asks.
let broken = json!({ "status": "done", "result": { "attempts": [{ "round": 4, "check": { "passed": false, "cases": [case("proof", 4, true)], "constraints": [{ "rule": "allow", "text": "only h, cz", "holds": false }] } }] } });
let v = verdict(&broken);
assert_eq!((v["level"].as_str(), v["holds"].as_bool()), (Some("proof"), Some(false)), "{v}");
assert!(v["statement"].as_str().unwrap().contains("does not keep the task's constraints: only h, cz"), "{v}");
}
#[cfg(target_os = "macos")]
#[test]
fn the_writer_sandbox_reads_its_model_and_writes_nothing() {
let dir = std::env::temp_dir().join(format!("wai-qsvc-writer-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("config.json"), "{}").unwrap();
let run = |exe: &str, args: &[&str]| std::process::Command::new("/usr/bin/sandbox-exec").arg("-p").arg(profile(Path::new(exe), &dir)).arg(exe).args(args).output().expect("sandbox-exec runs");
let out = run("/bin/cat", &[dir.join("config.json").to_str().unwrap()]);
assert_eq!(String::from_utf8_lossy(&out.stdout), "{}", "the writer reads its model's directory");
let probe = dir.join("written");
let out = run("/usr/bin/touch", &[probe.to_str().unwrap()]);
assert!(!out.status.success() && !probe.exists(), "a sandboxed writer wrote into its model's directory");
let out = run("/usr/bin/curl", &["-s", "-m", "5", "-o", "/dev/null", "-w", "%{http_code}", "https://example.com"]);
assert_eq!(String::from_utf8_lossy(&out.stdout), "000", "a sandboxed writer reached the network");
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -0,0 +1,428 @@
//! A program written for a task, against the service itself, its writer
//! process running a script in the model's place (`WAI_QSVC_WRITER_SCRIPT`):
//! a Bell pair's task, whose first program the engine finds wrong and whose
//! repair passes. The script is a FIFO, so the writer holds at its start, with
//! its model "loading", until the test writes the script into it: until then
//! every write stays queued, which is how the per-client limit, the order and
//! the attempts' events are seen as they happen.
use serde_json::{Value, json};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use wai_quantum::engine::schema::Registry;
const BELL: &str = "```qasm\nOPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;\n```";
const NO_CNOT: &str = "```qasm\nOPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\nc = measure q;\n```";
fn bell_task() -> Value {
json!({
"schema": "wai.quantum.exercise/1",
"title": "A Bell pair",
"task": "Entangle two qubits so that their measured bits always agree.",
"starter": ["OPENQASM 3.0;", "include \"stdgates.inc\";", "qubit[2] q;", "bit[2] c;", "c = measure q;"],
"intent": { "distribution": { "00": 0.5, "11": 0.5 } }
})
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
/// A body sent with chunked transfer coding, as it was meant.
fn dechunk(body: &str) -> String {
let (mut out, mut rest) = (String::new(), body);
while let Some((size, after)) = rest.split_once("\r\n") {
let Ok(n) = usize::from_str_radix(size.trim(), 16) else { break };
if n == 0 || after.len() < n {
break;
}
out.push_str(&after[..n]);
rest = after[n..].strip_prefix("\r\n").unwrap_or(&after[n..]);
}
out
}
/// One HTTP/1.1 exchange, with extra header fields: the status, the headers
/// (lowercased) and the body.
fn http(port: u16, method: &str, path: &str, body: Option<&Value>, extra: &[(&str, &str)]) -> (u16, String, String) {
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
s.set_read_timeout(Some(Duration::from_secs(180))).unwrap();
let text = body.map(Value::to_string).unwrap_or_default();
let fields: String = extra.iter().map(|(k, v)| format!("{k}: {v}\r\n")).collect();
write!(s, "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\n{fields}Content-Length: {}\r\nConnection: close\r\n\r\n{text}", text.len()).unwrap();
let mut out = String::new();
s.read_to_string(&mut out).unwrap();
let status = out.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
let (head, body) = out.split_once("\r\n\r\n").map(|(h, b)| (h.to_ascii_lowercase(), b.to_string())).unwrap_or_default();
let body = if head.contains("transfer-encoding: chunked") { dechunk(&body) } else { body };
(status, head, body)
}
fn json_of(port: u16, method: &str, path: &str, body: Option<&Value>, extra: &[(&str, &str)]) -> (u16, Value) {
let (code, _, text) = http(port, method, path, body, extra);
(code, serde_json::from_str(&text).unwrap_or_else(|e| panic!("{method} {path}: {e}: {text}")))
}
fn rpc(port: u16, method: &str, params: Value) -> Value {
let (code, v) = json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params })), &[]);
assert_eq!(code, 200, "{method}: {v}");
v
}
/// The service, its writer running the script at `script` if any, once it answers.
fn start(dir: &Path, port: u16, script: Option<&Path>) -> Child {
start_with(dir, port, script, 120)
}
/// [`start`], a write (and the writer's start) held to `write_secs`.
fn start_with(dir: &Path, port: u16, script: Option<&Path>, write_secs: u64) -> Child {
start_env(dir, port, script, write_secs, &[])
}
/// [`start_with`], with more of the service's variables.
fn start_env(dir: &Path, port: u16, script: Option<&Path>, write_secs: u64, env: &[(&str, &str)]) -> Child {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wai-quantum-service"));
cmd.arg("serve")
.env("WAI_QSVC_ADDR", format!("127.0.0.1:{port}"))
.env("WAI_QSVC_STORE", dir.join("store"))
.env("WAI_QSVC_KEY", dir.join("signing.key"))
.env("WAI_QSVC_JOB_GB", "2")
.env("WAI_QSVC_BUDGET_GB", "4")
.env("WAI_QSVC_WORKERS", "1")
.env("WAI_QSVC_ENERGY", "0")
.env("WAI_QSVC_WRITE_SECS", write_secs.to_string())
.env_remove("WAI_QSVC_MODEL")
.env_remove("WAI_QSVC_WRITER_SCRIPT")
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Some(s) = script {
cmd.env("WAI_QSVC_WRITER_SCRIPT", s);
}
for (k, v) in env {
cmd.env(k, v);
}
let child = cmd.spawn().expect("the service starts");
let t = Instant::now();
while TcpStream::connect(("127.0.0.1", port)).is_err() || http(port, "GET", "/v1/health", None, &[]).0 != 200 {
assert!(t.elapsed() < Duration::from_secs(60), "the service did not answer");
std::thread::sleep(Duration::from_millis(50));
}
child
}
/// Waits until a job's status is `want`.
fn until(port: u16, id: &str, want: &str) -> Value {
let t = Instant::now();
loop {
let (_, j) = json_of(port, "GET", &format!("/v1/jobs/{id}"), None, &[]);
if j["status"] == want {
return j;
}
assert!(t.elapsed() < Duration::from_secs(120), "{id} never {want}: {j}");
std::thread::sleep(Duration::from_millis(25));
}
}
/// The submissions left in this client's window, from a write's answer.
fn left(head: &str) -> u64 {
head.lines().find_map(|l| l.strip_prefix("ratelimit: ")).and_then(|v| v.split("r=").nth(1)).and_then(|v| v.split(';').next()).and_then(|v| v.trim().parse().ok()).unwrap()
}
/// A document's canonical text (RFC 8785), which a receipt binds: what "the
/// same" means for two documents, whichever reader carried them.
fn canonical(v: &Value) -> String {
String::from_utf8(wai_quantum::engine::receipt::canonical(v)).unwrap()
}
/// The code a refusal ends with.
fn code_of(v: &Value) -> &str {
v["code"].as_str().unwrap_or_else(|| panic!("no code: {v}"))
}
#[test]
fn a_write_passes_after_one_repair_its_attempts_stream_as_they_land_and_its_receipt_verifies() {
let dir = std::env::temp_dir().join(format!("wai-qsvc-write-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join("script.json");
assert!(Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let port = free_port();
let mut svc = start(&dir, port, Some(&fifo));
// The service says it writes, its model still loading.
let (_, d) = json_of(port, "GET", "/v1", None, &[]);
let w = &d["write"];
assert_eq!((w["available"].as_bool(), w["writer"].as_str(), w["model"].is_null()), (Some(true), Some("loading its model"), true), "{w}");
assert_eq!((w["limits"]["writes_at_once"].as_u64(), w["limits"]["writes_per_client"].as_u64(), w["limits"]["write_seconds"].as_u64()), (Some(1), Some(1), Some(120)), "{w}");
assert_eq!((w["limits"]["task_bytes"].as_u64(), w["limits"]["prompt_bytes"].as_u64(), w["limits"]["cases"].as_u64(), w["limits"]["intents"].as_u64()), (Some(65536), Some(8192), Some(16), Some(8)), "{w}");
assert!(d["endpoints"].as_array().unwrap().iter().any(|e| e == "POST /v1/write"), "{d}");
// The queue's limit is each kind's.
assert_eq!(d["limits"]["queue"]["per_kind"].as_u64(), Some(256), "{d}");
let (_, api) = json_of(port, "GET", "/v1/openapi.json", None, &[]);
assert!(api["paths"]["/v1/write"]["post"]["responses"]["422"]["description"].as_str().unwrap().contains("J031"), "{}", api["paths"]["/v1/write"]);
// A malformed task, and options out of range, each refused with its code.
for (req, status, code) in [
(json!({ "task": { "task": "anything" } }), 400, "J071"),
(json!({ "task": { "cases": [{ "name": "n = 1", "inputs": {} }] } }), 400, "J071"),
(json!({ "task": bell_task(), "rounds": 9 }), 400, "J072"),
(json!({ "task": bell_task(), "max_new": 0 }), 400, "J072"),
(json!({ "task": bell_task(), "qasm": "OPENQASM 3.0;" }), 400, "J072"),
] {
let (c, v) = json_of(port, "POST", "/v1/write", Some(&req), &[]);
assert_eq!((c, code_of(&v)), (status, code), "{req}: {v}");
}
// A write, queued while the writer loads; sent again under its key, the
// same job, counting as no submission.
let req = json!({ "task": bell_task() });
let (c, head, body) = http(port, "POST", "/v1/write", Some(&req), &[("Idempotency-Key", "write-1")]);
let a: Value = serde_json::from_str(&body).unwrap();
assert_eq!((c, a["status"].as_str(), a["method"].as_str()), (202, Some("queued"), Some("write")), "{a}");
let id = a["id"].as_str().unwrap().to_string();
let (c, head2, body) = http(port, "POST", "/v1/write", Some(&req), &[("Idempotency-Key", "write-1")]);
assert_eq!((c, serde_json::from_str::<Value>(&body).unwrap()["id"].as_str()), (202, Some(id.as_str())), "{body}");
assert_eq!(left(&head2), left(&head), "a write sent again under its key counts as nothing");
// The same key with another request is refused.
let (c, v) = json_of(port, "POST", "/v1/write", Some(&json!({ "task": bell_task(), "rounds": 1 })), &[("Idempotency-Key", "write-1")]);
assert_eq!((c, code_of(&v)), (422, "J034"), "{v}");
// One write a client: a second, queued or running beside the first, is refused.
let (c, v) = json_of(port, "POST", "/v1/write", Some(&req), &[]);
assert_eq!((c, code_of(&v)), (429, "J030"), "{v}");
assert!(v["error"].as_str().unwrap().contains("one at a time"), "{v}");
// Another client's write waits behind it.
let (c, b) = json_of(port, "POST", "/v1/write", Some(&req), &[("X-Forwarded-For", "203.0.113.9")]);
assert_eq!((c, b["status"].as_str()), (202, Some("queued")), "{b}");
let other = b["id"].as_str().unwrap().to_string();
// The write's events, followed from while it is queued to its end.
let events = {
let path = format!("/v1/jobs/{id}/events");
std::thread::spawn(move || http(port, "GET", &path, None, &[]).2)
};
std::thread::sleep(Duration::from_millis(300));
// The writer's model loads: the script reaches it.
let script = json!([NO_CNOT, BELL]);
std::fs::write(&fifo, script.to_string()).unwrap();
let done = until(port, &id, "done");
let t = &done["result"];
// The transcript: the task, the model, the prompt, the decoding, each attempt and the pass.
assert_eq!((t["schema"].as_str(), t["passed"].as_bool(), t["passed_round"].as_u64(), t["level"].as_str()), (Some("wai.quantum.write/1"), Some(true), Some(1), Some("proof")), "{t}");
assert_eq!(canonical(&t["task"]), canonical(&bell_task()));
assert_eq!(t["task_digest"], blake3::hash(&wai_quantum::engine::receipt::canonical(&bell_task())).to_hex().to_string());
assert_eq!(t["model"], json!({ "name": "scripted", "licence": "none", "digest": wai_quantum_writer::digest(&script) }));
assert_eq!(t["decoding"], json!({ "method": "greedy", "max_new": 3000, "think_max": 2000, "rounds": 4 }));
// Each check's run was planned in one job's memory (WAI_QSVC_JOB_GB), and the transcript says so.
assert_eq!(t["check_memory_bytes"].as_u64(), Some(2 << 30), "{t}");
assert_eq!((t["prompt"]["version"].as_str(), t["engine"].as_str()), (Some(wai_quantum_writer::PROMPT_VERSION), Some(wai_quantum::engine::schema::ENGINE_VERSION)), "{t}");
let attempts = t["attempts"].as_array().unwrap();
assert_eq!(attempts.len(), 2, "{t}");
assert_eq!((attempts[0]["passed"].as_bool(), attempts[0]["check"]["what_went_wrong"][0]["kind"].as_str()), (Some(false), Some("distribution")), "{t}");
assert!(attempts[1]["passed"] == true && t["program"].as_str().unwrap().contains("cx q[0], q[1];"), "{t}");
assert_eq!(t["program_digest"], blake3::hash(t["program"].as_str().unwrap().as_bytes()).to_hex().to_string());
assert!(t["energy"]["unmetered"].is_string(), "the energy is inside the signed body: {t}");
// The timing is beside the transcript, outside the signed body.
let timing = done["timing"].as_array().unwrap();
assert!(timing.len() == 2 && timing.iter().all(|x| x["reply_seconds"].as_f64().is_some() && x["check_seconds"].as_f64().is_some()), "{done}");
assert!(t.get("timing").is_none() && done.get("progress").is_none(), "{done}");
// The job follows its schema.
let errs = wai_quantum_schemas().check("service-tools.v1.json#/$defs/write", &done);
assert!(errs.is_empty(), "{}\n{done}", errs.join("; "));
// Its receipt verifies against its request, and a changed transcript does not.
let (_, v) = json_of(port, "POST", "/v1/verify", Some(&json!({ "request": done["request"], "result": done["result"] })), &[]);
assert_eq!(v["valid"], true, "{v}");
let mut forged = done["result"].clone();
forged["passed_round"] = json!(0);
let (_, v) = json_of(port, "POST", "/v1/verify", Some(&json!({ "request": done["request"], "result": forged })), &[]);
assert_eq!(v["valid"], false, "{v}");
// Its events: its status, then each attempt as it landed, then the job.
let stream = events.join().unwrap();
let mut seen: Vec<(String, Value)> = Vec::new();
let mut name = String::new();
for line in stream.lines() {
if let Some(e) = line.strip_prefix("event:") {
name = e.trim().to_string();
} else if let Some(d) = line.strip_prefix("data:") {
seen.push((name.clone(), serde_json::from_str(d.trim()).unwrap_or_else(|e| panic!("{e}: {d}"))));
}
}
// The stream began while the write was queued; a scripted write runs in
// milliseconds, so the stream may find it running or already past it.
let statuses: Vec<&str> = seen.iter().filter(|(k, _)| k == "status").filter_map(|(_, v)| v["status"].as_str()).collect();
assert!(statuses == ["queued", "running"] || statuses == ["queued"], "{stream}");
let rest: Vec<&(String, Value)> = seen.iter().filter(|(k, _)| k != "status").collect();
assert_eq!(rest.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(), ["attempt", "attempt", "job"], "{stream}");
for (n, (_, a)) in rest[..2].iter().enumerate() {
assert_eq!((a["id"].as_str(), a["attempt"]["round"].as_u64(), a["attempt"]["passed"].as_bool()), (Some(id.as_str()), Some(n as u64), Some(n == 1)), "{a}");
assert_eq!(canonical(&a["attempt"]), canonical(&attempts[n]));
assert!(a["timing"]["reply_seconds"].is_number(), "{a}");
}
assert_eq!(rest[2].1["status"], "done");
// The other client's write ran after it, one at a time, and the same
// task gave the same transcript.
let b = until(port, &other, "done");
assert!(b["started_ms"].as_u64().unwrap() >= done["finished_ms"].as_u64().unwrap(), "two writes ran at once: {done}\n{b}");
let strip = |r: &Value| {
let mut r = r.clone();
for k in ["energy", "receipt"] {
r.as_object_mut().unwrap().remove(k);
}
r
};
assert_eq!(canonical(&strip(&b["result"])), canonical(&strip(&done["result"])));
// The service names its model now, loaded.
let (_, d) = json_of(port, "GET", "/v1", None, &[]);
assert_eq!((d["write"]["writer"].as_str(), &d["write"]["model"]), (Some("ready"), &t["model"]), "{d}");
// As an MCP task: made at once, followed to its end, its result the job,
// with the verdict of the program it wrote, held to the tool's schema.
let tools = rpc(port, "tools/list", json!({}));
let tool = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "write_circuit").cloned().unwrap();
assert_eq!(tool["execution"]["taskSupport"], "optional", "{tool}");
let made = rpc(port, "tools/call", json!({ "name": "write_circuit", "arguments": { "task": bell_task(), "rounds": 1 }, "task": { "ttl": 60000 } }));
let task = made["result"]["task"]["taskId"].as_str().unwrap_or_else(|| panic!("{made}")).to_string();
let t0 = Instant::now();
while rpc(port, "tasks/get", json!({ "taskId": task }))["result"]["status"] == "working" {
assert!(t0.elapsed() < Duration::from_secs(120), "the task never ended");
std::thread::sleep(Duration::from_millis(50));
}
let r = rpc(port, "tasks/result", json!({ "taskId": task }));
let sc = &r["result"]["structuredContent"];
assert_eq!((r["result"]["isError"].as_bool(), sc["status"].as_str(), sc["result"]["passed_round"].as_u64()), (Some(false), Some("done"), Some(1)), "{r}");
assert_eq!((sc["verdict"]["level"].as_str(), sc["verdict"]["holds"].as_bool()), (Some("proof"), Some(true)), "{r}");
assert!(sc["verdict"]["statement"].as_str().unwrap().starts_with("the program written at round 1"), "{r}");
let get = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "get_quantum_job").cloned().unwrap();
let errs = Registry::empty().check_value(&get["outputSchema"], sc);
assert!(errs.is_empty(), "{}\n{sc}", errs.join("; "));
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("passed after 1 repair(s)"), "{r}");
svc.kill().unwrap();
svc.wait().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_service_with_no_model_refuses_a_write_and_says_so() {
let dir = std::env::temp_dir().join(format!("wai-qsvc-write-none-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let port = free_port();
let mut svc = start(&dir, port, None);
let (_, d) = json_of(port, "GET", "/v1", None, &[]);
assert_eq!(d["write"]["available"], false, "{d}");
assert!(d["write"]["why"].as_str().unwrap().contains("no model"), "{d}");
let (c, v) = json_of(port, "POST", "/v1/write", Some(&json!({ "task": bell_task() })), &[]);
assert_eq!((c, code_of(&v)), (503, "J070"), "{v}");
let r = rpc(port, "tools/call", json!({ "name": "write_circuit", "arguments": { "task": bell_task() } }));
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r["result"]["structuredContent"]["error"].as_str().unwrap().ends_with("[J070]"), "{r}");
let t = rpc(port, "tools/call", json!({ "name": "write_circuit", "arguments": { "task": bell_task() }, "task": {} }));
assert_eq!((t["error"]["code"].as_i64(), t["error"]["data"]["code"].as_str()), (Some(-32602), Some("J070")), "{t}");
svc.kill().unwrap();
svc.wait().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A write cancelled while the writer starts is not sent to it once it has
/// started: the cancel stands, and the writer never runs it. The script is a
/// FIFO nothing writes at first, so the writer's start at boot runs out of
/// time and fails; the write, taken next, then waits while the writer starts
/// again, and is cancelled there.
#[test]
fn a_write_cancelled_while_the_writer_starts_is_never_written() {
let dir = std::env::temp_dir().join(format!("wai-qsvc-write-cancel-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join("script.json");
assert!(Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let port = free_port();
let mut svc = start_with(&dir, port, Some(&fifo), 8);
let (c, a) = json_of(port, "POST", "/v1/write", Some(&json!({ "task": bell_task() })), &[]);
assert_eq!(c, 202, "{a}");
let id = a["id"].as_str().unwrap().to_string();
// Taken once the first start has failed, while the writer starts again.
until(port, &id, "running");
let (_, d) = json_of(port, "GET", "/v1", None, &[]);
assert_eq!(d["write"]["writer"], "loading its model", "{d}");
let (c, v) = json_of(port, "DELETE", &format!("/v1/jobs/{id}"), None, &[]);
assert!(c == 200 && v["status"] == "cancelled", "{c} {v}");
// The writer loads.
std::fs::write(&fifo, json!([BELL]).to_string()).unwrap();
let t = Instant::now();
while json_of(port, "GET", "/v1", None, &[]).1["write"]["writer"] != "ready" {
assert!(t.elapsed() < Duration::from_secs(60), "the writer never loaded");
std::thread::sleep(Duration::from_millis(25));
}
// A write after it runs, so whatever the writer was sent before it is done.
let (c, b) = json_of(port, "POST", "/v1/write", Some(&json!({ "task": bell_task() })), &[]);
assert_eq!(c, 202, "the cancelled write still counts against its client: {b}");
let next = until(port, b["id"].as_str().unwrap(), "done");
assert_eq!(next["result"]["passed"], true, "{next}");
let (_, w) = json_of(port, "GET", &format!("/v1/jobs/{id}"), None, &[]);
assert_eq!(w["status"], "cancelled", "{w}");
assert!(w.get("peak_rss_bytes").is_none() && w.get("result").is_none(), "the writer ran the cancelled write: {w}");
svc.kill().unwrap();
svc.wait().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A write is refused for what costs nothing to read before its client's
/// submission is counted, and its programs are read only after, each once and
/// all within one budget, as work that holds up no other request: four writes at
/// once, each with eight programs that run for millions of statements, are each
/// refused in moments, the service answers meanwhile, and the refusals count
/// against their client's window.
#[test]
fn a_write_s_programs_are_read_last_within_one_budget_and_hold_up_nothing() {
let dir = std::env::temp_dir().join(format!("wai-qsvc-write-budget-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("script.json");
std::fs::write(&script, json!([BELL]).to_string()).unwrap();
let port = free_port();
let mut svc = start_env(&dir, port, Some(&script), 120, &[("WAI_QSVC_CLIENT_BURST", "3")]);
let looping = |n: u64| format!("OPENQASM 3.0;\nqubit[1] q;\nint a = 0;\nfor int i in [0:{n}] {{ a += 1; }}\n");
let body = json!({ "task": { "intent": (0..8).map(|k| json!({ "equivalent": looping(9_999_990 - k) })).collect::<Vec<_>>() } });
let sent: Vec<_> = (0..4)
.map(|_| {
let b = body.clone();
std::thread::spawn(move || {
let t = Instant::now();
let (c, v) = json_of(port, "POST", "/v1/write", Some(&b), &[]);
(c, v, t.elapsed())
})
})
.collect();
std::thread::sleep(Duration::from_millis(200));
let t = Instant::now();
assert_eq!(http(port, "GET", "/v1/health", None, &[]).0, 200);
let health = t.elapsed();
let answers: Vec<(u16, Value, Duration)> = sent.into_iter().map(|h| h.join().unwrap()).collect();
assert!(health < Duration::from_secs(2), "the service took {health:?} to answer while four writes were read");
for (c, v, took) in &answers {
assert!(*took < Duration::from_secs(10), "a write took {took:?} to be refused: {c} {v}");
}
// Three counted and refused for their programs, past their budget; the fourth past the client's window.
let mut codes: Vec<(u16, String)> = answers.iter().map(|(c, v, _)| (*c, code_of(v).to_string())).collect();
codes.sort();
assert_eq!(codes, [(400, "J071".to_string()), (400, "J071".to_string()), (400, "J071".to_string()), (429, "J030".to_string())], "{answers:?}");
assert!(answers.iter().filter(|(c, _, _)| *c == 400).all(|(_, v, _)| v["error"].as_str().unwrap().contains("budget")), "{answers:?}");
svc.kill().unwrap();
svc.wait().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// The service's schemas, as its tools' answers are held to them.
fn wai_quantum_schemas() -> Registry {
let mut r = Registry::engine();
r.add("service-tools.v1.json", include_str!("../schemas/service-tools.v1.json")).unwrap();
r
}

View file

@ -29,9 +29,12 @@ use wai_quantum::engine::{course, receipt, schema};
#[cfg(feature = "coder")]
pub mod coder;
pub mod scripted;
/// The transcript's form.
pub const SCHEMA: &str = "wai.quantum.write/1";
/// The form a task is in, when it names one.
pub const TASK_SCHEMA: &str = "wai.quantum.exercise/1";
/// The version of the prompt: the system text, how a task is put to the model, and how
/// a failed check is put back. A change to any of them is a new version.
pub const PROMPT_VERSION: &str = "wai.quantum.write.prompt/1";
@ -201,15 +204,84 @@ pub fn feedback(check: &Value) -> String {
s
}
/// What every task must give: something the program must do, for the engine to check.
const TASK_NEEDS: &str = "a task is an exercise (wai.quantum.exercise/1) with an intent, or cases that each have one";
/// Whether an intent reads as one in shape: an object, or a list of objects.
fn intent_shape(v: &Value) -> bool {
match v {
Value::Object(_) => true,
Value::Array(a) => !a.is_empty() && a.iter().all(Value::is_object),
_ => false,
}
}
/// Whether `task` reads as a task: a JSON object, naming [`TASK_SCHEMA`] if it names a
/// form, with an `intent` (one, or a list), or `cases` that each have one. A case
/// without an intent takes the task's, so a task with an intent may have cases without.
/// Then its canonical form (what its digest names, and what the model is shown and the
/// engine checks) is held to the course's form as far as a check reads it
/// ([`course::exercise_form`]): every intent's shape, every bound whole numbers, every
/// constraint and field one the course names, a job of shots, seed and noise, and at most
/// 16 cases and 8 intents in one place. So no program passes a task that asks nothing, or
/// asks something a check would read as nothing.
pub fn check_task(task: &Value) -> Result<(), String> {
check_shape(task)?;
course::exercise_form(&canonical_task(task)?)
}
fn check_shape(task: &Value) -> Result<(), String> {
let Some(o) = task.as_object() else { return Err(format!("{TASK_NEEDS}: this is not a JSON object")) };
if let Some(s) = o.get("schema").filter(|s| **s != Value::String(TASK_SCHEMA.into())) {
return Err(format!("{TASK_NEEDS}: its schema is {s}, not \"{TASK_SCHEMA}\""));
}
let intent = o.get("intent").filter(|i| !i.is_null());
if let Some(i) = intent
&& !intent_shape(i)
{
return Err(format!("{TASK_NEEDS}: its intent is neither an object nor a list of objects"));
}
match o.get("cases") {
None | Some(Value::Null) if intent.is_some() => Ok(()),
None | Some(Value::Null) => Err(format!("{TASK_NEEDS}: it has neither")),
Some(Value::Array(cases)) if !cases.is_empty() => {
for (n, c) in cases.iter().enumerate() {
let Some(c) = c.as_object() else { return Err(format!("{TASK_NEEDS}: case {} is not an object", n + 1)) };
if c.get("inputs").is_some_and(|i| !i.is_object()) {
return Err(format!("{TASK_NEEDS}: case {}'s inputs are not an object", n + 1));
}
match c.get("intent").filter(|i| !i.is_null()) {
Some(i) if !intent_shape(i) => return Err(format!("{TASK_NEEDS}: case {}'s intent is neither an object nor a list of objects", n + 1)),
None if intent.is_none() => return Err(format!("{TASK_NEEDS}: case {} has no intent, and the task has none for it to take", n + 1)),
_ => {}
}
}
// A program declares the inputs it takes, and the reader refuses one
// given a value it does not declare, or none for one it does: cases
// that give different inputs can pass no program at all.
let names = |c: &Value| c.get("inputs").and_then(Value::as_object).map(|i| i.keys().cloned().collect::<Vec<_>>()).unwrap_or_default();
let first = names(&cases[0]);
if let Some((n, other)) = cases.iter().map(names).enumerate().find(|(_, x)| *x != first) {
return Err(format!("{TASK_NEEDS}: case 1 gives the inputs {first:?} and case {} gives {other:?}, so no program can pass every case: every case gives the same inputs, or none does", n + 1));
}
Ok(())
}
Some(_) => Err(format!("{TASK_NEEDS}: its cases are not a list of one or more")),
}
}
/// Write a program for `task`: the model writes, the engine checks, and what went wrong
/// goes back, until a program passes or the rounds run out.
pub fn write(model: &mut dyn Model, task: &Value, opts: &Options) -> Result<Written, String> {
if !task.is_object() || (task.get("intent").is_none() && task.get("cases").is_none()) {
return Err("a task is an exercise (wai.quantum.exercise/1) with an intent, or cases that each have one".into());
}
write_with(model, task, opts, &mut |_, _| {})
}
/// [`write`], with each attempt handed to `each` as it lands, with the seconds its reply
/// and its check took: what a service streams while a write runs.
pub fn write_with(model: &mut dyn Model, task: &Value, opts: &Options, each: &mut dyn FnMut(&Value, (f64, f64))) -> Result<Written, String> {
check_task(task)?;
// The task as its digest names it: its canonical form, read back.
let task = &canonical_task(task)?;
course::exercise_form(task)?;
let first = prompt(task);
model.open(SYSTEM, &first)?;
let mut attempts = Vec::new();
@ -241,6 +313,9 @@ pub fn write(model: &mut dyn Model, task: &Value, opts: &Options) -> Result<Writ
"thinking_tokens": reply.thinking_tokens,
"thinking_closed": reply.thinking_closed,
}));
if let (Some(a), Some(t)) = (attempts.last(), timing.last()) {
each(a, *t);
}
if passed {
passed_round = Some(round);
break;

View file

@ -0,0 +1,77 @@
//! A model whose replies are set in advance, and which records what it is told.
//!
//! The loop's own tests write with it, and so do the job service's
//! (`WAI_QSVC_WRITER_SCRIPT`), so the loop, the service's writer process, its
//! receipts and its events are all tested without a checkpoint. A script is a
//! list of replies, each the reply's answer, or `None` (JSON null) for a reply
//! that ended inside its thinking and so gave no answer.
//!
//! Each conversation it opens plays the script from its first reply, so every
//! write gives the same replies in the same order, as a greedy model does: a
//! service that runs a write again gets the same transcript.
use crate::{Model, Reply};
use serde_json::{json, Value};
/// A scripted model.
#[derive(Clone, Debug)]
pub struct Scripted {
replies: Vec<Option<String>>,
next: usize,
/// What it was told in its last conversation, in order: the system text,
/// the first turn, then each later turn.
pub told: Vec<String>,
}
impl Scripted {
/// A model that gives `replies`, in order, in each conversation.
pub fn new(replies: &[Option<&str>]) -> Scripted {
Scripted { replies: replies.iter().map(|r| r.map(str::to_string)).collect(), next: 0, told: Vec::new() }
}
/// A script as JSON: a list whose items are each a reply's answer (a
/// string), or null for a reply that never left its thinking.
pub fn from_json(script: &Value) -> Result<Scripted, String> {
let list = script.as_array().ok_or("a script is a JSON list of replies, each a string or null")?;
let replies = list
.iter()
.enumerate()
.map(|(i, r)| match r {
Value::String(s) => Ok(Some(s.clone())),
Value::Null => Ok(None),
_ => Err(format!("reply {} of the script is neither a string nor null", i + 1)),
})
.collect::<Result<Vec<_>, String>>()?;
Ok(Scripted { replies, next: 0, told: Vec::new() })
}
/// The script, as [`Scripted::from_json`] reads it.
pub fn script(&self) -> Value {
json!(self.replies)
}
}
impl Model for Scripted {
/// Named by the digest of its script, so a transcript says which script
/// wrote it.
fn identity(&self) -> Value {
json!({ "name": "scripted", "licence": "none", "digest": crate::digest(&self.script()) })
}
fn open(&mut self, system: &str, user: &str) -> Result<(), String> {
self.next = 0;
self.told = vec![system.to_string(), user.to_string()];
Ok(())
}
fn reply(&mut self, _max_new: usize, _think_max: usize, _done: &dyn Fn(&str) -> bool) -> Result<Reply, String> {
let r = self.replies.get(self.next).ok_or_else(|| format!("the script has {} replies, and reply {} was asked for", self.replies.len(), self.next + 1))?;
self.next += 1;
Ok(Reply { answer: r.clone(), tokens: 10, thinking_tokens: 4, thinking_closed: false })
}
fn answer(&mut self, user: &str) -> Result<(), String> {
self.told.push(user.to_string());
Ok(())
}
}

View file

@ -1,31 +1,10 @@
//! The loop, with a scripted model: its replies set in advance, what it is told recorded.
//! No checkpoint is needed, so these run anywhere the engine builds.
//! The loop, with the scripted model ([`wai_quantum_writer::scripted`]): its replies set in
//! advance, what it is told recorded. No checkpoint is needed, so these run anywhere the
//! engine builds.
use serde_json::{json, Value};
use wai_quantum_writer::{block_closed, program_of, prompt, write, Model, Options, Reply, NO_ANSWER, SCHEMA};
struct Scripted {
replies: Vec<Option<&'static str>>,
told: Vec<String>,
}
impl Model for Scripted {
fn identity(&self) -> Value {
json!({ "name": "scripted", "licence": "none", "digest": "0" })
}
fn open(&mut self, system: &str, user: &str) -> Result<(), String> {
self.told = vec![system.to_string(), user.to_string()];
Ok(())
}
fn reply(&mut self, _max_new: usize, _think_max: usize, _done: &dyn Fn(&str) -> bool) -> Result<Reply, String> {
let next = self.replies.remove(0);
Ok(Reply { answer: next.map(str::to_string), tokens: 10, thinking_tokens: 4, thinking_closed: false })
}
fn answer(&mut self, user: &str) -> Result<(), String> {
self.told.push(user.to_string());
Ok(())
}
}
use wai_quantum_writer::scripted::Scripted;
use wai_quantum_writer::{block_closed, check_task, program_of, prompt, write, write_with, Model, Options, NO_ANSWER, SCHEMA};
const BELL: &str = "```qasm\nOPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;\n```";
const NO_CNOT: &str = "```qasm\nOPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\nc = measure q;\n```";
@ -42,7 +21,7 @@ fn bell_task() -> Value {
#[test]
fn a_program_that_passes_at_once_ends_the_loop() {
let mut m = Scripted { replies: vec![Some(BELL)], told: vec![] };
let mut m = Scripted::new(&[Some(BELL)]);
let w = write(&mut m, &bell_task(), &Options::default()).unwrap();
let t = &w.transcript;
assert_eq!(t["schema"], SCHEMA);
@ -55,7 +34,7 @@ fn a_program_that_passes_at_once_ends_the_loop() {
#[test]
fn what_went_wrong_goes_back_in_the_engines_words_and_the_repair_passes() {
let mut m = Scripted { replies: vec![Some(NO_CNOT), Some(BELL)], told: vec![] };
let mut m = Scripted::new(&[Some(NO_CNOT), Some(BELL)]);
let w = write(&mut m, &bell_task(), &Options::default()).unwrap();
let t = &w.transcript;
assert_eq!(t["passed_round"].as_u64(), Some(1), "{t:#}");
@ -69,7 +48,7 @@ fn what_went_wrong_goes_back_in_the_engines_words_and_the_repair_passes() {
#[test]
fn a_reply_that_never_leaves_its_thinking_is_no_answer_and_is_asked_again() {
let mut m = Scripted { replies: vec![None, Some(BELL)], told: vec![] };
let mut m = Scripted::new(&[None, Some(BELL)]);
let w = write(&mut m, &bell_task(), &Options::default()).unwrap();
let t = &w.transcript;
assert_eq!((t["attempts"][0]["answered"].as_bool(), t["attempts"][0]["check"].is_null()), (Some(false), true), "{t:#}");
@ -79,7 +58,7 @@ fn a_reply_that_never_leaves_its_thinking_is_no_answer_and_is_asked_again() {
#[test]
fn the_rounds_run_out_without_a_pass() {
let mut m = Scripted { replies: vec![Some(NO_CNOT); 3], told: vec![] };
let mut m = Scripted::new(&[Some(NO_CNOT); 3]);
let w = write(&mut m, &bell_task(), &Options { rounds: 2, ..Options::default() }).unwrap();
let t = &w.transcript;
assert_eq!((t["passed"].as_bool(), t["passed_round"].is_null(), t["level"].is_null()), (Some(false), true, true), "{t:#}");
@ -89,16 +68,20 @@ fn the_rounds_run_out_without_a_pass() {
#[test]
fn the_same_model_task_and_options_give_the_same_transcript_bytes() {
let run = || {
let mut m = Scripted { replies: vec![Some(NO_CNOT), Some(BELL)], told: vec![] };
let mut m = Scripted::new(&[Some(NO_CNOT), Some(BELL)]);
serde_json::to_vec(&write(&mut m, &bell_task(), &Options::default()).unwrap().transcript).unwrap()
};
assert_eq!(run(), run());
}
/// A task that asks nothing is refused before the model is asked: the model here would
/// answer with a program that passes anything, so only the task's check can refuse it.
#[test]
fn a_task_needs_something_the_program_must_do() {
let mut m = Scripted { replies: vec![], told: vec![] };
assert!(write(&mut m, &json!({ "task": "anything" }), &Options::default()).is_err());
let mut m = Scripted::new(&[Some(BELL)]);
let r = write(&mut m, &json!({ "task": "anything" }), &Options::default());
assert!(r.is_err(), "a task that asks nothing was written for: {:#}", r.unwrap().transcript);
assert!(m.told.is_empty(), "the model was asked");
}
#[test]
@ -109,13 +92,85 @@ fn the_program_is_the_answers_code_block() {
assert!(prompt(&bell_task()).starts_with("Task: A Bell pair"));
}
#[test]
fn each_attempt_is_handed_on_as_it_lands() {
let mut m = Scripted::new(&[Some(NO_CNOT), Some(BELL)]);
let mut seen: Vec<(u64, bool)> = Vec::new();
let w = write_with(&mut m, &bell_task(), &Options::default(), &mut |a, (reply, check)| {
assert!(reply >= 0.0 && check >= 0.0);
seen.push((a["round"].as_u64().unwrap(), a["passed"].as_bool().unwrap()));
})
.unwrap();
assert_eq!(seen, [(0, false), (1, true)]);
// What was handed on is what the transcript holds, and write gives the same bytes.
let again = write(&mut Scripted::new(&[Some(NO_CNOT), Some(BELL)]), &bell_task(), &Options::default()).unwrap();
assert_eq!(serde_json::to_vec(&w.transcript).unwrap(), serde_json::to_vec(&again.transcript).unwrap());
}
/// A task must give the engine something to check: an intent, or cases that each have
/// one (a case without one takes the task's).
#[test]
fn a_task_reads_only_with_something_to_check() {
let case = |intent: Option<Value>| {
let mut c = json!({ "name": "n = 2", "inputs": { "n": 2 } });
if let Some(i) = intent {
c["intent"] = i;
}
c
};
let dist = json!({ "distribution": { "0": 1.0 } });
assert!(check_task(&bell_task()).is_ok());
assert!(check_task(&json!({ "cases": [case(Some(dist.clone()))] })).is_ok());
assert!(check_task(&json!({ "intent": dist, "cases": [case(None)] })).is_ok());
assert!(check_task(&json!({ "intent": [dist, dist] })).is_ok());
for (task, why) in [
(json!("a Bell pair"), "not a JSON object"),
(json!({ "task": "anything" }), "neither"),
(json!({ "cases": [case(Some(dist.clone())), case(None)] }), "case 2 has no intent"),
(json!({ "cases": [] }), "not a list of one or more"),
(json!({ "intent": "00 and 11" }), "neither an object nor a list"),
(json!({ "intent": [] }), "neither an object nor a list"),
(json!({ "schema": "wai.quantum.job/1", "intent": dist }), "its schema"),
(json!({ "cases": [{ "inputs": 3, "intent": dist }] }), "inputs are not an object"),
(json!({ "intent": dist, "cases": [{ "name": "a", "inputs": { "err": 0 } }, { "name": "b" }] }), "no program can pass every case"),
(json!({ "intent": dist, "cases": [{ "inputs": { "err": 0 } }, { "inputs": { "n": 0 } }] }), "no program can pass every case"),
] {
let e = check_task(&task).unwrap_err();
assert!(e.contains(why), "{task}: {e}");
}
// write refuses what check_task refuses, before the model is asked anything.
let mut m = Scripted::new(&[Some(BELL)]);
assert!(write(&mut m, &json!({ "cases": [case(None)] }), &Options::default()).is_err());
assert!(m.told.is_empty());
}
/// A script reads from JSON, names itself by its digest, plays from its first reply in
/// each conversation, and says so when it runs out.
#[test]
fn a_script_plays_from_its_start_in_each_conversation() {
let m = Scripted::from_json(&json!([null, BELL])).unwrap();
assert_eq!(m.script(), json!([null, BELL]));
assert_eq!(m.identity()["digest"], wai_quantum_writer::digest(&json!([null, BELL])));
assert_ne!(m.identity()["digest"], Scripted::new(&[Some(BELL)]).identity()["digest"]);
assert!(Scripted::from_json(&json!([1])).unwrap_err().contains("reply 1"));
assert!(Scripted::from_json(&json!({ "replies": [] })).is_err());
let mut m = m;
let first = write(&mut m, &bell_task(), &Options::default()).unwrap();
let second = write(&mut m, &bell_task(), &Options::default()).unwrap();
assert_eq!(first.transcript, second.transcript);
assert_eq!(first.transcript["passed_round"], 1);
let mut short = Scripted::new(&[Some(NO_CNOT)]);
let e = write(&mut short, &bell_task(), &Options::default()).unwrap_err();
assert!(e.contains("the script has 1 replies"), "{e}");
}
/// A task is read in its canonical form, the form its digest names: the same task with its
/// keys in another order, or a whole number written as a float, is the same prompt, the same
/// check and the same transcript, and a bound written `0.0` is the bound 0.
#[test]
fn one_task_digest_names_one_prompt_and_one_check() {
let run = |task: Value| {
let mut m = Scripted { replies: vec![Some(BELL)], told: vec![] };
let mut m = Scripted::new(&[Some(BELL)]);
let w = write(&mut m, &task, &Options { rounds: 0, ..Options::default() }).unwrap();
(serde_json::to_vec(&w.transcript).unwrap(), m.told[1].clone(), w.transcript)
};
@ -146,7 +201,7 @@ fn a_malformed_task_is_refused_before_the_model_is_asked() {
for (k, v) in extra.as_object().unwrap() {
task[k] = v.clone();
}
let mut m = Scripted { replies: vec![Some(BELL)], told: vec![] };
let mut m = Scripted::new(&[Some(BELL)]);
let r = write(&mut m, &task, &Options::default());
assert!(r.is_err(), "{task} was written for: {:#}", r.unwrap().transcript);
assert!(m.told.is_empty(), "{task}: the model was asked");
@ -158,7 +213,7 @@ fn a_malformed_task_is_refused_before_the_model_is_asked() {
/// or not.
#[test]
fn the_transcript_records_the_check_s_memory_and_the_thinking_cap() {
let mut m = Scripted { replies: vec![Some(BELL)], told: vec![] };
let mut m = Scripted::new(&[Some(BELL)]);
let opts = Options { job_bytes: 512 << 20, max_new: 900, ..Options::default() };
let t = write(&mut m, &bell_task(), &opts).unwrap().transcript;
assert_eq!(t["check_memory_bytes"].as_u64(), Some(512 << 20), "{t:#}");
@ -171,7 +226,7 @@ fn the_transcript_records_the_check_s_memory_and_the_thinking_cap() {
/// asked to check an empty one.
#[test]
fn an_empty_answer_is_no_program() {
let mut m = Scripted { replies: vec![Some(" \n "), Some(BELL)], told: vec![] };
let mut m = Scripted::new(&[Some(" \n "), Some(BELL)]);
let t = write(&mut m, &bell_task(), &Options::default()).unwrap().transcript;
assert_eq!((t["attempts"][0]["answered"].as_bool(), t["attempts"][0]["check"].is_null()), (Some(false), true), "{t:#}");
assert_eq!(m.told[2], NO_ANSWER);

View file

@ -3,7 +3,8 @@
The reader gives every refusal and warning about a program's text a code
(`Q…`, [qasm-diagnostics.md](qasm-diagnostics.md)). What is asked of a
program has codes of its own (`J…`): the request, the method, an intent, a
repair, and the host that runs the job. A refusal reads, on every surface:
repair, the host that runs the job, and a program the job service writes for a
task (`J070` to `J072`). A refusal reads, on every surface:
```text
stabilizer needs a Clifford circuit: … [J011]
@ -85,6 +86,9 @@ browser engine. A test holds this page to the code's table.
| `J063` | refused | A component the registry does not take: a name its kind holds already (a built-in's among them), a name or version that is empty or not plain text, a descriptor that is not a JSON object, a channel on other than one or two qubits, or a pass whose check states no bound. |
| `J064` | refused | A registered component's answer that breaks the engine's contract: a method's result without an accuracy class it declared, or one that writes a member the engine writes (method, qubits, gates, compiled, repaired, engine, schema, components, receipt, verdict); a pass's gates on qubits the program does not have, or that do not read back; a front end's program that does not read. Nothing it gave is returned. |
| `J065` | refused | An intent on a job whose noise names a registered channel: the intent checks model the built-in channels alone, so the check is refused rather than made against another model. |
| `J070` | refused | A write sent to a host with no model to write with: `GET /v1` says under `write` whether a host has one; write the program where the model is, with `wai-quantum-writer write --model DIR TASK.json`. |
| `J071` | refused | A write's task that does not read: a task is an exercise in the course's form (`wai.quantum.exercise/1`), a JSON object with an `intent`, or `cases` that each have one (a case without one takes the task's), so the engine has something to check each program against. |
| `J072` | refused | A write's options that do not read or are out of range: `rounds` a whole number from 0 to 4, `max_new` one from 1 to 4000, and nothing beside `task`, `rounds` and `max_new`. |
| `J100` | dropped | A line of an answer that is not a statement (`name = View(...)`), a program (a fenced `qasm` block with its name), a comment or a marker: left out. |
| `J101` | dropped | A view the answer layer does not have: left out (the views are listed in the layer's specification). |
| `J102` | dropped | An `@` call the engine does not have: the statement is left out. |

View file

@ -37,7 +37,8 @@
"const": "wai.quantum.course-check/1"
},
"lesson": {
"type": "string"
"type": ["string", "null"],
"description": "The lesson's id; null for an exercise that is no lesson's, checked by engine::course::check_exercise (a task the job service writes a program for)."
},
"locale": {
"type": "string"

View file

@ -4,7 +4,8 @@
//!
//! The reader owns the codes of a program's text (`Q…`); this table owns the
//! codes of what is asked of it (`J…`): the request, the method, the intent,
//! a repair, the host that runs it, and an answer's view statements (the
//! a repair, the host that runs it, a program the job service writes for a
//! task, and an answer's view statements (the
//! answer view layer, [`super::answer`]), whose codes say what becomes of the
//! statement: dropped, defaulted or warned of. A refusal's text ends with its code,
//! as the reader's does:
@ -118,6 +119,11 @@ table! {
CONTRACT = "J064", Refused, "A registered component's answer that breaks the engine's contract: a method's result without an accuracy class it declared, or one that writes a member the engine writes (method, qubits, gates, compiled, repaired, engine, schema, components, receipt, verdict); a pass's gates on qubits the program does not have, or that do not read back; a front end's program that does not read. Nothing it gave is returned.";
REGISTERED_INTENT = "J065", Refused, "An intent on a job whose noise names a registered channel: the intent checks model the built-in channels alone, so the check is refused rather than made against another model.";
// A program written for a task (the job service's write).
WRITE_NO_MODEL = "J070", Refused, "A write sent to a host with no model to write with: `GET /v1` says under `write` whether a host has one; write the program where the model is, with `wai-quantum-writer write --model DIR TASK.json`.";
WRITE_TASK = "J071", Refused, "A write's task that does not read: a task is an exercise in the course's form (`wai.quantum.exercise/1`), a JSON object with an `intent`, or `cases` that each have one (a case without one takes the task's), so the engine has something to check each program against.";
WRITE_OPTIONS = "J072", Refused, "A write's options that do not read or are out of range: `rounds` a whole number from 0 to 4, `max_new` one from 1 to 4000, and nothing beside `task`, `rounds` and `max_new`.";
// An answer's view statements (the answer view layer).
ANSWER_PARSE = "J100", Dropped, "A line of an answer that is not a statement (`name = View(...)`), a program (a fenced `qasm` block with its name), a comment or a marker: left out.";
ANSWER_VIEW = "J101", Dropped, "A view the answer layer does not have: left out (the views are listed in the layer's specification).";

View file

@ -368,10 +368,70 @@ pub fn read(src: &str) -> Result<Reading, Diagnostic> {
/// [`read`] for a generator, at `inputs`.
pub fn read_with(src: &str, inputs: &Inputs) -> Result<Reading, Diagnostic> {
let toks = lex::lex(src)?;
let (stmts, version) = parse::parse(src, &toks)?;
let (program, lines, diagnostics) = elab::elaborate(src, &toks, &stmts, version, inputs)?;
Ok(Reading { program, lines, diagnostics })
// Within a budget of reading, a program read before (with no inputs) is not read again.
if inputs.is_empty()
&& let Some(seen) = READS.with_borrow(|r| r.as_ref().and_then(|r| r.read.get(src).cloned()))
{
return seen;
}
let reading = (|| {
let toks = lex::lex(src)?;
let (stmts, version) = parse::parse(src, &toks)?;
let (program, lines, diagnostics) = elab::elaborate(src, &toks, &stmts, version, inputs)?;
Ok(Reading { program, lines, diagnostics })
})();
if inputs.is_empty() {
READS.with_borrow_mut(|r| {
if let Some(r) = r {
r.read.insert(src.to_string(), reading.clone());
}
});
}
reading
}
/// A budget of reading on one thread ([`within_steps`]): the statements left
/// to elaborate, and each program read under it.
struct Reads {
left: u64,
read: std::collections::HashMap<String, Result<Reading, Diagnostic>>,
}
thread_local! {
static READS: std::cell::RefCell<Option<Reads>> = const { std::cell::RefCell::new(None) };
}
/// One statement elaborated, spent from this thread's budget of reading when
/// one is set: whether the budget was already spent.
pub(crate) fn spend_step() -> bool {
READS.with_borrow_mut(|r| match r {
Some(r) if r.left == 0 => true,
Some(r) => {
r.left -= 1;
false
}
None => false,
})
}
/// Runs `f` with every program it reads on this thread read once (a program
/// read again with no inputs is answered from its first reading) and every
/// reading within one budget of `steps` statements together, each statement
/// the reader elaborates spending one; past it, a reading is refused
/// ([`diag::UNROLL_LIMIT`]). What a service holds the programs of one request
/// to, so its reading is bounded however many programs the request carries. A
/// budget set inside another runs within the outer one.
pub fn within_steps<T>(steps: u64, f: impl FnOnce() -> T) -> T {
struct Scope(bool);
impl Drop for Scope {
fn drop(&mut self) {
if self.0 {
READS.with_borrow_mut(|r| *r = None);
}
}
}
let _scope = Scope(READS.with_borrow_mut(|r| r.is_none().then(|| *r = Some(Reads { left: steps, read: Default::default() })).is_some()));
f()
}
/// The integer `input`s a program declares (`input uint n;`, `input int k;`),

View file

@ -336,6 +336,9 @@ impl<'a> Elab<'a> {
if self.count > MAX_STEPS {
return self.fail(diag::UNROLL_LIMIT, at, format!("the program unrolls past {MAX_STEPS} statements"));
}
if super::spend_step() {
return self.fail(diag::UNROLL_LIMIT, at, "the programs read for one request elaborate past its budget of statements together (wai_quantum::qasm::within_steps)");
}
Ok(())
}