Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions src/pyqasm/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions src/pyqasm/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
BoolType,
Cast,
DurationLiteral,
DurationType,
Expression,
FloatLiteral,
)
Expand All @@ -49,20 +50,80 @@
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."""

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
Expand Down Expand Up @@ -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 == "":
Expand Down
71 changes: 63 additions & 8 deletions src/pyqasm/maps/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 5 additions & 8 deletions src/pyqasm/pulse/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 2 additions & 3 deletions tests/qasm3/resources/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (
Expand All @@ -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);",
Expand Down
Loading