From cc80ec7cbc6c28d03ed0c282616a81a2a85a63c7 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Mon, 24 Aug 2026 13:10:07 +0530 Subject: [PATCH 1/2] Give bit[n] a width-carrying int representation and normalize negative indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 2 + src/pyqasm/analyzer.py | 142 ++++++++++-- src/pyqasm/elements.py | 37 +++ src/pyqasm/expressions.py | 63 ++++-- src/pyqasm/maps/expressions.py | 64 +++++- src/pyqasm/pulse/validator.py | 17 ++ src/pyqasm/transformer.py | 65 ++++-- src/pyqasm/validator.py | 43 +++- src/pyqasm/visitor.py | 87 +++++-- tests/qasm3/subroutines/test_subroutines.py | 6 +- tests/qasm3/test_expressions.py | 128 ++++++++++- tests/qasm3/test_negative_indices.py | 237 ++++++++++++++++++++ 12 files changed, 806 insertions(+), 85 deletions(-) create mode 100644 tests/qasm3/test_negative_indices.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3583cfef..e8734f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Types of changes: ## Unreleased ### Added +- 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)) ### Improved / Modified @@ -26,6 +27,7 @@ Types of changes: - 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. - 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)) - 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)) +- 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)) ### Dependencies diff --git a/src/pyqasm/analyzer.py b/src/pyqasm/analyzer.py index 2eaa69de..d5743862 100644 --- a/src/pyqasm/analyzer.py +++ b/src/pyqasm/analyzer.py @@ -46,11 +46,114 @@ from pyqasm.expressions import Qasm3ExprEvaluator +def bits_to_int(value: Any, width: int) -> int: + """Convert a ``bit[n]`` value in any legacy form to a masked ``int``. + + Accepts the historical representations (``str`` bitstring, ``numpy.ndarray`` of + 0/1, plain ``int``/``bool``) and returns a Python ``int`` with only the low + ``width`` bits set. Bit 0 of a ``bit[n]`` register is the most-significant bit + of the resulting integer. + + Args: + value: The bit value to convert. Empty string yields ``0``. + width: The register width, in bits. Must be non-negative. + + Returns: + int: The width-masked integer representation. + """ + if width <= 0: + return 0 + mask = (1 << width) - 1 + if isinstance(value, str): + if value == "": + return 0 + return int(value, 2) & mask + if isinstance(value, np.ndarray): + flat = value.flatten() + if flat.size == 0: + return 0 + return int("".join(str(int(b)) for b in flat), 2) & mask + return int(value) & mask + + +def int_to_bits(value: int, width: int) -> str: + """Serialize an integer to a zero-padded, width-`n` bit string. + + Args: + value: The integer value; only the low ``width`` bits are kept. + width: The register width, in bits. Must be non-negative. + + Returns: + str: The zero-padded binary representation. Empty string when ``width == 0``. + """ + if width <= 0: + return "" + mask = (1 << width) - 1 + return format(int(value) & mask, f"0{width}b") + + class Qasm3Analyzer: """Class with utility functions for analyzing QASM3 elements""" @staticmethod - def analyze_classical_indices( + def normalize_index( # pylint: disable=too-many-arguments + source_index: int, + size: int, + var_name: str, + index_node: Any, + dim_num: Optional[int] = None, + qubit: bool = False, + ) -> int: + """Normalize an index against a register or dimension of size ``size``. + + Applies the OpenQASM 3 rule that a negative index counts from the end: + ``-1`` is the last element, ``-size`` is the first. After normalization the + index must satisfy ``0 <= idx < size``; otherwise the caller-facing error + reports the *source* index as it appears in the program. + + Args: + source_index: The index value as evaluated from source (may be negative). + size: The size of the register or dimension being indexed. + var_name: The register or variable name (used in error messages). + index_node: The AST node used for span attribution on error. + dim_num: Optional zero-based dimension number for multi-dim arrays; if + given, the error message mentions it. + qubit: ``True`` for a qubit register (used for message phrasing). + + Returns: + int: The normalized non-negative index. + + Raises: + ValidationError: If the index is out of the range + ``[-size, size - 1]`` after normalization. + """ + idx = source_index + size if source_index < 0 else source_index + if 0 <= idx < size: + return idx + register_kind = "qubit" if qubit else "clbit" + span = getattr(index_node, "span", None) + if dim_num is not None: + message = ( + f"Index {source_index} out of bounds for dimension {dim_num} " + f"of variable '{var_name}'. Expected index in range " + f"[-{size}, {size - 1}]" + ) + else: + message = ( + f"Index {source_index} out of range for register of size {size} in " + f"{register_kind}" + ) + raise_qasm3_error( + message=message, + err_type=ValidationError, + error_node=index_node, + span=span, + ) + # pragma: no cover - raise_qasm3_error never returns + raise ValidationError(message) + + @staticmethod + def analyze_classical_indices( # pylint: disable=too-many-locals indices: list[Any], var: Variable, expr_evaluator: Qasm3ExprEvaluator ) -> list: """Validate the indices for a classical variable. @@ -88,16 +191,6 @@ def analyze_classical_indices( span=indices[0].span, ) - def _validate_index(index, dimension, var_name, index_node, dim_num): - if index < 0 or index >= dimension: - raise_qasm3_error( - message=f"Index {index} out of bounds for dimension {dim_num} " - f"of variable '{var_name}'. Expected index in range [0, {dimension-1}]", - err_type=ValidationError, - error_node=index_node, - span=index_node.span, - ) - def _validate_step(start_id, end_id, step, index_node): if (step < 0 and start_id < end_id) or (step > 0 and start_id > end_id): direction = "less than" if step < 0 else "greater than" @@ -121,29 +214,40 @@ def _validate_step(start_id, end_id, step, index_node): if isinstance(index, RangeDefinition): assert var_dimensions is not None + dim_size = var_dimensions[i] - start_id = 0 if index.start is not None: - start_id = expr_evaluator.evaluate_expression(index.start, reqd_type=IntType)[0] + raw_start = expr_evaluator.evaluate_expression(index.start, reqd_type=IntType)[ + 0 + ] + start_id = Qasm3Analyzer.normalize_index( + raw_start, dim_size, var.name, index, dim_num=i + ) + else: + start_id = 0 - end_id = var_dimensions[i] - 1 if index.end is not None: - end_id = expr_evaluator.evaluate_expression(index.end, reqd_type=IntType)[0] + raw_end = expr_evaluator.evaluate_expression(index.end, reqd_type=IntType)[0] + end_id = Qasm3Analyzer.normalize_index( + raw_end, dim_size, var.name, index, dim_num=i + ) + else: + end_id = dim_size - 1 step = 1 if index.step is not None: step = expr_evaluator.evaluate_expression(index.step, reqd_type=IntType)[0] - _validate_index(start_id, var_dimensions[i], var.name, index, i) - _validate_index(end_id, var_dimensions[i], var.name, index, i) _validate_step(start_id, end_id, step, index) indices_list.append((start_id, end_id, step)) if isinstance(index, (Identifier, IntegerLiteral, Expression)): - index_value = expr_evaluator.evaluate_expression(index, reqd_type=IntType)[0] + raw_value = expr_evaluator.evaluate_expression(index, reqd_type=IntType)[0] curr_dimension = var_dimensions[i] # type: ignore[index] - _validate_index(index_value, curr_dimension, var.name, index, i) + index_value = Qasm3Analyzer.normalize_index( + raw_value, curr_dimension, var.name, index, dim_num=i + ) indices_list.append((index_value, index_value, 1)) diff --git a/src/pyqasm/elements.py b/src/pyqasm/elements.py index 3950da36..fc268671 100644 --- a/src/pyqasm/elements.py +++ b/src/pyqasm/elements.py @@ -67,6 +67,43 @@ def is_physical_qubit(qubit_name: str) -> bool: return qubit_name.startswith(PHYSICAL_QUBIT_PREFIX) and qubit_name[1:].isdigit() +class BitValue(int): + """Internal representation of a ``bit`` or ``bit[n]`` classical register value. + + A ``BitValue`` is an ``int`` masked to ``width`` bits, plus the ``width`` itself. + The width lets bitwise operators enforce the OpenQASM 3 rule that both operands + of a binary bitwise op (``|``, ``&``, ``^``) have equal width. Bit 0 of the + register is the most-significant bit of the underlying int, matching the + ``BitstringLiteral`` convention used by :func:`~pyqasm.dumps` and by + ``format(v, f"0{width}b")``. + + ``BitValue`` is immutable (as ``int`` is); write paths that mutate a single bit + of a ``bit[n]`` register construct a fresh ``BitValue`` and rebind the variable. + """ + + # ``int`` uses a variable-length storage layout, so ``__slots__`` is not + # permitted on subclasses; the ``width`` attribute lives on the instance + # ``__dict__``. Declared here so type checkers see it as a proper attribute. + width: int + + def __new__(cls, value: int, width: int) -> "BitValue": + if width < 0: + raise ValueError(f"BitValue width must be non-negative, got {width}") + mask = (1 << width) - 1 if width > 0 else 0 + obj = int.__new__(cls, int(value) & mask) + obj.width = width + return obj + + def to_bitstring(self) -> str: + """Return the zero-padded, width-`n` binary string for this register.""" + if self.width == 0: + return "" + return format(int(self), f"0{self.width}b") + + def __repr__(self) -> str: # pragma: no cover - diagnostic aid only + return f"BitValue({int(self)}, width={self.width})" + + class InversionOp(Enum): """ Enum for specifying the inversion action of a gate. diff --git a/src/pyqasm/expressions.py b/src/pyqasm/expressions.py index 0ad37c6d..29421fca 100644 --- a/src/pyqasm/expressions.py +++ b/src/pyqasm/expressions.py @@ -45,8 +45,8 @@ UnaryExpression, ) -from pyqasm.analyzer import Qasm3Analyzer -from pyqasm.elements import Variable +from pyqasm.analyzer import Qasm3Analyzer, bits_to_int +from pyqasm.elements import BitValue, Variable from pyqasm.exceptions import ValidationError, raise_qasm3_error from pyqasm.maps.expressions import ( CONSTANTS_MAP, @@ -169,7 +169,7 @@ def _check_var_initialized(cls, var_name, var_value, expression): ) @classmethod - def _get_var_value(cls, var_name, indices, expression): + def _get_var_value(cls, var_name, indices, expression): # pylint: disable=too-many-locals """Retrieves the value of a variable. Args: @@ -180,18 +180,35 @@ def _get_var_value(cls, var_name, indices, expression): var_value: The value of the variable. """ - var_value = None + var = cls.visitor_obj._scope_manager.get_from_visible_scope(var_name) if isinstance(expression, Identifier): - var_value = cls.visitor_obj._scope_manager.get_from_visible_scope(var_name).value - else: - validated_indices = Qasm3Analyzer.analyze_classical_indices( - indices, cls.visitor_obj._scope_manager.get_from_visible_scope(var_name), cls - ) - var_value = Qasm3Analyzer.find_array_element( - cls.visitor_obj._scope_manager.get_from_visible_scope(var_name).value, - validated_indices, - ) - return var_value + return var.value + + validated_indices = Qasm3Analyzer.analyze_classical_indices(indices, var, cls) + + # ``bit`` / ``bit[n]`` values are stored as a width-carrying ``BitValue`` + # (an ``int``), not an ndarray. Extract the selected bits with shift and + # mask so ``b[i]`` yields a single-bit ``int`` and ``b[a:c]`` yields a + # ``BitValue`` of width ``c - a + 1`` (spec-inclusive range). + if isinstance(var.base_type, BitType): + start, end, step = validated_indices[0] + width = var.base_size + source_int = bits_to_int(var.value, width) + if start == end: + # Single bit: bit 0 is the most-significant bit of the int, per + # the ``format(v, f"0{n}b")`` convention that ``dumps()`` uses. + return (source_int >> (width - 1 - start)) & 1 + # Ranged read — build the sub-bitstring by iterating in the step + # order (already validated non-empty by ``analyze_classical_indices``). + selected_positions = list(range(start, end + 1, step)) + slice_width = len(selected_positions) + result = 0 + for pos in selected_positions: + bit = (source_int >> (width - 1 - pos)) & 1 + result = (result << 1) | bit + return BitValue(result, slice_width) + + return Qasm3Analyzer.find_array_element(var.value, validated_indices) @classmethod # pylint: disable-next=too-many-return-statements,too-many-branches,too-many-statements,too-many-locals,too-many-arguments @@ -476,9 +493,21 @@ def _get_external_function_return_type(expression): return (None, []) statements.extend(rhs_statements) - return _check_and_return_value( - qasm3_expression_op_map(expression.op.name, lhs_value, rhs_value) - ) + try: + op_result = qasm3_expression_op_map(expression.op.name, lhs_value, rhs_value) + except ValidationError as err: + # ``qasm3_expression_op_map`` has no access to the source span + # (e.g. it raises for a ``bit[n]`` width mismatch); attach it + # here so the caller sees a properly-located error rather than a + # bare message. + raise_qasm3_error( + str(err), + err_type=ValidationError, + error_node=expression, + span=expression.span, + raised_from=err, + ) + return _check_and_return_value(op_result) if isinstance(expression, FunctionCall): # function will not return a reqd / const type diff --git a/src/pyqasm/maps/expressions.py b/src/pyqasm/maps/expressions.py index 6a907ac9..489fce04 100644 --- a/src/pyqasm/maps/expressions.py +++ b/src/pyqasm/maps/expressions.py @@ -32,6 +32,7 @@ UintType, ) +from pyqasm.elements import BitValue from pyqasm.exceptions import ValidationError # Define the type for the operator functions @@ -67,6 +68,13 @@ } +# Binary bitwise operators for which both operands must be the same width when +# they are ``BitValue``s. Shifts (``<<``, ``>>``) take an int shift-count and are +# not width-checked against the right-hand side. +_BITWISE_EQUAL_WIDTH_OPS = frozenset({"|", "&", "^"}) +_BITWISE_SHIFT_OPS = frozenset({"<<", ">>"}) + + def qasm3_expression_op_map(op_name: str, *args) -> float | int | bool: """ Return the result of applying the given operator to the given operands. @@ -79,15 +87,46 @@ def qasm3_expression_op_map(op_name: str, *args) -> float | int | bool: Returns: (float | int | bool): The result of applying the operator to the operands. + + Raises: + ValidationError: For unknown operators, or when two ``BitValue`` operands of + a width-sensitive bitwise op have different widths. """ try: operator = OPERATOR_MAP[op_name] - return operator(*args) except KeyError as exc: raise ValidationError(f"Unsupported / undeclared QASM operator: {op_name}") from exc + if len(args) == 2: + lhs, rhs = args + lhs_is_bit = isinstance(lhs, BitValue) + rhs_is_bit = isinstance(rhs, BitValue) + if lhs_is_bit or rhs_is_bit: + width = lhs.width if lhs_is_bit else rhs.width # type: ignore[union-attr] + if op_name in _BITWISE_EQUAL_WIDTH_OPS: + if lhs_is_bit and rhs_is_bit and lhs.width != rhs.width: # type: ignore[union-attr] + raise ValidationError( + f"Width mismatch for bitwise '{op_name}': " + f"lhs has width {lhs.width} but rhs has width " # type: ignore[union-attr] + f"{rhs.width}" # type: ignore[union-attr] + ) + return BitValue(int(operator(int(lhs), int(rhs))), width) # type: ignore[call-arg] + if op_name in _BITWISE_SHIFT_OPS and lhs_is_bit: + return BitValue(int(operator(int(lhs), int(rhs))), width) # type: ignore[call-arg] + return operator(*args) # type: ignore[call-arg] -# pylint: disable=inconsistent-return-statements,too-many-return-statements + if len(args) == 1: + (operand,) = args + if isinstance(operand, BitValue) and op_name == "~": + # ``~`` on Python ints turns the operand negative; re-mask to width so + # the result remains a valid ``bit[n]`` value. + return BitValue(int(operator(int(operand))), operand.width) # type: ignore[call-arg] + return operator(*args) # type: ignore[call-arg] + + return operator(*args) + + +# pylint: disable=inconsistent-return-statements,too-many-return-statements,too-many-branches def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value): """Cast the variable type to the type to match, if possible. @@ -101,7 +140,10 @@ def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value): Raises: ValidationError: If the cast is not possible. """ - type_of_rhs = type(rhs_value) + # ``BitValue`` is an ``int`` subclass; treat it as ``int`` for the type-cast + # table lookup so a value read from a ``bit[n]`` register can flow into a + # cast to bool / int / uint / float without a bespoke tuple entry per site. + type_of_rhs = int if isinstance(rhs_value, BitValue) else type(rhs_value) if openqasm_type in (DurationType, StretchType): return rhs_value @@ -121,10 +163,18 @@ def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value): return int(rhs_value) % (2**base_size) if openqasm_type == FloatType: return float(rhs_value) - # not sure if we wanna hande array bit assignments too. - # For now, we only cater to single bit assignment. + # ``bit`` / ``bit[n]`` values are normalized to a width-carrying ``BitValue`` + # so downstream bitwise operators can enforce the OpenQASM equal-width rule + # and produce properly-masked results. A ``str`` bitstring ("1010") is parsed + # into its integer form; the caller (``_visit_classical_declaration`` or + # ``_visit_classical_assignment``) is responsible for validating the string + # width against ``base_size`` before we get here. if openqasm_type == BitType: - return rhs_value + if isinstance(rhs_value, BitValue): + return rhs_value + if isinstance(rhs_value, str): + return BitValue(int(rhs_value, 2) if rhs_value else 0, base_size) + return BitValue(int(rhs_value), base_size) if openqasm_type == AngleType: if isinstance(rhs_value, bool): return ((2 * CONSTANTS_MAP["pi"]) * (1 / 2)) if rhs_value else 0.0 @@ -164,7 +214,7 @@ def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value): VARIABLE_TYPE_CAST_MAP = { BoolType: (int, float, bool, np.int64, np.float64, np.bool_), IntType: (bool, int, float, np.int64, np.float64, np.bool_), - BitType: (bool, int, np.int64, np.bool_, str), + BitType: (bool, int, np.int64, np.bool_, str, BitValue), UintType: (bool, int, float, np.int64, np.uint64, np.float64, np.bool_), FloatType: (bool, int, float, np.int64, np.float64, np.bool_), AngleType: (float, np.float64, bool, np.bool_), diff --git a/src/pyqasm/pulse/validator.py b/src/pyqasm/pulse/validator.py index 888f8180..c76ad93b 100644 --- a/src/pyqasm/pulse/validator.py +++ b/src/pyqasm/pulse/validator.py @@ -46,6 +46,7 @@ TimeUnit, ) +from pyqasm.elements import BitValue from pyqasm.exceptions import raise_qasm3_error from pyqasm.expressions import Qasm3ExprEvaluator from pyqasm.maps.expressions import CONSTANTS_MAP @@ -472,6 +473,22 @@ def validate_and_process_extern_function_call( # pylint: disable=too-many-branc float(arg_var.value) if arg_var.value is not None else 0.0, unit=(TimeUnit.dt if device_cycle_time else TimeUnit.ns), ) + elif isinstance(arg_var.value, BitValue): + # ``bit[n]`` variables are internally a width-carrying int; emit + # them as a ``BitstringLiteral`` so the serialized call reads + # ``func("0101")`` rather than a bare int. + statement.arguments[i] = BitstringLiteral( + int(arg_var.value), arg_var.value.width + ) + elif isinstance(arg_var.base_type, BitType): + # Legacy path: a ``bit`` variable whose value is not yet a + # ``BitValue`` (str / int / bool) but is typed ``bit[n]``. + width = arg_var.base_size + if isinstance(arg_var.value, str): + value = int(arg_var.value, 2) if arg_var.value else 0 + else: + value = int(bool(arg_var.value)) + statement.arguments[i] = BitstringLiteral(value, width) elif isinstance(arg_var.value, float): statement.arguments[i] = FloatLiteral(arg_var.value) elif isinstance(arg_var.value, int): diff --git a/src/pyqasm/transformer.py b/src/pyqasm/transformer.py index 17b93241..0728092a 100644 --- a/src/pyqasm/transformer.py +++ b/src/pyqasm/transformer.py @@ -141,27 +141,51 @@ def get_qubits_from_range_definition( Returns: list[int]: The list of qubit identifiers. """ - start_qid = ( + # Range endpoints on a qubit register use this function's end-EXCLUSIVE + # convention: ``end`` defaults to ``qreg_size`` and is not itself + # iterated. A negative endpoint is normalized against the register size + # (``-1`` -> ``qreg_size - 1``) and then still treated as exclusive, + # matching Python slice semantics. + raw_start = ( 0 if range_def.start is None else Qasm3ExprEvaluator.evaluate_expression(range_def.start)[0] ) - end_qid = ( - qreg_size - if range_def.end is None - else Qasm3ExprEvaluator.evaluate_expression(range_def.end)[0] - ) + start_qid: int = Qasm3Validator.validate_register_index( + raw_start, qreg_size, qubit=is_qubit_reg, op_node=op_node + ) # type: ignore[assignment] + + if range_def.end is None: + end_qid = qreg_size + else: + raw_end = Qasm3ExprEvaluator.evaluate_expression(range_def.end)[0] + if raw_end < 0: + # Negative exclusive end: normalize against register size and use + # directly. Reject the empty-slice case ``end == -qreg_size`` up + # front only when it would slice past the start; otherwise a + # legitimate empty range simply yields no qubits. + end_qid = raw_end + qreg_size + if end_qid < 0 or end_qid > qreg_size: + raise_qasm3_error( + message=f"Index {raw_end} out of range for register of " + f"size {qreg_size} in " + f"{'qubit' if is_qubit_reg else 'clbit'}", + error_node=op_node, + span=op_node.span if op_node else None, + ) + else: + # Non-negative exclusive end: validate the last element that + # WOULD be iterated (``end - 1``), matching the pre-existing + # behavior for positive endpoints. + last_included = Qasm3Validator.validate_register_index( + raw_end - 1, qreg_size, qubit=is_qubit_reg, op_node=op_node + ) + end_qid = last_included + 1 # type: ignore[operator] step = ( 1 if range_def.step is None else Qasm3ExprEvaluator.evaluate_expression(range_def.step)[0] ) - Qasm3Validator.validate_register_index( - start_qid, qreg_size, qubit=is_qubit_reg, op_node=op_node - ) - Qasm3Validator.validate_register_index( - end_qid - 1, qreg_size, qubit=is_qubit_reg, op_node=op_node - ) return list(range(start_qid, end_qid, step)) @staticmethod @@ -332,8 +356,12 @@ def get_branch_params( error_node=condition, span=condition.span, ) + # Evaluate the index expression so ``c[-1]`` (a ``UnaryExpression`` + # with no ``.value`` attribute) resolves to a concrete ``int``; + # the caller then routes it through ``validate_register_index``. + index_value = Qasm3ExprEvaluator.evaluate_expression(condition.index[0])[0] return BranchParams( - condition.index[0].value, + index_value, condition.collection.name, BinaryOperator["=="], True, @@ -412,12 +440,13 @@ def get_target_qubits( ) target_qubits_size = len(target_qids) elif isinstance( - target.index[0], (IntegerLiteral, Identifier, BinaryExpression) - ): # "(q[0]); OR (q[i]); OR (q[i+1]);" - target_qids = [Qasm3ExprEvaluator.evaluate_expression(target.index[0])[0]] - Qasm3Validator.validate_register_index( - target_qids[0], qreg_size_map[target_name], qubit=True, op_node=target + target.index[0], (IntegerLiteral, Identifier, BinaryExpression, UnaryExpression) + ): # "(q[0]); OR (q[i]); OR (q[i+1]); OR (q[-1]);" + raw_qid = Qasm3ExprEvaluator.evaluate_expression(target.index[0])[0] + normalized_qid = Qasm3Validator.validate_register_index( + raw_qid, qreg_size_map[target_name], qubit=True, op_node=target ) + target_qids = [normalized_qid] target_qubits_size = 1 elif isinstance(target.index[0], RangeDefinition): # "(q[0:1:2]);" target_qids = Qasm3Transformer.get_qubits_from_range_definition( diff --git a/src/pyqasm/validator.py b/src/pyqasm/validator.py index b9097fd6..09e11cc7 100644 --- a/src/pyqasm/validator.py +++ b/src/pyqasm/validator.py @@ -17,7 +17,7 @@ """ -from typing import Any, Optional +from typing import Any, Optional, overload import numpy as np from openqasm3.ast import ( @@ -42,22 +42,50 @@ class Qasm3Validator: """Class with validation functions for QASM visitor""" + @overload + @staticmethod + def validate_register_index( + index: None, size: int, qubit: bool = ..., op_node: Optional[Any] = ... + ) -> None: ... + + @overload + @staticmethod + def validate_register_index( + index: int, size: int, qubit: bool = ..., op_node: Optional[Any] = ... + ) -> int: ... + @staticmethod def validate_register_index( index: Optional[int], size: int, qubit: bool = False, op_node: Optional[Any] = None - ) -> None: - """Validate the index for a register. + ) -> Optional[int]: + """Validate a register index, normalizing negative indices from the end. + + Applies the OpenQASM 3 rule that ``-1`` refers to the last element and + ``-size`` to the first. Returns the normalized non-negative index so the + caller can rewrite the emitted AST with it; passing the value through is + important because passes downstream of unroll (register consolidation, + idle-qubit removal, qubit-order reversal) expect concrete non-negative + integers in the AST. Args: - index (optional, int): The index to validate. + index (optional, int): The index to validate. ``None`` passes through. size (int): The size of the register. qubit (bool): Whether the register is a qubit register. + op_node: The AST node used for span attribution on error. + + Returns: + Optional[int]: The normalized index, or ``None`` if ``index`` was + ``None``. Raises: - ValidationError: If the index is out of range. + ValidationError: If the index is out of the range + ``[-size, size - 1]`` after normalization. """ - if index is None or 0 <= index < size: - return + if index is None: + return None + normalized = index + size if index < 0 else index + if 0 <= normalized < size: + return normalized raise_qasm3_error( message=f"Index {index} out of range for register of size {size} in " @@ -65,6 +93,7 @@ def validate_register_index( error_node=op_node, span=op_node.span if op_node else None, ) + return None # pragma: no cover - raise_qasm3_error never returns @staticmethod def validate_statement_type(blacklisted_stmts: set, statement: Any, construct: str) -> None: diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index d837f149..afdde36b 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -32,10 +32,11 @@ import openqasm3.ast as qasm3_ast from openqasm3.printer import dumps -from pyqasm.analyzer import Qasm3Analyzer +from pyqasm.analyzer import Qasm3Analyzer, bits_to_int from pyqasm.elements import ( INTERNAL_QUBIT_REGISTER, PHYSICAL_QUBIT_PREFIX, + BitValue, Capture, ClbitDepthNode, Context, @@ -87,6 +88,48 @@ logger.propagate = False +# pylint: disable-next=too-many-locals +def _write_bit_slice( + current: Any, + width: int, + indices: list[tuple[int, int, int]], + new_value: Any, +) -> BitValue: + """Return a new ``BitValue`` with the selected bit positions rewritten. + + Overwrites the positions selected by ``indices`` (as produced by + ``analyze_classical_indices``) with the low bits of ``new_value``. Bit 0 of + the register is the most-significant bit of the underlying integer, matching + the ``BitstringLiteral`` convention used by :func:`~pyqasm.dumps`. + + Args: + current: The current bit register value (``BitValue``, ``int``, ``str``, + or ``ndarray``); converted through :func:`bits_to_int`. + width: The register width, in bits. + indices: One ``(start, end, step)`` tuple per dimension, from + ``analyze_classical_indices`` — always exactly one for a bit register. + new_value: The value to write into the slice. For a single-bit target this + is the raw bit; for a ranged target its low ``k`` bits are used. + + Returns: + BitValue: The updated register value, masked to ``width`` bits. + """ + start, end, step = indices[0] + current_int = bits_to_int(current, width) + positions = list(range(start, end + 1, step)) + slice_width = len(positions) + new_int = bits_to_int(new_value, slice_width) + result = current_int + # Iterate MSB-first through the slice positions so the highest-order bit of + # ``new_value`` lands at the lowest slice position (matching the read order + # in ``_get_var_value``). + for offset, pos in enumerate(positions): + bit_from_new = (new_int >> (slice_width - 1 - offset)) & 1 + target_shift = width - 1 - pos + result = (result & ~(1 << target_shift)) | (bit_from_new << target_shift) + return BitValue(result, width) + + # pylint: disable-next=too-many-instance-attributes class QasmVisitor: """A visitor for basic OpenQASM program elements. @@ -387,7 +430,7 @@ def _get_op_bits( ) else: bit_id = Qasm3ExprEvaluator.evaluate_expression(bit.indices[0][0])[0] - Qasm3Validator.validate_register_index( + bit_id = Qasm3Validator.validate_register_index( bit_id, max_register_size, qubit=qubits, op_node=operation ) bit_ids = [bit_id] @@ -1899,7 +1942,7 @@ def _visit_classical_declaration( span=statement.span, ) - init_value = None + init_value: Any = None base_type = statement.type dimensions = [] final_dimensions = [] @@ -1923,10 +1966,14 @@ def _visit_classical_declaration( base_size = self._check_variable_type_size(statement, var_name, "variable", base_type) Qasm3Validator.validate_classical_type(base_type, base_size, var_name, statement) - # initialize the bit register + # initialize the bit register — stored as a width-carrying ``BitValue`` + # so downstream bitwise/shift/indexing operations can be evaluated as + # integers with a clean equal-width contract. ``final_dimensions`` still + # records the register width so ``analyze_classical_indices`` continues + # to work for ``b[i]`` / ``b[i:j]``. if isinstance(base_type, qasm3_ast.BitType): final_dimensions = [base_size] - init_value = np.full(final_dimensions, 0) + init_value = BitValue(0, base_size) if len(dimensions) > 0: # bit type arrays are not allowed @@ -2245,11 +2292,22 @@ def _visit_classical_assignment( span=statement.span, raised_from=err, ) - Qasm3Transformer.update_array_element( - multi_dim_arr=lvar.value, # type: ignore[union-attr, arg-type] - indices=validated_l_indices, - value=rvalue_eval, - ) + if isinstance(lvar_base_type, qasm3_ast.BitType): + # Bit registers are stored as a single ``BitValue`` — an ``int`` + # is immutable, so build a fresh masked value with the selected + # bit positions rewritten and rebind ``lvar.value``. + lvar.value = _write_bit_slice( # type: ignore[union-attr] + lvar.value, # type: ignore[union-attr, arg-type] + lvar.base_size, # type: ignore[union-attr] + validated_l_indices, + rvalue_eval, + ) + else: + Qasm3Transformer.update_array_element( + multi_dim_arr=lvar.value, # type: ignore[union-attr, arg-type] + indices=validated_l_indices, + value=rvalue_eval, + ) else: lvar.value = rvalue_eval # type: ignore[union-attr] self._scope_manager.update_var_in_scope(lvar) # type: ignore[arg-type] @@ -2357,8 +2415,9 @@ def _visit_branching_statement( else_block = self.visit_basic_block(statement.else_block) if reg_idx is not None: - # single bit branch - Qasm3Validator.validate_register_index( + # single bit branch — normalize a negative index against the register + # size (spec: ``c[-1]`` refers to the last classical bit). + reg_idx = Qasm3Validator.validate_register_index( reg_idx, self._global_creg_size_map[reg_name], qubit=False, op_node=condition ) @@ -2841,13 +2900,15 @@ def _visit_alias_statement(self, statement: qasm3_ast.AliasStatement) -> list[No target_qids = Qasm3Transformer.extract_values_from_discrete_set( value.index, statement ) - for qid in target_qids: + target_qids = [ Qasm3Validator.validate_register_index( qid, self._global_qreg_size_map[aliased_reg_name], qubit=True, op_node=statement, ) + for qid in target_qids + ] alias_reg_size = len(target_qids) else: if len(value.index) != 1: diff --git a/tests/qasm3/subroutines/test_subroutines.py b/tests/qasm3/subroutines/test_subroutines.py index 2b30bc78..6b4866b5 100644 --- a/tests/qasm3/subroutines/test_subroutines.py +++ b/tests/qasm3/subroutines/test_subroutines.py @@ -535,9 +535,9 @@ def test_extern_function_call(): bit[1] fc = -func1(1.0, 2); bit[2] b1 = true; extern func2(bit[2], angle) -> complex; - const complex d = func2(True, 1.5707963267948966); - const complex e = func2(True, 1.5707963267948966) + 2.0; - const complex f = -func2(True, 1.5707963267948966); + const complex d = func2("01", 1.5707963267948966); + const complex e = func2("01", 1.5707963267948966) + 2.0; + const complex f = -func2("01", 1.5707963267948966); extern func3(duration, bool) -> int; dd = func3(100.0ns, True); ee = func3(100.0ns, True) + 2; diff --git a/tests/qasm3/test_expressions.py b/tests/qasm3/test_expressions.py index ef549f51..f3c2ad2c 100644 --- a/tests/qasm3/test_expressions.py +++ b/tests/qasm3/test_expressions.py @@ -19,8 +19,11 @@ import pytest -from pyqasm.entrypoint import loads +from pyqasm.analyzer import bits_to_int, int_to_bits +from pyqasm.elements import BitValue +from pyqasm.entrypoint import dumps, loads from pyqasm.exceptions import ValidationError +from pyqasm.maps.expressions import qasm3_expression_op_map from tests.utils import check_measure_op, check_single_qubit_gate_op, check_single_qubit_rotation_op @@ -105,3 +108,126 @@ def test_incorrect_expressions(caplog): loads("OPENQASM 3; qubit q; int x; rx(x) q;").validate() assert "Error at line 1" in caplog.text assert "x" in caplog.text + + +# --------------------------------------------------------------------------- +# Issue #385 — bitwise, shift, and index operations on ``bit[n]`` +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "op,expected_bits", + [ + ("|", "1111"), # 1010 | 0101 + ("&", "0000"), # 1010 & 0101 + ("^", "1111"), # 1010 ^ 0101 + ], +) +def test_bit_register_binary_bitwise_ops(op, expected_bits): + """Binary bitwise operators on two ``bit[n]`` operands produce a masked result.""" + qasm = f""" + OPENQASM 3.0; + bit[4] a = "1010"; + bit[4] b = "0101"; + bit[4] c = a {op} b; + """ + loads(qasm).validate() + + # The declaration above only proves the operator no longer raises. Drive the + # same dispatch directly to pin the value it produces, and its width. + lhs = BitValue(bits_to_int("1010", 4), 4) + rhs = BitValue(bits_to_int("0101", 4), 4) + result = qasm3_expression_op_map(op, lhs, rhs) + assert int_to_bits(int(result), result.width) == expected_bits + assert result.width == 4 + + +def test_bit_register_unary_not_masks_to_width(): + """``~a`` on a ``bit[n]`` value re-masks so the result stays within ``n`` bits.""" + qasm = """ + OPENQASM 3.0; + bit[4] a = "1010"; + bit[4] c = ~a; + """ + module = loads(qasm) + module.validate() + assert "c = ~a" in dumps(module) + + +@pytest.mark.parametrize("shift_op", ["<<", ">>"]) +def test_bit_register_shift_ops(shift_op): + """``<<`` and ``>>`` on a ``bit[n]`` register with an integer shift-count work.""" + qasm = f""" + OPENQASM 3.0; + bit[4] a = "1010"; + bit[4] c = a {shift_op} 1; + """ + module = loads(qasm) + module.validate() + assert f"c = a {shift_op} 1" in dumps(module) + + +def test_bit_register_binary_width_mismatch_raises(caplog): + """Two ``bit[n]`` operands of different widths in a bitwise op raise ValidationError + with the source span attached (an unspanned error would ship without a line number).""" + qasm = """ + OPENQASM 3.0; + bit[4] a = "1010"; + bit[3] b = "010"; + bit[4] c; + c = a | b; + """ + with pytest.raises(ValidationError, match="Width mismatch for bitwise"): + with caplog.at_level("ERROR"): + loads(qasm).validate() + # The re-raise attaches the span; the logged message names the source line. + assert "Error at line" in caplog.text + + +def test_bit_register_single_element_index(): + """``b[i]`` returns a single-bit value that can initialize a ``bit`` variable.""" + qasm = """ + OPENQASM 3.0; + bit[4] b = "1010"; + bit c = b[0]; + """ + module = loads(qasm) + module.validate() + text = dumps(module) + assert "bit[1] c = b[0]" in text + + +def test_bit_register_range_and_stepped_index(): + """``b[a:c]`` and ``b[a:step:c]`` both yield a bit register of the sliced width.""" + module = loads(""" + OPENQASM 3.0; + bit[4] b = "1010"; + bit[3] c = b[0:2]; + bit[2] d = b[0:2:3]; + """) + module.validate() + text = dumps(module) + assert "bit[3] c = b[0:2]" in text + assert "bit[2] d = b[0:2:3]" in text + + +def test_bit_register_bitstring_literal_roundtrips(): + """A ``bit[n] = \"1010\"`` declaration serializes back to the same literal form.""" + src = 'OPENQASM 3.0;\nbit[4] a = "1010";\n' + out = dumps(loads(src)) + assert 'bit[4] a = "1010";' in out + + +def test_bit_register_op_result_stays_bit_type(): + """The result of ``a | b`` retains bit type and can seed a further ``bit[n]``.""" + module = loads(""" + OPENQASM 3.0; + bit[4] a = "1010"; + bit[4] b = "0101"; + bit[4] c = a | b; + bit[4] d = c & a; + """) + module.validate() + text = dumps(module) + assert "c = a | b" in text + assert "d = c & a" in text diff --git a/tests/qasm3/test_negative_indices.py b/tests/qasm3/test_negative_indices.py new file mode 100644 index 00000000..06062e0f --- /dev/null +++ b/tests/qasm3/test_negative_indices.py @@ -0,0 +1,237 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for OpenQASM 3 negative-index normalization (issue #391). + +Negative indices count from the end (``-1`` is the last element). These tests +cover arrays (single- and multi-dimensional, read and write), ``bit[n]``, +``qubit[n]``, ``let`` aliases, and ranges — including the stepped form — plus +the post-unroll transformation passes that must never see negative indices. +""" + +import pytest + +from pyqasm.entrypoint import dumps, loads +from pyqasm.exceptions import ValidationError + + +def test_negative_index_read_from_array(): + """``array[..., 5] a; ... a[-1]`` reads the last element.""" + module = loads(""" + OPENQASM 3.0; + array[int[32], 5] myArray = {0, 1, 2, 3, 4}; + int[32] x = myArray[-1]; + int[32] y = myArray[-5]; + """) + module.validate() + + +def test_negative_index_multi_dim_read(): + """Negative indices normalize per-dimension in a multi-dim array.""" + module = loads(""" + OPENQASM 3.0; + array[int[32], 2, 3] multiDim = {{0, 1, 2}, {10, 11, 12}}; + int[32] x = multiDim[-1, -1]; + """) + module.validate() + + +def test_negative_index_array_write(): + """``a[-1] = ...`` writes to the last element.""" + module = loads(""" + OPENQASM 3.0; + array[int[32], 5] a = {0, 1, 2, 3, 4}; + a[-1] = 10; + """) + module.validate() + + +def test_negative_index_qubit_read(): + """``h q[-1];`` targets the last qubit; unrolled AST shows the concrete index.""" + module = loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[4] q; + h q[-1]; + """) + module.unroll() + text = dumps(module) + assert "h q[3];" in text + + +def test_negative_bit_register_index_read(): + """``bit c = b[-1]`` reads the last bit of the register.""" + module = loads(""" + OPENQASM 3.0; + bit[4] b = "1010"; + bit c = b[-1]; + """) + module.validate() + + +def test_negative_bit_register_index_write(): + """``b[-1] = 1`` writes the last bit; the emitted statement preserves the source.""" + module = loads(""" + OPENQASM 3.0; + bit[4] b = "0000"; + b[-1] = 1; + """) + module.validate() + assert "b[-1] = 1" in dumps(module) + + +def test_negative_range_qubit_gate_expands_to_concrete_indices(): + """``h q[-3:-1];`` unrolls to concrete non-negative indices; the end is + exclusive (Python-slice convention on qubit ranges), so ``[-3:-1]`` on a + 5-qubit register selects positions 2 and 3.""" + module = loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[5] q; + h q[-3:-1]; + """) + module.unroll() + text = dumps(module) + assert "h q[2];" in text + assert "h q[3];" in text + # end-exclusive: q[4] is NOT touched. + assert "h q[4];" not in text + + +def test_negative_range_alias_indexable(): + """A ``let`` alias built from a negative range resolves to the intended qubits. + + On an 8-qubit register, ``two[-4:-1]`` = ``two[4:7]`` (end-exclusive) covers + three qubits, so ``last_three[0]`` maps to ``two[4]``. + """ + module = loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[8] two; + let last_three = two[-4:-1]; + h last_three[0]; + """) + module.unroll() + assert "h two[4];" in dumps(module) + + +def test_negative_index_out_of_bounds_reports_source_index(caplog): + """A qubit index still out of range after normalization reports the source index.""" + with pytest.raises(ValidationError, match=r"Index -5"): + with caplog.at_level("ERROR"): + loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[4] q; + h q[-5]; + """).validate() + assert "-5" in caplog.text + + +def test_negative_index_out_of_bounds_for_array_reports_source_index(caplog): + """For arrays, the message names the negative index as written in the source.""" + with pytest.raises(ValidationError): + with caplog.at_level("ERROR"): + loads(""" + OPENQASM 3.0; + array[int[32], 5] a = {0, 1, 2, 3, 4}; + int[32] x = a[-6]; + """).validate() + # The chained cause carries "Index -6 out of bounds ..."; the log captures it + # before it is re-raised as "Invalid initialization value for variable 'x'". + assert "-6" in caplog.text + + +def test_remove_idle_qubits_after_negative_source_index(): + """``remove_idle_qubits`` sees only non-negative indices after unroll.""" + module = loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[5] q; + h q[-1]; + x q[-2]; + """) + module.remove_idle_qubits() + text = dumps(module) + # Only 2 qubits remain — the ones actually operated on. + assert "qubit[2] q;" in text + assert "h q[1];" in text + assert "x q[0];" in text + + +def test_reverse_qubit_order_after_negative_source_index(): + """``reverse_qubit_order`` correctly handles negative source indices.""" + module = loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[5] q; + h q[-1]; + x q[-2]; + """) + module.reverse_qubit_order() + text = dumps(module) + # After reversal, q[4] -> q[0] and q[3] -> q[1]. + assert "h q[0];" in text + assert "x q[1];" in text + + +def test_descending_negative_range_positive_step_raises(): + """``a[-1:-5]`` with the implicit step of ``+1`` normalizes to a descending + range on a positive step — pyqasm rejects that as a step-direction mismatch, + which is the pre-existing behavior for any descending unstepped range. The + caller sees ``ValidationError`` with the step-direction cause chained.""" + with pytest.raises(ValidationError) as excinfo: + loads(""" + OPENQASM 3.0; + array[int[32], 5] a = {0, 1, 2, 3, 4}; + array[int[32], 5] sub = a[-1:-5]; + """).validate() + cause = excinfo.value.__cause__ or excinfo.value.__context__ + assert cause is not None + assert "step" in str(cause) + + +def test_negative_index_in_branch_condition_normalizes(): + """``if (c[-1])`` normalizes the negative index against the classical register size.""" + module = loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[4] q; + bit[4] c; + c = measure q; + if (c[-1]) { + h q[0]; + } + """) + module.unroll() + # After normalization the condition targets c[3] (last classical bit). + assert "c[3]" in dumps(module) + + +def test_negative_qubit_range_end_only(): + """A range with just a negative end (``[0:-1]``) is exclusive, matching the + end-exclusive convention of qubit ranges in pyqasm.""" + module = loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[5] q; + h q[0:-1]; + """) + module.unroll() + text = dumps(module) + # ``end = -1`` normalizes to 4 with end-exclusive iteration: + # positions 0, 1, 2, 3 are hit (not 4). + for i in range(4): + assert f"h q[{i}];" in text + assert "h q[4];" not in text From 387857ac164ac57752acbda50c3126660a09fa81 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Wed, 26 Aug 2026 16:03:24 +0530 Subject: [PATCH 2/2] Fix descending bit[n] slices selecting the wrong positions A ranged read or write on a bit[n] register computed its stop bound as `end + 1` regardless of direction. With a negative step that bound sits *behind* the traversal, so the range came out truncated or empty: `b[3:-1:0]` on `bit[4] b = "1100"` read one position instead of four and evaluated to 0, and `b[3:-1:2] = t` wrote nothing at all. Neither raised. The bound now moves one past `end` in the direction of travel, via a shared `slice_positions` helper used by both the read and write paths. Co-Authored-By: Claude Opus 5 (1M context) --- src/pyqasm/analyzer.py | 17 ++++++++++++++++ src/pyqasm/expressions.py | 4 ++-- src/pyqasm/visitor.py | 4 ++-- tests/qasm3/test_expressions.py | 35 +++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/pyqasm/analyzer.py b/src/pyqasm/analyzer.py index d5743862..78c8fc53 100644 --- a/src/pyqasm/analyzer.py +++ b/src/pyqasm/analyzer.py @@ -92,6 +92,23 @@ def int_to_bits(value: int, width: int) -> str: return format(int(value) & mask, f"0{width}b") +def slice_positions(start: int, end: int, step: int) -> range: + """Return the positions selected by an inclusive OpenQASM range. + + OpenQASM ranges include both endpoints, so the stop bound is pushed one past + ``end`` in the direction of travel. + + Args: + start: The first position of the range. + end: The last position of the range, inclusive. + step: The stride; negative for a descending range. Must be non-zero. + + Returns: + range: The selected positions, in traversal order. + """ + return range(start, end + (1 if step > 0 else -1), step) + + class Qasm3Analyzer: """Class with utility functions for analyzing QASM3 elements""" diff --git a/src/pyqasm/expressions.py b/src/pyqasm/expressions.py index 29421fca..8113893b 100644 --- a/src/pyqasm/expressions.py +++ b/src/pyqasm/expressions.py @@ -45,7 +45,7 @@ UnaryExpression, ) -from pyqasm.analyzer import Qasm3Analyzer, bits_to_int +from pyqasm.analyzer import Qasm3Analyzer, bits_to_int, slice_positions from pyqasm.elements import BitValue, Variable from pyqasm.exceptions import ValidationError, raise_qasm3_error from pyqasm.maps.expressions import ( @@ -200,7 +200,7 @@ def _get_var_value(cls, var_name, indices, expression): # pylint: disable=too-m return (source_int >> (width - 1 - start)) & 1 # Ranged read — build the sub-bitstring by iterating in the step # order (already validated non-empty by ``analyze_classical_indices``). - selected_positions = list(range(start, end + 1, step)) + selected_positions = slice_positions(start, end, step) slice_width = len(selected_positions) result = 0 for pos in selected_positions: diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index afdde36b..b7e9fd8a 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -32,7 +32,7 @@ import openqasm3.ast as qasm3_ast from openqasm3.printer import dumps -from pyqasm.analyzer import Qasm3Analyzer, bits_to_int +from pyqasm.analyzer import Qasm3Analyzer, bits_to_int, slice_positions from pyqasm.elements import ( INTERNAL_QUBIT_REGISTER, PHYSICAL_QUBIT_PREFIX, @@ -116,7 +116,7 @@ def _write_bit_slice( """ start, end, step = indices[0] current_int = bits_to_int(current, width) - positions = list(range(start, end + 1, step)) + positions = slice_positions(start, end, step) slice_width = len(positions) new_int = bits_to_int(new_value, slice_width) result = current_int diff --git a/tests/qasm3/test_expressions.py b/tests/qasm3/test_expressions.py index f3c2ad2c..30724145 100644 --- a/tests/qasm3/test_expressions.py +++ b/tests/qasm3/test_expressions.py @@ -211,6 +211,41 @@ def test_bit_register_range_and_stepped_index(): assert "bit[2] d = b[0:2:3]" in text +def test_bit_register_descending_slice_read(): + """A negative step reverses the slice and still keeps its final position. + + The stop bound has to move one past ``end`` in the direction of travel; a fixed + ``end + 1`` drops every position below ``start`` and yields ``0``. + """ + module = loads(""" + OPENQASM 3.0; + qubit q; + bit[4] b = "1100"; + bit[4] r = b[3:-1:0]; + int[8] v = r; + rx(v) q; + """) + module.unroll() + # Bit 0 is the most-significant bit, so "1100" read back-to-front is "0011" == 3. + check_single_qubit_rotation_op(module.unrolled_ast, 1, [0], [3], "rx") + + +def test_bit_register_descending_slice_write(): + """A descending target range writes every selected position, not just the first.""" + module = loads(""" + OPENQASM 3.0; + qubit q; + bit[4] b = "0000"; + bit[2] t = "11"; + b[3:-1:2] = t; + int[8] v = b; + rx(v) q; + """) + module.unroll() + # Positions 3 and 2 both become 1, giving "0011" == 3. + check_single_qubit_rotation_op(module.unrolled_ast, 1, [0], [3], "rx") + + def test_bit_register_bitstring_literal_roundtrips(): """A ``bit[n] = \"1010\"`` declaration serializes back to the same literal form.""" src = 'OPENQASM 3.0;\nbit[4] a = "1010";\n'