demsym 0.1.0__tar.gz

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.
demsym-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Olliver
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
demsym-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: demsym
3
+ Version: 0.1.0
4
+ Summary: Exact twisted symmetries of stim detector error models, with PyTorch equivariant decoder wrappers
5
+ Project-URL: Homepage, https://github.com/dwatces/equivariant-quantum-ml
6
+ Author-email: Daniel Olliver <dwatces@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: detector-error-model,equivariance,floquet-code,neural-decoder,quantum-error-correction,stim,symmetry
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Physics
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: numpy>=1.24
16
+ Requires-Dist: stim>=1.12
17
+ Provides-Extra: dev
18
+ Requires-Dist: pymatching; extra == 'dev'
19
+ Requires-Dist: pytest; extra == 'dev'
20
+ Requires-Dist: torch>=2.0; extra == 'dev'
21
+ Provides-Extra: torch
22
+ Requires-Dist: torch>=2.0; extra == 'torch'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # demsym
26
+
27
+ **Exact twisted symmetries of stim detector error models — solved, verified,
28
+ and wrapped into PyTorch equivariant decoders.**
29
+
30
+ A symmetry of a quantum error-correcting experiment is not just a permutation
31
+ of detectors. It acts on the *labels* too: logical observables pick up
32
+ syndrome-dependent corrections (the logical representative moves), and on some
33
+ codes the symmetry *mixes the logical qubits*. `demsym` works with the full
34
+ twisted action
35
+
36
+ ```
37
+ syndrome: x -> x_g (x_g[perm[d]] = x[d])
38
+ label: c -> A c ⊕ X·x_g
39
+ ```
40
+
41
+ with `A` invertible over GF(2) and `X` a per-observable syndrome-linear
42
+ correction. Given any stim circuit and a candidate detector permutation, it
43
+ **solves (A, X) directly from the detector error model** and verifies the
44
+ result exactly (multiset equality of all error mechanisms — no sampling, no
45
+ tolerance). A `None` is a real answer: the permutation is *not* a symmetry of
46
+ that DEM.
47
+
48
+ ## Install
49
+
50
+ ```
51
+ pip install demsym # solver only (numpy + stim)
52
+ pip install 'demsym[torch]' # + the PyTorch equivariant wrapper
53
+ ```
54
+
55
+ ## Sixty seconds: the whole story on 20 detectors
56
+
57
+ ```python
58
+ import stim, demsym
59
+
60
+ c = stim.Circuit.generated(
61
+ "repetition_code:memory", distance=5, rounds=4,
62
+ before_round_data_depolarization=0.04,
63
+ before_measure_flip_probability=0.01)
64
+
65
+ dem = demsym.Dem.from_circuit(c)
66
+ perm = demsym.perm_from_coords(c, lambda co: (8.0 - co[0],) + co[1:])
67
+
68
+ elem = demsym.solve_action(dem, perm, name="mirror")
69
+ print(elem) # Element('mirror', nd=20, A=I, |chi|=20)
70
+ ```
71
+
72
+ Three things just happened:
73
+
74
+ 1. **The mirror verifies** — it is an exact symmetry of the circuit-level DEM.
75
+ 2. **The twist is real**: `|chi| != 0`. The logical observable is the last
76
+ data qubit; its mirror image is the first; the difference is a chain of
77
+ checks — a syndrome-linear label correction. Drop it
78
+ (`X = 0`) and verification fails. Naive "invariant" models mis-tie exactly
79
+ here.
80
+ 3. Change the noise to `after_clifford_depolarization=0.01` and
81
+ `solve_action` returns `None` — **circuit-level extraction noise breaks
82
+ the spatial symmetry**. That is not a bug; it is a structural fact about
83
+ schedules, and finding it is the point of the tool.
84
+
85
+ Build the group and an exactly equivariant model:
86
+
87
+ ```python
88
+ group = demsym.close([elem], dem) # verified closure, identity included
89
+ model = demsym.nn.TwistedEquivariant(
90
+ demsym.nn.mlp_backbone(dem.num_detectors, dem.num_observables), group)
91
+ # model(x): (batch, nd) -> (batch, 2^k) logits over joint observable classes
92
+ demsym.nn.equivariance_probe(model, group, sampled_shots) # ~1e-6
93
+ ```
94
+
95
+ ## What this machinery found (the reason it exists)
96
+
97
+ Applied to memory experiments at circuit level (all numbers from logged runs;
98
+ preprint in preparation):
99
+
100
+ - **Surface code: obstruction.** No detector permutation implementing the
101
+ code's spatial symmetries verifies on circuit-level DEMs across 999
102
+ coordinate-map candidates and 1,728 extraction schedules (including
103
+ co-designed ones). Mechanism: hook errors de-symmetrize the schedule; a
104
+ mirror-axis ancilla cannot equal its own east-west swap.
105
+ - **Honeycomb Floquet code: exactness.** With completely naive uniform
106
+ extraction, the circuit-level DEM of a terminated Hastings–Haah honeycomb
107
+ memory carries an **exact p3 space group** (translations + C3, verified on
108
+ all 4,584 mechanism groups at L=6). Trivalence does the work: weight-2
109
+ checks make 1-bit schedules automatically covariant.
110
+ - **C3 mixes the logical qubits**: `A = [[1,1],[1,0]]`, order 3 in GL(2,F2) —
111
+ solved blind from the DEM. The correct decoder is therefore a 4-class
112
+ twisted-equivariant model; scalar per-observable equivariance cannot
113
+ express this.
114
+ - **Equivariance as a learnability switch.** Matched 63k-parameter MLPs on
115
+ the honeycomb task (p=0.002, MWPM joint accuracy 0.947, majority 0.291):
116
+
117
+ | train samples | plain | 9-element twisted tying |
118
+ |---|---|---|
119
+ | 250 | 0.258 | **0.363** |
120
+ | 1,000 | 0.267 | **0.404** |
121
+ | 16,000 | 0.274 | **0.600** |
122
+ | 64,000 | 0.290 | **0.744** |
123
+ | 256,000 | 0.391 | **0.806** |
124
+
125
+ The plain network at 64k samples — and at 22.5× the parameters (1.4M) — sits
126
+ at the majority baseline; the tied model at n=1,000 beats plain at
127
+ n=256,000 (≥256× sample efficiency). We claim **no** superiority over
128
+ matched-graph MWPM anywhere; the tied model remains ~14 pp below it on this
129
+ task.
130
+
131
+ ## Validation
132
+
133
+ The solver is validated against the analytic symmetry action of
134
+ **Egorov, Bondesan & Welling, "The END: An Equivariant Neural Decoder"
135
+ (arXiv:2304.07362)** on their setting (toric code, code capacity): the blind
136
+ DEM solve recovers their action — including the 90°-rotation logical swap —
137
+ **uniquely among all 24 observable permutations**, for every element. That
138
+ bridge ships as a test (`tests/test_toric_end.py`).
139
+
140
+ ## Scope and honest limits
141
+
142
+ - You supply candidate permutations (from detector coordinates via
143
+ `perm_from_coords`, or any construction you trust); demsym decides, exactly.
144
+ Automorphism *discovery* is out of scope for v0.1 — see AutDEC
145
+ (arXiv:2503.01738) for DEM-automorphism search via graph tools.
146
+ - `X` is gauge-fixed only modulo directions invisible to every mechanism:
147
+ equivariance is exact on **realizable** syndromes. Probe with sampled
148
+ shots, not random bit-vectors.
149
+ - Auto-enumeration of `A` candidates covers k ≤ 3 observables (GL(k,F2) =
150
+ 1, 6, 168); beyond that, pass `A_candidates` (e.g. the observable
151
+ permutation matrices, as the toric validation does for k=4).
152
+ - The wrapper costs |G| backbone passes per forward: matched parameters, not
153
+ matched FLOPs.
154
+ - Twisted equivariant decoding itself is prior art (The END, above). What
155
+ demsym adds is the solve-from-DEM route — the one that still exists at
156
+ circuit level, where analytic lattice-path constructions don't apply —
157
+ plus the verified wrapper.
158
+
159
+ ## License
160
+
161
+ MIT. Author: Daniel Olliver (dwatces@gmail.com).
demsym-0.1.0/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # demsym
2
+
3
+ **Exact twisted symmetries of stim detector error models — solved, verified,
4
+ and wrapped into PyTorch equivariant decoders.**
5
+
6
+ A symmetry of a quantum error-correcting experiment is not just a permutation
7
+ of detectors. It acts on the *labels* too: logical observables pick up
8
+ syndrome-dependent corrections (the logical representative moves), and on some
9
+ codes the symmetry *mixes the logical qubits*. `demsym` works with the full
10
+ twisted action
11
+
12
+ ```
13
+ syndrome: x -> x_g (x_g[perm[d]] = x[d])
14
+ label: c -> A c ⊕ X·x_g
15
+ ```
16
+
17
+ with `A` invertible over GF(2) and `X` a per-observable syndrome-linear
18
+ correction. Given any stim circuit and a candidate detector permutation, it
19
+ **solves (A, X) directly from the detector error model** and verifies the
20
+ result exactly (multiset equality of all error mechanisms — no sampling, no
21
+ tolerance). A `None` is a real answer: the permutation is *not* a symmetry of
22
+ that DEM.
23
+
24
+ ## Install
25
+
26
+ ```
27
+ pip install demsym # solver only (numpy + stim)
28
+ pip install 'demsym[torch]' # + the PyTorch equivariant wrapper
29
+ ```
30
+
31
+ ## Sixty seconds: the whole story on 20 detectors
32
+
33
+ ```python
34
+ import stim, demsym
35
+
36
+ c = stim.Circuit.generated(
37
+ "repetition_code:memory", distance=5, rounds=4,
38
+ before_round_data_depolarization=0.04,
39
+ before_measure_flip_probability=0.01)
40
+
41
+ dem = demsym.Dem.from_circuit(c)
42
+ perm = demsym.perm_from_coords(c, lambda co: (8.0 - co[0],) + co[1:])
43
+
44
+ elem = demsym.solve_action(dem, perm, name="mirror")
45
+ print(elem) # Element('mirror', nd=20, A=I, |chi|=20)
46
+ ```
47
+
48
+ Three things just happened:
49
+
50
+ 1. **The mirror verifies** — it is an exact symmetry of the circuit-level DEM.
51
+ 2. **The twist is real**: `|chi| != 0`. The logical observable is the last
52
+ data qubit; its mirror image is the first; the difference is a chain of
53
+ checks — a syndrome-linear label correction. Drop it
54
+ (`X = 0`) and verification fails. Naive "invariant" models mis-tie exactly
55
+ here.
56
+ 3. Change the noise to `after_clifford_depolarization=0.01` and
57
+ `solve_action` returns `None` — **circuit-level extraction noise breaks
58
+ the spatial symmetry**. That is not a bug; it is a structural fact about
59
+ schedules, and finding it is the point of the tool.
60
+
61
+ Build the group and an exactly equivariant model:
62
+
63
+ ```python
64
+ group = demsym.close([elem], dem) # verified closure, identity included
65
+ model = demsym.nn.TwistedEquivariant(
66
+ demsym.nn.mlp_backbone(dem.num_detectors, dem.num_observables), group)
67
+ # model(x): (batch, nd) -> (batch, 2^k) logits over joint observable classes
68
+ demsym.nn.equivariance_probe(model, group, sampled_shots) # ~1e-6
69
+ ```
70
+
71
+ ## What this machinery found (the reason it exists)
72
+
73
+ Applied to memory experiments at circuit level (all numbers from logged runs;
74
+ preprint in preparation):
75
+
76
+ - **Surface code: obstruction.** No detector permutation implementing the
77
+ code's spatial symmetries verifies on circuit-level DEMs across 999
78
+ coordinate-map candidates and 1,728 extraction schedules (including
79
+ co-designed ones). Mechanism: hook errors de-symmetrize the schedule; a
80
+ mirror-axis ancilla cannot equal its own east-west swap.
81
+ - **Honeycomb Floquet code: exactness.** With completely naive uniform
82
+ extraction, the circuit-level DEM of a terminated Hastings–Haah honeycomb
83
+ memory carries an **exact p3 space group** (translations + C3, verified on
84
+ all 4,584 mechanism groups at L=6). Trivalence does the work: weight-2
85
+ checks make 1-bit schedules automatically covariant.
86
+ - **C3 mixes the logical qubits**: `A = [[1,1],[1,0]]`, order 3 in GL(2,F2) —
87
+ solved blind from the DEM. The correct decoder is therefore a 4-class
88
+ twisted-equivariant model; scalar per-observable equivariance cannot
89
+ express this.
90
+ - **Equivariance as a learnability switch.** Matched 63k-parameter MLPs on
91
+ the honeycomb task (p=0.002, MWPM joint accuracy 0.947, majority 0.291):
92
+
93
+ | train samples | plain | 9-element twisted tying |
94
+ |---|---|---|
95
+ | 250 | 0.258 | **0.363** |
96
+ | 1,000 | 0.267 | **0.404** |
97
+ | 16,000 | 0.274 | **0.600** |
98
+ | 64,000 | 0.290 | **0.744** |
99
+ | 256,000 | 0.391 | **0.806** |
100
+
101
+ The plain network at 64k samples — and at 22.5× the parameters (1.4M) — sits
102
+ at the majority baseline; the tied model at n=1,000 beats plain at
103
+ n=256,000 (≥256× sample efficiency). We claim **no** superiority over
104
+ matched-graph MWPM anywhere; the tied model remains ~14 pp below it on this
105
+ task.
106
+
107
+ ## Validation
108
+
109
+ The solver is validated against the analytic symmetry action of
110
+ **Egorov, Bondesan & Welling, "The END: An Equivariant Neural Decoder"
111
+ (arXiv:2304.07362)** on their setting (toric code, code capacity): the blind
112
+ DEM solve recovers their action — including the 90°-rotation logical swap —
113
+ **uniquely among all 24 observable permutations**, for every element. That
114
+ bridge ships as a test (`tests/test_toric_end.py`).
115
+
116
+ ## Scope and honest limits
117
+
118
+ - You supply candidate permutations (from detector coordinates via
119
+ `perm_from_coords`, or any construction you trust); demsym decides, exactly.
120
+ Automorphism *discovery* is out of scope for v0.1 — see AutDEC
121
+ (arXiv:2503.01738) for DEM-automorphism search via graph tools.
122
+ - `X` is gauge-fixed only modulo directions invisible to every mechanism:
123
+ equivariance is exact on **realizable** syndromes. Probe with sampled
124
+ shots, not random bit-vectors.
125
+ - Auto-enumeration of `A` candidates covers k ≤ 3 observables (GL(k,F2) =
126
+ 1, 6, 168); beyond that, pass `A_candidates` (e.g. the observable
127
+ permutation matrices, as the toric validation does for k=4).
128
+ - The wrapper costs |G| backbone passes per forward: matched parameters, not
129
+ matched FLOPs.
130
+ - Twisted equivariant decoding itself is prior art (The END, above). What
131
+ demsym adds is the solve-from-DEM route — the one that still exists at
132
+ circuit level, where analytic lattice-path constructions don't apply —
133
+ plus the verified wrapper.
134
+
135
+ ## License
136
+
137
+ MIT. Author: Daniel Olliver (dwatces@gmail.com).
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "demsym"
7
+ version = "0.1.0"
8
+ description = "Exact twisted symmetries of stim detector error models, with PyTorch equivariant decoder wrappers"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Daniel Olliver", email = "dwatces@gmail.com" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = ["numpy>=1.24", "stim>=1.12"]
15
+ keywords = [
16
+ "quantum-error-correction", "stim", "detector-error-model",
17
+ "equivariance", "neural-decoder", "symmetry", "floquet-code",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Science/Research",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Scientific/Engineering :: Physics",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ torch = ["torch>=2.0"]
28
+ dev = ["pytest", "torch>=2.0", "pymatching"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/dwatces/equivariant-quantum-ml"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/demsym"]
@@ -0,0 +1,32 @@
1
+ """demsym — exact twisted symmetries of stim detector error models.
2
+
3
+ Given a stim circuit (or hand-built noise model): test candidate detector
4
+ permutations for exact twisted symmetry of the DEM, solving the label
5
+ action (A, X) over GF(2) — including actions that MIX logical observables
6
+ (A != I) and syndrome-dependent corrections (X != 0) — and wrap any
7
+ PyTorch backbone into an exactly equivariant decoder.
8
+
9
+ import demsym
10
+ dem = demsym.Dem.from_circuit(circuit)
11
+ perm = demsym.perm_from_coords(circuit, my_coord_map)
12
+ elem = demsym.solve_action(dem, perm, name="C3") # Element or None
13
+ group = demsym.close([elem], dem)
14
+ model = demsym.nn.TwistedEquivariant(backbone, group)
15
+
16
+ `None` from solve_action is a real answer: the permutation is not a
17
+ twisted symmetry of that DEM. See the README for what this machinery found
18
+ on the surface code (obstruction) vs the honeycomb Floquet code (exact
19
+ symmetry, with A mixing the logical qubits).
20
+ """
21
+ from .dem import Dem
22
+ from .element import Element, apply_A, identity
23
+ from .gf2 import gl2_matrices, solve_gf2
24
+ from .perms import perm_from_coords
25
+ from .solve import close, solve_action
26
+
27
+ from . import nn # noqa: E402 (torch imported lazily inside)
28
+
29
+ __version__ = "0.1.0"
30
+ __all__ = ["Dem", "Element", "apply_A", "identity", "gl2_matrices",
31
+ "solve_gf2", "perm_from_coords", "solve_action", "close", "nn",
32
+ "__version__"]
@@ -0,0 +1,78 @@
1
+ """Detector-error-model extraction and grouping.
2
+
3
+ The unit demsym works on is the multiset view of a DEM: error mechanisms
4
+ grouped by their detector set, each group holding the multiset of
5
+ (observable-mask, probability) pairs. Symmetry is then a statement about
6
+ this object — see `solve.solve_action`.
7
+
8
+ Grouping by detector set (rather than pairing individual mechanisms) is
9
+ load-bearing: stim merges error mechanisms that hit identical (detectors,
10
+ observables), so per-mechanism pairing across a candidate symmetry breaks on
11
+ merge collisions while the per-group multiset comparison does not.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from collections import defaultdict
16
+ from dataclasses import dataclass, field
17
+ from typing import Iterable
18
+
19
+
20
+ @dataclass
21
+ class Dem:
22
+ """Grouped detector error model.
23
+
24
+ groups: detector-set -> sorted tuple of (obs_mask, prob) pairs, where
25
+ obs_mask packs observable indices as bits (bit i = observable i flipped).
26
+ """
27
+ num_detectors: int
28
+ num_observables: int
29
+ groups: dict[frozenset[int], tuple[tuple[int, float], ...]] = \
30
+ field(default_factory=dict)
31
+
32
+ @property
33
+ def num_mechanisms(self) -> int:
34
+ return sum(len(g) for g in self.groups.values())
35
+
36
+ @classmethod
37
+ def from_circuit(cls, circuit, prob_decimals: int = 9) -> "Dem":
38
+ """Extract from a stim.Circuit (via its detector error model)."""
39
+ dem = circuit.detector_error_model(flatten_loops=True)
40
+ return cls.from_stim_dem(dem, prob_decimals=prob_decimals)
41
+
42
+ @classmethod
43
+ def from_stim_dem(cls, dem, prob_decimals: int = 9) -> "Dem":
44
+ """Extract from a stim.DetectorErrorModel."""
45
+ mechs = []
46
+ off = 0
47
+ for inst in dem.flattened():
48
+ if inst.type == "shift_detectors":
49
+ t0 = inst.targets_copy()[0]
50
+ off += t0.val if hasattr(t0, "val") else int(t0)
51
+ elif inst.type == "error":
52
+ p = inst.args_copy()[0]
53
+ dets, ob = [], 0
54
+ for t in inst.targets_copy():
55
+ if t.is_relative_detector_id():
56
+ dets.append(t.val + off)
57
+ elif t.is_logical_observable_id():
58
+ ob ^= 1 << t.val
59
+ mechs.append((dets, ob, p))
60
+ return cls.from_mechanisms(mechs, dem.num_detectors,
61
+ dem.num_observables,
62
+ prob_decimals=prob_decimals)
63
+
64
+ @classmethod
65
+ def from_mechanisms(cls, mechanisms: Iterable[tuple], num_detectors: int,
66
+ num_observables: int,
67
+ prob_decimals: int = 9) -> "Dem":
68
+ """Build from explicit (detector_ids, obs_mask, probability) triples.
69
+
70
+ Use this for code-capacity / hand-built noise models that never pass
71
+ through stim.
72
+ """
73
+ by_v = defaultdict(list)
74
+ for dets, ob, p in mechanisms:
75
+ by_v[frozenset(int(d) for d in dets)].append(
76
+ (int(ob), round(float(p), prob_decimals)))
77
+ groups = {v: tuple(sorted(g)) for v, g in by_v.items()}
78
+ return cls(int(num_detectors), int(num_observables), groups)
@@ -0,0 +1,107 @@
1
+ """Twisted symmetry elements of a detector error model.
2
+
3
+ An element acts on (syndrome, label) as
4
+
5
+ x -> x_g with x_g[perm[d]] = x[d]
6
+ c -> A c XOR X x_g
7
+
8
+ where c is the vector of logical-observable bits, A is invertible over
9
+ GF(2), and X (one row per observable) is a syndrome-linear correction.
10
+ The element is a symmetry when this map sends the DEM's mechanism multiset
11
+ to itself exactly (see `Element.verify`).
12
+
13
+ Gauge note: X is determined only modulo directions invisible to every
14
+ mechanism detector set, so two correct X's may differ as vectors while
15
+ acting identically on realizable syndromes. All checks here are therefore
16
+ multiset checks against the DEM, never X-equality.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ import numpy as np
23
+
24
+ from .dem import Dem
25
+
26
+
27
+ def apply_A(Amat: np.ndarray, ob: int) -> int:
28
+ """Apply a GF(2) matrix to a bit-packed observable mask."""
29
+ k = Amat.shape[0]
30
+ o = np.array([(ob >> i) & 1 for i in range(k)], dtype=np.int64)
31
+ o2 = Amat.dot(o) % 2
32
+ return int(sum((int(o2[i]) & 1) << i for i in range(k)))
33
+
34
+
35
+ @dataclass
36
+ class Element:
37
+ """A verified-or-candidate twisted symmetry element."""
38
+ name: str
39
+ perm: np.ndarray # (nd,) int64, detector d -> perm[d]
40
+ A: np.ndarray # (k, k) int64 over GF(2)
41
+ X: np.ndarray # (k, nd) int64 over GF(2)
42
+
43
+ def __post_init__(self):
44
+ self.perm = np.asarray(self.perm, dtype=np.int64)
45
+ self.A = np.asarray(self.A, dtype=np.int64) % 2
46
+ self.X = np.asarray(self.X, dtype=np.int64) % 2
47
+
48
+ @property
49
+ def num_detectors(self) -> int:
50
+ return len(self.perm)
51
+
52
+ @property
53
+ def num_observables(self) -> int:
54
+ return self.A.shape[0]
55
+
56
+ @property
57
+ def is_identity_perm(self) -> bool:
58
+ return bool((self.perm == np.arange(len(self.perm))).all())
59
+
60
+ def chi_weights(self) -> list[int]:
61
+ return [int(r.sum()) for r in self.X]
62
+
63
+ def __matmul__(self, first: "Element") -> "Element":
64
+ """self @ first = apply `first`, then `self` (function composition)."""
65
+ if len(first.perm) != len(self.perm):
66
+ raise ValueError("element sizes differ")
67
+ perm = self.perm[first.perm]
68
+ A = (self.A @ first.A) % 2
69
+ # X of `first` acts on the intermediate syndrome x_1; rewrite it on
70
+ # the final syndrome x_12 (x_1[e] = x_12[self.perm[e]]), then mix
71
+ # its rows through self.A and add self's own correction.
72
+ Y = np.zeros_like(first.X)
73
+ Y[:, self.perm] = first.X
74
+ X = ((self.A @ Y) + self.X) % 2
75
+ return Element(f"{self.name}.{first.name}", perm, A, X)
76
+
77
+ def verify(self, dem: Dem) -> bool:
78
+ """Exact multiset check: does this element map the DEM to itself?"""
79
+ perm = self.perm
80
+ for v, group in dem.groups.items():
81
+ v2 = frozenset(int(perm[d]) for d in v)
82
+ tgt = dem.groups.get(v2)
83
+ if tgt is None:
84
+ return False
85
+ idx = [int(perm[d]) for d in v]
86
+ b = 0
87
+ for i in range(self.num_observables):
88
+ b |= int(self.X[i][idx].sum() % 2) << i
89
+ img = sorted((apply_A(self.A, o) ^ b, p) for o, p in group)
90
+ if tuple(img) != tgt:
91
+ return False
92
+ return True
93
+
94
+ def __repr__(self):
95
+ a = "I" if (self.A == np.eye(self.num_observables,
96
+ dtype=np.int64)).all() \
97
+ else self.A.tolist()
98
+ return (f"Element({self.name!r}, nd={self.num_detectors}, "
99
+ f"A={a}, |chi|={self.chi_weights()})")
100
+
101
+
102
+ def identity(dem: Dem, name: str = "I") -> Element:
103
+ """The identity element for a given DEM."""
104
+ k = dem.num_observables
105
+ return Element(name, np.arange(dem.num_detectors),
106
+ np.eye(k, dtype=np.int64),
107
+ np.zeros((k, dem.num_detectors), dtype=np.int64))
@@ -0,0 +1,75 @@
1
+ """Dense GF(2) linear algebra, sized for detector-error-model problems."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+
7
+ def solve_gf2(A: np.ndarray, b: np.ndarray) -> np.ndarray | None:
8
+ """Solve A x = b over GF(2). Returns one solution, or None if inconsistent.
9
+
10
+ A: (m, n) 0/1 matrix, b: (m,) 0/1 vector. Free variables are set to 0.
11
+ """
12
+ A = (np.asarray(A).copy() % 2).astype(np.int8)
13
+ b = (np.asarray(b).copy() % 2).astype(np.int8)
14
+ m, n = A.shape
15
+ piv, r = [], 0
16
+ for col in range(n):
17
+ if r >= m:
18
+ break
19
+ rows = np.nonzero(A[r:, col])[0]
20
+ if len(rows) == 0:
21
+ continue
22
+ A[[r, r + rows[0]]] = A[[r + rows[0], r]]
23
+ b[[r, r + rows[0]]] = b[[r + rows[0], r]]
24
+ mask = A[:, col].astype(bool)
25
+ mask[r] = False
26
+ A[mask] ^= A[r]
27
+ b[mask] ^= b[r]
28
+ piv.append(col)
29
+ r += 1
30
+ if np.any(b[r:]):
31
+ return None
32
+ x = np.zeros(n, dtype=np.int64)
33
+ for k, col in enumerate(piv):
34
+ x[col] = b[k]
35
+ return x
36
+
37
+
38
+ def gl2_matrices(k: int) -> list[np.ndarray]:
39
+ """All invertible k x k matrices over GF(2). Sizes: 1, 6, 168 for k=1,2,3."""
40
+ if k > 3:
41
+ raise ValueError(
42
+ f"enumerating GL({k},F2) ({_gl_order(k)} matrices) is not done "
43
+ "automatically; pass A_candidates explicitly (e.g. permutation "
44
+ "matrices, or the subgroup your lattice action can produce)")
45
+ out = []
46
+ for bits in range(1 << (k * k)):
47
+ M = np.array([[(bits >> (i * k + j)) & 1 for j in range(k)]
48
+ for i in range(k)], dtype=np.int64)
49
+ if _gf2_det(M):
50
+ out.append(M)
51
+ # identity first: it is the most common answer and exits the search early
52
+ out.sort(key=lambda M: 0 if (M == np.eye(k, dtype=np.int64)).all() else 1)
53
+ return out
54
+
55
+
56
+ def _gf2_det(M: np.ndarray) -> int:
57
+ M = M.copy() % 2
58
+ n = M.shape[0]
59
+ for col in range(n):
60
+ rows = np.nonzero(M[col:, col])[0]
61
+ if len(rows) == 0:
62
+ return 0
63
+ r = col + rows[0]
64
+ M[[col, r]] = M[[r, col]]
65
+ mask = M[:, col].astype(bool)
66
+ mask[col] = False
67
+ M[mask] ^= M[col]
68
+ return 1
69
+
70
+
71
+ def _gl_order(k: int) -> int:
72
+ n = 1
73
+ for i in range(k):
74
+ n *= (1 << k) - (1 << i)
75
+ return n
@@ -0,0 +1,125 @@
1
+ """PyTorch twisted-equivariant wrapper (requires the `torch` extra).
2
+
3
+ The model averages a backbone over the group with the label twist applied:
4
+
5
+ f(x)[c] = (1/|G|) sum_g backbone(x_g)[ A_g c XOR X_g x_g ]
6
+
7
+ where x_g[perm_g[d]] = x[d]. With elements verified against the DEM, f is
8
+ exactly equivariant on realizable syndromes (X is gauge-fixed only on
9
+ those — probe with sampled shots, not random bit vectors).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+ from .element import Element, apply_A
16
+
17
+
18
+ def _torch():
19
+ try:
20
+ import torch
21
+ return torch
22
+ except ImportError as e: # pragma: no cover
23
+ raise ImportError("demsym.nn requires torch: pip install "
24
+ "'demsym[torch]'") from e
25
+
26
+
27
+ def TwistedEquivariant(backbone, elements: list[Element]):
28
+ """Wrap a logits backbone into a twisted-equivariant model.
29
+
30
+ backbone: torch.nn.Module mapping (batch, num_detectors) floats to
31
+ (batch, 2**k) logits, where k = num_observables. Class c is the
32
+ bit-packed joint observable value (bit i = observable i).
33
+ elements: the FULL group including the identity — pass the output of
34
+ demsym.close(...).
35
+ """
36
+ torch = _torch()
37
+ nn = torch.nn
38
+
39
+ if not elements:
40
+ raise ValueError("need at least the identity element")
41
+ k = elements[0].num_observables
42
+ nclass = 1 << k
43
+ if not any(e.is_identity_perm for e in elements):
44
+ raise ValueError("element list must include the identity "
45
+ "(use demsym.close, which always adds it)")
46
+
47
+ class _TwistedEquivariant(nn.Module):
48
+ def __init__(self):
49
+ super().__init__()
50
+ self.backbone = backbone
51
+ self.num_observables = k
52
+ invps, chis, cmaps = [], [], []
53
+ for e in elements:
54
+ invp = np.zeros(len(e.perm), dtype=np.int64)
55
+ invp[e.perm] = np.arange(len(e.perm))
56
+ invps.append(torch.tensor(invp, dtype=torch.long))
57
+ chis.append(torch.tensor(e.X.astype(np.float32)))
58
+ cmaps.append(torch.tensor(
59
+ [apply_A(e.A, c) for c in range(nclass)],
60
+ dtype=torch.long))
61
+ self.register_buffer("IP", torch.stack(invps)) # (|G|, nd)
62
+ self.register_buffer("CH", torch.stack(chis)) # (|G|, k, nd)
63
+ self.register_buffer("CM", torch.stack(cmaps)) # (|G|, 2^k)
64
+
65
+ def forward(self, x):
66
+ outs = []
67
+ for g in range(self.IP.shape[0]):
68
+ xg = x[:, self.IP[g]]
69
+ logit = self.backbone(xg)
70
+ b = torch.remainder(xg @ self.CH[g].T, 2.0).long()
71
+ bidx = torch.zeros_like(b[:, 0])
72
+ for i in range(self.num_observables):
73
+ bidx = bidx | (b[:, i] << i)
74
+ idx = self.CM[g][None, :].expand(x.shape[0], nclass) \
75
+ ^ bidx[:, None]
76
+ outs.append(torch.gather(logit, 1, idx))
77
+ return torch.stack(outs).mean(dim=0)
78
+
79
+ return _TwistedEquivariant()
80
+
81
+
82
+ def mlp_backbone(num_detectors: int, num_observables: int, hidden: int = 128):
83
+ """The matched-parameter 2-layer MLP used in the demsym experiments."""
84
+ torch = _torch()
85
+ nn = torch.nn
86
+ return nn.Sequential(nn.Linear(num_detectors, hidden), nn.ReLU(),
87
+ nn.Linear(hidden, hidden), nn.ReLU(),
88
+ nn.Linear(hidden, 1 << num_observables))
89
+
90
+
91
+ def equivariance_probe(model, elements: list[Element], x) -> float:
92
+ """Worst-case |f(x) - twisted f(x_g)| over the group, on given syndromes.
93
+
94
+ x must be REALIZABLE syndromes (sampled shots): X carries a gauge
95
+ freedom invisible to mechanisms, so random bit-vectors would report
96
+ false violations. Expect ~1e-6 (float32 averaging noise) when the
97
+ construction is correct.
98
+ """
99
+ torch = _torch()
100
+ nclass = 1 << elements[0].num_observables
101
+ x = torch.as_tensor(x, dtype=torch.float32,
102
+ device=next(model.parameters()).device)
103
+ worst = 0.0
104
+ model.eval()
105
+ with torch.no_grad():
106
+ fx = model(x)
107
+ for e in elements:
108
+ if e.is_identity_perm:
109
+ continue
110
+ invp = np.zeros(len(e.perm), dtype=np.int64)
111
+ invp[e.perm] = np.arange(len(e.perm))
112
+ xg = x[:, torch.tensor(invp, dtype=torch.long, device=x.device)]
113
+ fg = model(xg)
114
+ b = torch.remainder(
115
+ xg @ torch.tensor(e.X.astype(np.float32),
116
+ device=x.device).T, 2.0).long()
117
+ bidx = torch.zeros_like(b[:, 0])
118
+ for i in range(e.num_observables):
119
+ bidx = bidx | (b[:, i] << i)
120
+ cm = torch.tensor([apply_A(e.A, c) for c in range(nclass)],
121
+ device=x.device)
122
+ idx = cm[None, :].expand(x.shape[0], nclass) ^ bidx[:, None]
123
+ worst = max(worst,
124
+ float((fx - torch.gather(fg, 1, idx)).abs().max()))
125
+ return worst
@@ -0,0 +1,45 @@
1
+ """Candidate detector permutations from stim detector coordinates."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+
7
+ def perm_from_coords(circuit, fn, decimals: int = 6) -> np.ndarray:
8
+ """Detector permutation induced by a map on detector coordinates.
9
+
10
+ fn receives each detector's coordinate tuple (as floats, from
11
+ DETECTOR(...) annotations) and returns the image coordinate tuple.
12
+ Every detector must carry coordinates, and fn must permute the
13
+ coordinate set exactly; raises ValueError otherwise (which is itself
14
+ informative: the map is not a symmetry of the detector layout).
15
+
16
+ Tip for periodic layouts: fold coordinates modulo the lattice size
17
+ inside fn.
18
+ """
19
+ coords = circuit.get_detector_coordinates()
20
+ nd = circuit.num_detectors
21
+ if len(coords) != nd or any(len(c) == 0 for c in coords.values()):
22
+ raise ValueError("every detector needs a DETECTOR(...) coordinate "
23
+ "annotation to use perm_from_coords")
24
+
25
+ def key(c):
26
+ return tuple(round(float(x), decimals) for x in c)
27
+
28
+ index = {}
29
+ for d, c in coords.items():
30
+ kk = key(c)
31
+ if kk in index:
32
+ raise ValueError(f"two detectors share coordinates {kk}; "
33
+ "disambiguate (e.g. add a layer coordinate)")
34
+ index[kk] = d
35
+ perm = np.zeros(nd, dtype=np.int64)
36
+ for d, c in coords.items():
37
+ kk = key(fn(tuple(float(x) for x in c)))
38
+ if kk not in index:
39
+ raise ValueError(
40
+ f"image of detector {d} at {key(c)} -> {kk} is not a "
41
+ "detector coordinate; the map does not permute the layout")
42
+ perm[d] = index[kk]
43
+ if len(set(perm.tolist())) != nd:
44
+ raise ValueError("coordinate map is not a bijection on detectors")
45
+ return perm
@@ -0,0 +1,130 @@
1
+ """Solve the twisted label action (A, X) for a candidate detector permutation.
2
+
3
+ Method (per candidate A): for every detector-set group v, the image group
4
+ perm(v) must carry the same multiset of (observable, probability) pairs
5
+ after o -> A o XOR b, where b = X·indicator(perm(v)) is a single unknown
6
+ GF(2) bit-vector per group. Each group constrains b to a consistent set;
7
+ bits forced to a single value become linear equations on the rows of X,
8
+ solved by GF(2) elimination; the candidate is accepted only after a final
9
+ gauge-free multiset verification of the full DEM.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+ from .dem import Dem
16
+ from .element import Element, apply_A
17
+ from .gf2 import gl2_matrices, solve_gf2
18
+
19
+
20
+ def solve_action(dem: Dem, perm, name: str = "g",
21
+ A_candidates: list[np.ndarray] | None = None
22
+ ) -> Element | None:
23
+ """Find (A, X) making `perm` a twisted symmetry of `dem`.
24
+
25
+ perm: array of length num_detectors, detector d -> perm[d]. Must be a
26
+ bijection (raises otherwise).
27
+ A_candidates: optional list of k x k GF(2) matrices to try, in order.
28
+ Defaults to all of GL(k, F2) for k <= 3; for larger k pass candidates
29
+ explicitly (e.g. the 2^k observable permutation matrices).
30
+
31
+ Returns a verified Element, or None if no candidate A admits a
32
+ consistent X. None is a real answer: the permutation is not a twisted
33
+ symmetry of this DEM under any tried A.
34
+ """
35
+ perm = np.asarray(perm, dtype=np.int64)
36
+ nd = dem.num_detectors
37
+ if perm.shape != (nd,) or len(set(perm.tolist())) != nd:
38
+ raise ValueError(f"perm must be a bijection on {nd} detectors")
39
+ k = dem.num_observables
40
+ if A_candidates is None:
41
+ A_candidates = gl2_matrices(k)
42
+
43
+ groups = dem.groups
44
+ nb = 1 << k
45
+ for Amat in A_candidates:
46
+ Amat = np.asarray(Amat, dtype=np.int64) % 2
47
+ eq_rows = [[] for _ in range(k)]
48
+ eq_t = [[] for _ in range(k)]
49
+ ok = True
50
+ for v, group in groups.items():
51
+ v2 = frozenset(int(perm[d]) for d in v)
52
+ tgt = groups.get(v2)
53
+ if tgt is None:
54
+ ok = False
55
+ break
56
+ src = sorted((apply_A(Amat, o), p) for o, p in group)
57
+ cons = [b for b in range(nb)
58
+ if tuple(sorted((o ^ b, p) for o, p in src)) == tgt]
59
+ if not cons:
60
+ ok = False
61
+ break
62
+ row = None
63
+ for bit in range(k):
64
+ vals = {(b >> bit) & 1 for b in cons}
65
+ if len(vals) == 1:
66
+ if row is None:
67
+ row = np.zeros(nd, dtype=np.int8)
68
+ for d in v2:
69
+ row[d] = 1
70
+ eq_rows[bit].append(row)
71
+ eq_t[bit].append(vals.pop())
72
+ if not ok:
73
+ continue
74
+ X, good = [], True
75
+ for bit in range(k):
76
+ if eq_rows[bit]:
77
+ sol = solve_gf2(np.array(eq_rows[bit]),
78
+ np.array(eq_t[bit], dtype=np.int8))
79
+ if sol is None:
80
+ good = False
81
+ break
82
+ X.append(sol)
83
+ else:
84
+ X.append(np.zeros(nd, dtype=np.int64))
85
+ if not good:
86
+ continue
87
+ cand = Element(name, perm, Amat, np.stack(X))
88
+ if cand.verify(dem):
89
+ return cand
90
+ return None
91
+
92
+
93
+ def close(generators: list[Element], dem: Dem,
94
+ max_size: int = 1024) -> list[Element]:
95
+ """Group closure of verified elements, with the identity included.
96
+
97
+ Every product is re-verified against the DEM (cheap, and a convention
98
+ self-check). Elements are deduplicated by their detector permutation.
99
+ Raises if the closure exceeds max_size or a product fails to verify.
100
+ """
101
+ from .element import identity
102
+ if not generators:
103
+ return [identity(dem)]
104
+ els = {identity(dem).perm.tobytes(): identity(dem)}
105
+ frontier = []
106
+ for g in generators:
107
+ key = g.perm.tobytes()
108
+ if key not in els:
109
+ if not g.verify(dem):
110
+ raise ValueError(f"generator {g.name} does not verify")
111
+ els[key] = g
112
+ frontier.append(g)
113
+ while frontier:
114
+ new = []
115
+ for g in frontier:
116
+ for h in list(els.values()):
117
+ for prod in (g @ h, h @ g):
118
+ key = prod.perm.tobytes()
119
+ if key in els:
120
+ continue
121
+ if not prod.verify(dem):
122
+ raise AssertionError(
123
+ f"product {prod.name} of verified elements fails "
124
+ "verification — composition convention bug")
125
+ els[key] = prod
126
+ new.append(prod)
127
+ if len(els) > max_size:
128
+ raise ValueError(f"closure exceeds {max_size}")
129
+ frontier = new
130
+ return list(els.values())
@@ -0,0 +1,68 @@
1
+ """Composition law and verified group closure on the toric setting."""
2
+ import numpy as np
3
+ import pytest
4
+
5
+ from demsym import close, identity, solve_action
6
+
7
+ from toric_cc import build
8
+
9
+
10
+ @pytest.fixture(scope="module")
11
+ def toric4():
12
+ return build(L=4, p=0.1)
13
+
14
+
15
+ def solved(dem, perms, name, cands):
16
+ e = solve_action(dem, perms[name], name=name, A_candidates=cands)
17
+ assert e is not None
18
+ return e
19
+
20
+
21
+ def test_translation_closure_is_the_full_torus_group(toric4):
22
+ dem, perms = toric4
23
+ I4 = [np.eye(4, dtype=np.int64)]
24
+ t10 = solved(dem, perms, "T10", I4)
25
+ t01 = solved(dem, perms, "T01", I4)
26
+ grp = close([t10, t01], dem)
27
+ assert len(grp) == 16 # Z_4 x Z_4, identity included
28
+ assert all((e.A == np.eye(4, dtype=np.int64)).all() for e in grp)
29
+
30
+
31
+ def test_rotation_powers_close_to_order_4(toric4):
32
+ dem, perms = toric4
33
+ rot = solved(dem, perms, "ROT90",
34
+ [np.eye(4, dtype=np.int64)[[1, 0, 3, 2]]])
35
+ grp = close([rot], dem)
36
+ assert len(grp) == 4
37
+ r2 = rot @ rot
38
+ assert r2.verify(dem)
39
+ # C2 inverts both axes: A back to identity
40
+ assert (r2.A == np.eye(4, dtype=np.int64)).all()
41
+ r4 = r2 @ r2
42
+ assert r4.is_identity_perm
43
+ assert (r4.A == np.eye(4, dtype=np.int64)).all()
44
+ # X of the full cycle may be pure gauge; it must still verify as identity
45
+ assert r4.verify(dem)
46
+
47
+
48
+ def test_composition_is_associative_on_perms_and_A(toric4):
49
+ dem, perms = toric4
50
+ I4 = [np.eye(4, dtype=np.int64)]
51
+ t10 = solved(dem, perms, "T10", I4)
52
+ t01 = solved(dem, perms, "T01", I4)
53
+ rot = solved(dem, perms, "ROT90",
54
+ [np.eye(4, dtype=np.int64)[[1, 0, 3, 2]]])
55
+ a = (rot @ t10) @ t01
56
+ b = rot @ (t10 @ t01)
57
+ assert (a.perm == b.perm).all() and (a.A == b.A).all()
58
+ assert a.verify(dem) and b.verify(dem)
59
+
60
+
61
+ def test_identity_neutral(toric4):
62
+ dem, perms = toric4
63
+ I4 = [np.eye(4, dtype=np.int64)]
64
+ t10 = solved(dem, perms, "T10", I4)
65
+ e = identity(dem)
66
+ assert ((e @ t10).perm == t10.perm).all()
67
+ assert ((t10 @ e).perm == t10.perm).all()
68
+ assert (t10 @ e).verify(dem)
@@ -0,0 +1,48 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from demsym import Dem, gl2_matrices, solve_gf2
5
+
6
+
7
+ def test_gl_sizes():
8
+ assert len(gl2_matrices(1)) == 1
9
+ assert len(gl2_matrices(2)) == 6
10
+ assert len(gl2_matrices(3)) == 168
11
+
12
+
13
+ def test_gl_identity_first_and_invertible():
14
+ for k in (1, 2, 3):
15
+ mats = gl2_matrices(k)
16
+ assert (mats[0] == np.eye(k, dtype=np.int64)).all()
17
+
18
+
19
+ def test_gl_k4_requires_explicit_candidates():
20
+ with pytest.raises(ValueError):
21
+ gl2_matrices(4)
22
+
23
+
24
+ def test_solve_gf2_roundtrip():
25
+ rng = np.random.default_rng(0)
26
+ for _ in range(20):
27
+ m, n = rng.integers(3, 12, size=2)
28
+ A = rng.integers(0, 2, size=(m, n))
29
+ x = rng.integers(0, 2, size=n)
30
+ b = A.dot(x) % 2
31
+ sol = solve_gf2(A, b)
32
+ assert sol is not None
33
+ assert (A.dot(sol) % 2 == b).all()
34
+
35
+
36
+ def test_solve_gf2_inconsistent():
37
+ A = np.array([[1, 0], [1, 0]])
38
+ b = np.array([0, 1])
39
+ assert solve_gf2(A, b) is None
40
+
41
+
42
+ def test_dem_groups_merge_by_detector_set():
43
+ dem = Dem.from_mechanisms(
44
+ [({0, 1}, 0b01, 0.1), ({1, 0}, 0b10, 0.2), ({2}, 0, 0.3)],
45
+ num_detectors=3, num_observables=2)
46
+ assert dem.num_mechanisms == 3
47
+ assert len(dem.groups) == 2
48
+ assert dem.groups[frozenset({0, 1})] == ((1, 0.1), (2, 0.2))
@@ -0,0 +1,63 @@
1
+ """The 20-detector miniature of the whole story, on stim generated circuits:
2
+
3
+ - phenomenological noise: the mirror is an exact twisted symmetry, and the
4
+ twist is REAL (A = I with X = 0 does not verify — the moved logical needs
5
+ the syndrome-linear correction);
6
+ - circuit-level noise on the same code: the same mirror has NO solution —
7
+ extraction-schedule errors de-symmetrize the DEM (the obstruction).
8
+ """
9
+ import numpy as np
10
+ import pytest
11
+ import stim
12
+
13
+ from demsym import Dem, Element, identity, perm_from_coords, solve_action
14
+
15
+ D = 5
16
+
17
+
18
+ def gen(**noise):
19
+ return stim.Circuit.generated("repetition_code:memory", distance=D,
20
+ rounds=4, **noise)
21
+
22
+
23
+ def mirror(c):
24
+ return (2.0 * (D - 1) - c[0],) + tuple(c[1:])
25
+
26
+
27
+ @pytest.fixture(scope="module")
28
+ def phenom():
29
+ c = gen(before_round_data_depolarization=0.04,
30
+ before_measure_flip_probability=0.01)
31
+ return c, Dem.from_circuit(c), perm_from_coords(c, mirror)
32
+
33
+
34
+ def test_mirror_verifies_with_twist(phenom):
35
+ c, dem, perm = phenom
36
+ e = solve_action(dem, perm, name="mirror")
37
+ assert e is not None and e.verify(dem)
38
+ assert (e.A == np.eye(1, dtype=np.int64)).all()
39
+ assert sum(e.chi_weights()) > 0
40
+ # the twist is forced: the untwisted candidate is NOT a symmetry
41
+ bare = Element("mirror_untwisted", perm, e.A, np.zeros_like(e.X))
42
+ assert not bare.verify(dem)
43
+
44
+
45
+ def test_identity_always_solves(phenom):
46
+ _, dem, _ = phenom
47
+ e = solve_action(dem, np.arange(dem.num_detectors))
48
+ assert e is not None and (e.A == np.eye(1, dtype=np.int64)).all()
49
+ assert identity(dem).verify(dem)
50
+
51
+
52
+ def test_circuit_level_noise_obstructs_the_mirror():
53
+ c = gen(after_clifford_depolarization=0.01,
54
+ before_measure_flip_probability=0.01)
55
+ dem = Dem.from_circuit(c)
56
+ perm = perm_from_coords(c, mirror)
57
+ assert solve_action(dem, perm, name="mirror") is None
58
+
59
+
60
+ def test_perm_from_coords_rejects_non_layout_maps(phenom):
61
+ c, _, _ = phenom
62
+ with pytest.raises(ValueError):
63
+ perm_from_coords(c, lambda co: (co[0] + 2.0,) + tuple(co[1:]))
@@ -0,0 +1,59 @@
1
+ """Exact equivariance of the torch wrapper on realizable syndromes."""
2
+ import numpy as np
3
+ import pytest
4
+ import stim
5
+
6
+ from demsym import Dem, close, perm_from_coords, solve_action
7
+
8
+ torch = pytest.importorskip("torch")
9
+
10
+ from demsym.nn import TwistedEquivariant, equivariance_probe, mlp_backbone # noqa: E402
11
+
12
+ D = 5
13
+
14
+
15
+ @pytest.fixture(scope="module")
16
+ def setting():
17
+ c = stim.Circuit.generated("repetition_code:memory", distance=D, rounds=4,
18
+ before_round_data_depolarization=0.04,
19
+ before_measure_flip_probability=0.01)
20
+ dem = Dem.from_circuit(c)
21
+ perm = perm_from_coords(c, lambda co: (2.0 * (D - 1) - co[0],)
22
+ + tuple(co[1:]))
23
+ e = solve_action(dem, perm, name="mirror")
24
+ grp = close([e], dem)
25
+ assert len(grp) == 2
26
+ shots = c.compile_detector_sampler(seed=7).sample(128).astype(np.float32)
27
+ return dem, grp, shots
28
+
29
+
30
+ def test_probe_is_exact_on_realizable_syndromes(setting):
31
+ dem, grp, shots = setting
32
+ torch.manual_seed(0)
33
+ model = TwistedEquivariant(mlp_backbone(dem.num_detectors, 1, hidden=16),
34
+ grp)
35
+ assert equivariance_probe(model, grp, shots) < 1e-5
36
+
37
+
38
+ def test_forward_shape_and_gradients(setting):
39
+ dem, grp, shots = setting
40
+ torch.manual_seed(0)
41
+ model = TwistedEquivariant(mlp_backbone(dem.num_detectors, 1, hidden=16),
42
+ grp)
43
+ x = torch.tensor(shots[:32])
44
+ out = model(x)
45
+ assert out.shape == (32, 2)
46
+ loss = torch.nn.functional.cross_entropy(
47
+ out, torch.zeros(32, dtype=torch.long))
48
+ loss.backward()
49
+ grads = [p.grad for p in model.parameters()]
50
+ assert all(g is not None for g in grads)
51
+ assert any(float(g.abs().sum()) > 0 for g in grads)
52
+
53
+
54
+ def test_requires_identity_element(setting):
55
+ dem, grp, _ = setting
56
+ non_id = [e for e in grp if not e.is_identity_perm]
57
+ with pytest.raises(ValueError):
58
+ TwistedEquivariant(mlp_backbone(dem.num_detectors, 1, hidden=16),
59
+ non_id)
@@ -0,0 +1,46 @@
1
+ """Validation against The END's analytic symmetry action (arXiv:2304.07362).
2
+
3
+ On their setting — toric code, code capacity, i.i.d. depolarizing — the
4
+ blind DEM solve must recover their Fig. 3 action UNIQUELY among all 24
5
+ observable permutation matrices, for all four lattice elements. This is the
6
+ ground-truth bridge: no analytic input, the action comes out of the DEM.
7
+ """
8
+ import itertools
9
+
10
+ import numpy as np
11
+ import pytest
12
+
13
+ from demsym import solve_action
14
+
15
+ from toric_cc import build
16
+
17
+ PERMS24 = [np.eye(4, dtype=np.int64)[list(p)]
18
+ for p in itertools.permutations(range(4))]
19
+ EXPECTED_A = {
20
+ "T10": np.eye(4, dtype=np.int64),
21
+ "T01": np.eye(4, dtype=np.int64),
22
+ "ROT90": np.eye(4, dtype=np.int64)[[1, 0, 3, 2]], # X1<->X2, Z1<->Z2
23
+ "MIR": np.eye(4, dtype=np.int64),
24
+ }
25
+
26
+
27
+ @pytest.fixture(scope="module")
28
+ def toric():
29
+ return build(L=6, p=0.1)
30
+
31
+
32
+ @pytest.mark.parametrize("name", ["T10", "T01", "ROT90", "MIR"])
33
+ def test_blind_solve_unique_and_matches_end(toric, name):
34
+ dem, perms = toric
35
+ sols = [s for A in PERMS24
36
+ if (s := solve_action(dem, perms[name], name=name,
37
+ A_candidates=[A])) is not None]
38
+ assert len(sols) == 1, f"{name}: A not unique among 24 label perms"
39
+ assert (sols[0].A == EXPECTED_A[name]).all()
40
+ assert sols[0].verify(dem)
41
+
42
+
43
+ def test_translations_carry_nonzero_twist(toric):
44
+ dem, perms = toric
45
+ e = solve_action(dem, perms["T10"], A_candidates=[np.eye(4, dtype=np.int64)])
46
+ assert e is not None and sum(e.chi_weights()) > 0
@@ -0,0 +1,104 @@
1
+ """Shared toric-code code-capacity construction (The END's setting).
2
+
3
+ L x L torus, qubits on edges; A(i,j) X-stabilizers are detectors 0..L^2-1,
4
+ B(i,j) Z-stabilizers are detectors L^2..2L^2-1. Four logical observables
5
+ gamma = (X1, X2, Z1, Z2) as bits 0..3. Mechanisms: X, Z, Y on each edge at
6
+ p/3 (i.i.d. depolarizing, perfect syndromes).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from demsym import Dem
13
+
14
+
15
+ def build(L: int = 6, p: float = 0.1):
16
+ def vdet(i, j):
17
+ return (i % L) * L + (j % L)
18
+
19
+ def pdet(i, j):
20
+ return L * L + (i % L) * L + (j % L)
21
+
22
+ def edge_dets_X(e):
23
+ t, i, j = e
24
+ if t == "h":
25
+ return {pdet(i, j), pdet(i - 1, j)}
26
+ return {pdet(i, j), pdet(i, j - 1)}
27
+
28
+ def edge_dets_Z(e):
29
+ t, i, j = e
30
+ if t == "h":
31
+ return {vdet(i, j), vdet(i, j + 1)}
32
+ return {vdet(i, j), vdet(i + 1, j)}
33
+
34
+ edges = [(t, i, j) for i in range(L) for j in range(L) for t in ("h", "v")]
35
+ XBAR1 = {("h", i, 0) for i in range(L)}
36
+ XBAR2 = {("v", 0, j) for j in range(L)}
37
+ ZBAR1 = {("h", 0, j) for j in range(L)}
38
+ ZBAR2 = {("v", i, 0) for i in range(L)}
39
+
40
+ def obs_of(e, pauli):
41
+ o = 0
42
+ if pauli in ("Z", "Y"):
43
+ o |= (e in XBAR1) << 0
44
+ o |= (e in XBAR2) << 1
45
+ if pauli in ("X", "Y"):
46
+ o |= (e in ZBAR1) << 2
47
+ o |= (e in ZBAR2) << 3
48
+ return o
49
+
50
+ mechs = []
51
+ for e in edges:
52
+ for pauli in "XZY":
53
+ dets = set()
54
+ if pauli in ("X", "Y"):
55
+ dets ^= edge_dets_X(e)
56
+ if pauli in ("Z", "Y"):
57
+ dets ^= edge_dets_Z(e)
58
+ mechs.append((dets, obs_of(e, pauli), p / 3))
59
+ dem = Dem.from_mechanisms(mechs, 2 * L * L, 4)
60
+
61
+ def perm_from_edgemap(emap):
62
+ vsets, psets = {}, {}
63
+ for i in range(L):
64
+ for j in range(L):
65
+ vs = frozenset({("h", i, j), ("h", i, (j - 1) % L),
66
+ ("v", i, j), ("v", (i - 1) % L, j)})
67
+ ps = frozenset({("h", i, j), ("h", (i + 1) % L, j),
68
+ ("v", i, j), ("v", i, (j + 1) % L)})
69
+ vsets[vs] = vdet(i, j)
70
+ psets[ps] = pdet(i, j)
71
+ perm = np.zeros(2 * L * L, dtype=np.int64)
72
+ for sets in (vsets, psets):
73
+ for s, d in sets.items():
74
+ img = frozenset(emap(e) for e in s)
75
+ tgt = vsets.get(img, psets.get(img))
76
+ assert tgt is not None, "edge map is not a lattice automorphism"
77
+ perm[d] = tgt
78
+ assert len(set(perm.tolist())) == 2 * L * L
79
+ return perm
80
+
81
+ def T10(e):
82
+ t, i, j = e
83
+ return (t, (i + 1) % L, j)
84
+
85
+ def T01(e):
86
+ t, i, j = e
87
+ return (t, i, (j + 1) % L)
88
+
89
+ def ROT(e):
90
+ t, i, j = e
91
+ if t == "h":
92
+ return ("v", j % L, (-i) % L)
93
+ return ("h", j % L, (-i - 1) % L)
94
+
95
+ def MIR(e):
96
+ t, i, j = e
97
+ if t == "h":
98
+ return ("h", i, (-j - 1) % L)
99
+ return ("v", i, (-j) % L)
100
+
101
+ perms = {name: perm_from_edgemap(f)
102
+ for name, f in (("T10", T10), ("T01", T01),
103
+ ("ROT90", ROT), ("MIR", MIR))}
104
+ return dem, perms