diff --git a/CHANGELOG.md b/CHANGELOG.md index 3583cfef..3f7130f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Types of changes: ### Fixed - Fixed `pyqasm validate` wrapping its diagnostics at the console width, which split a file path longer than the width across lines mid-token and left it neither copyable nor clickable. The error console now uses `soft_wrap`, keeping one diagnostic per line. +- Fixed gate broadcasting over multiple registers unrolling to the wrong circuit. `cx q, r` on two `qubit[4]` registers emitted the linear chunking `cx q[0],q[1]; cx q[2],q[3]; ...`, and `cx q, r[0]` raised. Operands now zip element-wise per the OpenQASM 3 spec — `cx q[i], r[i]` — repeating single-qubit operands. Mismatched register lengths raise a `ValidationError` naming both operands, and an ambiguous shape such as `cx q, r, s` (three registers, arity 2) is rejected rather than silently chunked. ([#384](https://github.com/qBraid/pyqasm/issues/384)) - 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)) diff --git a/src/pyqasm/analyzer.py b/src/pyqasm/analyzer.py index 2eaa69de..564cb1d0 100644 --- a/src/pyqasm/analyzer.py +++ b/src/pyqasm/analyzer.py @@ -39,6 +39,7 @@ UnaryExpression, ) +from pyqasm.elements import QubitRef from pyqasm.exceptions import QasmParsingError, ValidationError, raise_qasm3_error if TYPE_CHECKING: @@ -288,6 +289,26 @@ def extract_qubit_key(qubit: IndexedIdentifier | Identifier) -> tuple[str, int]: assert isinstance(qubit, IndexedIdentifier) return (qubit.name.name, qubit.indices[0][0].value) # type: ignore + @staticmethod + def extract_operand_name(group: list[QubitRef]) -> str: + """Return the register (or physical qubit) name behind a resolved operand. + + Every qubit in a group comes from one source-level operand and so shares a + name; the first suffices. Used to name operands in error messages. + + Args: + group (list[QubitRef]): The resolved qubits of a single operand. + + Returns: + str: The operand's name, or "" if the group holds no qubits. + """ + if not group: + return "" + first = group[0] + if isinstance(first, Identifier): + return first.name + return first.name.name + @staticmethod def verify_gate_qubits(gate: QuantumGate, span: Optional[Span] = None): """ diff --git a/src/pyqasm/elements.py b/src/pyqasm/elements.py index 3950da36..d06a305d 100644 --- a/src/pyqasm/elements.py +++ b/src/pyqasm/elements.py @@ -22,6 +22,17 @@ from typing import Any, Optional import numpy as np +from openqasm3.ast import Identifier, IndexedIdentifier + +QubitRef = IndexedIdentifier | Identifier +"""A single resolved qubit: an indexed register element, or a physical qubit ("$n").""" + +OperandGroups = list[list[QubitRef]] +"""Resolved qubits grouped by the source-level operand they came from. + +``cx q, r[0]`` with ``qubit[2] q`` groups as ``[[q[0], q[1]], [r[0]]]``. Keeping the +boundary is what lets broadcasting zip register operands and repeat single-qubit ones. +""" INTERNAL_QUBIT_REGISTER = "__PYQASM_QUBITS__" """Reserved register that qubits are consolidated onto, and that physical qubits ("$n") diff --git a/src/pyqasm/transformer.py b/src/pyqasm/transformer.py index 17b93241..b9a2fe2a 100644 --- a/src/pyqasm/transformer.py +++ b/src/pyqasm/transformer.py @@ -48,7 +48,7 @@ UnaryOperator, ) -from pyqasm.elements import INTERNAL_QUBIT_REGISTER, Variable +from pyqasm.elements import INTERNAL_QUBIT_REGISTER, OperandGroups, Variable from pyqasm.exceptions import raise_qasm3_error from pyqasm.expressions import Qasm3ExprEvaluator from pyqasm.maps.expressions import VARIABLE_TYPE_MAP @@ -164,6 +164,30 @@ def get_qubits_from_range_definition( ) return list(range(start_qid, end_qid, step)) + @staticmethod + def drop_leading_qubits(groups: OperandGroups, count: int) -> OperandGroups: + """Drop the first ``count`` resolved qubits, keeping operand boundaries. + + A boundary landing inside a register splits that group, so the surviving + tail stays a usable operand: ``ctrl @ x q2`` consumes ``q2[0]`` as the + control and leaves ``[[q2[1]]]`` as the target. + + Args: + groups (OperandGroups): The resolved qubits, grouped by operand. + count (int): How many qubits to drop from the front. + + Returns: + OperandGroups: The remaining groups, in order. + """ + remaining: OperandGroups = [] + for group in groups: + if count >= len(group): + count -= len(group) + else: + remaining.append(group[count:]) + count = 0 + return remaining + @staticmethod def transform_gate_qubits( gate_op: QuantumGate | QuantumPhase, diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index d837f149..cd854a27 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -41,7 +41,9 @@ Context, Frame, InversionOp, + OperandGroups, QubitDepthNode, + QubitRef, Variable, Waveform, is_internal_qubit_register, @@ -290,14 +292,19 @@ def _visit_quantum_register( return [] return [register] - # pylint: disable-next=too-many-locals,too-many-branches,too-many-statements def _get_op_bits( self, operation: Any, qubits: bool = True, function_qubit_sizes: Optional[dict[str, int]] = None, - ) -> list[qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier]: + ) -> list[QubitRef]: """Get the quantum / classical bits for the operation. + + Returns a *flat* list; operand boundaries are collapsed. Callers that + need to preserve which resolved qubit came from which source-level + operand (for OpenQASM 3 broadcasting semantics) must use + :meth:`_get_op_bits_per_operand` instead. + Args: operation (Any): The operation to get qubits for. qubits (bool): Whether the bits are quantum bits or classical bits. Defaults to True. @@ -305,7 +312,34 @@ def _get_op_bits( list[IndexedIdentifier | Identifier]: The quantum or classical bits for the operation, or an empty list if check_only is true. """ - openqasm_bits: list[qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier] = [] + groups = self._get_op_bits_per_operand(operation, qubits, function_qubit_sizes) + return [bit for group in groups for bit in group] + + # pylint: disable-next=too-many-locals,too-many-branches,too-many-statements + def _get_op_bits_per_operand( + self, + operation: Any, + qubits: bool = True, + function_qubit_sizes: Optional[dict[str, int]] = None, + ) -> OperandGroups: + """Resolve each source-level operand of ``operation`` into its own qubit list. + + Preserving per-operand boundaries is required for OpenQASM 3 gate + broadcasting: element-wise register operands must be zipped, single-qubit + operands repeated, and mismatched register lengths reported. Flattening + (as :meth:`_get_op_bits` does) hides that boundary. + + Args: + operation (Any): The operation whose operands to resolve. + qubits (bool): Whether the bits are quantum bits or classical bits. Defaults to True. + function_qubit_sizes (dict[str, int] | None): Optional map used when + resolving qubits inside a nested function scope. + + Returns: + list[list[IndexedIdentifier | Identifier]]: One inner list per source-level + operand, each holding the resolved single-qubit references. + """ + operand_groups: OperandGroups = [] bit_list = [] if isinstance(operation, qasm3_ast.QuantumMeasurementStatement): @@ -315,20 +349,26 @@ def _get_op_bits( assert operation.target is not None bit_list = [operation.target] elif isinstance(operation, qasm3_ast.QuantumPhase) and operation.qubits is None: + # A bare `gphase(x)` applies to every declared qubit; each register is its + # own operand. The original code built generator objects here rather than + # lists, which no caller ever consumed as such. for reg_name, reg_size in self._global_qreg_size_map.items(): - bit_list.append( - qasm3_ast.IndexedIdentifier( - qasm3_ast.Identifier(reg_name), [[qasm3_ast.IntegerLiteral(i)]] - ) - for i in range(reg_size) + operand_groups.append( + [ + qasm3_ast.IndexedIdentifier( + qasm3_ast.Identifier(reg_name), [[qasm3_ast.IntegerLiteral(i)]] + ) + for i in range(reg_size) + ] ) - return bit_list + return operand_groups else: bit_list = ( operation.qubits if isinstance(operation.qubits, list) else [operation.qubits] ) for bit in bit_list: + openqasm_bits: list[QubitRef] = [] # required for each bit if isinstance(bit, qasm3_ast.IndexedIdentifier): reg_name = bit.name.name @@ -348,6 +388,7 @@ def _get_op_bits( self._register_physical_qubit(reg_name) # Keep as an Identifier so it serialises as "$0" rather than "$0[0]". openqasm_bits.append(qasm3_ast.Identifier(reg_name)) + operand_groups.append(openqasm_bits) continue max_register_size = 0 @@ -409,8 +450,9 @@ def _get_op_bits( for bit_id in bit_ids ] openqasm_bits.extend(new_bits) + operand_groups.append(openqasm_bits) - return openqasm_bits + return operand_groups def _check_variable_type_size( self, statement: qasm3_ast.Statement, var_name: str, var_format: str, base_type: Any @@ -1040,37 +1082,128 @@ def _visit_gate_definition(self, definition: qasm3_ast.QuantumGateDefinition) -> return [] + @staticmethod + def _broadcast_operand_groups( + operand_groups: OperandGroups, + operation: qasm3_ast.QuantumGate, + ) -> OperandGroups: + """Apply OpenQASM 3 element-wise broadcasting across per-operand qubit groups. + + Every register operand (length > 1) must share the same length ``n``; the + gate is emitted ``n`` times, taking element ``i`` from each register operand + and repeating single-qubit operands unchanged. With no register operands + exactly one application is emitted. Register operands whose sizes differ + raise :class:`ValidationError` naming the mismatched operands. + + See https://openqasm.com/versions/3.1/language/gates.html#broadcasting. + """ + # Width shared by every register operand; 0 until the first one is seen. + width = 0 + first: list[QubitRef] = [] + for group in operand_groups: + if len(group) <= 1: + continue + if not width: + first, width = group, len(group) + elif len(group) != width: + raise_qasm3_error( + f"Register operands broadcast to different sizes for gate " + f"'{operation.name.name}': operand " + f"'{Qasm3Analyzer.extract_operand_name(first)}' (size {width}) and operand " + f"'{Qasm3Analyzer.extract_operand_name(group)}' (size {len(group)}). " + f"All register operands must share the same length.", + error_node=operation, + span=operation.span, + ) + + if not width: + return [[group[0] for group in operand_groups]] + + return [ + [group[i] if len(group) > 1 else group[0] for group in operand_groups] + for i in range(width) + ] + def _unroll_multiple_target_qubits( - self, operation: qasm3_ast.QuantumGate, gate_qubit_count: int - ) -> list[list[qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier]]: - """Unroll the complete list of all qubits that the given operation is applied to. - E.g. this maps 'cx q[0], q[1], q[2], q[3]' to [[q[0], q[1]], [q[2], q[3]]] + self, + operation: qasm3_ast.QuantumGate, + gate_qubit_count: int, + operand_groups: Optional[OperandGroups] = None, + ) -> OperandGroups: + """Expand ``operation`` into the per-application target qubit lists. + + Dispatch is ordered so that a single input matches at most one rule; the + first rule that fits wins. + + 1. ``flat_count == gate_qubit_count`` -> one application over the flat + qubit list. Covers the single-application spec forms (``cx q[0], q[1]``) + and the pyqasm extension where a single register operand fills all + slots (``cx q2`` with ``qubit[2] q2``). + 2. ``group_count == gate_qubit_count`` -> OpenQASM 3 element-wise + broadcast. Register operands must share length; mismatches raise. + 3. All groups single-qubit and ``flat_count`` is a multiple of the arity + -> pyqasm legacy chunking (``cx q[0], q[1], q[1], q[2]``). + 4. Otherwise -> :class:`ValidationError`; the shape is ambiguous. Args: - operation (QuantumGate): The gate to be applied. - gate_qubit_count (list[int]): The number of qubits that a single gate acts on. + operation (QuantumGate): The gate whose targets to expand. + gate_qubit_count (int): The gate's target-qubit arity (i.e. after any + ``ctrl @`` qubits have already been peeled off). + operand_groups: Optional pre-resolved per-operand qubit groups. When + omitted, the groups are derived from ``operation.qubits`` (each + element becomes its own length-1 group, matching the shape of + qubit lists that have already been unrolled upstream). Returns: - list[list[IndexedIdentifier | Identifier]]: The list of all targets that - the unrolled gate should act on. + list[list[IndexedIdentifier | Identifier]]: One inner list per emitted + application, each of length ``gate_qubit_count``. """ - op_qubits = self._get_op_bits(operation, qubits=True) - if len(op_qubits) <= 0 or len(op_qubits) % gate_qubit_count != 0: + if operand_groups is None: + # Fallback: qubits have already been resolved to single-qubit references + # (e.g. during the inverse-recursion inside _visit_basic_gate_operation). + operand_groups = [[bit] for bit in operation.qubits] + + flat = [q for group in operand_groups for q in group] + flat_count = len(flat) + group_count = len(operand_groups) + + if flat_count == 0 or gate_qubit_count == 0: raise_qasm3_error( - f"Invalid number of qubits {len(op_qubits)} for operation {operation.name.name}", + f"Invalid number of qubits {flat_count} for operation {operation.name.name}", error_node=operation, span=operation.span, ) - qubit_subsets = [] - for i in range(0, len(op_qubits), gate_qubit_count): - # we apply the gate on the qubit subset linearly - qubit_subsets.append(op_qubits[i : i + gate_qubit_count]) - return qubit_subsets + + # Rule 1: exact-fit single application over the flat list. + if flat_count == gate_qubit_count: + return [flat] + + # Rule 2: spec broadcast when operand count matches the gate arity. + if group_count == gate_qubit_count: + return self._broadcast_operand_groups(operand_groups, operation) + + # Rule 3: legacy pyqasm chunking - only when every operand is single-qubit. + # Rejecting mixed-register cases here is what turns silent-wrong broadcasts + # (e.g. `cx q, r, s` with three qubit[2] registers) into loud errors. + all_single = all(len(g) == 1 for g in operand_groups) + if all_single and flat_count % gate_qubit_count == 0: + return [flat[i : i + gate_qubit_count] for i in range(0, flat_count, gate_qubit_count)] + + raise_qasm3_error( + f"Cannot broadcast operation '{operation.name.name}' onto " + f"{group_count} operand(s) (total {flat_count} qubit(s)) for a " + f"{gate_qubit_count}-qubit gate. Provide exactly {gate_qubit_count} operand(s) " + f"for element-wise broadcasting, or a total qubit count that is a multiple of " + f"the gate arity.", + error_node=operation, + span=operation.span, + ) + return [] # pragma: no cover - raise_qasm3_error always raises def _broadcast_gate_operation( self, gate_function: Callable, - all_targets: list[list[qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier]], + all_targets: OperandGroups, ctrls: Optional[list[qasm3_ast.IndexedIdentifier]] = None, ) -> list[qasm3_ast.QuantumGate]: """Broadcasts the application of a gate onto multiple sets of target qubits. @@ -1147,7 +1280,7 @@ def _get_qubit_name_and_id( def _update_qubit_depth_for_gate( self, - all_targets: list[list[qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier]], + all_targets: OperandGroups, ctrls: list[qasm3_ast.IndexedIdentifier], ) -> None: """Updates the depth of the circuit after applying a broadcasted gate. @@ -1177,6 +1310,7 @@ def _visit_basic_gate_operation( operation: qasm3_ast.QuantumGate, inverse: bool = False, ctrls: Optional[list[qasm3_ast.IndexedIdentifier]] = None, + operand_groups: Optional[OperandGroups] = None, ) -> list[qasm3_ast.QuantumGate]: """Visit a gate operation element. @@ -1232,7 +1366,9 @@ def _visit_basic_gate_operation( ) result = [] - unrolled_targets = self._unroll_multiple_target_qubits(operation, op_qubit_count) + unrolled_targets = self._unroll_multiple_target_qubits( + operation, op_qubit_count, operand_groups=operand_groups + ) unrolled_gate_function = partial(qasm_func, *op_parameters) if inverse: @@ -1292,6 +1428,7 @@ def _visit_custom_gate_operation( operation: qasm3_ast.QuantumGate, inverse: bool = False, ctrls: Optional[list[qasm3_ast.IndexedIdentifier]] = None, + operand_groups: Optional[OperandGroups] = None, ) -> list[qasm3_ast.QuantumGate | qasm3_ast.QuantumPhase]: """Visit a custom gate operation element recursively. @@ -1302,6 +1439,11 @@ def _visit_custom_gate_operation( inverse modifier is appended to each gate call. See https://openqasm.com/language/gates.html#inverse-modifier for more clarity. + operand_groups: Optional pre-resolved per-operand qubit groups. When + provided, OpenQASM 3 broadcasting applies (register operands zipped, + single-qubit operands repeated). When omitted the caller has already + arranged for ``operation.qubits`` to hold exactly one qubit per + formal argument. Returns: list[QuantumGate | QuantumPhase]: The list of gates and phase operations @@ -1312,10 +1454,47 @@ def _visit_custom_gate_operation( ctrls = [] gate_name: str = operation.name.name gate_definition: qasm3_ast.QuantumGateDefinition = self._custom_gates[gate_name] - op_qubits: list[qasm3_ast.IndexedIdentifier | qasm3_ast.Identifier] = self._get_op_bits( - operation, qubits=True + + # One entry per broadcast application, each holding exactly one resolved + # qubit per formal argument. + all_targets = self._unroll_multiple_target_qubits( + operation, len(gate_definition.qubits), operand_groups=operand_groups ) + result: list[qasm3_ast.QuantumGate | qasm3_ast.QuantumPhase] = [] + for per_app_qubits in all_targets: + result.extend( + self._expand_custom_gate_body( + operation, gate_definition, per_app_qubits, inverse, ctrls + ) + ) + if self._check_only: + return [] + return result + + # pylint: disable-next=too-many-locals,too-many-branches,too-many-arguments + def _expand_custom_gate_body( + self, + operation: qasm3_ast.QuantumGate, + gate_definition: qasm3_ast.QuantumGateDefinition, + op_qubits: list[QubitRef], + inverse: bool, + ctrls: list[qasm3_ast.IndexedIdentifier], + ) -> list[qasm3_ast.QuantumGate | qasm3_ast.QuantumPhase]: + """Expand one application of a custom gate against the given qubits. + + Args: + operation: The originating source-level gate call (used for error spans). + gate_definition: The custom gate definition to expand. + op_qubits: The resolved single-qubit references for this application, + one per formal argument. + inverse: Whether the ``inv @`` modifier applies. + ctrls: The list of control qubits accumulated from ``ctrl @`` modifiers. + + Returns: + list[QuantumGate | QuantumPhase]: The gates produced by this application. + """ + gate_name = gate_definition.name.name Qasm3Validator.validate_gate_call(operation, gate_definition, len(op_qubits)) # we need this because the gates applied inside a gate definition use the # VARIABLE names and not the qubits @@ -1415,6 +1594,7 @@ def _visit_external_gate_operation( operation: qasm3_ast.QuantumGate, inverse: bool = False, ctrls: Optional[list[qasm3_ast.IndexedIdentifier]] = None, + operand_groups: Optional[OperandGroups] = None, ) -> list[qasm3_ast.QuantumGate]: """Visit an external gate operation element. @@ -1437,7 +1617,9 @@ def _visit_external_gate_operation( if gate_name in self._custom_gates: # Ignore result, this is just for validation - self._visit_custom_gate_operation(operation, inverse, ctrls) + self._visit_custom_gate_operation( + operation, inverse, ctrls, operand_groups=operand_groups + ) # Don't need to check if custom gate exists, since we just validated the call gate_qubit_count = len(self._custom_gates[gate_name].qubits) else: @@ -1480,7 +1662,9 @@ def gate_function(*qubits): ) ] - all_targets = self._unroll_multiple_target_qubits(operation, gate_qubit_count) + all_targets = self._unroll_multiple_target_qubits( + operation, gate_qubit_count, operand_groups=operand_groups + ) result = self._broadcast_gate_operation(gate_function, all_targets) # record the external gate's own depth; the custom-gate path has already done so @@ -1631,7 +1815,12 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man ) ) - operation.qubits = self._get_op_bits(operation, qubits=True) # type: ignore + # Resolve into per-operand groups so element-wise broadcasting can happen + # downstream. Boundaries are lost as soon as we flatten, and the ctrl @ handling + # below still consumes single-qubit slots by flat index, so we keep a flat view + # here and split the first surviving group if a ctrl boundary lands mid-register. + operand_groups = self._get_op_bits_per_operand(operation, qubits=True) + operation.qubits = [bit for group in operand_groups for bit in group] # type: ignore # ctrl / pow / inv modifiers commute. so group them. exponent = 1 @@ -1686,7 +1875,10 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man power_value, inverse_value = abs(exponent), exponent < 0 - operation.qubits = operation.qubits[ctrl_arg_ind:] + # The ctrl @ qubits were taken off the front by flat index; drop the same + # count from the groups so the two views cannot drift apart. + target_operand_groups = Qasm3Transformer.drop_leading_qubits(operand_groups, ctrl_arg_ind) + operation.qubits = [qubit for group in target_operand_groups for qubit in group] operation.modifiers = [] # apply pow(int) via duplication @@ -1700,15 +1892,23 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man # get controlled? inverted? operation x power times result: list[qasm3_ast.QuantumGate | qasm3_ast.QuantumPhase] = [] - for _ in range(power_value): - if isinstance(operation, qasm3_ast.QuantumPhase): - result.extend(self._visit_phase_operation(operation, inverse_value, ctrls)) - elif self._in_verbatim_box or operation.name.name in self._external_gates: - result.extend(self._visit_external_gate_operation(operation, inverse_value, ctrls)) + visit_gate: Callable[..., list] + if isinstance(operation, qasm3_ast.QuantumPhase): + # A phase has no target operands, so it takes no groups. + visit_gate = partial(self._visit_phase_operation, operation, inverse_value, ctrls) + else: + if self._in_verbatim_box or operation.name.name in self._external_gates: + gate_visitor = self._visit_external_gate_operation elif operation.name.name in self._custom_gates: - result.extend(self._visit_custom_gate_operation(operation, inverse_value, ctrls)) + gate_visitor = self._visit_custom_gate_operation # type: ignore[assignment] else: - result.extend(self._visit_basic_gate_operation(operation, inverse_value, ctrls)) + gate_visitor = self._visit_basic_gate_operation + visit_gate = partial( + gate_visitor, operation, inverse_value, ctrls, target_operand_groups + ) + + for _ in range(power_value): + result.extend(visit_gate()) # negctrl -> ctrl conversion; build each x gate with fresh operand nodes so # the leading and trailing statements share nothing (issue #350) diff --git a/tests/qasm3/resources/gates.py b/tests/qasm3/resources/gates.py index 73fd6565..1bc008ee 100644 --- a/tests/qasm3/resources/gates.py +++ b/tests/qasm3/resources/gates.py @@ -337,7 +337,8 @@ def test_fixture(): qubit[3] q1; cx q1; // invalid application of gate, as we apply it to 3 qubits in blocks of 2 """, - "Invalid number of qubits 3 for operation cx", + r"Cannot broadcast operation 'cx' onto 1 operand\(s\) \(total 3 qubit\(s\)\)" + r" for a 2-qubit gate", 6, 8, "cx q1[0], q1[1], q1[2];", # expanded line @@ -444,7 +445,8 @@ def test_fixture(): qubit[3] q1; custom_gate(0.5, 0.5) q1; // qubit count mismatch """, - "Qubit count mismatch for gate 'custom_gate'. Expected 2 qubits, but got 3 instead.", + r"Cannot broadcast operation 'custom_gate' onto 1 operand\(s\) \(total 3 qubit\(s\)\)" + r" for a 2-qubit gate", 11, 8, "custom_gate(0.5, 0.5) q1[0], q1[1], q1[2];", # expanded line diff --git a/tests/qasm3/test_gate_broadcasting.py b/tests/qasm3/test_gate_broadcasting.py new file mode 100644 index 00000000..4162ca94 --- /dev/null +++ b/tests/qasm3/test_gate_broadcasting.py @@ -0,0 +1,172 @@ +# 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. + +Broadcasting applies a gate once per index across register operands, repeating +single-qubit operands. See https://openqasm.com/versions/3.1/language/gates.html#broadcasting + +""" + +import pytest + +from pyqasm.entrypoint import dumps, loads +from pyqasm.exceptions import ValidationError +from tests.utils import check_unrolled_qasm + + +def test_two_register_broadcast_zips_elementwise(): + """`cx q, r` with two same-size registers must zip, not linear-chunk (#384).""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[4] q; + qubit[4] r; + cx q, r; + """ + expected = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[4] q; + qubit[4] r; + cx q[0], r[0]; + cx q[1], r[1]; + cx q[2], r[2]; + cx q[3], r[3]; + """ + module = loads(qasm3_string) + module.unroll() + + assert module.num_qubits == 8 + check_unrolled_qasm(dumps(module), expected) + + +def test_mixed_register_and_single_qubit_broadcast(): + """`cx q, r[0]` (register + single qubit) must broadcast r[0] to every q[i] (#384).""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[4] q; + qubit[1] r; + cx q, r[0]; + """ + expected = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[4] q; + qubit[1] r; + cx q[0], r[0]; + cx q[1], r[0]; + cx q[2], r[0]; + cx q[3], r[0]; + """ + module = loads(qasm3_string) + module.unroll() + + check_unrolled_qasm(dumps(module), expected) + + +def test_spec_example_g4_broadcast_repeats_single_qubit_operands(): + """OpenQASM 3.1 broadcasting example: g4 qr0[0], qr1, qr2[0], qr3 (#384). + + See https://openqasm.com/versions/3.1/language/gates.html#broadcasting - + register operands qr1, qr3 (length 3) drive three applications; qr0[0] + and qr2[0] are repeated element-wise. + """ + qasm3_string = """ + OPENQASM 3.0; + gate g4 a, b, c, d { } + qubit[1] qr0; + qubit[3] qr1; + qubit[1] qr2; + qubit[3] qr3; + g4 qr0[0], qr1, qr2[0], qr3; + """ + expected = """ + OPENQASM 3.0; + qubit[1] qr0; + qubit[3] qr1; + qubit[1] qr2; + qubit[3] qr3; + 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]; + """ + module = loads(qasm3_string) + module.unroll(external_gates=["g4"]) + check_unrolled_qasm(dumps(module), expected) + + +def test_broadcast_size_mismatch_raises(): + """Register operands of different sizes must raise, naming both operands (#384).""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[4] q; + qubit[3] r; + cx q, r; + """ + with pytest.raises( + ValidationError, + match=r"Register operands broadcast to different sizes for gate 'cx':" + r" operand 'q' \(size 4\) and operand 'r' \(size 3\)", + ): + loads(qasm3_string).unroll() + + +def test_broadcast_under_ctrl_modifier(): + """`ctrl @ cx ctrl_q, a, b` with a, b registers must broadcast targets per i (#384).""" + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit ctrl_q; + qubit[3] a; + qubit[3] b; + ctrl @ cx ctrl_q, a, b; + """ + expected = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[1] ctrl_q; + qubit[3] a; + qubit[3] b; + ccx ctrl_q[0], a[0], b[0]; + ccx ctrl_q[0], a[1], b[1]; + ccx ctrl_q[0], a[2], b[2]; + """ + module = loads(qasm3_string) + module.unroll() + check_unrolled_qasm(dumps(module), expected) + + +def test_ambiguous_multi_register_broadcast_raises(): + """`cx q, r, s` (3 register operands, arity 2) is ambiguous and must raise (#384). + + Previously silently linear-chunked to `cx q[0],q[1]; cx r[0],r[1]; cx s[0],s[1]`, + which is the exact silent-wrong behavior #384 flags. + """ + qasm3_string = """ + OPENQASM 3.0; + include "stdgates.inc"; + qubit[2] q; + qubit[2] r; + qubit[2] s; + cx q, r, s; + """ + with pytest.raises( + ValidationError, + match=r"Cannot broadcast operation 'cx' onto 3 operand\(s\)", + ): + loads(qasm3_string).unroll() diff --git a/tests/qasm3/test_gates.py b/tests/qasm3/test_gates.py index d70ed435..2a2739d0 100644 --- a/tests/qasm3/test_gates.py +++ b/tests/qasm3/test_gates.py @@ -358,7 +358,7 @@ def test_duplicate_qubit_broadcast(): OPENQASM 3.0; include "stdgates.inc"; qubit[3] q; - + cx q[0], q[1], q[1], q[2];""" module = loads(qasm3_string)