From 89262c826e7847a5b40f356c2052dfe0dac5f6bc Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 23 Oct 2026 14:20:03 -0500 Subject: [PATCH 1/5] fix(visitor): pair register operands element by element when broadcasting a gate cx a, b over two registers unrolled to cx a[0], a[1]; cx b[0], b[1] because every operand was flattened into one list and chunked by the gate's arity. When a call has one operand per gate qubit, register operands now pair by index and single qubits repeat, as the spec defines. Slices, aliases, subroutine register arguments and ctrl @ take the same path. Calls with fewer operands than gate qubits, such as cx q2, keep filling operands from the register as before. Closes #384 --- src/pyqasm/visitor.py | 93 ++++++++++++++++++++++++++++++++++ tests/qasm3/test_broadcast.py | 94 +++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 tests/qasm3/test_broadcast.py diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index 8c2e203b..a5780520 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -1605,6 +1605,89 @@ def _visit_phase_operation( return [operation] + def _broadcast_operands( + self, operation: qasm3_ast.QuantumGate | qasm3_ast.QuantumPhase + ) -> Optional[list[list[qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier]]]: + """Split a gate applied to register operands into one operand list per index. + + OpenQASM 3 broadcasting pairs register operands element by element and repeats + single-qubit operands, so ``cx a, b`` is ``cx a[0], b[0]; cx a[1], b[1];``. An operand + that names several qubits (a register, alias, slice or set) is a register operand. + This applies only when there is one operand per gate qubit; with fewer, a register + still fills several operands (``cx q2`` is ``cx q2[0], q2[1]``), as before. + + Returns: + The operands of each gate application, still in the operation's own naming, or + None to leave the operation as is. + + Raises: + ValidationError: If the register operands have different lengths. + """ + if ( + not isinstance(operation, qasm3_ast.QuantumGate) + or len(operation.qubits) < 2 + or self._scope_manager.in_gate_scope() + or len(operation.qubits) != self._gate_qubit_count(operation) + ): + return None + function_sizes = self._function_qreg_size_map[-1] if self._function_qreg_size_map else None + # resolve the whole operation first so an invalid operand is reported against it + self._get_op_bits(operation, qubits=True, function_qubit_sizes=function_sizes) + resolved = [] + for operand in operation.qubits: + single = copy.copy(operation) + single.qubits = [operand] + resolved.append( + self._get_op_bits(single, qubits=True, function_qubit_sizes=function_sizes) + ) + lengths = {len(bits) for bits in resolved if len(bits) > 1} + if not lengths: + return None + if len(lengths) > 1: + raise_qasm3_error( + f"Cannot broadcast '{operation.name.name}' over registers of different lengths " + f"{sorted(lengths)}", + error_node=operation, + span=operation.span, + ) + size = lengths.pop() + return [[bits[i] if len(bits) > 1 else bits[0] for bits in resolved] for i in range(size)] + + def _gate_qubit_count(self, operation: qasm3_ast.QuantumGate) -> Optional[int]: + """The number of qubits one application of ``operation`` takes, controls included. + + Returns None when it cannot be known up front (an opaque gate, or a non-constant + modifier argument), which leaves broadcasting to the existing path. + """ + name = operation.name.name + if name in self._custom_gates: + count = len(self._custom_gates[name].qubits) + elif name in self._opaque_gates: + return None + else: + try: + _, count = map_qasm_op_to_callable(operation) + except ValidationError: + return None + for modifier in operation.modifiers: + if modifier.modifier in ( + qasm3_ast.GateModifierName.ctrl, + qasm3_ast.GateModifierName.negctrl, + ): + if modifier.argument is None: + count += 1 + continue + try: + controls = Qasm3ExprEvaluator.evaluate_expression( + modifier.argument, const_expr=True + )[0] + except ValidationError: + return None + if not isinstance(controls, int): + return None + count += controls + return count + def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-many-statements self, operation: qasm3_ast.QuantumGate | qasm3_ast.QuantumPhase, @@ -1659,6 +1742,16 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man # reported even when one of its qubits is also undeclared. map_qasm_op_to_callable(operation) + broadcast = self._broadcast_operands(operation) + if broadcast is not None: + applications: list[qasm3_ast.QuantumGate | qasm3_ast.QuantumPhase] = [] + for qubits in broadcast: + single = copy.copy(operation) + single.qubits = qubits # type: ignore[assignment] + single.modifiers = list(operation.modifiers) + applications.extend(self._visit_generic_gate_operation(single, ctrls)) + return applications + self._in_generic_gate_op_scope += 1 # only needs to be done once for a gate operation diff --git a/tests/qasm3/test_broadcast.py b/tests/qasm3/test_broadcast.py new file mode 100644 index 00000000..aadfa6f0 --- /dev/null +++ b/tests/qasm3/test_broadcast.py @@ -0,0 +1,94 @@ +# 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 OpenQASM 3 gate broadcasting over registers. + +""" + +import pytest + +from pyqasm.entrypoint import dumps, loads +from pyqasm.exceptions import ValidationError +from tests.utils import check_unrolled_qasm + + +@pytest.mark.parametrize( + "body, expected", + [ + ( + "qubit[2] a;\nqubit[2] b;\ncx a, b;", + "qubit[2] a;\nqubit[2] b;\ncx a[0], b[0];\ncx a[1], b[1];", + ), + ( + "qubit[4] q;\ncx q[0:1], q[2:3];", + "qubit[4] q;\ncx q[0], q[2];\ncx q[1], q[3];", + ), + ( + "qubit[4] q;\nlet a = q[0:1];\nlet b = q[2:3];\ncx a, b;", + "qubit[4] q;\ncx q[0], q[2];\ncx q[1], q[3];", + ), + ( + "def f(qubit[2] a, qubit[2] b) { cx a, b; }\nqubit[2] r;\nqubit[2] s;\nf(r, s);", + "qubit[2] r;\nqubit[2] s;\ncx r[0], s[0];\ncx r[1], s[1];", + ), + ( + "qubit[2] a;\nqubit[2] b;\nswap a, b;", + "qubit[2] a;\nqubit[2] b;\nswap a[0], b[0];\nswap a[1], b[1];", + ), + ( + "qubit[2] a;\nqubit[2] b;\nctrl @ x a, b;", + "qubit[2] a;\nqubit[2] b;\ncx a[0], b[0];\ncx a[1], b[1];", + ), + ( + "qubit[3] q;\nqubit[1] r;\ncx q, r[0];", + "qubit[3] q;\nqubit[1] r;\ncx q[0], r[0];\ncx q[1], r[0];\ncx q[2], r[0];", + ), + ], + ids=["registers", "slices", "aliases", "subroutine-registers", "swap", "ctrl", "mixed"], +) +def test_broadcast_pairs_register_operands(body, expected): + """Register operands pair element by element and single qubits repeat (issue #384).""" + header = 'OPENQASM 3.0;\ninclude "stdgates.inc";\n' + result = loads(header + body) + result.unroll() + check_unrolled_qasm(dumps(result), header + expected) + + +def test_broadcast_spec_example(): + """The spec's g4 example applies the gate once per index of qr1 and qr3.""" + qasm3_string = """ + OPENQASM 3.0; + gate g4 a, b, c, d { } + qubit[1] qr0; + qubit[3] qr1; + qubit[2] qr2; + qubit[3] qr3; + g4 qr0[0], qr1, qr2[0], qr3; + """ + result = loads(qasm3_string) + result.unroll(external_gates=["g4"]) + gates = [line for line in dumps(result).splitlines() if line.startswith("g4")] + assert gates == [ + "g4 qr0[0], qr1[0], qr2[0], qr3[0];", + "g4 qr0[0], qr1[1], qr2[0], qr3[1];", + "g4 qr0[0], qr1[2], qr2[0], qr3[2];", + ] + + +def test_broadcast_register_length_mismatch_raises(): + """Register operands of different lengths cannot be paired.""" + qasm3_string = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[2] a;\nqubit[3] b;\ncx a, b;\n' + with pytest.raises(ValidationError, match="different lengths"): + loads(qasm3_string).unroll() From 840aed0b501bd2f6443d8ca5b46a6da16653ffde Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Fri, 23 Oct 2026 14:20:24 -0500 Subject: [PATCH 2/5] docs(changelog): describe register broadcasting fix (#462) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0110b9cf..c621bfe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Types of changes: ### Removed ### Fixed +- `unroll()` now broadcasts a gate over register operands element by element, as the spec defines. `cx a, b` on two registers became `cx a[0], a[1]; cx b[0], b[1];` instead of `cx a[0], b[0]; cx a[1], b[1];`, and slices, aliases, subroutine register arguments and `ctrl @` did the same. A register beside single-qubit operands (`cx q, r[0]`) now repeats the single qubit instead of raising, and register operands of different lengths raise `ValidationError` ([#462](https://github.com/qBraid/pyqasm/pull/462), [#384](https://github.com/qBraid/pyqasm/issues/384)). - `loads()` syntax errors now name the offending token, line and column, e.g. `Failed to parse OpenQASM string: syntax error near ';' at line 3, column 13`. Before, the reason after the colon was empty ([#460](https://github.com/qBraid/pyqasm/pull/460)). - Fixed the matplotlib printer's controlled-gate lookup for `c3x` and `c4x` ([#458](https://github.com/qBraid/pyqasm/pull/458), [#322](https://github.com/qBraid/pyqasm/issues/322)). - Fixed `loads("")` / whitespace-only input raising `AttributeError` instead of `ValidationError`. Empty and whitespace programs now raise `ValidationError` like other parse failures. ([#451](https://github.com/qBraid/pyqasm/pull/451), [#437](https://github.com/qBraid/pyqasm/issues/437)) From a6defcfa652cfd9e2fa8587deefb1d8172c094c7 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Wed, 28 Oct 2026 10:14:51 -0500 Subject: [PATCH 3/5] fix(visitor): skip broadcast resolution for single-qubit operands; treat slices as registers Address review on #462: - Check operand forms before counting gate qubits or resolving operands. An indexed operand without a slice or set names one qubit, so plain `cx q[0], q[1]` no longer resolves every operand twice (30k-gate unroll: 1.86s -> 1.42s; main 1.37s). - A slice or set is a register operand even when it names one qubit, so `cx q[0:1], q[2:2]` raises a length mismatch instead of repeating `q[2]`. A bare length-1 register is still repeated, because the visitor rewrites `qubit x` to `qubit[1] x` in place. - Raise a QASM error for an empty broadcast operand instead of an IndexError. - Drop the unreachable opaque branch in `_gate_qubit_count`; opaque gates are custom gates with empty bodies by then. - Test OpenQASM 2 registers, duplicate qubits, length-1 slices, empty operands and single-qubit repetition. --- src/pyqasm/visitor.py | 57 ++++++++++++++++++++++++++++------- tests/qasm3/test_broadcast.py | 39 ++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index a5780520..7324fb80 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -1611,8 +1611,9 @@ def _broadcast_operands( """Split a gate applied to register operands into one operand list per index. OpenQASM 3 broadcasting pairs register operands element by element and repeats - single-qubit operands, so ``cx a, b`` is ``cx a[0], b[0]; cx a[1], b[1];``. An operand - that names several qubits (a register, alias, slice or set) is a register operand. + single-qubit operands, so ``cx a, b`` is ``cx a[0], b[0]; cx a[1], b[1];``. A slice or + set is a register operand even when it names one qubit; a bare name is one when it + names several (see ``_is_register_operand``). This applies only when there is one operand per gate qubit; with fewer, a register still fills several operands (``cx q2`` is ``cx q2[0], q2[1]``), as before. @@ -1621,16 +1622,22 @@ def _broadcast_operands( None to leave the operation as is. Raises: - ValidationError: If the register operands have different lengths. + ValidationError: If a register operand is empty or the register operands have + different lengths. """ if ( not isinstance(operation, qasm3_ast.QuantumGate) or len(operation.qubits) < 2 or self._scope_manager.in_gate_scope() - or len(operation.qubits) != self._gate_qubit_count(operation) ): return None function_sizes = self._function_qreg_size_map[-1] if self._function_qreg_size_map else None + forms = [self._is_register_operand(operand) for operand in operation.qubits] + # checked before anything is resolved, so plain ``cx q[0], q[1]`` stays cheap + if not any(forms) and None not in forms: + return None + if len(operation.qubits) != self._gate_qubit_count(operation): + return None # resolve the whole operation first so an invalid operand is reported against it self._get_op_bits(operation, qubits=True, function_qubit_sizes=function_sizes) resolved = [] @@ -1640,9 +1647,18 @@ def _broadcast_operands( resolved.append( self._get_op_bits(single, qubits=True, function_qubit_sizes=function_sizes) ) - lengths = {len(bits) for bits in resolved if len(bits) > 1} - if not lengths: + is_register = [ + len(bits) > 1 if form is None else form for form, bits in zip(forms, resolved) + ] + if not any(is_register): return None + if any(not bits for bits in resolved): + raise_qasm3_error( + f"Cannot broadcast '{operation.name.name}' over an empty operand", + error_node=operation, + span=operation.span, + ) + lengths = {len(bits) for bits, reg in zip(resolved, is_register) if reg} if len(lengths) > 1: raise_qasm3_error( f"Cannot broadcast '{operation.name.name}' over registers of different lengths " @@ -1651,19 +1667,38 @@ def _broadcast_operands( span=operation.span, ) size = lengths.pop() - return [[bits[i] if len(bits) > 1 else bits[0] for bits in resolved] for i in range(size)] + return [ + [bits[i] if reg else bits[0] for bits, reg in zip(resolved, is_register)] + for i in range(size) + ] + + @staticmethod + def _is_register_operand( + operand: qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier, + ) -> Optional[bool]: + """Whether a gate operand is written as a register rather than a single qubit. + + Returns None for a bare register or alias name, which the caller judges by resolved + length. So ``qubit[1] r`` is repeated like a single qubit: the visitor rewrites + ``qubit x`` to ``qubit[1] x`` in place, so the two cannot be told apart later. + """ + if isinstance(operand, qasm3_ast.IndexedIdentifier): + last = operand.indices[-1] + return isinstance(last, qasm3_ast.DiscreteSet) or any( + isinstance(index, qasm3_ast.RangeDefinition) for index in last + ) + return False if operand.name.startswith(PHYSICAL_QUBIT_PREFIX) else None def _gate_qubit_count(self, operation: qasm3_ast.QuantumGate) -> Optional[int]: """The number of qubits one application of ``operation`` takes, controls included. - Returns None when it cannot be known up front (an opaque gate, or a non-constant - modifier argument), which leaves broadcasting to the existing path. + Returns None when it cannot be known up front (a non-constant modifier argument), + which leaves broadcasting to the existing path. An ``opaque`` gate is a custom gate + with an empty body by now, so it broadcasts like one. """ name = operation.name.name if name in self._custom_gates: count = len(self._custom_gates[name].qubits) - elif name in self._opaque_gates: - return None else: try: _, count = map_qasm_op_to_callable(operation) diff --git a/tests/qasm3/test_broadcast.py b/tests/qasm3/test_broadcast.py index aadfa6f0..f0f309ef 100644 --- a/tests/qasm3/test_broadcast.py +++ b/tests/qasm3/test_broadcast.py @@ -92,3 +92,42 @@ def test_broadcast_register_length_mismatch_raises(): qasm3_string = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[2] a;\nqubit[3] b;\ncx a, b;\n' with pytest.raises(ValidationError, match="different lengths"): loads(qasm3_string).unroll() + + +def _cx_lines(qasm_string): + module = loads(qasm_string) + module.unroll() + return [line for line in dumps(module).splitlines() if line.startswith("cx")] + + +def test_broadcast_qasm2_registers(): + """OpenQASM 2 pairs register operands the same way.""" + qasm2_string = 'OPENQASM 2.0;\ninclude "qelib1.inc";\nqreg q[2];\nqreg r[2];\ncx q, r;\n' + assert _cx_lines(qasm2_string) == ["cx q[0], r[0];", "cx q[1], r[1];"] + + +def test_broadcast_duplicate_qubit_raises(): + """Broadcasting a register against itself pairs each qubit with itself.""" + qasm3_string = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[2] q;\ncx q, q;\n' + with pytest.raises(ValidationError, match="Duplicate qubit"): + loads(qasm3_string).unroll() + + +def test_broadcast_length_one_slice_is_a_register(): + """A slice naming one qubit is still a register operand, so its length must match.""" + qasm3_string = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[3] q;\ncx q[0:1], q[2:2];\n' + with pytest.raises(ValidationError, match=r"different lengths \[1, 2\]"): + loads(qasm3_string).unroll() + + +def test_broadcast_empty_operand_raises(): + """An empty slice is reported as a QASM error, not an IndexError.""" + qasm3_string = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[4] q;\ncx q[0:1], q[3:2];\n' + with pytest.raises(ValidationError, match="empty operand"): + loads(qasm3_string).unroll() + + +def test_broadcast_single_qubit_repeats(): + """A single qubit beside a register is repeated for each register element.""" + qasm3_string = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit x;\nqubit[2] q;\ncx x, q;\n' + assert _cx_lines(qasm3_string) == ["cx x[0], q[0];", "cx x[0], q[1];"] From 64cbed2e83abefd6fda131466918604af667c6b5 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Tue, 3 Nov 2026 14:02:55 +0530 Subject: [PATCH 4/5] refactor(visitor): move broadcast helpers to the validator and analyzer is_register_operand checks a gate operand's form, so it moves to Qasm3Validator. gate_qubit_count analyses a QuantumGate, so it moves to Qasm3Analyzer. It takes the custom gates and the expression evaluator as arguments, like analyze_classical_indices. The visitor calls both. Co-Authored-By: Claude Opus 5.5 --- src/pyqasm/analyzer.py | 39 +++++++++++++++++++++++++++ src/pyqasm/validator.py | 21 ++++++++++++++- src/pyqasm/visitor.py | 60 +++++------------------------------------ 3 files changed, 65 insertions(+), 55 deletions(-) diff --git a/src/pyqasm/analyzer.py b/src/pyqasm/analyzer.py index 9ecc571d..3f2a80ed 100644 --- a/src/pyqasm/analyzer.py +++ b/src/pyqasm/analyzer.py @@ -30,12 +30,14 @@ DiscreteSet, EndStatement, Expression, + GateModifierName, Identifier, IndexedIdentifier, IndexExpression, IntegerLiteral, IntType, QuantumGate, + QuantumGateDefinition, QuantumMeasurementStatement, RangeDefinition, Span, @@ -44,6 +46,7 @@ ) from pyqasm.exceptions import QasmParsingError, ValidationError, raise_qasm3_error +from pyqasm.maps.gates import map_qasm_op_to_callable if TYPE_CHECKING: from pyqasm.elements import Variable @@ -340,6 +343,42 @@ def verify_gate_qubits(gate: QuantumGate, span: Optional[Span] = None): span=span, ) + @staticmethod + def gate_qubit_count( + operation: QuantumGate, + custom_gates: dict[str, QuantumGateDefinition], + expr_evaluator: type[Qasm3ExprEvaluator], + ) -> Optional[int]: + """The number of qubits one application of ``operation`` takes, controls included. + + Returns None when it cannot be known up front (a non-constant modifier argument), + which leaves broadcasting to the existing path. An ``opaque`` gate is a custom gate + with an empty body by now, so it broadcasts like one. + """ + name = operation.name.name + if name in custom_gates: + count = len(custom_gates[name].qubits) + else: + try: + _, count = map_qasm_op_to_callable(operation) + except ValidationError: + return None + for modifier in operation.modifiers: + if modifier.modifier in (GateModifierName.ctrl, GateModifierName.negctrl): + if modifier.argument is None: + count += 1 + continue + try: + controls = expr_evaluator.evaluate_expression( + modifier.argument, const_expr=True + )[0] + except ValidationError: + return None + if not isinstance(controls, int): + return None + count += controls + return count + @staticmethod def condition_depends_on_measurement(condition: Expression, measurement_set: set[str]) -> bool: """Recursively check if the condition depends on a classical register set by measurement.""" diff --git a/src/pyqasm/validator.py b/src/pyqasm/validator.py index b9097fd6..6202ed55 100644 --- a/src/pyqasm/validator.py +++ b/src/pyqasm/validator.py @@ -23,18 +23,22 @@ from openqasm3.ast import ( ArrayType, ClassicalDeclaration, + DiscreteSet, FloatType, + Identifier, + IndexedIdentifier, ) from openqasm3.ast import IntType as Qasm3IntType from openqasm3.ast import ( QASMNode, QuantumGate, QuantumGateDefinition, + RangeDefinition, ReturnStatement, SubroutineDefinition, ) -from pyqasm.elements import Variable +from pyqasm.elements import PHYSICAL_QUBIT_PREFIX, Variable from pyqasm.exceptions import ValidationError, raise_qasm3_error from pyqasm.maps.expressions import LIMITS_MAP, VARIABLE_TYPE_MAP, qasm_variable_type_cast @@ -289,6 +293,21 @@ def validate_gate_call( span=operation.span, ) + @staticmethod + def is_register_operand(operand: IndexedIdentifier | Identifier) -> Optional[bool]: + """Whether a gate operand is written as a register rather than a single qubit. + + Returns None for a bare register or alias name, which the caller judges by resolved + length. So ``qubit[1] r`` is repeated like a single qubit: the visitor rewrites + ``qubit x`` to ``qubit[1] x`` in place, so the two cannot be told apart later. + """ + if isinstance(operand, IndexedIdentifier): + last = operand.indices[-1] + return isinstance(last, DiscreteSet) or any( + isinstance(index, RangeDefinition) for index in last + ) + return False if operand.name.startswith(PHYSICAL_QUBIT_PREFIX) else None + @staticmethod def validate_return_statement( # pylint: disable=inconsistent-return-statements subroutine_def: SubroutineDefinition, diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index 7324fb80..a5f99b98 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -1613,7 +1613,7 @@ def _broadcast_operands( OpenQASM 3 broadcasting pairs register operands element by element and repeats single-qubit operands, so ``cx a, b`` is ``cx a[0], b[0]; cx a[1], b[1];``. A slice or set is a register operand even when it names one qubit; a bare name is one when it - names several (see ``_is_register_operand``). + names several (see ``Qasm3Validator.is_register_operand``). This applies only when there is one operand per gate qubit; with fewer, a register still fills several operands (``cx q2`` is ``cx q2[0], q2[1]``), as before. @@ -1632,11 +1632,14 @@ def _broadcast_operands( ): return None function_sizes = self._function_qreg_size_map[-1] if self._function_qreg_size_map else None - forms = [self._is_register_operand(operand) for operand in operation.qubits] + forms = [Qasm3Validator.is_register_operand(operand) for operand in operation.qubits] # checked before anything is resolved, so plain ``cx q[0], q[1]`` stays cheap if not any(forms) and None not in forms: return None - if len(operation.qubits) != self._gate_qubit_count(operation): + gate_qubit_count = Qasm3Analyzer.gate_qubit_count( + operation, self._custom_gates, Qasm3ExprEvaluator + ) + if len(operation.qubits) != gate_qubit_count: return None # resolve the whole operation first so an invalid operand is reported against it self._get_op_bits(operation, qubits=True, function_qubit_sizes=function_sizes) @@ -1672,57 +1675,6 @@ def _broadcast_operands( for i in range(size) ] - @staticmethod - def _is_register_operand( - operand: qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier, - ) -> Optional[bool]: - """Whether a gate operand is written as a register rather than a single qubit. - - Returns None for a bare register or alias name, which the caller judges by resolved - length. So ``qubit[1] r`` is repeated like a single qubit: the visitor rewrites - ``qubit x`` to ``qubit[1] x`` in place, so the two cannot be told apart later. - """ - if isinstance(operand, qasm3_ast.IndexedIdentifier): - last = operand.indices[-1] - return isinstance(last, qasm3_ast.DiscreteSet) or any( - isinstance(index, qasm3_ast.RangeDefinition) for index in last - ) - return False if operand.name.startswith(PHYSICAL_QUBIT_PREFIX) else None - - def _gate_qubit_count(self, operation: qasm3_ast.QuantumGate) -> Optional[int]: - """The number of qubits one application of ``operation`` takes, controls included. - - Returns None when it cannot be known up front (a non-constant modifier argument), - which leaves broadcasting to the existing path. An ``opaque`` gate is a custom gate - with an empty body by now, so it broadcasts like one. - """ - name = operation.name.name - if name in self._custom_gates: - count = len(self._custom_gates[name].qubits) - else: - try: - _, count = map_qasm_op_to_callable(operation) - except ValidationError: - return None - for modifier in operation.modifiers: - if modifier.modifier in ( - qasm3_ast.GateModifierName.ctrl, - qasm3_ast.GateModifierName.negctrl, - ): - if modifier.argument is None: - count += 1 - continue - try: - controls = Qasm3ExprEvaluator.evaluate_expression( - modifier.argument, const_expr=True - )[0] - except ValidationError: - return None - if not isinstance(controls, int): - return None - count += controls - return count - def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-many-statements self, operation: qasm3_ast.QuantumGate | qasm3_ast.QuantumPhase, From 031856a49d28847bcf3298c68cf5f0da388bef4d Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Tue, 3 Nov 2026 14:10:24 +0530 Subject: [PATCH 5/5] refactor(transformer): move broadcast operand pairing to Qasm3Transformer broadcast_operands takes the resolved operands and their register forms, checks for empty operands and unequal lengths, and returns one operand list per index. It reads no visitor state. The visitor keeps the early returns, the gate qubit count check and the _get_op_bits calls. Co-Authored-By: Claude Opus 5.5 --- src/pyqasm/transformer.py | 46 +++++++++++++++++++++++++++++++++++++++ src/pyqasm/visitor.py | 25 +-------------------- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/pyqasm/transformer.py b/src/pyqasm/transformer.py index d31ba11f..d2eaa1c1 100644 --- a/src/pyqasm/transformer.py +++ b/src/pyqasm/transformer.py @@ -410,6 +410,52 @@ def unroll_register_comparison( # pylint: disable=too-many-arguments, too-many- ] return deepcopy(block) + @staticmethod + def broadcast_operands( + operation: QuantumGate, + resolved: list[list[IndexedIdentifier | Identifier]], + forms: list[Optional[bool]], + ) -> Optional[list[list[IndexedIdentifier | Identifier]]]: + """Pair resolved register operands element by element and repeat single qubits. + + Args: + operation: The gate being broadcast, used for error messages. + resolved: The qubits each operand resolves to, in operand order. + forms: For each operand, ``Qasm3Validator.is_register_operand``. None means the + operand is a register when it resolves to more than one qubit. + + Returns: + The operands of each gate application, or None if no operand is a register. + + Raises: + ValidationError: If a register operand is empty or the register operands have + different lengths. + """ + is_register = [ + len(bits) > 1 if form is None else form for form, bits in zip(forms, resolved) + ] + if not any(is_register): + return None + if any(not bits for bits in resolved): + raise_qasm3_error( + f"Cannot broadcast '{operation.name.name}' over an empty operand", + error_node=operation, + span=operation.span, + ) + lengths = {len(bits) for bits, reg in zip(resolved, is_register) if reg} + if len(lengths) > 1: + raise_qasm3_error( + f"Cannot broadcast '{operation.name.name}' over registers of different lengths " + f"{sorted(lengths)}", + error_node=operation, + span=operation.span, + ) + size = lengths.pop() + return [ + [bits[i] if reg else bits[0] for bits, reg in zip(resolved, is_register)] + for i in range(size) + ] + @classmethod def transform_function_qubits( cls, diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index a5f99b98..707d1158 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -1650,30 +1650,7 @@ def _broadcast_operands( resolved.append( self._get_op_bits(single, qubits=True, function_qubit_sizes=function_sizes) ) - is_register = [ - len(bits) > 1 if form is None else form for form, bits in zip(forms, resolved) - ] - if not any(is_register): - return None - if any(not bits for bits in resolved): - raise_qasm3_error( - f"Cannot broadcast '{operation.name.name}' over an empty operand", - error_node=operation, - span=operation.span, - ) - lengths = {len(bits) for bits, reg in zip(resolved, is_register) if reg} - if len(lengths) > 1: - raise_qasm3_error( - f"Cannot broadcast '{operation.name.name}' over registers of different lengths " - f"{sorted(lengths)}", - error_node=operation, - span=operation.span, - ) - size = lengths.pop() - return [ - [bits[i] if reg else bits[0] for bits, reg in zip(resolved, is_register)] - for i in range(size) - ] + return Qasm3Transformer.broadcast_operands(operation, resolved, forms) def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-many-statements self,