qoro-divi 0.2.0b1__py3-none-any.whl → 0.5.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.
Files changed (88) hide show
  1. divi/__init__.py +1 -2
  2. divi/backends/__init__.py +9 -0
  3. divi/backends/_circuit_runner.py +70 -0
  4. divi/backends/_execution_result.py +70 -0
  5. divi/backends/_parallel_simulator.py +486 -0
  6. divi/backends/_qoro_service.py +663 -0
  7. divi/backends/_qpu_system.py +101 -0
  8. divi/backends/_results_processing.py +133 -0
  9. divi/circuits/__init__.py +8 -0
  10. divi/{exp/cirq → circuits/_cirq}/__init__.py +1 -2
  11. divi/circuits/_cirq/_parser.py +110 -0
  12. divi/circuits/_cirq/_qasm_export.py +78 -0
  13. divi/circuits/_core.py +369 -0
  14. divi/{qasm.py → circuits/_qasm_conversion.py} +73 -14
  15. divi/circuits/_qasm_validation.py +694 -0
  16. divi/qprog/__init__.py +24 -6
  17. divi/qprog/_expectation.py +181 -0
  18. divi/qprog/_hamiltonians.py +281 -0
  19. divi/qprog/algorithms/__init__.py +14 -0
  20. divi/qprog/algorithms/_ansatze.py +356 -0
  21. divi/qprog/algorithms/_qaoa.py +572 -0
  22. divi/qprog/algorithms/_vqe.py +249 -0
  23. divi/qprog/batch.py +383 -73
  24. divi/qprog/checkpointing.py +556 -0
  25. divi/qprog/exceptions.py +9 -0
  26. divi/qprog/optimizers.py +1014 -43
  27. divi/qprog/quantum_program.py +231 -413
  28. divi/qprog/variational_quantum_algorithm.py +995 -0
  29. divi/qprog/workflows/__init__.py +10 -0
  30. divi/qprog/{_graph_partitioning.py → workflows/_graph_partitioning.py} +139 -95
  31. divi/qprog/workflows/_qubo_partitioning.py +220 -0
  32. divi/qprog/workflows/_vqe_sweep.py +560 -0
  33. divi/reporting/__init__.py +7 -0
  34. divi/reporting/_pbar.py +127 -0
  35. divi/reporting/_qlogger.py +68 -0
  36. divi/reporting/_reporter.py +133 -0
  37. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.5.0.dist-info}/METADATA +43 -15
  38. qoro_divi-0.5.0.dist-info/RECORD +43 -0
  39. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.5.0.dist-info}/WHEEL +1 -1
  40. qoro_divi-0.5.0.dist-info/licenses/LICENSES/.license-header +3 -0
  41. divi/_pbar.py +0 -73
  42. divi/circuits.py +0 -139
  43. divi/exp/cirq/_lexer.py +0 -126
  44. divi/exp/cirq/_parser.py +0 -889
  45. divi/exp/cirq/_qasm_export.py +0 -37
  46. divi/exp/cirq/_qasm_import.py +0 -35
  47. divi/exp/cirq/exception.py +0 -21
  48. divi/exp/scipy/_cobyla.py +0 -342
  49. divi/exp/scipy/pyprima/LICENCE.txt +0 -28
  50. divi/exp/scipy/pyprima/__init__.py +0 -263
  51. divi/exp/scipy/pyprima/cobyla/__init__.py +0 -0
  52. divi/exp/scipy/pyprima/cobyla/cobyla.py +0 -599
  53. divi/exp/scipy/pyprima/cobyla/cobylb.py +0 -849
  54. divi/exp/scipy/pyprima/cobyla/geometry.py +0 -240
  55. divi/exp/scipy/pyprima/cobyla/initialize.py +0 -269
  56. divi/exp/scipy/pyprima/cobyla/trustregion.py +0 -540
  57. divi/exp/scipy/pyprima/cobyla/update.py +0 -331
  58. divi/exp/scipy/pyprima/common/__init__.py +0 -0
  59. divi/exp/scipy/pyprima/common/_bounds.py +0 -41
  60. divi/exp/scipy/pyprima/common/_linear_constraints.py +0 -46
  61. divi/exp/scipy/pyprima/common/_nonlinear_constraints.py +0 -64
  62. divi/exp/scipy/pyprima/common/_project.py +0 -224
  63. divi/exp/scipy/pyprima/common/checkbreak.py +0 -107
  64. divi/exp/scipy/pyprima/common/consts.py +0 -48
  65. divi/exp/scipy/pyprima/common/evaluate.py +0 -101
  66. divi/exp/scipy/pyprima/common/history.py +0 -39
  67. divi/exp/scipy/pyprima/common/infos.py +0 -30
  68. divi/exp/scipy/pyprima/common/linalg.py +0 -452
  69. divi/exp/scipy/pyprima/common/message.py +0 -336
  70. divi/exp/scipy/pyprima/common/powalg.py +0 -131
  71. divi/exp/scipy/pyprima/common/preproc.py +0 -393
  72. divi/exp/scipy/pyprima/common/present.py +0 -5
  73. divi/exp/scipy/pyprima/common/ratio.py +0 -56
  74. divi/exp/scipy/pyprima/common/redrho.py +0 -49
  75. divi/exp/scipy/pyprima/common/selectx.py +0 -346
  76. divi/interfaces.py +0 -25
  77. divi/parallel_simulator.py +0 -258
  78. divi/qlogger.py +0 -119
  79. divi/qoro_service.py +0 -343
  80. divi/qprog/_mlae.py +0 -182
  81. divi/qprog/_qaoa.py +0 -440
  82. divi/qprog/_vqe.py +0 -275
  83. divi/qprog/_vqe_sweep.py +0 -144
  84. divi/utils.py +0 -116
  85. qoro_divi-0.2.0b1.dist-info/RECORD +0 -58
  86. /divi/{qem.py → circuits/qem.py} +0 -0
  87. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.5.0.dist-info/licenses}/LICENSE +0 -0
  88. {qoro_divi-0.2.0b1.dist-info → qoro_divi-0.5.0.dist-info/licenses}/LICENSES/Apache-2.0.txt +0 -0
divi/qprog/batch.py CHANGED
@@ -2,36 +2,35 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
+ import atexit
5
6
  import traceback
7
+ import warnings
6
8
  from abc import ABC, abstractmethod
7
- from concurrent.futures import ProcessPoolExecutor, as_completed
8
- from multiprocessing import Event, Manager
9
- from multiprocessing.synchronize import Event as EventClass
9
+ from concurrent.futures import Future, ThreadPoolExecutor, as_completed
10
10
  from queue import Empty, Queue
11
- from threading import Lock, Thread
11
+ from threading import Event, Lock, Thread
12
+ from typing import Any
12
13
  from warnings import warn
13
14
 
14
15
  from rich.console import Console
15
16
  from rich.progress import Progress, TaskID
16
17
 
17
- from divi._pbar import make_progress_bar
18
- from divi.interfaces import CircuitRunner
19
- from divi.parallel_simulator import ParallelSimulator
20
- from divi.qlogger import disable_logging
18
+ from divi.backends import CircuitRunner
21
19
  from divi.qprog.quantum_program import QuantumProgram
20
+ from divi.reporting import disable_logging, make_progress_bar
22
21
 
23
22
 
24
- def queue_listener(
23
+ def _queue_listener(
25
24
  queue: Queue,
26
25
  progress_bar: Progress,
27
26
  pb_task_map: dict[QuantumProgram, TaskID],
28
- done_event: EventClass,
27
+ done_event: Event,
29
28
  is_jupyter: bool,
30
29
  lock: Lock,
31
30
  ):
32
31
  while not done_event.is_set():
33
32
  try:
34
- msg = queue.get(timeout=0.1)
33
+ msg: dict[str, Any] = queue.get(timeout=0.1)
35
34
  except Empty:
36
35
  continue
37
36
  except Exception as e:
@@ -41,14 +40,30 @@ def queue_listener(
41
40
  with lock:
42
41
  task_id = pb_task_map[msg["job_id"]]
43
42
 
44
- progress_bar.update(
45
- task_id,
46
- advance=msg["progress"],
47
- poll_attempt=msg.get("poll_attempt", 0),
48
- message=msg.get("message", ""),
49
- final_status=msg.get("final_status", ""),
50
- refresh=is_jupyter,
51
- )
43
+ # Prepare update arguments, starting with progress.
44
+ update_args = {"advance": msg["progress"]}
45
+
46
+ if "poll_attempt" in msg:
47
+ update_args["poll_attempt"] = msg.get("poll_attempt", 0)
48
+ if "max_retries" in msg:
49
+ update_args["max_retries"] = msg.get("max_retries")
50
+ if "service_job_id" in msg:
51
+ update_args["service_job_id"] = msg.get("service_job_id")
52
+ if "job_status" in msg:
53
+ update_args["job_status"] = msg.get("job_status")
54
+ if msg.get("message"):
55
+ update_args["message"] = msg.get("message")
56
+ if "final_status" in msg:
57
+ update_args["final_status"] = msg.get("final_status", "")
58
+
59
+ update_args["refresh"] = is_jupyter
60
+
61
+ progress_bar.update(task_id, **update_args)
62
+ queue.task_done()
63
+
64
+
65
+ def _default_task_function(program: QuantumProgram):
66
+ return program.run()
52
67
 
53
68
 
54
69
  class ProgramBatch(ABC):
@@ -74,12 +89,12 @@ class ProgramBatch(ABC):
74
89
 
75
90
  self.backend = backend
76
91
  self._executor = None
77
- self.programs = {}
92
+ self._task_fn = _default_task_function
93
+ self._programs = {}
78
94
 
79
95
  self._total_circuit_count = 0
80
96
  self._total_run_time = 0.0
81
97
 
82
- self._is_local = isinstance(backend, ParallelSimulator)
83
98
  self._is_jupyter = Console().is_jupyter
84
99
 
85
100
  # Disable logging since we already have the bars to track progress
@@ -87,22 +102,100 @@ class ProgramBatch(ABC):
87
102
 
88
103
  @property
89
104
  def total_circuit_count(self):
105
+ """
106
+ Get the total number of circuits executed across all programs in the batch.
107
+
108
+ Returns:
109
+ int: Cumulative count of circuits submitted by all programs.
110
+ """
90
111
  return self._total_circuit_count
91
112
 
92
113
  @property
93
114
  def total_run_time(self):
115
+ """
116
+ Get the total runtime across all programs in the batch.
117
+
118
+ Returns:
119
+ float: Cumulative execution time in seconds across all programs.
120
+ """
94
121
  return self._total_run_time
95
122
 
123
+ @property
124
+ def programs(self) -> dict:
125
+ """
126
+ Get a copy of the programs dictionary.
127
+
128
+ Returns:
129
+ dict: Copy of the programs dictionary mapping program IDs to
130
+ QuantumProgram instances. Modifications to this dict will not
131
+ affect the internal state.
132
+ """
133
+ return self._programs.copy()
134
+
135
+ @programs.setter
136
+ def programs(self, value: dict):
137
+ """Set the programs dictionary."""
138
+ self._programs = value
139
+
96
140
  @abstractmethod
97
141
  def create_programs(self):
98
- self._manager = Manager()
99
- self._queue = self._manager.Queue()
142
+ """Generate and populate the programs dictionary for batch execution.
143
+
144
+ This method must be implemented by subclasses to create the quantum programs
145
+ that will be executed as part of the batch. The method operates via side effects:
146
+ it populates `self._programs` (or `self.programs`) with a dictionary mapping
147
+ program identifiers to `QuantumProgram` instances.
148
+
149
+ Implementation Notes:
150
+ - Subclasses should call `super().create_programs()` first to initialize
151
+ internal state (queue, events, etc.) and validate that no programs
152
+ already exist.
153
+ - After calling super(), subclasses should populate `self.programs` or
154
+ `self._programs` with their program instances.
155
+ - Program identifiers can be any hashable type (e.g., strings, tuples).
156
+ Common patterns include strings like "program_1", "program_2" or tuples like
157
+ ('A', 5) for partitioned problems.
158
+
159
+ Side Effects:
160
+ - Populates `self._programs` with program instances.
161
+ - Initializes `self._queue` for progress reporting.
162
+ - Initializes `self._done_event` if `max_iterations` attribute exists.
163
+
164
+ Raises:
165
+ RuntimeError: If programs already exist (should call `reset()` first).
166
+
167
+ Example:
168
+ >>> def create_programs(self):
169
+ ... super().create_programs()
170
+ ... self.programs = {
171
+ ... "prog1": QAOA(...),
172
+ ... "prog2": QAOA(...),
173
+ ... }
174
+ """
175
+ if len(self._programs) > 0:
176
+ raise RuntimeError(
177
+ "Some programs already exist. "
178
+ "Clear the program dictionary before creating new ones by using batch.reset()."
179
+ )
180
+
181
+ self._queue = Queue()
100
182
 
101
183
  if hasattr(self, "max_iterations"):
102
184
  self._done_event = Event()
103
185
 
104
186
  def reset(self):
105
- self.programs.clear()
187
+ """
188
+ Reset the batch to its initial state.
189
+
190
+ Clears all programs, stops any running executors, terminates listener threads,
191
+ and stops progress bars. This allows the batch to be reused for a new set of
192
+ programs.
193
+
194
+ Note:
195
+ Any running programs will be forcefully stopped. Results from incomplete
196
+ programs will be lost.
197
+ """
198
+ self._programs.clear()
106
199
 
107
200
  # Stop any active executor
108
201
  if self._executor is not None:
@@ -118,66 +211,111 @@ class ProgramBatch(ABC):
118
211
  if getattr(self, "_listener_thread", None) is not None:
119
212
  self._listener_thread.join(timeout=1)
120
213
  if self._listener_thread.is_alive():
121
- warn(
122
- "Listener thread did not terminate within timeout and may still be running."
123
- )
124
- else:
125
- self._listener_thread = None
126
- self._queue.close()
127
- self._queue.join_thread()
128
- self._queue = None
214
+ warn("Listener thread did not terminate within timeout.")
215
+ self._listener_thread = None
129
216
 
130
217
  # Stop the progress bar if it's still active
131
218
  if getattr(self, "_progress_bar", None) is not None:
132
219
  try:
133
220
  self._progress_bar.stop()
134
- self._progress_bar = None
135
221
  except Exception:
136
222
  pass # Already stopped or not running
137
-
223
+ self._progress_bar = None
138
224
  self._pb_task_map.clear()
139
225
 
140
- def add_program_to_executor(self, program):
141
- self.futures.append(self._executor.submit(program.run))
226
+ def _atexit_cleanup_hook(self):
227
+ # This hook is only registered for non-blocking runs.
228
+ if self._executor is not None:
229
+ warn(
230
+ "A non-blocking ProgramBatch run was not explicitly closed with "
231
+ "'join()'. The batch was cleaned up automatically on exit.",
232
+ UserWarning,
233
+ )
234
+ self.reset()
235
+
236
+ def _add_program_to_executor(self, program: QuantumProgram) -> Future:
237
+ """
238
+ Add a quantum program to the thread pool executor for execution.
239
+
240
+ Sets up the program with cancellation support and progress tracking, then
241
+ submits it for execution in a separate thread.
242
+
243
+ Args:
244
+ program (QuantumProgram): The quantum program to execute.
245
+
246
+ Returns:
247
+ Future: A Future object representing the program's execution.
248
+ """
249
+ if hasattr(program, "_set_cancellation_event"):
250
+ program._set_cancellation_event(self._cancellation_event)
142
251
 
143
252
  if self._progress_bar is not None:
144
253
  with self._pb_lock:
145
- self._pb_task_map[program.job_id] = self._progress_bar.add_task(
254
+ self._pb_task_map[program.program_id] = self._progress_bar.add_task(
146
255
  "",
147
- job_name=f"Job {program.job_id}",
256
+ job_name=f"Program {program.program_id}",
148
257
  total=self.max_iterations,
149
258
  completed=0,
150
- poll_attempt=0,
151
259
  message="",
152
- final_status="",
153
- mode=("simulation" if self._is_local else "network"),
154
260
  )
155
261
 
156
- def run(self):
262
+ return self._executor.submit(self._task_fn, program)
263
+
264
+ def run(self, blocking: bool = False):
265
+ """
266
+ Execute all programs in the batch.
267
+
268
+ Starts all quantum programs in parallel using a thread pool. Can run in
269
+ blocking or non-blocking mode.
270
+
271
+ Args:
272
+ blocking (bool, optional): If True, waits for all programs to complete
273
+ before returning. If False, returns immediately and programs run in
274
+ the background. Defaults to False.
275
+
276
+ Returns:
277
+ ProgramBatch: Returns self for method chaining.
278
+
279
+ Raises:
280
+ RuntimeError: If a batch is already running or if no programs have been
281
+ created.
282
+
283
+ Note:
284
+ In non-blocking mode, call `join()` later to wait for completion and
285
+ collect results.
286
+ """
157
287
  if self._executor is not None:
158
288
  raise RuntimeError("A batch is already being run.")
159
289
 
160
- if len(self.programs) == 0:
290
+ if len(self._programs) == 0:
161
291
  raise RuntimeError("No programs to run.")
162
292
 
163
293
  self._progress_bar = (
164
- make_progress_bar(
165
- max_retries=None if self._is_local else self.backend.max_retries,
166
- is_jupyter=self._is_jupyter,
167
- )
294
+ make_progress_bar(is_jupyter=self._is_jupyter)
168
295
  if hasattr(self, "max_iterations")
169
296
  else None
170
297
  )
171
298
 
172
- self._executor = ProcessPoolExecutor()
299
+ # Validate that all program instances are unique to prevent thread-safety issues
300
+ program_instances = list(self._programs.values())
301
+ if len(set(program_instances)) != len(program_instances):
302
+ raise RuntimeError(
303
+ "Duplicate program instances detected in batch. "
304
+ "QuantumProgram instances are stateful and NOT thread-safe. "
305
+ "You must provide a unique instance for each program ID."
306
+ )
307
+
308
+ self._executor = ThreadPoolExecutor()
309
+ self._cancellation_event = Event()
173
310
  self.futures = []
311
+ self._future_to_program = {}
174
312
  self._pb_task_map = {}
175
313
  self._pb_lock = Lock()
176
314
 
177
315
  if self._progress_bar is not None:
178
316
  self._progress_bar.start()
179
317
  self._listener_thread = Thread(
180
- target=queue_listener,
318
+ target=_queue_listener,
181
319
  args=(
182
320
  self._queue,
183
321
  self._progress_bar,
@@ -190,46 +328,218 @@ class ProgramBatch(ABC):
190
328
  )
191
329
  self._listener_thread.start()
192
330
 
193
- for program in self.programs.values():
194
- self.add_program_to_executor(program)
331
+ for program in self._programs.values():
332
+ future = self._add_program_to_executor(program)
333
+ self.futures.append(future)
334
+ self._future_to_program[future] = program
335
+
336
+ if not blocking:
337
+ # Arm safety net
338
+ atexit.register(self._atexit_cleanup_hook)
339
+ else:
340
+ self.join()
195
341
 
196
- def check_all_done(self):
342
+ return self
343
+
344
+ def check_all_done(self) -> bool:
345
+ """
346
+ Check if all programs in the batch have completed execution.
347
+
348
+ Returns:
349
+ bool: True if all programs are finished (successfully or with errors),
350
+ False if any are still running.
351
+ """
197
352
  return all(future.done() for future in self.futures)
198
353
 
199
- def wait_for_all(self):
354
+ def _collect_completed_results(self, completed_futures: list):
355
+ """
356
+ Collects results from any futures that have completed successfully.
357
+ Appends (circuit_count, run_time) tuples to the completed_futures list.
358
+
359
+ Args:
360
+ completed_futures: List to append results to
361
+ """
362
+ for future in self.futures:
363
+ if future.done() and not future.cancelled():
364
+ try:
365
+ completed_futures.append(future.result())
366
+ except Exception:
367
+ pass # Skip failed futures
368
+
369
+ def _handle_cancellation(self):
370
+ """
371
+ Handles cancellation gracefully, providing accurate feedback by checking
372
+ the result of future.cancel().
373
+ """
374
+ self._cancellation_event.set()
375
+
376
+ successfully_cancelled = []
377
+ unstoppable_futures = []
378
+
379
+ # --- Phase 1: Attempt to cancel all non-finished tasks ---
380
+ for future, program in self._future_to_program.items():
381
+ if future.done():
382
+ continue
383
+
384
+ task_id = self._pb_task_map.get(program.program_id)
385
+ if self._progress_bar and task_id is not None:
386
+ cancel_result = future.cancel()
387
+ if cancel_result:
388
+ # The task was pending and was successfully cancelled.
389
+ successfully_cancelled.append(program)
390
+ else:
391
+ # The task is already running and cannot be stopped.
392
+ # Attempt to cancel the cloud job to allow polling loop to exit.
393
+ program.cancel_unfinished_job()
394
+ unstoppable_futures.append(future)
395
+ self._progress_bar.update(
396
+ task_id,
397
+ message="Finishing... ⏳",
398
+ refresh=self._is_jupyter,
399
+ )
400
+
401
+ # --- Phase 2: Immediately mark the successfully cancelled tasks ---
402
+ for program in successfully_cancelled:
403
+ task_id = self._pb_task_map.get(program.program_id)
404
+ if self._progress_bar and task_id is not None:
405
+ self._progress_bar.update(
406
+ task_id,
407
+ final_status="Cancelled",
408
+ message="Cancelled by user",
409
+ refresh=self._is_jupyter,
410
+ )
411
+
412
+ # --- Phase 3: Wait for the unstoppable tasks to finish ---
413
+ if unstoppable_futures:
414
+ for future in as_completed(unstoppable_futures):
415
+ program = self._future_to_program[future]
416
+ task_id = self._pb_task_map.get(program.program_id)
417
+ if self._progress_bar and task_id is not None:
418
+ self._progress_bar.update(
419
+ task_id,
420
+ final_status="Aborted",
421
+ message="Completed during cancellation",
422
+ refresh=self._is_jupyter,
423
+ )
424
+
425
+ def join(self):
426
+ """
427
+ Wait for all programs in the batch to complete and collect results.
428
+
429
+ Blocks until all programs finish execution, aggregating their circuit counts
430
+ and run times. Handles keyboard interrupts gracefully by attempting to cancel
431
+ remaining programs.
432
+
433
+ Returns:
434
+ bool or None: Returns False if interrupted by KeyboardInterrupt, None otherwise.
435
+
436
+ Raises:
437
+ RuntimeError: If any program fails with an exception, after cancelling
438
+ remaining programs.
439
+
440
+ Note:
441
+ This method should be called after `run(blocking=False)` to wait for
442
+ completion. It's automatically called when using `run(blocking=True)`.
443
+ """
200
444
  if self._executor is None:
201
445
  return
202
446
 
203
- exceptions = []
447
+ completed_futures = []
204
448
  try:
205
- # Ensure all futures are completed and handle exceptions.
449
+ # The as_completed iterator will yield futures as they finish.
450
+ # If a task fails, future.result() will raise the exception immediately.
206
451
  for future in as_completed(self.futures):
207
- try:
208
- future.result() # Raises an exception if the task failed.
209
- except Exception as e:
210
- exceptions.append(e)
452
+ completed_futures.append(future.result())
453
+
454
+ except KeyboardInterrupt:
455
+
456
+ if self._progress_bar is not None:
457
+ self._progress_bar.console.print(
458
+ "[bold yellow]Shutdown signal received, waiting for programs to finish current iteration...[/bold yellow]"
459
+ )
460
+ self._handle_cancellation()
461
+
462
+ # Collect results from any futures that completed before/during cancellation
463
+ self._collect_completed_results(completed_futures)
464
+
465
+ return False
466
+
467
+ except Exception as e:
468
+ # A task has failed. Print the error and cancel the rest.
469
+ print(f"A task failed with an exception. Cancelling remaining tasks...")
470
+ traceback.print_exception(type(e), e, e.__traceback__)
471
+
472
+ # Collect results from any futures that completed before the failure
473
+ self._collect_completed_results(completed_futures)
474
+
475
+ # Cancel all other futures that have not yet completed.
476
+ for f in self.futures:
477
+ f.cancel()
478
+
479
+ # Re-raise a new error to indicate the batch failed.
480
+ raise RuntimeError("Batch execution failed and was cancelled.") from e
211
481
 
212
482
  finally:
213
- self._executor.shutdown(wait=True, cancel_futures=False)
483
+ # Aggregate results from completed futures
484
+ if completed_futures:
485
+ self._total_circuit_count += sum(
486
+ result[0] for result in completed_futures
487
+ )
488
+ self._total_run_time += sum(result[1] for result in completed_futures)
489
+ self.futures.clear()
490
+
491
+ self._executor.shutdown(wait=False)
214
492
  self._executor = None
215
493
 
216
494
  if self._progress_bar is not None:
495
+ self._queue.join()
217
496
  self._done_event.set()
218
497
  self._listener_thread.join()
498
+ self._progress_bar.stop()
219
499
 
220
- if exceptions:
221
- for i, exc in enumerate(exceptions, 1):
222
- print(f"Task {i} failed with exception:")
223
- traceback.print_exception(type(exc), exc, exc.__traceback__)
224
- raise RuntimeError("One or more tasks failed. Check logs for details.")
225
-
226
- if self._progress_bar is not None:
227
- self._progress_bar.stop()
228
-
229
- self._total_circuit_count += sum(future.result()[0] for future in self.futures)
230
- self._total_run_time += sum(future.result()[1] for future in self.futures)
231
- self.futures = []
500
+ # After successful cleanup, try to unregister the hook.
501
+ try:
502
+ atexit.unregister(self._atexit_cleanup_hook)
503
+ except TypeError:
504
+ pass
232
505
 
233
506
  @abstractmethod
234
507
  def aggregate_results(self):
235
- raise NotImplementedError
508
+ """
509
+ Aggregate results from all programs in the batch after execution.
510
+
511
+ This is an abstract method that must be implemented by subclasses. The base
512
+ implementation performs validation checks:
513
+ - Ensures programs have been created
514
+ - Waits for any running programs to complete (calls join() if needed)
515
+ - Verifies that all programs have completed execution (non-empty losses_history)
516
+
517
+ Subclasses should call super().aggregate_results() first, then implement
518
+ their own aggregation logic to combine results from all programs. The
519
+ aggregation should handle different result formats (counts dictionary,
520
+ expectation values, etc.) as appropriate for the specific use case.
521
+
522
+ Returns:
523
+ The aggregated result, format depends on the subclass implementation.
524
+
525
+ Raises:
526
+ RuntimeError: If no programs exist, or if programs haven't completed
527
+ execution (empty losses_history).
528
+ """
529
+ if len(self._programs) == 0:
530
+ raise RuntimeError("No programs to aggregate. Run create_programs() first.")
531
+
532
+ if self._executor is not None:
533
+ self.join()
534
+
535
+ # Suppress warnings when checking for empty losses_history for cleanliness sake
536
+ with warnings.catch_warnings():
537
+ warnings.filterwarnings(
538
+ "ignore", category=UserWarning, message=".*losses_history is empty.*"
539
+ )
540
+ if any(
541
+ len(program.losses_history) == 0 for program in self._programs.values()
542
+ ):
543
+ raise RuntimeError(
544
+ "Some/All programs have empty losses. Did you call run()?"
545
+ )