Skip to content

Commit 3c8b768

Browse files
rafaelhaclaude
andauthored
Validate probabilities in noise channel constructors (#132)
## Summary The Pauli channel probability helpers (`error_probs`, `pauli_channel_1_probs`, `pauli_channel_2_probs`, `heralded_pauli_channel_1_probs`) accepted invalid inputs and produced malformed distributions: `probs[0]` could come out negative when individual entries were outside `[0, 1]` or their sum exceeded 1. `ChannelSampler._precompute_sparse` then silently dropped those channels because `p_fire = 1 - probs[0] > 1` doesn't trigger its `p_fire <= 1e-15` early-return — sampling proceeded as if the channel did nothing. In the normal stim-driven pipeline this is unreachable (stim rejects bad probabilities at parse time), but it bites anyone constructing a `Channel` directly. - Added `_validate_probabilities` to check each input is in `[0, 1]` and the sum is ≤ 1. - Wired it into all four Pauli helpers. - `ChannelSampler._precompute_sparse` now raises `ValueError` when `p_fire < 0` instead of silently skipping. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 988aab7 commit 3c8b768

2 files changed

Lines changed: 46 additions & 0 deletions

File tree

src/tsim/noise/channels.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ class Channel:
2727
probs: np.ndarray
2828
unique_col_ids: tuple[int, ...]
2929

30+
def __post_init__(self) -> None:
31+
"""Validate channel probabilities."""
32+
tol = 1e-12
33+
if np.any(self.probs < -tol) or np.any(self.probs > 1.0 + tol):
34+
raise ValueError(f"Probabilities must lie in [0, 1], but got: {self.probs}")
35+
if not np.isclose(np.sum(self.probs), 1.0):
36+
raise ValueError(
37+
f"Probabilities must sum to 1, but got: {self.probs} "
38+
f"(sum {np.sum(self.probs)})"
39+
)
40+
3041
@property
3142
def num_bits(self) -> int:
3243
"""Number of bits in the channel (k where probs has shape 2^k)."""

test/unit/noise/test_channels.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,41 @@ def test_chain_with_certain_first_error(self):
162162
assert_allclose(probs[4], 0.0) # index 4 (0b100): third error
163163

164164

165+
class TestChannelValidation:
166+
"""Channel.__post_init__ rejects malformed probability distributions so
167+
they cannot reach the sampler and get silently dropped."""
168+
169+
def test_rejects_negative_entry(self):
170+
with pytest.raises(ValueError):
171+
Channel(probs=np.array([1.2, -0.2]), unique_col_ids=(0,))
172+
173+
def test_rejects_entry_above_one(self):
174+
with pytest.raises(ValueError):
175+
Channel(probs=np.array([-0.5, 1.5]), unique_col_ids=(0,))
176+
177+
def test_rejects_sum_below_one(self):
178+
with pytest.raises(ValueError):
179+
Channel(probs=np.array([0.5, 0.4]), unique_col_ids=(0,))
180+
181+
def test_rejects_sum_above_one(self):
182+
with pytest.raises(ValueError):
183+
Channel(probs=np.array([0.7, 0.7]), unique_col_ids=(0,))
184+
185+
def test_rejects_helper_with_arguments_summing_above_one(self):
186+
# pauli_channel_1_probs(0.6, 0.6, 0.6) returns probs[0] = -0.8.
187+
# Wrapping in a Channel surfaces the negative entry as a clear error.
188+
with pytest.raises(ValueError):
189+
Channel(probs=pauli_channel_1_probs(0.6, 0.6, 0.6), unique_col_ids=(0, 1))
190+
191+
def test_channel_sampler_rejects_invalid_channel(self):
192+
# ChannelSampler wraps probs in a Channel, so invalid distributions
193+
# get caught at construction time rather than silently dropped.
194+
bad_probs = np.array([1.2, -0.1, -0.05, -0.05])
195+
transform = np.array([[1, 0], [0, 1]], dtype=np.uint8)
196+
with pytest.raises(ValueError):
197+
ChannelSampler([bad_probs], transform, seed=42)
198+
199+
165200
def _sample_channels(channels, matrix, n_samples, seed=42):
166201
"""Sample from channels using the ChannelSampler infrastructure."""
167202
sampler = object.__new__(ChannelSampler)

0 commit comments

Comments
 (0)