Skip to content

Commit 90b5004

Browse files
committed
fix: import must not depend on the native core's exact symbol set
The v0.6.9 tag build failed its pre-publish smoke test: AttributeError: module 'qector_decoder_v3.qector_decoder_v3' has no attribute 'py_generate_parity_check_matrix' Nothing reached PyPI -- the smoke gate added in 0.6.8 caught it, which is exactly what it exists for. Cause: release wheels compile the Rust core from the RUST_SRC_B64_* secrets bundle, which can lag this repo's Python layer. `py_generate_parity_check_matrix` is registered in the repo's lib.rs but absent from the bundle's older core. Six utility lookups in __init__ were unguarded, so a symbol-set mismatch killed `import qector_decoder_v3` outright -- the same failure mode that made every v0.6.7 wheel unimportable. Unlike the class guards (which stub and raise on use), these six have exact pure-Python equivalents, so a wheel built against an older core keeps FULL functionality rather than degrading. Each fallback mirrors src/utils.rs one-to-one including the edge cases: out-of-range qubits dropped rather than raising, n_qubits inferred as max+1, surface distance >= 2 rejected, repetition distance 0 -> empty. python/tests/test_native_fallbacks.py locks both directions: every fallback is byte-identical to its native counterpart on this build (31 tests over surface / ring / toy / repetition / edges / parity-matrix incl. edge cases), and `_native_or` returns the fallback when the symbol is missing while preferring the compiled one when present. Verified against a simulated older core -- the native module with those six symbols stripped -- which reproduces the CI traceback exactly and now imports clean with all six resolved via fallback. Gates: ruff check + format clean; 139 passed across the fallback, release-import, comprehensive and new-modules suites.
1 parent a18481f commit 90b5004

2 files changed

Lines changed: 265 additions & 6 deletions

File tree

python/qector_decoder_v3/__init__.py

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,106 @@ def __repr__(self):
5656
_RustSparseBlossomDecoder = _guard("SparseBlossomDecoder")
5757
_RustHybridDecoder = _guard("HybridDecoder")
5858
_RustHybridCascadeDecoder = _guard("HybridCascadeDecoder")
59-
py_check_to_edges = _native_module.py_check_to_edges
60-
py_generate_surface_code_checks = _native_module.py_generate_surface_code_checks
61-
py_generate_toy_code_checks = _native_module.py_generate_toy_code_checks
62-
py_generate_ring_code_checks = _native_module.py_generate_ring_code_checks
63-
py_generate_repetition_code_checks = _native_module.py_generate_repetition_code_checks
64-
py_generate_parity_check_matrix = _native_module.py_generate_parity_check_matrix
59+
# ---------------------------------------------------------------------------
60+
# Utility functions: native when available, exact pure-Python fallback when not.
61+
#
62+
# Release wheels compile the Rust core from the RUST_SRC_B64_* secrets bundle,
63+
# which can lag the Python layer in this repo. v0.6.9's first tag build died at
64+
# import because `py_generate_parity_check_matrix` was registered in the repo's
65+
# lib.rs but absent from the bundle's older core -- the same failure mode that
66+
# made every v0.6.7 wheel unimportable. Import must never depend on the native
67+
# module's exact symbol set.
68+
#
69+
# Unlike the class guards above (which stub and raise on use), these six have
70+
# exact pure-Python equivalents, so a wheel with an older core keeps FULL
71+
# functionality. Each fallback mirrors src/utils.rs one-to-one, including edge
72+
# cases (out-of-range qubits dropped, n_qubits inferred as max+1, surface
73+
# distance >= 2, repetition distance == 0 -> empty). Equivalence against the
74+
# native implementations is locked by python/tests/test_native_fallbacks.py.
75+
# ---------------------------------------------------------------------------
76+
77+
78+
def _py_check_to_edges(check_to_qubits):
79+
edges = []
80+
for qubits in check_to_qubits:
81+
if len(qubits) < 2:
82+
continue
83+
for i in range(len(qubits) - 1):
84+
edges.append((int(qubits[i]), int(qubits[i + 1])))
85+
return edges
86+
87+
88+
def _py_generate_ring_code_checks(distance):
89+
n_qubits = distance * distance
90+
return [[i, (i + 1) % n_qubits] for i in range(n_qubits)], n_qubits
91+
92+
93+
def _py_generate_surface_code_checks(distance):
94+
if distance < 2:
95+
raise ValueError("distance must be >= 2")
96+
d = distance
97+
98+
def q(row, col):
99+
return (row % d) * d + (col % d)
100+
101+
checks = []
102+
for row in range(d):
103+
for col in range(d):
104+
checks.append([q(row, col), q(row, col + 1), q(row + 1, col), q(row + 1, col + 1)])
105+
for row in range(d):
106+
for col in range(d):
107+
checks.append([q(row, col), q(row, col + d - 1), q(row + d - 1, col), q(row + d - 1, col + d - 1)])
108+
return checks, d * d
109+
110+
111+
def _py_generate_toy_code_checks(distance):
112+
n = distance
113+
checks = []
114+
for _ in range(2):
115+
for row in range(n):
116+
for col in range(n):
117+
q0 = row * n + col
118+
q1 = row * n + ((col + 1) % n)
119+
q2 = ((row + 1) % n) * n + col
120+
q3 = ((row + 1) % n) * n + ((col + 1) % n)
121+
checks.append([q0, q1, q2, q3])
122+
return checks, n * n
123+
124+
125+
def _py_generate_repetition_code_checks(distance):
126+
if distance == 0:
127+
return [], 0
128+
return [[i, i + 1] for i in range(distance - 1)], distance
129+
130+
131+
def _py_generate_parity_check_matrix(check_to_qubits, n_qubits=None):
132+
import numpy as np
133+
134+
n_checks = len(check_to_qubits)
135+
max_q = max((int(q) for qs in check_to_qubits for q in qs), default=0)
136+
if n_qubits is None:
137+
n_qubits = max_q + 1
138+
h = np.zeros((n_checks, n_qubits), dtype=np.uint8)
139+
for ci, qs in enumerate(check_to_qubits):
140+
for q in qs:
141+
if int(q) < n_qubits:
142+
h[ci, int(q)] = 1
143+
return h
144+
145+
146+
def _native_or(name, fallback):
147+
"""Prefer the compiled implementation; fall back without losing function."""
148+
return getattr(_native_module, name, fallback)
149+
150+
151+
py_check_to_edges = _native_or("py_check_to_edges", _py_check_to_edges)
152+
py_generate_surface_code_checks = _native_or("py_generate_surface_code_checks", _py_generate_surface_code_checks)
153+
py_generate_toy_code_checks = _native_or("py_generate_toy_code_checks", _py_generate_toy_code_checks)
154+
py_generate_ring_code_checks = _native_or("py_generate_ring_code_checks", _py_generate_ring_code_checks)
155+
py_generate_repetition_code_checks = _native_or(
156+
"py_generate_repetition_code_checks", _py_generate_repetition_code_checks
157+
)
158+
py_generate_parity_check_matrix = _native_or("py_generate_parity_check_matrix", _py_generate_parity_check_matrix)
65159
try:
66160
run_mcp_server = _native_module.run_mcp_server
67161
except AttributeError:
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Pure-Python fallbacks must be byte-exact equivalents of the native utils.
2+
3+
Release wheels compile the Rust core from the RUST_SRC_B64_* secrets bundle,
4+
which can lag the repo's Python layer. The v0.6.9 tag build died at import
5+
because `py_generate_parity_check_matrix` existed in the repo's lib.rs but not
6+
in the bundle's older core -- the same class of failure that made every v0.6.7
7+
wheel unimportable. `__init__` now resolves these six utilities via
8+
`_native_or(name, fallback)`.
9+
10+
Two contracts locked here:
11+
12+
1. On a build where the native implementations exist (this machine), each
13+
fallback produces *identical* output -- so a wheel that falls back loses
14+
nothing.
15+
2. `_native_or` actually returns the fallback when the symbol is absent, so
16+
import survives an older core.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import numpy as np
22+
import pytest
23+
import qector_decoder_v3 as qd
24+
25+
_HAS_NATIVE = all(
26+
hasattr(qd._native_module, n)
27+
for n in (
28+
"py_check_to_edges",
29+
"py_generate_surface_code_checks",
30+
"py_generate_toy_code_checks",
31+
"py_generate_ring_code_checks",
32+
"py_generate_repetition_code_checks",
33+
"py_generate_parity_check_matrix",
34+
)
35+
)
36+
37+
needs_native = pytest.mark.skipif(
38+
not _HAS_NATIVE, reason="native utils absent in this build; equivalence needs both sides"
39+
)
40+
41+
42+
def _norm_checks(pair):
43+
"""Normalise (checks, n_qubits) across native (tuples/uints) and Python (lists/ints)."""
44+
checks, nq = pair
45+
return [[int(q) for q in c] for c in checks], int(nq)
46+
47+
48+
@needs_native
49+
@pytest.mark.parametrize("d", [2, 3, 5, 7])
50+
def test_surface_checks_equivalent(d):
51+
assert _norm_checks(qd._py_generate_surface_code_checks(d)) == _norm_checks(
52+
qd._native_module.py_generate_surface_code_checks(d)
53+
)
54+
55+
56+
@needs_native
57+
def test_surface_rejects_distance_below_two():
58+
with pytest.raises(ValueError):
59+
qd._py_generate_surface_code_checks(1)
60+
with pytest.raises(ValueError):
61+
qd._native_module.py_generate_surface_code_checks(1)
62+
63+
64+
@needs_native
65+
@pytest.mark.parametrize("d", [1, 2, 3, 5, 8])
66+
def test_ring_checks_equivalent(d):
67+
assert _norm_checks(qd._py_generate_ring_code_checks(d)) == _norm_checks(
68+
qd._native_module.py_generate_ring_code_checks(d)
69+
)
70+
71+
72+
@needs_native
73+
@pytest.mark.parametrize("d", [1, 2, 3, 5])
74+
def test_toy_checks_equivalent(d):
75+
assert _norm_checks(qd._py_generate_toy_code_checks(d)) == _norm_checks(
76+
qd._native_module.py_generate_toy_code_checks(d)
77+
)
78+
79+
80+
@needs_native
81+
@pytest.mark.parametrize("d", [0, 1, 2, 5, 9])
82+
def test_repetition_checks_equivalent(d):
83+
assert _norm_checks(qd._py_generate_repetition_code_checks(d)) == _norm_checks(
84+
qd._native_module.py_generate_repetition_code_checks(d)
85+
)
86+
87+
88+
EDGE_CASES = [
89+
[[0, 1], [1, 2], [2, 3]],
90+
[[0, 1, 3, 4], [1, 2, 4, 5]], # weight-4 checks -> consecutive pairs
91+
[[7]], # single-qubit check contributes no edges
92+
[[0, 5]], # sparse indices
93+
]
94+
95+
96+
@needs_native
97+
@pytest.mark.parametrize("c2q", EDGE_CASES)
98+
def test_check_to_edges_equivalent(c2q):
99+
py = [(int(a), int(b)) for a, b in qd._py_check_to_edges(c2q)]
100+
native = [(int(a), int(b)) for a, b in qd._native_module.py_check_to_edges(c2q)]
101+
assert py == native
102+
103+
104+
PARITY_CASES = [
105+
([[0, 1], [1, 2]], 3),
106+
([[0, 1], [1, 2]], None), # n_qubits inferred as max+1
107+
([[0, 3]], None),
108+
([[0, 5]], 3), # out-of-range qubit dropped, not an error
109+
([[0, 1, 3, 4], [1, 2, 4, 5]], 9),
110+
]
111+
112+
113+
@needs_native
114+
@pytest.mark.parametrize(("c2q", "nq"), PARITY_CASES)
115+
def test_parity_check_matrix_equivalent(c2q, nq):
116+
py = qd._py_generate_parity_check_matrix(c2q, nq)
117+
native = np.asarray(qd._native_module.py_generate_parity_check_matrix(c2q, nq))
118+
assert py.dtype == np.uint8
119+
assert py.shape == native.shape
120+
assert np.array_equal(py, native)
121+
122+
123+
def test_native_or_falls_back_when_symbol_missing(monkeypatch):
124+
"""The exact CI-wheel condition, exercised through the real resolver.
125+
126+
`_native_or` reads from the module-level `_native_module`, so swap that for
127+
a core with no utils registered and confirm the fallback is what comes back.
128+
"""
129+
130+
class _OlderCore:
131+
pass # the bundle's core: none of the six utils exist
132+
133+
monkeypatch.setattr(qd, "_native_module", _OlderCore())
134+
135+
sentinel = object()
136+
137+
def fallback():
138+
return sentinel
139+
140+
resolved = qd._native_or("py_generate_parity_check_matrix", fallback)
141+
assert resolved is fallback, "_native_or must return the fallback for a missing symbol"
142+
assert resolved() is sentinel
143+
144+
145+
@needs_native
146+
def test_native_or_prefers_the_compiled_symbol():
147+
"""When the core does provide it, the native implementation must win."""
148+
149+
def fallback(): # pragma: no cover - must never be selected here
150+
raise AssertionError("fallback selected despite a native symbol being present")
151+
152+
resolved = qd._native_or("py_generate_parity_check_matrix", fallback)
153+
assert resolved is qd._native_module.py_generate_parity_check_matrix
154+
155+
156+
def test_fallbacks_faithful_against_decoder():
157+
"""A fallback-generated code must decode correctly end to end."""
158+
checks, nq = qd._py_generate_ring_code_checks(3)
159+
dec = qd.UnionFindDecoder(checks, n_qubits=nq)
160+
h = qd._py_generate_parity_check_matrix(checks, nq)
161+
syndrome = np.zeros(len(checks), dtype=np.uint8)
162+
syndrome[0] = 1
163+
syndrome[1] = 1
164+
corr = dec.decode(syndrome)
165+
assert np.array_equal((h @ corr) & 1, syndrome)

0 commit comments

Comments
 (0)