dense-evolution 8.3.0__py3-none-win_amd64.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 (165) hide show
  1. dashboard_core/__init__.py +115 -0
  2. dashboard_core/_gate_tables.py +30 -0
  3. dashboard_core/band_structure.py +71 -0
  4. dashboard_core/circuit_builder_component.py +232 -0
  5. dashboard_core/circuit_diagram.py +216 -0
  6. dashboard_core/crypto_protocols.py +77 -0
  7. dashboard_core/engine.py +326 -0
  8. dashboard_core/graphical_builder.py +114 -0
  9. dashboard_core/hamiltonians.py +593 -0
  10. dashboard_core/mass_decomposition_tool.py +47 -0
  11. dashboard_core/mitigation.py +343 -0
  12. dashboard_core/native_hf_diagnostics.py +62 -0
  13. dashboard_core/noise_tools.py +125 -0
  14. dashboard_core/qasm_library.py +233 -0
  15. dashboard_core/qmmm.py +16 -0
  16. dashboard_core/rag_tool.py +45 -0
  17. dashboard_core/state_visuals.py +288 -0
  18. dashboard_core/system_limits.py +60 -0
  19. dashboard_core/vector_healing.py +102 -0
  20. dashboard_core/visuals.py +158 -0
  21. dashboard_core/vqe.py +533 -0
  22. dashboard_core/wormhole.py +580 -0
  23. dense_evolution/__init__.py +114 -0
  24. dense_evolution/autodiff.py +10 -0
  25. dense_evolution/backends/__init__.py +5 -0
  26. dense_evolution/backends/chunk/__init__.py +37 -0
  27. dense_evolution/backends/chunk/_engine_imports.py +57 -0
  28. dense_evolution/backends/chunk/circuit_chunker.py +55 -0
  29. dense_evolution/backends/chunk/core.py +432 -0
  30. dense_evolution/backends/chunk/disk_overflow.py +232 -0
  31. dense_evolution/backends/chunk/geometry.py +95 -0
  32. dense_evolution/backends/chunk/guard.py +190 -0
  33. dense_evolution/backends/chunk/kernels.py +531 -0
  34. dense_evolution/backends/mps.py +1569 -0
  35. dense_evolution/backends/statevector.py +616 -0
  36. dense_evolution/chunk.py +25 -0
  37. dense_evolution/circuits/__init__.py +20 -0
  38. dense_evolution/circuits/compiler.py +488 -0
  39. dense_evolution/circuits/diagram.py +94 -0
  40. dense_evolution/circuits/gates.py +91 -0
  41. dense_evolution/circuits/parser.py +632 -0
  42. dense_evolution/circuits/qft.py +66 -0
  43. dense_evolution/circuits/random_circuit.py +85 -0
  44. dense_evolution/circuits/registry.py +74 -0
  45. dense_evolution/circuits/topology.py +79 -0
  46. dense_evolution/circuits/trotter.py +265 -0
  47. dense_evolution/circuits/uccsd.py +275 -0
  48. dense_evolution/cli.py +199 -0
  49. dense_evolution/compiler.py +9 -0
  50. dense_evolution/config.py +49 -0
  51. dense_evolution/drawing.py +10 -0
  52. dense_evolution/entropy.py +9 -0
  53. dense_evolution/fermions.py +9 -0
  54. dense_evolution/gates.py +9 -0
  55. dense_evolution/harrison_tb.py +16 -0
  56. dense_evolution/healing.py +18 -0
  57. dense_evolution/interop/__init__.py +18 -0
  58. dense_evolution/interop/qiskit_pennylane.py +406 -0
  59. dense_evolution/measurement.py +10 -0
  60. dense_evolution/mitigation/__init__.py +54 -0
  61. dense_evolution/mitigation/healing.py +215 -0
  62. dense_evolution/mitigation/kl_divergence.py +93 -0
  63. dense_evolution/mitigation/magic_entropy.py +163 -0
  64. dense_evolution/mitigation/magic_entropy_shadows.py +262 -0
  65. dense_evolution/mitigation/renyi.py +168 -0
  66. dense_evolution/mitigation/stabilizer_renyi_entropy.py +103 -0
  67. dense_evolution/mitigation/zne.py +990 -0
  68. dense_evolution/mps.py +9 -0
  69. dense_evolution/native_hf/__init__.py +26 -0
  70. dense_evolution/native_hf/_libcint/LICENSE-libcint +10 -0
  71. dense_evolution/native_hf/_libcint/libdecint.dll +0 -0
  72. dense_evolution/native_hf/assembly.py +304 -0
  73. dense_evolution/native_hf/basis.py +117 -0
  74. dense_evolution/native_hf/boys.py +35 -0
  75. dense_evolution/native_hf/bridge.py +112 -0
  76. dense_evolution/native_hf/cartesian.py +64 -0
  77. dense_evolution/native_hf/coulomb.py +196 -0
  78. dense_evolution/native_hf/differentiable.py +53 -0
  79. dense_evolution/native_hf/gaussians.py +79 -0
  80. dense_evolution/native_hf/kinetic.py +52 -0
  81. dense_evolution/native_hf/libcint_bridge.py +167 -0
  82. dense_evolution/native_hf/overlap.py +91 -0
  83. dense_evolution/native_hf/scf.py +404 -0
  84. dense_evolution/noise/__init__.py +79 -0
  85. dense_evolution/noise/coherent_attack.py +264 -0
  86. dense_evolution/noise/cosmic_ray.py +61 -0
  87. dense_evolution/noise/density_matrix_channels.py +78 -0
  88. dense_evolution/noise/differentiable.py +66 -0
  89. dense_evolution/noise/kraus/__init__.py +6 -0
  90. dense_evolution/noise/kraus/amplitude_damping.py +47 -0
  91. dense_evolution/noise/kraus/bitflip.py +22 -0
  92. dense_evolution/noise/kraus/combined.py +16 -0
  93. dense_evolution/noise/kraus/depolarizing.py +47 -0
  94. dense_evolution/noise/kraus/ideal.py +10 -0
  95. dense_evolution/noise/kraus/phaseflip.py +21 -0
  96. dense_evolution/noise/kraus_channels.py +285 -0
  97. dense_evolution/noise/oscillating.py +32 -0
  98. dense_evolution/noise/pink.py +80 -0
  99. dense_evolution/observables.py +11 -0
  100. dense_evolution/parser.py +9 -0
  101. dense_evolution/physics/__init__.py +27 -0
  102. dense_evolution/physics/entropy.py +161 -0
  103. dense_evolution/physics/fermions.py +322 -0
  104. dense_evolution/physics/observables.py +523 -0
  105. dense_evolution/physics/qec.py +1113 -0
  106. dense_evolution/physics/spectral.py +143 -0
  107. dense_evolution/physics/states.py +43 -0
  108. dense_evolution/protocols/__init__.py +27 -0
  109. dense_evolution/protocols/bb84.py +133 -0
  110. dense_evolution/protocols/di_qkd_ghz.py +199 -0
  111. dense_evolution/protocols/dicka_protocol2.py +124 -0
  112. dense_evolution/qec.py +20 -0
  113. dense_evolution/qft.py +9 -0
  114. dense_evolution/qmmm/__init__.py +13 -0
  115. dense_evolution/qmmm/ase_bridge.py +97 -0
  116. dense_evolution/qmmm/forces.py +388 -0
  117. dense_evolution/qmmm/propagation.py +80 -0
  118. dense_evolution/qmmm/region.py +137 -0
  119. dense_evolution/random_circuit.py +15 -0
  120. dense_evolution/registry.py +9 -0
  121. dense_evolution/simulator.py +10 -0
  122. dense_evolution/solvers/__init__.py +19 -0
  123. dense_evolution/solvers/autodiff.py +169 -0
  124. dense_evolution/solvers/harrison_tb.py +189 -0
  125. dense_evolution/solvers/vhd_tb.py +187 -0
  126. dense_evolution/states.py +9 -0
  127. dense_evolution/topology.py +9 -0
  128. dense_evolution/trotter.py +9 -0
  129. dense_evolution/utils/__init__.py +13 -0
  130. dense_evolution/utils/drawing.py +101 -0
  131. dense_evolution/utils/mass_decomposition.py +246 -0
  132. dense_evolution/utils/measurement.py +94 -0
  133. dense_evolution/vhd_tb.py +16 -0
  134. dense_evolution-8.3.0.dist-info/METADATA +366 -0
  135. dense_evolution-8.3.0.dist-info/RECORD +165 -0
  136. dense_evolution-8.3.0.dist-info/WHEEL +5 -0
  137. dense_evolution-8.3.0.dist-info/entry_points.txt +2 -0
  138. dense_evolution-8.3.0.dist-info/licenses/license.md +58 -0
  139. dense_evolution-8.3.0.dist-info/top_level.txt +5 -0
  140. ia_utils/__init__.py +0 -0
  141. ia_utils/adversarial_vector_attack.py +196 -0
  142. ia_utils/rag.py +288 -0
  143. ia_utils/vector_healing.py +399 -0
  144. local_site/__init__.py +0 -0
  145. local_site/app/__init__.py +0 -0
  146. local_site/app/server.py +1009 -0
  147. mcp_server/__init__.py +0 -0
  148. mcp_server/client.py +324 -0
  149. mcp_server/config.py +32 -0
  150. mcp_server/models.py +347 -0
  151. mcp_server/molecules.py +71 -0
  152. mcp_server/server.py +119 -0
  153. mcp_server/tools/__init__.py +0 -0
  154. mcp_server/tools/chemistry_tools.py +225 -0
  155. mcp_server/tools/circuit_tools.py +83 -0
  156. mcp_server/tools/crypto_tools.py +66 -0
  157. mcp_server/tools/mitigation_tools.py +81 -0
  158. mcp_server/tools/noise_tools.py +60 -0
  159. mcp_server/tools/retrieval_tools.py +44 -0
  160. mcp_server/tools/system_tools.py +149 -0
  161. mcp_server/tools/wormhole_tools.py +142 -0
  162. mcp_server/utils/__init__.py +0 -0
  163. mcp_server/utils/cache.py +55 -0
  164. mcp_server/utils/images.py +67 -0
  165. mcp_server/utils/truncation.py +38 -0
@@ -0,0 +1,1113 @@
1
+ """Stabilizer-code quantum error correction utilities: generic Pauli-string
2
+ commutation/syndrome primitives, an erasure-aware decoder, and a
3
+ minimum-weight-perfect-matching (MWPM) decoder.
4
+
5
+ Promoted from Dense-Evolution-Discovery's Steane [[7,1,3]] code
6
+ investigation (scripts/steane_code_block6_erasure_conversion.py), where a
7
+ Steane-specific version of `erasure_aware_decode` was first built and
8
+ verified: on shots with exactly 2 simultaneous heralded erasures, it
9
+ achieved exactly 0 decoding failures across 94-12,469 such shots at every
10
+ tested physical error rate (0 failures out of >60,000 double-erasure
11
+ shots total, 40,000 trials x 10 p-values), versus ~25% failure for a
12
+ standard syndrome-only decoder blind to the erasure locations -- a clean
13
+ confirmation of the real erasure-correction bound below. This version is
14
+ code-agnostic (works from any stabilizer generator list, not a
15
+ hand-built Steane-specific table), so it moved here instead of staying
16
+ Discovery-repo-specific research code.
17
+
18
+ Erasure-aware decoding exploits a real, foundational fact: Grassl, Beth &
19
+ Pellizzari, "Codes for the quantum erasure channel", Phys. Rev. A 56, 33
20
+ (1997) -- a distance-d stabilizer code can correct up to (d-1) ERASURES
21
+ (known-location errors, e.g. a heralded lost photon in a dual-rail
22
+ photonic qubit), versus only floor((d-1)/2) arbitrary (unlocated)
23
+ errors. Erasure location information is worth roughly twice as much as
24
+ an ordinary syndrome bit, because knowing WHERE the error is removes
25
+ exactly the ambiguity a blind syndrome-only decoder has to guess at.
26
+
27
+ `pymatching_decode` (prog.txt Sezione 4.3) fills the complementary,
28
+ far more common case: a real decoder for when NO erasure locations are
29
+ known at all (the standard setting for e.g. a surface code under generic
30
+ physical noise) -- `erasure_aware_decode`'s brute force (4**k over
31
+ heralded qubits) has no answer when k=0 heralded qubits, by design
32
+ (returns None). Backed by `pymatching` (Apache-2.0, oscarhiggott/PyMatching,
33
+ the standard minimum-weight-perfect-matching decoder for stabilizer
34
+ codes), an optional dependency (`pip install dense-evolution[pymatching]`),
35
+ not a required one -- most callers of this module (erasure-aware
36
+ decoding, raw syndrome computation) never need it. Only works for
37
+ "graph-like" codes (see `pymatching_decode`'s own docstring for the
38
+ real >2-checks-per-qubit constraint) -- NOT Steane and similar small
39
+ non-topological codes.
40
+
41
+ `blind_minimum_weight_decode` (prog.txt Sezione 4.4) covers what neither
42
+ of the above two can: blind (no erasure locations) decoding for codes
43
+ `pymatching_decode` structurally can't handle, like Steane. Pure Python,
44
+ no new dependency -- brute-forces every possible error in increasing
45
+ WEIGHT order (0, 1, 2, ...), same `itertools.product` machinery
46
+ `erasure_aware_decode` uses, just searching over which qubits are
47
+ non-identity too instead of only over Pauli letters on a fixed known
48
+ set. Tried first: reusing `erasure_aware_decode` itself with every
49
+ qubit passed as "heralded" (`heralded_qubits=range(n_qubits)`) -- this
50
+ does NOT work (verified directly on Steane: returns None even for a
51
+ plain single-qubit error), because with no qubits assumed error-free,
52
+ many stabilizer-equivalent full-length errors share the same syndrome,
53
+ so `erasure_aware_decode`'s "exactly one match total" criterion is
54
+ essentially always violated. Minimum-weight selection (return the
55
+ lowest-weight match, not require a totally unique one across every
56
+ possible weight) is what makes blind decoding well-posed at all -- the
57
+ same principle `pymatching_decode`'s MWPM implements via a matching
58
+ graph instead of brute force.
59
+
60
+ `counts_in_intervals_dimension` answers a question upstream of all of
61
+ the above: IS a given stream of error/erasure timestamps actually
62
+ bursty, or Poissonian? It generalizes the "counts-in-spheres" fractal
63
+ dimension estimator used to measure large-scale cosmic homogeneity
64
+ (Scrimgeour et al. 2012, MNRAS 425, 116, arXiv:1205.6812) from 3-D
65
+ space to 1-D time: D~=1 means homogeneous arrivals, D<1 means temporal
66
+ clustering (e.g. cosmic-ray-correlated bursts, arXiv:2104.05219 --
67
+ the same physical noise source `cosmic_ray_burst_profile` in
68
+ `dense_evolution.mitigation.zne` models). Returns the fit's R^2
69
+ alongside D deliberately, never just the number -- a box-counting fit
70
+ through too few points spanning too narrow a range of scales can
71
+ report a fractal dimension as absurd as 142 in 3 dimensions purely
72
+ from fit noise, not from any real structure in the data.
73
+ """
74
+ import itertools
75
+ from collections import deque
76
+ from typing import Optional, Sequence
77
+
78
+ import numpy as np
79
+
80
+ try:
81
+ import pymatching
82
+ HAS_PYMATCHING = True
83
+ except ImportError: # pragma: no cover -- pymatching is always installed in CI (same as stim's equivalent branch in dense_evolution/interop/qiskit_pennylane.py)
84
+ HAS_PYMATCHING = False
85
+
86
+
87
+ def _require_pymatching():
88
+ if not HAS_PYMATCHING:
89
+ raise ImportError(
90
+ "MWPM decoding requires the 'pymatching' package. "
91
+ "Install it with: pip install dense-evolution[pymatching]")
92
+
93
+ _ANTICOMMUTING_PAIRS = {
94
+ frozenset(('X', 'Z')), frozenset(('X', 'Y')), frozenset(('Y', 'Z')),
95
+ }
96
+ PAULIS = ('I', 'X', 'Y', 'Z')
97
+
98
+
99
+ def pauli_commutes(p1: str, p2: str) -> bool:
100
+ """Whether two equal-length Pauli strings (each character in IXYZ, no
101
+ global phase) commute -- the standard symplectic rule: they commute
102
+ iff the number of qubit positions where the local single-qubit
103
+ Paulis anticommute (X/Z, X/Y, or Y/Z, in either order; I commutes
104
+ with everything) is EVEN.
105
+
106
+ >>> pauli_commutes('XX', 'ZZ') # X,Z anticommute at both qubits -> 2 (even) -> commute
107
+ True
108
+ >>> pauli_commutes('XI', 'ZI') # X,Z anticommute at 1 qubit -> 1 (odd) -> anticommute
109
+ False
110
+ """
111
+ if len(p1) != len(p2):
112
+ raise ValueError(f"Pauli strings must be equal length: {len(p1)} != {len(p2)}")
113
+ n_anticommuting = sum(
114
+ 1 for a, b in zip(p1, p2)
115
+ if a != 'I' and b != 'I' and a != b and frozenset((a, b)) in _ANTICOMMUTING_PAIRS
116
+ )
117
+ return n_anticommuting % 2 == 0
118
+
119
+
120
+ def compute_syndrome(pauli_error: str, stabilizers: Sequence[str]) -> tuple:
121
+ """The syndrome (one bit per stabilizer generator, 1 = anticommutes /
122
+ detected, 0 = commutes / undetected) a given Pauli error string would
123
+ produce against `stabilizers` (a list of equal-length Pauli strings,
124
+ the code's stabilizer generators -- X-type, Z-type, or mixed; this
125
+ function doesn't assume a CSS structure)."""
126
+ return tuple(0 if pauli_commutes(pauli_error, g) else 1 for g in stabilizers)
127
+
128
+
129
+ def _pauli_string(n_qubits: int, assignment: dict) -> str:
130
+ chars = ['I'] * n_qubits
131
+ for q, p in assignment.items():
132
+ chars[q] = p
133
+ return ''.join(chars)
134
+
135
+
136
+ _PAULI_SYMPLECTIC = {'I': (0, 0), 'X': (1, 0), 'Z': (0, 1), 'Y': (1, 1)}
137
+
138
+
139
+ def _pauli_to_symplectic(pauli_str: str) -> np.ndarray:
140
+ """Length-n Pauli string -> length-2n GF(2) vector (n X-bits, then n
141
+ Z-bits). The Pauli group modulo global phase is isomorphic to
142
+ GF(2)^2n under this map, with Pauli multiplication (mod phase)
143
+ corresponding to vector XOR -- so two Pauli strings differ by a
144
+ stabilizer element (same coset) iff the XOR of their symplectic
145
+ vectors lies in the GF(2) span of the stabilizers' own symplectic
146
+ vectors."""
147
+ n = len(pauli_str)
148
+ v = np.zeros(2 * n, dtype=np.uint8)
149
+ for i, p in enumerate(pauli_str):
150
+ x, z = _PAULI_SYMPLECTIC[p]
151
+ v[i] = x
152
+ v[n + i] = z
153
+ return v
154
+
155
+
156
+ def _gf2_rref(matrix: np.ndarray):
157
+ """Reduced row-echelon form of a GF(2) matrix, plus the column index
158
+ of each row's pivot. Pure Gaussian elimination mod 2 -- deciding
159
+ stabilizer-group membership needs this, not enumeration of the
160
+ 2**k group elements (k = number of independent generators)."""
161
+ m = matrix.copy() % 2
162
+ rows, cols = m.shape
163
+ pivots = []
164
+ pivot_row = 0
165
+ for col in range(cols):
166
+ if pivot_row >= rows:
167
+ break
168
+ pivot = next((r for r in range(pivot_row, rows) if m[r, col]), None)
169
+ if pivot is None:
170
+ continue
171
+ m[[pivot_row, pivot]] = m[[pivot, pivot_row]]
172
+ for r in range(rows):
173
+ if r != pivot_row and m[r, col]:
174
+ m[r] ^= m[pivot_row]
175
+ pivots.append(col)
176
+ pivot_row += 1
177
+ return m, pivots
178
+
179
+
180
+ def _in_gf2_span(v: np.ndarray, rref: np.ndarray, pivots: list) -> bool:
181
+ """Whether GF(2) vector v lies in the row space of `rref` (already in
182
+ reduced row-echelon form, with `pivots[i]` the pivot column of row
183
+ i) -- reduce v against each pivot row in turn, in the span iff the
184
+ residual is all-zero."""
185
+ v = v.copy() % 2
186
+ for row, col in enumerate(pivots):
187
+ if v[col]:
188
+ v ^= rref[row]
189
+ return not v.any()
190
+
191
+
192
+ def erasure_aware_decode(
193
+ observed_syndrome: tuple,
194
+ heralded_qubits: Sequence[int],
195
+ n_qubits: int,
196
+ stabilizers: Sequence[str],
197
+ ) -> Optional[str]:
198
+ """Erasure-aware decoder for any stabilizer code. Given the observed
199
+ syndrome and a list of qubits KNOWN to have been erased (e.g. a
200
+ heralded photon-loss event on a dual-rail-encoded qubit), brute-forces
201
+ every Pauli assignment (I/X/Y/Z, 4**len(heralded_qubits) combinations)
202
+ on just the heralded qubits and returns the unique full-length Pauli
203
+ string reproducing `observed_syndrome` exactly.
204
+
205
+ Returns `None` -- not a guess -- when there are zero heralded qubits,
206
+ when the observed syndrome is not explained by any assignment on the
207
+ heralded qubits alone, or when more than one assignment explains it
208
+ (ambiguous). Both `None` cases mean: fall back to a standard
209
+ syndrome-only decoder, or treat as a detected-but-uncorrectable
210
+ event -- this function will not silently return a wrong-but-plausible
211
+ correction. The number of heralded qubits this can actually resolve
212
+ unambiguously is bounded by the code's real distance (Grassl, Beth &
213
+ Pellizzari 1997: up to d-1 erasures) -- that bound emerges naturally
214
+ from the brute-force search itself (more heralded qubits than the
215
+ code can resolve typically yields zero or multiple matches), it is
216
+ not hard-coded here.
217
+
218
+ Cost is 4**len(heralded_qubits) syndrome computations, each O(n_qubits
219
+ * len(stabilizers)) -- fine for the small numbers of simultaneous
220
+ erasures a real per-shot noise rate produces (verified up to 2 in the
221
+ original Steane investigation; tractable up to 4-5 for most small
222
+ codes before it's worth switching to a smarter search).
223
+ """
224
+ if not heralded_qubits:
225
+ return None
226
+
227
+ matches = []
228
+ for assignment_paulis in itertools.product(PAULIS, repeat=len(heralded_qubits)):
229
+ assignment = dict(zip(heralded_qubits, assignment_paulis))
230
+ candidate = _pauli_string(n_qubits, assignment)
231
+ if compute_syndrome(candidate, stabilizers) == tuple(observed_syndrome):
232
+ matches.append(candidate)
233
+
234
+ if len(matches) != 1:
235
+ return None
236
+ return matches[0]
237
+
238
+
239
+ def pymatching_decode(
240
+ observed_syndrome: Sequence[int],
241
+ stabilizers: Sequence[str],
242
+ n_qubits: int,
243
+ error_type: str = 'X',
244
+ weights: Optional[Sequence[float]] = None,
245
+ ) -> str:
246
+ """MWPM syndrome decoder (via `pymatching`) for a single physical error
247
+ type, no erasure/heralding information needed -- the standard decoding
248
+ setting `erasure_aware_decode` deliberately doesn't cover (it always
249
+ returns None with zero heralded qubits).
250
+
251
+ Restricted to same-purpose stabilizers detecting ONE error type at a
252
+ time (the standard CSS setup: e.g. Z-type stabilizers decoding X
253
+ errors, or vice versa) -- NOT the fully general mixed-Pauli case
254
+ `compute_syndrome`/`erasure_aware_decode` accept. A code with both X-
255
+ and Z-type errors to correct needs two separate calls, one per type
256
+ (X-type stabilizers -> decode Z errors, Z-type stabilizers -> decode
257
+ X errors), each contributing its own half of the full correction --
258
+ the same two-pass structure any real CSS decoder uses, not a
259
+ limitation specific to this wrapper.
260
+
261
+ FURTHER REAL RESTRICTION (verified directly against pymatching, not
262
+ assumed from its docs): a matching-graph decoder needs every qubit
263
+ checked by AT MOST 2 stabilizers -- pymatching represents each
264
+ potential error as a graph EDGE between (at most) 2 detector nodes, a
265
+ structural fact about topological/surface codes (each qubit sits on
266
+ an edge between exactly 2 checks), not a general property of
267
+ stabilizer codes. Steane [[7,1,3]]'s weight-4 stabilizers are a real
268
+ counterexample -- qubit 6 is checked by all 3 X-stabilizers at once
269
+ (3 > 2), so `pymatching_decode` cannot be used for it at all
270
+ (confirmed: raises ValueError below, not just slow or approximate) --
271
+ use `erasure_aware_decode` for codes like that instead. Repetition
272
+ codes and the surface/toric code family satisfy the <=2 constraint by
273
+ construction; small non-topological codes generally do not.
274
+
275
+ The check matrix pymatching needs is built from `stabilizers` via this
276
+ module's own `pauli_commutes` (column q of row i is 1 iff stabilizer i
277
+ anticommutes with a lone `error_type` error on qubit q) rather than a
278
+ naive "non-identity entry" heuristic -- those disagree whenever a
279
+ stabilizer has the SAME letter as `error_type` at some qubit (e.g. an
280
+ 'X' entry in a stabilizer being used to decode X errors: it commutes
281
+ with an X error there and must NOT count as detecting it, but is very
282
+ much non-identity).
283
+
284
+ Parameters
285
+ ----------
286
+ observed_syndrome : sequence of int
287
+ One bit per stabilizer, same convention as `compute_syndrome`'s
288
+ return value (1 = that stabilizer detected/anticommuted).
289
+ stabilizers : sequence of str
290
+ The stabilizer generators used for this error type (e.g. every
291
+ Z-type generator, to decode X errors), each a length-`n_qubits`
292
+ Pauli string over IXYZ. Mixing generator types that detect
293
+ different error types in the same call silently produces a wrong
294
+ check matrix -- this function has no way to tell that apart from
295
+ a correctly-scoped single-type list, so it isn't validated here.
296
+ n_qubits : int
297
+ Number of physical qubits (columns of the check matrix / length
298
+ of the returned Pauli string).
299
+ error_type : str, optional
300
+ Which single-qubit Pauli error this decodes for -- one of 'X',
301
+ 'Y', 'Z'. Defaults to 'X'.
302
+ weights : sequence of float, optional
303
+ Per-qubit edge weight for MWPM (e.g. -log(p_q) for a known
304
+ per-qubit physical error rate p_q) -- forwarded to
305
+ `pymatching.Matching.from_check_matrix`. Defaults to pymatching's
306
+ own default (uniform weight 1.0 for every qubit, i.e. no prior
307
+ assumption about which qubits are more error-prone).
308
+
309
+ Returns
310
+ -------
311
+ str
312
+ Length-`n_qubits` Pauli string (only 'I' and `error_type`) giving
313
+ the minimum-weight correction pymatching found.
314
+
315
+ Raises
316
+ ------
317
+ ImportError
318
+ If `pymatching` isn't installed (`pip install dense-evolution[pymatching]`).
319
+ ValueError
320
+ If `error_type` isn't one of 'X'/'Y'/'Z', if `observed_syndrome`
321
+ doesn't have one entry per stabilizer, if a stabilizer's length
322
+ isn't `n_qubits`, or if every stabilizer commutes with every
323
+ possible `error_type` error (the check matrix would be all-zero --
324
+ almost always means `stabilizers` is the wrong generator type for
325
+ the requested `error_type`, not a real all-zero code).
326
+
327
+ Examples
328
+ --------
329
+ >>> # 3-qubit repetition code, Z-type stabilizers, decoding X errors
330
+ >>> stabilizers = ['ZZI', 'IZZ']
331
+ >>> syndrome = compute_syndrome('IXI', stabilizers) # X error on qubit 1
332
+ >>> pymatching_decode(syndrome, stabilizers, n_qubits=3, error_type='X')
333
+ 'IXI'
334
+ """
335
+ _require_pymatching()
336
+
337
+ if error_type not in ('X', 'Y', 'Z'):
338
+ raise ValueError(f"error_type must be one of 'X', 'Y', 'Z', got {error_type!r}")
339
+ if len(observed_syndrome) != len(stabilizers):
340
+ raise ValueError(
341
+ f"observed_syndrome has {len(observed_syndrome)} entries but there are "
342
+ f"{len(stabilizers)} stabilizers -- these must match one-to-one"
343
+ )
344
+ for i, s in enumerate(stabilizers):
345
+ if len(s) != n_qubits:
346
+ raise ValueError(f"stabilizers[{i}] has length {len(s)}, expected n_qubits={n_qubits}")
347
+
348
+ check_matrix = np.array(
349
+ [[0 if pauli_commutes(s[q], error_type) else 1 for q in range(n_qubits)] for s in stabilizers],
350
+ dtype=np.uint8,
351
+ )
352
+ if not check_matrix.any():
353
+ raise ValueError(
354
+ f"every stabilizer commutes with every possible {error_type} error -- "
355
+ f"stabilizers is very likely the wrong generator type to decode {error_type} "
356
+ f"errors (e.g. passing X-type stabilizers to decode X errors, which they "
357
+ f"cannot detect by construction)"
358
+ )
359
+ checks_per_qubit = check_matrix.sum(axis=0)
360
+ if (checks_per_qubit > 2).any():
361
+ bad_qubits = np.where(checks_per_qubit > 2)[0].tolist()
362
+ raise ValueError(
363
+ f"pymatching's matching-graph decoder needs every qubit checked by AT MOST 2 "
364
+ f"stabilizers (a graph edge connects at most 2 detector nodes) -- qubit(s) "
365
+ f"{bad_qubits} are each checked by {checks_per_qubit[bad_qubits].tolist()} "
366
+ f"stabilizers here. This is a real structural requirement of matching-graph "
367
+ f"decoding (true for the surface code and other topological codes, where each "
368
+ f"qubit sits on an edge between exactly 2 checks), NOT satisfied by every "
369
+ f"stabilizer code -- e.g. Steane [[7,1,3]]'s weight-4 stabilizers check some "
370
+ f"qubits 3 times, so pymatching_decode cannot be used for it; use "
371
+ f"erasure_aware_decode instead for codes like that."
372
+ )
373
+
374
+ matching = pymatching.Matching.from_check_matrix(check_matrix, weights=weights)
375
+ correction = matching.decode(np.asarray(observed_syndrome, dtype=np.uint8))
376
+
377
+ return ''.join(error_type if bit else 'I' for bit in correction)
378
+
379
+
380
+ def blind_minimum_weight_decode(
381
+ observed_syndrome: Sequence[int],
382
+ n_qubits: int,
383
+ stabilizers: Sequence[str],
384
+ max_weight: Optional[int] = None,
385
+ ) -> Optional[str]:
386
+ """Blind (no known erasure locations) minimum-weight decoder for any
387
+ stabilizer code -- including ones `pymatching_decode` structurally
388
+ cannot handle (Steane and other small non-"graph-like" codes; see
389
+ this module's docstring for why the naive `erasure_aware_decode(
390
+ ..., heralded_qubits=range(n_qubits))` trick does NOT work here).
391
+
392
+ Searches every possible Pauli error in increasing WEIGHT order (0
393
+ non-identity qubits, then 1, then 2, ...), stopping at the first
394
+ weight with at least one match. Multiple matches at that weight are
395
+ NOT automatically ambiguous: in a degenerate code (e.g. Shor
396
+ [[9,1,3]]), two lowest-weight corrections that differ by a
397
+ stabilizer-group element are the SAME physical correction (applying
398
+ either one restores the code space identically), not two competing
399
+ guesses. This checks that directly -- via the corrections' symplectic
400
+ (GF(2)) representation, not by re-deriving it from scratch each call
401
+ -- and returns the (deterministic, first-found) representative when
402
+ every match is in the same stabilizer coset. Returns `None` only when
403
+ two matches at the minimum weight differ by an actual LOGICAL
404
+ operator (not in the stabilizer group), which is genuine ambiguity, or
405
+ when nothing matches by `max_weight`. This IS the same principle
406
+ `pymatching_decode`'s MWPM implements, via brute force instead of a
407
+ matching graph -- deliberately much slower, in exchange for working
408
+ on ANY stabilizer code, graph-like or not.
409
+
410
+ Cost: for a given weight w, `C(n_qubits, w) * 3**w` syndrome checks,
411
+ each O(n_qubits * len(stabilizers)) -- explodes quickly for w beyond
412
+ a handful (e.g. n_qubits=7: 21 at w=1, 189 at w=2, 945 at w=3), so
413
+ this is for the small-code, low-weight-error regime
414
+ `erasure_aware_decode` already targets, not a general substitute for
415
+ `pymatching_decode` at surface-code sizes.
416
+
417
+ Parameters
418
+ ----------
419
+ observed_syndrome : sequence of int
420
+ One bit per stabilizer, same convention as `compute_syndrome`.
421
+ n_qubits : int
422
+ Number of physical qubits.
423
+ stabilizers : sequence of str
424
+ The code's stabilizer generators (any mix of Pauli types --
425
+ unlike `pymatching_decode`, not restricted to one error type per
426
+ call: this searches full IXYZ Pauli strings directly, same as
427
+ `erasure_aware_decode`).
428
+ max_weight : int, optional
429
+ Largest error weight to search before giving up. Defaults to
430
+ `n_qubits` (search everything) -- pass a small explicit bound
431
+ (matching the code's known error-correcting capability, e.g. 1
432
+ for a distance-3 code) to fail fast instead of paying the full
433
+ combinatorial cost on a syndrome nothing low-weight can explain.
434
+
435
+ Returns
436
+ -------
437
+ str or None
438
+ Length-`n_qubits` Pauli string (IXYZ), or `None` if ambiguous at
439
+ the minimum matching weight or unexplained up to `max_weight`.
440
+
441
+ Examples
442
+ --------
443
+ >>> # Steane [[7,1,3]], full X+Z stabilizer set -- exactly the code
444
+ >>> # pymatching_decode CANNOT handle (weight-4 stabilizers check some
445
+ >>> # qubits 3 times), blind single-qubit Z error, no erasure info:
446
+ >>> steane_x = ['IIIXXXX', 'IXXIIXX', 'XIXIXIX']
447
+ >>> steane_z = ['IIIZZZZ', 'IZZIIZZ', 'ZIZIZIZ']
448
+ >>> stabilizers = steane_x + steane_z
449
+ >>> syndrome = compute_syndrome('IIIZIII', stabilizers)
450
+ >>> blind_minimum_weight_decode(syndrome, n_qubits=7, stabilizers=stabilizers)
451
+ 'IIIZIII'
452
+ """
453
+ if max_weight is None:
454
+ max_weight = n_qubits
455
+ if not 0 <= max_weight <= n_qubits:
456
+ raise ValueError(f"max_weight must be between 0 and n_qubits={n_qubits}, got {max_weight}")
457
+ if len(observed_syndrome) != len(stabilizers):
458
+ raise ValueError(
459
+ f"observed_syndrome has {len(observed_syndrome)} entries but there are "
460
+ f"{len(stabilizers)} stabilizers -- these must match one-to-one"
461
+ )
462
+ for i, s in enumerate(stabilizers):
463
+ if len(s) != n_qubits:
464
+ raise ValueError(f"stabilizers[{i}] has length {len(s)}, expected n_qubits={n_qubits}")
465
+
466
+ target = tuple(observed_syndrome)
467
+ stab_rref, stab_pivots = _gf2_rref(
468
+ np.array([_pauli_to_symplectic(s) for s in stabilizers], dtype=np.uint8)
469
+ )
470
+
471
+ for weight in range(max_weight + 1):
472
+ matches = []
473
+ for qubits in itertools.combinations(range(n_qubits), weight):
474
+ for paulis in itertools.product(PAULIS[1:], repeat=weight):
475
+ assignment = dict(zip(qubits, paulis))
476
+ candidate = _pauli_string(n_qubits, assignment)
477
+ if compute_syndrome(candidate, stabilizers) == target:
478
+ matches.append(candidate)
479
+ if not matches:
480
+ continue
481
+ if len(matches) == 1:
482
+ return matches[0]
483
+
484
+ reference = matches[0]
485
+ reference_symplectic = _pauli_to_symplectic(reference)
486
+ same_coset = all(
487
+ _in_gf2_span(reference_symplectic ^ _pauli_to_symplectic(m), stab_rref, stab_pivots)
488
+ for m in matches[1:]
489
+ )
490
+ return reference if same_coset else None
491
+
492
+ return None
493
+
494
+
495
+ def counts_in_intervals_dimension(
496
+ event_times: Sequence[float],
497
+ window_sizes: Sequence[float],
498
+ min_reference_points: int = 5,
499
+ ) -> tuple:
500
+ """Correlation-dimension estimator for a 1-D point process (event
501
+ times), generalizing the "counts-in-spheres" statistic used to
502
+ measure the fractal dimension of galaxy distributions -- Scrimgeour
503
+ et al., "The WiggleZ Dark Energy Survey: the transition to
504
+ large-scale cosmic homogeneity," MNRAS 425, 116 (2012),
505
+ arXiv:1205.6812 -- from 3-D space down to 1-D time.
506
+
507
+ For a homogeneous (Poisson) point process, the expected number of
508
+ OTHER events within radius r of a given event scales as N(<=r) ~ r^1
509
+ exactly. Real burst-like noise (e.g. cosmic-ray-correlated error
510
+ events, arXiv:2104.05219, already modelled by `cosmic_ray_burst_profile`
511
+ in `dense_evolution.mitigation.zne`) clusters in time: events arrive
512
+ in tight groups separated by long, comparatively empty gaps, which
513
+ depresses this exponent below 1. This function measures that exponent
514
+ directly from a sequence of observed/simulated event times (e.g.
515
+ heralded-erasure timestamps, or detected-syndrome timestamps), instead
516
+ of assuming Poissonian noise or a particular burst model up front --
517
+ the measured dimension is then evidence for whether `decode_with_erasure_fallback`
518
+ style single-shot decoding or a burst-aware strategy is the right model
519
+ for a given noise source.
520
+
521
+ For each candidate radius r in `window_sizes`, every event at least r
522
+ away from both ends of the observed time range is used as a reference
523
+ point (events too close to either edge are skipped for that r, so a
524
+ partially-empty window near the boundary never silently deflates the
525
+ count -- the same edge correction real counts-in-spheres analyses
526
+ use). The mean count of other events within r of each valid reference
527
+ point is computed, and the dimension D is the slope of log(mean
528
+ count) vs log(r) over a linear least-squares fit -- with the fit's
529
+ R^2 returned alongside it, not hidden, because a narrow or
530
+ poorly-covered range of `window_sizes` can make the fitted slope
531
+ meaningless (see the warning below).
532
+
533
+ D ~= 1 : homogeneous/Poissonian arrivals, no clustering.
534
+ D < 1 : temporally clustered/bursty (e.g. cosmic-ray-correlated
535
+ error bursts) -- the smaller D, the tighter the clustering.
536
+ D > 1 : more regularly spaced than random (suppressed fluctuations,
537
+ "hyperuniform" arrivals) -- unusual for physical error
538
+ processes but not excluded by the statistic itself.
539
+
540
+ A LOW R^2 (rule of thumb: below ~0.98) means `window_sizes` does not
541
+ span enough dynamic range or lacks enough valid reference points for
542
+ the fit to be trustworthy -- widen the range (ideally 2-3 orders of
543
+ magnitude) and/or supply more events rather than trusting the
544
+ reported D. This mirrors a real, previously-made mistake: a spatial
545
+ box-counting fit through only 4 points spanning a narrow range of
546
+ scales produced a fractal dimension of 142 (physically impossible in
547
+ 3 dimensions) from noise alone, not from any real structure in the
548
+ data -- always inspect R^2 before quoting D.
549
+
550
+ Cost is O(len(window_sizes) * n_events * log(n_events)) -- `event_times`
551
+ is sorted once up front, and each radius's per-reference-point count
552
+ is a pair of `np.searchsorted` calls (vectorized across all reference
553
+ points at once) rather than an O(n_events) brute-force scan per point.
554
+
555
+ Parameters
556
+ ----------
557
+ event_times : sequence of float
558
+ Timestamps of observed/simulated events (need not be sorted).
559
+ window_sizes : sequence of float
560
+ Radii r to evaluate counts at, ideally spanning several orders of
561
+ magnitude and containing at least ~5-8 values.
562
+ min_reference_points : int, optional
563
+ Minimum number of edge-safe reference events required at a given
564
+ r for that r to be included in the fit. Radii with fewer valid
565
+ reference points (or a zero mean count) are silently dropped, not
566
+ zero-padded, so the returned `mean_counts` may be shorter than
567
+ `window_sizes`. Defaults to 5.
568
+
569
+ Returns
570
+ -------
571
+ dimension : float
572
+ Estimated scaling exponent D (the slope of the log-log fit).
573
+ r_squared : float
574
+ Coefficient of determination of the log-log linear fit --
575
+ inspect this before trusting `dimension` (see above).
576
+ mean_counts : dict[float, float]
577
+ Mean count of other events within each usable radius r, keyed by
578
+ the r values from `window_sizes` that had enough valid reference
579
+ points and a nonzero count.
580
+
581
+ Raises
582
+ ------
583
+ ValueError
584
+ If `event_times` has fewer than 2 events, if any `window_sizes`
585
+ entry is not positive, or if fewer than 2 radii end up with
586
+ enough valid reference points and a nonzero count to fit a slope
587
+ at all.
588
+
589
+ Examples
590
+ --------
591
+ >>> import numpy as np
592
+ >>> rng = np.random.default_rng(0)
593
+ >>> poisson_events = np.sort(rng.uniform(0, 1000, 2000))
594
+ >>> radii = np.logspace(0, 2, 10) # 1 to 100
595
+ >>> D, r2, _ = counts_in_intervals_dimension(poisson_events, radii)
596
+ >>> 0.9 < D < 1.1 and r2 > 0.99
597
+ True
598
+ """
599
+ event_times = np.asarray(event_times, dtype=float)
600
+ if event_times.size < 2:
601
+ raise ValueError(f"need at least 2 events, got {event_times.size}")
602
+ event_times = np.sort(event_times)
603
+ t_min, t_max = event_times[0], event_times[-1]
604
+
605
+ mean_counts = {}
606
+ for r in window_sizes:
607
+ if r <= 0:
608
+ raise ValueError(f"window_sizes must be positive, got {r}")
609
+ valid_refs = event_times[(event_times - r >= t_min) & (event_times + r <= t_max)]
610
+ if valid_refs.size < min_reference_points:
611
+ continue
612
+ # event_times is sorted (see above), so "count within r of t" is a
613
+ # pair of binary searches instead of an O(n) scan per reference
614
+ # point (prog.txt point 5c) -- both bounds inclusive, matching the
615
+ # brute-force `abs(event_times - t) <= r` this replaces exactly
616
+ # (verified: side='left'/'right' at t-r/t+r reproduces it for
617
+ # every t, including ties exactly on the r boundary).
618
+ lo = np.searchsorted(event_times, valid_refs - r, side='left')
619
+ hi = np.searchsorted(event_times, valid_refs + r, side='right')
620
+ counts = (hi - lo) - 1
621
+ mean_counts[float(r)] = float(np.mean(counts))
622
+
623
+ valid_items = sorted((r, c) for r, c in mean_counts.items() if c > 0)
624
+ if len(valid_items) < 2:
625
+ raise ValueError(
626
+ f"only {len(valid_items)} of {len(window_sizes)} window_sizes had at least "
627
+ f"{min_reference_points} edge-safe reference points with a nonzero count -- "
628
+ f"widen window_sizes, supply more events, or lower min_reference_points"
629
+ )
630
+
631
+ r_vals = np.array([r for r, _ in valid_items])
632
+ n_vals = np.array([c for _, c in valid_items])
633
+ log_r, log_n = np.log(r_vals), np.log(n_vals)
634
+ design = np.vstack([log_r, np.ones_like(log_r)]).T
635
+ slope, intercept = np.linalg.lstsq(design, log_n, rcond=None)[0]
636
+ predicted = slope * log_r + intercept
637
+ ss_res = np.sum((log_n - predicted) ** 2)
638
+ ss_tot = np.sum((log_n - np.mean(log_n)) ** 2)
639
+ r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else 1.0
640
+
641
+ return float(slope), float(r_squared), dict(valid_items)
642
+
643
+
644
+ def decode_with_erasure_fallback(
645
+ observed_syndrome: Sequence[int],
646
+ heralded_qubits: Sequence[int],
647
+ n_qubits: int,
648
+ stabilizers: Sequence[str],
649
+ max_weight: Optional[int] = None,
650
+ ) -> Optional[str]:
651
+ """The real-world decoding POLICY, not just a raw decoder call: use
652
+ `erasure_aware_decode` when there are any heralded qubits and it
653
+ resolves the syndrome uniquely; fall back to `blind_minimum_weight_decode`
654
+ otherwise (zero heralded qubits, or erasure-aware decoding can't
655
+ resolve it -- ambiguous, or more heralds than the code's real
656
+ erasure-correcting capacity).
657
+
658
+ Promoted from Dense-Evolution-Discovery's cosmic-ray-burst-as-erasure
659
+ experiment (scripts/cosmic_ray_erasure_decoding.py), where this exact
660
+ fallback logic was first written inline in a Monte Carlo loop. Never
661
+ worse than always calling `blind_minimum_weight_decode` directly --
662
+ it only uses herald information when doing so actually helps, exactly
663
+ the policy a real erasure-aware QEC system would run, since a
664
+ real-time detector (e.g. a cosmic-ray/particle-impact monitor, or a
665
+ photon-loss herald) only ever adds information, it doesn't obligate a
666
+ decoder to use it past the point where it stops being useful.
667
+
668
+ Parameters
669
+ ----------
670
+ observed_syndrome : sequence of int
671
+ One bit per stabilizer, same convention as `compute_syndrome`.
672
+ heralded_qubits : sequence of int
673
+ Qubits KNOWN to have been erased/disturbed this shot -- may be
674
+ empty (falls straight through to blind decoding).
675
+ n_qubits : int
676
+ Number of physical qubits.
677
+ stabilizers : sequence of str
678
+ The code's stabilizer generators (any mix of Pauli types).
679
+ max_weight : int, optional
680
+ Forwarded to `blind_minimum_weight_decode`'s fallback path only
681
+ (see its own docstring) -- `erasure_aware_decode` has no
682
+ equivalent parameter, its search is always exactly over the
683
+ heralded qubits.
684
+
685
+ Returns
686
+ -------
687
+ str or None
688
+ Length-`n_qubits` Pauli string (IXYZ), or `None` if neither
689
+ decoder can resolve the syndrome.
690
+ """
691
+ if heralded_qubits:
692
+ result = erasure_aware_decode(observed_syndrome, heralded_qubits, n_qubits, stabilizers)
693
+ if result is not None:
694
+ return result
695
+ return blind_minimum_weight_decode(observed_syndrome, n_qubits, stabilizers, max_weight=max_weight)
696
+
697
+
698
+ def nearest_coset_decode(measured_bits: str, coset_a: Sequence[str], coset_b: Sequence[str]) -> int:
699
+ """Nearest-coset (minimum Hamming distance) binary decoding: given a
700
+ measured bit string, decide which of two disjoint cosets of bit
701
+ strings it is closer to. Returns 0 if `measured_bits` is closer to
702
+ `coset_a`, 1 if closer to `coset_b` (ties broken toward `coset_a`).
703
+
704
+ This decodes a LOGICAL READOUT, not a syndrome -- distinct from
705
+ every other decoder in this module (`pymatching_decode`,
706
+ `blind_minimum_weight_decode`, `erasure_aware_decode`,
707
+ `decode_with_erasure_fallback`), which all turn a syndrome into an
708
+ error correction. For a CSS code whose logical |0>_L is proportional
709
+ to a superposition over one coset C of a classical linear code, and
710
+ |1>_L over the complementary coset C+1...1, a physical measurement in
711
+ the logical basis gives a bit string that exactly matches one
712
+ codeword in the noiseless case, and the NEAREST one under noise --
713
+ exactly the decoding rule Huang, Zhu, Ippoliti, Monroe & Gullans
714
+ (arXiv:2608.20676, "Continuous-angle logical rotations in the Steane
715
+ code") use for their real Steane-code logical Ramsey experiment.
716
+
717
+ Promoted from Dense-Evolution-Discovery's real reproduction of that
718
+ protocol (scripts/steane_continuous_logical_rotation.py) -- there,
719
+ `coset_a`/`coset_b` were the real 8-codeword-each cosets read
720
+ directly off this library's own Steane |0>_L / |1>_L statevectors
721
+ (not assumed from the paper's text), and this decoding rule matched
722
+ the paper's own theoretical logical-rotation model to within Monte
723
+ Carlo statistical noise across 6000 real circuit trials (7 data
724
+ qubits + 3 syndrome-extraction ancillas, real stochastic dephasing,
725
+ real projective measurement collapse).
726
+
727
+ Parameters
728
+ ----------
729
+ measured_bits : str
730
+ The measured bit string, e.g. '0101101'.
731
+ coset_a, coset_b : sequence of str
732
+ Two disjoint sets of equal-length bit strings.
733
+
734
+ Returns
735
+ -------
736
+ int
737
+ 0 or 1, indicating which coset `measured_bits` is nearest to.
738
+
739
+ Examples
740
+ --------
741
+ >>> from dense_evolution.qec import nearest_coset_decode
742
+ >>> coset_a = ['0000000', '1111000']
743
+ >>> coset_b = ['1111111', '0000111']
744
+ >>> nearest_coset_decode('0000000', coset_a, coset_b)
745
+ 0
746
+ >>> nearest_coset_decode('1111110', coset_a, coset_b)
747
+ 1
748
+ """
749
+ def _min_hamming_distance(bits, coset):
750
+ x = int(bits, 2)
751
+ return min(bin(x ^ int(c, 2)).count('1') for c in coset)
752
+
753
+ d_a = _min_hamming_distance(measured_bits, coset_a)
754
+ d_b = _min_hamming_distance(measured_bits, coset_b)
755
+ return 0 if d_a <= d_b else 1
756
+
757
+
758
+ def estimate_edge_probabilities_from_detection_events(check_matrix, events) -> np.ndarray:
759
+ """Error probability of every qubit, from detection events (Spitz et al.).
760
+
761
+ Implements the exact inversion of S. T. Spitz, B. Tarasinski,
762
+ C. W. J. Beenakker and T. E. O'Brien, "Adaptive weight estimator for
763
+ quantum error correction in a time-dependent environment",
764
+ arXiv:1712.02360, Eqs. (13) and (16). For a code in which every qubit is
765
+ checked by at most two checks (repetition and surface codes), each qubit is
766
+ an edge between two checks, or between one check and the boundary. A qubit
767
+ shared by checks ``i`` and ``j`` has probability
768
+
769
+ ``p = 1/2 - sqrt(1/4 - (<v_i v_j> - <v_i><v_j>) / (1 - 2 <v_i xor v_j>))``
770
+
771
+ where ``v`` are the detection events and ``<.>`` the average over cycles. A
772
+ qubit on the boundary of check ``i`` has
773
+
774
+ ``p = 1/2 + (<v_i> - 1/2) / prod(1 - 2 p_ij)`` over the other qubits of ``i``.
775
+
776
+ The result can be passed as ``weights`` (``-log(p / (1 - p))``) to
777
+ `pymatching_decode`.
778
+
779
+ Parameters
780
+ ----------
781
+ check_matrix : array_like of shape (n_checks, n_qubits)
782
+ 0/1 matrix, entry ``[c, q] = 1`` when check ``c`` detects an error on
783
+ qubit ``q``. Every column has one or two ones; no two qubits may join
784
+ the same pair of checks.
785
+ events : array_like of shape (n_cycles, n_checks)
786
+ 0/1 detection events: ``1`` when a check changed value since the
787
+ previous cycle (the syndrome of one cycle's new errors).
788
+
789
+ Returns
790
+ -------
791
+ numpy.ndarray of shape (n_qubits,)
792
+ Estimated error probability of each qubit, clipped to [0, 0.5].
793
+
794
+ Raises
795
+ ------
796
+ ValueError
797
+ If the shapes do not match, ``events`` is not 0/1, a column of
798
+ ``check_matrix`` does not have one or two ones, or two qubits join the
799
+ same pair of checks.
800
+
801
+ Notes
802
+ -----
803
+ Valid for independent errors and one error type at a time. Needs about
804
+ ``1 / p`` cycles per qubit for a stable estimate (the paper's Eq. 18). A
805
+ pair of checks whose correlation is below the statistical noise gives a
806
+ probability near zero.
807
+
808
+ Examples
809
+ --------
810
+ >>> import numpy as np
811
+ >>> from dense_evolution.physics.qec import estimate_edge_probabilities_from_detection_events
812
+ >>> checks = np.array([[1, 0], [1, 1]])
813
+ >>> events = np.array([[1, 1]] * 20 + [[0, 0]] * 80)
814
+ >>> p = estimate_edge_probabilities_from_detection_events(checks, events)
815
+ >>> bool(p[0] < 0.5 and p[1] < 0.5)
816
+ True
817
+ """
818
+ h = np.asarray(check_matrix, dtype=int)
819
+ v = np.asarray(events, dtype=float)
820
+ if h.ndim != 2:
821
+ raise ValueError("check_matrix must be 2-D (n_checks, n_qubits)")
822
+ if v.ndim != 2 or v.shape[1] != h.shape[0]:
823
+ raise ValueError(
824
+ f"events must have shape (n_cycles, {h.shape[0]}), got {v.shape}"
825
+ )
826
+ if v.shape[0] == 0:
827
+ raise ValueError("events needs at least one cycle")
828
+ if not np.isin(v, (0.0, 1.0)).all():
829
+ raise ValueError("events must contain only 0 and 1")
830
+
831
+ n_q = h.shape[1]
832
+ ends = []
833
+ for q in range(n_q):
834
+ rows = np.flatnonzero(h[:, q])
835
+ if len(rows) not in (1, 2):
836
+ raise ValueError(
837
+ f"qubit {q} is checked by {len(rows)} checks; need one or two"
838
+ )
839
+ ends.append(tuple(int(r) for r in rows))
840
+ pairs = [e for e in ends if len(e) == 2]
841
+ if len(set(pairs)) != len(pairs):
842
+ raise ValueError("two qubits join the same pair of checks")
843
+
844
+ mean = v.mean(axis=0)
845
+ p = np.zeros(n_q)
846
+ for q, e in enumerate(ends):
847
+ if len(e) == 2:
848
+ i, j = e
849
+ cov = (v[:, i] * v[:, j]).mean() - mean[i] * mean[j]
850
+ xor = np.abs(v[:, i] - v[:, j]).mean()
851
+ denom = 1.0 - 2.0 * xor
852
+ inside = 0.25 - cov / denom if denom > 0 else 0.25
853
+ p[q] = 0.5 - np.sqrt(max(inside, 0.0))
854
+ for q, e in enumerate(ends):
855
+ if len(e) == 1:
856
+ i = e[0]
857
+ prod = 1.0
858
+ for r, other in enumerate(ends):
859
+ if len(other) == 2 and i in other:
860
+ prod *= 1.0 - 2.0 * p[r]
861
+ if prod <= 0:
862
+ p[q] = 0.5
863
+ else:
864
+ p[q] = 0.5 + (mean[i] - 0.5) / prod
865
+ return np.clip(p, 0.0, 0.5)
866
+
867
+
868
+ def erasure_ml_decode(
869
+ observed_syndrome: tuple,
870
+ heralded_qubits: Sequence[int],
871
+ n_qubits: int,
872
+ stabilizers: Sequence[str],
873
+ ) -> Optional[str]:
874
+ """Maximum-likelihood decoder for erasures at known locations, for any
875
+ stabilizer code.
876
+
877
+ With the erased qubits known, the error is supported on them, and the
878
+ syndrome becomes a linear system over GF(2) in the X and Z bits of those
879
+ qubits (Delfosse & Zemor, arXiv:1703.01517; Kuo & Ouyang,
880
+ arXiv:2411.13509). Solving it by Gaussian elimination costs O(n^3), where
881
+ `erasure_aware_decode` tries 4**m assignments for m erased qubits. Any
882
+ solution is a valid correction when every zero-syndrome operator on the
883
+ erased qubits is a stabilizer element, so degenerate errors (several
884
+ errors that differ by a stabilizer) are decoded too, not rejected.
885
+
886
+ Parameters
887
+ ----------
888
+ observed_syndrome : sequence of int
889
+ One bit per stabilizer, same convention as `compute_syndrome`.
890
+ heralded_qubits : sequence of int
891
+ Indices of the erased qubits.
892
+ n_qubits : int
893
+ Number of physical qubits.
894
+ stabilizers : sequence of str
895
+ Stabilizer generators as Pauli strings of length `n_qubits`.
896
+
897
+ Returns
898
+ -------
899
+ str or None
900
+ A Pauli string supported on the erased qubits that reproduces the
901
+ syndrome and is equivalent, up to a stabilizer, to every other
902
+ solution. `None` when no error on the erased qubits explains the
903
+ syndrome, or when the erased qubits contain a logical operator so the
904
+ correction is ambiguous. With no erased qubits it returns the identity
905
+ for a zero syndrome and `None` otherwise.
906
+
907
+ Raises
908
+ ------
909
+ ValueError
910
+ If the syndrome length does not match the stabilizers, a stabilizer
911
+ has the wrong length, or an erased index is out of range.
912
+
913
+ Examples
914
+ --------
915
+ >>> stabs = ['IIIXXXX', 'IXXIIXX', 'XIXIXIX', 'IIIZZZZ', 'IZZIIZZ', 'ZIZIZIZ']
916
+ >>> syndrome = compute_syndrome('XIIIIIX', stabs)
917
+ >>> erasure_ml_decode(syndrome, [0, 6], 7, stabs)
918
+ 'XIIIIIX'
919
+ """
920
+ stabs = list(stabilizers)
921
+ if len(observed_syndrome) != len(stabs):
922
+ raise ValueError(
923
+ f"observed_syndrome has {len(observed_syndrome)} entries but there are "
924
+ f"{len(stabs)} stabilizers"
925
+ )
926
+ for i, s in enumerate(stabs):
927
+ if len(s) != n_qubits:
928
+ raise ValueError(f"stabilizers[{i}] has length {len(s)}, expected n_qubits={n_qubits}")
929
+ erased = sorted({int(q) for q in heralded_qubits})
930
+ if any(q < 0 or q >= n_qubits for q in erased):
931
+ raise ValueError(f"heralded_qubits must be in range(0, {n_qubits})")
932
+
933
+ syn = np.array(observed_syndrome, dtype=np.uint8) % 2
934
+ k = len(erased)
935
+ if k == 0:
936
+ return 'I' * n_qubits if not syn.any() else None
937
+
938
+ sym = np.array([_pauli_to_symplectic(s) for s in stabs], dtype=np.uint8)
939
+ sx, sz = sym[:, :n_qubits], sym[:, n_qubits:]
940
+ a = np.concatenate([sz[:, erased], sx[:, erased]], axis=1)
941
+ rref, pivots = _gf2_rref(np.concatenate([a, syn[:, None]], axis=1))
942
+ if 2 * k in pivots:
943
+ return None
944
+
945
+ sol = np.zeros(2 * k, dtype=np.uint8)
946
+ for row, col in enumerate(pivots):
947
+ sol[col] = rref[row, 2 * k]
948
+ free = [c for c in range(2 * k) if c not in pivots]
949
+ stab_rref, stab_pivots = _gf2_rref(sym)
950
+
951
+ def embed(v):
952
+ full = np.zeros(2 * n_qubits, dtype=np.uint8)
953
+ for j, q in enumerate(erased):
954
+ full[q] = v[j]
955
+ full[n_qubits + q] = v[k + j]
956
+ return full
957
+
958
+ for f in free:
959
+ vec = np.zeros(2 * k, dtype=np.uint8)
960
+ vec[f] = 1
961
+ for row, col in enumerate(pivots):
962
+ if rref[row, f]:
963
+ vec[col] = 1
964
+ if not _in_gf2_span(embed(vec), stab_rref, stab_pivots):
965
+ return None
966
+
967
+ full = embed(sol)
968
+ letters = {(0, 0): 'I', (1, 0): 'X', (0, 1): 'Z', (1, 1): 'Y'}
969
+ return ''.join(letters[(int(full[q]), int(full[n_qubits + q]))] for q in range(n_qubits))
970
+
971
+
972
+ def _peel(h, erased, syn):
973
+ m = h.shape[0]
974
+ adj = {}
975
+ for q in erased:
976
+ rows = np.flatnonzero(h[:, q])
977
+ u, v = (int(rows[0]), int(rows[1])) if len(rows) == 2 else (int(rows[0]), m)
978
+ adj.setdefault(u, []).append((v, q))
979
+ adj.setdefault(v, []).append((u, q))
980
+ syn = [int(b) for b in syn] + [0]
981
+ seen, order, parent = set(), [], {}
982
+ starts = ([m] if m in adj else []) + [v for v in adj if v != m]
983
+ for s in starts:
984
+ if s in seen:
985
+ continue
986
+ seen.add(s)
987
+ queue = deque([s])
988
+ while queue:
989
+ u = queue.popleft()
990
+ order.append(u)
991
+ for v, q in adj[u]:
992
+ if v not in seen:
993
+ seen.add(v)
994
+ parent[v] = (u, q)
995
+ queue.append(v)
996
+ corr = set()
997
+ for u in reversed(order):
998
+ if u in parent:
999
+ p, q = parent[u]
1000
+ if syn[u]:
1001
+ corr.add(q)
1002
+ syn[p] ^= 1
1003
+ syn[u] = 0
1004
+ elif u != m and syn[u]:
1005
+ return None
1006
+ if any(syn[v] for v in range(m) if v not in seen):
1007
+ return None
1008
+ return corr
1009
+
1010
+
1011
+ def peeling_decode(stabilizers, observed_syndrome, heralded_qubits, n_qubits) -> Optional[str]:
1012
+ """Peeling decoder for a CSS code whose X-type and Z-type checks each join
1013
+ every qubit to at most two checks (repetition and surface codes)."""
1014
+ stabs = list(stabilizers)
1015
+ syn = np.array(observed_syndrome, dtype=np.uint8)
1016
+ erased = sorted({int(q) for q in heralded_qubits})
1017
+ zi = [i for i, s in enumerate(stabs) if 'Z' in s and 'X' not in s]
1018
+ xi = [i for i, s in enumerate(stabs) if 'X' in s and 'Z' not in s]
1019
+ hz = np.array([[c != 'I' for c in stabs[i]] for i in zi], dtype=np.uint8)
1020
+ hx = np.array([[c != 'I' for c in stabs[i]] for i in xi], dtype=np.uint8)
1021
+ x_part = _peel(hz, erased, syn[zi])
1022
+ z_part = _peel(hx, erased, syn[xi])
1023
+ if x_part is None or z_part is None:
1024
+ return None
1025
+ return ''.join('IXZY'[(q in x_part) + 2 * (q in z_part)] for q in range(n_qubits))
1026
+
1027
+
1028
+ def _uf_grow(h, erased, syn):
1029
+ m, n = h.shape
1030
+ ends = []
1031
+ for q in range(n):
1032
+ rows = np.flatnonzero(h[:, q])
1033
+ ends.append((int(rows[0]), int(rows[1])) if len(rows) == 2 else (int(rows[0]), m))
1034
+ parent = list(range(m + 1))
1035
+
1036
+ def find(x):
1037
+ while parent[x] != x:
1038
+ parent[x] = parent[parent[x]]
1039
+ x = parent[x]
1040
+ return x
1041
+
1042
+ support = [0] * n
1043
+ for q in erased:
1044
+ support[q] = 2
1045
+ parent[find(ends[q][0])] = find(ends[q][1])
1046
+ s = [int(b) for b in syn] + [0]
1047
+
1048
+ def odd_roots():
1049
+ par = {}
1050
+ for v in range(m + 1):
1051
+ r = find(v)
1052
+ par[r] = par.get(r, 0) ^ s[v]
1053
+ return {r for r, p in par.items() if p and r != find(m)}
1054
+
1055
+ odd = odd_roots()
1056
+ while odd:
1057
+ grown = False
1058
+ for q in range(n):
1059
+ if support[q] < 2:
1060
+ ru, rv = find(ends[q][0]), find(ends[q][1])
1061
+ inc = (ru in odd) + (rv in odd and rv != ru)
1062
+ if inc:
1063
+ support[q] = min(2, support[q] + inc)
1064
+ grown = True
1065
+ if not grown:
1066
+ return None
1067
+ for q in range(n):
1068
+ if support[q] == 2:
1069
+ parent[find(ends[q][0])] = find(ends[q][1])
1070
+ odd = odd_roots()
1071
+ return [q for q in range(n) if support[q] == 2]
1072
+
1073
+
1074
+ def _css_split(stabilizers, observed_syndrome):
1075
+ stabs = list(stabilizers)
1076
+ syn = np.array(observed_syndrome, dtype=np.uint8)
1077
+ zi = [i for i, s in enumerate(stabs) if 'Z' in s and 'X' not in s]
1078
+ xi = [i for i, s in enumerate(stabs) if 'X' in s and 'Z' not in s]
1079
+ hz = np.array([[c != 'I' for c in stabs[i]] for i in zi], dtype=np.uint8)
1080
+ hx = np.array([[c != 'I' for c in stabs[i]] for i in xi], dtype=np.uint8)
1081
+ return hz, syn[zi], hx, syn[xi]
1082
+
1083
+
1084
+ def union_find_decode(stabilizers, observed_syndrome, heralded_qubits, n_qubits) -> Optional[str]:
1085
+ """Union-Find decoder with erasures and Pauli errors (Delfosse and
1086
+ Nickerson, arXiv:1709.06218): clusters start from the erased qubits and
1087
+ grow by half-edges until every cluster has even syndrome parity or touches
1088
+ the boundary, then each grown cluster is peeled."""
1089
+ hz, sz, hx, sx = _css_split(stabilizers, observed_syndrome)
1090
+ erased = sorted({int(q) for q in heralded_qubits})
1091
+ parts = []
1092
+ for h, s in ((hz, sz), (hx, sx)):
1093
+ grown = _uf_grow(h, erased, s)
1094
+ if grown is None:
1095
+ return None
1096
+ part = _peel(h, grown, s)
1097
+ if part is None:
1098
+ return None
1099
+ parts.append(part)
1100
+ return ''.join('IXZY'[(q in parts[0]) + 2 * (q in parts[1])] for q in range(n_qubits))
1101
+
1102
+
1103
+ def matching_erasure_decode(stabilizers, observed_syndrome, heralded_qubits, n_qubits) -> str:
1104
+ """Minimum-weight perfect matching with weight 0 on the erased qubits
1105
+ (Stace, Barrett and Doherty, arXiv:0904.3556), via pymatching."""
1106
+ import pymatching
1107
+
1108
+ hz, sz, hx, sx = _css_split(stabilizers, observed_syndrome)
1109
+ w = np.ones(n_qubits)
1110
+ w[list(heralded_qubits)] = 0.0
1111
+ xp = pymatching.Matching.from_check_matrix(hz, weights=w).decode(sz)
1112
+ zp = pymatching.Matching.from_check_matrix(hx, weights=w).decode(sx)
1113
+ return ''.join('IXZY'[int(xp[q]) + 2 * int(zp[q])] for q in range(n_qubits))