- Python: qudit, qudit_check, wasmq (an envelope's bytes, a path or an example's name), wasmq_verify (with the envelope, run again byte for byte) and wasmq_examples, the extension module running an envelope on wai-wasmq's own interpreter (no compiler in the wheel); Service.wasmq and Service.qudit run them on a service. Tests: both kinds run and verified here, a qudit run equal to `wai-quantum qudit run`'s bits and a wasmQ/1 job equal to `wai-quantum run`'s byte for byte, and both run on a service built from this checkout. - npm (wai-web, quantum_engine): QuantumEngine.qudit and QuantumEngine.wasmq (the interpreter, the only engine on wasm32), engine_qudit_check, engine_wasmq_verify, engine_wasmq_examples and engine_wasmq_example. The smoke test runs every example in Node, each sealed, held to its form and run again, a fault sealed and W013 refused. - Quantum Studio: the "Qudits" and "wasmQ" panes (closed until opened), in the browser and in the desktop app (its qudit, qudit_check, wasmq, wasmq_verify and wasmq_examples commands): the qudit counts by digits, amplitudes as integers, bounds, the seal checked and the request run again; an example envelope or a .wai file opened there, its outcome, calls, fuel, counts and energy, its seal and form checked and the envelope run again; a refusal with its code. The browser e2e drives each. - quantum-wasmq §11.9 and quantum-sim §9.10 name every surface. Tested on the build hosts: wai-web for wasm32-unknown-unknown and the npm smoke test in Node 26 (macOS arm64); the Python package's wheel (maturin 1.15.0), clippy -D warnings and pytest (95 passed, the widgets' node test skipped there) on Linux x86_64; the Studio's browser e2e (macOS arm64, STUDIO_SKIP_SERVICE=1), every qudit and wasmQ check passing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
263 lines
12 KiB
Python
263 lines
12 KiB
Python
"""The service client: against a stub that records what it is sent (how the
|
|
client names itself, its JSON, how it follows a job), and against the job
|
|
service built from this checkout, run here. No test reaches the public
|
|
service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
import pytest
|
|
|
|
import wai_quantum as wq
|
|
from conftest import BELL, bare
|
|
|
|
DEMO = wq.Key.demo()
|
|
|
|
#: A stub's answer that is none: the connection closes once the request is read.
|
|
DROP = (None, None)
|
|
|
|
|
|
# -- a stub, for what the client sends ------------------------------------------
|
|
|
|
|
|
class Stub:
|
|
"""A server that answers from a script and keeps every request."""
|
|
|
|
def __init__(self, answers):
|
|
self.answers = list(answers)
|
|
self.seen = []
|
|
stub = self
|
|
|
|
class H(BaseHTTPRequestHandler):
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def _any(self):
|
|
n = int(self.headers.get("content-length") or 0)
|
|
body = self.rfile.read(n) if n else b""
|
|
stub.seen.append({"method": self.command, "path": self.path, "headers": dict(self.headers), "body": json.loads(body) if body else None})
|
|
status, answer = stub.answers.pop(0) if stub.answers else (500, {"error": "the stub ran out of answers"})
|
|
if status is None:
|
|
return
|
|
raw = answer if isinstance(answer, bytes) else json.dumps(answer).encode()
|
|
self.send_response(status)
|
|
self.send_header("content-type", "application/json")
|
|
self.send_header("content-length", str(len(raw)))
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
|
|
do_GET = do_POST = do_DELETE = _any
|
|
|
|
self.server = HTTPServer(("127.0.0.1", 0), H)
|
|
self.url = f"http://127.0.0.1:{self.server.server_port}/quantum-jobs"
|
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
|
|
def close(self):
|
|
self.server.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def stub():
|
|
made = []
|
|
|
|
def make(answers):
|
|
s = Stub(answers)
|
|
made.append(s)
|
|
return s
|
|
|
|
yield make
|
|
for s in made:
|
|
s.close()
|
|
|
|
|
|
def test_the_client_names_itself_and_sends_json(stub):
|
|
done = {"id": "j1", "status": "done", "method": "stabilizer", "request": {"qasm": BELL}, "result": {"counts": {"00": 1}}}
|
|
s = stub([(202, {"id": "j1", "status": "queued"}), (200, {"id": "j1", "status": "running"}), (200, done)])
|
|
job = wq.Service(s.url, timeout=10).run(BELL, shots=1, wait=30, poll=0.01)
|
|
assert isinstance(job, wq.Job) and job.counts == {"00": 1}
|
|
post, *gets = s.seen
|
|
assert post["method"] == "POST" and post["path"] == "/quantum-jobs/v1/run" and post["body"] == {"qasm": BELL, "shots": 1}
|
|
assert [g["path"] for g in gets] == ["/quantum-jobs/v1/jobs/j1"] * 2
|
|
for r in s.seen:
|
|
assert r["headers"]["User-Agent"] == f"wai-quantum-python/{wq.__version__}" == wq.USER_AGENT
|
|
assert r["headers"]["Accept"] == "application/json"
|
|
assert post["headers"]["Content-Type"] == "application/json"
|
|
|
|
|
|
def test_the_base_may_name_v1_or_not_and_the_environment_may_name_it(stub, monkeypatch):
|
|
s = stub([(200, {"ok": True}), (200, {"ok": True}), (200, {"ok": True})])
|
|
assert wq.Service(s.url + "/v1").health() and wq.Service(s.url + "/").health()
|
|
monkeypatch.setenv("WAI_QUANTUM_SERVICE", s.url)
|
|
assert wq.Service().base == s.url and wq.Service().health()
|
|
assert [r["path"] for r in s.seen] == ["/quantum-jobs/v1/health"] * 3
|
|
monkeypatch.delenv("WAI_QUANTUM_SERVICE")
|
|
assert wq.Service().base == wq.DEFAULT_SERVICE
|
|
|
|
|
|
def test_a_refusal_carries_the_services_status_and_words(stub):
|
|
s = stub([(400, {"error": "line 1: unknown gate foo"}), (409, {"error": "the job has already finished"}), (502, b"<html>bad gateway</html>")])
|
|
svc = wq.Service(s.url)
|
|
with pytest.raises(wq.ServiceError) as e:
|
|
svc.submit("qubit[1] q; foo q[0];")
|
|
assert e.value.status == 400 and e.value.message == "line 1: unknown gate foo" and e.value.body == {"error": "line 1: unknown gate foo"}
|
|
with pytest.raises(wq.ServiceError) as e:
|
|
svc.cancel("j/1")
|
|
assert e.value.status == 409 and s.seen[1]["path"] == "/quantum-jobs/v1/jobs/j%2F1" and s.seen[1]["method"] == "DELETE"
|
|
with pytest.raises(wq.ServiceError, match="not JSON"):
|
|
svc.describe()
|
|
|
|
|
|
def test_a_failed_or_slow_job_raises(stub):
|
|
s = stub([(200, {"id": "j", "status": "failed", "error": "the worker ran out of memory"}), (200, {"id": "k", "status": "running"}), (200, {"id": "k", "status": "running"})])
|
|
svc = wq.Service(s.url)
|
|
with pytest.raises(wq.ServiceError, match="ran out of memory"):
|
|
svc.result("j")
|
|
with pytest.raises(wq.ServiceError, match="still running"):
|
|
svc.result("k", wait=0.05, poll=0.1)
|
|
|
|
|
|
def test_a_job_whose_answer_is_lost_is_sent_again_under_its_key(stub):
|
|
s = stub([DROP, (200, {"idempotency": {"on": ["POST /v1/jobs", "POST /v1/run"]}}), (202, {"id": "j1", "status": "queued"})])
|
|
assert wq.Service(s.url, timeout=10).submit(BELL, shots=1)["id"] == "j1"
|
|
assert [(r["method"], r["path"]) for r in s.seen] == [("POST", "/quantum-jobs/v1/jobs"), ("GET", "/quantum-jobs/v1"), ("POST", "/quantum-jobs/v1/jobs")]
|
|
first, again = s.seen[0]["headers"]["Idempotency-Key"], s.seen[2]["headers"]["Idempotency-Key"]
|
|
assert first == again and len(first) == 32
|
|
# A key given is the key sent.
|
|
s = stub([(202, {"id": "j2", "status": "queued"})])
|
|
wq.Service(s.url).submit(BELL, shots=1, idempotency_key="mine-1")
|
|
assert s.seen[0]["headers"]["Idempotency-Key"] == "mine-1" and s.seen[0]["body"] == {"qasm": BELL, "shots": 1}
|
|
|
|
|
|
def test_a_job_is_not_sent_again_to_a_service_that_keeps_no_keys(stub):
|
|
s = stub([DROP, (200, {"service": "wai-quantum-service"})])
|
|
with pytest.raises(wq.ServiceError) as e:
|
|
wq.Service(s.url, timeout=10).submit(BELL, shots=1)
|
|
assert e.value.status is None and [r["method"] for r in s.seen] == ["POST", "GET"]
|
|
|
|
|
|
def test_an_interrupted_job_ends_the_wait(stub):
|
|
s = stub([(200, {"id": "j", "status": "interrupted", "error": "interrupted: the service stopped while this job ran"})])
|
|
with pytest.raises(wq.ServiceError, match="interrupted"):
|
|
wq.Service(s.url).result("j", wait=30, poll=0.01)
|
|
assert len(s.seen) == 1
|
|
|
|
|
|
def test_an_unreachable_service_raises_without_a_status():
|
|
with pytest.raises(wq.ServiceError) as e:
|
|
wq.Service("http://127.0.0.1:9", timeout=2).describe()
|
|
assert e.value.status is None
|
|
|
|
|
|
def test_verify_is_offline(stub):
|
|
s = stub([])
|
|
job = wq.run(BELL, shots=10, key=DEMO)
|
|
assert wq.Service(s.url).verify(job).valid and s.seen == []
|
|
|
|
|
|
# -- the job service, run here ----------------------------------------------------
|
|
|
|
|
|
def test_the_service_describes_itself(service_url):
|
|
svc = wq.Service(service_url)
|
|
assert svc.health()
|
|
d = svc.describe()
|
|
assert d["service"] == "wai-quantum-service" and len(d["receipt_key"]) == 64
|
|
|
|
|
|
def test_a_job_run_on_the_service_is_the_job_run_here(service_url):
|
|
svc = wq.Service(service_url)
|
|
spec = {"shots": 700, "seed": 11, "observables": ["Z0 Z1"]}
|
|
theirs = svc.run(BELL, **spec)
|
|
ours = wq.run(BELL, key=DEMO, **spec)
|
|
assert theirs.status == "done" and theirs["request"] == ours["request"]
|
|
assert (theirs["method"], theirs["why"], theirs["estimate"]) == (ours["method"], ours["why"], ours["estimate"])
|
|
assert bare(theirs.result) == bare(ours.result)
|
|
# The service adds what the result came from, its host named in it.
|
|
p = theirs.result["provenance"]
|
|
assert p["schema"] == "wai.quantum.provenance/1" and p["software"]["host"].startswith("wai-quantum-service")
|
|
# Its receipt, sealed with the service's key, verifies here.
|
|
v = svc.verify(theirs)
|
|
assert v.valid and v.key == svc.describe()["receipt_key"]
|
|
forged = json.loads(json.dumps(theirs))
|
|
forged["result"]["counts"]["00"] = forged["result"]["counts"].get("00", 0) + 1
|
|
assert not wq.verify(forged)
|
|
|
|
|
|
def test_submit_status_and_result(service_url):
|
|
svc = wq.Service(service_url)
|
|
rec = svc.submit("OPENQASM 3.0; qubit[3] q; h q[0]; cx q[0], q[1]; ry(0.2) q[2];", observables=["Z2"])
|
|
assert rec["status"] in ("queued", "running", "done") and rec["id"]
|
|
job = svc.result(rec["id"], wait=120, poll=0.1)
|
|
assert job.status == "done" and job.verify().valid
|
|
assert svc.status(rec["id"])["status"] == "done"
|
|
with pytest.raises(wq.ServiceError) as e:
|
|
svc.status("no-such-job")
|
|
assert e.value.status == 404
|
|
|
|
|
|
def test_cancel_a_queued_job_and_a_running_one(service_url):
|
|
svc = wq.Service(service_url)
|
|
# One worker: a long job holds it, so the next one waits in the queue.
|
|
long = "OPENQASM 3.0; qubit[22] q;\n" + "".join(f"rx(0.{k % 9 + 1}) q[{k % 22}];\ncx q[{k % 22}], q[{(k + 7) % 22}];\n" for k in range(4000))
|
|
a = svc.submit(long, observables=["Z0"], method="statevector")
|
|
b = svc.submit(BELL, shots=10)
|
|
assert svc.cancel(b["id"])["status"] == "cancelled"
|
|
assert svc.status(b["id"])["status"] == "cancelled"
|
|
with pytest.raises(wq.ServiceError, match="cancelled"):
|
|
svc.result(b["id"], wait=5)
|
|
# The long one, queued or running, is cancelled too, and stays so.
|
|
assert svc.cancel(a["id"])["status"] == "cancelled"
|
|
time.sleep(0.5)
|
|
assert svc.status(a["id"])["status"] == "cancelled"
|
|
with pytest.raises(wq.ServiceError) as e:
|
|
svc.cancel(a["id"])
|
|
assert e.value.status == 409
|
|
|
|
|
|
def test_a_job_sent_again_under_its_key_is_the_same_job(service_url):
|
|
svc = wq.Service(service_url)
|
|
a = svc.submit(BELL, shots=10, idempotency_key="py-key-1")
|
|
b = svc.submit(BELL, shots=10, idempotency_key="py-key-1")
|
|
assert a["id"] == b["id"]
|
|
assert svc.run(BELL, shots=10, idempotency_key="py-key-1", wait=120, poll=0.1).status == "done"
|
|
assert svc.submit(BELL, shots=10)["id"] != a["id"], "a fresh key is a new job"
|
|
with pytest.raises(wq.ServiceError) as e:
|
|
svc.submit(BELL, shots=11, idempotency_key="py-key-1")
|
|
assert e.value.status == 422 and e.value.message.endswith("[J034]")
|
|
with pytest.raises(wq.ServiceError) as e:
|
|
svc.submit(BELL, shots=10, idempotency_key="has space")
|
|
assert e.value.status == 400 and e.value.message.endswith("[J034]")
|
|
|
|
|
|
def test_the_service_refuses_what_the_engine_refuses_with_its_words(service_url):
|
|
svc = wq.Service(service_url)
|
|
with pytest.raises(wq.ServiceError) as e:
|
|
svc.run("OPENQASM 3.0;\nqubit[1] q;\nfoo q[0];\n")
|
|
assert e.value.status == 400
|
|
with pytest.raises(wq.EngineError) as here:
|
|
wq.run("OPENQASM 3.0;\nqubit[1] q;\nfoo q[0];\n", key=DEMO)
|
|
assert e.value.message == here.value.message
|
|
|
|
|
|
def test_a_wasmq_envelope_and_a_qudit_job_run_on_the_service(service_url):
|
|
s = wq.Service(service_url, timeout=60)
|
|
job = s.wasmq("v2-t-toffoli", wait=240, poll=0.2)
|
|
assert (job["status"], job["method"]) == ("done", "wasmq")
|
|
assert wq.verify(job).valid
|
|
w = wq.wasmq_verify(job, "v2-t-toffoli")
|
|
assert w["valid"] and w["reproduced"] is True, w
|
|
run = job["result"]["energy"]["run"]
|
|
assert (run.get("joules_micro") or 0) >= 1 or (set(run) == {"unmetered"} and run["unmetered"]), run
|
|
e = next(x for x in wq.wasmq_examples() if x["name"] == "surface3-decode64")
|
|
by_bytes = s.wasmq(e["envelope"], wait=240, poll=0.2)
|
|
assert by_bytes["result"]["counts"] == wq.wasmq(e["envelope"], key=DEMO)["result"]["counts"]
|
|
q = s.qudit({"d": 6, "qudits": 2, "gates": [{"gate": "fourier", "qudits": [0]}, {"gate": "csum", "qudits": [0, 1]}], "shots": 120}, wait=240, poll=0.2)
|
|
assert q["result"]["schema"] == "wai.quantum.qudit/1" and wq.qudit_check(q)["verified"]
|
|
with pytest.raises(wq.ServiceError) as err:
|
|
s.qudit({"d": 5, "qudits": 1, "gates": []})
|
|
assert err.value.status == 422 and "J091" in str(err.value)
|
|
|