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
@@ -0,0 +1,619 @@
1
+ # SPDX-FileCopyrightText: 2025 Qoro Quantum Ltd <divi@qoroquantum.de>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import heapq
6
+ import re
7
+ import string
8
+ from collections.abc import Callable, Sequence
9
+ from concurrent.futures import ProcessPoolExecutor
10
+ from dataclasses import dataclass
11
+ from functools import partial
12
+ from typing import Literal, Optional
13
+ from warnings import warn
14
+
15
+ import matplotlib.cm as cm
16
+ import matplotlib.pyplot as plt
17
+ import networkx as nx
18
+ import numpy as np
19
+ import rustworkx as rx
20
+ import scipy.sparse.linalg as spla
21
+ from pymetis import part_graph
22
+ from sklearn.cluster import SpectralClustering
23
+
24
+ from divi.interfaces import CircuitRunner
25
+ from divi.qprog import QAOA, ProgramBatch
26
+ from divi.qprog._qaoa import (
27
+ _SUPPORTED_INITIAL_STATES_LITERAL,
28
+ GraphProblem,
29
+ draw_graph_solution_nodes,
30
+ )
31
+
32
+ from .optimizers import Optimizer
33
+
34
+ AggregateFn = Callable[
35
+ [list[int], str, nx.Graph | rx.PyGraph, dict[int, int]], list[int]
36
+ ]
37
+
38
+ # TODO: Make this dynamic through an interaction with usher
39
+ # once a proper endpoint is exposed
40
+ _MAXIMUM_AVAILABLE_QUBITS = 30
41
+
42
+
43
+ @dataclass(frozen=True, eq=True)
44
+ class PartitioningConfig:
45
+ max_n_nodes_per_cluster: Optional[int] = None
46
+ minimum_n_clusters: Optional[int] = None
47
+ partitioning_algorithm: Literal["spectral", "metis", "kernighan_lin"] = "spectral"
48
+
49
+ def __post_init__(self):
50
+ if self.max_n_nodes_per_cluster is None and self.minimum_n_clusters is None:
51
+ raise ValueError("At least one constraint must be specified.")
52
+
53
+ if self.minimum_n_clusters is not None and self.minimum_n_clusters < 1:
54
+ raise ValueError("'minimum_n_clusters' must be a positive integer.")
55
+
56
+ if (
57
+ self.max_n_nodes_per_cluster is not None
58
+ and self.max_n_nodes_per_cluster < 1
59
+ ):
60
+ raise ValueError("'max_n_nodes_per_cluster' must be a positive number.")
61
+
62
+ if self.partitioning_algorithm not in ("spectral", "metis", "kernighan_lin"):
63
+ raise ValueError(
64
+ f"Unsupported partitioning algorithm: {self.partitioning_algorithm}. "
65
+ "Use 'spectral' or 'metis'."
66
+ )
67
+
68
+
69
+ def _divide_edges(
70
+ graph: nx.DiGraph, edge_selection_predicate: Callable
71
+ ) -> tuple[nx.DiGraph, nx.DiGraph]:
72
+ """
73
+ Divides a graph into two subgraphs based on the provided edge selection criteria.
74
+
75
+ Args:
76
+ graph (nx.DiGraph): The input graph to be divided.
77
+ edge_selection_predicate (Callable): A function which decides if an edge should be
78
+ included in the selected subgraph.
79
+
80
+ Returns:
81
+ tuple[nx.DiGraph, nx.DiGraph]: A tuple containing two DiGraphs: the selected subgraph
82
+ and the rest of the graph.
83
+ """
84
+ selected_edges = [
85
+ (u, v)
86
+ for u, v in graph.edges(data=False)
87
+ if edge_selection_predicate(graph, u, v)
88
+ ]
89
+ rest_edges = [
90
+ (u, v) for u, v in graph.edges(data=False) if (u, v) not in selected_edges
91
+ ]
92
+
93
+ selected_subgraph = graph.edge_subgraph(selected_edges).copy()
94
+ rest_of_graph = graph.edge_subgraph(rest_edges).copy()
95
+
96
+ rest_of_graph.remove_edges_from(selected_edges) # to avoid overlap
97
+
98
+ return selected_subgraph, rest_of_graph
99
+
100
+
101
+ def _fielder_laplacian_predicate(
102
+ growing_graph: nx.DiGraph, src: int, dest: int
103
+ ) -> bool:
104
+ """
105
+ Determines if an edge should be included in the selected subgraph based on spectral partitioning.
106
+
107
+ This function uses the Fiedler vector of the graph's Laplacian matrix to divide
108
+ the nodes into two partitions. An edge is included in the selected subgraph
109
+ if both its source and destination nodes belong to the same partition.
110
+
111
+ Args:
112
+ growing_graph (nx.DiGraph): The graph containing the currently selected edges.
113
+ src (int): The source node of the edge.
114
+ dest (int): The destination node of the edge.
115
+
116
+ Returns:
117
+ bool: True if the edge should be included in the selected subgraph, False otherwise.
118
+ """
119
+ if growing_graph.number_of_edges() == 0:
120
+ return True
121
+
122
+ L = nx.laplacian_matrix(growing_graph).astype(float)
123
+
124
+ # Create an initial random guess for the eigenvectors
125
+ n = L.shape[0]
126
+ X = np.random.rand(n, 2)
127
+ X, _ = np.linalg.qr(X) # Orthonormalize initial guess
128
+
129
+ # Use LOBPCG to compute the two smallest eigenvalues and corresponding eigenvectors
130
+ _, eigenvectors = spla.lobpcg(L, X, largest=False)
131
+
132
+ fiedler_vector = eigenvectors[:, 1].real
133
+ partition = set(i for i, v in enumerate(fiedler_vector) if v > 0)
134
+
135
+ return (src in partition) == (dest in partition)
136
+
137
+
138
+ def _edge_partition_graph(
139
+ graph: nx.DiGraph, n_max_nodes_per_cluster: int
140
+ ) -> list[nx.DiGraph]:
141
+ """
142
+ Partitions a directed graph into smaller subgraphs using recursive bipartite spectral partitioning.
143
+
144
+ The function repeatedly divides the input graph into two subgraphs based on the
145
+ Fiedler vector of the graph's Laplacian matrix. This process is repeated
146
+ until each of the subgraphs' no. of edges does not exceed the no. of qubits.
147
+
148
+ Args:
149
+ graph (nx.DiGraph): The input directed graph to be partitioned.
150
+ n_max_nodes_per_cluster (int, optional): The maximum number of nodes per subgraph.
151
+ Defaults to 8.
152
+
153
+ Returns:
154
+ list[nx.DiGraph]: A list of subgraphs resulting from the partitioning process.
155
+ """
156
+ subgraphs = [graph]
157
+
158
+ while any(g.number_of_edges() > n_max_nodes_per_cluster for g in subgraphs):
159
+ large_subgraphs = [
160
+ g for g in subgraphs if g.number_of_edges() > n_max_nodes_per_cluster
161
+ ]
162
+ subgraphs = [
163
+ g for g in subgraphs if g.number_of_edges() <= n_max_nodes_per_cluster
164
+ ]
165
+
166
+ if not large_subgraphs:
167
+ break
168
+
169
+ for large_subgraph in large_subgraphs:
170
+ selected_subgraph, rest_of_graph = _divide_edges(
171
+ large_subgraph, _fielder_laplacian_predicate
172
+ )
173
+ subgraphs.extend([selected_subgraph, rest_of_graph])
174
+
175
+ return subgraphs
176
+
177
+
178
+ def _apply_split_with_relabel(
179
+ graph: nx.Graph, algorithm: Literal["spectral", "metis"], n_clusters: int
180
+ ) -> tuple[nx.Graph, nx.Graph]:
181
+ """
182
+ Relabels nodes of a graph to (0, ..., N-1) for algorithms that
183
+ require this input/has output of this format and requires mapping
184
+ back to original labels.
185
+ """
186
+ int_graph = nx.convert_node_labels_to_integers(graph, label_attribute="orig_label")
187
+
188
+ if algorithm == "spectral":
189
+ adj_matrix = nx.to_scipy_sparse_array(graph, format="csr")
190
+
191
+ adj_matrix.indptr = adj_matrix.indptr.astype(np.int32)
192
+ adj_matrix.indices = adj_matrix.indices.astype(np.int32)
193
+
194
+ sc = SpectralClustering(
195
+ n_clusters=n_clusters,
196
+ affinity="precomputed",
197
+ n_init=100,
198
+ assign_labels="discretize",
199
+ )
200
+ parts = sc.fit_predict(adj_matrix)
201
+ elif algorithm == "metis":
202
+ adj_list = list(nx.to_dict_of_lists(int_graph).values())
203
+ _, parts = part_graph(n_clusters, adjacency=adj_list)
204
+ else:
205
+ raise RuntimeError("Relabeling only needed for `spectral` and `metis`.")
206
+
207
+ clusters = [[] for _ in range(n_clusters)]
208
+ for idx, part in enumerate(parts):
209
+ orig_label = int_graph.nodes[idx]["orig_label"]
210
+ clusters[part].append(orig_label)
211
+
212
+ return tuple(graph.subgraph(clstr) for clstr in clusters)
213
+
214
+
215
+ def _split_graph(
216
+ graph: nx.Graph, partitioning_config: PartitioningConfig
217
+ ) -> Sequence[nx.Graph]:
218
+ """
219
+ Splits a graph.
220
+
221
+ If the requested partitioning algorithm is either "spectral" or "metis",
222
+ then the requested `min_n_clusters` will be returned.
223
+ For "kernighan_lin", a bisection will be returned
224
+
225
+ Args:
226
+ graph (nx.Graph): The input graph to be partitioned.
227
+ partitioning_config (PartitioningConfig): The configuration to follow.
228
+
229
+ Returns:
230
+ subgraphs: a sequence of the generated partitions.
231
+ """
232
+ if (algorithm := partitioning_config.partitioning_algorithm) in (
233
+ "spectral",
234
+ "metis",
235
+ ):
236
+ return _apply_split_with_relabel(
237
+ graph,
238
+ algorithm,
239
+ # If minimum clusters isn't a constraint, then default to bisection
240
+ partitioning_config.minimum_n_clusters or 2,
241
+ )
242
+ elif partitioning_config.partitioning_algorithm == "kernighan_lin":
243
+ part_1, part_2 = nx.algorithms.community.kernighan_lin_bisection(graph)
244
+ return graph.subgraph(part_1), graph.subgraph(part_2)
245
+
246
+
247
+ def _bisect_with_predicate(
248
+ initial_partitions: Sequence[nx.Graph],
249
+ predicate: Callable[[nx.Graph | None, Sequence[nx.Graph] | None], bool],
250
+ partitioning_config: PartitioningConfig,
251
+ ) -> Sequence[nx.Graph]:
252
+ """
253
+ Recursively bisects a list of graph partitions based on a user-defined predicate.
254
+
255
+ This helper function repeatedly applies a partitioning strategy to a sequence of graph
256
+ subgraphs. At each iteration, it evaluates a predicate to determine whether a subgraph
257
+ should be further split. The process continues until no subgraphs satisfy the predicate,
258
+ at which point the resulting collection of subgraphs is returned.
259
+
260
+ The predicate is expected to accept two arguments:
261
+ - The current subgraph under consideration.
262
+ - A list of other subgraphs in the current iteration (both previously processed
263
+ and yet to be processed), serving as the context for the decision.
264
+
265
+ Returns the final list of subgraphs as a heapified sequence, ordered by descending
266
+ node count.
267
+ """
268
+ subgraphs = initial_partitions
269
+ heapq.heapify(subgraphs)
270
+
271
+ while True:
272
+ new_subgraphs = []
273
+ changed = False
274
+
275
+ while subgraphs:
276
+ (_, _, subgraph) = heapq.heappop(subgraphs)
277
+
278
+ if predicate(subgraph, new_subgraphs + subgraphs):
279
+ new_subgraphs.extend(_split_graph(subgraph, partitioning_config))
280
+ changed = True
281
+ else:
282
+ new_subgraphs.append(subgraph)
283
+
284
+ subgraphs = [
285
+ (-sg.number_of_nodes(), i, sg) for (i, sg) in enumerate(new_subgraphs)
286
+ ]
287
+ heapq.heapify(subgraphs)
288
+
289
+ if not changed:
290
+ break
291
+
292
+ return subgraphs
293
+
294
+
295
+ def _node_partition_graph(
296
+ graph: nx.Graph, partitioning_config: PartitioningConfig
297
+ ) -> list[nx.Graph]:
298
+
299
+ subgraphs = [(-graph.number_of_nodes(), 0, graph)]
300
+
301
+ # First generate the minimum number of clusters, requested by user
302
+ # Initialize the graph as the initial subgraph
303
+ # Add generic ID to break ties in heap
304
+ if partitioning_config.minimum_n_clusters:
305
+ if partitioning_config.minimum_n_clusters > graph.number_of_nodes():
306
+ raise ValueError(
307
+ "Number of requested clusters larger than the size of the graph."
308
+ )
309
+
310
+ subgraphs = _bisect_with_predicate(
311
+ [(-graph.number_of_nodes(), 0, graph)],
312
+ lambda _, subgraphs: len(subgraphs)
313
+ < partitioning_config.minimum_n_clusters - 1,
314
+ partitioning_config,
315
+ )
316
+
317
+ # Split oversized clusters
318
+ if partitioning_config.max_n_nodes_per_cluster:
319
+ subgraphs = _bisect_with_predicate(
320
+ subgraphs,
321
+ lambda subgraph, _: (
322
+ subgraph.number_of_nodes() > partitioning_config.max_n_nodes_per_cluster
323
+ ),
324
+ partitioning_config,
325
+ )
326
+
327
+ if any(-sg[0] > _MAXIMUM_AVAILABLE_QUBITS for sg in subgraphs):
328
+ warn(
329
+ "At least one cluster has more nodes than what can be executed on "
330
+ f"the available backends: {_MAXIMUM_AVAILABLE_QUBITS} qubits."
331
+ )
332
+
333
+ # Clean up on aisle 3
334
+ return tuple(graph for (_, _, graph) in subgraphs)
335
+
336
+
337
+ def linear_aggregation(curr_solution, solution_bitstring, graph, reverse_index_maps):
338
+ for node in graph.nodes():
339
+ solution_index = reverse_index_maps[node]
340
+ curr_solution[solution_index] = int(solution_bitstring[node])
341
+
342
+ return curr_solution
343
+
344
+
345
+ def domninance_aggregation(
346
+ curr_solution, solution_bitstring, graph, reverse_index_maps
347
+ ):
348
+ for node in graph.nodes():
349
+ solution_index = reverse_index_maps[node]
350
+
351
+ # Use existing assignment if dominant in previous solutions
352
+ # (e.g., more 0s than 1s or vice versa)
353
+ count_0 = curr_solution.count(0)
354
+ count_1 = curr_solution.count(1)
355
+
356
+ if (
357
+ (count_0 > count_1 and curr_solution[node] == 0)
358
+ or (count_1 > count_0 and curr_solution[node] == 1)
359
+ or (count_0 == count_1)
360
+ ):
361
+ # Assign based on QAOA if tie
362
+ curr_solution[solution_index] = int(solution_bitstring[node])
363
+
364
+ return curr_solution
365
+
366
+
367
+ class GraphPartitioningQAOA(ProgramBatch):
368
+ def __init__(
369
+ self,
370
+ graph: nx.Graph | rx.PyGraph,
371
+ graph_problem: GraphProblem,
372
+ n_layers: int,
373
+ backend: CircuitRunner,
374
+ partitioning_config: PartitioningConfig,
375
+ initial_state: _SUPPORTED_INITIAL_STATES_LITERAL = "Recommended",
376
+ aggregate_fn: AggregateFn = linear_aggregation,
377
+ optimizer=Optimizer.MONTE_CARLO,
378
+ max_iterations=10,
379
+ **kwargs,
380
+ ):
381
+ """
382
+ Initializes the graph partitioning class.
383
+
384
+ Args:
385
+ graph (nx.Graph | rx.PyGraph): The input graph to be partitioned.
386
+ graph_problem (GraphProblem): The type of graph partitioning problem (e.g., EDGE_PARTITIONING).
387
+ n_layers (int): Number of layers for the QAOA circuit.
388
+ backend (CircuitRunner): Backend used to run quantum/classical circuits.
389
+ partitioning_config (PartitioningConfig): the configuration of the partitioning as to the algorithm and
390
+ expected output.
391
+ initial_state ("Zeros", "Ones", "Superposition", "Recommended", optional): Initial state for the QAOA algorithm. Defaults to "Recommended".
392
+ aggregate_fn (optional): Aggregation function to combine results. Defaults to `linear_aggregation`.
393
+ optimizer (optional): Optimizer to use for QAOA. Defaults to `Optimizers.MONTE_CARLO`.
394
+ max_iterations (int, optional): Maximum number of optimization iterations. Defaults to 10.
395
+ **kwargs: Additional keyword arguments passed to the QAOA constructor.
396
+
397
+ """
398
+ super().__init__(backend=backend)
399
+
400
+ self.main_graph = graph
401
+ self.is_edge_problem = graph_problem == GraphProblem.EDGE_PARTITIONING
402
+
403
+ check_fn = (
404
+ nx.is_connected if not self.is_edge_problem else nx.is_weakly_connected
405
+ )
406
+ if not check_fn(self.main_graph):
407
+ raise ValueError("Provided graph is not fully connected.")
408
+
409
+ self.partitioning_config = partitioning_config
410
+ self.max_iterations = max_iterations
411
+
412
+ self.aggregate_fn = aggregate_fn
413
+
414
+ self._solution_nodes = None
415
+
416
+ self._constructor = partial(
417
+ QAOA,
418
+ initial_state=initial_state,
419
+ graph_problem=graph_problem,
420
+ optimizer=optimizer,
421
+ max_iterations=self.max_iterations,
422
+ backend=self.backend,
423
+ n_layers=n_layers,
424
+ **kwargs,
425
+ )
426
+
427
+ def create_programs(self):
428
+ if len(self.programs) > 0:
429
+ raise RuntimeError(
430
+ "Some programs already exist. "
431
+ "Clear the program dictionary before creating new ones by using batch.reset()."
432
+ )
433
+
434
+ super().create_programs()
435
+
436
+ if self.is_edge_problem:
437
+ subgraphs = _edge_partition_graph(
438
+ self.main_graph,
439
+ n_max_nodes_per_cluster=self.partitioning_config.max_n_nodes_per_cluster,
440
+ )
441
+ cleaned_subgraphs = list(filter(lambda x: x.size() > 0, subgraphs))
442
+ else:
443
+ subgraphs = _node_partition_graph(
444
+ self.main_graph,
445
+ partitioning_config=self.partitioning_config,
446
+ )
447
+
448
+ self._bitstring_solution = [0] * self.main_graph.number_of_nodes()
449
+ self.reverse_index_maps = {}
450
+
451
+ for i, subgraph in enumerate(subgraphs):
452
+ index_map = {node: idx for idx, node in enumerate(subgraph.nodes())}
453
+ self.reverse_index_maps[i] = {v: k for k, v in index_map.items()}
454
+ _subgraph = nx.relabel_nodes(subgraph, index_map)
455
+
456
+ prog_id = (string.ascii_uppercase[i], subgraph.number_of_nodes())
457
+
458
+ self.programs[prog_id] = self._constructor(
459
+ job_id=prog_id,
460
+ problem=_subgraph,
461
+ losses=self._manager.list(),
462
+ probs=self._manager.dict(),
463
+ final_params=self._manager.list(),
464
+ progress_queue=self._queue,
465
+ )
466
+
467
+ def compute_final_solutions(self):
468
+ if self._executor is not None:
469
+ self.wait_for_all()
470
+
471
+ if self._executor is not None:
472
+ raise RuntimeError("A batch is already being run.")
473
+
474
+ if len(self.programs) == 0:
475
+ raise RuntimeError("No programs to run.")
476
+
477
+ self._executor = ProcessPoolExecutor()
478
+
479
+ self.futures = [
480
+ self._executor.submit(program.compute_final_solution)
481
+ for program in self.programs.values()
482
+ ]
483
+
484
+ def aggregate_results(self):
485
+ if len(self.programs) == 0:
486
+ raise RuntimeError("No programs to aggregate. Run create_programs() first.")
487
+
488
+ if self._executor is not None:
489
+ self.wait_for_all()
490
+
491
+ if any(len(program.losses) == 0 for program in self.programs.values()):
492
+ raise RuntimeError(
493
+ "Some/All programs have empty losses. Did you call run()?"
494
+ )
495
+
496
+ if any(len(program.probs) == 0 for program in self.programs.values()):
497
+ raise RuntimeError(
498
+ "Not all final probabilities computed yet. Please call `compute_final_solutions()` first."
499
+ )
500
+
501
+ # Extract the solutions from each program
502
+ for program, reverse_index_maps in zip(
503
+ self.programs.values(), self.reverse_index_maps.values()
504
+ ):
505
+ # Extract the final probabilities of the lowest energy
506
+ last_iteration_losses = program.losses[-1]
507
+ minimum_key = min(last_iteration_losses, key=last_iteration_losses.get)
508
+
509
+ # Find the key matching the best_solution_idx with possible metadata in between
510
+ pattern = re.compile(rf"^{minimum_key}(?:_[^_]*)*_0$")
511
+ matching_keys = [k for k in program.probs.keys() if pattern.match(k)]
512
+
513
+ if len(matching_keys) > 1:
514
+ raise RuntimeError(f"More than one matching key found.")
515
+
516
+ best_solution_key = matching_keys[0]
517
+
518
+ minimum_probabilities = program.probs[best_solution_key]
519
+
520
+ # The bitstring corresponding to the solution, with flip for correct endianness
521
+ max_prob_key = max(minimum_probabilities, key=minimum_probabilities.get)[
522
+ ::-1
523
+ ]
524
+
525
+ self._bitstring_solution = self.aggregate_fn(
526
+ self._bitstring_solution,
527
+ max_prob_key,
528
+ program.problem,
529
+ reverse_index_maps,
530
+ )
531
+
532
+ self.solution = list(np.where(self._bitstring_solution)[0])
533
+
534
+ return self.solution
535
+
536
+ def draw_partitions(
537
+ self,
538
+ pos: dict | None = None,
539
+ figsize: tuple[int, int] | None = (10, 8),
540
+ node_size: int | None = 300,
541
+ ):
542
+ """
543
+ Draw a NetworkX graph with nodes colored by partition.
544
+
545
+ Parameters:
546
+ -----------
547
+ pos : dict, optional
548
+ Node positions. If None, uses spring layout
549
+ figsize : tuple, optional
550
+ Figure size (width, height)
551
+ node_size : int, optional
552
+ Size of nodes
553
+ """
554
+
555
+ if len(self.programs) == 0:
556
+ raise RuntimeError(
557
+ "There are no partitions to draw. Did you run create_programs()?"
558
+ )
559
+
560
+ # Convert partitions to node-to-partition mapping
561
+ node_to_partition = {}
562
+ for partition_id, mapping in self.reverse_index_maps.items():
563
+ for node in mapping.values():
564
+ node_to_partition[node] = string.ascii_uppercase[partition_id]
565
+
566
+ # Get unique partition IDs and create color map
567
+ unique_partitions = sorted(list(set(node_to_partition.values())))
568
+ n_partitions = len(unique_partitions)
569
+ colors = cm.Set3(np.linspace(0, 1, n_partitions))
570
+ partition_colors = {pid: colors[i] for i, pid in enumerate(unique_partitions)}
571
+
572
+ # Create node color list
573
+ node_colors = [
574
+ partition_colors[node_to_partition.get(node, 0)]
575
+ for node in self.main_graph.nodes()
576
+ ]
577
+
578
+ # Set positions
579
+ if pos is None:
580
+ pos = nx.spring_layout(self.main_graph, seed=42)
581
+
582
+ # Draw the graph
583
+ plt.figure(figsize=figsize)
584
+ nx.draw(
585
+ self.main_graph,
586
+ pos,
587
+ node_color=node_colors,
588
+ node_size=node_size,
589
+ with_labels=True,
590
+ font_size=8,
591
+ font_weight="bold",
592
+ edge_color="gray",
593
+ alpha=0.8,
594
+ )
595
+
596
+ # Add legend
597
+ legend_elements = [
598
+ plt.Line2D(
599
+ [0],
600
+ [0],
601
+ marker="o",
602
+ color="w",
603
+ markerfacecolor=partition_colors[pid],
604
+ markersize=10,
605
+ label=f"Partition {pid}",
606
+ )
607
+ for pid in unique_partitions
608
+ ]
609
+ plt.legend(handles=legend_elements, loc="best")
610
+
611
+ plt.title("Graph Partitions Visualization")
612
+ plt.axis("off")
613
+ plt.show()
614
+
615
+ def draw_solution(self):
616
+ if self._solution_nodes is None:
617
+ self.aggregate_results()
618
+
619
+ draw_graph_solution_nodes(self.main_graph, self.solution)