diff --git a/CHANGELOG.md b/CHANGELOG.md index 745a3745..0aae3c07 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)). - Decompose `p`, `phaseshift`, and `u1` (`U1`) gates into a single `rz(theta)` rotation instead of `h; rx(theta); h` ([#470](https://github.com/qBraid/pyqasm/pull/470), [#469](https://github.com/qBraid/pyqasm/issues/469)). - Fixed `rebase` failing on `ccx`, `id`, and the `p` gates produced by unrolling `c3x` for the `ROTATIONAL_CX` / `CLIFFORD_T` basis sets ([#468](https://github.com/qBraid/pyqasm/pull/468), [#467](https://github.com/qBraid/pyqasm/issues/467)). - `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)). 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/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/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 8c2e203b..707d1158 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -1605,6 +1605,53 @@ 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];``. A slice or + set is a register operand even when it names one qubit; a bare name is one when it + 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. + + 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 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() + ): + return None + function_sizes = self._function_qreg_size_map[-1] if self._function_qreg_size_map else None + 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 + 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) + 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) + ) + return Qasm3Transformer.broadcast_operands(operation, resolved, forms) + def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-many-statements self, operation: qasm3_ast.QuantumGate | qasm3_ast.QuantumPhase, @@ -1659,6 +1706,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..f0f309ef --- /dev/null +++ b/tests/qasm3/test_broadcast.py @@ -0,0 +1,133 @@ +# 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() + + +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];"]