diff --git a/.github/workflows/wai-quantum-lang.yml b/.github/workflows/wai-quantum-lang.yml index 3dd9e95ba..08c5b6ef8 100644 --- a/.github/workflows/wai-quantum-lang.yml +++ b/.github/workflows/wai-quantum-lang.yml @@ -79,7 +79,7 @@ jobs: set -euo pipefail cargo test --locked --release --features full --lib qasm 2>&1 | tee "$RUNNER_TEMP/qasm.log" grep -qE '^test result: ok\. [1-9][0-9]+ passed; 0 failed' "$RUNNER_TEMP/qasm.log" || { echo "::error::the reader's tests did not pass"; exit 1; } - for m in qasm::diag qasm::lex qasm::parse qasm::value qasm::lang_tests qasm::whole_tests qasm::tests quantum_toolchain::tests::qasm_round_trip; do + for m in qasm::diag qasm::lex qasm::parse qasm::value qasm::lang_tests qasm::whole_tests qasm::depth_tests qasm::tests quantum_toolchain::tests::qasm_round_trip; do grep -q "^test $m.* \.\.\. ok$" "$RUNNER_TEMP/qasm.log" || { echo "::error::no test of $m ran"; exit 1; } done # A gate held whole, each test by name. diff --git a/.github/workflows/wai-quantum-pulse.yml b/.github/workflows/wai-quantum-pulse.yml index cdbdad608..89289cad3 100644 --- a/.github/workflows/wai-quantum-pulse.yml +++ b/.github/workflows/wai-quantum-pulse.yml @@ -74,7 +74,7 @@ jobs: run: | set -euo pipefail cargo test --locked --release --features full --lib -- qasm::pulse:: quantum_pulse:: engine::pulse:: 2>&1 | tee "$RUNNER_TEMP/pulse.log" - grep -qE '^test result: ok\. 26 passed; 0 failed' "$RUNNER_TEMP/pulse.log" || { echo "::error::the pulse tests did not all pass (26 expected)"; exit 1; } + grep -qE '^test result: ok\. 28 passed; 0 failed' "$RUNNER_TEMP/pulse.log" || { echo "::error::the pulse tests did not all pass (28 expected)"; exit 1; } for t in qasm::pulse::tests::a_program_reads_to_its_schedule_on_frames \ qasm::pulse::tests::barriers_align_frames_and_a_gate_starts_its_frames_together \ qasm::pulse::tests::phase_and_frequency_follow_the_frame_and_the_carrier_runs_on \ @@ -84,6 +84,7 @@ jobs: qasm::pulse::tests::ports_map_by_name_and_by_the_options \ qasm::pulse::tests::what_the_pulse_reader_does_not_take_is_refused_with_its_code_and_place \ qasm::pulse::tests::the_schedule_writes_as_json_with_every_instruction \ + qasm::pulse::tests::a_calibration_s_expression_nested_past_the_limit_is_refused \ quantum_pulse::tests::a_resonant_drive_turns_the_qubit_as_rabi_says \ quantum_pulse::tests::a_quarter_turn_of_phase_turns_the_axis_to_minus_y \ quantum_pulse::tests::a_detuned_drive_turns_less_and_faster \ @@ -100,7 +101,8 @@ jobs: engine::pulse::tests::a_pulse_job_is_checked_by_running_it_again \ engine::pulse::tests::a_schedule_is_drawn_with_its_digests \ engine::pulse::tests::the_request_schema_holds_the_options_the_engine_reads \ - engine::pulse::tests::a_pulse_job_is_the_same_bits_on_every_platform; do + engine::pulse::tests::a_pulse_job_is_the_same_bits_on_every_platform \ + engine::pulse::tests::no_pulse_result_passes_the_bound_its_plan_gave; do grep -q "^test $t \.\.\. ok$" "$RUNNER_TEMP/pulse.log" || { echo "::error::$t did not run and pass"; exit 1; } done # Without warnings: a feature gate that leaves code unreachable or a @@ -206,4 +208,4 @@ jobs: run: | set -euo pipefail cargo test --locked --target wasm32-wasip2 --features full --lib -- qasm::pulse:: quantum_pulse:: engine::pulse:: 2>&1 | tee "$RUNNER_TEMP/wasip2.log" - grep -qE '^test result: ok\. 26 passed; 0 failed' "$RUNNER_TEMP/wasip2.log" || { echo "::error::the pulse tests did not all pass under wasip2 (26 expected)"; exit 1; } + grep -qE '^test result: ok\. 28 passed; 0 failed' "$RUNNER_TEMP/wasip2.log" || { echo "::error::the pulse tests did not all pass under wasip2 (28 expected)"; exit 1; } diff --git a/.github/workflows/wai-quantum-qec.yml b/.github/workflows/wai-quantum-qec.yml index 10aa8a43b..e6fa38419 100644 --- a/.github/workflows/wai-quantum-qec.yml +++ b/.github/workflows/wai-quantum-qec.yml @@ -101,6 +101,9 @@ jobs: quantum_qec_text::tests::any_layout_reads_to_its_rewritten_form \ quantum_qec_text::tests::what_does_not_read_is_refused_with_its_line \ quantum_qec_text::tests::a_model_unrolls_with_its_offsets_tags_and_parts \ + quantum_qec_text::tests::blocks_nested_past_the_limit_are_refused_on_their_line \ + quantum_qec_text::tests::an_empty_block_repeated_without_end_is_refused_not_run \ + quantum_qec_text::tests::an_index_past_what_a_text_holds_is_refused_on_its_line \ quantum_window::tests::narrow_windows_correct_every_edge_set_below_half_the_distance \ quantum_window::tests::a_window_over_everything_is_the_batch_decoder \ quantum_window::tests::narrow_windows_track_the_batch_decoder \ @@ -113,6 +116,7 @@ jobs: quantum_frame::tests::pauli_targets_agree_between_sampler_and_model \ quantum_frame::tests::tags_reach_the_model_and_its_text \ quantum_frame::tests::the_text_round_trips \ + quantum_frame::tests::blocks_that_write_nothing_end_and_unrun_ones_are_checked_once \ quantum_frame::tests::a_pauli_channel_s_rates_have_its_law \ quantum_frame::tests::a_pauli_channel_samples_its_law \ quantum_frame::tests::the_cycling_gates_cycle_the_axes \ @@ -132,6 +136,9 @@ jobs: engine::mitigate::tests::a_job_carries_its_mitigation_and_its_refusals_name_their_code \ engine::mitigate::tests::refusals_say_why \ engine::qec::tests::every_family_reads_and_certifies_its_distance \ + engine::qec::tests::a_time_holds_the_shots_sampled_and_decoded \ + engine::qec::tests::a_time_is_measured_apart_from_setup_and_holds_every_threshold \ + engine::qec::tests::a_circuit_s_pilot_runs_for_its_floor_within_its_shots \ engine::qec::tests::each_decoder_corrects_every_single_error \ engine::qec::tests::circuit_level_decoding_reads_every_model_form \ engine::qec::tests::a_threshold_is_measured_and_distance_helps_below_it \ diff --git a/.github/workflows/wai-quantum-service.yml b/.github/workflows/wai-quantum-service.yml index 1b2cefde9..a1a93ce71 100644 --- a/.github/workflows/wai-quantum-service.yml +++ b/.github/workflows/wai-quantum-service.yml @@ -85,18 +85,26 @@ jobs: # sweeps and their Merkle paths, a result's provenance, the package # counter's reader and probe (on fake trees; a container hides the real # one), and a real-valued input bound by the reader. - - name: The engine's sets, provenance and package energy + # The bound on a result the service admits a job by (a circuit's, a + # set's and a pulse run's): no result passes it, for jobs of every kind + # drawn at random, and it is close where the method knows its outcomes. + - name: The engine's sets, provenance, result bounds and package energy shell: bash working-directory: wai/wai-quantum run: | set -euo pipefail - cargo test --locked --release --features full --lib -- engine::sets:: engine::energy:: merkle:: engine::run::tests::a_result_s_provenance qasm::tests::a_bound_parameter 2>&1 | tee "$RUNNER_TEMP/engine.log" + cargo test --locked --release --features full --lib -- engine::sets:: engine::energy:: merkle:: engine::run::tests::a_result_s_provenance engine::run::tests::no_result_passes_the_bound engine::pulse::tests::no_pulse_result_passes_the_bound engine::bound_tests:: qasm::tests::a_bound_parameter 2>&1 | tee "$RUNNER_TEMP/engine.log" grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/engine.log" || { echo "::error::the engine's tests did not pass"; exit 1; } for t in engine::sets::tests::one_receipt_binds_every_item_and_any_change_breaks_it \ engine::sets::tests::a_sweep_is_its_grid_each_point_the_program_written_out \ engine::sets::tests::a_failed_item_is_bound_by_its_error \ merkle::tests::every_leaf_s_path_leads_to_the_root_and_nothing_else_does \ engine::run::tests::a_result_s_provenance_names_the_passes_its_certificate_ran \ + engine::run::tests::no_result_passes_the_bound_its_plan_gave \ + engine::pulse::tests::no_pulse_result_passes_the_bound_its_plan_gave \ + engine::bound_tests::no_random_job_s_result_passes_its_bound \ + engine::bound_tests::a_failing_intent_trajectories_and_notes_stay_within_their_bounds \ + engine::bound_tests::a_ghz_state_and_a_long_loop_are_bounded_by_what_they_can_list \ engine::energy::tests::the_probe_finds_a_live_counter_s_cadence_and_refuses_a_stalled_one \ engine::energy::tests::only_top_level_package_zones_are_read_and_a_frozen_one_is_not_live \ engine::energy::tests::a_package_figure_names_its_scope_and_declares_its_edges \ diff --git a/.github/workflows/wai-quantum-verdict.yml b/.github/workflows/wai-quantum-verdict.yml index 339dcff71..1890ba570 100644 --- a/.github/workflows/wai-quantum-verdict.yml +++ b/.github/workflows/wai-quantum-verdict.yml @@ -110,6 +110,8 @@ jobs: for t in engine::answer::tests::streaming_at_every_split_ends_as_the_whole_text_reads \ engine::answer::tests::every_problem_has_its_code_and_says_what_becomes_of_the_statement \ engine::answer::tests::an_answer_s_calls_run_on_the_engine_and_its_seal_verifies_when_run_again \ + engine::answer::tests::a_chain_of_statements_past_the_limit_is_refused_not_overflowed \ + engine::answer::tests::the_chain_limit_refuses_the_same_statements_in_any_order \ engine::telemetry::tests::the_failures_become_a_corpus_in_the_conformance_format; do grep -q "^test $t \.\.\. ok$" "$RUNNER_TEMP/answer.log" || { echo "::error::$t did not run"; exit 1; } done diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 30ff49b70..e58c573b4 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -271,9 +271,10 @@ jobs: # run whose final density matrix is the native build's bits (its pinned # digest), a program converted between formats to the QIR bytes the # conformance set pins, the variational library's gradient to the engine's - # pinned bytes beside a program built and checked, and every worked example of - # an energy-landscape job to the native build's pinned digest. The count is the - # four tests above and these eighteen. + # pinned bytes beside a program built and checked, every worked example of + # an energy-landscape job to the native build's pinned digest, and a program + # nested thousands deep refused at every door (no trap). The count is the + # four tests above and these nineteen. - name: The job engine in a page (wasm32 tests under Node) shell: bash working-directory: wai/wai-web @@ -281,7 +282,7 @@ 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. 22 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine-wasm.log" + grep -q "test result: ok. 23 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine-wasm.log" # Its native half: the service's form and receipt, any angle on the state # vector, what does not run saying why, plan, lint, draw and methods as JSON, # critique, equivalence and variants as JSON, the classical shortcuts, the diff --git a/wai/changes/2026-11-06-quantum-parse-depth.md b/wai/changes/2026-11-06-quantum-parse-depth.md new file mode 100644 index 000000000..157feadb3 --- /dev/null +++ b/wai/changes/2026-11-06-quantum-parse-depth.md @@ -0,0 +1,189 @@ +### WAI Quantum: the reader holds its nesting and its memory, and no request can end the job service or hold it without limit + +- **The reader's nesting** (`wai_quantum::qasm`). A program nested past what the reader takes is refused with + the new code `Q006` rather than read by a recursion that ran a thread's stack out: 64 levels in its text + (`qasm::parse::MAX_NESTING`: statements in blocks and bodies, parentheses, operators, brackets, calls, casts + and types together, and every expression's tree as deep as its statement leaves room for), and 256 in all + through the gates and subroutines it calls (the elaborator's statements, expressions, conditions and calls). + An array has at most 7 dimensions (`MAX_ARRAY_DIMS`): they are a comma apart in the text, and each is a level of + its value. What people write is not refused: a run of operators of one precedence (`a + b - c`, a condition of + a thousand `&&`s) and an `else if` chain are held flat and read, run and dropped in loops, at any length, on a + stack of 1 MiB, left to right, so a floating-point sum is the one it was; a switch's case list on measured bits + is flat too, where it crashed the reader at about ten thousand values. What newly refuses a program main read: + more than about 60 levels of real nesting (parentheses, blocks, a `**` chain, an index chain), a `++` chain of + that length, and a recursive subroutine whose body nests more than about seven levels, which reaches 256 before + its 32 calls (Q006). QIR and jeff conditions now stop at the 30 levels of parentheses the reader takes (Q705), + from 32, with the converter's own refusal. `docs/qasm-feature-matrix.md` and `docs/qasm-diagnostics.md` state + the limits. +- **Every other reader of a client's text**: the generator's writer (`qasm::instantiate`: its integer + expressions, loops and conditions), the pulse reader's calibrations (through the parser), the error-correction + circuit and model texts (`REPEAT` blocks at most `quantum_qec_text::MAX_BLOCK_DEPTH`, 32, deep, refused on their + line; a block's every pass counts toward the bound on what a text unrolls to, so one repeated without end that + writes nothing ends, refused, and `REPEAT n { H 0 }` is refused from about 25 000 000 passes, not 50 000 000 + operations; blocks repeated no times are checked once on one copy), the editor's index (a statement nested past + the reader's limit is passed over without recursion), and the answer layer's statements (at most + `engine::answer::eval::CHAIN_DEPTH`, 32, each naming the next, the same statements refused in any order). The + exact expectation values of a program that measures in the middle walk its branches with a list rather than a + recursion, in the same order, so a program that branches thousands of times is refused at `MAX_LEAVES`, not run + out of stack. +- **An index in a few bytes of text** (`quantum_qec_text::MAX_QUBITS`, `MAX_DETECTORS`, each 2²⁴). What is made for + a detector or a qubit is as long as its index: `shift_detectors 100000000000000` and a `detector(1) D0` asked for + 2.4 PB in 79 bytes, and an allocation that fails ends the process. Each index is refused where it is read, on + its line, offsets counted, however it is named. A circuit's width with its correlated errors' carrier is held to + the same cap, refused at the first such error. +- **The reader's memory** (`Q130`, new). A program whose reading would hold more than the reader takes is + refused before it is made: one classical value (an array or a bit register, each element counted at its width, + so `array[bit[1048576], 1000]` is a gigabyte) of more than 64 MiB, or more than 512 MiB in all of its values and + of its gates, steps, measurements and timing as they grow (a `reset` of a wide register in a loop grew them + without end). A declaration of a hundred bytes, `array[int[8], 4294967295] a;`, asked for 137 GB. Making or + copying a value counts toward the reader's steps, at one for every 128 bytes; an element read from an array + copies the element alone; a loop over a range makes each value as it reaches it, not all of them first. A + statement on a register of tens of thousands of qubits is checked in its own length, where each qubit was + compared with every one before it. Each operand of a long run or a set counts as a statement. A bit string wider + than an integer, given to one, is refused (`Q112`) where the reader panicked. +- **A budget of reading** (`qasm::within_steps`, `qasm::steps_left`): every program read on a thread within it + is read once (a program read again with no inputs is answered from its first reading) and every reading + draws on one budget of steps: each statement elaborated, each gate written, what is made or copied by its + size, each pass of a loop a generator is written out with and what it writes. Past it, a reading is refused + (`Q312`), and `steps_left` is `Some(0)`. +- **The job service: admission by cost.** A submission (`POST /v1/jobs`, `POST /v1/run`, a dry run, and the + MCP run tools and tasks) sent again under its key is answered first, reading nothing: a job given in another + format (`"source"`) is matched by the digest of the request as received, kept with the job. Then come the refusals + that read nothing and are not counted (the client's jobs at once, the memory they hold, a full queue, a spent + window), then a turn, then the count, so every request refused after that counts and one told to try again has + read and spent nothing; there is no refund. Then what reads nothing (at most 16 intents, each of a set's items + too; at most 100 items in a set; exact whole numbers), and only then are its programs read, each once, within + one budget of 22 000 000 steps for the whole request (`read_steps` in `GET /v1`'s limits), the reader's bounds on + a job's statements and gates together, so a job's own program that reads within them reads here. A request past + it, by its own program, its references or its items, is refused, `422` and `J031`. `Retry-After` on a `429` is + now the pace at which a refusal that counts would not spend the window (20 s by default), not 5. +- **The job service: every check.** A check (`/v1/critique` and the others, and the estimate, error-correction, + pulse and variational calls) is counted once it has its turn and before its bounds read anything, so their + `422`s count. A check's answer reads within the same budget as its bounds, each program once, so a repair of forty + fixes to a slow program ends at the budget where it ran for minutes. A budget spent is a refusal whatever a bound + made of it: an equivalence bound read a refusal as no work and let the check run. `/v1/verify` and MCP + `verify_quantum_receipt` on a set are counted and read as a check is, as its sweep is written out again, and a + seal that does not hold is not. A decode is held to 1 024 fired detectors, as matching grows faster than their + square, and a memory experiment to 21 rounds. An error-correction decode or threshold measures its shots as they + run (`engine::qec::within_time`): each point is set up and sampled, and a pilot of its own shots decoded (one in a + hundred, and on for at least 200 ms where its shots allow: 20 ms of a light decode read three times slow on a + loaded host and refused a call that fit), before the rest of any, and the call is refused at once when what is measured says it would take more than + `WAI_QSVC_QEC_SECS` (25), stopped only if it runs a fifth past that; setup is not multiplied by the shots, so a call + that fits is run, and code-capacity thresholds are measured on a pilot of trials of each point too. One at the + published bounds ran for many minutes and held its turn to the reading's cap. +- **The job service: in a child.** Admission, a dry run, every check's bounds and answer, the error-correction, + pulse and variational calls and the estimates, a set's receipt, and the verdicts the MCP tools state beside an + answer are read by a child, the service's own binary run as `wai-quantum-service read`, sandboxed as a worker + is. Four requests are read at once and four checks beside them. A client has its share of each, + `WAI_QSVC_CLIENT_READS` (2) and `WAI_QSVC_CLIENT_CHECKS` (1), smaller than the pool, so one client holds no pool + for longer than a quick call; past its share one more turn is lent to it while the pool keeps two free, for at most + five seconds once others wait (so its quick calls pass its own slow one; a call on a lent turn moves onto the client's own turn as one comes free, and is refused, `503`, only if others wait for a turn after five seconds), + and the rest wait behind its own turns in order (an agent's burst of eight is answered one after another), eight + at most, 32 in all, each for ten seconds; past any of these a request is refused, `503` and `J032`, saying whether + its own requests or the pool held it up. People behind one address share one client's turns. A child is polled every 50 ms from a thread of its own (on macOS its + physical footprint, compressed pages included; on Linux resident and swapped pages) and killed past + `WAI_QSVC_READ_GB` (4) or `WAI_QSVC_READ_SECS` (45), its request refused, `422` and `J031`; the kill is a signal the + service sends itself, to the child's process group, again until it has ended, so it works when no new process can + be started. The poll is not a kernel limit; on Linux a child also holds its address space + to twice the cap and a gigabyte, and the kernel ends readers and workers before the service. What a reading + gives back is held to `WAI_QSVC_READ_ANSWER_MIB` (128) as it takes once read (about 80 bytes a value), refused in + the child before it is written, and read off the request threads; a dry run gets back its plans and points alone. + This newly refuses a check whose answer is that large: a critique of a program whose gates each draw a remark + (`qubit q; for int i in [1:N] { x q; }`) is refused, `422` and `J031`, at 200 000 gates, which main answered (150 000 + are answered). One that panics, + overflows or aborts is answered `500`; the service runs on. A client that hangs up ends its child. +- **The job service: MCP.** A tool that says nothing about a program takes no turn. A verdict reads the program only + for a call that was counted; a poll states what its job holds, and reads nothing, however often it is asked (so a + poll of a queued or running job, or of an ended one sent without `verdict` or `intent`, now gives a verdict with + empty `diagnostics` and `critique`, and a view on a poll no drawing, where they read the program before); a + call refused before it was counted (a bad key, a tool the service does not have, past the window) or told to try + again states a verdict that gives the reason. One that finds no turn for its verdict does the same, so every + answer about a program has one, and follows its schema. A refused task is the same, where it read the program + on a runtime worker with no budget. A run's MCP arguments that are not an object are refused, where they dropped + the connection. +- **The job service: one answer within a proxy's wait.** Every request is answered within `WAI_QSVC_ANSWER_SECS` (55) + of its arrival: its waits for a turn and its readings end by then, a run waits for its job only for what is left + (`/v1/run` and the MCP run tools answer with the job, still running, to poll, rather than past a proxy's 60 s), and + an MCP answer's verdict is read only if time is left, saying so where it is not. +- **The job service: what ended jobs keep.** A job keeps its request as the canonical text the store keeps, where it + kept the parsed request (about 80 bytes a value, 37 times a body of numbers) for a day, across restarts and + counted against nothing; a set's items are let go once it ends. What ended jobs keep, their requests' text and + their results as read, is held to `WAI_QSVC_CLIENT_KEPT_MIB` (128) a client and `WAI_QSVC_KEPT_MIB` (2048) in all, + the oldest dropped first, before their `WAI_QSVC_RETAIN_SECS`. A request's body is parsed only once its client's + window has room (a resend under its key, answered before any limit, first), and a large MCP message from a client + past its window is answered under its own id with a tool result that says why, read only for its id, method, tool + and key (a resend under a key the client holds, and a receipt check, are read whole). A body is read before its + window only for a resend under a key its client holds a job under. A receipt check has a window of its own, + `WAI_QSVC_CLIENT_VERIFIES` (120). An ended job not yet read is kept for `WAI_QSVC_KEPT_MIN_SECS` (600) whatever it + weighs, and past the service's cap the client keeping the most loses its results first. A job's report is one + RFC 8785 text on every route and after a restart, its request and result written in as kept, never parsed again, and the + memory a large request or answer was parsed into is given back once it is answered. On macOS the service starts + itself with the allocator's large-block cache off (`MallocLargeCache=0`), which kept every large block a parsed + request freed, dirty and charged to the service: on arm64, 30 requests of a megabyte kept 1 GiB; with it off, 2 MiB. +- **The job service: connections, updates and restarts.** A request's header section is held to + `WAI_QSVC_HEADER_SECS` (150, which also closes an idle connection, so it sits above a proxy's two-minute idle + limit) and its body to `WAI_QSVC_BODY_SECS` (30; past it, `408`). It raises its open-file limit to its hard + limit as it starts (on macOS to `kern.maxfilesperproc` and 10240 at most) and holds at most `WAI_QSVC_CONNECTIONS` + (4096) connections under it: past them the one idle longest is closed for the new one, so a health check is always + answered, or the new one is answered `503` at once; a client may have `WAI_QSVC_CLIENT_REQUESTS` (32) requests open, + refused `503` past them before a body is read. `tasks/result` holds its answer within what is left of the answer's + time. Four connections past the cap are held for health checks alone, and long polls and event streams are held to + `WAI_QSVC_CLIENT_STREAMS` (4) a client and `WAI_QSVC_STREAMS` (512, a quarter of the connections at most); past them + a long poll of a job still queued or running is not held, and says so: `429` (its client's) or `503` (everyone's), + `J032`, with `Retry-After: 2` and the job's status (the command line's `follow` waits and polls again). A reading + that moves from a lent turn onto its client's own gives the lent one back; one still on a lent turn past 5 s goes + back once another client's request waits for a turn or one of its own client's has waited 7 s, so two long checks + of one client never hold its quick calls past their wait. A client's eleventh request at once is refused, of + readings (the reading pool lends no turn at the default share of two) and of checks alike, with eight waiting. Results read and not yet read are counted apart: only read ones are dropped before their + time, the largest holder's first; a client's next job waits while its unread results, with each job running at + its result's bound and the new one at `WAI_QSVC_RESULT_MIB` (64), would pass `WAI_QSVC_CLIENT_UNREAD_MIB` (256), + and every next job while all unread, with every job queued or running at its result's bound and the new one, would + pass `WAI_QSVC_UNREAD_MIB` (8192), so what ended jobs keep stays under the two caps. A job's or a set's result is + bounded before it runs, part by part (`catalog::plan_bounded`, `catalog::plan_bound` and `catalog::Bound`, + `sets::plan_bounded`, new): each item's counts at most the samples it lists (its shots, or a `trajectories` run's + trajectories) among the outcomes its measured bits can give (a noiseless `stabilizer` run's, two to the gates + that branch: a GHZ state's two) and 65,536; a verdict's critique by the remarks its program's gates can draw + (`critique::smells_bound`, new: the walk the critique makes), each intent's check with the 64 outcomes it lists + at a time, and the reader's notes and repair again; every other part by what it may hold or at its length as + RFC 8785 writes it, each number in its own form (`receipt::canonical_len`, new); a pulse run's from its schedule, + `pulse::plan_bounded`, through `Method::plan_bounded`. A sampled intent's fit names at most 64 of the outcomes it + rules out and says how many there were (`impossible_outcomes`, new), and its reason says them once. A request + whose bound passes `WAI_QSVC_RESULT_MIB` is refused at once (`422`, `J031`), its bound, its largest part and what + makes that part smaller in the reason; a dry run states the bound (`result_bytes`), is held to what its + submission's reading would take back, and over MCP plans the job as the run tools run it, its verdict asked; and + `GET /v1`, the dry run's bounds and the OpenAPI document the cap. At 64 MiB, 100 points of 65,536 outcomes (about + 150 MB) are refused, and 100 points of 16 bits at 30,000 shots are taken and run to the end: a set's worker keeps + each item's result as text as it ends, where it kept every item's as a tree and passed the memory it was admitted + with. 100 copies of a 24-qubit GHZ state at 30,000 shots, and a loop of 124,700 gates with its verdict, are taken. + The check as a result ends stays as a backstop, for a job kept across a restart that lowered the cap. + MCP answers echo each JSON-RPC id as the text it was sent as (`1.0`, `1e3`, an integer past 64 bits). Readers and workers start from the service's own + binary as it was started (Linux: its running image; elsewhere a copy kept beside the signing key), so the binary + can be replaced before the restart. A restart reads no program: a job keeps the plan it was admitted with and a + set its items, where a queue of slow programs held the port unbound for the sum of their reads. +- **Tests**: every nesting construct at thousands of levels on a 2 MiB thread, refused; runs, `else if` chains and + case lists of a hundred thousand on 1 MiB, read to what the tree gives; a seeded property test over mixed nesting; + the service binary started in a test and sent each of these through each door, answering `/v1/health` after each; + refused MCP tasks that hold no worker; heavy requests from eight clients beside a health probe; a client's share + of the turns; a reading stopped at its deadline; a hang-up that frees its turn; stuck connections let go; and a + restart that reads nothing. Two tests that could only end by the job's own limit now fail by name. A property test + draws jobs of every kind (failing sampled intents, trajectories, verdicts over notes and smells, random Clifford + circuits, noise, observables, amplitudes, mitigations, repairs, compiles, measurements in the middle, device + targets, pulse runs, intents carrying long numbers) and holds each result to its bound; the service's tests run + the README's largest sweep to the end, a GHZ set, the long loop, an MCP dry run against its run, a dry run against + its submission, a set's long numbers, two long checks beside quick calls, raw ids, a long poll past its holds and + the unread cap. +- **Breaking:** `api:rust:wai_quantum::qasm::ast::E::Chain`: a run of left-associative operators of one precedence + is one node, `E::Chain(first, [(op, operand), …])`, so a run of any length holds no recursion; a match over every + variant of `E` outside the crate adds this one. +- **Breaking:** `api:rust:wai_quantum::qasm::ast::S::If`: an `if` and its `else if`s are one statement, + `S::If { arms: [(cond, then), …], other }`, where it was `S::If { cond, then, other }` with the `else if`s nested in + `other`; code that builds or destructures it reads each arm in turn. +- **Breaking:** `api:rust:wai_quantum::engine::catalog::Request::smells_bytes`: a request read for a verdict carries + the most its critique takes, so a result's bound counts the smells its program can draw rather than every gate; + code outside the crate that builds a `Request` by its fields sets it to 0 (or to `critique::smells_bound` of its + program, where it asks for a verdict). +- **Breaking:** `api:rust:wai_quantum::qasm::MAX_NESTING`: main's bound of 128 levels (a `u32`, from 15ae7d457, in no + release) is replaced by this reader's, `qasm::parse::MAX_NESTING` (64 levels in the text, a `usize`, and 256 through + calls); read that constant instead. +- **Breaking:** `api:rust:wai_quantum::qasm::MAX_ELSE_IF`: an `else if` ladder is read flat, at any length, so it has no + bound to name (main's 1024, from 15ae7d457, in no release); code that held a ladder to it holds nothing now. diff --git a/wai/changes/2026-11-09-reader-nesting-bound.md b/wai/changes/2026-11-09-reader-nesting-bound.md index bcaa3ac49..92bfcb4b8 100644 --- a/wai/changes/2026-11-09-reader-nesting-bound.md +++ b/wai/changes/2026-11-09-reader-nesting-bound.md @@ -1,25 +1,20 @@ ### The OpenQASM reader bounds its nesting, counted as the program's text nests -- Every reading of a program (`qasm::read`, `read_whole`, `tangents`, and every surface over - them: the job service, the command line, both MCP servers, the views) refuses a program - nested deeper than 128 with a code of its own, `Q130`, before a reader's stack can run out. - A statement at the top level is at depth 1. Each statement in a block or a body, each - expression a statement holds, each operand of an operator, what a bracket holds (of an - expression or a type) and each index's indexed value are one deeper than what holds them. - Operators group as they bind (`a + b + c` is `(a + b) + c`), so a chain of 127 operators is at - the bound. An `else if` is at its `if`'s depth, and one `if` holds at most 1024 of them. A - gate's or a subroutine's body is read in place of its call, its statements one deeper than - the call, so nesting through calls is bounded as nesting in one body is (`Q209` still bounds - calls at 32 deep). - The reader had no bound, and recursed as deep as a program nested. A flat sum of 2000 terms, a 4 KB program, overflowed the job service's 2 MiB worker stack and ended the service (`POST /v1/jobs`, `/v1/estimate`, `/v1/gradient`, and `/v1/verify` given an estimate's - request). Binary operators are now read by precedence climbing, in one frame for a chain of - any length and any mix of operators. Every program within the bounds reads on a 1 MiB stack - in a release build, natively and on wasm32 (`wai-quantum`, `qasm::parse`, `qasm::elab`; - `tests/reader_stack.rs` at each bound: each shape nested 128 deep, chains of 25 bodies of 4 - loops and of 31 of 2, ladders of 1024 arms; the service's `tests/verify.rs`). -- **Breaking:** a program nested deeper than 128, or an `if` with more than 1024 `else if` - arms, is refused with `Q130`, where it read when the reader's stack held it. Write a long - sum as several statements (`x += …`) or a `for` loop, a deep expression as named `const` - values, a long ladder as a `switch`, and nest fewer blocks or calls. + request). +- As merged with the reader's nesting and memory bounds (`2026-11-06-quantum-parse-depth.md`), + which hold in place of the bound this entry first took (`Q130` at 128 levels, a chain of at + most 127 operators, at most 1024 `else if` arms; no release carried it): every reading of a + program refuses one nested past the reader's bound with `Q006`, 64 levels in its text and + 256 through the bodies its calls read, and `Q130` is the reader's memory refusal. A run of + one operator and an `else if` ladder are flat, read at any length, so the sum of 2000 terms + reads. +- `tests/reader_stack.rs` reads every program at those bounds on a 1 MiB and a 2 MiB thread in + a release build (16 MiB in debug), and on wasm32: each shape nested to just inside the text's + bound, chains of 25 bodies of 4 loops and of 31 of 2, the 31-of-2 chain ending in a deep + angle, ladders of 1024 arms decided on reading and at run time, and runs of 5000 of one + operator; the same shapes nested 5000 deep, and a chain of 31 bodies of 8 loops, are refused + (`Q006`) on the same stacks. The service's `tests/verify.rs` sends the sum, 1000 brackets and + 1000 blocks to seven routes: the sum is read, the others refused, and the service stays up. diff --git a/wai/extensions/browser-sink.md b/wai/extensions/browser-sink.md index 673ee6223..92a6e7f5c 100644 --- a/wai/extensions/browser-sink.md +++ b/wai/extensions/browser-sink.md @@ -844,7 +844,7 @@ package of several components: the media classes `graphics` (7) and `text` (8) beside packaging's others, the rungs each medium takes, how each component is cut at the lead's piece starts, each piece's object and `target`, and the decoded-output digest each entry states. The reference package is written by -`wai-pack` under `compose/policy.toml`: the lead `v`, a 320 × 180 test pattern (the fixture +`wai-pack` under `compose/policy.toml`: the lead `v`, a test pattern of 320 × 180 (the fixture `compose-01`) with an integer base and a supplied AV1 floor for every piece; `g`, `vector-conformance`'s title card and credit roll; `t`, three captions; `s`, a score with a FLAC floor of each piece's mixdown, which this page does not decode (no route: it holds no diff --git a/wai/wai-quantum-cli/docs/llms-full.txt b/wai/wai-quantum-cli/docs/llms-full.txt index 4adebe58f..68f86c8a3 100644 --- a/wai/wai-quantum-cli/docs/llms-full.txt +++ b/wai/wai-quantum-cli/docs/llms-full.txt @@ -672,7 +672,7 @@ Parameters: ### `dry_run_quantum_job`: Plan a quantum job or set without running it -What the service would do with a job or a set, running nothing: the method and why, the memory, the estimated time and energy (each with its basis: this service's own timings and readings where it has them, otherwise the planner's nominal rate, and no energy figure without readings), the bounds that apply, where you stand against your limits, and whether the service would take it now, or the refusal it would give, with its code. Takes what run_quantum_circuit or run_quantum_set takes. It counts as one submission. +What the service would do with a job or a set, running nothing: the method and why, the memory, the estimated time and energy (each with its basis: this service's own timings and readings where it has them, otherwise the planner's nominal rate, and no energy figure without readings), the bounds that apply, where you stand against your limits, and whether the service would take it now, or the refusal it would give, with its code. Takes what run_quantum_circuit or run_quantum_set takes, and plans it as they run it, its verdict asked: what it says they take, they take. It counts as one submission. Parameters: - `qasm` (string): The program, OpenQASM 3 (or 2), read as the whole language: registers, aliases and slices; the standard gates, `U` and `gphase`; user `gate` definitions and `def` subroutines, inlined; every modifier (`ctrl @`, `negctrl @`, `inv @`, `pow(k) @`) on any gate, lowered exactly; classical types (int, uint, float, angle, complex, bool, bit, arrays), their arithmetic and built-in functions, computed on reading; `for`, `while`, `switch`, `break`, `continue`; measurement and `reset` anywhere; classical control on measured bits (`if (c[0]) x q[1];`, `if (c >= 2) { … } else { … }`, `&&`, `||`); a `while` on measured bits takes `@wai.max_iterations N` before it. `output` declarations come back in the result; `delay`, `box` and `stretch` are carried and labelled as not applied; `defcal` and `cal` are kept, not run. A program that measures in the middle or acts on what it measured runs on statevector or stabilizer: counts split their shots at each measurement; expectation values are exact, of the state before the final measurements. What the reader refuses comes back with a code, as `[Q103]`; qasm_diagnostics says what each code means. @@ -991,6 +991,9 @@ Each construct below is supported (read as the language defines it), lowered (re | Comments `//` and `/* */` | supported; an unclosed block comment is refused | Q003 | | Identifiers, Unicode letters included (`θ`, `π`) | supported | | | Statements and blocks `{ }`, scopes | supported; qubits, gates, subroutines and externs are declared at the top level | Q105 | +| Nesting: statements in blocks and bodies, parentheses, operators, brackets, calls, casts and types | supported to 64 levels together in the text (a statement, each enclosing statement, and each level of its expressions' trees count one). A run of operators of one precedence (`a + b - c`, `c[0] && c[1] && …`) is one level, and an `else if` chain one level, at any length: both are read and run in loops, left to right. Deeper is refused, never read by a recursion that could run out of stack; an array has at most 7 dimensions | Q006 | +| Nesting in all, through the gates and subroutines a program calls | supported to 256 levels (each statement, expression and call the reader runs counts one), and calls 32 deep; deeper is refused. Both hold: a recursive subroutine whose body nests more than about seven levels reaches 256 before 32 calls | Q006, Q209 | +| What a program holds on reading: its classical values, gates, steps, measurements and timing | supported to 64 MiB for one value (an array or a bit register, each element counted at its width) and 512 MiB in all; more is refused before it is made. Making or copying a value counts toward the reader's 20 000 000 steps at one step for every 128 bytes, and an element read from an array copies the element alone | Q130, Q312 | | `pragma …`, `#pragma …`, annotations `@keyword …` | warned: kept, not applied; `@wai.max_iterations N` bounds the `while` after it | Q013 | #### Literals @@ -1075,6 +1078,7 @@ Each construct below is supported (read as the language defines it), lowered (re | `pow(k) @`, `k` a whole number | lowered exactly: angles scaled or the gate repeated; negative `k` repeats the inverse | Q203 | | `pow(r) @`, `r` fractional | lowered where an exact principal root exists (`pow(1/2) @ x` is `sx`; rotations by less than 2π; phases; controlled forms); refused otherwise (`u`, `cu`, user gates) | Q204, Q205 | | A modifier on a subroutine, or nested as deep as 32 | refused | Q211, Q209 | +| A chain of modifiers (`inv @ inv @ …`) | read at any length: a list, not a nesting | | #### Measurement and reset @@ -1110,7 +1114,6 @@ Each construct below is supported (read as the language defines it), lowered (re | Bits declared in a subroutine | lowered: bits of its own, fed forward | Q322 | | A subroutine called as a gate (`f(1) q, r;`) | supported | | | Recursion | refused past 32 calls deep | Q209 | -| Statements, operators, brackets and indexes nested deeper than 128, counted through the bodies calls read in place; an `if` with more than 1024 `else if` arms | refused, before a reader's stack could run out | Q130 | #### Timing @@ -1196,6 +1199,7 @@ Every code: | `Q003` | refused | A string, a block comment, a bracket or a calibration block that is not closed. | | `Q004` | refused | Something else was expected here: a `;`, a name, an expression or a statement (the message says which). | | `Q005` | refused | A statement in a place the language does not allow it: `else` with no `if`, `case` outside `switch`, a declaration inside a gate. | +| `Q006` | refused | A program nested deeper than the reader takes: statements, parentheses, operators, brackets and types more than 64 levels deep in its text (a run of one precedence, `a + b + c`, and an `else if` chain each count one, however long), an array of more than 7 dimensions, or more than 256 levels in all through the gates and subroutines it calls; refused, never read by a recursion that could run out of stack. | | `Q010` | warned | An `OPENQASM` version other than 2.x or 3.x: the program is read as OpenQASM 3. | | `Q011` | warned | An `include` of a file other than the standard libraries (`stdgates.inc`, `qelib1.inc`): its definitions are not read, so a gate it defines is unknown. | | `Q012` | lowered | `include "stdgates.inc"` or `"qelib1.inc"`: the standard gates, `U`, `gphase` and OpenQASM 2's names are built in. | @@ -1230,7 +1234,7 @@ Every code: | `Q127` | refused | An assignment to a constant, an input or a loop variable. | | `Q128` | refused | A measurement where a value is needed that is not a bit (measurement gives bits, into a bit register). | | `Q129` | refused | A parameter (`input float`, `input angle`) a gradient cannot follow to the gates: under a modifier, in a classical variable, a condition, an index, a size, a loop's range, a subroutine or `gphase`, or one whose type rounds it (`angle[n]`, `float[32]`); a gradient follows a parameter through arithmetic, the built-in functions and gate definitions into a gate's arguments. | -| `Q130` | refused | A program nested deeper than 128, before a reader's stack could run out: a statement at the top level is at depth 1, and each statement in a block or a body, each expression a statement holds, each operand of an operator, what a bracket holds (of an expression or a type) and each index's indexed value, one deeper than what holds it; operators group as they bind (`a + b + c` is `(a + b) + c`), an `else if` is at the depth of its `if` (one `if` holds at most 1024 of them), and a call's body is read in its place, its statements one deeper than the call. | +| `Q130` | refused | A program whose reading would hold more memory than the reader takes: a classical value (an array, a bit register) of more than 64 MiB, each element counted at its width, or more than 512 MiB in all of its values, gates, steps, measurements and timing; refused before it is made. | | `Q200` | lowered | A `gate` the program defines, inlined at each call with its parameters bound. | | `Q201` | lowered | `ctrl @` and `negctrl @` on any gate, lowered exactly to standard gates (a negative control is a control between two `x`). | | `Q202` | lowered | `inv @` on any gate, user gates included: each gate's exact inverse, in reverse order. | @@ -1256,7 +1260,7 @@ Every code: | `Q309` | lowered | `while` whose condition is known on reading: unrolled until it fails. | | `Q310` | refused | A `while` whose condition reads measured bits and that declares no bound: add `@wai.max_iterations N` before it. | | `Q311` | warned | A `while` on measured bits run at most its declared bound (`@wai.max_iterations N`): a run that still meets its condition after N iterations stops looping there. | -| `Q312` | refused | A program that does not finish unrolling within the reader's bound (20 000 000 statements and loop iterations). | +| `Q312` | refused | A program that does not finish unrolling within the reader's bound (20 000 000 statements and loop iterations), or that runs out a caller's budget of reading (`qasm::within_steps`: statements, gates and a generator's loop passes, shared by every program read under it). | | `Q313` | refused | A range with a zero step, or that does not read as `[start:end]` or `[start:step:end]`. | | `Q314` | refused | `break` or `continue` outside a loop, or `return` outside a subroutine. | | `Q315` | refused | A `return` with a value under a condition on measured bits. | diff --git a/wai/wai-quantum-cli/docs/local-mcp.json b/wai/wai-quantum-cli/docs/local-mcp.json index 08198bceb..2e6c17e14 100644 --- a/wai/wai-quantum-cli/docs/local-mcp.json +++ b/wai/wai-quantum-cli/docs/local-mcp.json @@ -2368,7 +2368,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -6212,7 +6217,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -8401,7 +8411,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -8809,7 +8824,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -9804,7 +9824,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -10212,7 +10237,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -10700,7 +10730,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -11101,7 +11136,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -11511,7 +11551,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -12044,7 +12089,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -12452,7 +12502,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -12994,7 +13049,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -13395,7 +13455,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -13805,7 +13870,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -14253,7 +14323,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -14661,7 +14736,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -15742,7 +15822,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -16143,7 +16228,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -16553,7 +16643,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -17066,7 +17161,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -17467,7 +17567,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -17877,7 +17982,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -18869,7 +18979,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -19277,7 +19392,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -20515,7 +20635,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -20923,7 +21048,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -22724,7 +22854,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -23125,7 +23260,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -23535,7 +23675,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -24237,7 +24382,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -24617,7 +24767,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -24997,7 +25152,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -25401,7 +25561,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -25777,7 +25942,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -26216,7 +26386,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -26626,7 +26801,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -32834,7 +33014,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -36678,7 +36863,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -52883,7 +53073,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -56727,7 +56922,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" diff --git a/wai/wai-quantum-cli/docs/service-mcp.json b/wai/wai-quantum-cli/docs/service-mcp.json index eb163cebb..52c825bb7 100644 --- a/wai/wai-quantum-cli/docs/service-mcp.json +++ b/wai/wai-quantum-cli/docs/service-mcp.json @@ -2384,7 +2384,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -6228,7 +6233,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -8417,7 +8427,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -8516,7 +8531,7 @@ }, "accepted": { "type": "boolean", - "description": "Whether the service would take it now, by every limit." + "description": "Whether the service would take it now, by every limit, its reading held to what the submission's would take; the MCP tools plan it as their runs run it, its verdict asked." }, "status": { "type": "integer", @@ -8636,9 +8651,34 @@ }, "bounds": { "type": "object", - "description": "The bounds that apply here: memory, time, queue and items of a set.", + "description": "The bounds that apply here: memory, time, queue, items of a set, and result_bytes, the most a job's or a set's result may take as served.", "additionalProperties": true }, + "result_bytes": { + "description": "The most its result takes as served, bytes, bounded before it runs: past bounds.result_bytes the job is refused (422, J031). A pulse run is bounded from its schedule. For a method that gives no bound before it runs (no built-in is such): {\"bytes\": null, \"why\"}, and it is counted at bounds.result_bytes.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "object", + "required": [ + "bytes", + "why" + ], + "properties": { + "bytes": { + "type": "null" + }, + "why": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, "client": { "type": "object", "description": "Where the client stands: its active jobs, the memory they hold, the submissions left in its window and when the window resets.", @@ -8986,7 +9026,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -9394,7 +9439,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -9771,7 +9821,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -10181,7 +10236,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -12642,7 +12702,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -16486,7 +16551,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -18675,7 +18745,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -18774,7 +18849,7 @@ }, "accepted": { "type": "boolean", - "description": "Whether the service would take it now, by every limit." + "description": "Whether the service would take it now, by every limit, its reading held to what the submission's would take; the MCP tools plan it as their runs run it, its verdict asked." }, "status": { "type": "integer", @@ -18894,9 +18969,34 @@ }, "bounds": { "type": "object", - "description": "The bounds that apply here: memory, time, queue and items of a set.", + "description": "The bounds that apply here: memory, time, queue, items of a set, and result_bytes, the most a job's or a set's result may take as served.", "additionalProperties": true }, + "result_bytes": { + "description": "The most its result takes as served, bytes, bounded before it runs: past bounds.result_bytes the job is refused (422, J031). A pulse run is bounded from its schedule. For a method that gives no bound before it runs (no built-in is such): {\"bytes\": null, \"why\"}, and it is counted at bounds.result_bytes.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "object", + "required": [ + "bytes", + "why" + ], + "properties": { + "bytes": { + "type": "null" + }, + "why": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, "client": { "type": "object", "description": "Where the client stands: its active jobs, the memory they hold, the submissions left in its window and when the window resets.", @@ -19244,7 +19344,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -19652,7 +19757,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -20029,7 +20139,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -20439,7 +20554,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -20528,7 +20648,7 @@ { "name": "dry_run_quantum_job", "title": "Plan a quantum job or set without running it", - "description": "What the service would do with a job or a set, running nothing: the method and why, the memory, the estimated time and energy (each with its basis: this service's own timings and readings where it has them, otherwise the planner's nominal rate, and no energy figure without readings), the bounds that apply, where you stand against your limits, and whether the service would take it now, or the refusal it would give, with its code. Takes what run_quantum_circuit or run_quantum_set takes. It counts as one submission.", + "description": "What the service would do with a job or a set, running nothing: the method and why, the memory, the estimated time and energy (each with its basis: this service's own timings and readings where it has them, otherwise the planner's nominal rate, and no energy figure without readings), the bounds that apply, where you stand against your limits, and whether the service would take it now, or the refusal it would give, with its code. Takes what run_quantum_circuit or run_quantum_set takes, and plans it as they run it, its verdict asked: what it says they take, they take. It counts as one submission.", "inputSchema": { "type": "object", "properties": { @@ -21101,7 +21221,7 @@ }, "accepted": { "type": "boolean", - "description": "Whether the service would take it now, by every limit." + "description": "Whether the service would take it now, by every limit, its reading held to what the submission's would take; the MCP tools plan it as their runs run it, its verdict asked." }, "status": { "type": "integer", @@ -21221,9 +21341,34 @@ }, "bounds": { "type": "object", - "description": "The bounds that apply here: memory, time, queue and items of a set.", + "description": "The bounds that apply here: memory, time, queue, items of a set, and result_bytes, the most a job's or a set's result may take as served.", "additionalProperties": true }, + "result_bytes": { + "description": "The most its result takes as served, bytes, bounded before it runs: past bounds.result_bytes the job is refused (422, J031). A pulse run is bounded from its schedule. For a method that gives no bound before it runs (no built-in is such): {\"bytes\": null, \"why\"}, and it is counted at bounds.result_bytes.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "object", + "required": [ + "bytes", + "why" + ], + "properties": { + "bytes": { + "type": "null" + }, + "why": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, "client": { "type": "object", "description": "Where the client stands: its active jobs, the memory they hold, the submissions left in its window and when the window resets.", @@ -21571,7 +21716,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -21979,7 +22129,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -23920,7 +24075,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -27764,7 +27924,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -29953,7 +30118,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -30052,7 +30222,7 @@ }, "accepted": { "type": "boolean", - "description": "Whether the service would take it now, by every limit." + "description": "Whether the service would take it now, by every limit, its reading held to what the submission's would take; the MCP tools plan it as their runs run it, its verdict asked." }, "status": { "type": "integer", @@ -30172,9 +30342,34 @@ }, "bounds": { "type": "object", - "description": "The bounds that apply here: memory, time, queue and items of a set.", + "description": "The bounds that apply here: memory, time, queue, items of a set, and result_bytes, the most a job's or a set's result may take as served.", "additionalProperties": true }, + "result_bytes": { + "description": "The most its result takes as served, bytes, bounded before it runs: past bounds.result_bytes the job is refused (422, J031). A pulse run is bounded from its schedule. For a method that gives no bound before it runs (no built-in is such): {\"bytes\": null, \"why\"}, and it is counted at bounds.result_bytes.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "object", + "required": [ + "bytes", + "why" + ], + "properties": { + "bytes": { + "type": "null" + }, + "why": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, "client": { "type": "object", "description": "Where the client stands: its active jobs, the memory they hold, the submissions left in its window and when the window resets.", @@ -30522,7 +30717,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -30930,7 +31130,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -31307,7 +31512,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -31717,7 +31927,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -32475,7 +32690,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -32876,7 +33096,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -33286,7 +33511,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -33696,7 +33926,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -34140,7 +34375,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -34548,7 +34788,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -34958,7 +35203,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -36086,7 +36336,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -36487,7 +36742,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -36897,7 +37157,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -37307,7 +37572,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -37813,7 +38083,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -38214,7 +38489,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -38624,7 +38904,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -39034,7 +39319,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -40013,7 +40303,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -40421,7 +40716,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -40831,7 +41131,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -42161,7 +42466,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -42569,7 +42879,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -46943,7 +47258,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -50787,7 +51107,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" @@ -52710,7 +53035,7 @@ }, "accepted": { "type": "boolean", - "description": "Whether the service would take it now, by every limit." + "description": "Whether the service would take it now, by every limit, its reading held to what the submission's would take; the MCP tools plan it as their runs run it, its verdict asked." }, "status": { "type": "integer", @@ -52830,9 +53155,34 @@ }, "bounds": { "type": "object", - "description": "The bounds that apply here: memory, time, queue and items of a set.", + "description": "The bounds that apply here: memory, time, queue, items of a set, and result_bytes, the most a job's or a set's result may take as served.", "additionalProperties": true }, + "result_bytes": { + "description": "The most its result takes as served, bytes, bounded before it runs: past bounds.result_bytes the job is refused (422, J031). A pulse run is bounded from its schedule. For a method that gives no bound before it runs (no built-in is such): {\"bytes\": null, \"why\"}, and it is counted at bounds.result_bytes.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "object", + "required": [ + "bytes", + "why" + ], + "properties": { + "bytes": { + "type": "null" + }, + "why": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, "client": { "type": "object", "description": "Where the client stands: its active jobs, the memory they hold, the submissions left in its window and when the window resets.", diff --git a/wai/wai-quantum-cli/src/service.rs b/wai/wai-quantum-cli/src/service.rs index 14e9ba94c..6094eb5b6 100644 --- a/wai/wai-quantum-cli/src/service.rs +++ b/wai/wai-quantum-cli/src/service.rs @@ -163,7 +163,11 @@ impl Client { } loop { let (code, v) = self.get(&format!("/v1/jobs/{id}?wait=25"))?; - if code >= 400 { + // A poll past the client's holds (or everyone's) is not held: it + // says so (429 or 503, J032) with the job's status, and is sent + // again after the pause below. + let unheld = matches!(code, 429 | 503) && v["code"] == "J032" && v["status"].is_string(); + if code >= 400 && !unheld { return Err(Self::refused(code, &v)); } let s = v["status"].as_str().unwrap_or("").to_string(); @@ -174,7 +178,8 @@ impl Client { if start.elapsed() > FOLLOW_FOR { return Err(format!("the job {id} is still {s}; read it later with `wai-quantum task status {id}`")); } - // A service that does not hold the poll answers at once. + // A service that does not hold the poll answers at once (and one + // past its holds says to wait 2 s). std::thread::sleep(Duration::from_secs(2)); } } diff --git a/wai/wai-quantum-cli/tests/front_door.rs b/wai/wai-quantum-cli/tests/front_door.rs index 1f76757fa..12bd3d8f8 100644 --- a/wai/wai-quantum-cli/tests/front_door.rs +++ b/wai/wai-quantum-cli/tests/front_door.rs @@ -124,3 +124,34 @@ fn the_docs_and_the_stdio_server_s_resources_agree() { assert_eq!(reply["result"]["contents"][0]["text"].as_str(), Some(language.as_str())); let _ = std::fs::remove_dir_all(&home); } + +/// A program nested thousands deep, and an error-correction circuit whose +/// blocks are, is refused by each command that reads one, with the +/// reader's code, and the command ends as a refusal does: by its own exit +/// code, never by a signal. +#[test] +fn a_program_nested_past_the_reader_s_limit_is_refused_by_each_command() { + let home = scratch("deep"); + let deep = home.join("deep.qasm"); + std::fs::write(&deep, format!("OPENQASM 3.0;\nqubit q;\nrz({}0.5{}) q;\n", "(".repeat(5_000), ")".repeat(5_000))).unwrap(); + let generator = home.join("generator.qasm"); + std::fs::write(&generator, format!("OPENQASM 3.0;\ninput int n;\nconst int a = {}n{};\nqubit q;\nx q;\n", "(".repeat(5_000), ")".repeat(5_000))).unwrap(); + let circuit = home.join("deep.stim"); + std::fs::write(&circuit, format!("{}H 0\n{}", "REPEAT 1 {\n".repeat(100_000), "}\n".repeat(100_000))).unwrap(); + let (deep, generator, circuit) = (deep.to_str().unwrap(), generator.to_str().unwrap(), circuit.to_str().unwrap()); + for (args, says) in [ + (vec!["lint", deep], "Q006"), + (vec!["run", deep], "Q006"), + (vec!["plan", deep], "Q006"), + (vec!["critique", deep], "Q006"), + (vec!["equivalent", deep, deep], "Q006"), + (vec!["instance", generator, "--input", "n=1"], "Q006"), + (vec!["qec", "decode", "--circuit", circuit], "nested deeper"), + ] { + let o = run(&home, &args); + assert!(o.status.code().is_some(), "{args:?} ended by a signal: {}", text(&o)); + assert!(text(&o).contains(says), "{args:?}: {}", text(&o)); + } + let _ = std::fs::remove_dir_all(&home); +} + diff --git a/wai/wai-quantum-py/tests/test_engine.py b/wai/wai-quantum-py/tests/test_engine.py index 8d0fee9ef..7a56907b5 100644 --- a/wai/wai-quantum-py/tests/test_engine.py +++ b/wai/wai-quantum-py/tests/test_engine.py @@ -124,6 +124,23 @@ def test_a_refusal_raises_the_engines_message_with_its_line(): wq.run(BELL, noise={"depolarising": 0.1}, key=DEMO) +def test_a_program_nested_past_the_readers_limit_raises_and_the_engine_goes_on(): + # Nested thousands deep: refused with the reader's code, in this process. + deep = "OPENQASM 3.0;\nqubit q;\nrz(" + "(" * 5000 + "0.5" + ")" * 5000 + ") q;\n" + with pytest.raises(wq.EngineError) as e: + wq.run(deep, key=DEMO) + assert e.value.code == "Q006" + assert wq.lint(deep)["ok"] is False + with pytest.raises(wq.EngineError): + wq.critique(deep) + blocks = "OPENQASM 3.0;\nqubit q;\n" + "{ " * 5000 + "x q;" + " }" * 5000 + "\n" + with pytest.raises(wq.EngineError) as e: + wq.run(blocks, key=DEMO) + assert e.value.code == "Q006" + # The interpreter goes on: the next program runs. + assert wq.run(BELL, shots=10, seed=1, key=DEMO).status == "done" + + def test_values_json_cannot_carry_are_refused_not_guessed(): with pytest.raises(ValueError, match="NaN"): wq.run(BELL, options={"x": float("nan")}, key=DEMO) diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index e17b71ac6..b62c5c79e 100644 --- a/wai/wai-quantum-service/Cargo.lock +++ b/wai/wai-quantum-service/Cargo.lock @@ -876,6 +876,8 @@ dependencies = [ "blake3", "futures-util", "getrandom 0.4.3", + "hyper", + "hyper-util", "libc", "serde", "serde_json", diff --git a/wai/wai-quantum-service/Cargo.toml b/wai/wai-quantum-service/Cargo.toml index 9234b1c50..022b747d6 100644 --- a/wai/wai-quantum-service/Cargo.toml +++ b/wai/wai-quantum-service/Cargo.toml @@ -9,11 +9,15 @@ publish = false [dependencies] axum = "0.8" +# The connection loop (http::serve): axum's own takes no header timeout. +hyper = { version = "1", features = ["http1", "server"] } +hyper-util = { version = "0.1", features = ["tokio", "service"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "time", "sync", "net", "io-util"] } serde = { version = "1", features = ["derive"] } # float_roundtrip: a number a client sends back reads as the double it was # (the default reader rounds twice), so its receipt still verifies. -serde_json = { version = "1", features = ["float_roundtrip"] } +# raw_value: a JSON-RPC id is echoed as the text it was sent as (src/http.rs). +serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] } getrandom = "0.4" # The job store's content addresses (src/store.rs), as the engine's receipts use. blake3 = "=1.8.7" @@ -21,8 +25,9 @@ blake3 = "=1.8.7" futures-util = { version = "=0.3.34", default-features = false } wai-quantum = { path = "../wai-quantum", default-features = false, features = ["quantum_engine", "quantum_receipt", "quantum_qec", "quantum_noise", "quantum_fabric", "quantum_sv_metal", "quantum_cutrank_metal"] } -# proc_pid_rusage and waitid, for each job's energy (src/energy.rs). -[target.'cfg(target_os = "macos")'.dependencies] +# proc_pid_rusage and waitid, for each job's energy (src/energy.rs) and a +# child's memory (src/jobs.rs); setrlimit, for a reader's (src/offload.rs). +[target.'cfg(unix)'.dependencies] libc = "0.2" [profile.release] diff --git a/wai/wai-quantum-service/README.md b/wai/wai-quantum-service/README.md index 561ee89a2..d33de0ef7 100644 --- a/wai/wai-quantum-service/README.md +++ b/wai/wai-quantum-service/README.md @@ -47,7 +47,9 @@ One request can run many programs, as one job: - **A set:** `"programs"`, a list, each item a program's text or a job (`{"qasm", …}`). The request's other fields (`shots`, `observables`, `seed`, `intent`, …) apply to every item that does not set them itself. - **A sweep:** `"qasm"` with `"sweep"`, each of the program's inputs and its values. Every combination runs, the names in sorted order and the first varying slowest. An integer input (`input uint n;`) takes whole numbers, and the program is written out at each. A float or angle input (`input float theta;`, `input angle phi;`) takes any number, bound to exactly the double given. Every input the program declares needs values; a list of one fixes it. -A set holds at most 100 items. They run one after another in one worker, inside one job's time limit, holding the memory of the largest item. Each item is a plain job: its request is complete in itself, and it runs, plans and verifies as it would alone. A set with an item that does not plan is refused, naming the item. +A set holds at most 100 items. They run one after another in one worker, inside one job's time limit, holding the memory of the largest item; each item's result is kept as text as it ends, so the results of earlier items take no more than they will as served. Each item is a plain job: its request is complete in itself, and it runs, plans and verifies as it would alone. A set with an item that does not plan is refused, naming the item. + +A job's result, or a set's with every item's request and result in it, may take at most `WAI_QSVC_RESULT_MIB` (64 MiB) as served. That is checked before anything runs, from a bound of the result read from the request: each item's counts list at most its shots (a `trajectories` run's, its trajectories), the outcomes its measured bits can give (a noiseless `stabilizer` run's, two to the power of the gates that branch, `h`, `sx` and `sxdg`: a GHZ state's two, however wide) and 65,536 outcomes, each its bit string, quotes, a colon, a comma and its count's digits; a verdict counts the critique's remarks the program can draw (one for each gate that does nothing and each pair that cancels or merges, not one for every gate), each intent's check with the 64 outcomes it lists at a time, and the reader's notes again; each other part is counted by what it may hold or at its length as RFC 8785 writes it, every number in its own form (`1e20` is 21 characters). A request whose bound passes the cap is refused at once, `422` and `J031`, with the bound, its largest part (the counts, the verdict, the reader's notes, the items' requests or the rest) and what makes that part smaller in the reason, and a dry run gives the bound as `result_bytes`. The cap is less than a set at its most can give: at about 22 bytes a 16-bit outcome, 100 points of 65,536 outcomes each are about 150 MB, and are refused; 100 points of 16 measured bits are taken, and run to the end, up to about 30,000 shots a point (30,878 without a verdict; 30,003 over MCP, whose runs ask for one). Send fewer points or shots, split the set, or run it on your own machine (`wai-quantum run`), where no cap applies. A pulse run is bounded the same way, from its schedule as read: its populations and its distribution by the qubits it simulates and the captures it reads, each keyed as wide as the program's bits, its counts by its shots, and each capture with its frame's and bit's names. The check as a result ends stays only as a backstop, for a job kept across a restart that lowered the cap. The result is a set (`"format": "wai.quantum.set/1"`). Each item has its own result and its own receipt. Each item's leaf is the digest its receipt signs (a failed item's: the digest of its request and its error). The head holds the Merkle root of the leaves, under the JWP Merkle domains, with the count, and the set's receipt seals the set's request and its head. Each item carries its `path` to the root, so one item can be shown sealed by its set without the others. `POST /v1/verify` checks the whole set: its receipt, every item as the request gives it, each item's receipt, the root, and each path. @@ -67,7 +69,8 @@ curl -s https://wai.transaction.science/quantum-jobs/v1/run -H 'content-type: ap | `memory_bytes`, `work` | the memory it would be admitted with, and the planner's work | | `time` | `seconds` with its `basis`: the median rate of the last jobs of the method this service timed, or, before it has timed three, the planner's nominal rate; or `seconds: null` with why, where the planner's work does not predict the time | | `energy` | `joules_micro_estimate`, class `Estimator`, from the median power of the last metered jobs of the method here; or `unestimated` with why. Never a reading | -| `bounds` | the limits that apply: memory and time per job, jobs and memory per client, submissions per window, the queue, the most items in a set | +| `bounds` | the limits that apply: memory and time per job, jobs and memory per client, submissions per window, the queue, the most items in a set, the most a result may take (`result_bytes`), the most intents, the steps a request's programs may take to read, the largest body | +| `result_bytes` | the most its result takes as served, bounded before it runs; past `bounds.result_bytes` it is refused, `422` and `J031` | | `client` | where you stand: active jobs, memory held, submissions left | | `accepted` | whether the service would take the job now, by every limit; if not, `status` and `refusal` (its message and code) say why | | `items` | for a set: each item's plan and estimates | @@ -142,10 +145,10 @@ Counts are always seeded samples. | `GET /v1` | the service: its methods, limits, load and receipt key | | `POST /v1/run` | submit a job and wait up to 25 s: the finished job, or `202` and the job if it is still running | | `POST /v1/jobs` | submit a job or a set: `202` and the job, with its `id`, once it is in the store; with `dry_run`, `200` and the plan | -| `GET /v1/jobs/{id}` | the job: its status, the request as submitted, its result when it is done, and the digests it is kept under; `?wait=S` (at most 25) holds the answer until its status changes | +| `GET /v1/jobs/{id}` | the job, as RFC 8785 text, the same on every route and after a restart: its status, the request as sealed (the canonical form of the one submitted), its result when it is done, and the digests it is kept under; `?wait=S` (at most 25) holds the answer until its status changes | | `GET /v1/jobs/{id}/events` | the job's status as server-sent events: a `status` event at each change, then one `job` event with the whole job when it ends | | `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 and every rule of [the provider protocol's section 8.4](../extensions/quantum-providers.md#84-what-a-verifier-checks) holds, read from the body's own text: `valid`, `reasons`, `key`, `form`, and what a provider's result says of itself | +| `POST /v1/verify` | `{"request": ..., "result": ...}` (a set's is counted and read as a check is, since its sweep is written out again; one whose seal does not hold is not): whether the result's receipt seals that request and that result and every rule of [the provider protocol's section 8.4](../extensions/quantum-providers.md#84-what-a-verifier-checks) holds, read from the body's own text: `valid`, `reasons`, `key`, `form`, and what a provider's result says of itself | | `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/convert` | `{"to", "program" \| "program_base64", "from"?, "profile"?, "check"?}`: the program carried between OpenQASM 3, QIR and jeff (as base64), with the program as OpenQASM 3, each note with its code, what was read and written, and the check (the output read back and held to the program by the equivalence ladder); a refusal is the reader's, with its code and place ([the formats page](../wai-quantum/docs/formats.md)) | @@ -175,19 +178,42 @@ The three error-correction calls and the five of the variational and algorithm l A job may give a `target`: a built-in target's name, or a target document (`wai.quantum.target/1`, its schema in `GET /v1/openapi.json`). The program is compiled to the target's gates, placed on the qubits and routes most likely to succeed, and simulated under the noise its calibration gives. The result's `target` names the layout, its expected success, the noise and the target's hash, inside the receipt. The service reads the document it is given and fetches nothing. -Every refusal says why and ends with its code, which the body also carries as `code` ([the job layer's codes](../wai-quantum/docs/job-refusals.md), beside the reader's): `400` for a program or job that does not read (a set that does not read: `J007`), `422` for one larger than a job may be (`J031`), `429` past the per-client limits (`J030`), `503` when the queue is full (`J032`), on a private deployment `401` without one of its API keys (`J033`), and an `Idempotency-Key` that does not read (`400`) or was sent before with another request (`422`): `J034`. `GET /v1` states the limits. +Every refusal says why and ends with its code, which the body also carries as `code` ([the job layer's codes](../wai-quantum/docs/job-refusals.md), beside the reader's): `400` for a program or job that does not read (a set that does not read: `J007`), `422` for one larger than a job may be (`J031`), `429` past the per-client limits (`J030`), `503` when the queue is full or no turn to read it came (`J032`), on a private deployment `401` without one of its API keys (`J033`), and an `Idempotency-Key` that does not read (`400`) or was sent before with another request (`422`): `J034`. `GET /v1` states the limits. ## Status and limits -A job is `queued`, `running`, `done`, `failed`, `cancelled`, or `interrupted`: the service stopped while it ran, after it had run it again once, so it has no result, and `error` says so. Follow a job by its event stream (`GET /v1/jobs/{id}/events`), or by polling `GET /v1/jobs/{id}`, with `?wait=S` to hold each poll until the status changes. +A job is `queued`, `running`, `done`, `failed`, `cancelled`, or `interrupted`: the service stopped while it ran, after it had run it again once, so it has no result, and `error` says so. Follow a job by its event stream (`GET /v1/jobs/{id}/events`), or by polling `GET /v1/jobs/{id}`, with `?wait=S` to hold each poll until the status changes. A client holds at most `WAI_QSVC_CLIENT_STREAMS` (4) long polls and event streams at once: a long poll past them is not held, and says so, `429` (`503` past everyone's), `J032`, with `Retry-After: 2` and the job's `status`; wait that long before polling again, or poll without `?wait`. Every answer carries the client's submission window in the rate-limit fields of the IETF HTTP API working group: `RateLimit-Policy: "submissions";q=30;w=600` (at most 30 submissions in 600 s) and `RateLimit: "submissions";r=12;t=34` (12 left, one more in 34 s). A refusal past the limits carries `Retry-After`. +A submission is admitted in the order of what each step costs: + +1. A job sent again under its `Idempotency-Key` is answered with the key's job, before any limit: it reads nothing, takes no turn and counts as nothing. A job given in another format (`"source"`) is matched as it was received, so it too comes back past the window. Sent again in some other form, it is read and counted to tell whether it is the same request. +2. What reads nothing and refuses whatever is sent: your jobs at once (`429`), the memory they hold (`429`), a full queue (`503`), a spent window (`429`). None of these is counted, so waiting as told spends nothing. +3. It waits for a turn to be read. If none comes, it is refused (`503`, `J032`): nothing was read and nothing is counted. +4. With its turn the submission is counted against your window. Every refusal after this counts. +5. What reads nothing: every whole number exact, at most 16 intents (each of a set's items too), at most 100 items in a set. +6. Its programs are read: the job's own, a set's items and a sweep's points, and every intent's reference, each once however often admission reads it, and all of them within one budget of 22,000,000 steps (statements the reader runs, gates it writes, a generator's loop passes and what it writes out, and every 128 bytes of a value it makes or copies). That is as many as one program may take, its statements and its gates each to the reader's bound, so a job whose own program reads within the reader's bounds reads here. A request whose programs take more is refused, `422` and `J031`, with the command that runs it on your machine. +7. The job is kept and queued, within your and the queue's limits, and within the most a result may take: one whose result's bound passes `WAI_QSVC_RESULT_MIB` is refused, `422` and `J031`, before it runs. + +`Retry-After` is the pace at which a refusal that counts would not spend your window (the window over its submissions, at least 5 s), or, past the window, when the next submission opens. A `503` carries it too. + +Reading happens outside the service's own process, as each job's run does: each request is read by a child, this binary run as `wai-quantum-service read`, sandboxed as a worker is. + +- **Turns.** Four requests are read at a time (jobs, dry runs and the MCP tools' verdicts), and four checks beside them. A client has its share of each: `WAI_QSVC_CLIENT_READS` (2) of the first and `WAI_QSVC_CLIENT_CHECKS` (1) of the checks, so no client holds a whole pool. Past its share, one more turn is lent to a client while the pool keeps two free for others, so its quick calls (a critique, a lint, an estimate) pass its own slow one: of the checks, at their share of one; the reading pool's four turns, at its share of two, leave none to lend. A call on a lent turn moves onto the client's own turn as soon as one comes free, and then has the whole of its time and gives the lent turn back, so the client's quick calls pass it on that one; until then it runs past five seconds only while no other client's request waits for a turn and none of its own client's has waited seven seconds, and once one has it is refused, `503` and `J032`, to send again: two long calls of one client never hold its quick ones past their wait. Its other requests wait behind its own turns, in order, so an agent's burst of parallel calls is answered one after another: up to eight of them may wait (so its eleventh request at once, of readings or of checks, is refused), at most 32 requests wait in all, each for ten seconds, and past any of these a request is refused, `503` and `J032`, having read and spent nothing, saying whether its own requests or the pool held it up. A client is its address, so people behind one address (an office, a carrier's NAT, a class) share one client's turns and its window: raise the shares, or give such a group an API key of its own on a private deployment. +- **Memory.** A child's memory is polled every 50 ms from a thread of its own, and the child is killed once it holds more than `WAI_QSVC_READ_GB` (4); the request is refused, `422` and `J031`. The kill is a signal the service sends itself (`SIGKILL`, to the child's process group, and again every 100 ms until it has ended), so it needs no new process. On macOS the poll reads the child's physical footprint, which counts its compressed and swapped pages; on Linux its resident and swapped pages. A poll is not a kernel limit: a child can pass the cap by what it touches in one interval before it is killed. On Linux the child also holds its own address space to twice the cap and a gigabyte, so an allocation past that fails at once (`422`, `J031`), and the kernel ends a reader or a worker before the service when the host runs short. macOS gives an unprivileged process neither, and has no limit that counts compressed memory. +- **Time.** A child that reads for longer than `WAI_QSVC_READ_SECS` (45) is killed and the request refused, `422` and `J031`. Every request is answered within `WAI_QSVC_ANSWER_SECS` (55) of its arrival, under the 60 s a proxy in front commonly waits for an answer: its waits for a turn and its readings end by then, `POST /v1/run` and the MCP run tools wait for their job only for what is left and then answer with the job, still running, to poll, and an MCP answer's verdict is read again only if time is left (its verdict says so where it was not). An error-correction call's shots are measured as they run: each point is set up and sampled, and a pilot of its own shots decoded, before the rest of any; it is refused at once when what is measured says it would take more than `WAI_QSVC_QEC_SECS` (25), and stopped if it runs a fifth past that. +- **Its answer.** What a reading gives back comes into the service's memory, which every job shares: one that would take more than `WAI_QSVC_READ_ANSWER_MIB` (128) once read, about 80 bytes a value, is refused in the child before it is written (`422`, `J031`), and the service reads it off its request threads. A dry run gets back the plans and points alone. A check's answer can be that large: a critique of a program of 200,000 gates that each draw a remark (`qubit q; for int i in [1:200000] { x q; }`) is refused so; one of 150,000 is answered. +- **What ended jobs keep.** A job keeps its request as the canonical text the store keeps, and a set lets go of its items once it ends. What ended jobs keep in memory, their requests' text and their results as RFC 8785 text, is counted in two parts. Results that have been read (or ended more than `WAI_QSVC_KEPT_MIN_SECS`, 600 s, ago) are held to `WAI_QSVC_CLIENT_KEPT_MIB` (128) a client, its oldest dropped first, and `WAI_QSVC_KEPT_MIB` (4096) in all, those of the client keeping the most dropped first (between two keeping as much, the newer); only these are ever dropped before `WAI_QSVC_RETAIN_SECS`. Results not yet read are dropped by neither cap: a client's next job is refused, `429`, while its unread results, with each job it has running at its result's bound and the new one at `WAI_QSVC_RESULT_MIB` (64), would pass `WAI_QSVC_CLIENT_UNREAD_MIB` (256), and every next job is refused, `503`, while all unread results, with every job queued or running at its result's bound and the new one, would pass `WAI_QSVC_UNREAD_MIB` (8192). A job whose result's bound passes `WAI_QSVC_RESULT_MIB` is refused before it runs (see [Sets and sweeps](#sets-and-sweeps)). The bounds that remain: memory for ended jobs stays under the two caps, 12 GiB at the defaults, the results of jobs still queued or running counted within the unread cap at their bounds; reaching the unread cap takes 32 clients each holding its whole allowance unread, and holds every next job for at most 600 s; a client is its address, so people behind one address share its allowances, and one of them who leaves 256 MiB unread holds the others' next jobs until it is read or 600 s pass; and an honest client's read result is dropped early only where it, of all clients, keeps the most. A request's body is parsed only once its client's window has room (or, for a resend under a key the client holds a job under, first, as it is answered before any limit). A receipt check has a window of its own, `WAI_QSVC_CLIENT_VERIFIES` (120), asked before its body is read. An MCP message over 64 KiB from a client past its window is read only for its id, method, tool and key, and answered under its own id with a tool result that says why; a resend under a key the client holds, and a receipt check, are read whole. A job's report is one RFC 8785 text on every route (a poll, its events, a cancel, and in the MCP job tools' answers, written with RFC 8785's numbers, the job's own bytes, with each JSON-RPC id echoed as the text it was sent as: `1.0`, `1e3` and an integer past 64 bits as written) and after a restart: its request and its result are written in as the text each is kept as, never parsed again, and the memory a large request or answer was parsed into is given back to the system once it is answered. On macOS the service starts itself with the allocator's large-block cache off (`MallocLargeCache=0`): that cache kept every large block a parsed request had freed, dirty and charged to the service (on arm64, 30 requests of a megabyte kept 1 GiB; with it off, 2 MiB). +- **Ending.** A child that panics, overflows its stack or aborts is answered `500`, and the service runs on. A client that hangs up ends its child. +- **Connections.** A request whose header section takes longer than `WAI_QSVC_HEADER_SECS` (150) to arrive is closed; the same limit closes a connection left idle between requests, so it is set above a proxy's own idle limit (two minutes, commonly), and the proxy, not the service, closes its idle connections. A body that takes longer than `WAI_QSVC_BODY_SECS` (30) is refused, `408`. A client may have `WAI_QSVC_CLIENT_REQUESTS` (32) requests open at once; past it a request is refused, `503`, before its body is read (a health check is answered whatever that is). Long polls and event streams are held to `WAI_QSVC_CLIENT_STREAMS` (4) a client and `WAI_QSVC_STREAMS` (512, at most a quarter of the connections) in all: past them a long poll is answered at once, `429` (its client's) or `503` (everyone's), `J032`, with `Retry-After: 2` and the job's status, and an event stream is refused, `503`. Four connections past the connection cap are held for health checks alone, one request each, for 2 s. The service raises its open-file limit to its hard limit as it starts (on macOS no further than `kern.maxfilesperproc` and 10240), says so in its log, and holds at most `WAI_QSVC_CONNECTIONS` (4096) connections, fewer where that limit leaves less room: past them the connection idle longest (or the one whose body has been arriving longest) is closed for the new one, so a health check is always answered, and with none to close a new connection is answered `503` at once. +- **Updating.** Readers and workers start from the service's own binary as it was started: on Linux its running image, elsewhere a copy it keeps beside its signing key, named by its digest. So the binary can be replaced before the restart (by a copy over it, or, on Linux, where a running binary cannot be written, by a rename), and the running service goes on with its own until it is restarted. + ## Sending a job again A connection can fail after the service took a job and before its answer came back. To send the job again safely, give `POST /v1/jobs` or `POST /v1/run` an `Idempotency-Key` (the same working group's field): 1 to 255 visible ASCII characters, with no quote or backslash, as a structured field's string or bare. Make it unguessable, a UUID or 128 random bits, since it is kept per client and a client is an address. -- Sent again with the same key and the same request, the job is answered with the job the key names, before any limit, and counts as no submission. +- Sent again with the same key and the same request, the job is answered with the job the key names, before any limit, and counts as no submission. A send that overtakes its first, both still being read, is read and counts, and gets the one job. - The same key with another request is refused, `422` and `J034`. - A key names its job for as long as the job is kept, whatever its status, with one exception: a job the service interrupted. That job's key runs the request again. - The record keeps a digest of the key with the client's name, never the key. @@ -203,15 +229,17 @@ Every job is in the service's store before its submission is answered, and again - a job the service stops again while it runs once more comes back `interrupted`, with the reason, so a job that stops the service whenever it runs does not run for ever; - a queued one is queued again, in its order. +A restart reads no program: a job keeps the plan it was admitted with, and a set its items, so what is queued delays no restart. One too large for the service's limits as they now stand fails, saying why. + Finished jobs are kept for `WAI_QSVC_RETAIN_SECS` (a day here), then dropped. A record names its client by a keyed hash of the address, never the address. [docs/self-host.md](docs/self-host.md#the-job-store) describes the store. -The six 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: +The six checks answer at once, each read and answered in a reading child as a job's admission is. Each counts against the client's submissions, as a job does, once it has its turn and before it reads anything (a check that waits ten seconds and finds no turn is refused, `503`, having read and spent nothing). At most four run at a time, one of them a client's (and one more lent for a quick call), its others waiting behind it. Its bounds and its answer then read its programs within one budget, each read once: a check that runs the budget out is refused, `422` and `J031`, whatever it had made of it, so a repair of many fixes to a slow program ends at the budget. 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; - a compile of more than 20,000 gates, or with more than 256 rotations to approximate, or a budget that leaves a rotation less than 10⁻¹² (`wai-quantum compile`). A job that asks to be compiled first is held to the same bounds; -- an error-correction call past its bounds here (`wai-quantum qec`): a decode of more than 200,000 shots, of a memory experiment past distance 7, or of more than 2 MiB of circuit or model text; a threshold of more than 16 points or 200,000 shots in all, or at circuit level past distance 7; the distance certificate of `bb:288`; +- an error-correction call past its bounds here (`wai-quantum qec`): a decode of more than 200,000 shots, of more than 1,024 fired detectors, of a memory experiment past distance 7 or 21 rounds, or of more than 2 MiB of circuit or model text; a threshold of more than 16 points or 200,000 shots in all, or at circuit level past distance 7 or 21 rounds; a decode or a threshold whose shots, measured as they run, would take more than `WAI_QSVC_QEC_SECS` (25 s); the distance certificate of `bb:288`; - a gradient, an optimisation, a VQE or a QAOA run past its bounds here (`wai-quantum gradient`, `optimize`, `vqe`, `qaoa`): more than 16 qubits, more than 500 steps, or about 4·10⁹ gate amplitudes in all; phase estimation on more than 12 counting qubits, or amplitude estimation on more than 8 (`wai-quantum build`); - an estimate of more than 200,000 gates, or with more than 256 distinct rotation angles to synthesise, or a budget that leaves a rotation less than 10⁻¹² (`wai-quantum estimate`). Verifying an estimate re-runs it, within the same bounds. - a pulse check whose run takes more than 2·10¹⁰ of the simulator's multiply-adds, or a pulse call on more than 1 MiB of program (`wai-quantum pulse check`). A pulse job itself is held to the limits every job is. @@ -223,11 +251,13 @@ The six checks answer at once, in the service's own process. Each counts against - resources: the language the engine reads (`wai-quantum://docs/language`), its diagnostics, the methods with their accuracy classes (their reach in a job's memory here), and worked examples, each with what it must do; - prompts: writing and verifying a circuit for an intent, explaining a result and its receipt, and repairing a program from its diagnostics. Each names this server's own tools. +Every answer about a program states a verdict. Reading the program again for it costs what reading it cost, so it is read, in a reading child, only for a call that was counted. A poll (`get_quantum_job`) states what its job holds: the result's verdict where the job asked for one (`verdict` or `intent`); otherwise, and for a job not yet ended, that it reads, with empty `diagnostics` and `critique`, and a view on a poll draws nothing. A tool that says nothing about a program takes no turn. A call refused before it was counted, told to try again, or that finds no turn for its verdict states a verdict that gives the reason and nothing of the program. + A client whose `initialize` says its host draws interactive views (the extension `io.modelcontextprotocol/ui`, version 2026-01-26) gets the engine's answers as views, as from the command line's server: each tool that has one names its `ui://wai-quantum/*` resource, the six views are listed and read, and each answer carries what its view draws in its `_meta`. The service keeps no session, so the client's word is carried in the session id it is given at `initialize` (`wq-views-` and 32 random hex digits, kept nowhere), which a client sends back with every request (`Mcp-Session-Id`); a client that draws no views gets no session id and none of this. For a job still running when the run's wait ends, the result view follows it with `poll_quantum_job`, which the host keeps from the model (`visibility: ["app"]`); a model reads a job with `get_quantum_job`. A client that speaks MCP 2025-11-25 may call the three runs as tasks (`tools/call` with `task`): `initialize` declares `tasks` (`cancel`, and `requests.tools.call`), and each run's `execution.taskSupport` is `optional`. -- **The task is the job.** Its `taskId` is the job's id. `tools/call` answers at once with the task, `working`. A program that does not read makes no task: the answer is an error (`-32602`) whose `data` is the refusal, with its codes and verdict. +- **The task is the job.** Its `taskId` is the job's id. `tools/call` answers at once with the task, `working`. A program that does not read makes no task: the answer is an error (`-32602`) whose `data` is the refusal, with its codes and verdict (of the program where the request was counted, as above). - **Statuses.** `tasks/get` gives the job as a task: `working` while it is queued or running, `completed` once done, `cancelled`, or `failed` for a job that failed or that the service interrupted, with why. Its `ttl` is the service's retention, which a job outlasts from its creation, since it is kept that long after it ends. - **The result.** `tasks/result` gives what `get_quantum_job` gives for the job that ended, naming the task in its `_meta` (`io.modelcontextprotocol/related-task`), an error (`isError`) where the job failed. - Asked while the task still works, by a client that takes `text/event-stream`, the answer is an event stream: kept open, and the answer its one event when the job ends. @@ -317,6 +347,26 @@ These variables set the limits: | `WAI_QSVC_CLIENT_GB` | the job size | memory one client's queued and running jobs may hold together | | `WAI_QSVC_CLIENT_BURST` | 30 | submissions one client may make per window | | `WAI_QSVC_CLIENT_WINDOW_SECS` | 600 | the window | +| `WAI_QSVC_READ_GB` | 4 | the memory one request's reading may hold, in its child | +| `WAI_QSVC_READ_SECS` | 45 | the time one request's reading may take | +| `WAI_QSVC_READ_ANSWER_MIB` | 128 | the most one request's reading may give back to the service, once read | +| `WAI_QSVC_QEC_SECS` | 25 | the time an error-correction call's shots may take, measured as they run | +| `WAI_QSVC_ANSWER_SECS` | 55 | the longest any answer takes, its waits and readings included | +| `WAI_QSVC_CLIENT_KEPT_MIB` | 128 | what one client's ended jobs may keep in memory | +| `WAI_QSVC_KEPT_MIB` | 4096 | what all ended jobs whose results have been read may keep in memory | +| `WAI_QSVC_CLIENT_UNREAD_MIB` | 256 | what one client's results not yet read may hold, each of its running jobs counted at its result's bound and the next at `WAI_QSVC_RESULT_MIB`; past it, its next job is refused (`429`) | +| `WAI_QSVC_UNREAD_MIB` | 8192 | what all results not yet read may hold; past it, every next job is refused (`503`) | +| `WAI_QSVC_RESULT_MIB` | 64 | the most one job's or set's result may take, as served: a request whose result's bound passes it is refused before it runs (`422`, `J031`) | +| `WAI_QSVC_CLIENT_STREAMS` | 4 | long polls (`?wait=`) and event streams one client may hold at once | +| `WAI_QSVC_STREAMS` | 512 | those of all clients, at most a quarter of the connections held | +| `WAI_QSVC_KEPT_MIN_SECS` | 600 | how long an ended job not yet read is kept whatever the two caps above say | +| `WAI_QSVC_CLIENT_VERIFIES` | 120 | receipts one client may have checked in its window (`POST /v1/verify`, `verify_quantum_receipt`) | +| `WAI_QSVC_CLIENT_REQUESTS` | 32 | requests one client may have open at once; past it, `503` before the body is read | +| `WAI_QSVC_CONNECTIONS` | 4096 | connections held at once, at most; lowered to what the open-file limit leaves room for | +| `WAI_QSVC_CLIENT_READS` | 2 | how many of one client's jobs, dry runs and verdicts are read at once (more wait) | +| `WAI_QSVC_CLIENT_CHECKS` | 1 | how many of one client's checks run at once (more wait) | +| `WAI_QSVC_HEADER_SECS` | 150 | how long a client may take to send a request's header section, and how long an idle connection stays open | +| `WAI_QSVC_BODY_SECS` | 30 | how long a client may take to send a request's body | | `WAI_QSVC_MAX_QUEUE` | 256 | jobs waiting at once | | `WAI_QSVC_RETAIN_SECS` | 86400 | how long finished jobs are kept, across restarts | | `WAI_QSVC_STORE` | `~/.local/share/wai-quantum-service/store` | the job store's directory, or `memory` | diff --git a/wai/wai-quantum-service/docs/scheduler.md b/wai/wai-quantum-service/docs/scheduler.md index 21f147095..679cd5e89 100644 --- a/wai/wai-quantum-service/docs/scheduler.md +++ b/wai/wai-quantum-service/docs/scheduler.md @@ -51,4 +51,4 @@ Before committing an allocation to it, a job can be planned without running: `wa ## Limits and keys -The public service counts a client by its address. A batch system submits from a few login or compute nodes on behalf of many people, so on a shared deployment give the scheduler its own API key (`WAI_QUANTUM_API_KEY`, see [self-host.md](self-host.md#api-keys-for-a-private-deployment)). Its limits are then counted by the key's name, and `WAI_QSVC_CLIENT_ACTIVE`, `WAI_QSVC_CLIENT_GB` and `WAI_QSVC_CLIENT_BURST` size them. Every answer carries the client's submission window in `RateLimit-Policy` and `RateLimit`, and a refusal past it carries `Retry-After`. The adapter exits 75 on such a refusal, which a scheduler can treat as "requeue". +The public service counts a client by its address. A batch system submits from a few login or compute nodes on behalf of many people, so on a shared deployment give the scheduler its own API key (`WAI_QUANTUM_API_KEY`, see [self-host.md](self-host.md#api-keys-for-a-private-deployment)). Its limits are then counted by the key's name, and `WAI_QSVC_CLIENT_ACTIVE`, `WAI_QSVC_CLIENT_GB`, `WAI_QSVC_CLIENT_BURST`, `WAI_QSVC_CLIENT_READS` and `WAI_QSVC_CLIENT_CHECKS` (how many of its submissions and checks are read at once; more wait behind them) size them. Every answer carries the client's submission window in `RateLimit-Policy` and `RateLimit`, and a refusal past it carries `Retry-After`. The adapter exits 75 on such a refusal, which a scheduler can treat as "requeue". diff --git a/wai/wai-quantum-service/docs/self-host.md b/wai/wai-quantum-service/docs/self-host.md index 4a82dfc22..e77636156 100644 --- a/wai/wai-quantum-service/docs/self-host.md +++ b/wai/wai-quantum-service/docs/self-host.md @@ -50,6 +50,26 @@ Everything is set by environment variables. | `WAI_QSVC_CLIENT_ACTIVE` | 3 | queued or running jobs one client may hold | | `WAI_QSVC_CLIENT_GB` | the job size | the memory one client's jobs may hold together | | `WAI_QSVC_CLIENT_BURST` | 30 | submissions one client may make in a window | +| `WAI_QSVC_READ_GB` | 4 | the memory one request's reading may take (polled; see the README's reading section) | +| `WAI_QSVC_READ_SECS` | 45 | the time one request's reading may take | +| `WAI_QSVC_READ_ANSWER_MIB` | 128 | the most one request's reading may give back to the service, once read | +| `WAI_QSVC_QEC_SECS` | 25 | the time an error-correction call's shots may take, measured as they run | +| `WAI_QSVC_ANSWER_SECS` | 55 | the longest any answer takes, its waits and readings included | +| `WAI_QSVC_CLIENT_KEPT_MIB` | 128 | what one client's ended jobs may keep in memory | +| `WAI_QSVC_KEPT_MIB` | 4096 | what all ended jobs whose results have been read may keep in memory | +| `WAI_QSVC_CLIENT_UNREAD_MIB` | 256 | what one client's results not yet read may hold, each of its running jobs counted at its result's bound and the next at `WAI_QSVC_RESULT_MIB`; past it, its next job is refused (`429`) | +| `WAI_QSVC_UNREAD_MIB` | 8192 | what all results not yet read may hold; past it, every next job is refused (`503`) | +| `WAI_QSVC_RESULT_MIB` | 64 | the most one job's or set's result may take, as served: a request whose result's bound passes it is refused before it runs (`422`, `J031`) | +| `WAI_QSVC_CLIENT_STREAMS` | 4 | long polls (`?wait=`) and event streams one client may hold at once | +| `WAI_QSVC_STREAMS` | 512 | those of all clients, at most a quarter of the connections held | +| `WAI_QSVC_KEPT_MIN_SECS` | 600 | how long an ended job not yet read is kept whatever the two caps above say | +| `WAI_QSVC_CLIENT_VERIFIES` | 120 | receipts one client may have checked in its window (`POST /v1/verify`, `verify_quantum_receipt`) | +| `WAI_QSVC_CLIENT_REQUESTS` | 32 | requests one client may have open at once; past it, `503` before the body is read | +| `WAI_QSVC_CONNECTIONS` | 4096 | connections held at once, at most; lowered to what the open-file limit leaves room for | +| `WAI_QSVC_CLIENT_READS` | 2 | how many of one client's jobs, dry runs and verdicts are read at once (more wait) | +| `WAI_QSVC_CLIENT_CHECKS` | 1 | how many of one client's checks run at once (more wait) | +| `WAI_QSVC_HEADER_SECS` | 150 | how long a client may take to send a request's header section, and how long an idle connection stays open | +| `WAI_QSVC_BODY_SECS` | 30 | how long a client may take to send a request's body | | `WAI_QSVC_CLIENT_WINDOW_SECS` | 600 | the window | | `WAI_QSVC_MAX_QUEUE` | 256 | jobs waiting at once | | `WAI_QSVC_SANDBOX` | 1 | 0 runs the macOS worker unsandboxed, for debugging | @@ -123,6 +143,10 @@ Each finished job carries its energy inside the signed result: a counter reading - **Linux:** the processor packages' energy counters, read through the kernel's power-capping interface before the worker starts and after it exits. They count whole packages, so the figure is the packages' energy over the job's window, every process on the host included, with no attribution to the job (`attribution: none`). It names how many other jobs ran in the window (`concurrent_jobs`). The counter is used only when every package zone reads and its count is live; its declared uncertainty covers the two reads and the counter's update edges, measured at start. - **Permissions:** many Linux hosts let only root, or a group such as `energy`, read the count. Add the service's user to that group. In a container the interface is usually hidden; a container that should meter needs it visible and readable, which widens what the container sees of the host. Without it, jobs say they are unmetered and why. +## Open files + +Each connection takes a file. The service raises its soft open-file limit to its hard one as it starts and logs the result (`open files: …`), and holds connections under it with room for its readers' and workers' pipes and its store's files (`connections: at most …`). On macOS a launchd service inherits `launchctl limit maxfiles`, soft 256 and hard unlimited by default: the service raises its soft limit itself (to 10240, or `kern.maxfilesperproc` if lower) and holds `WAI_QSVC_CONNECTIONS` (4096). Only where the hard limit is low does the cap fall: at a hard limit of 256 it holds 192 connections, so a plist that sets `HardResourceLimits` (`NumberOfFiles`) should set it to 10240 or more. Whatever the cap, a few connections past it are held for health checks alone, and long polls and event streams take at most a quarter of it. + ## Behind a reverse proxy Run the service on a loopback address and let the proxy take the public traffic. @@ -130,8 +154,18 @@ Run the service on a loopback address and let the proxy take the public traffic. - **The client's address.** The proxy sets `X-Forwarded-For`, and the service trusts it only from `WAI_QSVC_TRUSTED_PROXIES`. Have the proxy set the header from the connection itself, not pass on what the request carried, or a client could choose its own limits. - **A path prefix.** Mount the service under a prefix (the public one is `/quantum-jobs`) by stripping the prefix before passing the request on. Set `WAI_QSVC_PUBLIC_BASE` to the prefixed URL, so the links the service hands out are right. A well-known path belongs at the origin's root: map `/.well-known/wai-quantum-receipt-keys` there to the service's own. - **Event streams.** `GET /v1/jobs/{id}/events` is a server-sent event stream. The proxy must pass it through unbuffered (the service sends `X-Accel-Buffering: no` and a comment every 15 s), and allow it to stay open as long as `WAI_QSVC_JOB_SECS`. Polls with `?wait=` are held up to 25 s. -- **Bodies.** A request body is at most 1 MiB, and `POST /v1/run` waits up to 25 s before answering 202. +- **Bodies.** A request body is at most 1 MiB, and `POST /v1/run` waits up to 25 s before answering 202. An answer's body can be as large as a result may be as served, `WAI_QSVC_RESULT_MIB` (64 MiB), with the job's request beside it: a proxy that buffers or caps answers must allow that. A request whose result could be larger is refused before it runs (`422`, `J031`), its bound in the reason. - **Rate-limit fields.** Pass the `RateLimit-Policy`, `RateLimit` and `Retry-After` headers through. +- **Timeouts.** Every answer comes within `WAI_QSVC_ANSWER_SECS` (55) of the request's arrival, so within a proxy's 60 s wait for an answer's header. The worst case of each route, from arrival: a check, a dry run or a submission waits at most 10 s for a turn and reads for at most `WAI_QSVC_READ_SECS` (45), 55 s in all; `POST /v1/run` and the MCP run tools then wait for their job only for what is left of the 55 s (less 5 s, on MCP, for its verdict) and answer with the job, still running, to poll; an MCP answer's verdict waits for a turn and is read only within what is left, and says where no time was left; `tasks/result` holds a reply for what is left of the 55 s, 50 s at most. With a shorter wait in the proxy, lower `WAI_QSVC_ANSWER_SECS` below it. Keep the proxy's idle connections shorter than `WAI_QSVC_HEADER_SECS` (150), which also bounds an idle connection on the service's side: the proxy then closes its idle connections itself, and never sends a request on one as the service closes it. +- **One address, many people.** The service counts a client by the address the proxy gives it. People behind one address share one client's turns (`WAI_QSVC_CLIENT_READS`, `WAI_QSVC_CLIENT_CHECKS`) and window; raise those, or give such a group an API key. + +## Updating it + +Readers and workers start from the service's own binary as it was started: on Linux its running image (`/proc//exe`), elsewhere a copy kept beside the signing key (`bin/wai-quantum-service-`). So: + +1. Put the new binary in place: a copy over the old one, or a copy beside it renamed over it (on Linux a running binary cannot be written, so rename). The running service goes on reading and running jobs with its own binary. +2. Restart the service. It keeps a copy of the new binary on its first start (on Linux, nothing to keep). +3. Check `GET /v1/health`, then `smoke.sh`. Copies of earlier binaries beside the key are not removed; delete them once no service started from them runs. A minimal site, in one common proxy's configuration format: @@ -141,6 +175,9 @@ quantum.example.org { reverse_proxy 127.0.0.1:8241 { header_up X-Forwarded-For {remote_host} flush_interval -1 + transport http { + keepalive 60s + } } } } diff --git a/wai/wai-quantum-service/schemas/service-tools.v1.json b/wai/wai-quantum-service/schemas/service-tools.v1.json index e06c0aa2e..83a355d02 100644 --- a/wai/wai-quantum-service/schemas/service-tools.v1.json +++ b/wai/wai-quantum-service/schemas/service-tools.v1.json @@ -304,7 +304,7 @@ }, "accepted": { "type": "boolean", - "description": "Whether the service would take it now, by every limit." + "description": "Whether the service would take it now, by every limit, its reading held to what the submission's would take; the MCP tools plan it as their runs run it, its verdict asked." }, "status": { "type": "integer", @@ -390,9 +390,34 @@ }, "bounds": { "type": "object", - "description": "The bounds that apply here: memory, time, queue and items of a set.", + "description": "The bounds that apply here: memory, time, queue, items of a set, and result_bytes, the most a job's or a set's result may take as served.", "additionalProperties": true }, + "result_bytes": { + "description": "The most its result takes as served, bytes, bounded before it runs: past bounds.result_bytes the job is refused (422, J031). A pulse run is bounded from its schedule. For a method that gives no bound before it runs (no built-in is such): {\"bytes\": null, \"why\"}, and it is counted at bounds.result_bytes.", + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "object", + "required": [ + "bytes", + "why" + ], + "properties": { + "bytes": { + "type": "null" + }, + "why": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, "client": { "type": "object", "description": "Where the client stands: its active jobs, the memory they hold, the submissions left in its window and when the window resets.", diff --git a/wai/wai-quantum-service/src/analysis.rs b/wai/wai-quantum-service/src/analysis.rs index fff9cb89c..2361519e2 100644 --- a/wai/wai-quantum-service/src/analysis.rs +++ b/wai/wai-quantum-service/src/analysis.rs @@ -1,12 +1,13 @@ //! Design checks the engine answers at once, without a job: a program's //! critique, two programs' equivalence, the improvements its fixes make, its //! classical shortcuts and its compile (`wai_quantum::engine::{critique, -//! equiv, shortcuts, compile}`). They run in this process, off the request's -//! thread, within bounds: +//! equiv, shortcuts, compile}`). Each is read and answered by a reading +//! child ([`crate::offload`]), whose memory and time are capped, 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); +//! - at most [`crate::offload::CHECKS_AT_ONCE`] run at a time, and a request +//! that waits [`crate::offload::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 @@ -24,8 +25,9 @@ //! - a job's intents are held to the same bound as the equivalence check //! ([`intent_bounds`]), so a check that would take long is refused before //! the job is queued, with the command that runs it locally; -//! - a repair ([`Check::Repair`]) is bounded by the program's size: it reads -//! the program once a repair, at most `engine::repair::MAX_ROUNDS` times; +//! - a repair ([`Check::Repair`]) reads the program again for each fix, at +//! most `engine::repair::MAX_ROUNDS` times, within the check's one budget +//! of reading: a fix makes a new text, so each round costs what it reads; //! - a conversion between formats ([`Check::Convert`]) reads at most //! [`CONVERT_MAX_BYTES`], and its check is held to the equivalence check's //! bound. @@ -33,22 +35,24 @@ //! Every refusal ends with its code: J031 for what is larger than the //! service takes, J032 when every check is running. //! +//! **In the order of what each step costs.** A check past the client's +//! window is refused first; then it waits for a turn; with its turn it is +//! counted, so every check refused after that counts, and one told to try +//! again (`503`) has read and spent nothing. Then, in its reading child, its +//! bounds read its programs and its answer is made, each program read once, +//! all within one budget of reading ([`crate::http::READ_STEPS`]). +//! //! 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. +//! in the gates or close to it. The other engine calls are held to their own +//! work bounds above, and in their child to its memory and its time. use crate::http::App; use serde_json::Value; -use std::time::Duration; -use tokio::sync::Semaphore; use wai_quantum::engine::synth::lower; use wai_quantum::engine::refusal::{self, tag}; use wai_quantum::engine::{compile, critique, equiv, repair, 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; /// The most gates a compile here takes. @@ -64,7 +68,10 @@ pub const ESTIMATE_MAX_ANGLES: usize = 256; /// The smallest share of the synthesis budget a rotation may get here. pub const ESTIMATE_MIN_EACH: f64 = 1e-12; -static TURNS: Semaphore = Semaphore::const_new(AT_ONCE); +/// The most intents a job states (a set's items each): an intent's +/// reference is a program to read and to check against the job's, so their +/// number is held before any is read. +pub const MAX_INTENTS: usize = 16; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Check { @@ -77,6 +84,27 @@ pub enum Check { Convert, } +impl Check { + const ALL: [(Check, &'static str); 7] = [ + (Check::Critique, "critique"), + (Check::Equivalent, "equivalent"), + (Check::Improve, "improve"), + (Check::Shortcuts, "shortcuts"), + (Check::Compile, "compile"), + (Check::Repair, "repair"), + (Check::Convert, "convert"), + ]; + + /// The check's name, as a reading child is given it. + pub fn name(self) -> &'static str { + Self::ALL.iter().find(|(c, _)| *c == self).map(|(_, n)| *n).expect("every check is named") + } + + pub fn named(name: &str) -> Option { + Self::ALL.iter().find(|(_, n)| *n == name).map(|(c, _)| *c) + } +} + 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"))) } @@ -169,6 +197,14 @@ pub const QEC_MAX_POINTS: usize = 16; pub const QEC_MAX_CIRCUIT_DISTANCE: u64 = 7; /// The most bytes of circuit or model text a decode reads here. pub const QEC_MAX_TEXT: usize = 2 << 20; +/// The most rounds of a memory experiment a decode or a threshold runs here: +/// three times the largest distance, past which a fit gains nothing a +/// distance does not. +pub const QEC_MAX_ROUNDS: u64 = 3 * QEC_MAX_CIRCUIT_DISTANCE; +/// The most detectors a decode here is given as fired. Matching grows faster +/// than the square of them, in time and in memory: a thousand take about a +/// second and a hundred MiB. +pub const QEC_MAX_FIRED: usize = 1024; /// Whether the service takes the error-correction call `op` on `body`: the /// reason it does not, with the status to answer, or nothing. The engine's @@ -194,9 +230,15 @@ pub fn qec_bounds(op: &str, body: &Value) -> Result<(), (u16, String)> { if body.get("shots").and_then(Value::as_u64).is_some_and(|s| s > QEC_MAX_SHOTS) { return far(format!("more than {QEC_MAX_SHOTS} shots; {}", local("decode"))); } + if body.get("detectors").and_then(Value::as_array).is_some_and(|d| d.len() > QEC_MAX_FIRED) { + return far(format!("more than {QEC_MAX_FIRED} fired detectors; {}", local("decode"))); + } if body.pointer("/memory/distance").and_then(Value::as_u64).is_some_and(|d| d > QEC_MAX_CIRCUIT_DISTANCE) { return far(format!("a memory experiment past distance {QEC_MAX_CIRCUIT_DISTANCE}; {}", local("decode"))); } + if body.pointer("/memory/rounds").and_then(Value::as_u64).is_some_and(|r| r > QEC_MAX_ROUNDS) { + return far(format!("a memory experiment of more than {QEC_MAX_ROUNDS} rounds; {}", local("decode"))); + } } _ => { let n = |k: &str, d: usize| body.get(k).and_then(Value::as_array).map_or(d, Vec::len); @@ -205,6 +247,9 @@ pub fn qec_bounds(op: &str, body: &Value) -> Result<(), (u16, String)> { if points > QEC_MAX_POINTS || shots.saturating_mul(points as u64) > QEC_MAX_SHOTS { return far(format!("{points} points of {shots} shots, more than a threshold here measures ({QEC_MAX_POINTS} points, {QEC_MAX_SHOTS} shots in all); {}", local("threshold"))); } + if body.get("rounds").and_then(Value::as_u64).is_some_and(|r| r > QEC_MAX_ROUNDS) { + return far(format!("more than {QEC_MAX_ROUNDS} rounds; {}", local("threshold"))); + } let wide = body.get("distances").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).any(|d| d > QEC_MAX_CIRCUIT_DISTANCE); if wide && body.get("family").and_then(Value::as_str).unwrap_or("surface") == "surface" { return far(format!("a circuit-level threshold past distance {QEC_MAX_CIRCUIT_DISTANCE}; {}", local("threshold"))); @@ -312,19 +357,58 @@ pub fn variational_bounds(op: &str, body: &Value) -> Result<(), (u16, String)> { Ok(()) } -/// `work` for `who` within the bounds above (counted, one of [`AT_ONCE`], -/// off the request's thread): its answer, or 422 with its refusal. -pub async fn bounded(app: &App, who: &str, work: F) -> Result -where - F: FnOnce() -> Result + Send + 'static, -{ - 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(work).await.map_err(|e| (500, format!("the check failed: {e}")))?; - drop(turn); - v.map_err(|e| (422, e)) +/// The engine call `kind` (`estimate`, `estimate_verify`, `qec`, `pulse`, +/// `variational`) on `body`, held to its bounds first, within one budget of +/// reading: its answer, or the refusal. What a reading child runs; `key` is +/// the receipt key's secret in hex, for an estimate, which seals as it goes. +pub fn call(kind: &str, op: &str, body: &Value, key: Option<&str>) -> Result { + let engine = |r: Result| r.map_err(|e| (422, e)); + match kind { + "estimate" => { + crate::http::within_budget(|| estimate_bounds(body))?; + let hex = key.ok_or((500, "an estimate is sealed, and no key was given".to_string()))?; + let bytes: Vec = (0..hex.len()).step_by(2).filter_map(|i| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect(); + let secret: [u8; 32] = bytes.try_into().map_err(|_| (500, "the receipt key did not read".to_string()))?; + engine(wai_quantum::engine::estimate::sealed(body, &wai_quantum::ed25519_dalek::SigningKey::from_bytes(&secret))) + } + "estimate_verify" => { + crate::http::within_budget(|| estimate_bounds(&body["request"]))?; + engine(wai_quantum::engine::estimate::verify(body)) + } + "qec" => { + let op = ["code", "decode", "threshold"].into_iter().find(|o| *o == op).ok_or((400, format!("no error-correction call {op}")))?; + qec_bounds(op, body)?; + // Its shots are measured as they run, and refused as soon as the + // rest would pass the time: a shot's cost is the noise's as much + // as the counts'. + let secs = crate::jobs::Config::from_env().qec_secs; + engine(wai_quantum::engine::qec::within_time(std::time::Duration::from_secs(secs), || wai_quantum::engine::qec::call(op, body))) + } + "pulse" => { + let op = ["schedule", "check"].into_iter().find(|o| *o == op).ok_or((400, format!("no pulse call {op}")))?; + crate::http::within_budget(|| pulse_bounds(op, body))?; + engine(wai_quantum::engine::pulse::call(op, body)) + } + "variational" => { + let op = ["gradient", "optimize", "vqe", "qaoa", "build"].into_iter().find(|o| *o == op).ok_or((400, format!("no variational call {op}")))?; + crate::http::within_budget(|| variational_bounds(op, body))?; + engine(wai_quantum::engine::variational::call(op, body)) + } + other => Err((400, format!("no engine call {other}"))), + } +} + +/// The engine call `read` for `who` within the bounds above: refused past its +/// window, then, one of [`crate::offload::CHECKS_AT_ONCE`] in its turn, +/// counted, held to its bounds and run in a reading child whose memory and +/// time are capped ([`crate::offload`]): its answer, or the refusal. Told to +/// try again (`503`), it has read nothing, and is not counted. +pub async fn bounded(app: &App, who: &str, read: crate::offload::Read) -> Result { + let refused = |crate::jobs::Refused(code, why)| (code, why); + app.service.window_open(who).map_err(refused)?; + let turn = app.service.gate.turn(crate::offload::Pool::Checks, who).await?; + app.service.count(who).map_err(refused)?; + crate::offload::read(&app.service.config, turn, read).await } /// Whether the service takes `check` on `body`: the reason it does not, with @@ -392,10 +476,22 @@ pub fn convert_bounds(body: &Value) -> Result<(), (u16, String)> { Ok(()) } +/// Whether a job states at most [`MAX_INTENTS`] intents, read without +/// reading any of them: the reason it does not (400, `J020`), or nothing. +pub fn intent_count(request: &Value) -> Result<(), (u16, String)> { + match request.get("intent") { + Some(Value::Array(list)) if list.len() > MAX_INTENTS => Err((400, tag(refusal::BAD_INTENT, format!("{} intents; a job here states at most {MAX_INTENTS}", list.len())))), + _ => Ok(()), + } +} + /// Whether the service checks a job's intents beside its run: each /// reference within the equivalence check's bound, or the reason it does -/// not (422, with the command that runs it locally). +/// not (422, with the command that runs it locally). It reads the program +/// and each reference: within a budget of reading ([`crate::http::plan_job`]), +/// each is read once however often its intents name it. pub fn intent_bounds(request: &Value) -> Result<(), (u16, String)> { + intent_count(request)?; let Some(i) = request.get("intent").filter(|i| !i.is_null()) else { return Ok(()) }; let intents = wai_quantum::engine::verdict::intents(i).map_err(|e| (400, e))?; let qasm = request.get("qasm").and_then(Value::as_str).unwrap_or(""); @@ -439,15 +535,10 @@ pub fn answer(check: Check, body: &Value) -> Value { /// `check` on `body` for `who`, within the bounds above: the answer, or the /// status and reason it was refused. +/// It is held to its bounds and answered in a reading child, within one +/// budget ([`bounded`]). 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, tag(refusal::BUSY, 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) + bounded(app, who, crate::offload::Read::Check { check: check.name().into(), body }).await } #[cfg(test)] @@ -455,6 +546,48 @@ mod tests { use super::*; use serde_json::json; + /// A check that has run its budget out has no answer, whatever its + /// reading made of that: a program it could not read is not one of no + /// work. Each bound that reads refuses, under a budget too small for it, + /// where each used to read the refusal as nothing to do. + #[test] + fn a_budget_spent_is_a_refusal_whatever_the_check_made_of_it() { + let wide = |k: u32| format!("OPENQASM 3.0;\nqubit[12] q;\nint a = 0;\nfor int i in [0:99999] {{ a += 1; }}\nrz(0.1) q[0];\n// {k}\n"); + for (what, read) in [ + ("equivalent", Box::new(|| bounds(Check::Equivalent, &serde_json::json!({ "a": wide(0), "b": wide(1) }))) as Box Result<(), (u16, String)>>), + ("intent", Box::new(|| intent_bounds(&serde_json::json!({ "qasm": wide(2), "intent": { "equivalent": wide(3) } })))), + ("improve", Box::new(|| bounds(Check::Improve, &serde_json::json!({ "qasm": wide(4) })))), + ("gradient", Box::new(|| variational_bounds("gradient", &serde_json::json!({ "qasm": wide(5) })))), + ("convert", Box::new(|| convert_bounds(&serde_json::json!({ "program": wide(6), "from": "qasm", "to": "qasm" })))), + ] { + let e = wai_quantum::qasm::within_steps(1_000, || crate::http::within_budget(read)).unwrap_err(); + assert!(e.0 == 422 && e.1.ends_with("[J031]") && e.1.contains("steps"), "{what}: {e:?}"); + } + // An answer made under it is held the same way. + let e = wai_quantum::qasm::within_steps(1_000, || crate::http::within_budget(|| Ok(answer(Check::Critique, &serde_json::json!({ "qasm": wide(7) }))))).unwrap_err(); + assert!(e.0 == 422 && e.1.ends_with("[J031]"), "{e:?}"); + } + + /// Rounds are held, in a decode's memory experiment and in a threshold. + #[test] + fn a_memory_experiment_is_held_to_its_rounds() { + for (op, body) in [("decode", serde_json::json!({ "memory": { "distance": 7, "rounds": 50 }, "shots": 10 })), ("threshold", serde_json::json!({ "distances": [3], "rounds": 50 }))] { + let e = qec_bounds(op, &body).unwrap_err(); + assert!(e.0 == 422 && e.1.contains("rounds") && e.1.ends_with("[J031]"), "{op}: {e:?}"); + } + assert!(qec_bounds("decode", &serde_json::json!({ "memory": { "distance": 7, "rounds": QEC_MAX_ROUNDS }, "shots": 10 })).is_ok()); + } + + /// Matching grows faster than the square of the detectors fired: a decode + /// is held to how many it is given, and told so with the local command. + #[test] + fn a_decode_is_held_to_the_detectors_it_is_given() { + let body = |n: usize| serde_json::json!({ "model": "error(0.1) D0 D1\n", "detectors": vec![0; n] }); + assert!(qec_bounds("decode", &body(QEC_MAX_FIRED)).is_ok()); + let e = qec_bounds("decode", &body(QEC_MAX_FIRED + 1)).unwrap_err(); + assert!(e.0 == 422 && e.1.contains("wai-quantum qec decode") && e.1.ends_with("[J031]"), "{e:?}"); + } + #[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];" }); diff --git a/wai/wai-quantum-service/src/discovery.rs b/wai/wai-quantum-service/src/discovery.rs index e51f1dc9b..77081f1fd 100644 --- a/wai/wai-quantum-service/src/discovery.rs +++ b/wai/wai-quantum-service/src/discovery.rs @@ -104,7 +104,7 @@ pub fn examples() -> Value { } /// The API as an OpenAPI 3.1 document. -pub fn openapi(server: &str) -> Value { +pub fn openapi(server: &str, result_bytes: u64) -> 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"] }, @@ -139,7 +139,7 @@ pub fn openapi(server: &str) -> Value { "intent": { "description": "What the program must do, checked with the run: one intent or a list, each {\"distribution\": {\"00\": 0.5, \"11\": 0.5}, \"tolerance\"}, {\"equivalent\": \"\"} or {\"prepares\": \"\"}, with an optional note and \"require\": \"proof\". The result's `verdict` states the level the engine checked to (proof, evidence, executed, parsed, refused) and whether the intents hold, and the receipt seals it." }, "verdict": { "type": "boolean", "description": "true: the result carries the verdict with no intent (how far the engine checked)." }, "repair": { "description": "true, or the choices {\"max_iterations\", \"dyadic_bits\", \"sizes\"}: the program is repaired first on the lines its diagnostics flag (as POST /v1/repair does), and the result's `repaired` holds the program that ran and each repair." }, - "programs": { "type": "array", "minItems": 1, "maxItems": wai_quantum::engine::sets::MAX_ITEMS, "items": { "oneOf": [{ "type": "string" }, { "type": "object", "required": ["qasm"] }] }, "description": "A set: in place of qasm, the programs to run as one job, each a program's text or a job whose own fields win over the set's. Every other field applies to each item. The result is a set (SetResult)." }, + "programs": { "type": "array", "minItems": 1, "maxItems": wai_quantum::engine::sets::MAX_ITEMS, "items": { "oneOf": [{ "type": "string" }, { "type": "object", "required": ["qasm"] }] }, "description": format!("A set: in place of qasm, the programs to run as one job, each a program's text or a job whose own fields win over the set's. Every other field applies to each item. The result is a set (SetResult). A job or set whose result could take more than {result_bytes} bytes as served (result_bytes), by its bound before it runs (each item's counts at most its shots or a trajectories run's trajectories, the outcomes its measured bits can give and 65,536 outcomes; a verdict's critique by the remarks its gates can draw; its other parts at their length as read, every number as RFC 8785 writes it), is refused at once (422, J031), naming its largest part: at about 22 bytes a 16-bit outcome, 100 items of 65,536 outcomes each is about 150 MB.") }, "sweep": { "type": "object", "additionalProperties": { "type": "array", "minItems": 1, "items": { "type": "number" } }, "description": "A set over qasm's inputs: each input's values, every combination run (the names in sorted order, the first varying slowest). An integer input (`input uint n;`) takes whole numbers and the program is written out at each; a float or angle input (`input float theta;`) is bound to the double given. Every input the program declares is given values." }, "dry_run": { "type": "boolean", "description": "true: run nothing, and answer 200 with the plan (DryRun): the method, memory, estimated time and energy with their basis, the bounds, and whether the service would take it now." }, "pulse": wai_quantum::engine::pulse::options_schema(), @@ -152,7 +152,7 @@ pub fn openapi(server: &str) -> Value { "properties": { "dry_run": { "const": true }, "ran": { "const": false }, - "accepted": { "type": "boolean", "description": "Whether the service would take it now, by every limit." }, + "accepted": { "type": "boolean", "description": "Whether the service would take it now, by every limit, its reading held to what the submission's would take; the MCP tools plan it as their runs run it, its verdict asked." }, "status": { "type": "integer", "description": "With accepted false: the HTTP status the submission would get." }, "refusal": { "type": "object", "description": "With accepted false: the refusal as data (message, code, codes, outcome, meaning)." }, "method": { "type": "string" }, "why": { "type": "string" }, "qubits": { "type": "integer" }, @@ -161,7 +161,8 @@ pub fn openapi(server: &str) -> Value { "time": { "type": "object", "description": "{\"seconds\", \"basis\"}: an estimate from this service's own timings of the method, or the planner's nominal rate; or {\"seconds\": null, \"why\", \"at_most\"} where the work does not predict the time." }, "energy": { "type": "object", "description": "{\"joules_micro_estimate\", \"energy_provenance\": \"Estimator\", \"basis\"} from the power of past metered jobs of the method here, or {\"unestimated\": why}. Never a reading." }, "within_time_limit": { "type": ["boolean", "null"] }, - "bounds": { "type": "object", "description": "The limits that apply: job_memory_bytes, job_seconds, active_jobs_per_client, memory_per_client_bytes, submissions_per_window, window_seconds, queue, set_items, body_bytes." }, + "bounds": { "type": "object", "description": "The limits that apply: job_memory_bytes, job_seconds, active_jobs_per_client, memory_per_client_bytes, submissions_per_window, window_seconds, queue, set_items, result_bytes (the most a job's or a set's result may take as served, by its bound before it runs), intents (the most a job states), read_steps (the steps all of a request's programs may take to read together), body_bytes." }, + "result_bytes": { "type": ["integer", "object"], "description": "The most its result takes as served, bytes, bounded before it runs; past bounds.result_bytes the submission is refused (422, J031). {\"bytes\": null, \"why\"} for a method that gives no bound before it runs (no built-in), counted at bounds.result_bytes. A pulse run is bounded from its schedule." }, "client": { "type": "object", "description": "Where the client stands: active_jobs, memory_held_bytes, submissions_left, window_resets_in_seconds." }, "items": { "type": "array", "description": "A set's items, each with its index, point, method, memory, work, time and energy." } } @@ -248,10 +249,11 @@ pub fn openapi(server: &str) -> Value { "200": { "description": "The finished job, or with dry_run the plan (DryRun)", "headers": { "RateLimit": { "$ref": "#/components/headers/RateLimit" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" } }, "content": { "application/json": { "schema": { "oneOf": [{ "$ref": "#/components/schemas/Job" }, { "$ref": "#/components/schemas/DryRun" }] } } } }, "202": { "description": "Still running: poll GET /v1/jobs/{id}", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } }, "400": { "description": "A program, job or Idempotency-Key that does not read", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, - "422": { "description": "Larger than one job may be, or an Idempotency-Key sent before with another request (J034)" }, + "422": { "description": "Larger than one job may be (J031: its plan, its result's bound past result_bytes, or its reading past read_steps, the reading child's memory or its time), or an Idempotency-Key sent before with another request (J034)" }, "401": { "description": "A private deployment's key missing or not one it holds (J033)" }, "429": { "description": "Past the per-client limits (with Retry-After)" }, - "503": { "description": "The queue is full" } + "503": { "description": "The queue is full, or no turn to read it came (J032): nothing was read, and nothing counted" }, + "500": { "description": "Its reading failed inside the service: nothing was admitted, and the service runs on" } } } }, "/v1/jobs": { "post": { @@ -262,18 +264,19 @@ pub fn openapi(server: &str) -> Value { "202": { "description": "Accepted, and kept in the service's store before this answer; sent again with its Idempotency-Key, the key's job", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } }, "200": { "description": "With dry_run: the plan, nothing run", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DryRun" } } } }, "400": { "description": "A program, job or Idempotency-Key that does not read", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, - "422": { "description": "Larger than one job may be, or an Idempotency-Key sent before with another request (J034)" }, + "422": { "description": "Larger than one job may be (J031: its plan, its result's bound past result_bytes, or its reading past read_steps, the reading child's memory or its time), or an Idempotency-Key sent before with another request (J034)" }, "401": { "description": "A private deployment's key missing or not one it holds (J033)" }, "429": { "description": "Past the per-client limits (with Retry-After)" }, - "503": { "description": "The queue is full" } + "503": { "description": "The queue is full, or no turn to read it came (J032): nothing was read, and nothing counted" }, + "500": { "description": "Its reading failed inside the service: nothing was admitted, and the service runs on" } } } }, "/v1/jobs/{id}": { "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], "get": { "summary": "A job: its status, and its result when done. Kept across the service's restarts for its retention", - "parameters": [{ "name": "wait", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 25 }, "description": "Hold the answer until the job's status changes, or this many seconds pass." }], - "responses": { "200": { "description": "The job", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } }, "404": { "description": "No such job" } } + "parameters": [{ "name": "wait", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 25 }, "description": "Hold the answer until the job's status changes, or this many seconds pass. A client holds at most WAI_QSVC_CLIENT_STREAMS (4) long polls and event streams at once: past them a poll of a job still queued or running is not held, and is answered 429 (503 past everyone's) with Retry-After; wait that long, or poll without wait." }], + "responses": { "200": { "description": "The job", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } }, "404": { "description": "No such job" }, "429": { "description": "A long poll past its client's holds, not held (J032): {\"error\", \"code\", \"id\", \"status\"}, with Retry-After" }, "503": { "description": "A long poll past everyone's holds, not held (J032): {\"error\", \"code\", \"id\", \"status\"}, with Retry-After" } } }, "delete": { "summary": "Cancel a queued or running job", "responses": { "200": { "description": "Cancelled" }, "409": { "description": "Already finished" } } } }, @@ -284,32 +287,32 @@ pub fn openapi(server: &str) -> Value { "/v1/verify": { "post": { "summary": "Whether a result's receipt seals that request and that result and every rule of its form holds ({\"valid\", \"reasons\", \"key\", \"form\"}); an estimate is also re-run, and each of its resource receipts checked ({\"valid\", \"signed\", \"receipts\", \"reproduced\"}); a set's receipt is checked over its head, every item as its request gives it, each item's own receipt, the root and each path ({\"valid\", \"signed\", \"derived\", \"items_sealed\", \"root\"})", "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, \"reasons\": [string]}" }, "400": { "description": "The body is not JSON, not a job, or has no receipt" } } + "responses": { "200": { "description": "{\"valid\": bool, \"reasons\": [string]}" }, "400": { "description": "The body is not JSON, not a job, or has no receipt" }, "422": { "description": "A set whose sweep takes more to write out again than a request may read (J031)" }, "429": { "description": "A set, counted as a check is, past the per-client limits (with Retry-After)" }, "503": { "description": "A set, with every check running or your share of them (J032)" } } } }, "/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" } } + "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, or your share of them (J032): nothing was read, and nothing counted" }, "422": { "description": "Larger than the service takes (J031): its bounds, the budget of reading, the reading child's memory or time, or an answer past what the service takes back" }, "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } } } }, "/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" } } + "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, or your share of them (J032): nothing was read, and nothing counted" }, "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } } } }, "/v1/convert": { "post": { "summary": "A program carried between formats (OpenQASM 3, QIR as LLVM text, jeff as base64), the output read back and held to it by the equivalence ladder", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["to"], "properties": { "program": { "type": "string", "description": "The program's text: OpenQASM or QIR." }, "program_base64": { "type": "string", "description": "The program's bytes as base64: jeff." }, "from": { "enum": ["qasm", "qir", "jeff"] }, "to": { "enum": ["qasm", "qir", "jeff"] }, "profile": { "enum": ["base", "adaptive"] }, "check": { "type": "boolean" } } } } } }, - "responses": { "200": { "description": "{\"ok\": true, \"format\", \"from\", \"to\", \"program\" | \"program_base64\", \"qasm\", \"notes\", \"read\", \"written\", \"check\"}, or the refusal {\"error\", \"code\", \"codes\", \"outcome\", \"meaning\"} with the reader's code (Q700 to Q721) and place" }, "422": { "description": "A program or a check larger than the service takes" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running" } } + "responses": { "200": { "description": "{\"ok\": true, \"format\", \"from\", \"to\", \"program\" | \"program_base64\", \"qasm\", \"notes\", \"read\", \"written\", \"check\"}, or the refusal {\"error\", \"code\", \"codes\", \"outcome\", \"meaning\"} with the reader's code (Q700 to Q721) and place" }, "422": { "description": "A program or a check larger than the service takes" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running, or your share of them (J032): nothing was read, and nothing counted" }, "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } } } }, "/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" } } + "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" }, "503": { "description": "Every check is running, or your share of them (J032): nothing was read, and nothing counted" }, "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } } } }, "/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" } } + "responses": { "200": { "description": "{\"routes\", \"cheapest\", \"summary\", \"note\"}" }, "422": { "description": "A thorough search, which runs locally" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running, or your share of them (J032): nothing was read, and nothing counted" }, "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } } } }, "/v1/compile": { "post": { "summary": "The program compiled through checked passes, with a certificate of each pass and the distance, sealed with the service's key", @@ -319,7 +322,8 @@ pub fn openapi(server: &str) -> Value { "400": { "description": "A program or options that do not read" }, "422": { "description": "A compile the service does not take (too many gates or rotations, or ε too fine for them: the reason names the local command), or one the engine refuses" }, "429": { "description": "Past the per-client limits" }, - "503": { "description": "Every check is running" } + "503": { "description": "Every check is running, or your share of them (J032): nothing was read, and nothing counted" }, + "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } } } }, "/v1/estimate": { "post": { @@ -330,13 +334,14 @@ pub fn openapi(server: &str) -> Value { "400": { "description": "A request, program or document that does not read" }, "422": { "description": "An estimate the service does not take (too many gates or distinct rotation angles, or a budget too fine for its rotations: the reason names the local command), or one the engine refuses (its code, QE02 to QE08)" }, "429": { "description": "Past the per-client limits" }, - "503": { "description": "Every check is running" } + "503": { "description": "Every check is running, or your share of them (J032): nothing was read, and nothing counted" }, + "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } } } }, "/v1/repair": { "post": { "summary": "The program repaired on the lines its diagnostics flag alone: the mechanical fixes, a proposed repair held to those lines, and what needs a person", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["qasm"], "properties": { "qasm": { "type": "string" }, "proposed": { "type": "string", "description": "A repaired version: taken only if it changes no line the diagnostics do not flag." }, "max_iterations": { "type": "integer", "minimum": 1 }, "dyadic_bits": { "type": "integer", "minimum": 1, "maximum": 32 }, "sizes": { "type": "object", "additionalProperties": { "type": "integer" } }, "diagnostics": { "type": "array" }, "rules": { "type": "boolean" } } } } } }, - "responses": { "200": { "description": "{\"ok\", \"changed\", \"qasm\", \"repairs\": [{\"code\", \"rule\", \"by\", \"flagged\", \"edits\"}], \"needs_person\", \"verdict\"}, or {\"ok\": false, \"error\", \"refusal\"} for a proposal rejected (J021)" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running" } } + "responses": { "200": { "description": "{\"ok\", \"changed\", \"qasm\", \"repairs\": [{\"code\", \"rule\", \"by\", \"flagged\", \"edits\"}], \"needs_person\", \"verdict\"}, or {\"ok\": false, \"error\", \"refusal\"} for a proposal rejected (J021)" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running, or your share of them (J032): nothing was read, and nothing counted" }, "422": { "description": "Larger than the service takes (J031): its bounds, the budget of reading, the reading child's memory or time, or an answer past what the service takes back" }, "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } } } }, "/mcp": { "post": { "summary": "The Model Context Protocol endpoint (streamable HTTP, JSON responses): tools to run circuits and sets, plan a job without running it, read jobs, verify receipts, list examples, and critique, compare, improve, price, compile, estimate and repair programs", "responses": { "200": { "description": "A JSON-RPC response" } } } } } @@ -344,12 +349,31 @@ pub fn openapi(server: &str) -> Value { for (path, item) in qec_paths().into_iter().chain(pulse_paths()).chain(variational_paths()) { doc["paths"][path] = item; } + // What every route can answer before its handler runs: a body that did + // not arrive in time, one past the size a request may be, and a client + // with as many requests open as it may have, or a service with as many + // connections as it holds. + let before = [ + ("408", "The body did not arrive within WAI_QSVC_BODY_SECS (30 s)"), + ("413", "The body is larger than the 1 MiB a request may be"), + ("503", "The client has WAI_QSVC_CLIENT_REQUESTS (32) requests open at once, or the service holds as many connections as it may (J032); Retry-After says when to try again"), + ]; + if let Some(paths) = doc["paths"].as_object_mut() { + for item in paths.values_mut() { + for op in item.as_object_mut().into_iter().flat_map(|o| o.values_mut()) { + let Some(responses) = op.get_mut("responses").and_then(Value::as_object_mut) else { continue }; + for (code, why) in before { + responses.entry(code).or_insert_with(|| json!({ "description": why, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } })); + } + } + } + } doc } /// The pulse calls' paths. fn pulse_paths() -> Vec<(&'static str, Value)> { - let responses = |ok: &str| json!({ "200": { "description": ok }, "400": { "description": "A body that does not read" }, "422": { "description": "A program the pulse reader refuses (its Q code), a request the engine refuses (J040, J041), or a check larger than the service takes (J031, naming the local command)" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running" } }); + let responses = |ok: &str| json!({ "200": { "description": ok }, "400": { "description": "A body that does not read" }, "422": { "description": "A program the pulse reader refuses (its Q code), a request the engine refuses (J040, J041), or a check larger than the service takes (J031, naming the local command)" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running, or your share of them (J032): nothing was read, and nothing counted" }, "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } }); vec![ ("/v1/pulse/schedule", json!({ "post": { "summary": "A pulse program's schedule on frames: every play, delay, phase and frequency change, capture and barrier at its sample, each waveform's samples, the gate calls, and the digests a pulse run's receipt binds; sealed with the service's key", @@ -367,7 +391,7 @@ fn pulse_paths() -> Vec<(&'static str, Value)> { /// The variational and algorithm library's paths, apart, so the document's /// macro stays within its depth. fn variational_paths() -> Vec<(&'static str, Value)> { - let responses = |ok: &str| json!({ "200": { "description": ok }, "400": { "description": "A body that does not read" }, "422": { "description": "A request the engine refuses (J050 params, J051 a gradient, J052 an optimisation, J053 a builder, Q129 a parameter it cannot follow) or larger than the service takes (J031, naming the local command)" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running" } }); + let responses = |ok: &str| json!({ "200": { "description": ok }, "400": { "description": "A body that does not read" }, "422": { "description": "A request the engine refuses (J050 params, J051 a gradient, J052 an optimisation, J053 a builder, Q129 a parameter it cannot follow) or larger than the service takes (J031, naming the local command)" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running, or your share of them (J032): nothing was read, and nothing counted" }, "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } }); let body = || json!({ "required": true, "content": { "application/json": { "schema": { "type": "object" } } } }); vec![ ("/v1/gradient", json!({ "post": { @@ -401,7 +425,7 @@ fn variational_paths() -> Vec<(&'static str, Value)> { /// The error-correction calls' paths, apart, so the document's macro stays /// within its depth. fn qec_paths() -> Vec<(&'static str, Value)> { - let responses = |ok: &str| json!({ "200": { "description": ok }, "400": { "description": "A body that does not read" }, "422": { "description": "A request the engine refuses (J009) or larger than the service takes (J031, naming the local command)" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running" } }); + let responses = |ok: &str| json!({ "200": { "description": ok }, "400": { "description": "A body that does not read" }, "422": { "description": "A request the engine refuses (J009) or larger than the service takes (J031, naming the local command)" }, "429": { "description": "Past the per-client limits" }, "503": { "description": "Every check is running, or your share of them (J032): nothing was read, and nothing counted" }, "500": { "description": "Its reading failed inside the service: nothing was answered, and the service runs on" } }); vec![ ("/v1/qec/code", json!({ "post": { "summary": "An error-correcting code (surface:D, toric:L, steane, color:D, bb:72 to bb:288, gross): its checks, stabilizers, k, layout, the decoders it takes, and its distance certificate (a witness and every lighter support ruled out); with a certificate, its check; sealed with the service's key", @@ -494,12 +518,21 @@ mod tests { #[test] fn the_openapi_document_names_every_route() { - let d = openapi("https://example.org/q"); + let d = openapi("https://example.org/q", 64 << 20); for p in ["/v1", "/v1/methods", "/v1/examples", "/v1/targets", "/v1/run", "/v1/jobs", "/v1/jobs/{id}", "/v1/jobs/{id}/events", "/.well-known/wai-quantum-receipt-keys", "/v1/verify", "/v1/compile", "/v1/estimate", "/v1/convert", "/v1/qec/code", "/v1/qec/decode", "/v1/qec/threshold", "/v1/gradient", "/v1/optimize", "/v1/vqe", "/v1/qaoa", "/v1/build", "/v1/pulse/schedule", "/v1/pulse/check", "/mcp"] { assert!(d["paths"].get(p).is_some(), "{p}"); } assert_eq!(d["openapi"], "3.1.0"); + // What any route answers before its handler: a body late, too large, + // or a client or the service with as many open as it may have. + for (path, item) in d["paths"].as_object().unwrap() { + for (method, op) in item.as_object().unwrap().iter().filter(|(m, _)| ["get", "post", "delete"].contains(&m.as_str())) { + for code in ["408", "413", "503"] { + assert!(op["responses"].get(code).is_some(), "{method} {path} lists no {code}"); + } + } + } } } diff --git a/wai/wai-quantum-service/src/http.rs b/wai/wai-quantum-service/src/http.rs index d69720d8b..97334ec7f 100644 --- a/wai/wai-quantum-service/src/http.rs +++ b/wai/wai-quantum-service/src/http.rs @@ -96,7 +96,8 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use serde_json::{Value, json}; use std::net::{IpAddr, SocketAddr}; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use std::time::Duration; use wai_quantum::engine::refusal::{self, tag}; use wai_quantum::engine::sets; @@ -141,6 +142,64 @@ fn reply(status: u16, body: Value) -> Response { r } +/// `v` as JSON text, its keys in their order and each fractional number as +/// RFC 8785 writes it (an integer exactly, as read): a job read back from its +/// report, which is RFC 8785, is written as the same bytes. +pub fn canonical_text(v: &Value) -> String { + struct Es; + impl serde_json::ser::Formatter for Es { + fn write_f64(&mut self, w: &mut W, x: f64) -> std::io::Result<()> { + w.write_all(wai_quantum::engine::receipt::es_number(x).as_bytes()) + } + fn write_f32(&mut self, w: &mut W, x: f32) -> std::io::Result<()> { + w.write_all(wai_quantum::engine::receipt::es_number(f64::from(x)).as_bytes()) + } + } + let mut out = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut out, Es); + match serde::Serialize::serialize(v, &mut ser) { + Ok(()) => String::from_utf8(out).unwrap_or_else(|_| v.to_string()), + Err(_) => v.to_string(), + } +} + +/// The text of a JSON-RPC message's `id` as it was sent, where it has one: +/// `1.0`, `1e3` and an integer past 64 bits as written, which a number read +/// and written again would not keep. +fn raw_id(body: &[u8]) -> Option { + #[derive(serde::Deserialize)] + struct Message<'a> { + #[serde(borrow)] + id: Option<&'a serde_json::value::RawValue>, + } + let m: Message<'_> = serde_json::from_slice(body).ok()?; + m.id.map(|r| r.get().to_string()) +} + +/// An MCP answer as text ([`canonical_text`]), its `id` the request's exactly +/// as it was sent (`raw`, [`raw_id`]): each JSON-RPC id is echoed as its +/// text, whatever number it is. The id is written in its place through a +/// mark no answer can hold (random, made for this answer). +fn mcp_text(mut answer: Value, raw: Option<&str>) -> String { + let Some(raw) = raw.filter(|_| answer.get("id").is_some_and(|i| !i.is_null())) else { + return canonical_text(&answer); + }; + let mut b = [0u8; 16]; + if getrandom::fill(&mut b).is_err() { + return canonical_text(&answer); + } + let mark = format!("wai-mcp-id-{}", b.iter().map(|x| format!("{x:02x}")).collect::()); + answer["id"] = json!(mark); + canonical_text(&answer).replacen(&format!("\"{mark}\""), raw, 1) +} + +fn reply_text(status: u16, body: String) -> Response { + let mut r = (StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), [("content-type", "application/json")], body).into_response(); + r.headers_mut().insert("access-control-allow-origin", HeaderValue::from_static("*")); + r.headers_mut().insert("access-control-expose-headers", HeaderValue::from_static("ratelimit, ratelimit-policy, retry-after")); + r +} + /// A refusal: its reason, and the codes it ends with (`engine::refusal`). fn error(status: u16, why: &str) -> Response { let r = wai_quantum::engine::refusal::refusal_json(why); @@ -213,6 +272,12 @@ async fn describe(State(app): State) -> Response { "queue": c.max_queue, "result_retention_seconds": c.retain.as_secs(), "set_items": sets::MAX_ITEMS, + // A job or set whose result could take more, by its bound + // before it runs, is refused at once (422, J031). + "result_bytes": c.result_mib << 20, + "unread_results_per_client_bytes": c.client_unread_mib << 20, + "intents": crate::analysis::MAX_INTENTS, + "read_steps": READ_STEPS, }, "load": { "queued": queued, "running": running, "memory_held_bytes": held << 20 }, "uptime_seconds": app.service.started.elapsed().map(|d| d.as_secs()).unwrap_or(0), @@ -236,8 +301,7 @@ async fn describe(State(app): State) -> Response { } /// The bounds that apply to a job here, for a dry run and the description. -pub(crate) fn bounds(app: &App) -> Value { - let c = &app.service.config; +fn bounds(c: &crate::jobs::Config) -> Value { json!({ "job_memory_bytes": c.job_mib << 20, "job_seconds": c.job_timeout.as_secs(), @@ -247,6 +311,9 @@ pub(crate) fn bounds(app: &App) -> Value { "window_seconds": c.per_client_window.as_secs(), "queue": c.max_queue, "set_items": sets::MAX_ITEMS, + "result_bytes": c.result_mib << 20, + "intents": crate::analysis::MAX_INTENTS, + "read_steps": READ_STEPS, "body_bytes": MAX_BODY, }) } @@ -260,7 +327,8 @@ async fn health() -> Response { } /// The checks one job's request passes before it is planned: its numbers, -/// its compile and its intents within the service's bounds. +/// its compile and its intents within the service's bounds. They read its +/// programs, so they run within the request's budget of reading ([`plan_job`]). fn job_bounds(request: &Value) -> Result<(), (u16, String)> { if let Some(at) = crate::receipt::inexact_number(request) { return Err((400, format!("`{at}`: a whole number here must be within ±9007199254740991 (2^53 − 1), so that every JSON reader reads it alike and the receipt binds it"))); @@ -271,37 +339,187 @@ fn job_bounds(request: &Value) -> Result<(), (u16, String)> { crate::analysis::intent_bounds(request) } -/// `request` planned: one job's plan, or a set's plan with its items' (each -/// held to a job's bounds), or the refusal's status and reason. -pub(crate) fn plan_job(app: &App, request: &Value) -> Result<(catalog::Plan, Work), (u16, String)> { - let cap = app.service.config.job_mib << 20; +/// The checks that read nothing, made before anything is: every whole +/// number exact, at most [`crate::analysis::MAX_INTENTS`] intents (a set's +/// items each too), at most [`sets::MAX_ITEMS`] items in a set. +pub(crate) fn cheap_bounds(request: &Value) -> Result<(), (u16, String)> { + if let Some(at) = crate::receipt::inexact_number(request) { + return Err((400, format!("`{at}`: a whole number here must be within ±9007199254740991 (2^53 − 1), so that every JSON reader reads it alike and the receipt binds it"))); + } if catalog::is_set(request) { // The set's own request is sealed too: its sweep's values and every field. wai_quantum::engine::job::exact_request(request).map_err(|e| (400, e))?; - let items = sets::expand(request).map_err(|e| (400, e))?; - for (i, it) in items.iter().enumerate() { - job_bounds(&it.request).map_err(|(c, e)| (c, format!("item {i}: {e}")))?; - } - let plans = sets::plan(&items, cap).map_err(|e| (400, e))?; - return Ok((sets::summary(&plans), Work::Set { items, plans })); } - job_bounds(request)?; - Ok((catalog::plan(request, cap).map_err(|e| (400, e))?, Work::One)) + crate::analysis::intent_count(request)?; + if let Some(list) = request.get("programs").and_then(Value::as_array) { + if list.len() > sets::MAX_ITEMS { + return Err((400, tag(refusal::BAD_SET, format!("{} programs; a set holds at most {}", list.len(), sets::MAX_ITEMS)))); + } + for (i, it) in list.iter().enumerate() { + crate::analysis::intent_count(it).map_err(|(c, e)| (c, format!("item {i}: {e}")))?; + } + } + if let Some(grid) = request.get("sweep").and_then(Value::as_object) { + let points = grid.values().try_fold(1usize, |n, v| n.checked_mul(v.as_array().map_or(1, Vec::len)).filter(|&n| n <= sets::MAX_ITEMS)); + if points.is_none() { + return Err((400, tag(refusal::BAD_SET, format!("the sweep has more points than a set holds ({})", sets::MAX_ITEMS)))); + } + } + Ok(()) +} + +/// The steps one request's programs may take to read, all together: each +/// statement the reader elaborates, each gate it writes, what is made or +/// copied by its size, and each pass of a loop a sweep is written out with +/// (`wai_quantum::qasm::within_steps`). As many as one program may take on +/// reading, its statements and its gates each to the reader's bound, so a +/// job whose own program reads within the reader's bounds reads here, and a +/// request whose references or items would read for longer is refused within +/// about that bound's time. Each program is read once, however often its +/// admission reads it. +pub const READ_STEPS: u64 = wai_quantum::qasm::MAX_STEPS + wai_quantum::qasm::MAX_OPS as u64; + +/// The refusal of a request whose programs read past [`READ_STEPS`]. +fn read_too_long() -> (u16, String) { + ( + 422, + tag( + refusal::TOO_LARGE_HERE, + format!("reading this request's programs takes more than {READ_STEPS} steps together (statements, gates and loop passes, each program read once), more than the service reads for one request; run it on your machine (`wai-quantum run program.qasm`, with `--intent reference.qasm` for an intent)"), + ), + ) +} + +/// `read` within one budget of [`READ_STEPS`], each program it reads read +/// once. A budget spent is [`read_too_long`], whatever `read` made of it: what +/// ran out reading tells nothing of the program, so it is no answer, and no +/// bound a check held the program to has been met. +pub(crate) fn within_budget(read: impl FnOnce() -> Result) -> Result { + let (r, spent) = wai_quantum::qasm::within_steps(READ_STEPS, || { + let r = read(); + (r, wai_quantum::qasm::steps_left() == Some(0)) + }); + if spent { Err(read_too_long()) } else { r } +} + +/// What a job's result takes as served beside what the engine bounds +/// ([`catalog::plan_bounded`]): its receipt and its energy figure. +const HOST_BYTES: u64 = 4 << 10; +/// What a set's item takes as the set serves it beside its result and its +/// request: its index, status, method, point and path to the root, and its +/// receipt and energy. +const ITEM_BYTES: u64 = 4 << 10; +/// A set's head as served: its format, counts, root, energy and receipt. +const SET_HEAD_BYTES: u64 = 4 << 10; + +/// `v`'s length as RFC 8785 text, written nowhere, each number in its own +/// form ([`wai_quantum::engine::receipt::canonical_len`]): what a set serves +/// of each item's request. +fn json_len(v: &Value) -> u64 { + wai_quantum::engine::receipt::canonical_len(v) +} + +/// `request` planned: one job's plan, or a set's plan with its items' (each +/// held to a job's bounds), and the most its result takes as served, bytes +/// (`None` where a method that reads its own program leaves it unbounded), +/// or the refusal's status and reason. The checks that read nothing come +/// first; then every program the request gives is read, once, within one +/// budget ([`READ_STEPS`]). A set's result serves each item's request, its +/// result and its own fields, and the set's head. +pub(crate) fn plan_job(cap: u64, request: &Value) -> Result<(catalog::Plan, Work, Option), (u16, String)> { + cheap_bounds(request)?; + within_budget(|| { + if catalog::is_set(request) { + let items = sets::expand(request).map_err(|e| (400, e))?; + for (i, it) in items.iter().enumerate() { + job_bounds(&it.request).map_err(|(c, e)| (c, format!("item {i}: {e}")))?; + } + let planned = sets::plan_bounded(&items, cap).map_err(|e| (400, e))?; + let head = catalog::Bound { rest: SET_HEAD_BYTES, ..Default::default() }; + let bound = planned.iter().zip(&items).try_fold(head, |all, ((p, result), it)| { + let item = catalog::Bound { requests: json_len(&it.request), rest: ITEM_BYTES + json_len(&json!(p.why)) + it.point.as_ref().map_or(0, json_len), ..Default::default() }; + Some(all.plus((*result)?).plus(item)) + }); + let plans: Vec = planned.into_iter().map(|(p, _)| p).collect(); + return Ok((sets::summary(&plans), Work::Set { items, plans }, bound)); + } + job_bounds(request)?; + let (plan, bound) = catalog::plan_bound(request, cap).map_err(|e| (400, e))?; + Ok((plan, Work::One, bound.map(|b| b.plus(catalog::Bound { rest: HOST_BYTES, ..Default::default() })))) + }) } /// Plans and admits `request` for `who`: the job's id, or the refusal's -/// status and reason. A request sent again with its key is the key's job, -/// whatever planning it now would give. -pub(crate) fn admit(app: &App, who: &str, request: Value, key: Option) -> Result { - let request = with_source(request)?; +/// status and reason ([`admit_to`]). +pub(crate) async fn admit(app: &App, who: &str, request: Value, key: Option) -> Result { + admit_to(&app.service, who, request, key.as_deref()).await +} + +/// A job's reading as its child gives it back ([`crate::offload::Read::Admit`]): +/// the request as read (from its source, where it has one), and its plan, +/// work and the most its result takes as served ([`plan_job`]), or the +/// refusal its planning gave. +type Admitted = (Value, Result<(catalog::Plan, Work, Option), (u16, String)>); + +fn admitted(mut v: Value) -> Result { + let bad = |e: serde_json::Error| (500, format!("the reading's answer did not read: {e}")); + // Each part taken, not copied: a set's items can be large. + let mut take = |k: &str| v.as_object_mut().and_then(|o| o.remove(k)).unwrap_or(Value::Null); + let request = take("request"); + if let Value::Array(r) = take("refused") { + return Ok((request, Err((r.first().and_then(Value::as_u64).unwrap_or(400) as u16, r.get(1).and_then(Value::as_str).unwrap_or("refused").to_string())))); + } + let plan: catalog::Plan = serde_json::from_value(take("plan")).map_err(bad)?; + let bound: Option = serde_json::from_value(take("result_parts")).map_err(bad)?; + let work = match take("items") { + Value::Null => Work::One, + items => Work::Set { items: crate::jobs::items_from(items), plans: serde_json::from_value(take("plans")).map_err(bad)? }, + }; + Ok((request, Ok((plan, work, bound)))) +} + +/// [`admit`], in the order of what each step costs: +/// +/// 1. a request sent again with its key is the key's job, as it was received +/// or as it read, whatever planning it now would give: it reads nothing, +/// takes no turn and counts as nothing ([`Service::named`]); +/// 2. what reads nothing and refuses whatever is sent (the client's jobs at +/// once, a full queue, a spent window) refuses uncounted, so a client told +/// to wait spends nothing waiting; +/// 3. it waits for a turn (`503` if none comes: nothing read, nothing spent); +/// 4. with its turn the submission is counted against the client's window, so +/// every request refused after this counts; +/// 5. the checks that read nothing ([`cheap_bounds`]); +/// 6. its programs read, each once, within one budget ([`plan_job`]), in a +/// child whose memory and time are capped ([`crate::offload`]); +/// 7. the job kept and queued, within the client's and the queue's limits. +pub(crate) async fn admit_to(service: &Service, who: &str, request: Value, key: Option<&str>) -> Result { let refused = |Refused(code, why)| (code, why); + let key = key.map(Key); if let Some(k) = key - && let Some(id) = app.service.named(who, k, &request).map_err(refused)? + && let Some(id) = service.named(who, k, &request).map_err(refused)? { return Ok(id); } - let (plan, work) = plan_job(app, &request)?; - app.service.submit_work(who, request, plan, work, key).map_err(refused) + if let Some(r) = service.refused_before_reading(who) { + return Err(refused(r)); + } + service.window_open(who).map_err(refused)?; + let turn = service.gate.turn(crate::offload::Pool::Reading, who).await?; + service.count(who).map_err(refused)?; + cheap_bounds(&request)?; + let received = crate::jobs::sourced(&request).then(|| crate::jobs::received_digest(&request)); + let read = crate::offload::read(&service.config, turn, crate::offload::Read::Admit { request, job_bytes: service.config.job_mib << 20, keep: true }).await?; + let (request, planned) = admitted(read)?; + // Sent in another form under a key: it may read as the key's job. + if received.is_some() + && let Some(k) = key + && let Some(id) = service.named(who, k, &request).map_err(refused)? + { + return Ok(id); + } + let (plan, work, bound) = planned?; + service.submit_counted(who, request, plan, work, key, received, bound).map_err(refused) } /// The `Idempotency-Key` a request gives, if it gives one: a structured @@ -332,20 +550,35 @@ pub(crate) fn is_dry_run(request: &Value) -> bool { /// The plan of `request` for `who`, running nothing: its method, memory, /// estimated time and energy (each with its basis), the bounds that apply, /// where the client stands, and whether the service would take it now, or -/// the refusal it would give. It counts as one submission, as a check does. -pub(crate) fn dry_run(app: &App, who: &str, request: &Value) -> Result { - app.service.count(who).map_err(|Refused(code, why)| (code, why))?; - let expanded = with_source(request.clone())?; - let request = &expanded; - let window = app.service.window(who); - let (active, held) = app.service.holding(who); +/// the refusal it would give. It counts as one submission, as a check does, +/// before anything is read; it is read as a job is ([`admit_to`]), in a child. +pub(crate) async fn dry_run(app: &App, who: &str, request: &Value) -> Result { + let service = &app.service; + let refused = |Refused(code, why)| (code, why); + service.window_open(who).map_err(refused)?; + let turn = service.gate.turn(crate::offload::Pool::Reading, who).await?; + service.count(who).map_err(refused)?; + // A reading refused for its size (its memory, its time, or what it would + // give back, as the submission's would be) is the dry run's refusal, said + // as any other: the submission would be refused so. + match crate::offload::read(&service.config, turn, crate::offload::Read::Admit { request: request.clone(), job_bytes: service.config.job_mib << 20, keep: false }).await { + Ok(read) => dry_run_now(service, who, admitted(read)?), + Err((422, why)) => dry_run_now(service, who, (Value::Null, Err((422, why)))), + Err(e) => Err(e), + } +} + +/// [`dry_run`]'s answer, its submission counted and its request read. +fn dry_run_now(service: &Service, who: &str, (_, planned): Admitted) -> Result { + let window = service.window(who); + let (active, held) = service.holding(who); let mut out = json!({ "dry_run": true, "ran": false, - "bounds": bounds(app), + "bounds": bounds(&service.config), "client": { "active_jobs": active, "memory_held_bytes": held << 20, "submissions_left": window.remaining, "window_resets_in_seconds": window.reset_secs }, }); - let (plan, work) = match plan_job(app, request) { + let (plan, work, bound) = match planned { Ok(p) => p, Err((status, why)) => { out["accepted"] = json!(false); @@ -354,7 +587,7 @@ pub(crate) fn dry_run(app: &App, who: &str, request: &Value) -> Result { let (t, e) = estimate(&plan); @@ -366,7 +599,7 @@ pub(crate) fn dry_run(app: &App, who: &str, request: &Value) -> Result = each.iter().map(|(_, e)| e["joules_micro_estimate"].as_u64()).sum(); let time = match secs { Some(s) => json!({ "seconds": s, "basis": "the sum of the items' estimates, each with its own basis", "note": "an estimate: the set is bounded by one job's time limit, not by this" }), - None => json!({ "seconds": null, "why": "an item's time is not estimated (each item's own says why)", "at_most": app.service.config.job_timeout.as_secs() }), + None => json!({ "seconds": null, "why": "an item's time is not estimated (each item's own says why)", "at_most": service.config.job_timeout.as_secs() }), }; let energy = match uj { Some(u) => json!({ "joules_micro_estimate": u, "energy_provenance": "Estimator", "basis": "the sum of the items' estimates", "note": "an estimate from past readings, never a reading of this job" }), @@ -388,18 +621,23 @@ pub(crate) fn dry_run(app: &App, who: &str, request: &Value) -> Result json!(s <= app.service.config.job_timeout.as_secs_f64()), + Some(s) => json!(s <= service.config.job_timeout.as_secs_f64()), None => Value::Null, }; out["time"] = time; out["energy"] = energy; + // The most its result takes as served, known before it runs. + out["result_bytes"] = match bound { + Some(b) => json!(b.bytes()), + None => json!({ "bytes": null, "why": "its method gives no bound of its result before it runs (no built-in method is such): it is counted at the most a job's result may take, and held to it as it ends" }), + }; if let Some(t) = &plan.target { out["target"] = t.clone(); } @@ -417,14 +655,15 @@ pub(crate) fn dry_run(app: &App, who: &str, request: &Value) -> Result, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>) -> Response { - let Json(request) = match body { - Ok(b) => b, - Err(e) => return error(400, &format!("the body is not a JSON job: {e}")), - }; +async fn submit(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: axum::body::Bytes) -> Response { let who = client(&app, peer, &headers); + // A resend under a key this client holds is answered before any limit, so read first. + let request = match json_body(&app, &who, &body, resend(&app, &who, &headers), "the body is not a JSON job") { + Ok(v) => v, + Err((code, why)) => return error(code, &why), + }; if is_dry_run(&request) { - return match dry_run(&app, &who, &request) { + return match dry_run(&app, &who, &request).await { Ok(plan) => reply(200, plan), Err((code, why)) => error(code, &why), }; @@ -433,20 +672,21 @@ async fn submit(State(app): State, ConnectInfo(peer): ConnectInfo k, Err((code, why)) => return error(code, &why), }; - match admit(&app, &who, request, key.as_deref().map(Key)) { - Ok(id) => reply(202, app.service.get(&id).unwrap_or(json!({ "id": id }))), + match admit(&app, &who, request, key).await { + Ok(id) => reply_text(202, app.service.report(&id).unwrap_or_else(|| json!({ "id": id }).to_string())), Err((code, why)) => error(code, &why), } } -async fn run(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>) -> Response { - let Json(request) = match body { - Ok(b) => b, - Err(e) => return error(400, &format!("the body is not a JSON job: {e}")), - }; +async fn run(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: axum::body::Bytes) -> Response { let who = client(&app, peer, &headers); + // A resend under a key this client holds is answered before any limit, so read first. + let request = match json_body(&app, &who, &body, resend(&app, &who, &headers), "the body is not a JSON job") { + Ok(v) => v, + Err((code, why)) => return error(code, &why), + }; if is_dry_run(&request) { - return match dry_run(&app, &who, &request) { + return match dry_run(&app, &who, &request).await { Ok(plan) => reply(200, plan), Err((code, why)) => error(code, &why), }; @@ -455,18 +695,43 @@ async fn run(State(app): State, ConnectInfo(peer): ConnectInfo k, Err((code, why)) => return error(code, &why), }; - let id = match admit(&app, &who, request, key.as_deref().map(Key)) { + let id = match admit(&app, &who, request, key).await { Ok(id) => id, Err((code, why)) => return error(code, &why), }; - let status = app.service.wait(&id, RUN_WAIT).await; - let body = app.service.get(&id).unwrap_or(json!({ "id": id })); + // Within the answer's time, whatever admission took. + let status = app.service.wait(&id, crate::offload::left().map_or(RUN_WAIT, |l| l.min(RUN_WAIT))).await; + let body = app.service.report(&id).unwrap_or_else(|| json!({ "id": id }).to_string()); match status { - Some(s) if !s.active() => reply(200, body), - _ => reply(202, body), + Some(s) if !s.active() => reply_text(200, body), + _ => reply_text(202, body), } } +/// A request or an answer past this many bytes has the memory it was parsed +/// into given back to the system once it is answered ([`crate::jobs::give_back`]). +const LARGE: usize = 256 << 10; + +/// The largest MCP message read from a client past its window. +const MCP_UNREAD: usize = 64 << 10; + +/// Whether the request is a resend under an idempotency key that names a job +/// `who` holds: only then is its body read before its window is asked. +fn resend(app: &App, who: &str, headers: &HeaderMap) -> bool { + headers.get("idempotency-key").and_then(|v| v.to_str().ok()).is_some_and(|k| app.service.holds_key(who, k)) +} + +/// A request's body read as JSON, once its client's window has room, so that +/// a request refused for its window is never parsed: a parsed body takes many +/// times its length. With `first` (a resend under its key, answered before any +/// limit) it is read whatever the window says. +fn json_body(app: &App, who: &str, body: &[u8], first: bool, bad: &str) -> Result { + if !first && let Err(Refused(code, why)) = app.service.window_open(who) { + return Err((code, why)); + } + serde_json::from_slice(body).map_err(|e| (400, format!("{bad}: {e}"))) +} + /// The longest `GET /v1/jobs/{id}?wait=S` holds its answer. pub const POLL_WAIT: Duration = Duration::from_secs(25); @@ -475,13 +740,39 @@ struct Poll { wait: Option, } +/// How long a long poll past the holds is told to wait before it polls +/// again, seconds ([`job`]). +pub const POLL_AGAIN: u64 = 2; + /// A job's report; with `?wait=S` (at most 25 s), held until its status -/// changes from what it was, or the wait passes. -async fn job(State(app): State, Path(id): Path, Query(poll): Query) -> Response { +/// changes from what it was, or the wait passes. Past its client's holds (or +/// everyone's), a long poll of a job still queued or running is answered at +/// once `429` (its client's) or `503` (everyone's), `J032`, with +/// `Retry-After` and the job's status: a poll that is not held says so, and +/// when to poll again, so a client that polls in a loop waits. +async fn job(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, Path(id): Path, Query(poll): Query) -> Response { let Some(first) = app.service.status(&id) else { return error(404, &format!("no such job (finished jobs are kept for {} s)", app.service.config.retain.as_secs())); }; - if let Some(w) = poll.wait.filter(|&w| w > 0 && first.active()) { + let held = match poll.wait.filter(|&w| w > 0 && first.active()) { + None => None, + Some(w) => match app.service.hold_stream_or(&client(&app, peer, &headers)) { + Ok(h) => Some((w, h)), + Err(mine) => { + let why = if mine { + format!("your {} long polls and event streams are held, all one client may hold; this poll was not held: poll again after {POLL_AGAIN} s, or without ?wait", app.service.config.client_streams) + } else { + format!("as many long polls and event streams are held as the service may; this poll was not held: poll again after {POLL_AGAIN} s, or without ?wait") + }; + let why = tag(refusal::BUSY, why); + let r = wai_quantum::engine::refusal::refusal_json(&why); + let mut resp = reply(if mine { 429 } else { 503 }, json!({ "error": why, "code": r["code"], "codes": r["codes"], "id": id, "status": first.name() })); + resp.headers_mut().insert("retry-after", HeaderValue::from(POLL_AGAIN)); + return resp; + } + }, + }; + if let Some((w, _held)) = held { let mut rx = app.service.subscribe(); let deadline = tokio::time::Instant::now() + Duration::from_secs(w).min(POLL_WAIT); while app.service.status(&id) == Some(first) { @@ -491,8 +782,8 @@ async fn job(State(app): State, Path(id): Path, Query(poll): Que } } } - match app.service.get(&id) { - Some(j) => reply(200, j), + match app.service.report(&id) { + Some(j) => reply_text(200, j), None => error(404, &format!("no such job (finished jobs are kept for {} s)", app.service.config.retain.as_secs())), } } @@ -500,32 +791,37 @@ async fn job(State(app): State, Path(id): Path, Query(poll): Que /// A job's status as server-sent events: `status` events (`{"id", "status"}`) /// as it changes, then one `job` event with the whole report when it ends, /// and the stream closes. A comment every 15 s keeps an idle stream open. -async fn events(State(app): State, Path(id): Path) -> Response { +async fn events(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, Path(id): Path) -> Response { if app.service.status(&id).is_none() { return error(404, &format!("no such job (finished jobs are kept for {} s)", app.service.config.retain.as_secs())); } + let Some(held) = app.service.hold_stream(&client(&app, peer, &headers)) else { + let mut r = error(503, &tag(refusal::BUSY, "as many event streams and long polls are open as may be, yours or everyone's; poll GET /v1/jobs/{id} instead")); + r.headers_mut().insert("retry-after", HeaderValue::from_static("5")); + return r; + }; struct Follow { app: Shared, id: String, rx: tokio::sync::broadcast::Receiver, last: Option, ended: bool, + _held: crate::jobs::OpenRequest, } - let start = Follow { rx: app.service.subscribe(), app, id, last: None, ended: false }; + let start = Follow { rx: app.service.subscribe(), app, id, last: None, ended: false, _held: held }; let stream = futures_util::stream::unfold(start, |mut f| async move { if f.ended { return None; } loop { - let job = f.app.service.get(&f.id)?; - let status = job["status"].as_str().unwrap_or("").to_string(); + let status = f.app.service.status(&f.id)?.name().to_string(); if f.last.as_deref() != Some(status.as_str()) { f.last = Some(status.clone()); let ev = if matches!(status.as_str(), "queued" | "running") { Event::default().event("status").data(json!({ "id": f.id, "status": status }).to_string()) } else { f.ended = true; - Event::default().event("job").data(job.to_string()) + Event::default().event("job").data(f.app.service.report(&f.id)?) }; return Some((Ok::(ev), f)); } @@ -553,7 +849,7 @@ async fn receipt_keys(State(app): State) -> Response { async fn cancel(State(app): State, Path(id): Path) -> Response { match app.service.cancel(&id) { - Some(true) => reply(200, app.service.get(&id).unwrap_or(json!({ "id": id }))), + Some(true) => reply_text(200, app.service.report(&id).unwrap_or_else(|| json!({ "id": id }).to_string())), Some(false) => error(409, "the job has already finished"), None => error(404, "no such job"), } @@ -567,6 +863,10 @@ async fn cancel(State(app): State, Path(id): Path) -> Response { /// resource receipt in it checked. async fn verify(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: Result) -> Response { let Ok(bytes) = body else { return error(400, "the body is not JSON") }; + // Counted against the client's own window for checks, before it is read. + if let Err(Refused(code, why)) = app.service.verify_counted(&client(&app, peer, &headers)) { + return error(code, &why); + } let Some(text) = std::str::from_utf8(&bytes).ok() else { return error(400, "the body is not JSON") }; let Ok(v) = serde_json::from_str::(text) else { return error(400, "the body is not JSON") }; // Read as every job's text is, whatever kind of job it is. @@ -577,17 +877,20 @@ async fn verify(State(app): State, ConnectInfo(peer): ConnectInfo reply(200, checked), Err((code, why)) => error(code, &why), }; } - // A set: its receipt over its head, every item, the root and the paths (§8.6). + // A set: its receipt over its head, every item, the root and the paths + // (§8.6). A sweep's programs are written out again to check its items, so + // it is counted and read as a check is. if v["result"]["format"] == sets::FORMAT { - return reply(200, wai_quantum::engine::job::verdict_of_text(text).unwrap_or_else(|e| json!({ "valid": false, "error": e }))); + let who = client(&app, peer, &headers); + return match crate::analysis::bounded(&app, &who, crate::offload::Read::Verify { text: text.to_string() }).await { + Ok(checked) => reply(200, checked), + Err((code, why)) => error(code, &why), + }; } let (Some(_), Some(result)) = (v.get("request"), v.get("result")) else { return error(400, "expected {\"request\": ..., \"result\": ...}, the result carrying its receipt"); @@ -599,9 +902,12 @@ async fn verify(State(app): State, ConnectInfo(peer): ConnectInfo, axum::extract::rejection::JsonRejection>, check: crate::analysis::Check) -> Response { - let Ok(Json(v)) = body else { return error(400, "the body is not JSON") }; +async fn analysis(app: Shared, peer: SocketAddr, headers: HeaderMap, body: axum::body::Bytes, check: crate::analysis::Check) -> Response { let who = client(&app, peer, &headers); + let v = match json_body(&app, &who, &body, false, "the body is not JSON") { + Ok(v) => v, + Err((code, why)) => return error(code, &why), + }; match crate::analysis::analyse(&app, &who, check, v).await { Ok(answer) => reply(200, answer), Err((code, why)) => error(code, &why), @@ -632,9 +938,8 @@ pub(crate) async fn estimate(app: &App, who: &str, request: Value) -> Result, axum::extract::rejection::JsonRejection>, op: &'static str) -> Response { - let Ok(Json(v)) = body else { return error(400, "the body is not JSON") }; +async fn pulse_route(app: Shared, peer: SocketAddr, headers: HeaderMap, body: axum::body::Bytes, op: &'static str) -> Response { let who = client(&app, peer, &headers); + let v = match json_body(&app, &who, &body, false, "the body is not JSON") { + Ok(v) => v, + Err((code, why)) => return error(code, &why), + }; match pulse(&app, &who, op, v).await { Ok(sealed) => reply(200, sealed), Err((code, why)) => error(code, &why), } } -async fn qec_route(app: Shared, peer: SocketAddr, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>, op: &'static str) -> Response { - let Ok(Json(v)) = body else { return error(400, "the body is not JSON") }; +async fn qec_route(app: Shared, peer: SocketAddr, headers: HeaderMap, body: axum::body::Bytes, op: &'static str) -> Response { let who = client(&app, peer, &headers); + let v = match json_body(&app, &who, &body, false, "the body is not JSON") { + Ok(v) => v, + Err((code, why)) => return error(code, &why), + }; match qec(&app, &who, op, v).await { Ok(sealed) => reply(200, sealed), Err((code, why)) => error(code, &why), @@ -691,34 +998,41 @@ pub(crate) async fn variational(app: &App, who: &str, op: &'static str, request: if let Some(at) = crate::receipt::inexact_number(&request) { return Err((400, format!("`{at}`: a whole number here must be within ±9007199254740991 (2^53 − 1), so that every JSON reader reads it alike and the receipt binds it"))); } - crate::analysis::variational_bounds(op, &request)?; - let req = request.clone(); - let mut result = crate::analysis::bounded(app, who, move || wai_quantum::engine::variational::call(op, &req)).await?; + let mut result = crate::analysis::bounded(app, who, crate::offload::Read::Call { kind: "variational".into(), op: op.into(), body: request.clone(), key: None }).await?; app.signer.seal_into(&request, &mut result); Ok(json!({ "request": request, "result": result })) } -async fn variational_route(app: Shared, peer: SocketAddr, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>, op: &'static str) -> Response { - let Ok(Json(v)) = body else { return error(400, "the body is not JSON") }; +async fn variational_route(app: Shared, peer: SocketAddr, headers: HeaderMap, body: axum::body::Bytes, op: &'static str) -> Response { let who = client(&app, peer, &headers); + let v = match json_body(&app, &who, &body, false, "the body is not JSON") { + Ok(v) => v, + Err((code, why)) => return error(code, &why), + }; match variational(&app, &who, op, v).await { Ok(sealed) => reply(200, sealed), Err((code, why)) => error(code, &why), } } -async fn estimate_route(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>) -> Response { - let Ok(Json(v)) = body else { return error(400, "the body is not JSON") }; +async fn estimate_route(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: axum::body::Bytes) -> Response { let who = client(&app, peer, &headers); + let v = match json_body(&app, &who, &body, false, "the body is not JSON") { + Ok(v) => v, + Err((code, why)) => return error(code, &why), + }; match estimate(&app, &who, v).await { Ok(sealed) => reply(200, sealed), Err((code, why)) => error(code, &why), } } -async fn compile_route(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>) -> Response { - let Ok(Json(v)) = body else { return error(400, "the body is not JSON") }; +async fn compile_route(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: axum::body::Bytes) -> Response { let who = client(&app, peer, &headers); + let v = match json_body(&app, &who, &body, false, "the body is not JSON") { + Ok(v) => v, + Err((code, why)) => return error(code, &why), + }; match compile(&app, &who, v).await { Ok(sealed) => reply(200, sealed), Err((code, why)) => error(code, &why), @@ -734,7 +1048,7 @@ async fn examples() -> Response { } async fn openapi(State(app): State) -> Response { - reply(200, crate::discovery::openapi(&app.base)) + reply(200, crate::discovery::openapi(&app.base, app.service.config.result_mib << 20)) } /// The root: a browser goes to the console page; a program gets an index. @@ -756,14 +1070,25 @@ async fn index(State(app): State, headers: HeaderMap) -> Response { /// (`wq-views-…`, random, held nowhere), and every request that carries it is /// answered with the views (see `mcp`); any other client gets no session id /// and no views. -async fn mcp(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>) -> Response { - let Ok(Json(msg)) = body else { +async fn mcp(State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: axum::body::Bytes) -> Response { + let who = client(&app, peer, &headers); + // A large message from a client past its window can only be a call it + // would be refused: refused unread (a parsed message takes many times its + // length). A small one is read, for what costs nothing (a list, a poll). + // Each answer echoes the id as it was sent. + let raw = raw_id(&body); + if body.len() > MCP_UNREAD + && let Err(Refused(_, why)) = app.service.window_open(&who) + && let Some(answer) = crate::mcp::past_window(&app, &who, &body, &why) + { + return reply_text(200, mcp_text(answer, raw.as_deref())); + } + let Ok(msg) = serde_json::from_slice::(&body) else { return reply(400, json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": "not JSON" } })); }; if !msg.is_object() { return reply(400, json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32600, "message": "one JSON-RPC message a request" } })); } - let who = client(&app, peer, &headers); let opening = msg["method"] == "initialize" && wai_quantum::engine::views::negotiated(&msg["params"]); let views = opening || headers.get("mcp-session-id").and_then(|v| v.to_str().ok()).is_some_and(crate::mcp::is_views_session); // tasks/result waits until its task ends: to a client that takes an @@ -782,7 +1107,7 @@ async fn mcp(State(app): State, ConnectInfo(peer): ConnectInfo(Event::default().data(a.to_string())) + Ok::(Event::default().data(mcp_text(a, raw.as_deref()))) }); let mut r = Sse::new(answer).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response(); r.headers_mut().insert("access-control-allow-origin", HeaderValue::from_static("*")); @@ -791,7 +1116,9 @@ async fn mcp(State(app): State, ConnectInfo(peer): ConnectInfo { - let mut r = reply(200, answer); + // Written as RFC 8785, so a job in an answer has its report's + // bytes, and its id as it was sent. + let mut r = reply_text(200, mcp_text(answer, raw.as_deref())); if opening && let Ok(id) = HeaderValue::from_str(&crate::mcp::views_session()) { r.headers_mut().insert("mcp-session-id", id); r.headers_mut().insert("access-control-expose-headers", HeaderValue::from_static("mcp-session-id")); @@ -858,10 +1185,255 @@ fn rate_headers(app: &App, who: &str, r: &mut Response) { if let Ok(v) = HeaderValue::from_str(&format!("\"submissions\";r={};t={}", w.remaining, w.reset_secs)) { h.insert("ratelimit", v); } - if r.status() == StatusCode::TOO_MANY_REQUESTS - && let Ok(v) = HeaderValue::from_str(&(if w.remaining == 0 { w.reset_secs.max(1) } else { 5 }).to_string()) + // Sent again at this pace, a refusal that counts never spends the window; + // a handler that says when to come back (a long poll not held) is kept. + let pace = (window / w.limit.max(1) as u64).max(5); + let said = r.headers().contains_key("retry-after"); + if !said + && r.status() == StatusCode::TOO_MANY_REQUESTS + && let Ok(v) = HeaderValue::from_str(&(if w.remaining == 0 { w.reset_secs.max(1) } else { pace }).to_string()) { r.headers_mut().insert("retry-after", v); + } else if !said && r.status() == StatusCode::SERVICE_UNAVAILABLE { + r.headers_mut().insert("retry-after", HeaderValue::from_static("5")); + } +} + +/// A request with its body read, within `wait`: a client that stops sending +/// holds its connection no longer than that (`408`). Only then is it handled. +/// The request is answered within `answer` of its arrival +/// ([`crate::offload::answering_by`]). +async fn body_in_time(State(app): State, request: axum::extract::Request, next: axum::middleware::Next) -> Response { + let (wait, answer) = (app.service.config.body_wait, Duration::from_secs(app.service.config.answer_secs)); + let due = std::time::Instant::now() + answer; + let (parts, body) = request.into_parts(); + // Counted open before its body is read: one client's held requests are + // held to their number. A health check is answered whatever that is. + let _open = if parts.uri.path() == "/v1/health" { + None + } else { + let peer = parts.extensions.get::>().map_or(SocketAddr::from(([0, 0, 0, 0], 0)), |c| c.0); + match app.service.open_request(&client(&app, peer, &parts.headers)) { + Ok(open) => Some(open), + Err(Refused(code, why)) => { + let mut r = error(code, &why); + r.headers_mut().insert("retry-after", HeaderValue::from_static("5")); + // The body is not read: the connection goes with the answer. + r.headers_mut().insert("connection", HeaderValue::from_static("close")); + return r; + } + } + }; + match tokio::time::timeout(wait, axum::body::to_bytes(body, MAX_BODY + 1)).await { + Ok(Ok(bytes)) => { + // Read: its connection is answering now, no longer to be closed for room. + if let Some(OnConn(c)) = parts.extensions.get::() { + c.set(Phase::Answer); + } + let large = bytes.len() > LARGE; + let r = crate::offload::answering_by(due, next.run(axum::extract::Request::from_parts(parts, axum::body::Body::from(bytes)))).await; + // What a large request or answer was parsed into, given back. + if large || axum::body::HttpBody::size_hint(r.body()).exact().is_some_and(|n| n > LARGE as u64) { + crate::jobs::give_back(); + } + r + } + Ok(Err(_)) => error(413, &format!("the body is larger than the {MAX_BODY} bytes a request may be, or it broke off")), + Err(_) => error(408, &format!("the body did not arrive within {} s", wait.as_secs())), + } +} + +/// Where a connection is: waiting for a request's header section (or idle +/// between requests), reading a request's body, answering, or being closed. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Phase { + Idle, + Body, + Answer, + Closing, +} + +/// One connection's phase and since when, and how to close it. +struct Conn { + phase: Mutex<(Phase, std::time::Instant)>, + close: tokio::sync::Notify, +} + +impl Conn { + fn set(&self, phase: Phase) { + let mut p = self.phase.lock().unwrap(); + if p.0 != Phase::Closing { + *p = (phase, std::time::Instant::now()); + } + } +} + +/// The connection a request came on, so that its body read moves it on. +#[derive(Clone)] +struct OnConn(Arc); + +/// The connections the service holds. +#[derive(Default)] +struct Conns { + held: Mutex>>, + next: std::sync::atomic::AtomicU64, + /// Connections being answered `503` past the cap. + refusing: std::sync::atomic::AtomicUsize, + /// Connections past the cap held for a health check alone. + health: std::sync::atomic::AtomicUsize, +} + +/// Connections past the cap held for a health check alone, each for one +/// request and 2 s at most: a health check is answered however many +/// connections answer long requests. +const HEALTH_ROOM: usize = 4; + +/// How many connections past the cap are answered at once; the rest are closed. +const REFUSING: usize = 8; + +impl Conns { + /// One to close so another can be held: the one idle longest, or else + /// the one whose body has been arriving longest, past a second. + fn evict(&self) -> bool { + let held = self.held.lock().unwrap(); + let pick = |phase: Phase, after: Duration| { + held.values().filter_map(|c| { + let p = *c.phase.lock().unwrap(); + (p.0 == phase && p.1.elapsed() >= after).then_some((p.1, Arc::clone(c))) + }).min_by_key(|(since, _)| *since).map(|(_, c)| c) + }; + let Some(c) = pick(Phase::Idle, Duration::ZERO).or_else(|| pick(Phase::Body, Duration::from_secs(1))) else { return false }; + c.phase.lock().unwrap().0 = Phase::Closing; + c.close.notify_one(); + true + } +} + +/// A response's body, its connection idle again once the body is sent. +struct Tracked { + inner: axum::body::Body, + conn: Arc, +} + +impl hyper::body::Body for Tracked { + type Data = axum::body::Bytes; + type Error = axum::Error; + + fn poll_frame(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll, Self::Error>>> { + hyper::body::Body::poll_frame(std::pin::Pin::new(&mut self.get_mut().inner), cx) + } + + fn is_end_stream(&self) -> bool { + hyper::body::Body::is_end_stream(&self.inner) + } + + fn size_hint(&self) -> hyper::body::SizeHint { + hyper::body::Body::size_hint(&self.inner) + } +} + +impl Drop for Tracked { + fn drop(&mut self) { + self.conn.set(Phase::Idle); + } +} + +/// The answer to a connection past [`serve`]'s cap, sent before anything is read. +const FULL: &str = "{\"error\":\"the service holds as many connections as it may; try again shortly [J032]\"}"; +const FULL_WHY: &str = "the service holds as many connections as it may; try again shortly [J032]"; + +/// Serves `router` on `listener` until the service stops. Each connection is +/// held to `header_wait` for a request's header section, and the router to +/// its body's time for its body, so a client that stops sending holds a +/// connection for no longer than those (axum's own loop takes no such limit). +/// At most `max` connections are held: past it the one idle longest is +/// closed (or the one whose body has been arriving longest), so a new +/// connection, a health check say, is always answered; with none to close it +/// is answered `503` at once and closed. `max` is set under the open-file +/// limit, so accepting never runs out of descriptors. +pub async fn serve(listener: tokio::net::TcpListener, router: Router, header_wait: Duration, max: usize) { + let service = hyper_util::service::TowerToHyperService::new(router); + let conns = Arc::new(Conns::default()); + loop { + // Connections closed for room close as their tasks next run: no more + // is accepted until they have, so the descriptors held stay under + // the cap and a few beside it, however fast connections arrive. + while conns.held.lock().unwrap().len() + conns.refusing.load(std::sync::atomic::Ordering::SeqCst) + conns.health.load(std::sync::atomic::Ordering::SeqCst) >= max + REFUSING + HEALTH_ROOM { + tokio::time::sleep(Duration::from_millis(5)).await; + } + let (mut stream, peer) = match listener.accept().await { + Ok(accepted) => accepted, + Err(e) => { + // Out of descriptors, say: those held free themselves. + eprintln!("accepting a connection: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; + let full = conns.held.lock().unwrap().len() >= max && !conns.evict(); + if full && conns.health.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < HEALTH_ROOM { + // Held for a health check alone, one request, in 2 s. + let (service, conns) = (service.clone(), Arc::clone(&conns)); + tokio::spawn(async move { + let health_only = hyper::service::service_fn(move |mut r: hyper::Request| { + let call = (r.method() == hyper::Method::GET && r.uri().path() == "/v1/health").then(|| { + r.extensions_mut().insert(ConnectInfo(peer)); + hyper::service::Service::call(&service, r) + }); + async move { + match call { + Some(answered) => answered.await, + None => Ok(error(503, FULL_WHY)), + } + } + }); + let io = hyper_util::rt::TokioIo::new(stream); + let _ = tokio::time::timeout(Duration::from_secs(2), hyper::server::conn::http1::Builder::new().timer(hyper_util::rt::TokioTimer::new()).keep_alive(false).header_read_timeout(Duration::from_secs(2)).serve_connection(io, health_only)).await; + conns.health.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + }); + continue; + } + if full { + conns.health.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + // Answered by a few at a time, each for 2 s at most; past them + // closed at once, so refusing takes no more descriptors either. + if conns.refusing.fetch_add(1, std::sync::atomic::Ordering::SeqCst) >= REFUSING { + conns.refusing.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + drop(stream); + continue; + } + let conns = Arc::clone(&conns); + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + let answer = format!("HTTP/1.1 503 Service Unavailable\r\ncontent-type: application/json\r\nretry-after: 5\r\nconnection: close\r\ncontent-length: {}\r\n\r\n{FULL}", FULL.len()); + let _ = tokio::time::timeout(Duration::from_secs(2), async { stream.write_all(answer.as_bytes()).await.and(stream.shutdown().await) }).await; + drop(stream); + conns.refusing.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + }); + continue; + } + let conn = Arc::new(Conn { phase: Mutex::new((Phase::Idle, std::time::Instant::now())), close: tokio::sync::Notify::new() }); + let id = conns.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + conns.held.lock().unwrap().insert(id, Arc::clone(&conn)); + let (service, conns) = (service.clone(), Arc::clone(&conns)); + tokio::spawn(async move { + let on = Arc::clone(&conn); + let with_peer = hyper::service::service_fn(move |mut r: hyper::Request| { + on.set(Phase::Body); + r.extensions_mut().insert(ConnectInfo(peer)); + r.extensions_mut().insert(OnConn(Arc::clone(&on))); + let answered = hyper::service::Service::call(&service, r); + let on = Arc::clone(&on); + async move { answered.await.map(|r| r.map(|inner| Tracked { inner, conn: on })) } + }); + let io = hyper_util::rt::TokioIo::new(stream); + let served = hyper::server::conn::http1::Builder::new().timer(hyper_util::rt::TokioTimer::new()).header_read_timeout(header_wait).serve_connection(io, with_peer); + tokio::select! { + _ = served => {} + () = conn.close.notified() => {} + } + conns.held.lock().unwrap().remove(&id); + }); } } @@ -900,6 +1472,7 @@ pub fn router(app: Shared) -> Router { .route("/mcp", post(mcp).get(mcp_get)) .route("/", get(index)) .fallback(|| async { error(404, "no such endpoint; GET /v1 lists them") }) + .layer(axum::middleware::from_fn_with_state(Arc::clone(&app), body_in_time)) .layer(axum::middleware::from_fn_with_state(Arc::clone(&app), guard)) .layer(DefaultBodyLimit::max(MAX_BODY)) .with_state(app) @@ -907,9 +1480,172 @@ pub fn router(app: Shared) -> Router { #[cfg(test)] mod tests { - use super::{identity, idempotency_key}; + use super::{admit_to, identity, idempotency_key}; + use crate::jobs::{Config, Service}; use axum::http::{HeaderMap, HeaderValue}; + use serde_json::json; use std::net::IpAddr; + use std::time::Duration; + + fn service(burst: usize) -> std::sync::Arc { + Service::new(Config { + budget_mib: 1024, + job_mib: 512, + workers: 1, + min_free_pct: 0, + job_timeout: Duration::from_secs(5), + retain: Duration::from_secs(60), + max_queue: 16, + per_client_active: 16, + per_client_mib: 1 << 20, + sandbox: true, + per_client_burst: burst, + per_client_window: Duration::from_secs(600), + read_mib: 4096, + read_timeout: Duration::from_secs(300), + read_answer_mib: 128, + qec_secs: 25, + answer_secs: 55, + client_kept_mib: 128, + kept_mib: 2048, + kept_min: Duration::from_secs(600), + client_unread_mib: 256, + unread_mib: 8192, + result_mib: 64, + client_verifies: 120, + client_requests: 32, + connections: 4096, + client_streams: 4, + streams: 512, + read_in_child: false, + per_client_reads: 2, + per_client_checks: 1, + header_wait: Duration::from_secs(30), + body_wait: Duration::from_secs(30), + }) + } + + /// Admission in the order of what each step costs: a refusal after the + /// count counts, whether the program is too deep, an intent too many, + /// or its references too long to read; a job sent again under its key + /// counts as nothing; and each reference is read once, however often + /// the request names it and however often its admission reads it. + #[tokio::test] + async fn a_refused_job_counts_and_each_reference_is_read_once_within_one_budget() { + let s = service(100); + let bell = "OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;\n"; + let deep = format!("OPENQASM 3.0;\nqubit[1] q;\nrz({}0.5{}) q[0];\n", "(".repeat(5_000), ")".repeat(5_000)); + let left = |s: &Service| s.window("a").remaining; + let (code, why) = admit_to(&s, "a", json!({ "qasm": deep }), None).await.unwrap_err(); + assert!(code == 400 && why.ends_with("[Q006]"), "{code} {why}"); + assert_eq!(left(&s), 99); + let many: Vec<_> = (0..17).map(|_| json!({ "equivalent": bell })).collect(); + let (code, why) = admit_to(&s, "a", json!({ "qasm": bell, "intent": many }), None).await.unwrap_err(); + assert!(code == 400 && why.ends_with("[J020]"), "{code} {why}"); + assert_eq!(left(&s), 98); + // Sixteen intents naming one reference that loops two million + // times: read once, it is within one budget; read for each intent + // and each step of admission, it was far past it. + let heavy = "OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nint a = 0;\nfor int i in [0:1999999] { a += 1; }\nh q[0];\nc = measure q;\n"; + let same: Vec<_> = (0..16).map(|_| json!({ "equivalent": heavy })).collect(); + let job = json!({ "qasm": bell, "shots": 10, "intent": same }); + let id = admit_to(&s, "a", job.clone(), Some("k")).await.unwrap(); + assert_eq!(left(&s), 97); + // Sent again under its key: the same job, counted as nothing. + assert_eq!(admit_to(&s, "a", job, Some("k")).await.unwrap(), id); + assert_eq!(left(&s), 97); + // Sixteen different ones are not: refused, as too large here, and counted. + let each: Vec<_> = (0..16).map(|k| json!({ "equivalent": format!("{heavy}// {k}\n") })).collect(); + let (code, why) = admit_to(&s, "a", json!({ "qasm": bell, "shots": 10, "intent": each }), None).await.unwrap_err(); + assert!(code == 422 && why.ends_with("[J031]") && why.contains("steps"), "{code} {why}"); + assert_eq!(left(&s), 96); + } + + /// A job's own program that reads within the reader's bounds reads here: + /// the budget covers its statements and its gates, each to its bound. + #[test] + fn the_budget_is_as_large_as_one_program_may_be() { + assert!(super::READ_STEPS >= wai_quantum::qasm::MAX_STEPS + wai_quantum::qasm::MAX_OPS as u64); + } + + /// With no turn to be had, a request is told so within the wait, and has + /// spent nothing, by any route that reads. Past its window it is told so + /// at once, without waiting. + #[tokio::test] + async fn a_request_with_no_turn_is_refused_in_time_and_spends_nothing() { + let s = service(2); + let mut held = Vec::new(); + for i in 0..crate::offload::AT_ONCE { + held.push(s.gate.turn(crate::offload::Pool::Reading, &format!("other {i}")).await.ok().unwrap()); + } + let t = std::time::Instant::now(); + let (code, why) = admit_to(&s, "a", json!({ "qasm": "OPENQASM 3.0; qubit q; h q;", "shots": 1 }), None).await.unwrap_err(); + assert!(code == 503 && why.ends_with("[J032]"), "{code} {why}"); + assert!(t.elapsed() < crate::offload::WAIT + Duration::from_secs(5), "{:?}", t.elapsed()); + assert_eq!(s.window("a").remaining, 2); + s.count("a").unwrap(); + s.count("a").unwrap(); + let t = std::time::Instant::now(); + let (code, why) = admit_to(&s, "a", json!({ "qasm": "OPENQASM 3.0; qubit q; h q;", "shots": 1 }), None).await.unwrap_err(); + assert!(code == 429 && why.ends_with("[J030]") && t.elapsed() < Duration::from_secs(1), "{code} {why}"); + } + + /// A job in another format sent again under its key is matched as it was + /// received: no reading, no turn, no count, whatever the window says. A + /// resend that is another request under the key counts as one, and reads. + /// There is no reading to be had for free, so nothing is given back. + #[tokio::test] + async fn a_source_job_sent_again_under_its_key_reads_nothing_and_counts_nothing() { + let s = service(2); + let job = json!({ "source": { "format": "qasm", "program": "OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;\n" }, "shots": 10 }); + let id = admit_to(&s, "a", job.clone(), Some("k")).await.unwrap(); + assert_eq!(s.window("a").remaining, 1); + let mut held = Vec::new(); + for i in 0..crate::offload::AT_ONCE { + held.push(s.gate.turn(crate::offload::Pool::Reading, &format!("other {i}")).await.ok().unwrap()); + } + s.count("a").unwrap(); + let t = std::time::Instant::now(); + assert_eq!(admit_to(&s, "a", job.clone(), Some("k")).await.unwrap(), id); + assert!(t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed()); + drop(held); + // Another program in the same format under the key is read to tell, + // and counts: the key names one job. + let s = service(3); + admit_to(&s, "a", job.clone(), Some("k")).await.unwrap(); + assert_eq!(s.window("a").remaining, 2); + let other = json!({ "source": { "format": "qasm", "program": "OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;\n" }, "shots": 10 }); + let (code, why) = admit_to(&s, "a", other, Some("k")).await.unwrap_err(); + assert!(code == 422 && why.ends_with("[J034]"), "{code} {why}"); + assert_eq!(s.window("a").remaining, 1); + } + + /// A client told to try again shortly spends nothing doing so: refused + /// for its jobs at once before anything is counted or read, its window + /// stays whole; and a job sent again under its key, once its window is + /// spent, is still its job, as an honest retry after a lost answer is. + #[tokio::test] + async fn a_client_told_to_retry_spends_nothing_and_a_key_s_job_comes_back_past_the_window() { + let s = Service::new(Config { per_client_active: 1, ..service(3).config.clone() }); + let bell = |k: u32| json!({ "qasm": format!("OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;\n// {k}\n"), "shots": 10 }); + let id = admit_to(&s, "a", bell(0), Some("lost")).await.unwrap(); + assert_eq!(s.window("a").remaining, 2); + for k in 1..20 { + let (code, why) = admit_to(&s, "a", bell(k), None).await.unwrap_err(); + assert!(code == 429 && why.contains("wait for one to finish"), "{code} {why}"); + } + assert_eq!(s.window("a").remaining, 2, "a refusal it was told to retry spent the window"); + // Its window spent by refusals that do count (programs that do not read): + let broken = json!({ "qasm": "OPENQASM 3.0;\nqubit q;\nfoo q;\n" }); + assert_eq!(s.cancel(&id), Some(true)); + for _ in 0..2 { + assert_eq!(admit_to(&s, "a", broken.clone(), None).await.unwrap_err().0, 400); + } + assert_eq!(s.window("a").remaining, 0); + assert_eq!(admit_to(&s, "a", bell(99), None).await.unwrap_err().0, 429); + // and the job its key named still comes back for it. + assert_eq!(admit_to(&s, "a", bell(0), Some("lost")).await.unwrap(), id); + } #[test] fn an_idempotency_key_reads_as_a_structured_string_or_bare() { diff --git a/wai/wai-quantum-service/src/jobs.rs b/wai/wai-quantum-service/src/jobs.rs index 5fceb7394..7389227c4 100644 --- a/wai/wai-quantum-service/src/jobs.rs +++ b/wai/wai-quantum-service/src/jobs.rs @@ -52,13 +52,13 @@ //! job the service interrupted: that key runs the request again. A record //! keeps the key's digest with the client's name, never the key. -use crate::catalog::Plan; +use crate::catalog::{Bound, Plan}; use crate::energy; use crate::store::{self, Store}; use serde_json::{Value, json}; use std::collections::{BTreeSet, HashMap, VecDeque}; use std::process::Stdio; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -95,6 +95,65 @@ pub struct Config { /// Submissions one client may make in a window. pub per_client_burst: usize, pub per_client_window: Duration, + /// The memory, MiB, and the time one request's reading may take + /// ([`crate::offload`]): the cap on its reading child. + pub read_mib: u64, + pub read_timeout: Duration, + /// The most a reading may answer with, MiB, counted as what it takes + /// once read (each value and each string): a sweep's items, written out, + /// come into the service's own memory. + pub read_answer_mib: u64, + /// What ended jobs may keep in memory, MiB: one client's, and all of + /// them; past either, its oldest ended jobs are dropped before their time. + pub client_kept_mib: u64, + pub kept_mib: u64, + /// How long an ended job that has not been read is kept whatever the + /// caps above say ([`Service::trim`]): a result is never dropped before + /// its client could read it. + pub kept_min: Duration, + /// What one client's results not yet read may keep, MiB, its jobs still + /// running counted at their results' bounds ([`Job::bound`]) and its next + /// at [`Config::result_mib`]: past it its next job + /// is refused until it reads them. Everyone's, [`Config::unread_mib`]: + /// past it every next job is refused. + pub client_unread_mib: u64, + pub unread_mib: u64, + /// The largest result a job may give, MiB, as served: a job whose + /// result's bound passes it is refused before it runs + /// ([`Service::would_refuse`]); one whose result passes it as it ends (a + /// backstop) fails, saying so. + pub result_mib: u64, + /// Receipts one client may have checked in its window + /// (`POST /v1/verify`, `verify_quantum_receipt`), before any is read. + pub client_verifies: usize, + /// Requests one client may have open at once, before any body is read. + pub client_requests: usize, + /// Connections the service holds at once, at most: lowered to what its + /// open-file limit leaves room for ([`crate::http::serve`]). + pub connections: usize, + /// Long polls (`?wait=`) and event streams one client may hold at once, + /// and all clients: lowered to a quarter of the connections held. + pub client_streams: usize, + pub streams: usize, + /// How long the service takes to answer a request at most, seconds: every + /// wait for a turn, every reading and every wait for a job within it end + /// by then, so an answer comes within a proxy's wait for one. + pub answer_secs: u64, + /// The seconds an error-correction call's sampled shots may take, measured + /// as they run (`wai_quantum::engine::qec::within_time`). + pub qec_secs: u64, + /// Whether requests are read in a child (the service), or on a thread of + /// the process (the service's own tests, whose binary is not the service). + pub read_in_child: bool, + /// How many of one client's requests may be read at once, and how many + /// of its checks may run at once ([`crate::offload::Gate`]); more wait + /// behind them. + pub per_client_reads: usize, + pub per_client_checks: usize, + /// How long a client may take over a request's header section, and over + /// its body: past them the connection is closed, or refused `408`. + pub header_wait: Duration, + pub body_wait: Duration, } fn env_num(key: &str, default: T) -> T { @@ -107,7 +166,18 @@ impl Config { /// `WAI_QSVC_RETAIN_SECS` (86400), `WAI_QSVC_MAX_QUEUE` (256), /// `WAI_QSVC_CLIENT_ACTIVE` (3), `WAI_QSVC_CLIENT_GB` (the job size), /// `WAI_QSVC_CLIENT_BURST` (30 in `WAI_QSVC_CLIENT_WINDOW_SECS`, 600), - /// `WAI_QSVC_SANDBOX` (1; 0 runs workers unsandboxed). + /// `WAI_QSVC_SANDBOX` (1; 0 runs workers unsandboxed), `WAI_QSVC_READ_GB` + /// (4), `WAI_QSVC_READ_SECS` (45) and `WAI_QSVC_READ_ANSWER_MIB` (128), + /// what one request's reading may take and give, `WAI_QSVC_QEC_SECS` (25), + /// what an error-correction call's shots may take, + /// `WAI_QSVC_CLIENT_READS` (2) and `WAI_QSVC_CLIENT_CHECKS` (1), how many + /// of a client's requests are read at once in each pool, + /// `WAI_QSVC_ANSWER_SECS` (55), the longest any answer takes, + /// `WAI_QSVC_CLIENT_KEPT_MIB` (128) and `WAI_QSVC_KEPT_MIB` (2048), what + /// ended jobs may keep in memory, one client's and all, + /// `WAI_QSVC_HEADER_SECS` (150) and `WAI_QSVC_BODY_SECS` (30), how long a + /// client may take to send a request (the first also bounds an idle + /// connection kept open between requests). pub fn from_env() -> Config { let job_gb = env_num("WAI_QSVC_JOB_GB", 72u64); Config { @@ -123,6 +193,27 @@ impl Config { sandbox: env_num("WAI_QSVC_SANDBOX", 1u8) != 0, per_client_burst: env_num("WAI_QSVC_CLIENT_BURST", 30usize), per_client_window: Duration::from_secs(env_num("WAI_QSVC_CLIENT_WINDOW_SECS", 600u64)), + read_mib: (env_num("WAI_QSVC_READ_GB", 4f64).max(0.0) * 1024.0) as u64, + read_timeout: Duration::from_secs(env_num("WAI_QSVC_READ_SECS", 45u64)), + read_answer_mib: env_num("WAI_QSVC_READ_ANSWER_MIB", 128u64), + qec_secs: env_num("WAI_QSVC_QEC_SECS", 25u64).max(1), + answer_secs: env_num("WAI_QSVC_ANSWER_SECS", 55u64).max(1), + client_kept_mib: env_num("WAI_QSVC_CLIENT_KEPT_MIB", 128u64), + kept_mib: env_num("WAI_QSVC_KEPT_MIB", 4096u64), + client_unread_mib: env_num("WAI_QSVC_CLIENT_UNREAD_MIB", 256u64), + unread_mib: env_num("WAI_QSVC_UNREAD_MIB", 8192u64), + result_mib: env_num("WAI_QSVC_RESULT_MIB", 64u64), + kept_min: Duration::from_secs(env_num("WAI_QSVC_KEPT_MIN_SECS", 600u64)), + client_verifies: env_num("WAI_QSVC_CLIENT_VERIFIES", 120usize), + client_requests: env_num("WAI_QSVC_CLIENT_REQUESTS", 32usize).max(1), + connections: env_num("WAI_QSVC_CONNECTIONS", 4096usize).max(16), + client_streams: env_num("WAI_QSVC_CLIENT_STREAMS", 4usize).max(1), + streams: env_num("WAI_QSVC_STREAMS", 512usize).max(1), + per_client_reads: env_num("WAI_QSVC_CLIENT_READS", 2usize).max(1), + per_client_checks: env_num("WAI_QSVC_CLIENT_CHECKS", 1usize).max(1), + header_wait: Duration::from_secs(env_num("WAI_QSVC_HEADER_SECS", 150u64).max(1)), + body_wait: Duration::from_secs(env_num("WAI_QSVC_BODY_SECS", 30u64).max(1)), + read_in_child: !cfg!(test), } } } @@ -195,6 +286,8 @@ pub enum Work { pub struct Digests { pub request: Option, pub programs: Vec, + /// A set's items as written out, so that a restart reads no program. + pub items: Option, pub result: Option, pub receipt: Option, } @@ -205,45 +298,253 @@ pub struct Job { pub id: String, /// The client's name as the store keeps it ([`store::pseudonym`]). pub client: String, - pub request: Value, + /// The request as its canonical text, the bytes the store keeps and the + /// receipt seals: a parsed request takes many times its text, and a job is + /// kept for a day, so it is read only where it is used ([`Job::request`]). + pub request: std::sync::Arc, pub plan: Plan, pub work: Work, pub status: Status, pub submitted_ms: u64, pub started_ms: Option, pub finished_ms: Option, - pub result: Option, + /// The result as served, its receipt in it: RFC 8785 text, the same + /// before a restart and after, read only where it is used. + pub result: Option>, pub error: Option, pub peak_rss: u64, pub digests: Digests, /// The digest of the key it was sent with, and its client's name /// ([`Service::key_digest`]), if it was sent with one. pub key: Option, + /// The digest of the request as it was received, where that is not the + /// request as it reads (a `"source"`): what lets a send under its key be + /// matched before it is read. + pub received: Option, /// When the service stopped while the job ran, in milliseconds since /// 1970, each time it did. pub interruptions: Vec, child: Option, + /// What the job keeps in memory once it has ended, bytes: its request's + /// text and its result as read ([`crate::offload::weight`]), counted + /// against its client's and the service's caps on what ended jobs keep. + kept: u64, + /// Whether its result has been served since it ended: until then the + /// caps on what ended jobs keep pass it by, for [`Config::kept_min`]. + read: bool, + /// The most its result takes as served, bytes, known as it was admitted + /// (`http::plan_job`): what its client's unread results are counted to + /// hold while it runs. `u64::MAX` where it is not known (a job kept + /// across a restart), counted at [`Config::result_mib`]. + bound: u64, +} + +tokio::task_local! { + /// Whether the request being answered on this task was counted. + static COUNTED: std::cell::Cell; +} + +/// `work`'s answer, and whether it counted a submission: what says that a +/// client has paid for more reading in the same request. +pub async fn noting_count(work: F) -> (F::Output, bool) { + COUNTED + .scope(std::cell::Cell::new(false), async { + let out = work.await; + (out, COUNTED.with(std::cell::Cell::get)) + }) + .await +} + +/// What the service starts its readers and workers from ([`self_exe`]). +static SELF_EXE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Keeps what the service starts its readers and workers from, as the +/// running service is, whatever becomes of the file it was started from: on +/// Linux its own image (`/proc//exe`, which names the running binary +/// even once the file is replaced or removed); elsewhere a copy of the binary +/// in `dir`, named by its digest, made once. So the binary can be replaced +/// before the restart, by a copy over it or a rename, and the running service +/// goes on with its own until it is restarted. +pub fn keep_self(dir: &std::path::Path) -> Result { + let kept = if cfg!(target_os = "linux") { + std::path::PathBuf::from(format!("/proc/{}/exe", std::process::id())) + } else { + let exe = std::env::current_exe().map_err(|e| format!("the service could not find itself: {e}"))?; + let bytes = std::fs::read(&exe).map_err(|e| format!("{}: {e}", exe.display()))?; + let path = dir.join(format!("wai-quantum-service-{}", &blake3::hash(&bytes).to_hex()[..16])); + if std::fs::read(&path).map(|b| b != bytes).unwrap_or(true) { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let part = dir.join(format!(".{}-{}", path.file_name().and_then(|n| n.to_str()).unwrap_or("copy"), std::process::id())); + std::fs::write(&part, &bytes).map_err(|e| format!("{}: {e}", part.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&part, std::fs::Permissions::from_mode(0o755)).map_err(|e| format!("{}: {e}", part.display()))?; + } + std::fs::rename(&part, &path).map_err(|e| format!("{}: {e}", path.display()))?; + } + path + }; + Ok(SELF_EXE.get_or_init(|| kept).clone()) +} + +/// What the service starts its readers and workers from: what +/// [`keep_self`] kept, or, where it was not asked to keep it (a test), the +/// binary it was started from. +pub fn self_exe() -> Result { + match SELF_EXE.get() { + Some(p) => Ok(p.clone()), + None => std::env::current_exe().map_err(|e| format!("the service could not find itself: {e}")), + } +} + +/// Gives the memory freed since back to the system: a parsed request of a +/// megabyte takes tens of megabytes, and an allocator keeps what it freed, +/// which on macOS stays charged to the service's footprint. Called after a +/// large request or answer. +pub fn give_back() { + #[cfg(all(target_os = "linux", target_env = "gnu"))] + // SAFETY: a plain call into the C allocator. + unsafe { + libc::malloc_trim(0); + } + #[cfg(target_os = "macos")] + { + unsafe extern "C" { + fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize; + } + // SAFETY: every zone (a null zone), as much as can be given back. + unsafe { + malloc_zone_pressure_relief(std::ptr::null_mut(), 0); + } + } +} + +/// Makes this process the one a host short of memory ends first (Linux: +/// `oom_score_adj` 1000), so that a reader or a worker that grows between two +/// polls of its memory is ended before the service is. +pub fn expendable() { + #[cfg(target_os = "linux")] + { + let _ = std::fs::write("/proc/self/oom_score_adj", "1000"); + } +} + +/// Whether `request` carries its program in another format, so that what it +/// reads as is known only by reading it. +pub fn sourced(request: &Value) -> bool { + request.get("source").is_some_and(|s| !s.is_null()) +} + +/// The digest a request is matched by as it was received. +pub fn received_digest(request: &Value) -> String { + store::digest(&canon::canonical(request)) +} + +/// A set's items as JSON, each its request and its point. +pub fn items_json(items: &[sets::Item]) -> Value { + items.iter().map(|i| json!({ "request": i.request, "point": i.point })).collect() +} + +/// [`items_json`] read back. +pub fn items_of(v: &Value) -> Vec { + items_from(v.clone()) +} + +/// [`items_of`], taking the values rather than copying them. +pub fn items_from(v: Value) -> Vec { + let Value::Array(list) = v else { return Vec::new() }; + list.into_iter() + .map(|i| { + let Value::Object(mut o) = i else { return sets::Item { request: Value::Null, point: None } }; + sets::Item { request: o.remove("request").unwrap_or(Value::Null), point: o.remove("point").filter(|p| !p.is_null()) } + }) + .collect() } fn now_ms() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) } +/// Where a report's request and result are written in ([`Service::report`]): +/// no request or result holds these, as no JSON text holds a bare NUL. +const REQUEST_HERE: &str = "\u{0}the request here\u{0}"; +const RESULT_HERE: &str = "\u{0}the result here\u{0}"; + +/// A string as RFC 8785 writes it. +fn canonical_str(s: &str) -> String { + String::from_utf8(canon::canonical(&json!(s))).unwrap_or_default() +} + +/// A result as served, its receipt in it, as RFC 8785 text: written the +/// same way as a job ends and as it is read back after a restart. +fn served_text(result: &Value) -> std::sync::Arc { + String::from_utf8(canon::canonical(result)).unwrap_or_default().into() +} + +/// A client's request open ([`Service::open_request`]); dropped, it is not. +pub struct OpenRequest { + open: Arc>>, + client: String, +} + +impl Drop for OpenRequest { + fn drop(&mut self) { + let mut open = self.open.lock().unwrap(); + if let Some(n) = open.get_mut(&self.client) { + *n -= 1; + if *n == 0 { + open.remove(&self.client); + } + } + } +} + /// The record format a store keeps a job in. pub const RECORD: &str = "wai.quantum.job-record/1"; impl Job { + /// The job has ended: what it needed only to run is let go (a set's + /// items, each a copy of its fields), and what it keeps is counted. + fn ended(&mut self) { + if let Work::Set { items, .. } = &mut self.work { + *items = Vec::new(); + } + self.kept = self.request.len() as u64 + self.result.as_ref().map_or(0, |r| r.len() as u64) + self.error.as_ref().map_or(0, |e| e.len() as u64); + } + + /// The request, read from its text. + pub fn request(&self) -> Value { + serde_json::from_str(&self.request).unwrap_or(Value::Null) + } + + /// The job as the API reports it, but its request. + /// The report without its request and result, each to be written in + /// as the text it is kept as ([`Service::report`]). + fn lean(&self) -> Value { + let mut o = self.to_json_with(Value::Null, false); + if let Some(m) = o.as_object_mut() { + m.shift_remove("request"); + } + o + } + /// The job as the API reports it. + #[cfg(test)] pub fn to_json(&self) -> Value { + self.to_json_with(self.request(), true) + } + + fn to_json_with(&self, request: Value, with_result: bool) -> Value { let mut o = json!({ "id": self.id, "status": self.status.name(), "method": self.plan.method, "why": self.plan.why, "estimate": { "memory_bytes": self.plan.memory_bytes, "work": self.plan.work }, - // The request as submitted, so whoever holds the id can check the + // The request as sealed, so whoever holds the id can check the // result's receipt against it. - "request": self.request, + "request": request, "submitted_ms": self.submitted_ms, }); if let Some(t) = self.started_ms { @@ -258,8 +559,8 @@ impl Job { if !self.interruptions.is_empty() { o["interruptions"] = json!(self.interruptions); } - if let Some(r) = &self.result { - o["result"] = r.clone(); + if with_result && let Some(r) = &self.result { + o["result"] = serde_json::from_str(r).unwrap_or(Value::Null); } if let Some(e) = &self.error { o["error"] = json!(e); @@ -311,6 +612,12 @@ impl Job { if let Some(k) = &self.key { r["key"] = json!(k); } + if let Some(d) = &self.received { + r["received"] = json!(d); + } + if let Some(d) = &self.digests.items { + r["set_items"] = json!(d); + } if !self.interruptions.is_empty() { r["interruptions"] = json!(self.interruptions); } @@ -328,6 +635,7 @@ pub struct Refused(pub u16, pub String); #[derive(Default)] struct Client { submissions: VecDeque, + verifies: VecDeque, } /// One timed job of a method: its work, the time the engine took over it, @@ -371,6 +679,14 @@ pub struct Service { clients: Mutex>, /// The job each key's digest names. Taken before `jobs` or `queue`. keys: Mutex>, + /// Each client's requests open now ([`Service::open_request`]). + open: Arc>>, + /// Each client's long polls and event streams held now, and how many all + /// may hold ([`Service::hold_stream`]). + streams: Arc>>, + pub stream_cap: AtomicUsize, + /// The turns requests are read in. + pub gate: crate::offload::Gate, wake: Notify, memory: Arc, workers: Arc, @@ -420,13 +736,19 @@ impl Service { fn build(config: Config, store: Box, restored: Restored) -> Service { let memory = Arc::new(Semaphore::new(usize::try_from(config.budget_mib).unwrap_or(usize::MAX).min(Semaphore::MAX_PERMITS))); let workers = Arc::new(Semaphore::new(config.workers)); + let gate = crate::offload::Gate::new(config.per_client_reads, config.per_client_checks); + let streams = config.streams; Service { + gate, config, store, jobs: Mutex::new(HashMap::new()), queue: Mutex::new(VecDeque::new()), clients: Mutex::new(HashMap::new()), keys: Mutex::new(HashMap::new()), + open: Arc::default(), + streams: Arc::default(), + stream_cap: AtomicUsize::new(streams), wake: Notify::new(), memory, workers, @@ -442,8 +764,10 @@ impl Service { /// A service over `store`, its jobs reloaded: finished ones as they /// were; running ones queued again to run once more, or `interrupted` /// with the reason when they were run again already; queued ones queued - /// again in their order (planned again under `config`; one that no longer - /// plans fails, saying why); expired ones dropped. + /// again in their order (one too large for `config` fails, saying why); + /// expired ones dropped. No program is read: a job keeps its plan, and a + /// set its items, so a restart's time follows what the store holds, not + /// what its programs cost to read. pub fn open(config: Config, store: Box) -> Result, String> { let (records, unreadable) = store.records()?; for id in &unreadable { @@ -487,26 +811,19 @@ impl Service { } match job.status { Status::Queued => { - let cap = s.config.job_mib << 20; - let replanned = match &job.work { - Work::One => crate::catalog::plan(&job.request, cap).map(|p| (p, Work::One)), - Work::Set { items, .. } => sets::plan(items, cap).map(|plans| (sets::summary(&plans), Work::Set { items: items.clone(), plans })), - }; - match replanned.and_then(|(p, w)| if p.memory_bytes.div_ceil(1 << 20) > s.config.job_mib { Err(format!("it needs about {} MiB, more than one job may take here now", p.memory_bytes >> 20)) } else { Ok((p, w)) }) { - Ok((p, w)) => { - job.plan = p; - job.work = w; - queued.push((job.submitted_ms, job.id.clone())); - if rerun { - s.restored.rerun += 1; - } else { - s.restored.requeued += 1; - } - } - Err(e) => { - job.status = Status::Failed; - job.finished_ms = Some(now); - job.error = Some(format!("on the service's restart, the job no longer plans under its limits: {e}")); + // Its plan is the one it was admitted with: a restart reads + // no program, so what is queued delays no restart. + let mib = job.plan.memory_bytes.div_ceil(1 << 20); + if mib > s.config.job_mib { + job.status = Status::Failed; + job.finished_ms = Some(now); + job.error = Some(format!("on the service's restart, the job no longer plans under its limits: it needs about {mib} MiB, more than one job may take here now")); + } else { + queued.push((job.submitted_ms, job.id.clone())); + if rerun { + s.restored.rerun += 1; + } else { + s.restored.requeued += 1; } } s.store.put_record(&job.id, &job.record())?; @@ -514,7 +831,9 @@ impl Service { _ => { if job.status == Status::Done { s.restored.finished += 1; - s.time(&job); + if let Some(r) = job.result.as_deref().and_then(|t| serde_json::from_str::(t).ok()) { + s.time(&job, &r); + } } } } @@ -528,6 +847,8 @@ impl Service { queued.sort(); s.queue.get_mut().unwrap().extend(queued.into_iter().map(|(_, id)| id)); let s = Arc::new(s); + // What ended jobs keep, held to its caps as they were on starting. + s.trim(); s.sweep(); Ok(s) } @@ -542,14 +863,25 @@ impl Service { self.store.get(d)?.ok_or_else(|| format!("the object {d} is not in the store")) }; let json_of = |d: &Value| -> Result { serde_json::from_slice(&text(d)?).map_err(|e| format!("an object does not read: {e}")) }; - let request = json_of(&rec["request"])?; + let request_text = String::from_utf8(text(&rec["request"])?).map_err(|e| format!("its request is not text: {e}"))?; + // Read only where it is needed (a set from before sets kept their + // items): a parsed request takes many times its text. + let request = || serde_json::from_str::(&request_text).map_err(|e| format!("its request does not read: {e}")); let plan: Plan = serde_json::from_value(rec["plan"].clone()).map_err(|e| format!("its plan does not read: {e}"))?; let status = rec["status"].as_str().and_then(Status::from_name).ok_or("its status does not read")?; let work = match rec.get("set_plans") { Some(p) => { let plans: Vec = serde_json::from_value(p.clone()).map_err(|e| format!("its set's plans do not read: {e}"))?; - let items = sets::expand(&request)?; - if items.len() != plans.len() { + let items = match rec.get("set_items") { + Some(d) if status.active() => items_of(&json_of(d)?), + // Kept before sets kept their items; what has ended needs none. + None if status.active() => { + let request = request()?; + wai_quantum::qasm::within_steps(crate::http::READ_STEPS, || sets::expand(&request))? + } + _ => Vec::new(), + }; + if status.active() && items.len() != plans.len() { return Err("its set's plans do not match its items".into()); } Work::Set { items, plans } @@ -562,13 +894,13 @@ impl Service { if rec.get("receipt").is_some() { r["receipt"] = json_of(&rec["receipt"])?; } - result = Some(r); + result = Some(served_text(&r)); } let s = |k: &str| rec[k].as_str().map(str::to_string); - Ok(Job { + let mut job = Job { id: s("id").ok_or("no id")?, client: s("client").unwrap_or_default(), - request, + request: request_text.into(), plan, work, status, @@ -581,13 +913,21 @@ impl Service { digests: Digests { request: s("request"), programs: rec["programs"].as_array().into_iter().flatten().filter_map(|d| d.as_str().map(str::to_string)).collect(), + items: s("set_items"), result: s("result"), receipt: s("receipt"), }, key: s("key"), + received: s("received"), interruptions: rec["interruptions"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(), child: None, - }) + kept: 0, read: false, + bound: u64::MAX, + }; + if job.finished_ms.is_some() { + job.ended(); + } + Ok(job) } /// The store, for the service's description. @@ -625,6 +965,91 @@ impl Service { /// 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> { + self.in_window(client, true) + } + + /// One receipt check counted against `client`'s own window for them + /// ([`Config::client_verifies`]), before its body is read: the refusal + /// where it is spent. + pub fn verify_counted(&self, client: &str) -> Result<(), Refused> { + self.verify_window(client, true) + } + + /// Whether `client`'s window for receipt checks has room, counting one + /// where `spend` says so. + pub fn verify_window(&self, client: &str, spend: bool) -> Result<(), Refused> { + let now = Instant::now(); + let name = self.name(client); + let mut clients = self.clients.lock().unwrap(); + let c = clients.entry(name).or_default(); + while c.verifies.front().is_some_and(|t| now.duration_since(*t) > self.config.per_client_window) { + c.verifies.pop_front(); + } + if c.verifies.len() >= self.config.client_verifies { + return Err(Refused(429, tag(refusal::RATE, format!("at most {} receipts checked in {} s; try again shortly", self.config.client_verifies, self.config.per_client_window.as_secs())))); + } + if spend { + c.verifies.push_back(now); + } + Ok(()) + } + + /// A request of `client`'s open, held until it is answered: refused at + /// once, before its body is read, past [`Config::client_requests`] of + /// them, so one client's held connections cannot take every one the + /// service has. + pub fn open_request(&self, client: &str) -> Result { + let mut open = self.open.lock().unwrap(); + let n = open.entry(client.to_string()).or_default(); + if *n >= self.config.client_requests { + return Err(Refused(503, tag(refusal::BUSY, format!("{n} of your requests are open at once, all one client may have; send this when one is answered")))); + } + *n += 1; + Ok(OpenRequest { open: Arc::clone(&self.open), client: client.to_string() }) + } + + /// A long poll or an event stream of `client`'s held, until it is + /// dropped: none past [`Config::client_streams`] of the client's, or + /// past [`Service::stream_cap`] of everyone's, so they cannot hold every + /// connection the service has. + pub fn hold_stream(&self, client: &str) -> Option { + self.hold_stream_or(client).ok() + } + + /// [`Service::hold_stream`], or whose holds are full: the client's own + /// (`true`), or everyone's (`false`). + pub fn hold_stream_or(&self, client: &str) -> Result { + let mut held = self.streams.lock().unwrap(); + let all: usize = held.values().sum(); + let n = held.entry(client.to_string()).or_default(); + if *n >= self.config.client_streams { + return Err(true); + } + if all >= self.stream_cap.load(Ordering::SeqCst) { + return Err(false); + } + *n += 1; + Ok(OpenRequest { open: Arc::clone(&self.streams), client: client.to_string() }) + } + + /// Whether `client` holds a job under the idempotency key `key`: a + /// resend of it is answered before any limit, so its body is read first; + /// a request under any other key waits for the window like the rest. + pub fn holds_key(&self, client: &str, key: &str) -> bool { + let digest = Self::key_digest(&self.name(client), Key(key)); + let Some(id) = self.keys.lock().unwrap().get(&digest).cloned() else { return false }; + self.jobs.lock().unwrap().get(&id).is_some_and(|j| j.status.holds_key()) + } + + /// Whether `client`'s window has room, spending none of it: what a + /// request is asked before it waits for a turn. It is counted once it has + /// its turn ([`crate::offload`]), so a request told to try again while + /// the service is busy has read nothing and spent nothing. + pub fn window_open(&self, client: &str) -> Result<(), Refused> { + self.in_window(client, false) + } + + fn in_window(&self, client: &str, spend: bool) -> Result<(), Refused> { let now = Instant::now(); let name = self.name(client); let mut clients = self.clients.lock().unwrap(); @@ -635,7 +1060,10 @@ impl Service { if c.submissions.len() >= self.config.per_client_burst { return Err(Refused(429, tag(refusal::RATE, 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); + if spend { + c.submissions.push_back(now); + let _ = COUNTED.try_with(|counted| counted.set(true)); + } Ok(()) } @@ -649,6 +1077,49 @@ impl Service { Window { limit: self.config.per_client_burst, remaining: self.config.per_client_burst.saturating_sub(live.len()), reset_secs } } + /// What ended jobs not yet read keep, bytes, that the caps pass by + /// ([`Service::trim`]): `name`'s, and everyone's. + fn unread(&self, name: &str) -> (u64, u64) { + let now = now_ms(); + let min = self.config.kept_min.as_millis() as u64; + let jobs = self.jobs.lock().unwrap(); + jobs.values().filter(|j| !j.read && j.finished_ms.is_some_and(|f| now.saturating_sub(f) < min)).fold((0, 0), |(m, a), j| (m + if j.client == name { j.kept } else { 0 }, a + j.kept)) + } + + /// What the results of `name`'s jobs still queued or running may hold + /// once they end, bytes: each its bound ([`Job::bound`]), one not known + /// at the most a job's result may take ([`Config::result_mib`]). + fn running_results(&self, name: &str) -> u64 { + let cap = self.config.result_mib << 20; + let jobs = self.jobs.lock().unwrap(); + jobs.values().filter(|j| j.client == name && j.status.active()).map(|j| j.bound.min(cap)).sum() + } + + /// What every result not yet read may hold, bytes: those of ended jobs + /// as they are kept ([`Service::unread`]), and those of every job still + /// queued or running at its bound, as [`Service::running_results`] + /// counts a client's. What the service-wide cap on unread results holds + /// ([`Config::unread_mib`]): a job is taken only while this and its own + /// bound stay under it, so ended jobs' unread results never pass it. + fn unread_all(&self) -> u64 { + let cap = self.config.result_mib << 20; + let now = now_ms(); + let min = self.config.kept_min.as_millis() as u64; + let jobs = self.jobs.lock().unwrap(); + jobs.values() + .map(|j| if j.status.active() { j.bound.min(cap) } else if !j.read && j.finished_ms.is_some_and(|f| now.saturating_sub(f) < min) { j.kept } else { 0 }) + .sum() + } + + /// The service-wide unread cap's refusal of a job whose result may take + /// `bound` bytes, if it is past the cap ([`Service::unread_all`]). + fn past_unread_cap(&self, bound: u64) -> Option { + let all = self.unread_all(); + (all.saturating_add(bound) > self.config.unread_mib << 20).then(|| { + Refused(503, tag(refusal::BUSY, format!("results not yet read, with those of the jobs queued and running, could hold about {} MiB, and this one {:.1} MiB more; all the service keeps unread is {} MiB: try again in a few minutes", all >> 20, bound as f64 / f64::from(1 << 20), self.config.unread_mib))) + }) + } + /// The client's active jobs and the memory they hold, MiB. fn held(&self, name: &str) -> (usize, u64) { let jobs = self.jobs.lock().unwrap(); @@ -656,13 +1127,62 @@ impl Service { (mine.len(), mine.iter().map(|j| j.plan.memory_bytes.div_ceil(1 << 20)).sum()) } + /// Whether `client` is refused whatever it sends, by what reads nothing: + /// its jobs at once, or a full queue. Checked before a submission is + /// counted, so a client told to try again shortly spends nothing doing so. + pub fn refused_before_reading(&self, client: &str) -> Option { + let (mine, _) = self.unread(&self.name(client)); + // Each job still running counted at its bound, and this one, not yet + // read, at the most a job's result may take. + let room = mine + self.running_results(&self.name(client)) + (self.config.result_mib << 20); + if room > self.config.client_unread_mib << 20 { + let room = room.div_ceil(1 << 20); + return Some(Refused(429, tag(refusal::RATE, format!("results of yours not yet read hold about {} MiB, and with your jobs running and this one they could hold {room} MiB, more than the {} MiB one client's unread results may; read them (GET /v1/jobs/{{id}}) before sending more, or wait {} s", mine >> 20, self.config.client_unread_mib, self.config.kept_min.as_secs())))); + } + // Every result not yet read, and this one at the most a job's may take. + if let Some(r) = self.past_unread_cap(self.config.result_mib << 20) { + return Some(r); + } + let (active, held) = self.held(&self.name(client)); + if active >= self.config.per_client_active { + return Some(Refused(429, tag(refusal::RATE, format!("{active} of your jobs are queued or running; wait for one to finish (at most {} at once)", self.config.per_client_active)))); + } + if held >= self.config.per_client_mib { + return Some(Refused(429, tag(refusal::RATE, format!("your queued and running jobs already hold about {held} MiB, all one client may hold at once; wait for one to finish")))); + } + let queued = self.queue.lock().unwrap().len(); + if queued >= self.config.max_queue { + return Some(Refused(503, tag(refusal::BUSY, format!("the queue is full ({} jobs); try again later", self.config.max_queue)))); + } + None + } + /// Whether `client` may submit a job planned as `plan` now, by every /// limit but its submission window: the refusal it would get, or nothing. - pub fn would_refuse(&self, client: &str, plan: &Plan) -> Option { + /// `bound` is the most its result takes as served (`http::plan_job`): + /// past [`Config::result_mib`] it is refused at once, before it runs. + pub fn would_refuse(&self, client: &str, plan: &Plan, bound: Option) -> Option { let job_mib = plan.memory_bytes.div_ceil(1 << 20); if job_mib > self.config.job_mib { return Some(Refused(422, tag(refusal::TOO_LARGE_HERE, 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 cap = self.config.result_mib << 20; + let mib = |b: u64| b as f64 / f64::from(1 << 20); + if let Some(b) = bound.filter(|b| b.bytes() > cap) { + // Named by its largest part, and what makes that part smaller. + let (part, what, fix) = b.largest(); + return Some(Refused(422, tag(refusal::TOO_LARGE_HERE, format!("its result could take up to {:.1} MiB as served, {:.1} MiB of it {what}: more than the {} MiB one job's result here may take; {fix}, split it into several jobs, or run it on your own machine (`wai-quantum run`)", mib(b.bytes()), mib(part), self.config.result_mib)))); + } + let bound = bound.map(|b| b.bytes()); + if let Some(r) = self.past_unread_cap(bound.unwrap_or(cap).min(cap)) { + return Some(r); + } + let name = self.name(client); + let (mine, _) = self.unread(&name); + let room = mine + self.running_results(&name) + bound.unwrap_or(cap).min(cap); + if room > self.config.client_unread_mib << 20 { + return Some(Refused(429, tag(refusal::RATE, format!("results of yours not yet read hold about {} MiB, and with your jobs running and this one they could hold {} MiB, more than the {} MiB one client's unread results may; read them (GET /v1/jobs/{{id}}) before sending more, or wait {} s", mine >> 20, room.div_ceil(1 << 20), self.config.client_unread_mib, self.config.kept_min.as_secs())))); + } let (active, held) = self.held(&self.name(client)); if active >= self.config.per_client_active { return Some(Refused(429, tag(refusal::RATE, format!("{active} of your jobs are queued or running; wait for one to finish (at most {} at once)", self.config.per_client_active)))); @@ -684,24 +1204,28 @@ impl Service { } /// The job `digest` names, if it still answers for it: its id when it - /// was sent with the request whose digest is `request`, or the refusal - /// of a key sent with another. - fn named_by(&self, keys: &HashMap, digest: &str, request: &str) -> Result, Refused> { + /// was sent with the request whose digest is `request`, as it was received + /// or as it read. Another request is the refusal of a key sent with + /// another, where `read` says that `request` is what it reads as; one not + /// yet read may read as the same request, so it is no answer yet. + fn named_by(&self, keys: &HashMap, digest: &str, request: &str, read: bool) -> Result, Refused> { let Some(id) = keys.get(digest) else { return Ok(None) }; let jobs = self.jobs.lock().unwrap(); match jobs.get(id) { - Some(j) if j.status.holds_key() && j.digests.request.as_deref() == Some(request) => Ok(Some(id.clone())), - Some(j) if j.status.holds_key() => Err(Refused(422, tag(refusal::IDEMPOTENCY_KEY, "this idempotency key was sent with another request; a key names one job, so send this request with a new key"))), + Some(j) if j.status.holds_key() && (j.digests.request.as_deref() == Some(request) || j.received.as_deref() == Some(request)) => Ok(Some(id.clone())), + Some(j) if j.status.holds_key() && read => Err(Refused(422, tag(refusal::IDEMPOTENCY_KEY, "this idempotency key was sent with another request; a key names one job, so send this request with a new key"))), _ => Ok(None), } } /// The job `key` names for `client` and `request`, if one does: what a - /// submission sent again is answered with, before it is planned. + /// submission sent again is answered with, before it is planned. It + /// reads nothing: a request in another format (`"source"`) is matched as + /// it was received, and sent any other way it is read, and counted, first. pub fn named(&self, client: &str, key: Key, request: &Value) -> Result, Refused> { let digest = Self::key_digest(&self.name(client), key); let keys = self.keys.lock().unwrap(); - self.named_by(&keys, &digest, &store::digest(&canon::canonical(request))) + self.named_by(&keys, &digest, &received_digest(request), !sourced(request)) } /// Admits a planned job for `client`, or says why not. Its request and @@ -714,7 +1238,23 @@ impl Service { /// [`Service::submit`] for one program or a set's items, sent with a key /// or without one: a request sent again with its key is answered with /// the key's job, before any limit, and counts as nothing. + #[cfg(test)] pub fn submit_work(&self, client: &str, request: Value, plan: Plan, work: Work, key: Option) -> Result { + self.keep_work(client, request, plan, work, key, None, None, true) + } + + /// [`Service::submit_work`] for a submission already counted against + /// its client's window (`http::admit` counts before it reads anything), + /// `received` being the digest of the request as it came, where that is + /// not the request as it read, and `bound` the most its result takes as + /// served (`http::plan_job`). + #[allow(clippy::too_many_arguments)] + pub fn submit_counted(&self, client: &str, request: Value, plan: Plan, work: Work, key: Option, received: Option, bound: Option) -> Result { + self.keep_work(client, request, plan, work, key, received, bound, false) + } + + #[allow(clippy::too_many_arguments)] + fn keep_work(&self, client: &str, request: Value, plan: Plan, work: Work, key: Option, received: Option, bound: Option, count: bool) -> Result { let name = self.name(client); let canonical = canon::canonical(&request); let request_digest = store::digest(&canonical); @@ -722,17 +1262,19 @@ impl Service { // Held until the job is kept, so two sends of one key make one job. let mut keys = key.as_ref().map(|_| self.keys.lock().unwrap()); if let (Some(keys), Some(k)) = (&keys, &key) - && let Some(id) = self.named_by(keys, k, &request_digest)? + && let Some(id) = self.named_by(keys, k, &request_digest, true)? { return Ok(id); } // A full queue is checked again below, as the job is queued. - if let Some(r) = self.would_refuse(client, &plan) + if let Some(r) = self.would_refuse(client, &plan, bound) && r.0 != 503 { return Err(r); } - self.count(client)?; + if count { + self.count(client)?; + } let unkept = |e: String| Refused(503, tag(refusal::BUSY, format!("the job store could not keep the job ({e}); try again later"))); let programs: Vec<&str> = match &work { Work::One => request.get("qasm").and_then(Value::as_str).into_iter().collect(), @@ -742,11 +1284,14 @@ impl Service { for p in programs { digests.programs.push(self.store.put(p.as_bytes()).map_err(unkept)?); } + if let Work::Set { items, .. } = &work { + digests.items = Some(self.store.put(items_json(items).to_string().as_bytes()).map_err(unkept)?); + } let id = job_id(); let job = Job { id: id.clone(), client: name, - request, + request: String::from_utf8_lossy(&canonical).into(), plan, work, status: Status::Queued, @@ -758,18 +1303,26 @@ impl Service { peak_rss: 0, digests, key: key.clone(), + received, interruptions: Vec::new(), child: None, + kept: 0, read: false, + bound: bound.map_or(u64::MAX, |b| b.bytes()), }; { + // Under the queue's lock, so no two jobs pass the caps on one + // count: the queue's, and every unread result's with this one's. let mut queue = self.queue.lock().unwrap(); if queue.len() >= self.config.max_queue { return Err(Refused(503, tag(refusal::BUSY, format!("the queue is full ({} jobs); try again later", self.config.max_queue)))); } + if let Some(r) = self.past_unread_cap(job.bound.min(self.config.result_mib << 20)) { + return Err(r); + } self.store.put_record(&id, &job.record()).map_err(unkept)?; + self.jobs.lock().unwrap().insert(id.clone(), job); queue.push_back(id.clone()); } - self.jobs.lock().unwrap().insert(id.clone(), job); if let (Some(keys), Some(k)) = (keys.as_mut(), key) { keys.insert(k, id.clone()); } @@ -779,9 +1332,46 @@ impl Service { Ok(id) } - /// The job's report, if it is known. + /// The job's report, if it is known: [`Service::report`] read, the job + /// counted as read. pub fn get(&self, id: &str) -> Option { - self.jobs.lock().unwrap().get(id).map(Job::to_json) + self.report(id).and_then(|t| serde_json::from_str(&t).ok()) + } + + /// The job's report but its request and result, not counted as read: + /// for what states a job's status and not its result (a task's state). + pub fn peek(&self, id: &str) -> Option { + self.jobs.lock().unwrap().get(id).map(Job::lean) + } + + /// The job's report as RFC 8785 text, its request and its result written + /// in as the text each is kept as, never read: what every route answers + /// with, so a job's bytes are the same on each, and before a restart and + /// after. An ended job served counts as read ([`Service::trim`]). + pub fn report(&self, id: &str) -> Option { + self.report_as(id, true) + } + + fn report_as(&self, id: &str, read: bool) -> Option { + let (mut lean, request, result) = { + let mut jobs = self.jobs.lock().unwrap(); + let j = jobs.get_mut(id)?; + if read && j.finished_ms.is_some() { + j.read = true; + } + (j.lean(), std::sync::Arc::clone(&j.request), j.result.clone()) + }; + lean["request"] = json!(REQUEST_HERE); + if result.is_some() { + lean["result"] = json!(RESULT_HERE); + } + let mut out = String::from_utf8(canon::canonical(&lean)).ok()?; + // Each where RFC 8785 sorts it among the report's keys. + if let Some(r) = &result { + out = out.replacen(&format!("\"result\":{}", canonical_str(RESULT_HERE)), &format!("\"result\":{r}"), 1); + } + out = out.replacen(&format!("\"request\":{}", canonical_str(REQUEST_HERE)), &format!("\"request\":{request}"), 1); + Some(out) } /// Whether the job `id` is `client`'s. @@ -829,8 +1419,7 @@ impl Service { } /// Keeps a finished job's timing for the dry run's estimates. - fn time(&self, job: &Job) { - let Some(r) = &job.result else { return }; + fn time(&self, job: &Job, r: &Value) { let mut t = self.timings.lock().unwrap(); let mut push = |method: &str, work: u64, r: &Value, energy: &Value| { let Some(elapsed_ms) = r["elapsed_ms"].as_u64() else { return }; @@ -932,7 +1521,7 @@ impl Service { fn sweep(&self) { let keep: BTreeSet = { let jobs = self.jobs.lock().unwrap(); - jobs.values().flat_map(|j| j.digests.request.iter().chain(&j.digests.programs).chain(&j.digests.result).chain(&j.digests.receipt).cloned().collect::>()).collect() + jobs.values().flat_map(|j| j.digests.request.iter().chain(&j.digests.programs).chain(&j.digests.items).chain(&j.digests.result).chain(&j.digests.receipt).cloned().collect::>()).collect() }; if let Err(e) = self.store.sweep(&keep) { eprintln!("the job store: sweeping: {e}"); @@ -1001,7 +1590,7 @@ impl Service { j.started_ms = Some(now_ms()); let _ = self.keep(j); let input = match &j.work { - Work::One => json!({ "request": j.request, "plan": j.plan }), + Work::One => json!({ "request": j.request(), "plan": j.plan }), Work::Set { items, plans } => json!({ "set": items.iter().zip(plans).map(|(i, p)| json!({ "request": i.request, "plan": p })).collect::>() }), }; Some((input, matches!(j.plan.method.as_str(), "statevector_single" | "cutrank_single") || matches!(&j.work, Work::Set { plans, .. } if plans.iter().any(|p| matches!(p.method.as_str(), "statevector_single" | "cutrank_single"))), others)) @@ -1027,7 +1616,7 @@ impl Service { /// the store's writes are done outside the lock on the jobs. fn finish(&self, id: &str, ran: Ran) { let Ran { outcome, peak, energy: figure, elapsed_ms } = ran; - let (request, work) = { + let (request, work, request_len) = { let mut jobs = self.jobs.lock().unwrap(); let Some(j) = jobs.get_mut(id) else { return }; j.child = None; @@ -1036,20 +1625,27 @@ impl Service { let _ = self.keep(j); return; } - (j.request.clone(), j.work.clone()) + (j.request(), j.work.clone(), j.request.len()) }; let finished_ms = now_ms(); let mut kept: (Option, Option) = (None, None); - let outcome = outcome.and_then(|out| self.seal(&request, &work, out, figure, elapsed_ms)).map(|(result, receipt)| { - // Served as the store has it, so it reads the same before a restart and after. + let outcome = outcome.and_then(|out| self.seal(&request, &work, out, figure, elapsed_ms)).and_then(|(mut result, receipt)| { let bare = canon::canonical(&result); - match (self.store.put(&bare), self.store.put(&canon::canonical(&receipt))) { + // Served as RFC 8785 text, receipt and all, as a restart reads it back. + result["receipt"] = receipt; + let text = served_text(&result); + // A backstop: a job is admitted only on a bound of its result + // within the cap ([`Service::would_refuse`]), so this is a job + // kept across a restart that lowered the cap (every built-in + // method's result is bounded before it runs, a pulse run's too). + if text.len() as u64 > self.config.result_mib << 20 { + return Err(tag(refusal::TOO_LARGE_HERE, format!("its result is {} MiB as served, more than the {} MiB one job's result here may take; send fewer points or fewer shots, or run it on your own machine (`wai-quantum run`)", text.len() >> 20, self.config.result_mib))); + } + match (self.store.put(&bare), self.store.put(&canon::canonical(&result["receipt"]))) { (Ok(r), Ok(s)) => kept = (Some(r), Some(s)), (Err(e), _) | (_, Err(e)) => eprintln!("the job store: {id}: its result: {e}"), } - let mut served: Value = serde_json::from_slice(&bare).unwrap_or(result); - served["receipt"] = receipt; - served + Ok((result, text)) }); // The record as the job ends, written before the end is seen. let record = { @@ -1084,20 +1680,97 @@ impl Service { } j.finished_ms = Some(finished_ms); match outcome { - Ok(served) => { + Ok((served, text)) => { j.status = Status::Done; - j.result = Some(served); + self.time(j, &served); + j.result = Some(text); j.digests.result = kept.0; j.digests.receipt = kept.1; - self.time(j); } Err(e) => { j.status = Status::Failed; j.error = Some(e); } } + j.ended(); } + // Trimmed before the end is seen: the job is not yet read, so it is + // kept, and whoever waits for it reads it. + self.trim(); self.changed(id); + // A large request was read to run and to seal it: given back. + if request_len > 256 << 10 { + give_back(); + } + } + + /// Ended jobs past what they may keep, dropped: each client's past + /// [`Config::client_kept_mib`], its oldest first, then all past + /// [`Config::kept_mib`], the oldest of the client that keeps the most + /// first, so many clients together drop the largest holder's results, + /// not another's. An ended job not yet read is passed by for + /// [`Config::kept_min`] whatever it weighs: a result is never dropped + /// before its client could read it. While such jobs alone are past a cap, + /// new jobs are refused instead ([`Service::refused_before_reading`]). A + /// job is kept for [`Config::retain`], or until these say otherwise. + fn trim(&self) { + let gone: Vec = { + let mut jobs = self.jobs.lock().unwrap(); + let now = now_ms(); + let min = self.config.kept_min.as_millis() as u64; + // (ended, id, client, kept, passed by) + let mut ended: Vec<(u64, String, String, u64, bool)> = jobs + .values() + .filter_map(|j| j.finished_ms.map(|f| (f, j.id.clone(), j.client.clone(), j.kept, !j.read && now.saturating_sub(f) < min))) + .collect(); + ended.sort(); + // Read results are counted and dropped apart from those not yet + // read, which are dropped by none of this ([`Config::unread_mib`]). + let mut by_client: HashMap = HashMap::new(); + let mut total = 0u64; + for (_, _, c, k, passed) in &ended { + if !*passed { + *by_client.entry(c.clone()).or_default() += k; + total += k; + } + } + let (client_cap, cap) = (self.config.client_kept_mib << 20, self.config.kept_mib << 20); + let mut gone = Vec::new(); + let mut left: HashMap> = HashMap::new(); + for (f, id, c, k, passed) in &ended { + if *passed { + continue; + } + let mine = by_client.get(c).copied().unwrap_or(0); + if mine > client_cap { + by_client.insert(c.clone(), mine - k); + total -= k; + gone.push(id.clone()); + } else { + left.entry(c.clone()).or_default().push_back((*f, id.clone(), *k)); + } + } + // The client keeping the most goes first; between two keeping as + // much, the one whose results are newest, so those who came first keep theirs. + while total > cap { + let Some(c) = left.iter().filter_map(|(c, q)| Some((by_client.get(c).copied().unwrap_or(0), q.back()?.0, c))).max().map(|(_, _, c)| c.clone()) else { break }; + let Some((_, id, k)) = left.get_mut(&c).and_then(VecDeque::pop_front) else { break }; + *by_client.entry(c).or_default() -= k; + total -= k; + gone.push(id); + } + for id in &gone { + jobs.remove(id); + } + gone + }; + for id in &gone { + let _ = self.store.remove_record(id); + } + if !gone.is_empty() { + self.keys.lock().unwrap().retain(|_, id| !gone.contains(id)); + self.sweep(); + } } /// A finished worker's output sealed: the result without its receipt, @@ -1136,7 +1809,7 @@ impl Service { /// long it took. async fn supervise(&self, id: &str, input: Value, mib: u64, gpu: bool, others: u64, start: u64) -> Ran { let failed = |e: String| Ran { outcome: Err(e), peak: 0, energy: Value::Null, elapsed_ms: 0 }; - let exe = match std::env::current_exe() { + let exe = match self_exe() { Ok(e) => e, Err(e) => return failed(format!("the service could not find itself: {e}")), }; @@ -1157,6 +1830,10 @@ impl Service { j.child = Some(pid); } let text = input.to_string(); + drop(input); + if text.len() > 256 << 10 { + give_back(); + } if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(text.as_bytes()).await; } @@ -1202,14 +1879,12 @@ impl Service { if let Some(rss) = rss_bytes(pid) { peak = peak.max(rss); if rss > limit { - kill(pid); - let _ = child.wait().await; + stop(&mut child, pid).await; break Err(format!("the job used {} MiB, over the {} MiB it was admitted with; it was stopped", rss >> 20, mib)); } } if Instant::now() > deadline { - kill(pid); - let _ = child.wait().await; + stop(&mut child, pid).await; break Err(format!("the job ran past {} s; it was stopped", self.config.job_timeout.as_secs())); } if exited.is_none() { @@ -1266,6 +1941,25 @@ struct Ran { } /// The worker command at a lower priority than the host's other work. +/// This binary run in `mode` (`read`, a request's reading), in the sandbox a +/// worker runs in where `sandbox` says so, at the service's own priority: +/// its client waits for it. +pub(crate) fn sandboxed(exe: &std::path::Path, sandbox: bool, mode: &str) -> tokio::process::Command { + let mut cmd = if cfg!(target_os = "macos") && sandbox { + let exe = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf()); + let mut c = tokio::process::Command::new("/usr/bin/sandbox-exec"); + c.arg("-p").arg(worker_profile(&exe)).arg(exe); + c + } else { + tokio::process::Command::new(exe) + }; + cmd.arg(mode); + // A group of its own, so that killing it kills what it started too. + #[cfg(unix)] + cmd.process_group(0); + cmd +} + fn low_priority(exe: &std::path::Path, sandbox: bool) -> tokio::process::Command { let mut cmd = if cfg!(target_os = "macos") { let mut c = tokio::process::Command::new("/usr/sbin/taskpolicy"); @@ -1285,6 +1979,8 @@ fn low_priority(exe: &std::path::Path, sandbox: bool) -> tokio::process::Command c }; cmd.arg("worker"); + #[cfg(unix)] + cmd.process_group(0); cmd } @@ -1302,26 +1998,69 @@ pub fn worker_profile(exe: &std::path::Path) -> String { ) } -fn kill(pid: u32) { - if pid > 0 { - let _ = std::process::Command::new("kill").args(["-9", &pid.to_string()]).status(); +/// Kills a reader or a worker, and whatever it started: a signal sent by the +/// service itself, which needs no process of its own, so it works when no new +/// process can be started. The child leads a process group of its own +/// ([`sandboxed`]), so the group is sent the signal, then the child: whether +/// either was sent (or the child is already gone). +pub(crate) fn kill(pid: u32) -> bool { + if pid == 0 { + return false; + } + #[cfg(unix)] + { + let Ok(pid) = i32::try_from(pid) else { return false }; + // SAFETY: plain signals to a child of this process and its group. + let group = unsafe { libc::kill(-pid, libc::SIGKILL) } == 0; + let one = unsafe { libc::kill(pid, libc::SIGKILL) } == 0; + group || one || std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + } + #[cfg(not(unix))] + { + std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().is_ok_and(|s| s.success()) } } -/// A process's resident memory, bytes, the worker's child included where the -/// platform folds it in. -fn rss_bytes(pid: u32) -> Option { +/// Stops `child`: killed, and killed again every 200 ms until it has ended, +/// so a signal that did not take is sent again rather than waited out. +pub(crate) async fn stop(child: &mut tokio::process::Child, pid: u32) { + loop { + kill(pid); + let _ = child.start_kill(); + if let Ok(Ok(_)) = tokio::time::timeout(Duration::from_millis(200), child.wait()).await { + return; + } + } +} + +/// A process's memory, bytes, as its cap counts it: on Linux resident and +/// swapped, on macOS its physical footprint (compressed pages included). +pub(crate) fn rss_bytes(pid: u32) -> Option { if pid == 0 { return None; } + // Linux: resident and swapped, so a page swapped out still counts. if cfg!(target_os = "linux") { let s = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?; - let kb: u64 = s.lines().find(|l| l.starts_with("VmRSS:"))?.split_whitespace().nth(1)?.parse().ok()?; - return Some(kb * 1024); + let kb = |field: &str| -> Option { s.lines().find(|l| l.starts_with(field))?.split_whitespace().nth(1)?.parse().ok() }; + return Some((kb("VmRSS:")? + kb("VmSwap:").unwrap_or(0)) * 1024); + } + // macOS: the physical footprint, what the kernel charges the process, + // compressed and swapped pages included, which resident pages are not. + // Asked of the kernel: this runs for every child, many times a second. + #[cfg(target_os = "macos")] + { + let mut info: libc::rusage_info_v2 = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is a `rusage_info_v2`, the flavour asked for. + let got = unsafe { libc::proc_pid_rusage(pid as i32, libc::RUSAGE_INFO_V2, (&raw mut info).cast()) }; + (got == 0).then_some(info.ri_phys_footprint) + } + #[cfg(not(target_os = "macos"))] + { + let o = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid.to_string()]).output().ok()?; + let kb: u64 = String::from_utf8_lossy(&o.stdout).trim().parse().ok()?; + Some(kb * 1024) } - let o = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid.to_string()]).output().ok()?; - let kb: u64 = String::from_utf8_lossy(&o.stdout).trim().parse().ok()?; - Some(kb * 1024) } /// Whether the host's free memory covers `bytes` with `reserve_pct` of its @@ -1383,9 +2122,137 @@ mod tests { sandbox: true, per_client_burst: 3, per_client_window: Duration::from_secs(60), + read_mib: 4096, + read_timeout: Duration::from_secs(300), + read_answer_mib: 128, + qec_secs: 25, + answer_secs: 55, + client_kept_mib: 128, + kept_mib: 2048, + kept_min: Duration::from_secs(600), + client_unread_mib: 256, + unread_mib: 8192, + result_mib: 64, + client_verifies: 120, + client_requests: 32, + connections: 4096, + client_streams: 4, + streams: 512, + read_in_child: false, + per_client_reads: 2, + per_client_checks: 1, + header_wait: Duration::from_secs(30), + body_wait: Duration::from_secs(30), } } + /// What ended jobs keep is their request's text, not its parsed tree, and + /// it is held to its caps: past a client's, its oldest ended jobs go first, + /// and past the service's, the largest holder's, the newest between equals, + /// so many clients cannot drop an earlier one's; jobs still running are + /// kept, and so is an ended job not yet read, whatever it weighs. + #[test] + fn ended_jobs_keep_their_request_as_text_within_their_caps() { + let s = Service::new(Config { client_kept_mib: 1, kept_mib: 2, client_unread_mib: 64, ..config() }); + let pad = |k: usize| serde_json::json!({ "qasm": "OPENQASM 3.0; qubit q; h q;", "pad": vec![k; 300_000] }); + let job = |client: &str, k: usize, ended: Option| { + let request = pad(k); + let text = String::from_utf8(canon::canonical(&request)).unwrap(); + let mut j = Job { id: job_id(), client: s.name(client), request: text.into(), plan: plan(1 << 20), work: Work::One, status: if ended.is_some() { Status::Done } else { Status::Running }, submitted_ms: 0, started_ms: None, finished_ms: ended, result: Some(served_text(&serde_json::json!({ "counts": { "0": 1 } }))), error: None, peak_rss: 0, digests: Digests::default(), key: None, received: None, interruptions: Vec::new(), child: None, kept: 0, read: false, bound: u64::MAX }; + if ended.is_some() { + j.ended(); + } + assert!(j.kept < 700_000, "kept as text: {}", j.kept); + assert_eq!(j.request()["pad"].as_array().map(Vec::len), Some(300_000)); + j + }; + let mut ids = Vec::new(); + for (client, k, ended) in [("a", 1, Some(1)), ("a", 2, Some(2)), ("a", 3, Some(3)), ("a", 4, None), ("b", 5, Some(4)), ("c", 6, Some(5)), ("d", 7, Some(6))] { + let j = job(client, k, ended); + ids.push(j.id.clone()); + s.jobs.lock().unwrap().insert(j.id.clone(), j); + } + for j in s.jobs.lock().unwrap().values_mut() { + j.read = true; + } + s.trim(); + let have: Vec = ids.iter().map(|id| s.jobs.lock().unwrap().contains_key(id)).collect(); + // a's two oldest ended jobs go for its client's cap; then, all four + // clients keeping as much, the newest for the service's (d's); the + // running one stays. + assert_eq!(have, [false, false, true, true, true, true, false], "{have:?}"); + let kept: u64 = s.jobs.lock().unwrap().values().filter(|j| j.finished_ms.is_some()).map(|j| j.kept).sum(); + assert!(kept <= 2 << 20, "{kept}"); + // An ended job past its client's cap, not yet read, stays until it is. + let big = job("e", 8, Some(now_ms())); + let (big_id, mut bigger) = (big.id.clone(), job("e", 9, Some(now_ms()))); + bigger.kept = 2 << 20; + let bigger_id = bigger.id.clone(); + s.jobs.lock().unwrap().insert(big_id.clone(), big); + s.jobs.lock().unwrap().insert(bigger_id.clone(), bigger); + s.trim(); + assert!(s.jobs.lock().unwrap().contains_key(&bigger_id), "kept until it is read"); + assert!(s.refused_before_reading("e").is_some(), "and the client's next job waits for it to be read"); + assert!(s.report(&bigger_id).is_some()); + s.trim(); + assert!(!s.jobs.lock().unwrap().contains_key(&bigger_id), "read, it goes"); + } + + /// Past the service's cap only read results go, the largest holder's + /// first, so results not yet read survive however many clients press + /// on it; and a client's next job is refused while its unread results, + /// with what its running jobs may give, would pass its allowance. + #[test] + fn read_results_go_before_unread_ones_and_unread_ones_hold_their_client_s_next_job() { + let s = Service::new(Config { client_kept_mib: 4, kept_mib: 2, client_unread_mib: 64, result_mib: 64, ..config() }); + let job = |client: &str, ended: u64, read: bool| { + let text = String::from_utf8(canon::canonical(&serde_json::json!({ "qasm": "OPENQASM 3.0; qubit q; h q;", "pad": vec![1; 300_000] }))).unwrap(); + let mut j = Job { id: job_id(), client: s.name(client), request: text.into(), plan: plan(1 << 20), work: Work::One, status: Status::Done, submitted_ms: 0, started_ms: None, finished_ms: Some(ended), result: None, error: None, peak_rss: 0, digests: Digests::default(), key: None, received: None, interruptions: Vec::new(), child: None, kept: 0, read, bound: u64::MAX }; + j.ended(); + let id = j.id.clone(); + s.jobs.lock().unwrap().insert(id.clone(), j); + id + }; + let now = now_ms(); + // An honest client's unread result, then four others' read ones. + let honest = job("honest", now, false); + let others: Vec = (0..4).map(|i| job(&format!("other-{i}"), now + 1 + i, true)).collect(); + s.trim(); + assert!(s.jobs.lock().unwrap().contains_key(&honest), "an unread result is not dropped for others' read ones"); + let left = others.iter().filter(|id| s.jobs.lock().unwrap().contains_key(*id)).count(); + assert!(left < 4, "read results go past the service's cap"); + assert!(s.refused_before_reading("honest").is_some(), "its unread result and a next job's room pass its allowance"); + assert!(s.refused_before_reading("fresh").is_none(), "another client is not refused for it"); + } + + /// A job whose result's bound passes the cap on a job's result is + /// refused before it runs (`422`, `J031`), naming the bound and the cap; + /// one at the cap is taken. A client's jobs still running are counted + /// against its unread allowance at their own bounds, where each was + /// counted at the cap: seven small jobs running leave room for an + /// eighth, which seven caps had refused. + #[test] + fn a_result_s_bound_is_held_to_the_cap_and_running_jobs_count_their_own() { + let s = Service::new(Config { result_mib: 1, client_unread_mib: 4, per_client_active: 8, max_queue: 16, ..config() }); + let p = plan(1 << 20); + let Refused(code, why) = s.would_refuse("a", &p, Some(Bound { counts: (1 << 20) + 1, ..Bound::default() })).expect("past the cap"); + assert!(code == 422 && why.contains("J031") && why.contains("1.0 MiB") && why.contains("the 1 MiB"), "{code} {why}"); + assert!(s.would_refuse("a", &p, Some(Bound { counts: 1 << 20, ..Bound::default() })).is_none()); + for k in 0..7 { + s.keep_work("a", json!({ "qasm": "OPENQASM 3.0; qubit q; h q;", "shots": k + 1 }), p.clone(), Work::One, None, None, Some(Bound { rest: 4096, ..Bound::default() }), false).unwrap(); + } + assert!(s.refused_before_reading("a").is_none(), "seven small results and a next job's room fit in 4 MiB"); + assert!(s.would_refuse("a", &p, Some(Bound { rest: 4096, ..Bound::default() })).is_none()); + // Not known (a job kept across a restart), a job running is counted at the cap. + let t = Service::new(Config { result_mib: 1, client_unread_mib: 4, per_client_active: 8, max_queue: 16, ..config() }); + for k in 0..3 { + t.keep_work("a", json!({ "qasm": "OPENQASM 3.0; qubit q; h q;", "shots": k + 1 }), p.clone(), Work::One, None, None, None, false).unwrap(); + } + assert_eq!(t.refused_before_reading("a").map(|r| r.0), None, "three caps and a next job's fit in 4 MiB"); + t.keep_work("a", json!({ "qasm": "OPENQASM 3.0; qubit q; h q;", "shots": 9 }), p.clone(), Work::One, None, None, None, false).unwrap(); + assert_eq!(t.refused_before_reading("a").map(|r| r.0), Some(429), "four caps and a next job's do not"); + } + fn ids_of(s: &Service, client: &str) -> Vec { let name = s.name(client); s.jobs.lock().unwrap().values().filter(|j| j.client == name).map(|j| j.id.clone()).collect() @@ -1461,6 +2328,48 @@ mod tests { /// A key outlives the service: reopened, the store's job still answers /// for it, but for the job that was running, now interrupted. + /// What the service waits for sits inside what the proxy in front of it + /// waits for (the public deployment's: 60 s for an answer's header, two + /// minutes for an idle connection kept open): a reading and its wait for a + /// turn end before the proxy gives up on them, so its client hears why; + /// an idle connection is the proxy's to close, not the service's, so no + /// request sent on one is lost as it closes; an error-correction call's + /// shots end well inside the reading's time; and a client's share of each + /// pool is smaller than the pool. + #[test] + fn the_defaults_fit_inside_the_proxy_s() { + let c = Config::from_env(); + assert!(c.read_timeout + crate::offload::WAIT < Duration::from_secs(60), "{:?}", c.read_timeout); + assert!(c.answer_secs < 60 && c.read_timeout + crate::offload::WAIT <= Duration::from_secs(c.answer_secs), "{}", c.answer_secs); + assert!(c.header_wait > Duration::from_secs(120), "{:?}", c.header_wait); + assert!((c.qec_secs as f64) < 0.6 * c.read_timeout.as_secs_f64(), "{}", c.qec_secs); + assert!(c.per_client_reads < crate::offload::AT_ONCE && c.per_client_checks < crate::offload::CHECKS_AT_ONCE); + assert!(c.read_answer_mib <= 128); + } + + /// A restart reads no program: what is queued keeps its plan, and a set + /// its items, so a queue of slow programs delays no restart. These could + /// not be read at all, and come back queued, as they were. + #[test] + fn a_restart_reads_no_program() { + let dir = store_dir("noread"); + let cfg = || Config { per_client_active: 4, per_client_burst: 10, ..config() }; + let unreadable = "OPENQASM 3.0; qubit q; this does not read"; + let (one, set); + { + let s = Service::with_store(cfg(), Box::new(store::Files::open(&dir.join("store")).unwrap())); + one = s.submit_work("a", json!({ "qasm": unreadable, "shots": 5 }), plan(1 << 20), Work::One, None).unwrap(); + let items = vec![sets::Item { request: json!({ "qasm": unreadable, "shots": 5 }), point: Some(json!({ "n": 1 })) }]; + let request = json!({ "qasm": format!("input int n;\n{unreadable}"), "sweep": { "n": [1] } }); + set = s.submit_work("a", request, plan(1 << 20), Work::Set { items, plans: vec![plan(1 << 20)] }, None).unwrap(); + } + let s = Service::open(cfg(), Box::new(store::Files::open(&dir.join("store")).unwrap())).unwrap(); + assert_eq!(s.get(&one).unwrap()["status"], "queued", "{}", s.get(&one).unwrap()); + assert_eq!(s.get(&set).unwrap()["status"], "queued", "{}", s.get(&set).unwrap()); + let jobs = s.jobs.lock().unwrap(); + assert!(matches!(&jobs[&set].work, Work::Set { items, .. } if items.len() == 1 && items[0].point == Some(json!({ "n": 1 })))); + } + #[test] fn a_key_names_its_job_across_a_restart() { let dir = store_dir("keys"); @@ -1666,7 +2575,7 @@ mod tests { let key = wai_quantum::ed25519_dalek::SigningKey::from_bytes(&[19; 32]); let request = json!({ "qasm": "OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];", "shots": 20, "observables": ["Z0 Z1"] }); let (p, mut result) = wai_quantum::engine::job::plan_and_run(&request, 1 << 30).unwrap(); - let mut job = Job { id: job_id(), client: "test".into(), request: request.clone(), plan: p, work: Work::One, status: Status::Queued, submitted_ms: now_ms(), started_ms: None, finished_ms: None, result: None, error: None, peak_rss: 0, digests: Digests::default(), key: None, interruptions: Vec::new(), child: None }; + let mut job = Job { id: job_id(), client: "test".into(), request: request.to_string().into(), plan: p, work: Work::One, status: Status::Queued, submitted_ms: now_ms(), started_ms: None, finished_ms: None, result: None, error: None, peak_rss: 0, digests: Digests::default(), key: None, received: None, interruptions: Vec::new(), child: None, kept: 0, read: false, bound: u64::MAX }; let r = crate::mcp::schemas(); let check = |j: &Job| { let v = j.to_json(); @@ -1683,7 +2592,7 @@ mod tests { job.status = Status::Done; job.finished_ms = Some(now_ms()); job.peak_rss = 1 << 20; - job.result = Some(result); + job.result = Some(served_text(&result)); let done = check(&job); assert!(wai_quantum::engine::job::verify(&done).unwrap().0, "{done}"); assert_eq!(done["result"]["schema"], wai_quantum::engine::schema::JOB_RESULT); @@ -1696,7 +2605,7 @@ mod tests { let items = wai_quantum::engine::sets::expand(&set_request).unwrap(); let plans = wai_quantum::engine::sets::plan(&items, 1 << 30).unwrap(); let body = wai_quantum::engine::sets::run(&set_request, 1 << 30, &key, json!({ "unmetered": "a test" })).unwrap(); - let set = Job { id: job_id(), client: "test".into(), request: set_request, plan: wai_quantum::engine::sets::summary(&plans), work: Work::Set { items, plans }, status: Status::Done, submitted_ms: now_ms(), started_ms: Some(now_ms()), finished_ms: Some(now_ms()), result: Some(body["result"].clone()), error: None, peak_rss: 1 << 20, digests: Digests::default(), key: None, interruptions: Vec::new(), child: None }; + let set = Job { id: job_id(), client: "test".into(), request: set_request.to_string().into(), plan: wai_quantum::engine::sets::summary(&plans), work: Work::Set { items, plans }, status: Status::Done, submitted_ms: now_ms(), started_ms: Some(now_ms()), finished_ms: Some(now_ms()), result: Some(served_text(&body["result"])), error: None, peak_rss: 1 << 20, digests: Digests::default(), key: None, received: None, interruptions: Vec::new(), child: None, kept: 0, read: false, bound: u64::MAX }; let done = check(&set); assert_eq!(wai_quantum::engine::sets::verify(&done)["valid"], true, "{done}"); } @@ -1805,8 +2714,8 @@ mod tests { let s = Service::new(config()); let p = Plan { method: "mps".into(), why: "".into(), memory_bytes: 1, work: 1_000_000_000, qubits: 30, target: None, metered: true }; for _ in 0..3 { - let job = Job { id: job_id(), client: "x".into(), request: json!({}), plan: p.clone(), work: Work::One, status: Status::Done, submitted_ms: 0, started_ms: None, finished_ms: Some(1), result: Some(json!({ "elapsed_ms": 500, "energy": { "joules_micro": 5_000_000, "energy_uncertainty": { "window_us": 500_000 } } })), error: None, peak_rss: 0, digests: Digests::default(), key: None, interruptions: Vec::new(), child: None }; - s.time(&job); + let job = Job { id: job_id(), client: "x".into(), request: "{}".into(), plan: p.clone(), work: Work::One, status: Status::Done, submitted_ms: 0, started_ms: None, finished_ms: Some(1), result: Some(served_text(&json!({ "elapsed_ms": 500, "energy": { "joules_micro": 5_000_000, "energy_uncertainty": { "window_us": 500_000 } } }))), error: None, peak_rss: 0, digests: Digests::default(), key: None, received: None, interruptions: Vec::new(), child: None, kept: 0, read: false, bound: u64::MAX }; + s.time(&job, &serde_json::from_str(job.result.as_deref().unwrap_or("null")).unwrap()); } let (t, _) = s.estimate(&p); assert!(t["basis"].as_str().unwrap().contains("nominal"), "{t}"); @@ -1824,8 +2733,8 @@ mod tests { assert!(t["seconds"].is_null() && t["at_most"] == 5, "{t}"); // Timed jobs here take over, and their metered power gives an energy estimate. for _ in 0..3 { - let job = Job { id: job_id(), client: "x".into(), request: json!({}), plan: Plan { work: 1_000_000_000, ..p.clone() }, work: Work::One, status: Status::Done, submitted_ms: 0, started_ms: None, finished_ms: Some(1), result: Some(json!({ "elapsed_ms": 500, "energy": { "joules_micro": 5_000_000, "energy_uncertainty": { "window_us": 500_000 } } })), error: None, peak_rss: 0, digests: Digests::default(), key: None, interruptions: Vec::new(), child: None }; - s.time(&job); + let job = Job { id: job_id(), client: "x".into(), request: "{}".into(), plan: Plan { work: 1_000_000_000, ..p.clone() }, work: Work::One, status: Status::Done, submitted_ms: 0, started_ms: None, finished_ms: Some(1), result: Some(served_text(&json!({ "elapsed_ms": 500, "energy": { "joules_micro": 5_000_000, "energy_uncertainty": { "window_us": 500_000 } } }))), error: None, peak_rss: 0, digests: Digests::default(), key: None, received: None, interruptions: Vec::new(), child: None, kept: 0, read: false, bound: u64::MAX }; + s.time(&job, &serde_json::from_str(job.result.as_deref().unwrap_or("null")).unwrap()); } let (t, e) = s.estimate(&p); assert!((t["seconds"].as_f64().unwrap() - 1.0).abs() < 1e-9 && t["basis"].as_str().unwrap().contains("this service timed"), "{t}"); diff --git a/wai/wai-quantum-service/src/main.rs b/wai/wai-quantum-service/src/main.rs index ce3b2ff7b..b6dd988d7 100644 --- a/wai/wai-quantum-service/src/main.rs +++ b/wai/wai-quantum-service/src/main.rs @@ -11,6 +11,8 @@ //! wai-quantum-service serve the HTTP API (WAI_QSVC_ADDR, 127.0.0.1:8241) //! wai-quantum-service worker one job: {"request", "plan"} (or a set's //! {"set": [...]}) on stdin, the result on stdout +//! wai-quantum-service read one request's reading (crate::offload::Read) +//! on stdin, its answer on stdout //! wai-quantum-service run FILE one job or set from a JSON file, here, printed //! wai-quantum-service rotate-key retire the receipt key and make a new one //! wai-quantum-service api-key NAME a new API key for a private deployment @@ -30,6 +32,7 @@ mod energy; mod http; mod jobs; mod mcp; +mod offload; use wai_quantum::engine::{catalog, run, sets}; mod receipt; mod store; @@ -42,6 +45,11 @@ fn main() { let args: Vec = std::env::args().collect(); match args.get(1).map(String::as_str) { Some("worker") => worker(), + Some("read") => { + watch_parent(); + jobs::expendable(); + offload::child() + } Some("run") => match args.get(2) { Some(path) => run_file(path), None => usage(), @@ -90,6 +98,7 @@ fn watch_parent() { /// `{"items": [{"result"} | {"error"}, ...]}`. fn worker() { watch_parent(); + jobs::expendable(); let mut input = String::new(); if let Err(e) = std::io::stdin().read_to_string(&mut input) { println!("{}", serde_json::json!({ "error": format!("the job did not arrive: {e}") })); @@ -99,11 +108,29 @@ fn worker() { let out = match serde_json::from_str::(&input) { Ok(v) if v.get("set").is_some() => { let pairs: Option> = v["set"].as_array().and_then(|a| a.iter().map(|it| Some((it.get("request")?.clone(), plan_of(&it["plan"])?))).collect()); + drop(v); match pairs { - Some(pairs) => serde_json::json!({ "items": sets::run_items(&pairs, HOST).into_iter().map(|o| match o { - Ok(r) => serde_json::json!({ "result": r }), - Err(e) => serde_json::json!({ "error": e }), - }).collect::>() }), + // Each item's result written out as it ends, and its tree + // dropped: a counted outcome takes about 21 bytes as text and + // ten times that as a tree, so a set of a hundred items held + // as trees passed the memory its largest item was admitted + // with. The set holds one item's tree at a time, and the text. + Some(pairs) => { + let mut text = String::from("{\"items\":["); + for (i, (request, plan)) in pairs.iter().enumerate() { + if i > 0 { + text.push(','); + } + let item = match run::run_with_provenance(request, plan, HOST) { + Ok(r) => serde_json::json!({ "result": r }), + Err(e) => serde_json::json!({ "error": e }), + }; + text.push_str(&item.to_string()); + } + text.push_str("]}"); + println!("{text}"); + return; + } None => serde_json::json!({ "error": "the set lacks an item's request or plan" }), } } @@ -163,9 +190,77 @@ fn run_file(path: &str) { println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "plan": plan, "result": result })).expect("JSON")); } +/// On macOS, the allocator keeps large blocks it freed, dirty, in a cache +/// charged to the process: a parsed request of a megabyte takes tens of +/// megabytes in blocks that large, so each one read stayed in the service's +/// footprint (measured on arm64: 30 requests of a megabyte, 1 GiB kept; with +/// the cache off, 2 MiB). The allocator reads its settings as the process +/// starts, so the service starts itself again with the cache off, as the same +/// process (`exec`); its readers and workers inherit the setting. +#[cfg(target_os = "macos")] +fn without_large_cache() { + use std::os::unix::process::CommandExt; + if std::env::var_os("MallocLargeCache").is_some() { + return; + } + let Ok(exe) = std::env::current_exe() else { return }; + let e = std::process::Command::new(exe).args(std::env::args_os().skip(1)).env("MallocLargeCache", "0").exec(); + eprintln!("starting again with the allocator's large cache off: {e}; going on with it on"); +} + +/// The open-file limit raised as far as the system lets this process: its +/// soft limit to its hard one (on macOS no further than `kern.maxfilesperproc` +/// and `OPEN_MAX`, as an infinite limit is refused there). Logged, and the +/// limit it ends at returned: the connections the service holds are set +/// under it ([`http::serve`]). +fn open_files() -> u64 { + // SAFETY: getrlimit and setrlimit read and write the struct given, and + // sysctlbyname writes at most the size it is told into the integer given. + unsafe { + let mut l = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; + if libc::getrlimit(libc::RLIMIT_NOFILE, &mut l) != 0 { + eprintln!("open files: the limit could not be read; connections are held under 256"); + return 256; + } + let before = l.rlim_cur; + #[allow(unused_mut)] + let mut want = l.rlim_max; + #[cfg(target_os = "macos")] + { + const OPEN_MAX: libc::rlim_t = 10240; + let mut per: libc::c_int = 0; + let mut size = std::mem::size_of::(); + if libc::sysctlbyname(c"kern.maxfilesperproc".as_ptr(), (&raw mut per).cast(), &mut size, std::ptr::null_mut(), 0) == 0 && per > 0 { + want = want.min(per as libc::rlim_t); + } + want = want.min(OPEN_MAX); + } + if want > before { + let raised = libc::rlimit { rlim_cur: want, rlim_max: l.rlim_max }; + if libc::setrlimit(libc::RLIMIT_NOFILE, &raised) == 0 { + eprintln!("open files: {want} (raised from {before}; the hard limit is {})", l.rlim_max); + return want; + } + eprintln!("open files: {before}; raising it to {want} was refused: {}", std::io::Error::last_os_error()); + return before; + } + eprintln!("open files: {before}, the hard limit"); + before + } +} + fn serve() { + #[cfg(target_os = "macos")] + without_large_cache(); + let files = open_files(); let config = jobs::Config::from_env(); let key_path = receipt::default_path(); + // Readers and workers start from what this keeps, so the binary can be + // replaced before the restart. + let bin = key_path.parent().map(|d| d.join("bin")).unwrap_or_else(|| std::path::PathBuf::from("bin")); + if let Err(e) = jobs::keep_self(&bin) { + eprintln!("keeping the service's binary for its readers and workers: {e}; they start from the file it was started from"); + } let fail = |what: &str, e: String| -> ! { eprintln!("{what}: {e}"); std::process::exit(1) @@ -211,8 +306,18 @@ fn serve() { eprintln!("binding {addr}: {e}"); std::process::exit(1) }); - eprintln!("wai-quantum-service {} on {addr}, receipt key {}", env!("CARGO_PKG_VERSION"), app.signer.public_key()); + // The address bound, which names its port where port 0 asked for any. + let bound = listener.local_addr().unwrap_or(addr); + eprintln!("wai-quantum-service {} on {bound}, receipt key {}", env!("CARGO_PKG_VERSION"), app.signer.public_key()); let router = http::router(Arc::clone(&app)); - axum::serve(listener, router.into_make_service_with_connect_info::()).await.expect("the server"); + let header_wait = app.service.config.header_wait; + // Connections held under the open-file limit, with room beside them + // for readers' and workers' pipes, the store's files and the listener. + let max = app.service.config.connections.min(files.saturating_sub((files / 4).max(64)) as usize).max(16); + // Long polls and event streams held to a quarter of the connections. + let streams = app.service.config.streams.min(max / 4).max(1); + app.service.stream_cap.store(streams, std::sync::atomic::Ordering::SeqCst); + eprintln!("connections: at most {max} at once, {} open requests a client; {streams} long polls and event streams, {} a client", app.service.config.client_requests, app.service.config.client_streams); + http::serve(listener, router, header_wait, max).await; }); } diff --git a/wai/wai-quantum-service/src/mcp.rs b/wai/wai-quantum-service/src/mcp.rs index 6c3265fce..971a481d2 100644 --- a/wai/wai-quantum-service/src/mcp.rs +++ b/wai/wai-quantum-service/src/mcp.rs @@ -82,13 +82,46 @@ fn output_of(tool: &str) -> Option<&'static str> { }) } +/// What an answer's verdict may read. Reading costs what reading a request +/// costs, so it is done in a reading child, for a request that was counted; +/// a poll, or a call that was refused before it was counted, reads nothing. +#[derive(Clone, Copy, Debug)] +pub enum Reading<'a> { + /// The programs, again: the verdict of what they say. + Read, + /// What the job holds: admitted, it read, and a poll reads it no more. + Stored, + /// Nothing: the verdict says why it is not of the program. + Unread(&'a str), +} + +impl Reading<'_> { + /// The verdict of `request` refused for `error`. + fn of_refusal(self, request: &Value, error: &str) -> Value { + match self { + Reading::Read => verdict::of_refusal(request, error), + Reading::Stored => verdict::of_admitted(Some(error)), + Reading::Unread(_) => verdict::of_refusal(&Value::Null, error), + } + } + + /// The verdict of the program `src`. + fn of_text(self, src: &str) -> Value { + match self { + Reading::Read => verdict::of_text(src), + Reading::Stored => verdict::of_admitted(None), + Reading::Unread(why) => verdict::of_refusal(&Value::Null, why), + } + } +} + /// The verdict a dry run states: of the refusal it would give, or of what /// it read (each item of a set). -fn dry_run_verdict(sc: &Value, args: &Value) -> Option { +fn dry_run_verdict(sc: &Value, args: &Value, reading: Reading) -> Option { Some(match sc["refusal"]["message"].as_str().or_else(|| sc["error"].as_str()) { - Some(e) => verdict::of_refusal(args, e), - None if wai_quantum::engine::catalog::is_set(args) => set_verdict(&json!({ "request": args })).unwrap_or(Value::Null), - None => verdict::of_text(args["qasm"].as_str().unwrap_or("")), + Some(e) => reading.of_refusal(args, e), + None if wai_quantum::engine::catalog::is_set(args) => set_verdict(&json!({ "request": args }), reading).unwrap_or(Value::Null), + None => reading.of_text(args["qasm"].as_str().unwrap_or("")), }) } @@ -114,6 +147,9 @@ pub fn versions() -> Value { /// How long a `run_quantum_circuit` call waits before handing back the job id. pub const CALL_WAIT: Duration = Duration::from_secs(50); +/// What an answer keeps, of its time, to read its verdict after a run. +pub const VERDICT_TIME: Duration = Duration::from_secs(5); + const INSTRUCTIONS: &str = "Run quantum programs written in OpenQASM 3 (or 2). Call run_quantum_circuit with the program and what you want back: shots for measurement counts, observables (Pauli strings such as \"Z0 Z1\") for expectation values, amplitudes (bit strings, qubit n-1 first) for amplitudes, and `intent` for what the program must do. The service picks the strongest method the circuit allows and says which and why; every result names its accuracy, states its verdict (how far the engine checked: proof, evidence, executed, parsed or refused) and carries a signature verify_quantum_receipt can check. Counts are keyed by the classical bits, the highest bit first. If a call answers with status \"running\", call get_quantum_job with its id. A client that speaks tasks (MCP 2025-11-25) may call the three runs as tasks: the task is the job, its id the job's, and tasks/get, tasks/result and tasks/cancel follow it. run_quantum_set runs several programs, or one over a grid of its inputs, as one set with one receipt; dry_run_quantum_job says what a job or a set would take (method, memory, time, energy, bounds) and whether the service would take it, running nothing. quantum_circuit_examples gives ready-to-run programs."; /// What `initialize` tells an agent: how to use the service, then the @@ -272,7 +308,7 @@ fn tool_list() -> Value { { "name": "dry_run_quantum_job", "title": "Plan a quantum job or set without running it", - "description": "What the service would do with a job or a set, running nothing: the method and why, the memory, the estimated time and energy (each with its basis: this service's own timings and readings where it has them, otherwise the planner's nominal rate, and no energy figure without readings), the bounds that apply, where you stand against your limits, and whether the service would take it now, or the refusal it would give, with its code. Takes what run_quantum_circuit or run_quantum_set takes. It counts as one submission.", + "description": "What the service would do with a job or a set, running nothing: the method and why, the memory, the estimated time and energy (each with its basis: this service's own timings and readings where it has them, otherwise the planner's nominal rate, and no energy figure without readings), the bounds that apply, where you stand against your limits, and whether the service would take it now, or the refusal it would give, with its code. Takes what run_quantum_circuit or run_quantum_set takes, and plans it as they run it, its verdict asked: what it says they take, they take. It counts as one submission.", "inputSchema": { "type": "object", "properties": plan_props }, "annotations": { "readOnlyHint": true } }, @@ -419,18 +455,19 @@ pub use wai_quantum::engine::views::job_text as summary; /// A set's verdict: its weakest item's (an item not yet run, or whose run /// stated none, what the reader said of it), as the set's, the intents /// holding only if every item's do. -pub fn set_verdict(job: &Value) -> Option { +pub fn set_verdict(job: &Value, reading: Reading) -> Option { let each: Vec = match job.pointer("/result/items").and_then(Value::as_array) { Some(list) => list .iter() .map(|it| { it.pointer("/result/verdict").cloned().unwrap_or_else(|| match it["error"].as_str() { - Some(e) => verdict::of_refusal(&it["request"], e), - None => verdict::of_text(it["request"]["qasm"].as_str().unwrap_or("")), + Some(e) => reading.of_refusal(&it["request"], e), + None => reading.of_text(it["request"]["qasm"].as_str().unwrap_or("")), }) }) .collect(), - None => wai_quantum::engine::sets::expand(job.get("request").unwrap_or(job)).ok()?.iter().map(|it| verdict::of_text(it.request["qasm"].as_str().unwrap_or(""))).collect(), + None if matches!(reading, Reading::Read) => wai_quantum::engine::sets::expand(job.get("request").unwrap_or(job)).ok()?.iter().map(|it| verdict::of_text(it.request["qasm"].as_str().unwrap_or(""))).collect(), + None => vec![reading.of_text("")], }; let n = each.len(); let holds = if each.iter().any(|v| v["holds"] == false) { @@ -454,6 +491,46 @@ fn fail(id: &Value, code: i64, message: &str) -> Value { json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) } +/// The answer to a message over 64 KiB from a client past its window, read +/// only for its id, its method, and its tool's name and key (the rest passed +/// over, unread): a tool result under the message's own id, stating why. A +/// resend under a key the client holds, and a receipt checked within the +/// client's own window for checks, are read whole as any message is: `None`. +pub fn past_window(app: &App, who: &str, body: &[u8], why: &str) -> Option { + #[derive(serde::Deserialize)] + struct Args { + idempotency_key: Option, + } + #[derive(serde::Deserialize)] + struct Params { + name: Option, + arguments: Option, + } + #[derive(serde::Deserialize)] + struct Message { + id: Option, + method: Option, + params: Option, + } + // Not a message: read whole, it is answered as one that is not. + let m: Message = serde_json::from_slice(body).ok()?; + let id = m.id.unwrap_or(Value::Null); + if m.method.as_deref() != Some("tools/call") { + return Some(fail(&id, -32000, why)); + } + let (name, key) = m.params.map(|p| (p.name.unwrap_or_default(), p.arguments.and_then(|a| a.idempotency_key))).unwrap_or_default(); + if key.is_some_and(|k| app.service.holds_key(who, &k)) { + return None; + } + if name == "verify_quantum_receipt" { + return match app.service.verify_window(who, false) { + Ok(()) => None, + Err(crate::jobs::Refused(_, w)) => Some(ok(&id, tool_result(format!("refused: {w}"), json!({ "error": w }), true))), + }; + } + Some(ok(&id, finish(&name, &Value::Null, tool_result(format!("refused: {why}"), json!({ "error": why }), true), false, Reading::Unread(why)))) +} + /// A tool's answer: text for the model, the whole object as structured content. fn tool_result(text: String, structured: Value, is_error: bool) -> Value { json!({ "content": [{ "type": "text", "text": text }], "structuredContent": structured, "isError": is_error }) @@ -511,7 +588,7 @@ fn task_job(app: &App, who: &str, task: &str) -> Option { if app.keys.is_some() && !app.service.owned_by(task, who) { return None; } - app.service.get(task) + app.service.peek(task) } fn no_task(id: &Value, app: &App) -> Value { @@ -519,12 +596,30 @@ fn no_task(id: &Value, app: &App) -> Value { } /// A run's job admitted as a task: its id, or the refusal as data. -fn admit_task(app: &App, who: &str, name: &str, args: Value) -> Result { +async fn admit_task(app: &App, who: &str, name: &str, args: Value) -> Result { let (args, key) = take_key(args)?; if let Some(r) = run_refusal(name, &args) { return Err(r["structuredContent"]["error"].as_str().unwrap_or("refused").to_string()); } - admit_run(app, who, args, key.as_deref()).map_err(|r| r["structuredContent"]["error"].as_str().unwrap_or("refused").to_string()) + admit_run(app, who, args, key).await.map_err(|r| r["structuredContent"]["error"].as_str().unwrap_or("refused").to_string()) +} + +/// The verdict of a task that was refused, `why`. It reads the program only +/// for a request that was counted and read before: refused before that, or +/// told to try again, it reads nothing. +async fn task_verdict(app: &App, who: &str, args: Value, why: String, counted: bool) -> Value { + if !counted || told_to_wait(&why) { + return verdict::of_refusal(&Value::Null, &why); + } + let Ok(turn) = app.service.gate.turn(crate::offload::Pool::Reading, who).await else { return verdict::of_refusal(&Value::Null, &why) }; + let unread = verdict::of_refusal(&Value::Null, &why); + crate::offload::read(&app.service.config, turn, crate::offload::Read::Refusal { args, why }).await.unwrap_or(unread) +} + +/// Whether `why` tells the client to come back: nothing of its request was +/// read, or the service is too busy to read more of it. +fn told_to_wait(why: &str) -> bool { + [wai_quantum::engine::refusal::RATE, wai_quantum::engine::refusal::BUSY].iter().any(|code| why.ends_with(&format!("[{code}]"))) } /// What `tasks/result` answers for a task that has ended: the answer @@ -550,29 +645,75 @@ fn run_refusal(name: &str, args: &Value) -> Option { } /// A run's job admitted as the run tools admit it, its verdict asked: its -/// id, or the tool's refusal. -fn admit_run(app: &App, who: &str, args: Value, key: Option<&str>) -> Result { +/// id, or the tool's refusal. It is admitted as `POST /v1/jobs` admits one +/// (`http::admit`): counted before anything is read, and read in a child. +async fn admit_run(app: &App, who: &str, args: Value, key: Option) -> Result { let refused = |why: String| tool_result(format!("refused: {why}"), json!({ "error": why }), true); - // Every run an agent asks for states its verdict. - let mut args = crate::http::with_source(args).map_err(|(_, why)| refused(why))?; - if !verdict::asked(&args) { + if !args.is_object() { + return Err(refused(wai_quantum::engine::refusal::tag(wai_quantum::engine::refusal::BAD_FIELD, "a run's arguments are an object: {\"qasm\": …, \"shots\": …}"))); + } + crate::http::admit(app, who, as_run(args), key).await.map_err(|(_, why)| refused(why)) +} + +/// A run tool's arguments as the job it admits: every run an agent asks for +/// states its verdict. A dry run of the tools plans the same job +/// ([`dry_run_answer`]), so what it says is taken is taken. +fn as_run(mut args: Value) -> Value { + if args.is_object() && !verdict::asked(&args) { args["verdict"] = json!(true); } - crate::http::admit(app, who, args, key.map(crate::jobs::Key)).map_err(|(_, why)| refused(why)) + args } /// A tool's answer as `tools/call` gives it: the call's, the verdict of /// what it read beside it, and what a view draws, for a host that draws it. +/// Reading the programs again costs what reading them cost, so only a call +/// that was counted does, in a reading child ([`crate::offload`]) within one +/// budget. A tool that states nothing about a program takes no turn. A poll +/// states what its job holds. A call refused before it was counted, told to +/// try again, or that finds no turn, states why its verdict is not of the +/// program: every answer about a program has a verdict. async fn tool_answer(app: &App, who: &str, name: &str, args: Value, base: &str, views: bool) -> Value { - let mut answer = call(app, who, name, args.clone(), base).await; + let (answer, counted) = crate::jobs::noting_count(call(app, who, name, args.clone(), base)).await; + if !states_verdict(name) && !(views && views::for_tool(name).is_some()) { + return answer; + } + let sc = &answer["structuredContent"]; + let error = sc["error"].as_str().filter(|_| answer["isError"] == true).map(str::to_string); + // A job's answer, polled or sent again, or with its verdict in hand. + let job = sc.get("id").is_some() && sc.get("status").is_some(); + if job && (!counted || (sc.pointer("/result/verdict").is_some() && !views) || matches!(name, "get_quantum_job" | "poll_quantum_job")) { + return finish(name, &args, answer, views, Reading::Stored); + } + if !counted || error.as_deref().is_some_and(told_to_wait) { + let why = error.unwrap_or_else(|| "this answer was not read again".to_string()); + return finish(name, &args, answer, views, Reading::Unread(&why)); + } + // No time left within the answer to read the program again: said so. + if crate::offload::left().is_some_and(|l| l < Duration::from_secs(1)) { + return finish(name, &args, answer, views, Reading::Unread("no time was left within this answer to read the program again; poll the job for its verdict")); + } + let finished = match app.service.gate.turn(crate::offload::Pool::Reading, who).await { + Ok(turn) => crate::offload::read(&app.service.config, turn, crate::offload::Read::Finish { name: name.to_string(), args: args.clone(), answer: answer.clone(), views }).await, + Err(e) => Err(e), + }; + match finished { + Ok(v) => v, + Err((_, why)) => finish(name, &args, answer, views, Reading::Unread(&why)), + } +} + +/// [`tool_answer`]'s verdicts and view added to the call's `answer`, reading +/// what `reading` says it may. +pub(crate) fn finish(name: &str, args: &Value, mut answer: Value, views: bool, reading: Reading) -> Value { // A set's answer, and a dry run's, state the verdict of what they read. let sc = &answer["structuredContent"]; let extra = match name { - "run_quantum_circuit" | "run_on_target" | "run_quantum_set" if crate::http::is_dry_run(&args) && sc.get("dry_run").is_some() => dry_run_verdict(sc, &args), - "run_quantum_set" if sc.is_object() && sc.get("error").is_none() => set_verdict(sc), - "run_quantum_set" => sc["error"].as_str().map(|e| verdict::of_refusal(&args, e)), - "get_quantum_job" if sc["method"] == "set" => set_verdict(sc), - "dry_run_quantum_job" => dry_run_verdict(sc, &args), + "run_quantum_circuit" | "run_on_target" | "run_quantum_set" if crate::http::is_dry_run(args) && sc.get("dry_run").is_some() => dry_run_verdict(sc, args, reading), + "run_quantum_set" if sc.is_object() && sc.get("error").is_none() => set_verdict(sc, reading), + "run_quantum_set" => sc["error"].as_str().map(|e| reading.of_refusal(args, e)), + "get_quantum_job" if sc["method"] == "set" => set_verdict(sc, reading), + "dry_run_quantum_job" => dry_run_verdict(sc, args, reading), _ => None, }; if let Some(v) = extra.filter(|v| !v.is_null()) { @@ -581,7 +722,7 @@ async fn tool_answer(app: &App, who: &str, name: &str, args: Value, base: &str, // What the view draws, to a host that draws it. if views && answer["structuredContent"].is_object() - && let Some(d) = views::data(name, &args, &answer["structuredContent"]) + && let Some(d) = views::data_of(name, args, &answer["structuredContent"], matches!(reading, Reading::Read)) { answer["_meta"] = Value::Object([(views::META.to_string(), d)].into_iter().collect()); } @@ -593,12 +734,16 @@ async fn tool_answer(app: &App, who: &str, name: &str, args: Value, base: &str, Some(e) if answer["isError"] == true => { let mut r = wai_quantum::engine::refusal::refusal_json(e); r["error"] = json!(e); - r["verdict"] = verdict::of_refusal(&args, e); + r["verdict"] = reading.of_refusal(args, e); answer["structuredContent"] = r; None } _ if name == "get_quantum_job" && sc["method"] == "set" => None, - _ => verdict::for_tool(name, &args, sc), + _ => match reading { + Reading::Read => verdict::for_tool(name, args, sc), + // What the answer holds, or what can be said without reading. + _ => sc.pointer("/result/verdict").or_else(|| sc.get("verdict").filter(|v| v.is_object())).cloned().or_else(|| Some(reading.of_text(""))), + }, }; if let Some(v) = v { answer["structuredContent"][verdict::verdict_key(name)] = v; @@ -680,11 +825,11 @@ pub async fn handle(app: &App, who: &str, msg: &Value, base: &str, views: bool) if crate::http::is_dry_run(&args) { return Some(fail(&id, -32602, "a dry run runs nothing and answers at once: call it without a task")); } - match admit_task(app, who, name, args.clone()) { - Ok(job) => ok(&id, json!({ "task": task_of(&app.service.get(&job).unwrap_or(json!({ "id": job })), app.service.config.retain.as_millis() as u64) })), - Err(why) => { + match crate::jobs::noting_count(admit_task(app, who, name, args.clone())).await { + (Ok(job), _) => ok(&id, json!({ "task": task_of(&app.service.peek(&job).unwrap_or(json!({ "id": job })), app.service.config.retain.as_millis() as u64) })), + (Err(why), counted) => { let mut data = wai_quantum::engine::refusal::refusal_json(&why); - data["verdict"] = verdict::of_refusal(&args, &why); + data["verdict"] = task_verdict(app, who, args, why.clone(), counted).await; json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": format!("no task was made: {why}"), "data": data } }) } } @@ -704,14 +849,16 @@ pub async fn handle(app: &App, who: &str, msg: &Value, base: &str, views: bool) "tasks/cancel" if !active => fail(&id, -32602, &format!("the task has ended ({}): a task that has ended is not cancelled", task_of(&job, ttl)["status"].as_str().unwrap_or(""))), "tasks/cancel" => { app.service.cancel(task); - ok(&id, task_of(&app.service.get(task).unwrap_or(job), ttl)) + ok(&id, task_of(&app.service.peek(task).unwrap_or(job), ttl)) } _ => { // An answer held until the task ends, as long as a reply // may be held here; the HTTP endpoint streams it to a // client that takes an event stream, for as long as it takes. - if active && app.service.wait(task, CALL_WAIT).await.is_some_and(|s| s.active()) { - return Some(fail(&id, -32603, &format!("the task is still working after {} s, longer than one reply is held here: ask tasks/get until it has ended, then tasks/result, or ask for tasks/result with Accept: text/event-stream and the answer waits until it ends", CALL_WAIT.as_secs()))); + // Held within the answer's time from the request's arrival. + let hold = crate::offload::left().map_or(CALL_WAIT, |l| l.min(CALL_WAIT)); + if active && app.service.wait(task, hold).await.is_some_and(|s| s.active()) { + return Some(fail(&id, -32603, &format!("the task is still working after {} s, as long as one reply is held here: ask tasks/get until it has ended, then tasks/result, or ask for tasks/result with Accept: text/event-stream and the answer waits until it ends", hold.as_secs()))); } ok(&id, task_result(app, who, task, base, views).await) } @@ -770,15 +917,20 @@ async fn call(app: &App, who: &str, name: &str, args: Value, base: &str) -> Valu Err(why) => tool_result(format!("refused: {why}"), json!({ "error": why }), true), } } - "dry_run_quantum_job" => dry_run_answer(app, who, &args), - "run_quantum_circuit" | "run_on_target" | "run_quantum_set" if crate::http::is_dry_run(&args) => dry_run_answer(app, who, &args), + "dry_run_quantum_job" => dry_run_answer(app, who, &args).await, + "run_quantum_circuit" | "run_on_target" | "run_quantum_set" if crate::http::is_dry_run(&args) => dry_run_answer(app, who, &args).await, "run_quantum_circuit" | "run_on_target" | "run_quantum_set" => { - let id = match admit_run(app, who, args, key.as_deref()) { + let id = match admit_run(app, who, args, key).await { Ok(id) => id, Err(refused) => return refused, }; - app.service.wait(&id, CALL_WAIT).await; - let job = app.service.get(&id).unwrap_or(json!({ "id": id })); + // Within the answer's time, whatever admission took; a verdict + // read after it needs time too. + let wait = crate::offload::left().map_or(CALL_WAIT, |l| l.saturating_sub(VERDICT_TIME).min(CALL_WAIT)); + app.service.wait(&id, wait).await; + let Some(job) = app.service.get(&id) else { + return tool_result(format!("the job {id} is no longer kept; send the run again"), json!({ "id": id, "error": "no such job" }), true); + }; let failed = job["status"] == "failed"; tool_result(summary(&job), job, failed) } @@ -802,32 +954,43 @@ async fn call(app: &App, who: &str, name: &str, args: Value, base: &str) -> Valu Err((_, why)) => tool_result(format!("refused: {why}"), json!({ "error": why }), true), }, "verify_quantum_receipt" if args["result"]["format"] == wai_quantum::engine::estimate::FORMAT => { - if let Err((_, why)) = crate::analysis::estimate_bounds(&args["request"]) { - return tool_result(format!("refused: {why}"), json!({ "error": why }), true); - } - let body = args.clone(); - match crate::analysis::bounded(app, who, move || wai_quantum::engine::estimate::verify(&body)).await { + match crate::analysis::bounded(app, who, crate::offload::Read::Call { kind: "estimate_verify".into(), op: String::new(), body: args.clone(), key: None }).await { Ok(v) => tool_result(if v["valid"] == true { "valid: the seal, every estimate's receipt, and the request re-run here all hold".into() } else { format!("not valid: signed {}, receipts {}, reproduced {}", v["signed"], v["receipts"], v["reproduced"]) }, v, false), Err((_, why)) => tool_result(format!("refused: {why}"), json!({ "error": why }), true), } } - "verify_quantum_receipt" if args["result"]["format"] == wai_quantum::engine::sets::FORMAT => { - let v = wai_quantum::engine::sets::verify(&args); - let text = if v["valid"] == true { - format!("valid: the set's receipt seals its request and head, and every one of its {} items is sealed by its own receipt and its path to the root", v["items"]) - } else { - format!("not valid: signed {}, items as the request gives them {}, each item sealed {}, root {}", v["signed"], v["derived"], v["items_sealed"], v["root"]) - }; - tool_result(text, v, false) - } + // A set's programs are written out again to check its items: it is + // counted and read as a check is. + "verify_quantum_receipt" if args["result"]["format"] == wai_quantum::engine::sets::FORMAT => match crate::analysis::bounded(app, who, crate::offload::Read::VerifySet { job: args.clone() }).await { + Ok(v) => { + let text = if v["valid"] == true { + format!("valid: the set's receipt seals its request and head, and every one of its {} items is sealed by its own receipt and its path to the root", v["items"]) + } else { + format!("not valid: signed {}, items as the request gives them {}, each item sealed {}, root {}", v["signed"], v["derived"], v["items_sealed"], v["root"]) + }; + tool_result(text, v, false) + } + Err((_, why)) => tool_result(format!("refused: {why}"), json!({ "error": why }), true), + }, "verify_quantum_receipt" if args["job"].is_string() => { // The job's text, read as the protocol's section 8.2 reads it. let text = args["job"].as_str().unwrap_or_default(); - let v = wai_quantum::engine::job::verdict_of_text(text).unwrap_or_else(|_| wai_quantum::engine::job::verdict_of(&wai_quantum::engine::job::check_text(text))); + let is_set = serde_json::from_str::(text).is_ok_and(|j| j["result"]["format"] == wai_quantum::engine::sets::FORMAT); + let v = if is_set { + match crate::analysis::bounded(app, who, crate::offload::Read::Verify { text: text.to_string() }).await { + Ok(v) => v, + Err((_, why)) => return tool_result(format!("refused: {why}"), json!({ "error": why }), true), + } + } else { + wai_quantum::engine::job::verdict_of_text(text).unwrap_or_else(|_| wai_quantum::engine::job::verdict_of(&wai_quantum::engine::job::check_text(text))) + }; let line = if v["valid"] == true { "valid: the receipt seals exactly this request and this result, and every rule of its form holds".to_string() } else { format!("not valid: {}", v["reasons"]) }; tool_result(line, v, false) } "verify_quantum_receipt" => { + if let Err(crate::jobs::Refused(_, why)) = app.service.verify_counted(who) { + return tool_result(format!("refused: {why}"), json!({ "error": why }), true); + } let (request, result) = (&args["request"], &args["result"]); if result.get("receipt").is_none() { return tool_result("the result carries no receipt".into(), json!({ "valid": false }), true); @@ -849,7 +1012,7 @@ async fn call(app: &App, who: &str, name: &str, args: Value, base: &str) -> Valu }, "limits": { "job_seconds": c.job_timeout.as_secs(), "active_jobs_per_client": c.per_client_active, "memory_per_client_bytes": c.per_client_mib << 20, "submissions_per_window": c.per_client_burst, "window_seconds": c.per_client_window.as_secs() }, "load": { "queued": queued, "running": running }, - "sets": { "most_items": wai_quantum::engine::sets::MAX_ITEMS }, + "sets": { "most_items": wai_quantum::engine::sets::MAX_ITEMS, "result_bytes": c.result_mib << 20, "note": "a job or set whose result could take more as served, by its bound before it runs, is refused at once (J031)" }, "store": { "kind": app.service.store().kind(), "durable": app.service.store().durable(), "retention_seconds": c.retain.as_secs() }, "energy": crate::energy::meter().describe(), "receipt_keys": format!("{base}/.well-known/wai-quantum-receipt-keys"), @@ -979,9 +1142,10 @@ async fn call(app: &App, who: &str, name: &str, args: Value, base: &str) -> Valu } } -/// A dry run's answer, as a tool gives it. -fn dry_run_answer(app: &App, who: &str, args: &Value) -> Value { - match crate::http::dry_run(app, who, args) { +/// A dry run's answer, as a tool gives it: of the job the run tools would +/// admit ([`as_run`]), its verdict asked. +async fn dry_run_answer(app: &App, who: &str, args: &Value) -> Value { + match crate::http::dry_run(app, who, &as_run(args.clone())).await { Ok(v) => { let secs = v["time"]["seconds"].as_f64().map(|t| format!("about {t:.3} s ({})", v["time"]["basis"].as_str().unwrap_or(""))).unwrap_or_else(|| format!("time not estimated ({})", v["time"]["why"].as_str().unwrap_or(""))); let energy = v["energy"]["joules_micro_estimate"].as_u64().map(|u| format!("about {:.3} J, an estimate", u as f64 / 1e6)).unwrap_or_else(|| format!("energy not estimated ({})", v["energy"]["unestimated"].as_str().unwrap_or(""))); @@ -1094,6 +1258,79 @@ mod tests { rt.block_on(handle(&app, "test", &msg, "http://127.0.0.1:1", views)).unwrap() } + /// Reading a program again costs what reading it cost, so a call that + /// was not counted reads nothing: each states a verdict, as every answer + /// about a program does, of why it was refused, with no code of the + /// reader's, as reading this program gives. + #[test] + fn a_call_that_was_not_counted_reads_nothing_and_still_states_a_verdict() { + let bad = "OPENQASM 3.0;\nqubit q;\nno_such_gate q;\n"; + let unread = |v: &Value, what: &str| { + assert_eq!(v["level"], "refused", "{what}: {v}"); + assert!(v["parsed"].get("code").is_none() && v["parsed"]["ok"] == false, "{what}: {v}"); + }; + for (tool, args) in [ + ("lint_qasm", json!({ "qasm": bad })), + ("get_quantum_job", json!({ "id": "no such job", "qasm": bad })), + ("run_quantum_circuit", json!({ "qasm": bad, "idempotency_key": 5 })), + ("run_quantum_circuit", json!({ "qasm": bad, "idempotency_key": "" })), + ] { + let r = rpc("tools/call", json!({ "name": tool, "arguments": args })); + assert_eq!(r["result"]["isError"], true, "{tool}: {r}"); + unread(&r["result"]["structuredContent"]["verdict"], tool); + } + // A refused task is the same, on the error it answers with. + let r = rpc("tools/call", json!({ "name": "run_quantum_circuit", "arguments": { "qasm": bad, "idempotency_key": 5 }, "task": {} })); + unread(&r["error"]["data"]["verdict"], "task"); + // Counted, it is read: the same program, the reader's own code. + let r = rpc("tools/call", json!({ "name": "run_quantum_circuit", "arguments": { "qasm": bad } })); + assert!(r["result"]["structuredContent"]["verdict"]["parsed"]["code"].is_string(), "{r}"); + } + + /// With every turn held, a tool that reads nothing answers at once, and + /// one that needs a turn for its verdict still has one, saying why it is + /// not of the program; past its window a run reads nothing either. + #[tokio::test] + async fn the_tools_are_answered_with_no_turn_to_be_had() { + let dir = std::env::temp_dir().join(format!("wai-qsvc-mcp-turns-{}", std::process::id())); + let signer = std::sync::Arc::new(crate::receipt::Signer::load_or_create(&dir.join("key")).unwrap()); + let service = crate::jobs::Service::new(crate::jobs::Config { per_client_burst: 3, ..crate::jobs::Config::from_env() }); + let app = App::bare(service, signer, "http://127.0.0.1:1"); + let call = |name: &str, args: Value| json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": name, "arguments": args } }); + let mut held = Vec::new(); + for i in 0..crate::offload::AT_ONCE { + held.push(app.service.gate.turn(crate::offload::Pool::Reading, &format!("other {i}")).await.ok().unwrap()); + } + let t = std::time::Instant::now(); + let r = handle(&app, "test", &call("list_targets", json!({})), "http://127.0.0.1:1", false).await.unwrap(); + assert!(r["result"]["structuredContent"]["targets"].is_array() && t.elapsed() < std::time::Duration::from_secs(1), "{r}"); + let r = handle(&app, "test", &call("critique_qasm", json!({ "qasm": "OPENQASM 3.0; qubit q; h q;" })), "http://127.0.0.1:1", false).await.unwrap(); + let v = &r["result"]["structuredContent"]["verdict"]; + assert!(v["statement"].as_str().unwrap().contains("[J032]") && v["level"] == "refused", "{r}"); + let tool = tools().as_array().unwrap().iter().find(|t| t["name"] == "critique_qasm").cloned().unwrap(); + let bad = schemas().check_value(&tool["outputSchema"], &r["result"]["structuredContent"]); + assert!(bad.is_empty(), "{bad:?}"); + drop(held); + app.service.count("test").unwrap(); + app.service.count("test").unwrap(); + let r = handle(&app, "test", &call("run_quantum_circuit", json!({ "qasm": "OPENQASM 3.0;\nqubit q;\nno_such_gate q;\n" })), "http://127.0.0.1:1", false).await.unwrap(); + let sc = &r["result"]["structuredContent"]; + assert!(sc["error"].as_str().unwrap().ends_with("[J030]") && sc["verdict"]["parsed"].get("code").is_none(), "{r}"); + } + + /// A poll states what its job holds, read at its admission, and reads + /// nothing more, however often it is asked. + #[test] + fn a_poll_reads_nothing() { + let v = verdict::of_admitted(None); + assert!(v["level"] == "parsed" && v["parsed"]["ok"] == true, "{v}"); + let job = json!({ "id": "j", "status": "queued", "request": { "qasm": "this does not read" } }); + let answer = tool_result("queued".into(), job, false); + let done = finish("get_quantum_job", &json!({ "id": "j" }), answer, true, Reading::Stored); + assert_eq!(done["structuredContent"]["verdict"]["level"], "parsed", "{done}"); + assert!(done["_meta"][views::META].get("layout").is_none(), "{done}"); + } + /// A client whose host draws views gets them here as from the command /// line's server, and the view's own tool for a running job, kept from /// the model; any other client gets none of it. @@ -1462,7 +1699,7 @@ mod tests { let bell = "OPENQASM 3.0; qubit[2] q; bit[2] c; h q[0]; cx q[0], q[1]; c = measure q;"; // The job a first call made, as the call admits it (with its verdict // asked), cancelled so that no call waits for it here. - let id = crate::http::admit(&app, "test", json!({ "qasm": bell, "shots": 20, "verdict": true }), Some(crate::jobs::Key("agent-1"))).unwrap(); + let id = rt.block_on(crate::http::admit_to(&app.service, "test", json!({ "qasm": bell, "shots": 20, "verdict": true }), Some("agent-1"))).unwrap(); assert_eq!(app.service.cancel(&id), Some(true)); let again = run(json!({ "qasm": bell, "shots": 20, "idempotency_key": "agent-1" })); assert_eq!(again["isError"], false, "{again}"); diff --git a/wai/wai-quantum-service/src/offload.rs b/wai/wai-quantum-service/src/offload.rs new file mode 100644 index 000000000..fba393bc7 --- /dev/null +++ b/wai/wai-quantum-service/src/offload.rs @@ -0,0 +1,1093 @@ +//! Work that reads a client's programs, out of the service's own process. +//! +//! Reading a program (the reader's elaboration of its loops, a set's sweep +//! written out, a reference program's equivalence bound, a plan, a check's +//! answer, a set's receipt checked) is bounded work: the reader holds its +//! nesting, its steps and its memory to limits of its own. But a limit the +//! service merely trusts is one a mistake can lift, and what is at stake is +//! every job the service holds: a stack overflow or an allocation that fails +//! ends a Rust process, and neither can be caught (an allocator may not +//! unwind, so exhaustion aborts). And steps bound work, not time: a statement +//! that copies what the program holds costs one. So each request's reading +//! runs in a child, the service's own binary run as `wai-quantum-service +//! read`, the same boundary each job runs behind: +//! +//! - **Its memory is capped** ([`crate::jobs::Config::read_mib`]): polled +//! every 50 ms from a thread of its own ([`Watch`]; on macOS its physical +//! footprint, compressed and swapped pages included, on Linux its resident +//! and swapped pages), and killed (`kill -9`) past it; the request is +//! refused, `422` and `J031`, with the command that runs it on the client's +//! own machine. A poll is not a kernel limit: a child can pass the cap by +//! what it touches in one interval. On Linux the child also holds its own +//! address space ([`child`]) and is the process the kernel ends first when +//! the host runs short ([`crate::jobs::expendable`]); macOS offers an +//! unprivileged process neither. **Its time is capped** the same way +//! ([`crate::jobs::Config::read_timeout`]): nothing a request reads holds a +//! turn past it. **Its answer is capped** by what it takes once read +//! ([`crate::jobs::Config::read_answer_mib`], [`weight`]), in the child, +//! as it comes into this process, which reads it off the request threads. +//! - **It ends alone**: a child that panics, overflows its stack or aborts is +//! answered `500`, and the service runs on. +//! - **It is sandboxed** where jobs are (macOS: no network, no file writes, +//! no further processes), is given only what it reads ([`Read`]), and is +//! started from the service's own binary as it was started +//! ([`crate::jobs::self_exe`]), whatever has become of the file since. +//! - **It waits its turn** ([`Gate`]): [`AT_ONCE`] are read at once, and +//! [`CHECKS_AT_ONCE`] checks beside them. A client has its share of each +//! ([`crate::jobs::Config::per_client_reads`], +//! [`crate::jobs::Config::per_client_checks`]), smaller than the pool; its +//! requests past that wait behind it, [`CLIENT_WAITING`] at most; at most +//! [`MAX_WAITING`] wait in all, each for at most [`WAIT`], and the rest are +//! refused (`503`, `J032`), having read and spent nothing. A client that +//! hangs up ends its child and frees its turn. +//! +//! The service's own tests read on a thread of their own ([`STACK`]) instead, +//! as a test binary is not the service's (`Config::read_in_child`). + +use crate::jobs::Config; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::process::{ExitStatus, Stdio}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use wai_quantum::engine::refusal::{self, tag}; + +/// The stack a reading runs on where it runs on a thread. +pub const STACK: usize = 64 << 20; +/// Requests read at once; the rest wait their turn. +pub const AT_ONCE: usize = 4; +/// Checks answered at once, beside them. +pub const CHECKS_AT_ONCE: usize = 4; +/// How long a request waits for a turn before it is refused. +pub const WAIT: Duration = Duration::from_secs(10); +/// How many requests may wait for a turn: each holds its body meanwhile. +pub const MAX_WAITING: usize = 32; +/// How many of one client's requests may wait behind its share of a pool: an +/// agent's burst of parallel calls waits, rather than being refused. +pub const CLIENT_WAITING: usize = 8; +/// How long a turn lent to a client past its share lasts: long enough for a +/// quick call (a critique, a lint, an estimate) beside its own slow one. +pub const LENT_FOR: Duration = Duration::from_secs(5); +/// A turn is lent past a client's share only while this many of the pool's +/// turns stay free for other clients after it. +pub const LEND_LEAVES: usize = 2; + +tokio::task_local! { + /// When the request being served must be answered by. + static DUE: Instant; +} + +/// `work`, its answer due at `due`: every turn it waits for and every +/// reading it makes ends by then ([`Gate::turn`], [`read`]). +pub async fn answering_by(due: Instant, work: F) -> F::Output { + DUE.scope(due, work).await +} + +/// When the request being served must be answered by, where that was said. +pub fn due() -> Option { + DUE.try_with(|d| *d).ok() +} + +/// The time left until the request being served must be answered, if any is +/// said. +pub fn left() -> Option { + due().map(|d| d.saturating_duration_since(Instant::now())) +} + +/// What a request is read among. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Pool { + /// Jobs and dry runs, and the verdicts of MCP answers. + Reading, + /// The design checks and the engine calls ([`crate::analysis`]). + Checks, +} + +/// Each client's share of each pool, and how many of its requests hold or +/// wait for one. +type Clients = Arc>>; + +struct Share { + turns: Arc, + requests: usize, + lent: usize, + /// Of its requests, those waiting for a turn: when each began to wait, + /// by a number of its own. + waiting: HashMap, + /// Of its requests, those holding one of its own turns while they wait + /// for the pool's: each will run on its own turn, so none is lent one. + between: usize, +} + +/// The turns requests are read in: the service's two pools, each client's +/// share of each, and who is waiting. A client holds at most its share of a +/// pool for as long as a reading may take. Past its share, one more turn may +/// be lent to it while the pool keeps [`LEND_LEAVES`] free for others, for at +/// most [`LENT_FOR`], so its quick calls pass its own slow one; the rest wait +/// behind its own turns, in order, so its parallel calls are answered one +/// after another rather than refused. No client holds more than its share of +/// a pool for longer than a quick call takes, however many it sends. +pub struct Gate { + reading: Arc, + checks: Arc, + waiting: Arc, + clients: Clients, + /// A client's share of the reading pool and of the checks. + shares: (usize, usize), +} + +/// A turn, held until its request has been read; dropped, the next goes. +pub struct Turn { + // Dropped in this order: the client's own turn first, so a request of + // the same client waiting for one takes it before the pool's turn comes + // free, and is never lent a turn past its share in between. + _share: Option, + _pool: OwnedSemaphorePermit, + _client: ClientHold, + /// For a lent turn: the client's own share, which the reading moves onto + /// when one of its turns comes free, and how many others are waiting. + lend: Option<(Arc, Others)>, +} + +#[cfg(test)] +impl Turn { + /// Whether it was lent past the client's share, and so lasts at most + /// [`LENT_FOR`] once others wait, unless the client's own turn comes free. + pub fn lent(&self) -> bool { + self.lend.is_some() + } +} + +struct ClientHold { + clients: Clients, + key: (Pool, String), + lent: Arc, +} + +impl ClientHold { + fn lend(&mut self) { + self.lent.store(true, Ordering::SeqCst); + } + + /// How to give its lent turn back while it is held ([`Unlend`]). + fn unlend(&self) -> Unlend { + Unlend { clients: Arc::clone(&self.clients), key: self.key.clone(), lent: Arc::clone(&self.lent) } + } +} + +/// A lent turn given back once its reading has moved onto the client's own +/// turn: the client may be lent another, so its quick calls pass the long one. +struct Unlend { + clients: Clients, + key: (Pool, String), + lent: Arc, +} + +impl Unlend { + fn run(&self) { + if self.lent.swap(false, Ordering::SeqCst) + && let Some(s) = self.clients.lock().unwrap().get_mut(&self.key) + { + s.lent -= 1; + } + } +} + +impl Drop for ClientHold { + fn drop(&mut self) { + let mut clients = self.clients.lock().unwrap(); + if let Some(s) = clients.get_mut(&self.key) { + s.requests -= 1; + if self.lent.load(Ordering::SeqCst) { + s.lent -= 1; + } + if s.requests == 0 { + clients.remove(&self.key); + } + } + } +} + +struct Waiting(Arc, Clients, (Pool, String), u64); + +/// A request of the client holding its own turn and waiting for the pool's +/// ([`Share::between`]), until it is dropped. +struct Between(Clients, (Pool, String)); + +impl Between { + fn new(clients: &Clients, key: &(Pool, String)) -> Between { + if let Some(s) = clients.lock().unwrap().get_mut(key) { + s.between += 1; + } + Between(Arc::clone(clients), key.clone()) + } +} + +impl Drop for Between { + fn drop(&mut self) { + if let Some(s) = self.0.lock().unwrap().get_mut(&self.1) { + s.between -= 1; + } + } +} + +impl Drop for Waiting { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + if let Some(s) = self.1.lock().unwrap().get_mut(&self.2) { + s.waiting.remove(&self.3); + } + } +} + +/// How long one of a client's own requests may wait behind a turn lent to +/// that client before the lent turn goes back: as long as a call of its own +/// that ends sooner (a decode beside a threshold) may take to free one of its +/// turns, and short enough that the waiting call takes the lent turn with +/// time to spare before its [`WAIT`] ends (the lent reading is stopped within +/// a second and a half of it being due). +pub const OWN_PATIENCE: Duration = Duration::from_secs(WAIT.as_secs() - 3); + +/// Whether a turn lent to a client should go back: requests of other +/// clients are waiting for a turn, or one of its own has waited +/// [`OWN_PATIENCE`]. Its own requests that have waited less wait on: the +/// call on its own turn may end first and free it. +#[derive(Clone)] +pub struct Others { + waiting: Arc, + clients: Clients, + key: (Pool, String), +} + +impl Others { + fn waiting(&self) -> bool { + let clients = self.clients.lock().unwrap(); + let (count, oldest) = clients.get(&self.key).map_or((0, None), |s| (s.waiting.len(), s.waiting.values().min().copied())); + self.waiting.load(Ordering::SeqCst) > count || oldest.is_some_and(|t| t.elapsed() >= OWN_PATIENCE) + } +} + +/// A number for each request that waits, so it is told from the others. +static TICKETS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +impl Gate { + /// The pools, a client having `reads` turns of the reading pool and + /// `checks` of the checks (each at least one, and at most the pool). + pub fn new(reads: usize, checks: usize) -> Gate { + Gate { + reading: Arc::new(Semaphore::new(AT_ONCE)), + checks: Arc::new(Semaphore::new(CHECKS_AT_ONCE)), + waiting: Arc::new(AtomicUsize::new(0)), + clients: Arc::default(), + shares: (reads.clamp(1, AT_ONCE), checks.clamp(1, CHECKS_AT_ONCE)), + } + } + + /// A turn for `who` in `pool`, waiting at most [`WAIT`], and no later + /// than the request's answer is due ([`due`]). + pub async fn turn(&self, pool: Pool, who: &str) -> Result { + let until = Instant::now() + WAIT; + self.turn_until(pool, who, due().map_or(until, |d| d.min(until))).await + } + + /// A turn for `who` in `pool`, or the refusal (`503`, `J032`): at once + /// past [`CLIENT_WAITING`] of the client's own requests waiting, or past + /// [`MAX_WAITING`] in all; at `until` (and at most [`WAIT`]) for one that + /// found none, saying whether the client's own requests or the pool held + /// it up. + pub async fn turn_until(&self, pool: Pool, who: &str, until: Instant) -> Result { + let busy = |why: String| (503, tag(refusal::BUSY, why)); + let (share, pool_turns, pool_size, full) = match pool { + Pool::Reading => (self.shares.0, &self.reading, AT_ONCE, format!("{AT_ONCE} requests are being read; try again shortly")), + Pool::Checks => (self.shares.1, &self.checks, CHECKS_AT_ONCE, format!("{CHECKS_AT_ONCE} checks are running; try again shortly")), + }; + let key = (pool, who.to_string()); + let (mine, mut client) = { + let mut clients = self.clients.lock().unwrap(); + let s = clients.entry(key.clone()).or_insert_with(|| Share { turns: Arc::new(Semaphore::new(share)), requests: 0, lent: 0, waiting: HashMap::new(), between: 0 }); + // Its share, a turn lent past it where the pool can ever lend one + // to a client holding its share ([`LEND_LEAVES`]), and + // [`CLIENT_WAITING`] waiting behind them. + let lendable = usize::from(pool_size.saturating_sub(share) > LEND_LEAVES); + if s.requests >= share + lendable + CLIENT_WAITING { + return Err(busy(format!("{} of your requests are being read or waiting to be; send the next when one is answered", s.requests))); + } + s.requests += 1; + (Arc::clone(&s.turns), ClientHold { clients: Arc::clone(&self.clients), key, lent: Arc::default() }) + }; + // A turn of the client's own, and the pool's. + if let Ok(m) = Arc::clone(&mine).try_acquire_owned() + && let Ok(p) = Arc::clone(pool_turns).try_acquire_owned() + { + return Ok(Turn { _pool: p, _share: Some(m), _client: client, lend: None }); + } + if let Some(p) = self.lend(pool_turns, &mine, &mut client) { + { + let others = self.others(&client.key); + return Ok(Turn { _pool: p, _share: None, _client: client, lend: Some((Arc::clone(&mine), others)) }); + } + } + if self.waiting.fetch_add(1, Ordering::SeqCst) >= MAX_WAITING { + self.waiting.fetch_sub(1, Ordering::SeqCst); + return Err(busy(full)); + } + let ticket = TICKETS.fetch_add(1, Ordering::SeqCst); + if let Some(s) = self.clients.lock().unwrap().get_mut(&client.key) { + s.waiting.insert(ticket, Instant::now()); + } + let _waiting = Waiting(Arc::clone(&self.waiting), Arc::clone(&self.clients), client.key.clone(), ticket); + let until = until.min(Instant::now() + WAIT); + // Each acquisition kept across the loop, so its place in line is kept. + let mut own: Option = None; + let mut _between: Option = None; + let mut own_wait = Box::pin(Arc::clone(&mine).acquire_owned()); + let mut pool_wait = Box::pin(Arc::clone(pool_turns).acquire_owned()); + loop { + let now = Instant::now(); + if now >= until { + break; + } + let step = tokio::time::sleep((until - now).min(Duration::from_millis(20))); + if own.is_none() { + tokio::select! { + m = &mut own_wait => match m { + Ok(m) => { + own = Some(m); + _between = Some(Between::new(&self.clients, &client.key)); + } + Err(_) => break, + }, + () = step => {} + } + if own.is_none() + && let Some(p) = self.lend(pool_turns, &mine, &mut client) + { + { + let others = self.others(&client.key); + return Ok(Turn { _pool: p, _share: None, _client: client, lend: Some((Arc::clone(&mine), others)) }); + } + } + } else { + tokio::select! { + p = &mut pool_wait => match p { + Ok(p) => return Ok(Turn { _pool: p, _share: own, _client: client, lend: None }), + Err(_) => break, + }, + () = step => {} + } + } + } + Err(busy(if own.is_some() { + full + } else { + format!("your own {share} turn{} of this pool {} still taken by your other requests after {} s; send this when one is answered", if share == 1 { "" } else { "s" }, if share == 1 { "is" } else { "are" }, WAIT.as_secs()) + })) + } + + fn others(&self, key: &(Pool, String)) -> Others { + Others { waiting: Arc::clone(&self.waiting), clients: Arc::clone(&self.clients), key: key.clone() } + } + + /// A turn lent to `client` past its share, if the pool can spare one and + /// still keep [`LEND_LEAVES`] free, and the client holds none lent and + /// has every one of its own turns running: none free (`mine`), and none + /// taken by a request still waiting for the pool's turn, as each of those + /// is about to run on its own (a turn's pool turn may come free a moment + /// before such a request takes it, and the pool then looks to have one + /// to spare). + fn lend(&self, pool_turns: &Arc, mine: &Semaphore, client: &mut ClientHold) -> Option { + let mut clients = self.clients.lock().unwrap(); + let s = clients.get_mut(&client.key)?; + if s.lent > 0 || s.between > 0 || mine.available_permits() > 0 || pool_turns.available_permits() <= LEND_LEAVES { + return None; + } + let p = Arc::clone(pool_turns).try_acquire_owned().ok()?; + s.lent += 1; + client.lend(); + Some(p) + } +} + +/// One request's reading, as its child takes it on standard input. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "read", rename_all = "snake_case")] +pub enum Read { + /// A job or a set: its source read where it has one, held to the checks + /// that read and planned within `job_bytes` (`http::plan_job`). The answer + /// is `{"request", "plan", "items", "plans", "result_bytes", + /// "result_parts"}` (the most its result takes as served, in all and by + /// part, or null), or `{"request", "refused": + /// [status, why]}` where the planning refused it. With `keep` false (a + /// dry run, which keeps nothing) the request and each item's request are + /// left out: a dry run reads the plans and the points alone. + Admit { request: Value, job_bytes: u64, keep: bool }, + /// A design check (`analysis::Check`, by name): its bounds and its + /// answer, within one budget. + Check { check: String, body: Value }, + /// An engine call held to its bounds: `kind` is `estimate`, + /// `estimate_verify`, `qec`, `pulse` or `variational`; `key`, the receipt + /// key's secret in hex, for an estimate, which seals as it goes. + Call { kind: String, op: String, body: Value, key: Option }, + /// The verdicts and the view an MCP tool's answer states (`mcp::finish`). + Finish { name: String, args: Value, answer: Value, views: bool }, + /// The verdict a refused MCP task states. + Refusal { args: Value, why: String }, + /// A set's receipt checked (`POST /v1/verify`), from the job's text. + Verify { text: String }, + /// A set's receipt checked (the MCP tool), from the parsed job. + VerifySet { job: Value }, +} + +/// `read`, in its `turn`, in a child whose memory and time are capped by +/// `config` (or on a thread of its own, where `config` says so): its answer, +/// or the refusal. +pub async fn read(config: &Config, turn: Turn, r: Read) -> Result { + read_until(config, turn, r, due()).await +} + +/// [`read`], ended by `until` too, where the request has to be answered by +/// then (an answer due within a proxy's wait). +pub async fn read_until(config: &Config, turn: Turn, r: Read, until: Option) -> Result { + let now = Instant::now(); + let own_by = [Some(now + config.read_timeout), until].into_iter().flatten().min().unwrap_or(now + config.read_timeout); + let own_why = (until == Some(own_by) && until < Some(now + config.read_timeout)).then(|| tag(refusal::BUSY, "no time was left to read this within one answer; send it again shortly".to_string())); + let due = Due::new(own_by, own_why.clone()); + // A turn lent past the client's share lasts a quick call's time, unless + // one of the client's own turns comes free first: the reading then moves + // onto it and has the whole of its time. Past that time it goes on while + // no other client's request waits for a turn and none of its own client's + // has waited OWN_PATIENCE, and is stopped when one does: two long calls + // of one client never hold its quick ones past their wait. + let mut keeper = None; + if let Some((own, waiting)) = turn.lend.clone() { + let unlend = turn._client.unlend(); + let lent_why = tag(refusal::BUSY, format!("this was read on a turn lent beside your own requests, which lasts {} s once another client's request waits for a turn or one of yours has waited {} s, and it took longer; send it again when your others are answered", LENT_FOR.as_secs(), OWN_PATIENCE.as_secs())); + let lent_by = { + let own_why = own_why.clone(); + move |t: Instant| if t < own_by { Some(lent_why.clone()) } else { own_why.clone() } + }; + let first = (now + LENT_FOR).min(own_by); + due.set(first, lent_by(first)); + let d = due.clone(); + let held: Arc>> = Arc::default(); + let keep = Arc::clone(&held); + let own_why = own_why.clone(); + keeper = Some(( + tokio::spawn(async move { + let mut until = first; + let mut acquire = Box::pin(own.acquire_owned()); + loop { + tokio::select! { + p = &mut acquire => { + if let Ok(p) = p { + *keep.lock().unwrap() = Some(p); + d.set(own_by, own_why); + unlend.run(); + } + return; + } + // Asked half a second before it is due, so the + // watcher never sees a time about to be moved. + () = tokio::time::sleep_until((until - Duration::from_millis(500)).into()) => { + if until >= own_by || waiting.waiting() { + return; + } + until = (until + Duration::from_secs(1)).min(own_by); + d.set(until, lent_by(until)); + } + } + } + }), + held, + )); + } + let mut turn = Some(turn); + let out = if config.read_in_child { + in_child(config, &r, &due).await + } else { + // The turn held as long as the thread reads, however long that is. + let held = turn.take(); + let read = on_thread(move || { + let _turn = held; + run(r) + }); + tokio::pin!(read); + loop { + let (by, why) = due.get(); + match tokio::time::timeout(by.saturating_duration_since(Instant::now()).min(Duration::from_millis(100)), &mut read).await { + Ok(r) => break r.and_then(|r| r), + Err(_) if Instant::now() < due.get().0 => {} + Err(_) => break Err((if why.is_some() { 503 } else { 422 }, why.unwrap_or_else(|| tag(refusal::TOO_LARGE_HERE, format!("reading this request took more than {} s, longer than the service reads for; {LOCAL}", config.read_timeout.as_secs()))))), + } + } + }; + if let Some((k, held)) = keeper { + k.abort(); + drop(held); + } + drop(turn); + out +} + +/// When a reading must end by, and why it is cut short if that is before +/// its own time: moved while it runs, as a lent turn becomes the client's own. +#[derive(Clone)] +struct Due(Arc)>>); + +impl Due { + fn new(by: Instant, why: Option) -> Due { + Due(Arc::new(Mutex::new((by, why)))) + } + + fn set(&self, by: Instant, why: Option) { + *self.0.lock().unwrap() = (by, why); + } + + fn get(&self) -> (Instant, Option) { + self.0.lock().unwrap().clone() + } +} + +/// `read`, carried out here: what the child does. +pub fn run(r: Read) -> Result { + match r { + Read::Admit { request, job_bytes, keep } => wai_quantum::qasm::within_steps(crate::http::READ_STEPS, || { + let request = crate::http::within_budget(|| crate::http::with_source(request))?; + // A refusal of the plan is the answer's, beside the request as read. + let planned = crate::http::plan_job(job_bytes, &request); + let request = if keep { request } else { Value::Null }; + let (p, work, bound) = match planned { + Ok(planned) => planned, + Err((code, why)) => return Ok(json!({ "request": request, "refused": [code, why] })), + }; + let (items, plans) = match work { + crate::jobs::Work::One => (Value::Null, Value::Null), + crate::jobs::Work::Set { items, plans } => { + let items = if keep { crate::jobs::items_json(&items) } else { items.iter().map(|i| json!({ "point": i.point })).collect() }; + (items, serde_json::to_value(plans).expect("plans are JSON")) + } + }; + Ok(json!({ "request": request, "plan": p, "items": items, "plans": plans, "result_bytes": bound.map(|b| b.bytes()), "result_parts": bound })) + }), + Read::Check { check, body } => { + let check = crate::analysis::Check::named(&check).ok_or((400, format!("no check {check}")))?; + crate::http::within_budget(|| { + crate::analysis::bounds(check, &body)?; + Ok(crate::analysis::answer(check, &body)) + }) + } + Read::Call { kind, op, body, key } => crate::analysis::call(&kind, &op, &body, key.as_deref()), + Read::Finish { name, args, answer, views } => crate::http::within_budget(|| Ok(crate::mcp::finish(&name, &args, answer, views, crate::mcp::Reading::Read))), + Read::Refusal { args, why } => crate::http::within_budget(|| Ok(wai_quantum::engine::verdict::of_refusal(&args, &why))), + Read::Verify { text } => crate::http::within_budget(|| Ok(wai_quantum::engine::job::verdict_of_text(&text).unwrap_or_else(|e| json!({ "valid": false, "error": e })))), + Read::VerifySet { job } => crate::http::within_budget(|| Ok(wai_quantum::engine::sets::verify(&job))), + } +} + +/// What `v` takes once read, about: each value and each string or key, +/// counted as `serde_json` holds them. A JSON text can take many times its +/// own length once read (`[0,0,…]` two bytes a value), so what a reading may +/// give back to the service is held to this, not to its length. +pub fn weight(v: &Value) -> u64 { + // A value with its place in an array, or an object's entry with its + // key and index (objects keep their order): measured, about 80 bytes. + const VALUE: u64 = 80; + let mut stack = vec![v]; + let mut total = 0u64; + while let Some(v) = stack.pop() { + total += VALUE; + match v { + Value::String(s) => total += s.len() as u64, + Value::Array(a) => stack.extend(a.iter()), + Value::Object(o) => { + for (k, x) in o { + total += k.len() as u64 + VALUE; + stack.push(x); + } + } + _ => {} + } + } + total +} + +/// `wai-quantum-service read`: one [`Read`] on standard input, its answer +/// on standard output as `{"ok": …}` or `{"refused": [status, why]}`. An +/// answer that would take more than the service takes back of one +/// ([`Config::read_answer_mib`], as [`weight`] counts it) is refused here, +/// before it is written. On Linux the child also holds its own address space +/// to twice its memory cap and a gigabyte, so that one that grows faster than +/// the service polls it fails its allocation rather than taking the host. +pub fn child() { + let config = Config::from_env(); + #[cfg(target_os = "linux")] + { + let bytes = (config.read_mib << 20).saturating_mul(2).saturating_add(1 << 30); + let lim = libc::rlimit { rlim_cur: bytes as libc::rlim_t, rlim_max: bytes as libc::rlim_t }; + // SAFETY: a plain call on this process's own limit, with a valid struct. + unsafe { libc::setrlimit(libc::RLIMIT_AS, &lim) }; + } + let cap = config.read_answer_mib << 20; + let mut input = String::new(); + let out = match std::io::Read::read_to_string(&mut std::io::stdin(), &mut input).map_err(|e| e.to_string()).and_then(|_| serde_json::from_str::(&input).map_err(|e| e.to_string())) { + Ok(r) => { + // A dry run reads as its submission reads, and is held to what the + // submission's answer would take, here and as the service reads it + // ([`in_child`]): it is given back its plans and points alone. + let dry = matches!(r, Read::Admit { keep: false, .. }); + let r = match r { + Read::Admit { request, job_bytes, keep: false } => Read::Admit { request, job_bytes, keep: true }, + r => r, + }; + match run(r) { + Ok(v) if weight(&v) > cap || (dry && text_len(&json!({ "ok": v })) + 1 > cap) => json!({ "refused": [422, too_much(config.read_answer_mib)] }), + Ok(v) if dry => json!({ "ok": plans_alone(v) }), + Ok(v) => json!({ "ok": v }), + Err((code, why)) => json!({ "refused": [code, why] }), + } + } + Err(e) => json!({ "refused": [500, format!("the reading did not arrive: {e}")] }), + }; + println!("{out}"); +} + +/// `v`'s length as the child writes it, bytes, written nowhere. +fn text_len(v: &Value) -> u64 { + struct Count(u64); + impl std::io::Write for Count { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.0 += b.len() as u64; + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut c = Count(0); + let _ = serde_json::to_writer(&mut c, v); + c.0 +} + +/// An admission's answer as a dry run takes it ([`Read::Admit`] with `keep` +/// false): the request and each item's request left out, the plans and the +/// points kept. +fn plans_alone(mut v: Value) -> Value { + if v.get("request").is_some() { + v["request"] = Value::Null; + } + if let Some(items) = v.get_mut("items").and_then(Value::as_array_mut) { + for it in items.iter_mut() { + *it = json!({ "point": it.get("point").cloned().unwrap_or(Value::Null) }); + } + } + v +} + +/// The refusal of a reading whose answer is more than the service takes back. +fn too_much(mib: u64) -> String { + tag(refusal::TOO_LARGE_HERE, format!("reading this request gave more than {mib} MiB once read, more than the service takes back of one; {LOCAL}")) +} + +/// The local command a refusal for size names. +const LOCAL: &str = "run it on your own machine (`wai-quantum run`, `wai-quantum critique` and the rest read without these limits)"; + +/// Why a watcher ended a child. +const OVER_MEMORY: u8 = 1; +const OVER_TIME: u8 = 2; + +/// A child's memory and time watched from a thread of its own, every 50 ms, +/// and the child killed (`kill -9`) past either: a runtime busy with other +/// requests does not delay the check. Dropped (the request answered, or its +/// client gone), it stops within one poll. +struct Watch { + done: Arc, + over: Arc, +} + +impl Watch { + fn start(pid: u32, limit: u64, due: Due) -> Watch { + let done = Arc::new(AtomicBool::new(false)); + let over = Arc::new(std::sync::atomic::AtomicU8::new(0)); + let (d, o) = (Arc::clone(&done), Arc::clone(&over)); + let watch = move || { + while !d.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(50)); + if d.load(Ordering::SeqCst) { + return; + } + let why = if crate::jobs::rss_bytes(pid).is_some_and(|rss| rss > limit) { + OVER_MEMORY + } else if Instant::now() > due.get().0 { + OVER_TIME + } else { + continue; + }; + // Said before the kill, so whoever sees the child end sees why. + o.store(why, Ordering::SeqCst); + // Killed again until it has ended: a signal that did not take + // is sent again rather than waited out. + while !d.load(Ordering::SeqCst) { + crate::jobs::kill(pid); + std::thread::sleep(Duration::from_millis(100)); + } + return; + } + }; + if std::thread::Builder::new().name("wai-qsvc-watch".into()).spawn(watch).is_err() { + // No thread to watch it: it is not run unwatched. + over.store(OVER_TIME, Ordering::SeqCst); + crate::jobs::kill(pid); + } + Watch { done, over } + } + + fn over(&self) -> u8 { + self.over.load(Ordering::SeqCst) + } + + /// The child has ended: no signal is sent to its pid again. + fn ended(&self) { + self.done.store(true, Ordering::SeqCst); + } +} + +impl Drop for Watch { + fn drop(&mut self) { + self.done.store(true, Ordering::SeqCst); + } +} + +async fn in_child(config: &Config, r: &Read, due: &Due) -> Result { + let exe = crate::jobs::self_exe().map_err(|e| (500, e))?; + let mut cmd = crate::jobs::sandboxed(&exe, config.sandbox, "read"); + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); + let mut child = cmd.spawn().map_err(|e| (503, tag(refusal::BUSY, format!("the service could not start a reader ({e}); try again shortly"))))?; + let pid = child.id().unwrap_or(0); + let watch = Watch::start(pid, config.read_mib << 20, due.clone()); + let text = serde_json::to_vec(r).expect("a reading is JSON"); + let mut stdin = child.stdin.take().expect("piped"); + let writer = tokio::spawn(async move { + let _ = stdin.write_all(&text).await; + }); + let mut stdout = child.stdout.take().expect("piped"); + let mut stderr = child.stderr.take().expect("piped"); + // Its answer comes into this process: the child holds it to what the + // service takes back once read; its length is held here too, past which + // the child is ended. + let answer_limit = config.read_answer_mib << 20; + let too_long = Arc::new(AtomicBool::new(false)); + let over = Arc::clone(&too_long); + let reader = tokio::spawn(async move { + let mut out = Vec::new(); + let _ = (&mut stdout).take(answer_limit + 1).read_to_end(&mut out).await; + over.store(out.len() as u64 > answer_limit, Ordering::SeqCst); + out + }); + let errors = tokio::spawn(async move { + let mut out = Vec::new(); + let _ = (&mut stderr).take(1 << 20).read_to_end(&mut out).await; + out + }); + let ended = loop { + match tokio::time::timeout(Duration::from_millis(50), child.wait()).await { + Ok(Ok(status)) => { + watch.ended(); + break Ok(status); + } + Ok(Err(e)) => break Err((500, format!("the reader could not be watched: {e}"))), + Err(_) => {} + } + if too_long.load(Ordering::SeqCst) { + crate::jobs::stop(&mut child, pid).await; + watch.ended(); + break Ok(ExitStatus::default()); + } + }; + let _ = writer.await; + let out = reader.await.unwrap_or_default(); + let err = errors.await.unwrap_or_default(); + let refused = |why: String| Err((422, tag(refusal::TOO_LARGE_HERE, why))); + // Asked here, as a child cut off may end by itself before the loop sees it. + if too_long.load(Ordering::SeqCst) { + return Err((422, too_much(config.read_answer_mib))); + } + let detail = String::from_utf8_lossy(&err); + match watch.over() { + OVER_MEMORY => return refused(format!("reading this request took more than {} MiB, more than the service reads with; {LOCAL}", config.read_mib)), + OVER_TIME => { + // Cut short for its turn or its answer's time: to try again. + if let Some(why) = due.get().1 { + return Err((503, why)); + } + return refused(format!("reading this request took more than {} s, longer than the service reads for; {LOCAL}", config.read_timeout.as_secs())); + } + _ => {} + } + drop(watch); + let status = ended?; + // An allocation its address-space limit refused: its memory, as above. + if !status.success() && detail.contains("memory allocation of") { + return refused(format!("reading this request took more than {} MiB, more than the service reads with; {LOCAL}", config.read_mib)); + } + // Read off the runtime: an answer of many megabytes takes a while. Read + // here where it is small, or where no thread can be started for it. + let parsed = if out.len() < 256 << 10 { + serde_json::from_slice::(&out).ok() + } else { + let out = Arc::new(out); + let theirs = Arc::clone(&out); + match catch_unwind(AssertUnwindSafe(|| tokio::task::spawn_blocking(move || serde_json::from_slice::(&theirs).ok()))) { + Ok(task) => task.await.ok().flatten(), + Err(_) => serde_json::from_slice::(&out).ok(), + } + }; + match parsed { + Some(Value::Object(mut v)) if status.success() && v.contains_key("ok") => Ok(v.remove("ok").unwrap_or(Value::Null)), + Some(v) if status.success() && v["refused"][0].is_u64() => Err((v["refused"][0].as_u64().unwrap_or(500) as u16, v["refused"][1].as_str().unwrap_or("refused").to_string())), + _ => { + let last = detail.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim(); + Err((500, format!("reading this request failed inside the service ({status}{}{last}); nothing was admitted, and the service runs on", if last.is_empty() { "" } else { ": " }))) + } + } +} + +/// `work` on a thread of its own with a stack of [`STACK`]: its value, or +/// the refusal (500 when it panicked). +pub async fn on_thread(work: impl FnOnce() -> T + Send + 'static) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + std::thread::Builder::new() + .name("wai-qsvc-read".into()) + .stack_size(STACK) + .spawn(move || { + let _ = tx.send(catch_unwind(AssertUnwindSafe(work))); + }) + .map_err(|e| (503, tag(refusal::BUSY, format!("the service could not start a thread to read the request ({e}); try again shortly"))))?; + match rx.await { + Ok(Ok(v)) => Ok(v), + Ok(Err(p)) => { + let what = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "no message".into()); + Err((500, format!("reading this request failed inside the service ({what}); nothing was admitted, and the service runs on"))) + } + Err(_) => Err((500, "reading this request ended without an answer; nothing was admitted".into())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A panic in the work is the request's 500, and the next request is + /// read as usual. + #[tokio::test] + async fn a_panic_fails_its_request_and_the_next_one_runs() { + let (code, why) = on_thread(|| -> u32 { panic!("a test's panic") }).await.unwrap_err(); + assert_eq!(code, 500); + assert!(why.contains("a test's panic"), "{why}"); + assert_eq!(on_thread(|| 7).await.unwrap(), 7); + } + + /// The work's thread has the stack this module states: a recursion that + /// would overflow a runtime thread's 2 MiB returns here. + #[tokio::test] + async fn the_work_runs_on_a_stack_of_its_own_size() { + // At least 1 KiB a frame, kept: some 8 MiB of stack in all. + fn deep(n: u64) -> u64 { + let pad = std::hint::black_box([n; 128]); + if n == 0 { + return 1; + } + deep(n - 1).wrapping_mul(3).wrapping_add(pad[(n % 128) as usize]) + } + let want = (1..=8000u64).fold(1u64, |v, k| v.wrapping_mul(3).wrapping_add(k)); + assert_eq!(on_thread(|| deep(8000)).await.unwrap(), want); + } + + /// A client has its share of each pool, smaller than the pool. Past it, + /// one more turn is lent to it while the pool keeps [`LEND_LEAVES`] free, + /// so its quick call passes its own slow one; the rest wait behind its own + /// turns, and every other client is served at once. As turns come back, + /// those waiting go. Past its share, one lent turn and [`CLIENT_WAITING`] + /// waiting, it is told at once. + #[tokio::test] + async fn a_client_holds_no_more_of_a_pool_than_its_share_and_a_quick_call_passes() { + let gate = Gate::new(2, 1); + let soon = Duration::from_millis(300); + for (pool, share, size) in [(Pool::Reading, 2, AT_ONCE), (Pool::Checks, 1, CHECKS_AT_ONCE)] { + assert!(share < size); + let mut held = Vec::new(); + for _ in 0..share { + let t = gate.turn(pool, "a").await.ok().unwrap(); + assert!(!t.lent()); + held.push(t); + } + // Lent only while the pool keeps two free for others. + let lent = if size - share > LEND_LEAVES { + let t = gate.turn(pool, "a").await.ok().unwrap(); + assert!(t.lent(), "{pool:?}"); + Some(t) + } else { + None + }; + // Past that, it waits; every other client is served at once. + assert!(tokio::time::timeout(soon, gate.turn(pool, "a")).await.is_err(), "{pool:?}: a client past its share waits"); + let t = Instant::now(); + let other = gate.turn(pool, "b").await.ok().unwrap(); + assert!(!other.lent() && t.elapsed() < soon, "{pool:?}"); + drop(other); + // Its next goes once one of its turns comes back. + let g = &gate; + let next = async move { g.turn(pool, "a").await.ok().map(|_| ()) }; + let release = async { + tokio::time::sleep(soon).await; + held.pop(); + }; + let (got, ()) = tokio::join!(next, release); + assert!(got.is_some(), "{pool:?}"); + drop((held, lent)); + } + // No turn is lent where the pool would keep fewer than two free. + let gate = Gate::new(2, 1); + let (b, c) = (gate.turn(Pool::Checks, "b").await.ok().unwrap(), gate.turn(Pool::Checks, "c").await.ok().unwrap()); + let a = gate.turn(Pool::Checks, "a").await.ok().unwrap(); + let (code, why) = gate.turn_until(Pool::Checks, "a", Instant::now() + soon).await.err().unwrap(); + assert!(code == 503 && why.contains("your own 1 turn"), "{why}"); + drop((a, b, c)); + // Past its share, one lent and the waiting it is allowed, told at once. + let gate = std::sync::Arc::new(Gate::new(1, 1)); + let first = gate.turn(Pool::Checks, "a").await.ok().unwrap(); + let lent = gate.turn(Pool::Checks, "a").await.ok().unwrap(); + assert!(lent.lent()); + let waiters: Vec<_> = (0..CLIENT_WAITING) + .map(|_| { + let g = std::sync::Arc::clone(&gate); + tokio::spawn(async move { g.turn(Pool::Checks, "a").await.err().map(|e| e.0) }) + }) + .collect(); + while gate.waiting.load(Ordering::SeqCst) < CLIENT_WAITING { + tokio::time::sleep(Duration::from_millis(5)).await; + } + let t = Instant::now(); + let (code, why) = gate.turn(Pool::Checks, "a").await.err().unwrap(); + assert!(code == 503 && why.ends_with("[J032]") && t.elapsed() < soon, "{why}"); + drop((first, lent)); + for w in waiters { + assert_eq!(w.await.unwrap(), None, "each waiting request had its turn in time"); + } + assert!(gate.clients.lock().unwrap().is_empty(), "a client that holds nothing is not kept"); + } + + /// At the default shares, a client's readings run two at a time and at + /// most eight wait: no reading turn is lent to a client holding its share + /// of two (the pool would keep fewer than two free for others), so its + /// eleventh request at once is refused, at once. A check's share of one is + /// lent one more turn, and eight wait behind both: its eleventh is + /// refused too. As turns come back, every one that waited goes. + #[tokio::test] + async fn a_client_s_eleventh_request_at_once_is_refused_and_eight_wait() { + for (pool, share, lent) in [(Pool::Reading, 2, false), (Pool::Checks, 1, true)] { + let gate = Arc::new(Gate::new(2, 1)); + let mut held = Vec::new(); + for _ in 0..share { + held.push(gate.turn(pool, "a").await.ok().unwrap()); + } + if lent { + let t = gate.turn(pool, "a").await.ok().unwrap(); + assert!(t.lent(), "{pool:?}"); + held.push(t); + } + let waiters: Vec<_> = (0..CLIENT_WAITING) + .map(|_| { + let g = Arc::clone(&gate); + tokio::spawn(async move { g.turn(pool, "a").await.map(|t| t.lent()).map_err(|e| e.0) }) + }) + .collect(); + while gate.waiting.load(Ordering::SeqCst) < CLIENT_WAITING { + tokio::time::sleep(Duration::from_millis(5)).await; + } + let t = Instant::now(); + let (code, why) = gate.turn(pool, "a").await.err().expect("an eleventh request at once is refused"); + assert!(code == 503 && why.contains(&format!("{} of your requests", held.len() + CLIENT_WAITING)) && t.elapsed() < Duration::from_millis(300), "{pool:?}: {why}"); + drop(held); + for w in waiters { + let r = w.await.unwrap(); + assert!(r.is_ok(), "{pool:?}: a waiting request had its turn: {r:?}"); + } + } + } + + /// A client at its share of the readings is never lent one, even in the + /// moment between a turn of its own coming free and the request waiting + /// for it taking the pool's: through fifty bursts of eight readings each, + /// every one runs on the client's own turn, and the pool always keeps two + /// free for others. + #[tokio::test] + async fn a_client_at_its_share_of_the_readings_is_never_lent_one() { + let gate = Arc::new(Gate::new(2, 1)); + for _ in 0..50 { + let held = vec![gate.turn(Pool::Reading, "a").await.ok().unwrap(), gate.turn(Pool::Reading, "a").await.ok().unwrap()]; + let burst: Vec<_> = (0..CLIENT_WAITING) + .map(|_| { + let g = Arc::clone(&gate); + tokio::spawn(async move { + let t = g.turn(Pool::Reading, "a").await.ok().unwrap(); + let free = g.reading.available_permits(); + tokio::time::sleep(Duration::from_millis(1)).await; + (t.lent(), free) + }) + }) + .collect(); + while gate.waiting.load(Ordering::SeqCst) < CLIENT_WAITING { + tokio::time::sleep(Duration::from_millis(1)).await; + } + drop(held); + for b in burst { + let (lent, free) = b.await.unwrap(); + assert!(!lent && free >= LEND_LEAVES, "a reading lent past the share, or the pool left with {free} free"); + } + } + } + + /// Past [`MAX_WAITING`] a request is told at once, and those that wait + /// are told within [`WAIT`]: nothing waits without limit. + #[tokio::test] + async fn a_wait_for_a_turn_is_bounded_in_time_and_in_number() { + let gate = Arc::new(Gate::new(1, 1)); + let mut held = Vec::new(); + for i in 0..AT_ONCE { + held.push(gate.turn(Pool::Reading, &format!("holder {i}")).await.ok().unwrap()); + } + let waiting: Vec<_> = (0..MAX_WAITING) + .map(|i| { + let g = Arc::clone(&gate); + tokio::spawn(async move { g.turn(Pool::Reading, &format!("waiter {i}")).await.err().map(|e| e.0) }) + }) + .collect(); + while gate.waiting.load(Ordering::SeqCst) < MAX_WAITING { + tokio::time::sleep(Duration::from_millis(5)).await; + } + let t = Instant::now(); + let (code, why) = gate.turn(Pool::Reading, "one more").await.err().unwrap(); + assert_eq!(code, 503); + assert!(why.contains("[J032]") && t.elapsed() < Duration::from_secs(1), "{why}"); + for w in waiting { + assert_eq!(w.await.unwrap(), Some(503)); + } + assert_eq!(gate.waiting.load(Ordering::SeqCst), 0); + drop(held); + } + + /// A reading goes to its child and back as JSON, each kind of it. + #[test] + fn a_reading_is_json_both_ways() { + let r = Read::Call { kind: "qec".into(), op: "decode".into(), body: json!({ "circuit": "H 0\n" }), key: None }; + let back: Read = serde_json::from_value(serde_json::to_value(&r).unwrap()).unwrap(); + assert!(matches!(back, Read::Call { ref kind, .. } if kind == "qec")); + let a = run(Read::Admit { request: json!({ "qasm": "OPENQASM 3.0; qubit q; h q;", "shots": 4 }), job_bytes: 1 << 30, keep: true }).unwrap(); + assert!(a["plan"]["method"].is_string() && a["items"].is_null(), "{a}"); + } +} diff --git a/wai/wai-quantum-service/tests/depth.rs b/wai/wai-quantum-service/tests/depth.rs new file mode 100644 index 000000000..1c2e87639 --- /dev/null +++ b/wai/wai-quantum-service/tests/depth.rs @@ -0,0 +1,2017 @@ +//! The service itself, started here, sent programs nested thousands deep in +//! every place a client can put one: the job's own program and an intent's +//! reference (`POST /v1/jobs`, `POST /v1/run`, a dry run), the MCP run tool, +//! the checks, and an error-correction circuit. A stack overflow in any of +//! them would take the whole process down, on a POST of a few kilobytes. Each +//! is refused with the reader's code, and the service still answers +//! `/v1/health` after each. +//! +//! And a request whose references would take long to read is refused +//! within one budget, in bounded time, and counts toward its client's +//! window as every refused request does. + +use serde_json::{Value, json}; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + + +/// One HTTP/1.1 exchange: the status, the headers and the body. +fn http(port: u16, method: &str, path: &str, body: Option<&Value>) -> (u16, String, String) { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(120))).unwrap(); + let text = body.map(Value::to_string).unwrap_or_default(); + write!(s, "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}", text.len()).unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + let status = out.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0); + let (head, body) = out.split_once("\r\n\r\n").map(|(h, b)| (h.to_string(), b.to_string())).unwrap_or_default(); + (status, head, body) +} + +fn json_of(port: u16, method: &str, path: &str, body: Option<&Value>) -> (u16, Value) { + let (code, _, text) = http(port, method, path, body); + (code, serde_json::from_str(&text).unwrap_or_else(|e| panic!("{method} {path}: {code}: {e}: {text}"))) +} + +/// The service, its store at `dir`, `burst` submissions a window, once it +/// answers: its process and the port it bound. +fn start(dir: &Path, burst: u32) -> (Child, u16) { + start_with(dir, burst, &[]) +} + +/// [`start`], with `env` set beside. +fn start_with(dir: &Path, burst: u32, env: &[(&str, &str)]) -> (Child, u16) { + start_from(Path::new(env!("CARGO_BIN_EXE_wai-quantum-service")), dir, burst, env) +} + +/// [`start_with`], the service started from the binary at `bin`. It binds a +/// port the system chooses, and says which in its log, so no two services +/// started at once (by these tests or anything else on the host) race for one. +fn start_from(bin: &Path, dir: &Path, burst: u32, env: &[(&str, &str)]) -> (Child, u16) { + let mut cmd = Command::new(bin); + cmd.arg("serve"); + launch(cmd, dir, burst, env) +} + +/// [`start_with`], the service started by a shell that runs `limit` first +/// (`ulimit -n 256`, say). +#[cfg(unix)] +fn start_limited(dir: &Path, limit: &str, env: &[(&str, &str)]) -> (Child, u16) { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(format!("{limit} && exec \"$0\" serve")).arg(env!("CARGO_BIN_EXE_wai-quantum-service")); + launch(cmd, dir, burst_of(env), env) +} + +#[cfg(unix)] +fn burst_of(env: &[(&str, &str)]) -> u32 { + env.iter().find(|(k, _)| *k == "WAI_QSVC_CLIENT_BURST").and_then(|(_, v)| v.parse().ok()).unwrap_or(1000) +} + +/// Every service log written under `dir`. +fn logs(dir: &Path) -> String { + let mut out = String::new(); + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + if e.file_name().to_string_lossy().starts_with("service-") { + out += &std::fs::read_to_string(e.path()).unwrap_or_default(); + } + } + out +} + +/// `cmd`, the service, started over the store at `dir`. +fn launch(mut cmd: Command, dir: &Path, burst: u32, env: &[(&str, &str)]) -> (Child, u16) { + static STARTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let path = dir.join(format!("service-{}.log", STARTS.fetch_add(1, std::sync::atomic::Ordering::SeqCst))); + cmd.envs(env.iter().copied()) + .env("WAI_QSVC_ADDR", "127.0.0.1:0") + .env("WAI_QSVC_STORE", dir.join("store")) + .env("WAI_QSVC_KEY", dir.join("signing.key")) + .env("WAI_QSVC_JOB_GB", "2") + .env("WAI_QSVC_BUDGET_GB", "4") + .env("WAI_QSVC_WORKERS", "1") + .env("WAI_QSVC_ENERGY", "0") + .env("WAI_QSVC_CLIENT_BURST", burst.to_string()) + .stdout(Stdio::null()) + .stderr(std::fs::File::create(&path).map(Stdio::from).unwrap_or_else(|_| Stdio::null())); + // A binary this test process has just written (a copy to replace) can + // be busy for a moment: a child another test forked between the write's + // open and its close holds the file open for writing until it execs + // (`ETXTBSY`, Linux), whatever close-on-exec says. Started again shortly. + let tried = Instant::now(); + let mut child = loop { + match cmd.spawn() { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy && tried.elapsed() < Duration::from_secs(10) => std::thread::sleep(Duration::from_millis(50)), + started => break started.expect("the service starts"), + } + }; + let log = || std::fs::read_to_string(&path).unwrap_or_default(); + let t = Instant::now(); + let port = loop { + if let Some(p) = log().lines().find_map(|l| l.split(" on 127.0.0.1:").nth(1)?.split(',').next()?.trim().parse::().ok()) { + break p; + } + if let Ok(Some(status)) = child.try_wait() { + panic!("the service ended as it started ({status}): {}", log()); + } + assert!(t.elapsed() < Duration::from_secs(120), "the service did not start in 120 s: {}", log()); + std::thread::sleep(Duration::from_millis(50)); + }; + while TcpStream::connect(("127.0.0.1", port)).is_err() || http(port, "GET", "/v1/health", None).0 != 200 { + if let Ok(Some(status)) = child.try_wait() { + panic!("the service ended ({status}): {}", log()); + } + assert!(t.elapsed() < Duration::from_secs(120), "the service did not answer in 120 s: {}", log()); + std::thread::sleep(Duration::from_millis(50)); + } + (child, port) +} + +/// The service is up and answering, and its process has not ended. +fn alive(port: u16, svc: &mut Child, after: &str) { + assert!(svc.try_wait().unwrap().is_none(), "the service ended after {after}"); + let (code, h) = json_of(port, "GET", "/v1/health", None); + assert_eq!((code, h["ok"].as_bool()), (200, Some(true)), "after {after}: {h}"); +} + +fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("wai-qsvc-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +const HEAD: &str = "OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\n"; + +/// `rz(((…0.5…))) q[0];`, `n` parentheses deep: about 2n bytes. +fn parens(n: usize) -> String { + format!("{HEAD}rz({}0.5{}) q[0];\nc = measure q;\n", "(".repeat(n), ")".repeat(n)) +} + +#[test] +fn programs_nested_thousands_deep_are_refused_and_the_service_runs_on() { + let d = dir("depth"); + let (mut svc, port) = start(&d, 1000); + let deep = parens(5_000); + let blocks = format!("{HEAD}{}x q[0];{}\nc = measure q;\n", "{ ".repeat(5_000), " }".repeat(5_000)); + let power = format!("{HEAD}rz(1{}) q[0];\nc = measure q;\n", " ** 1".repeat(50_000)); + let refused = |code: u16, body: &Value, what: &str| { + assert!((400..500).contains(&code), "{what}: {code} {body}"); + let text = body.to_string(); + assert!(text.contains("Q006"), "{what}: {body}"); + }; + + // The job's own program, through both submissions and a dry run. + for (path, qasm, what) in [("/v1/jobs", &deep, "parentheses"), ("/v1/run", &blocks, "blocks"), ("/v1/jobs", &power, "a power")] { + let (code, b) = json_of(port, "POST", path, Some(&json!({ "qasm": qasm, "shots": 10 }))); + refused(code, &b, what); + alive(port, &mut svc, what); + } + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&json!({ "qasm": deep, "shots": 10, "dry_run": true }))); + assert_eq!(code, 200, "{b}"); + assert_eq!(b["accepted"], false, "{b}"); + refused(400, &b["refusal"], "a dry run"); + alive(port, &mut svc, "a dry run"); + + // An intent's reference: `rz(((((…0…))))) q[0];`. + let reference = format!("{HEAD}rz({}0{}) q[0];\n", "(".repeat(5_000), ")".repeat(5_000)); + let bell = format!("{HEAD}h q[0];\nc = measure q;\n"); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&json!({ "qasm": bell, "shots": 10, "intent": { "equivalent": reference } }))); + refused(code, &b, "a reference"); + alive(port, &mut svc, "a reference"); + // A set's items, and a sweep's generator. + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&json!({ "programs": [bell, deep], "shots": 10 }))); + refused(code, &b, "a set"); + alive(port, &mut svc, "a set"); + let generator = format!("OPENQASM 3.0;\ninput int n;\nconst int a = {}n{};\nqubit[1] q;\nx q[0];\n", "(".repeat(5_000), ")".repeat(5_000)); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&json!({ "qasm": generator, "sweep": { "n": [1, 2] }, "shots": 10 }))); + refused(code, &b, "a sweep"); + alive(port, &mut svc, "a sweep"); + + // The MCP run tool, and a run as a task. + let call = |args: Value, task: bool| { + let mut params = json!({ "name": "run_quantum_circuit", "arguments": args }); + if task { + params["task"] = json!({ "ttl": 60000 }); + } + json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params }))) + }; + let (code, b) = call(json!({ "qasm": deep, "shots": 10 }), false); + assert_eq!((code, b["result"]["isError"].as_bool()), (200, Some(true)), "{b}"); + assert!(b.to_string().contains("Q006"), "{b}"); + alive(port, &mut svc, "the MCP run tool"); + let (code, b) = call(json!({ "qasm": bell, "shots": 10, "intent": { "equivalent": reference } }), true); + assert_eq!(code, 200, "{b}"); + assert!(b["error"].is_object() && b.to_string().contains("Q006"), "{b}"); + alive(port, &mut svc, "an MCP task"); + + // The checks: each reads its programs, past its count, off the runtime. + for (path, body) in [ + ("/v1/critique", json!({ "qasm": deep })), + ("/v1/equivalent", json!({ "a": deep, "b": bell })), + ("/v1/compile", json!({ "qasm": deep })), + ("/v1/estimate", json!({ "qasm": deep })), + ("/v1/shortcuts", json!({ "qasm": blocks })), + ] { + let (code, b) = json_of(port, "POST", path, Some(&body)); + assert!(code < 500, "{path}: {code} {b}"); + assert!(b.to_string().contains("Q006"), "{path}: {b}"); + alive(port, &mut svc, path); + } + + // An error-correction circuit's blocks, nested fifty thousand deep (as + // deep as a body of a megabyte holds). + let circuit = format!("{}H 0\n{}", "REPEAT 1 {\n".repeat(50_000), "}\n".repeat(50_000)); + let (code, b) = json_of(port, "POST", "/v1/qec/decode", Some(&json!({ "circuit": circuit, "shots": 10 }))); + assert!((400..500).contains(&code) && b.to_string().contains("nested deeper"), "{code} {b}"); + alive(port, &mut svc, "a circuit's blocks"); + + // Nested to what people write, a job still runs. + let (code, b) = json_of(port, "POST", "/v1/run", Some(&json!({ "qasm": parens(30), "shots": 10, "seed": 1 }))); + assert_eq!((code, b["status"].as_str()), (200, Some("done")), "{b}"); + + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// References that each take long to read, many of them: refused within one +/// budget for the whole request, each read once, in bounded time, as too +/// large for the service, and counted toward the client's window, so a +/// client cannot send such requests without end. +#[test] +fn a_request_with_heavy_references_is_refused_in_bounded_time_and_counted() { + let d = dir("budget"); + let (mut svc, port) = start(&d, 4); + // Each reference loops five million times on reading, and each differs, + // so none is read from another's reading. + let heavy = |k: usize| format!("OPENQASM 3.0;\nqubit[1] q;\nint a = 0;\nfor int i in [0:4999999] {{ a += 1; }}\n// {k}\nx q[0];\n"); + let job = json!({ + "qasm": format!("{HEAD}x q[0];\n"), + "shots": 10, + "intent": (0..16).map(|k| json!({ "equivalent": heavy(k) })).collect::>(), + }); + let t = Instant::now(); + let (code, _, body) = http(port, "POST", "/v1/jobs", Some(&job)); + let took = t.elapsed(); + assert_eq!(code, 422, "{body}"); + assert!(body.contains("J031") && body.contains("steps"), "{body}"); + // One budget is about as long as one program the reader takes at its + // bound; sixteen references read three or four times each took minutes. + assert!(took < Duration::from_secs(60), "{took:?}"); + alive(port, &mut svc, "heavy references"); + + // Too many intents are refused before any is read. + let many = json!({ "qasm": format!("{HEAD}x q[0];\n"), "intent": (0..17).map(|k| json!({ "equivalent": heavy(k) })).collect::>() }); + let t = Instant::now(); + let (code, _, body) = http(port, "POST", "/v1/jobs", Some(&many)); + assert!(code == 400 && body.contains("J020") && body.contains("at most 16"), "{code} {body}"); + assert!(t.elapsed() < Duration::from_secs(5), "{:?}", t.elapsed()); + + // Two refused requests and two deep ones count: the fourth is the + // window's last, and the fifth is refused for the window. + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&json!({ "qasm": parens(5_000), "shots": 10 }))); + assert!(code == 400 && b.to_string().contains("Q006"), "{code} {b}"); + let (code, head, _) = http(port, "POST", "/v1/jobs", Some(&json!({ "qasm": parens(5_000), "shots": 10 }))); + assert_eq!(code, 400, "{head}"); + assert!(head.to_ascii_lowercase().contains("ratelimit: \"submissions\";r=0"), "{head}"); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&json!({ "qasm": format!("{HEAD}x q[0];\n"), "shots": 10 }))); + assert!(code == 429 && b.to_string().contains("J030"), "{code} {b}"); + alive(port, &mut svc, "the window"); + + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// Memory, through every door that reads a program: a declaration of a +/// hundred bytes that asks for 137 GB, an array of 10^10 booleans, a bit +/// register of 4·10^9 bits, resets of a wide register in a loop. Each ended +/// the process (an allocation that fails aborts it) or grew it without end. +/// Now each is refused (`Q130`) and the service answers `/v1/health` after. +/// A bit string wider than an integer is a refusal, not a 500; a run whose +/// MCP arguments are not an object, a refusal, not a dropped connection. +#[test] +fn programs_that_would_hold_without_limit_are_refused_and_the_service_runs_on() { + let d = dir("memory"); + let (mut svc, port) = start(&d, 1000); + let program = |body: &str| format!("OPENQASM 3.0;\nqubit[1] q;\n{body}x q[0];\n"); + for body in [ + "array[int[8], 4294967295] a;\n", + "array[bool, 100000, 100000] a;\n", + "bit[4000000000] c;\n", + "qubit[60000] r;\nfor int i in [0:999999] reset r;\n", + ] { + let qasm = program(body); + for (path, req) in [("/v1/jobs", json!({ "qasm": qasm, "dry_run": true })), ("/v1/jobs", json!({ "qasm": qasm, "shots": 10 })), ("/v1/critique", json!({ "qasm": qasm }))] { + let t = Instant::now(); + let (code, b) = json_of(port, "POST", path, Some(&req)); + assert!(code < 500 && b.to_string().contains("Q130"), "{path} {body}: {code} {b}"); + assert!(t.elapsed() < Duration::from_secs(60), "{path} {body}: {:?}", t.elapsed()); + alive(port, &mut svc, body); + } + } + let wide = program(&format!("int[32] x = \"{}\";\n", "1".repeat(200))); + for (path, req) in [("/v1/jobs", json!({ "qasm": wide, "dry_run": true })), ("/v1/jobs", json!({ "qasm": wide, "shots": 10 })), ("/v1/critique", json!({ "qasm": wide }))] { + let (code, b) = json_of(port, "POST", path, Some(&req)); + assert!(code < 500 && b.to_string().contains("Q112"), "{path}: {code} {b}"); + } + for args in [json!([1, 2]), json!(5), json!("x")] { + let (code, b) = json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_quantum_circuit", "arguments": args } }))); + assert_eq!(code, 200, "{b}"); + assert!(b["result"]["isError"] == true || b["error"].is_object(), "{b}"); + alive(port, &mut svc, "non-object arguments"); + } + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// The reading child's own caps, what holds where a limit of the reader's +/// might not: given 100 MiB, a set whose programs each read within the +/// reader's limits but together hold more is refused for its memory, as is an error-correction circuit that unrolls past it; +/// the service runs on. +#[test] +fn a_reading_past_its_memory_is_stopped_and_refused_and_the_service_runs_on() { + let d = dir("readcap"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_READ_GB", "0.1"), ("WAI_QSVC_READ_SECS", "120")]); + let big = |k: u32| format!("OPENQASM 3.0;\nqubit[2] q;\nfor int i in [0:999998] {{ x q[0]; x q[1]; }}\n// {k}\n"); + let t = Instant::now(); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&json!({ "programs": (0..2).map(big).collect::>(), "shots": 10 }))); + assert!(code == 422 && b.to_string().contains("J031") && b.to_string().contains("MiB"), "{code} {b}"); + assert!(t.elapsed() < Duration::from_secs(120), "{:?}", t.elapsed()); + alive(port, &mut svc, "a set past the reading's memory"); + let circuit = "H 0 1 2 3 4 5 6 7\nREPEAT 100000000 {\n X_ERROR(0.1) 0 1 2 3 4 5 6 7\n}\nM 0\nDETECTOR rec[-1]\n"; + let t = Instant::now(); + let (code, b) = json_of(port, "POST", "/v1/qec/decode", Some(&json!({ "circuit": circuit, "shots": 10 }))); + assert!(code == 422 && b.to_string().contains("J031"), "{code} {b}"); + assert!(t.elapsed() < Duration::from_secs(150), "{:?}", t.elapsed()); + alive(port, &mut svc, "a circuit past the reading's memory"); + svc.kill().unwrap(); + svc.wait().unwrap(); + // A sweep written out comes back into the service: held too, however + // small its request, as that memory is the one every job shares. + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_READ_ANSWER_MIB", "1")]); + let body = "x q;\n".repeat(500); + let sweep = json!({ "qasm": format!("OPENQASM 3.0;\ninput int n;\nqubit q;\nfor int i in [1:n] {{\n{body}}}\n"), "sweep": { "n": (0..100).map(|k| 100 + k).collect::>() }, "shots": 1 }); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&sweep)); + assert!(code == 422 && b.to_string().contains("J031") && b.to_string().contains("gave more than"), "{code} {b}"); + alive(port, &mut svc, "a sweep past what a reading gives"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A client told to try again shortly spends nothing by doing so: refused +/// for its jobs at once, before anything is counted or read, its window +/// stays whole however often it obeys `Retry-After`; and a job sent again +/// under its key, its window spent, is still its job, given as OpenQASM or +/// from another format. +#[test] +fn a_client_told_to_retry_spends_nothing_and_its_key_s_job_comes_back_past_the_window() { + let d = dir("retry"); + let (mut svc, port) = start_with(&d, 4, &[("WAI_QSVC_CLIENT_ACTIVE", "1")]); + let left = |head: &str| head.to_ascii_lowercase().lines().find_map(|l| l.strip_prefix("ratelimit: \"submissions\";r=").map(|r| r.split(';').next().unwrap_or("").to_string())).unwrap_or_default(); + let post = |path: &str, body: &Value, key: Option<&str>| { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(120))).unwrap(); + let text = body.to_string(); + let k = key.map(|k| format!("Idempotency-Key: {k}\r\n")).unwrap_or_default(); + write!(s, "POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n{k}Content-Length: {}\r\nConnection: close\r\n\r\n{text}", text.len()).unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + let code: u16 = out.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0); + let (head, body) = out.split_once("\r\n\r\n").map(|(h, b)| (h.to_string(), b.to_string())).unwrap_or_default(); + (code, head, body) + }; + // A job that runs long, its answer lost: sent under a key, given from a + // source in OpenQASM. + let mut long = String::from("OPENQASM 3.0;\nqubit[24] q;\nh q;\n"); + for k in 0..6000 { + long += &format!("rx(0.{}) q[{}];\ncx q[{}], q[{}];\n", k % 9 + 1, k % 24, k % 24, (k + 1) % 24); + } + let job = json!({ "source": { "format": "qasm", "program": long }, "observables": ["Z0"] }); + let (code, head, first) = post("/v1/jobs", &job, Some("lost-answer")); + assert_eq!(code, 202, "{first}"); + assert_eq!(left(&head), "3", "{head}"); + let id = serde_json::from_str::(&first).unwrap()["id"].as_str().unwrap().to_string(); + // Told twenty times to wait for it: nothing spent. + let bell = json!({ "qasm": "OPENQASM 3.0; qubit q; h q;", "shots": 10 }); + for _ in 0..20 { + let (code, head, body) = post("/v1/jobs", &bell, None); + assert!(code == 429 && body.contains("wait for one to finish"), "{code} {body}"); + assert_eq!(left(&head), "3", "{head}"); + // Sent again at that pace, what counts would never spend the window. + assert!(head.to_ascii_lowercase().contains("retry-after: 150"), "{head}"); + } + // Its window spent by refusals that do count, + for _ in 0..3 { + let (code, _, body) = post("/v1/critique", &json!({ "qasm": "OPENQASM 3.0; qubit q; foo q;" }), None); + assert_eq!(code, 200, "{body}"); + } + let (code, _, _) = post("/v1/critique", &json!({ "qasm": "OPENQASM 3.0; qubit q; h q;" }), None); + assert_eq!(code, 429); + // its job, sent again under its key, still comes back to it. + let (code, _, again) = post("/v1/jobs", &job, Some("lost-answer")); + assert_eq!(code, 202, "{again}"); + assert_eq!(serde_json::from_str::(&again).unwrap()["id"], id.as_str(), "{again}"); + let _ = http(port, "DELETE", &format!("/v1/jobs/{id}"), None); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// An index in a few bytes of text would make what is as long as the index: +/// a decoder's table for 10^14 detectors, in 79 bytes. Each way of naming one +/// is refused for what it is, and the service runs on. +#[test] +fn an_index_past_what_a_model_holds_is_refused_and_the_service_runs_on() { + let d = dir("indices"); + let (mut svc, port) = start(&d, 1000); + for body in [ + json!({ "model": "shift_detectors 100000000000000\ndetector(1) D0\n", "detectors": [] }), + json!({ "model": "shift_detectors 100000000000000\ndetector D0\n", "detectors": [] }), + json!({ "model": "detector(1) D4294967294\n", "detectors": [] }), + json!({ "circuit": "M 4000000000\nDETECTOR rec[-1]\n", "shots": 10 }), + ] { + let (code, b) = json_of(port, "POST", "/v1/qec/decode", Some(&body)); + assert!((400..500).contains(&code) && b.to_string().contains("past the"), "{body}: {code} {b}"); + alive(port, &mut svc, "an index"); + } + let (code, b) = json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "qec_decode", "arguments": { "model": "shift_detectors 100000000000000\ndetector(1) D0\n", "detectors": [] } } }))); + assert!(code == 200 && b.to_string().contains("past the"), "{code} {b}"); + alive(port, &mut svc, "an index through MCP"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A program a reader takes seconds over, each differing, so none is read +/// from another's reading. +fn slow(k: usize) -> String { + format!("OPENQASM 3.0;\nqubit[1] q;\nint a = 0;\nfor int i in [0:4999999] {{ a += 1; }}\n// {k}\nx q[0];\n") +} + +/// `n` requests at once, each as `send` makes it: the seconds each took. +fn at_once(n: usize, send: impl Fn(usize) + Sync) -> Vec { + std::thread::scope(|t| { + let handles: Vec<_> = (0..n) + .map(|i| { + let send = &send; + t.spawn(move || { + let began = Instant::now(); + send(i); + began.elapsed().as_secs_f64() + }) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }) +} + +/// How long `/v1/health` takes while `during` runs beside it, at its worst. +fn health_during(port: u16, during: impl FnOnce() + Send) -> f64 { + let done = std::sync::atomic::AtomicBool::new(false); + std::thread::scope(|t| { + let probe = t.spawn(|| { + let mut worst = 0f64; + while !done.load(std::sync::atomic::Ordering::SeqCst) { + let began = Instant::now(); + assert_eq!(http(port, "GET", "/v1/health", None).0, 200); + worst = worst.max(began.elapsed().as_secs_f64()); + std::thread::sleep(Duration::from_millis(20)); + } + worst + }); + // Set on the way out however `during` ends, so a failure in it fails here. + struct Done<'a>(&'a std::sync::atomic::AtomicBool); + impl Drop for Done<'_> { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + { + let _done = Done(&done); + during(); + } + probe.join().unwrap() + }) +} + +/// A refused MCP task used to read its program on a runtime worker, with no +/// budget and no count: four of them stalled every route. A task refused +/// before anything was counted reads nothing, and spends nothing. +#[test] +fn a_refused_mcp_task_reads_nothing_and_holds_no_worker() { + let d = dir("tasks"); + let (mut svc, port) = start(&d, 1000); + let worst = health_during(port, || { + let took = at_once(4, |k| { + let (code, b) = json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_quantum_circuit", "task": {}, "arguments": { "qasm": slow(k), "idempotency_key": 5 } } }))); + assert!(code == 200 && b["error"]["data"]["verdict"]["level"] == "refused", "{b}"); + }); + assert!(took.iter().all(|&t| t < 1.0), "{took:?}"); + }); + assert!(worst < 1.0, "health took {worst} s"); + alive(port, &mut svc, "refused tasks"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// Every check counts, refused or not, before its bounds read anything; a +/// set's receipt is read as a check is, as its sweep is written out again. +#[test] +fn a_check_and_a_set_s_receipt_count_before_they_read() { + let d = dir("counted"); + let (mut svc, port) = start(&d, 5); + let left = |head: &str| head.to_ascii_lowercase().lines().find_map(|l| l.strip_prefix("ratelimit: \"submissions\";r=").map(|r| r.split(';').next().unwrap_or("").to_string())).unwrap_or_default(); + let wide = format!("OPENQASM 3.0;\nqubit[22] q;\nh q;\n{}", "rz(0.3) q;\n".repeat(200)); + let (code, head, body) = http(port, "POST", "/v1/equivalent", Some(&json!({ "a": wide, "b": wide }))); + assert!(code == 422 && body.contains("J031"), "{code} {body}"); + assert_eq!(left(&head), "4", "{head}"); + let (code, head, body) = http(port, "POST", "/v1/critique", Some(&json!({ "qasm": format!("OPENQASM 3.0;\nqubit q;\nrz({}0.5{}) q;\n", "(".repeat(5_000), ")".repeat(5_000)) }))); + assert!(code == 200 && body.contains("Q006"), "{code} {body}"); + assert_eq!(left(&head), "3", "{head}"); + let set = json!({ "request": { "qasm": "input int n;\nqubit q;\nx q;\n", "sweep": { "n": [1, 2] } }, "result": { "format": "wai.quantum.set/1", "count": 2, "items": [], "receipt": {} } }); + let (code, head, body) = http(port, "POST", "/v1/verify", Some(&set)); + assert!(code == 200 && body.contains("\"valid\":false"), "{code} {body}"); + assert_eq!(left(&head), "2", "{head}"); + let (code, b) = json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "verify_quantum_receipt", "arguments": set } }))); + assert!(code == 200 && b["result"]["structuredContent"]["valid"] == false, "{b}"); + let (_, head, _) = http(port, "GET", "/v1", None); + assert_eq!(left(&head), "1", "{head}"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// Reading runs off the request threads: eight clients' heavy requests at +/// once leave `/v1/health` answering, as a job's reading used to stall it. +/// A client has its share of the turns, told at once past it. +#[test] +fn reading_leaves_the_runtime_free_and_a_client_has_its_share() { + let d = dir("runtime"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1")]); + let from = |who: &str, path: &str, body: &Value| { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(120))).unwrap(); + let text = body.to_string(); + write!(s, "POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nX-Forwarded-For: {who}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}", text.len()).unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + out + }; + let worst = health_during(port, || { + at_once(8, |k| { + let job = json!({ "qasm": format!("{HEAD}x q[0];\n"), "shots": 10, "intent": (0..16).map(|i| json!({ "equivalent": slow(100 * k + i) })).collect::>() }); + let out = from(&format!("10.0.0.{k}"), "/v1/jobs", &job); + assert!(out.contains("J031") || out.contains("J032"), "{out}"); + }); + }); + assert!(worst < 1.0, "health took {worst} s"); + // One client's parallel calls, as an agent makes them: served one after + // another behind its share, every one answered. + at_once(5, |_| { + let out = from("10.0.1.1", "/v1/critique", &json!({ "qasm": "OPENQASM 3.0; qubit q; h q;" })); + assert!(out.starts_with("HTTP/1.1 200"), "{out}"); + }); + // Past its share, a lent turn and the requests it may have waiting, told + // at once. + let answers = std::sync::Mutex::new(Vec::new()); + at_once(11, |k| { + if k == 10 { + std::thread::sleep(Duration::from_millis(300)); + } + let began = Instant::now(); + let out = from("10.0.1.2", "/v1/critique", &json!({ "qasm": slow(1000 + k) })); + answers.lock().unwrap().push((out.contains("J032"), began.elapsed().as_secs_f64())); + }); + let answers = answers.into_inner().unwrap(); + assert!(answers.iter().any(|&(busy, took)| busy && took < 1.0), "{answers:?}"); + alive(port, &mut svc, "the turns"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// Steps bound work, not time: a reading past its deadline is stopped, in its +/// child, and refused, and the turn it held goes to the next. A client that +/// hangs up ends its reading, and its turn comes back at once. +#[test] +fn a_reading_past_its_time_is_stopped_and_a_hang_up_ends_it() { + let d = dir("deadline"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_READ_SECS", "1"), ("WAI_QSVC_CLIENT_READS", "1")]); + let long = "OPENQASM 3.0;\nqubit q;\nint a = 0;\nfor int i in [0:9899999] { a += 1; }\nx q;\n"; + let t = Instant::now(); + let (code, b) = json_of(port, "POST", "/v1/critique", Some(&json!({ "qasm": long }))); + assert!(code == 422 && b.to_string().contains("J031") && b.to_string().contains("1 s"), "{code} {b}"); + assert!(t.elapsed() < Duration::from_secs(10), "{:?}", t.elapsed()); + alive(port, &mut svc, "a reading past its time"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_CLIENT_READS", "1")]); + let text = json!({ "qasm": long }).to_string(); + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + write!(s, "POST /v1/critique HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{text}", text.len()).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + drop(s); + let began = Instant::now(); + loop { + let (code, _, body) = http(port, "POST", "/v1/critique", Some(&json!({ "qasm": "OPENQASM 3.0; qubit q; h q;" }))); + if code == 200 { + break; + } + assert!(code == 503 && began.elapsed() < Duration::from_secs(2), "{code} {body} after {:?}", began.elapsed()); + std::thread::sleep(Duration::from_millis(50)); + } + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A check's answer reads within the check's budget: a repair of forty fixes +/// of a program a read takes seconds over ran for minutes. Each pass of a +/// repair is a new text, so it ends at the budget, refused. +#[test] +fn a_check_s_answer_is_held_to_its_budget() { + let d = dir("answer"); + let (mut svc, port) = start(&d, 1000); + let program = format!("OPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nint x = 0;\nfor int i in [0:9899999] {{ x = i; }}\n{}", "measure q[0];\n".repeat(40)); + let t = Instant::now(); + let (code, b) = json_of(port, "POST", "/v1/repair", Some(&json!({ "qasm": program }))); + assert!(code == 422 && b.to_string().contains("J031"), "{code} {b}"); + assert!(t.elapsed() < Duration::from_secs(60), "{:?}", t.elapsed()); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A program within the reader's own bounds is taken here: its statements and +/// its gates each near their bound, past what one count of both would allow. +#[test] +fn a_job_within_the_reader_s_bounds_reads_here() { + let d = dir("own"); + let (mut svc, port) = start(&d, 1000); + let program = "OPENQASM 3.0;\nqubit q;\nint a = 0;\nfor int i in [0:8499999] { a += 1; }\nfor int i in [0:1399999] { x q; }\n"; + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&json!({ "qasm": program, "shots": 1, "dry_run": true }))); + assert!(code == 200 && b["accepted"] == true, "{code} {b}"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A client that stops sending holds its connection for no longer than the +/// limits say: a header section left open is closed, a body left open is +/// refused `408`, and the service answers beside them. +#[test] +fn a_stuck_connection_is_let_go() { + let d = dir("stuck"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_HEADER_SECS", "1"), ("WAI_QSVC_BODY_SECS", "1")]); + let open = |text: &str| { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + s.write_all(text.as_bytes()).unwrap(); + s + }; + let mut header = open("POST /v1/jobs HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Half: "); + let mut body = open("POST /v1/jobs HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n{\"qasm\""); + alive(port, &mut svc, "stuck connections"); + let t = Instant::now(); + let mut out = String::new(); + let _ = header.read_to_string(&mut out); + assert!(t.elapsed() < Duration::from_secs(5), "{:?}", t.elapsed()); + out.clear(); + let _ = body.read_to_string(&mut out); + assert!(out.starts_with("HTTP/1.1 408"), "{out}"); + assert!(t.elapsed() < Duration::from_secs(5), "{:?}", t.elapsed()); + alive(port, &mut svc, "connections let go"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// `body` sent to `path` as the client `who` (behind the trusted proxy): +/// the status and the whole answer. +fn post_as(port: u16, who: &str, path: &str, body: &Value) -> (u16, String) { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(150))).unwrap(); + let text = body.to_string(); + write!(s, "POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nX-Forwarded-For: {who}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}", text.len()).unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + (out.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0), out) +} + +/// An error-correction threshold at the service's bounds whose shots run for +/// minutes: what one client used to hold every check turn with. +fn heavy_threshold() -> Value { + json!({ "family": "surface", "distances": [7, 7, 7, 7], "p": [0.04, 0.045, 0.05, 0.055], "shots": 12_500 }) +} + +/// One client sending checks that each hold a turn for as long as a reading +/// may take holds no more than its share of the checks: every other client's +/// check is answered beside them, where it waited and was refused. +#[test] +fn one_client_cannot_hold_every_check_turn() { + let d = dir("checkshare"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1"), ("WAI_QSVC_QEC_SECS", "60"), ("WAI_QSVC_READ_SECS", "40")]); + std::thread::scope(|t| { + for _ in 0..2 { + t.spawn(|| post_as(port, "198.51.100.66", "/v1/qec/threshold", &heavy_threshold())); + } + std::thread::sleep(Duration::from_millis(1500)); + for (path, body) in [("/v1/critique", json!({ "qasm": "OPENQASM 3.0; qubit q; h q;" })), ("/v1/qec/code", json!({ "code": "surface:3", "certify": false }))] { + let began = Instant::now(); + let (code, out) = post_as(port, "203.0.113.7", path, &body); + assert!(code == 200 && began.elapsed() < Duration::from_secs(5), "{path}: {code} after {:?}: {out}", began.elapsed()); + } + alive(port, &mut svc, "one client's checks"); + svc.kill().unwrap(); + }); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// An error-correction call at the bounds whose shots would run for minutes +/// ends well inside the reading's time: refused as soon as its first shots +/// say the rest would pass the call's time, with the command that runs it +/// locally. One that fits is answered. +#[test] +fn a_qec_call_at_its_bounds_ends_well_inside_the_reading_s_time() { + let d = dir("qectime"); + let (mut svc, port) = start(&d, 1000); + for (path, body) in [("/v1/qec/threshold", heavy_threshold()), ("/v1/qec/decode", json!({ "memory": { "distance": 7, "rounds": 7, "p": 0.05 }, "shots": 200_000 }))] { + let began = Instant::now(); + let (code, b) = json_of(port, "POST", path, Some(&body)); + assert!(code == 422 && b.to_string().contains("J031") && b.to_string().contains("wai-quantum qec"), "{path}: {code} {b}"); + assert!(began.elapsed() < Duration::from_secs(30), "{path}: {:?}", began.elapsed()); + alive(port, &mut svc, path); + } + let (code, b) = json_of(port, "POST", "/v1/qec/threshold", Some(&json!({ "distances": [3], "p": [0.001], "shots": 1000 }))); + assert_eq!(code, 200, "{b}"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// The service's own peak memory, KiB, where the platform reports it. +fn peak_kib(svc: &Child) -> Option { + let s = std::fs::read_to_string(format!("/proc/{}/status", svc.id())).ok()?; + s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok() +} + +/// A reading's answer comes back into the service, whose memory every job +/// shares: a sweep whose hundred items each copy a large field, a few hundred +/// kilobytes sent, is refused for what its answer takes once read, before the +/// service reads it, and the service's own memory stays small. A dry run +/// takes back the plans and the points alone, and is held to what its +/// submission's answer would take: it says the same sweep is refused. +#[test] +fn a_reading_s_answer_is_held_to_what_it_takes_once_read() { + let d = dir("answer-size"); + let (mut svc, port) = start(&d, 1000); + let sweep = |pad: usize, dry: bool| json!({ "qasm": "OPENQASM 3.0;\ninput int n;\nqubit q;\nx q;\n", "sweep": { "n": (0..100).collect::>() }, "shots": 1, "dry_run": dry, "pad": vec![0; pad] }); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&sweep(0, true))); + assert!(code == 200 && b["accepted"] == true && b["items"].as_array().is_some_and(|i| i.len() == 100), "{code} {b}"); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&sweep(100_000, true))); + assert!(code == 200 && b["accepted"] == false && b["refusal"]["code"] == "J031" && b.to_string().contains("once read"), "{code} {b}"); + // A job keeps its items: past what the service takes back, refused. + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&sweep(100_000, false))); + assert!(code == 422 && b.to_string().contains("J031") && b.to_string().contains("once read"), "{code} {b}"); + if let Some(kib) = peak_kib(&svc) { + assert!(kib < 400 << 10, "the service held {kib} KiB"); + } + alive(port, &mut svc, "a large answer"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// The binary replaced before the restart, by a rename (Linux, where a copy +/// over a running binary is refused) or a copy over it (elsewhere): the +/// running service goes on reading and running jobs with its own until it is +/// restarted, where every reading failed. +#[test] +fn the_binary_can_be_replaced_before_the_restart() { + let d = dir("replace"); + let bin = d.join("app/wai-quantum-service"); + std::fs::create_dir_all(bin.parent().unwrap()).unwrap(); + std::fs::copy(env!("CARGO_BIN_EXE_wai-quantum-service"), &bin).unwrap(); + let (mut svc, port) = start_from(&bin, &d, 1000, &[]); + let stub = d.join("app/stub"); + std::fs::write(&stub, "#!/bin/sh\necho 'not this service' >&2\nexit 2\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + if cfg!(target_os = "linux") { + std::fs::rename(&stub, &bin).unwrap(); + } else { + std::fs::copy(&stub, &bin).unwrap(); + } + let (code, b) = json_of(port, "POST", "/v1/critique", Some(&json!({ "qasm": "OPENQASM 3.0; qubit q; h q;" }))); + assert_eq!(code, 200, "{b}"); + let (code, b) = json_of(port, "POST", "/v1/run", Some(&json!({ "qasm": "OPENQASM 3.0; qubit q; bit c; h q; c = measure q;", "shots": 10 }))); + assert!(code == 200 && b["status"] == "done", "{code} {b}"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// The service's memory now, KiB: resident and swapped on Linux, resident +/// elsewhere. +fn mem_kib(svc: &Child) -> u64 { + if let Ok(s) = std::fs::read_to_string(format!("/proc/{}/status", svc.id())) { + let kb = |f: &str| s.lines().find(|l| l.starts_with(f)).and_then(|l| l.split_whitespace().nth(1)?.parse::().ok()).unwrap_or(0); + return kb("VmRSS:") + kb("VmSwap:"); + } + let o = Command::new("ps").args(["-o", "rss=", "-p", &svc.id().to_string()]).output().unwrap(); + String::from_utf8_lossy(&o.stdout).trim().parse().unwrap_or(0) +} + +/// What ended jobs keep: thirty jobs from one client, each carrying a field +/// of a few hundred thousand numbers (a parsed request took tens of MiB, kept +/// for a day and reloaded on restart), keep their requests' text alone, and +/// the service stays small, after a restart too. +#[test] +fn ended_jobs_keep_little_and_a_restart_keeps_it_little() { + let d = dir("kept"); + let (mut svc, port) = start(&d, 1000); + let job = json!({ "qasm": "OPENQASM 3.0; qubit q; bit c; h q; c = measure q;", "shots": 8, "pad": vec![0; 400_000] }); + let mut last = String::new(); + for _ in 0..30 { + let (code, b) = json_of(port, "POST", "/v1/run", Some(&job)); + assert!(code == 200 && b["status"] == "done", "{code} {b}"); + last = b["id"].as_str().unwrap().to_string(); + } + let kib = mem_kib(&svc); + assert!(kib < 200 << 10, "after thirty ended jobs the service holds {kib} KiB"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let (mut svc, port) = start(&d, 1000); + let (code, b) = json_of(port, "GET", &format!("/v1/jobs/{last}"), None); + assert!(code == 200 && b["request"]["pad"].as_array().is_some_and(|p| p.len() == 400_000), "{code}"); + let kib = mem_kib(&svc); + assert!(kib < 200 << 10, "after a restart the service holds {kib} KiB"); + alive(port, &mut svc, "the jobs kept"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A program that runs for minutes. +fn long_job() -> Value { + let mut long = String::from("OPENQASM 3.0;\nqubit[24] q;\nh q;\n"); + for k in 0..6000 { + long += &format!("rx(0.{}) q[{}];\ncx q[{}], q[{}];\n", k % 9 + 1, k % 24, k % 24, (k + 1) % 24); + } + json!({ "qasm": long, "observables": ["Z0"] }) +} + +/// Every answer comes within the answer's time from the request's arrival: +/// a run whose job is still running answers with the job to poll, by REST +/// and by MCP, where it waited 25 s and 50 s and passed a proxy's wait. +#[test] +fn a_run_s_answer_comes_within_the_answer_s_time() { + let d = dir("answer-time"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_ANSWER_SECS", "15")]); + let began = Instant::now(); + let (code, b) = json_of(port, "POST", "/v1/run", Some(&long_job())); + assert!(code == 202 && b["id"].is_string() && began.elapsed() < Duration::from_secs(20), "{code} after {:?}", began.elapsed()); + let first = b["id"].as_str().unwrap().to_string(); + let began = Instant::now(); + let (code, b) = json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_quantum_circuit", "arguments": long_job() } }))); + let sc = &b["result"]["structuredContent"]; + assert!(code == 200 && sc["id"].is_string() && began.elapsed() < Duration::from_secs(20), "{code} after {:?}: {}", began.elapsed(), sc["status"]); + assert!(sc["verdict"].is_object(), "{sc}"); + for id in [first.as_str(), sc["id"].as_str().unwrap()] { + let _ = http(port, "DELETE", &format!("/v1/jobs/{id}"), None); + } + alive(port, &mut svc, "runs answered in time"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// One client's quick calls pass its own slow, in-bounds check, on a turn +/// lent beside it, and its burst of eight quick calls is answered in full, +/// where they waited behind it and were refused. +#[test] +fn a_client_s_quick_calls_pass_its_own_slow_one_and_its_burst_is_answered() { + let d = dir("ownqueue"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1"), ("WAI_QSVC_QEC_SECS", "60"), ("WAI_QSVC_READ_SECS", "40")]); + let me = "198.51.100.23"; + std::thread::scope(|t| { + t.spawn(|| post_as(port, me, "/v1/qec/threshold", &heavy_threshold())); + std::thread::sleep(Duration::from_millis(1500)); + let began = Instant::now(); + let (code, out) = post_as(port, me, "/v1/critique", &json!({ "qasm": "OPENQASM 3.0; qubit q; h q;" })); + // Well inside the slow call's 40 s, and inside the lent turn's 5 s. + assert!(code == 200 && began.elapsed() < Duration::from_secs(5), "{code} after {:?}: {out}", began.elapsed()); + let answers = at_once(8, |_| { + let (code, out) = post_as(port, me, "/v1/critique", &json!({ "qasm": "OPENQASM 3.0; qubit q; h q;" })); + assert_eq!(code, 200, "{out}"); + }); + assert!(answers.iter().all(|&s| s < 10.0), "{answers:?}"); + alive(port, &mut svc, "a client's own queue"); + svc.kill().unwrap(); + }); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// The caps stop a reading when no new process can be started: the kill is a +/// signal the service sends itself. Linux, where a process's limits can be +/// lowered while it runs (`prlimit`). +#[cfg(target_os = "linux")] +#[test] +fn a_reading_is_stopped_when_no_process_can_be_started() { + if Command::new("prlimit").arg("--version").output().is_err() { + eprintln!("no prlimit here: not run"); + return; + } + let d = dir("nproc"); + // No time of its own for the call: only the reading's cap stops it. + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_READ_SECS", "4"), ("WAI_QSVC_QEC_SECS", "100000")]); + let pid = svc.id().to_string(); + let answer = std::thread::spawn(move || { + let began = Instant::now(); + let (code, b) = json_of(port, "POST", "/v1/qec/threshold", Some(&heavy_threshold())); + (code, b, began.elapsed()) + }); + std::thread::sleep(Duration::from_millis(1500)); + let ok = Command::new("prlimit").args(["--pid", &pid, "--nproc=1:1"]).status().unwrap().success(); + assert!(ok, "prlimit"); + let (code, b, took) = answer.join().unwrap(); + assert!(code == 422 && b.to_string().contains("J031") && took < Duration::from_secs(15), "{code} after {took:?}: {b}"); + assert!(svc.try_wait().unwrap().is_none(), "the service ended"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// This test process's own open-file limit raised to `n`, as far as its hard +/// limit lets it: it holds hundreds of connections to the service. +#[cfg(unix)] +fn room_for(n: u64) { + // SAFETY: getrlimit and setrlimit read and write the struct given. + unsafe { + let mut l = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; + if libc::getrlimit(libc::RLIMIT_NOFILE, &mut l) == 0 && l.rlim_cur < n as libc::rlim_t { + l.rlim_cur = (n as libc::rlim_t).min(l.rlim_max); + libc::setrlimit(libc::RLIMIT_NOFILE, &l); + } + } +} + +/// How many connections this test process can hold open beside what it +/// needs for itself and its health checks: `want`, or fewer where its own +/// open-file limit is low (a whole suite run under `ulimit -n 256`). +#[cfg(unix)] +fn can_hold(want: usize) -> usize { + // SAFETY: getrlimit writes the struct given. + let soft = unsafe { + let mut l = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; + if libc::getrlimit(libc::RLIMIT_NOFILE, &mut l) == 0 { l.rlim_cur as usize } else { 256 } + }; + // Under a low limit of its own the suite's tests share it, four at a time. + if soft < 1024 { want.min(soft / 4) } else { want } +} + +/// Whether this test process's hard open-file limit lets a child raise its +/// soft one past 256. +#[cfg(unix)] +fn can_raise() -> bool { + // SAFETY: getrlimit writes the struct given. + unsafe { + let mut l = libc::rlimit { rlim_cur: 0, rlim_max: 0 }; + libc::getrlimit(libc::RLIMIT_NOFILE, &mut l) == 0 && l.rlim_max > 256 + } +} + +/// Whether a health check on a new connection is answered `200` within 3 s. +fn healthy(port: u16) -> bool { + let Ok(mut s) = TcpStream::connect_timeout(&std::net::SocketAddr::from(([127, 0, 0, 1], port)), Duration::from_secs(3)) else { return false }; + let _ = s.set_read_timeout(Some(Duration::from_secs(3))); + let mut out = String::new(); + write!(s, "GET /v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").is_ok() && s.read_to_string(&mut out).is_ok() && out.split_whitespace().nth(1) == Some("200") +} + +/// Connections held open, from one client and from many, partway through a +/// header section or a body, take no more of the service's open files than it +/// has, and a health check is answered throughout: where its limit is 256 and +/// cannot be raised (a launchd service's), and where only its soft limit is +/// 256, which the service raises. Past a client's open requests, the rest +/// are answered `503` at once, unread. +#[cfg(unix)] +#[test] +fn held_connections_leave_room_and_health_answers() { + room_for(8192); + for (name, limit) in [("hard", "ulimit -Sn 256 && ulimit -Hn 256"), ("soft", "ulimit -Sn 256")] { + let d = dir(&format!("files-{name}")); + let (mut svc, port) = start_limited(&d, limit, &[("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1")]); + let answered = |what: &str| { + let ok = (0..15).filter(|_| healthy(port)).count(); + assert_eq!(ok, 15, "{name}: health answered {ok} of 15 {what}: {}", logs(&d)); + }; + // One client, 400 header sections that never end. + let n = can_hold(400); + let held: Vec = (0..n) + .filter_map(|_| { + let mut s = TcpStream::connect(("127.0.0.1", port)).ok()?; + let _ = s.write_all(b"GET /v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\n"); + Some(s) + }) + .collect(); + std::thread::sleep(Duration::from_secs(1)); + answered("beside 400 header sections held open"); + drop(held); + // 400 clients, a body each that stops arriving. + let held: Vec = (0..n) + .filter_map(|i| { + let mut s = TcpStream::connect(("127.0.0.1", port)).ok()?; + let _ = write!(s, "POST /v1/run HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Forwarded-For: 198.51.{}.{}\r\nContent-Type: application/json\r\nContent-Length: 1048576\r\n\r\n{{\"qasm\": \"", i / 200, i % 200 + 1); + Some(s) + }) + .collect(); + std::thread::sleep(Duration::from_millis(1500)); + answered("beside 400 clients' bodies held open"); + drop(held); + // One client, 400 bodies that stop arriving: past its open requests, + // refused at once. + let mut held: Vec = (0..n) + .filter_map(|_| { + let mut s = TcpStream::connect(("127.0.0.1", port)).ok()?; + let _ = write!(s, "POST /v1/run HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: 1048576\r\n\r\n{{\"qasm\": \""); + Some(s) + }) + .collect(); + answered("beside one client's 400 bodies held open"); + let refused = held + .iter_mut() + .rev() + .take(n.saturating_sub(40).min(50)) + .map(|s| { + let _ = s.set_read_timeout(Some(Duration::from_secs(2))); + let mut out = [0u8; 512]; + let n = s.read(&mut out).unwrap_or(0); + let text = String::from_utf8_lossy(&out[..n]); + n == 0 || (text.starts_with("HTTP/1.1 503") && text.contains("J032")) + }) + .filter(|refused| *refused) + .count(); + assert_eq!(refused, n.saturating_sub(40).min(50), "{name}: a client's requests past its open ones are refused or closed at once"); + drop(held); + assert!(!logs(&d).contains("Too many open files"), "{name}: {}", logs(&d)); + // The limit, raised where it can be, and logged. + if name == "soft" && can_raise() { + assert!(logs(&d).contains("(raised from 256"), "{}", logs(&d)); + } else { + assert!(logs(&d).contains("open files: 256"), "{}", logs(&d)); + } + alive(port, &mut svc, "connections held open"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); + } +} + +/// A sweep whose result is more than its client's ended jobs may keep, +/// sent within every published limit, is read in full: by a poll, from +/// `/v1/run` and from the MCP set tool. It is kept until it has been read, +/// where it was dropped as it ended and the client was told no such job. +#[test] +fn a_result_larger_than_its_client_keeps_is_kept_until_it_is_read() { + let d = dir("large-result"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_CLIENT_KEPT_MIB", "1")]); + let sweep = |points: usize| json!({ "qasm": "OPENQASM 3.0;\ninput float theta;\nqubit[16] q;\nbit[16] c;\nh q;\nrz(theta) q[0];\nc = measure q;\n", "sweep": { "theta": (0..points).map(|i| 0.01 * (i + 1) as f64).collect::>() }, "shots": 20000, "seed": 7 }); + let done = |id: &str| { + let t = Instant::now(); + loop { + let (code, b) = json_of(port, "GET", &format!("/v1/jobs/{id}?wait=25"), None); + assert_eq!(code, 200, "{id} after {:?}: {b}", t.elapsed()); + if b["status"] == "done" { + return b; + } + assert!(b["status"] == "queued" || b["status"] == "running", "{b}"); + assert!(t.elapsed() < Duration::from_secs(120), "{id}"); + } + }; + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&sweep(10))); + assert_eq!(code, 202, "{b}"); + let job = done(b["id"].as_str().unwrap()); + assert_eq!(job["result"]["items"].as_array().map(Vec::len), Some(10)); + let (code, b) = json_of(port, "POST", "/v1/run", Some(&sweep(10))); + assert!(code == 200 || code == 202, "{code}: {b}"); + let job = done(b["id"].as_str().unwrap()); + assert_eq!(job["result"]["items"].as_array().map(Vec::len), Some(10)); + let (code, b) = json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_quantum_set", "arguments": sweep(10) } }))); + let sc = &b["result"]["structuredContent"]; + assert_eq!(code, 200, "{b}"); + assert!(b["result"]["isError"] == false && sc["id"].is_string(), "{}", b["result"]["content"]); + let job = done(sc["id"].as_str().unwrap()); + assert_eq!(job["result"]["items"].as_array().map(Vec::len), Some(10)); + alive(port, &mut svc, "large results"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A job's report is the same bytes on every route that serves it (a poll, +/// its events, the MCP job tool) and after a restart: one RFC 8785 text, +/// its receipt's keys in their order as well as its request's. +#[test] +fn a_job_reads_the_same_on_every_route_and_after_a_restart() { + let d = dir("same-bytes"); + let (mut svc, port) = start(&d, 1000); + let request = json!({ "qasm": "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n", "shots": 200, "seed": 9, "noise": { "depolarizing_1q": 0.000001, "readout": 1e-7, "depolarizing_2q": 0.0 }, "observables": ["Z0", "0.5 Z1"] }); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&request)); + assert_eq!(code, 202, "{b}"); + let id = b["id"].as_str().unwrap().to_string(); + let get = || { + let t = Instant::now(); + loop { + let (code, _, text) = http(port, "GET", &format!("/v1/jobs/{id}?wait=25"), None); + assert_eq!(code, 200, "{text}"); + if text.contains("\"status\":\"done\"") { + return text; + } + assert!(t.elapsed() < Duration::from_secs(60), "{text}"); + } + }; + let text = get(); + let v: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(text.as_bytes(), wai_quantum::engine::receipt::canonical(&v), "a report is RFC 8785"); + let (_, _, events) = http(port, "GET", &format!("/v1/jobs/{id}/events"), None); + let event = events.lines().find_map(|l| l.strip_prefix("data: ").filter(|d| d.contains("\"result\""))).unwrap_or_else(|| panic!("{events}")); + assert_eq!(event, text, "the job event"); + let (_, _, mcp) = http(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_quantum_job", "arguments": { "id": id } } }))); + // The job in the MCP answer is the report's own bytes, what MCP adds to + // it (its verdict) after them. + assert!(mcp.contains(&text[..text.len() - 1]), "the MCP job tool: {mcp}"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let (mut svc, port) = start(&d, 1000); + let (code, _, again) = http(port, "GET", &format!("/v1/jobs/{id}"), None); + assert_eq!(code, 200, "{again}"); + assert_eq!(again, text, "after a restart"); + alive(port, &mut svc, "a report on every route"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// An MCP message over 64 KiB from a client past its window is answered +/// under its own id, as a tool result that says why; a resend under its key +/// of a job it holds is answered with that job. +#[test] +fn a_large_mcp_message_past_the_window_is_answered_under_its_own_id() { + let d = dir("mcp-large"); + let (mut svc, port) = start(&d, 2); + let big = format!("OPENQASM 3.0;\nqubit[2] q;\n{}", "h q[0];\n".repeat(9000)); + let call = |id: u32, name: &str, args: Value| json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": args } }))); + let (code, first) = call(1, "run_quantum_circuit", json!({ "qasm": big, "shots": 10, "idempotency_key": "large-1" })); + assert_eq!(code, 200, "{first}"); + let job = first["result"]["structuredContent"]["id"].as_str().unwrap_or_else(|| panic!("{first}")).to_string(); + let (code, b) = call(2, "critique_qasm", json!({ "qasm": "OPENQASM 3.0; qubit q; h q;" })); + assert_eq!(code, 200, "{b}"); + let (code, b) = call(3, "critique_qasm", json!({ "qasm": big })); + assert_eq!((code, b["id"].as_u64(), b["result"]["isError"].as_bool()), (200, Some(3), Some(true)), "{b}"); + assert!(b["result"]["content"][0]["text"].as_str().is_some_and(|t| t.contains("J030")), "{b}"); + let (code, b) = call(4, "run_quantum_circuit", json!({ "qasm": big, "shots": 10, "idempotency_key": "large-1" })); + assert_eq!((code, b["id"].as_u64()), (200, Some(4)), "{b}"); + assert_eq!(b["result"]["structuredContent"]["id"].as_str(), Some(job.as_str()), "{b}"); + alive(port, &mut svc, "a large MCP message past the window"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// Past its window a client's body is not read, whatever key it carries +/// where the client holds no job under that key; a receipt check has a +/// window of its own, asked before its body is read. +#[test] +fn a_body_is_read_only_once_its_window_or_its_key_allows() { + let d = dir("unread-key"); + let (mut svc, port) = start_with(&d, 0, &[("WAI_QSVC_CLIENT_VERIFIES", "2")]); + let raw = |path: &str, extra: &str, body: &str| { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(30))).unwrap(); + write!(s, "POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n{extra}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + out.split_whitespace().nth(1).and_then(|c| c.parse::().ok()).unwrap_or(0) + }; + for path in ["/v1/jobs", "/v1/run"] { + assert_eq!(raw(path, "Idempotency-Key: fresh-key\r\n", "{not json"), 429, "{path}: a fresh key past the window"); + } + let codes: Vec = (0..3).map(|_| raw("/v1/verify", "", "{not json")).collect(); + assert_eq!(codes, [400, 400, 429], "verify's own window"); + alive(port, &mut svc, "bodies left unread"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// `tasks/result` holds its answer within the answer's time from arrival, +/// as every other route does, where it held a fixed 50 s. +#[test] +fn a_task_s_result_is_held_within_the_answer_s_time() { + let d = dir("task-wait"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_ANSWER_SECS", "10"), ("WAI_QSVC_MIN_FREE_PCT", "100")]); + let program = format!("OPENQASM 3.0;\nqubit[25] q;\nh q;\n{}", (0..25).map(|i| format!("t q[{i}];\n")).collect::()); + let (code, made) = json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_quantum_circuit", "task": {}, "arguments": { "qasm": program, "shots": 10 } } }))); + assert_eq!(code, 200, "{made}"); + let task = made["result"]["task"]["taskId"].as_str().unwrap_or_else(|| panic!("{made}")).to_string(); + let began = Instant::now(); + let (code, b) = json_of(port, "POST", "/mcp", Some(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tasks/result", "params": { "taskId": task } }))); + let took = began.elapsed(); + assert_eq!(code, 200, "{b}"); + assert!(took < Duration::from_secs(12), "answered after {took:?}: {b}"); + assert!(b["error"]["message"].as_str().is_some_and(|m| m.contains("still working")), "{b}"); + let _ = http(port, "DELETE", &format!("/v1/jobs/{task}"), None); + alive(port, &mut svc, "a task's result held"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A documented QEC call sent beside another check from the same client is +/// answered: a lent turn moves onto the client's own as that comes free, and +/// runs past its 5 s while no one waits, where it was stopped at 5 s. +#[test] +fn a_documented_qec_call_beside_another_check_from_its_client_is_answered() { + let d = dir("lent-qec"); + // The QEC, reading and answer times raised, so a slow or loaded host + // runs the decode, however long it takes there, rather than refusing it. + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1"), ("WAI_QSVC_QEC_SECS", "100"), ("WAI_QSVC_READ_SECS", "100"), ("WAI_QSVC_ANSWER_SECS", "110")]); + let me = "198.51.100.41"; + let decode = json!({ "memory": { "distance": 5, "rounds": 5, "p": 0.005 }, "shots": 200_000, "seed": 1 }); + let critique = json!({ "qasm": "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n" }); + for pair in [[("/v1/critique", &critique), ("/v1/qec/decode", &decode)], [("/v1/qec/decode", &decode), ("/v1/qec/decode", &decode)]] { + let answers: Vec<(u16, String, f64)> = std::thread::scope(|t| { + let calls: Vec<_> = pair + .iter() + .enumerate() + .map(|(i, (path, body))| { + t.spawn(move || { + std::thread::sleep(Duration::from_millis(30 * i as u64)); + let began = Instant::now(); + let (code, out) = post_as(port, me, path, body); + (code, out, began.elapsed().as_secs_f64()) + }) + }) + .collect(); + calls.into_iter().map(|c| c.join().unwrap()).collect() + }); + for ((path, _), (code, out, took)) in pair.iter().zip(&answers) { + assert_eq!(*code, 200, "{path} after {took:.1} s: {}", out.lines().last().unwrap_or("")); + } + } + alive(port, &mut svc, "QEC calls beside others"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// The program the round-5 verifier kept the service busy with: a layer of +/// T gates and a CX ladder, thirty times over, one shot. It runs for tens of +/// seconds (the verifier's 22 qubits took about 35 s on its host; 24 here, so +/// a faster host still runs it past every wait below), so a `?wait=25` poll +/// on it holds its whole wait. +fn pump() -> Value { + let n = 24; + let mut q = format!("OPENQASM 3.0;\nqubit[{n}] q;\nbit[{n}] c;\n"); + for _ in 0..30 { + q += "t q;\n"; + for i in 0..n - 1 { + q += &format!("cx q[{i}], q[{}];\n", i + 1); + } + } + q += "c = measure q;\n"; + json!({ "qasm": q, "method": "auto", "shots": 1 }) +} + +/// Whether a connection is still open, and what has been answered on it so +/// far, read without waiting. +fn so_far(s: &mut TcpStream) -> (bool, Vec) { + let _ = s.set_nonblocking(true); + let (mut got, mut b) = (Vec::new(), [0u8; 4096]); + let open = loop { + match s.read(&mut b) { + Ok(0) => break false, + Ok(n) => got.extend_from_slice(&b[..n]), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break true, + Err(_) => break false, + } + }; + let _ = s.set_nonblocking(false); + (open, got) +} + +/// The round-5 verifier's flood, as its probe sent it: where the service's +/// open-file limit is 256 and cannot be raised (192 connections), 210 long +/// polls (`?wait=25`) on a running job, each from an address of its own +/// (`10.0.k.m`), opened 3 ms apart, then health checked 25 times. They held +/// every connection, none could be closed for room, and every health check +/// was answered `503`. Long polls are held to a share of the connections, +/// past it answered at once (`503`, with `Retry-After`), and four connections past +/// the cap are kept for health checks: each is answered `200`. The same for +/// event streams, which held their connections as long polls did. Where this +/// test process holds fewer connections (a suite run under `ulimit -n 256`), +/// the service's cap is lowered as far, so they still outnumber it. +#[cfg(unix)] +#[test] +fn long_polls_and_streams_leave_a_health_check_answered() { + room_for(8192); + let n = can_hold(210); + let cap = (n * 192 / 210).max(16).to_string(); + let mut env = vec![("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1")]; + if n < 210 { + env.push(("WAI_QSVC_CONNECTIONS", cap.as_str())); + } + let d = dir("long-polls"); + let (mut svc, port) = start_limited(&d, "ulimit -Sn 256 && ulimit -Hn 256", &env); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&pump())); + assert_eq!(code, 202, "{b}"); + let id = b["id"].as_str().unwrap().to_string(); + for (what, path) in [("long polls", format!("/v1/jobs/{id}?wait=25")), ("event streams", format!("/v1/jobs/{id}/events"))] { + let mut held: Vec = (0..n) + .filter_map(|i| { + let mut s = TcpStream::connect(("127.0.0.1", port)).ok()?; + let _ = write!(s, "GET {path} HTTP/1.1\r\nHost: x\r\nX-Forwarded-For: 10.0.{}.{}\r\n\r\n", i % 8, i / 8 + 1); + std::thread::sleep(Duration::from_millis(3)); + Some(s) + }) + .collect(); + std::thread::sleep(Duration::from_secs(3)); + let ok = (0..25).filter(|_| healthy(port)).count(); + // The flood was real: the job still runs, and a long poll is held + // with nothing answered, a stream with its answer begun. + let waiting = held + .iter_mut() + .map(so_far) + .filter(|(open, got)| *open && if what == "long polls" { got.is_empty() } else { got.starts_with(b"HTTP/1.1 200") }) + .count(); + let sent = held.len(); + drop(held); + std::thread::sleep(Duration::from_millis(500)); + let (_, now) = json_of(port, "GET", &format!("/v1/jobs/{id}"), None); + assert!(now["status"] == "queued" || now["status"] == "running", "{what}: the job ended before the checks, so nothing was held for it: {now}"); + assert!(waiting > 0, "{what}: none of {sent} held"); + assert_eq!(ok, 25, "{what}: health answered {ok} of 25 beside {sent} {what}, {waiting} of them held: {}", logs(&d)); + } + let _ = http(port, "DELETE", &format!("/v1/jobs/{id}"), None); + alive(port, &mut svc, "long polls and streams"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// [`post_as`] for the MCP endpoint: a tool call, and whether it was answered +/// with no error. +fn mcp_as(port: u16, who: &str, tool: &str, args: &Value) -> (bool, String) { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": tool, "arguments": args } }); + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(150))).unwrap(); + let text = body.to_string(); + write!(s, "POST /mcp HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAccept: application/json\r\nX-Forwarded-For: {who}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}", text.len()).unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + let answer: Value = out.split_once("\r\n\r\n").and_then(|(_, b)| serde_json::from_str(b).ok()).unwrap_or(Value::Null); + (out.starts_with("HTTP/1.1 200") && answer["result"].is_object() && answer["result"]["isError"] != true, out) +} + +/// The README's threshold (`qec threshold --distances 3,5,7 --p +/// 0.002,0.004 --shots 20000`) made longer, as the round-5 verifier made it +/// longer (30,000 shots on its host, where that took about 15 s), until it +/// takes 11 s alone here, past the 10 s a request waits for a turn: its +/// shots raised half again at a time to the 200,000 a threshold here takes +/// in all, then its rounds a point raised from the distance to 9, 11, 13 (21 +/// at most here), for as long as the service still takes it within the 25 s +/// an error-correction call may take. The longest call it took, and how +/// long that took. +fn slow_threshold(port: u16) -> (Value, f64) { + let run = |t: &Value| -> Option { + let began = Instant::now(); + let (code, out) = post_as(port, "203.0.113.99", "/v1/qec/threshold", t); + let secs = began.elapsed().as_secs_f64(); + eprintln!("the threshold alone, {} shots, rounds {}: {code} in {secs:.1} s", t["shots"], t["rounds"]); + match code { + 200 => Some(secs), + 422 if out.contains("J031") => None, + _ => panic!("the threshold alone: {code}: {}", out.lines().last().unwrap_or("")), + } + }; + let mut t = json!({ "distances": [3, 5, 7], "p": [0.002, 0.004], "shots": 20000 }); + let mut best = (t.clone(), run(&t).expect("the README's threshold is taken")); + while best.1 < 11.0 { + let shots = t["shots"].as_u64().unwrap_or(20000); + let rounds = t["rounds"].as_u64().unwrap_or(7); + if shots < 33_333 { + t["shots"] = json!((shots * 3 / 2).min(33_333)); + } else if rounds < 13 { + t["rounds"] = json!(rounds + 2); + } else { + break; + } + match run(&t) { + Some(secs) => best = (t.clone(), secs), + None => break, + } + } + best +} + +/// The round-5 verifier's burst, at the service's own limits: one client +/// sends the README's decode (`--memory 5,5,0.003 --shots 20000 --decoder +/// correlated`), a threshold, and six critiques of a Bell pair, 30 ms apart, +/// over HTTP and then over MCP. The threshold, on a turn lent beside the +/// decode, moved onto the client's own turn when the decode ended and kept +/// the lent one, so no critique could have either: each waited out its 10 s +/// and was refused (`J032`), or, with the threshold under 10 s, was answered +/// only as it ended. A moved call gives its lent turn back: every call is +/// answered, each critique well before the threshold. On a host so loaded +/// (a whole suite beside it) that the threshold, measured beside the burst, +/// would pass the 25 s an error-correction call may take, it is refused at +/// once (`J031`) and nothing was tested: the threshold is measured again and +/// the burst sent again, three times at most. +#[test] +fn the_readme_s_burst_from_one_client_is_answered() { + let d = dir("readme-burst"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1")]); + let decode = json!({ "memory": { "distance": 5, "rounds": 5, "p": 0.003 }, "shots": 20000, "decoder": "correlated" }); + let critique = json!({ "qasm": "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n" }); + for (route, me) in [("HTTP", "198.51.100.77"), ("MCP", "198.51.100.78")] { + let mut tries = 0; + let (answers, at, alone) = loop { + tries += 1; + let (threshold, alone) = slow_threshold(port); + let mut calls: Vec<(&str, &str, &Value)> = vec![("/v1/qec/decode", "qec_decode", &decode), ("/v1/qec/threshold", "qec_threshold", &threshold)]; + calls.extend(std::iter::repeat_n(("/v1/critique", "critique_qasm", &critique), 6)); + let began = Instant::now(); + let answers: Vec<(bool, String, f64)> = std::thread::scope(|t| { + let each: Vec<_> = calls + .iter() + .enumerate() + .map(|(i, (path, tool, body))| { + let began = &began; + t.spawn(move || { + std::thread::sleep(Duration::from_millis(30 * i as u64)); + let (ok, out) = if route == "HTTP" { + let (code, out) = post_as(port, me, path, body); + (code == 200, out) + } else { + mcp_as(port, me, tool, body) + }; + (ok, out, began.elapsed().as_secs_f64()) + }) + }) + .collect(); + each.into_iter().map(|c| c.join().unwrap()).collect() + }); + let at: Vec = calls.iter().zip(&answers).map(|((path, tool, _), (ok, _, s))| format!("{} {} at {s:.2} s", if route == "HTTP" { path } else { tool }, if *ok { "answered" } else { "refused" })).collect(); + eprintln!("{route}, try {tries}, the threshold alone {alone:.1} s: {}", at.join(", ")); + let (ok, out, _) = &answers[1]; + if !ok && out.contains("J031") && out.contains("would take about") && tries < 3 { + eprintln!("{route}: the threshold, measured beside the burst, was past the time a call may take, so nothing was tested; again"); + continue; + } + break (answers, at, alone); + }; + for ((ok, out, _), name) in answers.iter().zip(&at) { + assert!(*ok, "{route}: {name} (the threshold alone {alone:.1} s): {}\n{at:#?}", out.lines().last().unwrap_or("")); + } + let threshold_at = answers[1].2; + for (_, _, s) in &answers[2..] { + assert!(*s + 1.0 < threshold_at, "{route}: a critique answered at {s:.2} s, the threshold at {threshold_at:.2} s (alone {alone:.1} s): the critiques waited for it\n{at:#?}"); + } + } + alive(port, &mut svc, "the README's burst"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A JSON-RPC id is echoed exactly, an integer past 2^53 included. +#[test] +fn an_mcp_id_is_echoed_exactly() { + let d = dir("mcp-ids"); + let (mut svc, port) = start(&d, 1000); + for id in ["9007199254740993", "-9007199254740993", "9223372036854775807", "12345678901234567890", "1730000000123456789"] { + let body: Value = serde_json::from_str(&format!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"ping\"}}")).unwrap(); + let (code, _, text) = http(port, "POST", "/mcp", Some(&body)); + assert_eq!(code, 200, "{text}"); + assert!(text.contains(&format!("\"id\":{id},")) || text.contains(&format!("\"id\":{id}}}")), "{id}: {text}"); + } + alive(port, &mut svc, "ids echoed"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A job's result is held to `WAI_QSVC_RESULT_MIB` before it runs, where a +/// result past it was run and then failed: a sweep whose result's bound +/// passes the cap is refused at once (`422`, `J031`), naming its bound and +/// the cap, on every route that takes a job, and its dry run says so; the +/// largest sweep under the cap is taken and ends done, its result within its +/// bound; a pulse run past the cap is refused at once as well, by its +/// schedule. At the defaults, the published set at its most, 100 items of +/// 65,536 outcomes each (about 150 MB as served), is refused, and 100 +/// points of 30,000 shots of 16 bits each are taken. +#[test] +fn a_result_past_what_a_job_may_give_is_refused_before_it_runs() { + let sweep = |points: usize, shots: u64| json!({ "qasm": "OPENQASM 3.0;\ninput float theta;\nqubit[16] q;\nbit[16] c;\nh q;\nrz(theta) q[0];\nc = measure q;\n", "sweep": { "theta": (0..points).map(|i| 0.01 * (i + 1) as f64).collect::>() }, "shots": shots, "seed": 7 }); + let dry = |port: u16, v: &Value| { + let mut v = v.clone(); + v["dry_run"] = json!(true); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&v)); + assert_eq!(code, 200, "{b}"); + b + }; + let d = dir("result-cap"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_RESULT_MIB", "1")]); + let cap = 1u64 << 20; + assert_eq!(dry(port, &sweep(1, 2000))["bounds"]["result_bytes"].as_u64(), Some(cap)); + // The most points of 2,000 shots a point under the cap, by the dry runs' bounds. + let bound = |n: usize| dry(port, &sweep(n, 2000))["result_bytes"].as_u64().unwrap(); + let (one, two) = (bound(1), bound(2)); + let mut points = ((cap - (2 * one - two)) / (two - one)) as usize; + while bound(points) > cap { + points -= 1; + } + while bound(points + 1) <= cap { + points += 1; + } + let past = dry(port, &sweep(points + 1, 2000)); + assert!(past["accepted"] == false && past["status"] == 422 && past["refusal"]["code"] == "J031", "{past}"); + // Refused at once, on every route that takes a job. + let mcp = |args: Value| json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_quantum_set", "arguments": args } }); + for (path, body) in [("/v1/jobs", sweep(points + 1, 2000)), ("/v1/run", sweep(points + 1, 2000)), ("/mcp", mcp(sweep(points + 1, 2000)))] { + let began = Instant::now(); + let (code, b) = json_of(port, "POST", path, Some(&body)); + let why = if path == "/mcp" { b["result"]["content"][0]["text"].as_str().unwrap_or("").to_string() } else { b["error"].as_str().unwrap_or("").to_string() }; + assert!(if path == "/mcp" { code == 200 && b["result"]["isError"] == true } else { code == 422 }, "{path}: {code} {b}"); + assert!(why.contains("J031") && why.contains("could take up to") && why.contains("the 1 MiB"), "{path}: {why}"); + assert!(began.elapsed() < Duration::from_secs(10), "{path}: answered after {:?}", began.elapsed()); + } + // At the bound: taken, and done, its result within its bound. + let at = bound(points); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&sweep(points, 2000))); + assert_eq!(code, 202, "{b}"); + let id = b["id"].as_str().unwrap().to_string(); + let t = Instant::now(); + let job = loop { + let (code, b) = json_of(port, "GET", &format!("/v1/jobs/{id}?wait=25"), None); + assert_eq!(code, 200, "{b}"); + if b["status"] != "queued" && b["status"] != "running" { + break b; + } + assert!(t.elapsed() < Duration::from_secs(120)); + }; + assert_eq!(job["status"], "done", "{}", job["error"]); + let served = wai_quantum::engine::receipt::canonical(&job["result"]).len() as u64; + eprintln!("{points} points of 2,000 shots: bound {at} bytes, served {served}"); + assert!(served <= at && at <= cap, "{points} points: served {served} bytes, bound {at}"); + // A pulse run is bounded from its schedule as well: 100,000 bits + // declared (in registers of 64, as the pulse reader takes them) and one + // captured, each bit named and every outcome's key as wide (about 1.4 MB + // as served), are refused at once, where the run had been taken and + // failed only as it ended; 100 bits are taken. + let pulse = |n: usize| json!({ "method": "pulse", "shots": 100, "qasm": format!("OPENQASM 3.0;\ndefcalgrammar \"openpulse\";\ncal {{\n extern port d0;\n extern port a0;\n frame q0 = newframe(d0, 5.0e9, 0);\n frame c0 = newframe(a0, 7.1e9, 0);\n}}\ndefcal x $0 {{ play(q0, gaussian(0.4, 64dt, 16dt)); }}\ndefcal measure $0 -> bit {{ return capture_v2(c0, 4dt); }}\n{}x $0;\nb{}[{}] = measure $0;\n", (0..n.div_ceil(64)).map(|r| format!("bit[{}] b{r};\n", 64.min(n - 64 * r))).collect::(), (n - 1) / 64, (n - 1) % 64) }); + let began = Instant::now(); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&pulse(100_000))); + if code == 202 { + let id = b["id"].as_str().unwrap_or_default().to_string(); + let job = loop { + let (_, b) = json_of(port, "GET", &format!("/v1/jobs/{id}?wait=25"), None); + if b["status"] != "queued" && b["status"] != "running" { + break b; + } + assert!(began.elapsed() < Duration::from_secs(300)); + }; + panic!("a pulse run of 100,000 bits was taken, and then after {:?}: {} {}", began.elapsed(), job["status"], job["error"]); + } + let why = b["error"].as_str().unwrap_or_default(); + assert!(code == 422 && why.contains("J031") && why.contains("could take up to") && why.contains("the 1 MiB"), "a pulse run past the cap: {code} {b}"); + assert!(began.elapsed() < Duration::from_secs(10), "answered after {:?}", began.elapsed()); + let small = dry(port, &pulse(100)); + assert!(small["accepted"] == true && small["result_bytes"].as_u64().is_some_and(|b| b < cap), "{small}"); + alive(port, &mut svc, "results held to their cap"); + svc.kill().unwrap(); + svc.wait().unwrap(); + // The defaults. + let (mut svc, port) = start(&d, 1000); + let most = dry(port, &sweep(100, 1_000_000)); + let bytes = most["result_bytes"].as_u64().unwrap_or(0); + eprintln!("100 points of 65,536 outcomes: bound {bytes} bytes"); + assert!(most["accepted"] == false && most["refusal"]["code"] == "J031" && (130_000_000..180_000_000).contains(&bytes), "{most}"); + let taken = dry(port, &sweep(100, 30_000)); + eprintln!("100 points of 30,000 shots: bound {} bytes", taken["result_bytes"]); + assert!(taken["accepted"] == true, "{taken}"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A job's report once it has ended, polled. +fn ended(port: u16, id: &str, within: Duration) -> Value { + let t = Instant::now(); + loop { + let (code, b) = json_of(port, "GET", &format!("/v1/jobs/{id}?wait=5"), None); + assert_eq!(code, 200, "{b}"); + if b["status"] != "queued" && b["status"] != "running" { + return b; + } + assert!(t.elapsed() < within, "{id} still {} after {within:?}", b["status"]); + } +} + +/// [`json_of`] as the client `who` (through a trusted proxy). +fn json_as(port: u16, who: &str, path: &str, body: &Value) -> (u16, Value) { + let (code, out) = post_as(port, who, path, body); + let text = out.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or(""); + (code, serde_json::from_str(text).unwrap_or(Value::Null)) +} + +/// An MCP tool's answer: its result's `structuredContent`, and whether it +/// was an error. +fn mcp_tool(port: u16, tool: &str, args: &Value) -> (bool, Value) { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": tool, "arguments": args } }); + let (code, b) = json_of(port, "POST", "/mcp", Some(&body)); + assert_eq!(code, 200, "{tool}: {b}"); + (b["result"]["isError"] == true, b["result"]["structuredContent"].clone()) +} + +/// A 16-qubit sweep: `points` angles, `shots` a point, 16 measured bits. +fn sweep16(points: usize, shots: u64) -> Value { + json!({ "qasm": "OPENQASM 3.0;\ninput float theta;\nqubit[16] q;\nbit[16] c;\nh q;\nrz(theta) q[0];\nc = measure q;\n", "sweep": { "theta": (0..points).map(|i| 0.01 * (i + 1) as f64).collect::>() }, "shots": shots, "seed": 7 }) +} + +/// An MCP dry run says what the MCP run tools take: it plans the job as they +/// run it, its verdict asked, so the most points of a sweep its dry run +/// accepts under a cap of 1 MiB are taken by `run_quantum_set`, where the +/// dry run planned the job without its verdict and the run then refused it +/// at once (`J031`). +#[test] +fn an_mcp_dry_run_says_what_the_run_tools_take() { + let d = dir("mcp-dry"); + let (mut svc, port) = start_with(&d, 10_000, &[("WAI_QSVC_RESULT_MIB", "1")]); + let accepted = |n: usize| -> bool { + let (err, v) = mcp_tool(port, "dry_run_quantum_job", &sweep16(n, 2000)); + assert!(!err, "{v}"); + v["accepted"] == true + }; + let mut n = 1; + while accepted(n + 1) { + n += 1; + assert!(n < 100); + } + let (err, v) = mcp_tool(port, "run_quantum_set", &sweep16(n, 2000)); + let why = v["error"].as_str().unwrap_or("").to_string(); + assert!(!(err && why.contains("J031")), "{n} points, accepted by the MCP dry run, refused by the run: {why}"); + let id = v["id"].as_str().unwrap_or_default().to_string(); + let job = if v["status"] == "done" { v } else { ended(port, &id, Duration::from_secs(120)) }; + assert_eq!(job["status"], "done", "{}", job["error"]); + let (err, v) = mcp_tool(port, "run_quantum_set", &sweep16(n + 1, 2000)); + assert!(err && v["error"].as_str().is_some_and(|w| w.contains("J031")), "{} points: {v}", n + 1); + alive(port, &mut svc, "MCP dry runs"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A dry run that says a request is taken is taken: a sweep whose items +/// each carry a large shared field read, kept, past what the service takes +/// back of one reading (`J031`) is refused by its dry run too, as the dry +/// run's reading is held to what the submission's would take; one under it +/// is taken by both. +#[test] +fn a_dry_run_that_says_taken_is_taken_by_its_submission() { + let d = dir("dry-weight"); + let (mut svc, port) = start(&d, 10_000); + for pad in [12_000usize, 20_000] { + let pads = vec!["1e20"; pad].join(","); + let text = format!("{{\"qasm\":\"OPENQASM 3.0;\\ninput float theta;\\nqubit q;\\nbit c;\\nrx(theta) q;\\nc = measure q;\\n\",\"sweep\":{{\"theta\":[{}]}},\"shots\":10,\"pad\":[{pads}]}}", (0..100).map(|i| format!("0.{}", i + 1)).collect::>().join(",")); + let send = |dry: bool| -> (u16, String) { + let body = if dry { text.replacen("{", "{\"dry_run\":true,", 1) } else { text.clone() }; + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(120))).unwrap(); + write!(s, "POST /v1/jobs HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + (out.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0), out.split_once("\r\n\r\n").map(|(_, b)| b.to_string()).unwrap_or_default()) + }; + let (code, dry) = send(true); + assert_eq!(code, 200, "{dry}"); + let dry: Value = serde_json::from_str(&dry).unwrap(); + let (code, sent) = send(false); + eprintln!("pad {pad}: dry run accepted {}, submission {code}", dry["accepted"]); + assert_eq!(dry["accepted"] == true, code == 202, "pad {pad}: the dry run said {} ({}), the submission {code} {sent}", dry["accepted"], dry["refusal"]); + if code == 202 { + let id = serde_json::from_str::(&sent).unwrap()["id"].as_str().unwrap().to_string(); + let _ = http(port, "DELETE", &format!("/v1/jobs/{id}"), None); + } else { + assert!(sent.contains("J031") && dry["refusal"]["code"] == "J031", "pad {pad}: {sent} / {dry}"); + } + } + alive(port, &mut svc, "dry runs held to the submission's reading"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A set's bound counts each item's request as it is served: a number RFC +/// 8785 writes longer than serde (`1e20`, 21 characters there) is counted at +/// its served length, so the result of a sweep sharing thousands of them is +/// within its dry run's `result_bytes`. +#[test] +fn a_set_s_bound_counts_its_items_requests_as_served() { + let d = dir("set-numbers"); + let (mut svc, port) = start(&d, 10_000); + let pads = vec!["1e20"; 20_000].join(","); + let text = format!("{{\"qasm\":\"OPENQASM 3.0;\\ninput float theta;\\nqubit q;\\nbit c;\\nrx(theta) q;\\nc = measure q;\\n\",\"sweep\":{{\"theta\":[0.1,0.2,0.3]}},\"shots\":10,\"pad\":[{pads}]}}"); + let req: Value = serde_json::from_str(&text).unwrap(); + let mut dry = req.clone(); + dry["dry_run"] = json!(true); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&dry)); + assert_eq!(code, 200, "{b}"); + let bound = b["result_bytes"].as_u64().unwrap(); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&req)); + assert_eq!(code, 202, "{b}"); + let job = ended(port, b["id"].as_str().unwrap(), Duration::from_secs(120)); + assert_eq!(job["status"], "done", "{}", job["error"]); + let served = wai_quantum::engine::receipt::canonical(&job["result"]).len() as u64; + eprintln!("3 points sharing 20,000 numbers: served {served} bytes, bound {bound}"); + assert!(served <= bound, "served {served} bytes, past the bound of {bound}"); + alive(port, &mut svc, "a set's numbers"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A GHZ state's sweep is bounded by the two outcomes the stabilizer method +/// can give, not by a distinct outcome a shot: 100 copies of a 24-qubit GHZ +/// state at 30,000 shots each, refused before at a bound of 89 MB for a +/// result of about 250 KB, are taken and run, their result within its bound. +#[test] +fn an_honest_clifford_set_is_taken_by_the_outcomes_it_can_give() { + let d = dir("ghz-set"); + let (mut svc, port) = start(&d, 10_000); + let mut ghz = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[24] q;\nbit[24] c;\nh q[0];\n".to_string(); + for i in 1..24 { + ghz += &format!("cx q[{}], q[{i}];\n", i - 1); + } + ghz += "c = measure q;\n"; + let set = json!({ "programs": vec![ghz; 100], "shots": 30_000, "seed": 3 }); + let mut dry = set.clone(); + dry["dry_run"] = json!(true); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&dry)); + assert_eq!(code, 200, "{b}"); + assert!(b["accepted"] == true, "{}", b["refusal"]); + let bound = b["result_bytes"].as_u64().unwrap(); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&set)); + assert_eq!(code, 202, "{b}"); + let job = ended(port, b["id"].as_str().unwrap(), Duration::from_secs(300)); + assert_eq!(job["status"], "done", "{}", job["error"]); + let served = wai_quantum::engine::receipt::canonical(&job["result"]).len() as u64; + eprintln!("100 GHZ24 at 30,000 shots: served {served} bytes, bound {bound}"); + assert!(served <= bound && bound < 8 << 20, "served {served}, bound {bound}"); + alive(port, &mut svc, "a GHZ set"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A run with its verdict is bounded by what its critique can say, not by a +/// remark on every gate: a loop of 124,700 gates with nothing to remark on is +/// run by an MCP run tool and by `POST /v1/run` with `"verdict": true` (the +/// Studio's request), where both were refused for a 64.7 MiB bound. A job +/// past the cap is refused naming its largest part and what makes it +/// smaller: a verdict's critique of thousands of gates that cancel, the +/// reader's notes, or the counts. +#[test] +fn a_long_loop_s_verdict_is_taken_and_a_refusal_names_its_part() { + let lp = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[8] q;\nbit[8] c;\nfor int s in [1:4300] {\n cx q[0], q[1]; rz(0.1) q[1]; cx q[0], q[1];\n cx q[1], q[2]; rz(0.2) q[2]; cx q[1], q[2];\n cx q[2], q[3]; rz(0.3) q[3]; cx q[2], q[3];\n cx q[3], q[4]; rz(0.4) q[4]; cx q[3], q[4];\n cx q[4], q[5]; rz(0.5) q[5]; cx q[4], q[5];\n cx q[5], q[6]; rz(0.6) q[6]; cx q[5], q[6];\n cx q[6], q[7]; rz(0.7) q[7]; cx q[6], q[7];\n rx(0.1) q[0]; rx(0.1) q[1]; rx(0.1) q[2]; rx(0.1) q[3]; rx(0.1) q[4]; rx(0.1) q[5]; rx(0.1) q[6]; rx(0.1) q[7];\n}\nc = measure q;\n"; + let d = dir("loop-verdict"); + let (mut svc, port) = start(&d, 10_000); + let (err, v) = mcp_tool(port, "run_quantum_circuit", &json!({ "qasm": lp, "shots": 1000 })); + assert!(!err, "the MCP run: {v}"); + let (code, b) = json_of(port, "POST", "/v1/run", Some(&json!({ "qasm": lp, "shots": 1000, "verdict": true }))); + assert!(code == 200 && b["status"] == "done", "{code} {}", b["error"]); + svc.kill().unwrap(); + svc.wait().unwrap(); + // Past a cap of 1 MiB: each refusal names the part that passes it. + let (mut svc, port) = start_with(&d, 10_000, &[("WAI_QSVC_RESULT_MIB", "1")]); + let cancels = "OPENQASM 3.0;\nqubit q;\nbit c;\nfor int i in [1:10000] { h q; h q; }\nc = measure q;\n"; + let noted = format!("OPENQASM 3.0;\nqubit q;\nbit c;\n{}h q;\nc = measure q;\n", (0..7000).map(|i| format!("pragma note{i} {}\n", "x".repeat(120))).collect::()); + for (what, job, says) in [ + ("a critique of 10,000 pairs", json!({ "qasm": cancels, "shots": 10, "verdict": true }), "its verdict"), + ("7,000 notes", json!({ "qasm": noted, "shots": 10 }), "the reader's warnings and notes"), + ("a sweep's counts", sweep16(40, 2000), "its counts"), + ] { + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&job)); + let why = b["error"].as_str().unwrap_or(""); + eprintln!("{what}: {code} {why}"); + assert!(code == 422 && why.contains("J031") && why.contains(says), "{what}: {code} {why}"); + } + alive(port, &mut svc, "verdicts bounded by their critique"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// The README's largest sweep, 100 points of 16 measured bits at 30,000 +/// shots a point, is taken and ends done at the service's defaults: the set's +/// worker keeps each item's result as text as it ends, where it kept every +/// item's as a tree and passed the memory it was admitted with (on Linux +/// every time, `the job used 693 MiB, over the 258 MiB`). +#[test] +fn the_readme_s_largest_sweep_is_taken_and_ends_done() { + let d = dir("readme-sweep"); + let (mut svc, port) = start(&d, 10_000); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&sweep16(100, 30_000))); + assert_eq!(code, 202, "{b}"); + let job = ended(port, b["id"].as_str().unwrap(), Duration::from_secs(600)); + assert_eq!(job["status"], "done", "{}", job["error"]); + assert_eq!(job["result"]["items"].as_array().map(Vec::len), Some(100)); + alive(port, &mut svc, "the README's sweep"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A client's quick calls are never stuck behind two of its own long checks: +/// one client sends two thresholds that each run past the 10 s a request +/// waits for a turn, then four critiques. The first threshold holds the +/// client's own turn and the second a lent one; the lent turn goes back once +/// a critique of its own has waited 7 s, so every critique is answered (the +/// second threshold may be refused, `J032`, to send again), where every +/// critique waited out its 10 s and was refused. +#[test] +fn a_client_s_quick_calls_pass_two_of_its_own_long_checks() { + let d = dir("two-long"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1")]); + let me = "198.51.100.91"; + let critique = json!({ "qasm": "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n" }); + let (threshold, alone) = slow_threshold(port); + if alone < 11.0 { + eprintln!("the threshold took {alone:.1} s alone, under the 10 s a request waits for a turn: nothing to test on this host"); + } + let began = Instant::now(); + let answers: Vec<(String, u16, String, f64)> = std::thread::scope(|t| { + let mut calls = Vec::new(); + for (i, (name, path, body, after)) in [("threshold 1", "/v1/qec/threshold", &threshold, 0u64), ("threshold 2", "/v1/qec/threshold", &threshold, 30)].into_iter().chain((0..4).map(|k| ("critique", "/v1/critique", &critique, 4000 + 30 * k))).enumerate() { + let began = &began; + calls.push(t.spawn(move || { + std::thread::sleep(Duration::from_millis(after)); + let (code, out) = post_as(port, me, path, body); + (format!("{name} #{i}"), code, out.lines().last().unwrap_or("").to_string(), began.elapsed().as_secs_f64()) + })); + } + calls.into_iter().map(|c| c.join().unwrap()).collect() + }); + eprintln!("{answers:#?}"); + for (name, code, out, at) in &answers { + if name.starts_with("critique") || name.starts_with("threshold 1") { + assert_eq!(*code, 200, "{name} at {at:.2} s: {out}"); + } else { + assert!(*code == 200 || (*code == 503 && out.contains("lent")), "{name} at {at:.2} s: {code} {out}"); + } + } + alive(port, &mut svc, "two long checks and quick calls"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// Each JSON-RPC id is echoed as it was sent: a fraction or an exponent +/// (`1.0`, `1e3`), and an integer past 64 bits, which read as a number and +/// written again came back as `1`, `1000` and `1.2345678901234568e+29`. +#[test] +fn an_mcp_id_is_echoed_as_it_was_sent() { + let d = dir("mcp-raw-ids"); + let (mut svc, port) = start(&d, 1000); + for id in ["1.0", "1e3", "1E3", "-0.0", "1.50", "18446744073709551616", "123456789012345678901234567890", "\"1.0\"", "7"] { + for method in ["ping", "tools/list"] { + let body = format!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"{method}\"}}"); + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(60))).unwrap(); + write!(s, "POST /mcp HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + assert!(out.starts_with("HTTP/1.1 200"), "{id} {method}: {out}"); + assert!(out.contains(&format!("\"id\":{id},")) || out.contains(&format!("\"id\":{id}}}")), "{method}: sent the id {id}, answered {}", out.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or("").chars().take(120).collect::()); + } + } + alive(port, &mut svc, "ids echoed as sent"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// A long poll past its client's holds is not held, and says so: `429`, +/// `J032`, `Retry-After` and the job's status, so a client polling in a +/// loop waits, where it was answered `200` at once, unchanged, and a client +/// that polled again at once spun. Another client's long poll is held. +#[test] +fn a_long_poll_past_its_client_s_holds_says_when_to_poll_again() { + let d = dir("poll-again"); + let (mut svc, port) = start_with(&d, 1000, &[("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1")]); + let (code, b) = json_of(port, "POST", "/v1/jobs", Some(&pump())); + assert_eq!(code, 202, "{b}"); + let id = b["id"].as_str().unwrap().to_string(); + let me = "198.51.100.93"; + let streams: Vec = (0..4) + .map(|_| { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + write!(s, "GET /v1/jobs/{id}/events HTTP/1.1\r\nHost: x\r\nX-Forwarded-For: {me}\r\n\r\n").unwrap(); + s + }) + .collect(); + std::thread::sleep(Duration::from_millis(500)); + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(60))).unwrap(); + let t = Instant::now(); + write!(s, "GET /v1/jobs/{id}?wait=25 HTTP/1.1\r\nHost: x\r\nX-Forwarded-For: {me}\r\nConnection: close\r\n\r\n").unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + let took = t.elapsed(); + let (head, body) = out.split_once("\r\n\r\n").unwrap_or_default(); + let body: Value = serde_json::from_str(body).unwrap_or(Value::Null); + assert!(head.starts_with("HTTP/1.1 429") && head.to_ascii_lowercase().contains("retry-after: 2"), "a fifth hold: {head}"); + assert!(body["code"] == "J032" && (body["status"] == "running" || body["status"] == "queued") && took < Duration::from_secs(5), "{body} after {took:?}"); + // Another client's long poll is held. + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(60))).unwrap(); + let t = Instant::now(); + write!(s, "GET /v1/jobs/{id}?wait=2 HTTP/1.1\r\nHost: x\r\nX-Forwarded-For: 198.51.100.94\r\nConnection: close\r\n\r\n").unwrap(); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + assert!(out.starts_with("HTTP/1.1 200") && t.elapsed() >= Duration::from_millis(1900), "another client's poll: {:?} {}", t.elapsed(), out.lines().next().unwrap_or("")); + drop(streams); + let _ = http(port, "DELETE", &format!("/v1/jobs/{id}"), None); + alive(port, &mut svc, "polls past the holds"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} + +/// What ended jobs keep unread stays under `WAI_QSVC_UNREAD_MIB`: a job is +/// taken only while every result not yet read, those of the jobs queued and +/// running at their bounds among them, and its own bound stay under the cap. +/// Thirty clients each send a job of about 0.8 MiB while one job holds the +/// only worker; where each was taken because nothing had ended, so their +/// results passed the 2 MiB cap six times over once they ended, those past +/// the cap are refused, `503`, and what they all hold unread stays under it. +#[test] +fn ended_jobs_unread_stay_under_the_unread_cap() { + let d = dir("unread-cap"); + let env = [("WAI_QSVC_TRUSTED_PROXIES", "127.0.0.1"), ("WAI_QSVC_UNREAD_MIB", "2"), ("WAI_QSVC_KEPT_MIB", "1"), ("WAI_QSVC_CLIENT_KEPT_MIB", "1"), ("WAI_QSVC_RESULT_MIB", "1"), ("WAI_QSVC_CLIENT_UNREAD_MIB", "4"), ("WAI_QSVC_MAX_QUEUE", "40")]; + let (mut svc, port) = start_with(&d, 1000, &env); + let (code, b) = json_as(port, "198.51.100.200", "/v1/jobs", &pump()); + assert_eq!(code, 202, "{b}"); + let blocker = b["id"].as_str().unwrap().to_string(); + let job = json!({ "qasm": "OPENQASM 3.0;\nqubit[15] q;\nbit[15] c;\nh q;\nc = measure q;\n", "shots": 30_000, "seed": 1 }); + let mut dry = job.clone(); + dry["dry_run"] = json!(true); + let (_, b) = json_as(port, "198.51.100.199", "/v1/jobs", &dry); + let bound = b["result_bytes"].as_u64().unwrap(); + assert!(b["accepted"] == true && bound > 512 << 10 && bound < 1 << 20, "{b}"); + let mut taken = Vec::new(); + for k in 0..30 { + let (code, b) = json_as(port, &format!("198.51.100.{}", k + 1), "/v1/jobs", &job); + match code { + 202 => taken.push(b["id"].as_str().unwrap().to_string()), + 503 => assert!(b["error"].as_str().is_some_and(|w| w.contains("not yet read") && w.contains("J032")), "{b}"), + _ => panic!("{code} {b}"), + } + } + let _ = http(port, "DELETE", &format!("/v1/jobs/{blocker}"), None); + // Each ended, none read until all have. + let t = Instant::now(); + loop { + let (_, v) = json_of(port, "GET", "/v1", None); + if v["load"]["queued"] == 0 && v["load"]["running"] == 0 { + break; + } + assert!(t.elapsed() < Duration::from_secs(120), "{}", v["load"]); + std::thread::sleep(Duration::from_millis(200)); + } + let held: u64 = taken.iter().map(|id| { + let (_, j) = json_of(port, "GET", &format!("/v1/jobs/{id}"), None); + assert_eq!(j["status"], "done", "{}", j["error"]); + http(port, "GET", &format!("/v1/jobs/{id}"), None).2.len() as u64 + }).sum(); + eprintln!("{} of 30 taken, bound {bound} each; held unread at once: {held} bytes of jobs' reports", taken.len()); + assert!(!taken.is_empty() && taken.len() < 30, "{} of 30 taken", taken.len()); + assert!(taken.len() as u64 * bound <= 2 << 20, "{} jobs of {bound} bytes taken under a 2 MiB cap", taken.len()); + alive(port, &mut svc, "the unread cap"); + svc.kill().unwrap(); + svc.wait().unwrap(); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/wai/wai-quantum-service/tests/verify.rs b/wai/wai-quantum-service/tests/verify.rs index d7c984b87..070760210 100644 --- a/wai/wai-quantum-service/tests/verify.rs +++ b/wai/wai-quantum-service/tests/verify.rs @@ -35,7 +35,9 @@ fn start(dir: &Path, port: u16) -> Child { .env("WAI_QSVC_BUDGET_GB", "4") .env("WAI_QSVC_WORKERS", "1") .env("WAI_QSVC_ENERGY", "0") - .env("WAI_QSVC_RATE", "100000") + // A set's receipt counts, as a check does: the corpus holds more than a window's. + .env("WAI_QSVC_CLIENT_BURST", "100000") + .env("WAI_QSVC_CLIENT_VERIFIES", "100000") .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -110,9 +112,10 @@ fn try_http(port: u16, method: &str, path: &str, body: &[u8]) -> Option<(u16, St Some((status, out.split_once("\r\n\r\n").map(|(_, b)| b.to_string()).unwrap_or_default())) } -/// A program nested past the reader's bound (Q130) is refused on every route that reads one, -/// and the service stays up: a flat sum of 2000 terms, 4 KB, used to overflow a worker's -/// stack and end the service, on the routes that read a program on an async worker. +/// A program nested past the reader's bound (Q006) is refused on every route that reads one, +/// a flat sum of 2000 terms (one level, however long) is read, and the service stays up: the +/// sum, 4 KB, used to overflow a worker's stack and end the service, on the routes that read +/// a program on an async worker. #[test] fn a_program_past_the_nesting_bound_is_refused_and_the_service_stays_up() { let dir = std::env::temp_dir().join(format!("wai-qsvc-deep-{}", std::process::id())); @@ -147,9 +150,14 @@ fn a_program_past_the_nesting_bound_is_refused_and_the_service_stays_up() { child = start(&dir, port); continue; } - // Read at once, it is refused by its code; queued, the job fails by it. - if (200..300).contains(&code) && !answer.contains("Q130") && path != "/v1/jobs" { - wrong.push(format!("{what} at {path}: {code} without Q130: {}", answer.chars().take(300).collect::())); + // Read at once, a nested one is refused by its code; queued, the job fails by it. + // The sum is flat, and read. + let nested = *what != "a sum of 2000 terms"; + if nested && (200..300).contains(&code) && !answer.contains("Q006") && path != "/v1/jobs" { + wrong.push(format!("{what} at {path}: {code} without Q006: {}", answer.chars().take(300).collect::())); + } + if !nested && (answer.contains("Q006") || code >= 500) { + wrong.push(format!("{what} at {path}: {code}: {}", answer.chars().take(300).collect::())); } } } diff --git a/wai/wai-quantum/docs/job-refusals.md b/wai/wai-quantum/docs/job-refusals.md index 07210d108..52017b4b9 100644 --- a/wai/wai-quantum/docs/job-refusals.md +++ b/wai/wai-quantum/docs/job-refusals.md @@ -65,12 +65,12 @@ browser engine. A test holds this page to the code's table. | `J011` | refused | The method named cannot take this job: a gate it does not hold, more qubits, memory or work than it takes here, or an answer it does not give (noise it cannot take is `J061`); the message says which, and `auto` picks a method that can. A job on `mps` runs under a meter of the bonds it reaches, so this can also come from a job that has started: it stopped where its work passed what a job may take. | | `J012` | refused | No method takes this job: each method's reason is listed, so the job can be changed to suit one. | | `J013` | refused | A placement on the fabric (`fabric`) this host cannot honour: no member by that name, no certified accelerator member here, a state or a partition larger than a member holds, a partition count that is not a power of two, or a method or program the fabric's members do not run (the message says which, and what is here). | -| `J020` | refused | An intent that does not read: not one of `distribution`, `equivalent` or `prepares`, a distribution whose outcomes are not the program's bits or whose probabilities do not sum to 1, or a reference program that does not read. | +| `J020` | refused | An intent that does not read: not one of `distribution`, `equivalent` or `prepares`, a distribution whose outcomes are not the program's bits or whose probabilities do not sum to 1, or a reference program that does not read, or more intents than the host takes. | | `J021` | refused | A proposed repair that changes a line no diagnostic flags, or adds lines away from one: rejected, and the program left as it was. | | `J022` | refused | A repair's choices that do not read (`max_iterations`, `dyadic_bits`, `sizes`), or a proposal that is not a program's text. | | `J030` | retry | The host takes no more from this client for now (submissions in a window, or jobs at once): send it again shortly. | -| `J031` | refused | A job or a check larger than this host takes: run it on your own machine, with the command line or its MCP server. | -| `J032` | retry | The host is full (its queue, or the checks running at once): send it again later. | +| `J031` | refused | A job or a check larger than this host takes, its reading included: run it on your own machine, with the command line or its MCP server. | +| `J032` | retry | The host is full (its queue, the checks running at once, or the requests being read, yours or all of them): send it again later. | | `J033` | refused | A host that takes requests only with one of its API keys: give yours as `Authorization: Bearer `; a key it does not hold is refused the same way. | | `J034` | refused | An idempotency key (`Idempotency-Key`, or a tool's `idempotency_key`) that does not read, or that this client sent before with another request: a key names one job, so another request needs another key. A key is 1 to 255 visible ASCII characters, and should be unguessable (a UUID, or 128 random bits). | | `J040` | refused | A pulse run that does not read or cannot be done: a field it does not take, options or device parameters that do not read, a target without the qubit, or a schedule outside the simulator's model (two tones on one qubit at once, a play after its qubit's capture, more levels and qubits than it holds, a frame far from its qubit); the message says which. | diff --git a/wai/wai-quantum/docs/qasm-diagnostics.md b/wai/wai-quantum/docs/qasm-diagnostics.md index 58b98c2bd..03c6e39a7 100644 --- a/wai/wai-quantum/docs/qasm-diagnostics.md +++ b/wai/wai-quantum/docs/qasm-diagnostics.md @@ -45,6 +45,7 @@ engine. A test holds this page to the code's table. | `Q003` | refused | A string, a block comment, a bracket or a calibration block that is not closed. | | `Q004` | refused | Something else was expected here: a `;`, a name, an expression or a statement (the message says which). | | `Q005` | refused | A statement in a place the language does not allow it: `else` with no `if`, `case` outside `switch`, a declaration inside a gate. | +| `Q006` | refused | A program nested deeper than the reader takes: statements, parentheses, operators, brackets and types more than 64 levels deep in its text (a run of one precedence, `a + b + c`, and an `else if` chain each count one, however long), an array of more than 7 dimensions, or more than 256 levels in all through the gates and subroutines it calls; refused, never read by a recursion that could run out of stack. | | `Q010` | warned | An `OPENQASM` version other than 2.x or 3.x: the program is read as OpenQASM 3. | | `Q011` | warned | An `include` of a file other than the standard libraries (`stdgates.inc`, `qelib1.inc`): its definitions are not read, so a gate it defines is unknown. | | `Q012` | lowered | `include "stdgates.inc"` or `"qelib1.inc"`: the standard gates, `U`, `gphase` and OpenQASM 2's names are built in. | @@ -79,7 +80,7 @@ engine. A test holds this page to the code's table. | `Q127` | refused | An assignment to a constant, an input or a loop variable. | | `Q128` | refused | A measurement where a value is needed that is not a bit (measurement gives bits, into a bit register). | | `Q129` | refused | A parameter (`input float`, `input angle`) a gradient cannot follow to the gates: under a modifier, in a classical variable, a condition, an index, a size, a loop's range, a subroutine or `gphase`, or one whose type rounds it (`angle[n]`, `float[32]`); a gradient follows a parameter through arithmetic, the built-in functions and gate definitions into a gate's arguments. | -| `Q130` | refused | A program nested deeper than 128, before a reader's stack could run out: a statement at the top level is at depth 1, and each statement in a block or a body, each expression a statement holds, each operand of an operator, what a bracket holds (of an expression or a type) and each index's indexed value, one deeper than what holds it; operators group as they bind (`a + b + c` is `(a + b) + c`), an `else if` is at the depth of its `if` (one `if` holds at most 1024 of them), and a call's body is read in its place, its statements one deeper than the call. | +| `Q130` | refused | A program whose reading would hold more memory than the reader takes: a classical value (an array, a bit register) of more than 64 MiB, each element counted at its width, or more than 512 MiB in all of its values, gates, steps, measurements and timing; refused before it is made. | | `Q200` | lowered | A `gate` the program defines, inlined at each call with its parameters bound. | | `Q201` | lowered | `ctrl @` and `negctrl @` on any gate, lowered exactly to standard gates (a negative control is a control between two `x`). | | `Q202` | lowered | `inv @` on any gate, user gates included: each gate's exact inverse, in reverse order. | @@ -105,7 +106,7 @@ engine. A test holds this page to the code's table. | `Q309` | lowered | `while` whose condition is known on reading: unrolled until it fails. | | `Q310` | refused | A `while` whose condition reads measured bits and that declares no bound: add `@wai.max_iterations N` before it. | | `Q311` | warned | A `while` on measured bits run at most its declared bound (`@wai.max_iterations N`): a run that still meets its condition after N iterations stops looping there. | -| `Q312` | refused | A program that does not finish unrolling within the reader's bound (20 000 000 statements and loop iterations). | +| `Q312` | refused | A program that does not finish unrolling within the reader's bound (20 000 000 statements and loop iterations), or that runs out a caller's budget of reading (`qasm::within_steps`: statements, gates and a generator's loop passes, shared by every program read under it). | | `Q313` | refused | A range with a zero step, or that does not read as `[start:end]` or `[start:step:end]`. | | `Q314` | refused | `break` or `continue` outside a loop, or `return` outside a subroutine. | | `Q315` | refused | A `return` with a value under a condition on measured bits. | diff --git a/wai/wai-quantum/docs/qasm-feature-matrix.md b/wai/wai-quantum/docs/qasm-feature-matrix.md index 8d73642ae..c4d036897 100644 --- a/wai/wai-quantum/docs/qasm-feature-matrix.md +++ b/wai/wai-quantum/docs/qasm-feature-matrix.md @@ -30,6 +30,9 @@ and in the browser build. | Comments `//` and `/* */` | supported; an unclosed block comment is refused | Q003 | | Identifiers, Unicode letters included (`θ`, `π`) | supported | | | Statements and blocks `{ }`, scopes | supported; qubits, gates, subroutines and externs are declared at the top level | Q105 | +| Nesting: statements in blocks and bodies, parentheses, operators, brackets, calls, casts and types | supported to 64 levels together in the text (a statement, each enclosing statement, and each level of its expressions' trees count one). A run of operators of one precedence (`a + b - c`, `c[0] && c[1] && …`) is one level, and an `else if` chain one level, at any length: both are read and run in loops, left to right. Deeper is refused, never read by a recursion that could run out of stack; an array has at most 7 dimensions | Q006 | +| Nesting in all, through the gates and subroutines a program calls | supported to 256 levels (each statement, expression and call the reader runs counts one), and calls 32 deep; deeper is refused. Both hold: a recursive subroutine whose body nests more than about seven levels reaches 256 before 32 calls | Q006, Q209 | +| What a program holds on reading: its classical values, gates, steps, measurements and timing | supported to 64 MiB for one value (an array or a bit register, each element counted at its width) and 512 MiB in all; more is refused before it is made. Making or copying a value counts toward the reader's 20 000 000 steps at one step for every 128 bytes, and an element read from an array copies the element alone | Q130, Q312 | | `pragma …`, `#pragma …`, annotations `@keyword …` | warned: kept, not applied; `@wai.max_iterations N` bounds the `while` after it | Q013 | ## Literals @@ -114,6 +117,7 @@ and in the browser build. | `pow(k) @`, `k` a whole number | lowered exactly: angles scaled or the gate repeated; negative `k` repeats the inverse | Q203 | | `pow(r) @`, `r` fractional | lowered where an exact principal root exists (`pow(1/2) @ x` is `sx`; rotations by less than 2π; phases; controlled forms); refused otherwise (`u`, `cu`, user gates) | Q204, Q205 | | A modifier on a subroutine, or nested as deep as 32 | refused | Q211, Q209 | +| A chain of modifiers (`inv @ inv @ …`) | read at any length: a list, not a nesting | | ## Measurement and reset @@ -149,7 +153,6 @@ and in the browser build. | Bits declared in a subroutine | lowered: bits of its own, fed forward | Q322 | | A subroutine called as a gate (`f(1) q, r;`) | supported | | | Recursion | refused past 32 calls deep | Q209 | -| Statements, operators, brackets and indexes nested deeper than 128, counted through the bodies calls read in place; an `if` with more than 1024 `else if` arms | refused, before a reader's stack could run out | Q130 | ## Timing diff --git a/wai/wai-quantum/docs/qec.md b/wai/wai-quantum/docs/qec.md index 81295b3c4..0c7fd2afa 100644 --- a/wai/wai-quantum/docs/qec.md +++ b/wai/wai-quantum/docs/qec.md @@ -131,7 +131,15 @@ a rate above 1/2 among them where the law needs one; a channel with no such flips (`PAULI_CHANNEL_1(0.1, 0.1, 0)`) is refused, since a model of independent mechanisms cannot state it. A circuit's model written back keeps its tags and coordinates. What the reader does not take it refuses with the -line, never skipping it. The +line, never skipping it. Blocks nest at most 32 deep (`MAX_BLOCK_DEPTH`), a +circuit names qubits below 2²⁴ (`MAX_QUBITS`; with its correlated errors' +carrier, it holds at most that many) and a model detectors below 2²⁴ +(`MAX_DETECTORS`, offsets counted): what is made for an index is as long as +the index, so each is refused on its line, as is a block nested deeper, on the +line that opens it, so neither reading, unrolling nor writing a text recurses +past that. Every pass through a block counts toward the bound on what a text +unrolls to, so a block repeated without end that writes nothing still ends, +refused. The [conformance vectors](../../quantum-conformance/qec-text/) hold each form, the model each circuit gives, and each refusal. diff --git a/wai/wai-quantum/schemas/verdict.v1.json b/wai/wai-quantum/schemas/verdict.v1.json index da023e48d..4a9f7e779 100644 --- a/wai/wai-quantum/schemas/verdict.v1.json +++ b/wai/wai-quantum/schemas/verdict.v1.json @@ -312,7 +312,12 @@ "integer", "array", "boolean" - ] + ], + "description": "The outcomes sampled that the stated distribution gives probability 0: the first 64, in order." + }, + "impossible_outcomes": { + "type": "integer", + "description": "How many outcomes sampled the stated distribution gives probability 0." }, "note": { "type": "string" diff --git a/wai/wai-quantum/src/engine/answer/eval.rs b/wai/wai-quantum/src/engine/answer/eval.rs index acf64e7f1..12f7a938d 100644 --- a/wai/wai-quantum/src/engine/answer/eval.rs +++ b/wai/wai-quantum/src/engine/answer/eval.rs @@ -34,14 +34,14 @@ pub struct Host<'a> { /// The value of the statement `name`: what its view shows or its call /// gives, or why the engine refused it. pub fn value(a: &Answer, name: &str, host: &Host) -> Result { - Ev { a, host, memo: BTreeMap::new() }.stmt(name) + Ev::new(a, host).stmt(name) } /// Every statement that is ready, carried out: `{"format", "digest", /// "results": {name: {"ok": true, "value"} | {"ok": false, "error", "code", /// …}}}`, in the order of their lines. pub fn evaluate(a: &Answer, host: &Host) -> Value { - let mut ev = Ev { a, host, memo: BTreeMap::new() }; + let mut ev = Ev::new(a, host); let mut results = serde_json::Map::new(); for c in &a.stmts { if c.status != Status::Ready || matches!(c.stmt.body, Body::Program { .. }) { @@ -111,7 +111,7 @@ pub fn verify(text: &str, job_bytes: u64) -> Result { return Err(format!("the receipt names {name}, which is not a seal the answer can run ({})", c.status.as_str())); } let host = Host { job_bytes, key: None }; - let mut ev = Ev { a: &a, host: &host, memo: BTreeMap::new() }; + let mut ev = Ev::new(&a, &host); let (request, result) = ev.sealed(seal_list(&c.stmt.body).ok_or("the seal lists nothing")?)?; let key = m["receipt"]["key"].clone(); let signer = key.as_str().and_then(job::did_of); @@ -134,30 +134,84 @@ fn seal_list(b: &Body) -> Option<&[Expr]> { } } +/// How many statements deep one statement's value may reach through the +/// statements it names (`a = @Seal([b])`, `b = @Seal([c])`, …): each is run +/// under the one that names it. +pub const CHAIN_DEPTH: usize = 32; + struct Ev<'a> { a: &'a Answer, host: &'a Host<'a>, - memo: BTreeMap>, + /// Each statement's answer, whatever reached it, with how many statements + /// long its own chain is: reached too deep for that, it is refused as it + /// would be run, so the order statements are asked in changes nothing. + memo: BTreeMap, usize)>, + /// What a statement gave where the chain's limit cut it short, with the + /// depth it was reached at: an answer of that depth, not the statement's, + /// so it stands only as deep or deeper, and is never run again there. + cut: BTreeMap, usize)>, + /// How many statements are being run, each under the one that names it. + depth: usize, + /// The deepest the statement being run has reached, and whether the + /// limit cut it short. + reached: usize, + was_cut: bool, } -impl Ev<'_> { +impl<'a> Ev<'a> { + fn new(a: &'a Answer, host: &'a Host<'a>) -> Self { + Ev { a, host, memo: BTreeMap::new(), cut: BTreeMap::new(), depth: 0, reached: 0, was_cut: false } + } + + fn too_deep(&mut self, name: &str) -> Result { + self.was_cut = true; + Err(format!("{name} is reached through more than {CHAIN_DEPTH} statements, each naming the next: name fewer in a chain")) + } + fn stmt(&mut self, name: &str) -> Result { - if let Some(r) = self.memo.get(name) { + if let Some((r, long)) = self.memo.get(name) { + if self.depth + long > CHAIN_DEPTH { + return self.too_deep(name); + } + self.reached = self.reached.max(self.depth + long); return r.clone(); } - let c = self.a.get(name).ok_or_else(|| format!("no statement {name}"))?; - let r = if c.status != Status::Ready { - Err(format!("{name} is {}: nothing to run", c.status.as_str())) + if let Some((r, at)) = self.cut.get(name) + && self.depth >= *at + { + let r = r.clone(); + self.was_cut = true; + return r; + } + if self.depth >= CHAIN_DEPTH { + return self.too_deep(name); + } + let at = self.depth; + self.depth += 1; + let (reached, was_cut) = (std::mem::replace(&mut self.reached, self.depth), std::mem::replace(&mut self.was_cut, false)); + let r = self.stmt_here(name); + self.depth -= 1; + if self.was_cut { + self.cut.insert(name.to_string(), (r.clone(), at)); } else { - match &c.stmt.body { - Body::Program { qasm, .. } => Ok(json!({ "qasm": qasm, "digest": blake3::hash(qasm.as_bytes()).to_hex().to_string() })), - Body::Expr(e) => self.expr(e), - } - }; - self.memo.insert(name.to_string(), r.clone()); + self.memo.insert(name.to_string(), (r.clone(), self.reached - at)); + } + self.reached = self.reached.max(reached); + self.was_cut |= was_cut; r } + fn stmt_here(&mut self, name: &str) -> Result { + let c = self.a.get(name).ok_or_else(|| format!("no statement {name}"))?; + if c.status != Status::Ready { + return Err(format!("{name} is {}: nothing to run", c.status.as_str())); + } + match &c.stmt.body { + Body::Program { qasm, .. } => Ok(json!({ "qasm": qasm, "digest": blake3::hash(qasm.as_bytes()).to_hex().to_string() })), + Body::Expr(e) => self.expr(e), + } + } + fn expr(&mut self, e: &Expr) -> Result { match e { Expr::View { name, args, .. } => match name.as_str() { diff --git a/wai/wai-quantum/src/engine/answer/tests.rs b/wai/wai-quantum/src/engine/answer/tests.rs index 572f6b2e5..e5b7f7ff7 100644 --- a/wai/wai-quantum/src/engine/answer/tests.rs +++ b/wai/wai-quantum/src/engine/answer/tests.rs @@ -295,3 +295,67 @@ fn the_prompt_names_every_view_and_call() { assert!(a.diags.is_empty(), "{:?}", a.diags); assert!(p.ends_with(EXAMPLE)); } + +/// A statement whose value runs through others, each sealing the next, is +/// refused past [`super::eval::CHAIN_DEPTH`] rather than run by a recursion +/// as deep as the answer is long; within it, it runs. +#[test] +fn a_chain_of_statements_past_the_limit_is_refused_not_overflowed() { + let chain = |n: usize| { + let mut t = String::from("```qasm p\nOPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;\n```\n"); + for k in 0..n { + t += &format!("s{k} = @Seal([s{}])\n", k + 1); + } + t + &format!("s{n} = @Sample(p, 10)\n") + }; + let value = |text: String| { + std::thread::Builder::new() + .stack_size(2 << 20) + .spawn(move || { + let k = key(); + super::eval::value(&parse(&text), "s0", &super::eval::Host { job_bytes: 1 << 28, key: Some(&k) }) + }) + .unwrap() + .join() + .unwrap() + }; + let e = value(chain(2_000)).unwrap_err(); + assert!(e.contains("more than 32 statements"), "{e}"); + assert!(value(chain(4)).is_ok()); +} + +/// Whether a statement of a chain is refused is its own: the same statements +/// are refused, whatever order they are asked in, and they are the ones +/// whose chains are too long read alone. Statements that name each other +/// twice are not run again for each way down. +#[test] +fn the_chain_limit_refuses_the_same_statements_in_any_order() { + let head = "```qasm p\nOPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;\n```\n"; + let lines = |n: usize, list: &str| -> Vec { + let mut v: Vec = (0..n).map(|k| format!("s{k} = @Seal([{}])\n", list.replace('#', &format!("s{}", k + 1)))).collect(); + v.push(format!("s{n} = @Sample(p, 10)\n")); + v + }; + let refused = |text: String| { + let k = key(); + let host = super::eval::Host { job_bytes: 1 << 28, key: Some(&k) }; + let a = parse(&text); + let out = super::eval::evaluate(&a, &host); + let mut bad: Vec = out["results"].as_object().unwrap().iter().filter(|(_, r)| r["ok"] != true).map(|(n, _)| n[1..].parse().unwrap()).collect(); + bad.sort_unstable(); + let alone: Vec = out["results"].as_object().unwrap().keys().filter(|n| super::eval::value(&a, n, &host).is_err()).map(|n| n[1..].parse().unwrap()).collect(); + (bad, alone) + }; + let forward = lines(40, "#"); + let (bad, alone) = refused(format!("{head}{}", forward.concat())); + let mut alone = alone; + alone.sort_unstable(); + assert_eq!(bad, (0..=8).collect::>()); + assert_eq!(alone, bad); + let reverse: Vec = forward.iter().rev().cloned().collect(); + assert_eq!(refused(format!("{head}{}", reverse.concat())).0, bad); + let t = std::time::Instant::now(); + let twice = lines(60, "#, #"); + assert!(!refused(format!("{head}{}", twice.concat())).0.is_empty()); + assert!(t.elapsed() < std::time::Duration::from_secs(120), "{:?}", t.elapsed()); +} diff --git a/wai/wai-quantum/src/engine/bound_tests.rs b/wai/wai-quantum/src/engine/bound_tests.rs new file mode 100644 index 000000000..ce0e76010 --- /dev/null +++ b/wai/wai-quantum/src/engine/bound_tests.rs @@ -0,0 +1,344 @@ +//! A job's result is never longer than the bound its plan gave before it ran +//! (`catalog::plan_bounded`), as a host writes it (RFC 8785, with its +//! provenance), for jobs of every kind drawn at random: sampled intents that +//! fail with many outcomes they rule out, distributions and references, +//! trajectories with their own count, verdicts over many notes and smells, +//! Clifford circuits of every shape on the stabilizer method, noise, observables, +//! amplitudes, compiles, repairs, mitigations, programs that measure in the +//! middle, device targets, pulse runs and intents carrying numbers RFC 8785 +//! writes long. And where the method knows its outcomes, the bound is close: +//! a GHZ state's two outcomes, and a long loop with nothing to remark on. + +use super::catalog::plan_bounded; +use super::run::run_with_provenance; +use serde_json::{Value, json}; + +const CAP: u64 = 8 << 30; + +/// The longest a host writes `v`: as the engine's RFC 8785 writer does (every +/// number as a double), or with each whole number exactly as read and every +/// other number as RFC 8785 does (a job service's served text). +fn served(v: &Value) -> u64 { + struct Es; + impl serde_json::ser::Formatter for Es { + fn write_f64(&mut self, w: &mut W, x: f64) -> std::io::Result<()> { + w.write_all(super::receipt::es_number(x).as_bytes()) + } + } + let mut out = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut out, Es); + serde::Serialize::serialize(v, &mut ser).unwrap(); + (super::receipt::canonical(v).len() as u64).max(out.len() as u64) +} + +/// A small generator of its own (SplitMix64), so a failure names its seed. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } + /// One of `lo..=hi`. + fn int(&mut self, lo: u64, hi: u64) -> u64 { + lo + self.next() % (hi - lo + 1) + } + fn coin(&mut self) -> bool { + self.next() & 1 == 1 + } + fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T { + &xs[(self.next() % xs.len() as u64) as usize] + } +} + +/// Numbers RFC 8785 writes longer than serde does, and some it does not. +fn long_numbers(rng: &mut Rng, n: u64) -> Value { + let pool = [json!(1e20), json!(1e-6), json!(1.5e-5), json!(123456.0), json!(-0.0), json!(1e21), json!(3), json!(0.1)]; + Value::Array((0..n).map(|_| rng.pick(&pool).clone()).collect()) +} + +/// The program `k` qubits under `h`, each fanned out by `cx` to the rest of +/// `w` qubits, every qubit measured into its bit: `2^k` outcomes `w` wide. +fn fanned(k: u64, w: u64) -> String { + let mut s = format!("OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[{w}] q;\nbit[{w}] c;\n"); + for i in 0..k { + s += &format!("h q[{i}];\n"); + } + for i in k..w { + s += &format!("cx q[{}], q[{i}];\n", i % k); + } + s + "c = measure q;\n" +} + +/// A sampled intent that the program's run does not fit: it states one +/// outcome (or a few), so every other outcome sampled is one it rules out. +fn sampled_intent(rng: &mut Rng, w: u64) -> Value { + let zeros = "0".repeat(w as usize); + let mut want = serde_json::Map::new(); + want.insert(zeros, json!(1.0)); + let mut i = json!({ "sampled": want, "test": rng.pick(&["chi_squared", "g_test"]), "alpha": 0.01, "shots": rng.int(200, 6000), "seed": rng.int(1, 9) }); + if rng.coin() { + i["name"] = json!(format!("intent {}", rng.int(0, 99))); + } + if rng.coin() { + let n = rng.int(1, 3000); + i["note"] = long_numbers(rng, n); + } + i +} + +/// A Clifford circuit drawn at random: `n` qubits, its gates of every +/// Clifford kind, some qubits measured, into bits that may repeat. +fn clifford(rng: &mut Rng, n: u64, gates: u64) -> String { + let bits = rng.int(1, n + 4); + let mut s = format!("OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[{n}] q;\nbit[{bits}] c;\n"); + for _ in 0..gates { + let a = rng.int(0, n - 1); + let b = (a + rng.int(1, n.max(2) - 1)) % n; + let g = *rng.pick(&["h", "s", "sdg", "x", "y", "z", "sx", "sxdg", "cx", "cz", "cy", "swap", "h", "cx"]); + if matches!(g, "cx" | "cz" | "cy" | "swap") && n > 1 { + s += &format!("{g} q[{a}], q[{b}];\n"); + } else if !matches!(g, "cx" | "cz" | "cy" | "swap") { + s += &format!("{g} q[{a}];\n"); + } + } + let measured = rng.int(1, n); + for _ in 0..measured { + s += &format!("c[{}] = measure q[{}];\n", rng.int(0, bits - 1), rng.int(0, n - 1)); + } + s +} + +/// A program with `pragmas` lines the reader notes, and gates that do +/// nothing, cancel or merge, among others, `gates` of them. +fn noted(rng: &mut Rng, pragmas: u64, gates: u64) -> String { + let n = rng.int(2, 6); + let mut s = format!("OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[{n}] q;\nbit[{n}] c;\n"); + for i in 0..pragmas { + s += &format!("pragma wai.unknown_{i} {}\n", "x".repeat(rng.int(0, 40) as usize)); + } + for _ in 0..gates { + let a = rng.int(0, n - 1); + s += &match rng.int(0, 5) { + 0 => format!("h q[{a}];\nh q[{a}];\n"), + 1 => format!("rz(0) q[{a}];\n"), + 2 => format!("rx(0.1) q[{a}];\nrx(0.2) q[{a}];\n"), + 3 => format!("cx q[{a}], q[{}];\ncx q[{a}], q[{}];\n", (a + 1) % n, (a + 1) % n), + 4 => format!("t q[{a}];\ntdg q[{a}];\n"), + _ => format!("h q[{a}];\n"), + }; + } + s + "c = measure q;\n" +} + +/// A job of the kind `kind`, drawn at random. +fn job(rng: &mut Rng, kind: u64) -> (String, Value) { + match kind { + // Sampled intents that fail, ruling out many wide outcomes. + 0 => { + let k = rng.int(3, 10); + let w = rng.int(k, 48); + let intents: Vec = (0..rng.int(1, 3)).map(|_| sampled_intent(rng, w)).collect(); + ("sampled intents that fail".into(), json!({ "qasm": fanned(k, w), "shots": rng.int(1, 200), "intent": intents, "seed": rng.int(1, 99) })) + } + // Trajectories, their own count apart from the shots. + 1 => { + let n = rng.int(2, 9); + let w = rng.int(n, 30); + let mut q = format!("OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[{n}] q;\nbit[{w}] c;\nh q;\n"); + for i in 0..n { + q += &format!("c[{}] = measure q[{i}];\n", rng.int(0, w - 1)); + } + let mut j = json!({ "qasm": q, "method": "trajectories", "shots": rng.int(1, 3000), "noise": { "depolarizing_1q": 0.01, "readout": 0.01 }, "options": { "trajectories": rng.int(1, 40_000) } }); + if rng.coin() { + j["verdict"] = json!(true); + } + ("trajectories".into(), j) + } + // A verdict over many notes and smells. + 2 => { + let (p, g) = (rng.int(0, 2500), rng.int(0, 3000)); + let mut j = json!({ "qasm": noted(rng, p, g), "shots": rng.int(1, 500), "verdict": true }); + if rng.coin() { + j["compile"] = json!({ "epsilon": 1e-3, "level": 2 }); + } + ("notes and smells, with a verdict".into(), j) + } + // A Clifford circuit, on the stabilizer method or as the planner picks. + 3 => { + let n = rng.int(1, 30); + let g = rng.int(0, 200); + let mut j = json!({ "qasm": clifford(rng, n, g), "shots": rng.int(1, 20_000), "seed": rng.int(1, 99) }); + if rng.coin() { + j["method"] = json!("stabilizer"); + } + if rng.int(0, 3) == 0 { + j["noise"] = json!({ "depolarizing_1q": 0.01, "readout": 0.02 }); + } + if rng.coin() { + j["verdict"] = json!(true); + } + ("a Clifford circuit".into(), j) + } + // Observables, amplitudes, a mitigation, a repair, measured in the middle. + 4 => { + let n = rng.int(2, 6); + let mut q = format!("OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[{n}] q;\nbit[{n}] c;\nh q;\nrx(0.3) q[0];\ncx q[0], q[1];\n"); + let mut j = json!({ "shots": rng.int(0, 4000), "seed": 3 }); + match rng.int(0, 4) { + 0 => j["observables"] = json!((0..rng.int(1, 40)).map(|i| format!("{}.25 Z{} X{}", i % 5, i % n, (i + 1) % n)).collect::>()), + 1 => j["amplitudes"] = json!((0..rng.int(1, 20)).map(|i| format!("{:0w$b}", i % (1 << n), w = n as usize)).collect::>()), + 2 => { + j["observables"] = json!(["Z0 Z1"]); + j["noise"] = json!({ "depolarizing_2q": 0.02, "depolarizing_1q": 0.004 }); + j["mitigate"] = json!({ "method": "zne", "shots": 2000 }); + } + 3 => { + q += "h q[0];\nh q[0];\n"; + j["repair"] = json!(true); + j["verdict"] = json!(true); + } + _ => q += "c[0] = measure q[0];\nreset q[0];\nh q[0];\n", + } + j["qasm"] = json!(q + "c = measure q;\n"); + // A seed the engine does not read, which the provenance names as sent. + match rng.int(0, 3) { + 0 => j["seed"] = json!("s".repeat(rng.int(1, 40_000) as usize)), + 1 => j["seed"] = long_numbers(rng, 3000), + _ => {} + } + if j["shots"] == 0 && j.get("observables").is_none() && j.get("amplitudes").is_none() { + j["shots"] = json!(100); + } + ("observables, amplitudes, mitigation, repair or a measurement in the middle".into(), j) + } + // A device target, its trajectories its own count. + 5 => { + let mut j = json!({ "qasm": "OPENQASM 3.0; qubit[3] q; bit[3] c; h q[0]; cx q[0], q[1]; cx q[1], q[2]; c = measure q;", "target": "synthetic-ring-8", "shots": rng.int(1, 3000), "seed": 2 }); + if rng.coin() { + j["method"] = json!("trajectories"); + j["options"] = json!({ "trajectories": rng.int(1, 30_000) }); + } + ("on a device target".into(), j) + } + // A pulse run with its bits named. + 6 => { + let n = rng.int(1, 300) as usize; + let regs: String = (0..n.div_ceil(64)).map(|r| format!("bit[{}] b{r};\n", 64.min(n - 64 * r))).collect(); + let q = format!("OPENQASM 3.0;\ndefcalgrammar \"openpulse\";\ncal {{\n extern port d0;\n extern port a0;\n frame q0 = newframe(d0, 5.0e9, 0);\n frame c0 = newframe(a0, 7.1e9, 0);\n}}\ndefcal x $0 {{ play(q0, gaussian(0.4, 64dt, 16dt)); }}\ndefcal measure $0 -> bit {{ return capture_v2(c0, 4dt); }}\n{regs}x $0;\nb{}[{}] = measure $0;\n", (n - 1) / 64, (n - 1) % 64); + ("a pulse run".into(), json!({ "method": "pulse", "shots": rng.int(1, 2000), "qasm": q, "verdict": rng.coin() })) + } + // A distribution, a reference and a state, as intents, with long numbers. + _ => { + let k = rng.int(1, 6); + let w = rng.int(k, 12); + let src = fanned(k, w); + let mut d = serde_json::Map::new(); + for i in 0..rng.int(1, 80) { + d.insert(format!("{:0w$b}", i % (1 << w.min(16)), w = w as usize), json!(0.0)); + } + let first = d.keys().next().cloned().unwrap(); + d.insert(first, json!(1.0)); + let mut intents = vec![json!({ "distribution": d, "tolerance": 1e-6, "note": long_numbers(rng, 200) }), json!({ "equivalent": src, "name": "itself" }), json!({ "prepares": fanned(k, w) })]; + intents.truncate(rng.int(1, 3) as usize); + ("a distribution, a reference and a state".into(), json!({ "qasm": src, "shots": rng.int(1, 3000), "intent": intents })) + } + } +} + +/// Every random job's result is within the bound its plan gave. The seed is +/// `WAI_BOUND_SEED` if set, so a failure can be read again. +#[test] +#[cfg(all(feature = "quantum_qasm", feature = "quantum_noise", feature = "quantum_receipt"))] +fn no_random_job_s_result_passes_its_bound() { + let seed = std::env::var("WAI_BOUND_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or(676); + let mut rng = Rng(seed); + let (mut checked, mut refused) = (0, 0); + for i in 0..96u64 { + let (what, job) = job(&mut rng, i % 8); + let (p, bound) = match plan_bounded(&job, CAP) { + Ok((p, Some(b))) => (p, b), + Ok((_, None)) => panic!("job {i} (seed {seed}), {what}: no bound"), + Err(e) => { + eprintln!("job {i}, {what}: not planned: {e}"); + refused += 1; + continue; + } + }; + let result = match run_with_provenance(&job, &p, "a host 1.0") { + Ok(r) => r, + Err(e) => { + eprintln!("job {i}, {what}: did not run: {e}"); + refused += 1; + continue; + } + }; + let len = served(&result); + eprintln!("job {i}, {what}: {len} bytes, bound {bound}"); + assert!(len <= bound, "job {i} (seed {seed}), {what}: its result is {len} bytes, past its bound of {bound}\n{job}"); + checked += 1; + } + assert!(checked >= 80 && refused <= 16, "{checked} checked, {refused} not run"); +} + +/// The jobs of round 6's review, each past its bound before: a sampled +/// intent ruling out thousands of 64-bit outcomes, a trajectories run of one +/// shot and 30,000 trajectories, and a verdict over 3,000 notes. +#[test] +#[cfg(all(feature = "quantum_qasm", feature = "quantum_noise", feature = "quantum_receipt"))] +fn a_failing_intent_trajectories_and_notes_stay_within_their_bounds() { + let zeros = "0".repeat(64); + let jobs = [ + ("a sampled intent ruling out 4,095 outcomes", json!({ "qasm": fanned(12, 64), "shots": 1, "intent": [{ "sampled": { zeros.clone(): 1.0 }, "test": "chi_squared", "alpha": 0.01, "shots": 40_000 }, { "sampled": { zeros: 1.0 }, "test": "g_test", "alpha": 0.01, "shots": 40_000 }] })), + ("one shot, 30,000 trajectories", json!({ "qasm": fanned(12, 12).replace("cx", "// cx"), "method": "trajectories", "shots": 1, "noise": { "depolarizing_1q": 1e-6 }, "options": { "trajectories": 30_000 } })), + ("3,000 notes and a verdict", json!({ "qasm": format!("OPENQASM 3.0;\nqubit q;\nbit c;\n{}h q;\nc = measure q;\n", (0..3000).map(|i| format!("pragma s{i}\n")).collect::()), "shots": 10, "verdict": true })), + ]; + let mut past = Vec::new(); + for (what, job) in jobs { + let (p, bound) = plan_bounded(&job, CAP).unwrap(); + let bound = bound.unwrap(); + let result = run_with_provenance(&job, &p, "a host 1.0").unwrap(); + let len = served(&result); + eprintln!("{what}: {len} bytes, bound {bound}"); + if len > bound { + past.push(format!("{what}: its result is {len} bytes, past its bound of {bound}")); + } + } + assert!(past.is_empty(), "{past:#?}"); +} + +/// Where the method knows how many outcomes there can be, or the critique +/// has nothing to say, the bound is close to the result: a GHZ state of 24 +/// qubits at 30,000 shots on the stabilizer method lists two outcomes, and a +/// loop of 124,700 gates that cancel nowhere has no remark, so neither is +/// bounded as if it listed every shot or remarked on every gate. +#[test] +#[cfg(all(feature = "quantum_qasm", feature = "quantum_noise", feature = "quantum_receipt"))] +fn a_ghz_state_and_a_long_loop_are_bounded_by_what_they_can_list() { + let mut ghz = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[24] q;\nbit[24] c;\nh q[0];\n".to_string(); + for i in 1..24 { + ghz += &format!("cx q[{}], q[{i}];\n", i - 1); + } + ghz += "c = measure q;\n"; + let lp = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[8] q;\nbit[8] c;\nfor int s in [1:4300] {\n cx q[0], q[1]; rz(0.1) q[1]; cx q[0], q[1];\n cx q[1], q[2]; rz(0.2) q[2]; cx q[1], q[2];\n cx q[2], q[3]; rz(0.3) q[3]; cx q[2], q[3];\n cx q[3], q[4]; rz(0.4) q[4]; cx q[3], q[4];\n cx q[4], q[5]; rz(0.5) q[5]; cx q[4], q[5];\n cx q[5], q[6]; rz(0.6) q[6]; cx q[5], q[6];\n cx q[6], q[7]; rz(0.7) q[7]; cx q[6], q[7];\n rx(0.1) q[0]; rx(0.1) q[1]; rx(0.1) q[2]; rx(0.1) q[3]; rx(0.1) q[4]; rx(0.1) q[5]; rx(0.1) q[6]; rx(0.1) q[7];\n}\nc = measure q;\n"; + let mut loose = Vec::new(); + for (what, job, most) in [ + ("GHZ24, 30,000 shots, stabilizer", json!({ "qasm": ghz.clone(), "shots": 30_000, "method": "stabilizer", "seed": 5 }), 32u64 << 10), + ("GHZ24, 30,000 shots, planned", json!({ "qasm": ghz, "shots": 30_000, "seed": 5 }), 32 << 10), + ("a loop of 124,700 gates, with its verdict", json!({ "qasm": lp, "shots": 1000, "verdict": true }), 256 << 10), + ] { + let (p, bound) = plan_bounded(&job, CAP).unwrap(); + let bound = bound.unwrap(); + let result = run_with_provenance(&job, &p, "a host 1.0").unwrap(); + let len = served(&result); + eprintln!("{what}: {len} bytes, bound {bound}, on {}", p.method); + assert!(len <= bound, "{what}: its result is {len} bytes, past its bound of {bound}"); + if bound > most { + loose.push(format!("{what}: bounded at {bound} bytes, where its result is {len}")); + } + } + assert!(loose.is_empty(), "{loose:#?}"); +} diff --git a/wai/wai-quantum/src/engine/catalog.rs b/wai/wai-quantum/src/engine/catalog.rs index 5d2cf4dc8..19882a81c 100644 --- a/wai/wai-quantum/src/engine/catalog.rs +++ b/wai/wai-quantum/src/engine/catalog.rs @@ -322,6 +322,12 @@ pub struct Request { /// With `"mitigate"`: the mitigation asked for, as given (read and /// checked by [`read`]; run after the job, its result under `mitigation`). pub mitigate: Option, + /// For a job that asks for a verdict: the most its critique takes as the + /// verdict lists it, bytes, from the program as read, before any compile + /// ([`super::critique::smells_bound`]; a program holding a gate whole, + /// which the critique reads in standard gates, at [`REMARKS_PER_GATE`] a + /// standard gate and a qubit). 0 for one that asks for none. + pub smells_bytes: u64, } impl Request { @@ -342,6 +348,7 @@ impl Request { compile_passes: self.compile_passes.clone(), repaired: self.repaired.clone(), mitigate: self.mitigate.clone(), + smells_bytes: self.smells_bytes, }) } } @@ -620,6 +627,14 @@ pub(crate) fn read_form(reg: &Registry, v: &Value, whole: bool) -> Result<(Reque if let Some(i) = v.get("intent").filter(|i| !i.is_null()) { super::verdict::fit(&super::verdict::intents(i)?, &program)?; } + // A verdict's critique reads the program as given, not as compiled. + let smells_bytes = if !super::verdict::asked(v) { + 0 + } else if program.has_whole() { + u64::try_from(program.standard_gates()).unwrap_or(u64::MAX).saturating_add(u64::from(program.n_qubits)).saturating_mul(REMARKS_PER_GATE) + } else { + super::critique::smells_bound(&program) + }; // "compile": {"epsilon", "level", "basis"}: the job runs the compiled // program, and its result says how far that is from the one given. let (program, compiled, compile_passes) = match v.get("compile") { @@ -709,6 +724,7 @@ pub(crate) fn read_form(reg: &Registry, v: &Value, whole: bool) -> Result<(Reque compile_passes, repaired, mitigate, + smells_bytes, }, uses)) } @@ -1249,6 +1265,198 @@ pub fn plan(v: &Value, job_bytes: u64) -> Result { plan_in(Registry::builtins(), v, job_bytes) } +/// [`plan`], with the most its result may take as JSON text, in bytes, +/// beside what a host adds to it (a receipt, an energy figure), known before +/// it runs ([`result_bound`]); a method that reads its own program bounds +/// its own ([`Method::plan_bounded`]: a pulse run's is +/// [`super::pulse::plan_bounded`]), and `None` is one that gives no bound, +/// which no built-in is. +pub fn plan_bounded(v: &Value, job_bytes: u64) -> Result<(Plan, Option), String> { + plan_bounded_in(Registry::builtins(), v, job_bytes).map(|(p, b)| (p, b.map(|b| b.bytes()))) +} + +/// [`plan_bounded`], the bound given by part ([`Bound`]). +pub fn plan_bound(v: &Value, job_bytes: u64) -> Result<(Plan, Option), String> { + plan_bounded_in(Registry::builtins(), v, job_bytes) +} + +/// The most a job's result takes as JSON text, bytes, part by part, known +/// before it runs ([`result_bound`]): what a host holds a result to before +/// it runs, and what a refusal past its cap names (the largest part, +/// [`Bound::largest`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Bound { + /// Its counts (and a mitigation's), each listing up to the samples it + /// takes, the outcomes its program can give and 65,536. + pub counts: u64, + /// A verdict's: its critique's remarks, each intent's check, and the + /// reader's notes and the repair again. + pub verdict: u64, + /// The reader's warnings and notes on the program. + pub notes: u64, + /// Each item's request, which a set serves beside its result. + pub requests: u64, + /// The rest: expectation values, amplitudes, outputs, a compile's, + /// repair's or target's account, and the fields every result has. + pub rest: u64, +} + +impl Bound { + /// The whole bound, bytes. + pub fn bytes(&self) -> u64 { + self.counts.saturating_add(self.verdict).saturating_add(self.notes).saturating_add(self.requests).saturating_add(self.rest) + } + + /// Both bounds together, part by part: a set's items'. + pub fn plus(self, o: Bound) -> Bound { + Bound { + counts: self.counts.saturating_add(o.counts), + verdict: self.verdict.saturating_add(o.verdict), + notes: self.notes.saturating_add(o.notes), + requests: self.requests.saturating_add(o.requests), + rest: self.rest.saturating_add(o.rest), + } + } + + /// The largest part: its bytes, what it is, and what makes it smaller. + pub fn largest(&self) -> (u64, &'static str, &'static str) { + let parts = [ + (self.counts, "its counts, listing up to the shots (or a trajectories run's trajectories), the outcomes its measured bits can give and 65,536 outcomes an item", "send fewer points, fewer shots or fewer measured bits"), + (self.verdict, "its verdict: the critique's remark on each gate that does nothing, cancels or merges, each intent's check, and the reader's notes again", "ask for no verdict (leave out \"verdict\" and \"intent\", and send it to POST /v1/jobs, not an MCP run tool, which asks for one), or send fewer points"), + (self.notes, "the reader's warnings and notes on its program, one for each line they are on", "remove or fix the lines the reader notes (each `pragma` it does not use draws one), or send fewer points"), + (self.requests, "each item's request, which a set serves beside the item's result", "send fewer points, or keep large fields out of the set's request: each item serves the fields it shares"), + (self.rest, "its expectation values, amplitudes, outputs and its compile's, repair's or target's account", "ask for fewer observables, amplitudes or outputs, or send fewer points"), + ]; + parts.into_iter().fold(parts[0], |a, b| if b.0 > a.0 { b } else { a }) + } +} + +/// What a result holds beside the parts [`result_bound`] counts one by one: +/// its method, qubits and gates, its accuracy and its notes, its state's +/// hash, its schema, its timing and its provenance, the plan's reason apart. +const RESULT_REST: u64 = 16 << 10; +/// An expectation value's numbers and names, beside its observable's text +/// (a bound, a ladder of thresholds, a standard error). +const EXPECTATION_BYTES: u64 = 1 << 10; +/// An amplitude's numbers and names, beside its bit string. +const AMPLITUDE_BYTES: u64 = 1 << 9; +/// A verdict's own fields, beside its critique's remarks, its intents', the +/// notes and the repair it repeats. +const VERDICT_BYTES: u64 = 16 << 10; +/// One intent's check, beside the intent as given and the outcomes it may +/// list ([`super::verdict::MAX_LISTED`]). +const INTENT_BYTES: u64 = 16 << 10; +/// A critique's remarks on one gate of a program it reads in standard gates +/// where the program as read holds a gate whole, at most: a gate draws one +/// that it does nothing, and is one of a pair that cancels or merges, each +/// naming its lines and its qubits. +pub(crate) const REMARKS_PER_GATE: u64 = 512; +/// A mitigation's own fields, beside a value for each observable and a +/// count for each outcome. +const MITIGATION_BYTES: u64 = 64 << 10; +/// An outcome as an intent's check lists it, beside its bit string: quotes, +/// a colon, a comma, and a count or a probability at its longest. +const LISTED_BYTES: u64 = 32; + +/// How many times an intent's check may list the outcomes it lists: a +/// sampled intent's counts, the outcomes of probability 0 it names (in its +/// fit, in the fit's note, and in its reason); a distribution's computed +/// outcomes and its largest gaps. +const LISTS_PER_INTENT: u64 = 4; + +/// The samples a job's counts are drawn from: its shots, or for the +/// trajectories method (and a device target's), its trajectories +/// ([`trajectory_count`]), one a sample, which the counts list. +pub(crate) fn samples_listed(r: &Request, p: &Plan) -> u64 { + if r.shots > 0 && p.method == "trajectories" { trajectory_count(r).max(r.shots) } else { r.shots } +} + +/// How many distinct outcomes a job's counts can list, at most: `2^b` for +/// the `b` classical bits its measurements write (the rest stay 0; every bit +/// of a program that measures in the middle), and for a noiseless run of the +/// stabilizer method, `2^d`, `d` its outcome dimension's bound: its measured +/// qubits, and the gates that branch (each `h`, `sx` and `sxdg` it runs as, +/// the rest permuting or phasing the basis states), each at most doubling +/// the outcomes a tableau can give. A GHZ state has 2, however wide. +pub(crate) fn outcomes_bound(r: &Request, p: &Plan) -> u64 { + let pow2 = |e: u64| if e >= 63 { u64::MAX } else { 1u64 << e }; + let prog = &r.program; + if prog.dynamic.is_some() { + return pow2(u64::from(prog.n_bits)); + } + let bits: std::collections::BTreeSet = prog.measured.iter().map(|&(_, b)| b).collect(); + let mut e = bits.len() as u64; + if p.method == "stabilizer" && r.noise.is_none() && p.target.is_none() { + use crate::quantum_tableau::Clifford; + let qubits: std::collections::BTreeSet = prog.measured.iter().map(|&(q, _)| q).collect(); + e = e.min(qubits.len() as u64); + if let Ok(gates) = prog.clifford() { + e = e.min(gates.iter().filter(|g| matches!(g, Clifford::H(_) | Clifford::SX(_) | Clifford::SXdg(_))).count() as u64); + } + } + pow2(e) +} + +/// The most the result of the job `v`, read as `r` and planned as `p`, takes +/// as JSON text, bytes, by part ([`Bound`]): each part that grows with what +/// the job asks bounded by what it may list, from the job as read. Its +/// counts (and a mitigation's) by [`super::run::counts_bound_among`], of +/// the samples it lists ([`samples_listed`]) among the outcomes it can give +/// ([`outcomes_bound`]); each expectation value by its observable's text, +/// escaped, and a fixed allowance; each amplitude by its bit string and one; +/// what its reading put in it (its diagnostics, its outputs, its repair, its +/// compile and its target) at its length as RFC 8785 writes it +/// ([`super::receipt::canonical_len`], every number in its own form); a +/// verdict by its fields, the diagnostics and the repair it repeats, its +/// critique's remarks ([`Request::smells_bytes`]), and each intent as given +/// with the outcomes its check lists, [`super::verdict::MAX_LISTED`] at a +/// time (and two intents' reasons again, in its statement); and the rest at +/// [`RESULT_REST`]. The counts are written exactly so; a sixteenth more is +/// allowed for the rest, for the numbers the engine writes. +pub(crate) fn result_bound(v: &Value, r: &Request, p: &Plan) -> Bound { + let size = super::receipt::canonical_len; + // A string of the job's own, every character escaped at its longest. + let text = |t: &str| 6 * t.len() as u64 + 2; + let bits = r.program.n_bits; + let samples = samples_listed(r, p); + let outcomes = outcomes_bound(r, p); + let mut counts = super::run::counts_bound_among(bits, samples, outcomes, None); + // The provenance names the job's shots and seed as they were sent, + // whatever they are. + let mut rest = RESULT_REST + 2 * size(&json!(p.why)) + ["shots", "seed"].iter().filter_map(|k| v.get(*k)).map(size).sum::(); + rest += r.observables.iter().map(|(t, _, _)| EXPECTATION_BYTES + text(t)).sum::(); + rest += r.amplitudes.len() as u64 * (AMPLITUDE_BYTES + u64::from(r.program.n_qubits)); + let notes = r.diagnostics.iter().map(|d| size(&d.to_json()) + 1).sum::(); + rest += r.program.extra.outputs.iter().map(|o| 64 + text(&o.name) + text(&o.ty) + match &o.value { + qasm::OutputValue::Bits(bits) => 11 * bits.len() as u64, + qasm::OutputValue::Value(_, x) => size(x), + }).sum::(); + let repaired = r.repaired.as_ref().map_or(0, size); + rest += repaired + r.compiled.as_ref().map_or(0, size) + r.compile_passes.as_ref().map_or(0, size); + rest += p.target.as_ref().map_or(0, size); + if r.mitigate.is_some() { + rest += MITIGATION_BYTES + r.observables.len() as u64 * EXPECTATION_BYTES; + counts = counts.saturating_add(super::run::counts_bound_among(bits, samples, outcomes, Some(24))); + } + let mut verdict = 0u64; + if super::verdict::asked(v) { + let intents: Vec<&Value> = match v.get("intent") { + Some(Value::Array(a)) => a.iter().collect(), + Some(i) if !i.is_null() => vec![i], + _ => Vec::new(), + }; + let listed = super::verdict::MAX_LISTED as u64 * (u64::from(bits) + LISTED_BYTES); + let each = |i: &Value| INTENT_BYTES + 2 * size(i) + LISTS_PER_INTENT * listed; + // The statement says the reason of the first that fails and of the + // first left unchecked again. + let said = intents.iter().map(|i| each(i)).max().unwrap_or(0); + verdict = VERDICT_BYTES + repaired + notes + r.smells_bytes; + verdict = verdict.saturating_add(intents.iter().map(|i| each(i)).sum::()).saturating_add(2 * said); + } + let rest = rest.saturating_add((rest + notes + verdict) / 16); + Bound { counts, verdict, notes, requests: 0, rest } +} + /// The method `v` names, when it reads its job's program itself (a pulse /// run: the pulse reader reads it, and the pulse simulator integrates it). pub(crate) fn reads_own<'a>(reg: &'a Registry, v: &Value) -> Option<&'a dyn Method> { @@ -1257,8 +1465,18 @@ pub(crate) fn reads_own<'a>(reg: &'a Registry, v: &Value) -> Option<&'a dyn Meth /// [`plan`] through `reg`: its methods, channels and passes. pub(crate) fn plan_in(reg: &Registry, v: &Value, job_bytes: u64) -> Result { + plan_bounded_in(reg, v, job_bytes).map(|(p, _)| p) +} + +/// [`plan_bound`] through `reg`. +pub(crate) fn plan_bounded_in(reg: &Registry, v: &Value, job_bytes: u64) -> Result<(Plan, Option), String> { if let Some(m) = reads_own(reg, v) { - return m.plan(&Job::new(reg, v, job_bytes, None, &[])); + // The built-in pulse run bounds its result by part; another method + // that reads its own program, as one sum. + if m.name() == "pulse" && reg.identity(super::registry::Kind::Method, "pulse").is_some_and(|i| i.is_builtin()) { + return super::pulse::plan_bound(v, job_bytes).map(|(p, b)| (p, Some(b))); + } + return m.plan_bounded(&Job::new(reg, v, job_bytes, None, &[])).map(|(p, b)| (p, b.map(|rest| Bound { rest, ..Bound::default() }))); } // A job that names a device target is compiled, placed and simulated as // the device would run it (engine::targets::job). @@ -1268,7 +1486,10 @@ pub(crate) fn plan_in(reg: &Registry, v: &Value, job_bytes: u64) -> Result Result String { } } +/// A smell's place in the program's ops: an op that does nothing, or two in a +/// row that cancel or merge. +enum Site { + Nothing(usize), + Cancels(usize, usize), + Merges(usize, usize), +} + +/// Each smell of the gates of `p` as it is found, in order: the walk +/// [`smells`] lists and [`smells_bound`] bounds, so the two never differ. +fn sites(p: &Program, mut found: impl FnMut(Site)) { + let n = p.n_qubits as usize; + // 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: Box>> = match &p.dynamic { + None => Box::new((0..p.ops.len()).map(Some)), + Some(steps) => Box::new(steps.iter().map(|s| if let Instr::Gate(k) = s { Some(*k) } else { None })), + }; + 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; + found(Site::Nothing(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; + found(Site::Cancels(i, j)); + } else if adjacent && merges(a, b) { + used[i] = true; + used[j] = true; + found(Site::Merges(i, j)); + } + } + for &q in &b.qubits { + last[q as usize] = Some(j); + } + } +} + +/// What one smell takes as a verdict lists it (`{"kind", "message", +/// "lines"}`), bytes, at most, beside the names of its qubits: its kind, the +/// longest message's words and gate names, and three line numbers of ten +/// digits. +const SMELL_BYTES: u64 = 256; +/// A qubit's index in a smell's message, at most: ten digits and ", ". +const QUBIT_BYTES: u64 = 12; + +/// The most the smells of `src` take as a verdict lists them, bytes, where +/// `p` is `src` as read: each smell the walk of its gates finds +/// ([`SMELL_BYTES`] and its qubits' names), and the idle and unmeasured +/// qubits, at most every qubit each. A program's critique grows with its +/// smells, not with its gates: a loop of a hundred thousand gates that +/// cancel nowhere has none. +pub fn smells_bound(p: &Program) -> u64 { + let arity = |k: usize| p.ops[k].qubits.len() as u64; + let mut b = 0u64; + sites(p, |s| { + b += SMELL_BYTES + + QUBIT_BYTES + * match s { + Site::Nothing(j) => arity(j), + Site::Cancels(i, _) | Site::Merges(i, _) => arity(i), + }; + }); + b + 2 * (SMELL_BYTES + QUBIT_BYTES * u64::from(p.n_qubits)) +} + fn smells(src: &str, p: &Program, lines: &[usize]) -> Vec { let text: Vec<&str> = src.split('\n').collect(); let n = p.n_qubits as usize; @@ -329,22 +407,9 @@ fn smells(src: &str, p: &Program, lines: &[usize]) -> Vec { 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; + sites(p, |site| match site { + Site::Nothing(j) => { + let b = &p.ops[j]; 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]), @@ -354,43 +419,34 @@ fn smells(src: &str, p: &Program, lines: &[usize]) -> Vec { 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() { + Site::Cancels(i, j) => { + let (a, b) = (&p.ops[i], &p.ops[j]); + 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 }); + } + Site::Merges(i, j) => { 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, - }, + 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, - }; - 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, - }); - } + }, + _ => 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 { diff --git a/wai/wai-quantum/src/engine/dynamic.rs b/wai/wai-quantum/src/engine/dynamic.rs index e03a4374a..f1bafa63f 100644 --- a/wai/wai-quantum/src/engine/dynamic.rs +++ b/wai/wai-quantum/src/engine/dynamic.rs @@ -246,58 +246,73 @@ impl Walk<'_> { /// The exact mixture's expectation values from step `pc`, the branch /// weighted by `weight`, added into `sums`; `leaves` counts the branches. + /// + /// The branches still to finish wait on a list, the latest first: at a + /// measurement with two outcomes, outcome 0 runs at once in a copy and + /// outcome 1 waits in this state. The leaves are reached in the order a + /// depth-first walk reaches them (so the sums are the same bits), and no + /// recursion follows the program's measurements, however many it makes. #[allow(clippy::too_many_arguments)] - fn expect(&mut self, mut pc: usize, mut state: E, mut bits: Vec, weight: f64, observables: &[Vec<(u32, char)>], sums: &mut [f64], leaves: &mut u64) -> Result<(), String> { - while pc < self.steps.len() { - match &self.steps[pc] { - Instr::Gate(i) => state.gate(*i)?, - Instr::Measure { .. } if self.skip.get(pc) == Some(&true) => {} - Instr::Measure { qubit, .. } | Instr::Reset(qubit) => { - let q = *qubit; - let (bit, reset) = step_target(&self.steps[pc]); - let p1 = state.prob_one(q)?; - let branches: Vec<(bool, f64)> = [(false, 1.0 - p1), (true, p1)].into_iter().filter(|&(_, p)| p > NEGLIGIBLE).collect(); - if let [(o, p), _] = branches[..] { - // Outcome 0 in a copy; outcome 1 in this state. - let mut child = self.copy(&state)?; - settle(&mut child, q, o, reset)?; - let mut child_bits = bits.clone(); - if let Some(b) = bit { - child_bits[b] = o; + fn expect(&mut self, pc: usize, state: E, bits: Vec, weight: f64, observables: &[Vec<(u32, char)>], sums: &mut [f64], leaves: &mut u64) -> Result<(), String> { + let mut todo = vec![(pc, state, bits, weight)]; + while let Some((mut pc, mut state, mut bits, mut weight)) = todo.pop() { + while pc < self.steps.len() { + match &self.steps[pc] { + Instr::Gate(i) => state.gate(*i)?, + Instr::Measure { .. } if self.skip.get(pc) == Some(&true) => {} + Instr::Measure { qubit, .. } | Instr::Reset(qubit) => { + let q = *qubit; + let (bit, reset) = step_target(&self.steps[pc]); + let p1 = state.prob_one(q)?; + let branches: Vec<(bool, f64)> = [(false, 1.0 - p1), (true, p1)].into_iter().filter(|&(_, p)| p > NEGLIGIBLE).collect(); + let (o, p) = *branches.last().ok_or("a measurement with no possible outcome")?; + if let [(o0, p0), _] = branches[..] { + // Outcome 0 in a copy, run now; outcome 1 in this state, after it. + let mut child = self.copy(&state)?; + settle(&mut child, q, o0, reset)?; + let mut child_bits = bits.clone(); + if let Some(b) = bit { + child_bits[b] = o0; + } + settle(&mut state, q, o, reset)?; + if let Some(b) = bit { + bits[b] = o; + } + todo.push((pc + 1, std::mem::replace(&mut state, child), std::mem::replace(&mut bits, child_bits), weight * p)); + weight *= p0; + } else { + settle(&mut state, q, o, reset)?; + if let Some(b) = bit { + bits[b] = o; + } } - self.expect(pc + 1, child, child_bits, weight * p, observables, sums, leaves)?; - self.live -= 1; } - let (o, p) = *branches.last().ok_or("a measurement with no possible outcome")?; - settle(&mut state, q, o, reset)?; - if let Some(b) = bit { - bits[b] = o; + Instr::JumpUnless(cond, to) => { + if !cond.holds(&bits) { + pc = *to; + continue; + } } - if branches.len() == 2 { - return self.expect(pc + 1, state, bits, weight * p, observables, sums, leaves); - } - } - Instr::JumpUnless(cond, to) => { - if !cond.holds(&bits) { + Instr::Jump(to) => { pc = *to; continue; } } - Instr::Jump(to) => { - pc = *to; - continue; - } + pc += 1; + } + *leaves += 1; + if *leaves > MAX_LEAVES { + return Err(format!( + "this program's measurements branch {MAX_LEAVES}+ ways, more than the exact expectation values follow; ask for counts, whose shots split only where they fall" + )); + } + for (s, o) in sums.iter_mut().zip(observables) { + *s += weight * state.expectation(o)?; + } + // This branch's state is done with; the next waiting one is alive. + if !todo.is_empty() { + self.live -= 1; } - pc += 1; - } - *leaves += 1; - if *leaves > MAX_LEAVES { - return Err(format!( - "this program's measurements branch {MAX_LEAVES}+ ways, more than the exact expectation values follow; ask for counts, whose shots split only where they fall" - )); - } - for (s, o) in sums.iter_mut().zip(observables) { - *s += weight * state.expectation(o)?; } Ok(()) } @@ -382,3 +397,42 @@ fn settle(state: &mut E, q: u32, outcome: bool, reset: bool) -> Resul pub fn measurements(steps: &[Instr]) -> usize { steps.iter().filter(|s| matches!(s, Instr::Measure { .. } | Instr::Reset(_))).count() } + +#[cfg(test)] +mod tests { + use serde_json::json; + + /// A program whose measurements branch thousands of times, each under + /// the last one's outcome, is walked without recursion: past + /// [`super::MAX_LEAVES`] the walk is refused, on a 2 MiB stack, rather + /// than recursing as deep as the program measures. + #[test] + fn a_program_that_branches_thousands_deep_is_refused_not_recursed() { + let src = format!("OPENQASM 3.0;\nqubit q;\nbit c;\nx q;\nc = measure q;\n{}", "if (c) { h q; c = measure q; }\n".repeat(5_000)); + let r = std::thread::Builder::new() + .stack_size(2 << 20) + .spawn(move || { + let req = json!({ "qasm": src, "observables": ["Z0"], "method": "statevector" }); + let plan = crate::engine::catalog::plan(&req, 1 << 30)?; + crate::engine::run::run(&req, &plan) + }) + .unwrap() + .join() + .unwrap(); + let e = r.unwrap_err(); + assert!(e.contains("4096+"), "{e}"); + } + + /// Walked with a list rather than a recursion, the branches are summed + /// into the exact mixture: q0 turned by 1 reads 1 with probability + /// sin²(½), and q1 copies it, so ⟨Z0⟩ = ⟨Z1⟩ = cos 1 and ⟨X1⟩ = 0. + #[test] + fn the_branches_sum_to_the_exact_mixture() { + let req = json!({ "qasm": "OPENQASM 3.0;\nqubit[2] q;\nbit c;\nry(1.0) q[0];\nc = measure q[0];\nif (c) x q[1];\n", "observables": ["Z0", "Z1", "X1"], "method": "statevector" }); + let plan = crate::engine::catalog::plan(&req, 1 << 30).unwrap(); + let r = crate::engine::run::run(&req, &plan).unwrap(); + let v: Vec = r["expectations"].as_array().unwrap().iter().map(|x| x["value"].as_f64().unwrap()).collect(); + assert!((v[0] - 1f64.cos()).abs() < 1e-12 && (v[1] - 1f64.cos()).abs() < 1e-12 && v[2].abs() < 1e-12, "{v:?}"); + } +} + diff --git a/wai/wai-quantum/src/engine/editor/index.rs b/wai/wai-quantum/src/engine/editor/index.rs index 6333448ae..89304fc63 100644 --- a/wai/wai-quantum/src/engine/editor/index.rs +++ b/wai/wai-quantum/src/engine/editor/index.rs @@ -180,7 +180,7 @@ impl Index { /// The index of a lexed program. pub fn build(src: &str, lexed: &Lexed) -> Index { let toks: Vec = lexed.tokens.iter().copied().filter(|t| !t.is_comment()).collect(); - let mut b = Builder { src, t: &toks, pos: 0, ix: Index::default(), scope: 0 }; + let mut b = Builder { src, t: &toks, pos: 0, ix: Index::default(), scope: 0, depth: 0 }; b.ix.scopes.push(Scope { kind: ScopeKind::Global, parent: None, span: (0, src.len()) }); while b.pos < toks.len() { let before = b.pos; @@ -294,8 +294,16 @@ struct Builder<'a> { pos: usize, ix: Index, scope: usize, + /// How many statements the builder is inside ([`MAX_DEPTH`]). + depth: usize, } +/// How deep statements nest for the index, as the reader takes them +/// ([`crate::qasm::parse::MAX_NESTING`]): a statement deeper is passed over +/// whole, its names not indexed, so a text nested past it (which the reader +/// refuses) never runs the builder out of stack. +const MAX_DEPTH: usize = crate::qasm::parse::MAX_NESTING; + impl Builder<'_> { fn tok(&self, i: usize) -> Option { self.t.get(i).copied() @@ -474,6 +482,42 @@ impl Builder<'_> { } fn statement(&mut self) { + if self.depth >= MAX_DEPTH { + return self.pass_over(); + } + self.depth += 1; + self.statement_here(); + self.depth -= 1; + } + + /// The statement at the cursor passed over without recursion: to its + /// `;` (read) or its block's `}` (read), or up to the `}` that closes + /// the block it stands in (not read). + fn pass_over(&mut self) { + let mut depth = 0usize; + while self.pos < self.t.len() { + match self.text(self.pos) { + "(" | "[" | "{" => depth += 1, + ")" | "]" => depth = depth.saturating_sub(1), + "}" if depth == 0 => return, + "}" => { + depth -= 1; + if depth == 0 { + self.pos += 1; + return; + } + } + ";" if depth == 0 => { + self.pos += 1; + return; + } + _ => {} + } + self.pos += 1; + } + } + + fn statement_here(&mut self) { let Some(tok) = self.tok(self.pos) else { return }; let stmt = tok.start; match tok.kind { @@ -1038,4 +1082,30 @@ mod tests { assert!(!ix.visible(0).iter().any(|&i| ix.decls[i].name == "i")); assert_eq!(ix.callable_of(ix.scope_at(inside_gate)).map(|d| ix.decls[d].name.as_str()), Some("g")); } + + /// Statements nested past the reader's limit (which the reader refuses) + /// are passed over, not recursed into: braces a megabyte deep, unclosed, + /// or `if` after `if`, index on a 2 MiB stack, and what stands beside + /// them is still found. + #[test] + fn nesting_past_the_reader_s_limit_is_passed_over_not_recursed_into() { + let on_stack = |src: String| std::thread::Builder::new().stack_size(2 << 20).spawn(move || index(&src)).unwrap().join().unwrap(); + for src in [ + format!("qubit[2] q;\n{}h q[0];\n", "{ ".repeat(200_000)), + format!("qubit[2] q;\n{}h q[0];{}\nbit c;\n", "{ ".repeat(100_000), " }".repeat(100_000)), + format!("qubit[2] q;\n{}h q[0];\nbit c;\n", "if (true) ".repeat(100_000)), + format!("qubit[2] q;\ngate g a {{ {}h a;{} }}\nbit c;\n", "{ ".repeat(100_000), " }".repeat(100_000)), + ] { + let closed = src.contains("bit c"); + let ix = on_stack(src); + assert_eq!(decl(&ix, "q").kind, DeclKind::QubitRegister); + if closed { + assert_eq!(decl(&ix, "c").kind, DeclKind::BitRegister); + } + } + // Nested within the limit, every use is still found. + let src = format!("qubit[2] q;\n{}h q[0];{}\n", "{ ".repeat(40), " }".repeat(40)); + let ix = on_stack(src); + assert!(ix.refs.iter().any(|r| r.name == "q" && r.decl.is_some())); + } } diff --git a/wai/wai-quantum/src/engine/formats/emit.rs b/wai/wai-quantum/src/engine/formats/emit.rs index f891a9232..813759719 100644 --- a/wai/wai-quantum/src/engine/formats/emit.rs +++ b/wai/wai-quantum/src/engine/formats/emit.rs @@ -106,8 +106,11 @@ impl Cond { /// The most terms a condition may have. pub(crate) const MAX_COND: usize = 1024; -/// The deepest a condition's parentheses may nest once written. -pub(crate) const MAX_COND_DEPTH: usize = 32; +/// The deepest a condition's parentheses may nest once written: what the +/// reader takes of it (a level of parentheses costs it two of +/// [`crate::qasm::MAX_NESTING`], the parenthesis and the operand it is), so a +/// condition that fits this reads back. +pub(crate) const MAX_COND_DEPTH: usize = 30; /// One statement. #[derive(Clone, Debug, PartialEq)] @@ -222,6 +225,24 @@ pub(crate) fn cond_fits(c: &Cond) -> bool { mod tests { use super::*; + /// A condition within the converters' bounds reads back: a flat run as + /// long as they take, and parentheses as deep, of either operator. + #[test] + fn a_condition_that_fits_reads_back() { + let reads = |c: Cond| { + assert!(cond_fits(&c), "size {} depth {}", c.size(), c.depth()); + let n = 64; + let body = vec![Stmt::Measure { qubit: 0, bit: 0 }, Stmt::If { cond: c, then: vec![Stmt::gate("x", vec![], vec![0])], other: vec![] }]; + let text = Circuit { n_qubits: 1, n_bits: n, body }.to_qasm("read from a test"); + crate::qasm::read(&text).unwrap_or_else(|d| panic!("{d}: {text}")); + }; + let bit = |k: usize| Cond::Bit((k % 64) as u32); + let run = |and: bool, n: usize| (1..n).fold(bit(0), |a, k| if and { Cond::And(Box::new(a), Box::new(bit(k))) } else { Cond::Or(Box::new(a), Box::new(bit(k))) }); + reads(run(true, MAX_COND / 2)); + reads(run(false, MAX_COND / 2)); + reads((0..MAX_COND_DEPTH).fold(bit(0), |c, k| if k % 2 == 0 { Cond::And(Box::new(bit(k + 1)), Box::new(c)) } else { Cond::Or(Box::new(bit(k + 1)), Box::new(c)) })); + } + #[test] fn a_circuit_writes_as_openqasm_and_reads_back() { let c = Circuit { diff --git a/wai/wai-quantum/src/engine/mod.rs b/wai/wai-quantum/src/engine/mod.rs index 0338ed620..325367934 100644 --- a/wai/wai-quantum/src/engine/mod.rs +++ b/wai/wai-quantum/src/engine/mod.rs @@ -194,6 +194,8 @@ pub mod algorithms; #[cfg(feature = "quantum_receipt")] pub mod answer; pub mod catalog; +#[cfg(test)] +mod bound_tests; pub mod compile; #[cfg(feature = "quantum_receipt")] pub mod converge; diff --git a/wai/wai-quantum/src/engine/pulse.rs b/wai/wai-quantum/src/engine/pulse.rs index 18c62938f..bfae29c8c 100644 --- a/wai/wai-quantum/src/engine/pulse.rs +++ b/wai/wai-quantum/src/engine/pulse.rs @@ -436,6 +436,88 @@ pub fn lint(request: &Value) -> Value { /// memory and the work, and why. pub fn plan(v: &Value, job_bytes: u64) -> Result { let (_, p) = prepare(v)?; + plan_of(&p, job_bytes) +} + +/// [`plan`], with the most the run's result ([`run`]) takes as JSON text, +/// bytes, known before it runs, from the same reading: as +/// [`super::catalog::plan_bounded`] gives a circuit's. +pub fn plan_bounded(v: &Value, job_bytes: u64) -> Result<(Plan, u64), String> { + plan_bound(v, job_bytes).map(|(p, b)| (p, b.bytes())) +} + +/// [`plan_bounded`], the bound given by part. +pub fn plan_bound(v: &Value, job_bytes: u64) -> Result<(Plan, super::catalog::Bound), String> { + let (_, p) = prepare(v)?; + let plan = plan_of(&p, job_bytes)?; + let bound = result_bound(&p, &plan); + Ok((plan, bound)) +} + +/// What a pulse result holds beside the parts [`result_bound`] counts one by +/// one: its accuracy, its integrator, its notes, its digests, its schema, +/// its verdict's own fields and its provenance, the plan's reason apart. +const RESULT_REST: u64 = 16 << 10; +/// A number as JSON text at its longest (`-2.2250738585072014e-308` is 24 +/// characters), with room. +const NUMBER: u64 = 32; +/// One capture as the result lists it, beside its frame's and its bit's +/// names: its qubit, its start in samples and in ns, and its two +/// probabilities. +const CAPTURE_BYTES: u64 = 192; + +/// The most [`run`]'s result of the job read as `p` and planned as `plan` +/// takes as JSON text, bytes, each part that grows with the program bounded +/// by what it may list: the populations, `2^n` outcomes of the `n` simulated +/// qubits; the distribution, the populations' where nothing is captured, +/// or the joint reading's, `2^d` outcomes of the `d` captures where each is +/// on its own qubit (else none), each keyed as wide as the program's bits +/// or the captures; the counts sampled from it +/// ([`super::run::counts_bound_among`]); the outcome bits' labels; each +/// capture with its frame's and bit's names; the leakage; the diagnostics, +/// in the result and in its verdict; the device as read and the target; and +/// the rest at [`RESULT_REST`], a sixteenth more for the forms RFC 8785 +/// writes a number in. +fn result_bound(p: &Prepared, plan: &Plan) -> super::catalog::Bound { + let size = super::receipt::canonical_len; + // A name of the program's own as JSON text: RFC 8785 escapes a string's + // characters as serde does (`receipt::write_str`). + let text = |t: &str| serde_json::to_string(t).map_or(6 * t.len() as u64 + 2, |s| s.len() as u64); + let pow2 = |e: u64| if e >= 63 { u64::MAX } else { 1u64 << e }; + let s = &p.reading.schedule; + let n = p.plan.qubits.len() as u64; + let caps: Vec<&ir::Instruction> = s.instructions.iter().filter(|i| matches!(i.op, Op::Capture { .. })).collect(); + let mut read: Vec = caps.iter().map(|i| s.frame_qubit(i.frame)).collect(); + read.sort_unstable(); + read.dedup(); + let bits = s.bits.len() as u64; + let (outcomes, width) = if caps.is_empty() { + (pow2(n), n) + } else if read.len() == caps.len() { + (pow2(read.len() as u64), bits.max(read.len() as u64)) + } else { + (0, 0) + }; + let entry = |w: u64| w + 4 + NUMBER; + // The populations, the distribution and the counts: what grows with the outcomes. + let mut counts = pow2(n).saturating_mul(entry(n)); + counts = counts.saturating_add(outcomes.saturating_mul(entry(width))); + counts = counts.saturating_add(super::run::counts_bound_among(u32::try_from(width).unwrap_or(u32::MAX), p.shots, outcomes, None)); + let mut b = RESULT_REST + 2 * size(&json!(plan.why)); + b = b.saturating_add(16 * n + s.bits.iter().map(|x| text(x) + 1).sum::() + 16 * caps.len() as u64); + b = b.saturating_add(caps.iter().map(|i| CAPTURE_BYTES + text(&s.frames[i.frame].name) + match &i.op { + Op::Capture { bit: Some(k), .. } => s.bits.get(*k).map_or(0, |x| text(x)), + _ => 0, + }).sum::()); + b = b.saturating_add(n * (NUMBER + 32)); + let notes = 2 * p.reading.diagnostics.iter().map(|d| size(&d.to_json()) + 1).sum::(); + b = b.saturating_add(size(&p.device_json) + p.target.as_ref().map_or(0, size)); + let rest = b.saturating_add(b.saturating_add(notes) / 16); + super::catalog::Bound { counts, notes, rest, ..Default::default() } +} + +/// [`plan`] of the job read as `p`. +fn plan_of(p: &Prepared, job_bytes: u64) -> Result { let s = &p.reading.schedule; let held: usize = s.waveforms.iter().map(|w| w.samples.len()).sum(); let memory = (32u64 << 20) + 16 * held as u64 + 96 * s.instructions.len() as u64 + 64 * (p.plan.dim * p.plan.dim) as u64 + 64 * p.plan.samples; diff --git a/wai/wai-quantum/src/engine/pulse/tests.rs b/wai/wai-quantum/src/engine/pulse/tests.rs index 5e5ac1d38..41fd57a24 100644 --- a/wai/wai-quantum/src/engine/pulse/tests.rs +++ b/wai/wai-quantum/src/engine/pulse/tests.rs @@ -197,3 +197,40 @@ fn a_pulse_job_is_the_same_bits_on_every_platform() { let r = crate::engine::run::run(&req, &p).unwrap(); assert_eq!(r["pulse"]["density_hash"].as_str(), Some(XM_DENSITY), "the bits differ from the pinned ones: {}", r["pulse"]["density_hash"]); } + +/// No pulse result is larger than the bound its plan gave before it ran +/// (`catalog::plan_bounded`, through the pulse method's own), as RFC 8785 +/// writes it with its provenance: one capture; 3,000 bits declared and one +/// captured (every key of the distribution and the counts as wide as the +/// program's bits, and each bit named); nothing captured (the populations as +/// the distribution); and two qubits captured at once. A qubit is captured +/// once at most (`J040`), so what grows is the bits. Each is bounded, and +/// within a few times what it gives. +#[test] +#[cfg(feature = "quantum_receipt")] +fn no_pulse_result_passes_the_bound_its_plan_gave() { + let cal = "OPENQASM 3.0;\ndefcalgrammar \"openpulse\";\ncal {\n extern port d0;\n extern port m0;\n extern port a0;\n extern port d1;\n extern port a1;\n frame q0 = newframe(d0, 5.0e9, 0);\n frame r0 = newframe(m0, 7.1e9, 0);\n frame c0 = newframe(a0, 7.1e9, 0);\n frame q1 = newframe(d1, 5.2e9, 0);\n frame c1 = newframe(a1, 7.2e9, 0);\n}\ndefcal x $0 { play(q0, gaussian(0.4, 64dt, 16dt)); }\ndefcal x $1 { play(q1, gaussian(0.4, 64dt, 16dt)); }\ndefcal measure $0 -> bit { return capture_v2(c0, 4dt); }\ndefcal measure $1 -> bit { return capture_v2(c1, 4dt); }\n"; + // Registers of 64 bits at most (Q460), as many as `n` bits take, and the + // name of bit `i`. + let regs = |n: usize| (0..n.div_ceil(64)).map(|r| format!("bit[{}] b{r};\n", 64.min(n - 64 * r))).collect::(); + let bit = |i: usize| format!("b{}[{}]", i / 64, i % 64); + let jobs = [ + ("one capture", XM.to_string(), 500), + ("one bit of 3,000 captured", format!("{cal}{}x $0;\n{} = measure $0;\n", regs(3000), bit(2999)), 2000), + ("nothing captured", format!("{cal}x $0;\n"), 500), + ("two qubits captured", format!("{cal}bit[2] c;\nx $0;\nx $1;\nc[0] = measure $0;\nc[1] = measure $1;\n"), 4000), + ]; + for (name, qasm, shots) in jobs { + let job = json!({ "qasm": qasm, "method": "pulse", "shots": shots, "seed": 3 }); + let (p, bound) = match catalog::plan_bounded(&job, 1 << 30) { + Ok((p, Some(b))) => (p, b), + Ok((_, None)) => panic!("{name}: no bound"), + Err(e) => panic!("{name}: {e}"), + }; + let result = crate::engine::run::run_with_provenance(&job, &p, "a host 1.0").unwrap_or_else(|e| panic!("{name}: {e}")); + let served = crate::engine::receipt::canonical(&result).len() as u64; + eprintln!("{name}: {served} bytes, bound {bound}"); + assert!(served <= bound, "{name}: its result is {served} bytes, past its bound of {bound}"); + assert!(bound <= 4 * served + (32 << 10), "{name}: {served} bytes, bound {bound}"); + } +} diff --git a/wai/wai-quantum/src/engine/qec.rs b/wai/wai-quantum/src/engine/qec.rs index a16493874..7fe43fb07 100644 --- a/wai/wai-quantum/src/engine/qec.rs +++ b/wai/wai-quantum/src/engine/qec.rs @@ -45,6 +45,87 @@ fn qerr(m: impl Into) -> String { tag(refusal::QEC, m.into()) } +thread_local! { + /// The time this thread's sampled decodes may take ([`within_time`]): + /// when the budget began, and how long it is. + static TIME: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Runs `f` with the shots it samples and decodes held to `limit` in all, on +/// this thread: a decode with `shots`, and a threshold of any kind, each +/// refused (`J031`, [`refusal::TOO_LARGE_HERE`]) at once when what it has +/// measured says it would take longer, and stopped if it runs a fifth past +/// that. What a shot costs depends on the noise, the distance and the decoder +/// as much as on the counts, so each point is measured before its shots are +/// run: its setup and sampling as they are, and a pilot of its own shots +/// (one in a hundred, at least 32, and on for [`PILOT_FLOOR`] where its shots +/// allow) for its rate. The rest is +/// projected from those, so setup is never multiplied by the shots, and a +/// call whose measured rate says it fits is run. +pub fn within_time(limit: std::time::Duration, f: impl FnOnce() -> T) -> T { + struct Reset(Option<(std::time::Instant, std::time::Duration)>); + impl Drop for Reset { + fn drop(&mut self) { + TIME.with(|t| t.set(self.0)); + } + } + let _reset = Reset(TIME.with(|t| t.replace(Some((std::time::Instant::now(), limit))))); + f() +} + +/// The shots of a point decoded first to measure its rate: at most one in a +/// hundred (and at least 32), for at most half a second, and at least 8. +fn pilot_of(shots: u64) -> u64 { + shots.min((shots / 100).max(32)) +} + +/// Whether a pilot begun at `began` with `done` of its `most` shots decoded +/// goes on. +fn piloting(done: usize, most: usize, began: std::time::Instant) -> bool { + done < most && (done < 8 || began.elapsed() < std::time::Duration::from_millis(500)) +} + +/// The least time a circuit's pilot runs, its shots allowing. Its shots are +/// the call's own, decoded once and counted in its result, so measuring for +/// longer costs nothing; one in a hundred of a light decode is 20 ms, and +/// one pause of a loaded host's scheduler in so short a time read the rate +/// three times slow and refused a call that fit (a decode that took 2.7 s +/// alone, projected at 7 s against 6, at load 40 on the M3 Ultra). +const PILOT_FLOOR: std::time::Duration = std::time::Duration::from_millis(200); + +/// [`piloting`] for a pilot of a call's own shots, `total` in all: past its +/// one in a hundred it goes on until [`PILOT_FLOOR`]. With no time set +/// (`most` 0) there is no pilot. +fn piloting_own(done: usize, most: usize, total: usize, began: std::time::Instant) -> bool { + most > 0 && done < total && (piloting(done, most, began) || (done >= most && began.elapsed() < PILOT_FLOOR)) +} + +/// The refusal of a call whose shots would take `secs`. +fn too_slow(secs: f64, limit: std::time::Duration, op: &str) -> String { + tag(refusal::TOO_LARGE_HERE, format!("its shots would take about {secs:.0} s to sample and decode here, more than the {} s a call here takes; run `wai-quantum qec {op}` on your machine", limit.as_secs())) +} + +/// Whether the call, `rest` seconds of measured work from done, fits this +/// thread's time ([`within_time`]): the refusal where it does not. +fn fits(rest: f64, op: &str) -> Result<(), String> { + let Some((start, limit)) = TIME.with(std::cell::Cell::get) else { return Ok(()) }; + let projected = start.elapsed().as_secs_f64() + rest; + if projected > limit.as_secs_f64() { Err(too_slow(projected, limit, op)) } else { Ok(()) } +} + +/// Whether the call has run a fifth past this thread's time: the refusal +/// where it has, its projection having been short. +fn still_on_time(op: &str) -> Result<(), String> { + let Some((start, limit)) = TIME.with(std::cell::Cell::get) else { return Ok(()) }; + let spent = start.elapsed(); + if spent > limit + limit / 5 { Err(too_slow(spent.as_secs_f64(), limit, op)) } else { Ok(()) } +} + +/// Whether a time is set on this thread: only then is a pilot measured. +fn timed() -> bool { + TIME.with(std::cell::Cell::get).is_some() +} + fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } @@ -360,7 +441,23 @@ pub fn decode(request: &Value) -> Result { let seed = request.get("seed").and_then(Value::as_u64).unwrap_or(1); let smp = frame::sample(&c, shots as usize, seed); let mut failures = 0u64; - for i in 0..smp.shots { + // The pilot's shots measure the rate; the rest are projected from it. + let most = if timed() { pilot_of(shots) as usize } else { 0 }; + let began = std::time::Instant::now(); + let mut i = 0; + while piloting_own(i, most, smp.shots, began) { + if dec.run(&smp.fired(i), &mut ws)?.0 != smp.flips(i) { + failures += 1; + } + i += 1; + } + if i > 0 { + fits(began.elapsed().as_secs_f64() / i as f64 * (smp.shots - i) as f64, "decode")?; + } + for i in i..smp.shots { + if i % 64 == 0 { + still_on_time("decode")?; + } if dec.run(&smp.fired(i), &mut ws)?.0 != smp.flips(i) { failures += 1; } @@ -523,6 +620,22 @@ pub fn threshold(request: &Value) -> Result { "surface" => { decoder = request.get("decoder").and_then(Value::as_str).unwrap_or("matching"); noise = "circuit level: depolarizing noise after every gate and on idle data, flips after resets and before measurements, all at p"; + // Each point set up, sampled and its pilot decoded first, so the + // whole call is projected from what every point costs before the + // rest of any is run; then each point's other shots, in order. + struct Point { + d: u32, + rounds: u32, + p: f64, + smp: frame::Detections, + dec: Dec, + ws: Option, + failures: u64, + piloted: usize, + } + let most = if timed() { pilot_of(shots) as usize } else { 0 }; + let mut run: Vec = Vec::with_capacity(points); + let mut rest = 0f64; for &d in &dists { let d = d as u32; if !(3..=MAX_CIRCUIT_DISTANCE).contains(&d) || d.is_multiple_of(2) { @@ -534,36 +647,88 @@ pub fn threshold(request: &Value) -> Result { let model = frame::error_model(&c).map_err(|e| qerr(e.to_string()))?; let dec = decoder_for(decoder, &model, request)?; let smp = frame::sample(&c, shots as usize, seed.wrapping_add(u64::from(d) * 1000 + (p * 1e6) as u64)); - let mut ws = None; - let mut failures = 0u64; - for i in 0..smp.shots { - if dec.run(&smp.fired(i), &mut ws)?.0 != smp.flips(i) { - failures += 1; + let mut pt = Point { d, rounds, p, smp, dec, ws: None, failures: 0, piloted: 0 }; + let began = std::time::Instant::now(); + while piloting_own(pt.piloted, most, pt.smp.shots, began) { + let i = pt.piloted; + if pt.dec.run(&pt.smp.fired(i), &mut pt.ws)?.0 != pt.smp.flips(i) { + pt.failures += 1; } + pt.piloted += 1; } - let pt = MemoryPoint { distance: d, rounds, p, shots, failures }; - memory.push(pt); - let rate = failures as f64 / shots as f64; - out_points.push(json!({ "distance": d, "p": p, "rounds": rounds, "shots": shots, "failures": failures, "rate": rate, "stderr": (rate * (1.0 - rate) / shots as f64).sqrt(), "per_round": pt.per_round() })); + if pt.piloted > 0 { + rest += began.elapsed().as_secs_f64() / pt.piloted as f64 * (pt.smp.shots - pt.piloted) as f64; + // Refused as soon as what is measured already passes it. + fits(rest, "threshold")?; + } + run.push(pt); } } + for mut pt in run { + for i in pt.piloted..pt.smp.shots { + if i % 64 == 0 { + still_on_time("threshold")?; + } + if pt.dec.run(&pt.smp.fired(i), &mut pt.ws)?.0 != pt.smp.flips(i) { + pt.failures += 1; + } + } + let (d, rounds, p, failures) = (pt.d, pt.rounds, pt.p, pt.failures); + let pt = MemoryPoint { distance: d, rounds, p, shots, failures }; + memory.push(pt); + let rate = failures as f64 / shots as f64; + out_points.push(json!({ "distance": d, "p": p, "rounds": rounds, "shots": shots, "failures": failures, "rate": rate, "stderr": (rate * (1.0 - rate) / shots as f64).sqrt(), "per_round": pt.per_round() })); + } } "color" | "toric" | "surface_capacity" => { decoder = if family == "color" { "color" } else { "union_find" }; noise = "code capacity: independent X flips on the data at p, perfect syndromes"; + let mut codes = Vec::with_capacity(dists.len()); for &d in &dists { let code_name = match family { "color" => format!("color:{d}"), "toric" => format!("toric:{d}"), _ => format!("surface:{d}"), }; - let c = read_code(&json!(code_name))?; + codes.push((d, read_code(&json!(code_name))?)); + } + let bench = |c: &Code, p: f64, trials: u32, s: u64| match &c.color { + Some(cc) => crate::quantum_color::benchmark(cc, p, trials, s), + None => crate::quantum_qec::benchmark_uf(&c.css, p, trials, s), + }; + // A point runs whole, so each is measured on a pilot of its own + // trials first, and the call projected from them all. The pilot + // runs in batches, each twice the one before, held as a circuit's + // is to half a second ([`piloting`]): run whole, a point's one in + // a hundred of 200,000 trials of a large code took 11 s in a debug + // build before the call could be refused. A batch has no setup of + // its own (each trial is drawn and decoded alone), so its time + // over its trials is the point's rate. + if timed() { + let pilot = pilot_of(shots) as usize; + let mut rest = 0f64; + for (d, c) in &codes { + for &p in &ps { + let s = seed.wrapping_add(d * 1000 + (p * 1e6) as u64); + let began = std::time::Instant::now(); + let (mut done, mut batch) = (0usize, 8usize); + while piloting(done, pilot, began) { + let n = batch.min(pilot - done); + bench(c, p, n as u32, s.wrapping_add(done as u64)); + done += n; + batch *= 2; + } + rest += began.elapsed().as_secs_f64() / done.max(1) as f64 * shots as f64; + fits(rest, "threshold")?; + } + } + } + for (d, c) in &codes { + let d = *d; for &p in &ps { + still_on_time("threshold")?; let s = seed.wrapping_add(d * 1000 + (p * 1e6) as u64); - let (trials, failures) = match &c.color { - Some(cc) => crate::quantum_color::benchmark(cc, p, shots as u32, s), - None => crate::quantum_qec::benchmark_uf(&c.css, p, shots as u32, s), - }; + let (trials, failures) = bench(c, p, shots as u32, s); let rate = f64::from(failures) / f64::from(trials); out_points.push(json!({ "distance": d, "p": p, "shots": trials, "failures": failures, "rate": rate, "stderr": (rate * (1.0 - rate) / f64::from(trials)).sqrt() })); } @@ -693,6 +858,77 @@ pub fn call(op: &str, request: &Value) -> Result { mod tests { use super::*; + /// A circuit's pilot goes on past its one in a hundred until + /// [`PILOT_FLOOR`], so its rate is not read from 20 ms of a light decode; + /// it never passes its shots, nor half a second once one in a hundred is + /// not done, and there is none with no time set. + #[test] + fn a_circuit_s_pilot_runs_for_its_floor_within_its_shots() { + let now = std::time::Instant::now(); + let ago = |ms: u64| now.checked_sub(std::time::Duration::from_millis(ms)).expect("an instant that long ago"); + assert!(piloting_own(200, 200, 20_000, now), "past one in a hundred, within the floor"); + assert!(!piloting_own(200, 200, 20_000, ago(PILOT_FLOOR.as_millis() as u64 + 1)), "past the floor"); + assert!(!piloting_own(20_000, 200, 20_000, now), "every shot done"); + assert!(!piloting_own(0, 0, 20_000, now), "no time set"); + assert!(piloting_own(100, 200, 20_000, ago(300)), "short of one in a hundred, within half a second"); + assert!(!piloting_own(100, 200, 20_000, ago(600)), "past half a second"); + assert!(piloting_own(3, 200, 20_000, ago(600)), "fewer than 8 done"); + } + + /// A call whose setup and sampling are most of its time, and its shots + /// cheap, is run within a time it fits: what is measured before the first + /// shot is not multiplied by the shots. A code-capacity threshold, whose + /// points run whole, is measured on a pilot of each point and refused at + /// once where it would not fit. A documented default answers, or is + /// refused within a few seconds, not at the end of its time. + #[test] + fn a_time_is_measured_apart_from_setup_and_holds_every_threshold() { + // Its time measured here first, with no time set; then, given twice + // that and a second, it is run, not refused: a time here is a build's + // and a host's (a debug build beside the library's other tests took + // past 15 s, where a release build alone takes a second or two). + let circuit = crate::quantum_frame::surface_code_memory(11, 50, 1e-5); + let req = json!({ "circuit": circuit, "shots": 20_000 }); + let t = std::time::Instant::now(); + call("decode", &req).unwrap(); + let took = t.elapsed(); + let t = std::time::Instant::now(); + let r = within_time(took * 2 + std::time::Duration::from_secs(1), || call("decode", &req)); + assert!(r.is_ok(), "{r:?} after {:?}, where it took {took:?} with no time set", t.elapsed()); + // Refused on its pilot, which is held to about half a second, not on + // its one in a hundred run whole (11 s in a debug build on macOS): + // within 5 s, or twice the decode's time where the host makes that + // longer (8.2 s in a release build beside the library's other tests, + // the Framework at load 77, where 5 s was asked). + let t = std::time::Instant::now(); + let e = within_time(std::time::Duration::from_secs(3), || call("threshold", &json!({ "family": "color", "distances": [15], "p": [0.08], "shots": 200_000 }))).unwrap_err(); + let most = std::time::Duration::from_secs(5).max(took * 2); + assert!(e.ends_with("[J031]") && t.elapsed() < most, "{e} after {:?}, where {most:?} was asked (the decode took {took:?})", t.elapsed()); + let t = std::time::Instant::now(); + let r = within_time(std::time::Duration::from_secs(25), || call("threshold", &json!({ "family": "surface", "decoder": "correlated" }))); + assert!(r.is_ok() || t.elapsed() < std::time::Duration::from_secs(12), "{r:?} after {:?}", t.elapsed()); + } + + /// Within a time, a decode and a threshold whose shots would take longer + /// are refused as soon as their first shots say so, not at the end of the + /// time; one that fits answers as it did; with no time set, none is. + #[test] + fn a_time_holds_the_shots_sampled_and_decoded() { + let heavy = [ + ("threshold", json!({ "family": "surface", "distances": [7, 7, 7, 7], "p": [0.04, 0.045, 0.05, 0.055], "shots": 12_500 })), + ("decode", json!({ "memory": { "distance": 7, "rounds": 7, "p": 0.05 }, "shots": 200_000 })), + ]; + for (op, req) in heavy { + let t = std::time::Instant::now(); + let e = within_time(std::time::Duration::from_secs(3), || call(op, &req)).unwrap_err(); + assert!(e.ends_with("[J031]") && e.contains("wai-quantum qec"), "{op}: {e}"); + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{op}: {:?}", t.elapsed()); + } + let small = json!({ "family": "surface", "distances": [3], "p": [0.001], "shots": 200 }); + assert!(within_time(std::time::Duration::from_secs(30), || call("threshold", &small)).is_ok()); + assert!(TIME.with(std::cell::Cell::get).is_none()); + } + #[test] fn every_family_reads_and_certifies_its_distance() { for (name, n, k, d) in [("surface:5", 25, 1, 5), ("toric:4", 32, 2, 4), ("steane", 7, 1, 3), ("color:7", 37, 1, 7), ("bb:72", 72, 12, 6), ("gross", 144, 12, 12)] { diff --git a/wai/wai-quantum/src/engine/receipt.rs b/wai/wai-quantum/src/engine/receipt.rs index a699405c3..002a8992c 100644 --- a/wai/wai-quantum/src/engine/receipt.rs +++ b/wai/wai-quantum/src/engine/receipt.rs @@ -188,6 +188,54 @@ fn write(v: &Value, o: &mut Vec) { } } +/// The length of `v` as RFC 8785 text, bytes, written nowhere: its length as +/// [`canonical`] writes it, or as a host writes it with each whole number +/// exactly as read and every other number as [`canonical`] does (a job +/// service's served text), whichever is longer. A number's length is its own +/// form's, never serde's (`1e20` is 21 characters here, 4 there), so this is +/// what a result that copies a request takes as served. +pub fn canonical_len(v: &Value) -> u64 { + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + let mut number = |n: &serde_json::Number| -> u64 { + let x = n.as_f64().unwrap_or(f64::NAN); + let es = *seen.entry(x.to_bits()).or_insert_with(|| es_number(x).len() as u64); + // A whole number written as read takes its own digits. + let exact = if n.is_f64() { 0 } else { n.to_string().len() as u64 }; + es.max(exact) + }; + let mut total = 0u64; + let mut stack = vec![v]; + while let Some(v) = stack.pop() { + total += match v { + Value::Null => 4, + Value::Bool(b) => if *b { 4 } else { 5 }, + Value::Number(n) => number(n), + Value::String(s) => str_len(s), + Value::Array(a) => { + stack.extend(a.iter()); + 2 + a.len().saturating_sub(1) as u64 + } + Value::Object(m) => { + stack.extend(m.values()); + 2 + m.len().saturating_sub(1) as u64 + m.keys().map(|k| str_len(k) + 1).sum::() + } + }; + } + total +} + +/// A string's length as [`write_str`] writes it, bytes. +fn str_len(s: &str) -> u64 { + 2 + s + .chars() + .map(|c| match c { + '"' | '\\' | '\u{8}' | '\t' | '\n' | '\u{c}' | '\r' => 2, + c if (c as u32) < 0x20 => 6, + c => c.len_utf8() as u64, + }) + .sum::() +} + /// A string as RFC 8785 writes it: `"` and `\\` escaped, the five short /// control escapes, every other control character as `\u00xx`, and the rest /// as it is. diff --git a/wai/wai-quantum/src/engine/refusal.rs b/wai/wai-quantum/src/engine/refusal.rs index 6331965ed..4bc15ad94 100644 --- a/wai/wai-quantum/src/engine/refusal.rs +++ b/wai/wai-quantum/src/engine/refusal.rs @@ -90,14 +90,14 @@ table! { FABRIC = "J013", Refused, "A placement on the fabric (`fabric`) this host cannot honour: no member by that name, no certified accelerator member here, a state or a partition larger than a member holds, a partition count that is not a power of two, or a method or program the fabric's members do not run (the message says which, and what is here)."; // Intents and repairs. - BAD_INTENT = "J020", Refused, "An intent that does not read: not one of `distribution`, `equivalent` or `prepares`, a distribution whose outcomes are not the program's bits or whose probabilities do not sum to 1, or a reference program that does not read."; + BAD_INTENT = "J020", Refused, "An intent that does not read: not one of `distribution`, `equivalent` or `prepares`, a distribution whose outcomes are not the program's bits or whose probabilities do not sum to 1, or a reference program that does not read, or more intents than the host takes."; REPAIR_REJECTED = "J021", Refused, "A proposed repair that changes a line no diagnostic flags, or adds lines away from one: rejected, and the program left as it was."; BAD_REPAIR = "J022", Refused, "A repair's choices that do not read (`max_iterations`, `dyadic_bits`, `sizes`), or a proposal that is not a program's text."; // The host. RATE = "J030", Retry, "The host takes no more from this client for now (submissions in a window, or jobs at once): send it again shortly."; - TOO_LARGE_HERE = "J031", Refused, "A job or a check larger than this host takes: run it on your own machine, with the command line or its MCP server."; - BUSY = "J032", Retry, "The host is full (its queue, or the checks running at once): send it again later."; + TOO_LARGE_HERE = "J031", Refused, "A job or a check larger than this host takes, its reading included: run it on your own machine, with the command line or its MCP server."; + BUSY = "J032", Retry, "The host is full (its queue, the checks running at once, or the requests being read, yours or all of them): send it again later."; KEY_REQUIRED = "J033", Refused, "A host that takes requests only with one of its API keys: give yours as `Authorization: Bearer `; a key it does not hold is refused the same way."; IDEMPOTENCY_KEY = "J034", Refused, "An idempotency key (`Idempotency-Key`, or a tool's `idempotency_key`) that does not read, or that this client sent before with another request: a key names one job, so another request needs another key. A key is 1 to 255 visible ASCII characters, and should be unguessable (a UUID, or 128 random bits)."; diff --git a/wai/wai-quantum/src/engine/registry/builtin.rs b/wai/wai-quantum/src/engine/registry/builtin.rs index e960c3a84..066a6aa68 100644 --- a/wai/wai-quantum/src/engine/registry/builtin.rs +++ b/wai/wai-quantum/src/engine/registry/builtin.rs @@ -147,6 +147,9 @@ impl Method for PulseMethod { fn plan(&self, job: &Job<'_>) -> Result { crate::engine::pulse::plan(job.json(), job.job_bytes()) } + fn plan_bounded(&self, job: &Job<'_>) -> Result<(Plan, Option), String> { + crate::engine::pulse::plan_bounded(job.json(), job.job_bytes()).map(|(p, b)| (p, Some(b))) + } fn run(&self, job: &Job<'_>, plan: &Plan, out: &mut Value) -> Result<(), String> { *out = crate::engine::pulse::run(job.json(), plan)?; Ok(()) diff --git a/wai/wai-quantum/src/engine/registry/mod.rs b/wai/wai-quantum/src/engine/registry/mod.rs index 9a83c3468..1b9398d55 100644 --- a/wai/wai-quantum/src/engine/registry/mod.rs +++ b/wai/wai-quantum/src/engine/registry/mod.rs @@ -259,6 +259,14 @@ pub trait Method: Send + Sync { } /// The job admitted, with the memory and work it will take, or why not. fn plan(&self, job: &Job<'_>) -> Result; + /// [`Method::plan`], with the most its result takes as JSON text, bytes, + /// known before it runs, for a method that reads its own program + /// ([`Method::reads_program`]): the engine bounds every other method's + /// result itself. `None`, the default, where it does not say; a host + /// then holds the result to its cap only as it ends. + fn plan_bounded(&self, job: &Job<'_>) -> Result<(Plan, Option), String> { + Ok((self.plan(job)?, None)) + } /// The plan carried out: the method writes its answers into `out`, which /// holds the method, the qubits and the gates (and a compile's or a /// repair's account) already. It must write `accuracy`, with its diff --git a/wai/wai-quantum/src/engine/run.rs b/wai/wai-quantum/src/engine/run.rs index 6415272bc..e6d5011b7 100644 --- a/wai/wai-quantum/src/engine/run.rs +++ b/wai/wai-quantum/src/engine/run.rs @@ -14,6 +14,28 @@ fn hex(b: &[u8]) -> String { /// The most characters of bit strings a counts map lists. const MAX_COUNTS_CHARS: usize = 16 << 20; +/// The most a counts map ([`counts`]) of `shots` samples of `width`-bit +/// outcomes, at most `distinct` of them possible, takes as JSON text, bytes: +/// as many outcomes as it may list (no more than the shots, `distinct`, +/// [`MAX_OUTCOMES`] and what [`MAX_COUNTS_CHARS`] of bit strings hold), each +/// its bit string, two quotes, a colon and a comma, and the map's own +/// fields; with `value`, that many bytes for each outcome's value, and +/// without, its count's digits. `k` counts that sum to `shots` have at most +/// `k·(log10(shots/k) + 1)` digits in all, the most where they are equal. +pub(crate) fn counts_bound_among(width: u32, shots: u64, distinct: u64, value: Option) -> u64 { + if shots == 0 || distinct == 0 { + return 0; + } + let w = u64::from(width).max(1); + let keep = (MAX_OUTCOMES as u64).min((MAX_COUNTS_CHARS as u64 / w).max(1)); + let k = shots.min(distinct).min(keep); + let values = match value { + Some(v) => k * v, + None => ((k as f64) * ((shots as f64 / k as f64).log10() + 1.0)).ceil() as u64 + 16, + }; + k * (u64::from(width) + 4) + values + 128 +} + /// A counts map: the most frequent outcomes (at most [`MAX_OUTCOMES`], and /// at most [`MAX_COUNTS_CHARS`] characters of bit strings), and how many /// shots the omitted ones held. @@ -1068,6 +1090,65 @@ mod tests { run(&job, &p).unwrap() } + /// No result is larger than the bound its plan gave before it ran + /// (`catalog::plan_bounded`), as RFC 8785 writes it with its provenance: + /// the worked examples with their verdicts, and jobs that ask for each + /// part a result grows with (counts at the most a map lists, wide bit + /// strings, measurements in the middle, observables, amplitudes, a + /// critique's remarks on every gate, a repair, a compile, noise and a + /// mitigation, a device target). Where counts make the result, the bound + /// is within a quarter of it. + #[test] + #[cfg(all(feature = "quantum_qasm", feature = "quantum_noise", feature = "quantum_receipt"))] + fn no_result_passes_the_bound_its_plan_gave() { + let wide = format!("OPENQASM 3.0;\nqubit[16] q;\nbit[16] c;\nh q;\nc = measure q;\n"); + let observables: Vec = (0..256).map(|i| format!("{}.5 Z{} X{} Y{}", i % 7, i % 16, (i + 1) % 16, (i + 2) % 16)).collect(); + let amplitudes: Vec = (0..64).map(|i| format!("{:010b}", i * 13)).collect(); + let mut jobs: Vec<(String, Value)> = crate::engine::guide::examples() + .iter() + .map(|e| { + let mut intent = e.intent.to_string(); + if let Some(r) = e.reference { + intent = intent.replace("\"reference.qasm\"", &json!(r).to_string()); + } + let mut j = json!({ "qasm": e.program, "verdict": true, "intent": serde_json::from_str::(&intent).unwrap() }); + j.as_object_mut().unwrap().extend(e.run.as_object().cloned().unwrap_or_default()); + (format!("example {}", e.name), j) + }) + .collect(); + jobs.extend([ + ("65,536 outcomes of 16 bits".to_string(), json!({ "qasm": wide, "shots": 1_000_000, "seed": 3 })), + ("10,000 outcomes of 16 bits".to_string(), json!({ "qasm": wide, "shots": 10_000, "seed": 3 })), + ("outcomes of 300 bits".to_string(), json!({ "qasm": "OPENQASM 3.0;\nqubit[12] q;\nbit[300] c;\nh q;\nfor uint i in [0:11] { c[i * 25] = measure q[i]; }\n", "shots": 20000 })), + ("measured in the middle".to_string(), json!({ "qasm": "OPENQASM 3.0;\nqubit[3] q;\nbit[6] c;\nh q;\nc[0] = measure q[0];\nc[1] = measure q[1];\nc[2] = measure q[2];\nreset q;\nh q;\nc[3] = measure q[0];\nc[4] = measure q[1];\nc[5] = measure q[2];\n", "shots": 5000 })), + ("256 observables".to_string(), json!({ "qasm": "OPENQASM 3.0;\nqubit[16] q;\nh q;\nrx(0.3) q[2];\ncx q[0], q[1];\n", "observables": observables })), + ("64 amplitudes".to_string(), json!({ "qasm": "OPENQASM 3.0;\nqubit[10] q;\nh q;\nrz(0.7) q[3];\n", "amplitudes": amplitudes })), + ("a remark on every gate".to_string(), json!({ "qasm": "OPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nfor int i in [1:4000] { x q[0]; h q[1]; h q[1]; }\nc = measure q;\n", "shots": 100, "verdict": true })), + ("repaired".to_string(), json!({ "qasm": "OPENQASM 3.0;\nqubit q;\nbit c;\nh q;\nh q;\nx q;\nc = measure q;\n", "shots": 100, "repair": true, "verdict": true })), + ("compiled".to_string(), json!({ "qasm": "OPENQASM 3.0;\nqubit[2] q;\nbit[2] c;\nrz(0.1234) q[0];\nrx(0.77) q[1];\ncx q[0], q[1];\nc = measure q;\n", "shots": 500, "compile": { "epsilon": 1e-3, "level": 2 } })), + ("noisy".to_string(), json!({ "qasm": "OPENQASM 3.0;\nqubit[8] q;\nbit[8] c;\nh q;\ncx q[0], q[1];\nc = measure q;\n", "shots": 30000, "noise": { "depolarizing_1q": 0.01, "readout": 0.02 } })), + ("mitigated".to_string(), json!({ "qasm": "OPENQASM 3.0;\nqubit[3] q;\nh q[0];\ncx q[0], q[1];\ncx q[1], q[2];\n", "observables": ["Z0 Z1", "Z1 Z2"], "noise": { "depolarizing_2q": 0.02, "depolarizing_1q": 0.004 }, "mitigate": { "method": "zne", "shots": 4000 } })), + ("on a device target".to_string(), json!({ "qasm": "OPENQASM 3.0; qubit[3] q; bit[3] c; h q[0]; cx q[0], q[1]; cx q[1], q[2]; c = measure q;", "target": "synthetic-ring-8", "shots": 3000, "seed": 2 })), + ]); + let mut checked = 0; + for (name, job) in &jobs { + let (p, bound) = match crate::engine::catalog::plan_bounded(job, CAP) { + Ok((p, Some(b))) => (p, b), + Ok((_, None)) => panic!("{name}: no bound"), + Err(e) => panic!("{name}: {e}"), + }; + let result = run_with_provenance(job, &p, "a host 1.0").unwrap_or_else(|e| panic!("{name}: {e}")); + let served = crate::engine::receipt::canonical(&result).len() as u64; + eprintln!("{name}: {served} bytes, bound {bound}"); + assert!(served <= bound, "{name}: its result is {served} bytes, past its bound of {bound}"); + if name.contains("outcomes of 16 bits") { + assert!(bound <= served + served / 4 + (64 << 10), "{name}: {served} bytes, bound {bound}"); + } + checked += 1; + } + assert_eq!(checked, jobs.len()); + } + #[test] #[cfg(feature = "quantum_toolchain")] fn grover_as_the_ide_exports_it_finds_the_marked_item() { diff --git a/wai/wai-quantum/src/engine/sets.rs b/wai/wai-quantum/src/engine/sets.rs index 34bb76140..739514304 100644 --- a/wai/wai-quantum/src/engine/sets.rs +++ b/wai/wai-quantum/src/engine/sets.rs @@ -213,6 +213,12 @@ fn sweep_of(request: &Value, sweep: &Value) -> Result, String> { /// Each item planned within `job_bytes`, or the first that does not plan, /// with its index. pub fn plan(items: &[Item], job_bytes: u64) -> Result, String> { + plan_bounded(items, job_bytes).map(|planned| planned.into_iter().map(|(p, _)| p).collect()) +} + +/// [`plan`], each item's plan with the most its result may take, by part +/// ([`catalog::plan_bound`]). +pub fn plan_bounded(items: &[Item], job_bytes: u64) -> Result)>, String> { items .iter() .enumerate() @@ -220,7 +226,7 @@ pub fn plan(items: &[Item], job_bytes: u64) -> Result, String if let Some(at) = receipt::inexact_number(&it.request) { return Err(super::refusal::tag(super::refusal::LIMIT, format!("item {i}: `{at}`: a whole number here must be within ±9007199254740991 (2^53 − 1)"))); } - catalog::plan(&it.request, job_bytes).map_err(|e| format!("item {i}: {e}")) + catalog::plan_bound(&it.request, job_bytes).map_err(|e| format!("item {i}: {e}")) }) .collect() } @@ -410,8 +416,9 @@ pub fn verify(job: &Value) -> Value { // The seal alone; the rules on its numbers, by its form, are §8.6's (`job::check`). let signed = receipt::verify(request, &head, set_receipt); let items = result["items"].as_array().cloned().unwrap_or_default(); - let derived = match expand(request) { - Ok(want) => { + // Writing a sweep out reads its programs: for a seal that holds, and no other. + let derived = match signed.then(|| expand(request).ok()).flatten() { + Some(want) => { want.len() == items.len() && want.iter().zip(&items).enumerate().all(|(i, (w, got))| { got["index"].as_u64() == Some(i as u64) @@ -419,7 +426,7 @@ pub fn verify(job: &Value) -> Value { && w.point.as_ref().is_none_or(|p| receipt::canonical(p) == receipt::canonical(&got["point"])) }) } - Err(_) => false, + None => false, }; let count_ok = result["count"].as_u64() == Some(items.len() as u64); let each = items.iter().all(|it| verify_item(request, &head, set_receipt, it)); @@ -455,6 +462,15 @@ mod tests { SigningKey::from_bytes(&[5; 32]) } + /// Writing a sweep out reads it: a seal that does not hold is told so + /// without that, so forging one buys no reading. + #[test] + fn a_seal_that_does_not_hold_is_not_written_out() { + let forged = json!({ "request": { "qasm": "input int n;\nqubit q;\nfor int i in [1:n] { x q; }\n", "sweep": { "n": [1000] } }, "result": { "format": FORMAT, "count": 0, "items": [], "receipt": {} } }); + let (v, left) = crate::qasm::within_steps(1_000_000, || (verify(&forged), crate::qasm::steps_left())); + assert_eq!((v["signed"].clone(), v["derived"].clone(), left), (json!(false), json!(false), Some(1_000_000))); + } + #[test] fn a_list_of_programs_is_one_job_each_the_set_s_fields_shared() { let set = json!({ "programs": [BELL, { "qasm": "OPENQASM 3.0; qubit q; rx(0.3) q;", "observables": ["Z0"], "shots": 0 }], "shots": 100, "seed": 7, "dry_run": false }); diff --git a/wai/wai-quantum/src/engine/stats.rs b/wai/wai-quantum/src/engine/stats.rs index 2f7549287..118868453 100644 --- a/wai/wai-quantum/src/engine/stats.rs +++ b/wai/wai-quantum/src/engine/stats.rs @@ -103,11 +103,19 @@ pub fn chi2_tail(x: f64, k: u64) -> f64 { gamma_q(k as f64 / 2.0, x / 2.0) } +/// The most outcomes of probability 0 an answer lists by name: past them it +/// gives how many there were ([`goodness_of_fit`]), so what it says stays +/// within a bound however many outcomes a run gave. +pub const MAX_IMPOSSIBLE_LISTED: usize = 64; + /// Whether `observed` counts are consistent with `expected` probabilities, /// by `test` (`chi_squared` or `g_test`) at significance `alpha`: /// `{"test", "statistic", "dof", "p_value", "alpha", "shots", "rejected", -/// "smallest_expected", "impossible", "note"}`. Rejected when the p-value is -/// below `alpha`, or when an outcome of probability 0 was sampled. +/// "smallest_expected", "impossible", "impossible_outcomes", "note"}`. +/// Rejected when the p-value is below `alpha`, or when an outcome of +/// probability 0 was sampled: `impossible` lists the first +/// [`MAX_IMPOSSIBLE_LISTED`] of those, in order, and `impossible_outcomes` +/// says how many there were. pub fn goodness_of_fit(test: &str, observed: &BTreeMap, expected: &BTreeMap, alpha: f64) -> Result { if !TESTS.contains(&test) { return Err(format!("{test}: the tests are {}", TESTS.join(" and "))); @@ -145,10 +153,15 @@ pub fn goodness_of_fit(test: &str, observed: &BTreeMap, expected: & String::new() }; let name = if test == "chi_squared" { "Pearson's chi-squared test" } else { "the G-test" }; - let note = if impossible.is_empty() { + let all = impossible.len(); + let mut impossible = impossible; + impossible.truncate(MAX_IMPOSSIBLE_LISTED); + let note = if all == 0 { format!("{name} of {shots} samples against the stated distribution, {dof} degrees of freedom{law}") } else { - format!("{} sampled, which the stated distribution gives probability 0", impossible.iter().map(|s| s.as_str()).collect::>().join(", ")) + let listed = impossible.iter().map(|s| s.as_str()).collect::>().join(", "); + let more = if all > impossible.len() { format!(" and {} more", all - impossible.len()) } else { String::new() }; + format!("{listed}{more} sampled, which the stated distribution gives probability 0") }; Ok(json!({ "test": test, @@ -160,6 +173,7 @@ pub fn goodness_of_fit(test: &str, observed: &BTreeMap, expected: & "rejected": rejected, "smallest_expected": if smallest.is_finite() { json!(smallest) } else { Value::Null }, "impossible": impossible, + "impossible_outcomes": all, "note": note, })) } diff --git a/wai/wai-quantum/src/engine/verdict.rs b/wai/wai-quantum/src/engine/verdict.rs index 0e9f4d626..5bc66e0c1 100644 --- a/wai/wai-quantum/src/engine/verdict.rs +++ b/wai/wai-quantum/src/engine/verdict.rs @@ -387,8 +387,10 @@ pub(crate) fn check_intent_for_in(reg: &super::registry::Registry, intent: &Valu let note = g["note"].as_str().unwrap_or("").to_string(); let on = format!("{shots} samples (seed {seed}, on {})", plan.method); let (outcome, strength, why) = if g["rejected"] == true { - let reason = if g["impossible"].as_array().is_some_and(|a| !a.is_empty()) { note.clone() } else { format!("p = {p:.3e} < α = {alpha}") }; - ("fails", "evidence", format!("{on} are not consistent with the intent's distribution: {reason}; {note}")) + // An outcome of probability 0 sampled is the reason, and the note says + // it; a p-value under α is the reason beside the note's test. + let why = if g["impossible_outcomes"].as_u64().is_some_and(|n| n > 0) { format!("{on} are not consistent with the intent's distribution: {note}") } else { format!("{on} are not consistent with the intent's distribution: p = {p:.3e} < α = {alpha}; {note}") }; + ("fails", "evidence", why) } else if proof_only { ("unchecked", "none", format!("{on} are consistent with the intent's distribution (p = {p:.4} ≥ α = {alpha}), but a statistical test is evidence and the intent requires a proof")) } else { @@ -722,6 +724,18 @@ pub fn of_refusal(request: &Value, error: &str) -> Value { v } +/// The verdict of a job a service admitted, given without reading it again +/// (a client's every poll would otherwise read it): admission read it, so it +/// reads; `error` is why it failed since, if it did. +pub fn of_admitted(error: Option<&str>) -> Value { + let mut v = base(json!({ "ok": true, "diagnostics": [] })); + if let Some(e) = error { + v["refusal"] = refusal::refusal_json(e); + } + conclude(&mut v); + v +} + /// The verdict of a pulse run ([`super::pulse`]): what the pulse reader /// said of the program (the result's diagnostics), and the run's method and /// accuracy. A pulse run checks no intent. diff --git a/wai/wai-quantum/src/engine/views.rs b/wai/wai-quantum/src/engine/views.rs index 56cc7e71a..8fbc650ef 100644 --- a/wai/wai-quantum/src/engine/views.rs +++ b/wai/wai-quantum/src/engine/views.rs @@ -217,13 +217,21 @@ fn program_of<'a>(tool: &str, args: &'a Value, answer: &'a Value) -> Option<&'a /// view, the tool, the program, its layout and description (or why it is not /// drawn), and for a target, its map. `None` for a tool with no view. pub fn data(tool: &str, args: &Value, answer: &Value) -> Option { + data_of(tool, args, answer, true) +} + +/// [`data`], without reading anything where `draw` is false: the view, the +/// tool and the program, for an answer that cannot pay to read it again (a +/// poll, which a view makes every few seconds). The view shows the answer, +/// and no drawing. +pub fn data_of(tool: &str, args: &Value, answer: &Value, draw: bool) -> Option { let view = for_tool(tool)?; let mut d = json!({ "view": view.name(), "tool": tool, "protocol": PROTOCOL }); match view { View::Result | View::Circuit | View::Critique => { let src = program_of(tool, args, answer); d["program"] = json!(src); - if let (Some(o), Some(x)) = (d.as_object_mut(), drawing(src).as_object()) { + if draw && let (Some(o), Some(x)) = (d.as_object_mut(), drawing(src).as_object()) { o.extend(x.clone()); } } diff --git a/wai/wai-quantum/src/qasm.rs b/wai/wai-quantum/src/qasm.rs index a489806f2..752120ee3 100644 --- a/wai/wai-quantum/src/qasm.rs +++ b/wai/wai-quantum/src/qasm.rs @@ -58,6 +58,7 @@ mod unroll; pub mod pulse; pub use diag::{Diagnostic, Outcome, Span}; +pub use elab::MAX_STEPS; #[cfg(feature = "quantum_qasm")] use crate::gates::{C, Gate as Matrix, complex}; @@ -69,14 +70,6 @@ use crate::quantum_tableau::Clifford; /// The most qubits a program declares. pub const MAX_QUBITS: u32 = 65_536; -/// The deepest a program nests ([`diag::NESTING`]): a statement at the top level is at -/// depth 1, and each statement, expression or type one deeper than the statement, -/// expression, type or bracket that holds it, operators grouped as they bind and each -/// call's body read in its place; past it the program is refused before it could use up -/// a reader's stack, so that every host, a small one among them, reads it the same way. -pub const MAX_NESTING: u32 = 128; -/// The most `else if` arms one `if` holds: each at the depth of its `if`. -pub const MAX_ELSE_IF: usize = 1024; /// The most gates a program expands to. pub const MAX_OPS: usize = 2_000_000; @@ -402,10 +395,98 @@ pub fn read(src: &str) -> Result { /// [`read`] for a generator, at `inputs`. pub fn read_with(src: &str, inputs: &Inputs) -> Result { - let toks = lex::lex(src)?; - let (stmts, version, depths) = parse::parse_depths(src, &toks)?; - let (program, lines, diagnostics) = elab::elaborate(src, &toks, &stmts, &depths, version, inputs)?; - Ok(Reading { program, lines, diagnostics }) + // Within a budget of reading, a program read before (with no inputs) is not read again. + if inputs.is_empty() + && let Some(seen) = READS.with_borrow(|r| r.as_ref().and_then(|r| r.read.get(src).cloned())) + { + return seen; + } + let reading = (|| { + let toks = lex::lex(src)?; + let (stmts, version) = parse::parse(src, &toks)?; + let (program, lines, diagnostics) = elab::elaborate(src, &toks, &stmts, version, inputs)?; + Ok(Reading { program, lines, diagnostics }) + })(); + if inputs.is_empty() { + READS.with_borrow_mut(|r| { + if let Some(r) = r { + r.read.insert(src.to_string(), reading.clone()); + } + }); + } + reading +} + +/// A budget of reading on one thread ([`within_steps`]): the steps left +/// (statements elaborated, gates written, a generator's loops written out), +/// and each program read under it. +struct Reads { + left: u64, + read: std::collections::HashMap>, +} + +thread_local! { + static READS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// One step of reading (a statement elaborated, a gate written, a pass of a +/// generator's loop written out), spent from this thread's budget of reading +/// when one is set: whether the budget was already spent. +pub(crate) fn spend_step() -> bool { + READS.with_borrow_mut(|r| match r { + Some(r) if r.left == 0 => true, + Some(r) => { + r.left -= 1; + false + } + None => false, + }) +} + +/// `n` steps of reading spent at once (values made or copied, counted by +/// their bytes), from this thread's budget when one is set: whether the +/// budget ran out. What is left is spent, and the budget then holds no more. +pub(crate) fn spend_steps(n: u64) -> bool { + READS.with_borrow_mut(|r| match r { + Some(r) if r.left < n => { + r.left = 0; + true + } + Some(r) => { + r.left -= n; + false + } + None => false, + }) +} + +/// Runs `f` with every program it reads on this thread read once (a program +/// read again with no inputs is answered from its first reading) and every +/// reading within one budget of `steps` together: each statement the reader +/// elaborates, each gate it writes, and each pass of a loop a generator is +/// written out with ([`instantiate`]) spending one; past it, a reading is +/// refused ([`diag::UNROLL_LIMIT`]), and [`steps_left`] is `Some(0)`. What a +/// service holds the programs of one request to, so its reading is bounded +/// however many programs the request carries and however often it reads +/// each. A budget set inside another runs within the outer one. +pub fn within_steps(steps: u64, f: impl FnOnce() -> T) -> T { + struct Scope(bool); + impl Drop for Scope { + fn drop(&mut self) { + if self.0 { + READS.with_borrow_mut(|r| *r = None); + } + } + } + let _scope = Scope(READS.with_borrow_mut(|r| r.is_none().then(|| *r = Some(Reads { left: steps, read: Default::default() })).is_some())); + f() +} + +/// The steps left of this thread's budget of reading ([`within_steps`]), or +/// `None` outside one: `Some(0)` once a reading was refused for it, so a +/// caller tells that refusal from the program's own. +pub fn steps_left() -> Option { + READS.with_borrow(|r| r.as_ref().map(|r| r.left)) } /// [`read`], each gate whose standard form would double with its controls @@ -419,8 +500,8 @@ pub fn read_whole(src: &str) -> Result { /// [`read_whole`] for a generator, at `inputs`. pub fn read_whole_with(src: &str, inputs: &Inputs) -> Result { let toks = lex::lex(src)?; - let (stmts, version, depths) = parse::parse_depths(src, &toks)?; - let (program, lines, diagnostics) = elab::elaborate_whole(src, &toks, &stmts, &depths, version, inputs)?; + let (stmts, version) = parse::parse(src, &toks)?; + let (program, lines, diagnostics) = elab::elaborate_whole(src, &toks, &stmts, version, inputs)?; Ok(Reading { program, lines, diagnostics }) } @@ -461,9 +542,9 @@ pub fn bind(src: &str, values: &std::collections::BTreeMap) -> Resu /// [`diag::NOT_DIFFERENTIABLE`] (`Q129`), on its line. pub fn tangents(src: &str, names: &[String]) -> Result<(Reading, Vec>>), Diagnostic> { let toks = lex::lex(src)?; - let (stmts, version, depths) = parse::parse_depths(src, &toks)?; + let (stmts, version) = parse::parse(src, &toks)?; let none = Inputs::new(); - let (program, lines, diagnostics, t) = elab::elaborate_with(src, &toks, &stmts, &depths, version, &none, Some(elab::Tan::new(names)))?; + let (program, lines, diagnostics, t) = elab::elaborate_with(src, &toks, &stmts, version, &none, Some(elab::Tan::new(names)))?; Ok((Reading { program, lines, diagnostics }, t)) } @@ -1871,6 +1952,9 @@ mod lang_tests; #[cfg(all(test, feature = "quantum_qasm"))] mod whole_tests; +#[cfg(all(test, feature = "quantum_qasm"))] +mod depth_tests; + #[cfg(all(test, feature = "quantum_qasm"))] mod tests { use super::*; diff --git a/wai/wai-quantum/src/qasm/ast.rs b/wai/wai-quantum/src/qasm/ast.rs index 56e5d78cb..9e2af2c41 100644 --- a/wai/wai-quantum/src/qasm/ast.rs +++ b/wai/wai-quantum/src/qasm/ast.rs @@ -54,6 +54,12 @@ pub enum E { Index(Box, Vec), Unary(&'static str, Box), Binary(&'static str, Box, Box), + /// A run of left-associative operators of one precedence, `a + b - c`: + /// its first operand and each operator with the operand after it, applied + /// left to right. Written flat, so that a run of any length holds no + /// recursion: reading it, evaluating it and dropping it are each a loop. + /// A single operator is a [`E::Binary`]. + Chain(Box, Vec<(&'static str, Expr)>), /// A built-in function, a subroutine or an `extern`. Call(Name, Vec), Cast(Type, Box), @@ -176,7 +182,10 @@ pub enum S { /// `x = e;`, `x += e;`, `c[0] = measure q[0];`. Assign { target: Expr, op: &'static str, value: Expr }, Expr(Expr), - If { cond: Expr, then: Vec, other: Option> }, + /// `if (c) … else if (d) … else …`: each condition with its branch, in + /// order, and the last `else`. An `else if` chain is flat, as a run of + /// operators is. + If { arms: Vec<(Expr, Vec)>, other: Option> }, For { ty: Option, var: Name, over: Expr, body: Vec }, While { cond: Expr, body: Vec }, Switch { on: Expr, cases: Vec, default: Option> }, diff --git a/wai/wai-quantum/src/qasm/depth_tests.rs b/wai/wai-quantum/src/qasm/depth_tests.rs new file mode 100644 index 000000000..61f8c531d --- /dev/null +++ b/wai/wai-quantum/src/qasm/depth_tests.rs @@ -0,0 +1,456 @@ +//! Nesting: every recursive construct the reader takes, nested thousands +//! deep, refused with `Q006`; nested to just inside the limits, read. Each +//! runs on a thread of 2 MiB, a runtime worker's stack (and the default for a +//! test's), in whatever profile the tests are built in. + +use super::*; +use crate::qasm::parse::MAX_NESTING; + +/// A runtime worker's stack, and a test thread's. +const STACK: usize = 2 << 20; + +/// `f` on a thread with a stack of `bytes`. A stack overflow aborts the whole +/// test binary, which is what these tests guard against. +fn on_stack(bytes: usize, f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new().stack_size(bytes).spawn(f).expect("a thread").join().expect("no panic") +} + +/// The code `src` is read with: `Ok` and its gates, or the refusal's code. +fn read_on_stack(src: String) -> Result { + on_stack(STACK, move || read(&src).map(|r| r.program.ops.len()).map_err(|d| d.code)) +} + +const HEAD: &str = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit q;\nbit c;\n"; + +/// A program whose one statement nests `n` deep in the construct `kind`. +fn nested(kind: &str, n: usize) -> String { + let r = |s: &str| s.repeat(n); + let body = match kind { + "paren" => format!("rz({}0.5{}) q;\n", r("("), r(")")), + "minus" => format!("rz({}0.5) q;\n", r("- ")), + "not" => format!("bool b = {}true;\nx q;\n", r("!")), + "tilde" => format!("const uint k = {}1;\nx q;\n", r("~")), + "sum" => format!("rz(0.5{}) q;\n", r(" + 0")), + "power" => format!("rz(1{}) q;\n", r(" ** 1")), + "call" => format!("rz({}0.5{}) q;\n", r("sin("), r(")")), + "cast" => format!("rz({}0.5{}) q;\n", r("float("), r(")")), + "set" => format!("for int i in {}1{} x q;\n", r("{"), r("}")), + "index" => format!("if (c{} == 1) x q;\n", r("[0]")), + "concat" => format!("let a = q{};\nx q;\n", r(" ++ q")), + "block" => format!("{}x q;{}\n", r("{ "), r(" }")), + "if" => format!("{}x q;\n", r("if (true) ")), + "if_block" => format!("{}x q;{}\n", r("if (true) { "), r(" }")), + "else_if" => format!("{}x q;\n", r("if (false) x q; else ")), + "for" => format!("{}x q;{}\n", r("for int i in [0:0] { "), r(" }")), + "while" => format!("int k = 0;\n{}x q;\n", r("while (k > 0) ")), + "switch" => format!("int k = 0;\n{}x q;{}\n", r("switch (k) { case 0 { "), r(" } }")), + "box" => format!("{}x q;{}\n", r("box { "), r(" }")), + "gate_body" => format!("gate g a {{ {}x a;{} }}\ng q;\n", r("{ "), r(" }")), + "def_body" => format!("def f(qubit a) {{ {}x a;{} }}\nf(q);\n", r("{ "), r(" }")), + "complex" => format!("input {}float{} z;\nx q;\n", r("complex["), r("]")), + "array" => format!("def f(readonly {}int{} a) {{ }}\nx q;\n", r("array["), r(", 1]")), + "durationof" => format!("rz({}0.5{}) q;\n", r("durationof({ "), r(" })")), + "measure" => format!("c = {}measure q{};\n", r("("), r(")")), + other => panic!("no construct {other}"), + }; + format!("{HEAD}{body}") +} + +/// Every construct that nests, each read by a recursion of the parser, the +/// elaborator, or both. +const NESTING: [&str; 23] = [ + "paren", "minus", "not", "tilde", "power", "call", "cast", "set", "index", "concat", "block", "if", "if_block", "for", "while", "switch", "box", "gate_body", "def_body", "complex", "array", "durationof", "measure", +]; + +/// Nested thousands deep, as a request of a few kilobytes can be, each is +/// refused, `Q006`, on a 2 MiB stack: an overflow would end the process. +#[test] +fn every_construct_nested_past_the_limit_is_refused_not_overflowed() { + for kind in NESTING { + for n in [MAX_NESTING + 1, 5_000] { + assert_eq!(read_on_stack(nested(kind, n)), Err(diag::NESTING), "{kind} nested {n} deep"); + } + } + // Chains whose tree is as deep as they are long: a power, an index or a + // `++` of a hundred thousand is refused too. + for kind in ["power", "index", "concat", "minus", "not"] { + assert_eq!(read_on_stack(nested(kind, 100_000)), Err(diag::NESTING), "{kind} 100000 long"); + } +} + +/// What people write nests a few levels; to just inside the limit, each +/// construct still reads, and the reader's answer is the one it gave before. +#[test] +fn every_construct_nested_inside_the_limit_reads() { + // The statement and the call's arguments take two levels; the rest is + // the construct's. + let inside = MAX_NESTING - 8; + for kind in NESTING { + // `durationof({ … })` nests a block and a statement in each level. + let n = if kind == "durationof" { inside / 3 } else { inside }; + let r = read_on_stack(nested(kind, n)); + match kind { + // Read, or refused for what they are (a set of sets, a bit's + // bit), never for their depth. + "set" | "index" | "concat" | "tilde" | "complex" | "array" | "durationof" | "measure" => assert!(r.is_ok() || r.is_err_and(|c| c != diag::NESTING), "{kind}: {r:?}"), + _ => assert!(r.is_ok(), "{kind} nested {n} deep: {r:?}"), + } + } + // The angle comes through every level: the statement, the call's + // arguments and the number take three levels, the parentheses the rest. + let p = on_stack(STACK, || parse(&nested("paren", MAX_NESTING - 3)).unwrap()); + assert_eq!(p.ops[0].params, vec![0.5]); + let p = on_stack(STACK, || parse(&nested("minus", MAX_NESTING - 3)).unwrap()); + assert_eq!(p.ops[0].params, vec![if (MAX_NESTING - 3) % 2 == 0 { 0.5 } else { -0.5 }]); +} + +/// A run of operators of one precedence, and an `else if` chain, are flat: +/// read, run and dropped in loops, so they read at any length, on a stack of +/// 1 MiB, and only what nests counts. Each reads to what the same text in +/// parentheses gives, left to right. +#[test] +fn a_run_of_operators_and_an_else_if_chain_read_at_any_length() { + const SMALL: usize = 1 << 20; + let reads = |src: String| on_stack(SMALL, move || read(&src).map(|r| r.program.ops.len()).map_err(|d| d.code)); + for n in [MAX_NESTING + 1, 100_000] { + let run = |op: &str, term: &str| format!("{term}{}", format!(" {op} {term}").repeat(n)); + for (what, src) in [ + ("sum", format!("{HEAD}rz({}) q;\n", run("+", "0.5"))), + ("product", format!("{HEAD}rz({}) q;\n", run("*", "1.0"))), + ("declared", format!("{HEAD}float x = {};\nx q;\n", run("-", "0.5"))), + ("or", format!("{HEAD}bool b = {};\nx q;\n", run("||", "false"))), + ("and", format!("{HEAD}bool b = {};\nx q;\n", run("&&", "true"))), + ("known bits", format!("{HEAD}if ({}) x q;\n", run("&&", "true"))), + ("measured bits", format!("{HEAD}c = measure q;\nif ({}) x q;\n", run("&&", "c"))), + ("measured bits or", format!("{HEAD}c = measure q;\nif ({}) x q;\n", run("||", "c"))), + ("else if known", format!("{HEAD}{}x q;\n", "if (false) y q; else ".repeat(n))), + ("else if measured", format!("{HEAD}c = measure q;\n{}x q;\n", "if (c == 1) y q; else ".repeat(n))), + ] { + assert!(reads(src).is_ok_and(|ops| ops >= 1), "{what} of {n}"); + } + } + // Left to right, in the double's own order: not rebalanced. + let terms = [0.1, 0.7, 1e-17, 0.3, 1e17, -1e17, 0.2]; + let flat: String = terms.iter().map(|t| format!("{t:e}")).collect::>().join(" + "); + let want = terms[1..].iter().fold(terms[0], |a, t| a + t); + let p = parse(&format!("{HEAD}rz({flat}) q;\n")).unwrap(); + assert_eq!(p.ops[0].params, vec![want]); + for (run, tree) in [("7 - 2 + 3 - 4", "((7 - 2) + 3) - 4"), ("2 * 7 / 4 % 3", "((2 * 7) / 4) % 3"), ("1 << 3 >> 1 << 2", "((1 << 3) >> 1) << 2"), ("1 < 2 == true != false", "((1 < 2) == true) != false")] { + let v = |e: &str| parse(&format!("{HEAD}const int k = {e};\nqubit[32] r;\nx r[k];\n")).map(|p| p.ops[0].qubits.clone()); + if run.contains('<') && run.contains("==") { + let v = |e: &str| parse(&format!("{HEAD}if ({e}) x q;\n")).map(|p| p.ops.len()); + assert_eq!(v(run), v(tree), "{run}"); + } else { + assert_eq!(v(run), v(tree), "{run}"); + } + } + // A derivative runs along a run too, as it does along the tree. + let names = ["a".to_string()]; + for (run, tree) in [("a + 2.0 - a * 3.0 + 1.5", "((a + 2.0) - (a * 3.0)) + 1.5"), ("a * 2.0 * a / 3.0", "((a * 2.0) * a) / 3.0")] { + let derivative = |e: &str| tangents(&format!("{HEAD}const float a = 0.7;\nrz({e}) q;\n"), &names).map(|(_, t)| t).map_err(|d| d.code); + assert_eq!(derivative(run), derivative(tree), "{run}"); + } + // An `else if` chain's steps are the ones the same branches nested write, + // and what its branches assign differently is no longer known after it. + let chain = "if (c == 1) x q; else if (c == 0) y q; else if (true) z q; else h q;"; + let nest = "if (c == 1) x q; else { if (c == 0) y q; else { if (true) z q; else h q; } }"; + let steps = |body: &str| read(&format!("{HEAD}c = measure q;\n{body}\n")).unwrap().program; + assert_eq!(steps(chain), steps(nest)); + let after = |a: u32, b: u32, c: u32| read_on_stack(format!("{HEAD}qubit[3] r;\nc = measure q;\nint k = 0;\nif (c == 1) k = {a}; else if (c == 0) k = {b}; else k = {c};\nx r[k];\n")); + assert_eq!(after(1, 1, 1), Ok(1)); + assert_eq!(after(1, 2, 1), Err(diag::NOT_KNOWN)); + assert_eq!(after(1, 1, 2), Err(diag::NOT_KNOWN)); +} + +/// A switch's case list on measured bits makes one condition of every value +/// listed: held flat, however many there are, on a stack of 1 MiB. +#[test] +fn a_switch_on_measured_bits_lists_any_number_of_values() { + let src = format!("OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nc = measure q;\nswitch (c) {{ case {}1 {{ x q[0]; }} }}\n", "1, ".repeat(600_000)); + let r = on_stack(1 << 20, move || read(&src).map(|r| r.program.ops.len()).map_err(|d| d.code)); + assert!(r.is_ok_and(|ops| ops >= 1), "{r:?}"); +} + +/// An array's dimensions cost the text no level, as they are a comma apart, +/// but each is a level of its value, which what copies, measures and writes +/// it recurses on: so they are held, where the type is read. +#[test] +fn an_array_past_its_dimensions_is_refused_where_its_type_is_read() { + let array = |n: usize| format!("{HEAD}array[int[8]{}] a;\nx q;\n", ", 1".repeat(n)); + assert_eq!(read_on_stack(array(crate::qasm::parse::MAX_ARRAY_DIMS)), Ok(1)); + for n in [crate::qasm::parse::MAX_ARRAY_DIMS + 1, 100_000] { + assert_eq!(read_on_stack(array(n)), Err(diag::NESTING), "{n} dimensions"); + } +} + +/// What a generator's loops cost is spent from the budget as they are written +/// out: a pass of a loop that writes nothing, and what a loop writes, by its +/// size, so that a sweep's points are held to the request's one budget. +#[test] +fn a_generator_s_passes_and_what_they_write_spend_the_budget() { + let (r, left) = within_steps(1_000, || (instantiate("input int n;\nqubit q;\nfor int i in [1:n] { }\nx q;\n", &[("n".to_string(), 999_999)].into_iter().collect()), steps_left())); + assert!(r.is_err_and(|e| e.contains("Q312"))); + assert_eq!(left, Some(0)); + let body = "x q;\n".repeat(1_000); + let fat = format!("input int n;\nqubit q;\nfor int i in [1:n] {{\n{body}}}\n"); + let (r, left) = within_steps(1_000, || (instantiate(&fat, &[("n".to_string(), 3_000)].into_iter().collect()), steps_left())); + assert!(r.is_err_and(|e| e.contains("Q312"))); + assert_eq!(left, Some(0)); +} + +/// A generator's integer `const`s fold in the writer, and both readers of a +/// generator agree on a flat sum, and an `else if` chain, of any length. +#[test] +fn both_readers_of_a_generator_take_a_flat_sum() { + let src = format!("input int nn;\nqubit[1] q;\nconst int s = nn{};\nfor int i in [0:s % 3] {{ x q[0]; }}\n", " + nn".repeat(999)); + let inputs: Inputs = [("nn".to_string(), 2)].into_iter().collect(); + let direct = read_with(&src, &inputs).unwrap(); + let written = read(&instantiate(&src, &inputs).unwrap()).unwrap(); + assert_eq!(direct.program.ops.len(), written.program.ops.len()); + // An `else if` chain as well, decided on writing or left to the reader. + for arm in ["if (nn == 0) x q[0]; else ", "if (true) x q[0]; else "] { + let src = format!("input int nn;\nqubit[1] q;\n{}x q[0];\n", arm.repeat(5_000)); + let (a, b) = on_stack(1 << 20, move || { + let inputs: Inputs = [("nn".to_string(), 2)].into_iter().collect(); + (read_with(&src, &inputs).map(|r| r.program.ops.len()), instantiate(&src, &inputs).and_then(|w| read(&w).map(|r| r.program.ops.len()).map_err(|d| d.to_string()))) + }); + assert_eq!(a.map_err(|d| d.to_string()), b, "{arm}"); + } +} + +/// A modifier chain is a list, and an `include` a statement of its own: +/// neither nests, so either reads at any length (the gates the chain makes +/// are bounded by the reader's gate limit). +#[test] +fn modifier_chains_and_includes_read_at_any_length() { + let inv = format!("{HEAD}{}x q;\n", "inv @ ".repeat(100_001)); + assert_eq!(read_on_stack(inv), Ok(1)); + let includes = format!("{HEAD}{}x q;\n", "include \"other.inc\";\n".repeat(2_000)); + let r = on_stack(STACK, move || read(&includes).map(|r| (r.program.ops.len(), r.diagnostics.iter().all(|d| d.code == diag::UNKNOWN_INCLUDE)))); + assert_eq!(r, Ok((1, true))); +} + +/// Calls stack their bodies: within one body the text's limit holds, and +/// past what calls stack up to, in all, the elaborator refuses (`Q006`) +/// before the stack runs out. Each body here nests within the text's limit, +/// and thirty of them, each called from the one before, nest past it in all. +#[test] +fn calls_that_stack_past_the_limit_in_all_are_refused() { + let chain = |levels: usize, calls: usize| { + let mut s = format!("{HEAD}def f0(float t, qubit a) {{ rz(t) a; }}\n"); + for i in 1..=calls { + s += &format!("def f{i}(float t, qubit a) {{ {}f{}(t, a); }}\n", "if (true) ".repeat(levels), i - 1); + } + s + &format!("f{calls}(0.5, q);\n") + }; + assert_eq!(read_on_stack(chain(60, 31)), Err(diag::NESTING)); + // Shallower bodies read, through as many calls. + assert_eq!(read_on_stack(chain(4, 31)), Ok(1)); + // Calls deeper than the call limit keep their own code. + assert_eq!(read_on_stack(chain(0, 40)), Err(diag::TOO_DEEP)); +} + +/// A generator written out (`instantiate`) folds its integers and unrolls +/// its loops by a reader of its own: nested past the limit, it refuses too. +#[test] +fn a_generator_nested_past_the_limit_is_refused_when_written_out() { + let inst = |src: String| on_stack(STACK, move || instantiate(&src, &Inputs::new()).map(|_| ()).map_err(|e| e[e.len() - 5..e.len() - 1].to_string())); + for (what, src) in [ + ("parentheses", format!("{HEAD}const int a = {}1{};\nx q;\n", "(".repeat(5_000), ")".repeat(5_000))), + ("minus", format!("{HEAD}const int a = {}1;\nx q;\n", "- ".repeat(10_000))), + ("power", format!("{HEAD}const int a = 1{};\nx q;\n", " ** 1".repeat(10_000))), + ("loops", format!("{HEAD}{}x q;{}\n", "for int i in [0:0] { ".repeat(5_000), " }".repeat(5_000))), + ("conditions", format!("{HEAD}const int k = 1;\n{}x q;\n", "if (k == 1) ".repeat(5_000))), + ] { + assert_eq!(inst(src), Err("Q006".into()), "{what}"); + } + // Inside the limit, written out as before. + let ok = format!("{HEAD}const int a = {}1{};\nx q;\n", "(".repeat(40), ")".repeat(40)); + assert!(on_stack(STACK, move || instantiate(&ok, &Inputs::new())).is_ok()); +} + +/// A budget of reading ([`within_steps`]) holds every program read under it +/// together, statements, gates and a generator's loop passes alike; each +/// program read once however often it is read; and says when it ran out. +#[test] +fn a_budget_holds_every_reading_under_it_together_and_each_program_is_read_once() { + let heavy = |k: usize| format!("OPENQASM 3.0;\nqubit q;\nint a = 0;\nfor int i in [0:99999] {{ a += 1; }}\n// {k}\nx q;\n"); + // One heavy program reads within a budget its size; read again it costs nothing. + let (first, again, left) = within_steps(500_000, || { + let first = read(&heavy(0)).is_ok(); + let spent = steps_left(); + let again = read(&heavy(0)).is_ok(); + (first, again, (spent, steps_left())) + }); + assert!(first && again); + assert!(left.0 == left.1 && left.0.is_some_and(|l| l < 400_000), "{left:?}"); + // Ten different ones do not: the budget refuses the reading that runs it out. + let (refused, left) = within_steps(500_000, || { + let r: Vec> = (0..10).map(|k| read(&heavy(k))).collect(); + (r.iter().position(Result::is_err), steps_left()) + }); + assert!(refused.is_some_and(|k| (1..10).contains(&k)), "{refused:?}"); + assert_eq!(left, Some(0)); + // Gates count: a power that writes a million gates is past a budget of a thousand. + let gates = "OPENQASM 3.0;\ngate g a { x a; h a; }\nqubit q;\npow(5000) @ g q;\n".to_string(); + let r = within_steps(1_000, || read(&gates).map(|_| ()).map_err(|d| d.code)); + assert_eq!(r, Err(diag::UNROLL_LIMIT)); + // Outside a budget, nothing is counted. + assert_eq!(steps_left(), None); + assert!(read(&heavy(0)).is_ok()); +} + +/// A small property test, with a seeded generator rather than a crate: any +/// mix of nesting constructs, at any depth, reads or is refused, and never +/// runs a 2 MiB stack out; inside the limit it reads, past it the refusal is +/// `Q006`. What the limit holds is the parser's recursion and each tree's +/// height together: a construct that nests by recursion (parentheses), one +/// that nests by height (a prefix `-`), or both (a call, a cast, a product +/// in parentheses). Either alone is at least the nesting; their sum, at most. +#[test] +fn any_mix_of_nesting_reads_or_is_refused_never_overflows() { + on_stack(STACK, || { + let mut x: u64 = 0x9e37_79b9_7f4a_7c15; + let mut next = move |n: u64| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x % n + }; + for _ in 0..300 { + let depth = next(400) as usize; + let statements = next(depth as u64 + 1) as usize; + let (mut open, mut close) = (String::new(), String::new()); + for _ in 0..statements { + match next(3) { + 0 => { + open += "{ "; + close = format!(" }}{close}"); + } + // With its own braces: `if (true) { … }` is one level, + // its body the braces' statements. + 1 => { + open += "if (true) { "; + close = format!(" }}{close}"); + } + _ => { + open += "for int i in [0:0] { "; + close = format!(" }}{close}"); + } + } + } + let (mut pre, mut post, mut recursion, mut height) = (String::new(), String::new(), 0, 0); + for _ in statements..depth { + let (p, q, r, h) = [("(", ")", 1, 0), ("- ", "", 0, 1), ("sin(", ")", 1, 1), ("float(", ")", 1, 1), ("(", " * 1)", 1, 1)][next(5) as usize]; + pre += p; + post = format!("{q}{post}"); + recursion += r; + height += h; + } + let src = format!("{HEAD}{open}rz({pre}0.5{post}) q;{close}\n"); + let r = read(&src).map(|_| ()).map_err(|d| d.code); + // The statements, the call's arguments and the number take their + // levels; a product's right operand, one more at most. + let at_least = statements + 2 + recursion.max(height) + 1; + let at_most = statements + 2 + recursion + height + 2; + if at_most <= MAX_NESTING { + assert_eq!(r, Ok(()), "{depth} deep:\n{src}"); + } else if at_least > MAX_NESTING { + assert_eq!(r, Err(diag::NESTING), "{depth} deep:\n{src}"); + } + } + }); +} + +/// The table's meaning for `Q006` states the limits the reader holds. +#[test] +fn the_code_states_the_limits() { + let m = diag::lookup(diag::NESTING).expect("Q006 is in the table").meaning; + assert!(m.contains(&format!("more than {MAX_NESTING} levels")), "{m}"); + assert!(m.contains(&format!("more than {} levels in all", super::elab::MAX_NEST)), "{m}"); +} + +/// Memory: a value as large as its declaration asks for, a register's steps +/// grown in a loop, a bit string wider than an integer: each refused, on a +/// 2 MiB thread, before what it would hold is made. A declaration of a +/// hundred bytes asked for 137 GB here, which no refusal stopped: an +/// allocation that fails ends the process. +#[test] +fn what_a_program_would_hold_past_the_limit_is_refused_before_it_is_made() { + for (what, body) in [ + ("an array of 2^32 − 1 integers", "array[int[8], 4294967295] a;\n"), + ("an array of 10^10 booleans", "array[bool, 100000, 100000] a;\n"), + ("an array of wide bit strings", "array[bit[1048576], 1000] a;\n"), + ("a bit register of 4·10^9 bits", "bit[4000000000] c;\n"), + ("resets of a wide register, repeated", "qubit[60000] r;\nfor int i in [0:999999] reset r;\n"), + ("measurements of a wide register, repeated", "qubit[60000] r;\nbit[60000] m;\nfor int i in [0:999999] m = measure r;\n"), + ("barriers on a wide register, repeated", "qubit[60000] r;\nfor int i in [0:999999] barrier r;\n"), + ] { + let t = std::time::Instant::now(); + assert_eq!(read_on_stack(format!("{HEAD}{body}x q;\n")), Err(diag::TOO_LARGE), "{what}"); + assert!(t.elapsed() < std::time::Duration::from_secs(60), "{what}: {:?}", t.elapsed()); + eprintln!("{what}: refused in {:?}", t.elapsed()); + } + // Within the limits, as programs hold them, each reads. + for body in ["array[int[8], 1000] a;\n", "array[float, 64, 64] m;\n", "bit[1048576] wide;\n", "qubit[64] r;\nfor int i in [0:99] reset r;\n"] { + assert!(read_on_stack(format!("{HEAD}{body}x q;\n")).is_ok(), "{body}"); + } + // Reading an element of a large array copies the element, not the array; + // copying the whole array is counted as reading time. + let elements = format!("{HEAD}array[int[32], 100000] a;\nint s = 0;\nfor int i in [0:99999] {{ s += a[i]; }}\nx q;\n"); + assert!(read_on_stack(elements).is_ok()); + // Within a budget of a million steps, a loop that copies a 3.2 MB array + // runs out of it after some forty copies, not after a hundred thousand. + let copies = format!("{HEAD}array[int[32], 100000] a;\narray[int[32], 100000] b;\nfor int i in [0:99999] {{ b = a; }}\nx q;\n"); + let r = on_stack(STACK, move || within_steps(1_000_000, || read(&copies).map(|_| ()).map_err(|d| d.code))); + assert_eq!(r, Err(diag::UNROLL_LIMIT)); +} + +/// A statement on a register of tens of thousands of qubits costs its +/// length: each qubit is checked once, where each was compared with every one +/// before it (some 1.8·10⁹ comparisons a statement, so a loop of resets ran +/// for minutes within a few hundred steps). +#[test] +fn a_statement_on_a_wide_register_costs_its_length() { + let t = std::time::Instant::now(); + for body in ["for int i in [0:49] reset r;\n", "for int i in [0:49] barrier r;\n", "bit[60000] m;\nfor int i in [0:49] m = measure r;\n", "for int i in [0:9] x r;\n"] { + assert!(read_on_stack(format!("{HEAD}qubit[60000] r;\n{body}x q;\n")).is_ok(), "{body}"); + } + assert!(t.elapsed() < std::time::Duration::from_secs(30), "{:?}", t.elapsed()); + assert_eq!(read_on_stack(format!("{HEAD}qubit[4] r;\nlet a = r[0:3] ++ r[1:2];\nreset a;\n")), Err(diag::QUBIT_TWICE)); +} + +/// A bit string wider than an integer holds, given to an integer, is refused +/// with its code; the reader once panicked on it. +#[test] +fn a_bit_string_wider_than_an_integer_is_refused_not_a_panic() { + for body in [format!("int[32] x = \"{}\";\n", "1".repeat(200)), "bit[200] b;\nint[32] x = b;\n".to_string(), format!("uint y = \"{}\";\n", "0".repeat(121))] { + assert_eq!(read_on_stack(format!("{HEAD}{body}x q;\n")), Err(diag::INTEGER_RANGE), "{body}"); + } + assert!(read_on_stack(format!("{HEAD}int[32] x = \"{}\";\nx q;\n", "1".repeat(20))).is_ok()); +} + +/// A loop over a range makes each value as it reaches it: a range of twenty +/// million that breaks at once holds no list of twenty million values (608 +/// MB, before its first pass). Measured in a process of its own, by its peak +/// resident memory, where the system says it (Linux). +#[cfg(target_os = "linux")] +#[test] +fn a_range_is_not_made_all_at_once() { + const CHILD: &str = "WAI_QUANTUM_DEPTH_RANGE_CHILD"; + let peak = || std::fs::read_to_string("/proc/self/status").ok().and_then(|s| s.lines().find(|l| l.starts_with("VmHWM:")).and_then(|l| l.split_whitespace().nth(1)?.parse::().ok())).unwrap_or(0); + if std::env::var_os(CHILD).is_some() { + let src = format!("{HEAD}for int i in [0:19999999] {{ break; }}\nx q;\n"); + assert!(read(&src).is_ok()); + println!("peak-kib {}", peak()); + return; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "qasm::depth_tests::a_range_is_not_made_all_at_once", "--nocapture", "--test-threads", "1"]) + .env(CHILD, "1") + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + let kib: u64 = text.split("peak-kib ").nth(1).and_then(|v| v.split_whitespace().next()?.parse().ok()).unwrap_or_else(|| panic!("no peak: {text}")); + assert!(out.status.success() && kib < 300 << 10, "peak {kib} KiB:\n{text}"); +} diff --git a/wai/wai-quantum/src/qasm/diag.rs b/wai/wai-quantum/src/qasm/diag.rs index 79322e301..7d559fee7 100644 --- a/wai/wai-quantum/src/qasm/diag.rs +++ b/wai/wai-quantum/src/qasm/diag.rs @@ -163,6 +163,7 @@ table! { UNCLOSED = "Q003", Refused, "A string, a block comment, a bracket or a calibration block that is not closed."; EXPECTED = "Q004", Refused, "Something else was expected here: a `;`, a name, an expression or a statement (the message says which)."; MISPLACED = "Q005", Refused, "A statement in a place the language does not allow it: `else` with no `if`, `case` outside `switch`, a declaration inside a gate."; + NESTING = "Q006", Refused, "A program nested deeper than the reader takes: statements, parentheses, operators, brackets and types more than 64 levels deep in its text (a run of one precedence, `a + b + c`, and an `else if` chain each count one, however long), an array of more than 7 dimensions, or more than 256 levels in all through the gates and subroutines it calls; refused, never read by a recursion that could run out of stack."; // Versions, includes, pragmas and annotations. UNKNOWN_VERSION = "Q010", Warned, "An `OPENQASM` version other than 2.x or 3.x: the program is read as OpenQASM 3."; @@ -201,7 +202,7 @@ table! { ASSIGN_CONSTANT = "Q127", Refused, "An assignment to a constant, an input or a loop variable."; MEASURE_PLACE = "Q128", Refused, "A measurement where a value is needed that is not a bit (measurement gives bits, into a bit register)."; NOT_DIFFERENTIABLE = "Q129", Refused, "A parameter (`input float`, `input angle`) a gradient cannot follow to the gates: under a modifier, in a classical variable, a condition, an index, a size, a loop's range, a subroutine or `gphase`, or one whose type rounds it (`angle[n]`, `float[32]`); a gradient follows a parameter through arithmetic, the built-in functions and gate definitions into a gate's arguments."; - NESTING = "Q130", Refused, "A program nested deeper than 128, before a reader's stack could run out: a statement at the top level is at depth 1, and each statement in a block or a body, each expression a statement holds, each operand of an operator, what a bracket holds (of an expression or a type) and each index's indexed value, one deeper than what holds it; operators group as they bind (`a + b + c` is `(a + b) + c`), an `else if` is at the depth of its `if` (one `if` holds at most 1024 of them), and a call's body is read in its place, its statements one deeper than the call."; + TOO_LARGE = "Q130", Refused, "A program whose reading would hold more memory than the reader takes: a classical value (an array, a bit register) of more than 64 MiB, each element counted at its width, or more than 512 MiB in all of its values, gates, steps, measurements and timing; refused before it is made."; // Gates and modifiers. GATE_INLINED = "Q200", Lowered, "A `gate` the program defines, inlined at each call with its parameters bound."; @@ -231,7 +232,7 @@ table! { WHILE_UNROLLED = "Q309", Lowered, "`while` whose condition is known on reading: unrolled until it fails."; UNBOUNDED_WHILE = "Q310", Refused, "A `while` whose condition reads measured bits and that declares no bound: add `@wai.max_iterations N` before it."; WHILE_CUT = "Q311", Warned, "A `while` on measured bits run at most its declared bound (`@wai.max_iterations N`): a run that still meets its condition after N iterations stops looping there."; - UNROLL_LIMIT = "Q312", Refused, "A program that does not finish unrolling within the reader's bound (20 000 000 statements and loop iterations)."; + UNROLL_LIMIT = "Q312", Refused, "A program that does not finish unrolling within the reader's bound (20 000 000 statements and loop iterations), or that runs out a caller's budget of reading (`qasm::within_steps`: statements, gates and a generator's loop passes, shared by every program read under it)."; BAD_RANGE = "Q313", Refused, "A range with a zero step, or that does not read as `[start:end]` or `[start:step:end]`."; OUTSIDE_LOOP = "Q314", Refused, "`break` or `continue` outside a loop, or `return` outside a subroutine."; RUNTIME_RETURN = "Q315", Refused, "A `return` with a value under a condition on measured bits."; diff --git a/wai/wai-quantum/src/qasm/elab.rs b/wai/wai-quantum/src/qasm/elab.rs index 07e3dbce2..fd9c24182 100644 --- a/wai/wai-quantum/src/qasm/elab.rs +++ b/wai/wai-quantum/src/qasm/elab.rs @@ -18,7 +18,7 @@ use std::rc::Rc; use super::ast::*; use super::diag::{self, Diagnostic}; use super::lex::{Tok, Token, Verb}; -use super::parse::{Depths, Version}; +use super::parse::Version; use super::value::{self, Angle, Dur, Ty, Val}; use super::controls::{self, Sink}; use super::{ @@ -27,6 +27,27 @@ use super::{ /// How deep gate definitions and subroutine calls may nest. pub(crate) const MAX_DEPTH: usize = 32; +/// How deep the reader's own recursion may go in all: statements, +/// expressions, conditions, gate definitions and subroutine calls, each a +/// level. Within one body the text's nesting ([`super::parse::MAX_NESTING`]) +/// bounds it; this bounds what calls stack on top of each other, so that +/// reading never runs out of stack. +pub(crate) const MAX_NEST: usize = 256; +/// The most memory one program's reading may hold, in bytes: its classical +/// values as each is made, and its gates, steps, measurements and timing as +/// they grow, counted together and never given back. A reading is refused +/// past it ([`diag::TOO_LARGE`]) before it allocates more, so what a program +/// asks for on reading is bounded by this, not by the host. +pub(crate) const MAX_HELD: u64 = 512 << 20; +/// The most one classical value (an array, a bit string) may take, in bytes. +pub(crate) const MAX_VALUE: u64 = 64 << 20; +/// Making or copying this many bytes counts as one step of reading, toward +/// [`MAX_STEPS`] and a budget of reading (`qasm::within_steps`): a program +/// that copies a large array in a loop costs what its copying costs. +const BYTES_A_STEP: u64 = 128; +/// What a value and an entry of the program's lists take, at least, in bytes. +const VAL_BYTES: u64 = 32; +const ENTRY_BYTES: u64 = 48; /// The most controls modifiers may add where a gate is written in standard /// gates: a phase on m qubits is written as 2^m − 1 `p` and 2^m − 2 `cx`. pub(crate) const MAX_CONTROLS: u32 = 16; @@ -36,7 +57,7 @@ pub(crate) const MAX_WHOLE_CONTROLS: u32 = 255; /// What a refusal past the standard form tells a job it can do. pub(crate) const HELD_WHOLE: &str = "a job on the state vector holds a gate under as many as 255 as one, and on the bit-exact method with \"options\": {\"controls\": \"whole\"}"; /// The most statements and loop iterations a program may run on reading. -pub(crate) const MAX_STEPS: u64 = 20_000_000; +pub const MAX_STEPS: u64 = 20_000_000; /// The values of a `for` loop, one at a time (`Elab::loop_values`). A range's /// are made as they are reached, never collected: a loop may run up to @@ -173,16 +194,12 @@ struct GateDef { params: Vec, qubits: Vec, body: Vec, - /// The deepest depth its body reaches as written ([`Depths`]). - deepest: u32, } struct DefFn { args: Vec, ret: Option, body: Vec, - /// The deepest depth its body reaches as written ([`Depths`]). - deepest: u32, } /// An expression's value as the reader holds it. @@ -201,8 +218,25 @@ enum Ct { Const(bool), Atom(Cond), Not(Box), - And(Box, Box), - Or(Box, Box), + /// Every one holds, tried in order. A run is held flat, so a condition or + /// a case list of any length is walked by a loop. + All(Vec), + /// One of them holds, tried in order. + Any(Vec), +} + +/// An `if` chain's branches on measured bits, as they are read. +struct Branching { + /// Conditions on measured bits read so far. + runtime: usize, + /// The scopes the first of them was reached with. + start: Option>, + /// The scopes of the branches that go on, joined. + joined: Option>, + /// How the rest ended, the least of them. + flow: Option, + /// Jumps from a branch's end past the rest. + ends: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -223,20 +257,6 @@ struct Loop { conts: Vec>, } -/// An arm of an `if` whose condition is known only at run time, waiting for its `else`. -struct Arm { - /// The scopes before the arm. - before: Vec, - /// The scopes each way out of the arm so far ends with. - outs: Vec>, - /// The jump to the arm's end, after its body. - end: Vec, - /// How its body ends. - ft: Flow, - /// Whether it has an `else`. - other: bool, -} - struct Frame { base: usize, runtime: usize, @@ -253,11 +273,6 @@ struct Frame { pub(crate) struct Elab<'a> { src: &'a str, - /// How deep the program's calls and definitions stand as written. - depths: &'a Depths, - /// The depth the body being read starts from: 0 at the top, and for a call's body read - /// in its place, one less than the call's own depth. - nest: u32, version: Version, inputs: &'a Inputs, used: BTreeSet, @@ -285,6 +300,12 @@ pub(crate) struct Elab<'a> { diags: Vec, count: u64, depth: usize, + /// How deep the reader's recursion stands ([`MAX_NEST`]). + nest: usize, + /// The bytes the reading holds ([`MAX_HELD`]), and how far into the + /// program's lists (its ops, steps, measurements, timing) they count. + held: u64, + seen: (usize, usize, usize, usize), runtime: usize, loops: Vec, frames: Vec, @@ -301,19 +322,19 @@ pub(crate) struct Elab<'a> { /// Whether a gate whose standard form would double stays one op. whole: bool, /// The qubits of the gates held whole so far. - held: usize, + held_whole: usize, } /// The program `stmts` (read from `src`, its tokens `toks`) run into the /// engine's program, the line of each op, and the warnings and notes. -pub(crate) fn elaborate(src: &str, toks: &[Token], stmts: &[Stmt], depths: &Depths, version: Version, inputs: &Inputs) -> R<(Program, Vec, Vec)> { - elaborate_with(src, toks, stmts, depths, version, inputs, None).map(|(p, l, d, _)| (p, l, d)) +pub(crate) fn elaborate(src: &str, toks: &[Token], stmts: &[Stmt], version: Version, inputs: &Inputs) -> R<(Program, Vec, Vec)> { + elaborate_with(src, toks, stmts, version, inputs, None).map(|(p, l, d, _)| (p, l, d)) } /// [`elaborate`], a gate whose standard form would double staying one op /// ([`super::controls`]). -pub(crate) fn elaborate_whole(src: &str, toks: &[Token], stmts: &[Stmt], depths: &Depths, version: Version, inputs: &Inputs) -> R<(Program, Vec, Vec)> { - elaborate_form(src, toks, stmts, depths, version, inputs, None, true).map(|(p, l, d, _)| (p, l, d)) +pub(crate) fn elaborate_whole(src: &str, toks: &[Token], stmts: &[Stmt], version: Version, inputs: &Inputs) -> R<(Program, Vec, Vec)> { + elaborate_form(src, toks, stmts, version, inputs, None, true).map(|(p, l, d, _)| (p, l, d)) } /// One op's parameters' derivatives by each of the program's parameters. @@ -322,16 +343,14 @@ pub(crate) type OpTangents = Vec>; /// [`elaborate`] with how every op's parameters depend on the parameters /// `tan` names ([`Tan`]): for each op, for each of its parameters, its /// derivative by each name. -pub(crate) fn elaborate_with(src: &str, toks: &[Token], stmts: &[Stmt], depths: &Depths, version: Version, inputs: &Inputs, tan: Option) -> R<(Program, Vec, Vec, Vec)> { - elaborate_form(src, toks, stmts, depths, version, inputs, tan, false) +pub(crate) fn elaborate_with(src: &str, toks: &[Token], stmts: &[Stmt], version: Version, inputs: &Inputs, tan: Option) -> R<(Program, Vec, Vec, Vec)> { + elaborate_form(src, toks, stmts, version, inputs, tan, false) } #[allow(clippy::too_many_arguments, clippy::type_complexity)] -fn elaborate_form(src: &str, toks: &[Token], stmts: &[Stmt], depths: &Depths, version: Version, inputs: &Inputs, tan: Option, whole: bool) -> R<(Program, Vec, Vec, Vec)> { +fn elaborate_form(src: &str, toks: &[Token], stmts: &[Stmt], version: Version, inputs: &Inputs, tan: Option, whole: bool) -> R<(Program, Vec, Vec, Vec)> { let mut e = Elab { src, - depths, - nest: 0, version, inputs, used: BTreeSet::new(), @@ -359,6 +378,9 @@ fn elaborate_form(src: &str, toks: &[Token], stmts: &[Stmt], depths: &Depths, ve diags: Vec::new(), count: 0, depth: 0, + nest: 0, + held: 0, + seen: (0, 0, 0, 0), runtime: 0, loops: Vec::new(), frames: Vec::new(), @@ -369,7 +391,7 @@ fn elaborate_form(src: &str, toks: &[Token], stmts: &[Stmt], depths: &Depths, ve written: BTreeSet::new(), tan, whole, - held: 0, + held_whole: 0, }; e.prescan(toks, stmts)?; e.block(stmts)?; @@ -379,6 +401,7 @@ fn elaborate_form(src: &str, toks: &[Token], stmts: &[Stmt], depths: &Depths, ve Ok((p, l, d, tangents)) } + /// The reader as what a controlled gate's lowering writes to: each gate an op /// and a step, at the statement that made it. struct Emit<'x, 'a> { @@ -420,14 +443,96 @@ impl<'a> Elab<'a> { } } + /// `f` one level deeper in the reader's recursion, or the refusal past + /// [`MAX_NEST`]. + fn deeper(&mut self, at: Loc, f: impl FnOnce(&mut Self) -> R) -> R { + if self.nest >= MAX_NEST { + return self.fail( + diag::NESTING, + at, + format!("the program nests deeper than {MAX_NEST} levels in all through the gates and subroutines it calls (statements, expressions and calls together); call fewer levels deep, or nest less in each"), + ); + } + self.nest += 1; + let r = f(self); + self.nest -= 1; + r + } + fn tick(&mut self, at: Loc) -> R<()> { self.count += 1; if self.count > MAX_STEPS { return self.fail(diag::UNROLL_LIMIT, at, format!("the program unrolls past {MAX_STEPS} statements")); } + if super::spend_step() { + return self.fail(diag::UNROLL_LIMIT, at, "the programs read for one request elaborate past its budget of statements together (wai_quantum::qasm::within_steps)"); + } + self.grown(at) + } + + /// What the program's lists have grown by since they were last counted + /// (gates, steps, measurements and timing, each with what it carries), + /// held. Counted at every statement, so no statement's growth goes + /// uncounted past the next. + fn grown(&mut self, at: Loc) -> R<()> { + let (o, st, m, ti) = self.seen; + let mut bytes = 0u64; + for op in &self.ops[o..] { + bytes += ENTRY_BYTES + 4 * op.qubits.len() as u64 + 8 * op.params.len() as u64; + } + bytes += ENTRY_BYTES * (self.steps.len() - st) as u64 + 8 * (self.measured.len() - m) as u64; + for t in &self.extra.timing[ti..] { + bytes += ENTRY_BYTES + 4 * t.qubits.len() as u64; + } + self.seen = (self.ops.len(), self.steps.len(), self.measured.len(), self.extra.timing.len()); + self.hold(bytes, at) + } + + /// `bytes` more held by the reading: refused past [`MAX_HELD`], and + /// counted as steps of reading at one for every [`BYTES_A_STEP`]. + fn hold(&mut self, bytes: u64, at: Loc) -> R<()> { + self.held = self.held.saturating_add(bytes); + if self.held > MAX_HELD { + return self.fail( + diag::TOO_LARGE, + at, + format!("reading the program would hold more than {} MiB (its classical values, gates, steps and timing together); read it where it can, on your own machine, or make it smaller", MAX_HELD >> 20), + ); + } + self.charge(bytes, at) + } + + /// `bytes` made or copied, counted as steps of reading (toward + /// [`MAX_STEPS`] and a budget of reading) at one for every + /// [`BYTES_A_STEP`]. + fn charge(&mut self, bytes: u64, at: Loc) -> R<()> { + let steps = bytes / BYTES_A_STEP; + if steps == 0 { + return Ok(()); + } + self.count = self.count.saturating_add(steps); + if self.count > MAX_STEPS { + return self.fail(diag::UNROLL_LIMIT, at, format!("the program unrolls past {MAX_STEPS} statements (the values it makes and copies counted with them)")); + } + if super::spend_steps(steps) { + return self.fail(diag::UNROLL_LIMIT, at, "the programs read for one request elaborate past its budget of steps together (wai_quantum::qasm::within_steps)"); + } Ok(()) } + /// A value of `bytes` about to be made: refused past [`MAX_VALUE`], else + /// held. + fn make(&mut self, bytes: Option, at: Loc) -> R<()> { + match bytes { + Some(b) if b <= MAX_VALUE => self.hold(b, at), + _ => self.fail( + diag::TOO_LARGE, + at, + format!("this value would take more than {} MiB (each element counted at its width); the reader holds no classical value larger", MAX_VALUE >> 20), + ), + } + } + // ----------------------------------------------------------------------- // Before running: physical qubits, and the names registers and gates take // ----------------------------------------------------------------------- @@ -541,12 +646,15 @@ impl<'a> Elab<'a> { if self.ops.len() >= MAX_OPS { return self.fail(diag::TOO_MANY_GATES, at, format!("more than {MAX_OPS} gates")); } + if super::spend_step() { + return self.fail(diag::UNROLL_LIMIT, at, "the programs read for one request write past its budget of statements and gates together (wai_quantum::qasm::within_steps)"); + } let op = Op { kind, qubits, params }; // A gate held whole carries its qubits. Its standard gates are at least // as many, so whatever the standard form holds is within this. if op.is_whole() { - self.held += op.qubits.len(); - if self.held > MAX_OPS { + self.held_whole += op.qubits.len(); + if self.held_whole > MAX_OPS { return self.fail(diag::TOO_MANY_GATES, at, format!("gates held whole on more than {MAX_OPS} qubits in all")); } } @@ -689,16 +797,8 @@ impl<'a> Elab<'a> { if bits.is_empty() { Ct::Const(c.equal) } else { Ct::Atom(Cond { bits, value, equal: c.equal }) } } Ct::Not(x) => negate(self.fold(*x)), - Ct::And(a, b) => match (self.fold(*a), self.fold(*b)) { - (Ct::Const(false), _) | (_, Ct::Const(false)) => Ct::Const(false), - (Ct::Const(true), t) | (t, Ct::Const(true)) => t, - (a, b) => Ct::And(Box::new(a), Box::new(b)), - }, - Ct::Or(a, b) => match (self.fold(*a), self.fold(*b)) { - (Ct::Const(true), _) | (_, Ct::Const(true)) => Ct::Const(true), - (Ct::Const(false), t) | (t, Ct::Const(false)) => t, - (a, b) => Ct::Or(Box::new(a), Box::new(b)), - }, + Ct::All(list) => joined(list.into_iter().map(|t| self.fold(t)), true), + Ct::Any(list) => joined(list.into_iter().map(|t| self.fold(t)), false), t => t, } } @@ -741,14 +841,13 @@ impl<'a> Elab<'a> { self.steps.push(Instr::JumpUnless(c.clone(), usize::MAX)); } Ct::Not(x) => self.jump_true(x, patches), - Ct::And(a, b) => { - self.jump_false(a, patches); - self.jump_false(b, patches); - } - Ct::Or(a, b) => { + Ct::All(list) => list.iter().for_each(|t| self.jump_false(t, patches)), + Ct::Any(list) => { let mut yes = Vec::new(); - self.jump_true(a, &mut yes); - self.jump_false(b, patches); + if let Some((last, first)) = list.split_last() { + first.iter().for_each(|t| self.jump_true(t, &mut yes)); + self.jump_false(last, patches); + } let h = self.here(); self.patch(&yes, h); } @@ -765,14 +864,13 @@ impl<'a> Elab<'a> { self.steps.push(Instr::JumpUnless(Cond { equal: !c.equal, ..c.clone() }, usize::MAX)); } Ct::Not(x) => self.jump_false(x, patches), - Ct::Or(a, b) => { - self.jump_true(a, patches); - self.jump_true(b, patches); - } - Ct::And(a, b) => { + Ct::Any(list) => list.iter().for_each(|t| self.jump_true(t, patches)), + Ct::All(list) => { let mut no = Vec::new(); - self.jump_false(a, &mut no); - self.jump_true(b, patches); + if let Some((last, first)) = list.split_last() { + first.iter().for_each(|t| self.jump_false(t, &mut no)); + self.jump_true(last, patches); + } let h = self.here(); self.patch(&no, h); } @@ -807,19 +905,6 @@ impl<'a> Elab<'a> { // Statements // ----------------------------------------------------------------------- - /// The depth a call's body, read in its place, starts from: one less than the call's - /// own depth, so that the body's statements are one deeper than the call. Refused - /// ([`diag::NESTING`]) when what the body reaches would pass [`super::MAX_NESTING`]: - /// nesting is counted through the calls, as the reader holds it. - fn inlined(&self, call: &Name, deepest: u32) -> R { - let Some(&at) = self.depths.calls.get(&call.at.start) else { return Ok(self.nest) }; - let base = self.nest + at - 1; - if base + deepest > super::MAX_NESTING { - return self.fail(diag::NESTING, call.at, format!("statements and expressions nest deeper than {} with the body of {} read in its place", super::MAX_NESTING, call.text)); - } - Ok(base) - } - fn block(&mut self, stmts: &[Stmt]) -> R { for s in stmts { let f = self.exec(s)?; @@ -837,22 +922,54 @@ impl<'a> Elab<'a> { f } - /// What reading any statement begins with: its unit of work, its line, its annotations. - fn enter(&mut self, s: &Stmt) -> R<()> { + fn exec(&mut self, s: &Stmt) -> R { + self.deeper(s.at, |e| e.exec_here(s)) + } + + /// A statement run. The forms that hold other statements are run here; + /// the rest by [`Elab::exec_leaf`], so a nested statement stacks small + /// frames. + fn exec_here(&mut self, s: &Stmt) -> R { self.tick(s.at)?; if self.frames.last().is_none_or(|f| !f.gate) { self.top = s.at.line; } + if !s.anns.is_empty() { + self.annotations(s); + } + match &s.kind { + S::If { arms, other } => self.if_stmt(arms, other.as_deref()), + S::For { ty, var, over, body } => self.for_stmt(ty.as_ref(), var, over, body, s.at), + S::While { cond, body } => self.while_stmt(cond, body, s), + S::Switch { on, cases, default } => self.switch(on, cases, default.as_deref(), s.at), + S::Box { duration, body } => self.box_stmt(duration.as_ref(), body), + S::Block(b) => self.scoped(b), + _ => self.exec_leaf(s), + } + } + + #[inline(never)] + fn annotations(&mut self, s: &Stmt) { for (k, _, at) in &s.anns { if !(k == "wai.max_iterations" && matches!(s.kind, S::While { .. })) { self.note(diag::UNUSED_PRAGMA, *at, format!("the annotation `@{k}` is kept, not applied")); } } - Ok(()) } - fn exec(&mut self, s: &Stmt) -> R { - self.enter(s)?; + #[inline(never)] + fn box_stmt(&mut self, duration: Option<&Expr>, body: &[Stmt]) -> R { + let d = duration.map(|e| self.duration(e)).transpose()?; + self.extra.timing.push(Timing { at: self.ops.len(), kind: TimingKind::BoxStart, qubits: Vec::new(), duration: d }); + let f = self.scoped(body)?; + self.extra.timing.push(Timing { at: self.ops.len(), kind: TimingKind::BoxEnd, qubits: Vec::new(), duration: None }); + Ok(f) + } + + /// A statement that holds no other: a declaration, a gate's call, a + /// measurement, a jump. (A subroutine it calls runs its body under it.) + #[inline(never)] + fn exec_leaf(&mut self, s: &Stmt) -> R { let at = s.at; match &s.kind { S::Version(v) => { @@ -935,8 +1052,7 @@ impl<'a> Elab<'a> { if self.gates.contains_key(&name.text) || self.defs.contains_key(&name.text) { return self.fail(diag::DECLARED_TWICE, name.at, format!("{} is declared twice", name.text)); } - let deepest = self.depths.bodies.get(&name.at.start).copied().unwrap_or(0); - self.gates.insert(name.text.clone(), Rc::new(GateDef { params: params.clone(), qubits: qubits.clone(), body: body.clone(), deepest })); + self.gates.insert(name.text.clone(), Rc::new(GateDef { params: params.clone(), qubits: qubits.clone(), body: body.clone() })); } S::Opaque { name, .. } => { self.opaque.insert(name.text.clone()); @@ -946,8 +1062,7 @@ impl<'a> Elab<'a> { if self.gates.contains_key(&name.text) || self.defs.contains_key(&name.text) || builtin(&name.text).is_some() { return self.fail(diag::DECLARED_TWICE, name.at, format!("{} is declared twice", name.text)); } - let deepest = self.depths.bodies.get(&name.at.start).copied().unwrap_or(0); - self.defs.insert(name.text.clone(), Rc::new(DefFn { args: args.clone(), ret: ret.clone(), body: body.clone(), deepest })); + self.defs.insert(name.text.clone(), Rc::new(DefFn { args: args.clone(), ret: ret.clone(), body: body.clone() })); } S::Extern { name } => { self.externs.insert(name.text.clone()); @@ -984,13 +1099,6 @@ impl<'a> Elab<'a> { let qs = self.qubit_list(operands)?; self.extra.timing.push(Timing { at: self.ops.len(), kind: TimingKind::Delay, qubits: qs, duration: Some(d) }); } - S::Box { duration, body } => { - let d = duration.as_ref().map(|e| self.duration(e)).transpose()?; - self.extra.timing.push(Timing { at: self.ops.len(), kind: TimingKind::BoxStart, qubits: Vec::new(), duration: d }); - let f = self.scoped(body)?; - self.extra.timing.push(Timing { at: self.ops.len(), kind: TimingKind::BoxEnd, qubits: Vec::new(), duration: None }); - return Ok(f); - } S::Assign { target, op, value } => self.assign(target, op, value, at)?, S::Expr(e) => { if let E::Call(name, args) = &e.kind @@ -1001,10 +1109,6 @@ impl<'a> Elab<'a> { let _ = self.ev(e)?; } } - S::If { cond, then, other } => return self.if_stmt(cond, then, other.as_deref()), - S::For { ty, var, over, body } => return self.for_stmt(ty.as_ref(), var, over, body, at), - S::While { cond, body } => return self.while_stmt(cond, body, s), - S::Switch { on, cases, default } => return self.switch(on, cases, default.as_deref(), at), S::Break | S::Continue => { let brk = matches!(s.kind, S::Break); let word = if brk { "break" } else { "continue" }; @@ -1037,7 +1141,7 @@ impl<'a> Elab<'a> { } return Ok(Flow::End); } - S::Block(b) => return self.scoped(b), + S::If { .. } | S::For { .. } | S::While { .. } | S::Switch { .. } | S::Box { .. } | S::Block(_) => unreachable!("run by exec_here"), } Ok(Flow::Normal) } @@ -1061,21 +1165,12 @@ impl<'a> Elab<'a> { } Ok(()) }; - // In the order written, without a frame of the stack for each body: an `else if` - // ladder is as long as it is written. - let mut todo = vec![s]; - while let Some(s) = todo.pop() { - match &s.kind { - S::Call { operands, .. } | S::Barrier(operands) => operands.iter().try_for_each(check)?, - S::For { body, .. } | S::Block(body) | S::Box { body, .. } => todo.extend(body.iter().rev()), - S::If { then, other, .. } => { - todo.extend(other.iter().flatten().rev()); - todo.extend(then.iter().rev()); - } - _ => {} - } + match &s.kind { + S::Call { operands, .. } | S::Barrier(operands) => operands.iter().try_for_each(check), + S::For { body, .. } | S::Block(body) | S::Box { body, .. } => body.iter().try_for_each(|st| self.check_gate_operands(st, gate, qubits)), + S::If { arms, other } => arms.iter().flat_map(|(_, then)| then).chain(other.iter().flatten()).try_for_each(|st| self.check_gate_operands(st, gate, qubits)), + _ => Ok(()), } - Ok(()) } // ----------------------------------------------------------------------- @@ -1133,6 +1228,27 @@ impl<'a> Elab<'a> { }) } + /// The bytes a value of type `t` takes, at least; `None` past `u64`. + fn value_size(t: &Ty) -> Option { + match t { + Ty::Bit(n) => Some(VAL_BYTES + u64::from(n.unwrap_or(1))), + Ty::Array(inner, dims) => dims.iter().try_fold(Self::value_size(inner)?, |b, &d| b.checked_mul(d as u64)?.checked_add(VAL_BYTES)), + _ => Some(VAL_BYTES), + } + } + + /// The bytes the value `v` takes, at least: what copying it copies. + fn size_of(v: &Val) -> u64 { + match v { + Val::Array(items) => match items.first() { + Some(Val::Array(_) | Val::Bits(_)) => VAL_BYTES + items.iter().map(Self::size_of).sum::(), + _ => VAL_BYTES * (1 + items.len() as u64), + }, + Val::Bits(b) => VAL_BYTES + b.len() as u64, + _ => VAL_BYTES, + } + } + /// The default value of a type, for a declaration with none. fn zero(&self, t: &Ty) -> Option { Some(match t { @@ -1162,7 +1278,10 @@ impl<'a> Elab<'a> { Ty::Int(w) | Ty::Uint(w) => { let unsigned = matches!(t, Ty::Uint(_)); let i = match &v { - Val::Int(_) | Val::Bool(_) | Val::Bits(_) => v.integer().expect("integral"), + Val::Int(_) | Val::Bool(_) | Val::Bits(_) => match v.integer() { + Some(i) => i, + None => return self.fail(diag::INTEGER_RANGE, at, format!("`{name}` is {}, and {} bits are more than an integer here holds (120): cast a slice of them", t.name(), v.bits_len())), + }, Val::Float(f) if self.version == Version::Two && f.fract() == 0.0 => *f as i128, _ => return bad(self, &v), }; @@ -1288,6 +1407,8 @@ impl<'a> Elab<'a> { Some(e) => self.size(e)?, None => 1, }; + // Each bit a number the steps name, and a place in each outcome. + self.make(Some(VAL_BYTES + 4 * u64::from(n)), at)?; let bits = if self.global() { if self.lookup(&name.text).is_some() { return self.fail(diag::DECLARED_TWICE, name.at, format!("{} is declared twice", name.text)); @@ -1347,7 +1468,10 @@ impl<'a> Elab<'a> { How::Plain | How::Output => { let v = match init { None if t == Ty::Stretch => Some(Val::Duration(Dur::Stretch(name.text.clone()))), - None => self.zero(&t), + None => { + self.make(Self::value_size(&t), at)?; + self.zero(&t) + } Some(e) => match self.evs(e)? { Ev::Val(v) => Some(self.coerce(v, &t, &name.text, e.at)?), Ev::Unknown(why) => { @@ -1631,6 +1755,10 @@ impl<'a> Elab<'a> { Self::walk(a, f); Self::walk(b, f); } + E::Chain(first, rest) => { + Self::walk(first, f); + rest.iter().for_each(|(_, x)| Self::walk(x, f)); + } E::Call(_, args) | E::Set(args) => args.iter().for_each(|a| Self::walk(a, f)), E::Range(a, b, c) => [a, b, c].into_iter().flatten().for_each(|x| Self::walk(x, f)), _ => {} @@ -1642,6 +1770,30 @@ impl<'a> Elab<'a> { /// quotients, a constant power, the built-in functions and a cast to a /// real type. Anything else that depends on a parameter is refused. fn tangent(&mut self, e: &Expr) -> R> { + self.deeper(e.at, |x| x.tangent_here(e)) + } + + /// The derivative of a run of `*` and `/`, `t` being its first operand's: + /// the product and quotient rules applied left to right, on the value of + /// the run so far. The caller holds [`Elab::trunc`] for it. + fn tangent_products(&mut self, first: &Expr, rest: &[(&'static str, Expr)], mut t: Vec) -> R> { + let mut v = self.known(first)?; + let mut whole = first.at; + for (op, x) in rest { + self.tick(x.at)?; + let (va, vb) = (self.real_of(v.clone(), self.trunc, whole)?, self.real(x)?); + let tx = self.tangent(x)?; + // (a·b)' = a'·b + a·b'; (a/b)' = a'/b − a·b'/b² + let (p, q) = if *op == "*" { (vb, va) } else { (1.0 / vb, -va / (vb * vb)) }; + t = t.iter().zip(&tx).map(|(a, b)| p * a + q * b).collect(); + whole = whole.to(x.at); + let y = self.known(x)?; + v = self.binop(op, v, y, whole)?; + } + Ok(t) + } + + fn tangent_here(&mut self, e: &Expr) -> R> { let zero = self.tan.as_ref().map(Tan::zero).unwrap_or_default(); if !self.depends(e) { return Ok(zero); @@ -1678,6 +1830,27 @@ impl<'a> Elab<'a> { ta.iter().map(|t| k * t).collect() } E::Binary(op, _, _) => return refuse(self, &format!("`{op}`")), + E::Chain(first, rest) => { + let sums = rest.iter().all(|(op, _)| matches!(*op, "+" | "-")); + if !sums && let Some((op, _)) = rest.iter().rev().find(|(op, _)| !matches!(*op, "*" | "/")) { + return refuse(self, &format!("`{op}`")); + } + let t = self.tangent(first)?; + if sums { + let mut t = t; + for (op, x) in rest { + self.tick(x.at)?; + let tx = self.tangent(x)?; + t = axpy(1.0, &t, if *op == "+" { 1.0 } else { -1.0 }, &tx); + } + t + } else { + let saved = std::mem::replace(&mut self.trunc, false); + let r = self.tangent_products(first, rest, t); + self.trunc = saved; + r? + } + } E::Call(f, args) if args.len() == 1 => { let x = self.real(&args[0])?; let t = self.tangent(&args[0])?; @@ -1798,6 +1971,10 @@ impl<'a> Elab<'a> { /// A gate by name, without modifiers, as primitives. fn expand(&mut self, name: &Name, params: &[f64], qubits: &[u32], at: Loc, depth: usize) -> R> { + self.deeper(at, |e| e.expand_here(name, params, qubits, at, depth)) + } + + fn expand_here(&mut self, name: &Name, params: &[f64], qubits: &[u32], at: Loc, depth: usize) -> R> { // The derivatives of these parameters, when a gradient is asked. let pending = match self.tan.as_mut() { Some(t) => Some(t.pending.take().unwrap_or_else(|| vec![t.zero(); params.len()])), @@ -1865,15 +2042,7 @@ impl<'a> Elab<'a> { tan.formals.push(g.params.iter().zip(t).map(|(n, d)| (n.text.clone(), d)).collect()); } let mut prims = Vec::new(); - let r = match self.inlined(name, g.deepest) { - Ok(base) => { - let outer = std::mem::replace(&mut self.nest, base); - let r = g.body.iter().try_for_each(|s| self.gate_stmt(s, &mut prims, depth)); - self.nest = outer; - r - } - Err(e) => Err(e), - }; + let r = g.body.iter().try_for_each(|s| self.gate_stmt(s, &mut prims, depth)); if let Some(tan) = self.tan.as_mut() { tan.formals.pop(); } @@ -1884,6 +2053,10 @@ impl<'a> Elab<'a> { /// A statement of a gate's body, its gates added to `prims`. fn gate_stmt(&mut self, s: &Stmt, prims: &mut Vec, depth: usize) -> R<()> { + self.deeper(s.at, |e| e.gate_stmt_here(s, prims, depth)) + } + + fn gate_stmt_here(&mut self, s: &Stmt, prims: &mut Vec, depth: usize) -> R<()> { self.tick(s.at)?; match &s.kind { S::Call { mods, name, params, operands, .. } => { @@ -1919,25 +2092,19 @@ impl<'a> Elab<'a> { self.scopes.pop(); r?; } - S::If { cond, then, other } => { - let (mut cond, mut then, mut other) = (cond, then.as_slice(), other.as_deref()); - loop { + S::If { arms, other } => { + let mut body = other.as_deref(); + for (cond, then) in arms { + self.tick(cond.at)?; let c = self.known(cond)?; let Some(b) = c.truthy() else { return self.fail(diag::WRONG_TYPE, cond.at, "a condition is true or false") }; - let body = if b { Some(then) } else { other }; - // An `else` that is one `if`: the next arm, read in this loop. - if !b - && let Some([st]) = body - && let S::If { cond: c, then: t, other: o } = &st.kind - { - self.tick(st.at)?; - (cond, then, other) = (c, t.as_slice(), o.as_deref()); - continue; + if b { + body = Some(then.as_slice()); + break; } - for st in body.unwrap_or(&[]) { - self.gate_stmt(st, prims, depth)?; - } - break; + } + for st in body.unwrap_or(&[]) { + self.gate_stmt(st, prims, depth)?; } } S::For { ty, var, over, body } => { @@ -2024,6 +2191,10 @@ impl<'a> Elab<'a> { /// A subroutine called: its body inlined. `dest` takes a returned bit; /// the result is the returned value, if any. fn call_def(&mut self, name: &Name, args: &[Expr], dest: Option>>, at: Loc) -> R> { + self.deeper(at, |e| e.call_def_here(name, args, dest, at)) + } + + fn call_def_here(&mut self, name: &Name, args: &[Expr], dest: Option>>, at: Loc) -> R> { let def = self.defs.get(&name.text).cloned().expect("checked by the caller"); if self.depth >= MAX_DEPTH { return self.fail(diag::TOO_DEEP, at, format!("subroutine calls nest deeper than {MAX_DEPTH}")); @@ -2094,12 +2265,9 @@ impl<'a> Elab<'a> { envs: Vec::new(), }); self.scopes.push(scope); - let base = self.inlined(name, def.deepest)?; - let outer = std::mem::replace(&mut self.nest, base); self.depth += 1; let flow = self.block(&def.body); self.depth -= 1; - self.nest = outer; let inner = self.scopes.pop().expect("pushed"); let mut frame = self.frames.pop().expect("pushed"); let flow = flow?; @@ -2216,124 +2384,94 @@ impl<'a> Elab<'a> { // Control flow // ----------------------------------------------------------------------- - /// `if`, and each `else if` after it: a ladder is read arm after arm in a loop, each - /// arm as the `if` it is would be read, so that its length takes none of the reader's - /// stack. The arms whose condition is known only at run time wait in `open` for what - /// their `else` reads, and end as their `if` would, innermost first. - fn if_stmt(&mut self, cond: &Expr, then: &[Stmt], other: Option<&[Stmt]>) -> R { - let mut open = Vec::new(); - let mut flow = match self.if_ladder(cond, then, other, &mut open) { - Ok(f) => f, - Err(e) => { - // As each `if` ends on an error: its `else`'s scope gone, no longer at run time. - for _ in open.drain(..) { - self.scopes.pop(); - self.runtime -= 1; - } - return Err(e); + /// An `if` and its `else if`s, read in one loop however many there are. + /// A condition decided on reading leaves its branch, or the rest, to be + /// read in place. One on measured bits jumps past its branch; the rest + /// then run at run time, from the scopes it was reached with, and where + /// the branches join, what they assigned differently is no longer known. + fn if_stmt(&mut self, arms: &[(Expr, Vec)], other: Option<&[Stmt]>) -> R { + let mut branching = Branching { runtime: 0, start: None, joined: None, flow: None, ends: Vec::new() }; + let r = self.if_arms(arms, other, &mut branching); + self.runtime -= branching.runtime; + let f = r?; + let Some(start) = branching.start else { return Ok(f) }; + let h = self.here(); + self.patch(&branching.ends, h); + match branching.joined { + Some(scopes) => { + self.scopes = scopes; + Ok(Flow::Normal) + } + None => { + self.scopes = start; + Ok(branching.flow.unwrap_or(Flow::Normal)) } - }; - while let Some(arm) = open.pop() { - self.scopes.pop(); - flow = self.close_arm(arm, flow); } - Ok(flow) } - /// The ladder from `cond` on, to the last arm read; the flow of that arm. - fn if_ladder<'s>(&mut self, mut cond: &'s Expr, mut then: &'s [Stmt], mut other: Option<&'s [Stmt]>, open: &mut Vec) -> R { - loop { - // An `else` that holds one `if` and nothing more: the next arm. - let next = match other { - Some([s]) => match &s.kind { - S::If { cond, then, other } => Some((s, cond, then.as_slice(), other.as_deref())), - _ => None, - }, - _ => None, - }; + /// [`Elab::if_stmt`]'s loop: the flow of the branch taken, where every + /// condition was decided on reading. + fn if_arms(&mut self, arms: &[(Expr, Vec)], other: Option<&[Stmt]>, b: &mut Branching) -> R { + let mut last = other; + for (i, (cond, then)) in arms.iter().enumerate() { + if i > 0 { + self.tick(cond.at)?; + } let t = self.cond(cond)?; - if let Ct::Const(b) = t { - // Decided on reading: the branch taken is read in place. - return match (b, other, next) { - (true, _, _) => self.block(then), - (false, Some(_), Some((s, c, t, o))) => { - self.enter(s)?; - (cond, then, other) = (c, t, o); - continue; - } - (false, Some(o), None) => self.block(o), - (false, None, _) => Ok(Flow::Normal), - }; + if let Ct::Const(holds) = t { + if holds { + last = Some(then); + break; + } + continue; } self.dynamic = true; let mut no = Vec::new(); self.jump_false(&t, &mut no); let before = self.scopes.clone(); + b.start.get_or_insert_with(|| before.clone()); self.runtime += 1; - let ft = match self.scoped(then) { - Ok(f) => f, - Err(e) => { - self.runtime -= 1; - return Err(e); - } - }; - let mut outs = Vec::new(); - if ft == Flow::Normal { - outs.push(self.scopes.clone()); - } - let Some(o) = other else { + b.runtime += 1; + let f = self.scoped(then)?; + self.leave_branch(f, b); + if i + 1 == arms.len() && other.is_none() { let h = self.here(); self.patch(&no, h); - outs.push(before.clone()); - return Ok(self.close_arm(Arm { before, outs, end: Vec::new(), ft, other: false }, Flow::Normal)); - }; - let mut j = Vec::new(); - self.jump(&mut j); + self.scopes = before; + self.leave_branch(Flow::Normal, b); + return Ok(Flow::Normal); + } + self.jump(&mut b.ends); let h = self.here(); self.patch(&no, h); - self.scopes = before.clone(); - let arm = Arm { before, outs, end: j, ft, other: true }; - match next { - Some((s, c, t, o)) => { - // The `else`'s scope, as it is read as a block, then its one statement: - // the next arm. - self.scopes.push(Scope::new()); - open.push(arm); - self.enter(s)?; - (cond, then, other) = (c, t, o); - } - None => { - let fe = match self.scoped(o) { - Ok(f) => f, - Err(e) => { - self.runtime -= 1; - return Err(e); - } - }; - return Ok(self.close_arm(arm, fe)); - } - } + self.scopes = before; } + if b.start.is_none() { + // Every condition was decided on reading: read in place. + return match last { + Some(stmts) => self.block(stmts), + None => Ok(Flow::Normal), + }; + } + let f = match last { + Some(stmts) => self.scoped(stmts)?, + None => Flow::Normal, + }; + self.leave_branch(f, b); + Ok(Flow::Normal) } - /// The end of an arm whose condition is known only at run time, its `else` (when it - /// has one) read with flow `fe`. - fn close_arm(&mut self, arm: Arm, fe: Flow) -> Flow { - let Arm { before, mut outs, end, ft, other } = arm; - if other { - if fe == Flow::Normal { - outs.push(self.scopes.clone()); - } - let h = self.here(); - self.patch(&end, h); + /// A branch left with `f`: one that goes on joins the others. + fn leave_branch(&mut self, f: Flow, b: &mut Branching) { + if f != Flow::Normal { + b.flow = Some(b.flow.map_or(f, |g| g.min(f))); + return; } - self.runtime -= 1; - if outs.is_empty() { - self.scopes = before; - return ft.min(fe); - } - self.scopes = self.merge(outs); - Flow::Normal + let here = self.scopes.clone(); + b.joined = Some(match b.joined.take() { + Some(so_far) => self.merge(vec![so_far, here]), + None => here, + }); } /// The values a `for` loop runs over, given one at a time: a range is never @@ -2545,7 +2683,7 @@ impl<'a> Elab<'a> { let a = self.compare_bits(&bits, &name, "==", y, e.at)?; t = Some(match t { None => a, - Some(p) => Ct::Or(Box::new(p), Box::new(a)), + Some(p) => either(p, a), }); } let t = t.unwrap_or(Ct::Const(false)); @@ -2587,29 +2725,42 @@ impl<'a> Elab<'a> { // ----------------------------------------------------------------------- fn cond(&mut self, e: &Expr) -> R { + self.deeper(e.at, |x| x.cond_here(e)) + } + + /// `left` `op` `right` (`&&` or `||`): `right` is read only where `left` + /// leaves it to decide. + fn cond_then(&mut self, op: &str, left: Ct, right: &Expr) -> R { + let (stops, join): (bool, fn(Ct, Ct) -> Ct) = if op == "&&" { (false, both) } else { (true, either) }; + Ok(match left { + Ct::Const(b) if b == stops => Ct::Const(b), + Ct::Const(_) => self.cond(right)?, + t => match self.cond(right)? { + Ct::Const(b) if b == stops => Ct::Const(b), + Ct::Const(_) => t, + u => join(t, u), + }, + }) + } + + fn cond_here(&mut self, e: &Expr) -> R { Ok(match &e.kind { E::Unary("!", x) => match self.cond(x)? { Ct::Const(b) => Ct::Const(!b), t => Ct::Not(Box::new(t)), }, - E::Binary("&&", a, b) => match self.cond(a)? { - Ct::Const(false) => Ct::Const(false), - Ct::Const(true) => self.cond(b)?, - ta => match self.cond(b)? { - Ct::Const(true) => ta, - Ct::Const(false) => Ct::Const(false), - tb => Ct::And(Box::new(ta), Box::new(tb)), - }, - }, - E::Binary("||", a, b) => match self.cond(a)? { - Ct::Const(true) => Ct::Const(true), - Ct::Const(false) => self.cond(b)?, - ta => match self.cond(b)? { - Ct::Const(false) => ta, - Ct::Const(true) => Ct::Const(true), - tb => Ct::Or(Box::new(ta), Box::new(tb)), - }, - }, + E::Binary(op @ ("&&" | "||"), a, b) => { + let left = self.cond(a)?; + self.cond_then(op, left, b)? + } + E::Chain(first, rest) if matches!(rest[0].0, "&&" | "||") => { + let mut t = self.cond(first)?; + for (op, x) in rest { + self.tick(x.at)?; + t = self.cond_then(op, t, x)?; + } + t + } E::Binary(op @ ("==" | "!=" | "<" | "<=" | ">" | ">="), a, b) => { if matches!(*op, "==" | "!=") && let E::Unary("!", inner) = &a.kind @@ -2707,7 +2858,7 @@ impl<'a> Elab<'a> { let c = Ct::Atom(Cond { bits: bits[i..].to_vec(), value: (k >> i) & !1, equal: true }); t = Some(match t { None => c, - Some(p) => Ct::Or(Box::new(p), Box::new(c)), + Some(p) => either(p, c), }); } } @@ -2734,6 +2885,32 @@ impl<'a> Elab<'a> { // ----------------------------------------------------------------------- fn ev(&mut self, e: &Expr) -> R { + self.deeper(e.at, |x| x.ev_here(e)) + } + + /// An expression's value. The forms that hold other expressions are each + /// read by a function of their own, so a nested expression stacks small + /// frames. + fn ev_here(&mut self, e: &Expr) -> R { + let at = e.at; + match &e.kind { + E::Name(n) => self.name_ev(n, at), + E::Index(base, items) => self.index_ev(base, items, at), + E::Unary(op, x) => self.ev_unary(op, x, at), + E::Binary(op @ ("&&" | "||"), a, b) => self.ev_logic(op, a, b), + E::Binary(op, a, b) => self.ev_binary(op, a, b, at), + E::Chain(first, rest) => self.ev_chain(first, rest), + E::Call(name, args) => self.call_ev(name, args, at), + E::Cast(ty, x) => self.ev_cast(ty, x, at), + E::Set(items) => self.ev_set(items), + E::Concat(..) => self.reference(e), + _ => self.ev_leaf(e), + } + } + + /// A literal, or a form refused where a value is read. + #[inline(never)] + fn ev_leaf(&mut self, e: &Expr) -> R { let at = e.at; Ok(match &e.kind { E::Int(v) => { @@ -2760,75 +2937,118 @@ impl<'a> Elab<'a> { } Ev::Val(Val::Bits(digits.iter().rev().map(|&c| c == '1').collect())) } - E::Name(n) => self.name_ev(n, at)?, - E::Index(base, items) => self.index_ev(base, items, at)?, - E::Unary(op, x) => match self.evs(x)? { - Ev::Val(v) => Ev::Val(self.unary(op, v, at)?), - Ev::Bits(_, n) => return self.runtime_value(&n, at), - Ev::Qubits(_, n) => return self.fail(diag::WRONG_TYPE, at, format!("`{n}` is qubits, not a value")), - u => u, - }, - E::Binary(op @ ("&&" | "||"), a, b) => { - let x = self.ev(a)?; - match x { - Ev::Val(v) => { - let Some(t) = v.truthy() else { return self.fail(diag::WRONG_TYPE, a.at, format!("`{op}` reads true or false, not {}", v.kind())) }; - if (*op == "&&" && !t) || (*op == "||" && t) { - Ev::Val(Val::Bool(t)) - } else { - match self.ev(b)? { - Ev::Val(w) => match w.truthy() { - Some(u) => Ev::Val(Val::Bool(u)), - None => return self.fail(diag::WRONG_TYPE, b.at, format!("`{op}` reads true or false, not {}", w.kind())), - }, - Ev::Bits(_, n) => return self.runtime_value(&n, b.at), - o => o, - } - } - } - Ev::Bits(_, n) => return self.runtime_value(&n, a.at), - o => o, - } - } - E::Binary(op, a, b) => { - let x = self.evs(a)?; - let y = self.evs(b)?; - match (x, y) { - (Ev::Val(x), Ev::Val(y)) => Ev::Val(self.binop(op, x, y, at)?), - (Ev::Bits(_, n), _) | (_, Ev::Bits(_, n)) => return self.runtime_value(&n, at), - (Ev::Qubits(_, n), _) | (_, Ev::Qubits(_, n)) => return self.fail(diag::WRONG_TYPE, at, format!("`{n}` is qubits, not a value")), - (Ev::Unknown(w), _) | (_, Ev::Unknown(w)) => Ev::Unknown(w), - } - } - E::Call(name, args) => self.call_ev(name, args, at)?, - E::Cast(ty, x) => { - let t = self.ty(ty)?; - match self.evs(x)? { - Ev::Val(v) => Ev::Val(self.cast(v, &t, at)?), - Ev::Bits(b, n) if matches!(t, Ty::Int(_) | Ty::Uint(_) | Ty::Bool | Ty::Bit(_)) => Ev::Bits(b, n), - Ev::Bits(_, n) => return self.runtime_value(&n, at), - o => o, - } - } E::Measure(_) => return self.fail(diag::MEASURE_PLACE, at, "a measurement gives bits: assign it to a bit register (`c = measure q;`)"), - E::Set(items) => { - let mut out = Vec::with_capacity(items.len()); - for i in items { - match self.evs(i)? { - Ev::Val(v) => out.push(v), - Ev::Unknown(w) => return Ok(Ev::Unknown(w)), - Ev::Bits(_, n) => return self.runtime_value(&n, i.at), - Ev::Qubits(_, n) => return self.fail(diag::WRONG_TYPE, i.at, format!("`{n}` is qubits, not a value")), - } - } - Ev::Val(Val::Array(out)) - } E::Range(..) => return self.fail(diag::WRONG_TYPE, at, "a range is read only in an index or a `for` loop"), E::DurationOf(_) => return self.fail(diag::DURATION_OF, at, "`durationof` needs the duration of each gate, which the program does not give the reader"), - E::Concat(..) => self.reference(e)?, + E::Name(_) | E::Index(..) | E::Unary(..) | E::Binary(..) | E::Chain(..) | E::Call(..) | E::Cast(..) | E::Set(_) | E::Concat(..) => unreachable!("read by ev_here"), }) } + #[inline(never)] + fn ev_unary(&mut self, op: &str, x: &Expr, at: Loc) -> R { + Ok(match self.evs(x)? { + Ev::Val(v) => Ev::Val(self.unary(op, v, at)?), + Ev::Bits(_, n) => return self.runtime_value(&n, at), + Ev::Qubits(_, n) => return self.fail(diag::WRONG_TYPE, at, format!("`{n}` is qubits, not a value")), + u => u, + }) + } + + /// `a && b`, `a || b`: `b` read only where `a` does not decide it. + #[inline(never)] + fn ev_logic(&mut self, op: &str, a: &Expr, b: &Expr) -> R { + let x = self.ev(a)?; + self.logic_then(op, x, a.at, b) + } + + /// `x`, read at `left`, `op` `b` (`&&` or `||`): `b` is read only where + /// `x` leaves it to decide. + fn logic_then(&mut self, op: &str, x: Ev, left: Loc, b: &Expr) -> R { + Ok(match x { + Ev::Val(v) => { + let Some(t) = v.truthy() else { return self.fail(diag::WRONG_TYPE, left, format!("`{op}` reads true or false, not {}", v.kind())) }; + if (op == "&&" && !t) || (op == "||" && t) { + Ev::Val(Val::Bool(t)) + } else { + match self.ev(b)? { + Ev::Val(w) => match w.truthy() { + Some(u) => Ev::Val(Val::Bool(u)), + None => return self.fail(diag::WRONG_TYPE, b.at, format!("`{op}` reads true or false, not {}", w.kind())), + }, + Ev::Bits(_, n) => return self.runtime_value(&n, b.at), + o => o, + } + } + } + Ev::Bits(_, n) => return self.runtime_value(&n, left), + o => o, + }) + } + + #[inline(never)] + fn ev_binary(&mut self, op: &str, a: &Expr, b: &Expr, at: Loc) -> R { + let x = self.evs(a)?; + let y = self.evs(b)?; + self.binary_of(op, x, y, at) + } + + /// A run of operators of one precedence, left to right, in a loop: each + /// operand counts as a statement does, so what a run costs in a loop is + /// paid for. + #[inline(never)] + fn ev_chain(&mut self, first: &Expr, rest: &[(&'static str, Expr)]) -> R { + let logic = matches!(rest[0].0, "&&" | "||"); + let mut x = if logic { self.ev(first)? } else { self.evs(first)? }; + let mut whole = first.at; + for (op, e) in rest { + self.tick(e.at)?; + x = if logic { + self.logic_then(op, x, whole, e)? + } else { + let y = self.evs(e)?; + self.binary_of(op, x, y, whole.to(e.at))? + }; + whole = whole.to(e.at); + } + Ok(x) + } + + /// The operator `op` on two values. + fn binary_of(&mut self, op: &str, x: Ev, y: Ev, at: Loc) -> R { + Ok(match (x, y) { + (Ev::Val(x), Ev::Val(y)) => Ev::Val(self.binop(op, x, y, at)?), + (Ev::Bits(_, n), _) | (_, Ev::Bits(_, n)) => return self.runtime_value(&n, at), + (Ev::Qubits(_, n), _) | (_, Ev::Qubits(_, n)) => return self.fail(diag::WRONG_TYPE, at, format!("`{n}` is qubits, not a value")), + (Ev::Unknown(w), _) | (_, Ev::Unknown(w)) => Ev::Unknown(w), + }) + } + + #[inline(never)] + fn ev_cast(&mut self, ty: &Type, x: &Expr, at: Loc) -> R { + let t = self.ty(ty)?; + Ok(match self.evs(x)? { + Ev::Val(v) => Ev::Val(self.cast(v, &t, at)?), + Ev::Bits(b, n) if matches!(t, Ty::Int(_) | Ty::Uint(_) | Ty::Bool | Ty::Bit(_)) => Ev::Bits(b, n), + Ev::Bits(_, n) => return self.runtime_value(&n, at), + o => o, + }) + } + + #[inline(never)] + fn ev_set(&mut self, items: &[Expr]) -> R { + let mut out = Vec::with_capacity(items.len()); + for i in items { + self.tick(i.at)?; + match self.evs(i)? { + Ev::Val(v) => out.push(v), + Ev::Unknown(w) => return Ok(Ev::Unknown(w)), + Ev::Bits(_, n) => return self.runtime_value(&n, i.at), + Ev::Qubits(_, n) => return self.fail(diag::WRONG_TYPE, i.at, format!("`{n}` is qubits, not a value")), + } + } + Ok(Ev::Val(Val::Array(out))) + } + fn name_ev(&mut self, n: &str, at: Loc) -> R { match n { "pi" | "π" => return Ok(Ev::Val(Val::Float(PI))), @@ -2845,6 +3065,10 @@ impl<'a> Elab<'a> { } return Ok(Ev::Qubits(Rc::new(vec![q]), n.to_string())); } + if let Some(Binding::Var { val: Some(v @ (Val::Array(_) | Val::Bits(_))), .. }) = self.lookup(n) { + let bytes = Self::size_of(v); + self.charge(bytes, at)?; + } Ok(match self.lookup(n) { Some(Binding::Var { val: Some(v), .. }) => Ev::Val(v.clone()), Some(Binding::Var { val: None, why, .. }) => Ev::Unknown(if why.is_empty() { format!("`{n}` is not known on reading") } else { why.clone() }), @@ -2915,6 +3139,9 @@ impl<'a> Elab<'a> { } fn index_ev(&mut self, base: &Expr, items: &[Expr], at: Loc) -> R { + if let Some(v) = self.element_in_place(base, items, at)? { + return Ok(Ev::Val(v)); + } let b = self.ev(base)?; Ok(match b { Ev::Qubits(q, n) => { @@ -2931,16 +3158,75 @@ impl<'a> Elab<'a> { } /// An element (or slice) of a known value. + /// `a[i]`, `a[i, j]`: one element of an array variable, found in place, + /// so reading an element copies the element and not the array. `None` + /// where the index is a range or a set, the name is not an array known on + /// reading, or a gradient follows the name: read the usual way. + fn element_in_place(&mut self, base: &Expr, items: &[Expr], at: Loc) -> R> { + let E::Name(n) = &base.kind else { return Ok(None) }; + if self.tan.is_some() || items.iter().any(|i| matches!(i.kind, E::Range(..) | E::Set(_))) { + return Ok(None); + } + let at_path = |s: &Self, path: &[usize]| -> Option { + let Some(Binding::Var { val: Some(v), .. }) = s.lookup(n) else { return None }; + let mut cur = v; + for &k in path { + let Val::Array(a) = cur else { return None }; + cur = a.get(k)?; + } + Some(match cur { + Val::Array(a) => Val::Array(Vec::with_capacity(a.len())), + other => other.clone(), + }) + }; + let len_at = |s: &Self, path: &[usize]| -> Option { + let Some(Binding::Var { val: Some(v), .. }) = s.lookup(n) else { return None }; + let mut cur = v; + for &k in path { + let Val::Array(a) = cur else { return None }; + cur = a.get(k)?; + } + match cur { + Val::Array(a) => Some(a.len()), + _ => None, + } + }; + let mut path = Vec::with_capacity(items.len()); + for item in items { + let Some(len) = len_at(self, &path) else { return Ok(None) }; + let sel = self.select("the array", len, std::slice::from_ref(item), at)?; + let [k] = sel[..] else { return Ok(None) }; + path.push(k); + } + // An element that is itself an array is copied whole, as it is read. + match at_path(self, &path) { + Some(Val::Array(_)) => { + let Some(Binding::Var { val: Some(v), .. }) = self.lookup(n) else { return Ok(None) }; + let mut cur = v; + for &k in &path { + let Val::Array(a) = cur else { return Ok(None) }; + cur = &a[k]; + } + let copy = cur.clone(); + self.charge(Self::size_of(©), at)?; + Ok(Some(copy)) + } + other => Ok(other), + } + } + fn element(&mut self, v: &Val, items: &[Expr], at: Loc) -> R { match v { - Val::Array(a) => { - let mut cur = Val::Array(a.clone()); + Val::Array(_) => { + // Walked by reference: only what is selected is copied. + let mut cur = std::borrow::Cow::Borrowed(v); for item in items { - let Val::Array(a) = &cur else { return self.fail(diag::WRONG_TYPE, at, "an index into something that is not an array") }; + let Val::Array(a) = cur.as_ref() else { return self.fail(diag::WRONG_TYPE, at, "an index into something that is not an array") }; let sel = self.select("the array", a.len(), std::slice::from_ref(item), at)?; - cur = if sel.len() == 1 && !matches!(item.kind, E::Range(..) | E::Set(_)) { a[sel[0]].clone() } else { Val::Array(sel.iter().map(|&i| a[i].clone()).collect()) }; + let next = if sel.len() == 1 && !matches!(item.kind, E::Range(..) | E::Set(_)) { a[sel[0]].clone() } else { Val::Array(sel.iter().map(|&i| a[i].clone()).collect()) }; + cur = std::borrow::Cow::Owned(next); } - Ok(cur) + Ok(cur.into_owned()) } Val::Bits(b) => { let sel = self.select("the bits", b.len(), items, at)?; @@ -3032,8 +3318,15 @@ impl<'a> Elab<'a> { Ev::Bits(_, n) => return self.fail(diag::WRONG_TYPE, e.at, format!("`{n}` is a bit register, not qubits")), _ => return self.fail(diag::WRONG_TYPE, e.at, "expected qubits"), }; + // One pass with a mark for each qubit, not each against those before + // it: a register of tens of thousands is checked in its own length. + let mut seen = if qs.len() > 32 { vec![false; self.n_qubits as usize] } else { Vec::new() }; for (i, q) in qs.iter().enumerate() { - if qs[..i].contains(q) { + let twice = match seen.get_mut(*q as usize) { + Some(s) => std::mem::replace(s, true), + None => qs[..i].contains(q), + }; + if twice { return self.fail(diag::QUBIT_TWICE, e.at, format!("qubit {q} named twice in one reference")); } } @@ -3067,10 +3360,20 @@ impl<'a> Elab<'a> { if ops.is_empty() { return Ok((0..self.n_qubits).collect()); } - let mut out = Vec::new(); + // One operand names each of its qubits once already ([`Elab::qubits_of`]). + if let [o] = ops { + return self.qubits_of(o); + } + // Each qubit once, in the order first named: a register of tens of + // thousands is listed in one pass, not compared against itself. + let (mut out, mut seen) = (Vec::new(), vec![false; self.n_qubits as usize]); for o in ops { for q in self.qubits_of(o)? { - if !out.contains(&q) { + let first = match seen.get_mut(q as usize) { + Some(s) => !std::mem::replace(s, true), + None => !out.contains(&q), + }; + if first { out.push(q); } } @@ -3093,17 +3396,22 @@ impl<'a> Elab<'a> { let saved = std::mem::replace(&mut self.trunc, false); let v = self.known(e); let trunc = std::mem::replace(&mut self.trunc, saved); - let v = v?; + self.real_of(v?, trunc, e.at) + } + + /// `v` as a real number, `trunc` saying that its value truncated an + /// integer quotient. + fn real_of(&self, v: Val, trunc: bool, at: Loc) -> R { if trunc && !matches!(v, Val::Angle(_)) { return self.fail( diag::TRUNCATING_DIVISION, - e.at, + at, "integers divided with a remainder where a real number is wanted: OpenQASM 3 truncates the quotient, OpenQASM 2 does not; write a decimal point (`1.0/2`)", ); } match v { - Val::Complex(_, im) if im != 0.0 => self.fail(diag::WRONG_TYPE, e.at, "a gate's parameter is a real number, not a complex one"), - v => v.real().ok_or_else(|| self.d(diag::WRONG_TYPE, e.at, format!("a gate's parameter is a number, not {}", v.kind()))), + Val::Complex(_, im) if im != 0.0 => self.fail(diag::WRONG_TYPE, at, "a gate's parameter is a real number, not a complex one"), + v => v.real().ok_or_else(|| self.d(diag::WRONG_TYPE, at, format!("a gate's parameter is a number, not {}", v.kind()))), } } @@ -3147,6 +3455,7 @@ impl<'a> Elab<'a> { E::Name(n) => matches!(self.lookup(n), Some(Binding::Var { ty: Ty::Stretch, .. })), E::Unary(_, x) | E::Cast(_, x) => self.mentions_stretch(x), E::Binary(_, a, b) => self.mentions_stretch(a) || self.mentions_stretch(b), + E::Chain(first, rest) => self.mentions_stretch(first) || rest.iter().any(|(_, x)| self.mentions_stretch(x)), E::Call(_, args) => args.iter().any(|a| self.mentions_stretch(a)), _ => false, } @@ -3658,6 +3967,47 @@ impl<'a> Elab<'a> { } } +/// `a`, and `b` after it, in one flat list. +fn both(a: Ct, b: Ct) -> Ct { + match a { + Ct::All(mut list) => { + list.push(b); + Ct::All(list) + } + a => Ct::All(vec![a, b]), + } +} + +/// `a`, or `b` after it, in one flat list. +fn either(a: Ct, b: Ct) -> Ct { + match a { + Ct::Any(mut list) => { + list.push(b); + Ct::Any(list) + } + a => Ct::Any(vec![a, b]), + } +} + +/// A list of conditions with those already decided taken out: `all` of them +/// must hold, or any one. +fn joined(list: impl Iterator, all: bool) -> Ct { + let mut kept = Vec::new(); + for t in list { + match t { + Ct::Const(b) if b == all => {} + Ct::Const(b) => return Ct::Const(b), + t => kept.push(t), + } + } + match (kept.len(), all) { + (0, _) => Ct::Const(all), + (1, _) => kept.pop().expect("one left"), + (_, true) => Ct::All(kept), + (_, false) => Ct::Any(kept), + } +} + fn negate(t: Ct) -> Ct { match t { Ct::Const(b) => Ct::Const(!b), diff --git a/wai/wai-quantum/src/qasm/lang_tests.rs b/wai/wai-quantum/src/qasm/lang_tests.rs index 086eb153d..3adbc741f 100644 --- a/wai/wai-quantum/src/qasm/lang_tests.rs +++ b/wai/wai-quantum/src/qasm/lang_tests.rs @@ -646,30 +646,39 @@ pub(crate) fn chain(kind: &str, d: usize, k: usize, inner: usize) -> String { s } -/// The nesting bound is the text's (Q130): each shape is read at 128 and refused at 129, -/// whatever the shape, an operator chain or a type among them; an `else if` is at its -/// `if`'s depth, up to 1024 of them; and a call's body counts from the call, so nesting -/// through calls is bounded as nesting in one body is. +/// The shapes the reader's nesting bound is counted over, each refused past it +/// (`Q006`, at [`crate::qasm::parse::MAX_NESTING`] levels in the text) and read inside +/// it; a run of one operator and an `else if` ladder are flat, read at any length; and a +/// call's body counts toward the bound through calls, so nesting through calls is bounded +/// as nesting in one body is. (`depth_tests` holds every construct to the limits.) #[test] fn nesting_is_bounded_as_the_text_counts_it() { - for shape in ["parens", "unary", "sum", "product", "index", "concat", "type", "blocks", "ifs", "fors"] { - let past = read(&nested(shape, 127)).map(|_| ()).unwrap_err(); - assert_eq!(past.code, "Q130", "{shape} at 129: {past}"); - if let Err(d) = read(&nested(shape, 126)) { - assert_ne!(d.code, "Q130", "{shape} at 128 is within the bound: {d}"); + use crate::qasm::parse::MAX_NESTING; + for shape in ["parens", "unary", "index", "concat", "type", "blocks", "ifs", "fors"] { + let past = read(&nested(shape, 2 * MAX_NESTING)).map(|_| ()).unwrap_err(); + assert_eq!(past.code, diag::NESTING, "{shape} at {}: {past}", 2 * MAX_NESTING); + if let Err(d) = read(&nested(shape, MAX_NESTING - 8)) { + assert_ne!(d.code, diag::NESTING, "{shape} inside the bound: {d}"); } } - for shape in ["parens", "unary", "sum", "product", "blocks", "ifs", "fors"] { - assert!(read(&nested(shape, 126)).is_ok(), "{shape} at 128 reads"); + for shape in ["parens", "unary", "blocks", "ifs", "fors"] { + assert!(read(&nested(shape, MAX_NESTING - 8)).is_ok(), "{shape} inside the bound reads"); + } + // A run of one operator is one level, at any length. + for shape in ["sum", "product"] { + assert!(read(&nested(shape, 10_000)).is_ok(), "{shape} of 10,000 terms reads"); } let ladder = |n: usize| format!("OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit q;\nint s = 3;\nif (s == 0) x q;\n{}else id q;\n", (1..=n).map(|i| format!("else if (s == {i}) {}\n", if i == 3 { "x q;" } else { "id q;" })).collect::()); - let read_ladder = read(&ladder(1024)).unwrap_or_else(|d| panic!("1024 arms: {d}")); - assert_eq!(read_ladder.program.ops.len(), 1, "the arm that holds is the one read"); - assert_eq!(read(&ladder(1025)).map(|_| ()).unwrap_err().code, "Q130", "1025 arms"); - // Through calls: 25 gates of 4 loops each reach 128, and one block more 129. + for arms in [1024, 5000] { + let read_ladder = read(&ladder(arms)).unwrap_or_else(|d| panic!("{arms} arms: {d}")); + assert_eq!(read_ladder.program.ops.len(), 1, "{arms} arms: the arm that holds is the one read"); + } + // Through calls: 25 gates of 4 loops each read; 31 of 8 pass the bound in all. for kind in ["gates", "defs"] { - assert!(read(&chain(kind, 25, 4, 0)).is_ok(), "{kind} at 128: {:?}", read(&chain(kind, 25, 4, 0)).err()); - let past = read(&chain(kind, 25, 4, 1)).map(|_| ()).unwrap_err(); - assert_eq!(past.code, "Q130", "{kind} at 129: {past}"); + for inner in [0, 1] { + assert!(read(&chain(kind, 25, 4, inner)).is_ok(), "{kind}, 25 of 4 loops: {:?}", read(&chain(kind, 25, 4, inner)).err()); + } + let past = read(&chain(kind, 31, 8, 0)).map(|_| ()).unwrap_err(); + assert_eq!(past.code, diag::NESTING, "{kind}, 31 of 8 loops: {past}"); } } diff --git a/wai/wai-quantum/src/qasm/parse.rs b/wai/wai-quantum/src/qasm/parse.rs index cfbdfa3c6..1314cf57f 100644 --- a/wai/wai-quantum/src/qasm/parse.rs +++ b/wai/wai-quantum/src/qasm/parse.rs @@ -21,76 +21,60 @@ pub struct Parser<'a> { toks: &'a [Token], pos: usize, pub version: Version, - /// The depth of the node being read ([`super::MAX_NESTING`]). - depth: u32, - /// The deepest depth the expression being read reaches, its operators grouped as - /// they bind: an operator chain is read in a loop, but `a + b + c` is - /// `(a + b) + c`, so `a` is one deeper for each operator after it. - reach: u32, - /// The deepest depth reached since a definition's body began. - deepest: u32, - depths: Depths, -} - -/// How deep the calls and the definitions of a program stand, as the parser counts -/// depth: the reader reads a call's body in its place, its statements one deeper than -/// the call, so that the nesting the reader holds is counted through the calls. -#[derive(Clone, Debug, Default)] -pub(crate) struct Depths { - /// Each call's depth (a subroutine's, or a gate's), by where its name starts. - pub calls: std::collections::HashMap, - /// The deepest depth a definition's body reaches, by where its name starts. - pub bodies: std::collections::HashMap, + /// How many statements, parentheses, operators, brackets and types the + /// parser is inside ([`MAX_NESTING`]). + depth: usize, } type R = Result; +/// How deep a program's text may nest: statements inside blocks and bodies, +/// parentheses, operators, brackets, calls, casts and types together, and +/// every expression's tree as deep as its statement leaves room for. Past it +/// the program is refused ([`diag::NESTING`], `Q006`) rather than read by a +/// recursion that could run out of stack: on a thread of 1 MiB (a wasm +/// module's) the reader and everything after it stay well inside it. +/// Programs people write nest a few levels. A run of operators of one +/// precedence (`a + b + c`) is one level however long it is, and so is an +/// `else if` chain: they are read and run in loops, so no recursion follows +/// their length, and only what nests counts. +pub const MAX_NESTING: usize = 64; + +/// The most dimensions an array has. Comma-separated, they cost the text no +/// level, but each is a level of the array's value. +pub const MAX_ARRAY_DIMS: usize = 7; + /// Words that start a declaration of a classical type. const TYPES: [&str; 10] = ["bit", "int", "uint", "float", "angle", "bool", "complex", "duration", "stretch", "array"]; impl<'a> Parser<'a> { pub fn new(src: &'a str, toks: &'a [Token]) -> Self { - Parser { src, toks, pos: 0, version: Version::Unstated, depth: 0, reach: 0, deepest: 0, depths: Depths::default() } + Parser { src, toks, pos: 0, version: Version::Unstated, depth: 0 } } - /// One level deeper than the node being read. Past [`super::MAX_NESTING`] the program - /// is refused ([`diag::NESTING`]), on every host alike, before a reader's own stack - /// could run out on it. - fn deeper(&mut self) -> R<()> { - self.depth += 1; - self.reached(self.depth) + /// The refusal of what nests past [`MAX_NESTING`], at the current token. + fn too_deep(&self) -> R { + self.err( + diag::NESTING, + format!("this nests deeper than {MAX_NESTING} levels (statements, parentheses, operators, brackets and types together); write it in steps, with a `const` or a gate for a part"), + ) } - /// That the expression being read reaches `depth`: refused past the bound. - fn reached(&mut self, depth: u32) -> R<()> { - if depth > super::MAX_NESTING { - return self.err(diag::NESTING, format!("statements and expressions nest deeper than {}", super::MAX_NESTING)); + /// `f` one level deeper, or the refusal past [`MAX_NESTING`]. + fn nested(&mut self, f: impl FnOnce(&mut Self) -> R) -> R { + if self.depth >= MAX_NESTING { + return self.too_deep(); } - self.reach = self.reach.max(depth); - self.deepest = self.deepest.max(depth); - Ok(()) - } - - /// `f`, read one level deeper than the node being read. - fn below(&mut self, f: impl FnOnce(&mut Self) -> R) -> R { - self.deeper()?; + self.depth += 1; let r = f(self); self.depth -= 1; r } - /// `f`, and the deepest depth what it reads reaches, from a reach of `from`. - fn reaching(&mut self, from: u32, f: impl FnOnce(&mut Self) -> R) -> R<(T, u32)> { - let outer = std::mem::replace(&mut self.reach, from); - let r = f(self); - let reach = self.reach; - self.reach = outer.max(reach); - Ok((r?, reach)) - } - - /// The depths of the calls and definitions read, for the reader of the program. - pub(crate) fn take_depths(&mut self) -> Depths { - std::mem::take(&mut self.depths) + /// Whether an expression of height `h` (its longest path to a leaf) + /// fits at this depth. + fn fits(&self, h: usize) -> R<()> { + if self.depth + h > MAX_NESTING { self.too_deep() } else { Ok(()) } } /// How many of its tokens have been read: a reader of another grammar @@ -192,15 +176,6 @@ impl<'a> Parser<'a> { Ok(out) } - /// A definition's `{ block }`, and the deepest depth it reaches, kept by its name. - fn definition_body(&mut self, name: &Name) -> R> { - let outer = std::mem::take(&mut self.deepest); - let body = self.block(); - self.depths.bodies.insert(name.at.start, self.deepest); - self.deepest = self.deepest.max(outer); - body - } - /// A body: a `{ block }`, or one statement. fn body(&mut self) -> R> { if self.is_sym("{") { @@ -212,25 +187,14 @@ impl<'a> Parser<'a> { } } - /// `(cond) body` of an `if` or an `else if`. - fn if_arm(&mut self) -> R<(Expr, Vec)> { - self.expect("(")?; - let cond = self.expr()?; - self.expect(")")?; - let then = self.body()?; - Ok((cond, then)) - } - fn end(&mut self) -> R<()> { self.expect(";").map(|_| ()) } - /// A statement, one deeper than what holds it. pub fn statement(&mut self) -> R { - self.below(Self::statement_here) + self.nested(Self::statement_here) } - /// A statement at the depth of the node being read: one at the top, or an `else if`. fn statement_here(&mut self) -> R { let mut anns = Vec::new(); while let Some(Tok::Annotation(k, r)) = self.peek() { @@ -242,6 +206,8 @@ impl<'a> Parser<'a> { Ok(Stmt { kind, at: at.to(self.last()), anns }) } + /// One statement's form. A dispatcher: each form is read by a function of + /// its own, so the frames a nested statement stacks are each small. fn statement_kind(&mut self) -> R { let Some(tok) = self.peek() else { return self.err(diag::EXPECTED, "expected a statement") }; match tok { @@ -253,7 +219,7 @@ impl<'a> Parser<'a> { self.pos += 1; return Ok(S::Verbatim(*v, t.clone())); } - Tok::Sym("{") => return Ok(S::Block(self.block()?)), + Tok::Sym("{") => return self.block().map(S::Block), Tok::Ident(_) => {} Tok::Annotation(..) => return self.err(diag::EXPECTED, "expected a statement after the annotation"), _ => return self.err(diag::EXPECTED, "expected a statement"), @@ -265,278 +231,30 @@ impl<'a> Parser<'a> { return self.declaration(How::Plain); } match word { - "OPENQASM" => { + "OPENQASM" => self.version_line(), + "include" => self.include(), + "qubit" => self.qubit(), + "qreg" | "creg" => self.old_register(word == "creg"), + "const" | "input" | "output" => { self.pos += 1; - let mut v = String::new(); - while let Some(t) = self.peek() { - if *t == Tok::Sym(";") { - break; - } - v.push_str(&match &self.toks[self.pos].tok { - Tok::Float(_) | Tok::Int(_) => self.src[self.toks[self.pos].start..self.toks[self.pos].end].to_string(), - t => text(t), - }); - self.pos += 1; - } - self.end()?; - Ok(S::Version(v)) - } - "include" => { - self.pos += 1; - let file = match self.peek() { - Some(Tok::Str(s)) => s.clone(), - _ => return self.err(diag::EXPECTED, "expected a file name in quotes"), - }; - self.pos += 1; - self.end()?; - Ok(S::Include(file)) - } - "qubit" => { - self.pos += 1; - let size = if self.eat("[") { - let e = self.expr()?; - self.expect("]")?; - Some(e) - } else { - None - }; - let name = self.name()?; - self.end()?; - Ok(S::Qubit { name, size }) - } - "qreg" | "creg" => { - let old = word == "creg"; - self.pos += 1; - let name = self.name()?; - let size = if self.eat("[") { - let e = self.expr()?; - self.expect("]")?; - Some(e) - } else { - None - }; - if old { - let init = if self.eat("=") { Some(self.expr()?) } else { None }; - self.end()?; - return Ok(S::Decl { ty: Type::Bit(Some(Box::new(size.unwrap_or(Expr { kind: E::Int(1), at: name.at })))), name, init, how: How::Plain }); - } - self.end()?; - Ok(S::Qubit { name, size }) - } - "const" => { - self.pos += 1; - self.declaration(How::Const) - } - "input" => { - self.pos += 1; - self.declaration(How::Input) - } - "output" => { - self.pos += 1; - self.declaration(How::Output) - } - "let" => { - self.pos += 1; - let name = self.name()?; - self.expect("=")?; - let value = self.concat()?; - self.end()?; - Ok(S::Alias { name, value }) - } - "gate" | "opaque" => { - let opaque = word == "opaque"; - self.pos += 1; - let name = self.name()?; - let mut params = Vec::new(); - if self.eat("(") && !self.eat(")") { - loop { - params.push(self.name()?); - if self.eat(")") { - break; - } - self.expect(",")?; - } - } - let mut qubits = vec![self.name()?]; - while self.eat(",") { - qubits.push(self.name()?); - } - if opaque { - self.end()?; - return Ok(S::Opaque { name, params, qubits }); - } - let body = self.definition_body(&name)?; - Ok(S::Gate { name, params, qubits, body }) - } - "def" => { - self.pos += 1; - let name = self.name()?; - self.expect("(")?; - let mut args = Vec::new(); - if !self.eat(")") { - loop { - args.push(self.arg()?); - if self.eat(")") { - break; - } - self.expect(",")?; - } - } - let ret = if self.eat("->") { Some(self.ty()?) } else { None }; - let body = self.definition_body(&name)?; - Ok(S::Def { name, args, ret, body }) - } - "extern" => { - self.pos += 1; - let name = self.name()?; - self.expect("(")?; - let mut depth = 1usize; - while depth > 0 { - match self.peek() { - Some(Tok::Sym("(")) => depth += 1, - Some(Tok::Sym(")")) => depth -= 1, - None => return self.err(diag::UNCLOSED, "expected `)`"), - _ => {} - } - self.pos += 1; - } - if self.eat("->") { - self.ty()?; - } - self.end()?; - Ok(S::Extern { name }) - } - "measure" => { - self.pos += 1; - let src = self.operand()?; - let target = if self.eat("->") { Some(self.operand()?) } else { None }; - self.end()?; - Ok(S::Measure { src, target }) - } - "reset" => { - self.pos += 1; - let q = self.operand()?; - self.end()?; - Ok(S::Reset(q)) - } - "barrier" => { - self.pos += 1; - let operands = self.operands_opt()?; - self.end()?; - Ok(S::Barrier(operands)) - } - "delay" => { - self.pos += 1; - self.expect("[")?; - let duration = self.expr()?; - self.expect("]")?; - let operands = self.operands_opt()?; - self.end()?; - Ok(S::Delay { duration, operands }) - } - "box" => { - self.pos += 1; - let duration = if self.eat("[") { - let e = self.expr()?; - self.expect("]")?; - Some(e) - } else { - None - }; - let body = self.block()?; - Ok(S::Box { duration, body }) - } - "if" => { - self.pos += 1; - let (cond, then) = self.if_arm()?; - // An `else if` is at the depth of its `if`, read arm after arm: a ladder of - // them is as deep as one, and takes no more of the reader's stack. - let mut arms = Vec::new(); - let mut other = None; - while self.eat_word("else") { - if !self.is_word("if") { - other = Some(self.body()?); - break; - } - let at = self.here(); - self.pos += 1; - if arms.len() == super::MAX_ELSE_IF { - return self.err(diag::NESTING, format!("an `if` with more than {} `else if` arms", super::MAX_ELSE_IF)); - } - let (c, t) = self.if_arm()?; - arms.push((c, t, at)); - } - let end = self.last(); - for (c, t, at) in arms.into_iter().rev() { - other = Some(vec![Stmt { kind: S::If { cond: c, then: t, other }, at: at.to(end), anns: Vec::new() }]); - } - Ok(S::If { cond, then, other }) + self.declaration(match word { + "const" => How::Const, + "input" => How::Input, + _ => How::Output, + }) } + "let" => self.alias(), + "gate" | "opaque" => self.gate(word == "opaque"), + "def" => self.def(), + "extern" => self.extern_decl(), + "measure" | "reset" | "barrier" | "delay" => self.quantum(word), + "box" => self.box_stmt(), + "if" => self.if_stmt(), "else" => self.err(diag::MISPLACED, "`else` with no `if` before it"), "case" | "default" => self.err(diag::MISPLACED, format!("`{word}` outside a `switch`")), - "for" => { - self.pos += 1; - let ty = if matches!(self.peek(), Some(Tok::Ident(w)) if TYPES.contains(&w.as_str())) && !matches!(self.peek_at(1), Some(Tok::Ident(w)) if w == "in") { - Some(self.ty()?) - } else { - None - }; - let var = self.name()?; - if !self.eat_word("in") { - return self.err(diag::EXPECTED, format!("expected `in` after `for {}`", var.text)); - } - let over = if self.is_sym("[") { - let open = self.here(); - self.pos += 1; - let r = self.index_item()?; - self.expect("]")?; - if !matches!(r.kind, E::Range(..)) { - return Err(Diagnostic::new(diag::BAD_RANGE, Some(open.span(self.src)), "a range is `[start:end]` or `[start:step:end]`")); - } - r - } else { - self.expr()? - }; - let body = self.body()?; - Ok(S::For { ty, var, over, body }) - } - "while" => { - self.pos += 1; - self.expect("(")?; - let cond = self.expr()?; - self.expect(")")?; - let body = self.body()?; - Ok(S::While { cond, body }) - } - "switch" => { - self.pos += 1; - self.expect("(")?; - let on = self.expr()?; - self.expect(")")?; - self.expect("{")?; - let (mut cases, mut default) = (Vec::new(), None); - while !self.eat("}") { - let at = self.here(); - if self.eat_word("case") { - let mut values = vec![self.expr()?]; - while self.eat(",") { - values.push(self.expr()?); - } - let body = self.block()?; - cases.push(Case { values, body, at }); - } else if self.eat_word("default") { - if default.is_some() { - return Err(Diagnostic::new(diag::MISPLACED, Some(at.span(self.src)), "a `switch` with two `default`s")); - } - default = Some(self.block()?); - } else if self.pos >= self.toks.len() { - return self.err(diag::UNCLOSED, "expected `}`"); - } else { - return self.err(diag::EXPECTED, "expected `case` or `default`"); - } - } - Ok(S::Switch { on, cases, default }) - } + "for" => self.for_stmt(), + "while" => self.while_stmt(), + "switch" => self.switch(), "break" | "continue" | "end" => { self.pos += 1; self.end()?; @@ -546,52 +264,323 @@ impl<'a> Parser<'a> { _ => S::End, }) } - "return" => { - self.pos += 1; - let value = if self.is_sym(";") { None } else { Some(self.expr()?) }; - self.end()?; - Ok(S::Return(value)) - } + "return" => self.return_stmt(), "ctrl" | "negctrl" | "inv" | "pow" if matches!(self.peek_at(1), Some(Tok::Sym("@" | "("))) => { let mods = self.modifiers()?; let name = self.name()?; self.call(mods, name) } + _ => self.named(), + } + } + + #[inline(never)] + fn version_line(&mut self) -> R { + self.pos += 1; + let mut v = String::new(); + while let Some(t) = self.peek() { + if *t == Tok::Sym(";") { + break; + } + v.push_str(&match &self.toks[self.pos].tok { + Tok::Float(_) | Tok::Int(_) => self.src[self.toks[self.pos].start..self.toks[self.pos].end].to_string(), + t => text(t), + }); + self.pos += 1; + } + self.end()?; + Ok(S::Version(v)) + } + + #[inline(never)] + fn include(&mut self) -> R { + self.pos += 1; + let file = match self.peek() { + Some(Tok::Str(s)) => s.clone(), + _ => return self.err(diag::EXPECTED, "expected a file name in quotes"), + }; + self.pos += 1; + self.end()?; + Ok(S::Include(file)) + } + + /// `[e]`, if one comes next: a register's or a type's size. + fn size(&mut self) -> R> { + if self.eat("[") { + let e = self.expr()?; + self.expect("]")?; + Ok(Some(e)) + } else { + Ok(None) + } + } + + #[inline(never)] + fn qubit(&mut self) -> R { + self.pos += 1; + let size = self.size()?; + let name = self.name()?; + self.end()?; + Ok(S::Qubit { name, size }) + } + + #[inline(never)] + fn old_register(&mut self, creg: bool) -> R { + self.pos += 1; + let name = self.name()?; + let size = self.size()?; + if creg { + let init = if self.eat("=") { Some(self.expr()?) } else { None }; + self.end()?; + return Ok(S::Decl { ty: Type::Bit(Some(Box::new(size.unwrap_or(Expr { kind: E::Int(1), at: name.at })))), name, init, how: How::Plain }); + } + self.end()?; + Ok(S::Qubit { name, size }) + } + + #[inline(never)] + fn alias(&mut self) -> R { + self.pos += 1; + let name = self.name()?; + self.expect("=")?; + let value = self.concat()?; + self.end()?; + Ok(S::Alias { name, value }) + } + + #[inline(never)] + fn gate(&mut self, opaque: bool) -> R { + self.pos += 1; + let name = self.name()?; + let mut params = Vec::new(); + if self.eat("(") && !self.eat(")") { + loop { + params.push(self.name()?); + if self.eat(")") { + break; + } + self.expect(",")?; + } + } + let mut qubits = vec![self.name()?]; + while self.eat(",") { + qubits.push(self.name()?); + } + if opaque { + self.end()?; + return Ok(S::Opaque { name, params, qubits }); + } + let body = self.block()?; + Ok(S::Gate { name, params, qubits, body }) + } + + #[inline(never)] + fn def(&mut self) -> R { + self.pos += 1; + let name = self.name()?; + self.expect("(")?; + let mut args = Vec::new(); + if !self.eat(")") { + loop { + args.push(self.arg()?); + if self.eat(")") { + break; + } + self.expect(",")?; + } + } + let ret = if self.eat("->") { Some(self.ty()?) } else { None }; + let body = self.block()?; + Ok(S::Def { name, args, ret, body }) + } + + #[inline(never)] + fn extern_decl(&mut self) -> R { + self.pos += 1; + let name = self.name()?; + self.expect("(")?; + let mut depth = 1usize; + while depth > 0 { + match self.peek() { + Some(Tok::Sym("(")) => depth += 1, + Some(Tok::Sym(")")) => depth -= 1, + None => return self.err(diag::UNCLOSED, "expected `)`"), + _ => {} + } + self.pos += 1; + } + if self.eat("->") { + self.ty()?; + } + self.end()?; + Ok(S::Extern { name }) + } + + /// `measure`, `reset`, `barrier` or `delay`, the word read next. + #[inline(never)] + fn quantum(&mut self, word: &str) -> R { + self.pos += 1; + let s = match word { + "measure" => { + let src = self.operand()?; + let target = if self.eat("->") { Some(self.operand()?) } else { None }; + S::Measure { src, target } + } + "reset" => S::Reset(self.operand()?), + "barrier" => S::Barrier(self.operands_opt()?), _ => { - let name = self.name()?; - match self.peek() { - Some(Tok::Sym("=" | "+=" | "-=" | "*=" | "/=" | "%=" | "**=" | "&=" | "|=" | "^=" | "~=" | "<<=" | ">>=")) => { - let target = Expr { kind: E::Name(name.text), at: name.at }; - self.assignment(target) - } - Some(Tok::Sym("[")) => { - // `c[0] = …` assigns; `x[20ns] q;` is a gate with a duration. - let open = self.pos; - let target = self.postfix(Expr { kind: E::Name(name.text.clone()), at: name.at })?; - if matches!(self.peek(), Some(Tok::Sym(s)) if s.ends_with('=') && !matches!(*s, "==" | "!=" | "<=" | ">=")) { - self.assignment(target) - } else { - self.pos = open; - self.call(Vec::new(), name) - } - } - Some(Tok::Sym("(")) if name.text != "gphase" => { - // `f(a, b);` calls a subroutine; `rz(t) q;` is a gate. - let open = self.pos; - let args = self.args()?; - if self.is_sym(";") { - self.pos += 1; - let at = name.at.to(self.last()); - // A call that is a statement stands at the statement's depth. - self.depths.calls.insert(name.at.start, self.depth); - return Ok(S::Expr(Expr { kind: E::Call(name, args), at })); - } - self.pos = open; - self.call(Vec::new(), name) - } - _ => self.call(Vec::new(), name), + self.expect("[")?; + let duration = self.expr()?; + self.expect("]")?; + let operands = self.operands_opt()?; + S::Delay { duration, operands } + } + }; + self.end()?; + Ok(s) + } + + #[inline(never)] + fn box_stmt(&mut self) -> R { + self.pos += 1; + let duration = self.size()?; + let body = self.block()?; + Ok(S::Box { duration, body }) + } + + /// `(e)`, a condition. + fn condition(&mut self) -> R { + self.expect("(")?; + let cond = self.expr()?; + self.expect(")")?; + Ok(cond) + } + + #[inline(never)] + fn if_stmt(&mut self) -> R { + let mut arms = Vec::new(); + loop { + self.pos += 1; + let cond = self.condition()?; + let then = self.body()?; + arms.push((cond, then)); + if !self.eat_word("else") { + return Ok(S::If { arms, other: None }); + } + // An `else if` is the next arm, read in this loop. + if !matches!(self.peek(), Some(Tok::Ident(w)) if w == "if") { + return Ok(S::If { arms, other: Some(self.body()?) }); + } + } + } + + #[inline(never)] + fn for_stmt(&mut self) -> R { + self.pos += 1; + let ty = if matches!(self.peek(), Some(Tok::Ident(w)) if TYPES.contains(&w.as_str())) && !matches!(self.peek_at(1), Some(Tok::Ident(w)) if w == "in") { + Some(self.ty()?) + } else { + None + }; + let var = self.name()?; + if !self.eat_word("in") { + return self.err(diag::EXPECTED, format!("expected `in` after `for {}`", var.text)); + } + let over = if self.is_sym("[") { + let open = self.here(); + self.pos += 1; + let (r, _) = self.index_item()?; + self.expect("]")?; + if !matches!(r.kind, E::Range(..)) { + return Err(Diagnostic::new(diag::BAD_RANGE, Some(open.span(self.src)), "a range is `[start:end]` or `[start:step:end]`")); + } + r + } else { + self.expr()? + }; + let body = self.body()?; + Ok(S::For { ty, var, over, body }) + } + + #[inline(never)] + fn while_stmt(&mut self) -> R { + self.pos += 1; + let cond = self.condition()?; + let body = self.body()?; + Ok(S::While { cond, body }) + } + + #[inline(never)] + fn switch(&mut self) -> R { + self.pos += 1; + let on = self.condition()?; + self.expect("{")?; + let (mut cases, mut default) = (Vec::new(), None); + while !self.eat("}") { + let at = self.here(); + if self.eat_word("case") { + let mut values = vec![self.expr()?]; + while self.eat(",") { + values.push(self.expr()?); + } + let body = self.block()?; + cases.push(Case { values, body, at }); + } else if self.eat_word("default") { + if default.is_some() { + return Err(Diagnostic::new(diag::MISPLACED, Some(at.span(self.src)), "a `switch` with two `default`s")); + } + default = Some(self.block()?); + } else if self.pos >= self.toks.len() { + return self.err(diag::UNCLOSED, "expected `}`"); + } else { + return self.err(diag::EXPECTED, "expected `case` or `default`"); + } + } + Ok(S::Switch { on, cases, default }) + } + + #[inline(never)] + fn return_stmt(&mut self) -> R { + self.pos += 1; + let value = if self.is_sym(";") { None } else { Some(self.expr()?) }; + self.end()?; + Ok(S::Return(value)) + } + + /// A statement that starts with a name: an assignment, a subroutine's + /// call or a gate's. + #[inline(never)] + fn named(&mut self) -> R { + let name = self.name()?; + match self.peek() { + Some(Tok::Sym("=" | "+=" | "-=" | "*=" | "/=" | "%=" | "**=" | "&=" | "|=" | "^=" | "~=" | "<<=" | ">>=")) => { + let target = Expr { kind: E::Name(name.text), at: name.at }; + self.assignment(target) + } + Some(Tok::Sym("[")) => { + // `c[0] = …` assigns; `x[20ns] q;` is a gate with a duration. + let open = self.pos; + let (target, _) = self.postfix(Expr { kind: E::Name(name.text.clone()), at: name.at }, 1)?; + if matches!(self.peek(), Some(Tok::Sym(s)) if s.ends_with('=') && !matches!(*s, "==" | "!=" | "<=" | ">=")) { + self.assignment(target) + } else { + self.pos = open; + self.call(Vec::new(), name) } } + Some(Tok::Sym("(")) if name.text != "gphase" => { + // `f(a, b);` calls a subroutine; `rz(t) q;` is a gate. + let open = self.pos; + let args = self.args()?; + if self.is_sym(";") { + self.pos += 1; + let at = name.at.to(self.last()); + return Ok(S::Expr(Expr { kind: E::Call(name, args), at })); + } + self.pos = open; + self.call(Vec::new(), name) + } + _ => self.call(Vec::new(), name), } } @@ -629,12 +618,7 @@ impl<'a> Parser<'a> { } } - /// A type, one deeper than what holds it: what its brackets hold is one deeper again. pub fn ty(&mut self) -> R { - self.below(Self::ty_here) - } - - fn ty_here(&mut self) -> R { let w = match self.peek() { Some(Tok::Ident(w)) => w.clone(), _ => return self.err(diag::EXPECTED, "expected a type"), @@ -651,7 +635,7 @@ impl<'a> Parser<'a> { "stretch" => Type::Stretch, "complex" => { if self.eat("[") { - let t = self.ty()?; + let t = self.nested(Self::ty)?; self.expect("]")?; Type::Complex(Some(Box::new(t))) } else { @@ -660,7 +644,7 @@ impl<'a> Parser<'a> { } "array" => { self.expect("[")?; - let t = self.ty()?; + let t = self.nested(Self::ty)?; let mut dims = Vec::new(); while self.eat(",") { if self.is_sym("#") { @@ -673,6 +657,9 @@ impl<'a> Parser<'a> { continue; } dims.push(self.expr()?); + if dims.len() > MAX_ARRAY_DIMS { + return self.err(diag::NESTING, format!("an array has at most {MAX_ARRAY_DIMS} dimensions: each is a level of its value, read by what copies and writes it")); + } } self.expect("]")?; Type::Array(Box::new(t), dims) @@ -767,21 +754,33 @@ impl<'a> Parser<'a> { }; let operands = if name.text == "gphase" { self.operands_opt()? } else { self.operands()? }; self.end()?; - self.depths.calls.insert(name.at.start, self.depth); Ok(S::Call { mods, name, params, duration, operands }) } /// `(e, e, …)`, its parenthesis not yet read. fn args(&mut self) -> R> { + self.args_h().map(|(a, _)| a) + } + + /// [`Parser::args`], with the height of the tallest. + fn args_h(&mut self) -> R<(Vec, usize)> { self.expect("(")?; - let mut out = Vec::new(); - if self.eat(")") { - return Ok(out); + self.nested(|p| p.list(")")) + } + + /// `e, e, …` up to and including `close`, its opening already read: the + /// items, and the height of the tallest. + fn list(&mut self, close: &str) -> R<(Vec, usize)> { + let (mut out, mut h) = (Vec::new(), 0); + if self.eat(close) { + return Ok((out, h)); } loop { - out.push(self.expr()?); - if self.eat(")") { - return Ok(out); + let (e, he) = self.expr_h()?; + out.push(e); + h = h.max(he); + if self.eat(close) { + return Ok((out, h)); } self.expect(",")?; } @@ -801,270 +800,304 @@ impl<'a> Parser<'a> { /// A qubit or bit reference: a name, possibly indexed. fn operand(&mut self) -> R { - self.below(|p| { - let from = p.depth; - let n = p.name()?; - Ok(p.reaching(from, |p| p.postfix(Expr { kind: E::Name(n.text), at: n.at }))?.0) - }) + self.operand_h().map(|(e, _)| e) + } + + fn operand_h(&mut self) -> R<(Expr, usize)> { + let n = self.name()?; + self.postfix(Expr { kind: E::Name(n.text), at: n.at }, 1) } /// `a ++ b ++ …` (an alias's value). fn concat(&mut self) -> R { - let (mut a, mut reach) = self.reaching(0, Self::expr)?; + let (mut a, mut ha) = self.expr_h()?; while self.eat("++") { - let (b, rb) = self.operand_below(Self::expr)?; - // What `++` holds is one deeper than it: the chain so far, and `b`. - reach = (reach + 1).max(rb); - self.reached(reach)?; + let (b, hb) = self.expr_h()?; + ha = 1 + ha.max(hb); + self.fits(ha)?; let at = a.at.to(b.at); a = Expr { kind: E::Concat(Box::new(a), Box::new(b)), at }; } Ok(a) } - /// The right operand of an operator in a chain, read one deeper than the operator, - /// and the deepest depth it reaches. - fn operand_below(&mut self, f: impl FnOnce(&mut Self) -> R) -> R<(T, u32)> { - self.depth += 1; - let r = self.reaching(0, f); - self.depth -= 1; - r + pub fn expr(&mut self) -> R { + self.expr_h().map(|(e, _)| e) } - pub fn expr(&mut self) -> R { + /// An expression and its height: the longest path from it to a leaf, + /// which every reader of its tree recurses along, held with the depth + /// it stands at to [`MAX_NESTING`]. + fn expr_h(&mut self) -> R<(Expr, usize)> { self.binary(0) } - /// Binary operators by precedence, loosest first, read by precedence climbing: `min` - /// is the loosest level this read takes, and a chain of any length and any mix of - /// operators is read in one frame, a tighter right operand in one more. OpenQASM 3 - /// reads `^` as exclusive or; a program that does not say `OPENQASM 3` reads it as + /// Binary operators by precedence, loosest first: the operator at + /// `LEVELS[i]` binds operands of the levels after it. OpenQASM 3 reads `^` + /// as exclusive or; a program that does not say `OPENQASM 3` reads it as /// OpenQASM 2 did, a power, binding as tightly as `**`. - fn binary(&mut self, min: usize) -> R { + /// + /// Read by precedence climbing: the operators at `min` or tighter, each + /// left to right, its right operand read at the next level; the tree is + /// the one reading each level in turn makes, with one frame a level of + /// nesting rather than one a precedence level. + fn binary(&mut self, min: usize) -> R<(Expr, usize)> { const LEVELS: [&[&str]; 10] = [&["||"], &["&&"], &["|"], &["^"], &["&"], &["==", "!="], &["<", "<=", ">", ">="], &["<<", ">>"], &["+", "-"], &["*", "/", "%"]]; - let (mut a, mut reach) = self.reaching(0, Self::unary)?; + let (mut a, mut ha) = self.unary()?; + // The run being read: operators of one level, whose operands are read + // in this loop, so a run of any length holds no frame and no level. A + // later operator can only be of a lower level (the right operand took + // every higher one), and takes what was read as its first operand. + let (mut run, mut hr, mut at_level) = (Vec::new(), 0, usize::MAX); loop { let found = match self.peek() { Some(Tok::Sym(s)) if !(*s == "^" && self.version != Version::Three) => LEVELS.iter().position(|l| l.contains(s)).map(|level| (*s, level)), _ => None, }; - let Some((op, level)) = found.filter(|&(_, level)| level >= min) else { return Ok(a) }; + let Some((op, level)) = found.filter(|&(_, level)| level >= min) else { break }; self.pos += 1; - // Its right operand holds only what binds more tightly: `a + b + c` is - // `(a + b) + c`, and `a + b * c` is `a + (b * c)`. - let (b, rb) = self.operand_below(|p| if level + 1 == LEVELS.len() { p.unary() } else { p.binary(level + 1) })?; - // The operator takes this place, and what it holds is one deeper: the chain so - // far and its right operand. - reach = (reach + 1).max(rb); - self.reached(reach)?; - let at = a.at.to(b.at); - a = Expr { kind: E::Binary(op, Box::new(a), Box::new(b)), at }; - } - } - - /// An operand, one deeper than what holds it. - fn unary(&mut self) -> R { - self.below(|p| { - let from = p.depth; - Ok(p.reaching(from, Self::unary_here)?.0) - }) - } - - fn unary_here(&mut self) -> R { - let at = self.here(); - for op in ["-", "!", "~", "+"] { - if self.eat(op) { - let e = self.unary()?; - if op == "+" { - return Ok(e); - } - let at = at.to(e.at); - return Ok(Expr { kind: E::Unary(op, Box::new(e)), at }); + let (b, hb) = self.nested(|p| p.binary(level + 1))?; + if level != at_level { + (a, ha) = self.close_run(a, ha, std::mem::take(&mut run), hr)?; + (hr, at_level) = (0, level); } + hr = hr.max(hb); + run.push((op, b)); } - let (base, rbase) = self.reaching(self.depth, Self::postfix_atom)?; + self.close_run(a, ha, run, hr) + } + + /// `first` and the operators and operands of a run after it, as one + /// expression: a level of the tree however long the run is. + fn close_run(&self, first: Expr, h: usize, mut run: Vec<(&'static str, Expr)>, h_run: usize) -> R<(Expr, usize)> { + let Some(last) = run.last() else { return Ok((first, h)) }; + let at = first.at.to(last.1.at); + let h = 1 + h.max(h_run); + self.fits(h)?; + let kind = if run.len() == 1 { + let (op, b) = run.pop().expect("a run of one"); + E::Binary(op, Box::new(first), Box::new(b)) + } else { + E::Chain(Box::new(first), run) + }; + Ok((Expr { kind, at }, h)) + } + + /// Prefix operators, then a power: `-x`, `!b`, `~k`, `+x`, `a ** b` + /// (`a ^ b` outside OpenQASM 3). A run of prefix operators is read in one + /// frame, the innermost applied first. + fn unary(&mut self) -> R<(Expr, usize)> { + let mut ops = Vec::new(); + while let Some(op) = ["-", "!", "~", "+"].into_iter().find(|op| self.is_sym(op)) { + ops.push((op, self.here())); + self.pos += 1; + } + let (mut e, mut h) = self.power()?; + for (op, at) in ops.into_iter().rev() { + if op == "+" { + continue; + } + h += 1; + self.fits(h)?; + let at = at.to(e.at); + e = Expr { kind: E::Unary(op, Box::new(e)), at }; + } + Ok((e, h)) + } + + /// `a`, or `a ** b` with `b` read as a prefix expression: `**` (and `^` + /// outside OpenQASM 3) groups to the right. + fn power(&mut self) -> R<(Expr, usize)> { + let (base, hb) = self.postfix_atom()?; let caret = self.version != Version::Three && self.is_sym("^"); if self.is_sym("**") || caret { let op = if caret { "^" } else { "**" }; self.pos += 1; - let e = self.unary()?; - // The operator takes the base's place: the base is one deeper. - self.reached(rbase + 1)?; + let (e, he) = self.nested(Self::unary)?; + let h = 1 + hb.max(he); + self.fits(h)?; let at = base.at.to(e.at); - return Ok(Expr { kind: E::Binary(op, Box::new(base), Box::new(e)), at }); + return Ok((Expr { kind: E::Binary(op, Box::new(base), Box::new(e)), at }, h)); } - Ok(base) + Ok((base, hb)) } - fn postfix_atom(&mut self) -> R { - let a = self.atom()?; - self.postfix(a) + fn postfix_atom(&mut self) -> R<(Expr, usize)> { + let (a, h) = self.atom()?; + self.postfix(a, h) } - /// `a[i][j]…`: each index takes the place of what it indexes, which is one deeper, as - /// what its bracket holds is. - fn postfix(&mut self, mut a: Expr) -> R { - let mut reach = self.reach; + fn postfix(&mut self, mut a: Expr, mut h: usize) -> R<(Expr, usize)> { while self.is_sym("[") { self.pos += 1; - let (items, ri) = self.reaching(0, Self::index_items)?; + let (items, hi) = self.nested(|p| { + if p.is_sym("{") { + let open = p.here(); + p.pos += 1; + let (set, hs) = p.nested(|p| p.list("}"))?; + return Ok((vec![Expr { kind: E::Set(set), at: open.to(p.last()) }], hs + 1)); + } + let (mut items, mut hi) = (Vec::new(), 0); + loop { + let (e, he) = p.index_item()?; + items.push(e); + hi = hi.max(he); + if !p.eat(",") { + return Ok((items, hi)); + } + } + })?; self.expect("]")?; - reach = (reach + 1).max(ri); - self.reached(reach)?; + h = 1 + h.max(hi); + self.fits(h)?; let at = a.at.to(self.last()); a = Expr { kind: E::Index(Box::new(a), items), at }; } - Ok(a) - } - - /// What one `[ ]` holds, its bracket read. - fn index_items(&mut self) -> R> { - { - let mut items = Vec::new(); - if self.is_sym("{") { - let open = self.here(); - self.pos += 1; - let mut set = Vec::new(); - if !self.eat("}") { - loop { - set.push(self.expr()?); - if self.eat("}") { - break; - } - self.expect(",")?; - } - } - items.push(Expr { kind: E::Set(set), at: open.to(self.last()) }); - } else { - loop { - items.push(self.index_item()?); - if !self.eat(",") { - break; - } - } - } - Ok(items) - } + Ok((a, h)) } /// One item inside `[ ]`: an expression, or a range `a:b`, `a:s:b`, `:`. - fn index_item(&mut self) -> R { + fn index_item(&mut self) -> R<(Expr, usize)> { let at = self.here(); - let first = if self.is_sym(":") { None } else { Some(Box::new(self.expr()?)) }; + let mut h = 0; + let mut part = |p: &mut Self| -> R>> { + let (e, he) = p.expr_h()?; + h = h.max(he); + Ok(Some(Box::new(e))) + }; + let first = if self.is_sym(":") { None } else { part(self)? }; if !self.eat(":") { - return Ok(*first.expect("an expression was read")); + return Ok((*first.expect("an expression was read"), h)); } - let second = if self.is_sym(":") || self.is_sym("]") || self.is_sym(",") { None } else { Some(Box::new(self.expr()?)) }; + let second = if self.is_sym(":") || self.is_sym("]") || self.is_sym(",") { None } else { part(self)? }; let kind = if self.eat(":") { - let third = if self.is_sym("]") || self.is_sym(",") { None } else { Some(Box::new(self.expr()?)) }; + let third = if self.is_sym("]") || self.is_sym(",") { None } else { part(self)? }; E::Range(first, second, third) } else { E::Range(first, None, second) }; - Ok(Expr { kind, at: at.to(self.last()) }) + let h = h + 1; + self.fits(h)?; + Ok((Expr { kind, at: at.to(self.last()) }, h)) } - fn atom(&mut self) -> R { + /// A number, a name, a call, a cast, a set, a measurement, or an + /// expression in parentheses. A dispatcher: the forms that nest are each + /// read by a function of their own, so each level stacks small frames. + fn atom(&mut self) -> R<(Expr, usize)> { + match self.peek() { + Some(Tok::Sym("(")) => self.paren(), + Some(Tok::Sym("{")) => self.set(), + Some(Tok::Ident(w)) if matches!(w.as_str(), "true" | "false" | "measure" | "durationof") => self.word(), + Some(Tok::Ident(w)) if TYPES.contains(&w.as_str()) && w != "array" && matches!(self.peek_at(1), Some(Tok::Sym("(" | "["))) => self.cast(), + Some(Tok::Ident(_)) => self.name_or_call(), + _ => self.literal(), + } + } + + /// `( e )`: the expression, its place widened to the parentheses. + #[inline(never)] + fn paren(&mut self) -> R<(Expr, usize)> { + let at = self.here(); + self.pos += 1; + let (e, h) = self.nested(Self::expr_h)?; + self.expect(")")?; + Ok((Expr { kind: e.kind, at: at.to(self.last()) }, h)) + } + + /// `{a, b, c}`: an array's value, a loop's set. + #[inline(never)] + fn set(&mut self) -> R<(Expr, usize)> { + let at = self.here(); + self.pos += 1; + let (set, h) = self.nested(|p| p.list("}"))?; + self.node(E::Set(set), at, h + 1) + } + + /// A name, or a call `f(a, …)`. + #[inline(never)] + fn name_or_call(&mut self) -> R<(Expr, usize)> { + let at = self.here(); + let name = self.name()?; + if self.is_sym("(") { + let (args, h) = self.args_h()?; + return self.node(E::Call(name, args), at, h + 1); + } + self.node(E::Name(name.text), at, 1) + } + + /// A cast: `float(x)`, `int[8](x)`, `angle[20](t)`. + #[inline(never)] + fn cast(&mut self) -> R<(Expr, usize)> { + let at = self.here(); + let save = self.pos; + let (ty, (e, h)) = self.nested(|p| { + let ty = p.ty()?; + if !p.is_sym("(") { + p.pos = save; + let t = match p.peek() { + Some(Tok::Ident(w)) => w.clone(), + _ => String::new(), + }; + return p.err(diag::EXPECTED, format!("expected `(` after the type `{t}` to cast")); + } + p.pos += 1; + let e = p.expr_h()?; + p.expect(")")?; + Ok((ty, e)) + })?; + self.node(E::Cast(ty, Box::new(e)), at, h + 1) + } + + /// `true`, `false`, `measure q`, `durationof({ … })`. + #[inline(never)] + fn word(&mut self) -> R<(Expr, usize)> { + let at = self.here(); + let Some(Tok::Ident(w)) = self.peek() else { unreachable!("a word") }; + let w = w.clone(); + self.pos += 1; + match w.as_str() { + "true" | "false" => self.node(E::Bool(w == "true"), at, 1), + "measure" => { + let (q, h) = self.nested(Self::operand_h)?; + self.node(E::Measure(Box::new(q)), at, h + 1) + } + _ => { + self.expect("(")?; + let body = self.nested(Self::block)?; + self.expect(")")?; + self.node(E::DurationOf(body), at, 1) + } + } + } + + /// A number, a duration or a string. + #[inline(never)] + fn literal(&mut self) -> R<(Expr, usize)> { let at = self.here(); let Some(tok) = self.peek() else { return self.err(diag::EXPECTED, "expected a number, a name or `(`") }; let kind = match tok { - Tok::Int(v) => { - self.pos += 1; - E::Int(*v) - } - Tok::Float(v) => { - self.pos += 1; - E::Float(*v) - } - Tok::Imag(v) => { - self.pos += 1; - E::Imag(*v) - } - Tok::Duration(v, u) => { - self.pos += 1; - E::Duration(*v, *u) - } - Tok::Str(s) => { - self.pos += 1; - E::Str(s.clone()) - } - Tok::Sym("(") => { - self.pos += 1; - let e = self.expr()?; - self.expect(")")?; - return Ok(Expr { kind: e.kind, at: at.to(self.last()) }); - } - Tok::Sym("{") => { - self.pos += 1; - let mut set = Vec::new(); - if !self.eat("}") { - loop { - set.push(self.expr()?); - if self.eat("}") { - break; - } - self.expect(",")?; - } - } - E::Set(set) - } - Tok::Ident(w) => { - let w = w.clone(); - match w.as_str() { - "true" | "false" => { - self.pos += 1; - E::Bool(w == "true") - } - "measure" => { - self.pos += 1; - E::Measure(Box::new(self.operand()?)) - } - "durationof" => { - self.pos += 1; - self.expect("(")?; - let body = self.block()?; - self.expect(")")?; - E::DurationOf(body) - } - t if TYPES.contains(&t) && t != "array" && matches!(self.peek_at(1), Some(Tok::Sym("(" | "["))) => { - // A cast: `float(x)`, `int[8](x)`, `angle[20](t)`. - let save = self.pos; - let ty = self.ty()?; - if !self.is_sym("(") { - self.pos = save; - return self.err(diag::EXPECTED, format!("expected `(` after the type `{t}` to cast")); - } - self.pos += 1; - let e = self.expr()?; - self.expect(")")?; - E::Cast(ty, Box::new(e)) - } - _ => { - let name = self.name()?; - if self.is_sym("(") { - let args = self.args()?; - self.depths.calls.insert(name.at.start, self.depth); - E::Call(name, args) - } else { - E::Name(name.text) - } - } - } - } + Tok::Int(v) => E::Int(*v), + Tok::Float(v) => E::Float(*v), + Tok::Imag(v) => E::Imag(*v), + Tok::Duration(v, u) => E::Duration(*v, *u), + Tok::Str(s) => E::Str(s.clone()), t => return self.err(diag::EXPECTED, format!("expected a number, a name or `(`, not `{}`", text(t))), }; - Ok(Expr { kind, at: at.to(self.last()) }) + self.pos += 1; + self.node(kind, at, 1) + } + + /// The expression `kind` from `at` to the last token read, of height `h`, + /// or the refusal past [`MAX_NESTING`]. + fn node(&self, kind: E, at: Loc, h: usize) -> R<(Expr, usize)> { + self.fits(h)?; + Ok((Expr { kind, at: at.to(self.last()) }, h)) } } /// The program as written, and the version it declares. pub fn parse(src: &str, toks: &[Token]) -> R<(Vec, Version)> { - parse_depths(src, toks).map(|(stmts, version, _)| (stmts, version)) -} - -/// [`parse`], and how deep its calls and definitions stand ([`Depths`]), which the reader -/// counts nesting through. -pub(crate) fn parse_depths(src: &str, toks: &[Token]) -> R<(Vec, Version, Depths)> { let mut p = Parser::new(src, toks); // The version line comes first, and decides how `^` reads. let first_word = toks.iter().find(|t| !matches!(t.tok, Tok::Pragma(_) | Tok::Annotation(..))); @@ -1078,8 +1111,7 @@ pub(crate) fn parse_depths(src: &str, toks: &[Token]) -> R<(Vec, Version, p.version = if s.starts_with('2') { Version::Two } else { Version::Three }; } let stmts = p.program()?; - let depths = p.take_depths(); - Ok((stmts, p.version, depths)) + Ok((stmts, p.version)) } #[cfg(test)] diff --git a/wai/wai-quantum/src/qasm/pulse.rs b/wai/wai-quantum/src/qasm/pulse.rs index e020dcb65..06aab7d83 100644 --- a/wai/wai-quantum/src/qasm/pulse.rs +++ b/wai/wai-quantum/src/qasm/pulse.rs @@ -1469,6 +1469,16 @@ impl<'a> Lower<'a> { let (x, y) = (self.eval(a, locals)?, self.eval(b, locals)?); self.arith(op, x, y, at) } + E::Chain(first, rest) => { + let mut x = self.eval(first, locals)?; + let mut whole = first.at; + for (op, e) in rest { + let y = self.eval(e, locals)?; + whole = whole.to(e.at); + x = self.arith(op, x, y, whole)?; + } + Ok(x) + } E::Call(name, args) => self.call_value(&name.text, args, locals, at), E::Set(items) => { let mut s = Vec::with_capacity(items.len()); diff --git a/wai/wai-quantum/src/qasm/pulse/tests.rs b/wai/wai-quantum/src/qasm/pulse/tests.rs index b3b532347..8553a240a 100644 --- a/wai/wai-quantum/src/qasm/pulse/tests.rs +++ b/wai/wai-quantum/src/qasm/pulse/tests.rs @@ -211,3 +211,21 @@ fn the_schedule_writes_as_json_with_every_instruction() { assert_eq!(j["instructions"][2]["bit"], "c"); assert_eq!(j["calls"][0]["name"], "measure"); } + +/// An expression in a calibration nested past the reader's limit (the pulse +/// reader reads a calibration's expressions with the program's parser) is +/// refused, `Q006`, on a runtime worker's 2 MiB stack, as deep as a request +/// of a few kilobytes nests it. +#[test] +fn a_calibration_s_expression_nested_past_the_limit_is_refused() { + let deep = format!("{CAL}defcal x $0 {{ play(q0, gaussian({}0.2{}, 160dt, 40dt)); }}\nx $0;\n", "(".repeat(5_000), ")".repeat(5_000)); + #[cfg(not(target_family = "wasm"))] + let d = std::thread::Builder::new().stack_size(2 << 20).spawn(move || refused(&deep)).unwrap().join().unwrap(); + // No threads under WASI: the module's own stack. + #[cfg(target_family = "wasm")] + let d = refused(&deep); + assert_eq!(d.code, crate::qasm::diag::NESTING, "{d}"); + let inside = format!("{CAL}defcal x $0 {{ play(q0, gaussian({}0.2{}, 160dt, 40dt)); }}\nx $0;\n", "(".repeat(40), ")".repeat(40)); + assert_eq!(plays(&sched(&inside)).len(), 1); +} + diff --git a/wai/wai-quantum/src/qasm/unroll.rs b/wai/wai-quantum/src/qasm/unroll.rs index 08cf7c7bd..6128291e0 100644 --- a/wai/wai-quantum/src/qasm/unroll.rs +++ b/wai/wai-quantum/src/qasm/unroll.rs @@ -9,12 +9,14 @@ //! as it was, its integer names still replaced by their values; the reader //! reads that too. +use std::cell::Cell; use std::collections::{BTreeSet, HashMap}; use super::Inputs; use super::diag::{self, Diagnostic}; -use super::elab::{MAX_STEPS, RESERVED}; +use super::elab::{MAX_HELD, MAX_STEPS, RESERVED}; use super::lex::{Tok, Token, lex, text}; +use super::parse::MAX_NESTING; type R = Result; @@ -210,7 +212,7 @@ fn unroll(src: &str, toks: Vec, inputs: &Inputs) -> R> { } return Ok(toks); } - let mut u = Unroller { src, toks: &toks, inputs, env: HashMap::new(), shadow: BTreeSet::new(), taken: taken_names(&toks), out: Vec::with_capacity(toks.len()), used: Vec::new(), iterations: 0 }; + let mut u = Unroller { src, toks: &toks, inputs, env: HashMap::new(), shadow: BTreeSet::new(), taken: taken_names(&toks), out: Vec::with_capacity(toks.len()), used: Vec::new(), iterations: 0, depth: Cell::new(0) }; u.seq(0, toks.len())?; if let Some(unused) = inputs.keys().find(|k| !u.used.contains(k)) { return err(diag::INPUT_VALUE, None, src, format!("the program declares no `input {unused}`")); @@ -266,9 +268,36 @@ struct Unroller<'a> { out: Vec, used: Vec<&'a String>, iterations: u64, + /// How deep the unroller's recursion stands: statements in statements, + /// held to the reader's [`MAX_NESTING`]. + depth: Cell, +} + +/// The refusal of what nests past [`MAX_NESTING`], at `t`. +fn too_deep(t: Option<&Token>, src: &str) -> R { + err( + diag::NESTING, + t, + src, + format!("this nests deeper than {MAX_NESTING} levels (statements, parentheses and operators together); write it in steps, with a `const` for a part, or a long `else if` chain as a `switch`"), + ) } impl<'a> Unroller<'a> { + /// One level deeper, at the token `i`, or the refusal past [`MAX_NESTING`]. + fn enter(&self, i: usize) -> R<()> { + let d = self.depth.get(); + if d >= MAX_NESTING { + return too_deep(self.tok(i.min(self.toks.len().saturating_sub(1))), self.src); + } + self.depth.set(d + 1); + Ok(()) + } + + fn leave(&self) { + self.depth.set(self.depth.get() - 1); + } + fn tok(&self, i: usize) -> Option<&'a Token> { self.toks.get(i).or(self.toks.last()) } @@ -281,6 +310,14 @@ impl<'a> Unroller<'a> { if self.out.len() >= MAX_TOKENS { return err(diag::UNROLL_LIMIT, Some(&t), self.src, format!("the program unrolls past {MAX_TOKENS} tokens")); } + // The tokens written are held as the reader holds a program's lists + // (`elab::MAX_HELD`), and every sixteen count as a step of reading. + if (self.out.len() as u64 + 1) * std::mem::size_of::() as u64 > MAX_HELD { + return err(diag::TOO_LARGE, Some(&t), self.src, format!("the program written out would hold more than {} MiB of tokens; write it out on your own machine, or at smaller inputs", MAX_HELD >> 20)); + } + if self.out.len() % 16 == 15 && super::spend_step() { + return err(diag::UNROLL_LIMIT, Some(&t), self.src, "the programs read for one request unroll past its budget of steps together (wai_quantum::qasm::within_steps)"); + } self.out.push(t); Ok(()) } @@ -347,6 +384,13 @@ impl<'a> Unroller<'a> { /// The index just past the statement at `i` (a loop or `if` included). fn statement_end(&self, i: usize) -> R { + self.enter(i)?; + let r = self.statement_end_here(i); + self.leave(); + r + } + + fn statement_end_here(&self, i: usize) -> R { match self.ident(i) { Some("if") => self.if_extent(i), Some("for") => { @@ -372,17 +416,23 @@ impl<'a> Unroller<'a> { } } - /// The index just past the `if` statement at `i`, its `else` included. - fn if_extent(&self, i: usize) -> R { - if !self.sym(i + 1, "(") { - return self.fail(diag::EXPECTED, i, "expected `(` after `if`"); + /// The index just past the `if` statement at `i`, its `else` included: + /// an `else if` chain followed in a loop, at any length. + fn if_extent(&self, mut i: usize) -> R { + loop { + if !self.sym(i + 1, "(") { + return self.fail(diag::EXPECTED, i, "expected `(` after `if`"); + } + let close = self.close(i + 1, ("(", ")"))?; + let (_, next) = self.body(close)?; + if self.ident(next) != Some("else") { + return Ok(next); + } + if self.ident(next + 1) != Some("if") { + return Ok(self.body(next + 1)?.1); + } + i = next + 1; } - let close = self.close(i + 1, ("(", ")"))?; - let (_, mut next) = self.body(close)?; - if self.ident(next) == Some("else") { - next = if self.ident(next + 1) == Some("if") { self.if_extent(next + 1)? } else { self.body(next + 1)?.1 }; - } - Ok(next) } /// Whether the next token written starts a statement. @@ -438,7 +488,7 @@ impl<'a> Unroller<'a> { /// The integer the tokens `lo..hi` evaluate to. fn value(&self, lo: usize, hi: usize) -> R { - let mut e = IntExpr { src: self.src, toks: &self.toks[lo..hi], pos: 0, env: &self.env, at: self.tok(lo) }; + let mut e = IntExpr { src: self.src, toks: &self.toks[lo..hi], pos: 0, env: &self.env, at: self.tok(lo), depth: 0 }; let v = e.or()?; if e.pos != e.toks.len() { return err(diag::EXPECTED, e.toks.get(e.pos), self.src, format!("unexpected `{}` in an integer expression", text(&e.toks[e.pos].tok))); @@ -505,6 +555,13 @@ impl<'a> Unroller<'a> { } fn seq(&mut self, lo: usize, hi: usize) -> R<()> { + self.enter(lo)?; + let r = self.seq_here(lo, hi); + self.leave(); + r + } + + fn seq_here(&mut self, lo: usize, hi: usize) -> R<()> { let mut i = lo; while i < hi { let statement = self.at_statement_start(); @@ -587,17 +644,20 @@ impl<'a> Unroller<'a> { // A condition on integers alone is decided here. let keep = self.value(i + 2, close - 1)? != 0; let ((tlo, thi), mut next) = self.body(close)?; + let else_if = self.ident(next) == Some("else") && self.ident(next + 1) == Some("if"); + if !keep && else_if { + // The rest of the chain is read here, in this loop, + // so a chain of any length holds no recursion. + i = next + 1; + continue; + } let mut other = None; - if self.ident(next) == Some("else") { - if self.ident(next + 1) == Some("if") { - let end = self.if_extent(next + 1)?; - other = Some((next + 1, end)); - next = end; - } else { - let (span, after) = self.body(next + 1)?; - other = Some(span); - next = after; - } + if else_if { + next = self.if_extent(next + 1)?; + } else if self.ident(next) == Some("else") { + let (span, after) = self.body(next + 1)?; + other = Some(span); + next = after; } if keep { self.seq(tlo, thi)?; @@ -658,7 +718,8 @@ impl<'a> Unroller<'a> { return self.fail(diag::EXPECTED, at, format!("expected `in` after `for {name}`")); } j += 1; - let values: Option> = if self.sym(j, "[") { + // A range's values are made one at a time, as the loop reaches each. + let values: Option>> = if self.sym(j, "[") { let close = self.close(j, ("[", "]"))?; let parts = self.split(j + 1, close - 1, ":"); if !parts.iter().all(|p| self.foldable(p.0, p.1)) { @@ -678,15 +739,15 @@ impl<'a> Unroller<'a> { if count as u64 > MAX_STEPS { return self.fail(diag::UNROLL_LIMIT, at, format!("the loop runs {count} times, so the program unrolls past {MAX_STEPS} statements")); } - Some((0..count).map(|k| a + k * step).collect()) + Some(Box::new((0..count).map(move |k| a + k * step))) } } else if self.sym(j, "{") { let close = self.close(j, ("{", "}"))?; let parts = self.split(j + 1, close - 1, ","); if parts.iter().all(|p| self.foldable(p.0, p.1)) { - let vs = parts.into_iter().map(|(lo, hi)| self.value(lo, hi)).collect::>()?; + let vs: Vec = parts.into_iter().map(|(lo, hi)| self.value(lo, hi)).collect::>()?; j = close; - Some(vs) + Some(Box::new(vs.into_iter())) } else { None } @@ -704,6 +765,9 @@ impl<'a> Unroller<'a> { if self.iterations > MAX_STEPS { return self.fail(diag::UNROLL_LIMIT, at, format!("the program unrolls past {MAX_STEPS} statements")); } + if super::spend_step() { + return self.fail(diag::UNROLL_LIMIT, at, "the programs read for one request unroll past its budget of steps together (wai_quantum::qasm::within_steps)"); + } let old = self.bind(name, v, at)?; self.seq(blo, bhi)?; self.restore(name, old); @@ -721,6 +785,8 @@ struct IntExpr<'a> { pos: usize, env: &'a HashMap, at: Option<&'a Token>, + /// How deep the reading stands: every level passes through `unary`. + depth: usize, } impl IntExpr<'_> { @@ -812,6 +878,16 @@ impl IntExpr<'_> { } fn unary(&mut self) -> R { + if self.depth >= MAX_NESTING { + return too_deep(self.toks.get(self.pos).or(self.at), self.src); + } + self.depth += 1; + let r = self.unary_here(); + self.depth -= 1; + r + } + + fn unary_here(&mut self) -> R { if self.eat("-") { let v = self.unary()?; return self.checked(v.checked_neg()); diff --git a/wai/wai-quantum/src/qasm/value.rs b/wai/wai-quantum/src/qasm/value.rs index 61b15d75f..19f08675a 100644 --- a/wai/wai-quantum/src/qasm/value.rs +++ b/wai/wai-quantum/src/qasm/value.rs @@ -137,6 +137,14 @@ impl Val { /// The value as an integer, if it is one: an integer, a bool, known bits /// (unsigned, the lowest first), or a float with no fraction. + /// How many bits a bit string holds (0 for anything else). + pub fn bits_len(&self) -> usize { + match self { + Val::Bits(b) => b.len(), + _ => 0, + } + } + pub fn integer(&self) -> Option { match self { Val::Int(v) => Some(*v), diff --git a/wai/wai-quantum/src/quantum_frame.rs b/wai/wai-quantum/src/quantum_frame.rs index f9edda91e..fea2e0224 100644 --- a/wai/wai-quantum/src/quantum_frame.rs +++ b/wai/wai-quantum/src/quantum_frame.rs @@ -187,7 +187,7 @@ pub fn parse(text: &str) -> Result { pub fn lower(prog: &crate::quantum_qec_text::CircuitText) -> Result { let mut c = Circuit::default(); let mut shift: Vec = Vec::new(); - lower_items(&prog.items, &mut c, &mut shift)?; + lower_items(&prog.items, &mut c, &mut shift, &mut Unrolling { depth: 0, passes: 0, checking: false })?; let carrier = c.qubits; let mut carried = false; for op in &mut c.ops { @@ -205,9 +205,27 @@ pub fn lower(prog: &crate::quantum_qec_text::CircuitText) -> Result crate::quantum_qec_text::MAX_QUBITS { + return Err(FrameError::Parse { + line: first_line(&prog.items, &["E", "CORRELATED_ERROR"]).unwrap_or(0), + message: format!("{} qubits with the correlated errors' carrier, past the {} a circuit here holds", c.qubits, crate::quantum_qec_text::MAX_QUBITS), + }); + } Ok(c) } +/// Where unrolling stands: how deep in blocks, how many passes through a +/// block's body it has made (each counted with the operations against +/// [`MAX_OPS`], so a block that adds nothing still ends), and whether it is +/// checking the body of a block repeated no times. +struct Unrolling { + depth: usize, + passes: u64, + checking: bool, +} + /// The line of the first instruction named one of `names`, in a block too. fn first_line(items: &[crate::quantum_qec_text::Item], names: &[&str]) -> Option { use crate::quantum_qec_text::Item; @@ -256,23 +274,38 @@ fn pauli_channel_rates(px: f64, py: f64, pz: f64) -> Option<[f64; 3]> { Some(u.map(|x| ((1.0 - x) / 2.0).clamp(0.0, 1.0))) } -fn lower_items(items: &[crate::quantum_qec_text::Item], c: &mut Circuit, shift: &mut Vec) -> Result<(), FrameError> { - use crate::quantum_qec_text::Item; +fn lower_items(items: &[crate::quantum_qec_text::Item], c: &mut Circuit, shift: &mut Vec, u: &mut Unrolling) -> Result<(), FrameError> { + use crate::quantum_qec_text::{Item, MAX_BLOCK_DEPTH}; for it in items { match it { Item::Op(i) => lower_instruction(i, c, shift)?, Item::Repeat { count, body, line, .. } => { + let past = |m: String| FrameError::Parse { line: *line, message: m }; + if u.depth >= MAX_BLOCK_DEPTH { + return Err(past(format!("blocks nested deeper than {MAX_BLOCK_DEPTH}"))); + } + u.depth += 1; // A block repeated no times is still read, so what it holds - // is refused if it would be. - if *count == 0 { - lower_items(body, &mut c.clone(), &mut shift.clone())?; - } - for _ in 0..*count { - lower_items(body, c, shift)?; - if c.ops.len() > MAX_OPS { - return Err(FrameError::Parse { line: *line, message: format!("the circuit unrolls to more than {MAX_OPS} operations") }); - } - } + // is refused if it would be: once, on a copy, and a block + // inside one being checked is read once on that copy too. + let r = if *count == 0 && !u.checking { + u.checking = true; + let r = lower_items(body, &mut c.clone(), &mut shift.clone(), u); + u.checking = false; + r + } else { + let times = if u.checking { 1 } else { *count }; + (0..times).try_for_each(|_| { + u.passes += 1; + lower_items(body, c, shift, u)?; + if c.ops.len() as u64 + u.passes > MAX_OPS as u64 { + return Err(past(format!("the circuit unrolls to more than {MAX_OPS} operations and passes through its blocks"))); + } + Ok(()) + }) + }; + u.depth -= 1; + r?; } } } @@ -1620,14 +1653,25 @@ mod tests { } } - /// A text whose qubits reach 4294967294 leaves the carrier no index: a - /// circuit with a correlated error is refused at the error's line. + /// A text whose qubits reach 4294967294 is refused where it names one, as + /// past what a circuit here holds. One that names the last qubit a circuit + /// holds leaves its correlated errors' carrier no room within the cap: the + /// circuit is refused at the error's line, as the carrier would make it a + /// qubit wider than its text may name. #[test] fn a_correlated_error_with_no_index_left_for_its_carrier_is_refused() { - for (text, line) in [("R 0\nE(0.1) X1\nX_ERROR(0.1) 4294967294\nM 0\nDETECTOR rec[-1]\n", 2), ("R 0\nX 4294967294\nREPEAT 2 {\n CORRELATED_ERROR(0.1) Z0\n}\nM 0\n", 4)] { - let e = parse(text).map(|c| c.qubits).unwrap_err(); - assert!(e.to_string().starts_with(&format!("line {line}:")) && e.to_string().contains("4294967294"), "{text}: {e}"); + let last = crate::quantum_qec_text::MAX_QUBITS - 1; + for (text, line, names) in [ + ("R 0\nE(0.1) X1\nX_ERROR(0.1) 4294967294\nM 0\nDETECTOR rec[-1]\n".to_string(), 3, "4294967294".to_string()), + (format!("R 0\nE(0.1) X1\nX_ERROR(0.1) {last}\nM 0\nDETECTOR rec[-1]\n"), 2, (last + 2).to_string()), + (format!("R 0\nX {last}\nREPEAT 2 {{\n CORRELATED_ERROR(0.1) Z0\n}}\nM 0\n"), 4, (last + 2).to_string()), + ] { + let e = parse(&text).map(|c| c.qubits).unwrap_err(); + assert!(e.to_string().starts_with(&format!("line {line}:")) && e.to_string().contains(&names) && e.to_string().contains("past the"), "{text}: {e}"); } + // Without a correlated error the last qubit is the circuit's own. + assert_eq!(parse(&format!("R 0\nX_ERROR(0.1) {last}\nM 0\nDETECTOR rec[-1]\n")).unwrap().qubits, last + 1); + assert_eq!(parse(&format!("R 0\nE(0.1) X1\nX_ERROR(0.1) {}\nM 0\nDETECTOR rec[-1]\n", last - 1)).unwrap().qubits, last + 1); } /// The instructions this reader added are in the page that lists the text @@ -1792,4 +1836,25 @@ mod tests { assert_eq!(m.hyperedges(), 0, "d = {d}"); } } + + /// A block repeated without end that writes no operation (`TICK`, + /// coordinates, nothing) counts its passes with the operations, so it + /// ends at the bound, refused; blocks repeated no times, nested, are + /// each checked once on one copy, not on a copy each. + #[test] + fn blocks_that_write_nothing_end_and_unrun_ones_are_checked_once() { + crate::quantum_qec_text::ends_within(120, || { + for body in ["TICK\n", "SHIFT_COORDS(1)\n", ""] { + let text = format!("H 0\nREPEAT 18446744073709551615 {{\n{body}}}\n"); + let e = parse(&text).unwrap_err().to_string(); + assert!(e.contains("unrolls to more than"), "{e}"); + } + }); + let n = crate::quantum_qec_text::MAX_BLOCK_DEPTH; + let unrun = format!("{}H 0\n{}{}M 0\n", "REPEAT 0 {\n".repeat(n), "}\n".repeat(n), "H 0\n".repeat(1000)); + let c = parse(&unrun).unwrap(); + assert_eq!(c.ops.len(), 1001); + // What a block repeated no times holds is still refused. + assert!(parse("REPEAT 0 {\nREPEAT 0 {\nNOT_AN_INSTRUCTION 0\n}\n}\n").is_err()); + } } diff --git a/wai/wai-quantum/src/quantum_qec_text.rs b/wai/wai-quantum/src/quantum_qec_text.rs index 57c24215c..5b53e02ea 100644 --- a/wai/wai-quantum/src/quantum_qec_text.rs +++ b/wai/wai-quantum/src/quantum_qec_text.rs @@ -32,6 +32,12 @@ //! What a circuit means is [`crate::quantum_frame`]'s question: an //! instruction this module reads but that module does not implement is //! refused there, with its line, not skipped. +//! +//! Qubits are named below [`MAX_QUBITS`] and detectors below +//! [`MAX_DETECTORS`], each refused on its line: what is made for an index is +//! as long as the index. Blocks nest at most [`MAX_BLOCK_DEPTH`] deep; a deeper one is refused on +//! its line, so reading, unrolling and writing a text never runs out of +//! stack. use std::fmt::Write as _; @@ -233,10 +239,26 @@ fn circuit_target(line: usize, tok: &str) -> Result { }; let first = body.chars().next().ok_or_else(|| err(line, "an empty target"))?; if matches!(first, 'X' | 'Y' | 'Z' | 'x' | 'y' | 'z') { - let q: u32 = number(line, &body[1..], "a qubit")?; + let q = qubit(line, number(line, &body[1..], "a qubit")?)?; return Ok(Target::Pauli { pauli: first.to_ascii_uppercase(), q, inverted }); } - Ok(Target::Qubit { q: number(line, body, "a qubit target")?, inverted }) + Ok(Target::Qubit { q: qubit(line, number(line, body, "a qubit target")?)?, inverted }) +} + +/// `q`, a qubit's index a reader of the text may take ([`MAX_QUBITS`]). +fn qubit(line: usize, q: u32) -> Result { + if q >= MAX_QUBITS { + return Err(err(line, format!("qubit {q} is past the {MAX_QUBITS} a circuit here holds"))); + } + Ok(q) +} + +/// `d`, a detector's index a reader of the text may take ([`MAX_DETECTORS`]). +fn detector(line: usize, d: u64) -> Result { + if d >= MAX_DETECTORS { + return Err(err(line, format!("detector {d} is past the {MAX_DETECTORS} a model here holds"))); + } + Ok(d) } /// The lines of a text, numbered from 1, comments and blank lines dropped. @@ -244,15 +266,37 @@ fn lines(text: &str) -> Vec<(usize, &str)> { text.lines().enumerate().map(|(i, l)| (i + 1, l.split('#').next().unwrap_or("").trim())).filter(|(_, l)| !l.is_empty()).collect() } +/// How deep `REPEAT` and `repeat` blocks may nest: far past any circuit's +/// or model's own (one or two levels), and shallow enough that each reader +/// of the blocks, which recurses into them, stays well inside its stack. +pub const MAX_BLOCK_DEPTH: usize = 32; + +/// The most qubits a circuit's text may name, and so the widest circuit it +/// makes. An index costs a few bytes of text and what is made for it is as +/// long as the index, so it is held where it is read, with its line. +pub const MAX_QUBITS: u32 = 1 << 24; + +/// The most detectors a model's text may name, offsets counted: what is made +/// for them (their coordinates, every decoder's tables) is as long as the +/// highest index, whichever instruction names it. +pub const MAX_DETECTORS: u64 = 1 << 24; + +fn too_deep(line: usize) -> TextError { + err(line, format!("blocks nested deeper than {MAX_BLOCK_DEPTH}: write the inner ones out, or fold them into one with the product of their counts")) +} + /// Read a circuit's text. pub fn parse_circuit(text: &str) -> Result { let ls = lines(text); let mut pos = 0; - let items = circuit_block(&ls, &mut pos, None)?; + let items = circuit_block(&ls, &mut pos, None, 0)?; Ok(CircuitText { items }) } -fn circuit_block(ls: &[(usize, &str)], pos: &mut usize, open: Option) -> Result, TextError> { +fn circuit_block(ls: &[(usize, &str)], pos: &mut usize, open: Option, depth: usize) -> Result, TextError> { + if let (Some(line), true) = (open, depth > MAX_BLOCK_DEPTH) { + return Err(too_deep(line)); + } let mut items = Vec::new(); while *pos < ls.len() { let (line, text) = ls[*pos]; @@ -269,7 +313,7 @@ fn circuit_block(ls: &[(usize, &str)], pos: &mut usize, open: Option) -> if !h.args.is_empty() { return Err(err(line, "REPEAT takes no arguments")); } - let body = circuit_block(ls, pos, Some(line))?; + let body = circuit_block(ls, pos, Some(line), depth + 1)?; items.push(Item::Repeat { count, tag: h.tag, body, line }); continue; } @@ -353,11 +397,14 @@ fn write_items(out: &mut String, items: &[Item], depth: usize) { pub fn parse_dem(text: &str) -> Result { let ls = lines(text); let mut pos = 0; - let items = dem_block(&ls, &mut pos, None)?; + let items = dem_block(&ls, &mut pos, None, 0)?; Ok(DemText { items }) } -fn dem_block(ls: &[(usize, &str)], pos: &mut usize, open: Option) -> Result, TextError> { +fn dem_block(ls: &[(usize, &str)], pos: &mut usize, open: Option, depth: usize) -> Result, TextError> { + if let (Some(line), true) = (open, depth > MAX_BLOCK_DEPTH) { + return Err(too_deep(line)); + } let mut items = Vec::new(); while *pos < ls.len() { let (line, text) = ls[*pos]; @@ -372,7 +419,7 @@ fn dem_block(ls: &[(usize, &str)], pos: &mut usize, open: Option) -> Resu match h.name { "repeat" => { let count = h.rest.strip_suffix('{').map(str::trim).and_then(|n| n.parse::().ok()).ok_or_else(|| err(line, "expected 'repeat {'"))?; - let body = dem_block(ls, pos, Some(line))?; + let body = dem_block(ls, pos, Some(line), depth + 1)?; items.push(DemItem::Repeat { count, tag: h.tag, body, line }); } "error" | "detector" | "logical_observable" | "shift_detectors" => { @@ -383,7 +430,8 @@ fn dem_block(ls: &[(usize, &str)], pos: &mut usize, open: Option) -> Resu if t == "^" { Ok(DemTarget::Separator) } else if let Some(k) = t.strip_prefix('D') { - number(line, k, "a detector").map(DemTarget::Detector) + let d: u32 = number(line, k, "a detector")?; + detector(line, u64::from(d)).map(|_| DemTarget::Detector(d)) } else if let Some(k) = t.strip_prefix('L') { number(line, k, "an observable").map(DemTarget::Observable) } else { @@ -415,7 +463,7 @@ impl DemText { /// mechanisms with one tag are combined as independent events. pub fn to_model(&self) -> Result { let mut walk = DemWalk::default(); - walk.items(&self.items)?; + walk.items(&self.items, 0)?; Ok(walk.finish()) } } @@ -468,12 +516,18 @@ struct DemWalk { } impl DemWalk { - fn items(&mut self, items: &[DemItem]) -> Result<(), TextError> { + /// The items unrolled, `depth` blocks deep. A block's every pass counts + /// as work, so one that holds nothing still ends within the bound. + fn items(&mut self, items: &[DemItem], depth: usize) -> Result<(), TextError> { for it in items { match it { DemItem::Repeat { count, body, line, .. } => { + if depth >= MAX_BLOCK_DEPTH { + return Err(too_deep(*line)); + } for _ in 0..*count { - self.items(body)?; + self.work += 1; + self.items(body, depth + 1)?; if self.work > MAX_UNROLLED { return Err(err(*line, format!("the blocks unroll to more than {MAX_UNROLLED} instructions"))); } @@ -502,10 +556,7 @@ impl DemWalk { let cur = parts.last_mut().expect("a part"); match *t { DemTarget::Detector(k) => { - let d = self.offset + k as u64; - if d >= u32::MAX as u64 { - return Err(err(line, "a detector index beyond 2³²")); - } + let d = detector(line, self.offset + k as u64)?; self.detectors = self.detectors.max(d + 1); let d = d as u32; match cur.0.binary_search(&d) { @@ -551,7 +602,7 @@ impl DemWalk { for t in &i.targets { match *t { DemTarget::Detector(k) => { - let d = self.offset + k as u64; + let d = detector(line, self.offset + k as u64)?; self.detectors = self.detectors.max(d + 1); // A declaration without coordinates only names the detector. if !i.args.is_empty() { @@ -580,7 +631,8 @@ impl DemWalk { [DemTarget::Number(n)] => *n, _ => return Err(err(line, "shift_detectors takes one count")), }; - self.offset += n; + // Held here, so that an offset never overflows the sum above. + self.offset = detector(line, self.offset.saturating_add(n))?; for (j, a) in i.args.iter().enumerate() { if self.coord_shift.len() <= j { self.coord_shift.resize(j + 1, 0.0); @@ -617,6 +669,20 @@ impl DemWalk { } } +/// `work` run within `secs` seconds, or the test fails then: what a bound +/// keeps from running forever fails here, by name, if the bound goes, rather +/// than holding the job until its own limit ends it. +#[cfg(test)] +pub(crate) fn ends_within(secs: u64, work: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new().stack_size(8 << 20).spawn(move || tx.send(std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)))).unwrap(); + match rx.recv_timeout(std::time::Duration::from_secs(secs)) { + Ok(Ok(v)) => v, + Ok(Err(p)) => std::panic::resume_unwind(p), + Err(_) => panic!("not ended within {secs} s"), + } +} + #[cfg(test)] mod tests { use super::*; @@ -701,4 +767,73 @@ logical_observable L1 assert!(parse_dem("error(0.1) D0 ^ ^ D1\n").unwrap().to_model().is_err()); assert!(parse_dem("error(1.5) D0\n").unwrap().to_model().is_err()); } + + /// `f` on a thread of 2 MiB, a runtime worker's stack. + fn on_stack(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new().stack_size(2 << 20).spawn(f).unwrap().join().unwrap() + } + + /// Blocks nested past [`MAX_BLOCK_DEPTH`], as deep as a megabyte of + /// `REPEAT 1 {` lines makes them, are refused on the line that opens one + /// too deep; nested to the limit they read, unroll and write back. + #[test] + fn blocks_nested_past_the_limit_are_refused_on_their_line() { + let circuit = |n: usize| format!("{}H 0\n{}", "REPEAT 1 {\n".repeat(n), "}\n".repeat(n)); + let model = |n: usize| format!("{}error(0.1) D0\n{}", "repeat 1 {\n".repeat(n), "}\n".repeat(n)); + for n in [MAX_BLOCK_DEPTH + 1, 100_000] { + let e = on_stack(move || parse_circuit(&circuit(n)).unwrap_err()); + assert_eq!(e.line, MAX_BLOCK_DEPTH + 1, "{}", e.message); + assert!(e.message.contains("nested deeper"), "{}", e.message); + let e = on_stack(move || parse_dem(&model(n)).unwrap_err()); + assert_eq!(e.line, MAX_BLOCK_DEPTH + 1, "{}", e.message); + } + let c = on_stack(move || parse_circuit(&circuit(MAX_BLOCK_DEPTH)).unwrap()); + assert_eq!(parse_circuit(&c.to_text()).unwrap(), c); + assert_eq!(on_stack(move || crate::quantum_frame::lower(&c).map(|c| c.ops.len())).unwrap(), 1); + let m = on_stack(move || parse_dem(&model(MAX_BLOCK_DEPTH)).unwrap().to_model().map(|m| m.mechanisms.len())); + assert_eq!(m.unwrap(), 1); + } + + /// A block that holds no mechanism still counts each pass toward the + /// bound, so one repeated 2^64 − 1 times ends at once, refused. + #[test] + fn an_empty_block_repeated_without_end_is_refused_not_run() { + ends_within(60, || { + let e = parse_dem("repeat 18446744073709551615 {\n}\nerror(0.1) D0\n").unwrap().to_model().unwrap_err(); + assert!(e.message.contains("unroll"), "{}", e.message); + let e = parse_dem("repeat 18446744073709551615 {\nrepeat 18446744073709551615 {\n}\n}\n").unwrap().to_model().unwrap_err(); + assert!(e.message.contains("unroll"), "{}", e.message); + }); + } + + /// An index in a few bytes of text would make what is as long as the + /// index: every way of naming one is refused where it is read, with its + /// line, before anything is made. The highest that reads still makes its + /// model, and the model counts it exactly. + #[test] + fn an_index_past_what_a_text_holds_is_refused_on_its_line() { + let past = MAX_DETECTORS; + let d = past - 1; + for model in [ + format!("shift_detectors {past}\ndetector(1) D0\n"), + format!("shift_detectors {past}\ndetector D0\n"), + format!("shift_detectors 100000000000000\ndetector(1) D0\n"), + format!("shift_detectors 18446744073709551615\nshift_detectors 18446744073709551615\ndetector D0\n"), + format!("shift_detectors {d}\nerror(0.1) D1\n"), + format!("shift_detectors {d}\nshift_detectors 1\n"), + ] { + let e = parse_dem(&model).unwrap().to_model().unwrap_err(); + assert!(e.message.contains("past the"), "{model}: {}", e.message); + assert!(e.line > 0, "{model}: {}", e.message); + } + for model in ["detector(1) D4294967294\n", "detector D4294967294\n", "error(0.1) D4294967294\n"] { + assert!(parse_dem(model).unwrap_err().message.contains("past the"), "{model}"); + } + let m = parse_dem(&format!("shift_detectors {d}\ndetector D0\n")).unwrap().to_model().unwrap(); + assert_eq!(m.detectors as u64, MAX_DETECTORS); + for circuit in ["M 4000000000\n".to_string(), format!("H {MAX_QUBITS}\n"), format!("MPP X{MAX_QUBITS}\n")] { + assert!(parse_circuit(&circuit).unwrap_err().message.contains("past the"), "{circuit}"); + } + assert!(parse_circuit(&format!("H {}\n", MAX_QUBITS - 1)).is_ok()); + } } diff --git a/wai/wai-quantum/tests/reader_memory.rs b/wai/wai-quantum/tests/reader_memory.rs index d3f747708..1b3dbf9b5 100644 --- a/wai/wai-quantum/tests/reader_memory.rs +++ b/wai/wai-quantum/tests/reader_memory.rs @@ -71,8 +71,9 @@ fn read(src: String) -> Result Vec<(String, String, bool)> { let head = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\n"; let mut out = Vec::new(); - let nested = |shape: &str| -> String { - let k = 126; + let nested = |shape: &str, k: usize| -> String { match shape { "parens" => format!("{head}int x = {}1{};\n", "(".repeat(k), ")".repeat(k)), "unary" => format!("{head}int x = {}1;\n", "-".repeat(k)), @@ -26,8 +27,14 @@ fn at_the_bounds() -> Vec<(String, String, bool)> { other => panic!("no shape {other}"), } }; - for shape in ["parens", "unary", "sum", "product", "blocks", "ifs", "runtime ifs", "fors", "angle"] { - out.push((shape.to_string(), nested(shape), true)); + let inside = wai_quantum::qasm::parse::MAX_NESTING - 8; + for shape in ["parens", "unary", "blocks", "ifs", "runtime ifs", "fors", "angle"] { + out.push((format!("{shape}, {inside} deep"), nested(shape, inside), true)); + out.push((format!("{shape}, 5000 deep"), nested(shape, 5000), false)); + } + // A run of one operator is one level, however long. + for shape in ["sum", "product"] { + out.push((format!("{shape} of 5000 terms"), nested(shape, 5000), true)); } let chain = |kind: &str, d: usize, k: usize, inner: &str| -> String { let mut s = String::from("OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit q;\n"); @@ -40,11 +47,13 @@ fn at_the_bounds() -> Vec<(String, String, bool)> { s }; for kind in ["gates", "defs"] { - // 25 bodies of 4 loops: 128 through the calls; 32 calls of 2: the most calls nested. + // 25 bodies of 4 loops, and 31 of 2, the most calls nested, read; 31 of 8 pass + // the bound through the calls. out.push((format!("{kind}, 25 of 4 loops"), chain(kind, 25, 4, "x a;"), true)); out.push((format!("{kind}, 31 of 2 loops"), chain(kind, 31, 2, "x a;"), true)); // The innermost body's own expression as deep as is left: 31 × 3 + 3 + 32. out.push((format!("{kind}, 31 of 2 loops and a deep angle"), chain(kind, 31, 2, &format!("rz({}0.5{}) a;", "(".repeat(32), ")".repeat(32))), true)); + out.push((format!("{kind}, 31 of 8 loops"), chain(kind, 31, 8, "x a;"), false)); } let ladder = |runtime: bool| { let s = if runtime { "c[0] = measure q[0];\nif (c[0]) x q;\n".to_string() } else { "int s = 3;\nif (s == 0) x q;\n".to_string() }; @@ -84,6 +93,7 @@ fn every_program_inside_the_bounds_reads_on_a_small_stack() { eprintln!("reading {what} on {} KiB", stack >> 10); match read_on(src.clone(), stack) { Ok(Ok(())) if reads => {} + Ok(Err(d)) if !reads && d.contains("[Q006]") => {} Ok(r) => wrong.push(format!("{what} on {} KiB: {r:?}", stack >> 10)), Err(()) => wrong.push(format!("{what} on {} KiB: the reading did not come back", stack >> 10)), } diff --git a/wai/wai-web/compose/README.md b/wai/wai-web/compose/README.md index 97312aed0..a45fd8167 100644 --- a/wai/wai-web/compose/README.md +++ b/wai/wai-web/compose/README.md @@ -20,7 +20,7 @@ cut at the lead's piece starts, each piece one object: | component | capability | source | presented as | |---|---|---|---| -| `v` | `wai.video.int_motion` bases and a `wai.video.av1` floor for every piece | the fixture `compose-01`, a 320 × 180 test pattern at 25 frames a second | the picture, and the clock | +| `v` | `wai.video.int_motion` bases and a `wai.video.av1` floor for every piece | the fixture `compose-01`, a test pattern of 320 × 180 at 25 frames a second | the picture, and the clock | | `g` | `wai.graphics.vector` | `vector-conformance/vectors/motion/stroke-titles.wvg1`, a title card and a credit roll at 1280 × 720 | an overlay rasterised in WebAssembly | | `t` | `wai.text.timed` | `sources/t.vtt`, three captions | text, in the page's text layer | | `s` | `wai.audio.score`, and a `wai.audio.flac` floor of each piece's mixdown | `sources/s.wscr`, `score-conformance/vectors/scale.wscr`'s notes up and down | samples synthesised in the tab, on Web Audio | diff --git a/wai/wai-web/src/quantum_engine.rs b/wai/wai-web/src/quantum_engine.rs index 8cd10ab45..369f82721 100644 --- a/wai/wai-web/src/quantum_engine.rs +++ b/wai/wai-web/src/quantum_engine.rs @@ -1680,4 +1680,38 @@ mod wasm_tests { assert_eq!(body["status"], "done", "{req}: {body}"); } } + + /// A program nested thousands deep, or one that asks for more memory + /// than the reader holds, is refused with the reader's code, never a + /// trap, through every door that reads one: a run, the lint, the + /// critique, a generator written out; an error-correction circuit's + /// blocks are refused on their line, and an answer's chain of seals past + /// its depth; and the engine answers the next request as before. + #[wasm_bindgen_test] + fn a_program_nested_past_the_reader_s_limit_is_refused_not_trapped() { + let e = QuantumEngine::new(&[9u8; 32], 512).unwrap(); + let deep = format!("OPENQASM 3.0;\nqubit q;\nrz({}0.5{}) q;\n", "(".repeat(5_000), ")".repeat(5_000)); + let blocks = format!("OPENQASM 3.0;\nqubit q;\n{}x q;{}\n", "{ ".repeat(5_000), " }".repeat(5_000)); + for src in [&deep, &blocks] { + assert!(run(&e, json!({ "qasm": src, "shots": 10 })).to_string().contains("Q006")); + assert!(engine_lint(src).contains("Q006")); + assert!(engine_critique(src).contains("Q006")); + } + let generator = format!("OPENQASM 3.0;\ninput int n;\nconst int a = {}n{};\nqubit q;\nx q;\n", "(".repeat(5_000), ")".repeat(5_000)); + assert!(engine_instantiate(&generator, "{\"n\": 1}").contains("Q006")); + let circuit = format!("{}H 0\n{}", "REPEAT 1 {\n".repeat(50_000), "}\n".repeat(50_000)); + assert!(e.qec("decode", &json!({ "circuit": circuit, "shots": 10 }).to_string()).contains("nested deeper")); + let mut chain = String::from("```qasm p\nOPENQASM 3.0;\nqubit[1] q;\nbit[1] c;\nh q[0];\nc = measure q;\n```\n"); + for k in 0..2_000 { + chain += &format!("s{k} = @Seal([s{}])\n", k + 1); + } + chain += "s2000 = @Sample(p, 10)\n"; + assert!(e.answer_value(&chain, "s0", true).contains("more than 32 statements")); + // A declaration that asks for more than the page holds is refused, not a trap. + let huge = "OPENQASM 3.0;\nqubit q;\narray[int[8], 4294967295] a;\nx q;\n"; + assert!(run(&e, json!({ "qasm": huge, "shots": 10 })).to_string().contains("Q130")); + assert!(engine_critique(huge).contains("Q130")); + let body = run(&e, json!({ "qasm": "OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];", "shots": 20 })); + assert_eq!(body["status"], "done", "{body}"); + } }