qilisdk 0.1.5__py3-none-any.whl → 0.1.6__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 (33) hide show
  1. qilisdk/analog/hamiltonian.py +3 -3
  2. qilisdk/analog/linear_schedule.py +5 -2
  3. qilisdk/analog/schedule.py +8 -5
  4. qilisdk/backends/cuda_backend.py +1 -1
  5. qilisdk/backends/qutip_backend.py +2 -21
  6. qilisdk/{common → core}/model.py +7 -7
  7. qilisdk/{common → core}/qtensor.py +1 -1
  8. qilisdk/{common → core}/variables.py +42 -4
  9. qilisdk/cost_functions/cost_function.py +1 -1
  10. qilisdk/cost_functions/model_cost_function.py +5 -5
  11. qilisdk/cost_functions/observable_cost_function.py +2 -2
  12. qilisdk/digital/ansatz.py +0 -3
  13. qilisdk/digital/circuit.py +2 -2
  14. qilisdk/digital/gates.py +3 -3
  15. qilisdk/functionals/functional.py +2 -2
  16. qilisdk/functionals/functional_result.py +1 -1
  17. qilisdk/functionals/sampling.py +1 -1
  18. qilisdk/functionals/time_evolution.py +3 -3
  19. qilisdk/functionals/time_evolution_result.py +2 -2
  20. qilisdk/optimizers/optimizer_result.py +1 -1
  21. qilisdk/speqtrum/__init__.py +2 -0
  22. qilisdk/speqtrum/speqtrum.py +216 -61
  23. qilisdk/speqtrum/speqtrum_models.py +167 -0
  24. qilisdk/utils/visualization/schedule_renderers.py +6 -1
  25. {qilisdk-0.1.5.dist-info → qilisdk-0.1.6.dist-info}/METADATA +17 -17
  26. {qilisdk-0.1.5.dist-info → qilisdk-0.1.6.dist-info}/RECORD +33 -33
  27. /qilisdk/{common → core}/__init__.py +0 -0
  28. /qilisdk/{common → core}/algorithm.py +0 -0
  29. /qilisdk/{common → core}/exceptions.py +0 -0
  30. /qilisdk/{common → core}/parameterizable.py +0 -0
  31. /qilisdk/{common → core}/result.py +0 -0
  32. {qilisdk-0.1.5.dist-info → qilisdk-0.1.6.dist-info}/WHEEL +0 -0
  33. {qilisdk-0.1.5.dist-info → qilisdk-0.1.6.dist-info}/licenses/LICENCE +0 -0
@@ -18,15 +18,28 @@ import json
18
18
  import time
19
19
  from base64 import urlsafe_b64encode
20
20
  from datetime import datetime, timezone
21
- from typing import TYPE_CHECKING, Callable, cast
21
+ from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast, overload
22
22
 
23
23
  import httpx
24
24
  from loguru import logger
25
25
  from pydantic import TypeAdapter
26
26
 
27
- from qilisdk.functionals import Sampling, TimeEvolution, VariationalProgram
27
+ from qilisdk.functionals import (
28
+ Sampling,
29
+ SamplingResult,
30
+ TimeEvolution,
31
+ TimeEvolutionResult,
32
+ VariationalProgram,
33
+ VariationalProgramResult,
34
+ )
35
+ from qilisdk.functionals.functional_result import FunctionalResult
28
36
  from qilisdk.settings import get_settings
29
- from qilisdk.speqtrum.experiments import ExperimentFunctional, RabiExperiment, T1Experiment
37
+ from qilisdk.speqtrum.experiments import (
38
+ RabiExperiment,
39
+ RabiExperimentResult,
40
+ T1Experiment,
41
+ T1ExperimentResult,
42
+ )
30
43
 
31
44
  from .keyring import delete_credentials, load_credentials, store_credentials
32
45
  from .speqtrum_models import (
@@ -34,6 +47,7 @@ from .speqtrum_models import (
34
47
  ExecutePayload,
35
48
  ExecuteType,
36
49
  JobDetail,
50
+ JobHandle,
37
51
  JobId,
38
52
  JobInfo,
39
53
  JobStatus,
@@ -43,6 +57,7 @@ from .speqtrum_models import (
43
57
  T1ExperimentPayload,
44
58
  TimeEvolutionPayload,
45
59
  Token,
60
+ TypedJobDetail,
46
61
  VariationalProgramPayload,
47
62
  )
48
63
 
@@ -50,6 +65,9 @@ if TYPE_CHECKING:
50
65
  from qilisdk.functionals.functional import Functional, PrimitiveFunctional
51
66
 
52
67
 
68
+ ResultT = TypeVar("ResultT", bound=FunctionalResult)
69
+
70
+
53
71
  class SpeQtrum:
54
72
  """Synchronous client for the Qilimanjaro SpeQtrum API."""
55
73
 
@@ -60,14 +78,17 @@ class SpeQtrum:
60
78
  logger.error("No QaaS credentials found. Call `.login()` or set env vars before instantiation.")
61
79
  raise RuntimeError("Missing QaaS credentials - invoke SpeQtrum.login() first.")
62
80
  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
81
+ self._handlers: dict[type[Functional], Callable[[Functional, str, str | None], JobHandle[Any]]] = {
82
+ Sampling: lambda f, device, job_name: self._submit_sampling(cast("Sampling", f), device, job_name),
83
+ TimeEvolution: lambda f, device, job_name: self._submit_time_evolution(
84
+ cast("TimeEvolution", f), device, job_name
85
+ ),
86
+ RabiExperiment: lambda f, device, job_name: self._submit_rabi_program(
87
+ cast("RabiExperiment", f), device, job_name
88
+ ),
89
+ T1Experiment: lambda f, device, job_name: self._submit_t1_program(
90
+ cast("T1Experiment", f), device, job_name
68
91
  ),
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
92
  }
72
93
  self._settings = get_settings()
73
94
  logger.success("QaaS client initialised for user '{}'", self._username)
@@ -191,20 +212,27 @@ class SpeQtrum:
191
212
  logger.success("{} jobs retrieved", len(jobs))
192
213
  return [j for j in jobs if where(j)] if where else jobs
193
214
 
194
- def get_job_details(self, id: int) -> JobDetail:
195
- """Fetch the complete record of *id*.
215
+ @overload
216
+ def get_job(self, job: JobHandle[ResultT]) -> TypedJobDetail[ResultT]: ...
217
+
218
+ @overload
219
+ def get_job(self, job: int) -> JobDetail: ...
220
+
221
+ def get_job(self, job: int | JobHandle[Any]) -> JobDetail | TypedJobDetail[Any]:
222
+ """Fetch the complete record of *job*.
196
223
 
197
224
  Args:
198
- id: Identifier of the job.
225
+ job: Either the integer identifier or a previously returned `JobHandle`.
199
226
 
200
227
  Returns:
201
- A :class:`~qilisdk.models.JobDetail` instance containing payload,
202
- result, logs and error information.
228
+ A :class:`~qilisdk.models.JobDetail` snapshot. When a handle is supplied the
229
+ result is wrapped in :class:`~qilisdk.models.TypedJobDetail` to expose typed accessors.
203
230
  """
204
- logger.debug("Retrieving job {} details", id)
231
+ job_id = job.id if isinstance(job, JobHandle) else job
232
+ logger.debug("Retrieving job {} details", job_id)
205
233
  with httpx.Client() as client:
206
234
  response = client.get(
207
- f"{self._settings.speqtrum_api_url}/jobs/{id}",
235
+ f"{self._settings.speqtrum_api_url}/jobs/{job_id}",
208
236
  headers=self._get_authorized_headers(),
209
237
  params={
210
238
  "payload": True,
@@ -238,30 +266,51 @@ class SpeQtrum:
238
266
  data["logs"] = decoded_logs.decode("utf-8")
239
267
 
240
268
  job_detail = TypeAdapter(JobDetail).validate_python(data)
241
- logger.debug("Job {} details retrieved (status {})", id, job_detail.status.value)
269
+ logger.debug("Job {} details retrieved (status {})", job_id, job_detail.status.value)
270
+ if isinstance(job, JobHandle):
271
+ return job.bind(job_detail)
242
272
  return job_detail
243
273
 
274
+ @overload
275
+ def wait_for_job(
276
+ self,
277
+ job: JobHandle[ResultT],
278
+ *,
279
+ poll_interval: float = 5.0,
280
+ timeout: float | None = None,
281
+ ) -> TypedJobDetail[ResultT]: ...
282
+
283
+ @overload
244
284
  def wait_for_job(
245
285
  self,
246
- id: int,
286
+ job: int,
247
287
  *,
248
288
  poll_interval: float = 5.0,
249
289
  timeout: float | None = None,
250
- ) -> JobDetail:
251
- """Block until *id* reaches a terminal state.
290
+ ) -> JobDetail: ...
291
+
292
+ def wait_for_job(
293
+ self,
294
+ job: int | JobHandle[Any],
295
+ *,
296
+ poll_interval: float = 5.0,
297
+ timeout: float | None = None,
298
+ ) -> JobDetail | TypedJobDetail[Any]:
299
+ """Block until the job referenced by *job* reaches a terminal state.
252
300
 
253
301
  Args:
254
- id: Job identifier.
302
+ job: Either the integer job identifier or a previously returned `JobHandle`.
255
303
  poll_interval: Seconds between successive polls. Defaults to ``5``.
256
304
  timeout: Maximum wait time in seconds. ``None`` waits indefinitely.
257
305
 
258
306
  Returns:
259
- Final :class:`~qilisdk.models.JobDetail` snapshot.
307
+ Final :class:`~qilisdk.models.JobDetail` snapshot, optionally wrapped with type-safe accessors.
260
308
 
261
309
  Raises:
262
310
  TimeoutError: If *timeout* elapses before the job finishes.
263
311
  """
264
- logger.info("Waiting for job {} (poll={}s, timeout={}s)…", id, poll_interval, timeout)
312
+ job_id = job.id if isinstance(job, JobHandle) else job
313
+ logger.info("Waiting for job {} (poll={}s, timeout={}s)…", job_id, poll_interval, timeout)
265
314
  start_t = time.monotonic()
266
315
  terminal_states = {
267
316
  JobStatus.COMPLETED,
@@ -271,24 +320,62 @@ class SpeQtrum:
271
320
 
272
321
  # poll until we hit a terminal state or timeout
273
322
  while True:
274
- current = self.get_job_details(id)
323
+ current = self.get_job(job_id)
275
324
 
276
325
  if current.status in terminal_states:
277
- logger.success("Job {} reached terminal state {}", id, current.status.value)
326
+ logger.success("Job {} reached terminal state {}", job_id, current.status.value)
327
+ if isinstance(job, JobHandle):
328
+ return job.bind(current)
278
329
  return current
279
330
 
280
331
  if timeout is not None and (time.monotonic() - start_t) >= timeout:
281
332
  logger.error(
282
- "Timeout while waiting for job {} after {}s (last status {})", id, timeout, current.status.value
333
+ "Timeout while waiting for job {} after {}s (last status {})",
334
+ job_id,
335
+ timeout,
336
+ current.status.value,
283
337
  )
284
338
  raise TimeoutError(
285
- f"Timed out after {timeout}s while waiting for job {id} (last status {current.status.value!r})"
339
+ f"Timed out after {timeout}s while waiting for job {job_id} (last status {current.status.value!r})"
286
340
  )
287
341
 
288
- logger.debug("Job {} still {}, sleeping {}s", id, current.status.value, poll_interval)
342
+ logger.debug("Job {} still {}, sleeping {}s", job_id, current.status.value, poll_interval)
289
343
  time.sleep(poll_interval)
290
344
 
291
- def submit(self, functional: PrimitiveFunctional | ExperimentFunctional, device: str) -> int:
345
+ @overload
346
+ def submit(self, functional: Sampling, device: str, job_name: str | None = None) -> JobHandle[SamplingResult]: ...
347
+
348
+ @overload
349
+ def submit(
350
+ self, functional: TimeEvolution, device: str, job_name: str | None = None
351
+ ) -> JobHandle[TimeEvolutionResult]: ...
352
+
353
+ @overload
354
+ def submit(
355
+ self, functional: VariationalProgram[Sampling], device: str, job_name: str | None = None
356
+ ) -> JobHandle[VariationalProgramResult[SamplingResult]]: ...
357
+
358
+ @overload
359
+ def submit(
360
+ self, functional: VariationalProgram[TimeEvolution], device: str, job_name: str | None = None
361
+ ) -> JobHandle[VariationalProgramResult[TimeEvolutionResult]]: ...
362
+
363
+ @overload
364
+ def submit(
365
+ self, functional: VariationalProgram[PrimitiveFunctional[ResultT]], device: str, job_name: str | None = None
366
+ ) -> JobHandle[VariationalProgramResult[ResultT]]: ...
367
+
368
+ @overload
369
+ def submit(
370
+ self, functional: RabiExperiment, device: str, job_name: str | None = None
371
+ ) -> JobHandle[RabiExperimentResult]: ...
372
+
373
+ @overload
374
+ def submit(
375
+ self, functional: T1Experiment, device: str, job_name: str | None = None
376
+ ) -> JobHandle[T1ExperimentResult]: ...
377
+
378
+ def submit(self, functional: Functional, device: str, job_name: str | None = None) -> JobHandle[FunctionalResult]:
292
379
  """
293
380
  Submit a quantum functional for execution on the selected device.
294
381
 
@@ -299,6 +386,9 @@ class SpeQtrum:
299
386
 
300
387
  * :class:`~qilisdk.functionals.sampling.Sampling`
301
388
  * :class:`~qilisdk.functionals.time_evolution.TimeEvolution`
389
+ * :class:`~qilisdk.functionals.variational_program.VariationalProgram`
390
+ * :class:`~qilisdk.speqtrum.experiments.experiment_functional.RabiExperiment`
391
+ * :class:`~qilisdk.speqtrum.experiments.experiment_functional.T1Experiment`
302
392
 
303
393
  A backend device must be selected beforehand with
304
394
  :py:meth:`set_device`.
@@ -308,14 +398,28 @@ class SpeQtrum:
308
398
  ``Sampling`` or ``TimeEvolution``) that defines the quantum
309
399
  workload to be executed.
310
400
  device: Device code returned by :py:meth:`list_devices`.
401
+ job_name (optional): The name of the job, this can help you identify different jobs easier. Default: None.
311
402
 
312
403
  Returns:
313
- int: The numeric identifier of the created job on SpeQtrum.
404
+ JobHandle: A typed handle carrying the numeric job identifier and result type metadata.
314
405
 
315
406
  Raises:
316
407
  NotImplementedError: If *functional* is not of a supported type.
317
408
  """
318
409
  try:
410
+ if isinstance(functional, VariationalProgram):
411
+ inner = functional.functional
412
+ if isinstance(inner, Sampling):
413
+ return self._submit_variational_program(cast("VariationalProgram[Sampling]", functional), device)
414
+ if isinstance(inner, TimeEvolution):
415
+ return self._submit_variational_program(
416
+ cast("VariationalProgram[TimeEvolution]", functional), device
417
+ )
418
+
419
+ # Fallback to untyped handle for custom primitives.
420
+ job_handle = self._submit_variational_program(cast("VariationalProgram[Any]", functional), device)
421
+ return cast("JobHandle[FunctionalResult]", job_handle)
422
+
319
423
  handler = self._handlers[type(functional)]
320
424
  except KeyError as exc:
321
425
  logger.error("Unsupported functional type: {}", type(functional).__qualname__)
@@ -324,16 +428,25 @@ class SpeQtrum:
324
428
  ) from exc
325
429
 
326
430
  logger.info("Submitting {}", type(functional).__qualname__)
327
- job_id = handler(functional, device)
328
- logger.success("Submission complete - job {}", job_id)
329
- return job_id
431
+ job_handle = handler(functional, device, job_name)
432
+ logger.success("Submission complete - job {}", job_handle.id)
433
+ return job_handle
330
434
 
331
- def _submit_sampling(self, sampling: Sampling, device: str) -> int:
435
+ def _submit_sampling(
436
+ self, sampling: Sampling, device: str, job_name: str | None = None
437
+ ) -> JobHandle[SamplingResult]:
332
438
  payload = ExecutePayload(
333
439
  type=ExecuteType.SAMPLING,
334
440
  sampling_payload=SamplingPayload(sampling=sampling),
335
441
  )
336
- json = {"device_code": device, "payload": payload.model_dump_json(), "job_type": JobType.DIGITAL, "meta": {}}
442
+ json = {
443
+ "device_code": device,
444
+ "payload": payload.model_dump_json(),
445
+ "job_type": JobType.DIGITAL,
446
+ "meta": {},
447
+ }
448
+ if job_name:
449
+ json["name"] = job_name
337
450
  logger.debug("Executing Sampling on device {}", device)
338
451
  with httpx.Client() as client:
339
452
  response = client.post(
@@ -343,14 +456,23 @@ class SpeQtrum:
343
456
  )
344
457
  response.raise_for_status()
345
458
  job = JobId(**response.json())
346
- return job.id
459
+ return JobHandle.sampling(job.id)
347
460
 
348
- def _submit_rabi_program(self, rabi_experiment: RabiExperiment, device: str) -> int:
461
+ def _submit_rabi_program(
462
+ self, rabi_experiment: RabiExperiment, device: str, job_name: str | None = None
463
+ ) -> JobHandle[RabiExperimentResult]:
349
464
  payload = ExecutePayload(
350
465
  type=ExecuteType.RABI_EXPERIMENT,
351
466
  rabi_experiment_payload=RabiExperimentPayload(rabi_experiment=rabi_experiment),
352
467
  )
353
- json = {"device_code": device, "payload": payload.model_dump_json(), "job_type": JobType.PULSE, "meta": {}}
468
+ json = {
469
+ "device_code": device,
470
+ "payload": payload.model_dump_json(),
471
+ "job_type": JobType.PULSE,
472
+ "meta": {},
473
+ }
474
+ if job_name:
475
+ json["name"] = job_name
354
476
  logger.debug("Executing Rabi experiment on device {}", device)
355
477
  with httpx.Client() as client:
356
478
  response = client.post(
@@ -361,14 +483,23 @@ class SpeQtrum:
361
483
  response.raise_for_status()
362
484
  job = JobId(**response.json())
363
485
  logger.info("Rabi experiment job submitted: {}", job.id)
364
- return job.id
486
+ return JobHandle.rabi_experiment(job.id)
365
487
 
366
- def _submit_t1_program(self, t1_experiment: T1Experiment, device: str) -> int:
488
+ def _submit_t1_program(
489
+ self, t1_experiment: T1Experiment, device: str, job_name: str | None = None
490
+ ) -> JobHandle[T1ExperimentResult]:
367
491
  payload = ExecutePayload(
368
492
  type=ExecuteType.T1_EXPERIMENT,
369
493
  t1_experiment_payload=T1ExperimentPayload(t1_experiment=t1_experiment),
370
494
  )
371
- json = {"device_code": device, "payload": payload.model_dump_json(), "job_type": JobType.PULSE, "meta": {}}
495
+ json = {
496
+ "device_code": device,
497
+ "payload": payload.model_dump_json(),
498
+ "job_type": JobType.PULSE,
499
+ "meta": {},
500
+ }
501
+ if job_name:
502
+ json["name"] = job_name
372
503
  logger.debug("Executing T1 experiment on device {}", device)
373
504
  with httpx.Client() as client:
374
505
  response = client.post(
@@ -379,14 +510,23 @@ class SpeQtrum:
379
510
  response.raise_for_status()
380
511
  job = JobId(**response.json())
381
512
  logger.info("T1 experiment job submitted: {}", job.id)
382
- return job.id
513
+ return JobHandle.t1_experiment(job.id)
383
514
 
384
- def _submit_time_evolution(self, time_evolution: TimeEvolution, device: str) -> int:
515
+ def _submit_time_evolution(
516
+ self, time_evolution: TimeEvolution, device: str, job_name: str | None = None
517
+ ) -> JobHandle[TimeEvolutionResult]:
385
518
  payload = ExecutePayload(
386
519
  type=ExecuteType.TIME_EVOLUTION,
387
520
  time_evolution_payload=TimeEvolutionPayload(time_evolution=time_evolution),
388
521
  )
389
- json = {"device_code": device, "payload": payload.model_dump_json(), "job_type": JobType.ANALOG, "meta": {}}
522
+ json = {
523
+ "device_code": device,
524
+ "payload": payload.model_dump_json(),
525
+ "job_type": JobType.ANALOG,
526
+ "meta": {},
527
+ }
528
+ if job_name:
529
+ json["name"] = job_name
390
530
  logger.debug("Executing time evolution on device {}", device)
391
531
  with httpx.Client() as client:
392
532
  response = client.post(
@@ -397,23 +537,29 @@ class SpeQtrum:
397
537
  response.raise_for_status()
398
538
  job = JobId(**response.json())
399
539
  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
- """
540
+ return JobHandle.time_evolution(job.id)
541
+
542
+ @overload
543
+ def _submit_variational_program(
544
+ self, variational_program: VariationalProgram[Sampling], device: str, job_name: str | None = None
545
+ ) -> JobHandle[VariationalProgramResult[SamplingResult]]: ...
546
+
547
+ @overload
548
+ def _submit_variational_program(
549
+ self, variational_program: VariationalProgram[TimeEvolution], device: str, job_name: str | None = None
550
+ ) -> JobHandle[VariationalProgramResult[TimeEvolutionResult]]: ...
551
+
552
+ @overload
553
+ def _submit_variational_program(
554
+ self, variational_program: VariationalProgram[Any], device: str, job_name: str | None = None
555
+ ) -> JobHandle[VariationalProgramResult]: ...
556
+
557
+ def _submit_variational_program(
558
+ self, variational_program: VariationalProgram[Any], device: str, job_name: str | None = None
559
+ ) -> JobHandle[VariationalProgramResult]:
412
560
  payload = ExecutePayload(
413
561
  type=ExecuteType.VARIATIONAL_PROGRAM,
414
- variational_program_payload=VariationalProgramPayload(
415
- variational_program=variational_program,
416
- ),
562
+ variational_program_payload=VariationalProgramPayload(variational_program=variational_program),
417
563
  )
418
564
  json = {
419
565
  "device_code": device,
@@ -421,6 +567,9 @@ class SpeQtrum:
421
567
  "job_type": JobType.VARIATIONAL,
422
568
  "meta": {},
423
569
  }
570
+ if job_name:
571
+ json["name"] = job_name
572
+ logger.debug("Executing variational program on device {}", device)
424
573
  with httpx.Client() as client:
425
574
  response = client.post(
426
575
  self._settings.speqtrum_api_url + "/execute",
@@ -429,4 +578,10 @@ class SpeQtrum:
429
578
  )
430
579
  response.raise_for_status()
431
580
  job = JobId(**response.json())
432
- return job.id
581
+ logger.info("Variational program job submitted: {}", job.id)
582
+ inner = variational_program.functional
583
+ if isinstance(inner, Sampling):
584
+ return JobHandle.variational_program(job.id, result_type=SamplingResult)
585
+ if isinstance(inner, TimeEvolution):
586
+ return JobHandle.variational_program(job.id, result_type=TimeEvolutionResult)
587
+ return JobHandle.variational_program(job.id)
@@ -14,6 +14,7 @@
14
14
  # ruff: noqa: ANN001, ANN202, PLR6301
15
15
  from email.utils import parsedate_to_datetime
16
16
  from enum import Enum
17
+ from typing import Any, Callable, Generic, TypeVar, cast, overload
17
18
 
18
19
  from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_serializer, field_validator
19
20
 
@@ -25,6 +26,7 @@ from qilisdk.functionals import (
25
26
  VariationalProgram,
26
27
  VariationalProgramResult,
27
28
  )
29
+ from qilisdk.functionals.functional_result import FunctionalResult
28
30
  from qilisdk.speqtrum.experiments import RabiExperiment, RabiExperimentResult, T1Experiment, T1ExperimentResult
29
31
  from qilisdk.utils.serialization import deserialize, serialize
30
32
 
@@ -226,6 +228,142 @@ class ExecuteResult(SpeQtrumModel):
226
228
  return v
227
229
 
228
230
 
231
+ ResultT_co = TypeVar("ResultT_co", bound=FunctionalResult, covariant=True)
232
+ VariationalInnerResultT = TypeVar("VariationalInnerResultT", bound=FunctionalResult)
233
+
234
+
235
+ ResultExtractor = Callable[[ExecuteResult], ResultT_co]
236
+
237
+
238
+ # these helpers live outside the models so they can be referenced by default values
239
+ def _require_sampling_result(result: ExecuteResult) -> SamplingResult:
240
+ if result.sampling_result is None:
241
+ raise RuntimeError("SpeQtrum did not return a sampling_result for a sampling execution.")
242
+ return result.sampling_result
243
+
244
+
245
+ def _require_time_evolution_result(result: ExecuteResult) -> TimeEvolutionResult:
246
+ if result.time_evolution_result is None:
247
+ raise RuntimeError("SpeQtrum did not return a time_evolution_result for a time evolution execution.")
248
+ return result.time_evolution_result
249
+
250
+
251
+ def _require_variational_program_result(result: ExecuteResult) -> VariationalProgramResult:
252
+ if result.variational_program_result is None:
253
+ raise RuntimeError("SpeQtrum did not return a variational_program_result for a variational program execution.")
254
+ return result.variational_program_result
255
+
256
+
257
+ def _require_rabi_experiment_result(result: ExecuteResult) -> RabiExperimentResult:
258
+ if result.rabi_experiment_result is None:
259
+ raise RuntimeError("SpeQtrum did not return a rabi_experiment_result for a Rabi experiment execution.")
260
+ return result.rabi_experiment_result
261
+
262
+
263
+ def _require_t1_experiment_result(result: ExecuteResult) -> T1ExperimentResult:
264
+ if result.t1_experiment_result is None:
265
+ raise RuntimeError("SpeQtrum did not return a t1_experiment_result for a T1 experiment execution.")
266
+ return result.t1_experiment_result
267
+
268
+
269
+ def _require_variational_program_result_typed(
270
+ inner_result_type: type[VariationalInnerResultT],
271
+ ) -> ResultExtractor[VariationalProgramResult[VariationalInnerResultT]]:
272
+ def _extractor(result: ExecuteResult) -> VariationalProgramResult[VariationalInnerResultT]:
273
+ variational_result = _require_variational_program_result(result)
274
+ optimal_results = variational_result.optimal_execution_results
275
+ if not isinstance(optimal_results, inner_result_type):
276
+ raise RuntimeError(
277
+ "SpeQtrum returned a variational program result whose optimal execution result "
278
+ f"({type(optimal_results).__qualname__}) does not match the expected "
279
+ f"{inner_result_type.__qualname__}."
280
+ )
281
+ return cast("VariationalProgramResult[VariationalInnerResultT]", variational_result)
282
+
283
+ return _extractor
284
+
285
+
286
+ class JobHandle(SpeQtrumModel, Generic[ResultT_co]):
287
+ """Strongly typed reference to a submitted SpeQtrum job."""
288
+
289
+ id: int
290
+ execute_type: ExecuteType
291
+ extractor: ResultExtractor[ResultT_co] = Field(repr=False, exclude=True)
292
+
293
+ @classmethod
294
+ def sampling(cls, job_id: int) -> "JobHandle[SamplingResult]":
295
+ return cls(id=job_id, execute_type=ExecuteType.SAMPLING, extractor=_require_sampling_result) # type: ignore[return-value, arg-type]
296
+
297
+ @classmethod
298
+ def time_evolution(cls, job_id: int) -> "JobHandle[TimeEvolutionResult]":
299
+ return cls(id=job_id, execute_type=ExecuteType.TIME_EVOLUTION, extractor=_require_time_evolution_result) # type: ignore[return-value, arg-type]
300
+
301
+ @overload
302
+ @classmethod
303
+ def variational_program(cls, job_id: int) -> "JobHandle[VariationalProgramResult]": ...
304
+
305
+ @overload
306
+ @classmethod
307
+ def variational_program(
308
+ cls, job_id: int, *, result_type: type[VariationalInnerResultT]
309
+ ) -> "JobHandle[VariationalProgramResult[VariationalInnerResultT]]": ...
310
+
311
+ @classmethod
312
+ def variational_program(
313
+ cls, job_id: int, *, result_type: type[VariationalInnerResultT] | None = None
314
+ ) -> "JobHandle[Any]":
315
+ """Create a variational-program handle for an existing job identifier.
316
+
317
+ Args:
318
+ job_id: Numeric identifier returned by the SpeQtrum service.
319
+ result_type: Optional functional result type expected within the
320
+ variational program payload. When provided the returned handle
321
+ enforces that the optimiser output matches this type.
322
+
323
+ Returns:
324
+ JobHandle: A handle whose ``get_results`` invocation yields a
325
+ ``VariationalProgramResult`` preserving the requested inner result
326
+ type when supplied.
327
+ """
328
+ if result_type is None:
329
+ handle = cls(
330
+ id=job_id,
331
+ execute_type=ExecuteType.VARIATIONAL_PROGRAM,
332
+ extractor=_require_variational_program_result, # type: ignore[arg-type]
333
+ )
334
+ return cast("JobHandle[VariationalProgramResult]", handle)
335
+
336
+ extractor = _require_variational_program_result_typed(result_type)
337
+ handle = cls(id=job_id, execute_type=ExecuteType.VARIATIONAL_PROGRAM, extractor=extractor) # type: ignore[arg-type]
338
+ return cast("JobHandle[VariationalProgramResult[VariationalInnerResultT]]", handle)
339
+
340
+ @classmethod
341
+ def rabi_experiment(cls, job_id: int) -> "JobHandle[RabiExperimentResult]":
342
+ return cls(id=job_id, execute_type=ExecuteType.RABI_EXPERIMENT, extractor=_require_rabi_experiment_result) # type: ignore[return-value, arg-type]
343
+
344
+ @classmethod
345
+ def t1_experiment(cls, job_id: int) -> "JobHandle[T1ExperimentResult]":
346
+ return cls(id=job_id, execute_type=ExecuteType.T1_EXPERIMENT, extractor=_require_t1_experiment_result) # type: ignore[return-value, arg-type]
347
+
348
+ def bind(self, detail: "JobDetail") -> "TypedJobDetail[ResultT_co]":
349
+ """Attach this handle's typing information to a concrete job detail.
350
+
351
+ Args:
352
+ detail: Un-typed job detail payload returned by the SpeQtrum API.
353
+
354
+ Returns:
355
+ TypedJobDetail: Wrapper exposing ``get_results`` with the typing
356
+ captured when the handle was created.
357
+ """
358
+ return TypedJobDetail.model_validate(
359
+ {
360
+ **detail.model_dump(),
361
+ "expected_type": self.execute_type,
362
+ "extractor": self.extractor,
363
+ }
364
+ )
365
+
366
+
229
367
  class JobStatus(str, Enum):
230
368
  "Job has not been submitted to the Lab api"
231
369
 
@@ -298,3 +436,32 @@ class JobDetail(JobInfo):
298
436
  logs: str | None = None
299
437
  error: str | None = None
300
438
  error_logs: str | None = None
439
+
440
+
441
+ class TypedJobDetail(JobDetail, Generic[ResultT_co]):
442
+ """`JobDetail` subclass that exposes a strongly typed `get_results` method."""
443
+
444
+ expected_type: ExecuteType = Field(repr=False)
445
+ extractor: ResultExtractor[ResultT_co] = Field(repr=False, exclude=True)
446
+
447
+ def get_results(self) -> ResultT_co:
448
+ """Return the strongly typed execution result.
449
+
450
+ Returns:
451
+ ResultT_co: Result payload associated with the completed job,
452
+ respecting the type information carried by the originating
453
+ ``JobHandle``.
454
+
455
+ Raises:
456
+ RuntimeError: If SpeQtrum has not populated the result payload or
457
+ the execute type disagrees with the handle.
458
+ """
459
+ if self.result is None:
460
+ raise RuntimeError("The job completed without a result payload; inspect `error` or `logs` for details.")
461
+
462
+ if self.result.type != self.expected_type:
463
+ raise RuntimeError(
464
+ f"Expected a result of type '{self.expected_type.value}' but received '{self.result.type.value}'."
465
+ )
466
+
467
+ return self.extractor(self.result)
@@ -21,7 +21,7 @@ import matplotlib.pyplot as plt
21
21
 
22
22
  if TYPE_CHECKING:
23
23
  from qilisdk.analog.schedule import Schedule
24
- from qilisdk.common.variables import Number
24
+ from qilisdk.core.variables import Number
25
25
 
26
26
  from qilisdk.utils.visualization.style import ScheduleStyle
27
27
 
@@ -146,6 +146,11 @@ class MatplotlibScheduleRenderer:
146
146
 
147
147
  self.ax.figure.savefig(filename, bbox_inches="tight") # type: ignore[union-attr]
148
148
 
149
+ def show(self) -> None: # noqa: PLR6301
150
+ """Show the current figure."""
151
+
152
+ plt.show()
153
+
149
154
  @staticmethod
150
155
  def _make_axes(dpi: int, style: ScheduleStyle) -> plt.Axes:
151
156
  """