qoro-divi 0.5.0__py3-none-any.whl → 0.6.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.
@@ -1,4 +1,4 @@
1
- # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
1
+ # SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
@@ -198,13 +198,26 @@ class VQE(VariationalQuantumAlgorithm):
198
198
 
199
199
  return [
200
200
  self.meta_circuits[circuit_type].initialize_circuit_from_params(
201
- params_group, tag_prefix=f"{p}"
201
+ params_group, param_idx=p
202
202
  )
203
203
  for p, params_group in enumerate(self._curr_params)
204
204
  ]
205
205
 
206
206
  def _perform_final_computation(self, **kwargs):
207
- """Extract the eigenstate corresponding to the lowest energy found."""
207
+ """Extract the eigenstate corresponding to the lowest energy found.
208
+
209
+ This method performs the following steps:
210
+ 1. Executes measurement circuits with the best parameters (those that achieved the lowest loss).
211
+ 2. Retrieves the bitstring representing the eigenstate with the highest probability,
212
+ correcting for endianness.
213
+ 3. Converts the bitstring to a NumPy array of integers (int32) representing the eigenstate.
214
+ 4. Stores the eigenstate in the `_eigenstate` attribute.
215
+
216
+ Returns:
217
+ tuple[int, float]: A tuple containing:
218
+ - int: The total number of circuits executed.
219
+ - float: The total runtime of the optimization process.
220
+ """
208
221
  self.reporter.info(message="🏁 Computing Final Eigenstate 🏁", overwrite=True)
209
222
 
210
223
  self._run_solution_measurement()
divi/qprog/batch.py CHANGED
@@ -488,8 +488,11 @@ class ProgramBatch(ABC):
488
488
  self._total_run_time += sum(result[1] for result in completed_futures)
489
489
  self.futures.clear()
490
490
 
491
- self._executor.shutdown(wait=False)
492
- self._executor = None
491
+ # Shutdown executor and wait for all threads to complete
492
+ # This is critical for Python 3.12 to prevent process hangs
493
+ if self._executor is not None:
494
+ self._executor.shutdown(wait=True)
495
+ self._executor = None
493
496
 
494
497
  if self._progress_bar is not None:
495
498
  self._queue.join()
@@ -1,4 +1,4 @@
1
- # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
1
+ # SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
@@ -147,10 +147,11 @@ class QuantumProgram(ABC):
147
147
  contains job_id. For sync backends, contains results directly.
148
148
  """
149
149
  job_circuits = {}
150
+ self._reset_tag_cache()
150
151
 
151
152
  for bundle in self._curr_circuits:
152
153
  for executable in bundle.executables:
153
- job_circuits[executable.tag] = executable.qasm
154
+ job_circuits[self._encode_tag(executable.tag)] = executable.qasm
154
155
 
155
156
  self._total_circuit_count += len(job_circuits)
156
157
 
@@ -302,6 +303,7 @@ class QuantumProgram(ABC):
302
303
  raise ValueError("ExecutionResult has neither results nor job_id")
303
304
 
304
305
  results = {r["label"]: r["results"] for r in results}
306
+ results = self._decode_tags(results)
305
307
 
306
308
  result = self._post_process_results(results, **kwargs)
307
309
 
@@ -309,3 +311,14 @@ class QuantumProgram(ABC):
309
311
  finally:
310
312
  # Clear the execution result after processing
311
313
  self._current_execution_result = None
314
+
315
+ def _reset_tag_cache(self) -> None:
316
+ """Hook to reset per-run tag caches. Default is no-op."""
317
+
318
+ def _encode_tag(self, tag: Any) -> str:
319
+ """Convert a tag to a backend-safe string."""
320
+ return str(tag)
321
+
322
+ def _decode_tags(self, results: dict[str, dict[str, int]]) -> dict:
323
+ """Restore structured tags from backend result labels."""
324
+ return results
divi/qprog/typing.py ADDED
@@ -0,0 +1,62 @@
1
+ # SPDX-FileCopyrightText: 2026 Qoro Quantum Ltd <divi@qoroquantum.de>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import dimod
6
+ import networkx as nx
7
+ import numpy as np
8
+ import rustworkx as rx
9
+ import scipy.sparse as sps
10
+
11
+ GraphProblemTypes = nx.Graph | rx.PyGraph
12
+ QUBOProblemTypes = list | np.ndarray | sps.spmatrix | dimod.BinaryQuadraticModel
13
+
14
+
15
+ def qubo_to_matrix(qubo: QUBOProblemTypes) -> np.ndarray | sps.spmatrix:
16
+ """Convert supported QUBO inputs to a square matrix.
17
+
18
+ Args:
19
+ qubo: QUBO input as list, ndarray, sparse matrix, or BinaryQuadraticModel.
20
+
21
+ Returns:
22
+ Square QUBO matrix as a dense ndarray or sparse matrix.
23
+
24
+ Raises:
25
+ ValueError: If the input cannot be converted to a square matrix or the
26
+ BinaryQuadraticModel is not binary.
27
+ """
28
+ if isinstance(qubo, dimod.BinaryQuadraticModel):
29
+ if qubo.vartype != dimod.Vartype.BINARY:
30
+ raise ValueError(
31
+ f"BinaryQuadraticModel must have vartype='BINARY', got {qubo.vartype}"
32
+ )
33
+ variables = list(qubo.variables)
34
+ var_to_idx = {v: i for i, v in enumerate(variables)}
35
+ matrix = np.diag([qubo.linear.get(v, 0) for v in variables])
36
+ for (u, v), coeff in qubo.quadratic.items():
37
+ i, j = var_to_idx[u], var_to_idx[v]
38
+ matrix[i, j] = matrix[j, i] = coeff
39
+ return matrix
40
+
41
+ if isinstance(qubo, list):
42
+ qubo = np.asarray(qubo)
43
+
44
+ if isinstance(qubo, np.ndarray):
45
+ if qubo.ndim != 2 or qubo.shape[0] != qubo.shape[1]:
46
+ raise ValueError(
47
+ "Invalid QUBO matrix."
48
+ f" Got array of shape {qubo.shape}."
49
+ " Must be a square matrix."
50
+ )
51
+ return qubo
52
+
53
+ if sps.isspmatrix(qubo):
54
+ if qubo.shape[0] != qubo.shape[1]:
55
+ raise ValueError(
56
+ "Invalid QUBO matrix."
57
+ f" Got sparse matrix of shape {qubo.shape}."
58
+ " Must be a square matrix."
59
+ )
60
+ return qubo
61
+
62
+ raise ValueError(f"Unsupported QUBO type: {type(qubo)}")
@@ -1,4 +1,4 @@
1
- # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
1
+ # SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
@@ -7,10 +7,9 @@ import pickle
7
7
  from abc import abstractmethod
8
8
  from datetime import datetime
9
9
  from functools import partial
10
- from itertools import groupby
11
10
  from pathlib import Path
12
11
  from queue import Queue
13
- from typing import Any
12
+ from typing import Any, NamedTuple
14
13
  from warnings import warn
15
14
 
16
15
  import numpy as np
@@ -24,7 +23,7 @@ from divi.backends import (
24
23
  convert_counts_to_probs,
25
24
  reverse_dict_endianness,
26
25
  )
27
- from divi.circuits import CircuitBundle, MetaCircuit
26
+ from divi.circuits import CircuitBundle, CircuitTag, MetaCircuit, format_circuit_tag
28
27
  from divi.circuits.qem import _NoMitigation
29
28
  from divi.qprog._expectation import _batched_expectation
30
29
  from divi.qprog._hamiltonians import convert_hamiltonian_to_pauli_string
@@ -50,6 +49,20 @@ from divi.qprog.quantum_program import QuantumProgram
50
49
  logger = logging.getLogger(__name__)
51
50
 
52
51
 
52
+ class SolutionEntry(NamedTuple):
53
+ """A solution entry with bitstring, probability, and optional decoded value.
54
+
55
+ Args:
56
+ bitstring: Binary string representing a computational basis state.
57
+ prob: Measured probability in range [0.0, 1.0].
58
+ decoded: Optional problem-specific decoded representation. Defaults to None.
59
+ """
60
+
61
+ bitstring: str
62
+ prob: float
63
+ decoded: Any | None = None
64
+
65
+
53
66
  class SubclassState(BaseModel):
54
67
  """Container for subclass-specific state."""
55
68
 
@@ -249,6 +262,12 @@ class VariationalQuantumAlgorithm(QuantumProgram):
249
262
  QASM sizes manageable. Consider reducing precision if you need to minimize
250
263
  data transfer overhead, or increase it only if you require higher numerical
251
264
  precision in your circuit parameters.
265
+ decode_solution_fn (callable[[str], Any] | None): Function to decode bitstrings
266
+ into problem-specific solution representations. Called during final computation
267
+ and when `get_top_solutions(include_decoded=True)` is used. The function should
268
+ take a binary string (e.g., "0101") and return a decoded representation
269
+ (e.g., a list of indices, numpy array, or custom object). Defaults to
270
+ `lambda bitstring: bitstring` (identity function).
252
271
  """
253
272
 
254
273
  super().__init__(
@@ -291,6 +310,11 @@ class VariationalQuantumAlgorithm(QuantumProgram):
291
310
  self._qem_protocol = kwargs.pop("qem_protocol", None) or _NoMitigation()
292
311
  self._precision = kwargs.pop("precision", 8)
293
312
 
313
+ # --- Solution Decoding ---
314
+ self._decode_solution_fn = kwargs.pop(
315
+ "decode_solution_fn", lambda bitstring: bitstring
316
+ )
317
+
294
318
  # --- Circuit Factory & Templates ---
295
319
  self._meta_circuits = None
296
320
  self._meta_circuit_factory = partial(
@@ -451,11 +475,42 @@ class VariationalQuantumAlgorithm(QuantumProgram):
451
475
  return self._best_loss
452
476
 
453
477
  @property
454
- def best_probs(self):
455
- """Get a copy of the probability distribution for the best parameters.
478
+ def best_probs(self) -> dict[CircuitTag, dict[str, float]]:
479
+ """Get normalized probabilities for the best parameters.
480
+
481
+ This property provides access to the probability distribution computed
482
+ by running measurement circuits with the best parameters found during
483
+ optimization. The distribution maps bitstrings (computational basis states)
484
+ to their measured probabilities.
485
+
486
+ The probabilities are normalized and have deterministic ordering when
487
+ iterated (dictionary insertion order is preserved in Python 3.7+).
456
488
 
457
489
  Returns:
458
- dict: A copy of the best probability distribution.
490
+ dict[CircuitTag, dict[str, float]]: Dictionary mapping CircuitTag keys to
491
+ bitstring probability dictionaries. Bitstrings are binary strings
492
+ (e.g., "0101"), values are probabilities in range [0.0, 1.0].
493
+ Returns an empty dict if final computation has not been performed.
494
+
495
+ Raises:
496
+ RuntimeError: If attempting to access probabilities before running
497
+ the algorithm with final computation enabled.
498
+
499
+ Note:
500
+ To populate this distribution, you must run the algorithm with
501
+ `perform_final_computation=True` (the default):
502
+
503
+ >>> program.run(perform_final_computation=True)
504
+ >>> probs = program.best_probs
505
+
506
+ Example:
507
+ >>> program.run()
508
+ >>> probs = program.best_probs
509
+ >>> for bitstring, prob in probs.items():
510
+ ... print(f"{bitstring}: {prob:.2%}")
511
+ 0101: 42.50%
512
+ 1010: 31.20%
513
+ ...
459
514
  """
460
515
  if not self._best_probs:
461
516
  warn(
@@ -466,6 +521,109 @@ class VariationalQuantumAlgorithm(QuantumProgram):
466
521
  )
467
522
  return self._best_probs.copy()
468
523
 
524
+ def get_top_solutions(
525
+ self, n: int = 10, *, min_prob: float = 0.0, include_decoded: bool = False
526
+ ) -> list[SolutionEntry]:
527
+ """Get the top-N solutions sorted by probability.
528
+
529
+ This method extracts the most probable solutions from the measured
530
+ probability distribution. Solutions are sorted by probability (descending)
531
+ with deterministic tie-breaking using lexicographic ordering of bitstrings.
532
+
533
+ Args:
534
+ n (int): Maximum number of solutions to return. Must be non-negative.
535
+ If n is 0 or negative, returns an empty list. If n exceeds the
536
+ number of available solutions (after filtering), returns all
537
+ available solutions. Defaults to 10.
538
+ min_prob (float): Minimum probability threshold for including solutions.
539
+ Only solutions with probability >= min_prob will be included.
540
+ Must be in range [0.0, 1.0]. Defaults to 0.0 (no filtering).
541
+ include_decoded (bool): Whether to populate the `decoded` field of
542
+ each SolutionEntry by calling the `decode_solution_fn` provided
543
+ in the constructor. If False, the decoded field will be None.
544
+ Defaults to False.
545
+
546
+ Returns:
547
+ list[SolutionEntry]: List of solution entries sorted by probability
548
+ (descending), then by bitstring (lexicographically ascending)
549
+ for deterministic tie-breaking. Returns an empty list if no
550
+ probability distribution is available or n <= 0.
551
+
552
+ Raises:
553
+ RuntimeError: If probability distribution is not available because
554
+ optimization has not been run or final computation was not performed.
555
+ ValueError: If min_prob is not in range [0.0, 1.0] or n is negative.
556
+
557
+ Note:
558
+ The probability distribution must be computed by running the algorithm
559
+ with `perform_final_computation=True` (the default):
560
+
561
+ >>> program.run(perform_final_computation=True)
562
+ >>> top_10 = program.get_top_solutions(n=10)
563
+
564
+ Example:
565
+ >>> # Get top 5 solutions with probability >= 5%
566
+ >>> program.run()
567
+ >>> solutions = program.get_top_solutions(n=5, min_prob=0.05)
568
+ >>> for sol in solutions:
569
+ ... print(f"{sol.bitstring}: {sol.prob:.2%}")
570
+ 1010: 42.50%
571
+ 0101: 31.20%
572
+ 1100: 15.30%
573
+ 0011: 8.50%
574
+ 1111: 2.50%
575
+
576
+ >>> # Get solutions with decoding
577
+ >>> solutions = program.get_top_solutions(n=3, include_decoded=True)
578
+ >>> for sol in solutions:
579
+ ... print(f"{sol.bitstring} -> {sol.decoded}")
580
+ 1010 -> [0, 2]
581
+ 0101 -> [1, 3]
582
+ ...
583
+ """
584
+ # Validate inputs
585
+ if n < 0:
586
+ raise ValueError(f"n must be non-negative, got {n}")
587
+ if not (0.0 <= min_prob <= 1.0):
588
+ raise ValueError(f"min_prob must be in range [0.0, 1.0], got {min_prob}")
589
+
590
+ # Handle edge case: n == 0
591
+ if n == 0:
592
+ return []
593
+
594
+ # Require probability distribution to exist
595
+ if not self._best_probs:
596
+ raise RuntimeError(
597
+ "No probability distribution available. The final computation step "
598
+ "must be performed to compute the probability distribution. "
599
+ "Call run(perform_final_computation=True) to execute optimization "
600
+ "and compute the distribution."
601
+ )
602
+ # Extract the probability distribution (nested by parameter set)
603
+ # _best_probs structure: {tag: {bitstring: prob}}
604
+ probs_dict = next(iter(self._best_probs.values()))
605
+
606
+ # Filter by minimum probability and get top n sorted by probability (descending),
607
+ # then bitstring (ascending) for deterministic tie-breaking
608
+ top_items = sorted(
609
+ filter(
610
+ lambda bitstring_prob: bitstring_prob[1] >= min_prob, probs_dict.items()
611
+ ),
612
+ key=lambda bitstring_prob: (-bitstring_prob[1], bitstring_prob[0]),
613
+ )[:n]
614
+
615
+ # Build result list (decode on demand)
616
+ return [
617
+ SolutionEntry(
618
+ bitstring=bitstring,
619
+ prob=prob,
620
+ decoded=(
621
+ self._decode_solution_fn(bitstring) if include_decoded else None
622
+ ),
623
+ )
624
+ for bitstring, prob in top_items
625
+ ]
626
+
469
627
  @property
470
628
  def curr_params(self) -> npt.NDArray[np.float64]:
471
629
  """Get the current parameters.
@@ -706,6 +864,110 @@ class VariationalQuantumAlgorithm(QuantumProgram):
706
864
 
707
865
  return losses
708
866
 
867
+ @staticmethod
868
+ def _parse_result_tag(tag: CircuitTag) -> tuple[int, int]:
869
+ """Extract (param_id, qem_id) from a result tag."""
870
+ if not isinstance(tag, CircuitTag):
871
+ raise TypeError("Result tags must be CircuitTag instances.")
872
+ return tag.param_id, tag.qem_id
873
+
874
+ def _group_results(
875
+ self, results: dict[str, dict[str, int]]
876
+ ) -> dict[int, dict[int, list[dict[str, int]]]]:
877
+ """
878
+ Group results by parameter id and QEM id.
879
+
880
+ Returns:
881
+ dict[int, dict[int, list[dict[str, int]]]]: {param_id: {qem_id: [shots...]}}
882
+ """
883
+ grouped: dict[int, dict[int, list[dict[str, int]]]] = {}
884
+ for tag, shots in results.items():
885
+ param_id, qem_id = self._parse_result_tag(tag)
886
+ grouped.setdefault(param_id, {}).setdefault(qem_id, []).append(shots)
887
+ return grouped
888
+
889
+ def _reset_tag_cache(self) -> None:
890
+ """Reset per-run tag cache for structured result tags."""
891
+ self._tag_map: dict[str, CircuitTag] = {}
892
+
893
+ def _encode_tag(self, tag: CircuitTag | str) -> str:
894
+ """Convert structured tags to backend-safe strings."""
895
+ if isinstance(tag, CircuitTag):
896
+ tag_str = format_circuit_tag(tag)
897
+ self._tag_map[tag_str] = tag
898
+ return tag_str
899
+ return str(tag)
900
+
901
+ def _decode_tags(
902
+ self, results: dict[str, dict[str, int]]
903
+ ) -> dict[CircuitTag | str, dict[str, int]]:
904
+ """Restore structured tags from backend result labels."""
905
+ if not self._tag_map:
906
+ return results
907
+ return {self._tag_map.get(tag, tag): shots for tag, shots in results.items()}
908
+
909
+ def _apply_qem_protocol(
910
+ self, exp_matrix: npt.NDArray[np.float64]
911
+ ) -> list[npt.NDArray[np.float64]]:
912
+ """Apply the configured QEM protocol to expectation value matrices."""
913
+ return [
914
+ self._qem_protocol.postprocess_results(exp_vals) for exp_vals in exp_matrix
915
+ ]
916
+
917
+ def _compute_marginal_results(
918
+ self,
919
+ qem_groups: dict[int, list[dict[str, int]]],
920
+ measurement_groups: list[list[qml.operation.Operator]],
921
+ ham_ops: str | None,
922
+ ) -> list[npt.NDArray[np.float64] | list[npt.NDArray[np.float64]]]:
923
+ """Compute marginal results, handling backend modes and QEM."""
924
+ if self.backend.supports_expval:
925
+ if ham_ops is None:
926
+ raise ValueError(
927
+ "Hamiltonian operators (ham_ops) are required when using a backend "
928
+ "that supports expectation values, but were not provided."
929
+ )
930
+ ham_ops_list = ham_ops.split(";")
931
+ qem_group_values = [shots for _, shots in sorted(qem_groups.items())]
932
+ return [
933
+ self._apply_qem_protocol(
934
+ np.array(
935
+ [
936
+ [shot_dict[op] for op in ham_ops_list]
937
+ for shot_dict in shots_dicts
938
+ ]
939
+ ).T
940
+ )
941
+ for shots_dicts in qem_group_values
942
+ ] or []
943
+
944
+ shots_by_qem_idx = zip(*qem_groups.values())
945
+ marginal_results: list[
946
+ npt.NDArray[np.float64] | list[npt.NDArray[np.float64]]
947
+ ] = []
948
+ wire_order = tuple(reversed(self.cost_hamiltonian.wires))
949
+ for shots_dicts, curr_measurement_group in zip(
950
+ shots_by_qem_idx, measurement_groups
951
+ ):
952
+ exp_matrix = _batched_expectation(
953
+ shots_dicts, curr_measurement_group, wire_order
954
+ )
955
+ mitigated = self._apply_qem_protocol(exp_matrix)
956
+ marginal_results.append(mitigated if len(mitigated) > 1 else mitigated[0])
957
+
958
+ return marginal_results
959
+
960
+ @staticmethod
961
+ def _merge_param_group_counts(
962
+ param_group: list[tuple[str, dict[str, int]]],
963
+ ) -> dict[str, int]:
964
+ """Merge shot histograms for a single parameter group."""
965
+ shots_dict: dict[str, int] = {}
966
+ for _, d in param_group:
967
+ for s, c in d.items():
968
+ shots_dict[s] = shots_dict.get(s, 0) + c
969
+ return shots_dict
970
+
709
971
  def _post_process_results(
710
972
  self, results: dict[str, dict[str, int]], **kwargs
711
973
  ) -> dict[int, float]:
@@ -713,12 +975,8 @@ class VariationalQuantumAlgorithm(QuantumProgram):
713
975
  Post-process the results of the quantum problem.
714
976
 
715
977
  Args:
716
- results (dict[str, dict[str, int]]): The shot histograms of the quantum execution step.
717
- The keys should be strings of format {param_id}_*_{measurement_group_id}.
718
- i.e. an underscore-separated bunch of metadata, starting always with
719
- the index of some parameter and ending with the index of some measurement group.
720
- Any extra piece of metadata that might be relevant to the specific
721
- application can be kept in the middle.
978
+ results (dict[CircuitTag, dict[str, int]]): The shot histograms of the quantum execution
979
+ step. Keys are CircuitTag instances containing param, QEM, and measurement ids.
722
980
 
723
981
  Returns:
724
982
  dict[int, float]: The energies for each parameter set grouping, where the dict keys
@@ -736,58 +994,12 @@ class VariationalQuantumAlgorithm(QuantumProgram):
736
994
  losses = {}
737
995
  measurement_groups = self.meta_circuits["cost_circuit"].measurement_groups
738
996
 
739
- # Define key functions for grouping
740
- get_param_id = lambda item: int(item[0].split("_")[0])
741
- get_qem_id = lambda item: int(item[0].split("_")[1].split(":")[1])
742
-
743
- # Group the pre-sorted results by parameter ID.
744
- for p, param_group_iterator in groupby(results.items(), key=get_param_id):
745
- param_group_iterator = list(param_group_iterator)
746
-
747
- # Group by QEM ID to handle error mitigation
748
- qem_groups = {
749
- gid: [value for _, value in group]
750
- for gid, group in groupby(param_group_iterator, key=get_qem_id)
751
- }
752
-
753
- # Apply QEM protocol to expectation values (common for both backends)
754
- apply_qem = lambda exp_matrix: [
755
- self._qem_protocol.postprocess_results(exp_vals)
756
- for exp_vals in exp_matrix
757
- ]
758
-
759
- if self.backend.supports_expval:
760
- ham_ops = kwargs.get("ham_ops")
761
- if ham_ops is None:
762
- raise ValueError(
763
- "Hamiltonian operators (ham_ops) are required when using a backend "
764
- "that supports expectation values, but were not provided."
765
- )
766
- marginal_results = [
767
- apply_qem(
768
- np.array(
769
- [
770
- [shot_dict[op] for op in ham_ops.split(";")]
771
- for shot_dict in shots_dicts
772
- ]
773
- ).T
774
- )
775
- for shots_dicts in sorted(qem_groups.values())
776
- ] or []
777
- else:
778
- shots_by_qem_idx = zip(*qem_groups.values())
779
- marginal_results = []
780
- for shots_dicts, curr_measurement_group in zip(
781
- shots_by_qem_idx, measurement_groups
782
- ):
783
- wire_order = tuple(reversed(self.cost_hamiltonian.wires))
784
- exp_matrix = _batched_expectation(
785
- shots_dicts, curr_measurement_group, wire_order
786
- )
787
- mitigated = apply_qem(exp_matrix)
788
- marginal_results.append(
789
- mitigated if len(mitigated) > 1 else mitigated[0]
790
- )
997
+ for p, qem_groups in self._group_results(results).items():
998
+ marginal_results = self._compute_marginal_results(
999
+ qem_groups=qem_groups,
1000
+ measurement_groups=measurement_groups,
1001
+ ham_ops=kwargs.get("ham_ops"),
1002
+ )
791
1003
 
792
1004
  pl_loss = (
793
1005
  self.meta_circuits["cost_circuit"]
@@ -916,7 +1128,6 @@ class VariationalQuantumAlgorithm(QuantumProgram):
916
1128
  if current_loss < self._best_loss:
917
1129
  self._best_loss = current_loss
918
1130
  best_idx = np.argmin(intermediate_result.fun)
919
-
920
1131
  self._best_params = intermediate_result.x[best_idx].copy()
921
1132
 
922
1133
  self.current_iteration += 1
@@ -965,6 +1176,12 @@ class VariationalQuantumAlgorithm(QuantumProgram):
965
1176
 
966
1177
  self._final_params = self._minimize_res.x
967
1178
 
1179
+ # Set _best_params from final result (source of truth)
1180
+ x = np.atleast_2d(self._minimize_res.x)
1181
+ fun = np.atleast_1d(self._minimize_res.fun)
1182
+ best_idx = np.argmin(fun)
1183
+ self._best_params = x[best_idx].copy()
1184
+
968
1185
  if perform_final_computation:
969
1186
  self._perform_final_computation(**kwargs)
970
1187
 
@@ -974,10 +1191,6 @@ class VariationalQuantumAlgorithm(QuantumProgram):
974
1191
 
975
1192
  def _run_solution_measurement(self) -> None:
976
1193
  """Execute measurement circuits to obtain probability distributions for solution extraction."""
977
- if self._best_params is None:
978
- raise RuntimeError(
979
- "Optimization has not been run, no best parameters available."
980
- )
981
1194
 
982
1195
  if "meas_circuit" not in self.meta_circuits:
983
1196
  raise NotImplementedError(
@@ -1,4 +1,4 @@
1
- # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
1
+ # SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
@@ -14,9 +14,10 @@ import scipy.sparse as sps
14
14
  from dimod import BinaryQuadraticModel
15
15
 
16
16
  from divi.backends import CircuitRunner
17
- from divi.qprog.algorithms import QAOA, QUBOProblemTypes
17
+ from divi.qprog.algorithms import QAOA
18
18
  from divi.qprog.batch import ProgramBatch
19
19
  from divi.qprog.optimizers import MonteCarloOptimizer, Optimizer, copy_optimizer
20
+ from divi.qprog.typing import QUBOProblemTypes
20
21
 
21
22
 
22
23
  # Helper function to merge subsamples in-place
@@ -1,8 +1,10 @@
1
- # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
1
+ # SPDX-FileCopyrightText: 2025-2026 Qoro Quantum Ltd <divi@qoroquantum.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
+ import atexit
5
6
  import logging
7
+ import os
6
8
  from abc import ABC, abstractmethod
7
9
  from queue import Queue
8
10
 
@@ -64,12 +66,26 @@ class QueueProgressReporter(ProgressReporter):
64
66
  class LoggingProgressReporter(ProgressReporter):
65
67
  """Reports progress by logging messages to the console."""
66
68
 
69
+ _atexit_registered = False
70
+
67
71
  def __init__(self):
68
72
  # Use the same console instance that RichHandler uses to avoid interference
69
73
  self._console = Console(file=None) # file=None uses stdout, same as RichHandler
70
74
  self._status = None # Track active status for overwriting messages
71
75
  self._current_msg = None # Track current main message
72
76
  self._polling_msg = None # Track current polling message
77
+ self._disable_progress = self._should_disable_progress()
78
+
79
+ def _ensure_atexit_hook(self):
80
+ if self._disable_progress or LoggingProgressReporter._atexit_registered:
81
+ return
82
+ atexit.register(self._close_status)
83
+ LoggingProgressReporter._atexit_registered = True
84
+
85
+ @staticmethod
86
+ def _should_disable_progress() -> bool:
87
+ disable_env = os.getenv("DIVI_DISABLE_PROGRESS", "").strip().lower()
88
+ return disable_env in {"1", "true", "yes", "on"}
73
89
 
74
90
  def _close_status(self):
75
91
  """Close any active status."""
@@ -90,9 +106,12 @@ class LoggingProgressReporter(ProgressReporter):
90
106
 
91
107
  def _update_or_create_status(self):
92
108
  """Update existing status or create a new one with combined message."""
109
+ if self._disable_progress:
110
+ return
93
111
  status_msg = self._build_status_msg()
94
112
  if not status_msg:
95
113
  return
114
+ self._ensure_atexit_hook()
96
115
  if self._status:
97
116
  self._status.update(status_msg)
98
117
  else:
@@ -105,6 +124,9 @@ class LoggingProgressReporter(ProgressReporter):
105
124
  logger.info(f"Finished Iteration #{kwargs['iteration']}")
106
125
 
107
126
  def info(self, message: str, overwrite: bool = False, **kwargs):
127
+ if self._disable_progress:
128
+ logger.info(message)
129
+ return
108
130
  # A special check for iteration updates to use Rich's status for overwriting
109
131
  if "poll_attempt" in kwargs:
110
132
  self._polling_msg = (