qilisdk 0.1.5__py3-none-any.whl → 0.1.7__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.
Files changed (49) hide show
  1. qilisdk/analog/__init__.py +1 -2
  2. qilisdk/analog/hamiltonian.py +4 -71
  3. qilisdk/analog/schedule.py +291 -313
  4. qilisdk/backends/backend.py +5 -1
  5. qilisdk/backends/cuda_backend.py +10 -6
  6. qilisdk/backends/qutip_backend.py +24 -32
  7. qilisdk/{common → core}/__init__.py +4 -0
  8. qilisdk/core/interpolator.py +406 -0
  9. qilisdk/{common → core}/model.py +7 -7
  10. qilisdk/core/parameterizable.py +131 -0
  11. qilisdk/{common → core}/qtensor.py +1 -1
  12. qilisdk/{common → core}/variables.py +192 -11
  13. qilisdk/cost_functions/cost_function.py +1 -1
  14. qilisdk/cost_functions/model_cost_function.py +5 -5
  15. qilisdk/cost_functions/observable_cost_function.py +2 -2
  16. qilisdk/digital/ansatz.py +0 -3
  17. qilisdk/digital/circuit.py +3 -2
  18. qilisdk/digital/circuit_transpiler.py +46 -0
  19. qilisdk/digital/circuit_transpiler_passes/__init__.py +18 -0
  20. qilisdk/digital/circuit_transpiler_passes/circuit_transpiler_pass.py +36 -0
  21. qilisdk/digital/circuit_transpiler_passes/decompose_multi_controlled_gates_pass.py +216 -0
  22. qilisdk/digital/circuit_transpiler_passes/numeric_helpers.py +82 -0
  23. qilisdk/digital/gates.py +15 -5
  24. qilisdk/{speqtrum/experiments → experiments}/__init__.py +13 -2
  25. qilisdk/{speqtrum/experiments → experiments}/experiment_functional.py +90 -2
  26. qilisdk/{speqtrum/experiments → experiments}/experiment_result.py +16 -0
  27. qilisdk/functionals/functional.py +2 -2
  28. qilisdk/functionals/functional_result.py +1 -1
  29. qilisdk/functionals/sampling.py +8 -1
  30. qilisdk/functionals/time_evolution.py +8 -4
  31. qilisdk/functionals/time_evolution_result.py +2 -2
  32. qilisdk/functionals/variational_program.py +58 -0
  33. qilisdk/optimizers/optimizer_result.py +1 -1
  34. qilisdk/speqtrum/__init__.py +2 -0
  35. qilisdk/speqtrum/speqtrum.py +537 -152
  36. qilisdk/speqtrum/speqtrum_models.py +258 -2
  37. qilisdk/utils/openfermion/__init__.py +38 -0
  38. qilisdk/{common/algorithm.py → utils/openfermion/__init__.pyi} +2 -3
  39. qilisdk/utils/openfermion/openfermion.py +45 -0
  40. qilisdk/utils/visualization/schedule_renderers.py +22 -9
  41. {qilisdk-0.1.5.dist-info → qilisdk-0.1.7.dist-info}/METADATA +89 -39
  42. qilisdk-0.1.7.dist-info/RECORD +76 -0
  43. {qilisdk-0.1.5.dist-info → qilisdk-0.1.7.dist-info}/WHEEL +1 -1
  44. qilisdk/analog/linear_schedule.py +0 -118
  45. qilisdk/common/parameterizable.py +0 -75
  46. qilisdk-0.1.5.dist-info/RECORD +0 -69
  47. /qilisdk/{common → core}/exceptions.py +0 -0
  48. /qilisdk/{common → core}/result.py +0 -0
  49. {qilisdk-0.1.5.dist-info → qilisdk-0.1.7.dist-info}/licenses/LICENCE +0 -0
@@ -14,19 +14,37 @@
14
14
  from __future__ import annotations
15
15
 
16
16
  import base64
17
+ import binascii
17
18
  import json
18
19
  import time
19
20
  from base64 import urlsafe_b64encode
20
21
  from datetime import datetime, timezone
21
- from typing import TYPE_CHECKING, Callable, cast
22
+ from typing import TYPE_CHECKING, Any, Callable, Generator, TypeAlias, TypeVar, cast, overload
22
23
 
23
24
  import httpx
24
25
  from loguru import logger
25
- from pydantic import TypeAdapter
26
-
27
- from qilisdk.functionals import Sampling, TimeEvolution, VariationalProgram
26
+ from pydantic import TypeAdapter, ValidationError
27
+
28
+ from qilisdk.experiments import (
29
+ RabiExperiment,
30
+ RabiExperimentResult,
31
+ T1Experiment,
32
+ T1ExperimentResult,
33
+ T2Experiment,
34
+ T2ExperimentResult,
35
+ TwoTonesExperiment,
36
+ TwoTonesExperimentResult,
37
+ )
38
+ from qilisdk.functionals import (
39
+ Sampling,
40
+ SamplingResult,
41
+ TimeEvolution,
42
+ TimeEvolutionResult,
43
+ VariationalProgram,
44
+ VariationalProgramResult,
45
+ )
46
+ from qilisdk.functionals.functional_result import FunctionalResult
28
47
  from qilisdk.settings import get_settings
29
- from qilisdk.speqtrum.experiments import ExperimentFunctional, RabiExperiment, T1Experiment
30
48
 
31
49
  from .keyring import delete_credentials, load_credentials, store_credentials
32
50
  from .speqtrum_models import (
@@ -34,6 +52,7 @@ from .speqtrum_models import (
34
52
  ExecutePayload,
35
53
  ExecuteType,
36
54
  JobDetail,
55
+ JobHandle,
37
56
  JobId,
38
57
  JobInfo,
39
58
  JobStatus,
@@ -41,8 +60,11 @@ from .speqtrum_models import (
41
60
  RabiExperimentPayload,
42
61
  SamplingPayload,
43
62
  T1ExperimentPayload,
63
+ T2ExperimentPayload,
44
64
  TimeEvolutionPayload,
45
65
  Token,
66
+ TwoTonesExperimentPayload,
67
+ TypedJobDetail,
46
68
  VariationalProgramPayload,
47
69
  )
48
70
 
@@ -50,27 +72,199 @@ if TYPE_CHECKING:
50
72
  from qilisdk.functionals.functional import Functional, PrimitiveFunctional
51
73
 
52
74
 
75
+ TFunctionalResult = TypeVar("TFunctionalResult", bound=FunctionalResult)
76
+ JSONValue: TypeAlias = dict[str, "JSONValue"] | list["JSONValue"] | str | int | float | bool | None
77
+
78
+ _SKIP_ENSURE_OK_EXTENSION = "qilisdk.skip_ensure_ok"
79
+ _CONTEXT_EXTENSION = "qilisdk.request_context"
80
+
81
+
82
+ class SpeQtrumAPIError(httpx.HTTPStatusError):
83
+ """Raised when the SpeQtrum API responds with a non-success HTTP status."""
84
+
85
+ def __init__(self, message: str, *, request: httpx.Request, response: httpx.Response) -> None:
86
+ super().__init__(message, request=request, response=response)
87
+
88
+
89
+ def _safe_json_loads(value: str, *, context: str) -> JSONValue | None:
90
+ try:
91
+ result = json.loads(value)
92
+ return cast("JSONValue", result)
93
+ except json.JSONDecodeError as exc:
94
+ logger.warning("Failed to decode JSON for {}: {}", context, exc)
95
+ return None
96
+
97
+
98
+ def _safe_b64_decode(value: str, *, context: str) -> str | None:
99
+ try:
100
+ decoded_bytes = base64.b64decode(value)
101
+ except (binascii.Error, ValueError) as exc:
102
+ logger.warning("Failed to base64 decode {}: {}", context, exc)
103
+ return None
104
+ try:
105
+ return decoded_bytes.decode("utf-8")
106
+ except UnicodeDecodeError as exc:
107
+ logger.warning("Failed to UTF-8 decode {}: {}", context, exc)
108
+ return None
109
+
110
+
111
+ def _safe_b64_json(value: str, *, context: str) -> JSONValue | None:
112
+ decoded_text = _safe_b64_decode(value, context=context)
113
+ if decoded_text is None:
114
+ return None
115
+ return _safe_json_loads(decoded_text, context=context)
116
+
117
+
118
+ def _request_extensions(*, context: str | None = None, skip_ensure_ok: bool = False) -> dict[str, Any] | None:
119
+ extensions: dict[str, Any] = {}
120
+ if context:
121
+ extensions[_CONTEXT_EXTENSION] = context
122
+ if skip_ensure_ok:
123
+ extensions[_SKIP_ENSURE_OK_EXTENSION] = True
124
+ return extensions or None
125
+
126
+
127
+ def _response_context(response: httpx.Response) -> str:
128
+ request = response.request
129
+ if request is None:
130
+ return "SpeQtrum API call"
131
+ context = request.extensions.get(_CONTEXT_EXTENSION)
132
+ if isinstance(context, str) and context.strip():
133
+ return context
134
+ return f"{request.method} {request.url}"
135
+
136
+
137
+ def _stringify_payload(payload: JSONValue | None) -> str | None:
138
+ if payload is None:
139
+ return None
140
+ if isinstance(payload, dict):
141
+ for key in ("message", "detail", "error", "error_description", "title"):
142
+ value = payload.get(key)
143
+ if isinstance(value, str) and value.strip():
144
+ code = payload.get("code") or payload.get("error_code") or payload.get("errorCode")
145
+ code_suffix = f" (code={code})" if isinstance(code, (str, int)) else ""
146
+ return value.strip() + code_suffix
147
+ try:
148
+ return json.dumps(payload, sort_keys=True)
149
+ except (TypeError, ValueError):
150
+ return str(payload)
151
+ if isinstance(payload, list):
152
+ preview = ", ".join(str(item) for item in payload[:3])
153
+ return preview or str(payload)
154
+ if isinstance(payload, (str, int, float, bool)):
155
+ return str(payload)
156
+ return None
157
+
158
+
159
+ def _summarize_error_payload(response: httpx.Response) -> str:
160
+ context = _response_context(response)
161
+ try:
162
+ body_text = response.text or ""
163
+ except httpx.ResponseNotRead:
164
+ try:
165
+ response.read() # ensure body is buffered so we can reuse it later
166
+ body_text = response.text or ""
167
+ except Exception as exc: # noqa: BLE001
168
+ logger.debug("Failed to read response body for {}: {}", context, exc)
169
+ body_text = ""
170
+ payload = _safe_json_loads(body_text, context=f"{context} error body") if body_text else None
171
+ detail = _stringify_payload(payload)
172
+ if detail:
173
+ return detail
174
+ if body_text.strip():
175
+ return body_text.strip()
176
+ return "no response body"
177
+
178
+
179
+ def _ensure_ok(response: httpx.Response) -> None:
180
+ request = response.request
181
+ if request is not None and request.extensions.get(_SKIP_ENSURE_OK_EXTENSION):
182
+ return
183
+ if response.status_code == httpx.codes.UNAUTHORIZED:
184
+ return
185
+ try:
186
+ response.raise_for_status()
187
+ except httpx.HTTPStatusError:
188
+ context = _response_context(response)
189
+ detail = _summarize_error_payload(response)
190
+ logger.error(
191
+ "{} failed with status {} {}: {}",
192
+ context,
193
+ response.status_code,
194
+ response.reason_phrase,
195
+ detail,
196
+ )
197
+ message = f"{context} failed with status {response.status_code}: {detail}"
198
+ raise SpeQtrumAPIError(message, request=response.request, response=response) from None
199
+
200
+
201
+ class _BearerAuth(httpx.Auth):
202
+ """Bearer token auth handler with automatic refresh support."""
203
+
204
+ requires_response_body = True
205
+
206
+ def __init__(self, client: SpeQtrum) -> None:
207
+ self._client = client
208
+
209
+ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
210
+ request.headers["Authorization"] = f"Bearer {self._client.token.access_token}"
211
+ response = yield request
212
+
213
+ if response.status_code == httpx.codes.UNAUTHORIZED:
214
+ settings = get_settings()
215
+ refresh_request = httpx.Request(
216
+ "POST",
217
+ settings.speqtrum_api_url + "/authorisation-tokens/refresh",
218
+ headers={"Authorization": f"Bearer {self._client.token.refresh_token}"},
219
+ extensions=_request_extensions(context="Refreshing SpeQtrum token", skip_ensure_ok=True),
220
+ )
221
+ refresh_response = yield refresh_request
222
+
223
+ try:
224
+ refresh_response.raise_for_status()
225
+ except httpx.HTTPStatusError as exc:
226
+ logger.error(
227
+ "Token refresh failed with status {} {}",
228
+ exc.response.status_code,
229
+ exc.response.reason_phrase,
230
+ )
231
+ raise
232
+
233
+ try:
234
+ payload = refresh_response.json()
235
+ except json.JSONDecodeError as exc:
236
+ logger.error("Token refresh returned invalid JSON: {}", exc)
237
+ raise RuntimeError("SpeQtrum token refresh failed: invalid JSON payload") from exc
238
+
239
+ if not isinstance(payload, dict):
240
+ logger.error("Token refresh returned non-object payload: {}", type(payload).__name__)
241
+ raise RuntimeError("SpeQtrum token refresh failed: malformed token payload")
242
+
243
+ payload.pop("refreshToken", None)
244
+
245
+ try:
246
+ token = Token(**payload, refreshToken=self._client.token.refresh_token)
247
+ except (TypeError, ValidationError) as exc:
248
+ logger.error("Token refresh returned malformed payload: {}", exc)
249
+ raise RuntimeError("SpeQtrum token refresh failed: malformed token payload") from exc
250
+
251
+ self._client.token = token
252
+ store_credentials(self._client.username, self._client.token)
253
+ request.headers["Authorization"] = f"Bearer {self._client.token.access_token}"
254
+ yield request
255
+
256
+
53
257
  class SpeQtrum:
54
258
  """Synchronous client for the Qilimanjaro SpeQtrum API."""
55
259
 
56
260
  def __init__(self) -> None:
57
- logger.debug("Initializing QaaS client")
261
+ logger.debug("Initializing SpeQtrum client")
58
262
  credentials = load_credentials()
59
263
  if credentials is None:
60
- logger.error("No QaaS credentials found. Call `.login()` or set env vars before instantiation.")
61
- raise RuntimeError("Missing QaaS credentials - invoke SpeQtrum.login() first.")
62
- self._username, self._token = credentials
63
- self._handlers: dict[type[Functional], Callable[[Functional, str], int]] = {
64
- Sampling: lambda f, device: self._submit_sampling(cast("Sampling", f), device),
65
- TimeEvolution: lambda f, device: self._submit_time_evolution(cast("TimeEvolution", f), device),
66
- VariationalProgram: lambda f, device: self._submit_variational_program(
67
- cast("VariationalProgram", f), device
68
- ),
69
- RabiExperiment: lambda f, device: self._submit_rabi_program(cast("RabiExperiment", f), device),
70
- T1Experiment: lambda f, device: self._submit_t1_program(cast("T1Experiment", f), device),
71
- }
72
- self._settings = get_settings()
73
- logger.success("QaaS client initialised for user '{}'", self._username)
264
+ logger.error("No credentials found. Call `SpeQtrum.login()` before instantiation.")
265
+ raise RuntimeError("Missing credentials - invoke SpeQtrum.login() first.")
266
+ self.username, self.token = credentials
267
+ logger.success("SpeQtrum client initialised for user '{}'", self.username)
74
268
 
75
269
  @classmethod
76
270
  def _get_headers(cls) -> dict:
@@ -78,8 +272,15 @@ class SpeQtrum:
78
272
 
79
273
  return {"User-Agent": f"qilisdk/{__version__}"}
80
274
 
81
- def _get_authorized_headers(self) -> dict:
82
- return {**self._get_headers(), "Authorization": f"Bearer {self._token.access_token}"}
275
+ def _create_client(self) -> httpx.Client:
276
+ """Return a freshly configured HTTP client for SpeQtrum interactions."""
277
+ settings = get_settings()
278
+ return httpx.Client(
279
+ base_url=settings.speqtrum_api_url,
280
+ headers=self._get_headers(),
281
+ auth=_BearerAuth(self),
282
+ event_hooks={"response": [_ensure_ok]},
283
+ )
83
284
 
84
285
  @classmethod
85
286
  def login(
@@ -108,7 +309,7 @@ class SpeQtrum:
108
309
  apikey = apikey or settings.speqtrum_apikey
109
310
 
110
311
  if not username or not apikey:
111
- logger.warning("Login called without credentials - aborting")
312
+ logger.error("Login called without credentials.")
112
313
  return False
113
314
 
114
315
  # Send login request to QaaS
@@ -121,23 +322,24 @@ class SpeQtrum:
121
322
  "iat": int(datetime.now(timezone.utc).timestamp()),
122
323
  }
123
324
  encoded_assertion = urlsafe_b64encode(json.dumps(assertion, indent=2).encode("utf-8")).decode("utf-8")
124
- with httpx.Client(timeout=10.0) as client:
325
+ with httpx.Client(
326
+ base_url=settings.speqtrum_api_url,
327
+ headers=cls._get_headers(),
328
+ timeout=10.0,
329
+ event_hooks={"response": [_ensure_ok]},
330
+ ) as client:
125
331
  response = client.post(
126
- settings.speqtrum_api_url + "/authorisation-tokens",
332
+ "/authorisation-tokens",
127
333
  json={
128
334
  "grantType": "urn:ietf:params:oauth:grant-type:jwt-bearer",
129
335
  "assertion": encoded_assertion,
130
336
  "scope": "user profile",
131
337
  },
132
- headers=cls._get_headers(),
338
+ extensions=_request_extensions(context="Authenticating user"),
133
339
  )
134
340
  response.raise_for_status()
135
341
  token = Token(**response.json())
136
- except httpx.HTTPStatusError as exc:
137
- logger.error("Login failed - server returned {} {}", exc.response.status_code, exc.response.reason_phrase)
138
- return False
139
- except httpx.RequestError:
140
- logger.exception("Network error while logging in to QaaS")
342
+ except httpx.HTTPError:
141
343
  return False
142
344
 
143
345
  store_credentials(username=username, token=token)
@@ -161,12 +363,9 @@ class SpeQtrum:
161
363
  A list of :class:`~qilisdk.models.Device` objects.
162
364
  """
163
365
  logger.debug("Fetching device list from server…")
164
- with httpx.Client() as client:
165
- response = client.get(self._settings.speqtrum_api_url + "/devices", headers=self._get_authorized_headers())
166
- response.raise_for_status()
167
-
168
- devices = TypeAdapter(list[Device]).validate_python(response.json()["items"])
169
-
366
+ with self._create_client() as client:
367
+ response = client.get("/devices", extensions=_request_extensions(context="Fetching device list"))
368
+ devices = TypeAdapter(list[Device]).validate_python(response.json()["items"])
170
369
  logger.success("{} devices retrieved", len(devices))
171
370
  return [d for d in devices if where(d)] if where else devices
172
371
 
@@ -182,30 +381,33 @@ class SpeQtrum:
182
381
  A list of :class:`~qilisdk.models.JobInfo` objects.
183
382
  """
184
383
  logger.debug("Fetching job list…")
185
- with httpx.Client() as client:
186
- response = client.get(self._settings.speqtrum_api_url + "/jobs", headers=self._get_authorized_headers())
187
- response.raise_for_status()
188
-
189
- jobs = TypeAdapter(list[JobInfo]).validate_python(response.json()["items"])
190
-
384
+ with self._create_client() as client:
385
+ response = client.get("/jobs", extensions=_request_extensions(context="Fetching job list"))
386
+ jobs = TypeAdapter(list[JobInfo]).validate_python(response.json()["items"])
191
387
  logger.success("{} jobs retrieved", len(jobs))
192
388
  return [j for j in jobs if where(j)] if where else jobs
193
389
 
194
- def get_job_details(self, id: int) -> JobDetail:
195
- """Fetch the complete record of *id*.
390
+ @overload
391
+ def get_job(self, job: JobHandle[TFunctionalResult]) -> TypedJobDetail[TFunctionalResult]: ...
392
+
393
+ @overload
394
+ def get_job(self, job: int) -> JobDetail: ...
395
+
396
+ def get_job(self, job: int | JobHandle[Any]) -> JobDetail | TypedJobDetail[Any]:
397
+ """Fetch the complete record of *job*.
196
398
 
197
399
  Args:
198
- id: Identifier of the job.
400
+ job: Either the integer identifier or a previously returned `JobHandle`.
199
401
 
200
402
  Returns:
201
- A :class:`~qilisdk.models.JobDetail` instance containing payload,
202
- result, logs and error information.
403
+ A :class:`~qilisdk.models.JobDetail` snapshot. When a handle is supplied the
404
+ result is wrapped in :class:`~qilisdk.models.TypedJobDetail` to expose typed accessors.
203
405
  """
204
- logger.debug("Retrieving job {} details", id)
205
- with httpx.Client() as client:
406
+ job_id = job.id if isinstance(job, JobHandle) else job
407
+ logger.debug("Retrieving job {} details", job_id)
408
+ with self._create_client() as client:
206
409
  response = client.get(
207
- f"{self._settings.speqtrum_api_url}/jobs/{id}",
208
- headers=self._get_authorized_headers(),
410
+ f"/jobs/{job_id}",
209
411
  params={
210
412
  "payload": True,
211
413
  "result": True,
@@ -213,55 +415,75 @@ class SpeQtrum:
213
415
  "error_logs": True,
214
416
  "error": True,
215
417
  },
418
+ extensions=_request_extensions(context=f"Fetching job {job_id}"),
216
419
  )
217
- response.raise_for_status()
218
- data = response.json()
219
-
220
- raw_payload = data["payload"]
221
- if raw_payload is not None:
222
- data["payload"] = json.loads(raw_payload)
420
+ data = response.json()
421
+ raw_payload = data.get("payload")
422
+ if isinstance(raw_payload, str):
423
+ data["payload"] = _safe_json_loads(raw_payload, context=f"job {job_id} payload")
223
424
 
224
425
  raw_result = data.get("result")
225
- if raw_result is not None:
226
- decoded_result: bytes = base64.b64decode(raw_result)
227
- text_result = decoded_result.decode("utf-8")
228
- data["result"] = json.loads(text_result)
426
+ if isinstance(raw_result, str):
427
+ data["result"] = _safe_b64_json(raw_result, context=f"job {job_id} result")
229
428
 
230
429
  raw_error = data.get("error")
231
- if raw_error is not None:
232
- decoded_error: bytes = base64.b64decode(raw_error)
233
- data["error"] = decoded_error.decode("utf-8")
430
+ if isinstance(raw_error, str):
431
+ data["error"] = _safe_b64_decode(raw_error, context=f"job {job_id} error")
234
432
 
235
433
  raw_logs = data.get("logs")
236
- if raw_logs is not None:
237
- decoded_logs: bytes = base64.b64decode(raw_logs)
238
- data["logs"] = decoded_logs.decode("utf-8")
434
+ if isinstance(raw_logs, str):
435
+ data["logs"] = _safe_b64_decode(raw_logs, context=f"job {job_id} logs")
436
+
437
+ raw_error_logs = data.get("error_logs")
438
+ if isinstance(raw_error_logs, str):
439
+ data["error_logs"] = _safe_b64_decode(raw_error_logs, context=f"job {job_id} error logs")
239
440
 
240
441
  job_detail = TypeAdapter(JobDetail).validate_python(data)
241
- logger.debug("Job {} details retrieved (status {})", id, job_detail.status.value)
442
+ logger.debug("Job {} details retrieved (status {})", job_id, job_detail.status.value)
443
+ if isinstance(job, JobHandle):
444
+ return job.bind(job_detail)
242
445
  return job_detail
243
446
 
447
+ @overload
448
+ def wait_for_job(
449
+ self,
450
+ job: JobHandle[TFunctionalResult],
451
+ *,
452
+ poll_interval: float = 5.0,
453
+ timeout: float | None = None,
454
+ ) -> TypedJobDetail[TFunctionalResult]: ...
455
+
456
+ @overload
457
+ def wait_for_job(
458
+ self,
459
+ job: int,
460
+ *,
461
+ poll_interval: float = 5.0,
462
+ timeout: float | None = None,
463
+ ) -> JobDetail: ...
464
+
244
465
  def wait_for_job(
245
466
  self,
246
- id: int,
467
+ job: int | JobHandle[Any],
247
468
  *,
248
469
  poll_interval: float = 5.0,
249
470
  timeout: float | None = None,
250
- ) -> JobDetail:
251
- """Block until *id* reaches a terminal state.
471
+ ) -> JobDetail | TypedJobDetail[Any]:
472
+ """Block until the job referenced by *job* reaches a terminal state.
252
473
 
253
474
  Args:
254
- id: Job identifier.
475
+ job: Either the integer job identifier or a previously returned `JobHandle`.
255
476
  poll_interval: Seconds between successive polls. Defaults to ``5``.
256
477
  timeout: Maximum wait time in seconds. ``None`` waits indefinitely.
257
478
 
258
479
  Returns:
259
- Final :class:`~qilisdk.models.JobDetail` snapshot.
480
+ Final :class:`~qilisdk.models.JobDetail` snapshot, optionally wrapped with type-safe accessors.
260
481
 
261
482
  Raises:
262
483
  TimeoutError: If *timeout* elapses before the job finishes.
263
484
  """
264
- logger.info("Waiting for job {} (poll={}s, timeout={}s)…", id, poll_interval, timeout)
485
+ job_id = job.id if isinstance(job, JobHandle) else job
486
+ logger.info("Waiting for job {} (poll={}s, timeout={}s)…", job_id, poll_interval, timeout)
265
487
  start_t = time.monotonic()
266
488
  terminal_states = {
267
489
  JobStatus.COMPLETED,
@@ -271,24 +493,65 @@ class SpeQtrum:
271
493
 
272
494
  # poll until we hit a terminal state or timeout
273
495
  while True:
274
- current = self.get_job_details(id)
496
+ current = self.get_job(job_id)
275
497
 
276
498
  if current.status in terminal_states:
277
- logger.success("Job {} reached terminal state {}", id, current.status.value)
499
+ logger.success("Job {} reached terminal state {}", job_id, current.status.value)
500
+ if isinstance(job, JobHandle):
501
+ return job.bind(current)
278
502
  return current
279
503
 
280
504
  if timeout is not None and (time.monotonic() - start_t) >= timeout:
281
505
  logger.error(
282
- "Timeout while waiting for job {} after {}s (last status {})", id, timeout, current.status.value
506
+ "Timeout while waiting for job {} after {}s (last status {})",
507
+ job_id,
508
+ timeout,
509
+ current.status.value,
283
510
  )
284
511
  raise TimeoutError(
285
- f"Timed out after {timeout}s while waiting for job {id} (last status {current.status.value!r})"
512
+ f"Timed out after {timeout}s while waiting for job {job_id} (last status {current.status.value!r})"
286
513
  )
287
514
 
288
- logger.debug("Job {} still {}, sleeping {}s", id, current.status.value, poll_interval)
515
+ logger.debug("Job {} still {}, sleeping {}s", job_id, current.status.value, poll_interval)
289
516
  time.sleep(poll_interval)
290
517
 
291
- def submit(self, functional: PrimitiveFunctional | ExperimentFunctional, device: str) -> int:
518
+ @overload
519
+ def submit(self, functional: Sampling, device: str, job_name: str | None = None) -> JobHandle[SamplingResult]: ...
520
+
521
+ @overload
522
+ def submit(
523
+ self, functional: TimeEvolution, device: str, job_name: str | None = None
524
+ ) -> JobHandle[TimeEvolutionResult]: ...
525
+
526
+ @overload
527
+ def submit(
528
+ self, functional: VariationalProgram[Sampling], device: str, job_name: str | None = None
529
+ ) -> JobHandle[VariationalProgramResult[SamplingResult]]: ...
530
+
531
+ @overload
532
+ def submit(
533
+ self, functional: VariationalProgram[TimeEvolution], device: str, job_name: str | None = None
534
+ ) -> JobHandle[VariationalProgramResult[TimeEvolutionResult]]: ...
535
+
536
+ @overload
537
+ def submit(
538
+ self,
539
+ functional: VariationalProgram[PrimitiveFunctional[TFunctionalResult]],
540
+ device: str,
541
+ job_name: str | None = None,
542
+ ) -> JobHandle[VariationalProgramResult[TFunctionalResult]]: ...
543
+
544
+ @overload
545
+ def submit(
546
+ self, functional: RabiExperiment, device: str, job_name: str | None = None
547
+ ) -> JobHandle[RabiExperimentResult]: ...
548
+
549
+ @overload
550
+ def submit(
551
+ self, functional: T1Experiment, device: str, job_name: str | None = None
552
+ ) -> JobHandle[T1ExperimentResult]: ...
553
+
554
+ def submit(self, functional: Functional, device: str, job_name: str | None = None) -> JobHandle[FunctionalResult]:
292
555
  """
293
556
  Submit a quantum functional for execution on the selected device.
294
557
 
@@ -299,6 +562,9 @@ class SpeQtrum:
299
562
 
300
563
  * :class:`~qilisdk.functionals.sampling.Sampling`
301
564
  * :class:`~qilisdk.functionals.time_evolution.TimeEvolution`
565
+ * :class:`~qilisdk.functionals.variational_program.VariationalProgram`
566
+ * :class:`~qilisdk.speqtrum.experiments.experiment_functional.RabiExperiment`
567
+ * :class:`~qilisdk.speqtrum.experiments.experiment_functional.T1Experiment`
302
568
 
303
569
  A backend device must be selected beforehand with
304
570
  :py:meth:`set_device`.
@@ -308,112 +574,223 @@ class SpeQtrum:
308
574
  ``Sampling`` or ``TimeEvolution``) that defines the quantum
309
575
  workload to be executed.
310
576
  device: Device code returned by :py:meth:`list_devices`.
577
+ job_name (optional): The name of the job, this can help you identify different jobs easier. Default: None.
311
578
 
312
579
  Returns:
313
- int: The numeric identifier of the created job on SpeQtrum.
580
+ JobHandle: A typed handle carrying the numeric job identifier and result type metadata.
314
581
 
315
582
  Raises:
316
583
  NotImplementedError: If *functional* is not of a supported type.
317
584
  """
318
- try:
319
- handler = self._handlers[type(functional)]
320
- except KeyError as exc:
321
- logger.error("Unsupported functional type: {}", type(functional).__qualname__)
322
- raise NotImplementedError(
323
- f"{type(self).__qualname__} does not support {type(functional).__qualname__}"
324
- ) from exc
325
-
326
- logger.info("Submitting {}", type(functional).__qualname__)
327
- job_id = handler(functional, device)
328
- logger.success("Submission complete - job {}", job_id)
329
- return job_id
330
-
331
- def _submit_sampling(self, sampling: Sampling, device: str) -> int:
585
+ if isinstance(functional, VariationalProgram):
586
+ inner = functional.functional
587
+ if isinstance(inner, Sampling):
588
+ return self._submit_variational_program(
589
+ cast("VariationalProgram[Sampling]", functional), device, job_name
590
+ )
591
+ if isinstance(inner, TimeEvolution):
592
+ return self._submit_variational_program(
593
+ cast("VariationalProgram[TimeEvolution]", functional), device, job_name
594
+ )
595
+
596
+ # Fallback to untyped handle for custom primitives.
597
+ job_handle = self._submit_variational_program(cast("VariationalProgram[Any]", functional), device, job_name)
598
+ return cast("JobHandle[FunctionalResult]", job_handle)
599
+
600
+ if isinstance(functional, Sampling):
601
+ return self._submit_sampling(functional, device, job_name)
602
+
603
+ if isinstance(functional, TimeEvolution):
604
+ return self._submit_time_evolution(functional, device, job_name)
605
+
606
+ if isinstance(functional, RabiExperiment):
607
+ return self._submit_rabi(functional, device, job_name)
608
+
609
+ if isinstance(functional, T1Experiment):
610
+ return self._submit_t1(functional, device, job_name)
611
+
612
+ if isinstance(functional, T2Experiment):
613
+ return self._submit_t2(functional, device, job_name)
614
+
615
+ if isinstance(functional, TwoTonesExperiment):
616
+ return self._submit_two_tones(functional, device, job_name)
617
+
618
+ logger.error("Unsupported functional type: {}", type(functional).__qualname__)
619
+ raise NotImplementedError(f"{type(self).__qualname__} does not support {type(functional).__qualname__}")
620
+
621
+ def _submit_sampling(
622
+ self, sampling: Sampling, device: str, job_name: str | None = None
623
+ ) -> JobHandle[SamplingResult]:
332
624
  payload = ExecutePayload(
333
625
  type=ExecuteType.SAMPLING,
334
626
  sampling_payload=SamplingPayload(sampling=sampling),
335
627
  )
336
- json = {"device_code": device, "payload": payload.model_dump_json(), "job_type": JobType.DIGITAL, "meta": {}}
628
+ json = {
629
+ "device_code": device,
630
+ "payload": payload.model_dump_json(),
631
+ "job_type": JobType.DIGITAL,
632
+ "meta": {},
633
+ }
634
+ if job_name:
635
+ json["name"] = job_name
337
636
  logger.debug("Executing Sampling on device {}", device)
338
- with httpx.Client() as client:
339
- response = client.post(
340
- self._settings.speqtrum_api_url + "/execute",
341
- headers=self._get_authorized_headers(),
342
- json=json,
343
- )
344
- response.raise_for_status()
345
- job = JobId(**response.json())
346
- return job.id
347
-
348
- def _submit_rabi_program(self, rabi_experiment: RabiExperiment, device: str) -> int:
637
+ with self._create_client() as client:
638
+ response = client.post("/execute", json=json, extensions=_request_extensions(context="Executing Sampling"))
639
+ job = JobId(**response.json())
640
+ logger.info("Sampling job submitted: {}", job.id)
641
+ return JobHandle.sampling(job.id)
642
+
643
+ def _submit_rabi(
644
+ self, rabi_experiment: RabiExperiment, device: str, job_name: str | None = None
645
+ ) -> JobHandle[RabiExperimentResult]:
349
646
  payload = ExecutePayload(
350
647
  type=ExecuteType.RABI_EXPERIMENT,
351
648
  rabi_experiment_payload=RabiExperimentPayload(rabi_experiment=rabi_experiment),
352
649
  )
353
- json = {"device_code": device, "payload": payload.model_dump_json(), "job_type": JobType.PULSE, "meta": {}}
650
+ json = {
651
+ "device_code": device,
652
+ "payload": payload.model_dump_json(),
653
+ "job_type": JobType.PULSE,
654
+ "meta": {},
655
+ }
656
+ if job_name:
657
+ json["name"] = job_name
354
658
  logger.debug("Executing Rabi experiment on device {}", device)
355
- with httpx.Client() as client:
659
+ with self._create_client() as client:
356
660
  response = client.post(
357
- self._settings.speqtrum_api_url + "/execute",
358
- headers=self._get_authorized_headers(),
661
+ "/execute",
359
662
  json=json,
663
+ extensions=_request_extensions(context="Executing Rabi experiment"),
360
664
  )
361
- response.raise_for_status()
362
- job = JobId(**response.json())
665
+ job = JobId(**response.json())
363
666
  logger.info("Rabi experiment job submitted: {}", job.id)
364
- return job.id
667
+ return JobHandle.rabi_experiment(job.id)
365
668
 
366
- def _submit_t1_program(self, t1_experiment: T1Experiment, device: str) -> int:
669
+ def _submit_t1(
670
+ self, t1_experiment: T1Experiment, device: str, job_name: str | None = None
671
+ ) -> JobHandle[T1ExperimentResult]:
367
672
  payload = ExecutePayload(
368
673
  type=ExecuteType.T1_EXPERIMENT,
369
674
  t1_experiment_payload=T1ExperimentPayload(t1_experiment=t1_experiment),
370
675
  )
371
- json = {"device_code": device, "payload": payload.model_dump_json(), "job_type": JobType.PULSE, "meta": {}}
676
+ json = {
677
+ "device_code": device,
678
+ "payload": payload.model_dump_json(),
679
+ "job_type": JobType.PULSE,
680
+ "meta": {},
681
+ }
682
+ if job_name:
683
+ json["name"] = job_name
372
684
  logger.debug("Executing T1 experiment on device {}", device)
373
- with httpx.Client() as client:
685
+ with self._create_client() as client:
374
686
  response = client.post(
375
- self._settings.speqtrum_api_url + "/execute",
376
- headers=self._get_authorized_headers(),
687
+ "/execute",
377
688
  json=json,
689
+ extensions=_request_extensions(context="Executing T1 experiment"),
378
690
  )
379
- response.raise_for_status()
380
- job = JobId(**response.json())
691
+ job = JobId(**response.json())
381
692
  logger.info("T1 experiment job submitted: {}", job.id)
382
- return job.id
693
+ return JobHandle.t1_experiment(job.id)
383
694
 
384
- def _submit_time_evolution(self, time_evolution: TimeEvolution, device: str) -> int:
695
+ def _submit_t2(
696
+ self, t2_experiment: T2Experiment, device: str, job_name: str | None = None
697
+ ) -> JobHandle[T2ExperimentResult]:
698
+ payload = ExecutePayload(
699
+ type=ExecuteType.T2_EXPERIMENT,
700
+ t2_experiment_payload=T2ExperimentPayload(t2_experiment=t2_experiment),
701
+ )
702
+ json = {
703
+ "device_code": device,
704
+ "payload": payload.model_dump_json(),
705
+ "job_type": JobType.PULSE,
706
+ "meta": {},
707
+ }
708
+ if job_name:
709
+ json["name"] = job_name
710
+ logger.debug("Executing T2 experiment on device {}", device)
711
+ with self._create_client() as client:
712
+ response = client.post(
713
+ "/execute",
714
+ json=json,
715
+ extensions=_request_extensions(context="Executing T2 experiment"),
716
+ )
717
+ job = JobId(**response.json())
718
+ logger.info("T2 experiment job submitted: {}", job.id)
719
+ return JobHandle.t2_experiment(job.id)
720
+
721
+ def _submit_two_tones(
722
+ self, two_tones_experiment: TwoTonesExperiment, device: str, job_name: str | None = None
723
+ ) -> JobHandle[TwoTonesExperimentResult]:
724
+ payload = ExecutePayload(
725
+ type=ExecuteType.TWO_TONES_EXPERIMENT,
726
+ two_tones_experiment_payload=TwoTonesExperimentPayload(two_tones_experiment=two_tones_experiment),
727
+ )
728
+ json = {
729
+ "device_code": device,
730
+ "payload": payload.model_dump_json(),
731
+ "job_type": JobType.PULSE,
732
+ "meta": {},
733
+ }
734
+ if job_name:
735
+ json["name"] = job_name
736
+ logger.debug("Executing Two-Tones experiment on device {}", device)
737
+ with self._create_client() as client:
738
+ response = client.post(
739
+ "/execute",
740
+ json=json,
741
+ extensions=_request_extensions(context="Executing Two-Tones experiment"),
742
+ )
743
+ job = JobId(**response.json())
744
+ logger.info("Two-Tones experiment job submitted: {}", job.id)
745
+ return JobHandle.two_tones_experiment(job.id)
746
+
747
+ def _submit_time_evolution(
748
+ self, time_evolution: TimeEvolution, device: str, job_name: str | None = None
749
+ ) -> JobHandle[TimeEvolutionResult]:
385
750
  payload = ExecutePayload(
386
751
  type=ExecuteType.TIME_EVOLUTION,
387
752
  time_evolution_payload=TimeEvolutionPayload(time_evolution=time_evolution),
388
753
  )
389
- json = {"device_code": device, "payload": payload.model_dump_json(), "job_type": JobType.ANALOG, "meta": {}}
754
+ json = {
755
+ "device_code": device,
756
+ "payload": payload.model_dump_json(),
757
+ "job_type": JobType.ANALOG,
758
+ "meta": {},
759
+ }
760
+ if job_name:
761
+ json["name"] = job_name
390
762
  logger.debug("Executing time evolution on device {}", device)
391
- with httpx.Client() as client:
763
+ with self._create_client() as client:
392
764
  response = client.post(
393
- self._settings.speqtrum_api_url + "/execute",
394
- headers=self._get_authorized_headers(),
765
+ "/execute",
395
766
  json=json,
767
+ extensions=_request_extensions(context="Executing time evolution"),
396
768
  )
397
- response.raise_for_status()
398
- job = JobId(**response.json())
399
- logger.info("Time evolution job submitted: {}", job.id)
400
- return job.id
401
-
402
- def _submit_variational_program(self, variational_program: VariationalProgram, device: str) -> int:
403
- """Run a Variational Program on the selected device.
404
-
405
- Args:
406
- variational_program (VariationalProgram): Problem definition containing Hamiltonian and ansatz.
407
- device (str): The SpeQtrum device's code to execute the variational program upon.
408
-
409
- Returns:
410
- The numeric identifier of the created job.
411
- """
769
+ job = JobId(**response.json())
770
+ logger.info("Time Evolution job submitted: {}", job.id)
771
+ return JobHandle.time_evolution(job.id)
772
+
773
+ @overload
774
+ def _submit_variational_program(
775
+ self, variational_program: VariationalProgram[Sampling], device: str, job_name: str | None = None
776
+ ) -> JobHandle[VariationalProgramResult[SamplingResult]]: ...
777
+
778
+ @overload
779
+ def _submit_variational_program(
780
+ self, variational_program: VariationalProgram[TimeEvolution], device: str, job_name: str | None = None
781
+ ) -> JobHandle[VariationalProgramResult[TimeEvolutionResult]]: ...
782
+
783
+ @overload
784
+ def _submit_variational_program(
785
+ self, variational_program: VariationalProgram[Any], device: str, job_name: str | None = None
786
+ ) -> JobHandle[VariationalProgramResult]: ...
787
+
788
+ def _submit_variational_program(
789
+ self, variational_program: VariationalProgram[Any], device: str, job_name: str | None = None
790
+ ) -> JobHandle[VariationalProgramResult]:
412
791
  payload = ExecutePayload(
413
792
  type=ExecuteType.VARIATIONAL_PROGRAM,
414
- variational_program_payload=VariationalProgramPayload(
415
- variational_program=variational_program,
416
- ),
793
+ variational_program_payload=VariationalProgramPayload(variational_program=variational_program),
417
794
  )
418
795
  json = {
419
796
  "device_code": device,
@@ -421,12 +798,20 @@ class SpeQtrum:
421
798
  "job_type": JobType.VARIATIONAL,
422
799
  "meta": {},
423
800
  }
424
- with httpx.Client() as client:
801
+ if job_name:
802
+ json["name"] = job_name
803
+ logger.debug("Executing variational program on device {}", device)
804
+ with self._create_client() as client:
425
805
  response = client.post(
426
- self._settings.speqtrum_api_url + "/execute",
427
- headers=self._get_authorized_headers(),
806
+ "/execute",
428
807
  json=json,
808
+ extensions=_request_extensions(context="Executing variational program"),
429
809
  )
430
- response.raise_for_status()
431
- job = JobId(**response.json())
432
- return job.id
810
+ job = JobId(**response.json())
811
+ logger.info("Variational program job submitted: {}", job.id)
812
+ inner = variational_program.functional
813
+ if isinstance(inner, Sampling):
814
+ return JobHandle.variational_program(job.id, result_type=SamplingResult)
815
+ if isinstance(inner, TimeEvolution):
816
+ return JobHandle.variational_program(job.id, result_type=TimeEvolutionResult)
817
+ return JobHandle.variational_program(job.id)