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.
- divi/backends/__init__.py +2 -1
- divi/backends/_backend_properties_conversion.py +227 -0
- divi/backends/_parallel_simulator.py +7 -7
- divi/circuits/__init__.py +8 -3
- divi/circuits/_core.py +36 -14
- divi/qprog/__init__.py +4 -3
- divi/qprog/algorithms/__init__.py +4 -2
- divi/qprog/algorithms/_ansatze.py +18 -6
- divi/qprog/algorithms/_custom_vqa.py +263 -0
- divi/qprog/algorithms/_pce.py +262 -0
- divi/qprog/algorithms/_qaoa.py +43 -36
- divi/qprog/algorithms/_vqe.py +16 -3
- divi/qprog/batch.py +5 -2
- divi/qprog/quantum_program.py +15 -2
- divi/qprog/typing.py +62 -0
- divi/qprog/variational_quantum_algorithm.py +283 -70
- divi/qprog/workflows/_qubo_partitioning.py +3 -2
- divi/reporting/_reporter.py +23 -1
- {qoro_divi-0.5.0.dist-info → qoro_divi-0.6.0.dist-info}/METADATA +5 -5
- {qoro_divi-0.5.0.dist-info → qoro_divi-0.6.0.dist-info}/RECORD +24 -20
- {qoro_divi-0.5.0.dist-info → qoro_divi-0.6.0.dist-info}/WHEEL +0 -0
- {qoro_divi-0.5.0.dist-info → qoro_divi-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {qoro_divi-0.5.0.dist-info → qoro_divi-0.6.0.dist-info}/licenses/LICENSES/.license-header +0 -0
- {qoro_divi-0.5.0.dist-info → qoro_divi-0.6.0.dist-info}/licenses/LICENSES/Apache-2.0.txt +0 -0
divi/qprog/algorithms/_vqe.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
492
|
-
|
|
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()
|
divi/qprog/quantum_program.py
CHANGED
|
@@ -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
|
|
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
|
|
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[
|
|
717
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
|
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
|
divi/reporting/_reporter.py
CHANGED
|
@@ -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 = (
|