From 346ac237a34cfc13cad8af98c3255898d20b76fc Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Mon, 24 Aug 2026 18:41:28 +0530 Subject: [PATCH] Add the missing built-in constant-expression functions (#390) Adds ceiling, floor, exp, log, mod, popcount, rotl, and rotr to FUNCTION_MAP, each usable in a const initializer and as a gate argument. FUNCTION_MAP now carries an arity alongside each implementation, so the evaluator can dispatch multi-argument calls and reject a wrong argument count. rotl and rotr preserve the operand's declared width by resolving it from a BitValue, a bitstring literal, or the declaration of a bit[n] / uint[n] identifier; a widthless operand is rejected rather than given a guessed width. An unknown function name, a wrong arity, or a wrong argument type now raises a FunctionCallError, which raise_qasm3_error merges into the statement-level message so the offending function is named instead of only reporting "Invalid initialization value". pow is excluded: it is ambiguous with the gate modifier of the same name, and upstream removed it from the spec in openqasm/openqasm#635. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + src/pyqasm/exceptions.py | 10 +++ src/pyqasm/expressions.py | 73 ++++++++++++++++++++-- src/pyqasm/maps/expressions.py | 63 +++++++++++++++---- src/pyqasm/visitor.py | 26 ++++++-- tests/qasm3/test_expressions.py | 104 ++++++++++++++++++++++++++++++++ 6 files changed, 254 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8734f69..6cad44be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ 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)) +- Added the built-in constant expression functions `ceiling`, `floor`, `exp`, `log`, `mod`, `popcount`, `rotl`, and `rotr`, each usable in a `const` initializer and as a gate argument. `rotl` and `rotr` preserve the operand's declared width, so `rotl(a, n) == rotr(a, -n)`. An unknown function name, a wrong argument count, or a wrong argument type now names the function instead of reporting only `Invalid initialization value`. `pow` is deliberately excluded: it is ambiguous with the gate modifier of the same name, and upstream removed it from the spec in [openqasm/openqasm#635](https://github.com/openqasm/openqasm/pull/635), leaving `**` as the supported spelling. ([#390](https://github.com/qBraid/pyqasm/issues/390)) + ### Improved / Modified ### Deprecated diff --git a/src/pyqasm/exceptions.py b/src/pyqasm/exceptions.py index dfb3b161..d404537e 100644 --- a/src/pyqasm/exceptions.py +++ b/src/pyqasm/exceptions.py @@ -36,6 +36,11 @@ class ValidationError(PyQasmError): """Exception raised when a OpenQASM program fails validation.""" +class FunctionCallError(ValidationError): + """Exception raised when a function is called with an unknown name, the wrong + number of arguments, or an argument of the wrong type.""" + + class UnrollError(PyQasmError): """Exception raised when a OpenQASM program fails unrolling.""" @@ -133,5 +138,10 @@ def raise_qasm3_error( # Extract the latest message from the traceback if raised_from is provided if raised_from: + if isinstance(raised_from, FunctionCallError): + # Statement-level handlers wrap any evaluation failure in a generic message + # ("Invalid initialization value for constant 'c'"). Merge the function + # diagnostic in so the offending function is still named. + message = f"{message}: {raised_from}" raise err_type(message) from raised_from raise err_type(message) diff --git a/src/pyqasm/expressions.py b/src/pyqasm/expressions.py index 29421fca..a1158545 100644 --- a/src/pyqasm/expressions.py +++ b/src/pyqasm/expressions.py @@ -42,13 +42,15 @@ SizeOf, Statement, StretchType, + UintType, UnaryExpression, ) from pyqasm.analyzer import Qasm3Analyzer, bits_to_int from pyqasm.elements import BitValue, Variable -from pyqasm.exceptions import ValidationError, raise_qasm3_error +from pyqasm.exceptions import FunctionCallError, ValidationError, raise_qasm3_error from pyqasm.maps.expressions import ( + BIT_ROTATION_FUNCTIONS, CONSTANTS_MAP, FUNCTION_MAP, TIME_UNITS_MAP, @@ -210,6 +212,69 @@ def _get_var_value(cls, var_name, indices, expression): # pylint: disable=too-m return Qasm3Analyzer.find_array_element(var.value, validated_indices) + @classmethod + def _as_bit_value( # type: ignore[return] # pylint: disable=inconsistent-return-statements + cls, value, expression + ) -> BitValue: + """Recover the register width of a ``rotl`` / ``rotr`` operand. + + A rotation is only defined against a declared width, so an operand whose width + is unknown (a bare integer literal) is rejected rather than given a guessed one. + """ + if isinstance(value, BitValue): + return value + if isinstance(value, str): + return BitValue(int(value, 2) if value else 0, len(value)) + argument = expression.arguments[0] + if isinstance(argument, Identifier) and isinstance(value, int): + var = cls.visitor_obj._scope_manager.get_from_visible_scope( # type: ignore[union-attr] + argument.name + ) + if isinstance(var.base_type, (BitType, UintType)): + return BitValue(value, var.base_size) + raise_qasm3_error( + f"Function '{expression.name.name}' expects a 'bit[n]' or 'uint[n]' " + "operand of known width", + err_type=FunctionCallError, + error_node=expression, + span=expression.span, + ) + + @classmethod + def _evaluate_builtin_function( # pylint: disable=inconsistent-return-statements + cls, expression, const_expr, reqd_type + ): + """Evaluate a call to a built-in constant expression function. + + Reference: https://openqasm.com/language/types.html#built-in-constant-expression-functions + """ + fn_name = expression.name.name + function, arity = FUNCTION_MAP[fn_name] + if len(expression.arguments) != arity: + raise_qasm3_error( + f"Function '{fn_name}' expects {arity} argument(s), but " + f"{len(expression.arguments)} were given", + err_type=FunctionCallError, + error_node=expression, + span=expression.span, + ) + values = [ + cls.evaluate_expression(argument, const_expr, reqd_type)[0] + for argument in expression.arguments + ] + if fn_name in BIT_ROTATION_FUNCTIONS: + values[0] = cls._as_bit_value(values[0], expression) + try: + return function(*values) + except (TypeError, ValueError) as err: + raise_qasm3_error( + f"Invalid argument for function '{fn_name}': {err}", + err_type=FunctionCallError, + error_node=expression, + span=expression.span, + raised_from=err, + ) + @classmethod # pylint: disable-next=too-many-return-statements,too-many-branches,too-many-statements,too-many-locals,too-many-arguments def evaluate_expression( # type: ignore[return] @@ -519,11 +584,9 @@ def _get_external_function_return_type(expression): return (None, statements) if expression.name.name in FUNCTION_MAP: - _val, _ = cls.evaluate_expression( - expression.arguments[0], const_expr, reqd_type, validate_only + return _check_and_return_value( + cls._evaluate_builtin_function(expression, const_expr, reqd_type) ) - _val = FUNCTION_MAP[expression.name.name](_val) # type: ignore - return _check_and_return_value(_val) ret_value, ret_stmts = cls.visitor_obj._visit_function_call(expression) # type: ignore statements.extend(ret_stmts) diff --git a/src/pyqasm/maps/expressions.py b/src/pyqasm/maps/expressions.py index 489fce04..889511a0 100644 --- a/src/pyqasm/maps/expressions.py +++ b/src/pyqasm/maps/expressions.py @@ -17,7 +17,7 @@ """ -from typing import Callable +from typing import Any, Callable import numpy as np from openqasm3.ast import ( @@ -243,16 +243,53 @@ def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value): "s": {"ns": 1_000_000_000, "s": 1}, } -# Function map for complex functions -FUNCTION_MAP = { - "abs": np.abs, - "real": lambda v: v.real if isinstance(v, complex) else v, - "imag": lambda v: v.imag if isinstance(v, complex) else v, - "sqrt": np.sqrt, - "sin": np.sin, - "cos": np.cos, - "tan": np.tan, - "arccos": np.arccos, - "arcsin": np.arcsin, - "arctan": np.arctan, + +def _popcount(value: Any) -> int: + """Count the set bits of a ``bit[n]`` / ``uint[n]`` value.""" + if isinstance(value, str): + value = int(value, 2) if value else 0 + if not isinstance(value, (int, np.integer)) or value < 0: + raise TypeError("expected a non-negative 'bit[n]' or 'uint[n]' operand") + return int(value).bit_count() + + +def _rotl(value: BitValue, amount: Any) -> BitValue: + """Rotate ``value`` left by ``amount`` bits, preserving its width.""" + if not isinstance(amount, (int, np.integer)): + # Reject a non-integral rotation amount rather than silently truncating it. + raise TypeError("rotation amount must be an integer") + width = value.width + if width == 0: + return value + shift = int(amount) % width + return BitValue((int(value) << shift) | (int(value) >> (width - shift)), width) + + +# Functions whose first operand must carry a register width; see ``rotl`` / ``rotr``. +BIT_ROTATION_FUNCTIONS = frozenset({"rotl", "rotr"}) + +# Built-in constant expression functions, mapped to their implementation and arity. +# Reference: https://openqasm.com/language/types.html#built-in-constant-expression-functions +# ``pow`` is absent by design: it is ambiguous with the gate modifier of the same name, +# so ``openqasm3`` cannot parse ``pow(a, b)`` as a call. Upstream removed it from the +# spec (openqasm/openqasm#635); use the ``**`` operator instead. +FUNCTION_MAP: dict[str, tuple[Callable[..., Any], int]] = { + "abs": (np.abs, 1), + "real": (lambda v: v.real if isinstance(v, complex) else v, 1), + "imag": (lambda v: v.imag if isinstance(v, complex) else v, 1), + "sqrt": (np.sqrt, 1), + "sin": (np.sin, 1), + "cos": (np.cos, 1), + "tan": (np.tan, 1), + "arccos": (np.arccos, 1), + "arcsin": (np.arcsin, 1), + "arctan": (np.arctan, 1), + "exp": (np.exp, 1), + "log": (np.log, 1), + "ceiling": (np.ceil, 1), + "floor": (np.floor, 1), + "mod": (np.mod, 2), + "popcount": (_popcount, 1), + "rotl": (_rotl, 2), + "rotr": (lambda v, n: _rotl(v, -n), 2), } diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index afdde36b..691d4eda 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -51,6 +51,7 @@ from pyqasm.exceptions import ( BreakSignal, ContinueSignal, + FunctionCallError, LoopControlSignal, LoopLimitExceededError, ValidationError, @@ -609,13 +610,14 @@ def _qubit_register_consolidation( return _valid_statements def _handle_function_init_expression( - self, expression: qasm3_ast.FunctionCall, init_value: Any + self, expression: qasm3_ast.FunctionCall, init_value: Any, base_type: Any = None ) -> None | qasm3_ast.Expression: """Handle function initialization expression. Args: expression (FunctionCall): The statement to handle function initialization expression. init_value (Any): The value to handle function initialization expression. + base_type (Any): The declared type of the assignment target, if known. Returns: None | Expression: The resultant expression if @@ -624,8 +626,15 @@ def _handle_function_init_expression( if isinstance(expression, qasm3_ast.FunctionCall): func_name = expression.name.name if func_name in FUNCTION_MAP: - if isinstance(init_value, (float, int)): - return qasm3_ast.FloatLiteral(init_value) + # ``BitValue`` is an ``int`` subclass, so it must be matched first. + if isinstance(init_value, BitValue): + if isinstance(base_type, qasm3_ast.BitType): + return qasm3_ast.BitstringLiteral(int(init_value), init_value.width) + return qasm3_ast.IntegerLiteral(int(init_value)) + if isinstance(init_value, (int, np.integer)): + return qasm3_ast.IntegerLiteral(int(init_value)) + if isinstance(init_value, (float, np.floating)): + return qasm3_ast.FloatLiteral(float(init_value)) return None def _handle_extern_function_cleanup( @@ -1891,7 +1900,9 @@ def _visit_constant_declaration( if isinstance(statement.init_expression, qasm3_ast.FunctionCall): statement.init_expression = ( - self._handle_function_init_expression(statement.init_expression, init_value) + self._handle_function_init_expression( + statement.init_expression, init_value, base_type + ) or statement.init_expression ) self._handle_extern_function_cleanup(statements, statement) @@ -2136,7 +2147,9 @@ def _visit_classical_declaration( if isinstance(statement.init_expression, qasm3_ast.FunctionCall): statement.init_expression = ( - self._handle_function_init_expression(statement.init_expression, init_value) + self._handle_function_init_expression( + statement.init_expression, init_value, base_type + ) or statement.init_expression ) @@ -2321,7 +2334,7 @@ def _visit_classical_assignment( if isinstance(statement.rvalue, qasm3_ast.FunctionCall): statement.rvalue = ( - self._handle_function_init_expression(statement.rvalue, rvalue_eval) + self._handle_function_init_expression(statement.rvalue, rvalue_eval, lvar_base_type) or statement.rvalue ) @@ -2675,6 +2688,7 @@ def _visit_function_call( return None, [] raise_qasm3_error( f"Undefined subroutine '{fn_name}' was called", + err_type=FunctionCallError, error_node=statement, span=statement.span, ) diff --git a/tests/qasm3/test_expressions.py b/tests/qasm3/test_expressions.py index f3c2ad2c..75f0f2c7 100644 --- a/tests/qasm3/test_expressions.py +++ b/tests/qasm3/test_expressions.py @@ -17,6 +17,9 @@ """ +import math +import re + import pytest from pyqasm.analyzer import bits_to_int, int_to_bits @@ -231,3 +234,104 @@ def test_bit_register_op_result_stays_bit_type(): text = dumps(module) assert "c = a | b" in text assert "d = c & a" in text + + +# --------------------------------------------------------------------------- +# Issue #390 — built-in constant expression functions +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call,expected", + [ + ("exp(1.0)", math.e), + ("log(2.0)", math.log(2.0)), + ("ceiling(1.2)", 2.0), + ("floor(1.8)", 1.0), + ("mod(7, 2)", 1), + ("popcount(37)", 3), + ("sqrt(4.0)", 2.0), + ], +) +def test_builtin_function_in_const_and_gate_argument(call, expected): + """Each built-in evaluates identically in a ``const`` initializer and inline + as a gate argument.""" + module = loads(f""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + const float[64] c = {call}; + rx(c) q[0]; + rx({call}) q[0]; + """) + module.unroll() + check_single_qubit_rotation_op(module.unrolled_ast, 2, [0, 0], [expected, expected], "rx") + + +@pytest.mark.parametrize("amount", [0, 1, 3, 8, 11, -3]) +def test_bit_rotation_preserves_width_and_direction(amount): + """``rotl(a, n) == rotr(a, -n)``, both in and out of a ``const`` initializer, and + both keep the operand's declared width.""" + module = loads(f""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + bit[8] left = rotl("00101010", {amount}); + bit[8] right = rotr("00101010", {-amount}); + const bit[8] const_left = rotl("00101010", {amount}); + const bit[8] const_right = rotr("00101010", {-amount}); + rx(const_left) q[0]; + rx(const_right) q[0]; + """) + module.unroll() + source, shift = "00101010", amount % 8 + expected = source[shift:] + source[:shift] + left, right = re.findall(r'= "(\d+)";', dumps(module)) + assert left == right == expected + assert len(left) == 8 + rotated = int(expected, 2) + check_single_qubit_rotation_op(module.unrolled_ast, 2, [0, 0], [rotated, rotated], "rx") + + +def test_rotation_on_uint_uses_declared_width(): + """A ``uint[n]`` operand carries no width at evaluation time, so the width is + recovered from its declaration.""" + module = loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + const uint[8] u = 37; + rx(rotl(u, 3)) q[0]; + rx(rotr(u, -3)) q[0]; + """) + module.unroll() + check_single_qubit_rotation_op(module.unrolled_ast, 2, [0, 0], [41, 41], "rx") + + +@pytest.mark.parametrize( + "call,message", + [ + ("sqrt(2.0, 3.0)", r"Function 'sqrt' expects 1 argument\(s\), but 2 were given"), + ("mod(7)", r"Function 'mod' expects 2 argument\(s\), but 1 were given"), + ('rotl("1010")', r"Function 'rotl' expects 2 argument\(s\), but 1 were given"), + ('ceiling("01")', r"Invalid argument for function 'ceiling'"), + ('rotl("1010", 1.5)', r"Invalid argument for function 'rotl'"), + ("popcount(1.5)", r"Invalid argument for function 'popcount'"), + ("rotl(37, 3)", r"Function 'rotl' expects a 'bit\[n\]' or 'uint\[n\]' operand"), + ("nosuchfn(2.0)", r"Undefined subroutine 'nosuchfn' was called"), + ], +) +def test_builtin_function_errors_name_the_function(call, message): + """An unknown name, a wrong arity, or a wrong argument type names the offending + function instead of only reporting a generic initialization failure.""" + with pytest.raises(ValidationError, match=message): + loads(f"OPENQASM 3.0;\nconst float[64] c = {call};").validate() + + +def test_pow_function_is_parse_blocked_and_power_operator_works(): + """``pow`` is ambiguous with the gate modifier of the same name, so ``openqasm3`` + rejects the call before pyqasm sees it. Upstream removed ``pow`` from the spec + (openqasm/openqasm#635); ``**`` is the supported spelling.""" + with pytest.raises(ValidationError, match="Failed to parse OpenQASM string"): + loads("OPENQASM 3.0;\nconst int c = pow(2, 3);") + loads("OPENQASM 3.0;\nconst int c = 2 ** 3;").validate()