qoro-divi 0.2.0b1__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 (58) hide show
  1. divi/__init__.py +8 -0
  2. divi/_pbar.py +73 -0
  3. divi/circuits.py +139 -0
  4. divi/exp/cirq/__init__.py +7 -0
  5. divi/exp/cirq/_lexer.py +126 -0
  6. divi/exp/cirq/_parser.py +889 -0
  7. divi/exp/cirq/_qasm_export.py +37 -0
  8. divi/exp/cirq/_qasm_import.py +35 -0
  9. divi/exp/cirq/exception.py +21 -0
  10. divi/exp/scipy/_cobyla.py +342 -0
  11. divi/exp/scipy/pyprima/LICENCE.txt +28 -0
  12. divi/exp/scipy/pyprima/__init__.py +263 -0
  13. divi/exp/scipy/pyprima/cobyla/__init__.py +0 -0
  14. divi/exp/scipy/pyprima/cobyla/cobyla.py +599 -0
  15. divi/exp/scipy/pyprima/cobyla/cobylb.py +849 -0
  16. divi/exp/scipy/pyprima/cobyla/geometry.py +240 -0
  17. divi/exp/scipy/pyprima/cobyla/initialize.py +269 -0
  18. divi/exp/scipy/pyprima/cobyla/trustregion.py +540 -0
  19. divi/exp/scipy/pyprima/cobyla/update.py +331 -0
  20. divi/exp/scipy/pyprima/common/__init__.py +0 -0
  21. divi/exp/scipy/pyprima/common/_bounds.py +41 -0
  22. divi/exp/scipy/pyprima/common/_linear_constraints.py +46 -0
  23. divi/exp/scipy/pyprima/common/_nonlinear_constraints.py +64 -0
  24. divi/exp/scipy/pyprima/common/_project.py +224 -0
  25. divi/exp/scipy/pyprima/common/checkbreak.py +107 -0
  26. divi/exp/scipy/pyprima/common/consts.py +48 -0
  27. divi/exp/scipy/pyprima/common/evaluate.py +101 -0
  28. divi/exp/scipy/pyprima/common/history.py +39 -0
  29. divi/exp/scipy/pyprima/common/infos.py +30 -0
  30. divi/exp/scipy/pyprima/common/linalg.py +452 -0
  31. divi/exp/scipy/pyprima/common/message.py +336 -0
  32. divi/exp/scipy/pyprima/common/powalg.py +131 -0
  33. divi/exp/scipy/pyprima/common/preproc.py +393 -0
  34. divi/exp/scipy/pyprima/common/present.py +5 -0
  35. divi/exp/scipy/pyprima/common/ratio.py +56 -0
  36. divi/exp/scipy/pyprima/common/redrho.py +49 -0
  37. divi/exp/scipy/pyprima/common/selectx.py +346 -0
  38. divi/interfaces.py +25 -0
  39. divi/parallel_simulator.py +258 -0
  40. divi/qasm.py +220 -0
  41. divi/qem.py +191 -0
  42. divi/qlogger.py +119 -0
  43. divi/qoro_service.py +343 -0
  44. divi/qprog/__init__.py +13 -0
  45. divi/qprog/_graph_partitioning.py +619 -0
  46. divi/qprog/_mlae.py +182 -0
  47. divi/qprog/_qaoa.py +440 -0
  48. divi/qprog/_vqe.py +275 -0
  49. divi/qprog/_vqe_sweep.py +144 -0
  50. divi/qprog/batch.py +235 -0
  51. divi/qprog/optimizers.py +75 -0
  52. divi/qprog/quantum_program.py +493 -0
  53. divi/utils.py +116 -0
  54. qoro_divi-0.2.0b1.dist-info/LICENSE +190 -0
  55. qoro_divi-0.2.0b1.dist-info/LICENSES/Apache-2.0.txt +73 -0
  56. qoro_divi-0.2.0b1.dist-info/METADATA +57 -0
  57. qoro_divi-0.2.0b1.dist-info/RECORD +58 -0
  58. qoro_divi-0.2.0b1.dist-info/WHEEL +4 -0
divi/qprog/_mlae.py ADDED
@@ -0,0 +1,182 @@
1
+ # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from functools import reduce
6
+
7
+ import numpy as np
8
+ import scipy.optimize as optimize
9
+ from qiskit import QuantumCircuit
10
+ from qiskit_algorithms import EstimationProblem, MaximumLikelihoodAmplitudeEstimation
11
+
12
+ from divi.circuits import Circuit
13
+ from divi.qprog.quantum_program import QuantumProgram
14
+
15
+
16
+ class BernoulliA(QuantumCircuit):
17
+ """A circuit representing the Bernoulli A operator."""
18
+
19
+ def __init__(self, probability):
20
+ super().__init__(1)
21
+
22
+ theta_p = 2 * np.arcsin(np.sqrt(probability))
23
+ self.ry(theta_p, 0)
24
+
25
+
26
+ class BernoulliQ(QuantumCircuit):
27
+ """A circuit representing the Bernoulli Q operator."""
28
+
29
+ def __init__(self, probability):
30
+ super().__init__(1)
31
+
32
+ self._theta_p = 2 * np.arcsin(np.sqrt(probability))
33
+ self.ry(2 * self._theta_p, 0)
34
+
35
+ def power(self, k):
36
+ # implement the efficient power of Q
37
+ q_k = QuantumCircuit(1)
38
+ q_k.ry(2 * k * self._theta_p, 0)
39
+ return q_k
40
+
41
+
42
+ class MLAE(QuantumProgram):
43
+ """
44
+ An implementation of the Maximum Likelihood Amplitude Estimateion described in
45
+ https://arxiv.org/pdf/1904.10246
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ grovers: list[int],
51
+ qubits_to_measure: list[int],
52
+ probability: float,
53
+ **kwargs,
54
+ ):
55
+ """
56
+ Initializes the MLAE problem.
57
+ args:
58
+ grovers (list): A list of non-negative integers corresponding to the powers of the Grover
59
+ operator for each iteration
60
+ qubits: An integer or list of integers containing the index of the qubits to measure
61
+ probability: The probability of being in the good state to estimate
62
+ shots: The number of shots to run for each circuit. Default set at 5000.
63
+ """
64
+
65
+ super().__init__(**kwargs)
66
+
67
+ self.grovers = grovers
68
+ self.qubits_to_measure = qubits_to_measure
69
+ self.probability = probability
70
+ self.likelihood_functions = []
71
+
72
+ def _create_meta_circuits_dict(self):
73
+ return super()._create_meta_circuits_dict()
74
+
75
+ def _generate_circuits(self, params=None, **kwargs):
76
+ """
77
+ Generates the circuits that perform step one of the MLAE algorithm,
78
+ the quantum amplitude amplification.
79
+
80
+ Inputs a selection of m values corresponding to the powers of the
81
+ Grover operatorfor each iteration.
82
+
83
+ Returns:
84
+ A list of QASM circuits to run on various devices
85
+ """
86
+ self.circuits.clear()
87
+
88
+ A = BernoulliA(self.probability)
89
+ Q = BernoulliQ(self.probability)
90
+
91
+ problem = EstimationProblem(
92
+ state_preparation=A,
93
+ grover_operator=Q,
94
+ objective_qubits=self.qubits_to_measure,
95
+ )
96
+
97
+ qiskit_circuits = MaximumLikelihoodAmplitudeEstimation(
98
+ self.grovers
99
+ ).construct_circuits(problem)
100
+
101
+ for circuit, grover in zip(qiskit_circuits, self.grovers):
102
+ circuit.measure_all()
103
+ self.circuits.append(Circuit(circuit, tags=[f"{grover}"]))
104
+
105
+ def run(self, store_data=False, data_file=None):
106
+ self._generate_circuits()
107
+ self._dispatch_circuits_and_process_results(
108
+ store_data=store_data, data_file=data_file
109
+ )
110
+
111
+ def _post_process_results(self, results):
112
+ """
113
+ Generates the likelihood function for each circuit of the quantum
114
+ amplitude amplification. These likelihood functions will then
115
+ be combined to create a maximum likelihood function to analyze.
116
+
117
+ Returns:
118
+ A callable maximum likelihood function
119
+ """
120
+
121
+ # Define the necessary variables Nk, Mk, Lk
122
+ for label, shots_dict in results.items():
123
+ mk = int(label)
124
+ Nk = 0
125
+ hk = 0
126
+ for key, shots in shots_dict.items():
127
+ Nk += shots
128
+ hk += shots if key.count("1") == len(key) else 0
129
+
130
+ def likelihood_function(theta, mk=mk, hk=hk, Nk=Nk):
131
+ as_theta = np.arcsin(np.sqrt(theta))
132
+ return ((np.sin((2 * mk + 1) * as_theta)) ** (2 * hk)) * (
133
+ (np.cos((2 * mk + 1) * as_theta)) ** (2 * (Nk - hk))
134
+ )
135
+
136
+ self.likelihood_functions.append(likelihood_function)
137
+
138
+ def generate_maximum_likelihood_function(self, factor=1.0):
139
+ """
140
+ Post-processing takes in likelihood functions.
141
+
142
+ A large factor (e.g. 1e200) should be used for visualization purposes.
143
+ Returns:
144
+ The maximum likelihood function.
145
+ """
146
+
147
+ def combined_likelihood_function(theta):
148
+ return (
149
+ reduce(
150
+ lambda result, f: result * f(theta), self.likelihood_functions, 1.0
151
+ )
152
+ * factor
153
+ )
154
+
155
+ self.maximum_likelihood_fn = combined_likelihood_function
156
+
157
+ return combined_likelihood_function
158
+
159
+ def estimate_amplitude(self, factor):
160
+ """
161
+ Uses the maximum likelihood function to ascertain
162
+ a value for the amplitude.
163
+
164
+ Returns
165
+ Estimation of the amplitude
166
+ """
167
+
168
+ def minimum_likelihood_function(theta):
169
+ # The factor to set to -10e30 in the older branch
170
+ return (
171
+ reduce(
172
+ lambda result, f: result * f(theta), self.likelihood_functions, 1.0
173
+ )
174
+ * factor
175
+ )
176
+
177
+ # create the range of possible amplitudes
178
+ amplitudes = np.linspace(0, 1, 100)
179
+
180
+ bounds = [(min(amplitudes), max(amplitudes))]
181
+
182
+ return optimize.differential_evolution(minimum_likelihood_function, bounds).x[0]
divi/qprog/_qaoa.py ADDED
@@ -0,0 +1,440 @@
1
+ # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import re
6
+ from enum import Enum
7
+ from functools import reduce
8
+ from typing import Literal, Optional, get_args
9
+ from warnings import warn
10
+
11
+ import matplotlib.pyplot as plt
12
+ import networkx as nx
13
+ import numpy as np
14
+ import pennylane as qml
15
+ import pennylane.qaoa as pqaoa
16
+ import rustworkx as rx
17
+ import scipy.sparse as sps
18
+ import sympy as sp
19
+ from qiskit_optimization import QuadraticProgram
20
+ from qiskit_optimization.converters import QuadraticProgramToQubo
21
+ from qiskit_optimization.problems import VarType
22
+
23
+ from divi.circuits import MetaCircuit
24
+ from divi.qprog import QuantumProgram
25
+ from divi.qprog.optimizers import Optimizer
26
+ from divi.utils import convert_qubo_matrix_to_pennylane_ising
27
+
28
+ GraphProblemTypes = nx.Graph | rx.PyGraph
29
+ QUBOProblemTypes = list | np.ndarray | sps.spmatrix | QuadraticProgram
30
+
31
+
32
+ def draw_graph_solution_nodes(main_graph, partition_nodes):
33
+ # Create a dictionary for node colors
34
+ node_colors = [
35
+ "red" if node in partition_nodes else "lightblue" for node in main_graph.nodes()
36
+ ]
37
+
38
+ plt.figure(figsize=(10, 8))
39
+
40
+ pos = nx.spring_layout(main_graph)
41
+
42
+ nx.draw_networkx_nodes(main_graph, pos, node_color=node_colors, node_size=500)
43
+ nx.draw_networkx_edges(main_graph, pos)
44
+ nx.draw_networkx_labels(main_graph, pos, font_size=10, font_weight="bold")
45
+
46
+ # Remove axes
47
+ plt.axis("off")
48
+
49
+ # Show the plot
50
+ plt.tight_layout()
51
+ plt.show()
52
+
53
+
54
+ class GraphProblem(Enum):
55
+ MAX_CLIQUE = ("max_clique", "Zeros", "Superposition")
56
+ MAX_INDEPENDENT_SET = ("max_independent_set", "Zeros", "Superposition")
57
+ MAX_WEIGHT_CYCLE = ("max_weight_cycle", "Superposition", "Superposition")
58
+ MAXCUT = ("maxcut", "Superposition", "Superposition")
59
+ MIN_VERTEX_COVER = ("min_vertex_cover", "Ones", "Superposition")
60
+
61
+ # This is an internal problem with no pennylane equivalent
62
+ EDGE_PARTITIONING = ("", "", "")
63
+
64
+ def __init__(
65
+ self,
66
+ pl_string: str,
67
+ constrained_initial_state: str,
68
+ unconstrained_initial_state: str,
69
+ ):
70
+ self.pl_string = pl_string
71
+
72
+ # Recommended initial state as per Pennylane's documentation.
73
+ # Value is duplicated if not applicable to the problem
74
+ self.constrained_initial_state = constrained_initial_state
75
+ self.unconstrained_initial_state = unconstrained_initial_state
76
+
77
+
78
+ _SUPPORTED_INITIAL_STATES_LITERAL = Literal[
79
+ "Zeros", "Ones", "Superposition", "Recommended"
80
+ ]
81
+
82
+
83
+ def _convert_quadratic_program_to_pennylane_ising(qp: QuadraticProgram):
84
+ qiskit_sparse_op, constant = qp.to_ising()
85
+
86
+ pauli_list = qiskit_sparse_op.paulis
87
+
88
+ pennylane_ising = 0.0
89
+ for pauli_string, coeff in zip(pauli_list.z, qiskit_sparse_op.coeffs):
90
+ sanitized_coeff = coeff.real if np.isreal(coeff) else coeff
91
+
92
+ curr_term = (
93
+ reduce(
94
+ lambda x, y: x @ y,
95
+ map(lambda x: qml.Z(x), np.flatnonzero(pauli_string)),
96
+ )
97
+ * sanitized_coeff.item()
98
+ )
99
+
100
+ pennylane_ising += curr_term
101
+
102
+ return pennylane_ising, constant.item(), pauli_list.num_qubits
103
+
104
+
105
+ def _resolve_circuit_layers(
106
+ initial_state, problem, graph_problem, **kwargs
107
+ ) -> tuple[qml.operation.Operator, qml.operation.Operator, Optional[dict], str]:
108
+ """
109
+ Generates the cost and mixer hamiltonians for a given problem, in addition to
110
+ optional metadata returned by Pennylane if applicable
111
+ """
112
+
113
+ if isinstance(problem, GraphProblemTypes):
114
+ is_constrained = kwargs.pop("is_constrained", True)
115
+
116
+ if graph_problem == GraphProblem.MAXCUT:
117
+ params = (problem,)
118
+ else:
119
+ params = (problem, is_constrained)
120
+
121
+ if initial_state == "Recommended":
122
+ resolved_initial_state = (
123
+ graph_problem.constrained_initial_state
124
+ if is_constrained
125
+ else graph_problem.constrained_initial_state
126
+ )
127
+ else:
128
+ resolved_initial_state = initial_state
129
+
130
+ return *getattr(pqaoa, graph_problem.pl_string)(*params), resolved_initial_state
131
+ else:
132
+ if isinstance(problem, QuadraticProgram):
133
+ cost_hamiltonian, constant, n_qubits = (
134
+ _convert_quadratic_program_to_pennylane_ising(problem)
135
+ )
136
+ else:
137
+ cost_hamiltonian, constant = convert_qubo_matrix_to_pennylane_ising(problem)
138
+
139
+ n_qubits = problem.shape[0]
140
+
141
+ return (
142
+ cost_hamiltonian,
143
+ pqaoa.x_mixer(range(n_qubits)),
144
+ {"constant": constant},
145
+ "Superposition",
146
+ )
147
+
148
+
149
+ class QAOA(QuantumProgram):
150
+ def __init__(
151
+ self,
152
+ problem: GraphProblemTypes | QUBOProblemTypes,
153
+ graph_problem: Optional[GraphProblem] = None,
154
+ n_layers: int = 1,
155
+ initial_state: _SUPPORTED_INITIAL_STATES_LITERAL = "Recommended",
156
+ optimizer: Optimizer = Optimizer.MONTE_CARLO,
157
+ max_iterations: int = 10,
158
+ **kwargs,
159
+ ):
160
+ """
161
+ Initialize the QAOA problem.
162
+
163
+ Args:
164
+ problem: The problem to solve, can either be a graph or a QUBO.
165
+ For graph inputs, the graph problem to solve must be provided
166
+ through the `graph_problem` variable.
167
+ graph_problem (str): The graph problem to solve.
168
+ n_layers (int): number of QAOA layers
169
+ initial_state (str): The initial state of the circuit
170
+ """
171
+
172
+ if isinstance(problem, QUBOProblemTypes):
173
+ if graph_problem is not None:
174
+ warn("Ignoring the 'problem' argument as it is not applicable to QUBO.")
175
+
176
+ self.graph_problem = None
177
+
178
+ if isinstance(problem, QuadraticProgram):
179
+ if (
180
+ any(var.vartype != VarType.BINARY for var in problem.variables)
181
+ or problem.linear_constraints
182
+ or problem.quadratic_constraints
183
+ ):
184
+ warn(
185
+ "Quadratic Program contains non-binary variables. Converting to QUBO."
186
+ )
187
+ self._qp_converter = QuadraticProgramToQubo()
188
+ problem = self._qp_converter.convert(problem)
189
+
190
+ self.n_qubits = problem.get_num_vars()
191
+ else:
192
+ if isinstance(problem, list):
193
+ problem = np.array(problem)
194
+
195
+ if problem.ndim != 2 or problem.shape[0] != problem.shape[1]:
196
+ raise ValueError(
197
+ "Invalid QUBO matrix."
198
+ f" Got array of shape {problem.shape}."
199
+ " Must be a square matrix."
200
+ )
201
+
202
+ self.n_qubits = problem.shape[1]
203
+ else:
204
+ if not isinstance(graph_problem, GraphProblem):
205
+ raise ValueError(
206
+ f"Unsupported Problem. Got '{graph_problem}'. Must be one of type divi.qprog.GraphProblem."
207
+ )
208
+
209
+ self.graph_problem = graph_problem
210
+ self.n_qubits = problem.number_of_nodes()
211
+
212
+ self.problem = problem
213
+
214
+ if initial_state not in get_args(_SUPPORTED_INITIAL_STATES_LITERAL):
215
+ raise ValueError(
216
+ f"Unsupported Initial State. Got {initial_state}. Must be one of: {get_args(_SUPPORTED_INITIAL_STATES_LITERAL)}"
217
+ )
218
+
219
+ # Local Variables
220
+ self.n_layers = n_layers
221
+ self.optimizer = optimizer
222
+ self.max_iterations = max_iterations
223
+ self.current_iteration = 0
224
+ self._solution_nodes = None
225
+ self.n_params = 2
226
+ self._is_compute_probabilites = False
227
+
228
+ # Shared Variables
229
+ self.probs = kwargs.pop("probs", {})
230
+
231
+ (
232
+ self.cost_hamiltonian,
233
+ self.mixer_hamiltonian,
234
+ *problem_metadata,
235
+ self.initial_state,
236
+ ) = _resolve_circuit_layers(
237
+ initial_state=initial_state,
238
+ problem=problem,
239
+ graph_problem=graph_problem,
240
+ **kwargs,
241
+ )
242
+ self.problem_metadata = problem_metadata[0] if problem_metadata else {}
243
+
244
+ self.loss_constant = self.problem_metadata.get("constant", 0.0)
245
+
246
+ kwargs.pop("is_constrained", None)
247
+ super().__init__(**kwargs)
248
+
249
+ self._meta_circuits = self._create_meta_circuits_dict()
250
+
251
+ @property
252
+ def solution(self):
253
+ return (
254
+ self._solution_nodes
255
+ if self.graph_problem is not None
256
+ else self._solution_bitstring
257
+ )
258
+
259
+ def _create_meta_circuits_dict(self) -> dict[str, MetaCircuit]:
260
+ """
261
+ Generate the meta circuits for the QAOA problem.
262
+
263
+ In this method, we generate the scaffolding for the circuits that will be
264
+ executed during optimization.
265
+ """
266
+
267
+ betas = sp.symarray("β", self.n_layers)
268
+ gammas = sp.symarray("γ", self.n_layers)
269
+
270
+ sym_params = np.vstack((betas, gammas)).transpose()
271
+
272
+ def _qaoa_layer(params):
273
+ gamma, beta = params
274
+ pqaoa.cost_layer(gamma, self.cost_hamiltonian)
275
+ pqaoa.mixer_layer(beta, self.mixer_hamiltonian)
276
+
277
+ def _prepare_circuit(hamiltonian, params, final_measurement):
278
+ """
279
+ Prepare the circuit for the QAOA problem.
280
+ Args:
281
+ hamiltonian (qml.Hamiltonian): The Hamiltonian term to measure
282
+ """
283
+
284
+ # Note: could've been done as qml.[Insert Gate](wires=range(self.n_qubits))
285
+ # but there seems to be a bug with program capture in Pennylane.
286
+ # Maybe check when a new version comes out?
287
+ if self.initial_state == "Ones":
288
+ for i in range(self.n_qubits):
289
+ qml.PauliX(wires=i)
290
+ elif self.initial_state == "Superposition":
291
+ for i in range(self.n_qubits):
292
+ qml.Hadamard(wires=i)
293
+
294
+ qml.layer(_qaoa_layer, self.n_layers, params)
295
+
296
+ if final_measurement:
297
+ return qml.probs()
298
+ else:
299
+ return qml.expval(hamiltonian)
300
+
301
+ return {
302
+ "cost_circuit": self._meta_circuit_factory(
303
+ qml.tape.make_qscript(_prepare_circuit)(
304
+ self.cost_hamiltonian, sym_params, final_measurement=False
305
+ ),
306
+ symbols=sym_params.flatten(),
307
+ ),
308
+ "meas_circuit": self._meta_circuit_factory(
309
+ qml.tape.make_qscript(_prepare_circuit)(
310
+ self.cost_hamiltonian, sym_params, final_measurement=True
311
+ ),
312
+ symbols=sym_params.flatten(),
313
+ ),
314
+ }
315
+
316
+ def _generate_circuits(self):
317
+ """
318
+ Generate the circuits for the QAOA problem.
319
+
320
+ In this method, we generate bulk circuits based on the selected parameters.
321
+ """
322
+
323
+ circuit_type = (
324
+ "cost_circuit" if not self._is_compute_probabilites else "meas_circuit"
325
+ )
326
+
327
+ for p, params_group in enumerate(self._curr_params):
328
+ circuit = self._meta_circuits[circuit_type].initialize_circuit_from_params(
329
+ params_group, tag_prefix=f"{p}"
330
+ )
331
+
332
+ self.circuits.append(circuit)
333
+
334
+ def _post_process_results(self, results):
335
+ """
336
+ Post-process the results of the QAOA problem.
337
+
338
+ Returns:
339
+ (dict) The losses for each parameter set grouping.
340
+ """
341
+
342
+ if self._is_compute_probabilites:
343
+ return {
344
+ outer_k: {
345
+ inner_k: inner_v / self.backend.shots
346
+ for inner_k, inner_v in outer_v.items()
347
+ }
348
+ for outer_k, outer_v in results.items()
349
+ }
350
+
351
+ losses = super()._post_process_results(results)
352
+
353
+ return losses
354
+
355
+ def _run_final_measurement(self):
356
+ self._is_compute_probabilites = True
357
+
358
+ self._curr_params = np.array(self.final_params)
359
+
360
+ self.circuits[:] = []
361
+
362
+ self._generate_circuits()
363
+
364
+ self.probs.update(self._dispatch_circuits_and_process_results())
365
+
366
+ self._is_compute_probabilites = False
367
+
368
+ def compute_final_solution(self):
369
+ """
370
+ Computes and extracts the final solution from the QAOA optimization process.
371
+ This method performs the following steps:
372
+ 1. Identifies the best solution index based on the lowest loss value from the last optimization step.
373
+ 2. Executes the final measurement circuit to obtain the probability distributions of solutions.
374
+ 3. Retrieves the bitstring representing the best solution, correcting for endianness.
375
+ 4. Depending on the problem type:
376
+ - For QUBO problems, stores the solution as a NumPy array of bits.
377
+ - For graph problems, stores the solution as a list of node indices corresponding to '1's in the bitstring.
378
+ 5. Returns the total circuit count and total runtime for the optimization process.
379
+ Returns:
380
+ tuple: A tuple containing:
381
+ - int: The total number of circuits executed.
382
+ - float: The total runtime of the optimization process.
383
+ Raises:
384
+ RuntimeError: If more than one/no matching key is found for the best solution index.
385
+ """
386
+
387
+ # Convert losses dict to list to apply ordinal operations
388
+ final_losses_list = list(self.losses[-1].values())
389
+
390
+ # Get the index of the smallest loss in the last operation
391
+ best_solution_idx = min(
392
+ range(len(final_losses_list)),
393
+ key=lambda x: final_losses_list.__getitem__(x),
394
+ )
395
+
396
+ # Insert the measurement circuit here
397
+ self._run_final_measurement()
398
+
399
+ # Find the key matching the best_solution_idx with possible metadata in between
400
+ pattern = re.compile(rf"^{best_solution_idx}(?:_[^_]*)*_0$")
401
+ matching_keys = [k for k in self.probs.keys() if pattern.match(k)]
402
+
403
+ # Some minor sanity checks
404
+ if len(matching_keys) == 0:
405
+ raise RuntimeError("No matching key found.")
406
+ if len(matching_keys) > 1:
407
+ raise RuntimeError(f"More than one matching key found.")
408
+
409
+ best_solution_key = matching_keys[0]
410
+ # Retrieve the probability distribution dictionary of the best solution
411
+ best_solution_probs = self.probs[best_solution_key]
412
+
413
+ # Retrieve the bitstring with the actual best solution
414
+ # Reverse to account for the endianness difference
415
+ best_solution_bitstring = max(best_solution_probs, key=best_solution_probs.get)[
416
+ ::-1
417
+ ]
418
+
419
+ if isinstance(self.problem, QUBOProblemTypes):
420
+ self._solution_bitstring = np.fromiter(
421
+ best_solution_bitstring, dtype=np.int32
422
+ )
423
+
424
+ if isinstance(self.problem, GraphProblemTypes):
425
+ self._solution_nodes = [
426
+ m.start() for m in re.finditer("1", best_solution_bitstring)
427
+ ]
428
+
429
+ return self._total_circuit_count, self._total_run_time
430
+
431
+ def draw_solution(self):
432
+ if self.graph_problem is None:
433
+ raise RuntimeError(
434
+ "The problem is not a graph problem. Cannot draw solution."
435
+ )
436
+
437
+ if not self._solution_nodes:
438
+ self.compute_final_solution()
439
+
440
+ draw_graph_solution_nodes(self.problem, self._solution_nodes)