qec 0.3.0__py3-none-any.whl → 0.3.3__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.
@@ -0,0 +1,654 @@
1
+ from qec.utils.sparse_binary_utils import (
2
+ convert_to_binary_scipy_sparse,
3
+ binary_csr_matrix_to_dict,
4
+ )
5
+ from qec.utils.binary_pauli_utils import (
6
+ symplectic_product,
7
+ check_binary_pauli_matrices_commute,
8
+ pauli_str_to_binary_pcm,
9
+ binary_pcm_to_pauli_str,
10
+ binary_pauli_hamming_weight,
11
+ )
12
+ import re
13
+ import numpy.typing
14
+ import numpy as np
15
+ import scipy.sparse
16
+ import json
17
+ from tqdm import tqdm
18
+ from ldpc import BpOsdDecoder
19
+ import ldpc.mod2
20
+ import time
21
+ from typing import Tuple, Optional, Union, Sequence
22
+ from pathlib import Path
23
+ import logging
24
+
25
+ logging.basicConfig(level=logging.DEBUG)
26
+
27
+
28
+ class StabilizerCode(object):
29
+ """
30
+ A quantum stabilizer code, which defines and manipulates stabilizer generators,
31
+ computes logical operators, and stores parameters such as the number of physical qubits
32
+ and the number of logical qubits.
33
+
34
+ Parameters
35
+ ----------
36
+ stabilizers : numpy.typing.ArrayLike or scipy.sparse.spmatrix or list
37
+ Either a binary parity check matrix (with an even number of columns),
38
+ or a list of Pauli strings that specify the stabilizers of the code.
39
+ name : str, optional
40
+ A name for the code. Defaults to "stabilizer code".
41
+
42
+ Attributes
43
+ ----------
44
+ name : str
45
+ The name of the code.
46
+ stabilizer_matrix : scipy.sparse.spmatrix
47
+ The binary parity check matrix representation of the stabilizers.
48
+ phyical_qubit_count : int
49
+ The number of physical qubits in the code.
50
+ logical_qubit_count : int
51
+ The number of logical qubits in the code.
52
+ code_distance : int
53
+ (Not computed by default) The distance of the code, if known or computed.
54
+ logical_operator_basis : scipy.sparse.spmatrix or None
55
+ A basis for the logical operators of the code.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ stabilizers: Union[np.ndarray, scipy.sparse.spmatrix, list],
61
+ name: str = None,
62
+ ):
63
+ """
64
+ Construct a StabilizerCode instance from either a parity check matrix or a list of
65
+ Pauli stabilizers.
66
+
67
+ Parameters
68
+ ----------
69
+ stabilizers : numpy.typing.ArrayLike or scipy.sparse.spmatrix or list
70
+ Either a binary parity check matrix (with an even number of columns),
71
+ or a list of Pauli strings that specify the stabilizers of the code.
72
+ name : str, optional
73
+ A name for the code. If None, it defaults to "stabilizer code".
74
+
75
+ Raises
76
+ ------
77
+ TypeError
78
+ If `stabilizers` is not an array-like, sparse matrix, or list of Pauli strings.
79
+ ValueError
80
+ If the parity check matrix does not have an even number of columns,
81
+ or the stabilizers do not mutually commute.
82
+ """
83
+ self.name = name if name else "stabilizer code"
84
+
85
+ self.stabilizer_matrix = None
86
+ self.physical_qubit_count = None
87
+ self._logical_qubit_count = None
88
+ self.code_distance = None
89
+ self.logical_operator_basis = None
90
+
91
+ if isinstance(stabilizers, list):
92
+ stabilizers = np.array(stabilizers)
93
+
94
+ if not isinstance(stabilizers, (np.ndarray, scipy.sparse.spmatrix)):
95
+ raise TypeError(
96
+ "Please provide either a parity check matrix or a list of Pauli stabilizers."
97
+ )
98
+
99
+ if isinstance(stabilizers, np.ndarray) and stabilizers.dtype.kind in {"U", "S"}:
100
+ self.stabilizer_matrix = pauli_str_to_binary_pcm(stabilizers)
101
+ else:
102
+ if stabilizers.shape[1] % 2 == 0:
103
+ self.stabilizer_matrix = convert_to_binary_scipy_sparse(stabilizers)
104
+ else:
105
+ raise ValueError(
106
+ "The parity check matrix must have an even number of columns."
107
+ )
108
+
109
+ self.physical_qubit_count = self.stabilizer_matrix.shape[1] // 2
110
+
111
+ # Check that stabilizers commute
112
+ if not self.check_stabilizers_commute():
113
+ raise ValueError("The stabilizers do not commute.")
114
+
115
+ @property
116
+ def logical_qubit_count(self):
117
+ if self._logical_qubit_count is None:
118
+ self._logical_qubit_count = self.physical_qubit_count - ldpc.mod2.rank(
119
+ self.stabilizer_matrix, method="dense"
120
+ )
121
+ return self._logical_qubit_count
122
+
123
+ @logical_qubit_count.setter
124
+ def logical_qubit_count(self, value):
125
+ self._logical_qubit_count = value
126
+
127
+ @property
128
+ def pauli_stabilizers(self):
129
+ """
130
+ Get or set the stabilizers in Pauli string format.
131
+
132
+ Returns
133
+ -------
134
+ np.ndarray
135
+ An array of Pauli strings representing the stabilizers.
136
+ """
137
+ return binary_pcm_to_pauli_str(self.stabilizer_matrix)
138
+
139
+ @pauli_stabilizers.setter
140
+ def pauli_stabilizers(self, pauli_stabilizers: np.ndarray):
141
+ """
142
+ Set the stabilizers using Pauli strings.
143
+
144
+ Parameters
145
+ ----------
146
+ pauli_stabilizers : np.ndarray
147
+ An array of Pauli strings representing the stabilizers.
148
+
149
+ Raises
150
+ ------
151
+ AssertionError
152
+ If the newly set stabilizers do not commute.
153
+ """
154
+ self.stabilizer_matrix = pauli_str_to_binary_pcm(pauli_stabilizers)
155
+ if not self.check_stabilizers_commute():
156
+ raise ValueError("The stabilizers do not commute.")
157
+
158
+ def check_stabilizers_commute(self) -> bool:
159
+ """
160
+ Check whether the current set of stabilizers mutually commute.
161
+
162
+ Returns
163
+ -------
164
+ bool
165
+ True if all stabilizers commute, otherwise False.
166
+ """
167
+ return check_binary_pauli_matrices_commute(
168
+ self.stabilizer_matrix, self.stabilizer_matrix
169
+ )
170
+
171
+ def compute_logical_basis(self) -> scipy.sparse.spmatrix:
172
+ """
173
+ Compute a basis for the logical operators of the code by extending the parity check
174
+ matrix. The resulting basis operators are stored in `self.logicals`.
175
+
176
+ Returns
177
+ -------
178
+ scipy.sparse.spmatrix
179
+ A basis for the logical operators in binary representation.
180
+
181
+ Notes
182
+ -----
183
+ This method uses the kernel of the parity check matrix to find operators that
184
+ commute with all stabilizers, and then identifies a subset that spans the space
185
+ of logical operators.
186
+ """
187
+ kernel_h = ldpc.mod2.kernel(self.stabilizer_matrix)
188
+
189
+ # Sort the rows of the kernel by weight
190
+ row_weights = kernel_h.getnnz(axis=1)
191
+ sorted_rows = np.argsort(row_weights)
192
+ kernel_h = kernel_h[sorted_rows, :]
193
+
194
+ swapped_kernel = scipy.sparse.hstack(
195
+ [
196
+ kernel_h[:, self.physical_qubit_count :],
197
+ kernel_h[:, : self.physical_qubit_count],
198
+ ]
199
+ )
200
+
201
+ logical_stack = scipy.sparse.vstack([self.stabilizer_matrix, swapped_kernel])
202
+ p_rows = ldpc.mod2.pivot_rows(logical_stack)
203
+
204
+ self.logical_operator_basis = logical_stack[
205
+ p_rows[self.stabilizer_matrix.shape[0] :]
206
+ ]
207
+
208
+ if self.logical_operator_basis.nnz == 0:
209
+ self.code_distance = np.inf
210
+ return self.logical_operator_basis
211
+
212
+ basis_minimum_hamming_weight = np.min(
213
+ binary_pauli_hamming_weight(self.logical_operator_basis).flatten()
214
+ )
215
+
216
+ # Update distance based on the minimum hamming weight of the logical operators in this basis
217
+ if self.code_distance is None:
218
+ self.code_distance = basis_minimum_hamming_weight
219
+ elif basis_minimum_hamming_weight < self.code_distance:
220
+ self.code_distance = basis_minimum_hamming_weight
221
+ else:
222
+ pass
223
+
224
+ return logical_stack[p_rows[self.stabilizer_matrix.shape[0] :]]
225
+
226
+ @property
227
+ def logical_operator_basis(self):
228
+ if self._logical_operator_basis is None:
229
+ self._logical_operator_basis = self.compute_logical_basis()
230
+
231
+ return self._logical_operator_basis
232
+
233
+ @logical_operator_basis.setter
234
+ def logical_operator_basis(self, basis: scipy.sparse.spmatrix):
235
+ self._logical_operator_basis = basis
236
+
237
+ def check_valid_logical_basis(self) -> bool:
238
+ """
239
+ Validate that the stored logical operators form a proper logical basis for the code.
240
+
241
+ Checks that they commute with the stabilizers, pairwise anti-commute (in the symplectic
242
+ sense), and have full rank.
243
+
244
+ Returns
245
+ -------
246
+ bool
247
+ True if the logical operators form a valid basis, otherwise False.
248
+ """
249
+ try:
250
+ assert check_binary_pauli_matrices_commute(
251
+ self.stabilizer_matrix, self.logical_operator_basis
252
+ ), "Logical operators do not commute with stabilizers."
253
+
254
+ logical_product = symplectic_product(
255
+ self.logical_operator_basis, self.logical_operator_basis
256
+ )
257
+ logical_product.eliminate_zeros()
258
+ assert (
259
+ logical_product.nnz != 0
260
+ ), "The logical operators do not anti-commute with one another."
261
+
262
+ assert (
263
+ ldpc.mod2.rank(self.logical_operator_basis, method="dense")
264
+ == 2 * self.logical_qubit_count
265
+ ), "The logical operators do not form a basis for the code."
266
+
267
+ assert (
268
+ self.logical_operator_basis.shape[0] == 2 * self.logical_qubit_count
269
+ ), "The logical operators are not linearly independent."
270
+
271
+ except AssertionError as e:
272
+ logging.error(e)
273
+ return False
274
+
275
+ return True
276
+
277
+ def compute_exact_code_distance(
278
+ self, timeout: float = 0.5
279
+ ) -> Tuple[Optional[int], float]:
280
+ """
281
+ Compute the distance of the code by searching through linear combinations of
282
+ logical operators and stabilizers, returning a tuple of the minimal Hamming weight
283
+ found and the fraction of logical operators considered before timing out.
284
+
285
+ Parameters
286
+ ----------
287
+ timeout : float, optional
288
+ The time limit (in seconds) for the exhaustive search. Default is 0.5 seconds. To obtain the exact distance, set to `np.inf`.
289
+
290
+ Returns
291
+ -------
292
+ Tuple[Optional[int], float]
293
+ A tuple containing:
294
+ - The best-known distance of the code as an integer (or `None` if no distance was found).
295
+ - The fraction of logical combinations considered before the search ended.
296
+
297
+ Notes
298
+ -----
299
+ - We compute the row span of both the stabilizers and the logical operators.
300
+ - For every logical operator in the logical span, we add (mod 2) each stabilizer
301
+ in the stabilizer span to form candidate logical operators.
302
+ - We compute the Hamming weight of each candidate operator (i.e. how many qubits
303
+ are acted upon by the operator).
304
+ - We track the minimal Hamming weight encountered. If `timeout` is exceeded,
305
+ we immediately return the best distance found so far.
306
+
307
+ Examples
308
+ --------
309
+ >>> code = StabilizerCode(["XZZX", "ZZXX"])
310
+ >>> dist, fraction = code.compute_exact_code_distance(timeout=1.0)
311
+ >>> print(dist, fraction)
312
+ """
313
+ start_time = time.time()
314
+
315
+ stabilizer_span = ldpc.mod2.row_span(self.stabilizer_matrix)[1:]
316
+ logical_span = ldpc.mod2.row_span(self.logical_operator_basis)[1:]
317
+
318
+ if self.code_distance is None:
319
+ distance = np.inf
320
+ else:
321
+ distance = self.code_distance
322
+
323
+ logicals_considered = 0
324
+ total_logical_operators = stabilizer_span.shape[0] * logical_span.shape[0]
325
+
326
+ for logical in logical_span:
327
+ if time.time() - start_time > timeout:
328
+ break
329
+ for stabilizer in stabilizer_span:
330
+ if time.time() - start_time > timeout:
331
+ break
332
+ candidate_logical = logical + stabilizer
333
+ candidate_logical.data %= 2
334
+
335
+ hamming_weight = binary_pauli_hamming_weight(candidate_logical)[0]
336
+ if hamming_weight < distance:
337
+ distance = hamming_weight
338
+ logicals_considered += 1
339
+
340
+ self.code_distance = distance
341
+ fraction_considered = logicals_considered / total_logical_operators
342
+
343
+ return (
344
+ (int(distance), fraction_considered)
345
+ if distance != np.inf
346
+ else (None, fraction_considered)
347
+ )
348
+
349
+ def get_code_parameters(self) -> tuple:
350
+ """
351
+ Return the parameters of the code as a tuple: (n, k, d).
352
+
353
+ Returns
354
+ -------
355
+ tuple
356
+ A tuple of integers representing the number of physical qubits, logical qubits,
357
+ and the distance of the code.
358
+ """
359
+ return self.physical_qubit_count, self.logical_qubit_count, self.code_distance
360
+
361
+ def __repr__(self):
362
+ """
363
+ Return an unambiguous string representation of the StabilizerCode instance.
364
+
365
+ Returns
366
+ -------
367
+ str
368
+ An unambiguous representation for debugging and development.
369
+ """
370
+ return f"Name: {self.name}, Class: Stabilizer Code"
371
+
372
+ def __str__(self):
373
+ """
374
+ Return a string describing the stabilizer code, including its parameters.
375
+
376
+ Returns
377
+ -------
378
+ str
379
+ A human-readable string with the name, n, k, and d parameters of the code.
380
+ """
381
+ return f"< Stabilizer Code, Name: {self.name}, Parameters: [[{self.physical_qubit_count}, {self.logical_qubit_count}, {self.code_distance}]] >"
382
+
383
+ def estimate_min_distance(
384
+ self,
385
+ timeout_seconds: float = 0.25,
386
+ p: float = 0.25,
387
+ reduce_logical_basis: bool = False,
388
+ decoder: Optional[BpOsdDecoder] = None,
389
+ ) -> int:
390
+ """
391
+ Estimate the minimum distance of the stabilizer code using a BP+OSD decoder-based search.
392
+
393
+ Parameters
394
+ ----------
395
+ timeout_seconds : float
396
+ Time limit in seconds for the search. Default: 0.25
397
+ p : float
398
+ Probability for including each logical operator in trial combinations. Default: 0.25
399
+ reduce_logical_basis : bool
400
+ Whether to attempt reducing logical operator basis. Default: False
401
+ decoder : Optional[BpOsdDecoder]
402
+ Pre-configured BP+OSD decoder. If None, initialises with default settings.
403
+
404
+ Returns
405
+ -------
406
+ int
407
+ Best estimate of code distance found within time limit
408
+ """
409
+ if self.logical_operator_basis is None:
410
+ self.logical_operator_basis = self.compute_logical_basis()
411
+
412
+ # Initial setup of decoder and parameters
413
+ bp_osd, stack, full_rank_stabilizer_matrix, min_distance, max_distance = (
414
+ self._setup_distance_estimation_decoder(decoder)
415
+ )
416
+
417
+ # Initialize storage for candidate logicals and tracking
418
+ candidate_logicals = []
419
+ weight_one_syndromes_searched = 0
420
+
421
+ # Main search loop
422
+ start_time = time.time()
423
+ with tqdm(total=timeout_seconds, desc="Estimating distance") as pbar:
424
+ while time.time() - start_time < timeout_seconds:
425
+ # Update progress bar
426
+ elapsed = time.time() - start_time
427
+ pbar.update(elapsed - pbar.n)
428
+
429
+ # Initialize an empty dummy syndrome
430
+ dummy_syndrome = np.zeros(stack.shape[0], dtype=np.uint8)
431
+
432
+ if weight_one_syndromes_searched < self.logical_operator_basis.shape[0]:
433
+ # Try each logical operator individually first
434
+ dummy_syndrome[
435
+ full_rank_stabilizer_matrix.shape[0]
436
+ + weight_one_syndromes_searched
437
+ ] = 1
438
+ weight_one_syndromes_searched += 1
439
+ else:
440
+ # Randomly pick a combination of logical rows
441
+ while True:
442
+ random_mask = np.random.choice(
443
+ [0, 1],
444
+ size=self.logical_operator_basis.shape[0],
445
+ p=[1 - p, p],
446
+ )
447
+ if np.any(random_mask):
448
+ break
449
+ for idx, bit in enumerate(random_mask):
450
+ if bit == 1:
451
+ dummy_syndrome[full_rank_stabilizer_matrix.shape[0] + idx] = 1
452
+
453
+ candidate = bp_osd.decode(dummy_syndrome)
454
+ w = np.count_nonzero(
455
+ candidate[: self.physical_qubit_count]
456
+ | candidate[self.physical_qubit_count :]
457
+ )
458
+
459
+ if w < min_distance:
460
+ min_distance = w
461
+ if w < max_distance and reduce_logical_basis:
462
+ lc = np.hstack(
463
+ [
464
+ candidate[self.physical_qubit_count :],
465
+ candidate[: self.physical_qubit_count],
466
+ ]
467
+ )
468
+ candidate_logicals.append(lc)
469
+
470
+ # Reduce logical operator basis if we have enough candidates
471
+ if (
472
+ len(candidate_logicals) >= self.logical_qubit_count
473
+ and reduce_logical_basis
474
+ ):
475
+ self._reduce_logical_operator_basis(candidate_logicals)
476
+ (
477
+ bp_osd,
478
+ stack,
479
+ full_rank_stabilizer_matrix,
480
+ min_distance,
481
+ max_distance,
482
+ ) = self._setup_distance_estimation_decoder(decoder)
483
+ candidate_logicals = []
484
+ weight_one_syndromes_searched = 0
485
+
486
+ pbar.set_description(
487
+ f"Estimating distance: min-weight found <= {min_distance}, "
488
+ f"basis weights: {self.logical_basis_weights()}"
489
+ )
490
+
491
+ # Final basis reduction if needed
492
+ if reduce_logical_basis and candidate_logicals:
493
+ self._reduce_logical_operator_basis(candidate_logicals)
494
+
495
+ self.code_distance = min_distance
496
+ return min_distance
497
+
498
+ def _setup_distance_estimation_decoder(
499
+ self, decoder: Optional[BpOsdDecoder] = None
500
+ ) -> Tuple[BpOsdDecoder, scipy.sparse.spmatrix, scipy.sparse.spmatrix, int, int]:
501
+ """
502
+ Set up decoder and initial parameters.
503
+
504
+ Parameters
505
+ ----------
506
+ decoder : Optional[BpOsdDecoder]
507
+ Pre-configured decoder. If None, initialises with default settings.
508
+
509
+ Returns
510
+ -------
511
+ Tuple[BpOsdDecoder, scipy.sparse.spmatrix, scipy.sparse.spmatrix, int, int]
512
+ Returns (decoder, stack matrix, full rank stabilizer matrix, min distance, max distance)
513
+ """
514
+ # Remove redundant rows from stabilizer matrix
515
+ p_rows = ldpc.mod2.pivot_rows(self.stabilizer_matrix)
516
+ full_rank_stabilizer_matrix = self.stabilizer_matrix[p_rows]
517
+
518
+ # Build a stacked matrix of stabilizers and logicals
519
+ stack = scipy.sparse.vstack(
520
+ [full_rank_stabilizer_matrix, self.logical_operator_basis]
521
+ ).tocsr()
522
+
523
+ # Initial distance estimate from current logicals
524
+ min_distance = np.min(binary_pauli_hamming_weight(self.logical_operator_basis))
525
+ max_distance = np.max(self.logical_basis_weights())
526
+
527
+ # Set up BP+OSD decoder if not provided
528
+ if decoder is None:
529
+ decoder = BpOsdDecoder(
530
+ stack,
531
+ error_rate=0.1,
532
+ max_iter=10,
533
+ bp_method="ms",
534
+ schedule="parallel",
535
+ ms_scaling_factor=1.0,
536
+ osd_method="osd_0",
537
+ osd_order=0,
538
+ )
539
+
540
+ return decoder, stack, full_rank_stabilizer_matrix, min_distance, max_distance
541
+
542
+ def _reduce_logical_operator_basis(
543
+ self,
544
+ candidate_logicals: Union[Sequence, np.ndarray, scipy.sparse.spmatrix] = [],
545
+ ):
546
+ """
547
+ Reduce the logical operator basis to include lower-weight logicals.
548
+
549
+ Parameters
550
+ ----------
551
+ candidate_logicals : Union[Sequence, np.ndarray, scipy.sparse.spmatrix], optional
552
+ A list or array of candidate logical operators to be considered for reducing the basis.
553
+ Defaults to an empty list.
554
+ """
555
+ if len(candidate_logicals) != 0:
556
+ # Convert candidates to a sparse matrix if they aren't already
557
+ if not isinstance(candidate_logicals, scipy.sparse.spmatrix):
558
+ candidate_logicals = scipy.sparse.csr_matrix(
559
+ scipy.sparse.csr_matrix(candidate_logicals)
560
+ )
561
+
562
+ # Stack the candidate logicals with the existing logicals
563
+ temp1 = scipy.sparse.vstack(
564
+ [candidate_logicals, self.logical_operator_basis]
565
+ ).tocsr()
566
+
567
+ # Compute the Hamming weight over GF4 (number of qubits with non-identity operators)
568
+ # Split into X and Z parts
569
+ row_weights = binary_pauli_hamming_weight(temp1).flatten()
570
+
571
+ # Sort the rows by Hamming weight (ascending)
572
+ sorted_rows = np.argsort(row_weights)
573
+ temp1 = temp1[sorted_rows, :]
574
+
575
+ # Add the stabilizer matrix to the top of the stack
576
+ temp1 = scipy.sparse.vstack([self.stabilizer_matrix, temp1])
577
+
578
+ # Calculate the rank of the stabilizer matrix (todo: find way of removing this step)
579
+ stabilizer_rank = ldpc.mod2.rank(self.stabilizer_matrix)
580
+
581
+ # Perform row reduction to find a new logical basis
582
+ p_rows = ldpc.mod2.pivot_rows(temp1)
583
+ self.logical_operator_basis = temp1[p_rows[stabilizer_rank:]]
584
+
585
+ def logical_basis_weights(self):
586
+ """
587
+ Return the Hamming weights of the logical operators in the current basis.
588
+
589
+ Returns
590
+ -------
591
+ np.ndarray
592
+ An array of integers representing the Hamming weights of the logical operators.
593
+ """
594
+ return binary_pauli_hamming_weight(self.logical_operator_basis).flatten()
595
+
596
+ def _class_specific_save(self):
597
+ class_specific_data = {
598
+ "stabilizers": binary_csr_matrix_to_dict(self.stabilizer_matrix),
599
+ "logical_operator_basis": binary_csr_matrix_to_dict(
600
+ self.logical_operator_basis
601
+ )
602
+ if self.logical_operator_basis is not None
603
+ else "?",
604
+ }
605
+ return class_specific_data
606
+
607
+ def save_code(self, filepath: Union[str, Path], notes: str = "") -> None:
608
+ """
609
+ Save the stabilizer code as a JSON file.
610
+
611
+ Parameters
612
+ ----------
613
+ filepath : Union[str, Path]
614
+ Path where the generated JSON is saved.
615
+ notes : str
616
+ Additional notes to be saved.
617
+ """
618
+
619
+ def repl_function(match):
620
+ return " ".join(match.group().split())
621
+
622
+ filepath = Path(filepath)
623
+ if not filepath.parent.exists():
624
+ filepath.parent.mkdir(parents=True)
625
+
626
+ general_data = {
627
+ "notes": notes,
628
+ "class_name": self.__class__.__name__,
629
+ "name": self.name,
630
+ "physical_qubit_count": self.physical_qubit_count,
631
+ "logical_qubit_count": self.logical_qubit_count,
632
+ "code_distance": int(self.code_distance)
633
+ if hasattr(self, "code_distance") and self.code_distance is not None
634
+ else "?",
635
+ }
636
+
637
+ class_specific_data = self._class_specific_save()
638
+ merged_data = general_data.copy()
639
+
640
+ for key, value in class_specific_data.items():
641
+ if (
642
+ key in merged_data
643
+ and isinstance(merged_data[key], dict)
644
+ and isinstance(value, dict)
645
+ ):
646
+ merged_data[key].update(value)
647
+ else:
648
+ merged_data[key] = value
649
+
650
+ output = json.dumps(merged_data, indent=4)
651
+ formatted_output = re.sub(r"(?<=\[)[^\[\]]+(?=\])", repl_function, output)
652
+
653
+ with open(filepath, "w") as f:
654
+ f.write(formatted_output)