lcd-qec 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lcd/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Logical Coset Distinguishability (LCD): rigorous logical-error bounds and numerics for the resource theory of
2
+ surface-code noise. Distributed on PyPI as ``lcd-qec``."""
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .crossing import crossing
2
+ from .stratified import Estimate, Strata, StratifiedEstimator
3
+
4
+ __all__ = ["Estimate", "Strata", "StratifiedEstimator", "crossing"]
@@ -0,0 +1,337 @@
1
+ """Burst detection from detection events: counting versus a space-time scan statistic (theory §5.4-5.5).
2
+
3
+ Theorem 5.9 says that, under the marked-Poisson model (M), a test that uses the space-time pattern of the detection
4
+ events has Stein exponent D_pattern = D_count + Lambda_1 D(pi_1 || pi_0) >= D_count, where D_count belongs to the test that
5
+ uses only the total number of events. This module provides the two tests for detector data of a 1-D (repetition) code
6
+ laid out on a (round, position) grid, with a common interface:
7
+
8
+ * ``CountDetector``: z-score of the total number of detection events against the baseline mean and the *empirical*
9
+ variance of the per-shot total (any monotone function of the total, e.g. its Poisson or binomial likelihood ratio
10
+ against an elevated rate, gives the same one-sided test and the same ROC once thresholds are calibrated).
11
+ * ``ScanDetector``: maximum over rectangles (contiguous positions x contiguous rounds, sizes from a bounded list) of the
12
+ Bernoulli log-likelihood ratio "every cell of the rectangle has a common rate rho > baseline" versus the per-cell
13
+ baseline rates p_i (rho at its maximum-likelihood value k / n). With m shots summed into the counts K, the cell counts
14
+ are Binomial(m, p_i) and
15
+
16
+ LLR(A) = k ln(rho) + (n - k) ln(1 - rho) - sum_{i in A} [K_i ln p_i + (m - K_i) ln(1 - p_i)], n = m |A|, rho = k / n,
17
+
18
+ set to 0 unless k exceeds its baseline expectation. All box sums come from 2-D cumulative sums (integral images),
19
+ so one rectangle size costs O(cells) per shot. ``model="poisson"`` gives the Kulldorff Poisson scan
20
+ k ln(k / mu) - (k - mu) with mu = m sum_A p_i instead.
21
+
22
+ Both detectors take an array K of shape (batch, rounds, positions) holding detection events summed over m consecutive
23
+ shots (m = 1: single shots), and optionally the number M of Bernoulli trials per cell; ``counts_and_trials`` builds
24
+ (K, M), optionally after masking leakage-like streaks (``streak_mask``: runs of >= min_run consecutive events of one
25
+ detector, which dominate the tail of the scan statistic on real data). Thresholds are calibrated empirically on clean shots
26
+ (``thresholds``): neither statistic's null distribution is taken from the model, because real detection events are
27
+ correlated (one fault flips two detectors) and the per-shot totals are over-dispersed.
28
+
29
+ ``plant_bursts`` XORs synthetic burst events into real shots; ``marked_poisson_divergences`` and
30
+ ``bernoulli_divergences`` give D_count and D_pattern (Lemma 5.8 / Theorem 5.9, and the independent-Bernoulli analogue)
31
+ for a planted burst at a known location.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import collections
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Sequence
39
+
40
+ import numpy as np
41
+ from scipy.special import xlogy
42
+
43
+ DEFAULT_WIDTHS = (1, 2, 3, 4, 6, 8, 12, 16, 24)
44
+ DEFAULT_HEIGHTS = (1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 51)
45
+
46
+
47
+ # ---------------------------------------------------------------------------------------------------------- data access
48
+ def chain_layout(coords: dict, dem) -> np.ndarray:
49
+ """Detector indices of a 1-D code on a (round, position) grid, positions ordered along the chain.
50
+
51
+ ``coords``: detector -> coordinates (x, y, t) as returned by ``stim.Circuit.get_detector_coordinates``; ``dem``: a
52
+ ``stim.DetectorErrorModel`` whose two-detector mechanisms inside one time slice give the chain's adjacency."""
53
+ times = sorted({float(c[-1]) for c in coords.values()})
54
+ tindex = {t: i for i, t in enumerate(times)}
55
+ sites = sorted({tuple(float(v) for v in c[:-1]) for c in coords.values()})
56
+ adj: dict = collections.defaultdict(set)
57
+ for inst in dem.flattened():
58
+ if inst.type != "error":
59
+ continue
60
+ ds = [t.val for t in inst.targets_copy() if t.is_relative_detector_id()]
61
+ if len(ds) == 2 and coords[ds[0]][-1] == coords[ds[1]][-1]:
62
+ a, b = (tuple(float(v) for v in coords[d][:-1]) for d in ds)
63
+ if a != b:
64
+ adj[a].add(b)
65
+ adj[b].add(a)
66
+ ends = [s for s in sites if len(adj[s]) == 1]
67
+ if len(ends) != 2 or any(len(adj[s]) > 2 for s in sites):
68
+ raise ValueError("the space-like adjacency is not a simple chain")
69
+ order, prev = [min(ends)], None
70
+ while len(order) < len(sites):
71
+ nxt = [s for s in adj[order[-1]] if s != prev]
72
+ prev = order[-1]
73
+ order.append(nxt[0])
74
+ pos = {s: i for i, s in enumerate(order)}
75
+ layout = np.full((len(times), len(order)), -1, dtype=np.int64)
76
+ for d, c in coords.items():
77
+ layout[tindex[float(c[-1])], pos[tuple(float(v) for v in c[:-1])]] = d
78
+ if (layout < 0).any():
79
+ raise ValueError("some (round, position) cells have no detector")
80
+ return layout
81
+
82
+
83
+ class B8Reader:
84
+ """Random access to a stim b8 file of detection events (one byte-aligned, little-endian record per shot)."""
85
+
86
+ def __init__(self, path: str | Path, num_detectors: int):
87
+ self.num_detectors = num_detectors
88
+ self.nbytes = (num_detectors + 7) // 8
89
+ self.raw = np.memmap(path, dtype=np.uint8, mode="r")
90
+ if self.raw.size % self.nbytes:
91
+ raise ValueError("file size is not a multiple of the record size")
92
+ self.shots = self.raw.size // self.nbytes
93
+ self.raw = self.raw.reshape(self.shots, self.nbytes)
94
+
95
+ def events(self, idx) -> np.ndarray:
96
+ """Detection events of the shots ``idx`` (slice or index array) as a uint8 array (shots, num_detectors)."""
97
+ return np.unpackbits(np.asarray(self.raw[idx]), axis=1, bitorder="little")[:, :self.num_detectors]
98
+
99
+ def grid(self, idx, layout: np.ndarray) -> np.ndarray:
100
+ """Detection events of the shots ``idx`` on the (round, position) grid: uint8 array (shots, rounds, positions)."""
101
+ return self.events(idx)[:, layout]
102
+
103
+
104
+ def window_sums(grids: np.ndarray, w: int, starts: Sequence[int] | None = None) -> np.ndarray:
105
+ """Sums of ``w`` consecutive shots: out[j] = grids[starts[j]:starts[j] + w].sum(0) (all starts if None)."""
106
+ g = np.asarray(grids, dtype=np.int32)
107
+ if w == 1 and starts is None:
108
+ return g
109
+ c = np.concatenate([np.zeros((1,) + g.shape[1:], np.int32), np.cumsum(g, axis=0, dtype=np.int32)])
110
+ s = np.arange(len(g) - w + 1) if starts is None else np.asarray(starts)
111
+ return c[s + w] - c[s]
112
+
113
+
114
+ # ------------------------------------------------------------------------------------------------------------- baseline
115
+ def streak_mask(grids: np.ndarray, min_run: int) -> np.ndarray:
116
+ """Cells (shots, rounds, positions) that belong to a run of at least ``min_run`` consecutive detection events of one
117
+ detector in time. Such runs are the signature of leakage (a detector firing in every round), far more frequent in
118
+ real data than independent rates allow; masking them makes a test insensitive to them."""
119
+ x = np.asarray(grids).astype(np.int16)
120
+ T = x.shape[1]
121
+ f = np.zeros_like(x)
122
+ b = np.zeros_like(x)
123
+ f[:, 0] = x[:, 0]
124
+ for t in range(1, T):
125
+ f[:, t] = (f[:, t - 1] + 1) * x[:, t]
126
+ b[:, T - 1] = x[:, T - 1]
127
+ for t in range(T - 2, -1, -1):
128
+ b[:, t] = (b[:, t + 1] + 1) * x[:, t]
129
+ return (f + b - 1 >= min_run).astype(np.uint8)
130
+
131
+
132
+ def counts_and_trials(grids: np.ndarray, min_run: int | None = None, w: int = 1,
133
+ starts: Sequence[int] | None = None) -> tuple[np.ndarray, np.ndarray | None]:
134
+ """(K, M): detection events and Bernoulli trials per cell summed over windows of ``w`` shots, after masking streak
135
+ cells (``streak_mask``) if ``min_run`` is given; M is None (= w for every cell) without masking."""
136
+ g = np.asarray(grids, dtype=np.uint8)
137
+ if min_run is None:
138
+ return window_sums(g, w, starts), None
139
+ keep = 1 - streak_mask(g, min_run)
140
+ return window_sums(g * keep, w, starts), window_sums(keep, w, starts)
141
+
142
+
143
+ @dataclass
144
+ class Baseline:
145
+ """Per-cell detection rates p (rounds, positions) and the mean / variance of the per-shot total, from clean shots.
146
+ With ``min_run`` the rates and totals are those of the streak-masked data and ``count_var`` is the variance of the
147
+ total minus its expectation given the mask."""
148
+ p: np.ndarray
149
+ count_mean: float
150
+ count_var: float
151
+ shots: int
152
+ min_run: int | None = None
153
+
154
+ @classmethod
155
+ def fit(cls, grids: np.ndarray, min_run: int | None = None) -> "Baseline":
156
+ """Estimate from grids (shots, rounds, positions); rates clipped to [1/(2n), 1 - 1/(2n)]."""
157
+ g = np.asarray(grids, dtype=np.uint8)
158
+ n = len(g)
159
+ if n < 2:
160
+ raise ValueError("need at least two shots")
161
+ K, M = counts_and_trials(g, min_run)
162
+ M = np.ones_like(K) if M is None else M
163
+ p = np.clip(K.sum(0) / np.maximum(M.sum(0), 1), 0.5 / n, 1 - 0.5 / n)
164
+ res = K.reshape(n, -1).sum(1) - (M * p).reshape(n, -1).sum(1)
165
+ return cls(p=p, count_mean=float(p.sum()), count_var=float(res.var(ddof=1)), shots=n, min_run=min_run)
166
+
167
+
168
+ # ------------------------------------------------------------------------------------------------------------ detectors
169
+ class CountDetector:
170
+ """z-score of the total number of detection events of m summed shots: (N - E) / sqrt(m var), E = sum_i M_i p_i
171
+ (= m mu without masking)."""
172
+ name = "count"
173
+
174
+ def __init__(self, baseline: Baseline):
175
+ self.p, self.var = baseline.p, baseline.count_var
176
+
177
+ def score(self, K: np.ndarray, M: np.ndarray | None = None, m: int = 1) -> np.ndarray:
178
+ K = np.asarray(K)
179
+ N = K.reshape(len(K), -1).sum(1, dtype=np.int64).astype(np.float64)
180
+ E = m * self.p.sum() if M is None else (np.asarray(M) * self.p).reshape(len(K), -1).sum(1)
181
+ return (N - E) / np.sqrt(m * self.var)
182
+
183
+
184
+ def _integral(x: np.ndarray) -> np.ndarray:
185
+ """Integral image over the last two axes, with a leading row and column of zeros."""
186
+ c = np.cumsum(np.cumsum(x, axis=-2), axis=-1)
187
+ pad = [(0, 0)] * (x.ndim - 2) + [(1, 0), (1, 0)]
188
+ return np.pad(c, pad)
189
+
190
+
191
+ def _box(c: np.ndarray, h: int, w: int) -> np.ndarray:
192
+ """All h x w box sums from an integral image: shape (..., T - h + 1, Q - w + 1)."""
193
+ return c[..., h:, w:] - c[..., :-h, w:] - c[..., h:, :-w] + c[..., :-h, :-w]
194
+
195
+
196
+ class ScanDetector:
197
+ """Space-time scan statistic: max over rectangles (heights x widths) of the elevated-rate log-likelihood ratio.
198
+
199
+ Each cell i carries K_i events in M_i Bernoulli trials (M_i = m without masking). For a rectangle A with
200
+ k = sum K_i, n = sum M_i, rho = k / n:
201
+ bernoulli: LLR = k ln rho + (n - k) ln(1 - rho) - sum_A [K_i ln p_i + (M_i - K_i) ln(1 - p_i)] if k > sum_A M_i p_i
202
+ poisson: LLR = k ln(k / mu) - (k - mu), mu = sum_A M_i p_i if k > mu
203
+ and 0 otherwise."""
204
+ name = "pattern"
205
+
206
+ def __init__(self, baseline: Baseline, widths: Sequence[int] = DEFAULT_WIDTHS,
207
+ heights: Sequence[int] = DEFAULT_HEIGHTS, model: str = "bernoulli"):
208
+ if model not in ("bernoulli", "poisson"):
209
+ raise ValueError("model must be 'bernoulli' or 'poisson'")
210
+ p = np.asarray(baseline.p, dtype=np.float64)
211
+ T, Q = p.shape
212
+ self.p, self.model = p, model
213
+ self.widths = sorted({int(w) for w in widths if 1 <= w <= Q})
214
+ self.heights = sorted({int(h) for h in heights if 1 <= h <= T})
215
+ self.logit = np.log(p) - np.log1p(-p)
216
+ self.log1mp = np.log1p(-p)
217
+
218
+ def regions(self) -> int:
219
+ T, Q = self.p.shape
220
+ return sum((T - h + 1) * (Q - w + 1) for h in self.heights for w in self.widths)
221
+
222
+ def _integrals(self, K: np.ndarray, M: np.ndarray | None, m: int) -> dict:
223
+ K = np.asarray(K, dtype=np.float64)
224
+ Mf = np.full(K.shape[1:], float(m)) if M is None else np.asarray(M, dtype=np.float64)
225
+ return dict(k=_integral(K), ka=_integral(K * self.logit), n=_integral(Mf), nb=_integral(Mf * self.log1mp),
226
+ mu=_integral(Mf * self.p))
227
+
228
+ def llr_boxes(self, K: np.ndarray, h: int, w: int, M: np.ndarray | None = None, m: int = 1,
229
+ C: dict | None = None) -> np.ndarray:
230
+ """LLR of every h x w rectangle: shape (batch, T - h + 1, Q - w + 1)."""
231
+ C = self._integrals(K, M, m) if C is None else C
232
+ k, mu = _box(C["k"], h, w), _box(C["mu"], h, w)
233
+ if self.model == "poisson":
234
+ with np.errstate(divide="ignore", invalid="ignore"):
235
+ llr = xlogy(k, k / mu) - (k - mu)
236
+ return np.where(k > mu, llr, 0.0)
237
+ n = _box(C["n"], h, w)
238
+ with np.errstate(divide="ignore", invalid="ignore"):
239
+ rho = np.where(n > 0, k / np.maximum(n, 1e-300), 0.0)
240
+ alt = xlogy(k, rho) + xlogy(n - k, 1.0 - rho)
241
+ null = _box(C["ka"], h, w) + _box(C["nb"], h, w)
242
+ return np.where(k > mu, alt - null, 0.0)
243
+
244
+ def score(self, K: np.ndarray, M: np.ndarray | None = None, m: int = 1, return_argmax: bool = False,
245
+ batch: int = 256):
246
+ """Scan statistic of each row of K (batch, rounds, positions); M: trials per cell (None: m everywhere).
247
+
248
+ With ``return_argmax`` also returns an int array (batch, 4) of the maximizing (round0, pos0, height, width)."""
249
+ K = np.asarray(K)
250
+ out = np.zeros(len(K))
251
+ arg = np.zeros((len(K), 4), dtype=np.int64)
252
+ Q = self.p.shape[1]
253
+ for s in range(0, len(K), batch):
254
+ Kb = K[s:s + batch]
255
+ C = self._integrals(Kb, None if M is None else M[s:s + batch], m)
256
+ best = np.zeros(len(Kb))
257
+ ab = arg[s:s + batch]
258
+ for h in self.heights:
259
+ for w in self.widths:
260
+ L = self.llr_boxes(Kb, h, w, C=C).reshape(len(Kb), -1)
261
+ j = L.argmax(1)
262
+ v = L[np.arange(len(Kb)), j]
263
+ better = v > best
264
+ best = np.where(better, v, best)
265
+ if return_argmax and better.any():
266
+ q = Q - w + 1
267
+ ab[better] = np.stack([j[better] // q, j[better] % q, np.full(better.sum(), h),
268
+ np.full(better.sum(), w)], 1)
269
+ out[s:s + batch] = best
270
+ return (out, arg) if return_argmax else out
271
+
272
+
273
+ # ---------------------------------------------------------------------------------------------- calibration and planting
274
+ def thresholds(null_scores: np.ndarray, fars: Sequence[float]) -> dict:
275
+ """Empirical thresholds: the smallest t with (#null scores > t) / n <= far, for each false-alarm rate."""
276
+ s = np.sort(np.asarray(null_scores))[::-1]
277
+ out = {}
278
+ for a in fars:
279
+ j = int(np.floor(a * len(s))) # allow j exceedances
280
+ out[float(a)] = float(s[j]) if j < len(s) else float(-np.inf)
281
+ return out
282
+
283
+
284
+ def plant_bursts(grids: np.ndarray, rng: np.random.Generator, R: int, T: int, theta: float,
285
+ corner: tuple[int, int] | None = None) -> tuple[np.ndarray, tuple[int, int]]:
286
+ """XOR a burst into every shot of ``grids`` (shots, rounds, positions): each cell of one R-position x T-round
287
+ rectangle flips independently with probability theta. The rectangle is the same for all shots (a burst lasting
288
+ the whole window); its corner (round0, pos0) is uniformly random unless given. Returns (new grids, corner)."""
289
+ g = np.array(grids, dtype=np.uint8, copy=True)
290
+ nT, nQ = g.shape[1:]
291
+ if corner is None:
292
+ corner = (int(rng.integers(0, nT - T + 1)), int(rng.integers(0, nQ - R + 1)))
293
+ t0, q0 = corner
294
+ flips = rng.random((len(g), T, R)) < theta
295
+ g[:, t0:t0 + T, q0:q0 + R] ^= flips.astype(np.uint8)
296
+ return g, corner
297
+
298
+
299
+ # --------------------------------------------------------------------------------------------------------------- theory
300
+ def planted_rates(p: np.ndarray, R: int, T: int, theta: float, corner: tuple[int, int]) -> np.ndarray:
301
+ """Per-cell rates after XOR-ing Bernoulli(theta) into the rectangle: p + theta (1 - 2p) inside, p outside."""
302
+ p1 = np.array(p, dtype=np.float64, copy=True)
303
+ t0, q0 = corner
304
+ s = p1[t0:t0 + T, q0:q0 + R]
305
+ p1[t0:t0 + T, q0:q0 + R] = s + theta * (1 - 2 * s)
306
+ return p1
307
+
308
+
309
+ def marked_poisson_divergences(lam0: np.ndarray, lam1: np.ndarray) -> dict:
310
+ """Lemma 5.8 / Theorem 5.9: D_pattern, D_count, and the decomposition D_pattern = D_count + Lambda_1 D(pi_1||pi_0)."""
311
+ l0, l1 = np.ravel(lam0).astype(float), np.ravel(lam1).astype(float)
312
+ dp = float(np.sum(xlogy(l1, l1 / l0) - l1 + l0))
313
+ L0, L1 = l0.sum(), l1.sum()
314
+ dc = float(L1 * np.log(L1 / L0) - L1 + L0)
315
+ pi0, pi1 = l0 / L0, l1 / L1
316
+ dpi = float(np.sum(xlogy(pi1, pi1 / pi0)))
317
+ return dict(D_pattern=dp, D_count=dc, Lambda0=float(L0), Lambda1=float(L1), D_pi=dpi,
318
+ identity_residual=float(dp - dc - L1 * dpi), ratio=dp / dc if dc > 0 else float("inf"))
319
+
320
+
321
+ def _poisson_binomial(p: np.ndarray) -> np.ndarray:
322
+ """Distribution of a sum of independent Bernoulli(p_i) (exact, by convolution)."""
323
+ dist = np.ones(1)
324
+ for q in np.ravel(p):
325
+ dist = np.convolve(dist, [1 - q, q])
326
+ return dist
327
+
328
+
329
+ def bernoulli_divergences(p0: np.ndarray, p1: np.ndarray) -> dict:
330
+ """Independent-Bernoulli cells: D_pattern = sum_i KL(Bern(p1_i)||Bern(p0_i)); D_count = KL of the exact
331
+ Poisson-binomial laws of the totals. Per shot, H1 relative to H0."""
332
+ a, b = np.ravel(p0).astype(float), np.ravel(p1).astype(float)
333
+ dp = float(np.sum(xlogy(b, b / a) + xlogy(1 - b, (1 - b) / (1 - a))))
334
+ f0, f1 = _poisson_binomial(a), _poisson_binomial(b)
335
+ mask = f1 > 0
336
+ dc = float(np.sum(f1[mask] * (np.log(f1[mask]) - np.log(np.maximum(f0[mask], 1e-300)))))
337
+ return dict(D_pattern=dp, D_count=dc, ratio=dp / dc if dc > 0 else float("inf"))
@@ -0,0 +1,300 @@
1
+ """Circuit-level Peierls bound for minimum-weight matching on a detector error model (theory, Theorem 4.28).
2
+
3
+ Input: a stim detector error model with one logical observable, decomposed into graphlike components
4
+ (``circuit.detector_error_model(decompose_errors=True)``), and the decoder's edge weights (by default those of
5
+ ``pymatching.Matching.from_detector_error_model``). Output: an upper bound on the logical failure probability of an
6
+ exact minimum-weight decoder with those weights, hence of the maximum-likelihood decoder.
7
+
8
+ The bound (Theorem 4.28):
9
+ * the observable graph is the part of the decoding graph whose components (without the boundary vertex b) carry an
10
+ edge that flips the observable; every mechanism is written as a set of its edges with the same detectors and the same
11
+ observable flip, as one edge whenever such an edge exists ("re-decomposition");
12
+ * if every cycle avoiding b has even observable parity (checked; the graph is "balanced"), the boundary detectors
13
+ split into two sides and the odd cycles are the paths between the sides closed through b;
14
+ * for lam >= 0 an odd simple cycle gamma contributes at most prod_{e in gamma} beta_e(lam), with
15
+ beta_e(lam) = exp(-lam w_e) (1 - q_e + q_e exp(2 lam w_e)) * prod_{split m on e} (1 + p_m^(1/k_m)(exp(2 lam w_e) - 1)),
16
+ q_e the flip probability of edge e from the mechanisms that have no other edge on any odd cycle with e, and the
17
+ remaining (split) mechanisms m with k_m edges shared out by the splitting lemma;
18
+ * pymatching minimizes rounded weights; every beta_e is multiplied by exp(lam delta), delta the rounding slack (4.28(e));
19
+ * summing over non-backtracking walks from one side to the other gives start^T (I - B)^{-1} end, finite when the
20
+ spectral radius of the weighted non-backtracking matrix B is < 1.
21
+
22
+ from lcd.analysis.circuit_peierls import peierls_bound
23
+ res = peierls_bound(circuit.detector_error_model(decompose_errors=True))
24
+ res["bound"], res["lam"], res["rho_upper"]
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import collections
29
+ from dataclasses import dataclass, field
30
+ from math import sqrt
31
+
32
+ import numpy as np
33
+ import scipy.sparse as sp
34
+ import scipy.sparse.linalg as spla
35
+
36
+ Edge = tuple # (u,) for an edge to the boundary vertex, (u, v) with u < v otherwise
37
+
38
+
39
+ @dataclass
40
+ class DemGraph:
41
+ edges: list # edges of the observable graph
42
+ flag: np.ndarray # observable flag of each edge (0/1)
43
+ q: np.ndarray # flip probability of each edge from its exclusive mechanisms
44
+ split: list # per edge: the shares p_m^(1/k_m) of mechanisms that are split
45
+ side: dict # boundary detector -> side 0/1 (odd cycles join the two sides)
46
+ stats: dict = field(default_factory=dict)
47
+
48
+ @property
49
+ def index(self) -> dict:
50
+ return {e: i for i, e in enumerate(self.edges)}
51
+
52
+
53
+ def _parse(dem) -> list:
54
+ """[(p, [(detectors, observable parity), ...]), ...] with one entry per graphlike component."""
55
+ if dem.num_observables != 1:
56
+ raise ValueError("one logical observable expected")
57
+ import stim
58
+ out = []
59
+ for inst in dem.flattened():
60
+ if inst.type != "error":
61
+ continue
62
+ comps, dets, o = [], [], 0
63
+ for t in inst.targets_copy() + [stim.DemTarget.separator()]:
64
+ if t.is_separator():
65
+ comps.append((tuple(sorted(dets)), o))
66
+ dets, o = [], 0
67
+ elif t.is_logical_observable_id():
68
+ o ^= 1
69
+ else:
70
+ dets.append(t.val)
71
+ p = inst.args_copy()[0]
72
+ if not 0 <= p <= 0.5:
73
+ raise ValueError(f"mechanism probability {p} outside [0, 1/2]")
74
+ out.append((p, comps))
75
+ return out
76
+
77
+
78
+ def dem_graph(dem) -> DemGraph:
79
+ """The observable graph of a decomposed detector error model, with re-decomposed mechanisms (Definition 4.27)."""
80
+ mechs = _parse(dem)
81
+ flag: dict = {}
82
+ for _, comps in mechs:
83
+ for e, o in comps:
84
+ if len(e) not in (1, 2):
85
+ raise ValueError(f"component {e} is not graphlike (use decompose_errors=True)")
86
+ if flag.setdefault(e, o) != o:
87
+ raise ValueError(f"edge {e} carries both observable flags")
88
+ parent: dict = {}
89
+
90
+ def find(x):
91
+ parent.setdefault(x, x)
92
+ while parent[x] != x:
93
+ parent[x] = parent[parent[x]]
94
+ x = parent[x]
95
+ return x
96
+
97
+ for e in flag:
98
+ find(e[0])
99
+ if len(e) == 2:
100
+ parent[find(e[0])] = find(e[1])
101
+ roots = {find(e[0]) for e, o in flag.items() if o}
102
+ in_obs = lambda e: find(e[0]) in roots # noqa: E731
103
+ edges = sorted(e for e in flag if in_obs(e))
104
+ eid = {e: i for i, e in enumerate(edges)}
105
+
106
+ # balance and sides
107
+ adj = collections.defaultdict(list)
108
+ for e in edges:
109
+ if len(e) == 2:
110
+ adj[e[0]].append((e[1], flag[e]))
111
+ adj[e[1]].append((e[0], flag[e]))
112
+ phi: dict = {}
113
+ for s in {x for e in edges for x in e}:
114
+ if s in phi:
115
+ continue
116
+ phi[s], stack = 0, [s]
117
+ while stack:
118
+ x = stack.pop()
119
+ for y, o in adj[x]:
120
+ if y not in phi:
121
+ phi[y] = phi[x] ^ o
122
+ stack.append(y)
123
+ elif phi[y] != phi[x] ^ o:
124
+ raise NotImplementedError("an odd cycle avoids the boundary vertex (unbalanced graph)")
125
+ side = {e[0]: flag[e] ^ phi[e[0]] for e in edges if len(e) == 1}
126
+
127
+ # re-decomposition
128
+ lists = collections.defaultdict(list)
129
+ split = [[] for _ in edges]
130
+ n_re = n_excl = n_split = 0
131
+ for p, comps in mechs:
132
+ zc = [(e, o) for e, o in comps if in_obs(e)]
133
+ if not zc:
134
+ continue
135
+ S, L = set(), 0
136
+ for e, o in zc:
137
+ S ^= set(e)
138
+ L ^= o
139
+ key = tuple(sorted(S))
140
+ if not S:
141
+ if L:
142
+ raise ValueError("a mechanism flips the observable without any detector")
143
+ continue
144
+ if key in eid and flag[key] == L:
145
+ lists[eid[key]].append(p)
146
+ n_re += len(zc) > 1
147
+ continue
148
+ if len(zc) == 1:
149
+ lists[eid[zc[0][0]]].append(p)
150
+ continue
151
+ es = [eid[e] for e, _ in zc]
152
+ if all(len(e) == 1 for e, _ in zc) and len({side[e[0]] for e, _ in zc}) == 1:
153
+ for i in es: # boundary edges on one side: never two on an odd simple cycle
154
+ lists[i].append(p)
155
+ n_excl += 1
156
+ else:
157
+ for i in es:
158
+ split[i].append(p ** (1 / len(es)))
159
+ n_split += 1
160
+ q = np.zeros(len(edges))
161
+ for i, ps in lists.items():
162
+ q[i] = 0.5 * (1 - np.prod([1 - 2 * x for x in ps]))
163
+ stats = dict(mechanisms=len(mechs), edges=len(edges), boundary_edges=sum(len(e) == 1 for e in edges),
164
+ redecomposed=n_re, exclusive_multi=n_excl, split_multi=n_split)
165
+ return DemGraph(edges=edges, flag=np.array([flag[e] for e in edges], np.int8), q=q, split=split, side=side,
166
+ stats=stats)
167
+
168
+
169
+ def pymatching_weights(dem, G: DemGraph) -> np.ndarray:
170
+ """Weights of the observable-graph edges in pymatching's matching graph built from the same model."""
171
+ import pymatching
172
+ m = pymatching.Matching.from_detector_error_model(dem)
173
+ eid = G.index
174
+ w = np.full(len(G.edges), np.nan)
175
+ for u, v, attr in m.edges():
176
+ key = (u,) if v is None else tuple(sorted((u, v)))
177
+ if key in eid:
178
+ w[eid[key]] = attr["weight"]
179
+ if np.isnan(w).any() or (w < 0).any():
180
+ raise ValueError("an observable-graph edge has no nonnegative pymatching weight")
181
+ return w
182
+
183
+
184
+ # pymatching 2.x (sparse_blossom/driver/user_graph.h, user_graph.cc) minimizes the integer weights
185
+ # 2 round(kappa w), kappa = (NUM_DISTINCT_WEIGHTS - 1) / max|w| over all edges (kappa = 1 if every weight is an integer).
186
+ PYMATCHING_NUM_DISTINCT_WEIGHTS = 1 << 24
187
+
188
+
189
+ def pymatching_rounding_slack(dem) -> float:
190
+ """delta of Theorem 4.28(e): pymatching minimizes weights within delta per edge of its float weights (up to scale)."""
191
+ import pymatching
192
+ if int(pymatching.__version__.split(".")[0]) != 2:
193
+ raise RuntimeError(f"weight discretization checked for pymatching 2.x only, found {pymatching.__version__}")
194
+ ws = np.array([attr["weight"] for _, _, attr in pymatching.Matching.from_detector_error_model(dem).edges()])
195
+ if ws.size == 0 or np.all(np.round(ws) == ws):
196
+ return 0.0
197
+ return float(np.abs(ws).max()) / (2 * (PYMATCHING_NUM_DISTINCT_WEIGHTS - 1))
198
+
199
+
200
+ def edge_factors(G: DemGraph, w: np.ndarray, lam: float, delta: float = 0.0) -> np.ndarray:
201
+ """beta_e(lam) of Theorem 4.28, times exp(lam delta) for a decoder that minimizes weights within delta of w (4.28(e))."""
202
+ a = np.exp(2 * lam * w)
203
+ beta = np.exp(lam * (delta - w)) * (1 - G.q + G.q * a)
204
+ for i, shares in enumerate(G.split):
205
+ for s in shares:
206
+ beta[i] *= 1 + s * (a[i] - 1)
207
+ return beta
208
+
209
+
210
+ @dataclass
211
+ class _Arcs:
212
+ arc_edge: np.ndarray
213
+ rows: np.ndarray
214
+ cols: np.ndarray
215
+ start_bdry: np.ndarray # boundary edge index of the arc's tail if on side 0, else -1
216
+ end_bdry: np.ndarray # boundary edge index of the arc's head if on side 1, else -1
217
+
218
+
219
+ def _arcs(G: DemGraph) -> _Arcs:
220
+ arcs, arc_edge = [], []
221
+ for i, e in enumerate(G.edges):
222
+ if len(e) == 2:
223
+ arcs += [(e[0], e[1]), (e[1], e[0])]
224
+ arc_edge += [i, i]
225
+ out = collections.defaultdict(list)
226
+ for a, (x, _) in enumerate(arcs):
227
+ out[x].append(a)
228
+ rows, cols = [], []
229
+ for a, (x, y) in enumerate(arcs):
230
+ for b in out[y]:
231
+ if arcs[b][1] != x:
232
+ rows.append(a)
233
+ cols.append(b)
234
+ bedge = {e[0]: i for i, e in enumerate(G.edges) if len(e) == 1}
235
+ sb = np.array([bedge[x] if G.side.get(x) == 0 else -1 for x, _ in arcs], dtype=np.int64)
236
+ eb = np.array([bedge[y] if G.side.get(y) == 1 else -1 for _, y in arcs], dtype=np.int64)
237
+ return _Arcs(np.array(arc_edge, np.int64), np.array(rows, np.int64), np.array(cols, np.int64), sb, eb)
238
+
239
+
240
+ def walk_sum(G: DemGraph, beta: np.ndarray, arcs: _Arcs | None = None) -> tuple[float, float]:
241
+ """(bound, rho_upper): the sum over non-backtracking walks between the sides, closed by their boundary edges.
242
+
243
+ rho_upper = 1 - 1/max z with z = (I - B)^{-1} 1 is a Collatz-Wielandt upper bound on the spectral radius of B; if z
244
+ is not positive the radius is >= 1 and the bound is infinite.
245
+ """
246
+ A = arcs if arcs is not None else _arcs(G)
247
+ n = len(A.arc_edge)
248
+ if n == 0:
249
+ return 0.0, 0.0
250
+ B = sp.csc_matrix((beta[A.arc_edge[A.cols]], (A.rows, A.cols)), shape=(n, n))
251
+ lu = spla.splu((sp.identity(n, format="csc") - B).tocsc())
252
+ z = lu.solve(np.ones(n))
253
+ if not np.all(z > 0):
254
+ return np.inf, np.nan
255
+ rho_upper = 1 - 1 / z.max()
256
+ start = np.where(A.start_bdry >= 0, beta[np.maximum(A.start_bdry, 0)] * beta[A.arc_edge], 0.0)
257
+ end = np.where(A.end_bdry >= 0, beta[np.maximum(A.end_bdry, 0)], 0.0)
258
+ return float(start @ lu.solve(end)), float(rho_upper)
259
+
260
+
261
+ def peierls_bound(dem, weights: np.ndarray | None = None, lams=None, G: DemGraph | None = None,
262
+ delta: float | None = None) -> dict:
263
+ """Minimize the bound of Theorem 4.28 over lam (the bound is log-convex in lam: coarse grid, then golden section).
264
+
265
+ Default: pymatching's weights and its rounding slack delta (Theorem 4.28(e)). With explicit ``weights`` the decoder is
266
+ assumed to minimize exactly those weights (delta = 0) unless ``delta`` is given.
267
+ """
268
+ G = G if G is not None else dem_graph(dem)
269
+ if weights is None:
270
+ w = pymatching_weights(dem, G)
271
+ delta = pymatching_rounding_slack(dem) if delta is None else delta
272
+ else:
273
+ w, delta = weights, (0.0 if delta is None else delta)
274
+ A = _arcs(G)
275
+
276
+ def f(lam):
277
+ return walk_sum(G, edge_factors(G, w, lam, delta), A)
278
+
279
+ grid = list(lams) if lams is not None else [0.1 * k for k in range(1, 10)]
280
+ vals = [f(l)[0] for l in grid]
281
+ k = int(np.argmin(vals))
282
+ if lams is None and np.isfinite(vals[k]):
283
+ lo, hi = grid[max(k - 1, 0)] if k > 0 else 0.01, grid[min(k + 1, len(grid) - 1)] if k < len(grid) - 1 else 0.99
284
+ g = (sqrt(5) - 1) / 2
285
+ x1, x2 = hi - g * (hi - lo), lo + g * (hi - lo)
286
+ f1, f2 = f(x1)[0], f(x2)[0]
287
+ for _ in range(25):
288
+ if f1 <= f2:
289
+ hi, x2, f2 = x2, x1, f1
290
+ x1 = hi - g * (hi - lo)
291
+ f1 = f(x1)[0]
292
+ else:
293
+ lo, x1, f1 = x1, x2, f2
294
+ x2 = lo + g * (hi - lo)
295
+ f2 = f(x2)[0]
296
+ lam = x1 if f1 <= f2 else x2
297
+ else:
298
+ lam = grid[k]
299
+ val, rho = f(lam)
300
+ return dict(bound=val, lam=float(lam), rho_upper=rho, delta=delta, stats=G.stats)