diff --git a/CHANGELOG.md b/CHANGELOG.md index e8734f69..8e6b808f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Types of changes: ### 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)) +- `angle(x)` and `angle[n](x)` now cast from `float` and `angle`, narrowing by truncating low-order bits, and `int[n](b)`/`uint[n](b)` reinterpret a `bit[n]` register. An unsupported cast now raises `ValidationError` naming both types, replacing `Invalid initialization value`. ([#399](https://github.com/qBraid/pyqasm/issues/399)) ### Improved / Modified diff --git a/src/pyqasm/elements.py b/src/pyqasm/elements.py index fc268671..f4403eb3 100644 --- a/src/pyqasm/elements.py +++ b/src/pyqasm/elements.py @@ -17,12 +17,15 @@ """ +import math from dataclasses import dataclass from enum import Enum from typing import Any, Optional import numpy as np +TWO_PI = 2 * math.pi + INTERNAL_QUBIT_REGISTER = "__PYQASM_QUBITS__" """Reserved register that qubits are consolidated onto, and that physical qubits ("$n") are rewritten to for OpenPulse programs.""" @@ -104,6 +107,57 @@ def __repr__(self) -> str: # pragma: no cover - diagnostic aid only return f"BitValue({int(self)}, width={self.width})" +class AngleValue(float): + """Internal representation of an ``angle[n]`` classical value. + + An ``angle[n]`` is a fixed-point number in ``[0, 2*pi)``: the register holds the + unsigned integer ``bits``, and the angle it denotes is ``2*pi * bits / 2**n``. See + https://openqasm.com/versions/3.1/language/types.html#angles. + + ``AngleValue`` subclasses ``float`` so an angle stays usable anywhere a plain float + is (gate arguments, arithmetic, ``float()`` casts) while carrying the ``width`` that + narrowing needs. The float itself keeps the *unrounded* angle; quantization to + ``width`` bits happens only in :meth:`bits` and :meth:`resize`, so declaring an + angle does not perturb the value a gate is called with. + """ + + # ``float`` uses a fixed C-level layout that forbids ``__slots__`` on subclasses, + # so ``width`` lives on the instance ``__dict__``. Declared for type checkers. + width: int + + def __new__(cls, value: float, width: int) -> "AngleValue": + if width <= 0: + raise ValueError(f"AngleValue width must be positive, got {width}") + obj = float.__new__(cls, float(value) % TWO_PI) + obj.width = width + return obj + + @classmethod + def from_bits(cls, bits: int, width: int) -> "AngleValue": + """Build the angle whose ``uint[width]`` bit pattern is ``bits``.""" + return cls(TWO_PI * (bits % (1 << width)) / (1 << width), width) + + @property + def bits(self) -> int: + """The unsigned integer whose ``uint[width]`` bit pattern is this angle.""" + return round(float(self) / TWO_PI * (1 << self.width)) % (1 << self.width) + + def resize(self, width: int) -> "AngleValue": + """Return this angle re-expressed with ``width`` bits of precision. + + Narrowing truncates the low-order bits, which the spec names as the + hardware-friendly of its two permitted behaviours. Widening keeps the value + as-is, which is exactly a left shift of the fixed-point integer. + """ + if width >= self.width: + return AngleValue(float(self), width) + return AngleValue.from_bits(self.bits >> (self.width - width), width) + + def to_bitstring(self) -> str: + """Return the zero-padded, width-`n` binary string for this angle.""" + return format(self.bits, f"0{self.width}b") + + 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 29421fca..9fbce4ae 100644 --- a/src/pyqasm/expressions.py +++ b/src/pyqasm/expressions.py @@ -26,6 +26,7 @@ BoolType, Cast, DurationLiteral, + DurationType, Expression, FloatLiteral, ) @@ -49,13 +50,23 @@ from pyqasm.elements import BitValue, Variable from pyqasm.exceptions import ValidationError, raise_qasm3_error from pyqasm.maps.expressions import ( + ALLOWED_CASTS, CONSTANTS_MAP, FUNCTION_MAP, TIME_UNITS_MAP, qasm3_expression_op_map, + qasm_type_name, ) from pyqasm.validator import Qasm3Validator +_LITERAL_CAST_TYPES: dict[type, type] = { + BooleanLiteral: BoolType, + IntegerLiteral: Qasm3IntType, + FloatLiteral: Qasm3FloatType, + BitstringLiteral: BitType, + DurationLiteral: DurationType, +} + class Qasm3ExprEvaluator: """Class for evaluating QASM3 expressions.""" @@ -63,6 +74,56 @@ class Qasm3ExprEvaluator: visitor_obj = None angle_var_in_expr = None + @classmethod + def _resolve_cast_source_type(cls, argument) -> type | None: + """Resolve the OpenQASM type a cast argument is being converted *from*. + + Args: + argument: The argument node of a :class:`~openqasm3.ast.Cast`. + + Returns: + type | None: The AST type class of the argument, or ``None`` when it cannot + be pinned down — a binary expression, a function call, a built-in constant. + Callers must treat ``None`` as "unclassified" and skip the cast-table check, + so an expression pyqasm cannot type is never rejected on that basis alone. + """ + if isinstance(argument, Cast): + return type(argument.type) + if isinstance(argument, (Identifier, IndexExpression)): + var_name = ( + argument.name + if isinstance(argument, Identifier) + else Qasm3Analyzer.analyze_index_expression(argument)[0] + ) + var = cls.visitor_obj._scope_manager.get_from_visible_scope(var_name) # type: ignore + return type(var.base_type) if var else None + return _LITERAL_CAST_TYPES.get(type(argument)) + + @classmethod + def _validate_cast_types(cls, expression: Cast) -> None: + """Reject an explicit cast that pyqasm does not support. + + Args: + expression (Cast): The cast node to check. + + Raises: + ValidationError: If the source and target types are both known and the pair + is absent from :data:`~pyqasm.maps.expressions.ALLOWED_CASTS`. + """ + source_type = cls._resolve_cast_source_type(expression.argument) + if source_type is None: + return + allowed = ALLOWED_CASTS.get(source_type) + target_type = type(expression.type) + if allowed is None or target_type in allowed: + return + raise_qasm3_error( + f"Cannot cast '{qasm_type_name(source_type)}' to '{qasm_type_name(target_type)}'", + err_type=ValidationError, + error_node=expression, + span=expression.span, + ) + @classmethod def set_visitor_obj(cls, visitor_obj) -> None: cls.visitor_obj = visitor_obj @@ -540,6 +601,17 @@ def _get_external_function_return_type(expression): var_value, cast_stmts = cls.evaluate_expression( expression=expression.argument, const_expr=const_expr ) + cls._validate_cast_types(expression) + + if isinstance(expression.type, AngleType) and expression.type.size is None: + # A designator-less ``angle(x)`` takes its fixed-point width from the + # assignment context, so leave the value unquantized here and let the + # declaration or assignment settle the width. ``bool`` is mapped now, + # because by then nothing can tell a deferred ``true`` from the number 1. + statements.extend(cast_stmts) + if isinstance(var_value, bool): + var_value = CONSTANTS_MAP["pi"] if var_value else 0.0 + return _check_and_return_value(var_value) var_format = "variable" if var_name == "": diff --git a/src/pyqasm/maps/expressions.py b/src/pyqasm/maps/expressions.py index 489fce04..cb226583 100644 --- a/src/pyqasm/maps/expressions.py +++ b/src/pyqasm/maps/expressions.py @@ -32,7 +32,7 @@ UintType, ) -from pyqasm.elements import BitValue +from pyqasm.elements import AngleValue, BitValue from pyqasm.exceptions import ValidationError # Define the type for the operator functions @@ -126,6 +126,35 @@ def qasm3_expression_op_map(op_name: str, *args) -> float | int | bool: return operator(*args) +def qasm_type_name(qasm_type: type) -> str: + """Return the OpenQASM source spelling of an AST type class, e.g. ``uint``.""" + return qasm_type.__name__.removesuffix("Type").lower() + + +def cast_to_angle(value: float, width: int) -> AngleValue: + """Cast ``value`` to an ``angle[width]``. + + An angle source is re-expressed at the new width — truncating when narrowing — so a + cast between angle widths follows the fixed-point rules rather than re-rounding the + real number. + + Args: + value (float): The value to cast — a real number, a ``bool``, or an + :class:`~pyqasm.elements.AngleValue`. + width (int): Target precision, in bits. + + Returns: + AngleValue: The angle, quantized to ``width`` bits. + """ + if isinstance(value, bool): + # OpenQASM has no bool -> angle cast, but pyqasm has long accepted one that + # maps ``true`` to the half-turn; see ALLOWED_CASTS. + value = CONSTANTS_MAP["pi"] if value else 0.0 + if isinstance(value, AngleValue): + return value.resize(width) + return AngleValue(float(value), width) + + # 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. @@ -140,10 +169,12 @@ def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value): Raises: ValidationError: If the cast is not possible. """ - # ``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) + # ``BitValue`` and ``AngleValue`` are ``int`` / ``float`` subclasses; look them up + # under their base type so a value read from a ``bit[n]`` or ``angle[n]`` register + # flows into a cast without a bespoke tuple entry per site. + type_of_rhs = type(rhs_value) + if isinstance(rhs_value, (BitValue, AngleValue)): + type_of_rhs = type_of_rhs.__base__ if openqasm_type in (DurationType, StretchType): return rhs_value @@ -158,6 +189,11 @@ def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value): if openqasm_type == BoolType: return bool(rhs_value) if openqasm_type == IntType: + if isinstance(rhs_value, BitValue) and rhs_value.width == base_size: + # ``int[n](b)`` reinterprets a ``bit[n]`` register as the integer's + # two's-complement bit pattern, so a set sign bit reads as negative. + sign_bit = int(rhs_value) >> (base_size - 1) + return int(rhs_value) - (1 << base_size) if sign_bit else int(rhs_value) return int(rhs_value) if openqasm_type == UintType: return int(rhs_value) % (2**base_size) @@ -176,9 +212,7 @@ def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value): 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 - return rhs_value # not sure + return cast_to_angle(rhs_value, base_size) if openqasm_type == ComplexType: if isinstance(rhs_value, float): return complex(rhs_value) @@ -221,6 +255,27 @@ def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value): ComplexType: (complex, np.complex128, float, np.float64), } +# The explicit-cast surface, as ``source type -> allowed target types``. Rows follow the +# spec's allowed-casts table (https://openqasm.com/language/types.html#allowed-casts), +# with these deliberate differences, each of which pyqasm has always had: +# - bool -> angle, angle -> {int, uint, float} and bit -> float are marked "No" by the +# spec but are accepted here. The spec is self-inconsistent on angle -> float: its +# own angle comparison example uses it. Left alone pending an upstream ruling. +# - duration -> * is marked "No" by the spec but is accepted here. +# - bit -> angle and angle -> bit are marked "Yes" by the spec but are not implemented. +# A source type absent from this map (``stretch``, arrays) is not classified, so its +# casts fall through to the value-level check in ``qasm_variable_type_cast``. +ALLOWED_CASTS: dict[type, frozenset[type]] = { + BoolType: frozenset({BoolType, IntType, UintType, FloatType, AngleType, BitType}), + IntType: frozenset({BoolType, IntType, UintType, FloatType, BitType}), + UintType: frozenset({BoolType, IntType, UintType, FloatType, BitType}), + FloatType: frozenset({BoolType, IntType, UintType, FloatType, AngleType, ComplexType}), + AngleType: frozenset({BoolType, IntType, UintType, FloatType, AngleType, ComplexType}), + BitType: frozenset({BoolType, IntType, UintType, FloatType, BitType}), + DurationType: frozenset({BoolType, IntType, UintType, FloatType, AngleType, ComplexType}), + ComplexType: frozenset({ComplexType}), +} + ARRAY_TYPE_MAP = { BitType: np.bool_, IntType: np.int64, diff --git a/src/pyqasm/pulse/validator.py b/src/pyqasm/pulse/validator.py index c76ad93b..aab7e17f 100644 --- a/src/pyqasm/pulse/validator.py +++ b/src/pyqasm/pulse/validator.py @@ -46,10 +46,10 @@ TimeUnit, ) -from pyqasm.elements import BitValue +from pyqasm.elements import AngleValue, BitValue from pyqasm.exceptions import raise_qasm3_error from pyqasm.expressions import Qasm3ExprEvaluator -from pyqasm.maps.expressions import CONSTANTS_MAP +from pyqasm.maps.expressions import cast_to_angle class PulseValidator: @@ -93,16 +93,13 @@ def validate_angle_type_value( span=statement.span, ) angle_type_size = compiler_angle_width - angle_bit_string = format(expression.value, f"0{angle_type_size}b") # Reference: https://openqasm.com/language/types.html#angles - angle_val = (2 * CONSTANTS_MAP["pi"]) * (expression.value / (2**angle_type_size)) + angle_val = AngleValue.from_bits(expression.value, angle_type_size) else: - angle_val = init_value % (2 * CONSTANTS_MAP["pi"]) angle_type_size = compiler_angle_width or base_size - bit_string_value = round((2**angle_type_size) * (angle_val / (2 * CONSTANTS_MAP["pi"]))) - angle_bit_string = format(bit_string_value, f"0{angle_type_size}b") + angle_val = cast_to_angle(init_value, angle_type_size) - return angle_val, angle_bit_string + return angle_val, angle_val.to_bitstring() @staticmethod def validate_duration_or_stretch_statements( diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index afdde36b..f6a29caa 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -520,6 +520,17 @@ def _check_variable_cast_type( if not val_type: val_type = base_type + if ( + isinstance(val_type, qasm3_ast.AngleType) + and isinstance(base_type, qasm3_ast.AngleType) + and val_type.size is None + ): + # A designator-less ``angle(...)`` cast inherits the declared width, as in + # the spec's ``angle[20] a; angle[10] c; c = angle(a + b);``. Other types + # still require the cast width to match, because their casts must commit to + # a width before the assignment site is reached. + return + var_format = "variable" if is_const: var_format = "constant" diff --git a/tests/qasm3/resources/variables.py b/tests/qasm3/resources/variables.py index 654b3380..016172a0 100644 --- a/tests/qasm3/resources/variables.py +++ b/tests/qasm3/resources/variables.py @@ -422,7 +422,7 @@ bool b = bool(f); int i = int(f); uint u = uint(f); - // angle[8] a = angle[8](f); + angle[8] a = angle[8](f); """ ), "Bit_test": ( @@ -448,8 +448,7 @@ const float[64] f1 = 2.5; const bit[2] b1 = bit[2](f1); """, - "Cannot cast 'float' to 'BitType'. Invalid assignment of type " - "'float' to variable 'f1' of type 'BitType'", + "Cannot cast 'float' to 'bit'", 5, 8, "const bit[2] b1 = bit[2](f1);", diff --git a/tests/qasm3/test_casting.py b/tests/qasm3/test_casting.py new file mode 100644 index 00000000..c96ca3c2 --- /dev/null +++ b/tests/qasm3/test_casting.py @@ -0,0 +1,197 @@ +# 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. + +""" +Module containing unit tests for explicit casts between OpenQASM 3 classical types. + +""" + +import math + +import pytest + +from pyqasm.entrypoint import loads +from pyqasm.exceptions import ValidationError +from pyqasm.visitor import QasmVisitor, ScopeManager + + +def global_scope(qasm3_string: str) -> dict: + """Validate ``qasm3_string`` and return its global symbol table.""" + module = loads(qasm3_string) + module.validate() + scope_manager = ScopeManager() + module.accept(QasmVisitor(module, scope_manager, check_only=True)) + return scope_manager.get_global_scope() + + +@pytest.mark.parametrize( + "bits,expected_int,expected_uint", + [("00000101", 5, 5), ("11111111", -1, 255), ("10000000", -128, 128)], +) +def test_bit_register_to_int(bits, expected_int, expected_uint): + """A ``bit[n]`` casts to ``int[n]``/``uint[n]`` by reinterpreting its bit pattern. + + Bit 0 of the register is the most significant bit, so "00000101" is 5. ``int[n]`` + reads the pattern as two's complement, while ``uint[n]`` reads it as unsigned. + """ + scope = global_scope(f""" + OPENQASM 3.0; + bit[8] b = "{bits}"; + int[8] i = int[8](b); + uint[8] u = uint[8](b); + """) + assert scope["i"].value == expected_int + assert scope["u"].value == expected_uint + + +def test_bit_register_to_int_wider_target_is_unsigned(): + """A ``bit[n]`` widened past its own width has no sign bit to reinterpret.""" + scope = global_scope(""" + OPENQASM 3.0; + bit[4] b = "1111"; + int[32] i = int[32](b); + """) + assert scope["i"].value == 15 + + +@pytest.mark.parametrize( + "declaration,expression,expected_bits", + [ + # Worked examples from https://openqasm.com/language/types.html#angles + ("angle[4]", "pi", "1000"), + ("angle[6]", "pi / 2", "010000"), + ("angle[8]", "7 * (pi / 8)", "01110000"), + ("angle[4]", "7 * (pi / 8)", "0111"), + ], +) +def test_angle_fixed_point_bit_patterns(declaration, expression, expected_bits): + """An ``angle[n]`` stores ``round(value / (2*pi) * 2**n)`` as its bit pattern.""" + scope = global_scope(f"OPENQASM 3.0;\n{declaration} a = {expression};\n") + assert scope["a"].angle_bit_string == expected_bits + + +def test_float_to_angle_cast(): + """``angle[n](f)`` quantizes a float to the target width — issue #399 example.""" + scope = global_scope(""" + OPENQASM 3.0; + float[64] f = 1.5; + angle[8] a = angle[8](f); + """) + assert scope["a"].angle_bit_string == "00111101" # round(256 * 1.5 / (2*pi)) == 61 + + +def test_sizeless_angle_cast_takes_width_from_context(): + """``angle(f)`` with no designator quantizes at the declared width, not a default.""" + scope = global_scope(""" + OPENQASM 3.0; + float[64] f = 1.0; + angle[20] a = angle(f); + """) + assert scope["a"].angle_bit_string == format(round(2**20 / (2 * math.pi)), "020b") + assert scope["a"].value == pytest.approx(1.0) + + +def test_angle_narrowing_truncates(): + """Narrowing an angle drops the low-order bits rather than rounding them. + + ``angle[4]`` holding "0111" narrows to ``angle[2]`` as "01". Rounding would give + "10", so this pins the truncation behaviour the spec names as hardware-friendly. + """ + scope = global_scope(""" + OPENQASM 3.0; + angle[4] wide = 7 * (pi / 8); + angle[2] narrow = angle[2](wide); + """) + assert scope["wide"].angle_bit_string == "0111" + assert scope["narrow"].angle_bit_string == "01" + assert scope["narrow"].value == pytest.approx(math.pi / 2) + + +def test_angle_widening_is_lossless(): + """Widening an angle left-shifts the fixed-point integer, adding zero bits.""" + scope = global_scope(""" + OPENQASM 3.0; + angle[2] narrow = pi / 2; + angle[4] wide = angle[4](narrow); + """) + assert scope["wide"].angle_bit_string == "0100" + + +def test_angle_narrowing_from_expression(): + """The spec's own narrowing example: ``angle[20]`` operands assigned to ``angle[10]``.""" + scope = global_scope(""" + OPENQASM 3.0; + angle[20] a = pi / 2; + angle[20] b = pi; + angle[10] c; + c = angle(a + b); + """) + assert scope["c"].angle_bit_string == "1100000000" # 3/2 * pi + + +def test_angle_to_float_still_allowed(): + """``float(angle)`` is marked "No" by the cast table but used by the spec's own + comparison example. pyqasm accepts it; issue #399 deliberately leaves it alone.""" + scope = global_scope(""" + OPENQASM 3.0; + angle[8] a = pi; + float[64] f = float[64](a); + """) + assert scope["f"].value == pytest.approx(math.pi) + + +@pytest.mark.parametrize( + "qasm3_string,var_name,expected_value", + [ + ("float[64] f = 2.5;\nint[8] i = int[8](f);", "i", 2), + ("float[64] f = 2.5;\nuint u = uint(f);", "u", 2), + ("int[8] i = 3;\nbool b = bool(i);", "b", True), + ("int[8] i = 3;\nbit[8] b = bit[8](i);", "b", 3), + ], +) +def test_previously_supported_casts_unchanged(qasm3_string, var_name, expected_value): + """The casts that worked before issue #399 keep their values.""" + scope = global_scope(f"OPENQASM 3.0;\n{qasm3_string}\n") + assert scope[var_name].value == expected_value + + +@pytest.mark.parametrize( + "qasm3_string,expected_error", + [ + ( + "int[8] i = 3;\nangle[8] a = angle[8](i);", + "Cannot cast 'int' to 'angle'", + ), + ( + "angle[8] a = pi;\nbit[8] b = bit[8](a);", + "Cannot cast 'angle' to 'bit'", + ), + ( + "float[64] f = 2.5;\nbit[2] b = bit[2](f);", + "Cannot cast 'float' to 'bit'", + ), + ( + 'bit[8] b = "00000101";\nangle[8] a = angle[8](b);', + "Cannot cast 'bit' to 'angle'", + ), + ], +) +def test_unsupported_cast_names_both_types(qasm3_string, expected_error): + """An unsupported cast reports the source and target type, not a generic message.""" + with pytest.raises(ValidationError) as excinfo: + loads(f"OPENQASM 3.0;\n{qasm3_string}\n").validate() + + chained = excinfo.value.__cause__ or excinfo.value.__context__ + assert chained is not None, "Expected a chained ValidationError" + assert expected_error in str(chained)