diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 5b2260d8b..44e58e8f2 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -152,8 +152,10 @@ jobs: # The job service's engine in a page (src/quantum_engine.rs, feature # quantum_engine, which only the IDE's bundle carries): a job planned, run and # signed in wasm32 under Node, its receipt checked there and a changed result - # refused, and every method a tab holds run with no clock and no threads. The - # count is the four tests above and its two. + # refused, every method a tab holds run with no clock and no threads, a + # program's critique, equivalence check and variants, and its classical + # shortcuts with a contraction search. The count is the four tests above and + # its four. - name: The job engine in a page (wasm32 tests under Node) shell: bash working-directory: wai/wai-web @@ -161,16 +163,17 @@ jobs: set -euo pipefail CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \ cargo test --locked --target wasm32-unknown-unknown --features quantum_engine 2>&1 | tee "$RUNNER_TEMP/wai-web-engine-wasm.log" - grep -q "test result: ok. 6 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine-wasm.log" + grep -q "test result: ok. 8 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine-wasm.log" # Its native half: the service's form and receipt, any angle on the state - # vector, what does not run saying why, and plan, lint, draw and methods as JSON. + # vector, what does not run saying why, plan, lint, draw and methods as JSON, + # critique, equivalence and variants as JSON, and the classical shortcuts. - name: The job engine in a page (native unit tests) shell: bash working-directory: wai/wai-web run: | set -euo pipefail cargo test --locked --lib --features quantum_engine quantum_engine:: 2>&1 | tee "$RUNNER_TEMP/wai-web-engine.log" - grep -q "test result: ok. 4 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine.log" + grep -q "test result: ok. 6 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine.log" # The crate's native unit tests, run on the host: the pure-Rust halves of the # bindings, which wasm_tests does not run. They decode the committed integer # payloads to their sidecars' SHA-256 (int_payload::tests) and check that the diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index a0b72e830..8c95eb5b3 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -538,6 +538,95 @@ wire format may change and such changes are recorded here. - **`tools/ops-gpu-bench`** times each capability's decode with its graphs on the CPU and on each adapter, and reads the energy counters the host has. - **CI:** `wai-ops-gpu.yml` runs the Metal and browser legs on the `macos` runner, and requires on a runner with no GPU that the device tests skip without `WAI_OPS_GPU_REQUIRE` and fail with it. +### wai-quantum 0.6.14 and wai-quantum-cli 0.1.2: contraction trees that sweep along the line, and the design checks on the job service + +0.6.13 was not published on its own: this release carries it to the package registry, and `wai-quantum-cli` 0.1.2 requires it. + +- **Sweep seeds for the contraction search** (`quantum_tn`): + - `Network::amplitude_split_placed` builds the same network as `amplitude_split`, index for index, and gives each index its place in the circuit: a qubit and a time. + - `tensor_places` gives each tensor its place. + - `sweep_orders` orders the tensors qubit by qubit along time, or time by time across the qubits. + - `sweep_path` absorbs them in that order. + - `find_path_seeded` and `find_path_restarts_seeded` score such trees beside the trials, and polish the best of them all alike. A seed that is not a tree of the network is ignored. + + A deep circuit on a line, swept qubit by qubit, keeps only a time slice of the bonds between two neighbouring qubits as its boundary: about half its depth for a controlled-Z brickwork. The bipartition and greedy families rarely find that tree on their own. The engine's `tensor_network` method and the thorough classical-shortcut lens now take both sweeps as seeds. +- **On the 70-qubit random graph-state circuit** (CZ-depth 70), for one amplitude: + + | | largest intermediate | multiply-adds | + |---|---|---| + | the search alone, 16 trials | 2^59 | 10^23.5 | + | the search with the sweeps as seeds, 16 trials | 2^36 | 10^14.6 | + | the thorough lens, before | 2^48 | 2^56.1 | + | the thorough lens, with the sweeps | 2^36 | 2^48.7 | + + The thorough check took 182 s, down from 238 s. The plans pinned in `tests/bit_identity.rs` call `find_path` itself and do not change. +- **The design checks on the job service:** + - `POST /v1/critique`; + - `POST /v1/equivalent`; + - `POST /v1/improve`; + - `POST /v1/shortcuts`; + - the matching MCP tools: `critique_qasm`, `check_equivalent`, `improve_qasm`, `classical_shortcuts`. + + They answer at once, in the service's own process, within bounds: + - each counts against the client's submissions, as a job does; + - at most two run at a time, and one that waits ten seconds for a turn is refused (503); + - some checks are refused (422) with the command that runs them locally: an equivalence check past 4·10⁹ gate amplitudes, an improvement whose checks would pass that, and a thorough search for shortcuts. + + The service's smoke checks cover each one. + +### wai-quantum 0.6.13 and wai-quantum-cli 0.1.1: the engine critiques a design, checks two circuits are the same, offers alternatives, and prices its classical shortcuts + +The engine is the microscope and the judge, a local model is a muse that waits to be asked, and the reader is the author. Every figure below is computed by the engine. Nothing changes the reader's program until they apply a change. + +- **`engine::critique`** gives a program's measures and its smells before it runs. + - The measures: gates by kind; one-, two- and three-qubit gates; T gates; gates off the Clifford angles; depth and two-qubit depth; and the measured qubits. + - It says whether the program is a Clifford circuit. + - It gives a fidelity at reference error rates (`REFERENCE_RATES`), named as a model of a device and not a run. + - The smells, each with its lines and its operations: gates that cancel; rotations that merge; rotations that do nothing; qubits nothing uses; and qubits acted on but never measured. + - In a program that measures in the middle, a measurement, a reset or a condition is a barrier, and no smell spans one. +- **`critique::variants`** rewrites the program in its own text with its smells fixed. + - It offers every fix, to a fixed point, since one fix can bring two gates together. It also offers each first-round fix alone, and each distinct program once. + - A fix touches only a statement alone on its line. A merge keeps the program's own expressions, as `rz(pi/4 + pi/8)`, and its comment. + - Each candidate carries its measures and the equivalence check's verdict against the program. A candidate the check finds different is never offered. +- **`engine::equiv`** says whether two programs are the same circuit up to a global phase, and how it knows: + - Clifford circuits by their tableaus, at any number of qubits; + - otherwise on every basis state, up to 8 qubits and within a work budget; + - otherwise on three random product states, up to 22 qubits, which is evidence and not proof; + - otherwise unchecked, with the reason. + + A difference names the input it was found on. Programs that measure in the middle are not compared. Programs of different widths, or that write different qubits to the bits, differ by shape. +- **`qasm::parse_lines`** gives each operation's source line: a broadcast's gates and a definition's expansion share the call's line. **`draw::layout`** gives each gate the index of its operation (`op`), so a diagram can mark the operations a smell names. +- **In the browser:** `wai-web` exports `engine_critique`, `engine_equivalent` and `engine_variants`, tested native and under wasm. +- **Quantum Studio, in the browser and on the desktop:** + - **The critique, always on.** Under the diagram it shows the measures, each with its change since the last run, a Clifford badge and the modelled fidelity. The smells are listed with their lines. Their gates are flagged on the diagram, and each gate's tooltip names its smell. Show selects the lines and marks the gates. Fix applies the engine's rewrite, and the note says how the result was checked the same circuit; Undo brings the lines back. + - **Alternatives.** Improve offers the same circuit, cheaper: the engine's rewrites, and the model's when one is connected, each checked against the draft. A rewrite the check finds different is listed as not offered, with the reason. Explore asks the model for different circuits toward an aim the reader names. The engine reads, measures and compares each one, and gives how many lines it changes. An atlas plots the draft and each alternative by two-qubit gates and depth. Apply puts one in the editor. + - **Predict first** (off by default). Before a run, the reader names the outcome they expect most often and its share of the shots. The run's counts are set against it, and a tally of how close their predictions come is kept on this device. + - **The assistant asks questions first, and speaks only when asked.** The reader chooses how it helps: it asks questions first (the default), suggests changes, or writes programs. They also choose when it speaks: only when asked (the default), or with a hint offered, never sent, after a problem the engine has reported for a while or two refused runs in a row. New quick asks are "Ask me a question" and "Hint". New tools are `critique_qasm` and `check_equivalent`. For Alternatives it is called once per request, without tools and outside the conversation, and only proposes. + - The desktop app answers `critique`, `equivalent` and `variants` natively. +- **`wai-quantum-cli` 0.1.1:** + - `wai-quantum critique` gives the measures and smells. + - `wai-quantum equivalent a.qasm b.qasm` exits 0 for the same circuit, 1 for different and 2 for unchecked. + - `wai-quantum improve` prints the improved program, and what changed and how it was checked on standard error. + - `wai-quantum shortcuts prog.qasm [--thorough] [--json]` prices each exact classical route. + - The MCP server adds `critique_qasm`, `check_equivalent`, `improve_qasm` and `classical_shortcuts`. +- **`engine::shortcuts`** lists every exact classical route the engine knows for simulating a program, with its cost on that program: + - **the stabilizer tableau**, for Clifford circuits, at any width; + - **the cut-rank sum**, for a Clifford circuit followed by one layer of one-qubit gates. Its cost is a table of 2^cut entries along the best order found, and includes running the bulk's tableau and reducing it to graph form; + - **an exact matrix product state** along the qubits' own order. Its bond is bounded across each cut by the product of the operator Schmidt ranks of the gates that cross it, each computed from the gate's matrix, and never by more than the smaller side's dimension. The bound is rigorous: a test holds it against the true Schmidt rank at every cut of random circuits of every two- and three-qubit gate; + - **the light cone** of a one-qubit expectation value; + - **a tensor-network contraction** of one amplitude of a seeded random output, by the engine's tree search (when asked to be thorough); + - **the state vector**. + + Each route gives its memory, the log2 of its operations to a first answer, and a time at a reference rate of 10¹⁰ operations a second, which is a reference and not a measurement. The cheapest route is named for counts, amplitudes, expectation values and one-qubit expectation values. A summary leads with "classically easy at any width" for a Clifford circuit. + + A cheap route proves a design classically easy. None being cheap proves nothing about hardness: other methods, approximate ones above all, may cost far less, and the result says so. +- **On the 70-qubit random graph-state circuit** (CZ-depth 70, a closing layer of face-basis rotations), the quick check finds the cut-rank sum in under 2 s on one workstation. Its widest cut is 30 bits along a quick order search, at about 2^35 operations a sample. The matrix product state's bound is 2^35 at the middle cut, and the light cone of every qubit is all 70. +- **The lens in the browser and on the desktop:** `wai-web` exports `engine_shortcuts`, tested native and in wasm. The desktop app answers `shortcuts` off the window's thread. +- **The lens in Quantum Studio:** + - Under the critique, "Classical cost" prices each route and names the cheapest. "Thorough" adds the contraction search. + - A check is marked stale once the program changes. + - The assistant gains the tool `classical_shortcuts`. + ### wai-quantum 0.6.12: cheaper contraction trees, and the cut-rank table on the GPU 0.6.11 was not published on its own: this release carries it to the package registry, and `wai-quantum-cli` 0.1.0 requires it, since the command line's `metal` feature turns on `quantum_cutrank_metal`. diff --git a/wai/quantum-studio/src/assistant.js b/wai/quantum-studio/src/assistant.js index 2e2129bd8..9b34dfc1a 100644 --- a/wai/quantum-studio/src/assistant.js +++ b/wai/quantum-studio/src/assistant.js @@ -3,16 +3,24 @@ // tool calls included: the JouleClaw runtime, a llama.cpp server, others. By default // it is local, and nothing leaves the machine. // -// Tools first: the model asks the engine (lint, draw, plan, run) and may state -// only numbers the engine returned. Every program it proposes is checked by the +// Tools first: the model asks the engine (lint, critique, draw, plan, run, the +// equivalence check) and may state only numbers the engine returned. Every program it proposes is checked by the // engine before the Studio offers it: read as the engine reads it, and run on // this device. A proposal that does not parse goes back to the model once, with // the engine's reason, to be corrected. +// +// The reader is the author. The assistant speaks only when asked, and by default +// it asks a question before it proposes (the stance, which the reader sets). For +// Alternatives it is a source of candidates, never the judge: each one it writes +// is read, measured and compared by the engine before the Studio shows it. const SYSTEM = `You are the assistant in Quantum Studio, an editor for OpenQASM programs. Tools run the Studio's own engine: - get_program: the program in the editor, the job's settings and the last result. - lint_qasm: read a program as the engine reads it (its qubits and gates, or the first problem and its line). +- critique_qasm: the program's measures (gates, two-qubit gates, depth, T gates, whether it is Clifford, a modelled fidelity) and its smells, each with its lines. +- check_equivalent: whether two programs are the same circuit, and how the engine knows. +- classical_shortcuts: every exact classical route to simulating a program and its cost; a cheap route proves it classically easy, none being cheap proves nothing. - draw_circuit: the circuit as text. - plan_job: the method that would run a job, why, and the memory it needs. - run_job: run a job on this device; counts, expectation values, amplitudes and the accuracy of each. @@ -23,9 +31,20 @@ For a state, a probability or an amplitude, run the program and quote the result Write plain text: no Markdown headings, no LaTeX. Write states as |00⟩ and roots as √2. Qubit 0 is the rightmost bit of a basis state. Answer briefly.`; +// How the assistant helps, which the reader chooses. Asking first is the default: +// a question the reader answers teaches more than an answer they read. +export const STANCES = { + questions: { label: 'Ask me questions first', text: 'The user is the author and is learning. Before you propose a program or a change, ask one short question that helps them see it themselves: what they expect a line to do, or what a result shows. Answer a direct question from the tools, then ask one question back that moves them forward. Write a whole program only when they ask you to.' }, + suggest: { label: 'Suggest changes', text: 'The user is the author. Suggest changes as small edits to their program, each naming its line and the reason the tools gave; write a whole program only when they ask for one.' }, + write: { label: 'Write programs', text: 'When a change is wanted, write the whole program as described above.' }, +}; + export const TOOLS = [ { type: 'function', function: { name: 'get_program', description: 'The program in the editor, the job settings, and a summary of the last result.', parameters: { type: 'object', properties: {} } } }, { type: 'function', function: { name: 'lint_qasm', description: 'Read an OpenQASM program as the engine reads it: its qubits, gates and measurements, or the first problem with its line.', parameters: { type: 'object', required: ['qasm'], properties: { qasm: { type: 'string' } } } } }, + { type: 'function', function: { name: 'critique_qasm', description: 'A program\'s measures (gates by kind, two-qubit gates, depth, two-qubit depth, T count, whether it is a Clifford circuit, a fidelity under reference error rates, which is a model and not a run) and its smells: gates that cancel, rotations that merge or do nothing, idle or unmeasured qubits, each with its lines.', parameters: { type: 'object', required: ['qasm'], properties: { qasm: { type: 'string' } } } } }, + { type: 'function', function: { name: 'check_equivalent', description: 'Whether two programs are the same circuit up to a global phase, and how the engine knows: equal tableaus (Clifford, any size), every basis state (to 8 qubits), random inputs (to 22 qubits, evidence rather than proof), or unchecked. b defaults to the program in the editor.', parameters: { type: 'object', required: ['a'], properties: { a: { type: 'string' }, b: { type: 'string' } } } } }, + { type: 'function', function: { name: 'classical_shortcuts', description: 'Every exact classical route the engine knows for simulating a program (stabilizer tableau, cut-rank sum, exact matrix product state at a rigorous bond bound, light cone, tensor network when thorough, state vector), each with its memory and the log2 of its operations, and the cheapest for each question. A cheap route proves the program classically easy; none being cheap proves nothing about hardness.', parameters: { type: 'object', properties: { qasm: { type: 'string' }, thorough: { type: 'boolean' } } } } }, { type: 'function', function: { name: 'draw_circuit', description: 'A program\'s circuit drawn as text.', parameters: { type: 'object', required: ['qasm'], properties: { qasm: { type: 'string' } } } } }, { type: 'function', @@ -102,14 +121,29 @@ export class DesktopModel { } export class Assistant { - // engine: the Studio's local engine; context(): {qasm, job, last} as the editor holds them. - constructor({ engine, model, context }) { + // engine: the Studio's local engine; context(): {qasm, job, last} as the editor holds them; + // stance(): a key of STANCES. + constructor({ engine, model, context, stance = () => 'questions' }) { this.engine = engine; this.model = model; this.context = context; + this.stance = stance; this.history = []; } + // Candidates for Alternatives, from one call without tools and outside the + // conversation: [{idea, qasm}]. The engine checks each; nothing here does. + async muse(prompt, temperature) { + const res = await this.model.chat({ model: this.model.name || undefined, temperature, messages: [{ role: 'system', content: MUSE }, { role: 'user', content: prompt }] }); + return ideas(unlatex((res.choices?.[0]?.message?.content || '').replace(/[\s\S]*?<\/think>\s*/g, ''))); + } + improve(qasm, k = 3) { + return this.muse(`Write ${k} programs that are the same circuit as this one (the same unitary up to a global phase, and the same measurements) but cheaper: fewer two-qubit gates, less depth or fewer T gates. Before each, one line that starts "Idea:" and says what it changes. Each program whole, in its own \`\`\`qasm block.\n\n\`\`\`qasm\n${qasm}\n\`\`\``, 0.4); + } + explore(qasm, aim, k = 4) { + return this.muse(`Imagine ${k} different programs in the spirit of this one${aim ? `, toward this aim: ${aim}` : ''}. Make them differ from each other in kind, not only in their angles: another structure, another entangler, another symmetry, another use of the qubits. Before each, one line that starts "Idea:" and says what it tries. Each program whole, in its own \`\`\`qasm block.\n\n\`\`\`qasm\n${qasm}\n\`\`\``, 0.9); + } + async tool(name, args) { const ctx = this.context(); const job = (a) => { @@ -121,6 +155,9 @@ export class Assistant { switch (name) { case 'get_program': return { qasm: ctx.qasm, job: ctx.job, last_result: ctx.last ? summary(ctx.last) : null }; case 'lint_qasm': return this.engine.lint(args.qasm ?? ctx.qasm); + case 'critique_qasm': return this.engine.critique(args.qasm ?? ctx.qasm); + case 'check_equivalent': return this.engine.equivalent(args.a ?? ctx.qasm, args.b ?? ctx.qasm); + case 'classical_shortcuts': return this.engine.shortcuts(args.qasm ?? ctx.qasm, !!args.thorough); case 'draw_circuit': return { text: (await this.engine.draw(args.qasm ?? ctx.qasm, 80)).text }; case 'plan_job': return this.engine.plan(job({ ...args, qasm: args.qasm ?? ctx.qasm })); case 'run_job': return summary(await this.engine.run(job({ ...args, qasm: args.qasm ?? ctx.qasm }))); @@ -136,7 +173,7 @@ export class Assistant { const ranWith = new Map(); // program → the settings the model ran it with const heard = [ctx.qasm, JSON.stringify(ctx.job ?? {}), JSON.stringify(ctx.last ? summary(ctx.last) : {})]; // what the engine and the editor told it const messages = [ - { role: 'system', content: SYSTEM }, + { role: 'system', content: `${SYSTEM}\n${(STANCES[this.stance()] || STANCES.questions).text}` }, ...this.history, { role: 'user', content: `${text}\n\n(The program in the editor:)\n\`\`\`qasm\n${ctx.qasm}\n\`\`\`` }, ]; @@ -197,6 +234,25 @@ export class Assistant { } } +// The muse's brief: candidates, not answers, and no figures. +const MUSE = `You propose OpenQASM 3 programs for Quantum Studio's Alternatives. The Studio's engine reads, measures and compares every program you write; you do not judge them. Write each program whole, in its own \`\`\`qasm block, each after one line that starts "Idea:". State no counts, probabilities or costs. Use only what the engine reads: registers, the standard gates, gate definitions, ctrl and negctrl modifiers, measurement and reset, and if on measured bits; no loops, inv or pow.`; + +// The ideas in a muse's answer: each \`\`\`qasm block with the "Idea:" line +// before it. +export function ideas(text) { + const out = []; + const re = /\`\`\`(?:qasm|openqasm|openqasm3)?\s*\n([\s\S]*?)\`\`\`/gi; + let from = 0; + for (let m; (m = re.exec(text)); ) { + const before = text.slice(from, m.index); + from = m.index + m[0].length; + if (!/\b(qubit|qreg|OPENQASM)\b/.test(m[1])) continue; + const said = [...before.matchAll(/^[\s>*#-]*(?:\d+[.)]\s*)?\**idea\**\s*[:.]\**\s*(.+)$/gim)].pop(); + out.push({ idea: said ? said[1].replace(/\*+/g, '').trim().replace(/\.$/, '') : '', qasm: m[1].trim() + '\n' }); + } + return out; +} + // The figures in an answer's prose that no tool returned and the program does not // hold: a decimal, a percentage, or a whole number of two digits or more. export function unsourced(answer, heard) { diff --git a/wai/quantum-studio/src/circuit-svg.js b/wai/quantum-studio/src/circuit-svg.js index 5cfca134e..2b2144583 100644 --- a/wai/quantum-studio/src/circuit-svg.js +++ b/wai/quantum-studio/src/circuit-svg.js @@ -2,7 +2,9 @@ // qubit, gates packed into columns, a control as a dot, a CX target as ⊕, a swap as ×, // a measurement as a meter with its bit, a reset as |0⟩, and a gate under a condition // with a dashed outline and the condition's number. Colours come from the page's -// tokens, through classes in studio.css. +// tokens, through classes in studio.css. Each gate carries its operation's index +// in the program (data-op) and each wire its qubit (data-q), which the critique +// marks: see mark(). const NS = 'http://www.w3.org/2000/svg'; const ROW = 40; // between wires const LEFT = 40; // the qubits' names @@ -65,14 +67,16 @@ export function circuitSvg(layout) { const gates = layout.columns.reduce((s, c) => s + c.length, 0); const svg = node('svg', { xmlns: NS, width: W, height: H, viewBox: `0 0 ${W} ${H}`, class: 'qc', role: 'img', 'aria-label': `The circuit: ${n} qubits, ${gates} operations in ${layout.columns.length} columns` }); for (let q = 0; q < n; q++) { - svg.append(text(LEFT - 14, y(q), `q${q}`, 'qc-name'), node('line', { x1: LEFT, y1: y(q), x2: W - PAD, y2: y(q), class: 'qc-wire' })); + const name = text(LEFT - 14, y(q), `q${q}`, 'qc-name'); + name.setAttribute('data-q', q); + svg.append(name, node('line', { x1: LEFT, y1: y(q), x2: W - PAD, y2: y(q), class: 'qc-wire', 'data-q': q })); } let x = LEFT + PAD; cols.forEach((col, ci) => { const cx = x + widths[ci] / 2; for (const it of col) { const qs = it.cells.map(([q]) => q); - const g = node('g', { class: 'qc-item' }); + const g = node('g', { class: 'qc-item', 'data-op': it.op }); g.append(node('title', {}, it.cells.map(([q, l]) => `${l} on q${q}`).join(', '))); if (it.joined && qs.length > 1) g.append(node('line', { x1: cx, y1: y(Math.min(...qs)), x2: cx, y2: y(Math.max(...qs)), class: 'qc-join' })); for (const [q, l] of it.cells) g.append(cell(cx, y(q), parse(l))); @@ -82,3 +86,17 @@ export function circuitSvg(layout) { }); return { svg, drawn: cols.length, total: layout.columns.length }; } + +// Marks on a drawn circuit: `cls` on the gates whose operations are in `ops` and +// on the wires of `qubits`, off everywhere else. +export function mark(root, cls, ops = [], qubits = []) { + const o = new Set(ops.map(String)), q = new Set(qubits.map(String)); + for (const g of root.querySelectorAll('.qc-item')) g.classList.toggle(cls, o.has(g.dataset.op)); + for (const w of root.querySelectorAll('[data-q]')) w.classList.toggle(cls, q.has(w.dataset.q)); +} + +// A note added to the tooltip of the gate drawn for operation `op`. +export function annotate(root, op, note) { + const t = root.querySelector(`.qc-item[data-op="${op}"] > title`); + if (t) t.textContent += ` · ${note}`; +} diff --git a/wai/quantum-studio/src/critique-view.js b/wai/quantum-studio/src/critique-view.js new file mode 100644 index 000000000..a2b84768a --- /dev/null +++ b/wai/quantum-studio/src/critique-view.js @@ -0,0 +1,69 @@ +// The engine's critique of the program in the editor, kept up as it changes: +// its measures, each with its change since the last run; whether it is a +// Clifford circuit; the fidelity under reference error rates, named as a model; +// and its smells, each with the lines it is on, Show, and Fix where the engine +// can rewrite it. Every figure is the engine's; nothing here is a model's guess. +import { el } from './dom.js'; + +export const MEASURES = [ + ['qubits', 'qubits'], ['gates', 'gates'], ['two_qubit', 'two-qubit'], ['depth', 'depth'], + ['two_qubit_depth', 'two-qubit depth'], ['t_count', 'T'], ['non_clifford', 'non-Clifford'], +]; + +// The measures as chips, each with its change from `base` when it has one. +export function chips(c, base) { + return MEASURES.filter(([k]) => c[k] != null && !(k === 'non_clifford' && !c[k])).map(([k, label]) => { + const d = base && base[k] != null ? c[k] - base[k] : 0; + return el('span', { class: 'qs-chip', 'data-k': k }, + el('b', { text: String(c[k]) }), ' ' + label, + d ? el('span', { class: 'qs-delta', title: 'since the last run', text: d > 0 ? ` +${d}` : ` −${-d}` }) : null); + }); +} + +export function fidelityChip(c) { + const f = c.fidelity_model; + if (!f) return null; + const r = f.rates || {}; + return el('span', { class: 'qs-chip qs-model', title: `The ${f.note}. Rates: ${r.one_qubit} per one-qubit gate, ${r.two_qubit} per two-qubit gate, ${r.readout} per measured qubit.` }, + el('b', { text: Number(f.estimate).toFixed(3) }), ' fidelity, modelled'); +} + +const where = (s) => (s.lines?.length ? (s.lines.length === 1 ? `line ${s.lines[0]}` : `lines ${s.lines.join(', ')}`) : s.qubits?.length ? s.qubits.map((q) => `q${q}`).join(' ') : ''); + +// The panel. `onShow(smell)` takes the reader to a smell, `onFix(smell)` asks +// for its rewrite, and `onMark(smell | null)` marks it on the diagram. +export function critiquePanel({ onShow, onFix, onMark }) { + const head = el('div', { class: 'qs-chips', id: 'qs-measures' }); + const list = el('ul', { class: 'qs-smells', id: 'qs-smells' }); + const node = el('div', { class: 'qs-critique', id: 'qs-critique', 'aria-label': 'The engine’s critique' }, head, list); + let last = null; // the latest critique + let base = null; // the measures when the program last ran + + function update(c) { + last = c; + if (!c?.ok) { + head.replaceChildren(el('span', { class: 'qs-note', text: 'The critique waits for a program the engine can read.' })); + list.replaceChildren(); + return; + } + head.replaceChildren(...[...chips(c, base), + c.clifford ? el('span', { class: 'qs-badge ok', id: 'qs-clifford', title: 'Every gate is a Clifford gate: the stabilizer tableau runs it exactly, at any width.', text: 'Clifford' }) : null, + fidelityChip(c)].filter(Boolean)); // replaceChildren writes a null as the text "null" + list.replaceChildren(...c.smells.map((s) => { + const li = el('li', { class: 'qs-smell', 'data-kind': s.kind, tabindex: '-1' }, + el('span', { class: 'qs-smell-text', text: s.message }), + el('span', { class: 'qs-smell-where qs-note', text: where(s) }), + el('span', { class: 'qs-smell-acts' }, + el('button', { class: 'qs-btn qs-small', type: 'button', text: 'Show', onclick: () => onShow(s) }), + s.fixable ? el('button', { class: 'qs-btn qs-small qs-fix', type: 'button', text: 'Fix', title: 'The engine rewrites these lines and checks the result is the same circuit', onclick: () => onFix(s) }) : null)); + for (const [ev, v] of [['mouseenter', s], ['focusin', s], ['mouseleave', null], ['focusout', null]]) li.addEventListener(ev, () => onMark(v)); + return li; + })); + } + return { + node, + update, + // The program ran: its measures become the base the changes count from. + ran() { if (last?.ok) { base = { ...last }; update(last); } }, + }; +} diff --git a/wai/quantum-studio/src/dom.js b/wai/quantum-studio/src/dom.js new file mode 100644 index 000000000..a79df273d --- /dev/null +++ b/wai/quantum-studio/src/dom.js @@ -0,0 +1,25 @@ +// An element with attributes and children: `class`, `text`, `on…` handlers, and +// boolean attributes as true. Built as nodes, never from HTML. +export const el = (tag, attrs = {}, ...kids) => { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') e.className = v; + else if (k === 'text') e.textContent = v; + else if (k.startsWith('on')) e.addEventListener(k.slice(2), v); + else if (v === true) e.setAttribute(k, ''); + else if (v !== false && v != null) e.setAttribute(k, v); + } + for (const k of kids.flat()) if (k != null) e.append(k); + return e; +}; + +// The engine's equivalence verdict in words. +export function sameness(eq) { + if (!eq) return { text: 'not checked', cls: '' }; + if (eq.verdict === 'equivalent') { + const how = eq.how === 'tableau' ? 'equal tableaus' : eq.how === 'exact' ? 'on every basis state' : eq.how === 'random' ? 'on random inputs' : eq.how; + return { text: `same circuit · ${how}`, cls: 'ok', why: eq.why }; + } + if (eq.verdict === 'different') return { text: 'a different circuit', cls: 'bad', why: eq.why }; + return { text: 'not checked', cls: '', why: eq.why }; +} diff --git a/wai/quantum-studio/src/engine.js b/wai/quantum-studio/src/engine.js index 2453a3388..24bb295b0 100644 --- a/wai/quantum-studio/src/engine.js +++ b/wai/quantum-studio/src/engine.js @@ -6,8 +6,8 @@ // // Each takes the service's request JSON and gives the service's job JSON // (request, plan and result with its receipt), so the Studio shows a job the -// same way wherever it ran. Lint, draw and verify run locally: in the tab, or -// natively on the desktop. +// same way wherever it ran. Lint, draw, verify, the critique, the equivalence +// check and the variants run locally: in the tab, or natively on the desktop. const KEY_ITEM = 'wai.quantum-ide.signing-key'; // one key per browser, shared with the IDE and the console @@ -60,6 +60,10 @@ export class TabEngine { draw(qasm, width) { return this.call('draw', { qasm, width }); } layout(qasm) { return this.call('layout', { qasm }); } verify(job) { return this.call('verify', { job }); } + critique(qasm) { return this.call('critique', { qasm }); } + equivalent(a, b) { return this.call('equivalent', { a, b }); } + variants(qasm) { return this.call('variants', { qasm }); } + shortcuts(qasm, thorough = false) { return this.call('shortcuts', { qasm, thorough }); } async signer() { return (await this.call('signer')).did; } cancel() { this.worker?.terminate(); @@ -123,6 +127,11 @@ export class DesktopEngine { draw(qasm, width) { return this.call('draw', { qasm, width }); } layout(qasm) { return this.call('layout', { qasm }); } verify(job) { return this.call('verify', { job: JSON.stringify(job) }); } + // A critique names a problem as {ok: false, error}, as a lint does. + async critique(qasm) { return JSON.parse(await this.invoke('critique', { qasm })); } + equivalent(a, b) { return this.call('equivalent', { a, b }); } + variants(qasm) { return this.call('variants', { qasm }); } + shortcuts(qasm, thorough = false) { return this.call('shortcuts', { qasm, thorough }); } async signer() { return (await this.call('signer', {})).did; } saveJob(name, job) { return this.call('save_job', { name, job: JSON.stringify(job, null, 2) }); } saveProgram(name, text) { return this.call('save_program', { name, text }); } diff --git a/wai/quantum-studio/src/gallery.js b/wai/quantum-studio/src/gallery.js new file mode 100644 index 000000000..58b398201 --- /dev/null +++ b/wai/quantum-studio/src/gallery.js @@ -0,0 +1,179 @@ +// Alternatives: other programs set beside the draft, each measured by the engine. +// +// Improve offers the same circuit, cheaper: the engine's own rewrites (its +// smells fixed), and the model's when one is connected. Every one is checked +// against the draft; one the check finds different is not offered. Explore asks +// the model for different circuits toward an aim the reader names; the engine +// reads, measures and compares each one, and says how far it is from the draft. +// The model proposes, the engine judges, and the reader chooses: nothing is +// applied until they press Apply. +import { el, sameness } from './dom.js'; +import { chips } from './critique-view.js'; + +const NS = 'http://www.w3.org/2000/svg'; +const svg = (tag, attrs = {}, ...kids) => { + const e = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) if (v != null) e.setAttribute(k, String(v)); + for (const k of kids) if (k != null) e.append(typeof k === 'string' ? document.createTextNode(k) : k); + return e; +}; + +// How many statement lines one program changes in the other: the lines of +// either outside their longest common subsequence, comments and blanks aside. +export function lineDistance(a, b) { + const lines = (s) => s.split('\n').map((l) => l.replace(/\/\/.*$/, '').trim()).filter(Boolean); + const x = lines(a), y = lines(b); + const row = new Array(y.length + 1).fill(0); + for (let i = 1; i <= x.length; i++) { + let prev = 0; + for (let j = 1; j <= y.length; j++) { + const keep = row[j]; + row[j] = x[i - 1] === y[j - 1] ? prev + 1 : Math.max(row[j], row[j - 1]); + prev = keep; + } + } + const common = row[y.length]; + return { changed: x.length - common + (y.length - common), of: Math.max(x.length, y.length) }; +} + +// Two-qubit gates across, depth up: the draft as a ring, each alternative as a +// numbered dot. Hovering or focusing a dot lights its card; a click goes to it. +export function atlas(draft, cands, onPick) { + const W = 320, H = 176, L = 34, B = 26, T = 22, R = 12; + const pts = [draft, ...cands.map((c) => c.measures)]; + const xmax = Math.max(1, ...pts.map((p) => p.two_qubit)) * 1.15; + const ymax = Math.max(1, ...pts.map((p) => p.depth)) * 1.15; + const sx = (v) => L + (v / xmax) * (W - L - R), sy = (v) => H - B - (v / ymax) * (H - B - T); + const ticks = (max) => { const step = Math.max(1, Math.ceil(max / 4)); const out = []; for (let v = 0; v <= max; v += step) out.push(v); return out; }; + const g = svg('svg', { class: 'qs-atlas', id: 'qs-atlas', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': `The draft and ${cands.length} alternatives by two-qubit gates and depth` }); + for (const v of ticks(xmax)) g.append(svg('line', { x1: sx(v), y1: T, x2: sx(v), y2: H - B, class: 'qs-gridline' }), svg('text', { x: sx(v), y: H - B + 13, class: 'qs-tick', 'text-anchor': 'middle' }, String(v))); + for (const v of ticks(ymax)) g.append(svg('line', { x1: L, y1: sy(v), x2: W - R, y2: sy(v), class: 'qs-gridline' }), svg('text', { x: L - 6, y: sy(v) + 3, class: 'qs-tick', 'text-anchor': 'end' }, String(v))); + g.append(svg('text', { x: W - R, y: H - 2, class: 'qs-axis', 'text-anchor': 'end' }, 'two-qubit gates →'), + svg('text', { x: L - 6, y: 10, class: 'qs-axis', 'text-anchor': 'start' }, '↑ depth')); + const d = svg('g', { class: 'qs-pt qs-pt-draft' }, svg('title', {}, `the draft: ${draft.two_qubit} two-qubit gates, depth ${draft.depth}`), + svg('circle', { cx: sx(draft.two_qubit), cy: sy(draft.depth), r: 6, class: 'qs-ring' })); + g.append(d); + cands.forEach((c, i) => { + const x = sx(c.measures.two_qubit), y = sy(c.measures.depth); + const p = svg('g', { class: 'qs-pt', tabindex: '0', role: 'button', 'data-i': i, 'aria-label': `alternative ${i + 1}: ${c.measures.two_qubit} two-qubit gates, depth ${c.measures.depth}` }, + svg('title', {}, `${i + 1}. ${c.label}: ${c.measures.two_qubit} two-qubit gates, depth ${c.measures.depth}`), + svg('circle', { cx: x, cy: y, r: 12, class: 'qs-hit' }), + svg('circle', { cx: x, cy: y, r: 5, class: 'qs-dot' }), + svg('text', { x: x + 8, y: y - 7, class: 'qs-tick qs-pt-n' }, String(i + 1))); + p.addEventListener('click', () => onPick(i, true)); + p.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPick(i, true); } }); + for (const ev of ['mouseenter', 'focus']) p.addEventListener(ev, () => onPick(i, false)); + for (const ev of ['mouseleave', 'blur']) p.addEventListener(ev, () => onPick(-1, false)); + g.append(p); + }); + return g; +} + +// The pane. `draft()` is the editor's program; `model()` the assistant or null +// when no model is connected; `apply(qasm, note)` puts a program in the editor. +export function galleryPane({ engine, draft, model, apply }) { + const intent = el('input', { class: 'qs-input', id: 'qs-explore-aim', placeholder: 'Toward… fewer qubits, another entangler, more symmetry', autocomplete: 'off', 'aria-label': 'What Explore should aim for' }); + const improveBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-improve', type: 'button', text: 'Improve', title: 'The same circuit, cheaper: each checked equivalent to the draft' }); + const exploreBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-explore', type: 'button', text: 'Explore', title: 'Different circuits toward the aim, from the model, each measured by the engine' }); + const note = el('p', { class: 'qs-note', id: 'qs-alt-note', text: 'Improve: the same circuit, cheaper, each checked equivalent. Explore: different circuits toward an aim, from the model, each measured by the engine. Nothing changes until you apply one.' }); + const body = el('div', { class: 'qs-alt-body', id: 'qs-alt-body' }); + const node = el('section', { class: 'qs-pane qs-alt', 'aria-labelledby': 'qs-alt-h' }, + el('div', { class: 'qs-head' }, el('h2', { id: 'qs-alt-h', text: 'Alternatives' }), el('span', { class: 'qs-grow' }), improveBtn, exploreBtn), + el('div', { class: 'qs-alt-aim' }, intent), note, body); + + let busy = false; + // A candidate {label, qasm, source, idea?, equivalence?} measured against the draft. + async function measure(src, c, base) { + const lint = await engine.lint(c.qasm); + if (!lint.ok) return { ...c, refused: `does not parse: ${lint.error}` }; + const crit = await engine.critique(c.qasm); + if (!crit.ok) return { ...c, refused: crit.error }; + const equivalence = c.equivalence ?? await engine.equivalent(src, c.qasm); + return { ...c, measures: crit, equivalence, distance: lineDistance(src, c.qasm), base }; + } + + function show(mode, src, base, cands, refused, said) { + const cards = cands.map((c, i) => { + const s = sameness(c.equivalence); + if (mode === 'explore' && s.cls === 'bad') s.cls = ''; // a different circuit is what Explore asks for + const card = el('li', { class: 'qs-cand', 'data-i': i, id: `qs-cand-${i}`, tabindex: '-1' }, + el('div', { class: 'qs-cand-head' }, + el('span', { class: 'qs-cand-n', text: String(i + 1) }), + el('span', { class: 'qs-cand-title', text: c.idea || c.label }), + el('span', { class: 'qs-badge', text: c.source === 'engine' ? 'the engine' : 'the model' })), + el('div', { class: 'qs-chips' }, ...chips(c.measures, base).filter((x) => x.dataset.k !== 'qubits' || c.measures.qubits !== base.qubits)), + el('div', { class: 'qs-cand-facts' }, + el('span', { class: `qs-badge ${s.cls}`, title: s.why || '', text: s.text }), + el('span', { class: 'qs-note', text: c.distance.changed ? `${c.distance.changed} lines changed` : 'the same lines' })), + c.changes?.length ? el('p', { class: 'qs-note', text: c.changes.join('; ') }) : null, + el('details', { class: 'qs-cand-src' }, el('summary', { text: 'Program' }), el('pre', { class: 'qs-ai-code', text: c.qasm })), + el('div', { class: 'qs-actions' }, + el('button', { class: 'qs-btn qs-small qs-apply', type: 'button', text: 'Apply', onclick: () => apply(c.qasm, `${mode === 'improve' ? 'An improvement' : 'An exploration'} from ${c.source === 'engine' ? 'the engine' : 'the model'}, applied: ${c.idea || c.label}. ${s.text[0].toUpperCase() + s.text.slice(1)}.`) }))); + return card; + }); + const pick = (i, go) => { + cards.forEach((c, j) => c.classList.toggle('on', j === i)); + if (go && cards[i]) { cards[i].scrollIntoView({ block: 'nearest' }); cards[i].focus({ preventScroll: true }); } + }; + body.replaceChildren(...[ + cands.length ? atlas(base, cands, pick) : null, + cands.length ? el('ol', { class: 'qs-cands', id: 'qs-cands' }, ...cards) : el('p', { class: 'qs-empty', text: mode === 'improve' ? 'Nothing to improve that the engine can check: no gates cancel or merge, and the model offered none it could confirm.' : 'The model offered nothing the engine could read.' }), + refused.length ? el('p', { class: 'qs-note', id: 'qs-alt-refused', text: `Not offered: ${refused.join('; ')}.` }) : null, + said ? el('p', { class: 'qs-note', text: said }) : null].filter(Boolean)); // replaceChildren writes a null as the text "null" + } + + async function improve() { + if (busy) return; + busy = true; improveBtn.disabled = exploreBtn.disabled = true; + const src = draft(); + body.replaceChildren(el('p', { class: 'qs-note', text: 'The engine is looking…' })); + try { + const base = await engine.critique(src); + if (!base.ok) { body.replaceChildren(el('p', { class: 'qs-empty', text: `The engine cannot read the draft: ${base.error}` })); return; } + const v = await engine.variants(src); + const raw = (v.candidates || []).map((c) => ({ label: c.label, qasm: c.qasm, changes: c.changes, source: 'engine', equivalence: c.equivalence })); + const m = model(); + let said = m ? '' : 'Connect a model under “Model…” in the Assistant for its rewrites as well.'; + if (m) { + try { for (const p of await m.improve(src, 3)) raw.push({ label: p.idea || 'a rewrite', idea: p.idea, qasm: p.qasm, source: 'model' }); } + catch (e) { said = `The model did not answer (${e.message}); these are the engine’s alone.`; } + } + const cands = [], refused = []; + for (const c of raw) { + const r = await measure(src, c, base); + if (r.refused) refused.push(`${r.label}: ${r.refused}`); + else if (r.equivalence.verdict === 'different') refused.push(`${r.label}: not the same circuit (${r.equivalence.why})`); + else if (!cands.some((x) => x.qasm.trim() === r.qasm.trim()) && r.qasm.trim() !== src.trim()) cands.push(r); + } + show('improve', src, base, cands, refused, said); + } catch (e) { body.replaceChildren(el('p', { class: 'qs-empty', text: e.message })); } + finally { busy = false; improveBtn.disabled = exploreBtn.disabled = false; } + } + + async function explore() { + if (busy) return; + const m = model(); + if (!m) { body.replaceChildren(el('p', { class: 'qs-empty', id: 'qs-alt-nomodel', text: 'Explore asks a model for ideas, and none is connected. Connect one under “Model…” in the Assistant; Improve works without one.' })); return; } + busy = true; improveBtn.disabled = exploreBtn.disabled = true; + const src = draft(); + body.replaceChildren(el('p', { class: 'qs-note', text: 'The model is imagining; the engine will measure each idea…' })); + try { + const base = await engine.critique(src); + if (!base.ok) { body.replaceChildren(el('p', { class: 'qs-empty', text: `The engine cannot read the draft: ${base.error}` })); return; } + const ideas = await m.explore(src, intent.value.trim(), 4); + const cands = [], refused = []; + for (const p of ideas) { + const r = await measure(src, { label: p.idea || 'an idea', idea: p.idea, qasm: p.qasm, source: 'model' }, base); + if (r.refused) refused.push(`${r.label}: ${r.refused}`); + else if (!cands.some((x) => x.qasm.trim() === r.qasm.trim())) cands.push(r); + } + show('explore', src, base, cands, refused, ''); + } catch (e) { body.replaceChildren(el('p', { class: 'qs-empty', text: `The model did not answer: ${e.message}` })); } + finally { busy = false; improveBtn.disabled = exploreBtn.disabled = false; } + } + + improveBtn.addEventListener('click', improve); + exploreBtn.addEventListener('click', explore); + intent.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); explore(); } }); + return { node, improve, explore }; +} diff --git a/wai/quantum-studio/src/main.js b/wai/quantum-studio/src/main.js index 46d474da6..4c643c8de 100644 --- a/wai/quantum-studio/src/main.js +++ b/wai/quantum-studio/src/main.js @@ -3,6 +3,11 @@ // why, and how accurate it is, and carries a receipt checked here. One // interface for the browser and the desktop app. // +// The engine is the microscope and the judge: it critiques the draft as it is +// written, measures and checks every alternative, and answers every run. The +// assistant, a model on this machine, is an untrusted muse that speaks only when +// asked. The reader is the author: nothing changes the program until they apply it. +// // The host gives a mount element: //
@@ -19,8 +24,13 @@ import { openqasm, complete } from './qasm-lang.js'; import { TabEngine, ServiceEngine, DesktopEngine, onDesktop } from './engine.js'; import { EXAMPLES } from './examples.js'; import * as History from './history.js'; -import { circuitSvg } from './circuit-svg.js'; -import { Assistant, HttpModel, DesktopModel } from './assistant.js'; +import { circuitSvg, mark, annotate } from './circuit-svg.js'; +import { Assistant, HttpModel, DesktopModel, STANCES } from './assistant.js'; +import { el, sameness } from './dom.js'; +import { critiquePanel } from './critique-view.js'; +import { galleryPane } from './gallery.js'; +import { shortcutsPanel } from './shortcuts-view.js'; +import * as Predict from './predict.js'; const METHOD_NAMES = { exact: 'Exact state vector', statevector: 'State vector, double precision', statevector_single: 'State vector on the GPU, single precision', @@ -37,19 +47,7 @@ const ACCURACY = { }; const NOISE = [['depolarizing_1q', 'one-qubit depolarizing'], ['depolarizing_2q', 'two-qubit depolarizing'], ['amplitude_damping', 'amplitude damping'], ['dephasing', 'dephasing'], ['readout', 'readout flips']]; const DRAFT = 'wai.quantum-studio.draft'; - -const el = (tag, attrs = {}, ...kids) => { - const e = document.createElement(tag); - for (const [k, v] of Object.entries(attrs)) { - if (k === 'class') e.className = v; - else if (k === 'text') e.textContent = v; - else if (k.startsWith('on')) e.addEventListener(k.slice(2), v); - else if (v === true) e.setAttribute(k, ''); - else if (v !== false && v != null) e.setAttribute(k, v); - } - for (const k of kids.flat()) if (k != null) e.append(k); - return e; -}; +const PREDICT_FIRST = 'wai.quantum-studio.predict-first'; const ms = (t) => (t < 1 ? 'under 1 ms' : t < 10000 ? `${Math.round(t)} ms` : `${(t / 1000).toFixed(1)} s`); const compact = (b) => (b.length <= 24 ? b : `${b.slice(0, 8)}…${b.slice(-8)} (${b.length} bits)`); const split = (s) => s.split(/[,\n]/).map((x) => x.trim()).filter(Boolean); @@ -70,6 +68,10 @@ function mount(root) { const status = el('span', { class: 'qs-status', role: 'status', 'aria-live': 'polite' }); const runBtn = el('button', { class: 'qs-btn qs-primary', id: 'qs-run', type: 'button', title: 'Run (Ctrl or ⌘ + Enter)', text: '▶ Run' }); const cancelBtn = el('button', { class: 'qs-btn', id: 'qs-cancel', type: 'button', hidden: true, text: 'Cancel' }); + const predictFirst = el('input', { type: 'checkbox', id: 'qs-predict-first' }); + try { predictFirst.checked = localStorage.getItem(PREDICT_FIRST) === '1'; } catch (e) {} + predictFirst.addEventListener('change', () => { try { localStorage.setItem(PREDICT_FIRST, predictFirst.checked ? '1' : '0'); } catch (e) {} }); + const predictLabel = el('label', { class: 'qs-check', for: 'qs-predict-first', title: 'Say what you expect before each run, and see how close you come' }, predictFirst, el('span', { text: 'Predict first' })); const whereLocal = el('button', { class: 'qs-seg on', type: 'button', 'aria-pressed': 'true', 'data-where': 'local', text: desktop ? 'This computer' : 'This browser' }); const whereService = el('button', { class: 'qs-seg', type: 'button', 'aria-pressed': 'false', 'data-where': 'service', text: 'The service', disabled: !service }); const examples = el('select', { class: 'qs-select', id: 'qs-example', 'aria-label': 'Load an example' }, @@ -78,7 +80,7 @@ function mount(root) { el('div', { class: 'qs-brand' }, el('span', { class: 'qs-mark', 'aria-hidden': 'true', text: '⟨ψ⟩' }), el(embedded ? 'span' : 'h1', { class: 'qs-title', text: 'Quantum Studio' })), examples, el('div', { class: 'qs-where', role: 'group', 'aria-label': 'Run on' }, whereLocal, whereService), - runBtn, cancelBtn); + predictLabel, runBtn, cancelBtn); // ---- the program ---- const lintBadge = el('span', { class: 'qs-badge', id: 'qs-lint', text: '…' }); @@ -118,9 +120,35 @@ function mount(root) { circuit.hidden = !on; diagram.hidden = on; asText.setAttribute('aria-pressed', String(on)); asText.textContent = on ? 'Diagram' : 'Text'; }); + // The critique, kept up as the program changes, and marked on the diagram. + // A smell's gates are marked; a smell about whole qubits (idle, unmeasured) marks their wires. + const wires = (s) => (s?.ops?.length ? [] : s?.qubits); + const critique = critiquePanel({ + onShow: (s) => { + mark(diagram, 'qc-smell', s.ops, wires(s)); + diagram.querySelector('.qc-smell')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + if (!s.lines?.length) return; + const doc = view.state.doc; + const a = doc.line(Math.min(Math.min(...s.lines), doc.lines)), b = doc.line(Math.min(Math.max(...s.lines), doc.lines)); + view.dispatch({ selection: { anchor: a.from, head: b.to }, scrollIntoView: true }); + view.focus(); + }, + onFix: (s) => fix(s), + onMark: (s) => mark(diagram, 'qc-smell', s?.ops, wires(s)), + }); + // The classical-shortcut lens, asked for: every exact route and its cost. + const lens = shortcutsPanel({ engine: local, program: () => text() }); const circuitPane = el('section', { class: 'qs-pane qs-circ', 'aria-labelledby': 'qs-circuit-h' }, el('div', { class: 'qs-head' }, el('h2', { id: 'qs-circuit-h', text: 'Circuit' }), el('span', { class: 'qs-grow' }), el('span', { class: 'qs-note', id: 'qs-plan' }), asText), - diagram, circuit, legend); + diagram, circuit, legend, critique.node, lens.node); + + // ---- the alternatives ---- + const gallery = galleryPane({ + engine: local, + draft: () => text(), + model: () => (aiConnected ? assistantFor() : null), + apply: (qasm, note) => { setText(qasm); shows.textContent = note; setStatus('Applied the alternative. Undo brings the draft back.', 'ok'); }, + }); // ---- the result ---- const state = el('span', { class: 'qs-badge', id: 'qs-state', text: 'ready' }); @@ -137,21 +165,41 @@ function mount(root) { // ---- the assistant ---- const AI_ITEM = 'wai.quantum-studio.assistant'; - const aiConf = { endpoint: 'http://127.0.0.1:8080/v1', model: '' }; + const aiConf = { endpoint: 'http://127.0.0.1:8080/v1', model: '', stance: 'questions', initiative: 'asked' }; try { Object.assign(aiConf, JSON.parse(localStorage.getItem(AI_ITEM) || '{}')); } catch (e) {} const [aiEndpoint, aiEndpointF] = field('qs-ai-endpoint', 'Model server', { value: aiConf.endpoint, spellcheck: 'false', autocomplete: 'off' }); const [aiModel, aiModelF] = field('qs-ai-model', 'Model', { value: aiConf.model, placeholder: 'the server’s own', spellcheck: 'false', autocomplete: 'off' }); const aiState = el('span', { class: 'qs-badge', id: 'qs-ai-state', text: 'not connected' }); const aiConnect = el('button', { class: 'qs-btn qs-small', id: 'qs-ai-connect', type: 'button', text: 'Connect' }); + const aiStance = el('select', { class: 'qs-select', id: 'qs-ai-stance' }, ...Object.entries(STANCES).map(([k, v]) => el('option', { value: k, text: v.label }))); + aiStance.value = STANCES[aiConf.stance] ? aiConf.stance : 'questions'; + const aiInitiative = el('select', { class: 'qs-select', id: 'qs-ai-initiative' }, + el('option', { value: 'asked', text: 'Only when I ask' }), el('option', { value: 'hint', text: 'Offer a hint when I seem stuck' })); + aiInitiative.value = aiConf.initiative === 'hint' ? 'hint' : 'asked'; + const keepConf = () => { try { localStorage.setItem(AI_ITEM, JSON.stringify({ endpoint: aiEndpoint.value.trim(), model: aiModel.value.trim(), stance: aiStance.value, initiative: aiInitiative.value })); } catch (e) {} }; + aiStance.addEventListener('change', keepConf); + aiInitiative.addEventListener('change', () => { keepConf(); if (aiInitiative.value === 'asked') stuck.hidden = true; }); + const aiModes = el('div', { class: 'qs-ai-modes' }, + el('label', { class: 'qs-field', for: 'qs-ai-stance' }, el('span', { text: 'How it helps' }), aiStance), + el('label', { class: 'qs-field', for: 'qs-ai-initiative' }, el('span', { text: 'When it speaks' }), aiInitiative)); const aiSettings = el('div', { class: 'qs-ai-settings', id: 'qs-ai-settings', hidden: true }, el('div', { class: 'qs-row' }, aiEndpointF, aiModelF), el('div', { class: 'qs-actions' }, aiConnect), el('p', { class: 'qs-note', text: 'A model on this machine by default: any server with the chat-completions interface and tool calls. Nothing leaves this machine unless the address points elsewhere.' })); const aiLog = el('ol', { class: 'qs-ai-log', id: 'qs-ai-log', 'aria-live': 'polite' }, - el('li', { class: 'qs-note', text: 'Ask about the program. The assistant uses the engine to check what it says: every number comes from a run, and every program it proposes is read and run here before it is offered.' })); + el('li', { class: 'qs-note', text: 'Ask about the program. The assistant speaks only when asked, and uses the engine to check what it says: every number comes from a run, and every program it proposes is read and run here before it is offered.' })); const aiInput = el('textarea', { class: 'qs-input qs-ai-input', id: 'qs-ai-input', rows: '2', placeholder: 'Ask about this program…', 'aria-label': 'Ask the assistant' }); const aiSend = el('button', { class: 'qs-btn qs-primary', id: 'qs-ai-send', type: 'button', text: 'Ask' }); + // Offered, never sent: with “Offer a hint when I seem stuck”, a problem the + // engine has reported for a while, or runs refused twice in a row, show this. + const stuck = el('button', { class: 'qs-btn qs-small qs-stuck', id: 'qs-ai-stuck', type: 'button', hidden: true, text: 'Stuck? Ask for a hint', onclick: () => { stuck.hidden = true; ask(HINT, 'Hint'); } }); + const STUCK_MS = Number(root.dataset.stuckMs) || 45000; + let badSince = null, stuckTimer = null, refusals = 0; + const offerHint = () => { if (aiInitiative.value === 'hint') stuck.hidden = false; }; + const HINT = 'Give me one hint toward what is wrong or could be better in this program: point at where to look, not at the fix. Use the tools to find it first.'; const QUICK = [ + ['Ask me a question', 'Ask me one question about this program that would help me see what it does or how to make it better. Use the tools first if they help, and do not give the answer.'], + ['Hint', HINT], ['Explain this circuit', 'Explain what this program does, step by step, and what its result shows.'], ['Why this method?', 'Why does the engine pick this method for this job, and what would make it pick another?'], ['Fix the problem', 'The program has a problem. Find it with the tools and give the corrected whole program.'], @@ -159,14 +207,14 @@ function mount(root) { const aiPane = el('section', { class: 'qs-pane qs-ai', 'aria-labelledby': 'qs-ai-h' }, el('div', { class: 'qs-head' }, el('h2', { id: 'qs-ai-h', text: 'Assistant' }), aiState, el('span', { class: 'qs-grow' }), el('button', { class: 'qs-btn qs-small', type: 'button', id: 'qs-ai-gear', text: 'Model…', 'aria-controls': 'qs-ai-settings', onclick: () => { aiSettings.hidden = !aiSettings.hidden; } })), - aiSettings, - el('div', { class: 'qs-ai-quick' }, ...QUICK.map(([label, q]) => el('button', { class: 'qs-btn qs-small', type: 'button', text: label, onclick: () => ask(q, label) }))), + aiSettings, aiModes, + el('div', { class: 'qs-ai-quick' }, ...QUICK.map(([label, q]) => el('button', { class: 'qs-btn qs-small', type: 'button', text: label, onclick: () => ask(q, label) })), stuck), aiLog, el('div', { class: 'qs-ai-ask' }, aiInput, aiSend)); const footer = el('footer', { class: 'qs-foot' }, el('span', { class: 'qs-signer', id: 'qs-signer' }), status); root.classList.add('qs'); - root.replaceChildren(header, el('div', { class: 'qs-grid' }, program, el('div', { class: 'qs-side' }, circuitPane, resultPane, aiPane, historyPane)), footer); + root.replaceChildren(header, el('div', { class: 'qs-grid' }, program, el('div', { class: 'qs-side' }, circuitPane, gallery.node, resultPane, aiPane, historyPane)), footer); // ---- the editor ---- const style = HighlightStyle.define([ @@ -187,9 +235,12 @@ function mount(root) { if (v.ok) { lintBadge.textContent = `${v.qubits} qubits · ${v.gates} gates${v.dynamic_steps != null ? ' · measures in the middle' : ''}`; lintBadge.className = 'qs-badge ok'; + badSince = null; clearTimeout(stuckTimer); + if (!refusals) stuck.hidden = true; return []; } lintBadge.textContent = 'problem'; lintBadge.className = 'qs-badge bad'; + if (badSince == null) { badSince = Date.now(); clearTimeout(stuckTimer); stuckTimer = setTimeout(() => { if (badSince != null) offerHint(); }, STUCK_MS); } const doc = view.state.doc; const line = v.line && v.line <= doc.lines ? doc.line(v.line) : doc.line(doc.lines); return [{ from: line.from, to: Math.max(line.to, line.from), severity: 'error', message: v.error }]; @@ -251,15 +302,22 @@ function mount(root) { drawTimer = setTimeout(async () => { const src = text(); try { localStorage.setItem(DRAFT, src); } catch (e) {} + lens.stale(src); try { - const [d, l] = await Promise.all([local.draw(src, Math.max(40, Math.floor((circuit.clientWidth || diagram.clientWidth) / 7.8) || 80)), local.layout(src)]); + const [d, l, c] = await Promise.all([local.draw(src, Math.max(40, Math.floor((circuit.clientWidth || diagram.clientWidth) / 7.8) || 80)), local.layout(src), local.critique(src)]); circuit.textContent = d.text ?? ''; circuit.classList.remove('stale'); const { svg, drawn, total } = circuitSvg(l); diagram.replaceChildren(svg); diagram.classList.remove('stale'); legend.textContent = [...l.legend.map((c, i) => `${i + 1}: ${c}`), drawn < total ? `The first ${drawn} of ${total} columns are drawn; “Text” shows them all.` : ''].filter(Boolean).join(' · '); + critique.update(c); + // The smells, on the diagram: each gate in one is flagged, its tooltip saying why. + const smells = c.ok ? c.smells : []; + mark(diagram, 'qc-flag', smells.flatMap((s) => s.ops), smells.flatMap((s) => s.qubits && !s.ops.length ? s.qubits : [])); + for (const s of smells) for (const op of s.ops) annotate(diagram, op, s.message); } catch (e) { + critique.update({ ok: false, error: e.message }); circuit.textContent = e.message; circuit.classList.add('stale'); diagram.replaceChildren(el('p', { class: 'qs-note', text: e.message })); diagram.classList.add('stale'); legend.textContent = ''; @@ -280,7 +338,16 @@ function mount(root) { // ---- running ---- function setStatus(s, cls = '') { status.textContent = s; status.className = 'qs-status ' + cls; } function busy(on) { runBtn.disabled = on; cancelBtn.hidden = !on; } + // With “Predict first”, the reader says what they expect before the run. async function run() { + if (running) return; + if (!predictFirst.checked) return go(null); + // The program as it is now, not as the last redraw saw it. + const c = await local.critique(text()).catch(() => null); + resultBody.replaceChildren(Predict.form({ width: c?.ok ? c.bits : 0, onRun: go })); + state.textContent = 'your prediction'; state.className = 'qs-badge run'; + } + async function go(prediction) { if (running) return; const job = request(); const eng = where === 'service' ? service : local; @@ -291,13 +358,16 @@ function mount(root) { try { const body = await eng.run(job, (j) => setStatus(`On the service: ${j.status}…`)); const took = performance.now() - t0; - current = { job: body, where: eng.where, took }; + current = { job: body, where: eng.where, took, prediction }; + refusals = 0; + critique.ran(); await show(current); setStatus(`Done on ${eng.where} in ${ms(took)}.`, 'ok'); await History.save({ key: body.result?.receipt?.digest || String(Date.now()), saved: Date.now(), where: eng.where, title: firstLine(job.qasm), job: body }); showHistory(); } catch (e) { state.textContent = e.message === 'cancelled' ? 'cancelled' : 'refused'; state.className = 'qs-badge bad'; + if (e.message !== 'cancelled' && ++refusals >= 2) offerHint(); setStatus(e.message === 'cancelled' ? 'Cancelled.' : e.message, 'bad'); } finally { running = null; busy(false); } } @@ -305,7 +375,7 @@ function mount(root) { const firstLine = (q) => (q.split('\n').find((l) => /\b(h|x|cx|rx|ry|rz|ccx|measure)\b/.test(l)) || q.split('\n')[0] || '').trim().slice(0, 60); // ---- a result ---- - async function show({ job, where: ran, took }) { + async function show({ job, where: ran, took, prediction }) { const r = job.result || {}; state.textContent = job.status || 'done'; state.className = 'qs-badge ok'; const acc = r.accuracy || {}; @@ -324,6 +394,7 @@ function mount(root) { extra.push(parts.join('; ')); } const blocks = [ + prediction ? Predict.block(prediction, r) : null, el('div', { class: 'qs-block' }, el('div', { class: 'qs-k', text: 'Method' }), el('div', {}, el('b', { class: 'qs-method', text: METHOD_NAMES[job.method] || job.method || '' }), el('span', { class: 'qs-why', text: ' ' + (job.why || '') }))), el('div', { class: 'qs-block' }, el('div', { class: 'qs-k', text: 'Accuracy' }), @@ -373,7 +444,7 @@ function mount(root) { el('tr', {}, el('th', { scope: 'row', text: 'energy' }), el('td', { text: energy })))), el('p', { class: 'qs-note', text: 'A receipt shows that this key signed this request and this result; anyone can run the request again to compare. It does not say who holds the key.' }), el('div', { class: 'qs-actions' }, dl))); - resultBody.replaceChildren(...blocks); + resultBody.replaceChildren(...blocks.filter(Boolean)); try { const v = await local.verify({ request: job.request, result: job.result }); const whose = v.key === serviceKey ? 'the service’s key' : v.signer && v.signer === mySigner ? (desktop ? 'this computer’s key' : 'this browser’s key') : 'the key it names'; @@ -383,6 +454,21 @@ function mount(root) { } catch (e) { check.textContent = `not checked: ${e.message}`; check.className = 'qs-badge bad'; } } + // ---- a smell fixed: the engine's rewrite, checked the same circuit ---- + async function fix(s) { + const src = text(); + try { + const v = await local.variants(src); + const c = (v.candidates || []).find((x) => x.changes.length === 1 && x.changes[0] === s.message); + if (!c) { setStatus('The engine has no rewrite for that one now; the program may have changed.', 'bad'); return; } + if (text() !== src) return; // edited meanwhile: the rewrite is of an older draft + const same = sameness(c.equivalence); + setText(c.qasm); + shows.textContent = `Fixed: ${s.message}. The engine checked the result: ${same.why || same.text}.`; + setStatus('Fixed. Undo brings the lines back.', 'ok'); + } catch (e) { setStatus(e.message, 'bad'); } + } + // ---- files ---- function download(name, text, type) { const a = el('a', { href: URL.createObjectURL(new Blob([text], { type })), download: name }); @@ -444,14 +530,15 @@ function mount(root) { // ---- asking ---- const model = () => { const endpoint = aiEndpoint.value.trim(), name = aiModel.value.trim(); - try { localStorage.setItem(AI_ITEM, JSON.stringify({ endpoint, model: name })); } catch (e) {} + keepConf(); return desktop ? new DesktopModel(endpoint, name) : new HttpModel(endpoint, name); }; let assistant = null; + let aiConnected = false; // the model server answered: Alternatives may ask it const assistantFor = () => { const m = model(); if (!assistant || assistant.model.endpoint !== m.endpoint || assistant.model.name !== m.name) { - assistant = new Assistant({ engine: local, model: m, context: () => ({ qasm: text(), job: request(), last: current?.job }) }); + assistant = new Assistant({ engine: local, model: m, context: () => ({ qasm: text(), job: request(), last: current?.job }), stance: () => aiStance.value }); } return assistant; }; @@ -475,6 +562,7 @@ function mount(root) { aiLog.insertBefore(el('li', { class: 'qs-ai-tool' }, el('span', { class: 'qs-note', text: `The engine could not read the proposed program (${ev.error}); asked for a correction.` })), thinking); } else if (ev.type === 'answer') { thinking.remove(); + aiConnected = true; const li = say('qs-ai-answer', ...prose(ev.text)); for (const p of ev.proposals) { const box = el('div', { class: 'qs-ai-proposal' }); @@ -496,6 +584,7 @@ function mount(root) { thinking.remove(); say('qs-ai-error', el('p', { text: `The assistant could not answer: ${e.message}. Check the model server under “Model…”.` })); aiState.textContent = 'not connected'; aiState.className = 'qs-badge bad'; + aiConnected = false; } finally { aiSend.disabled = false; } } aiSend.addEventListener('click', () => { const q = aiInput.value.trim(); if (q) { aiInput.value = ''; ask(q); } }); @@ -506,8 +595,9 @@ function mount(root) { const ids = await model().models(); const name = ids[0] ? ids[0].split(/[\\/]/).pop().replace(/\.gguf$/i, '') : ''; aiState.textContent = `connected${name ? ' · ' + name : ''}`; aiState.className = 'qs-badge ok'; aiState.title = ids[0] || ''; + aiConnected = true; if (!aiModel.value && ids[0]) aiModel.value = ids[0]; - } catch (e) { aiState.textContent = 'not connected'; aiState.className = 'qs-badge bad'; aiState.title = e.message; } + } catch (e) { aiState.textContent = 'not connected'; aiState.className = 'qs-badge bad'; aiState.title = e.message; aiConnected = false; } }); // ---- start ---- @@ -523,7 +613,7 @@ function mount(root) { service?.describe().then((d) => { serviceKey = d.receipt_key || null; }).catch(() => { whereService.disabled = true; whereService.title = 'The service did not answer'; }); edited(); forceLinting(view); - return { view, run, fill, request }; + return { view, run, fill, request, gallery }; } const root = document.getElementById('studio'); diff --git a/wai/quantum-studio/src/predict.js b/wai/quantum-studio/src/predict.js new file mode 100644 index 000000000..3eff79ffa --- /dev/null +++ b/wai/quantum-studio/src/predict.js @@ -0,0 +1,82 @@ +// Predict before running: the reader says which outcome they expect most and +// its share of the shots, the run answers, and the Studio keeps a tally of how +// close they come, on this device only. A prediction is the reader's; the +// Studio never makes one. +import { el } from './dom.js'; + +const ITEM = 'wai.quantum-studio.predictions'; +const KEEP = 200; + +export function load() { + try { const v = JSON.parse(localStorage.getItem(ITEM) || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } +} +function save(all) { try { localStorage.setItem(ITEM, JSON.stringify(all.slice(-KEEP))); } catch (e) {} } +export function forget() { try { localStorage.removeItem(ITEM); } catch (e) {} } + +// A prediction set against a run's counts: {bits, share} against the outcome on top. +export function compare(pred, counts, omitted = 0) { + const entries = Object.entries(counts || {}); + if (!entries.length) return null; + const total = entries.reduce((s, [, n]) => s + n, 0) + omitted; + entries.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)); + const [top, topN] = entries[0]; + const got = (counts[pred.bits] || 0) / total; + return { bits: pred.bits, share: pred.share, top, topShare: topN / total, got, right: top === pred.bits, off: Math.abs(got - pred.share), width: top.length }; +} + +// The tally over the predictions kept here. +export function calibration(all = load()) { + if (!all.length) return null; + const right = all.filter((p) => p.right).length; + const off = all.reduce((s, p) => s + p.off, 0) / all.length; + return { n: all.length, right, off }; +} + +const pct = (x) => `${(100 * x).toFixed(x < 0.1 ? 1 : 0)}%`; + +// The form shown in place of the result until the reader predicts or skips. +export function form({ width, onRun }) { + const bits = el('input', { class: 'qs-input', id: 'qs-predict-bits', placeholder: width ? '0'.repeat(width) : '00', autocomplete: 'off', spellcheck: 'false', inputmode: 'numeric', pattern: '[01]+' }); + const share = el('input', { class: 'qs-input', id: 'qs-predict-share', type: 'number', min: '0', max: '100', step: '1', placeholder: '50', inputmode: 'decimal' }); + const note = el('p', { class: 'qs-note', id: 'qs-predict-note', text: `Counts are keyed by the classical bits, highest bit first${width ? `: ${width} of them` : ''}.` }); + const go = () => { + const b = bits.value.trim(), s = parseFloat(share.value); + if (!/^[01]+$/.test(b)) { note.textContent = 'An outcome is a string of 0s and 1s, highest bit first.'; bits.focus(); return; } + if (width && b.length !== width) { note.textContent = `This program has ${width} classical bit${width === 1 ? '' : 's'}; the outcome has ${b.length}.`; bits.focus(); return; } + if (!(s >= 0 && s <= 100)) { note.textContent = 'The share is a percentage of the shots, from 0 to 100.'; share.focus(); return; } + onRun({ bits: b, share: s / 100 }); + }; + for (const i of [bits, share]) i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); + const node = el('form', { class: 'qs-predict', id: 'qs-predict', onsubmit: (e) => { e.preventDefault(); go(); } }, + el('div', { class: 'qs-k', text: 'Before it runs' }), + el('p', { class: 'qs-why', text: 'Which outcome do you expect most often, and in what share of the shots?' }), + el('div', { class: 'qs-row' }, + el('label', { class: 'qs-field', for: 'qs-predict-bits' }, el('span', { text: 'Most likely outcome' }), bits), + el('label', { class: 'qs-field', for: 'qs-predict-share' }, el('span', { text: 'Its share, %' }), share)), + note, + el('div', { class: 'qs-actions' }, + el('button', { class: 'qs-btn qs-primary', type: 'submit', id: 'qs-predict-run', text: 'Run' }), + el('button', { class: 'qs-btn', type: 'button', id: 'qs-predict-skip', text: 'Run without predicting', onclick: () => onRun(null) }))); + setTimeout(() => bits.focus(), 0); + return node; +} + +// The block that sets a prediction against the run, and records it. +export function block(pred, result) { + const c = compare(pred, result?.counts, result?.omitted_shots || 0); + const head = el('div', { class: 'qs-k', text: 'Your prediction' }); + if (!c) return el('div', { class: 'qs-block qs-predicted', id: 'qs-predicted' }, head, el('p', { class: 'qs-why', text: 'This run gave no counts to set it against: give the job shots.' })); + const all = load(); + all.push({ at: Date.now(), right: c.right, off: c.off }); + save(all); + const t = calibration(all); + const verdict = c.right + ? `The outcome you named came out on top; your share was ${(100 * c.off).toFixed(1)} points off.` + : `Another outcome came out on top: ${c.top} in ${pct(c.topShare)} of the shots.`; + return el('div', { class: 'qs-block qs-predicted', id: 'qs-predicted' }, head, + el('table', { class: 'qs-table' }, el('tbody', {}, + el('tr', {}, el('th', { scope: 'row', text: 'you said' }), el('td', { class: 'mono', text: `${c.bits} in ${pct(c.share)}` })), + el('tr', {}, el('th', { scope: 'row', text: 'the run' }), el('td', { class: 'mono', text: `${c.bits} in ${pct(c.got)}` })))), + el('p', { class: 'qs-why', id: 'qs-predict-verdict', text: verdict }), + el('p', { class: 'qs-note', id: 'qs-calibration', text: t.n === 1 ? 'Your first prediction kept on this device; each one after it adds to a tally here.' : `Over ${t.n} predictions kept on this device: you named the top outcome in ${t.right}, and your share was off by ${(100 * t.off).toFixed(1)} points on average.` })); +} diff --git a/wai/quantum-studio/src/shortcuts-view.js b/wai/quantum-studio/src/shortcuts-view.js new file mode 100644 index 000000000..6cd9a583c --- /dev/null +++ b/wai/quantum-studio/src/shortcuts-view.js @@ -0,0 +1,83 @@ +// The classical-shortcut lens: every exact classical route the engine knows for +// the program, each with its cost on it, and the cheapest. It runs when asked, +// not on every edit: a large program's order search takes a moment, and a +// thorough one, with its contraction search, can take minutes. A cheap route +// proves the design classically easy; none being cheap proves nothing. +import { el } from './dom.js'; + +const NAMES = { stabilizer: 'Stabilizer tableau', cutrank: 'Cut-rank sum', mps: 'Exact matrix product state', light_cone: 'Light cone', tensor_network: 'Tensor network', statevector: 'State vector' }; + +const bytes = (b) => { + if (b >= 2 ** 70) return `2^${Math.log2(b).toFixed(1)} B`; + for (const [u, s] of [['EiB', 60], ['PiB', 50], ['TiB', 40], ['GiB', 30], ['MiB', 20], ['KiB', 10]]) if (b >= 2 ** s) return `${(b / 2 ** s).toFixed(b / 2 ** s < 10 ? 1 : 0)} ${u}`; + return `${Math.round(b)} B`; +}; +const time = (s) => (s < 1e-3 ? 'under 1 ms' : s < 1 ? `${Math.round(s * 1e3)} ms` : s < 120 ? `${Math.round(s)} s` : s < 7200 ? `${Math.round(s / 60)} min` : s < 172800 ? `${Math.round(s / 3600)} h` : s < 6.3e7 ? `${Math.round(s / 86400)} days` : `${(s / 3.15e7).toExponential(1)} years`); +const size = (r) => { + const z = r.size || {}; + if (z.widest_cut != null) return `widest cut ${z.widest_cut} bits`; + if (z.log2_bond != null) return z.log2_bond === 0 ? 'a product state' : `bond ≤ 2^${z.log2_bond}`; + if (z.widest != null) return `cone of ${z.widest} qubits`; + if (z.log2_largest_tensor != null) return `largest tensor 2^${z.log2_largest_tensor}`; + if (z.log2_amplitudes != null) return `2^${z.log2_amplitudes} amplitudes`; + if (z.tableau_bits != null) return 'a tableau'; + return ''; +}; + +// `engine` answers shortcuts(qasm, thorough); `program()` is the editor's text. +export function shortcutsPanel({ engine, program }) { + const quick = el('button', { class: 'qs-btn qs-small', id: 'qs-shortcuts', type: 'button', text: 'Classical cost', title: 'Every exact classical route the engine knows for this program, with its cost' }); + const deep = el('button', { class: 'qs-btn qs-small', id: 'qs-shortcuts-thorough', type: 'button', text: 'Thorough', title: 'Also search a contraction tree and a longer cut-rank order: seconds to minutes on a large program' }); + const body = el('div', { class: 'qs-shortcuts-body', id: 'qs-shortcuts-body' }); + const node = el('div', { class: 'qs-shortcuts', id: 'qs-shortcuts-panel' }, + el('div', { class: 'qs-shortcuts-head' }, el('span', { class: 'qs-k', text: 'Classically' }), el('span', { class: 'qs-grow' }), quick, deep), + body); + let checkedFor = null; + let busy = false; + + function render(v) { + if (!v.ok) { body.replaceChildren(el('p', { class: 'qs-note', text: `The engine cannot read the program: ${v.error}` })); return; } + const best = v.cheapest?.counts?.route; + const rows = v.routes.map((r) => { + const costed = r.applies && r.log2_work != null; + return el('tr', { class: [r.applies ? '' : 'off', r.route === best ? 'best' : ''].filter(Boolean).join(' '), 'data-route': r.route, title: r.why }, + el('th', { scope: 'row', text: NAMES[r.route] || r.route }), + el('td', { text: r.applies ? size(r) : 'does not apply' }), + el('td', { class: 'num', text: costed ? bytes(r.memory_bytes) : '' }), + el('td', { class: 'num', text: costed ? `2^${Number(r.log2_work).toFixed(1)}` : r.applies ? 'not searched' : '' }), + el('td', { class: 'num', text: costed ? time(r.seconds_at_reference) : '' })); + }); + body.replaceChildren( + el('p', { class: 'qs-why', id: 'qs-shortcuts-summary', text: v.summary }), + el('div', { class: 'qs-scroll' }, el('table', { class: 'qs-table qs-routes', id: 'qs-routes' }, + el('thead', {}, el('tr', {}, el('th', { text: 'route' }), el('th', { text: 'size' }), el('th', { class: 'num', text: 'memory' }), el('th', { class: 'num', text: 'operations' }), el('th', { class: 'num', text: 'at 10¹⁰/s' }))), + el('tbody', {}, ...rows))), + el('p', { class: 'qs-note', text: `Each route is exact; operations are to a first sample, amplitude or expectation value. The times are at a reference rate, not a measurement. ${v.note[0].toUpperCase()}${v.note.slice(1)}.` })); + } + + async function check(thorough) { + if (busy) return; + busy = true; quick.disabled = deep.disabled = true; + const src = program(); + node.classList.remove('stale'); + body.replaceChildren(el('p', { class: 'qs-note', text: thorough ? 'Searching each route, a contraction tree among them: this can take minutes on a large program…' : 'Pricing each route…' })); + try { + const v = await engine.shortcuts(src, thorough); + checkedFor = src; + render(v); + if (program() !== src) stale(program()); + } catch (e) { body.replaceChildren(el('p', { class: 'qs-note', text: e.message })); } + finally { busy = false; quick.disabled = deep.disabled = false; } + } + // The program changed: an earlier check no longer describes it. + function stale(src) { + if (checkedFor == null) return; + const old = src !== checkedFor; + node.classList.toggle('stale', old); + body.querySelector('.qs-stale-note')?.remove(); + if (old) body.prepend(el('p', { class: 'qs-note qs-stale-note', text: 'For an earlier version of the program: check again.' })); + } + quick.addEventListener('click', () => check(false)); + deep.addEventListener('click', () => check(true)); + return { node, check, stale }; +} diff --git a/wai/quantum-studio/src/studio.css b/wai/quantum-studio/src/studio.css index 32dd7e8b5..7242c6229 100644 --- a/wai/quantum-studio/src/studio.css +++ b/wai/quantum-studio/src/studio.css @@ -127,6 +127,71 @@ .qs-ai-proposal { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 4px; } .qs-ai-ask { display: flex; gap: 8px; align-items: flex-end; padding: 8px 12px 12px; border-top: 1px solid var(--border); } .qs-ai-input { min-height: 40px; resize: vertical; font-family: var(--font-sans, system-ui, sans-serif); font-size: 13px; } +/* The critique, under the diagram: chips of measures, then the smells. */ +.qs-critique { display: flex; flex-direction: column; gap: 6px; padding: 8px 12px 10px; border-top: 1px solid var(--border); } +.qs-chips { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; } +.qs-chip { display: inline-flex; align-items: baseline; gap: 3px; padding: 2px 8px; border-radius: 6px; background: color-mix(in srgb, var(--text-muted) 10%, transparent); font: 11px var(--font-mono, ui-monospace, monospace); font-variant-numeric: tabular-nums; color: var(--text-secondary); white-space: nowrap; } +.qs-chip b { color: var(--text-primary); font-weight: 600; } +.qs-delta { color: var(--info-text); } +.qs-model { border: 1px dashed var(--border-subtle); background: transparent; cursor: help; } +.qs-smells { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } +.qs-smell { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "text acts" "where acts"; column-gap: 8px; padding: 6px 8px; border-radius: 6px; border: 1px solid color-mix(in srgb, var(--warn-text) 30%, var(--border)); background: color-mix(in srgb, var(--warn-text) 6%, var(--bg-base)); font-size: 12px; line-height: 1.4; } +.qs-smell:hover, .qs-smell:focus-within { border-color: var(--warn-text); } +.qs-smell-text { grid-area: text; color: var(--text-primary); } +.qs-smell-where { grid-area: where; } +.qs-smell-acts { grid-area: acts; display: flex; gap: 4px; align-items: center; } +.qc-flag .qc-box, .qc-flag .qc-ring { stroke: var(--warn-text); stroke-dasharray: 4 2; } +.qc-wire.qc-flag { stroke: color-mix(in srgb, var(--warn-text) 55%, var(--border-subtle)); stroke-dasharray: 5 3; } +.qc-smell .qc-box, .qc-smell .qc-ring { stroke: var(--warn-text); stroke-width: 2.6; stroke-dasharray: none; fill: color-mix(in srgb, var(--warn-text) 16%, var(--bg-surface)); } +.qc-wire.qc-smell { stroke: var(--warn-text); stroke-width: 2.5; stroke-dasharray: none; } +.qc-name.qc-smell { fill: var(--warn-text); font-weight: 650; } +/* The classical-shortcut lens. */ +.qs-shortcuts { display: flex; flex-direction: column; gap: 6px; padding: 8px 12px 10px; border-top: 1px solid var(--border); } +.qs-shortcuts-head { display: flex; align-items: center; gap: 6px; } +.qs-shortcuts-body:empty { display: none; } +.qs-shortcuts-body { display: flex; flex-direction: column; gap: 6px; } +.qs-shortcuts-body .qs-why, .qs-shortcuts-body .qs-note { margin: 0; } +.qs-scroll { overflow-x: auto; } +.qs-routes tr.off { color: var(--text-muted); } +.qs-routes tr.off th { color: var(--text-muted); } +.qs-routes tr.best th, .qs-routes tr.best td { color: var(--ok-text); font-weight: 600; } +.qs-shortcuts.stale .qs-routes, .qs-shortcuts.stale #qs-shortcuts-summary { opacity: 0.55; } +.qs-stale-note { color: var(--warn-text) !important; } +/* Alternatives: the atlas, then a card for each. */ +.qs-alt-aim { padding: 8px 12px 0; } +.qs-alt > .qs-note { padding: 6px 12px 0; margin: 0; } +.qs-alt-body { display: flex; flex-direction: column; gap: 8px; padding: 8px 12px 12px; } +.qs-alt-body:empty { display: none; } +.qs-atlas { width: 100%; max-width: 420px; height: auto; font-family: var(--font-mono, ui-monospace, monospace); } +.qs-atlas .qs-gridline { stroke: color-mix(in srgb, var(--text-muted) 18%, transparent); stroke-width: 1; } +.qs-atlas .qs-tick { fill: var(--text-muted); font-size: 9px; font-variant-numeric: tabular-nums; } +.qs-atlas .qs-axis { fill: var(--text-secondary); font-size: 9px; } +.qs-atlas .qs-ring { fill: none; stroke: var(--text-primary); stroke-width: 2; } +.qs-atlas .qs-dot { fill: var(--accent); stroke: var(--bg-surface); stroke-width: 2; } +.qs-atlas .qs-hit { fill: transparent; } +.qs-atlas .qs-pt { cursor: pointer; outline: none; } +.qs-atlas .qs-pt:hover .qs-dot, .qs-atlas .qs-pt:focus-visible .qs-dot { r: 7; stroke: var(--text-primary); } +.qs-atlas .qs-pt-n { fill: var(--text-primary); } +.qs-cands { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.qs-cand { display: flex; flex-direction: column; gap: 6px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-base); } +.qs-cand.on, .qs-cand:focus { border-color: var(--accent); outline: none; } +.qs-cand-head { display: flex; align-items: baseline; gap: 8px; } +.qs-cand-n { flex: none; width: 18px; height: 18px; display: inline-grid; place-items: center; border-radius: 50%; background: var(--accent); color: var(--on-accent, #0c0a09); font: 600 10px var(--font-mono, ui-monospace, monospace); } +.qs-cand-title { flex: 1; min-width: 0; font-size: 12px; line-height: 1.4; } +.qs-cand-facts { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.qs-cand-src summary { cursor: pointer; font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-muted); } +.qs-cand-src pre { margin-top: 4px; max-height: 220px; } +/* Predict first. */ +.qs-check { display: inline-flex; align-items: center; gap: 6px; font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); cursor: pointer; } +.qs-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; } +.qs-predict { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px dashed var(--accent); border-radius: 8px; } +.qs-predict .qs-why { margin: 0; } +.qs-predicted { padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--accent) 7%, var(--bg-base)); } +.qs-predicted .qs-why, .qs-predicted .qs-note { margin: 0; } +/* The assistant's modes, and the hint it offers but never sends. */ +.qs-ai-modes { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 12px 0; } +.qs-ai-modes .qs-select { width: 100%; } +.qs-stuck { border-style: dashed; border-color: var(--info-text); color: var(--info-text); } @media (max-width: 900px) { .qs-grid { grid-template-columns: minmax(0, 1fr); } .qs-program { min-height: 0; } diff --git a/wai/quantum-studio/src/worker.js b/wai/quantum-studio/src/worker.js index e97401d0d..67513a698 100644 --- a/wai/quantum-studio/src/worker.js +++ b/wai/quantum-studio/src/worker.js @@ -37,10 +37,15 @@ self.onmessage = async (ev) => { case 'draw': text = mod.engine_draw(ev.data.qasm, ev.data.width ?? 100); break; case 'layout': text = mod.engine_layout(ev.data.qasm); break; case 'verify': text = mod.engine_verify(JSON.stringify(ev.data.job)); break; + case 'critique': text = mod.engine_critique(ev.data.qasm); break; + case 'equivalent': text = mod.engine_equivalent(ev.data.a, ev.data.b); break; + case 'variants': text = mod.engine_variants(ev.data.qasm); break; + case 'shortcuts': text = mod.engine_shortcuts(ev.data.qasm, !!ev.data.thorough); break; default: throw new Error(`no operation ${op}`); } const value = JSON.parse(text); - if (value && value.error && op !== 'lint') self.postMessage({ id, ok: false, error: value.error }); + // A lint or a critique of a program that does not read is an answer that names the problem. + if (value && value.error && op !== 'lint' && op !== 'critique') self.postMessage({ id, ok: false, error: value.error }); else self.postMessage({ id, ok: true, value }); } catch (err) { self.postMessage({ id, ok: false, error: String(err && err.message ? err.message : err) }); diff --git a/wai/quantum-studio/test/studio.test.mjs b/wai/quantum-studio/test/studio.test.mjs index 53440d824..5b128e079 100644 --- a/wai/quantum-studio/test/studio.test.mjs +++ b/wai/quantum-studio/test/studio.test.mjs @@ -12,9 +12,23 @@ // - the same Bell job on the service gives the counts run here, shot for shot, and its // receipt checks here as the service's (left out with STUDIO_SKIP_SERVICE=1); // - Ctrl+Enter in the editor runs; the program and the history survive a reload; +// - the critique: its measures as the program changes and their change since the last +// run; a smell named with its lines, flagged on the diagram, shown, and fixed by the +// engine's rewrite, checked the same circuit; +// - Alternatives: Improve offers the engine's rewrite checked equivalent, plotted and +// applied; Explore without a model says it needs one; +// - the classical-shortcut lens: a Clifford ring with a closing rotation layer priced by +// the cut-rank sum, a Clifford circuit easy at any width, a contraction searched only +// when thorough, and a check marked stale once the program changes; +// - predict first: the form stops a run until the reader predicts, refuses an outcome of +// the wrong width, and sets the prediction against the counts; // - the assistant, against a scripted model server: a tool call run on the engine, a // proposal the engine cannot read sent back with its reason, the corrected one checked -// and run before it is offered, and Apply; +// and run before it is offered, and Apply; it asks questions first by default and speaks +// only when asked; offering a hint after refused runs only when the reader allows it, +// and never asking the model itself; Improve and Explore with the model, each candidate +// read, measured and compared by the engine, a rewrite that is not the same circuit +// left out with its reason; // - no console error and no failed request; at a phone's width nothing scrolls sideways. // Exit 0 iff every check passes. import { createServer } from 'node:http'; @@ -138,6 +152,110 @@ await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint' check((await page.evaluate(() => window.quantumStudio.view.state.doc.toString())) === program, 'the program survives a reload'); await page.waitForFunction((n) => document.querySelectorAll('.qs-hitem').length >= n, before + 1, { timeout: LONG }); check(true, 'the history survives a reload'); + +// The critique, as the program changes. +const doc = () => page.evaluate(() => window.quantumStudio.view.state.doc.toString()); +const setProgram = async (q) => { await page.evaluate((q) => window.quantumStudio.fill({ qasm: q }), q); await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, q); }; +const chip = (k) => page.$eval(`#qs-measures [data-k=${k}]`, (c) => c.textContent).catch(() => ''); +{ + await page.selectOption('#qs-example', 'bell'); + await page.waitForFunction(() => /2 gates/.test(document.querySelector('#qs-measures')?.textContent ?? ''), null, { timeout: LONG }); + const m = await page.textContent('#qs-measures'); + check(/2 qubits/.test(m) && /1 two-qubit/.test(m) && await page.isVisible('#qs-clifford') && /fidelity, modelled/.test(m) && (await page.$$('#qs-smells li')).length === 0, + `the critique gives the Bell pair's measures, that it is Clifford, a modelled fidelity, and no smells (${m})`); + const SMELLY = 'OPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\nx q[1];\nx q[1];\ncx q[0], q[1];\nc = measure q;\n'; + await setProgram(SMELLY); + await page.waitForSelector('.qs-smell[data-kind=cancels]', { timeout: LONG }); + const smell = await page.textContent('.qs-smell[data-kind=cancels] .qs-smell-text'); + check(smell === 'x on qubit 1 twice in a row (lines 5 and 6): together they do nothing', `a smell is named with its lines (${smell})`); + check((await page.$$('#qs-diagram .qc-item.qc-flag')).length === 2, 'its two gates are flagged on the diagram'); + await page.hover('.qs-smell[data-kind=cancels]'); + check((await page.$$('#qs-diagram .qc-item.qc-smell')).length === 2 && (await page.$$('#qs-diagram .qc-wire.qc-smell')).length === 0, 'pointing at the smell marks its gates, and not their wires'); + await page.click('.qs-smell[data-kind=cancels] button >> text=Show'); + const sel = await page.evaluate(() => { const v = window.quantumStudio.view, d = v.state.doc, r = v.state.selection.main; return [r.from === d.line(5).from, r.to === d.line(6).to]; }); + check(sel[0] && sel[1], 'Show selects its lines in the editor'); + await page.click('#qs-run'); await done(page); + await page.click('.qs-smell[data-kind=cancels] .qs-fix'); + await page.waitForFunction(() => !/x q\[1\];/.test(window.quantumStudio.view.state.doc.toString()), null, { timeout: LONG }); + const shows = await page.textContent('#qs-shows'); + check(/^Fixed: x on qubit 1/.test(shows) && /same tableau/.test(shows), `Fix applies the engine's rewrite, checked the same circuit (${shows})`); + await page.waitForFunction(() => /−2/.test(document.querySelector('#qs-measures [data-k=gates]')?.textContent ?? ''), null, { timeout: LONG }); + check(true, `the measures show their change since the last run (${await chip('gates')})`); + await page.click('.qs-editor .cm-content'); + await page.keyboard.press('ControlOrMeta+z'); + await page.waitForFunction((q) => window.quantumStudio.view.state.doc.toString() === q, SMELLY, { timeout: LONG }); + check(true, 'Undo brings the fixed lines back'); + await setProgram('OPENQASM 3.0;\nqubit[3] q;\nbit[1] c;\nh q[0];\nc[0] = measure q[0];\n'); + await page.waitForSelector('.qs-smell[data-kind=idle]', { timeout: LONG }); + check((await page.$$('#qs-diagram .qc-wire.qc-flag')).length === 2 && !(await page.$('.qs-smell[data-kind=idle] .qs-fix')), 'qubits nothing uses are flagged on their wires, with no Fix'); +} + +// Alternatives: Improve, without a model; Explore says it needs one. +{ + const MERGE = 'OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nh q[0];\nrz(0.25) q[0];\nrz(0.5) q[0];\nc[0] = measure q[0];\n'; + await setProgram(MERGE); + await page.click('#qs-improve'); + await page.waitForSelector('.qs-cand', { timeout: LONG }); + const card = await page.textContent('.qs-cand'); + check(/the engine/.test(card) && /same circuit · on every basis state/.test(card) && /2 gates/.test(card), `Improve offers the engine's rewrite, checked on every basis state (${card.slice(0, 160)})`); + check(await page.isVisible('#qs-atlas') && (await page.$$('#qs-atlas .qs-dot')).length === (await page.$$('.qs-cand')).length && (await page.$$('#qs-atlas .qs-ring')).length === 1, 'the atlas plots the draft and each alternative'); + check(/Connect a model/.test(await page.textContent('#qs-alt-body')), 'without a model, Improve says the engine’s are the only ones'); + check((await doc()) === MERGE, 'nothing changes the program until an alternative is applied'); + await page.click('.qs-cand .qs-apply'); + check(/rz\(0\.25 \+ 0\.5\) q\[0\];/.test(await doc()), 'Apply puts the alternative in the editor'); + await page.click('#qs-explore'); + await page.waitForSelector('#qs-alt-nomodel', { timeout: LONG }); + check(true, 'Explore without a model says it needs one'); +} + +// The classical-shortcut lens. +{ + let RING = 'OPENQASM 3.0;\nqubit[16] q;\nbit[16] c;\nh q;\n'; + for (let i = 0; i < 16; i++) RING += `cz q[${i}], q[${(i + 1) % 16}];\n`; + RING += 'rz(0.3) q;\nc = measure q;\n'; + await setProgram(RING); + await page.click('#qs-shortcuts'); + await page.waitForSelector('#qs-shortcuts-summary', { timeout: LONG }); + const summary = await page.textContent('#qs-shortcuts-summary'); + const cut = await page.textContent('#qs-routes tr[data-route=cutrank]'); + check(/cut-rank sum/.test(summary) && /widest cut 2 bits/.test(cut) && await page.$('#qs-routes tr.best[data-route=cutrank]') && /does not apply/.test(await page.textContent('#qs-routes tr[data-route=stabilizer]')), + `the lens prices a Clifford ring with a closing layer by the cut-rank sum (${summary})`); + check(/not searched/.test(await page.textContent('#qs-routes tr[data-route=tensor_network]')), 'the quick check leaves the contraction search for a thorough one'); + await page.click('#qs-shortcuts-thorough'); + await page.waitForFunction(() => /2\^/.test(document.querySelector('#qs-routes tr[data-route=tensor_network]')?.textContent ?? ''), null, { timeout: LONG }); + check(true, `a thorough check searches a contraction tree (${(await page.textContent('#qs-routes tr[data-route=tensor_network]')).trim()})`); + await page.click('.qs-editor .cm-content'); + await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.type('\nh q[0];'); + await page.waitForSelector('#qs-shortcuts-panel.stale .qs-stale-note', { timeout: LONG }); + check(true, 'a check is marked stale once the program changes'); + await page.selectOption('#qs-example', 'bell'); + await page.waitForFunction(() => /2 gates/.test(document.querySelector('#qs-measures')?.textContent ?? ''), null, { timeout: LONG }); + await page.click('#qs-shortcuts'); + await page.waitForFunction(() => /^Classically easy at any width/.test(document.querySelector('#qs-shortcuts-summary')?.textContent ?? '') && !document.querySelector('#qs-shortcuts-panel.stale'), null, { timeout: LONG }); + check(true, 'a Clifford circuit is classically easy at any width'); +} + +// Predict first. +{ + await page.selectOption('#qs-example', 'bell'); + await page.check('#qs-predict-first'); + const ran = (await page.$$('.qs-hitem')).length; + await page.click('#qs-run'); + await page.waitForSelector('#qs-predict', { timeout: LONG }); + check((await page.$$('.qs-hitem')).length === ran && !(await page.$('.qs-method')), 'with Predict first, the run waits for a prediction'); + await page.fill('#qs-predict-bits', '000'); + await page.fill('#qs-predict-share', '50'); + await page.click('#qs-predict-run'); + check(/2 classical bits; the outcome has 3/.test(await page.textContent('#qs-predict-note')), 'an outcome of the wrong width is refused, with the width'); + await page.fill('#qs-predict-bits', '00'); + await page.click('#qs-predict-run'); + await done(page); + await page.waitForSelector('#qs-predicted', { timeout: LONG }); + const pred = await page.textContent('#qs-predicted'); + check(/you said00 in 50%/.test(pred) && /the run00 in \d/.test(pred) && /(came out on top|Another outcome)/.test(pred) && /first prediction/.test(pred), `the prediction is set against the counts (${pred})`); + await page.uncheck('#qs-predict-first'); +} // The assistant, against a scripted model server: it calls a tool, proposes a program // the engine cannot read, gets the engine's reason back, and proposes a corrected one, // which the Studio checks and runs before offering it. @@ -154,6 +272,15 @@ check(true, 'the history survives a reload'); const r = JSON.parse(body); asked.push(r); const msgs = r.messages; + // Alternatives' calls carry no tools: candidates, one of them wrong on purpose. + if (!r.tools) { + const want = msgs[1].content; + const content = /^Write \d+ programs that are the same circuit/.test(want) + ? 'Idea: the two Hadamards cancel, and the X moves past the CNOT.\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\ncx q[0], q[1];\nx q[1];\nc = measure q;\n```\nIdea: drop the X.\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\ncx q[0], q[1];\nc = measure q;\n```' + : 'Idea: a GHZ state on three qubits.\n```qasm\nOPENQASM 3.0;\nqubit[3] q;\nbit[3] c;\nh q[0];\ncx q[0], q[1];\ncx q[1], q[2];\nc = measure q;\n```\n**Idea:** entangle with CZ instead.\n```qasm\nOPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\nh q[1];\ncz q[0], q[1];\nh q[1];\nc = measure q;\n```'; + res.writeHead(200, { ...cors, 'content-type': 'application/json' }).end(JSON.stringify({ choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }] })); + return; + } const told = msgs.some((m) => m.role === 'user' && /could not read the program you proposed/.test(m.content)); const toolDone = msgs.some((m) => m.role === 'tool'); let message; @@ -181,6 +308,41 @@ check(true, 'the history survives a reload'); check(toolMsg && JSON.parse(toolMsg.content).qubits === 2, 'the tool\'s answer went back to the model'); await page.click('.qs-ai-apply'); check((await page.evaluate(() => window.quantumStudio.view.state.doc.toString())) === GOOD, 'Apply puts the checked program in the editor'); + check((await page.inputValue('#qs-ai-stance')) === 'questions' && (await page.inputValue('#qs-ai-initiative')) === 'asked' && /ask one short question/.test(asked[0].messages[0].content), + 'by default the assistant asks questions first and speaks only when asked'); + const quick = await page.$$eval('.qs-ai-quick button', (bs) => bs.map((b) => b.textContent)); + check(quick.includes('Ask me a question') && quick.includes('Hint'), `it can be asked for a question or a hint (${quick.join(', ')})`); + check(asked.every((r) => !r.tools || r.tools.some((t) => t.function.name === 'critique_qasm') && r.tools.some((t) => t.function.name === 'check_equivalent')), 'its tools include the critique and the equivalence check'); + // Runs refused twice: no hint offered while it speaks only when asked; offered, not sent, when allowed. + const calls = asked.length; + await setProgram('OPENQASM 3.0;\nqubit[1] q;\nfoo q[0];\n'); + for (let i = 0; i < 2; i++) { await page.click('#qs-run'); await done(page); } + check(await page.isHidden('#qs-ai-stuck'), 'while it speaks only when asked, refused runs offer no hint'); + await page.selectOption('#qs-ai-initiative', 'hint'); + for (let i = 0; i < 2; i++) { await page.click('#qs-run'); await done(page); } + check(await page.isVisible('#qs-ai-stuck') && asked.length === calls, 'allowed to, it offers a hint after refused runs, and asks the model nothing'); + await page.selectOption('#qs-ai-initiative', 'asked'); + // Improve and Explore with the model: the engine reads, measures and compares each. + const DRAFT = 'OPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\nh q[0];\nx q[1];\ncx q[0], q[1];\nc = measure q;\n'; + await setProgram(DRAFT); + await page.click('#qs-improve'); + await page.waitForFunction(() => document.querySelectorAll('.qs-cand').length && !/looking/.test(document.querySelector('#qs-alt-body')?.textContent ?? ''), null, { timeout: LONG }); + const cards = await page.$$eval('.qs-cand', (cs) => cs.map((c) => c.textContent)); + const refused = await page.textContent('#qs-alt-refused').catch(() => ''); + check(cards.some((t) => /the model/.test(t) && /the two Hadamards cancel/.test(t) && /same circuit · equal tableaus/.test(t)) && /drop the X: not the same circuit/.test(refused), + `Improve with the model: its rewrite checked the same circuit, its wrong one left out with the reason (${refused})`); + await page.fill('#qs-explore-aim', 'another entangler'); + await page.click('#qs-explore'); + await page.waitForFunction(() => /a GHZ state/.test(document.querySelector('#qs-alt-body')?.textContent ?? ''), null, { timeout: LONG }); + const ideas = await page.$$eval('.qs-cand', (cs) => cs.map((c) => c.textContent)); + const muse = asked.filter((r) => !r.tools).map((r) => r.messages[1].content); + check(ideas.length === 2 && ideas.some((t) => /3 qubits/.test(t) && /a different circuit/.test(t)) && ideas.some((t) => /entangle with CZ instead/.test(t)) && muse.some((m) => /toward this aim: another entangler/.test(m)), + `Explore: the model's ideas, each read and measured by the engine (${ideas.map((t) => t.slice(0, 60)).join(' | ')})`); + check((await doc()) === DRAFT, 'neither changes the program until one is applied'); + // The candidates come back within the editor's redraw delay: wait for the draft's diagram. + await page.waitForFunction(() => /4 gates/.test(document.querySelector('#qs-lint')?.textContent ?? '') && document.querySelectorAll('#qs-diagram .qc-flag').length === 2, null, { timeout: LONG }); + check(!/null/.test(await page.textContent('#qs-alt-body')) && !/null/.test(await page.textContent('#qs-measures')), 'no empty part is written out as “null”'); + await page.screenshot({ path: join(SHOTS, 'studio-alternatives.png'), fullPage: true }); model.close(); } check(seen.errors.length === 0, `no console error${seen.errors.length ? ': ' + seen.errors.join(' | ') : ''}`); diff --git a/wai/tools/quantum-meter/Cargo.lock b/wai/tools/quantum-meter/Cargo.lock index 4f55bb65b..4ba2668d3 100644 --- a/wai/tools/quantum-meter/Cargo.lock +++ b/wai/tools/quantum-meter/Cargo.lock @@ -1017,7 +1017,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/tools/quantum-wgsl-check/Cargo.lock b/wai/tools/quantum-wgsl-check/Cargo.lock index cfa6c68ef..c4fb684b4 100644 --- a/wai/tools/quantum-wgsl-check/Cargo.lock +++ b/wai/tools/quantum-wgsl-check/Cargo.lock @@ -599,7 +599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index ca0b3d0cb..aea581f98 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -713,7 +713,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-cli/Cargo.lock b/wai/wai-quantum-cli/Cargo.lock index 10d2bcfb7..00a59a28b 100644 --- a/wai/wai-quantum-cli/Cargo.lock +++ b/wai/wai-quantum-cli/Cargo.lock @@ -1120,7 +1120,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", @@ -1133,7 +1133,7 @@ dependencies = [ [[package]] name = "wai-quantum-cli" -version = "0.1.0" +version = "0.1.2" dependencies = [ "clap", "clap_complete", diff --git a/wai/wai-quantum-cli/Cargo.toml b/wai/wai-quantum-cli/Cargo.toml index 19a7c2834..82e9f0267 100644 --- a/wai/wai-quantum-cli/Cargo.toml +++ b/wai/wai-quantum-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum-cli" -version = "0.1.0" +version = "0.1.2" edition = "2024" rust-version = "1.97" license = "Apache-2.0" @@ -34,4 +34,4 @@ getrandom = "0.4" # result's receipt still verifies after any JSON writer has carried it. serde_json = { version = "1", features = ["float_roundtrip"] } ureq = { version = "3", optional = true, features = ["json"] } -wai-quantum = { version = "0.6.12", path = "../wai-quantum", default-features = false, features = ["full"] } +wai-quantum = { version = "0.6.14", path = "../wai-quantum", default-features = false, features = ["full"] } diff --git a/wai/wai-quantum-cli/README.md b/wai/wai-quantum-cli/README.md index 4ac2b2ffc..b2ade0b08 100644 --- a/wai/wai-quantum-cli/README.md +++ b/wai/wai-quantum-cli/README.md @@ -95,6 +95,30 @@ q1: ───────────⊕──●───M1── q2: ──Rz(π/4)─────⊕───M2── ``` +## Critique, equivalence and improvement + +```sh +wai-quantum critique prog.qasm # its measures, a modelled fidelity, and its smells with their lines +wai-quantum equivalent a.qasm b.qasm # the same circuit? exit 0 the same, 1 different, 2 unchecked +wai-quantum improve prog.qasm > better.qasm # its smells fixed; what changed, and the check, on standard error +wai-quantum shortcuts prog.qasm # each exact classical route to simulating it, and its cost here +``` + +The critique's figures are computed, not estimated: gates by kind, two-qubit gates, depth and two-qubit depth, the T count, and whether the program is a Clifford circuit. Its fidelity is a model at reference error rates, and says so. Its smells are gates that cancel, rotations that merge or do nothing, idle qubits, and qubits acted on but never measured. + +`equivalent` compares two programs up to a global phase. Clifford circuits are compared by their tableaus at any size, and others on every basis state up to 8 qubits. Between 8 and 22 qubits it compares them on random product states, which is evidence, not proof. Beyond that it answers unchecked, with the reason. `improve` rewrites only statements alone on their lines, and prints a rewrite only when the check does not find it different. + +`shortcuts` lists every exact classical route the engine knows for simulating the program, each with its memory and operations, and names the cheapest. The routes: + +- the stabilizer tableau, for Clifford circuits; +- the cut-rank sum, for a Clifford circuit followed by one layer of one-qubit gates; +- an exact matrix product state, at a bond bounded across each cut by the operator Schmidt ranks of the gates that cross it; +- the light cone of a one-qubit expectation value; +- a tensor-network contraction, with `--thorough`, which also searches longer for a cut-rank order; +- the state vector. + +A cheap route proves a design classically easy. None being cheap proves nothing about hardness: other methods, approximate ones above all, may cost far less. + ## The service `wai-quantum submit` sends a job to `https://wai.transaction.science/quantum-jobs`, or to `--service URL` (also read from `$WAI_QUANTUM_SERVICE`). It waits for a long job to finish, then prints the result just as `run` does. @@ -106,6 +130,10 @@ q2: ──Rz(π/4)─────⊕───M2── - `run_quantum_circuit` - `plan_quantum_circuit` - `lint_qasm` +- `critique_qasm` +- `check_equivalent` +- `improve_qasm` +- `classical_shortcuts` - `draw_circuit` - `verify_quantum_result` - `list_methods` diff --git a/wai/wai-quantum-cli/smoke.sh b/wai/wai-quantum-cli/smoke.sh index bd4430188..156b7c1c1 100644 --- a/wai/wai-quantum-cli/smoke.sh +++ b/wai/wai-quantum-cli/smoke.sh @@ -75,6 +75,25 @@ has "$tmp/draw" "^q0: .*H.*●" has "$tmp/draw" "^q2: .*⊕.*M2" echo "draw: ok" +printf 'OPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nh q[0];\nh q[0];\nh q[0];\ncx q[0], q[1];\nc = measure q;\n' > "$tmp/twice.qasm" +"$W" critique "$tmp/twice.qasm" > "$tmp/critique" +has "$tmp/critique" "2 qubits, 4 gates (3 one-qubit, 1 two-qubit, 0 three-qubit), depth 4" +has "$tmp/critique" "^ cancels: h on qubit 0 twice in a row (lines 4 and 5)" +"$W" improve "$tmp/twice.qasm" > "$tmp/better.qasm" 2> "$tmp/improve" +has "$tmp/improve" "^gates 4 → 2" +"$W" equivalent "$tmp/twice.qasm" "$tmp/better.qasm" > "$tmp/same" +has "$tmp/same" "^equivalent: both are Clifford circuits with the same tableau" +if "$W" equivalent "$tmp/twice.qasm" "$tmp/ghz.qasm" > "$tmp/differ"; then fail "a 2-qubit and a 3-qubit program were the same"; fi +has "$tmp/differ" "^different: one has 2 qubits and the other 3" +echo "critique, improve, equivalent: ok" + +"$W" shortcuts "$tmp/ghz.qasm" > "$tmp/shortcuts" +has "$tmp/shortcuts" "^Classically easy at any width" +printf 'OPENQASM 3.0;\nqubit[8] q;\nh q;\ncz q[0], q[1];\ncz q[1], q[2];\ncz q[2], q[3];\ncz q[3], q[4];\ncz q[4], q[5];\ncz q[5], q[6];\ncz q[6], q[7];\ncz q[7], q[0];\nrz(0.3) q;\n' > "$tmp/ring.qasm" +"$W" shortcuts "$tmp/ring.qasm" --json > "$tmp/ring.json" +python3 -c 'import json,sys; v=json.load(open(sys.argv[1])); c=[r for r in v["routes"] if r["route"]=="cutrank"][0]; assert c["applies"] and c["size"]["widest_cut"]==2, c; assert v["cheapest"]["counts"]["route"]=="cutrank", v["cheapest"]' "$tmp/ring.json" +echo "shortcuts: ok" + printf '%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ @@ -87,7 +106,8 @@ import json, sys lines = [json.loads(l) for l in open(sys.argv[1])] assert [m["id"] for m in lines] == [1, 2, 3], lines assert lines[0]["result"]["serverInfo"]["name"] == "wai-quantum" -assert "run_quantum_circuit" in [t["name"] for t in lines[1]["result"]["tools"]] +names = [t["name"] for t in lines[1]["result"]["tools"]] +assert {"run_quantum_circuit", "critique_qasm", "check_equivalent", "improve_qasm", "classical_shortcuts"} <= set(names), names job = lines[2]["result"]["structuredContent"] assert job["result"]["expectations"][0]["value"] == 1.0, job EOF diff --git a/wai/wai-quantum-cli/src/main.rs b/wai/wai-quantum-cli/src/main.rs index a440c10c0..f82086c85 100644 --- a/wai/wai-quantum-cli/src/main.rs +++ b/wai/wai-quantum-cli/src/main.rs @@ -8,6 +8,10 @@ //! wai-quantum plan prog.qasm # the method and why, without running //! wai-quantum lint prog.qasm # each problem with its line //! wai-quantum draw prog.qasm # the circuit as text +//! wai-quantum critique prog.qasm # its measures and smells, each with its lines +//! wai-quantum equivalent a.qasm b.qasm # the same circuit? and how the engine knows +//! wai-quantum improve prog.qasm > better.qasm # its smells fixed, checked the same circuit +//! wai-quantum shortcuts prog.qasm # each exact classical route and its cost here //! wai-quantum submit prog.qasm # on the job service //! wai-quantum verify result.json # a result's receipt, or a lab receipt //! wai-quantum key # the key this user signs with @@ -28,7 +32,7 @@ mod mcp; use clap::{Args, CommandFactory, Parser, Subcommand}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; -use wai_quantum::engine::{catalog, draw, job}; +use wai_quantum::engine::{catalog, critique, draw, equiv, job, shortcuts}; use wai_quantum_cli::keys; #[derive(Parser)] @@ -78,6 +82,43 @@ enum Command { #[arg(long, default_value_t = 100)] width: usize, }, + /// A program's measures (gates, two-qubit gates, depth, T count, a modelled fidelity) and its smells, each with its lines + Critique { + /// The program; `-` or nothing reads standard input + file: Option, + /// Print the critique as JSON + #[arg(long)] + json: bool, + }, + /// Whether two programs are the same circuit, up to a global phase, and how the engine knows (exit 0 the same, 1 different, 2 unchecked) + Equivalent { + /// The first program + a: PathBuf, + /// The second program + b: PathBuf, + /// Print the verdict as JSON + #[arg(long)] + json: bool, + }, + /// Print the program with its smells fixed, checked the same circuit; what changed goes to standard error + Improve { + /// The program; `-` or nothing reads standard input + file: Option, + /// Print every candidate (all the fixes, and each alone) with its measures and verdict, as JSON + #[arg(long)] + json: bool, + }, + /// Each exact classical route to simulating a program, with its cost on it, and the cheapest for each question + Shortcuts { + /// The program; `-` or nothing reads standard input + file: Option, + /// Also search a contraction tree for one amplitude, and search longer for a cut-rank order (seconds to minutes on a large circuit) + #[arg(long)] + thorough: bool, + /// Print the routes as JSON + #[arg(long)] + json: bool, + }, /// Send a program to the job service and print its result #[cfg(feature = "service")] Submit { @@ -108,7 +149,7 @@ enum Command { #[arg(long, default_value_t = DEFAULT_MEMORY_GB)] memory_gb: u64, }, - /// Serve run, plan, lint, draw and verify to an agent over standard input and output (MCP) + /// Serve run, plan, lint, critique, the equivalence check, improve, the classical shortcuts, draw and verify to an agent over standard input and output (MCP) Mcp { /// The memory a job may take, GiB #[arg(long, default_value_t = DEFAULT_MEMORY_GB)] @@ -249,6 +290,43 @@ pub fn run_here(req: &Value, memory_gb: u64, key: &wai_quantum::ed25519_dalek::S job::run(req, memory_gb << 30, key, json!({ "unmetered": UNMETERED })) } +/// A critique as a reader takes it in. +fn print_critique(name: &str, c: &Value) { + let n = |k: &str| c[k].as_u64().unwrap_or(0); + println!("{name}: {} qubits, {} gates ({} one-qubit, {} two-qubit, {} three-qubit), depth {}, two-qubit depth {}", n("qubits"), n("gates"), n("one_qubit"), n("two_qubit"), n("three_qubit"), n("depth"), n("two_qubit_depth")); + let clifford = if c["clifford"] == true { "a Clifford circuit".to_string() } else { format!("{} gates off the Clifford angles", n("non_clifford")) }; + println!(" {} T gates; {clifford}; {} qubits measured", n("t_count"), n("measured")); + let f = &c["fidelity_model"]; + println!(" fidelity {:.4}, modelled: {}", f["estimate"].as_f64().unwrap_or(f64::NAN), f["note"].as_str().unwrap_or("")); + for s in c["smells"].as_array().into_iter().flatten() { + let fix = if s["fixable"] == true { " (improve fixes it)" } else { "" }; + println!(" {}: {}{fix}", s["kind"].as_str().unwrap_or(""), s["message"].as_str().unwrap_or("")); + } +} + +/// The classical routes as a reader takes them in. +fn print_shortcuts(v: &Value) { + println!("{}", v["summary"].as_str().unwrap_or("")); + println!(); + println!("{:<15} {:>10} {:>9} {:>12} why", "route", "memory", "log2 ops", "at 10^10/s"); + for r in v["routes"].as_array().into_iter().flatten() { + let name = r["route"].as_str().unwrap_or(""); + let why = r["why"].as_str().unwrap_or(""); + if r["applies"] != true || r.get("log2_work").is_none() { + let mark = if r["applies"] == true { "—" } else { "no" }; + println!("{name:<15} {mark:>10} {:>9} {:>12} {why}", "", ""); + continue; + } + let bytes = r["memory_bytes"].as_f64().unwrap_or(0.0); + let mem = if bytes < 1024.0 { format!("{bytes:.0} B") } else { format!("2^{:.1} B", bytes.log2()) }; + let secs = r["seconds_at_reference"].as_f64().unwrap_or(f64::NAN); + let t = if secs < 1.0 { format!("{:.1e} s", secs) } else if secs < 3.15e7 { format!("{secs:.3e} s") } else { format!("{:.1e} y", secs / 3.15e7) }; + println!("{name:<15} {mem:>10} {:>9.2} {t:>12} {why}", r["log2_work"].as_f64().unwrap_or(f64::NAN)); + } + println!(); + println!("{}", v["note"].as_str().unwrap_or("")); +} + /// A job body as a reader takes it in. fn print_job(body: &Value, top: usize) { let r = &body["result"]; @@ -382,6 +460,73 @@ fn main() { std::process::exit(1); } } + Command::Critique { file, json } => { + let name = file.as_deref().filter(|p| *p != Path::new("-")).map(|p| p.display().to_string()).unwrap_or_else(|| "".into()); + let src = read_input(file.as_deref()).unwrap_or_else(|e| die(&e)); + let c = critique::critique(&src); + if json { + println!("{c}"); + } else if c["ok"] == true { + print_critique(&name, &c); + } else { + die(c["error"].as_str().unwrap_or("the program does not read")); + } + if c["ok"] != true { + std::process::exit(1); + } + } + Command::Equivalent { a, b, json } => { + let (x, y) = (read_input(Some(&a)).unwrap_or_else(|e| die(&e)), read_input(Some(&b)).unwrap_or_else(|e| die(&e))); + let v = equiv::equivalent(&x, &y); + if json { + println!("{v}"); + } else { + println!("{}: {} ({})", v["verdict"].as_str().unwrap_or(""), v["why"].as_str().unwrap_or(""), v["how"].as_str().unwrap_or("")); + } + std::process::exit(match v["verdict"].as_str() { + Some("equivalent") => 0, + Some("different") => 1, + _ => 2, + }); + } + Command::Improve { file, json } => { + let src = read_input(file.as_deref()).unwrap_or_else(|e| die(&e)); + let v = critique::variants(&src); + if v["ok"] != true { + die(v["error"].as_str().unwrap_or("the program does not read")); + } + if json { + println!("{v}"); + return; + } + match v["candidates"].as_array().and_then(|c| c.first()) { + Some(c) => { + for ch in c["changes"].as_array().into_iter().flatten() { + eprintln!("fixed: {}", ch.as_str().unwrap_or("")); + } + let (b, m) = (&v["base"], &c["measures"]); + eprintln!("gates {} → {}, two-qubit {} → {}, depth {} → {}", b["gates"], m["gates"], b["two_qubit"], m["two_qubit"], b["depth"], m["depth"]); + eprintln!("checked: {}", c["equivalence"]["why"].as_str().unwrap_or("")); + print!("{}", c["qasm"].as_str().unwrap_or("")); + } + None => { + eprintln!("nothing to improve that the engine can check: no gates cancel or merge; the program is unchanged"); + print!("{src}"); + } + } + } + Command::Shortcuts { file, thorough, json } => { + let src = read_input(file.as_deref()).unwrap_or_else(|e| die(&e)); + let v = shortcuts::shortcuts(&src, thorough); + if v["ok"] != true { + die(v["error"].as_str().unwrap_or("the program does not read")); + } + if json { + println!("{v}"); + } else { + print_shortcuts(&v); + } + } Command::Draw { file, width } => { let src = read_input(file.as_deref()).unwrap_or_else(|e| die(&e)); let p = wai_quantum::qasm::parse(&src).unwrap_or_else(|e| die(&e)); @@ -510,6 +655,9 @@ mod tests { } // An unknown flag is an error, not ignored. assert!(Cli::try_parse_from(["wai-quantum", "run", "p.qasm", "--shot", "10"]).is_err()); + assert!(matches!(Cli::try_parse_from(["wai-quantum", "equivalent", "a.qasm", "b.qasm", "--json"]).unwrap().command, Command::Equivalent { json: true, .. })); + assert!(matches!(Cli::try_parse_from(["wai-quantum", "improve", "-"]).unwrap().command, Command::Improve { json: false, .. })); + assert!(matches!(Cli::try_parse_from(["wai-quantum", "shortcuts", "p.qasm", "--thorough"]).unwrap().command, Command::Shortcuts { thorough: true, json: false, .. })); // The research tools take their own words. match Cli::try_parse_from(["wai-quantum", "lab", "qec", "-d", "5", "--p", "0.003"]).unwrap().command { Command::Lab { args } => assert_eq!(args, ["qec", "-d", "5", "--p", "0.003"]), diff --git a/wai/wai-quantum-cli/src/mcp.rs b/wai/wai-quantum-cli/src/mcp.rs index 9d8f140c1..5a0bddee1 100644 --- a/wai/wai-quantum-cli/src/mcp.rs +++ b/wai/wai-quantum-cli/src/mcp.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use std::io::{BufRead, Write}; use wai_quantum::ed25519_dalek::SigningKey; -use wai_quantum::engine::{catalog, draw, job}; +use wai_quantum::engine::{catalog, critique, draw, equiv, job, shortcuts}; const PROTOCOL: &str = "2025-06-18"; @@ -55,6 +55,30 @@ fn tools() -> Value { "description": "Parse an OpenQASM program as the engine reads it: its qubits, gates and measured bits, or the first problem with its line.", "inputSchema": qasm_schema(json!({})), }, + { + "name": "critique_qasm", + "title": "Critique an OpenQASM program", + "description": "A program's measures, computed by the engine: gates by kind, one-, two- and three-qubit gates, depth and two-qubit depth, T count, whether it is a Clifford circuit, and a fidelity under reference error rates, which is a model and not a run. And its smells, each with its lines: gates that cancel, rotations that merge or do nothing, idle qubits, qubits acted on but never measured.", + "inputSchema": qasm_schema(json!({})), + }, + { + "name": "check_equivalent", + "title": "Check two programs are the same circuit", + "description": "Whether two programs are the same circuit up to a global phase, and how the engine knows: equal tableaus for Clifford circuits at any size, every basis state to 8 qubits, random product states to 22 qubits (evidence, not proof), or unchecked with the reason.", + "inputSchema": { "type": "object", "required": ["a", "b"], "properties": { "a": { "type": "string", "description": "The first program." }, "b": { "type": "string", "description": "The second program." } } }, + }, + { + "name": "improve_qasm", + "title": "Improve an OpenQASM program", + "description": "The program with its smells fixed in its own text (all of them, and each alone), each with its measures and the equivalence check's verdict against the original. A candidate the check finds different is never returned.", + "inputSchema": qasm_schema(json!({})), + }, + { + "name": "classical_shortcuts", + "title": "Find a program's classical shortcuts", + "description": "Every exact classical route the engine knows for simulating a program, with its cost on it: the stabilizer tableau (Clifford circuits), the cut-rank sum (a Clifford circuit then one layer of one-qubit gates), an exact matrix product state at a rigorous bond bound, the light cone of a one-qubit expectation value, a tensor-network contraction (thorough only) and the state vector. Each gives its memory and the log2 of its operations, with the cheapest for each question. A cheap route proves the program classically easy; none being cheap proves nothing about hardness.", + "inputSchema": qasm_schema(json!({ "thorough": { "type": "boolean", "description": "Also search a contraction tree, and search longer for a cut-rank order: seconds to minutes on a large program." } })), + }, { "name": "draw_circuit", "title": "Draw a circuit", @@ -89,6 +113,13 @@ fn call(name: &str, args: &Value, memory_gb: u64, key: &SigningKey) -> Result Ok(job::lint(qasm()?)), + "critique_qasm" => Ok(critique::critique(qasm()?)), + "check_equivalent" => { + let side = |k: &str| args.get(k).and_then(Value::as_str).ok_or(format!("give \"{k}\": a program")); + Ok(equiv::equivalent(side("a")?, side("b")?)) + } + "improve_qasm" => Ok(critique::variants(qasm()?)), + "classical_shortcuts" => Ok(shortcuts::shortcuts(qasm()?, args.get("thorough").and_then(Value::as_bool).unwrap_or(false))), "draw_circuit" => { let p = wai_quantum::qasm::parse(qasm()?)?; let width = args.get("width").and_then(Value::as_u64).unwrap_or(100).max(20) as usize; @@ -168,7 +199,7 @@ mod tests { assert!(handle(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), 1, &key()).is_none()); let list = handle(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }), 1, &key()).unwrap(); let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); - assert_eq!(names, ["run_quantum_circuit", "plan_quantum_circuit", "lint_qasm", "draw_circuit", "verify_quantum_result", "list_methods"]); + assert_eq!(names, ["run_quantum_circuit", "plan_quantum_circuit", "lint_qasm", "critique_qasm", "check_equivalent", "improve_qasm", "classical_shortcuts", "draw_circuit", "verify_quantum_result", "list_methods"]); let run = handle( &json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "run_quantum_circuit", "arguments": { "qasm": "OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];", "observables": ["Z0 Z1"] } } }), 1, @@ -193,4 +224,21 @@ mod tests { let none = handle(&json!({ "jsonrpc": "2.0", "id": 3, "method": "nonsense" }), 1, &key()).unwrap(); assert_eq!(none["error"]["code"], -32601); } + + #[test] + fn an_agent_critiques_compares_and_improves_a_program() { + let tool = |name: &str, args: Value| handle(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": args } }), 1, &key()).unwrap()["result"].clone(); + let draft = "qubit[2] q;\nh q[0];\nh q[0];\ncx q[0], q[1];\n"; + let c = tool("critique_qasm", json!({ "qasm": draft })); + assert_eq!(c["structuredContent"]["smells"][0]["kind"], "cancels", "{c}"); + let v = tool("improve_qasm", json!({ "qasm": draft })); + let better = v["structuredContent"]["candidates"][0]["qasm"].as_str().unwrap().to_string(); + assert_eq!(v["structuredContent"]["candidates"][0]["measures"]["gates"], 1, "{v}"); + let e = tool("check_equivalent", json!({ "a": draft, "b": better })); + assert_eq!(e["structuredContent"]["verdict"], "equivalent", "{e}"); + assert_eq!(tool("check_equivalent", json!({ "a": draft, "b": "qubit[2] q;" }))["structuredContent"]["verdict"], "different"); + assert_eq!(tool("check_equivalent", json!({ "a": draft }))["isError"], true); + let s = tool("classical_shortcuts", json!({ "qasm": draft })); + assert!(s["structuredContent"]["summary"].as_str().unwrap().starts_with("Classically easy"), "{s}"); + } } diff --git a/wai/wai-quantum-desktop/Cargo.lock b/wai/wai-quantum-desktop/Cargo.lock index 083c54b56..b284cbb5a 100644 --- a/wai/wai-quantum-desktop/Cargo.lock +++ b/wai/wai-quantum-desktop/Cargo.lock @@ -4130,7 +4130,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", @@ -4143,7 +4143,7 @@ dependencies = [ [[package]] name = "wai-quantum-cli" -version = "0.1.0" +version = "0.1.2" dependencies = [ "clap", "clap_complete", diff --git a/wai/wai-quantum-desktop/Cargo.toml b/wai/wai-quantum-desktop/Cargo.toml index 198c4f423..c5c925d36 100644 --- a/wai/wai-quantum-desktop/Cargo.toml +++ b/wai/wai-quantum-desktop/Cargo.toml @@ -16,7 +16,7 @@ tauri = { version = "2", features = [] } serde_json = { version = "1", features = ["float_roundtrip"] } # The assistant's model server, called by the app (a local one by default). ureq = { version = "3", features = ["json"] } -wai-quantum = { version = "0.6.12", path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt"] } +wai-quantum = { version = "0.6.14", path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt"] } wai-quantum-cli = { version = "0.1.0", path = "../wai-quantum-cli", default-features = false } [features] diff --git a/wai/wai-quantum-desktop/capabilities/default.json b/wai/wai-quantum-desktop/capabilities/default.json index d2afe49d5..5bf461409 100644 --- a/wai/wai-quantum-desktop/capabilities/default.json +++ b/wai/wai-quantum-desktop/capabilities/default.json @@ -1,6 +1,6 @@ { "identifier": "default", - "description": "The Studio's window: the core bridge, by which it calls the app's own commands (run, plan, lint, draw, verify, save).", + "description": "The Studio's window: the core bridge, by which it calls the app's own commands (run, plan, lint, critique, shortcuts, draw, verify, save).", "windows": ["main"], "permissions": ["core:default"] } diff --git a/wai/wai-quantum-desktop/src/main.rs b/wai/wai-quantum-desktop/src/main.rs index 112d53f02..54d7e6e2d 100644 --- a/wai/wai-quantum-desktop/src/main.rs +++ b/wai/wai-quantum-desktop/src/main.rs @@ -5,7 +5,10 @@ //! large one cannot take the app's memory with it, and Cancel ends it. The //! worker plans and runs; the app seals the answer with this user's key //! (the command line's, `~/.config/wai/quantum-key`), which never leaves it. -//! - Lint, draw, plan, the methods and a receipt's check answer at once, here. +//! - Lint, draw, plan, the methods, the critique, the equivalence check, the +//! variants and a receipt's check answer at once, here. The classical +//! shortcuts run off the window's thread, since a thorough search takes +//! seconds to minutes. //! - A job and a program are saved under `~/Documents/Quantum Studio/`. //! - The service is one switch away in the interface, as in the browser. //! @@ -22,7 +25,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; use wai_quantum::ed25519_dalek::SigningKey; -use wai_quantum::engine::{catalog, draw, job}; +use wai_quantum::engine::{catalog, critique, draw, equiv, job, shortcuts}; /// What a result says of its energy here. const UNMETERED: &str = "the desktop app does not meter a run's energy"; @@ -132,6 +135,27 @@ fn lint(qasm: String) -> String { job::lint(&qasm).to_string() } +#[tauri::command] +fn critique(qasm: String) -> String { + critique::critique(&qasm).to_string() +} + +#[tauri::command] +fn equivalent(a: String, b: String) -> String { + equiv::equivalent(&a, &b).to_string() +} + +#[tauri::command] +fn variants(qasm: String) -> String { + critique::variants(&qasm).to_string() +} + +#[tauri::command] +async fn shortcuts(qasm: String, thorough: bool) -> String { + // A thorough search takes seconds to minutes: off the window's thread. + tauri::async_runtime::spawn_blocking(move || shortcuts::shortcuts(&qasm, thorough).to_string()).await.unwrap_or_else(|e| error_json(e.to_string())) +} + #[tauri::command] fn draw(qasm: String, width: u32) -> String { match wai_quantum::qasm::parse(&qasm) { @@ -280,7 +304,7 @@ fn main() { }; tauri::Builder::default() .manage(Studio { key, job_bytes: job_bytes(), running: Arc::new(Mutex::new(None)), cancelled: Arc::new(Mutex::new(false)) }) - .invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, draw, layout, verify, signer, save_job, save_program, chat, models]) + .invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, critique, equivalent, variants, shortcuts, draw, layout, verify, signer, save_job, save_program, chat, models]) .run(tauri::generate_context!()) .expect("the window opens"); } @@ -320,4 +344,16 @@ mod tests { let v: Value = serde_json::from_str(&verify("{}".into())).unwrap(); assert!(v["error"].is_string()); } + + #[test] + fn critique_equivalence_and_variants_answer_as_json() { + let c: Value = serde_json::from_str(&critique("qubit[1] q;\nh q[0];\nh q[0];\n".into())).unwrap(); + assert_eq!((c["ok"].as_bool(), c["smells"][0]["kind"].as_str()), (Some(true), Some("cancels")), "{c}"); + let e: Value = serde_json::from_str(&equivalent("qubit[1] q; h q[0]; h q[0];".into(), "qubit[1] q;".into())).unwrap(); + assert_eq!(e["verdict"], "equivalent", "{e}"); + let v: Value = serde_json::from_str(&variants("qubit[1] q;\nh q[0];\nh q[0];\n".into())).unwrap(); + assert_eq!(v["candidates"][0]["equivalence"]["verdict"], "equivalent", "{v}"); + let s: Value = serde_json::from_str(&tauri::async_runtime::block_on(shortcuts("qubit[2] q; h q[0]; cx q[0], q[1];".into(), false))).unwrap(); + assert!(s["summary"].as_str().unwrap().starts_with("Classically easy"), "{s}"); + } } diff --git a/wai/wai-quantum-qpu/Cargo.lock b/wai/wai-quantum-qpu/Cargo.lock index 863807efa..deea629b5 100644 --- a/wai/wai-quantum-qpu/Cargo.lock +++ b/wai/wai-quantum-qpu/Cargo.lock @@ -422,7 +422,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index ad6e2c829..772d80764 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index e41084596..a3499ac40 100644 --- a/wai/wai-quantum-service/Cargo.lock +++ b/wai/wai-quantum-service/Cargo.lock @@ -857,7 +857,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/README.md b/wai/wai-quantum-service/README.md index b6517c3b6..5467601c6 100644 --- a/wai/wai-quantum-service/README.md +++ b/wai/wai-quantum-service/README.md @@ -102,9 +102,19 @@ Counts are always seeded samples. | `GET /v1/jobs/{id}` | the job: its status, the request as submitted, and its result when it is done | | `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 | +| `POST /v1/equivalent` | `{"a", "b"}`: whether two programs are the same circuit up to a global phase, and how that was decided | +| `POST /v1/improve` | `{"qasm"}`: the program with its smells fixed, each candidate with its measures and checked the same circuit | +| `POST /v1/shortcuts` | `{"qasm"}`: each exact classical route to simulating the program, priced on it, and the cheapest for each question | Every refusal says why: `400` for a program or job that does not read, `422` for one larger than a job may be, `429` past the per-client limits, `503` when the queue is full. `GET /v1` states the limits. +The four checks answer at once, in the service's own process. Each counts against the client's submissions, as a job does. At most two run at a time, and a check that waits ten seconds for its turn is refused (`503`). Some checks are refused (`422`) with the command that runs them on your own machine: + +- an equivalence check that would apply more than 4·10⁹ gate amplitudes; +- an improvement whose candidates' checks would apply more than that; +- a thorough search for shortcuts. + ## Receipts Every finished result carries a `receipt`: an Ed25519 signature, by the key `GET /v1` publishes, over the BLAKE3 digest of the request as submitted and the result as returned. `POST /v1/verify` checks one, and so can any Ed25519 implementation: diff --git a/wai/wai-quantum-service/smoke.sh b/wai/wai-quantum-service/smoke.sh index dc2c74b77..e8f72c88b 100644 --- a/wai/wai-quantum-service/smoke.sh +++ b/wai/wai-quantum-service/smoke.sh @@ -15,6 +15,18 @@ trap 'rm -rf "$tmp"' EXIT post() { curl -sS --max-time 120 -X POST "$U$1" -H 'content-type: application/json' --data-binary "@$2"; } py() { python3 -I -c "$@"; } +# POST /v1/run, then follow a job still queued or running past the service's +# wait through GET /v1/jobs/{id}, as a client would: on a loaded host a job +# can outlast the wait without anything being wrong. At most ten minutes. +run() { + post /v1/run "$1" > "$2" + for _ in $(seq 1 120); do + case "$(py 'import json,sys; print(json.load(open(sys.argv[1])).get("status", ""))' "$2")" in + queued|running) sleep 5; curl -sS --max-time 30 "$U/v1/jobs/$(py 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$2")" > "$2.next" && mv "$2.next" "$2" ;; + *) return 0 ;; + esac + done +} # The service describes itself, with its methods and its receipt key. curl -sS --max-time 30 "$U/v1" > "$tmp/describe.json" @@ -32,7 +44,7 @@ py 'import json n=40 q="OPENQASM 3.0;\nqubit[%d] q;\nh q[0];\n" % n + "".join("cx q[%d], q[%d];\n" % (i,i+1) for i in range(n-1)) print(json.dumps({"qasm": q, "shots": 1000, "seed": 7}))' > "$tmp/ghz.json" -post /v1/run "$tmp/ghz.json" > "$tmp/ghz.out" +run "$tmp/ghz.json" "$tmp/ghz.out" py 'import json,sys d=json.load(open(sys.argv[1])); r=d["result"] assert d["status"]=="done" and r["method"]=="stabilizer", d @@ -94,7 +106,7 @@ post /v1/verify "$tmp/vjs.json" | py 'import json,sys; d=json.load(sys.stdin); a # Continuous angles on a small circuit: the dense state vector, and the # probabilities sum to the shots. py 'import json; print(json.dumps({"qasm": "qubit[3] q; rx(0.3) q[0]; ry(1.2) q[1]; cx q[0], q[2]; crz(0.7) q[1], q[2];", "shots": 500, "observables": ["Z0", "Z1 Z2"]}))' > "$tmp/rot.json" -post /v1/run "$tmp/rot.json" > "$tmp/rot.out" +run "$tmp/rot.json" "$tmp/rot.out" py 'import json,sys,math d=json.load(open(sys.argv[1])); r=d["result"] assert d["status"]=="done" and r["method"]=="statevector", d @@ -108,7 +120,7 @@ py 'import json n=100 q="qubit[%d] q;\n" % n + "".join("rx(0.3) q[%d];\n" % i for i in range(n)) + "".join("cx q[%d], q[%d];\n" % (i,i+1) for i in range(n-1)) print(json.dumps({"qasm": q, "observables": ["Z0", "Z50 Z51"]}))' > "$tmp/wide.json" -post /v1/run "$tmp/wide.json" > "$tmp/wide.out" +run "$tmp/wide.json" "$tmp/wide.out" py 'import json,sys,math d=json.load(open(sys.argv[1])); r=d["result"] assert d["status"]=="done" and r["method"]=="pauli", d @@ -130,7 +142,7 @@ py 'import json n=14 q="qubit[%d] q;\nbit[%d] c;\n" % (n,n) + "".join("ry(0.4) q[%d];\n" % i for i in range(n)) + "".join("cz q[%d], q[%d];\n" % (i,i+1) for i in range(n-1)) + "c = measure q;\n" print(json.dumps({"qasm": q, "method": "trajectories", "shots": 2000, "noise": {"depolarizing_1q": 0.0005, "depolarizing_2q": 0.002, "readout": 0.01}}))' > "$tmp/noisy.json" -post /v1/run "$tmp/noisy.json" > "$tmp/noisy.out" +run "$tmp/noisy.json" "$tmp/noisy.out" py 'import json,sys d=json.load(open(sys.argv[1])); assert d.get("status")=="done", d; r=d["result"] assert r["method"]=="trajectories", d @@ -143,7 +155,7 @@ py 'import json n=500 q="qubit[%d] q;\n" % n + "h q[0];\n" + "".join("cx q[%d], q[%d];\n" % (i,i+1) for i in range(n-1)) print(json.dumps({"qasm": q, "shots": 1000, "noise": {"depolarizing_2q": 0.0005, "readout": 0.001}, "observables": ["Z0 Z499"]}))' > "$tmp/nghz.json" -post /v1/run "$tmp/nghz.json" > "$tmp/nghz.out" +run "$tmp/nghz.json" "$tmp/nghz.out" py 'import json,sys d=json.load(open(sys.argv[1])); assert d.get("status")=="done", d; r=d["result"] assert r["method"]=="stabilizer" and r["accuracy"]["class"]=="estimate", d @@ -156,8 +168,8 @@ print("noisy-ghz-500: ok,", r["method"], " =", round(e["value"],4), "± if py 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if any(m["method"]=="statevector_single" and m.get("available") for m in d["methods"]) else 1)' "$tmp/describe.json"; then py 'import json; print(json.dumps({"qasm": "qubit[12] q; h q; rx(0.3) q[0]; cx q[0], q[11]; crz(0.7) q[3], q[9]; ry(1.1) q[5];", "observables": ["Z0 Z11", "X5"], "method": "statevector_single"}))' > "$tmp/gpu.json" py 'import json,sys; d=json.load(open(sys.argv[1])); d["method"]="statevector"; json.dump(d, open(sys.argv[2],"w"))' "$tmp/gpu.json" "$tmp/cpu.json" - post /v1/run "$tmp/gpu.json" > "$tmp/gpu.out" - post /v1/run "$tmp/cpu.json" > "$tmp/cpu.out" + run "$tmp/gpu.json" "$tmp/gpu.out" + run "$tmp/cpu.json" "$tmp/cpu.out" py 'import json,sys g=json.load(open(sys.argv[1])); c=json.load(open(sys.argv[2])); assert g.get("status")=="done" and c.get("status")=="done", (g, c) gv=[e["value"] for e in g["result"]["expectations"]]; cv=[e["value"] for e in c["result"]["expectations"]] @@ -190,7 +202,7 @@ mcp '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' > "$tmp/mcp-tools.json" py 'import json,sys i=json.load(open(sys.argv[1]))["result"]; t=json.load(open(sys.argv[2]))["result"]["tools"] assert i["protocolVersion"]=="2025-06-18" and "tools" in i["capabilities"], i -names={x["name"] for x in t}; assert {"run_quantum_circuit","get_quantum_job","verify_quantum_receipt","quantum_service_info","quantum_circuit_examples"} <= names, names +names={x["name"] for x in t}; assert {"run_quantum_circuit","get_quantum_job","verify_quantum_receipt","quantum_service_info","quantum_circuit_examples","critique_qasm","check_equivalent","improve_qasm","classical_shortcuts"} <= names, names assert sys.argv[3]=="202", sys.argv[3] print("mcp: ok, initialized,", len(t), "tools")' "$tmp/mcp-init.json" "$tmp/mcp-tools.json" "$code" py 'import json,sys @@ -219,7 +231,7 @@ for _ in range(4): q += flips + mcz() + flips q += "".join("h q[%d];\nx q[%d];\n" % (i, i) for i in range(n)) + mcz() + "".join("x q[%d];\nh q[%d];\n" % (i, i) for i in range(n)) print(json.dumps({"qasm": q, "shots": 1000}))' > "$tmp/grover.json" -post /v1/run "$tmp/grover.json" > "$tmp/grover.out" +run "$tmp/grover.json" "$tmp/grover.out" py 'import json,sys d=json.load(open(sys.argv[1])); r=d["result"] hits=r["counts"].get("10110", 0) @@ -229,7 +241,7 @@ print("grover-5 (ctrl modifiers): ok,", r["method"], hits, "of 1000 on the marke # A program that measures in the middle and acts on what it read: teleportation # delivers ry(0.7)|0⟩ to q[2], so ⟨Z2⟩ = cos 0.7 and ⟨X2⟩ = sin 0.7 exactly. py 'import json; print(json.dumps({"qasm": "OPENQASM 3.0; qubit[3] q; bit[3] c; ry(0.7) q[0]; h q[1]; cx q[1], q[2]; cx q[0], q[1]; h q[0]; c[0] = measure q[0]; c[1] = measure q[1]; if (c[1]) x q[2]; if (c[0]) z q[2]; c[2] = measure q[2];", "observables": ["Z2", "X2"], "shots": 2000}))' > "$tmp/tele.json" -post /v1/run "$tmp/tele.json" > "$tmp/tele.out" +run "$tmp/tele.json" "$tmp/tele.out" py 'import json,math,sys d=json.load(open(sys.argv[1])); r=d["result"]; e=[x["value"] for x in r["expectations"]] assert d["status"]=="done" and abs(e[0]-math.cos(0.7))<1e-12 and abs(e[1]-math.sin(0.7))<1e-12, d @@ -244,7 +256,7 @@ n=60; q="OPENQASM 3.0; qubit[%d] q; bit[%d] c; h q;" % (n, n) q+="".join(" cz q[%d], q[%d]; cz q[%d], q[%d];" % (i, (i+1)%n, i, (i+5)%n) for i in range(n)) q+="".join(" t q[%d]; h q[%d];" % (i, i) for i in range(n)) + " c = measure q;" print(json.dumps({"qasm": q, "shots": 100, "seed": 3}))' > "$tmp/cut.json" -post /v1/run "$tmp/cut.json" > "$tmp/cut.out" +run "$tmp/cut.json" "$tmp/cut.out" py 'import json,sys d=json.load(open(sys.argv[1])); r=d["result"] assert d["status"]=="done" and r["method"]=="cutrank" and sum(r["counts"].values())==100, d @@ -257,4 +269,22 @@ code=$(curl -sS -o "$tmp/bad.out" -w '%{http_code}' -X POST "$U/v1/jobs" -H 'con [ "$code" = 400 ] || { echo "a bad program got HTTP $code"; exit 1; } py 'import json,sys; d=json.load(open(sys.argv[1])); assert "not supported" in d["error"], d; print("refusal: ok,", d["error"])' "$tmp/bad.out" +# The design checks answer at once: a critique, an improvement checked the same +# circuit, the equivalence check, the classical shortcuts, and a thorough search +# sent to the command line. +py 'import json; print(json.dumps({"qasm": "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\nh q[0];\ncx q[0], q[1];\n"}))' > "$tmp/design.json" +post /v1/critique "$tmp/design.json" > "$tmp/critique.out" +py 'import json,sys; d=json.load(open(sys.argv[1])); assert d["ok"] and d["gates"]==3 and d["smells"][0]["kind"]=="cancels" and d["smells"][0]["lines"]==[3,4], d; print("critique: ok,", d["smells"][0]["message"])' "$tmp/critique.out" +post /v1/improve "$tmp/design.json" > "$tmp/improve.out" +py 'import json,sys; c=json.load(open(sys.argv[1]))["candidates"][0]; assert c["measures"]["gates"]==1 and c["equivalence"]["verdict"]=="equivalent", c; print("improve: ok,", c["equivalence"]["how"])' "$tmp/improve.out" +py 'import json; print(json.dumps({"a": "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\nh q[0];\ncx q[0], q[1];\n", "b": "OPENQASM 3.0;\nqubit[2] q;\ncx q[0], q[1];\n"}))' > "$tmp/pair.json" +post /v1/equivalent "$tmp/pair.json" > "$tmp/equivalent.out" +py 'import json,sys; d=json.load(open(sys.argv[1])); assert d["verdict"]=="equivalent" and d["how"]=="tableau", d; print("equivalent: ok, by tableau")' "$tmp/equivalent.out" +post /v1/shortcuts "$tmp/design.json" > "$tmp/shortcuts.out" +py 'import json,sys; d=json.load(open(sys.argv[1])); assert d["summary"].startswith("Classically easy at any width"), d; print("shortcuts: ok,", d["summary"][:40] + "…")' "$tmp/shortcuts.out" +py 'import json; print(json.dumps({"qasm": "qubit[1] q;", "thorough": True}))' > "$tmp/thorough.json" +code=$(curl -sS -o "$tmp/thorough.out" -w '%{http_code}' -X POST "$U/v1/shortcuts" -H 'content-type: application/json' --data-binary "@$tmp/thorough.json") +[ "$code" = 422 ] || { echo "a thorough search got HTTP $code"; exit 1; } +py 'import json,sys; d=json.load(open(sys.argv[1])); assert "wai-quantum shortcuts --thorough" in d["error"], d; print("thorough: ok, sent to the command line")' "$tmp/thorough.out" + echo "smoke: every check held at $U" diff --git a/wai/wai-quantum-service/src/analysis.rs b/wai/wai-quantum-service/src/analysis.rs new file mode 100644 index 000000000..456a12cd4 --- /dev/null +++ b/wai/wai-quantum-service/src/analysis.rs @@ -0,0 +1,151 @@ +//! Design checks the engine answers at once, without a job: a program's +//! critique, two programs' equivalence, the improvements its fixes make, and +//! its classical shortcuts (`wai_quantum::engine::{critique, equiv, +//! shortcuts}`). They run in this process, off the request's thread, within +//! bounds: +//! +//! - each counts against the client's submissions, as a job does; +//! - at most [`AT_ONCE`] run at a time, and a request that waits [`WAIT`] for +//! a turn is refused (503); +//! - an equivalence check that would apply more than [`EQUIV_MAX_WORK`] gate +//! amplitudes is refused (422) with the command that runs it on the client's +//! own machine. So is an improvement whose candidates' checks would, and so +//! is a thorough search for shortcuts, which can take minutes. +//! +//! The critique and the quick shortcuts are bounded by the program's size. +//! The tableau, the cut-rank planner's search and the light cones are linear +//! in the gates or close to it. + +use crate::http::App; +use serde_json::Value; +use std::time::Duration; +use tokio::sync::Semaphore; +use wai_quantum::engine::{critique, equiv, shortcuts}; + +/// Checks that run at once; the rest wait their turn. +pub const AT_ONCE: usize = 2; +/// How long a check waits for a turn before it is refused. +pub const WAIT: Duration = Duration::from_secs(10); +/// Gate applications times amplitudes one equivalence check may take here. +pub const EQUIV_MAX_WORK: f64 = 4e9; + +static TURNS: Semaphore = Semaphore::const_new(AT_ONCE); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Check { + Critique, + Equivalent, + Improve, + Shortcuts, +} + +fn program<'a>(body: &'a Value, key: &str) -> Result<&'a str, (u16, String)> { + body.get(key).and_then(Value::as_str).ok_or_else(|| (400, format!("give \"{key}\": an OpenQASM program"))) +} + +/// What an equivalence check of `a` against `b` would apply, in gate +/// applications times amplitudes: none for Clifford circuits (their tableaus +/// are compared) or past the widest the check runs; every basis state up to +/// eight qubits, within the check's own budget; three random inputs beyond. +fn equivalence_work(a: &str, b: &str) -> f64 { + let (Ok(pa), Ok(pb)) = (wai_quantum::qasm::parse(a), wai_quantum::qasm::parse(b)) else { return 0.0 }; + if pa.clifford().is_ok() && pb.clifford().is_ok() { + return 0.0; + } + let n = pa.n_qubits.max(pb.n_qubits); + if n > equiv::RANDOM_MAX_QUBITS || n <= equiv::EXACT_MAX_QUBITS { + return 0.0; + } + equiv::RANDOM_INPUTS as f64 * (pa.ops.len() + pb.ops.len()) as f64 * 2f64.powi(n as i32) +} + +/// Whether the service takes `check` on `body`: the reason it does not, with +/// the status to answer, or nothing. +pub fn bounds(check: Check, body: &Value) -> Result<(), (u16, String)> { + let too_much = |work: f64, local: &str| -> Result<(), (u16, String)> { + if work > EQUIV_MAX_WORK { + return Err((422, format!("the equivalence check would apply about {work:.1e} gate amplitudes, more than the service takes ({EQUIV_MAX_WORK:.0e}); run `{local}` on your machine"))); + } + Ok(()) + }; + match check { + Check::Critique => program(body, "qasm").map(|_| ()), + Check::Equivalent => too_much(equivalence_work(program(body, "a")?, program(body, "b")?), "wai-quantum equivalent a.qasm b.qasm"), + // Each candidate is checked against the program: at most every fix + // together and six alone. + Check::Improve => { + let q = program(body, "qasm")?; + too_much(7.0 * equivalence_work(q, q), "wai-quantum improve program.qasm") + } + Check::Shortcuts => { + program(body, "qasm")?; + if body.get("thorough").and_then(Value::as_bool) == Some(true) { + return Err((422, "a thorough search (a contraction tree, and longer cut-rank searches) can take minutes; run `wai-quantum shortcuts --thorough program.qasm` on your machine".into())); + } + Ok(()) + } + } +} + +/// The engine's answer to `check` on `body`, which [`bounds`] admitted. +pub fn answer(check: Check, body: &Value) -> Value { + let s = |k: &str| body.get(k).and_then(Value::as_str).unwrap_or(""); + match check { + Check::Critique => critique::critique(s("qasm")), + Check::Equivalent => equiv::equivalent(s("a"), s("b")), + Check::Improve => critique::variants(s("qasm")), + Check::Shortcuts => shortcuts::shortcuts(s("qasm"), false), + } +} + +/// `check` on `body` for `who`, within the bounds above: the answer, or the +/// status and reason it was refused. +pub async fn analyse(app: &App, who: &str, check: Check, body: Value) -> Result { + bounds(check, &body)?; + app.service.count(who).map_err(|crate::jobs::Refused(code, why)| (code, why))?; + let Ok(Ok(turn)) = tokio::time::timeout(WAIT, TURNS.acquire()).await else { + return Err((503, format!("{AT_ONCE} checks are running; try again shortly"))); + }; + let v = tokio::task::spawn_blocking(move || answer(check, &body)).await.map_err(|e| (500, format!("the check failed: {e}")))?; + drop(turn); + Ok(v) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn the_bounds_refuse_what_would_take_long_and_say_where_it_runs() { + let small = json!({ "qasm": "qubit[2] q; h q[0]; h q[0]; cx q[0], q[1];" }); + assert!(bounds(Check::Critique, &small).is_ok()); + assert!(bounds(Check::Improve, &small).is_ok()); + assert!(bounds(Check::Shortcuts, &small).is_ok()); + assert_eq!(bounds(Check::Critique, &json!({})).unwrap_err().0, 400); + assert_eq!(bounds(Check::Equivalent, &json!({ "a": "qubit[1] q;" })).unwrap_err().0, 400); + // Thorough shortcuts run locally. + let e = bounds(Check::Shortcuts, &json!({ "qasm": "qubit[1] q;", "thorough": true })).unwrap_err(); + assert!(e.0 == 422 && e.1.contains("wai-quantum shortcuts --thorough"), "{e:?}"); + // A wide non-Clifford pair is refused with the local command; a wide + // Clifford pair, compared by tableau, is not. + let mut wide = String::from("qubit[22] q;\nh q;\n"); + for _ in 0..200 { + wide += "rz(0.3) q;\n"; + } + let e = bounds(Check::Equivalent, &json!({ "a": wide, "b": wide })).unwrap_err(); + assert!(e.0 == 422 && e.1.contains("wai-quantum equivalent"), "{e:?}"); + let clifford = "qubit[22] q;\nh q;\ncx q[0], q[21];\n"; + assert!(bounds(Check::Equivalent, &json!({ "a": clifford, "b": clifford })).is_ok()); + } + + #[test] + fn each_check_answers_as_the_engine_does() { + let body = json!({ "qasm": "qubit[2] q;\nh q[0];\nh q[0];\ncx q[0], q[1];\n" }); + assert_eq!(answer(Check::Critique, &body)["smells"][0]["kind"], "cancels"); + assert_eq!(answer(Check::Improve, &body)["candidates"][0]["equivalence"]["verdict"], "equivalent"); + assert!(answer(Check::Shortcuts, &body)["summary"].as_str().unwrap().starts_with("Classically easy")); + let e = answer(Check::Equivalent, &json!({ "a": "qubit[1] q; h q[0]; h q[0];", "b": "qubit[1] q;" })); + assert_eq!(e["verdict"], "equivalent"); + } +} diff --git a/wai/wai-quantum-service/src/discovery.rs b/wai/wai-quantum-service/src/discovery.rs index 5145412af..05f61a2d8 100644 --- a/wai/wai-quantum-service/src/discovery.rs +++ b/wai/wai-quantum-service/src/discovery.rs @@ -99,6 +99,7 @@ pub fn examples() -> Value { /// The API as an OpenAPI 3.1 document. pub fn openapi(server: &str) -> Value { + let prog = json!({ "type": "object", "required": ["qasm"], "properties": { "qasm": { "type": "string", "description": "The program, OpenQASM 3 or 2." } } }); let pauli = json!({ "oneOf": [ { "type": "string", "description": "A Pauli string: factors such as `Z0`, `X3`, `Y12`, separated by spaces; qubit indices count from 0.", "examples": ["Z0 Z1", "X3 Y4"] }, { "type": "object", "required": ["pauli"], "properties": { "pauli": { "type": "string" }, "coeff": { "type": "number", "default": 1.0 } } } @@ -177,7 +178,27 @@ pub fn openapi(server: &str) -> Value { "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["request", "result"], "properties": { "request": { "$ref": "#/components/schemas/JobRequest" }, "result": { "type": "object" } } } } } }, "responses": { "200": { "description": "{\"valid\": bool}" } } } }, - "/mcp": { "post": { "summary": "The Model Context Protocol endpoint (streamable HTTP, JSON responses): tools to run circuits, read jobs, verify receipts and list examples", "responses": { "200": { "description": "A JSON-RPC response" } } } } + "/v1/critique": { "post": { + "summary": "A program's measures and smells, each with its lines", + "requestBody": { "required": true, "content": { "application/json": { "schema": prog.clone() } } }, + "responses": { "200": { "description": "The critique, or {\"ok\": false, \"error\", \"line\"} for a program that does not read" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running" } } + } }, + "/v1/equivalent": { "post": { + "summary": "Whether two programs are the same circuit up to a global phase, and how that was decided", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["a", "b"], "properties": { "a": { "type": "string" }, "b": { "type": "string" } } } } } }, + "responses": { "200": { "description": "{\"verdict\": \"equivalent\" | \"different\" | \"unchecked\", \"how\", \"why\"}" }, "422": { "description": "A check longer than the service takes; the reason names the command that runs it locally" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running" } } + } }, + "/v1/improve": { "post": { + "summary": "The program with its smells fixed, each candidate checked the same circuit", + "requestBody": { "required": true, "content": { "application/json": { "schema": prog.clone() } } }, + "responses": { "200": { "description": "{\"base\", \"candidates\": [{\"label\", \"qasm\", \"changes\", \"measures\", \"equivalence\"}]}" }, "422": { "description": "Checks longer than the service takes" }, "429": { "description": "Past the per-client limits" } } + } }, + "/v1/shortcuts": { "post": { + "summary": "Each exact classical route to simulating a program, priced on it, and the cheapest for each question", + "requestBody": { "required": true, "content": { "application/json": { "schema": prog } } }, + "responses": { "200": { "description": "{\"routes\", \"cheapest\", \"summary\", \"note\"}" }, "422": { "description": "A thorough search, which runs locally" }, "429": { "description": "Past the per-client limits" } } + } }, + "/mcp": { "post": { "summary": "The Model Context Protocol endpoint (streamable HTTP, JSON responses): tools to run circuits, read jobs, verify receipts, list examples, and critique, compare, improve and price programs", "responses": { "200": { "description": "A JSON-RPC response" } } } } } }) } diff --git a/wai/wai-quantum-service/src/http.rs b/wai/wai-quantum-service/src/http.rs index 3fa34dd94..80bbad619 100644 --- a/wai/wai-quantum-service/src/http.rs +++ b/wai/wai-quantum-service/src/http.rs @@ -10,6 +10,12 @@ //! POST /v1/run submit and wait (up to 25 s): the finished job, //! or 202 and the job if it is still running //! POST /v1/verify a receipt checked against its request and result +//! POST /v1/critique a program's measures and smells, each with its lines +//! POST /v1/equivalent whether two programs are the same circuit, and how +//! that was decided +//! POST /v1/improve the program with its smells fixed, each candidate +//! checked the same circuit +//! POST /v1/shortcuts each exact classical route to simulating it, priced //! GET /v1/examples ready-to-run example jobs //! GET /v1/openapi.json this API as an OpenAPI 3.1 document //! POST /mcp the Model Context Protocol endpoint (see `mcp`) @@ -18,7 +24,9 @@ //! //! A job is `{"qasm": "", "method": "auto", "shots": N, //! "seed": S, "observables": ["Z0 Z1", ...], "options": {...}}`; see -//! [`crate::catalog`]. Every answer is JSON, and every refusal says why. +//! [`crate::catalog`]. The checks take `{"qasm"}` (`{"a", "b"}` to compare) and +//! answer at once, within the bounds in [`crate::analysis`]. Every answer is +//! JSON, and every refusal says why. use crate::catalog; use crate::jobs::{Refused, Service, Status}; @@ -97,7 +105,7 @@ async fn describe(State(app): State) -> Response { "load": { "queued": queued, "running": running, "memory_held_bytes": held << 20 }, "uptime_seconds": app.service.started.elapsed().map(|d| d.as_secs()).unwrap_or(0), "receipt_key": app.signer.public_key(), - "endpoints": ["GET /v1", "GET /v1/health", "GET /v1/methods", "GET /v1/examples", "GET /v1/openapi.json", "POST /v1/jobs", "GET /v1/jobs/{id}", "DELETE /v1/jobs/{id}", "POST /v1/run", "POST /v1/verify", "POST /mcp"], + "endpoints": ["GET /v1", "GET /v1/health", "GET /v1/methods", "GET /v1/examples", "GET /v1/openapi.json", "POST /v1/jobs", "GET /v1/jobs/{id}", "DELETE /v1/jobs/{id}", "POST /v1/run", "POST /v1/verify", "POST /v1/critique", "POST /v1/equivalent", "POST /v1/improve", "POST /v1/shortcuts", "POST /mcp"], "mcp": format!("{}/mcp", app.base), "console": app.console, }), @@ -182,6 +190,16 @@ async fn verify(body: Result, axum::extract::rejection::JsonRejectio reply(200, json!({ "valid": receipt::verify(request, &bare, receipt), "key": receipt.get("key") })) } +/// A design check ([`crate::analysis`]): the engine's answer, or the refusal. +async fn analysis(app: Shared, peer: SocketAddr, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>, check: crate::analysis::Check) -> Response { + let Ok(Json(v)) = body else { return error(400, "the body is not JSON") }; + let who = client(&app, peer, &headers); + match crate::analysis::analyse(&app, &who, check, v).await { + Ok(answer) => reply(200, answer), + Err((code, why)) => error(code, &why), + } +} + async fn examples() -> Response { reply(200, json!({ "examples": crate::discovery::examples() })) } @@ -247,6 +265,10 @@ pub fn router(app: Shared) -> Router { .route("/v1/jobs/{id}", get(job).delete(cancel)) .route("/v1/run", post(run)) .route("/v1/verify", post(verify)) + .route("/v1/critique", post(|State(a): State, ConnectInfo(p): ConnectInfo, h: HeaderMap, b| analysis(a, p, h, b, crate::analysis::Check::Critique))) + .route("/v1/equivalent", post(|State(a): State, ConnectInfo(p): ConnectInfo, h: HeaderMap, b| analysis(a, p, h, b, crate::analysis::Check::Equivalent))) + .route("/v1/improve", post(|State(a): State, ConnectInfo(p): ConnectInfo, h: HeaderMap, b| analysis(a, p, h, b, crate::analysis::Check::Improve))) + .route("/v1/shortcuts", post(|State(a): State, ConnectInfo(p): ConnectInfo, h: HeaderMap, b| analysis(a, p, h, b, crate::analysis::Check::Shortcuts))) .route("/v1/examples", get(examples)) .route("/v1/openapi.json", get(openapi)) .route("/mcp", post(mcp).get(mcp_get)) diff --git a/wai/wai-quantum-service/src/jobs.rs b/wai/wai-quantum-service/src/jobs.rs index 259582c95..1a814e936 100644 --- a/wai/wai-quantum-service/src/jobs.rs +++ b/wai/wai-quantum-service/src/jobs.rs @@ -217,6 +217,22 @@ impl Service { let _ = self.sealer.set(signer); } + /// Counts a submission against `client`'s window, or says why it is one + /// too many. A job and an analysis ([`crate::analysis`]) each count once. + pub fn count(&self, client: &str) -> Result<(), Refused> { + let now = Instant::now(); + let mut clients = self.clients.lock().unwrap(); + let c = clients.entry(client.to_string()).or_default(); + while c.submissions.front().is_some_and(|t| now.duration_since(*t) > self.config.per_client_window) { + c.submissions.pop_front(); + } + if c.submissions.len() >= self.config.per_client_burst { + return Err(Refused(429, format!("at most {} submissions in {} s; try again shortly", self.config.per_client_burst, self.config.per_client_window.as_secs()))); + } + c.submissions.push_back(now); + Ok(()) + } + /// Admits a planned job for `client`, or says why not. pub fn submit(&self, client: &str, request: Value, plan: Plan) -> Result { let job_mib = plan.memory_bytes.div_ceil(1 << 20); @@ -226,7 +242,6 @@ impl Service { format!("the {} method would need about {} MiB for this circuit; one job may take at most {} MiB", plan.method, job_mib, self.config.job_mib), )); } - let now = Instant::now(); { let jobs = self.jobs.lock().unwrap(); let active = jobs.values().filter(|j| j.client == client && j.status.active()).count(); @@ -234,20 +249,7 @@ impl Service { return Err(Refused(429, format!("{active} of your jobs are queued or running; wait for one to finish (at most {} at once)", self.config.per_client_active))); } } - { - let mut clients = self.clients.lock().unwrap(); - let c = clients.entry(client.to_string()).or_default(); - while c.submissions.front().is_some_and(|t| now.duration_since(*t) > self.config.per_client_window) { - c.submissions.pop_front(); - } - if c.submissions.len() >= self.config.per_client_burst { - return Err(Refused( - 429, - format!("at most {} submissions in {} s; try again shortly", self.config.per_client_burst, self.config.per_client_window.as_secs()), - )); - } - c.submissions.push_back(now); - } + self.count(client)?; let id = job_id(); { let mut queue = self.queue.lock().unwrap(); diff --git a/wai/wai-quantum-service/src/main.rs b/wai/wai-quantum-service/src/main.rs index 7012d4eab..404d6f591 100644 --- a/wai/wai-quantum-service/src/main.rs +++ b/wai/wai-quantum-service/src/main.rs @@ -13,6 +13,7 @@ //! wai-quantum-service run FILE one job from a JSON file, here, printed //! ``` +mod analysis; mod discovery; mod energy; mod http; diff --git a/wai/wai-quantum-service/src/mcp.rs b/wai/wai-quantum-service/src/mcp.rs index c06eea7ab..9a205c9dd 100644 --- a/wai/wai-quantum-service/src/mcp.rs +++ b/wai/wai-quantum-service/src/mcp.rs @@ -57,6 +57,34 @@ fn tools() -> Value { "inputSchema": { "type": "object", "required": ["request", "result"], "properties": { "request": { "type": "object" }, "result": { "type": "object" } } }, "annotations": { "readOnlyHint": true } }, + { + "name": "critique_qasm", + "title": "Critique a quantum program", + "description": "A program's measures, computed by the engine: gates by kind, two-qubit gates, depth and two-qubit depth, T count, whether it is a Clifford circuit, and a fidelity under reference error rates (a model, not a run). Also its smells, each with its lines: gates that cancel, rotations that merge or do nothing, idle qubits, and qubits acted on but never measured.", + "inputSchema": { "type": "object", "required": ["qasm"], "properties": { "qasm": { "type": "string" } } }, + "annotations": { "readOnlyHint": true } + }, + { + "name": "check_equivalent", + "title": "Check two quantum programs are the same circuit", + "description": "Whether two programs are the same circuit up to a global phase, and how the engine knows: Clifford circuits by their tableaus at any size, others on every basis state to 8 qubits, on random product states to 22 (evidence, not proof), otherwise unchecked with the reason. A check that would take long here is refused with the command that runs it locally.", + "inputSchema": { "type": "object", "required": ["a", "b"], "properties": { "a": { "type": "string" }, "b": { "type": "string" } } }, + "annotations": { "readOnlyHint": true } + }, + { + "name": "improve_qasm", + "title": "Improve a quantum program", + "description": "The program with its smells fixed in its own text (all of them, and each alone), each candidate with its measures and the equivalence check's verdict against the program. A candidate the check finds different is never returned.", + "inputSchema": { "type": "object", "required": ["qasm"], "properties": { "qasm": { "type": "string" } } }, + "annotations": { "readOnlyHint": true } + }, + { + "name": "classical_shortcuts", + "title": "Find a quantum program's classical shortcuts", + "description": "Every exact classical route to simulating a program, priced on it: the stabilizer tableau, the cut-rank sum, an exact matrix product state at a rigorous bond bound, the light cone of a one-qubit expectation value, and the state vector. The cheapest is named for each question. A cheap route proves the program classically easy; none being cheap proves nothing about hardness. A thorough search, with a contraction tree, runs locally: wai-quantum shortcuts --thorough.", + "inputSchema": { "type": "object", "required": ["qasm"], "properties": { "qasm": { "type": "string" } } }, + "annotations": { "readOnlyHint": true } + }, { "name": "quantum_service_info", "title": "What the quantum service can do", @@ -267,6 +295,23 @@ async fn call(app: &App, who: &str, name: &str, args: Value, base: &str) -> Valu let lines: Vec = ex.as_array().into_iter().flatten().map(|e| format!("{} — {}: {}", e["name"].as_str().unwrap_or(""), e["title"].as_str().unwrap_or(""), e["shows"].as_str().unwrap_or(""))).collect(); tool_result(format!("{}\n(each example's `job` in the structured content runs as given)", lines.join("\n")), json!({ "examples": ex }), false) } + "critique_qasm" | "check_equivalent" | "improve_qasm" | "classical_shortcuts" => { + use crate::analysis::Check; + let check = match name { + "critique_qasm" => Check::Critique, + "check_equivalent" => Check::Equivalent, + "improve_qasm" => Check::Improve, + _ => Check::Shortcuts, + }; + match crate::analysis::analyse(app, who, check, args).await { + Ok(v) => { + let text = v["summary"].as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + let failed = v["ok"] == false; + tool_result(text, v, failed) + } + Err((_, why)) => tool_result(format!("refused: {why}"), json!({ "error": why }), true), + } + } other => tool_result(format!("no tool {other}"), json!({ "error": "no such tool" }), true), } } diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index c6baa28c5..9ec87fe1b 100644 --- a/wai/wai-quantum/Cargo.lock +++ b/wai/wai-quantum/Cargo.lock @@ -1079,7 +1079,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "block2", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index c7c8844cf..67a0540c6 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" edition = "2024" rust-version = "1.97" license = "Apache-2.0" @@ -389,6 +389,10 @@ required-features = ["quantum_gbs"] name = "tn_path" required-features = ["quantum_tn"] +[[example]] +name = "tn_sweep" +required-features = ["quantum_qasm", "quantum_tn"] + [[example]] name = "qec_threshold" required-features = ["quantum_frame"] diff --git a/wai/wai-quantum/examples/cutrank_qasm.rs b/wai/wai-quantum/examples/cutrank_qasm.rs index 7370ce9d0..90e701f6d 100644 --- a/wai/wai-quantum/examples/cutrank_qasm.rs +++ b/wai/wai-quantum/examples/cutrank_qasm.rs @@ -5,8 +5,14 @@ //! cargo run --release --example cutrank_qasm --features quantum_cutrank -- FILE cuts //! cargo run --release --example cutrank_qasm --features quantum_cutrank -- FILE amplitudes N [SEED] //! cargo run --release --example cutrank_qasm --features quantum_cutrank -- FILE sample N [SEED] +//! cargo run --release --example cutrank_qasm --features quantum_cutrank -- FILE probs STRINGS [qiskit|q0] //! ``` //! +//! `probs` reads one bit string per line from STRINGS (as a device's samples) +//! and prints each one's `2^n · p`, then their mean and the linear cross-entropy +//! `2^n · mean(p) − 1` with its standard error. `qiskit` (the default) reads a +//! string with qubit n−1 first, as Qiskit writes it; `q0` with qubit 0 first. +//! //! The bulk takes `h s sdg sx x y z cx cz swap`; a qubit's closing layer //! starts at its first non-Clifford gate (`t tdg rz rx ry p u u3`) and may hold //! any single-qubit gates. `2^n · p` is printed per bit string: for exact @@ -168,6 +174,30 @@ fn main() { } println!("{count} samples in {el:?} ({:?} each); mean 2^n·p = {mean:.4}, linear XEB = {:.4}", el / count as u32, mean - 1.0); } + // `probs FILE [qiskit|q0]`: the exact 2^n·p of each bit string in FILE (one + // a line; `qiskit`, the default: the first character is qubit n−1, as + // Qiskit prints counts; `q0`: the first is qubit 0), and their linear + // XEB, 2^n·mean(p) − 1, with its standard error. `count` is unused. + "probs" => { + let file = args.get(3).expect("a file of bit strings"); + let qiskit = args.get(4).map(|s| s.as_str()) != Some("q0"); + let text = std::fs::read_to_string(file).expect("the bit strings read"); + let t = Instant::now(); + let mut v = Vec::new(); + for (i, line) in text.lines().map(str::trim).filter(|l| !l.is_empty()).enumerate() { + assert_eq!(line.len(), n as usize, "line {i}: {} characters, not {n}", line.len()); + let c: Vec = line.bytes().map(|b| b == b'1').collect(); + let x: Vec = (0..n as usize).map(|q| if qiskit { c[n as usize - 1 - q] } else { c[q] }).collect(); + let a = pm.amplitude_in(&x, limit, precision).unwrap(); + let w = scale * (a.re * a.re + a.im * a.im); + println!("{line} {w:.9}"); + v.push(w); + } + let m = v.len() as f64; + let mean = v.iter().sum::() / m; + let var = v.iter().map(|w| (w - mean) * (w - mean)).sum::() / (m - 1.0).max(1.0); + println!("{} strings ({}) in {:?}: mean 2^n·p = {mean:.4}, linear XEB = {:.4} ± {:.4}", v.len(), if qiskit { "qubit n-1 first" } else { "qubit 0 first" }, t.elapsed(), mean - 1.0, (var / m).sqrt()); + } other => panic!("mode {other}"), } } diff --git a/wai/wai-quantum/examples/tn_sweep.rs b/wai/wai-quantum/examples/tn_sweep.rs new file mode 100644 index 000000000..72ff00ae9 --- /dev/null +++ b/wai/wai-quantum/examples/tn_sweep.rs @@ -0,0 +1,48 @@ +//! A contraction's cost with and without sweep seeds: the trees that absorb a +//! circuit's tensors qubit by qubit or time by time ([`sweep_path`]), beside the +//! hyper-optimiser's own. +//! +//! ```text +//! cargo run --release --example tn_sweep --features quantum_qasm,quantum_tn -- CIRCUIT.qasm [TRIALS] [RESTARTS] +//! ``` +//! +//! Prints, for one amplitude of a seeded random output: each sweep's cost as +//! given, the search's best without seeds, and with them. The costs are log10 +//! of complex multiply-adds and log2 of the largest intermediate. + +use std::time::Instant; +use wai_quantum::quantum_tn::{Network, PathOptions, find_path_restarts, find_path_restarts_seeded, path_cost, sweep_orders, sweep_path, tensor_places}; + +fn main() { + let args: Vec = std::env::args().collect(); + let src = std::fs::read_to_string(&args[1]).expect("the circuit reads"); + let trials: usize = args.get(2).map_or(64, |s| s.parse().unwrap()); + let restarts: usize = args.get(3).map_or(1, |s| s.parse().unwrap()); + let p = wai_quantum::qasm::parse(&src).expect("the program parses"); + let n = p.n_qubits; + let mut s = 0x9e37_79b9_7f4a_7c15u64; + let bits: Vec = (0..n) + .map(|_| { + s = s.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = s; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + ((z ^ (z >> 31)) & 1) as u8 + }) + .collect(); + let (mut net, place) = Network::amplitude_split_placed(n, &p.matrices(), &bits, &[]).expect("the network builds"); + net.reduce(); + let shape = net.shape(); + println!("{n} qubits, {} gates, {} tensors after reduction", p.ops.len(), shape.len()); + let sweeps: Vec<_> = sweep_orders(&tensor_places(&net, &place)).iter().map(|o| sweep_path(o)).collect(); + for (name, path) in ["qubit by qubit", "time by time"].iter().zip(&sweeps) { + let c = path_cost(&shape, &net.dims, &net.open, path); + println!(" sweep {name:<15} log10 flops {:>7.3} log2 largest {:>5.1}", c.log10_flops(), c.log2_size()); + } + let opts = PathOptions { trials, ..PathOptions::default() }; + for (name, seeds) in [("search", &[][..]), ("search + sweeps", &sweeps[..])] { + let t = Instant::now(); + let plan = if seeds.is_empty() { find_path_restarts(&shape, &net.dims, &net.open, &opts, restarts) } else { find_path_restarts_seeded(&shape, &net.dims, &net.open, &opts, restarts, seeds) }; + println!(" {name:<21} log10 flops {:>7.3} log2 largest {:>5.1} in {:.1?}", plan.total_flops().log10(), plan.per_slice.log2_size(), t.elapsed()); + } +} diff --git a/wai/wai-quantum/src/engine/critique.rs b/wai/wai-quantum/src/engine/critique.rs new file mode 100644 index 000000000..720543661 --- /dev/null +++ b/wai/wai-quantum/src/engine/critique.rs @@ -0,0 +1,560 @@ +//! A program's critique: what the engine can say about a design before it +//! runs, each figure computed, not estimated by a model, and its smells, each +//! with the lines it is on; and the variants its fixes make, each checked +//! equivalent to the program before it is offered. +//! +//! - [`critique`]: the program's measures (gates by kind, one-, two- and +//! three-qubit gates, T gates, gates off the Clifford angles, depth and +//! two-qubit depth, measurements), whether it is a Clifford circuit, a +//! fidelity estimate under reference error rates (a model, said to be one), +//! and its smells: gates that cancel, rotations that merge, rotations that +//! do nothing, qubits nothing uses, qubits acted on but never measured. +//! - [`variants`]: the program with its smells fixed in its own text (all of +//! them, to a fixed point, and each first one alone), each with its measures +//! and the [`super::equiv`] verdict against the program. A fix touches only +//! a statement alone on its line; a broadcast's gates or a gate definition's +//! are reported, not rewritten. +//! +//! In a program that measures in the middle, a measurement, a reset or a +//! condition is a barrier: no smell spans one. + +use super::equiv; +use crate::qasm::{self, Instr, Kind, Op, Program}; +use serde_json::{Map, Value, json}; +use std::f64::consts::TAU; + +/// The reference error rates of the fidelity estimate: per one-qubit gate, +/// per two-qubit gate (a three-qubit gate counts as six), and per measured +/// qubit. +pub const REFERENCE_RATES: (f64, f64, f64) = (1e-3, 5e-3, 1e-2); + +/// A program's name for a gate kind. +pub fn kind_name(k: Kind) -> &'static str { + use Kind::*; + match k { + Id => "id", + X => "x", + Y => "y", + Z => "z", + H => "h", + S => "s", + Sdg => "sdg", + T => "t", + Tdg => "tdg", + Sx => "sx", + Sxdg => "sxdg", + Rx => "rx", + Ry => "ry", + Rz => "rz", + P => "p", + U => "u", + Cx => "cx", + Cy => "cy", + Cz => "cz", + Ch => "ch", + Cp => "cp", + Crx => "crx", + Cry => "cry", + Crz => "crz", + Cu => "cu", + Swap => "swap", + Iswap => "iswap", + Rxx => "rxx", + Ryy => "ryy", + Rzz => "rzz", + Ccx => "ccx", + Ccz => "ccz", + Cswap => "cswap", + } +} + +/// One change to a line of the source (lines from 1). +#[derive(Clone, Debug, PartialEq)] +enum Edit { + Delete(usize), + Replace(usize, String), +} + +#[derive(Clone, Debug)] +struct Smell { + kind: &'static str, + message: String, + lines: Vec, + ops: Vec, + qubits: Vec, + fix: Option>, +} + +/// The critique of `src` as JSON; `{"ok": false, "error", "line"}` when it +/// does not read. +pub fn critique(src: &str) -> Value { + match qasm::parse_lines(src) { + Ok((p, lines)) => { + let mut v = measures(&p); + v["smells"] = smells(src, &p, &lines).iter().map(smell_json).collect(); + v + } + Err(e) => { + let line = e.strip_prefix("line ").and_then(|r| r.split(':').next()).and_then(|n| n.parse::().ok()); + json!({ "ok": false, "error": e, "line": line }) + } + } +} + +fn smell_json(s: &Smell) -> Value { + json!({ "kind": s.kind, "message": s.message, "lines": s.lines, "ops": s.ops, "qubits": s.qubits, "fixable": s.fix.is_some() }) +} + +/// The program's measures, without its smells. +fn measures(p: &Program) -> Value { + let n = p.n_qubits as usize; + let mut by = Map::new(); + let (mut one, mut two, mut three, mut t, mut off) = (0u64, 0u64, 0u64, 0u64, 0u64); + for op in &p.ops { + let e = by.entry(kind_name(op.kind)).or_insert(json!(0)); + *e = json!(e.as_u64().unwrap_or(0) + 1); + match op.qubits.len() { + 1 => one += 1, + 2 => two += 1, + _ => three += 1, + } + if matches!(op.kind, Kind::T | Kind::Tdg) { + t += 1; + } + if !is_clifford(op) { + off += 1; + } + } + // Depth: each gate starts after the last gate on any of its qubits. + let depth_of = |filter: &dyn Fn(&Op) -> bool| -> u64 { + let mut front = vec![0u64; n]; + let mut deepest = 0u64; + for op in p.ops.iter().filter(|o| filter(o)) { + let d = op.qubits.iter().map(|&q| front[q as usize]).max().unwrap_or(0) + 1; + for &q in &op.qubits { + front[q as usize] = d; + } + deepest = deepest.max(d); + } + deepest + }; + let depth = depth_of(&|_| true); + let two_depth = depth_of(&|o| o.qubits.len() >= 2); + let (r1, r2, rm) = REFERENCE_RATES; + let measured = p.measured.len() as f64; + let estimate = (1.0 - r1).powf(one as f64) * (1.0 - r2).powf((two + 6 * three) as f64) * (1.0 - rm).powf(measured); + json!({ + "ok": true, + "qubits": p.n_qubits, + "bits": p.n_bits, + "gates": p.ops.len(), + "one_qubit": one, + "two_qubit": two, + "three_qubit": three, + "t_count": t, + "non_clifford": off, + "clifford": off == 0, + "depth": depth, + "two_qubit_depth": two_depth, + "measured": p.measured.len(), + "dynamic": p.dynamic.is_some(), + "by_gate": by, + "fidelity_model": { + "estimate": estimate, + "rates": { "one_qubit": r1, "two_qubit": r2, "readout": rm }, + "note": "the product of (1 − rate) over the gates and the measured qubits at these reference rates, a three-qubit gate counting as six two-qubit gates: a model of a device, not a run", + }, + }) +} + +fn is_clifford(op: &Op) -> bool { + Program::lower_clifford(std::slice::from_ref(op)).is_ok() +} + +/// Whether `a` then `b` on the same qubits is the identity (up to a phase). +fn cancels(a: &Op, b: &Op) -> bool { + use Kind::*; + let same_seq = a.qubits == b.qubits; + let mut sa = a.qubits.clone(); + let mut sb = b.qubits.clone(); + sa.sort_unstable(); + sb.sort_unstable(); + let same_set = sa == sb; + match (a.kind, b.kind) { + (X, X) | (Y, Y) | (Z, Z) | (H, H) => true, + (S, Sdg) | (Sdg, S) | (T, Tdg) | (Tdg, T) | (Sx, Sxdg) | (Sxdg, Sx) => true, + (Cx, Cx) | (Cy, Cy) | (Ch, Ch) => same_seq, + (Cz, Cz) | (Swap, Swap) | (Ccz, Ccz) => same_set, + (Ccx, Ccx) => a.qubits[2] == b.qubits[2] && same_set, + (Cswap, Cswap) => a.qubits[0] == b.qubits[0] && same_set, + _ => false, + } +} + +/// Whether `a` then `b` is one gate of the same kind with the angles added. +fn merges(a: &Op, b: &Op) -> bool { + use Kind::*; + let mut sa = a.qubits.clone(); + let mut sb = b.qubits.clone(); + sa.sort_unstable(); + sb.sort_unstable(); + a.kind == b.kind + && match a.kind { + Rx | Ry | Rz | P => true, + Crx | Cry | Crz | Cp => a.qubits == b.qubits, + Rxx | Ryy | Rzz => sa == sb, + _ => false, + } +} + +/// Whether the op is the identity, up to a global phase. +fn does_nothing(op: &Op) -> bool { + use Kind::*; + let zero_mod = |x: f64, period: f64| { + let r = x.rem_euclid(period); + r < 1e-12 || period - r < 1e-12 + }; + match op.kind { + Id => true, + Rx | Ry | Rz | P | Rxx | Ryy | Rzz | Cp => zero_mod(op.params[0], TAU), + Crx | Cry | Crz => zero_mod(op.params[0], 2.0 * TAU), + U => zero_mod(op.params[0], TAU) && zero_mod(op.params[1] + op.params[2], TAU), + _ => false, + } +} + +/// A statement alone on its line: its gate name, its parameter text (inside +/// the parentheses), its argument text, the indent and any trailing comment. +struct Statement { + indent: String, + name: String, + params: Option, + args: String, + comment: String, +} + +fn statement(text: &str) -> Option { + let (code, comment) = match text.find("//") { + Some(i) => (&text[..i], text[i..].to_string()), + None => (text, String::new()), + }; + let indent: String = code.chars().take_while(|c| c.is_whitespace()).collect(); + let body = code.trim(); + if body.matches(';').count() != 1 || !body.ends_with(';') { + return None; + } + let body = body.trim_end_matches(';').trim_end(); + let name_end = body.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).unwrap_or(body.len()); + let name = &body[..name_end]; + if name.is_empty() || name.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return None; + } + let mut rest = body[name_end..].trim_start(); + let mut params = None; + if rest.starts_with('(') { + let mut depth = 0i32; + let mut close = None; + for (i, c) in rest.char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + close = Some(i); + break; + } + } + _ => {} + } + } + let close = close?; + params = Some(rest[1..close].trim().to_string()); + rest = rest[close + 1..].trim_start(); + } + if rest.is_empty() { + return None; + } + Some(Statement { indent, name: name.to_string(), params, args: rest.to_string(), comment }) +} + +fn qubit_list(qs: &[u32]) -> String { + match qs { + [q] => format!("qubit {q}"), + _ => format!("qubits {}", qs.iter().map(u32::to_string).collect::>().join(", ")), + } +} + +fn smells(src: &str, p: &Program, lines: &[usize]) -> Vec { + let text: Vec<&str> = src.split('\n').collect(); + let n = p.n_qubits as usize; + // How many ops each line made: a fix needs a line that made one. + let mut per_line = std::collections::HashMap::::new(); + for &l in lines { + *per_line.entry(l).or_insert(0) += 1; + } + let alone = |op: usize| -> Option { + let l = lines[op]; + if per_line.get(&l) != Some(&1) { + return None; + } + statement(text.get(l.wrapping_sub(1))?) + }; + let mut out = Vec::new(); + // The order the ops run in, with a barrier (None) at each measurement, + // reset or condition of a program that measures in the middle. + let order: Vec> = match &p.dynamic { + None => (0..p.ops.len()).map(Some).collect(), + Some(steps) => steps.iter().map(|s| if let Instr::Gate(k) = s { Some(*k) } else { None }).collect(), + }; + let mut last: Vec> = vec![None; n]; + let mut used = vec![false; p.ops.len()]; + for step in order { + let Some(j) = step else { + last.iter_mut().for_each(|l| *l = None); + continue; + }; + let b = &p.ops[j]; + if does_nothing(b) { + used[j] = true; + out.push(Smell { + kind: "does_nothing", + message: format!("{} on {} (line {}) does nothing, up to a global phase", kind_name(b.kind), qubit_list(&b.qubits), lines[j]), + lines: vec![lines[j]], + ops: vec![j], + qubits: b.qubits.clone(), + fix: alone(j).map(|_| vec![Edit::Delete(lines[j])]), + }); + } + let prev: Vec> = b.qubits.iter().map(|&q| last[q as usize]).collect(); + if let Some(Some(i)) = prev.first().copied() { + let a = &p.ops[i]; + let adjacent = prev.iter().all(|x| *x == Some(i)) && a.qubits.len() == b.qubits.len() && !used[i] && !used[j]; + if adjacent && cancels(a, b) { + used[i] = true; + used[j] = true; + let what = if a.kind == b.kind { format!("{} on {} twice in a row", kind_name(a.kind), qubit_list(&a.qubits)) } else { format!("{} then {} on {}", kind_name(a.kind), kind_name(b.kind), qubit_list(&a.qubits)) }; + let fix = (alone(i).is_some() && alone(j).is_some() && lines[i] != lines[j]).then(|| vec![Edit::Delete(lines[i]), Edit::Delete(lines[j])]); + out.push(Smell { kind: "cancels", message: format!("{what} (lines {} and {}): together they do nothing", lines[i], lines[j]), lines: vec![lines[i], lines[j]], ops: vec![i, j], qubits: a.qubits.clone(), fix }); + } else if adjacent && merges(a, b) { + used[i] = true; + used[j] = true; + let fix = match (alone(i), alone(j)) { + (Some(sa), Some(sb)) if lines[i] != lines[j] && sa.name == sb.name => match (&sa.params, &sb.params) { + (Some(ea), Some(eb)) if !ea.is_empty() && !eb.is_empty() => Some(vec![ + Edit::Replace(lines[i], format!("{}{}({ea} + {eb}) {};{}", sa.indent, sa.name, sa.args, if sa.comment.is_empty() { String::new() } else { format!(" {}", sa.comment) })), + Edit::Delete(lines[j]), + ]), + _ => None, + }, + _ => None, + }; + out.push(Smell { + kind: "merges", + message: format!("{} on {} twice in a row (lines {} and {}): one {} with the angles added", kind_name(a.kind), qubit_list(&a.qubits), lines[i], lines[j], kind_name(a.kind)), + lines: vec![lines[i], lines[j]], + ops: vec![i, j], + qubits: a.qubits.clone(), + fix, + }); + } + } + for &q in &b.qubits { + last[q as usize] = Some(j); + } + } + // Qubits nothing uses, and qubits acted on but never measured. + let mut touched = vec![false; n]; + for op in &p.ops { + for &q in &op.qubits { + touched[q as usize] = true; + } + } + let mut measured = vec![false; n]; + for &(q, _) in &p.measured { + measured[q as usize] = true; + } + let explicit = src.contains("measure"); + let idle: Vec = (0..n as u32).filter(|&q| !touched[q as usize] && !(explicit && measured[q as usize])).collect(); + if !idle.is_empty() { + out.push(Smell { kind: "idle", message: format!("{} declared, and no gate or measurement uses {}", qubit_list(&idle), if idle.len() == 1 { "it" } else { "them" }), lines: vec![], ops: vec![], qubits: idle, fix: None }); + } + if explicit { + let unmeasured: Vec = (0..n as u32).filter(|&q| touched[q as usize] && !measured[q as usize]).collect(); + if !unmeasured.is_empty() { + out.push(Smell { + kind: "unmeasured", + message: format!("{} acted on but never measured: {} gates change the counts only through gates shared with measured qubits", qubit_list(&unmeasured), if unmeasured.len() == 1 { "its" } else { "their" }), + lines: vec![], + ops: vec![], + qubits: unmeasured, + fix: None, + }); + } + } + out +} + +/// `src` with `edits` made (lines from 1). +fn apply(src: &str, edits: &[Edit]) -> String { + let mut text: Vec> = src.split('\n').map(|l| Some(l.to_string())).collect(); + for e in edits { + match e { + Edit::Delete(l) => { + if let Some(t) = text.get_mut(l - 1) { + *t = None; + } + } + Edit::Replace(l, s) => { + if let Some(t) = text.get_mut(l - 1) { + *t = Some(s.clone()); + } + } + } + } + text.into_iter().flatten().collect::>().join("\n") +} + +fn edit_line(e: &Edit) -> usize { + match e { + Edit::Delete(l) | Edit::Replace(l, _) => *l, + } +} + +/// The variants of `src` its fixes make, as JSON: `base` (its measures) and +/// `candidates`, each with a `label`, its `qasm`, the `changes` made, its +/// `measures` and its `equivalence` to `src`. Only a candidate the check does +/// not find different is offered. +pub fn variants(src: &str) -> Value { + let (p, lines) = match qasm::parse_lines(src) { + Ok(x) => x, + Err(e) => return json!({ "ok": false, "error": e }), + }; + let base = measures(&p); + let first = smells(src, &p, &lines); + let fixable: Vec<&Smell> = first.iter().filter(|s| s.fix.is_some()).collect(); + let mut raw: Vec<(String, String, Vec)> = Vec::new(); + // Every fix, to a fixed point: a fix can bring two gates together. + let mut cur = src.to_string(); + let mut changes = Vec::new(); + for _ in 0..8 { + let Ok((pc, lc)) = qasm::parse_lines(&cur) else { break }; + let mut edits: Vec = Vec::new(); + let mut taken = std::collections::HashSet::new(); + for s in smells(&cur, &pc, &lc).into_iter().filter(|s| s.fix.is_some()) { + let f = s.fix.unwrap(); + if f.iter().any(|e| taken.contains(&edit_line(e))) { + continue; + } + taken.extend(f.iter().map(edit_line)); + changes.push(s.message); + edits.extend(f); + } + if edits.is_empty() { + break; + } + cur = apply(&cur, &edits); + } + if !changes.is_empty() { + raw.push(("every simplification".into(), cur, changes)); + } + // Each fix alone, so one can be taken without the rest; a lone fix that + // is also every simplification is offered once. + for s in fixable.iter().take(6) { + raw.push((format!("only: {}", s.message), apply(src, s.fix.as_ref().unwrap()), vec![s.message.clone()])); + } + let mut seen = std::collections::HashSet::new(); + raw.retain(|(_, qasm, _)| seen.insert(qasm.clone())); + let candidates: Vec = raw + .into_iter() + .filter_map(|(label, qasm, changes)| { + let pc = qasm::parse(&qasm).ok()?; + let eq = equiv::equivalent_programs(&p, &pc); + (eq["verdict"] != "different").then(|| json!({ "label": label, "qasm": qasm, "changes": changes, "measures": measures(&pc), "equivalence": eq })) + }) + .collect(); + json!({ "ok": true, "base": base, "candidates": candidates }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_measures_are_counted() { + let c = critique("OPENQASM 3.0;\nqubit[3] q;\nbit[3] c;\nh q[0];\nt q[1];\ncx q[0], q[1];\nccx q[0], q[1], q[2];\nrz(0.3) q[2];\nc = measure q;\n"); + assert_eq!(c["ok"], true, "{c}"); + assert_eq!((c["gates"].as_u64(), c["one_qubit"].as_u64(), c["two_qubit"].as_u64(), c["three_qubit"].as_u64()), (Some(5), Some(3), Some(1), Some(1))); + assert_eq!((c["t_count"].as_u64(), c["non_clifford"].as_u64(), c["clifford"].as_bool()), (Some(1), Some(3), Some(false))); + // h | t, then cx (after both), ccx, rz: depth 4; two-qubit depth 2. + assert_eq!((c["depth"].as_u64(), c["two_qubit_depth"].as_u64()), (Some(4), Some(2))); + assert_eq!(c["by_gate"]["cx"], 1); + let f = c["fidelity_model"]["estimate"].as_f64().unwrap(); + let want = (1.0 - 1e-3f64).powi(3) * (1.0 - 5e-3f64).powi(7) * (1.0 - 1e-2f64).powi(3); + assert!((f - want).abs() < 1e-12, "{f} vs {want}"); + assert!(c["smells"].as_array().unwrap().is_empty(), "{c}"); + assert_eq!(critique("qubit[2] q; h q[0]; nonsense q[1];")["ok"], false); + assert_eq!(critique("qubit[2] q; h q[0];\nnonsense q[1];")["line"], 2); + } + + #[test] + fn smells_name_their_lines_and_only_lone_statements_are_fixed() { + let src = "OPENQASM 3.0;\nqubit[4] q;\nbit[3] c;\nh q[0];\nh q[0]; // twice\nrz(pi/4) q[1];\nrz(-pi/8) q[1];\nrx(0) q[2];\ncx q[1], q[2];\nx q;\nx q;\nc[0] = measure q[0];\nc[1] = measure q[1];\nc[2] = measure q[2];\n"; + let c = critique(src); + let smells = c["smells"].as_array().unwrap(); + let find = |k: &str| smells.iter().filter(|s| s["kind"] == k).cloned().collect::>(); + let cancels = find("cancels"); + // h h on lines 4–5 (fixable); x q twice is a broadcast pair on every qubit (not fixable). + assert_eq!(cancels[0]["lines"], json!([4, 5])); + assert_eq!(cancels[0]["fixable"], true); + assert!(cancels.iter().skip(1).all(|s| s["fixable"] == false && s["lines"] == json!([10, 11])), "{cancels:?}"); + assert_eq!(find("merges")[0]["lines"], json!([6, 7])); + assert_eq!(find("does_nothing")[0]["lines"], json!([8])); + assert_eq!(find("idle").len(), 0, "every qubit has a gate"); + assert_eq!(find("unmeasured")[0]["qubits"], json!([3])); + // The ops a smell names are the program's, for the diagram. + assert_eq!(cancels[0]["ops"], json!([0, 1])); + let idle = critique("qubit[3] q; h q[0];"); + assert_eq!(idle["smells"][0]["kind"], "idle"); + assert_eq!(idle["smells"][0]["qubits"], json!([1, 2])); + } + + #[test] + fn a_measurement_in_the_middle_is_a_barrier() { + let c = critique("qubit[1] q; bit[2] c;\nh q[0];\nc[0] = measure q[0];\nh q[0];\nc[1] = measure q[0];\n"); + assert!(c["smells"].as_array().unwrap().iter().all(|s| s["kind"] != "cancels"), "{c}"); + assert_eq!(c["dynamic"], true); + } + + #[test] + fn variants_are_fixed_to_a_fixed_point_and_checked_equivalent() { + // rz(a) rz(−a) merges to rz(a + -a), which then does nothing; h h cancels, + // which brings two x together, which then cancel too. + let src = "OPENQASM 3.0;\nqubit[2] q;\nx q[0];\nh q[0];\nh q[0];\nx q[0];\nrz(0.3) q[1];\nrz(-0.3) q[1];\ncx q[0], q[1];\nry(0.2) q[1];\n"; + let v = variants(src); + assert_eq!(v["ok"], true, "{v}"); + let all = &v["candidates"][0]; + assert_eq!(all["label"], "every simplification"); + let q = all["qasm"].as_str().unwrap(); + assert!(q.lines().all(|l| !matches!(l.trim(), "h q[0];" | "x q[0];") && !l.contains("rz(")), "{q}"); + assert!(q.contains("cx q[0], q[1];") && q.contains("ry(0.2) q[1];"), "{q}"); + assert_eq!(all["measures"]["gates"], 2); + assert_eq!(all["equivalence"]["verdict"], "equivalent", "{all}"); + // The single fixes are offered too, each checked. + let labels: Vec<&str> = v["candidates"].as_array().unwrap().iter().map(|c| c["label"].as_str().unwrap()).collect(); + assert!(labels.iter().any(|l| l.starts_with("only: h")), "{labels:?}"); + assert!(v["candidates"].as_array().unwrap().iter().all(|c| c["equivalence"]["verdict"] == "equivalent")); + // A merge keeps the user's own expressions. + let m = variants("qubit[1] q;\nh q[0];\nrz(pi/4) q[0]; // quarter\nrz(pi/8) q[0];\n"); + assert_eq!(m["candidates"][0]["qasm"], "qubit[1] q;\nh q[0];\nrz(pi/4 + pi/8) q[0]; // quarter\n", "{m}"); + assert_eq!(m["candidates"][0]["equivalence"]["verdict"], "equivalent"); + // A lone fix that is also every simplification is offered once. + assert_eq!(m["candidates"].as_array().unwrap().len(), 1, "{m}"); + assert_eq!(m["candidates"][0]["changes"].as_array().unwrap().len(), 1); + // A lone fix that leads to another is offered alone as well. + let one = variants("qubit[1] q;\nx q[0];\nh q[0];\nh q[0];\nx q[0];\n"); + let changes: Vec = one["candidates"].as_array().unwrap().iter().map(|c| c["changes"].as_array().unwrap().len()).collect(); + assert_eq!(changes, vec![2, 1], "{one}"); + // Nothing to fix: no candidates. + assert_eq!(variants("qubit[2] q; h q[0]; cx q[0], q[1];")["candidates"], json!([])); + } +} diff --git a/wai/wai-quantum/src/engine/draw.rs b/wai/wai-quantum/src/engine/draw.rs index 5ea272971..f53959436 100644 --- a/wai/wai-quantum/src/engine/draw.rs +++ b/wai/wai-quantum/src/engine/draw.rs @@ -12,6 +12,8 @@ use crate::qasm::{Cond, Instr, Kind, Op, Program}; struct Item { cells: Vec<(u32, String)>, joined: bool, + /// The op it draws, for a gate. + op: Option, } /// `x` as a multiple of π where it is one with a small denominator, else in @@ -52,11 +54,11 @@ fn gate(op: &Op) -> Item { use Kind::*; let q = &op.qubits; let p = params(&op.params); - let one = |l: &str| Item { cells: vec![(q[0], l.to_string())], joined: false }; + let one = |l: &str| Item { cells: vec![(q[0], l.to_string())], joined: false, op: None }; let ctl = |rest: Vec<(u32, String)>| { let mut cells = vec![(q[0], "●".to_string())]; cells.extend(rest); - Item { cells, joined: true } + Item { cells, joined: true, op: None } }; match op.kind { Id => one("I"), @@ -84,14 +86,14 @@ fn gate(op: &Op) -> Item { Cry => ctl(vec![(q[1], format!("Ry({p})"))]), Crz => ctl(vec![(q[1], format!("Rz({p})"))]), Cu => ctl(vec![(q[1], format!("U({p})"))]), - Swap => Item { cells: vec![(q[0], "×".into()), (q[1], "×".into())], joined: true }, - Iswap => Item { cells: vec![(q[0], "iSWAP".into()), (q[1], "iSWAP".into())], joined: true }, - Rxx => Item { cells: vec![(q[0], format!("Rxx({p})")), (q[1], format!("Rxx({p})"))], joined: true }, - Ryy => Item { cells: vec![(q[0], format!("Ryy({p})")), (q[1], format!("Ryy({p})"))], joined: true }, - Rzz => Item { cells: vec![(q[0], format!("Rzz({p})")), (q[1], format!("Rzz({p})"))], joined: true }, - Ccx => Item { cells: vec![(q[0], "●".into()), (q[1], "●".into()), (q[2], "⊕".into())], joined: true }, - Ccz => Item { cells: vec![(q[0], "●".into()), (q[1], "●".into()), (q[2], "●".into())], joined: true }, - Cswap => Item { cells: vec![(q[0], "●".into()), (q[1], "×".into()), (q[2], "×".into())], joined: true }, + Swap => Item { cells: vec![(q[0], "×".into()), (q[1], "×".into())], joined: true, op: None }, + Iswap => Item { cells: vec![(q[0], "iSWAP".into()), (q[1], "iSWAP".into())], joined: true, op: None }, + Rxx => Item { cells: vec![(q[0], format!("Rxx({p})")), (q[1], format!("Rxx({p})"))], joined: true, op: None }, + Ryy => Item { cells: vec![(q[0], format!("Ryy({p})")), (q[1], format!("Ryy({p})"))], joined: true, op: None }, + Rzz => Item { cells: vec![(q[0], format!("Rzz({p})")), (q[1], format!("Rzz({p})"))], joined: true, op: None }, + Ccx => Item { cells: vec![(q[0], "●".into()), (q[1], "●".into()), (q[2], "⊕".into())], joined: true, op: None }, + Ccz => Item { cells: vec![(q[0], "●".into()), (q[1], "●".into()), (q[2], "●".into())], joined: true, op: None }, + Cswap => Item { cells: vec![(q[0], "●".into()), (q[1], "×".into()), (q[2], "×".into())], joined: true, op: None }, } } @@ -113,8 +115,8 @@ fn items(p: &Program) -> (Vec, Vec) { let mut out = Vec::new(); let mut legend = Vec::new(); let Some(steps) = &p.dynamic else { - out.extend(p.ops.iter().map(gate)); - out.extend(p.measured.iter().map(|&(q, b)| Item { cells: vec![(q, format!("M{b}"))], joined: false })); + out.extend(p.ops.iter().enumerate().map(|(k, o)| Item { op: Some(k), ..gate(o) })); + out.extend(p.measured.iter().map(|&(q, b)| Item { cells: vec![(q, format!("M{b}"))], joined: false, op: None })); return (out, legend); }; // Each step's condition: a JumpUnless guards the steps up to its target; @@ -143,9 +145,9 @@ fn items(p: &Program) -> (Vec, Vec) { } for (i, s) in steps.iter().enumerate() { let mut item = match s { - Instr::Gate(k) => gate(&p.ops[*k]), - Instr::Measure { qubit, bit } => Item { cells: vec![(*qubit, format!("M{bit}"))], joined: false }, - Instr::Reset(q) => Item { cells: vec![(*q, "|0⟩".into())], joined: false }, + Instr::Gate(k) => Item { op: Some(*k), ..gate(&p.ops[*k]) }, + Instr::Measure { qubit, bit } => Item { cells: vec![(*qubit, format!("M{bit}"))], joined: false, op: None }, + Instr::Reset(q) => Item { cells: vec![(*q, "|0⟩".into())], joined: false, op: None }, Instr::JumpUnless(..) | Instr::Jump(_) => continue, }; if let Some(n) = guard[i] { @@ -181,8 +183,9 @@ fn columns(p: &Program) -> (Vec>, Vec) { } /// The same drawing as data, for a page to draw: the qubits, the columns -/// (each a list of items, each its cells as `[qubit, label]` and whether a -/// line joins them) and the legend of conditions. A label is what the text +/// (each a list of items, each its cells as `[qubit, label]`, whether a line +/// joins them, and for a gate `op`, its index in the program's ops) and the +/// legend of conditions. A label is what the text /// drawing shows: `●` a control, `⊕` a CX target, `×` a swap, `Mk` a /// measurement into bit k, `|0⟩` a reset, any other the gate; `?n` after it /// marks condition n. @@ -193,6 +196,7 @@ pub fn layout(p: &Program) -> serde_json::Value { "columns": columns.iter().map(|col| col.iter().map(|it| serde_json::json!({ "cells": it.cells.iter().map(|(q, l)| serde_json::json!([q, l])).collect::>(), "joined": it.joined, + "op": it.op, })).collect::>()).collect::>(), "legend": legend, }) diff --git a/wai/wai-quantum/src/engine/equiv.rs b/wai/wai-quantum/src/engine/equiv.rs new file mode 100644 index 000000000..1d76fe081 --- /dev/null +++ b/wai/wai-quantum/src/engine/equiv.rs @@ -0,0 +1,213 @@ +//! Whether two programs apply the same gates, up to a global phase, and how +//! that was decided. +//! +//! A program's gates make one unitary `U`; two programs agree when +//! `U₁ = e^{iφ} U₂` and they write the same qubits to the same bits. The check +//! picks the strongest way it can afford: +//! +//! - **tableau**: both programs are Clifford circuits. Each one's stabilizer +//! tableau from `|0…0⟩` holds the images of every `Xⱼ` and `Zⱼ` with their +//! signs, which fix the unitary up to a global phase, so equal tableaus are +//! equal circuits. Exact, at any number of qubits. +//! - **exact**: at most [`EXACT_MAX_QUBITS`] qubits (and within a work budget). +//! Every basis state `|k⟩` is run through both, and the outputs must agree +//! with one phase for all `k`. Exact up to rounding. +//! - **random**: at most [`RANDOM_MAX_QUBITS`] qubits. [`RANDOM_INPUTS`] +//! product states with seeded random angles are run through both. Two +//! different unitaries agree on such a state with probability zero, so this +//! is a check with no false "different" and a vanishing chance of a false +//! "equivalent" (only an adversarial pair could pass); it says so. +//! - **unchecked**: wider than that and not Clifford, or a program that +//! measures in the middle or acts on what it measured. + +use crate::gates::{C, Gate, complex}; +use crate::qasm::{self, Program}; +use crate::quantum_sv::StateVector; +use crate::quantum_tableau::Tableau; +use serde_json::{Value, json}; + +/// The widest pair the exact check runs on every basis state. +pub const EXACT_MAX_QUBITS: u32 = 8; +/// The widest pair the random check runs. +pub const RANDOM_MAX_QUBITS: u32 = 22; +/// Random product states the random check runs. +pub const RANDOM_INPUTS: usize = 3; +/// Gate applications times amplitudes the exact check may spend before it +/// falls back to the random one. +const EXACT_BUDGET: f64 = 2e8; +/// How far an output's overlap may fall short of 1, and how far two phases +/// may differ (radians), and still agree. +const TOLERANCE: f64 = 1e-8; + +/// The verdict on `a` and `b` as JSON: `verdict` ("equivalent", "different" +/// or "unchecked"), `how` (the check that decided it), `why`, and for +/// equivalent programs `global_phase` (`φ/π`, where the check measures one). +pub fn equivalent(a: &str, b: &str) -> Value { + let (pa, pb) = match (qasm::parse(a), qasm::parse(b)) { + (Ok(x), Ok(y)) => (x, y), + (Err(e), _) => return json!({ "verdict": "unchecked", "how": "none", "why": format!("the first program does not read: {e}") }), + (_, Err(e)) => return json!({ "verdict": "unchecked", "how": "none", "why": format!("the second program does not read: {e}") }), + }; + equivalent_programs(&pa, &pb) +} + +/// [`equivalent`] on programs already read. +pub fn equivalent_programs(a: &Program, b: &Program) -> Value { + if a.dynamic.is_some() || b.dynamic.is_some() { + return json!({ "verdict": "unchecked", "how": "none", "why": "a program measures in the middle, resets after a gate or acts on what it measured; this check compares the gates of programs whose measurements all come at the end" }); + } + if a.n_qubits != b.n_qubits { + return json!({ "verdict": "different", "how": "shape", "why": format!("one has {} qubits and the other {}", a.n_qubits, b.n_qubits) }); + } + if a.measured != b.measured { + return json!({ "verdict": "different", "how": "shape", "why": "they write different qubits to the bits" }); + } + let n = a.n_qubits; + if let (Ok(ca), Ok(cb)) = (a.clifford(), b.clifford()) + && let (Ok(ta), Ok(tb)) = (Tableau::run(n, &ca), Tableau::run(n, &cb)) + { + return if ta == tb { + json!({ "verdict": "equivalent", "how": "tableau", "why": "both are Clifford circuits with the same tableau: the same unitary up to a global phase, at any number of qubits" }) + } else { + json!({ "verdict": "different", "how": "tableau", "why": "both are Clifford circuits, and their tableaus differ" }) + }; + } + let (ga, gb) = (a.matrices(), b.matrices()); + let work = (ga.len() + gb.len()) as f64 * f64::from(n).exp2() * f64::from(n).exp2(); + if n <= EXACT_MAX_QUBITS && work <= EXACT_BUDGET { + let inputs: Vec> = (0..1usize << n).map(|k| basis(n, k)).collect(); + return compare(n, &ga, &gb, &inputs, "exact", |k| format!("|{}⟩", bits(n, k))); + } + if n <= RANDOM_MAX_QUBITS { + let mut s = 0x2545_f491_4f6c_dd1du64; + let inputs: Vec> = (0..RANDOM_INPUTS).map(|_| random_product(n, &mut s)).collect(); + return compare(n, &ga, &gb, &inputs, "random", |k| format!("random product state {}", k + 1)); + } + json!({ "verdict": "unchecked", "how": "none", "why": format!("{n} qubits and not both Clifford: wider than this check runs ({RANDOM_MAX_QUBITS} qubits)") }) +} + +/// Run each input preparation followed by `a`, and by `b`, and compare. +fn compare(n: u32, a: &[Gate], b: &[Gate], inputs: &[Vec], how: &str, name: impl Fn(usize) -> String) -> Value { + let mut phase: Option = None; + for (k, prep) in inputs.iter().enumerate() { + let (Ok(sa), Ok(sb)) = (state(n, prep, a), state(n, prep, b)) else { + return json!({ "verdict": "unchecked", "how": "none", "why": "a gate does not apply to the state vector" }); + }; + // ⟨b|a⟩: of modulus 1 exactly when a = e^{iφ} b. + let (mut re, mut im) = (0.0f64, 0.0f64); + for i in 0..sa.re.len() { + re += sb.re[i] * sa.re[i] + sb.im[i] * sa.im[i]; + im += sb.re[i] * sa.im[i] - sb.im[i] * sa.re[i]; + } + let m = re.hypot(im); + if m < 1.0 - TOLERANCE { + return json!({ "verdict": "different", "how": how, "why": format!("on {} the outputs differ: their overlap is {m:.6}", name(k)) }); + } + let p = im.atan2(re); + match phase { + None => phase = Some(p), + Some(p0) => { + let d = (p - p0 + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + if d.abs() > 1e-6 { + return json!({ "verdict": "different", "how": how, "why": format!("on {} the outputs agree only up to a different phase ({:.4}π against {:.4}π): a relative phase the circuits do not share", name(k), p / std::f64::consts::PI, p0 / std::f64::consts::PI) }); + } + } + } + } + let why = match how { + "exact" => format!("every one of the {} basis states gives the same output up to one global phase", inputs.len()), + _ => format!("{} random product states give the same output up to one global phase: a check, not a proof; two different circuits pass it with probability zero", inputs.len()), + }; + json!({ "verdict": "equivalent", "how": how, "why": why, "global_phase": phase.unwrap_or(0.0) / std::f64::consts::PI }) +} + +fn state(n: u32, prep: &[Gate], gates: &[Gate]) -> Result { + let mut sv = StateVector::zero(n)?; + for g in prep.iter().chain(gates) { + sv.apply(g)?; + } + Ok(sv) +} + +/// `X` on the qubits whose bit is set in `k`: qubit 0 is the most significant +/// bit, as the state vector indexes. +fn basis(n: u32, k: usize) -> Vec { + let (o, l) = (complex(0.0, 0.0), complex(1.0, 0.0)); + (0..n).filter(|&q| k >> (n - 1 - q) & 1 == 1).map(|q| Gate { qubits: vec![q], matrix: vec![o, l, l, o] }).collect() +} + +fn bits(n: u32, k: usize) -> String { + (0..n).map(|q| if k >> (n - 1 - q) & 1 == 1 { '1' } else { '0' }).collect() +} + +/// `U(θ, φ, λ)` on every qubit, the angles from the stream `s`. +fn random_product(n: u32, s: &mut u64) -> Vec { + let mut unit = || { + *s = s.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = *s; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64 + }; + let cis = |x: f64| { + let (s, c) = crate::repro::sin_cos(x); + complex(c, s) + }; + let mul = |a: C, b: C| complex(a.re * b.re - a.im * b.im, a.re * b.im + a.im * b.re); + (0..n) + .map(|q| { + let (t, p, l) = (unit() * std::f64::consts::PI, unit() * std::f64::consts::TAU, unit() * std::f64::consts::TAU); + let (s, c) = crate::repro::sin_cos(t / 2.0); + let m = vec![complex(c, 0.0), mul(cis(l), complex(-s, 0.0)), mul(cis(p), complex(s, 0.0)), mul(cis(p + l), complex(c, 0.0))]; + Gate { qubits: vec![q], matrix: m } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(a: &str, b: &str) -> (String, String) { + let r = equivalent(a, b); + (r["verdict"].as_str().unwrap().to_string(), r["how"].as_str().unwrap().to_string()) + } + + #[test] + fn identities_hold_and_differences_are_found() { + // H X H = Z: Clifford, so the tableau decides. + assert_eq!(v("qubit[1] q; h q[0]; x q[0]; h q[0];", "qubit[1] q; z q[0];"), ("equivalent".into(), "tableau".into())); + // CX = (I ⊗ H) CZ (I ⊗ H). + assert_eq!(v("qubit[2] q; cx q[0], q[1];", "qubit[2] q; h q[1]; cz q[0], q[1]; h q[1];"), ("equivalent".into(), "tableau".into())); + assert_eq!(v("qubit[2] q; cx q[0], q[1];", "qubit[2] q; cx q[1], q[0];"), ("different".into(), "tableau".into())); + // rz(a) rz(b) = rz(a + b), off the Clifford angles: the exact check. + assert_eq!(v("qubit[2] q; h q; rz(0.3) q[0]; rz(0.4) q[0]; cx q[0], q[1];", "qubit[2] q; h q; rz(0.7) q[0]; cx q[0], q[1];"), ("equivalent".into(), "exact".into())); + assert_eq!(v("qubit[2] q; h q; rz(0.3) q[0]; cx q[0], q[1];", "qubit[2] q; h q; rz(0.31) q[0]; cx q[0], q[1];"), ("different".into(), "exact".into())); + // A relative phase: diag(1, i) against the identity agree on |0⟩ and |1⟩ in modulus, not in phase. + assert_eq!(v("qubit[1] q; s q[0]; t q[0]; tdg q[0];", "qubit[1] q; t q[0]; t q[0];").0, "equivalent"); + let r = equivalent("qubit[1] q; rz(0.2) q[0];", "qubit[1] q; id q[0];"); + assert_eq!(r["verdict"], "different"); + assert!(r["why"].as_str().unwrap().contains("phase"), "{r}"); + // A global phase alone is no difference: rz(2π) = −I. + let r = equivalent("qubit[1] q; h q[0]; rz(2*pi) q[0];", "qubit[1] q; h q[0];"); + assert_eq!(r["verdict"], "equivalent"); + } + + #[test] + fn wide_programs_take_the_random_check_and_shapes_are_compared_first() { + let a = "qubit[12] q; h q; rx(0.25) q[3]; rx(0.5) q[3]; cx q[3], q[9]; rz(1.1) q[11];"; + let b = "qubit[12] q; h q; rx(0.75) q[3]; cx q[3], q[9]; rz(1.1) q[11];"; + let r = equivalent(a, b); + assert_eq!((r["verdict"].as_str().unwrap(), r["how"].as_str().unwrap()), ("equivalent", "random"), "{r}"); + assert!(r["why"].as_str().unwrap().contains("not a proof")); + let c = "qubit[12] q; h q; rx(0.75) q[3]; cx q[3], q[9]; rz(1.0) q[11];"; + assert_eq!(equivalent(a, c)["verdict"], "different"); + assert_eq!(equivalent("qubit[2] q; h q[0];", "qubit[3] q; h q[0];")["how"], "shape"); + assert_eq!(equivalent("qubit[2] q; bit[1] c; h q[0]; c[0] = measure q[0];", "qubit[2] q; bit[1] c; h q[0]; c[0] = measure q[1];")["verdict"], "different"); + assert_eq!(equivalent("qubit[1] q; bit[1] c; h q[0]; c[0] = measure q[0]; x q[0];", "qubit[1] q; h q[0];")["verdict"], "unchecked"); + assert_eq!(equivalent("qubit[2] q; h q[0];", "this is not a program")["verdict"], "unchecked"); + // A wide Clifford pair is still exact. + let w = "qubit[40] q; h q; cz q[0], q[39]; s q[7];"; + assert_eq!(v(w, "qubit[40] q; h q; s q[7]; cz q[39], q[0];"), ("equivalent".into(), "tableau".into())); + } +} diff --git a/wai/wai-quantum/src/engine/mod.rs b/wai/wai-quantum/src/engine/mod.rs index c4dea9716..058063363 100644 --- a/wai/wai-quantum/src/engine/mod.rs +++ b/wai/wai-quantum/src/engine/mod.rs @@ -18,16 +18,29 @@ //! - [`job`] (with `quantum_receipt`): a job end to end, planned, run and //! sealed, in the service's form; its check; and a program's lint. //! - [`draw`]: a program's circuit as text. +//! - [`critique`]: what the engine can say about a design before it runs (its +//! measures and its smells, each with its lines), and the variants its fixes +//! make, each checked equivalent before it is offered. +//! - [`equiv`]: whether two programs apply the same gates up to a global +//! phase, and how that was decided (a tableau, every basis state, or random +//! product states). +//! - [`shortcuts`]: every exact classical route the engine knows for a +//! circuit, each with its cost on it, and the cheapest for each question. A +//! cheap route proves a design classically easy; none being cheap proves +//! nothing. //! //! Requests and results are the service's JSON (`/v1/openapi.json` there). //! Keys, metering a job's energy, queueing and limits per client stay with //! whatever hosts the engine. pub mod catalog; +pub mod critique; pub mod draw; pub mod dynamic; +pub mod equiv; #[cfg(feature = "quantum_receipt")] pub mod job; #[cfg(feature = "quantum_receipt")] pub mod receipt; pub mod run; +pub mod shortcuts; diff --git a/wai/wai-quantum/src/engine/run.rs b/wai/wai-quantum/src/engine/run.rs index 0e1ae86cb..386a6ce08 100644 --- a/wai/wai-quantum/src/engine/run.rs +++ b/wai/wai-quantum/src/engine/run.rs @@ -473,7 +473,7 @@ pub fn run(request: &Value, plan: &Plan) -> Result { } } "tensor_network" => { - use crate::quantum_tn::{Network, PathOptions, contract_plan, find_path_restarts}; + use crate::quantum_tn::{Network, PathOptions, contract_plan, find_path_restarts_seeded, sweep_orders, sweep_path, tensor_places}; // Independent path searches, side by side: the search's cost varies with its // seed, and the cheapest of a few is much cheaper than one. A tab has one thread. let restarts = r.options.get("path_restarts").and_then(Value::as_u64).map(|k| k.clamp(1, 64) as usize).unwrap_or(if cfg!(target_arch = "wasm32") { 1 } else { 8 }); @@ -485,9 +485,13 @@ pub fn run(request: &Value, plan: &Plan) -> Result { // qubit q's bit, in qubit order. let bits: Vec = (0..n).map(|q| b.as_bytes()[(n - 1 - q) as usize] - b'0').collect(); // Controlled and diagonal gates split at their block-diagonal qubits. - let mut net = Network::amplitude_split(n, &gates, &bits, &[]).map_err(|e| format!("{e:?}"))?; + let (mut net, place) = Network::amplitude_split_placed(n, &gates, &bits, &[]).map_err(|e| format!("{e:?}"))?; net.reduce(); - let plan = find_path_restarts(&net.shape(), &net.dims, &net.open, &PathOptions::default(), restarts); + // The sweeps qubit by qubit and time by time, beside the search's own + // trees: a deep circuit on a line wants the first, which the search + // alone rarely finds. + let sweeps: Vec<_> = sweep_orders(&tensor_places(&net, &place)).iter().map(|o| sweep_path(o)).collect(); + let plan = find_path_restarts_seeded(&net.shape(), &net.dims, &net.open, &PathOptions::default(), restarts, &sweeps); flops += plan.total_flops(); let t = contract_plan(&net, &plan).map_err(|e| format!("{e:?}"))?; let a = t.data.first().copied().unwrap_or(crate::gates::complex(0.0, 0.0)); diff --git a/wai/wai-quantum/src/engine/shortcuts.rs b/wai/wai-quantum/src/engine/shortcuts.rs new file mode 100644 index 000000000..f15c73d58 --- /dev/null +++ b/wai/wai-quantum/src/engine/shortcuts.rs @@ -0,0 +1,570 @@ +//! A circuit's classical shortcuts: every exact route the engine knows for +//! simulating it, each with its cost on this circuit. A designer sees whether +//! a design is classically easy, by which route, and why. +//! +//! - **stabilizer**: a Clifford circuit, by its tableau, at any width. +//! - **cutrank**: a Clifford circuit followed by one layer of one-qubit gates, +//! summed along an order of the qubits, with a table of `2^cut` entries. +//! - **mps**: a matrix product state along the qubits' own order, exact at a +//! bond no larger than a bound. Across each cut the bound is the product of +//! the operator Schmidt ranks of the gates that cross it, each computed from +//! the gate's matrix. It is never more than the smaller side's dimension. The +//! bound is rigorous; the bond actually needed may be smaller. +//! - **light_cone**: a one-qubit expectation value needs only the qubits in +//! its backward light cone, as a state vector over them. +//! - **tensor_network** (thorough only): one amplitude of a random output, +//! by the contraction tree the engine's search finds, with the sweeps +//! qubit by qubit and time by time among its candidates. The cost is its +//! largest intermediate and its multiply-adds. +//! - **statevector**: the dense state, `2^n` amplitudes. +//! +//! Each route gives its memory and the log2 of its operations to a first +//! answer: a sample where the route gives counts, otherwise an amplitude or an +//! expectation value. A time at +//! [`REFERENCE_RATE`] is given for scale; it is a reference, not a +//! measurement. +//! +//! Every route here is exact. A cheap route proves a design classically easy. +//! No cheap route among these proves nothing about hardness: other classical +//! methods, approximate ones above all, may cost far less. + +use super::catalog; +use crate::gates::{C, Gate}; +use crate::qasm::Program; +use serde_json::{Map, Value, json}; + +/// Operations a second, for the times given for scale. A reference, not a +/// measurement of any machine. +pub const REFERENCE_RATE: f64 = 1e10; + +/// What a route answers. +const COUNTS: &str = "counts"; +const AMPLITUDES: &str = "amplitudes"; +const EXPECTATIONS: &str = "expectations"; +const ONE_QUBIT: &str = "one_qubit_expectations"; + +struct Route { + route: &'static str, + answers: &'static [&'static str], + applies: bool, + why: String, + /// `(memory in bytes, log2 of the operations to a first answer)`, when the + /// route applies and was costed. + cost: Option<(f64, f64)>, + /// The route's own measure of size: its widest cut, bond, cone or tensor. + size: Map, +} + +impl Route { + fn not(route: &'static str, answers: &'static [&'static str], why: impl Into) -> Route { + Route { route, answers, applies: false, why: why.into(), cost: None, size: Map::new() } + } + + fn json(&self) -> Value { + let mut v = json!({ "route": self.route, "answers": self.answers, "applies": self.applies, "why": self.why }); + if let Some((bytes, log2_work)) = self.cost { + v["memory_bytes"] = json!(bytes); + v["log2_work"] = json!(round2(log2_work)); + v["seconds_at_reference"] = json!(seconds(log2_work)); + } + if !self.size.is_empty() { + v["size"] = Value::Object(self.size.clone()); + } + v + } +} + +fn round2(x: f64) -> f64 { + (x * 100.0).round() / 100.0 +} + +fn seconds(log2_work: f64) -> f64 { + 2f64.powf(log2_work) / REFERENCE_RATE +} + +/// The classical shortcuts `src` admits, as JSON: +/// `{ok, qubits, gates, routes: [...], cheapest: {counts, amplitudes, +/// expectations, one_qubit_expectations}, summary, reference_rate, note}`. +/// `thorough` also searches a contraction tree for one amplitude, and searches +/// longer for a cut-rank order. That takes seconds to minutes on a large +/// circuit, where the quick check takes a moment. +pub fn shortcuts(src: &str, thorough: bool) -> Value { + let r = match catalog::read(&json!({ "qasm": src })) { + Ok(r) => r, + Err(e) => return json!({ "ok": false, "error": e }), + }; + let p = &r.program; + let dynamic = p.dynamic.is_some(); + let in_the_middle = "the program measures in the middle, resets after a gate or acts on what it measured"; + let routes = vec![ + stabilizer(p), + if dynamic { Route::not("cutrank", &[COUNTS, AMPLITUDES], in_the_middle) } else { cutrank(&r, thorough) }, + if dynamic { Route::not("mps", &[COUNTS, AMPLITUDES, EXPECTATIONS], in_the_middle) } else { mps(p) }, + if dynamic { Route::not("light_cone", &[ONE_QUBIT], in_the_middle) } else { light_cone(p) }, + if dynamic { Route::not("tensor_network", &[AMPLITUDES], in_the_middle) } else { tensor_network(p, thorough) }, + statevector(p), + ]; + let cheapest = |question: &str| -> Value { + routes + .iter() + .filter(|r| r.applies && (r.answers.contains(&question) || (question == ONE_QUBIT && r.answers.contains(&EXPECTATIONS)))) + .filter_map(|r| r.cost.map(|c| (r, c))) + .min_by(|(_, a), (_, b)| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0))) + .map(|(r, (bytes, w))| json!({ "route": r.route, "memory_bytes": bytes, "log2_work": round2(w), "seconds_at_reference": seconds(w) })) + .unwrap_or(Value::Null) + }; + let best: Map = [COUNTS, AMPLITUDES, EXPECTATIONS, ONE_QUBIT].iter().map(|q| (q.to_string(), cheapest(q))).collect(); + let summary = summary(p.n_qubits, &routes, &best[COUNTS]); + json!({ + "ok": true, + "qubits": p.n_qubits, + "gates": p.ops.len(), + "thorough": thorough, + "routes": routes.iter().map(Route::json).collect::>(), + "cheapest": best, + "summary": summary, + "reference_rate": REFERENCE_RATE, + "note": "every route here is exact; a cheap one proves the circuit classically easy, and none being cheap proves nothing about hardness: other methods, approximate ones above all, may cost far less", + }) +} + +/// A sentence for the cheapest route to samples. +fn summary(n: u32, routes: &[Route], best: &Value) -> String { + if routes.iter().any(|r| r.route == "stabilizer" && r.applies) { + return format!("Classically easy at any width: every gate is Clifford, so the stabilizer tableau samples all {n} qubits in polynomial time."); + } + let Some(name) = best["route"].as_str() else { + return "No route here samples this circuit exactly.".into(); + }; + let w = best["log2_work"].as_f64().unwrap_or(f64::NAN); + let route = routes.iter().find(|r| r.route == name).expect("the cheapest is one of the routes"); + let what = match name { + "cutrank" => format!("the cut-rank sum, with a table of 2^{} entries ({})", route.size["widest_cut"], bytes_text(best["memory_bytes"].as_f64().unwrap_or(0.0))), + "mps" => format!("an exact matrix product state, at a bond of at most 2^{}", route.size["log2_bond"]), + "statevector" => format!("the state vector, 2^{n} amplitudes ({})", bytes_text(best["memory_bytes"].as_f64().unwrap_or(0.0))), + other => other.to_string(), + }; + format!("The cheapest exact route here to a sample is {what}: about 2^{w:.0} operations, {} at 10^10 a second.", time_text(seconds(w))) +} + +fn bytes_text(b: f64) -> String { + let units = [("EiB", 60), ("PiB", 50), ("TiB", 40), ("GiB", 30), ("MiB", 20), ("KiB", 10)]; + for (u, s) in units { + let x = b / 2f64.powi(s); + if x >= 1.0 { + return format!("{} {u}", if x < 10.0 { format!("{x:.1}") } else { format!("{x:.0}") }); + } + } + format!("{b:.0} bytes") +} + +fn time_text(s: f64) -> String { + match s { + s if s < 1e-3 => "under a millisecond".into(), + s if s < 1.0 => format!("{:.0} ms", s * 1e3), + s if s < 120.0 => format!("{s:.0} s"), + s if s < 7200.0 => format!("{:.0} minutes", s / 60.0), + s if s < 172_800.0 => format!("{:.0} hours", s / 3600.0), + s if s < 3.15e7 * 2.0 => format!("{:.0} days", s / 86_400.0), + s => format!("{:.1e} years", s / 3.15e7), + } +} + +fn stabilizer(p: &Program) -> Route { + let answers: &'static [&'static str] = &[COUNTS, EXPECTATIONS]; + if let Err(e) = p.clifford() { + return Route::not("stabilizer", answers, format!("not a Clifford circuit: {e}")); + } + let n = p.n_qubits as f64; + let max = catalog::STABILIZER_MAX_QUBITS; + if p.n_qubits > max { + return Route::not("stabilizer", answers, format!("a Clifford circuit, but the tableau here holds at most {max} qubits")); + } + let words = (n / 64.0).ceil(); + // Each gate a pass over a column pair; a sample, the elimination. + let work = p.ops.len() as f64 * 2.0 * words + n * n * words; + let mut size = Map::new(); + size.insert("tableau_bits".into(), json!(4.0 * n * n)); + Route { route: "stabilizer", answers, applies: true, why: format!("every gate is Clifford: the tableau of {} qubits, in polynomial time", p.n_qubits), cost: Some((1.5 * n * n, work.max(1.0).log2())), size } +} + +fn cutrank(r: &catalog::Request, thorough: bool) -> Route { + use crate::quantum_cutrank::Precision; + let answers: &'static [&'static str] = &[COUNTS, AMPLITUDES]; + match catalog::product_measured(r, thorough) { + Err(e) => Route::not("cutrank", answers, e), + Ok(pm) => { + let cut = pm.max_cut(); + // A sample's passes over the table (a read and a pass for each qubit, + // as the planner counts them), after the bulk's tableau is run and + // reduced to graph form (about a pass per gate and an elimination). + let (n, words) = (r.program.n_qubits as f64, (r.program.n_qubits as f64 / 64.0).ceil()); + let work = 3.0 * pm.work() + r.program.ops.len() as f64 * 2.0 * words + n * n * words; + let mut size = Map::new(); + size.insert("widest_cut".into(), json!(cut)); + let search = if thorough { "the best of several order searches" } else { "a quick order search" }; + Route { + route: "cutrank", + answers, + applies: true, + why: format!("a Clifford circuit then one layer of one-qubit gates: along {search}, the widest cut is {cut} bits"), + cost: Some((pm.table_bytes(Precision::Double) as f64, work.max(1.0).log2())), + size, + } + } + } +} + +/// log2 of a gate's operator Schmidt rank between the qubits where `left` is +/// true and the rest: the rank of its matrix realigned so that each side's +/// row and column bits index one dimension. +pub fn operator_schmidt_log2(g: &Gate, left: &[bool]) -> f64 { + let k = g.qubits.len(); + let dim = 1usize << k; + let (a, b) = (left.iter().filter(|&&l| l).count(), left.iter().filter(|&&l| !l).count()); + if a == 0 || b == 0 { + return 0.0; + } + let side = |x: usize, want: bool| -> usize { + // The bits of `x` (qubit t at bit k-1-t) on the side `want`, in qubit order. + let mut out = 0; + for (t, &l) in left.iter().enumerate() { + if l == want { + out = (out << 1) | (x >> (k - 1 - t) & 1); + } + } + out + }; + let (rows, cols) = (1usize << (2 * a), 1usize << (2 * b)); + let mut m = vec![C { re: 0.0, im: 0.0 }; rows * cols]; + for i in 0..dim { + for j in 0..dim { + let row = side(i, true) << a | side(j, true); + let col = side(i, false) << b | side(j, false); + m[row * cols + col] = g.matrix[i * dim + j]; + } + } + (rank(&mut m, rows, cols) as f64).max(1.0).log2() +} + +/// The rank of a `rows × cols` complex matrix, by elimination with partial +/// pivoting, an entry counting as zero below 1e-9 of the largest. +pub fn rank(m: &mut [C], rows: usize, cols: usize) -> usize { + let abs = |c: C| (c.re * c.re + c.im * c.im).sqrt(); + let largest = m.iter().map(|&c| abs(c)).fold(0.0, f64::max); + if largest == 0.0 { + return 0; + } + let tol = 1e-9 * largest; + let mut r = 0; + for c in 0..cols { + if r == rows { + break; + } + let (piv, best) = (r..rows).map(|i| (i, abs(m[i * cols + c]))).fold((r, 0.0), |acc, x| if x.1 > acc.1 { x } else { acc }); + if best <= tol { + continue; + } + if piv != r { + for j in 0..cols { + m.swap(piv * cols + j, r * cols + j); + } + } + let p = m[r * cols + c]; + let inv = { + let d = p.re * p.re + p.im * p.im; + C { re: p.re / d, im: -p.im / d } + }; + for i in r + 1..rows { + let f = m[i * cols + c].mul(inv); + if f.re == 0.0 && f.im == 0.0 { + continue; + } + for j in c..cols { + let v = m[r * cols + j].mul(f); + m[i * cols + j] = m[i * cols + j].sub(v); + } + } + r += 1; + } + r +} + +/// log2 of the bound on the Schmidt rank across each cut `k` (qubits below +/// `k` against the rest), for `k` in `0..=n`: the sum over the gates crossing +/// it of their operator Schmidt ranks' log2, at most `min(k, n − k)`. +pub fn bond_bounds(p: &Program) -> Vec { + let n = p.n_qubits as usize; + let mut e = vec![0.0f64; n + 1]; + for g in p.matrices() { + let lo = *g.qubits.iter().min().expect("a gate has qubits") as usize; + let hi = *g.qubits.iter().max().expect("a gate has qubits") as usize; + let mut last: Option<(Vec, f64)> = None; + for (k, ek) in e.iter_mut().enumerate().take(hi + 1).skip(lo + 1) { + let left: Vec = g.qubits.iter().map(|&q| (q as usize) < k).collect(); + let r = match &last { + Some((l, r)) if *l == left => *r, + _ => operator_schmidt_log2(&g, &left), + }; + *ek += r; + last = Some((left, r)); + } + } + e.iter().enumerate().map(|(k, &x)| x.min(k.min(n - k) as f64)).collect() +} + +fn mps(p: &Program) -> Route { + let answers: &'static [&'static str] = &[COUNTS, AMPLITUDES, EXPECTATIONS]; + let max = catalog::MPS_MAX_QUBITS; + if p.n_qubits > max { + return Route::not("mps", answers, format!("the matrix product state here holds at most {max} qubits")); + } + let e = bond_bounds(p); + let (at, &top) = e.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1).then(b.0.cmp(&a.0))).expect("n + 1 cuts"); + // Each site holds two values of its qubit between its two bonds. + let bytes: f64 = e.windows(2).map(|w| 2.0 * 2f64.powf(w[0]) * 2f64.powf(w[1]) * 16.0).sum(); + // Each gate's update is about χ³ per qubit it spans. + let span: f64 = p.ops.iter().map(|o| (o.qubits.iter().max().unwrap_or(&0) - o.qubits.iter().min().unwrap_or(&0)).max(1) as f64).sum(); + let mut size = Map::new(); + size.insert("log2_bond".into(), json!(round2(top))); + size.insert("at_cut".into(), json!(at)); + let why = if top == 0.0 { + "no gate entangles across any cut: a product state".to_string() + } else { + format!("along the qubits' own order, the bond is at most 2^{} (between qubits {} and {}), by the gates crossing each cut", round2(top), at.saturating_sub(1), at) + }; + Route { route: "mps", answers, applies: true, why, cost: Some((bytes, span.max(1.0).log2() + 3.0 * top)), size } +} + +fn light_cone(p: &Program) -> Route { + let n = p.n_qubits as usize; + let words = n.div_ceil(64); + let (mut widest, mut at, mut gates_in) = (0usize, 0usize, 0usize); + for q in 0..n { + let mut cone = vec![0u64; words]; + cone[q / 64] |= 1 << (q % 64); + let mut size = 1; + let mut count = 0; + for o in p.ops.iter().rev() { + if o.qubits.iter().any(|&x| cone[x as usize / 64] >> (x % 64) & 1 == 1) { + count += 1; + for &x in &o.qubits { + let (w, b) = (x as usize / 64, x % 64); + if cone[w] >> b & 1 == 0 { + cone[w] |= 1 << b; + size += 1; + } + } + } + } + if size > widest { + (widest, at, gates_in) = (size, q, count); + } + } + let mut size = Map::new(); + size.insert("widest".into(), json!(widest)); + size.insert("qubit".into(), json!(at)); + Route { + route: "light_cone", + answers: &[ONE_QUBIT], + applies: true, + why: format!("a one-qubit expectation value needs only its backward light cone: at most {widest} qubits (that of qubit {at})"), + cost: Some((16.0 * 2f64.powi(widest as i32), (gates_in.max(1) as f64).log2() + widest as f64)), + size, + } +} + +fn tensor_network(p: &Program, thorough: bool) -> Route { + use crate::quantum_tn::{Network, PathOptions, find_path_restarts_seeded, sweep_orders, sweep_path, tensor_places}; + let answers: &'static [&'static str] = &[AMPLITUDES]; + if !thorough { + return Route { route: "tensor_network", answers, applies: true, why: "not searched: a contraction search takes seconds to minutes; ask for a thorough check".into(), cost: None, size: Map::new() }; + } + let n = p.n_qubits; + // One amplitude of an output drawn from a fixed seed: an amplitude chosen + // by no one, so the reduction of fixed outputs flatters no circuit. + let mut s = 0x9e37_79b9_7f4a_7c15u64; + let bits: Vec = (0..n) + .map(|_| { + s = s.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = s; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + ((z ^ (z >> 31)) & 1) as u8 + }) + .collect(); + let (mut net, place) = match Network::amplitude_split_placed(n, &p.matrices(), &bits, &[]) { + Ok(x) => x, + Err(e) => return Route::not("tensor_network", answers, format!("the network does not build: {e:?}")), + }; + net.reduce(); + let restarts = if cfg!(target_arch = "wasm32") { 1 } else { 4 }; + // The sweeps qubit by qubit and time by time, beside the search's own trees. + let sweeps: Vec<_> = sweep_orders(&tensor_places(&net, &place)).iter().map(|o| sweep_path(o)).collect(); + let plan = find_path_restarts_seeded(&net.shape(), &net.dims, &net.open, &PathOptions::default(), restarts, &sweeps); + let largest = plan.per_slice.log2_size(); + let mut size = Map::new(); + size.insert("log2_largest_tensor".into(), json!(round2(largest))); + size.insert("log10_multiply_adds".into(), json!(round2(plan.total_flops().max(1.0).log10()))); + Route { + route: "tensor_network", + answers, + applies: true, + why: format!("one amplitude by the contraction tree the search finds: its largest intermediate 2^{} entries", round2(largest)), + cost: Some((16.0 * 2f64.powf(largest), plan.total_flops().max(1.0).log2())), + size, + } +} + +fn statevector(p: &Program) -> Route { + let n = p.n_qubits as f64; + let mut size = Map::new(); + size.insert("log2_amplitudes".into(), json!(p.n_qubits)); + Route { + route: "statevector", + answers: &[COUNTS, AMPLITUDES, EXPECTATIONS], + applies: true, + why: format!("the dense state: 2^{} amplitudes", p.n_qubits), + cost: Some((16.0 * 2f64.powf(n), (p.ops.len().max(1) as f64).log2() + n)), + size, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::quantum_sv::StateVector; + + fn route<'a>(v: &'a Value, name: &str) -> &'a Value { + v["routes"].as_array().unwrap().iter().find(|r| r["route"] == name).unwrap() + } + + #[test] + fn a_clifford_circuit_is_easy_at_any_width() { + let mut q = String::from("qubit[60] q;\nh q[0];\n"); + for i in 0..59 { + q += &format!("cx q[{i}], q[{}];\n", i + 1); + } + let v = shortcuts(&q, false); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(route(&v, "stabilizer")["applies"], true); + // Cheap by several routes; the summary leads with the certificate at any width. + assert!(v["cheapest"]["counts"]["log2_work"].as_f64().unwrap() < 16.0, "{v}"); + assert!(v["summary"].as_str().unwrap().starts_with("Classically easy at any width"), "{}", v["summary"]); + // A chain of CX crosses each cut once: an exact MPS at bond 2. + assert_eq!(route(&v, "mps")["size"]["log2_bond"], 1.0, "{v}"); + // The last qubit's cone runs back through the whole chain. + assert_eq!(route(&v, "light_cone")["size"]["widest"], 60); + assert!(route(&v, "statevector")["log2_work"].as_f64().unwrap() >= 60.0); + } + + #[test] + fn a_clifford_bulk_with_a_closing_layer_goes_to_the_cut_rank_sum() { + // Three layers of CZ on a ring of 24 (so the MPS bound grows), then rz on each. + let n = 24; + let mut q = format!("qubit[{n}] q;\nh q;\n"); + for layer in 0..3 { + for i in 0..n { + if (i + layer) % 2 == 0 { + q += &format!("cz q[{i}], q[{}];\n", (i + 1) % n); + } + } + q += "s q;\nh q;\n"; + } + q += "rz(0.3) q;\n"; + let v = shortcuts(&q, false); + assert_eq!(route(&v, "stabilizer")["applies"], false); + let c = route(&v, "cutrank"); + assert_eq!(c["applies"], true, "{v}"); + assert!(c["size"]["widest_cut"].as_u64().unwrap() <= 12, "{c}"); + let best = v["cheapest"]["counts"]["route"].as_str().unwrap(); + assert!(best == "cutrank" || best == "mps", "{v}"); + assert!(v["cheapest"]["counts"]["log2_work"].as_f64().unwrap() < route(&v, "statevector")["log2_work"].as_f64().unwrap()); + } + + #[test] + fn operator_schmidt_ranks_come_from_the_matrices() { + let g = |src: &str| crate::qasm::parse(src).unwrap().matrices().remove(0); + let both = [true, false]; + assert_eq!(operator_schmidt_log2(&g("qubit[2] q; cx q[0], q[1];"), &both), 1.0); + assert_eq!(operator_schmidt_log2(&g("qubit[2] q; cz q[0], q[1];"), &both), 1.0); + assert_eq!(operator_schmidt_log2(&g("qubit[2] q; swap q[0], q[1];"), &both), 2.0); + assert_eq!(operator_schmidt_log2(&g("qubit[2] q; cp(0) q[0], q[1];"), &both), 0.0); + assert_eq!(operator_schmidt_log2(&g("qubit[2] q; rzz(0.4) q[0], q[1];"), &both), 1.0); + // A Toffoli, cut either way, is two terms. + let t = g("qubit[3] q; ccx q[0], q[1], q[2];"); + assert_eq!(operator_schmidt_log2(&t, &[true, false, false]), 1.0); + assert_eq!(operator_schmidt_log2(&t, &[true, true, false]), 1.0); + } + + /// The true Schmidt rank across every cut, from the state vector, never + /// exceeds the bound, on random circuits of every kind of gate. + #[test] + fn the_bond_bound_holds_against_the_state_vector() { + let gates = ["cx", "cz", "swap", "ch", "crx(0.7)", "rzz(0.3)", "cp(1.1)", "iswap"]; + let mut s = 7u64; + let mut next = |m: u64| { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (s >> 33) % m + }; + for trial in 0..20 { + let n = 6 + (trial % 3) as u32; + let mut q = format!("qubit[{n}] q;\n"); + for _ in 0..(4 + next(10)) { + let a = next(n as u64); + let b = (a + 1 + next(n as u64 - 1)) % n as u64; + q += &format!("ry({}) q[{a}];\n{} q[{a}], q[{b}];\n", 0.1 + next(30) as f64 / 10.0, gates[next(gates.len() as u64) as usize]); + if next(4) == 0 { + let c = (b + 1 + next(n as u64 - 1)) % n as u64; + if c != a { + q += &format!("ccx q[{a}], q[{b}], q[{c}];\n"); + } + } + } + let p = crate::qasm::parse(&q).unwrap(); + let bound = bond_bounds(&p); + let mut sv = StateVector::zero(n).unwrap(); + for g in p.matrices() { + sv.apply(&g).unwrap(); + } + for (k, &b) in bound.iter().enumerate().take(n as usize).skip(1) { + let (rows, cols) = (1usize << k, 1usize << (n as usize - k)); + let mut m: Vec = (0..rows * cols).map(|i| C { re: sv.re[i], im: sv.im[i] }).collect(); + let true_rank = (rank(&mut m, rows, cols) as f64).log2(); + assert!(true_rank <= b + 1e-9, "trial {trial}, cut {k}: rank 2^{true_rank} above the bound 2^{b}\n{q}"); + } + } + } + + #[test] + fn a_light_cone_follows_the_gates_back() { + let v = shortcuts("qubit[6] q;\nh q[0];\ncx q[0], q[1];\ncx q[1], q[2];\nh q[5];\n", false); + let c = route(&v, "light_cone"); + // Qubit 1's cone takes in qubits 2 and 0 (the first of the widest). + assert_eq!((c["size"]["widest"].as_u64(), c["size"]["qubit"].as_u64()), (Some(3), Some(1)), "{c}"); + assert_eq!(v["cheapest"]["one_qubit_expectations"]["route"], "light_cone", "{v}"); + } + + #[test] + fn the_tensor_network_is_searched_only_when_thorough() { + let q = "qubit[8] q;\nh q;\ncx q[0], q[1];\ncx q[2], q[3];\nrx(0.3) q;\ncz q[1], q[2];\ncz q[5], q[6];\nt q;\ncx q[3], q[4];\n"; + let quick = shortcuts(q, false); + assert!(route(&quick, "tensor_network").get("log2_work").is_none()); + assert!(route(&quick, "tensor_network")["why"].as_str().unwrap().contains("not searched")); + let deep = shortcuts(q, true); + let tn = route(&deep, "tensor_network"); + assert!(tn["log2_work"].as_f64().unwrap() < 20.0, "{tn}"); + assert!(tn["size"]["log2_largest_tensor"].as_f64().unwrap() <= 8.0, "{tn}"); + } + + #[test] + fn a_program_that_measures_in_the_middle_keeps_the_routes_that_take_it() { + let v = shortcuts("qubit[2] q;\nbit[2] c;\nh q[0];\nc[0] = measure q[0];\nif (c[0] == 1) x q[1];\nc[1] = measure q[1];\n", false); + assert_eq!(v["ok"], true, "{v}"); + for r in ["cutrank", "mps", "light_cone", "tensor_network"] { + assert_eq!(route(&v, r)["applies"], false, "{r}"); + } + assert_eq!(route(&v, "stabilizer")["applies"], true); + assert_eq!(shortcuts("qubit[1] q; nonsense q[0];", false)["ok"], false); + } +} diff --git a/wai/wai-quantum/src/qasm.rs b/wai/wai-quantum/src/qasm.rs index 37bfe5a3f..81bffc000 100644 --- a/wai/wai-quantum/src/qasm.rs +++ b/wai/wai-quantum/src/qasm.rs @@ -418,6 +418,11 @@ struct Parser { n_bits: u32, defs: HashMap, ops: Vec, + /// The line of the statement each op came from: a gate a definition + /// expands to, or one of a broadcast's, has the line of the call. + lines: Vec, + /// The line of the statement being read, while its ops are made. + top: usize, measured: Vec<(u32, u32)>, measured_qubits: Vec, touched: Vec, @@ -1072,6 +1077,9 @@ impl Parser { if depth > MAX_DEPTH { return Err(format!("line {line}: gate definitions nest deeper than {MAX_DEPTH}")); } + if depth == 0 { + self.top = line; + } for (i, q) in qubits.iter().enumerate() { if qubits[..i].contains(q) { return Err(format!("line {line}: qubit {q} named twice in one gate")); @@ -1098,6 +1106,7 @@ impl Parser { self.touched[q as usize] = true; } self.ops.push(Op { kind, qubits: qubits.to_vec(), params }); + self.lines.push(self.top); self.steps.push(Instr::Gate(self.ops.len() - 1)); return Ok(()); } @@ -1134,6 +1143,13 @@ pub fn dyadic_circuit(src: &str) -> Result { } pub fn parse(src: &str) -> Result { + parse_lines(src).map(|(p, _)| p) +} + +/// [`parse`], with the line of the statement each of the program's ops came +/// from (`lines[i]` for `ops[i]`): a broadcast's gates, and the gates a gate +/// definition expands to, share the line of the statement that made them. +pub fn parse_lines(src: &str) -> Result<(Program, Vec), String> { let toks = lex(src)?; let mut p = Parser { toks, @@ -1144,6 +1160,8 @@ pub fn parse(src: &str) -> Result { n_bits: 0, defs: HashMap::new(), ops: Vec::new(), + lines: Vec::new(), + top: 0, measured: Vec::new(), measured_qubits: Vec::new(), touched: Vec::new(), @@ -1165,7 +1183,8 @@ pub fn parse(src: &str) -> Result { steps.extend((0..p.n_qubits).map(|q| Instr::Measure { qubit: q, bit: q })); } let dynamic = p.dynamic.then_some(steps); - Ok(Program { n_qubits: p.n_qubits, ops: p.ops, measured, n_bits, dynamic }) + debug_assert_eq!(p.lines.len(), p.ops.len()); + Ok((Program { n_qubits: p.n_qubits, ops: p.ops, measured, n_bits, dynamic }, p.lines)) } // --------------------------------------------------------------------------- @@ -1867,7 +1886,7 @@ impl Program { } /// The Clifford lowering of `ops`, and where each op's gates end in it. - fn lower_clifford(ops: &[Op]) -> Result<(Vec, Vec), String> { + pub(crate) fn lower_clifford(ops: &[Op]) -> Result<(Vec, Vec), String> { use Kind::*; let mut out = Vec::new(); let mut ends = Vec::with_capacity(ops.len()); diff --git a/wai/wai-quantum/src/quantum_tn.rs b/wai/wai-quantum/src/quantum_tn.rs index f78fc04bb..0e4ee4099 100644 --- a/wai/wai-quantum/src/quantum_tn.rs +++ b/wai/wai-quantum/src/quantum_tn.rs @@ -38,6 +38,18 @@ //! best few trees are then reconfigured: subtrees of up to eight tensors are //! re-contracted optimally, by dynamic programming over subsets, until //! nothing improves. +//! - **Sweep.** A placed network ([`Network::amplitude_split_placed`]) knows +//! where each index sits in the circuit, as a qubit and a time. [`sweep_orders`] +//! orders its tensors qubit by qubit along time, or time by time across the +//! qubits. [`sweep_path`] absorbs them in that order. Swept qubit by qubit, a +//! deep circuit on a line keeps only a time slice of the bonds between two +//! neighbouring qubits as its boundary: about half its depth for a +//! controlled-Z brickwork. The bipartition and greedy families rarely find +//! that tree on their own. [`find_path_seeded`] scores such trees beside the +//! trials and polishes the best of them all alike. On a 70-qubit circuit of +//! CZ-depth 70, the search with the sweeps as seeds finds a largest +//! intermediate of `2^36` and `10^14.6` multiply-adds for one amplitude; with +//! the same 16 trials and no seeds it finds `2^59` and `10^23.5`. //! - **Slice.** [`slice`] fixes, one at a time, the index that leaves the least //! total work, until the largest intermediate fits the cap. Each candidate is //! priced incrementally. A second search round then looks for a tree for the @@ -135,7 +147,7 @@ impl Network { /// `2^|open|` amplitudes over those qubits (first listed most /// significant), with the other outputs fixed to `bits`. pub fn amplitude(n: u32, gates: &[Gate], bits: &[u8], open_qubits: &[u32]) -> Result { - Self::build(n, gates, bits, open_qubits, false) + Self::build(n, gates, bits, open_qubits, false).map(|(net, _)| net) } /// [`Network::amplitude`], with each gate on two or more qubits split at @@ -148,20 +160,26 @@ impl Network { /// not the 4 of its two wires, which is most of what a contraction of /// controlled-Z or controlled-X circuits costs. pub fn amplitude_split(n: u32, gates: &[Gate], bits: &[u8], open_qubits: &[u32]) -> Result { + Self::build(n, gates, bits, open_qubits, true).map(|(net, _)| net) + } + + /// [`Network::amplitude_split`], with each index's place in the circuit: + /// `[qubit, time]`. A wire's place is its qubit and the gate it leaves + /// (time −1 for an input). A split gate's bond sits at its gates' mean + /// qubit. Indices are never renumbered, so the places stay valid through + /// [`Network::simplify`] and [`Network::reduce`]; [`tensor_places`] reads a + /// tensor's place from them, and [`sweep_orders`] the orders to sweep in. + pub fn amplitude_split_placed(n: u32, gates: &[Gate], bits: &[u8], open_qubits: &[u32]) -> Result<(Network, Vec<[f64; 2]>), TnError> { Self::build(n, gates, bits, open_qubits, true) } - fn build(n: u32, gates: &[Gate], bits: &[u8], open_qubits: &[u32], split: bool) -> Result { + fn build(n: u32, gates: &[Gate], bits: &[u8], open_qubits: &[u32], split: bool) -> Result<(Network, Vec<[f64; 2]>), TnError> { if bits.len() != n as usize || bits.iter().any(|&b| b > 1) || open_qubits.iter().any(|&q| q >= n) { return Err(TnError::BadOutput); } - let mut next = 0u32; - let mut fresh = || { - next += 1; - next - 1 - }; + let mut ids = Ids { place: Vec::new() }; let mut tensors = Vec::new(); - let mut wire: Vec = (0..n).map(|_| fresh()).collect(); + let mut wire: Vec = (0..n).map(|q| ids.fresh([q as f64, -1.0])).collect(); for &w in &wire { tensors.push(Tensor { inds: vec![w], data: vec![C::ONE, C::ZERO] }); } @@ -173,9 +191,10 @@ impl Network { if k == 0 || seen.len() != k || g.qubits.iter().any(|&q| q >= n) || g.matrix.len() != 1 << (2 * k) { return Err(TnError::BadGate(gi)); } + let t = gi as f64; if split && k >= 2 { - let (bonds, rest, data) = peel(&g.qubits, &g.matrix, &mut fresh, &mut wire, &mut tensors); - let outs: Vec = rest.iter().map(|_| fresh()).collect(); + let (bonds, rest, data) = peel(&g.qubits, &g.matrix, &mut ids, t, &mut wire, &mut tensors); + let outs: Vec = rest.iter().map(|&q| ids.fresh([q as f64, t])).collect(); let mut inds = bonds; inds.extend(&outs); inds.extend(rest.iter().map(|&q| wire[q as usize])); @@ -185,7 +204,7 @@ impl Network { } continue; } - let outs: Vec = (0..k).map(|_| fresh()).collect(); + let outs: Vec = g.qubits.iter().map(|&q| ids.fresh([q as f64, t])).collect(); let mut inds = outs.clone(); inds.extend(g.qubits.iter().map(|&q| wire[q as usize])); tensors.push(Tensor { inds, data: g.matrix.clone() }); @@ -203,7 +222,8 @@ impl Network { for &q in open_qubits { open.push(wire[q as usize]); } - Ok(Network { tensors, dims: vec![2; next as usize], open }) + let dims = vec![2; ids.place.len()]; + Ok((Network { tensors, dims, open }, ids.place)) } /// Absorb every tensor of rank at most two into a neighbour, while that @@ -333,7 +353,20 @@ impl Network { /// wire moving to `out`), and the rest keeps the blocks, indexed by the new /// bonds. Returns the bonds (most significant first), the qubits left, and the /// rest's entries over `[bonds…, outs…, ins…]`. -fn peel(qubits: &[u32], matrix: &[C], fresh: &mut impl FnMut() -> u32, wire: &mut [u32], tensors: &mut Vec) -> (Vec, Vec, Vec) { +/// Fresh indices, each with its place in the circuit (`[qubit, time]`). +struct Ids { + place: Vec<[f64; 2]>, +} + +impl Ids { + fn fresh(&mut self, at: [f64; 2]) -> u32 { + self.place.push(at); + (self.place.len() - 1) as u32 + } +} + +fn peel(qubits: &[u32], matrix: &[C], ids: &mut Ids, t: f64, wire: &mut [u32], tensors: &mut Vec) -> (Vec, Vec, Vec) { + let mid = qubits.iter().map(|&q| q as f64).sum::() / qubits.len() as f64; let mut bonds: Vec = Vec::new(); let mut rest: Vec = qubits.to_vec(); let mut data: Vec = matrix.to_vec(); @@ -351,7 +384,7 @@ fn peel(qubits: &[u32], matrix: &[C], fresh: &mut impl FnMut() -> u32, wire: &mu }); let Some(j) = found else { break }; let q = rest[j]; - let (k, out) = (fresh(), fresh()); + let (k, out) = (ids.fresh([mid, t]), ids.fresh([q as f64, t])); let mut delta = vec![C::ZERO; 8]; delta[0] = C::ONE; // out 0, in 0, k 0 delta[7] = C::ONE; // out 1, in 1, k 1 @@ -502,6 +535,55 @@ fn matmul_into(out: &mut [C], a: &[C], b: &[C], m: usize, k: usize, n: usize) { } } +/// Each tensor's place in the circuit (`[qubit, time]`): the mean of its +/// indices' places (from [`Network::amplitude_split_placed`]), or the mean +/// over every tensor's for a tensor with no index. +pub fn tensor_places(net: &Network, index_place: &[[f64; 2]]) -> Vec<[f64; 2]> { + let mean = |inds: &[u32]| { + let k = inds.len() as f64; + let (q, t) = inds.iter().fold((0.0, 0.0), |(q, t), &i| (q + index_place[i as usize][0], t + index_place[i as usize][1])); + [q / k, t / k] + }; + let placed: Vec> = net.tensors.iter().map(|x| (!x.inds.is_empty()).then(|| mean(&x.inds))).collect(); + let all = placed.iter().flatten().fold([0.0, 0.0], |a, p| [a[0] + p[0], a[1] + p[1]]); + let k = placed.iter().flatten().count().max(1) as f64; + placed.into_iter().map(|p| p.unwrap_or([all[0] / k, all[1] / k])).collect() +} + +/// The orders to sweep a placed network in: +/// - qubit by qubit, along time within each qubit. The boundary is then a +/// time slice of the bonds between two neighbouring qubits, which is what +/// a deep circuit on a line wants. +/// - time by time, across the qubits. The boundary is then a state over all +/// the qubits, which is what a shallow, wide circuit wants. +pub fn sweep_orders(places: &[[f64; 2]]) -> Vec> { + // The leading coordinate is rounded: a split gate's halves sit a fraction + // off their qubits (their bond lies between), and each must sweep with its + // own qubit in time order, not with every half like it at every time. + let order = |a: usize, b: usize| -> Vec { + let mut o: Vec = (0..places.len()).collect(); + o.sort_by(|&x, &y| { + let (px, py) = (places[x], places[y]); + px[a].round().total_cmp(&py[a].round()).then(px[b].total_cmp(&py[b])).then(px[a].total_cmp(&py[a])).then(x.cmp(&y)) + }); + o + }; + vec![order(0, 1), order(1, 0)] +} + +/// The tree that absorbs the tensors one at a time, in `order` (every tensor +/// once): a caterpillar. Its cost is the sweep's: the boundary between what +/// has been absorbed and what has not. +pub fn sweep_path(order: &[usize]) -> Path { + let n = order.len(); + let mut steps = Vec::with_capacity(n.saturating_sub(1)); + for (s, &t) in order.iter().enumerate().skip(1) { + let acc = if s == 1 { order[0] } else { n + s - 2 }; + steps.push((acc, t)); + } + Path { steps } +} + // --------------------------------------------------------------------------- // Contraction paths // --------------------------------------------------------------------------- @@ -1427,7 +1509,15 @@ impl Plan { /// randomised parameters, each sliced to the memory cap and scored by total /// operations. The best few are then reconfigured, annealed and re-sliced. pub fn find_path(shape: &[Vec], dims: &[usize], open: &[u32], opts: &PathOptions) -> Plan { - let mut best = search(shape, dims, open, opts); + find_path_seeded(shape, dims, open, opts, &[]) +} + +/// [`find_path`], with `seeds` (trees found some other way, such as +/// [`sweep_path`]) scored beside the trials. The best few of them all are +/// reconfigured and annealed alike, so a seed is polished whether or not it +/// wins as given. +pub fn find_path_seeded(shape: &[Vec], dims: &[usize], open: &[u32], opts: &PathOptions, seeds: &[Path]) -> Plan { + let mut best = search(shape, dims, open, opts, seeds); // With slices, search again on the network with some of the first // round's slices fixed: a tree found for the sliced network routes around // the sliced bonds instead of being patched after the fact. Try fixing all @@ -1442,7 +1532,7 @@ pub fn find_path(shape: &[Vec], dims: &[usize], open: &[u32], opts: &PathOp for (k, pre) in fixings.into_iter().enumerate() { let fixed = sliced_dims(dims, &pre); let again = PathOptions { seed: opts.seed ^ (0x5ee_d0f5_11ce + k as u64), trials: opts.trials.div_ceil(2), ..*opts }; - let mut second = search(shape, &fixed, open, &again); + let mut second = search(shape, &fixed, open, &again, seeds); let pre_slices: f64 = pre.iter().map(|&i| dims[i as usize] as f64).product(); let mut sliced = pre; sliced.extend(second.sliced.iter().copied()); @@ -1464,6 +1554,12 @@ pub fn find_path(shape: &[Vec], dims: &[usize], open: &[u32], opts: &PathOp /// run them. The search's cost varies widely with its seed on large networks, /// so a few restarts lower the cost more than more trials in one search. pub fn find_path_restarts(shape: &[Vec], dims: &[usize], open: &[u32], opts: &PathOptions, restarts: usize) -> Plan { + find_path_restarts_seeded(shape, dims, open, opts, restarts, &[]) +} + +/// [`find_path_restarts`], each restart with `seeds` beside its trials +/// ([`find_path_seeded`]). +pub fn find_path_restarts_seeded(shape: &[Vec], dims: &[usize], open: &[u32], opts: &PathOptions, restarts: usize, seeds_given: &[Path]) -> Plan { let r = restarts.max(1); // The other restarts' seeds are outputs of a generator of their own. Seeds a // multiple of the search generator's step apart would start each search @@ -1472,7 +1568,8 @@ pub fn find_path_restarts(shape: &[Vec], dims: &[usize], open: &[u32], opts let mut seeds = vec![opts.seed]; let mut g = Rng::new(opts.seed ^ 0xd1b5_4a32_d192_ed03); seeds.extend((1..r).map(|_| g.next())); - let run = |k: usize| find_path(shape, dims, open, &PathOptions { seed: seeds[k], ..*opts }); + let trees = seeds_given; + let run = |k: usize| find_path_seeded(shape, dims, open, &PathOptions { seed: seeds[k], ..*opts }, trees); let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |t| t.get()).min(r) }; let plans: Vec = if threads <= 1 { (0..r).map(run).collect() @@ -1500,7 +1597,7 @@ pub fn find_path_restarts(shape: &[Vec], dims: &[usize], open: &[u32], opts /// One round of the hyper-optimiser, on dimensions that may already have /// indices fixed (dimension one). -fn search(shape: &[Vec], dims: &[usize], open: &[u32], opts: &PathOptions) -> Plan { +fn search(shape: &[Vec], dims: &[usize], open: &[u32], opts: &PathOptions, seeds: &[Path]) -> Plan { let mut rng = Rng::new(opts.seed); let score = |path: &Path| -> Plan { let sliced = match opts.max_size { @@ -1521,6 +1618,9 @@ fn search(shape: &[Vec], dims: &[usize], open: &[u32], opts: &PathOptions) // The best few plans, kept for reconfiguration. let mut top: Vec = Vec::new(); let keep = 4; + for path in seeds.iter().filter(|p| p.steps.len() + 1 == shape.len()) { + push_top(&mut top, score(path), keep); + } for t in 0..trials { let seed = rng.next(); let greedy = match opts.families { @@ -1879,6 +1979,69 @@ pub(crate) mod tests { (a.re - b.re).abs() < 1e-12 && (a.im - b.im).abs() < 1e-12 } + /// A deep circuit on a line: a layer of random one-qubit gates, then + /// controlled-Z on alternate neighbours, `depth` times. + fn brickwork_cz(n: u32, depth: usize, seed: u64) -> Vec { + let mut rng = Rng::new(seed); + let mut gates = Vec::new(); + for layer in 0..depth { + for q in 0..n { + gates.push(Gate { qubits: vec![q], matrix: random_unitary(1, &mut rng) }); + } + let mut q = (layer % 2) as u32; + while q + 1 < n { + gates.push(Gate::cz(q, q + 1)); + q += 2; + } + } + gates + } + + #[test] + fn places_follow_each_index_through_the_circuit() { + let gates = vec![Gate::h(0), Gate::cz(0, 1), Gate::h(1)]; + let (net, place) = Network::amplitude_split_placed(2, &gates, &[0, 1], &[]).unwrap(); + assert_eq!(place.len(), net.dims.len()); + assert_eq!((place[0], place[1]), ([0.0, -1.0], [1.0, -1.0]), "the inputs"); + assert!(place.contains(&[0.0, 0.0]), "the h on qubit 0 leaves at time 0: {place:?}"); + assert!(place.contains(&[0.5, 1.0]), "the controlled-Z's bond sits between its qubits: {place:?}"); + // The same network, index for index, as without places. + assert_eq!(net.shape(), Network::amplitude_split(2, &gates, &[0, 1], &[]).unwrap().shape()); + } + + /// Sweeping a deep line qubit by qubit keeps a boundary of about half the + /// depth's bonds; as a seed, the search polishes it and never ends dearer + /// than it; and the plan gives the state vector's amplitude. + #[test] + fn a_sweep_seeds_the_search_on_a_deep_line() { + let (n, depth) = (12u32, 24usize); + let gates = brickwork_cz(n, depth, 3); + let psi = dense(n, &gates); + let x = 0b1011_0010_0111usize; + let bits: Vec = (0..n).map(|q| ((x >> (n - 1 - q)) & 1) as u8).collect(); + let (mut net, place) = Network::amplitude_split_placed(n, &gates, &bits, &[]).unwrap(); + net.reduce(); + let shape = net.shape(); + let orders = sweep_orders(&tensor_places(&net, &place)); + for o in &orders { + let mut seen = o.clone(); + seen.sort_unstable(); + assert_eq!(seen, (0..shape.len()).collect::>(), "every tensor once"); + } + let sweeps: Vec = orders.iter().map(|o| sweep_path(o)).collect(); + let raw = path_cost(&shape, &net.dims, &net.open, &sweeps[0]); + assert!(raw.log2_size() <= (depth / 2 + 3) as f64, "the qubit sweep's boundary: 2^{}", raw.log2_size()); + let opts = PathOptions { trials: 8, seed: 1, ..PathOptions::default() }; + let seeded = find_path_seeded(&shape, &net.dims, &net.open, &opts, &sweeps); + assert!(seeded.total_flops() <= raw.flops, "{} > {}", seeded.total_flops(), raw.flops); + let t = contract_plan(&net, &seeded).unwrap(); + assert!(close(t.data[0], psi[x]), "{:?} vs {:?}", t.data[0], psi[x]); + // A seed that is not a tree of this network is ignored, not trusted. + let wrong = Path { steps: vec![(0, 1)] }; + let p = find_path_seeded(&shape, &net.dims, &net.open, &opts, &[wrong]); + assert_eq!(p.path.steps.len() + 1, shape.len()); + } + #[test] fn contraction_matches_the_state_vector() { for seed in 0..6 { diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index bece350bc..6ce602869 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4319,7 +4319,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index a872ec65d..9f1ff5566 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -1241,7 +1241,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.12" +version = "0.6.14" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/src/quantum_engine.rs b/wai/wai-web/src/quantum_engine.rs index 04a84152c..54e553da4 100644 --- a/wai/wai-web/src/quantum_engine.rs +++ b/wai/wai-web/src/quantum_engine.rs @@ -7,7 +7,7 @@ use ed25519_dalek::SigningKey; use serde_json::{Value, json}; -use wai::engine::{catalog, draw, job}; +use wai::engine::{catalog, critique, draw, equiv, job, shortcuts}; use wasm_bindgen::prelude::*; /// What a result run in a tab says of its energy. @@ -97,6 +97,36 @@ pub fn engine_layout(src: &str) -> String { } } +/// What the engine can say about a program before it runs: its measures and +/// its smells, each with its lines ([`critique::critique`]); `{"ok": false, +/// "error", "line"}` when it does not read. +#[wasm_bindgen] +pub fn engine_critique(src: &str) -> String { + critique::critique(src).to_string() +} + +/// Whether two programs apply the same gates up to a global phase, and how +/// that was decided ([`equiv::equivalent`]). +#[wasm_bindgen] +pub fn engine_equivalent(a: &str, b: &str) -> String { + equiv::equivalent(a, b).to_string() +} + +/// The variants a program's fixes make, each checked equivalent to it +/// ([`critique::variants`]). +#[wasm_bindgen] +pub fn engine_variants(src: &str) -> String { + critique::variants(src).to_string() +} + +/// Every exact classical route to simulating a program, with its cost on it, +/// and the cheapest for each question ([`shortcuts::shortcuts`]). `thorough` +/// also searches a contraction tree: seconds to minutes on a large program. +#[wasm_bindgen] +pub fn engine_shortcuts(src: &str, thorough: bool) -> String { + shortcuts::shortcuts(src, thorough).to_string() +} + /// Whether a job's receipt seals its request and its result, checked here: /// `{"valid", "key", "signer"}` (the signer as a `did:key`), or `{"error"}`. #[wasm_bindgen] @@ -179,6 +209,28 @@ mod tests { let m: Value = serde_json::from_str(&e.methods()).unwrap(); assert!(m["methods"].as_array().unwrap().iter().any(|x| x["method"] == "cutrank"), "{m}"); } + + #[test] + fn critique_equivalence_and_variants_answer_as_json() { + let src = "qubit[2] q;\nh q[0];\nh q[0];\ncx q[0], q[1];\n"; + let c: Value = serde_json::from_str(&engine_critique(src)).unwrap(); + assert_eq!((c["gates"].as_u64(), c["smells"][0]["kind"].as_str()), (Some(3), Some("cancels")), "{c}"); + let e: Value = serde_json::from_str(&engine_equivalent("qubit[1] q; h q[0]; x q[0]; h q[0];", "qubit[1] q; z q[0];")).unwrap(); + assert_eq!(e["verdict"], "equivalent", "{e}"); + let v: Value = serde_json::from_str(&engine_variants(src)).unwrap(); + assert_eq!(v["candidates"][0]["measures"]["gates"], 1, "{v}"); + let l: Value = serde_json::from_str(&engine_layout(src)).unwrap(); + assert_eq!(l["columns"][0][0]["op"], 0, "{l}"); + } + + #[test] + fn the_classical_shortcuts_answer_as_json() { + let s: Value = serde_json::from_str(&engine_shortcuts("qubit[40] q;\nh q[0];\ncx q[0], q[39];\n", false)).unwrap(); + assert!(s["summary"].as_str().unwrap().starts_with("Classically easy"), "{s}"); + let t: Value = serde_json::from_str(&engine_shortcuts("qubit[6] q;\nh q;\nrx(0.3) q;\ncz q[0], q[1];\ncz q[2], q[3];\n", true)).unwrap(); + let tn = t["routes"].as_array().unwrap().iter().find(|r| r["route"] == "tensor_network").unwrap(); + assert!(tn["log2_work"].is_number(), "{t}"); + } } /// The engine compiled to wasm32 and run under Node, as a page runs it: no @@ -203,6 +255,31 @@ mod wasm_tests { assert_eq!(v["valid"], true, "{v}"); } + #[wasm_bindgen_test] + fn critique_equivalence_and_variants_run_in_wasm() { + let c: Value = serde_json::from_str(&engine_critique("qubit[2] q; h q[0]; rz(0.3) q[1]; cx q[0], q[1];")).unwrap(); + assert_eq!((c["depth"].as_u64(), c["non_clifford"].as_u64()), (Some(2), Some(1)), "{c}"); + let e: Value = serde_json::from_str(&engine_equivalent("qubit[12] q; h q; rx(0.25) q[3]; rx(0.5) q[3];", "qubit[12] q; h q; rx(0.75) q[3];")).unwrap(); + assert_eq!((e["verdict"].as_str(), e["how"].as_str()), (Some("equivalent"), Some("random")), "{e}"); + let v: Value = serde_json::from_str(&engine_variants("qubit[1] q;\nrz(0.5) q[0];\nrz(-0.5) q[0];\nh q[0];\n")).unwrap(); + assert_eq!(v["candidates"][0]["qasm"], "qubit[1] q;\nh q[0];\n", "{v}"); + } + + #[wasm_bindgen_test] + fn the_classical_shortcuts_run_in_wasm() { + // A Clifford ring then one rotation layer: the cut-rank sum, with its quick order search. + let mut q = String::from("qubit[16] q;\nh q;\n"); + for i in 0..16 { + q += &format!("cz q[{i}], q[{}];\n", (i + 1) % 16); + } + q += "rz(0.3) q;\n"; + let s: Value = serde_json::from_str(&engine_shortcuts(&q, true)).unwrap(); + assert_eq!(s["ok"], true, "{s}"); + let cut = s["routes"].as_array().unwrap().iter().find(|r| r["route"] == "cutrank").unwrap(); + assert_eq!(cut["size"]["widest_cut"], 2, "a ring's graph state has cut-rank 2 along the ring: {s}"); + assert!(s["routes"].as_array().unwrap().iter().any(|r| r["route"] == "tensor_network" && r["log2_work"].is_number()), "{s}"); + } + #[wasm_bindgen_test] fn every_method_a_tab_holds_runs_in_wasm() { let e = QuantumEngine::new(&[9u8; 32], 512).unwrap();