Skip to content

Commit f65532d

Browse files
TheGupta2012claude
andcommitted
Give bit[n] a width-carrying int representation and normalize negative indices
Fixes #385 and #391 in one branch because both rewrite the same index-resolution path. #385: `bit[n]` values were stored inconsistently as `str` (from bitstring literals) or `np.ndarray` (uninitialized), so every bitwise, shift, or index op reached a Python operator that `str` cannot handle and escaped the public API as a raw `TypeError`. The internal representation is now a `BitValue` — an `int` subclass carrying the register width — with shared `bits_to_int` / `int_to_bits` helpers in `pyqasm.analyzer`. `qasm3_expression_op_map` recognizes `BitValue` operands, enforces equal-width for `|`, `&`, `^`, re-masks `~` / shift / binary results to the declared width, and raises `ValidationError` for width-mismatched bitwise ops (the evaluator attaches the source span so the error is properly located). `b[i]` returns a single-bit `int`; `b[a:c]` returns a `BitValue` of the sliced width. Indexed writes (`b[i] = ...`, `b[-1] = ...`) rebuild the integer via a shared `_write_bit_slice` helper. The serialized AST is unchanged: `bit[4] a = "1010";` still round-trips through `dumps()`. #391: Added `Qasm3Analyzer.normalize_index`, applied at every index-resolution site (arrays incl. multi-dim and assignment targets, qubit registers, classical registers, `bit[n]`, `let` aliases, branch conditions, and the transformer's range-expansion helpers). `validate_register_index` now returns the normalized index so callers rewrite the emitted `IntegerLiteral`; downstream passes (`remove_idle_qubits`, `reverse_qubit_order`, and the register consolidator) only see concrete non-negative indices. An index still outside `[-size, size)` after normalization raises the existing out-of-range error and reports the index **as written in the source**. Range endpoints normalize per-endpoint and keep each function's existing convention: qubit ranges stay end-exclusive (matching Python slice semantics), classical array ranges stay end-inclusive. Two deliberate behavior changes fall out of the new representation: - `test_extern_function_call` expected output changed. A `bit[2] b1 = true` extern arg now serializes as `"01"` (the canonical bitstring for the register's value) rather than leaking Python `True`. The test's expected output was the pre-existing bug. - Oversized int inits to a `bit[n]` (e.g. `bit[4] c = 999`) now mask to width. Previously the raw value was stored uncapped; casts through `qasm_variable_type_cast` now go through `BitValue`. Test suite: 795 passed, 3 skipped (all pre-existing). Two CLI tests (`test_validate_qasm_with_invalid_file`, `test_validate_command_with_invalid_file`) fail on this branch and equally on `main` — pre-existing terminal-width truncation in Rich console output, unrelated to this change. Ran `black`, `isort`, `pylint`, `mypy` directly rather than through `tox` because `tox` would `pip install` into the shared environment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9d278a0 commit f65532d

12 files changed

Lines changed: 806 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Types of changes:
1515
## Unreleased
1616

1717
### Added
18+
- Negative indices are now honored across arrays, `bit[n]`, `qubit[n]`, and `let` aliases, including ranges: `myArray[-1]`, `a[-1] = 10`, `h q[-1]`, `bit c = b[-1]`, `let last_three = two[-4:-1]`. An index still outside `[-size, size)` after normalization raises `ValidationError` and names the index as written. ([#391](https://github.com/qBraid/pyqasm/issues/391))
1819

1920
### Improved / Modified
2021

@@ -26,6 +27,7 @@ Types of changes:
2627
- Fixed `pyqasm validate` wrapping its diagnostics at the console width, which split a file path longer than the width across lines mid-token and left it neither copyable nor clickable. The error console now uses `soft_wrap`, keeping one diagnostic per line.
2728
- Fixed an indirect cycle between gate definitions exhausting the Python stack: `gate a q { b q; }` with `gate b q { a q; }` raised a bare `RecursionError` naming nothing, while the direct case was already reported cleanly. The guard compared the body's gate name against one name, so it saw only a cycle of length one. It now tests membership of the whole expansion chain, and names the path: `Recursive definitions not allowed for gate 'a' (a -> b -> a)`. A gate reached twice down separate paths is a diamond, not a cycle, and still expands. ([#369](https://github.com/qBraid/pyqasm/issues/369))
2829
- Fixed a nested external custom gate counting the depth of the decomposition it skipped, the shape the [#352](https://github.com/qBraid/pyqasm/issues/352) fix did not reach: `unroll(external_gates=["outer"])` on a gate whose body calls another custom gate emitted one statement but reported `depth() == 13`. The suppression flag was assigned and cleared without save-restore, so the inner gate clobbered the outer gate's state in both directions. It is now saved and restored, and the depth is recorded once, from the outermost external gate. ([#367](https://github.com/qBraid/pyqasm/issues/367))
30+
- Fixed `|`, `&`, `^`, `~`, `<<`, `>>` and indexing on `bit[n]` escaping a raw `TypeError`, since the value was stored as a Python `str`. A `bit[n]` now carries its width internally, so these operators evaluate and re-mask to `n` bits, `b[i]` and `b[a:c]` read, and mismatched widths raise a `ValidationError`. The `"1010"` literal form still round-trips through `dumps()`. ([#385](https://github.com/qBraid/pyqasm/issues/385))
2931

3032
### Dependencies
3133

src/pyqasm/analyzer.py

Lines changed: 123 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,113 @@
4646
from pyqasm.expressions import Qasm3ExprEvaluator
4747

4848

49+
def bits_to_int(value: Any, width: int) -> int:
50+
"""Convert a ``bit[n]`` value in any legacy form to a masked ``int``.
51+
52+
Accepts the historical representations (``str`` bitstring, ``numpy.ndarray`` of
53+
0/1, plain ``int``/``bool``) and returns a Python ``int`` with only the low
54+
``width`` bits set. Bit 0 of a ``bit[n]`` register is the most-significant bit
55+
of the resulting integer.
56+
57+
Args:
58+
value: The bit value to convert. Empty string yields ``0``.
59+
width: The register width, in bits. Must be non-negative.
60+
61+
Returns:
62+
int: The width-masked integer representation.
63+
"""
64+
if width <= 0:
65+
return 0
66+
mask = (1 << width) - 1
67+
if isinstance(value, str):
68+
if value == "":
69+
return 0
70+
return int(value, 2) & mask
71+
if isinstance(value, np.ndarray):
72+
flat = value.flatten()
73+
if flat.size == 0:
74+
return 0
75+
return int("".join(str(int(b)) for b in flat), 2) & mask
76+
return int(value) & mask
77+
78+
79+
def int_to_bits(value: int, width: int) -> str:
80+
"""Serialize an integer to a zero-padded, width-`n` bit string.
81+
82+
Args:
83+
value: The integer value; only the low ``width`` bits are kept.
84+
width: The register width, in bits. Must be non-negative.
85+
86+
Returns:
87+
str: The zero-padded binary representation. Empty string when ``width == 0``.
88+
"""
89+
if width <= 0:
90+
return ""
91+
mask = (1 << width) - 1
92+
return format(int(value) & mask, f"0{width}b")
93+
94+
4995
class Qasm3Analyzer:
5096
"""Class with utility functions for analyzing QASM3 elements"""
5197

98+
# pylint: disable-next=too-many-arguments
99+
@staticmethod
100+
def normalize_index(
101+
source_index: int,
102+
size: int,
103+
var_name: str,
104+
index_node: Any,
105+
dim_num: Optional[int] = None,
106+
qubit: bool = False,
107+
) -> int:
108+
"""Normalize an index against a register or dimension of size ``size``.
109+
110+
Applies the OpenQASM 3 rule that a negative index counts from the end:
111+
``-1`` is the last element, ``-size`` is the first. After normalization the
112+
index must satisfy ``0 <= idx < size``; otherwise the caller-facing error
113+
reports the *source* index as it appears in the program.
114+
115+
Args:
116+
source_index: The index value as evaluated from source (may be negative).
117+
size: The size of the register or dimension being indexed.
118+
var_name: The register or variable name (used in error messages).
119+
index_node: The AST node used for span attribution on error.
120+
dim_num: Optional zero-based dimension number for multi-dim arrays; if
121+
given, the error message mentions it.
122+
qubit: ``True`` for a qubit register (used for message phrasing).
123+
124+
Returns:
125+
int: The normalized non-negative index.
126+
127+
Raises:
128+
ValidationError: If the index is out of the range
129+
``[-size, size - 1]`` after normalization.
130+
"""
131+
idx = source_index + size if source_index < 0 else source_index
132+
if 0 <= idx < size:
133+
return idx
134+
register_kind = "qubit" if qubit else "clbit"
135+
span = getattr(index_node, "span", None)
136+
if dim_num is not None:
137+
message = (
138+
f"Index {source_index} out of bounds for dimension {dim_num} "
139+
f"of variable '{var_name}'. Expected index in range "
140+
f"[-{size}, {size - 1}]"
141+
)
142+
else:
143+
message = (
144+
f"Index {source_index} out of range for register of size {size} in "
145+
f"{register_kind}"
146+
)
147+
raise_qasm3_error(
148+
message=message,
149+
err_type=ValidationError,
150+
error_node=index_node,
151+
span=span,
152+
)
153+
# pragma: no cover - raise_qasm3_error never returns
154+
raise ValidationError(message)
155+
52156
@staticmethod
53157
def analyze_classical_indices(
54158
indices: list[Any], var: Variable, expr_evaluator: Qasm3ExprEvaluator
@@ -88,16 +192,6 @@ def analyze_classical_indices(
88192
span=indices[0].span,
89193
)
90194

91-
def _validate_index(index, dimension, var_name, index_node, dim_num):
92-
if index < 0 or index >= dimension:
93-
raise_qasm3_error(
94-
message=f"Index {index} out of bounds for dimension {dim_num} "
95-
f"of variable '{var_name}'. Expected index in range [0, {dimension-1}]",
96-
err_type=ValidationError,
97-
error_node=index_node,
98-
span=index_node.span,
99-
)
100-
101195
def _validate_step(start_id, end_id, step, index_node):
102196
if (step < 0 and start_id < end_id) or (step > 0 and start_id > end_id):
103197
direction = "less than" if step < 0 else "greater than"
@@ -121,29 +215,40 @@ def _validate_step(start_id, end_id, step, index_node):
121215

122216
if isinstance(index, RangeDefinition):
123217
assert var_dimensions is not None
218+
dim_size = var_dimensions[i]
124219

125-
start_id = 0
126220
if index.start is not None:
127-
start_id = expr_evaluator.evaluate_expression(index.start, reqd_type=IntType)[0]
221+
raw_start = expr_evaluator.evaluate_expression(index.start, reqd_type=IntType)[
222+
0
223+
]
224+
start_id = Qasm3Analyzer.normalize_index(
225+
raw_start, dim_size, var.name, index, dim_num=i
226+
)
227+
else:
228+
start_id = 0
128229

129-
end_id = var_dimensions[i] - 1
130230
if index.end is not None:
131-
end_id = expr_evaluator.evaluate_expression(index.end, reqd_type=IntType)[0]
231+
raw_end = expr_evaluator.evaluate_expression(index.end, reqd_type=IntType)[0]
232+
end_id = Qasm3Analyzer.normalize_index(
233+
raw_end, dim_size, var.name, index, dim_num=i
234+
)
235+
else:
236+
end_id = dim_size - 1
132237

133238
step = 1
134239
if index.step is not None:
135240
step = expr_evaluator.evaluate_expression(index.step, reqd_type=IntType)[0]
136241

137-
_validate_index(start_id, var_dimensions[i], var.name, index, i)
138-
_validate_index(end_id, var_dimensions[i], var.name, index, i)
139242
_validate_step(start_id, end_id, step, index)
140243

141244
indices_list.append((start_id, end_id, step))
142245

143246
if isinstance(index, (Identifier, IntegerLiteral, Expression)):
144-
index_value = expr_evaluator.evaluate_expression(index, reqd_type=IntType)[0]
247+
raw_value = expr_evaluator.evaluate_expression(index, reqd_type=IntType)[0]
145248
curr_dimension = var_dimensions[i] # type: ignore[index]
146-
_validate_index(index_value, curr_dimension, var.name, index, i)
249+
index_value = Qasm3Analyzer.normalize_index(
250+
raw_value, curr_dimension, var.name, index, dim_num=i
251+
)
147252

148253
indices_list.append((index_value, index_value, 1))
149254

src/pyqasm/elements.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,43 @@ def is_physical_qubit(qubit_name: str) -> bool:
6767
return qubit_name.startswith(PHYSICAL_QUBIT_PREFIX) and qubit_name[1:].isdigit()
6868

6969

70+
class BitValue(int):
71+
"""Internal representation of a ``bit`` or ``bit[n]`` classical register value.
72+
73+
A ``BitValue`` is an ``int`` masked to ``width`` bits, plus the ``width`` itself.
74+
The width lets bitwise operators enforce the OpenQASM 3 rule that both operands
75+
of a binary bitwise op (``|``, ``&``, ``^``) have equal width. Bit 0 of the
76+
register is the most-significant bit of the underlying int, matching the
77+
``BitstringLiteral`` convention used by :func:`~pyqasm.dumps` and by
78+
``format(v, f"0{width}b")``.
79+
80+
``BitValue`` is immutable (as ``int`` is); write paths that mutate a single bit
81+
of a ``bit[n]`` register construct a fresh ``BitValue`` and rebind the variable.
82+
"""
83+
84+
# ``int`` uses a variable-length storage layout, so ``__slots__`` is not
85+
# permitted on subclasses; the ``width`` attribute lives on the instance
86+
# ``__dict__``. Declared here so type checkers see it as a proper attribute.
87+
width: int
88+
89+
def __new__(cls, value: int, width: int) -> "BitValue":
90+
if width < 0:
91+
raise ValueError(f"BitValue width must be non-negative, got {width}")
92+
mask = (1 << width) - 1 if width > 0 else 0
93+
obj = int.__new__(cls, int(value) & mask)
94+
obj.width = width
95+
return obj
96+
97+
def to_bitstring(self) -> str:
98+
"""Return the zero-padded, width-`n` binary string for this register."""
99+
if self.width == 0:
100+
return ""
101+
return format(int(self), f"0{self.width}b")
102+
103+
def __repr__(self) -> str: # pragma: no cover - diagnostic aid only
104+
return f"BitValue({int(self)}, width={self.width})"
105+
106+
70107
class InversionOp(Enum):
71108
"""
72109
Enum for specifying the inversion action of a gate.

src/pyqasm/expressions.py

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@
4545
UnaryExpression,
4646
)
4747

48-
from pyqasm.analyzer import Qasm3Analyzer
49-
from pyqasm.elements import Variable
48+
from pyqasm.analyzer import Qasm3Analyzer, bits_to_int
49+
from pyqasm.elements import BitValue, Variable
5050
from pyqasm.exceptions import ValidationError, raise_qasm3_error
5151
from pyqasm.maps.expressions import (
5252
CONSTANTS_MAP,
@@ -168,6 +168,7 @@ def _check_var_initialized(cls, var_name, var_value, expression):
168168
span=expression.span,
169169
)
170170

171+
# pylint: disable-next=too-many-locals
171172
@classmethod
172173
def _get_var_value(cls, var_name, indices, expression):
173174
"""Retrieves the value of a variable.
@@ -180,18 +181,35 @@ def _get_var_value(cls, var_name, indices, expression):
180181
var_value: The value of the variable.
181182
"""
182183

183-
var_value = None
184+
var = cls.visitor_obj._scope_manager.get_from_visible_scope(var_name)
184185
if isinstance(expression, Identifier):
185-
var_value = cls.visitor_obj._scope_manager.get_from_visible_scope(var_name).value
186-
else:
187-
validated_indices = Qasm3Analyzer.analyze_classical_indices(
188-
indices, cls.visitor_obj._scope_manager.get_from_visible_scope(var_name), cls
189-
)
190-
var_value = Qasm3Analyzer.find_array_element(
191-
cls.visitor_obj._scope_manager.get_from_visible_scope(var_name).value,
192-
validated_indices,
193-
)
194-
return var_value
186+
return var.value
187+
188+
validated_indices = Qasm3Analyzer.analyze_classical_indices(indices, var, cls)
189+
190+
# ``bit`` / ``bit[n]`` values are stored as a width-carrying ``BitValue``
191+
# (an ``int``), not an ndarray. Extract the selected bits with shift and
192+
# mask so ``b[i]`` yields a single-bit ``int`` and ``b[a:c]`` yields a
193+
# ``BitValue`` of width ``c - a + 1`` (spec-inclusive range).
194+
if isinstance(var.base_type, BitType):
195+
start, end, step = validated_indices[0]
196+
width = var.base_size
197+
source_int = bits_to_int(var.value, width)
198+
if start == end:
199+
# Single bit: bit 0 is the most-significant bit of the int, per
200+
# the ``format(v, f"0{n}b")`` convention that ``dumps()`` uses.
201+
return (source_int >> (width - 1 - start)) & 1
202+
# Ranged read — build the sub-bitstring by iterating in the step
203+
# order (already validated non-empty by ``analyze_classical_indices``).
204+
selected_positions = list(range(start, end + 1, step))
205+
slice_width = len(selected_positions)
206+
result = 0
207+
for pos in selected_positions:
208+
bit = (source_int >> (width - 1 - pos)) & 1
209+
result = (result << 1) | bit
210+
return BitValue(result, slice_width)
211+
212+
return Qasm3Analyzer.find_array_element(var.value, validated_indices)
195213

196214
@classmethod
197215
# pylint: disable-next=too-many-return-statements,too-many-branches,too-many-statements,too-many-locals,too-many-arguments
@@ -476,9 +494,21 @@ def _get_external_function_return_type(expression):
476494
return (None, [])
477495

478496
statements.extend(rhs_statements)
479-
return _check_and_return_value(
480-
qasm3_expression_op_map(expression.op.name, lhs_value, rhs_value)
481-
)
497+
try:
498+
op_result = qasm3_expression_op_map(expression.op.name, lhs_value, rhs_value)
499+
except ValidationError as err:
500+
# ``qasm3_expression_op_map`` has no access to the source span
501+
# (e.g. it raises for a ``bit[n]`` width mismatch); attach it
502+
# here so the caller sees a properly-located error rather than a
503+
# bare message.
504+
raise_qasm3_error(
505+
str(err),
506+
err_type=ValidationError,
507+
error_node=expression,
508+
span=expression.span,
509+
raised_from=err,
510+
)
511+
return _check_and_return_value(op_result)
482512

483513
if isinstance(expression, FunctionCall):
484514
# function will not return a reqd / const type

0 commit comments

Comments
 (0)