From 4842d43c538af140e85351123a1d29fcf3de8125 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Mon, 24 Aug 2026 19:17:47 +0530 Subject: [PATCH 1/2] Support bit and bit[n] subroutine return values `bit x = f();` crashed with `AttributeError: 'NoneType' object has no attribute 'value'` because `validate_return_statement` read `return_type.size.value` unconditionally, and `bit` has no size. The declared width is now evaluated by the caller and passed in, and a cast failure is re-raised as `Return type mismatch for subroutine 'f'. Expected bit[2] but got float`, naming both types instead of leaking an `AttributeError` or a bare cast message. A bit declaration is emitted verbatim while the subroutine definition is not, so a `bit` return also left an unresolved `f()` call in the output. The call is now replaced by the expression standing in for its return value: a `BitstringLiteral` when the register is statically known, and a reference when it is not. The substitution is applied to a copy of the statement so the source AST keeps its call and the module can be unrolled again -- `depth()` re-unrolls a copy. `return measure q;` needed both halves. `_visit_measurement` never applied the function qubit transform, so any measurement on a formal argument raised "Missing register declaration". It now rewrites the operand innermost scope outwards, exactly as `_visit_reset` does. The measurement itself has no compile-time value, so it is bound to a temporary bit register declared at the call site; the caller's variable is then initialised from that register, which keeps the emitted program valid OpenQASM and gives the measurement a classical target. Assign- then-return returns the local bit by reference instead, and a nested subroutine forwards whichever the inner call produced. Closes #387 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + src/pyqasm/transformer.py | 4 +- src/pyqasm/validator.py | 40 +- src/pyqasm/visitor.py | 188 +++++++++- .../subroutines/test_subroutine_returns.py | 355 ++++++++++++++++++ 5 files changed, 555 insertions(+), 33 deletions(-) create mode 100644 tests/qasm3/subroutines/test_subroutine_returns.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e8734f69..5af6eec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Types of changes: - 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)) +- Fixed subroutines returning `bit` / `bit[n]`: `bit x = f();` raised `AttributeError: 'NoneType' object has no attribute 'value'`, or emitted an unresolved call. `return measure q;` now works, measuring the caller's qubit into a temporary bit. A declared/returned type mismatch raises `ValidationError` naming both types. ([#387](https://github.com/qBraid/pyqasm/issues/387)) ### Dependencies diff --git a/src/pyqasm/transformer.py b/src/pyqasm/transformer.py index 0728092a..e80b0b9d 100644 --- a/src/pyqasm/transformer.py +++ b/src/pyqasm/transformer.py @@ -372,7 +372,9 @@ def get_branch_params( @classmethod def transform_function_qubits( cls, - q_op: QuantumGate | QuantumBarrier | QuantumReset | QuantumPhase, + q_op: ( + QuantumGate | QuantumBarrier | QuantumReset | QuantumPhase | QuantumMeasurementStatement + ), qubit_transform_map: dict[tuple, tuple], qubit_sizes: dict[str, int], ) -> list[IndexedIdentifier]: diff --git a/src/pyqasm/validator.py b/src/pyqasm/validator.py index 09e11cc7..c00a0ec7 100644 --- a/src/pyqasm/validator.py +++ b/src/pyqasm/validator.py @@ -33,6 +33,7 @@ ReturnStatement, SubroutineDefinition, ) +from openqasm3.printer import dumps from pyqasm.elements import Variable from pyqasm.exceptions import ValidationError, raise_qasm3_error @@ -323,6 +324,7 @@ def validate_return_statement( # pylint: disable=inconsistent-return-statements subroutine_def: SubroutineDefinition, return_statement: ReturnStatement, return_value: Any, + return_size: Optional[int] = None, ): """Validate the return type of a function. @@ -330,6 +332,8 @@ def validate_return_statement( # pylint: disable=inconsistent-return-statements subroutine_def (SubroutineDefinition): The subroutine definition. return_statement (ReturnStatement): The return statement. return_value (Any): The return value. + return_size (Optional[int]): The evaluated width of the declared return type, + or ``None`` for a type that carries no width (``bit``, ``bool``). Raises: ValidationError: If the return type is invalid. @@ -354,19 +358,25 @@ def validate_return_statement( # pylint: disable=inconsistent-return-statements error_node=return_statement, span=return_statement.span, ) - base_size = 1 - if hasattr(subroutine_def.return_type, "size"): - base_size = subroutine_def.return_type.size.value - - return Qasm3Validator.validate_variable_assignment_value( - Variable( - subroutine_def.name.name + "_return", - subroutine_def.return_type, - base_size, - None, - None, + try: + return Qasm3Validator.validate_variable_assignment_value( + Variable( + subroutine_def.name.name + "_return", + subroutine_def.return_type, + 1 if return_size is None else return_size, + None, + None, + span=return_statement.span, + ), + return_value, + op_node=return_statement, + ) + except ValidationError as err: + raise_qasm3_error( + f"Return type mismatch for subroutine '{subroutine_def.name.name}'. " + f"Expected {dumps(subroutine_def.return_type)} but got " + f"{type(return_value).__name__}", + error_node=return_statement, span=return_statement.span, - ), - return_value, - op_node=return_statement, - ) + raised_from=err, + ) diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index afdde36b..0f14b0f3 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -181,6 +181,10 @@ def __init__( # pylint: disable=too-many-arguments self._is_branch_qubits: set[tuple[str, int]] = set() self._is_branch_clbits: set[tuple[str, int]] = set() self._measurement_set: set[str] = set() + # Expression a caller must substitute for the most recently completed + # bit-returning subroutine call. See ``_bit_return_expression``. + self._fn_return_expr: qasm3_ast.Expression | None = None + self._fn_return_count: int = 0 self._init_utilities() self._loop_limit = max_loop_iters self._consolidate_qubits: bool = consolidate_qubits @@ -349,11 +353,14 @@ def _get_op_bits( or an empty list if check_only is true. """ openqasm_bits: list[qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier] = [] - bit_list = [] + bit_list: list[Any] = [] if isinstance(operation, qasm3_ast.QuantumMeasurementStatement): if qubits: - bit_list = [operation.measure.qubit] + # After an in-subroutine qubit transform the source holds the caller's + # actual qubits as a list, the same shape ``QuantumReset`` uses. + source = operation.measure.qubit + bit_list = source if isinstance(source, list) else [source] else: assert operation.target is not None bit_list = [operation.target] @@ -626,6 +633,11 @@ def _handle_function_init_expression( if func_name in FUNCTION_MAP: if isinstance(init_value, (float, int)): return qasm3_ast.FloatLiteral(init_value) + elif func_name in self._subroutine_defns: + # Bit declarations and assignments survive unrolling verbatim while the + # subroutine definition does not, so the call has to be replaced by the + # expression standing in for its return value. + return self._fn_return_expr return None def _handle_extern_function_cleanup( @@ -699,11 +711,22 @@ def _visit_measurement( # pylint: disable=too-many-locals,too-many-branches,too ) if is_pulse_gate: return [statement] - # TODO: handle in-function measurements source_name: str = ( source.name if isinstance(source, qasm3_ast.Identifier) else source.name.name ) - if source_name not in self._global_qreg_size_map: + if self._function_qreg_size_map: + # A formal qubit argument only exists inside the subroutine, so rewrite it to + # the caller's actual qubits, innermost scope outwards, as ``_visit_reset`` does. + for transform_map, size_map in zip( + reversed(self._function_qreg_transform_map), + reversed(self._function_qreg_size_map), + ): + statement.measure.qubit = ( + Qasm3Transformer.transform_function_qubits( # type: ignore[assignment] + statement, transform_map, size_map + ) + ) + elif source_name not in self._global_qreg_size_map: raise_qasm3_error( f"Missing register declaration for '{source_name}' in measurement " f"operation", error_node=statement, @@ -2135,10 +2158,17 @@ def _visit_classical_declaration( statement.init_expression = PulseValidator.make_complex_binary_expression(init_value) if isinstance(statement.init_expression, qasm3_ast.FunctionCall): - statement.init_expression = ( - self._handle_function_init_expression(statement.init_expression, init_value) - or statement.init_expression - ) + substitute = self._handle_function_init_expression( + statement.init_expression, init_value + ) + if substitute is not None: + if statements and statements[-1] is statement: + # An emitted bit declaration is the source node itself, and the + # substitute may name a register that exists only in the unrolled + # output. Emit a copy so re-unrolling the module still sees the call. + statement = copy.copy(statement) + statements[-1] = statement + statement.init_expression = substitute if self._check_only: return [] @@ -2320,10 +2350,18 @@ def _visit_classical_assignment( ) if isinstance(statement.rvalue, qasm3_ast.FunctionCall): - statement.rvalue = ( - self._handle_function_init_expression(statement.rvalue, rvalue_eval) - or statement.rvalue - ) + substitute = self._handle_function_init_expression(statement.rvalue, rvalue_eval) + if isinstance(substitute, qasm3_ast.Identifier) and isinstance( + lvar_base_type, qasm3_ast.BitType + ): + # A run-time bit value cannot be folded into ``lvar``, so the copy from the + # subroutine's register has to survive into the emitted program. Emit a copy + # so re-unrolling the module still sees the original call. + emitted = copy.copy(statement) + emitted.rvalue = substitute + statements.append(emitted) + elif substitute is not None: + statement.rvalue = substitute self._handle_extern_function_cleanup(statements, statement) @@ -2647,6 +2685,122 @@ def _visit_subroutine_definition( return statements + def _synthesize_measurement_return( + self, + subroutine_def: qasm3_ast.SubroutineDefinition, + return_statement: qasm3_ast.ReturnStatement, + ) -> tuple[BitValue, list[qasm3_ast.Statement], qasm3_ast.Identifier]: + """Bind ``return measure q;`` to a temporary bit register at the call site. + + A measurement has no compile-time value, so the returned expression cannot be + folded into a literal. Declaring a temporary register in the caller's scope keeps + the emitted program valid OpenQASM: the measurement gets a classical target, and + the caller's variable is initialised from that target. + + Args: + subroutine_def (SubroutineDefinition): The subroutine being called. + return_statement (ReturnStatement): The return statement holding the measurement. + + Returns: + tuple[BitValue, list[Statement], Identifier]: The compile-time value of the + temporary register, the statements declaring and measuring into it, and + the identifier a caller substitutes for the call. + + Raises: + ValidationError: If the subroutine's declared return type is not a bit type. + """ + fn_name = subroutine_def.name.name + return_type = subroutine_def.return_type + if not isinstance(return_type, qasm3_ast.BitType): + raise_qasm3_error( + f"Return type mismatch for subroutine '{fn_name}'. Expected " + f"{dumps(return_type) if return_type else 'void'} but got bit " + "from a measurement", + error_node=return_statement, + span=return_statement.span, + ) + + target = qasm3_ast.Identifier(f"__{fn_name}_return_{self._fn_return_count}") + self._fn_return_count += 1 + + declaration = qasm3_ast.ClassicalDeclaration( + type=cast(qasm3_ast.ClassicalType, copy.deepcopy(return_type)), + identifier=target, + init_expression=None, + ) + # The return statement is only shallow-copied per call, so the measurement node is + # shared with the subroutine definition; copy it before rewriting its qubit operand. + measurement = qasm3_ast.QuantumMeasurementStatement( + measure=copy.deepcopy(return_statement.expression), target=target # type: ignore + ) + declaration.span = measurement.span = return_statement.span + + statements: list[qasm3_ast.Statement] = list(self._visit_classical_declaration(declaration)) + statements.extend(self._visit_measurement(measurement)) + + return BitValue(0, self._global_creg_size_map[target.name]), statements, target + + def _bit_return_expression( + self, expression: qasm3_ast.Expression | None, return_value: Any + ) -> qasm3_ast.Expression | None: + """Pick the expression a caller substitutes for a bit-returning subroutine call. + + Args: + expression (Expression | None): The subroutine's return expression. + return_value (Any): The evaluated, type-checked return value. + + Returns: + Expression | None: The substitute expression, or ``None`` when the return type + is not a bit register and the call therefore leaves no emitted statement. + """ + if not isinstance(return_value, BitValue): + return None + # A register whose value is only known at run time — measured here, or forwarded + # from a nested call — must stay a reference; folding it would emit a stale 0. + if ( + isinstance(expression, qasm3_ast.FunctionCall) + and expression.name.name in self._subroutine_defns + and self._fn_return_expr is not None + ): + return self._fn_return_expr + if ( + isinstance(expression, qasm3_ast.Identifier) + and expression.name in self._measurement_set + ): + return expression + return qasm3_ast.BitstringLiteral(int(return_value), return_value.width) + + def _evaluate_return_expression( + self, + subroutine_def: qasm3_ast.SubroutineDefinition, + return_statement: qasm3_ast.ReturnStatement, + ) -> tuple[Any, list[qasm3_ast.Statement], qasm3_ast.Expression | None]: + """Evaluate and type-check a subroutine's return expression. + + Args: + subroutine_def (SubroutineDefinition): The subroutine being called. + return_statement (ReturnStatement): The return statement to evaluate. + + Returns: + tuple[Any, list[Statement], Expression | None]: The compile-time return value, + the statements the return expression emitted, and the expression a caller + substitutes for the call (``None`` for non-bit return types). + """ + expression = return_statement.expression + if isinstance(expression, qasm3_ast.QuantumMeasurement): + return self._synthesize_measurement_return(subroutine_def, return_statement) + + return_value, statements = Qasm3ExprEvaluator.evaluate_expression(expression) + return_size = None + if getattr(subroutine_def.return_type, "size", None) is not None: + return_size = Qasm3ExprEvaluator.evaluate_expression( + subroutine_def.return_type.size, const_expr=True # type: ignore[union-attr] + )[0] + return_value = Qasm3Validator.validate_return_statement( + subroutine_def, return_statement, return_value, return_size + ) + return return_value, statements, self._bit_return_expression(expression, return_value) + # pylint: disable=too-many-locals, too-many-statements def _visit_function_call( self, statement: qasm3_ast.FunctionCall @@ -2736,6 +2890,7 @@ def _visit_function_call( return_statement = None return_value = None + return_expr: qasm3_ast.Expression | None = None result: list[qasm3_ast.Statement | qasm3_ast.FunctionCall] = [] if isinstance(subroutine_def, qasm3_ast.ExternDeclaration): self._in_extern_function = True @@ -2756,14 +2911,13 @@ def _visit_function_call( result.extend(self.visit_statement(copy.deepcopy(function_op))) if return_statement: - return_value, stmts = Qasm3ExprEvaluator.evaluate_expression( - return_statement.expression, - ) - return_value = Qasm3Validator.validate_return_statement( - subroutine_def, return_statement, return_value + return_value, stmts, return_expr = self._evaluate_return_expression( + subroutine_def, return_statement ) result.extend(stmts) + self._fn_return_expr = return_expr + # remove qubit transformation map self._function_qreg_transform_map.pop() self._function_qreg_size_map.pop() diff --git a/tests/qasm3/subroutines/test_subroutine_returns.py b/tests/qasm3/subroutines/test_subroutine_returns.py new file mode 100644 index 00000000..9c6fca02 --- /dev/null +++ b/tests/qasm3/subroutines/test_subroutine_returns.py @@ -0,0 +1,355 @@ +# 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 bit-typed subroutine return values. + +Reference: https://openqasm.com/versions/3.1/language/subroutines.html + +""" + +import pytest + +from pyqasm.entrypoint import dumps, loads +from pyqasm.exceptions import ValidationError +from tests.utils import check_measure_op, check_single_qubit_gate_op, check_unrolled_qasm + + +def test_bit_return(): + """Test that a subroutine returning 'bit' initialises a caller's bit variable.""" + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def my_function(qubit q) -> bit { + h q; + bit b = "1"; + return b; + } + qubit q; + bit x = my_function(q); + """ + + result = loads(qasm_str) + result.unroll() + + check_single_qubit_gate_op(result.unrolled_ast, 1, [0], "h") + check_unrolled_qasm( + dumps(result), + """OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[0]; + bit[1] b = "1"; + bit[1] x = "1"; + """, + ) + + +def test_bit_register_return(): + """Test that a subroutine returning 'bit[n]' initialises a caller's bit[n] variable.""" + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def my_function(qubit q) -> bit[2] { + h q; + bit[2] b = "10"; + return b; + } + qubit q; + bit[2] x = my_function(q); + """ + + result = loads(qasm_str) + result.unroll() + + check_unrolled_qasm( + dumps(result), + """OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[0]; + bit[2] b = "10"; + bit[2] x = "10"; + """, + ) + + +def test_return_measure(): + """Test that 'return measure q;' measures the caller's qubit and binds the result.""" + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def my_function(qubit qin) -> bit { + h qin; + return measure qin; + } + qubit[2] q; + bit x = my_function(q[1]); + """ + + result = loads(qasm_str) + result.unroll() + assert result.num_qubits == 2 + + check_single_qubit_gate_op(result.unrolled_ast, 1, [1], "h") + check_measure_op(result.unrolled_ast, 1, [(("q", 1), ("__my_function_return_0", 0))]) + check_unrolled_qasm( + dumps(result), + """OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + h q[1]; + bit[1] __my_function_return_0; + __my_function_return_0[0] = measure q[1]; + bit[1] x = __my_function_return_0; + """, + ) + + +def test_return_measure_register(): + """Test that a multi-qubit 'return measure q;' unrolls to one measurement per qubit.""" + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def my_function(qubit[2] q) -> bit[2] { + h q[0]; + return measure q; + } + qubit[2] q; + bit[2] x = my_function(q); + """ + + result = loads(qasm_str) + result.unroll() + + check_measure_op( + result.unrolled_ast, + 2, + [ + (("q", 0), ("__my_function_return_0", 0)), + (("q", 1), ("__my_function_return_0", 1)), + ], + ) + + +def test_assign_then_return_measure(): + """Test that measuring into a local bit and returning it binds the caller's variable.""" + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def my_function(qubit q) -> bit { + bit c; + h q; + c = measure q; + return c; + } + qubit q; + bit x = my_function(q); + """ + + result = loads(qasm_str) + result.unroll() + + check_measure_op(result.unrolled_ast, 1, [(("q", 0), ("c", 0))]) + check_unrolled_qasm( + dumps(result), + """OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + bit[1] c; + h q[0]; + c[0] = measure q[0]; + bit[1] x = c; + """, + ) + + +def test_bit_return_in_caller_assignment(): + """Test that a bit return can be assigned to an already declared caller variable.""" + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def my_function(qubit q) -> bit { + return measure q; + } + qubit q; + bit x; + x = my_function(q); + """ + + result = loads(qasm_str) + result.unroll() + + check_unrolled_qasm( + dumps(result), + """OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + bit[1] x; + bit[1] __my_function_return_0; + __my_function_return_0[0] = measure q[0]; + x = __my_function_return_0; + """, + ) + + +def test_nested_subroutine_forwards_bit_return(): + """Test that an outer subroutine forwards an inner subroutine's measured bit return.""" + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def inner(qubit q) -> bit { + return measure q; + } + def outer(qubit q) -> bit { + h q; + return inner(q); + } + qubit q; + bit x = outer(q); + """ + + result = loads(qasm_str) + result.unroll() + + check_single_qubit_gate_op(result.unrolled_ast, 1, [0], "h") + check_measure_op(result.unrolled_ast, 1, [(("q", 0), ("__inner_return_0", 0))]) + check_unrolled_qasm( + dumps(result), + """OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] q; + h q[0]; + bit[1] __inner_return_0; + __inner_return_0[0] = measure q[0]; + bit[1] x = __inner_return_0; + """, + ) + + +def test_nested_subroutine_forwards_literal_bit_return(): + """Test that a forwarded, statically known bit return folds to a bitstring literal.""" + qasm_str = """OPENQASM 3.0; + def inner() -> bit[3] { + bit[3] b = "101"; + return b; + } + def outer() -> bit[3] { + return inner(); + } + bit[3] x = outer(); + """ + + result = loads(qasm_str) + result.unroll() + + check_unrolled_qasm( + dumps(result), + """OPENQASM 3.0; + bit[3] b = "101"; + bit[3] x = "101"; + """, + ) + + +def test_repeated_calls_get_distinct_return_registers(): + """Test that each call to a measurement-returning subroutine gets its own temporary + register. The return statement is shared between calls, so a qubit operand rewritten + in place on the first call used to leak into the second.""" + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def my_function(qubit qin) -> bit { + return measure qin; + } + qubit[2] q; + bit a = my_function(q[0]); + bit b = my_function(q[1]); + """ + + result = loads(qasm_str) + result.unroll() + + check_measure_op( + result.unrolled_ast, + 2, + [ + (("q", 0), ("__my_function_return_0", 0)), + (("q", 1), ("__my_function_return_1", 0)), + ], + ) + + +def test_module_can_be_unrolled_twice(): + """Test that unrolling twice reproduces the same program. Substituting the temporary + register into the source declaration used to make the second pass fail with + 'Undefined identifier', which `depth()` hit because it re-unrolls a copy.""" + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def my_function(qubit qin) -> bit { + h qin; + return measure qin; + } + qubit q; + bit x = my_function(q); + """ + + result = loads(qasm_str) + result.unroll() + first_pass = dumps(result) + + assert result.depth() == 2 + result.unroll() + check_unrolled_qasm(dumps(result), first_pass) + + +@pytest.mark.parametrize( + "return_type, returned, expected", + [ + ("bit[2]", "return 3.5;", "Expected bit\\[2\\] but got float"), + ("bit", "return 2.5;", "Expected bit but got float"), + ("int[8]", 'return "10";', "Expected int\\[8\\] but got str"), + ], +) +def test_return_type_mismatch_names_both_types(return_type, returned, expected, caplog): + """Test that a declared/returned type mismatch raises a ValidationError, not + an AttributeError, and names both the declared and the returned type.""" + qasm_str = f"""OPENQASM 3.0; + def my_function() -> {return_type} {{ + {returned} + }} + my_function(); + """ + + with pytest.raises( + ValidationError, + match=rf"Return type mismatch for subroutine 'my_function'\. {expected}", + ): + with caplog.at_level("ERROR"): + loads(qasm_str).validate() + + assert "Error at line 3, column 8" in caplog.text + + +def test_return_measure_from_void_subroutine(caplog): + """Test that returning a measurement from a void subroutine names both types.""" + qasm_str = """OPENQASM 3.0; + def my_function(qubit q) { + return measure q; + } + qubit q; + my_function(q); + """ + + with pytest.raises( + ValidationError, + match=r"Return type mismatch for subroutine 'my_function'\. " + r"Expected void but got bit from a measurement", + ): + with caplog.at_level("ERROR"): + loads(qasm_str).validate() + + assert "Error at line 3, column 8" in caplog.text From da04e7a88f2033be6547a2fa74dc100b02a0bcdc Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Mon, 24 Aug 2026 19:32:30 +0530 Subject: [PATCH 2/2] Transform a copy of an in-subroutine measurement, not the definition `_visit_function_call` only shallow-copies each body statement, so the inner `QuantumMeasurement` node stays shared with the subroutine definition. Rewriting `statement.measure.qubit` in place therefore wrote the caller's qubits onto the definition itself, and the next visit of that body read a list where an `Identifier` was expected: AttributeError: 'list' object has no attribute 'name' The shallow copy protects `statement.qubits` for `_visit_reset`, which rebinds one level up; a measurement's operand sits one level deeper. Reported as `validate()` followed by `unroll()` on an assign-then-return subroutine, but the trigger is any second visit of a subroutine body containing a measurement, with or without a return: calling such a subroutine twice in a single `unroll()` failed the same way. `_visit_measurement` now copies the statement and its `measure` node before applying the transform. `return measure q;` was already immune because `_synthesize_measurement_return` deep-copies the expression. Co-Authored-By: Claude Opus 5 (1M context) --- src/pyqasm/visitor.py | 5 + .../subroutines/test_subroutine_returns.py | 96 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index 0f14b0f3..9bfd9105 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -717,6 +717,11 @@ def _visit_measurement( # pylint: disable=too-many-locals,too-many-branches,too if self._function_qreg_size_map: # A formal qubit argument only exists inside the subroutine, so rewrite it to # the caller's actual qubits, innermost scope outwards, as ``_visit_reset`` does. + # The rewrite runs on a copy: a subroutine body is only shallow-copied per call, + # so the inner ``measure`` node is shared with the definition, and mutating it + # would leave the caller's qubits behind for the next visit of that body. + statement = copy.copy(statement) + statement.measure = copy.copy(statement.measure) for transform_map, size_map in zip( reversed(self._function_qreg_transform_map), reversed(self._function_qreg_size_map), diff --git a/tests/qasm3/subroutines/test_subroutine_returns.py b/tests/qasm3/subroutines/test_subroutine_returns.py index 9c6fca02..8be676b1 100644 --- a/tests/qasm3/subroutines/test_subroutine_returns.py +++ b/tests/qasm3/subroutines/test_subroutine_returns.py @@ -256,6 +256,102 @@ def outer() -> bit[3] { ) +MEASURING_SUBROUTINES = { + "return_measure": """ + def my_function(qubit qin) -> bit { + h qin; + return measure qin; + } + qubit q; + bit x = my_function(q); + """, + "assign_then_return": """ + def my_function(qubit qin) -> bit { + bit c; + h qin; + c = measure qin; + return c; + } + qubit q; + bit x = my_function(q); + """, + "nested_forward": """ + def inner(qubit qin) -> bit { + return measure qin; + } + def outer(qubit qin) -> bit { + h qin; + return inner(qin); + } + qubit q; + bit x = outer(q); + """, + "measure_without_return": """ + def my_function(qubit qin) { + bit c; + h qin; + c = measure qin; + } + qubit q; + my_function(q); + """, + "literal_bit_return": """ + def my_function(qubit qin) -> bit[2] { + h qin; + bit[2] b = "10"; + return b; + } + qubit q; + bit[2] x = my_function(q); + """, +} + + +@pytest.mark.parametrize("body", MEASURING_SUBROUTINES.values(), ids=MEASURING_SUBROUTINES) +def test_validate_then_unroll(body): + """Test that validating a module first does not change what unrolling it produces. + + A subroutine body is only shallow-copied per call, so rewriting a measurement's qubit + operand in place left the caller's qubits on the definition. The validation pass then + poisoned the definition for the unroll that followed, raising + `AttributeError: 'list' object has no attribute 'name'`. + """ + qasm_str = 'OPENQASM 3.0;\ninclude "stdgates.inc";\n' + body + + unrolled_only = loads(qasm_str) + unrolled_only.unroll() + + validated_first = loads(qasm_str) + validated_first.validate() + validated_first.unroll() + + check_unrolled_qasm(dumps(validated_first), dumps(unrolled_only)) + + +def test_repeated_assign_then_return_calls(): + """Test that calling an assign-then-return subroutine twice measures both qubits. + + The second call used to re-read the qubit operand the first call had rewritten, + raising `AttributeError: 'list' object has no attribute 'name'`. + """ + qasm_str = """OPENQASM 3.0; + include "stdgates.inc"; + def my_function(qubit qin) -> bit { + bit c; + c = measure qin; + return c; + } + qubit[2] q; + bit a = my_function(q[0]); + bit b = my_function(q[1]); + """ + + result = loads(qasm_str) + result.unroll() + + check_measure_op(result.unrolled_ast, 2, [(("q", 0), ("c", 0)), (("q", 1), ("c", 0))]) + + def test_repeated_calls_get_distinct_return_registers(): """Test that each call to a measurement-returning subroutine gets its own temporary register. The return statement is shared between calls, so a qubit operand rewritten