Skip to content

Commit 0b61e86

Browse files
jon-wurtzclaude
andcommitted
fix(python): raise IndexError for any out-of-range projection target
A target larger than usize::MAX passed the Python non-negativity check and failed during PyO3 conversion with OverflowError, contradicting the documented IndexError. Validate 0 <= target < n_qubits in the Python wrapper before calling the native layer, covering negative, oversized, and arbitrarily large integers. The native range check remains for Rust callers. Reported by Copilot review on #240. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5a4859d commit 0b61e86

2 files changed

Lines changed: 18 additions & 12 deletions

File tree

‎ppvm-python/src/ppvm/generalized_tableau.py‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,24 @@ def _projection_outcome(value: MeasurementResult) -> bool:
6565

6666

6767
def _projection_args(
68-
targets: Iterable[int], values: Iterable[MeasurementResult]
68+
targets: Iterable[int], values: Iterable[MeasurementResult], n_qubits: int
6969
) -> tuple[list[int], list[bool]]:
70-
"""Convert ``project_many`` / ``probability`` arguments for the native call.
70+
"""Validate and convert ``project_many`` / ``probability`` arguments.
7171
72-
Length mismatches and indices ``>= n_qubits`` are checked natively.
72+
Length mismatches are checked natively.
7373
"""
7474
targets = list(targets)
7575
for addr0 in targets:
76-
_check_nonnegative_index(addr0)
76+
_check_qubit_index(addr0, n_qubits)
7777
return targets, [_projection_outcome(v) for v in values]
7878

7979

80-
def _check_nonnegative_index(addr0: int) -> None:
81-
# The native layer takes unsigned indices; a negative int would surface as
82-
# OverflowError instead of the documented IndexError.
83-
if addr0 < 0:
84-
raise IndexError(f"qubit index must be non-negative (got {addr0}).")
80+
def _check_qubit_index(addr0: int, n_qubits: int) -> None:
81+
# Check here rather than relying on the native check: the native layer
82+
# takes unsigned machine-sized indices, so a negative or very large int
83+
# would surface as OverflowError instead of the documented IndexError.
84+
if not 0 <= addr0 < n_qubits:
85+
raise IndexError(f"qubit index {addr0} is out of range for {n_qubits} qubits.")
8586

8687

8788
@dataclass(frozen=True)
@@ -246,7 +247,7 @@ def project(self, addr0: int, value: MeasurementResult) -> float:
246247
be normalized). The state is left unchanged.
247248
IndexError: If ``addr0`` is not a valid qubit index.
248249
"""
249-
_check_nonnegative_index(addr0)
250+
_check_qubit_index(addr0, self.n_qubits)
250251
return self._interface.project(addr0, _projection_outcome(value))
251252

252253
def project_many(self, targets: Iterable[int], values: Iterable[MeasurementResult]) -> float:
@@ -273,7 +274,7 @@ def project_many(self, targets: Iterable[int], values: Iterable[MeasurementResul
273274
unchanged.
274275
IndexError: If any target is not a valid qubit index.
275276
"""
276-
return self._interface.project_many(*_projection_args(targets, values))
277+
return self._interface.project_many(*_projection_args(targets, values, self.n_qubits))
277278

278279
def probability(self, targets: Iterable[int], values: Iterable[MeasurementResult]) -> float:
279280
"""Return the joint probability of measuring ``values`` on ``targets``.
@@ -298,7 +299,7 @@ def probability(self, targets: Iterable[int], values: Iterable[MeasurementResult
298299
ValueError: If ``targets`` and ``values`` differ in length.
299300
IndexError: If any target is not a valid qubit index.
300301
"""
301-
return self._interface.probability(*_projection_args(targets, values))
302+
return self._interface.probability(*_projection_args(targets, values, self.n_qubits))
302303

303304
def measure_many(self, *targets: int | Iterable[int]) -> list[MeasurementResult]:
304305
"""Measure several qubits in the Z basis.

‎ppvm-python/test/generalized_tableau/test_project.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,11 @@ def test_project_out_of_range_is_atomic():
241241
tab.project_many([0, -1], [ZERO, ZERO])
242242
with pytest.raises(IndexError):
243243
tab.probability([-1], [ZERO])
244+
# Larger than usize::MAX: must still be IndexError, not OverflowError.
245+
with pytest.raises(IndexError):
246+
tab.project(2**64, ZERO)
247+
with pytest.raises(IndexError):
248+
tab.probability([0, 2**64], [ZERO, ZERO])
244249
assert str(tab) == before
245250
assert tab.current_measurement_record() == []
246251

0 commit comments

Comments
 (0)