quantufai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantufai/__init__.py +68 -0
- quantufai/_version.py +1 -0
- quantufai/canonical.py +100 -0
- quantufai/client.py +409 -0
- quantufai/types.py +427 -0
- quantufai/verify.py +196 -0
- quantufai-0.1.0.dist-info/METADATA +154 -0
- quantufai-0.1.0.dist-info/RECORD +10 -0
- quantufai-0.1.0.dist-info/WHEEL +5 -0
- quantufai-0.1.0.dist-info/top_level.txt +1 -0
quantufai/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""quantufai — the official QuantufAI Python SDK.
|
|
2
|
+
|
|
3
|
+
Quantum compute a program can PRICE and READ, but never SPEND: quotes before
|
|
4
|
+
any spend, job status/results with receipts and error bars verbatim, signed
|
|
5
|
+
receipt verification, circuit export, and a free sandbox simulator. There is
|
|
6
|
+
no dispatch or approval method — spending stays with a human in the dashboard
|
|
7
|
+
(see ``quantufai.client`` for the full contract).
|
|
8
|
+
|
|
9
|
+
This package intentionally replaces the repo's former SDK stubs, which called
|
|
10
|
+
endpoints that did not exist and fabricated job statuses from pattern-matched
|
|
11
|
+
text. Everything here calls the real, mounted, scope-gated API — and what the
|
|
12
|
+
API refuses, this SDK reports as a typed ``REFUSED``, never a made-up answer.
|
|
13
|
+
"""
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .client import FREE_SIMULATOR_PROVIDER, Client, ReceiptVerification
|
|
16
|
+
from .types import (
|
|
17
|
+
FAILED,
|
|
18
|
+
QUEUED,
|
|
19
|
+
REFUSED,
|
|
20
|
+
RUNNING,
|
|
21
|
+
SUCCEEDED,
|
|
22
|
+
CircuitExport,
|
|
23
|
+
GovernedReceipt,
|
|
24
|
+
JobResult,
|
|
25
|
+
JobStatus,
|
|
26
|
+
Me,
|
|
27
|
+
PlatformError,
|
|
28
|
+
Quote,
|
|
29
|
+
QuoteResponse,
|
|
30
|
+
ReceiptBundle,
|
|
31
|
+
Refusal,
|
|
32
|
+
ResultExport,
|
|
33
|
+
SandboxRun,
|
|
34
|
+
TransportError,
|
|
35
|
+
is_terminal,
|
|
36
|
+
normalize_state,
|
|
37
|
+
)
|
|
38
|
+
from .verify import ChainCheck, OfflineVerification, verify_receipt_offline
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"__version__",
|
|
42
|
+
"Client",
|
|
43
|
+
"FREE_SIMULATOR_PROVIDER",
|
|
44
|
+
"ReceiptVerification",
|
|
45
|
+
"Refusal",
|
|
46
|
+
"REFUSED",
|
|
47
|
+
"QUEUED",
|
|
48
|
+
"RUNNING",
|
|
49
|
+
"SUCCEEDED",
|
|
50
|
+
"FAILED",
|
|
51
|
+
"Quote",
|
|
52
|
+
"QuoteResponse",
|
|
53
|
+
"JobStatus",
|
|
54
|
+
"JobResult",
|
|
55
|
+
"ReceiptBundle",
|
|
56
|
+
"GovernedReceipt",
|
|
57
|
+
"CircuitExport",
|
|
58
|
+
"ResultExport",
|
|
59
|
+
"SandboxRun",
|
|
60
|
+
"Me",
|
|
61
|
+
"TransportError",
|
|
62
|
+
"PlatformError",
|
|
63
|
+
"normalize_state",
|
|
64
|
+
"is_terminal",
|
|
65
|
+
"OfflineVerification",
|
|
66
|
+
"ChainCheck",
|
|
67
|
+
"verify_receipt_offline",
|
|
68
|
+
]
|
quantufai/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
quantufai/canonical.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Canonical JSON — a byte-exact Python port of the platform's stableStringify.
|
|
2
|
+
|
|
3
|
+
Receipt signatures cover ``stableStringify(receipt minus signature fields)``:
|
|
4
|
+
deterministic JSON with lexicographically sorted object keys, serialized the
|
|
5
|
+
way JavaScript's ``JSON.stringify`` serializes scalars. Verification in Python
|
|
6
|
+
therefore needs the SAME bytes — including JavaScript's number formatting
|
|
7
|
+
(``1e21`` -> ``"1e+21"``, ``1e-7`` -> ``"1e-7"``, ``1.0`` -> ``"1"``), which
|
|
8
|
+
differs from Python's ``json.dumps`` in the corners.
|
|
9
|
+
|
|
10
|
+
Parity is pinned by shared test vectors (tests/fixtures/canonical_vectors.json)
|
|
11
|
+
asserted against both this module and the Node implementation.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _js_number(value: float) -> str:
|
|
21
|
+
"""Format a float exactly as ECMAScript Number::toString(10) would.
|
|
22
|
+
|
|
23
|
+
JSON.stringify emits non-finite numbers as ``null``; integral values
|
|
24
|
+
without a fractional part; exponential notation only for |exponent|
|
|
25
|
+
outside [-6, 21); exponents unpadded with an explicit sign.
|
|
26
|
+
"""
|
|
27
|
+
if math.isnan(value) or math.isinf(value):
|
|
28
|
+
return "null" # JSON.stringify(NaN) === "null"
|
|
29
|
+
if value == 0:
|
|
30
|
+
return "0"
|
|
31
|
+
# Shortest round-trip decimal digits (repr is shortest in Python >= 3.1,
|
|
32
|
+
# same guarantee V8 uses).
|
|
33
|
+
rep = repr(abs(value))
|
|
34
|
+
if "e" in rep or "E" in rep:
|
|
35
|
+
mantissa, _, exp_part = rep.lower().partition("e")
|
|
36
|
+
exp10 = int(exp_part)
|
|
37
|
+
else:
|
|
38
|
+
mantissa, exp10 = rep, 0
|
|
39
|
+
int_part, _, frac_part = mantissa.partition(".")
|
|
40
|
+
digits = (int_part + frac_part).lstrip("0")
|
|
41
|
+
# Decimal point position: value = 0.<digits> * 10**point
|
|
42
|
+
point = len(int_part.lstrip("0")) + exp10 if int_part.strip("0") else (
|
|
43
|
+
exp10 - (len(frac_part) - len(frac_part.lstrip("0")))
|
|
44
|
+
)
|
|
45
|
+
digits = digits.rstrip("0") or "0"
|
|
46
|
+
sign = "-" if value < 0 else ""
|
|
47
|
+
|
|
48
|
+
k = len(digits)
|
|
49
|
+
if k <= point <= 21: # integral, printed in full
|
|
50
|
+
return sign + digits + "0" * (point - k)
|
|
51
|
+
if 0 < point <= 21: # decimal point inside the digits
|
|
52
|
+
return sign + digits[:point] + "." + digits[point:]
|
|
53
|
+
if -6 < point <= 0: # small: leading zeros after "0."
|
|
54
|
+
return sign + "0." + "0" * (-point) + digits
|
|
55
|
+
# Exponential: d[.ddd]e±e (exponent = point - 1, unpadded, signed)
|
|
56
|
+
exponent = point - 1
|
|
57
|
+
exp_str = ("+" if exponent >= 0 else "-") + str(abs(exponent))
|
|
58
|
+
if k == 1:
|
|
59
|
+
return sign + digits + "e" + exp_str
|
|
60
|
+
return sign + digits[0] + "." + digits[1:] + "e" + exp_str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def stable_stringify(value: Any) -> str:
|
|
64
|
+
"""Deterministic JSON with sorted object keys — parity with the platform."""
|
|
65
|
+
if value is None:
|
|
66
|
+
return "null"
|
|
67
|
+
if value is True:
|
|
68
|
+
return "true"
|
|
69
|
+
if value is False:
|
|
70
|
+
return "false"
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
# ensure_ascii=False matches JSON.stringify (raw UTF-8, control chars
|
|
73
|
+
# escaped); Python and JS agree on the mandatory escape set.
|
|
74
|
+
return json.dumps(value, ensure_ascii=False)
|
|
75
|
+
if isinstance(value, int):
|
|
76
|
+
return str(value)
|
|
77
|
+
if isinstance(value, float):
|
|
78
|
+
# JS has one number type: 1.0 serializes as "1" (handled in _js_number).
|
|
79
|
+
return _js_number(value)
|
|
80
|
+
if isinstance(value, (list, tuple)):
|
|
81
|
+
return "[" + ",".join(stable_stringify(v) for v in value) + "]"
|
|
82
|
+
if isinstance(value, dict):
|
|
83
|
+
# JavaScript's Array.prototype.sort() compares strings by UTF-16 code
|
|
84
|
+
# units; utf-16-be byte order reproduces that exactly (receipt keys are
|
|
85
|
+
# ASCII in practice, but parity should not depend on practice).
|
|
86
|
+
items = sorted(
|
|
87
|
+
((str(k), v) for k, v in value.items()),
|
|
88
|
+
key=lambda kv: kv[0].encode("utf-16-be"),
|
|
89
|
+
)
|
|
90
|
+
return "{" + ",".join(
|
|
91
|
+
json.dumps(k, ensure_ascii=False) + ":" + stable_stringify(v) for k, v in items
|
|
92
|
+
) + "}"
|
|
93
|
+
raise TypeError(f"stable_stringify: unsupported type {type(value).__name__}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def unsigned_canonical(receipt: dict) -> str:
|
|
97
|
+
"""The exact bytes both platform signatures cover: the receipt minus its
|
|
98
|
+
``signature`` and ``publicSignature`` fields."""
|
|
99
|
+
clone = {k: v for k, v in receipt.items() if k not in ("signature", "publicSignature")}
|
|
100
|
+
return stable_stringify(clone)
|
quantufai/client.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""The QuantufAI client — quantum compute you can price and read, never spend.
|
|
2
|
+
|
|
3
|
+
THE DESIGN, STATED LOUDLY (this is not a missing feature)
|
|
4
|
+
=========================================================
|
|
5
|
+
This SDK has NO dispatch method, NO approval method, NO billing method.
|
|
6
|
+
A program holding an API key can:
|
|
7
|
+
|
|
8
|
+
- ask what a run would cost (``quote`` — estimates only, nothing reserved,
|
|
9
|
+
charged, or dispatched),
|
|
10
|
+
- follow runs it owns (``job_status``/``job_result``/``wait``),
|
|
11
|
+
- fetch and verify signed receipts (``receipt``/``governed_receipt``/
|
|
12
|
+
``verify_receipt``),
|
|
13
|
+
- take the exact circuit home (``export_circuit``/``export_result``),
|
|
14
|
+
- execute on the FREE local simulator (``sandbox_simulate`` — $0 by
|
|
15
|
+
construction; the request pins the free simulator on every call).
|
|
16
|
+
|
|
17
|
+
Spending money on quantum hardware requires a human approving a signed quote
|
|
18
|
+
in the QuantufAI dashboard. That is enforced SERVER-side (quote-before-spend;
|
|
19
|
+
scoped keys cannot approve), and this SDK's surface simply omits spending on
|
|
20
|
+
top of it. An AI agent driving this client cannot buy anything.
|
|
21
|
+
|
|
22
|
+
Every deliberate platform refusal is returned as a typed
|
|
23
|
+
:class:`~quantufai.types.Refusal` (status ``REFUSED``) — codes, messages, and
|
|
24
|
+
details verbatim. Exceptions are reserved for transport failures and
|
|
25
|
+
unexpected 5xx answers.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.parse
|
|
34
|
+
import urllib.request
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any, Dict, List, Optional, Union
|
|
37
|
+
|
|
38
|
+
from ._version import __version__
|
|
39
|
+
from .types import (
|
|
40
|
+
CircuitExport,
|
|
41
|
+
GovernedReceipt,
|
|
42
|
+
JobResult,
|
|
43
|
+
JobStatus,
|
|
44
|
+
Me,
|
|
45
|
+
PlatformError,
|
|
46
|
+
QuoteResponse,
|
|
47
|
+
ReceiptBundle,
|
|
48
|
+
Refusal,
|
|
49
|
+
ResultExport,
|
|
50
|
+
SandboxRun,
|
|
51
|
+
TransportError,
|
|
52
|
+
)
|
|
53
|
+
from .verify import OfflineVerification, verify_receipt_offline
|
|
54
|
+
|
|
55
|
+
#: The one provider the SDK will ever execute on: the platform's free local
|
|
56
|
+
#: statevector simulator. ``sandbox_simulate`` pins this on EVERY request, so
|
|
57
|
+
#: even a key that carries the paid ``runs:execute`` scope cannot reach
|
|
58
|
+
#: billable hardware through this SDK. (Sandbox keys are additionally clamped
|
|
59
|
+
#: server-side; naming any other provider gets a typed refusal, never a
|
|
60
|
+
#: silent reroute.)
|
|
61
|
+
FREE_SIMULATOR_PROVIDER = "classical"
|
|
62
|
+
|
|
63
|
+
_ENV_API_KEY = "QUANTUFAI_API_KEY"
|
|
64
|
+
_ENV_BASE_URL = "QUANTUFAI_BASE_URL"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ReceiptVerification:
|
|
69
|
+
"""Combined offline + platform verification report.
|
|
70
|
+
|
|
71
|
+
``offline`` is a pure function of the pasted bytes (Ed25519 tier).
|
|
72
|
+
``platform`` is the platform's own check of its HMAC server attestation
|
|
73
|
+
(requires the key's owner to own the receipt), or ``None`` when skipped,
|
|
74
|
+
or a :class:`Refusal` when the platform declined to answer.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
offline: OfflineVerification
|
|
78
|
+
platform: Optional[Union[Dict[str, Any], Refusal]] = None
|
|
79
|
+
notes: List[str] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def verified(self) -> bool:
|
|
83
|
+
"""True when ANY tier produced a positive verdict. Check the tiers
|
|
84
|
+
individually when you need to know WHICH guarantee you got."""
|
|
85
|
+
if self.offline.verified:
|
|
86
|
+
return True
|
|
87
|
+
return isinstance(self.platform, dict) and self.platform.get("valid") is True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Client:
|
|
91
|
+
"""Key-authenticated QuantufAI API client (see module docstring for the
|
|
92
|
+
can/can't-do contract — the short version: price and read, never spend).
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
api_key: a ``qfai_sk_...`` API key (default: ``$QUANTUFAI_API_KEY``).
|
|
96
|
+
base_url: the platform origin, e.g. ``https://<your-host>``
|
|
97
|
+
(default: ``$QUANTUFAI_BASE_URL``). Required — the SDK does not
|
|
98
|
+
guess a host.
|
|
99
|
+
timeout: per-request timeout in seconds.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
api_key: Optional[str] = None,
|
|
105
|
+
base_url: Optional[str] = None,
|
|
106
|
+
timeout: float = 30.0,
|
|
107
|
+
):
|
|
108
|
+
self.api_key = api_key or os.environ.get(_ENV_API_KEY) or ""
|
|
109
|
+
raw_base = base_url or os.environ.get(_ENV_BASE_URL) or ""
|
|
110
|
+
if not self.api_key:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"An API key is required (pass api_key= or set ${_ENV_API_KEY}). "
|
|
113
|
+
"Mint one in the QuantufAI dashboard — a sandbox-tier key runs the "
|
|
114
|
+
"free simulator with no card."
|
|
115
|
+
)
|
|
116
|
+
if not raw_base:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"A base URL is required (pass base_url= or set ${_ENV_BASE_URL})."
|
|
119
|
+
)
|
|
120
|
+
self.base_url = raw_base.rstrip("/")
|
|
121
|
+
self.timeout = timeout
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Transport
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def _headers(self) -> Dict[str, str]:
|
|
128
|
+
return {
|
|
129
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
"Accept": "application/json",
|
|
132
|
+
"User-Agent": f"quantufai-python/{__version__}",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _parse_error(status: int, body: Any) -> Refusal:
|
|
137
|
+
"""Shape the platform's two error envelopes into one typed Refusal:
|
|
138
|
+
``{error: {code, message, details}}`` and ``{error: "code", ...}``."""
|
|
139
|
+
if isinstance(body, dict):
|
|
140
|
+
err = body.get("error")
|
|
141
|
+
if isinstance(err, dict):
|
|
142
|
+
details = err.get("details")
|
|
143
|
+
return Refusal(
|
|
144
|
+
code=str(err.get("code", "unknown")),
|
|
145
|
+
message=str(err.get("message", err.get("code", ""))),
|
|
146
|
+
http_status=status,
|
|
147
|
+
details=details if isinstance(details, dict) else {},
|
|
148
|
+
)
|
|
149
|
+
if isinstance(err, str):
|
|
150
|
+
rest = {k: v for k, v in body.items() if k not in ("error", "message")}
|
|
151
|
+
return Refusal(
|
|
152
|
+
code=err,
|
|
153
|
+
message=str(body.get("message", err)),
|
|
154
|
+
http_status=status,
|
|
155
|
+
details=rest,
|
|
156
|
+
)
|
|
157
|
+
return Refusal(
|
|
158
|
+
code=f"http_{status}",
|
|
159
|
+
message=str(body)[:500] if body is not None else f"HTTP {status}",
|
|
160
|
+
http_status=status,
|
|
161
|
+
details={},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _request(
|
|
165
|
+
self,
|
|
166
|
+
method: str,
|
|
167
|
+
path: str,
|
|
168
|
+
*,
|
|
169
|
+
body: Optional[Dict[str, Any]] = None,
|
|
170
|
+
query: Optional[Dict[str, str]] = None,
|
|
171
|
+
) -> Union[Any, Refusal]:
|
|
172
|
+
"""Perform one request. 2xx -> parsed JSON (or raw text for non-JSON
|
|
173
|
+
bodies); 4xx -> :class:`Refusal`; 5xx -> :class:`PlatformError`;
|
|
174
|
+
network trouble -> :class:`TransportError`."""
|
|
175
|
+
url = self.base_url + path
|
|
176
|
+
if query:
|
|
177
|
+
url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
|
|
178
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
179
|
+
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
|
|
180
|
+
try:
|
|
181
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
182
|
+
raw = resp.read()
|
|
183
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
184
|
+
if "json" in content_type:
|
|
185
|
+
return json.loads(raw.decode("utf-8")) if raw else {}
|
|
186
|
+
return raw.decode("utf-8")
|
|
187
|
+
except urllib.error.HTTPError as err:
|
|
188
|
+
raw = err.read()
|
|
189
|
+
try:
|
|
190
|
+
parsed = json.loads(raw.decode("utf-8")) if raw else None
|
|
191
|
+
except (ValueError, UnicodeDecodeError):
|
|
192
|
+
parsed = raw.decode("utf-8", "replace") if raw else None
|
|
193
|
+
if 400 <= err.code < 500:
|
|
194
|
+
return self._parse_error(err.code, parsed)
|
|
195
|
+
refusal_shaped = self._parse_error(err.code, parsed)
|
|
196
|
+
raise PlatformError(err.code, refusal_shaped.code, refusal_shaped.message, parsed)
|
|
197
|
+
except urllib.error.URLError as err:
|
|
198
|
+
raise TransportError(f"{method} {url}: {err.reason}") from err
|
|
199
|
+
except TimeoutError as err:
|
|
200
|
+
raise TransportError(f"{method} {url}: timed out after {self.timeout}s") from err
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# Quote before spend (quotes:read)
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def quote(
|
|
207
|
+
self,
|
|
208
|
+
circuit: str,
|
|
209
|
+
*,
|
|
210
|
+
shots: int = 1000,
|
|
211
|
+
qubits: Optional[int] = None,
|
|
212
|
+
max_cost_usd: Optional[float] = None,
|
|
213
|
+
max_latency_ms: Optional[int] = None,
|
|
214
|
+
min_fidelity: Optional[float] = None,
|
|
215
|
+
) -> Union[QuoteResponse, Refusal]:
|
|
216
|
+
"""Ask what this run WOULD cost, per provider. A quote never reserves,
|
|
217
|
+
charges, or dispatches anything — and neither can any other method
|
|
218
|
+
here. Requires the ``quotes:read`` scope."""
|
|
219
|
+
intent: Dict[str, Any] = {"type": "circuit", "shots": shots}
|
|
220
|
+
if max_cost_usd is not None:
|
|
221
|
+
intent["maxCostUsd"] = max_cost_usd
|
|
222
|
+
if max_latency_ms is not None:
|
|
223
|
+
intent["maxLatencyMs"] = max_latency_ms
|
|
224
|
+
if min_fidelity is not None:
|
|
225
|
+
intent["minFidelity"] = min_fidelity
|
|
226
|
+
params: Dict[str, Any] = {"shots": shots}
|
|
227
|
+
if qubits is not None:
|
|
228
|
+
params["qubits"] = qubits
|
|
229
|
+
raw = self._request(
|
|
230
|
+
"POST", "/api/v1/quotes",
|
|
231
|
+
body={"intent": intent, "payload": {"circuit": circuit, "params": params}},
|
|
232
|
+
)
|
|
233
|
+
if isinstance(raw, Refusal):
|
|
234
|
+
return raw
|
|
235
|
+
return QuoteResponse.from_raw(raw)
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
# Follow your runs (jobs:read)
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def job_status(self, job_id: str) -> Union[JobStatus, Refusal]:
|
|
242
|
+
""""Is it done yet?" — status poll. Requires ``jobs:read``. You see
|
|
243
|
+
only your own runs; someone else's job answers ``job_not_found``."""
|
|
244
|
+
raw = self._request("GET", f"/api/v1/jobs/{urllib.parse.quote(job_id)}")
|
|
245
|
+
return raw if isinstance(raw, Refusal) else JobStatus.from_raw(raw)
|
|
246
|
+
|
|
247
|
+
def job_result(self, job_id: str) -> Union[JobResult, Refusal]:
|
|
248
|
+
"""Status + counts + operational ledger. Still-running hardware jobs
|
|
249
|
+
are refreshed from the provider at read time. Requires ``jobs:read``."""
|
|
250
|
+
raw = self._request("GET", f"/api/v1/jobs/{urllib.parse.quote(job_id)}/result")
|
|
251
|
+
return raw if isinstance(raw, Refusal) else JobResult.from_raw(raw)
|
|
252
|
+
|
|
253
|
+
def wait(
|
|
254
|
+
self,
|
|
255
|
+
job_id: str,
|
|
256
|
+
*,
|
|
257
|
+
timeout: float = 600.0,
|
|
258
|
+
poll_interval: float = 2.0,
|
|
259
|
+
) -> Union[JobResult, Refusal]:
|
|
260
|
+
"""Poll ``job_result`` until the run reaches a terminal state.
|
|
261
|
+
|
|
262
|
+
Raises :class:`TransportError` when ``timeout`` elapses first (the
|
|
263
|
+
run itself is NOT cancelled — this SDK cannot mutate runs)."""
|
|
264
|
+
deadline = time.monotonic() + timeout
|
|
265
|
+
while True:
|
|
266
|
+
result = self.job_result(job_id)
|
|
267
|
+
if isinstance(result, Refusal) or result.done:
|
|
268
|
+
return result
|
|
269
|
+
if time.monotonic() >= deadline:
|
|
270
|
+
raise TransportError(
|
|
271
|
+
f"wait({job_id!r}): still {result.state} after {timeout}s "
|
|
272
|
+
"(the run keeps going; poll again or raise the timeout)"
|
|
273
|
+
)
|
|
274
|
+
time.sleep(poll_interval)
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------
|
|
277
|
+
# Receipts (jobs:read)
|
|
278
|
+
# ------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def receipt(self, job_id: str) -> Union[ReceiptBundle, Refusal]:
|
|
281
|
+
"""The run's ledger receipts + artifacts summary. Requires ``jobs:read``."""
|
|
282
|
+
raw = self._request("GET", f"/api/v1/jobs/{urllib.parse.quote(job_id)}/receipt")
|
|
283
|
+
return raw if isinstance(raw, Refusal) else ReceiptBundle.from_raw(raw)
|
|
284
|
+
|
|
285
|
+
def governed_receipt(self, job_id: str) -> Union[GovernedReceipt, Refusal]:
|
|
286
|
+
"""The signed, downloadable Governed Run Receipt — the tamper-evident
|
|
287
|
+
artifact the dashboard serves, byte-for-byte. Requires ``jobs:read``."""
|
|
288
|
+
raw = self._request(
|
|
289
|
+
"GET", f"/api/v1/jobs/{urllib.parse.quote(job_id)}/governed-receipt"
|
|
290
|
+
)
|
|
291
|
+
return raw if isinstance(raw, Refusal) else GovernedReceipt(data=raw)
|
|
292
|
+
|
|
293
|
+
def verify_receipt(
|
|
294
|
+
self,
|
|
295
|
+
receipt: Union[Dict[str, Any], GovernedReceipt],
|
|
296
|
+
*,
|
|
297
|
+
public_key: Optional[str] = None,
|
|
298
|
+
offline_only: bool = False,
|
|
299
|
+
) -> ReceiptVerification:
|
|
300
|
+
"""Verify a receipt you hold. Two independent tiers, reported honestly:
|
|
301
|
+
|
|
302
|
+
- OFFLINE (Ed25519 ``publicSignature``): pure function of the pasted
|
|
303
|
+
bytes + QuantufAI's published public key. Needs ``public_key`` and
|
|
304
|
+
the ``quantufai[verify]`` extra.
|
|
305
|
+
- PLATFORM (HMAC server attestation): asks the platform to confirm
|
|
306
|
+
the signature is one IT issued (``POST /api/v1/jobs/verify-receipt``,
|
|
307
|
+
``jobs:read``; the receipt must be your own).
|
|
308
|
+
|
|
309
|
+
Neither tier is silently substituted for the other."""
|
|
310
|
+
data = receipt.data if isinstance(receipt, GovernedReceipt) else receipt
|
|
311
|
+
offline = verify_receipt_offline(data, public_key=public_key)
|
|
312
|
+
platform: Optional[Union[Dict[str, Any], Refusal]] = None
|
|
313
|
+
notes: List[str] = []
|
|
314
|
+
if not offline_only:
|
|
315
|
+
answer = self._request(
|
|
316
|
+
"POST", "/api/v1/jobs/verify-receipt", body={"receipt": data}
|
|
317
|
+
)
|
|
318
|
+
platform = answer
|
|
319
|
+
if isinstance(answer, Refusal):
|
|
320
|
+
notes.append(
|
|
321
|
+
f"platform check refused ({answer.code}): {answer.message}"
|
|
322
|
+
)
|
|
323
|
+
return ReceiptVerification(offline=offline, platform=platform, notes=notes)
|
|
324
|
+
|
|
325
|
+
# ------------------------------------------------------------------
|
|
326
|
+
# Take the circuit home (results:export)
|
|
327
|
+
# ------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def export_circuit(
|
|
330
|
+
self,
|
|
331
|
+
job_id: str,
|
|
332
|
+
*,
|
|
333
|
+
format: str,
|
|
334
|
+
which: str,
|
|
335
|
+
) -> Union[CircuitExport, Refusal]:
|
|
336
|
+
"""Export the exact stored circuit of a run as ready-to-run source
|
|
337
|
+
(``qiskit``/``cirq``/``braket``/``pytket``/``qasm``). ``which`` is
|
|
338
|
+
REQUIRED — ``"original"`` (as authored) or ``"as-dispatched"`` (the
|
|
339
|
+
exact program handed to the executor); the platform refuses to guess.
|
|
340
|
+
Untranslatable circuits get a typed refusal, never a lossy guess.
|
|
341
|
+
Requires ``results:export``."""
|
|
342
|
+
raw = self._request(
|
|
343
|
+
"GET",
|
|
344
|
+
f"/api/v1/jobs/{urllib.parse.quote(job_id)}/circuit",
|
|
345
|
+
query={"format": format, "which": which, "envelope": "json"},
|
|
346
|
+
)
|
|
347
|
+
return raw if isinstance(raw, Refusal) else CircuitExport.from_raw(raw)
|
|
348
|
+
|
|
349
|
+
def export_result(
|
|
350
|
+
self, job_id: str, *, format: str = "json"
|
|
351
|
+
) -> Union[ResultExport, Refusal]:
|
|
352
|
+
"""Download the run's result artifact (the Jobs-page "Download
|
|
353
|
+
Result"). Incomplete runs answer a typed refusal. Requires
|
|
354
|
+
``results:export``."""
|
|
355
|
+
raw = self._request(
|
|
356
|
+
"GET",
|
|
357
|
+
f"/api/v1/jobs/{urllib.parse.quote(job_id)}/export",
|
|
358
|
+
query={"format": format},
|
|
359
|
+
)
|
|
360
|
+
if isinstance(raw, Refusal):
|
|
361
|
+
return raw
|
|
362
|
+
content = raw if isinstance(raw, str) else json.dumps(raw)
|
|
363
|
+
return ResultExport(content=content, format=format)
|
|
364
|
+
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
# Sandbox execution (runs:simulate) — the ONLY execution here, $0
|
|
367
|
+
# ------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
def sandbox_simulate(
|
|
370
|
+
self,
|
|
371
|
+
circuit: str,
|
|
372
|
+
*,
|
|
373
|
+
shots: int = 1000,
|
|
374
|
+
qubits: Optional[int] = None,
|
|
375
|
+
) -> Union[SandboxRun, Refusal]:
|
|
376
|
+
"""Run the circuit on the FREE local statevector simulator.
|
|
377
|
+
|
|
378
|
+
The request pins ``preferredProviders=["classical"]`` — the free
|
|
379
|
+
simulator — on EVERY call, so no key, whatever its scopes, can reach
|
|
380
|
+
billable hardware through this method (zero eligible providers is a
|
|
381
|
+
typed failure server-side, never a silent reroute). Sandbox-tier keys
|
|
382
|
+
(``runs:simulate``) are additionally clamped and daily-quota'd by the
|
|
383
|
+
platform. Real statevector physics, real counts, $0."""
|
|
384
|
+
params: Dict[str, Any] = {"shots": shots}
|
|
385
|
+
if qubits is not None:
|
|
386
|
+
params["qubits"] = qubits
|
|
387
|
+
raw = self._request(
|
|
388
|
+
"POST",
|
|
389
|
+
"/api/v1/execute",
|
|
390
|
+
body={
|
|
391
|
+
"intent": {"type": "circuit", "shots": shots},
|
|
392
|
+
"payload": {"circuit": circuit, "params": params},
|
|
393
|
+
# THE CLAMP. Do not remove, do not parameterize: this constant
|
|
394
|
+
# is what makes the SDK's "can never spend" claim true even
|
|
395
|
+
# for keys that carry runs:execute.
|
|
396
|
+
"preferredProviders": [FREE_SIMULATOR_PROVIDER],
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
return raw if isinstance(raw, Refusal) else SandboxRun.from_raw(raw)
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
# Account awareness (account:read)
|
|
403
|
+
# ------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
def me(self) -> Union[Me, Refusal]:
|
|
406
|
+
"""Who is this key? Tier, credits, granted scopes, rate limits.
|
|
407
|
+
Requires ``account:read``."""
|
|
408
|
+
raw = self._request("GET", "/api/v1/me")
|
|
409
|
+
return raw if isinstance(raw, Refusal) else Me.from_raw(raw)
|