Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
128 changes: 128 additions & 0 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,124 @@ 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 ``_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 = [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 = []
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)
)
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)
]

@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,
Expand Down Expand Up @@ -1659,6 +1777,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
Expand Down
133 changes: 133 additions & 0 deletions tests/qasm3/test_broadcast.py
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +93 to +94

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L3] Missing OpenQASM 2 and duplicate-qubit tests — Implementation · Low

Rationale: OpenQASM 2 cx q, r; gave cx q[0], q[1]; cx r[0], r[1]; on main, and this PR fixes it, but no test covers it. cx q, q; now raises Duplicate qubit through the broadcast path, and nothing tests that either. Both tests below pass on head.

Change requested:

Suggested change
with pytest.raises(ValidationError, match="different lengths"):
loads(qasm3_string).unroll()
with pytest.raises(ValidationError, match="different lengths"):
loads(qasm3_string).unroll()
def test_broadcast_qasm2_registers():
"""OpenQASM 2 pairs register operands the same way."""
result = loads('OPENQASM 2.0;\ninclude "qelib1.inc";\nqreg q[2];\nqreg r[2];\ncx q, r;\n')
result.unroll()
gates = [line for line in dumps(result).splitlines() if line.startswith("cx")]
assert gates == ["cx q[0], r[0];", "cx q[1], r[1];"]
def test_broadcast_duplicate_qubit_raises():
"""Broadcasting a register against itself repeats a qubit within one gate."""
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()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added both in a6defcf, plus three more:

  • test_broadcast_length_one_slice_is_a_register
  • test_broadcast_empty_operand_raises
  • test_broadcast_single_qubit_repeats

After merging main, the suite is at 1033 passed, 3 skipped, and format-check is clean.



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];"]
Loading