Skip to content

Commit 0ffff05

Browse files
committed
fix(ci): repair the 17 CI-only test failures the matrix has been masking
The tests job reached pytest for the first time this cycle and reported 19 failures on every Python version. None were new; earlier runs died at the build or lint step and never got far enough to show them. expected_symbols.txt was never in the repo (6 failures) `.gitignore` carries a blanket `*.txt`, so the public-API contract fixture was untracked. test_public_symbols.py therefore passed on any developer checkout and failed in CI with FileNotFoundError on all six of its tests - the SemVer surface guard was enforced nowhere but locally. Add a negation and commit the fixture. TwoStageDecoder rejected short syndromes (1 failure) test_two_stage_decoder_syndrome_validation, added in the v0.7.0 release commit, asserts a trimmed syndrome decodes to an all-zero correction. The native core is strict, so it raised `ValueError: Syndrome length mismatch` and the test had never passed. Zero-pad up to n_checks in the wrapper, the same way pymatching_compat.decode_batch already does for the matching decoders; over-long input is still rejected. Stripe tests required production secrets (10 failures) They need STRIPE_SECRET_KEY / QECTOR_LICENSE_PRIVATE_KEY_B64, which are deliberately absent from CI, so they failed rather than skipped. test_stripe_integration.py already had the `_has_stripe_key` + skipif convention; extend it to the two files that lacked it. The tests asserting the *absence* path - test_raises_without_secret_key, test_missing_private_key_is_loud - are left unguarded on purpose, so the "fail loudly without a key" guarantee still runs in CI. Still open: 2 routing tests that monkeypatch gpu_backend and only pass on a machine with a real GPU. The patch seam is correct locally, so diagnosing it needs CI evidence rather than guesswork.
1 parent b8260f8 commit 0ffff05

6 files changed

Lines changed: 206 additions & 2 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ licenses_issued.json
115115
# --- Log files & scratch test outputs ---
116116
*.log
117117
*.txt
118+
# ...but this one is a committed test fixture, not scratch output. The blanket
119+
# `*.txt` above kept it untracked, so `test_public_symbols.py` passed locally
120+
# (where the file exists) and failed in CI with FileNotFoundError on all six of
121+
# its tests - the public-API contract was never actually enforced anywhere but
122+
# a developer's own checkout.
123+
!python/tests/expected_symbols.txt
118124
*.xml
119125
run_status.json
120126
__all_tests_dump.txt

python/qector_decoder_v3/__init__.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,12 +1439,29 @@ def __init__(self, check_to_qubits, check_types, n_qubits=None, x_decoder="bloss
14391439
c2q, nq = _validate_check_to_qubits(check_to_qubits, n_qubits)
14401440
self._inner = _RustTwoStageDecoder(c2q, check_types, nq, x_decoder, z_decoder)
14411441

1442+
def _pad(self, syndrome):
1443+
"""Zero-pad a short syndrome up to ``n_checks``.
1444+
1445+
Mirrors ``pymatching_compat.decode_batch``: callers routinely hand over a
1446+
syndrome whose trailing all-zero detectors were trimmed upstream, and the
1447+
matching decoders in this package already accept that. The native core is
1448+
strict, so without this the same input decodes fine through ``Matching``
1449+
and raises ``ValueError: Syndrome length mismatch`` through
1450+
``TwoStageDecoder``. Longer-than-expected input is still rejected: that is
1451+
a genuine mismatch, not a trimmed tail.
1452+
"""
1453+
n = self._inner.n_checks
1454+
if syndrome.shape[-1] < n:
1455+
pad = _np.zeros(syndrome.shape[:-1] + (n - syndrome.shape[-1],), dtype=_np.uint8)
1456+
syndrome = _np.concatenate([syndrome, pad], axis=-1)
1457+
return _np.ascontiguousarray(syndrome, dtype=_np.uint8)
1458+
14421459
def decode(self, syndrome):
14431460
if not isinstance(syndrome, _np.ndarray):
14441461
syndrome = _np.array(syndrome, dtype=_np.uint8)
14451462
if syndrome.dtype != _np.uint8:
14461463
raise TypeError(f"Syndrome must be dtype uint8, got {syndrome.dtype}")
1447-
return self._inner.decode(syndrome)
1464+
return self._inner.decode(self._pad(syndrome))
14481465

14491466
def batch_decode(self, syndromes):
14501467
if not isinstance(syndromes, _np.ndarray):
@@ -1453,7 +1470,7 @@ def batch_decode(self, syndromes):
14531470
syndromes = syndromes.astype(_np.uint8)
14541471
if syndromes.ndim != 2:
14551472
raise ValueError(f"syndromes must be 2D, got shape {syndromes.shape}")
1456-
return self._inner.batch_decode(syndromes)
1473+
return self._inner.batch_decode(self._pad(syndromes))
14571474

14581475
@property
14591476
def n_qubits(self):

python/tests/expected_symbols.txt

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Canonical public API surface of `qector_decoder_v3` (todo8 item 0.2).
2+
#
3+
# One name per line; `#` comments and blank lines ignored. This file is the
4+
# contract `test_public_symbols.py` enforces in both directions:
5+
# * a name here that is missing from the installed package fails the build.
6+
# * a public name in the package that is *not* here also fails. Adding to the
7+
# public surface is a deliberate act with SemVer consequences, so it has to
8+
# be recorded here in the same commit.
9+
#
10+
# Two releases (0.6.5, 0.6.7) were yanked because a symbol silently vanished
11+
# from a wheel — `_guard()` substitutes a stub that imports fine and only
12+
# raises when constructed, so nothing else catches it.
13+
#
14+
# Optional, build-dependent backends (CUDA / OpenCL) are listed but exempt from
15+
# the "must not be a stub" check — see OPTIONAL_SYMBOLS in the test.
16+
17+
AmbiguityClusterDecoder
18+
AutoDecoder
19+
AutoRouter
20+
BPOSDDecoder
21+
Backend
22+
BackendConfig
23+
BatchDecoder
24+
BatchedBpDecoder
25+
BeliefMatching
26+
BenchmarkSuite
27+
BlossomDecoder
28+
BpOsdDecoder
29+
CPUBatchDecoder
30+
CUDABatchDecoder
31+
CUDABpOsdDecoder
32+
ColourCodeDecoder
33+
DecodeResult
34+
DecoderName
35+
DecoderPool
36+
DetectorGraph
37+
FastUnionFindDecoder
38+
GNNBeliefMatcher
39+
GNNPredecoder
40+
GNNTrainer
41+
HardwareProfile
42+
HybridCascadeDecoder
43+
HybridDecoder
44+
LERBenchmark
45+
LookupTableDecoder
46+
MAX_WORKERS
47+
Matching
48+
NativeAutoDecoder
49+
NeuralPredecoder
50+
OpenCLBatchDecoder
51+
PredecodedDecoder
52+
Recommendation
53+
SlidingWindowDecoder
54+
SpaceTimeDecoder
55+
SparseBlossomDecoder
56+
StreamingDecoder
57+
StreamingResult
58+
StreamingSession
59+
StreamingTelemetry
60+
TwoStageDecoder
61+
UnionFindDecoder
62+
Workbench
63+
backend
64+
batched_bp_decode
65+
belief_matching
66+
benchmarking
67+
bp_cupy
68+
bposd
69+
changelog
70+
check_to_edges
71+
clear_decoder_cache
72+
codes
73+
colour_code
74+
compute_detector_differences
75+
cuda_is_available
76+
decode_mmap
77+
decode_with_diagnostics
78+
decode_with_gnn
79+
decoder_cache
80+
decoder_pool
81+
dem
82+
detect_hardware
83+
enforce_distance_cap
84+
enforce_unlocked
85+
estimate_distance
86+
flush_usage
87+
from_circuit
88+
generate_biconnected_qldpc_checks
89+
generate_parity_check_matrix
90+
generate_repetition_code_checks
91+
generate_ring_code_checks
92+
generate_space_time_surface_code_checks
93+
generate_surface_code_checks
94+
generate_toy_code_checks
95+
generate_triangular_color_code_4_8_8_checks
96+
get_accumulated_shots
97+
get_backend
98+
get_decoder
99+
get_decoder_pool
100+
get_latency_quantiles
101+
get_license_info
102+
gpu_available
103+
gpu_backend
104+
has_cuda_rust
105+
has_cupy
106+
license
107+
opencl_is_available
108+
predecoder
109+
pymatching
110+
pymatching_compat
111+
qiskit_plugin
112+
recommend
113+
recommend_decoder
114+
record_shots
115+
rest_api
116+
result
117+
routing
118+
run_grpc_server
119+
run_mcp_server
120+
set_license_key
121+
set_license_key_file
122+
sinter_compat
123+
sliding_window_decode
124+
sparse_blossom_radix_neighbors
125+
start_metrics_server
126+
stim_compat
127+
streaming
128+
stripe_integration
129+
verify_license_token
130+
workbench
131+
132+
# Reachable at package level but deliberately NOT promoted into `__all__`:
133+
# * Raw PyO3 `py_*` aliases. The supported spellings are the un-prefixed wrappers.
134+
# * `qector_decoder_v3`: The compiled extension submodule.
135+
# * `qector_memory_align`: Buffer conditioning helper for the PyO3 boundary.
136+
[reachable]
137+
bench_quick
138+
cli
139+
doctor
140+
ler
141+
py_check_to_edges
142+
py_generate_parity_check_matrix
143+
py_generate_repetition_code_checks
144+
py_generate_ring_code_checks
145+
py_generate_surface_code_checks
146+
py_generate_toy_code_checks
147+
qector_decoder_v3
148+
qector_memory_align

python/tests/test_stripe_integration.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def test_stripe_keys_loaded():
2929
assert keys["secret_key_prefix"].startswith("sk_live_")
3030

3131

32+
@pytest.mark.skipif(not _has_stripe_key, reason="STRIPE_SECRET_KEY not set")
3233
@patch("stripe.checkout.Session.create")
3334
def test_stripe_checkout_session_structure(mock_create):
3435
mock_session = MagicMock()

python/tests/test_stripe_sales_workflow.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@
2121
handle_stripe_webhook_payload,
2222
)
2323

24+
# Same convention as test_stripe_integration.py. These are production secrets and
25+
# are deliberately absent from CI, so the tests that need them skip rather than
26+
# fail. Importing stripe_integration above has already run load_dotenv(), so a
27+
# local .env is visible here.
28+
#
29+
# The tests that assert the *absence* path - `test_raises_without_secret_key`,
30+
# `test_missing_private_key_is_loud` - are intentionally left unguarded: they are
31+
# the ones that must keep running in CI.
32+
_has_stripe_key = bool(os.getenv("STRIPE_SECRET_KEY"))
33+
_has_license_key = bool(os.getenv("QECTOR_LICENSE_PRIVATE_KEY_B64"))
34+
35+
_needs_stripe = pytest.mark.skipif(not _has_stripe_key, reason="STRIPE_SECRET_KEY not set")
36+
_needs_signing = pytest.mark.skipif(not _has_license_key, reason="QECTOR_LICENSE_PRIVATE_KEY_B64 not set")
37+
2438

2539
class TestPricingRegistry:
2640
def test_self_serve_tiers_present(self):
@@ -37,13 +51,15 @@ def test_amounts_match_commercial_md(self):
3751
def test_commercial_alias_matches_evaluation(self):
3852
assert PRICING["commercial"] is PRICING["evaluation"]
3953

54+
@_needs_stripe
4055
def test_keys_snapshot_includes_bot_flag(self):
4156
keys = get_stripe_keys()
4257
assert "labs_bot_configured" in keys
4358
assert keys["secret_key_configured"] is True
4459

4560

4661
class TestEnsureProducts:
62+
@_needs_stripe
4763
@patch("stripe.Price.create")
4864
@patch("stripe.Price.list")
4965
@patch("stripe.Product.create")
@@ -72,6 +88,7 @@ def _mk_price(**kwargs):
7288
assert mock_pcreate.call_count == 3
7389
assert mock_price_create.call_count == 3
7490

91+
@_needs_stripe
7592
@patch("stripe.Price.create")
7693
@patch("stripe.Price.list")
7794
@patch("stripe.Product.create")
@@ -129,6 +146,7 @@ def test_failure_never_raises(self, _mock):
129146
with patch.object(si, "QECTOR_LABS_BOT_WEBHOOK_URL", "https://discord.test/hook"):
130147
assert _notify_labs_bot("sale!") is False
131148

149+
@_needs_signing
132150
@patch("urllib.request.urlopen", side_effect=OSError("network down"))
133151
def test_webhook_fulfillment_survives_bot_outage(self, _mock):
134152
with patch.object(si, "QECTOR_LABS_BOT_WEBHOOK_URL", "https://discord.test/hook"):
@@ -144,6 +162,7 @@ def test_webhook_fulfillment_survives_bot_outage(self, _mock):
144162

145163

146164
class TestPackageInternalSigning:
165+
@_needs_signing
147166
def test_sign_and_verify_roundtrip(self):
148167
token = create_license_token("rec_xyz", "Buyer@Qector.Store")
149168
assert verify_license_token(token, "buyer@qector.store") is True
@@ -156,6 +175,7 @@ def test_missing_private_key_is_loud(self):
156175
):
157176
create_license_token("rec_nokey", "a@b.c")
158177

178+
@_needs_signing
159179
def test_no_demo_fallback_in_package(self):
160180
"""The package must never mint tokens the production pubkey rejects."""
161181
token = create_license_token("rec_real", "real@qector.store")

python/tests/test_stripe_zero_dollar_sale.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,17 @@
1515
handle_stripe_webhook_payload,
1616
)
1717

18+
# Same convention as test_stripe_integration.py: production secrets are absent
19+
# from CI, so the tests that need them skip instead of failing. The import above
20+
# has already run load_dotenv(), so a local .env is visible here.
21+
_has_stripe_key = bool(os.getenv("STRIPE_SECRET_KEY"))
22+
_has_license_key = bool(os.getenv("QECTOR_LICENSE_PRIVATE_KEY_B64"))
1823

24+
_needs_stripe = pytest.mark.skipif(not _has_stripe_key, reason="STRIPE_SECRET_KEY not set")
25+
_needs_signing = pytest.mark.skipif(not _has_license_key, reason="QECTOR_LICENSE_PRIVATE_KEY_B64 not set")
26+
27+
28+
@_needs_stripe
1929
def test_zero_dollar_checkout_session_creation():
2030
"""Test creating a $0 test checkout session via Stripe."""
2131
with patch("stripe.checkout.Session.create") as mock_create:
@@ -32,6 +42,7 @@ def test_zero_dollar_checkout_session_creation():
3242
assert session["url"].startswith("https://checkout.stripe.com")
3343

3444

45+
@_needs_signing
3546
def test_zero_dollar_webhook_issues_valid_token():
3647
"""Simulate Stripe $0 checkout.session.completed → token issuance → Ed25519 verification."""
3748
test_email = "test_sale_user@qector.store"
@@ -78,6 +89,7 @@ def test_zero_dollar_webhook_issues_valid_token():
7889
assert verify_license_token(issued_token) is True
7990

8091

92+
@_needs_signing
8193
def test_license_activates_via_environment(monkeypatch):
8294
"""Verify QECTOR_LICENSE env var activates the license system."""
8395
test_email = "env_test@qector.store"

0 commit comments

Comments
 (0)