From 6f126b5cc615f68d8dfc575f490a92e770503275 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Tue, 20 Oct 2026 18:50:39 -0500 Subject: [PATCH 1/4] fix: support ctrl @ on any single-qubit standard gate ctrl @ tdg, sx, s, sdg, t and other standard gates without a dedicated controlled form raised "Unsupported controlled QASM operation". When the existing ctrl-chain lookup fails, the gate is now lowered to its single-qubit (or swap) base gate written exactly as e^{i*gamma} U(theta, phi, lam), and controlled on any number of qubits: a multi-controlled phase for diagonal gates, the ABC construction otherwise, and a recursive multi-controlled X past c4x. Also fixes ctrl @ u, which mapped to the 4-parameter cu and raised a TypeError, and ctrl(n) @ gphase for n >= 2, which put the phase gate's target on its own control. --- CHANGELOG.md | 1 + src/pyqasm/maps/gates.py | 183 ++++++++++++++++++++- src/pyqasm/visitor.py | 2 +- tests/qasm3/test_controlled_gates.py | 231 +++++++++++++++++++++++++++ tests/qasm3/test_gates.py | 11 +- tests/utils.py | 12 +- tests/visualization/test_mpl_draw.py | 4 +- 7 files changed, 430 insertions(+), 14 deletions(-) create mode 100644 tests/qasm3/test_controlled_gates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6515f5f9..794b8dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Types of changes: ### Removed ### Fixed +- Fixed `ctrl @` on standard gates with no dedicated controlled form, which raised `Unsupported controlled QASM operation`. `ctrl(n) @` now works for any number of controls on every single-qubit standard gate (including `s`, `sdg`, `t`, `tdg`, `sx`, `sxdg`, `id`, `U`/`u3`, `u2`, `prx`, `gpi`, `gpi2`), on gates that are themselves controlled single-qubit gates or swap (such as `cs`, `csx`, `cu`, `cz`, `cswap`, `c4x`), and on `gphase` with two or more controls. It also fixes `ctrl @ u`, which raised a `TypeError` ([#459](https://github.com/qBraid/pyqasm/pull/459), [#252](https://github.com/qBraid/pyqasm/issues/252)). - 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)) diff --git a/src/pyqasm/maps/gates.py b/src/pyqasm/maps/gates.py index a0d8ab42..21f23c74 100644 --- a/src/pyqasm/maps/gates.py +++ b/src/pyqasm/maps/gates.py @@ -1297,7 +1297,6 @@ def map_qasm_inv_op_to_callable(op_name: str): "rz": "crz", "p": "cp", "h": "ch", - "u": "cu", "swap": "cswap", "cx": "ccx", "ccx": "c3x", @@ -1315,6 +1314,184 @@ def map_qasm_inv_op_to_callable(op_name: str): } +_PI = CONSTANTS_MAP["pi"] + +# Each single-qubit gate as ``(theta, phi, lam, gamma)`` with gate = e^{i*gamma} U(theta, phi, lam), +# matching the matrices the gates unroll to (x is Pauli X, as in Qiskit). ``ctrl @`` turns gamma +# into a relative phase, so it must be exact, not just up to global phase. +ONE_QUBIT_U_FORM_MAP: dict[str, Callable[..., tuple[float, float, float, float]]] = { + "id": lambda: (0, 0, 0, 0), + "x": lambda: (_PI, 0, _PI, 0), + "y": lambda: (_PI, _PI / 2, _PI / 2, 0), + "z": lambda: (0, 0, _PI, 0), + "h": lambda: (_PI / 2, 0, _PI, 0), + "s": lambda: (0, 0, _PI / 2, 0), + "sdg": lambda: (0, 0, -_PI / 2, 0), + "t": lambda: (0, 0, _PI / 4, 0), + "tdg": lambda: (0, 0, -_PI / 4, 0), + "sx": lambda: (_PI / 2, -_PI / 2, _PI / 2, _PI / 4), + "sxdg": lambda: (_PI / 2, _PI / 2, -_PI / 2, -_PI / 4), + "rx": lambda theta: (theta, -_PI / 2, _PI / 2, 0), + "ry": lambda theta: (theta, 0, 0, 0), + "rz": lambda theta: (0, 0, theta, -theta / 2), + "p": lambda lam: (0, 0, lam, 0), + # gamma is only passed when ``cu`` is lowered to ``ctrl @ U`` + "U": lambda theta, phi, lam, gamma=0: (theta, phi, lam, gamma), + "u2": lambda phi, lam: (_PI / 2, phi, lam, 0), + "prx": lambda theta, phi: (theta, phi - _PI / 2, _PI / 2 - phi, 0), + "gpi": lambda phi: (_PI, phi, _PI - phi, 0), + "gpi2": lambda phi: (_PI / 2, phi - _PI / 2, _PI / 2 - phi, 0), +} +for _alias, _name in { + "not": "x", + "si": "sdg", + "ti": "tdg", + "v": "sx", + "vi": "sxdg", + "phaseshift": "p", + "u1": "p", + "U1": "p", + "u": "U", + "u3": "U", + "U3": "U", + "U2": "u2", +}.items(): + ONE_QUBIT_U_FORM_MAP[_alias] = ONE_QUBIT_U_FORM_MAP[_name] + +# Standard gates that are ``ctrl(n) @`` of a single-qubit gate or swap, as (base gate, n). +CTRL_BASE_GATE_MAP = { + "cx": ("x", 1), + "CX": ("x", 1), + "cnot": ("x", 1), + "cy": ("y", 1), + "cz": ("z", 1), + "ch": ("h", 1), + "cs": ("s", 1), + "csdg": ("sdg", 1), + "csx": ("sx", 1), + "cv": ("sx", 1), + "crx": ("rx", 1), + "cry": ("ry", 1), + "crz": ("rz", 1), + "cp": ("p", 1), + "cphaseshift": ("p", 1), + "cu1": ("p", 1), + "cu3": ("U", 1), + "cu": ("U", 1), + "ccx": ("x", 2), + "toffoli": ("x", 2), + "ccnot": ("x", 2), + "c3x": ("x", 3), + "c4x": ("x", 4), + "c3sx": ("sx", 3), + "c3sqrtx": ("sx", 3), + "cswap": ("swap", 1), +} + + +def _mcx_gate(ctrls: list[IndexedIdentifier], target: IndexedIdentifier) -> list[QuantumGate]: + """Implements an X gate controlled on any number of qubits, without relative phase.""" + if len(ctrls) == 1: + return two_qubit_gate_op("cx", ctrls[0], target) + if len(ctrls) <= 4: + mcx_gates: dict[int, Callable] = {2: ccx_gate_op, 3: c3x_gate, 4: c4x_gate} + return mcx_gates[len(ctrls)](*ctrls, target) + result = one_qubit_gate_op("h", target) + result.extend(_mc_phase_gate(_PI, ctrls, target)) + result.extend(one_qubit_gate_op("h", target)) + return result + + +def _mc_phase_gate( + lam: float, ctrls: list[IndexedIdentifier], target: IndexedIdentifier +) -> list[QuantumGate]: + """Implements a phase gate controlled on any number of qubits. + + Recurses on the last control with half-angle controlled phases (Barenco et al. 1995, + Lemma 7.5), so it needs no ancillas. + """ + if not ctrls: + return phaseshift_gate(lam, target) + if len(ctrls) == 1: + return cphaseshift_gate(lam, ctrls[0], target) + *rest, last = ctrls + result = cphaseshift_gate(lam / 2, last, target) + result.extend(_mcx_gate(rest, last)) + result.extend(cphaseshift_gate(-lam / 2, last, target)) + result.extend(_mcx_gate(rest, last)) + result.extend(_mc_phase_gate(lam / 2, rest, target)) + return result + + +# pylint: disable-next=too-many-arguments +def _mc_one_qubit_gate( + theta: float, + phi: float, + lam: float, + gamma: float, + ctrls: list[IndexedIdentifier], + target: IndexedIdentifier, +) -> list[QuantumGate]: + """Implements e^{i*gamma} U(theta, phi, lam) controlled on one or more qubits. + + Diagonal gates become a controlled phase; others use the ABC construction around two + multi-controlled X gates (Nielsen & Chuang, Corollary 4.2). The gate's overall phase is + applied as a phase on the last control, conditioned on the rest. + """ + *rest, last = ctrls + result: list[QuantumGate] = [] + if theta == 0: + alpha = gamma + if phi + lam: + result.extend(_mc_phase_gate(phi + lam, ctrls, target)) + else: + alpha = gamma + (phi + lam) / 2 + + def rot(name: str, angle: float) -> list[QuantumGate]: + return one_qubit_rotation_op(name, angle, target) if angle else [] + + result.extend(rot("rz", (lam - phi) / 2)) + result.extend(_mcx_gate(ctrls, target)) + result.extend(rot("rz", -(phi + lam) / 2) + rot("ry", -theta / 2)) + result.extend(_mcx_gate(ctrls, target)) + result.extend(rot("ry", theta / 2) + rot("rz", phi)) + if alpha: + result.extend(_mc_phase_gate(alpha, rest, last)) + return result + + +def _mc_swap_gate( + ctrls: list[IndexedIdentifier], qubit0: IndexedIdentifier, qubit1: IndexedIdentifier +) -> list[QuantumGate]: + """Implements a swap gate controlled on any number of qubits.""" + result = two_qubit_gate_op("cx", qubit1, qubit0) + result.extend(_mcx_gate([*ctrls, qubit0], qubit1)) + result.extend(two_qubit_gate_op("cx", qubit1, qubit0)) + return result + + +def _decomposed_ctrl_op(op_name: str, ctrl_count: int) -> tuple[Callable, int] | None: + """Build ``ctrl(ctrl_count) @ op_name`` from its single-qubit (or swap) base gate. + + Returns: + tuple | None: The callable (gate parameters, then qubits) and its total qubit count, + or None if the gate does not reduce to a supported base gate. + """ + base, implicit_ctrls = CTRL_BASE_GATE_MAP.get(op_name, (op_name, 0)) + num_ctrls = ctrl_count + implicit_ctrls + if base == "swap": + return (lambda *qubits: _mc_swap_gate(list(qubits[:-2]), *qubits[-2:])), num_ctrls + 2 + if base not in ONE_QUBIT_U_FORM_MAP: + return None + u_form, num_qubits = ONE_QUBIT_U_FORM_MAP[base], num_ctrls + 1 + + def gate(*args) -> list[QuantumGate]: + params, qubits = args[:-num_qubits], args[-num_qubits:] + return _mc_one_qubit_gate(*u_form(*params), list(qubits[:-1]), qubits[-1]) + + return gate, num_qubits + + def map_qasm_ctrl_op_to_callable(op_name: str, ctrl_count: int): """ Map a controlled QASM operation to a callable. @@ -1343,7 +1520,9 @@ def map_qasm_ctrl_op_to_callable(op_name: str, ctrl_count: int): if ctrl_op_name in FIVE_QUBIT_OP_MAP: return FIVE_QUBIT_OP_MAP[ctrl_op_name], 5 - # TODO: decompose controls if not built in + decomposed = _decomposed_ctrl_op(op_name, ctrl_count) + if decomposed is not None: + return decomposed raise ValidationError( f"Unsupported controlled QASM operation: {op_name} with {ctrl_count} controls" ) diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index 8c2e203b..07953de3 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -1575,7 +1575,7 @@ def _visit_phase_operation( ) ], name=qasm3_ast.Identifier("p"), - qubits=ctrls[0:1], # type: ignore + qubits=ctrls[-1:], # type: ignore arguments=[operation.argument], ), # type: ignore inverse, diff --git a/tests/qasm3/test_controlled_gates.py b/tests/qasm3/test_controlled_gates.py new file mode 100644 index 00000000..37d98149 --- /dev/null +++ b/tests/qasm3/test_controlled_gates.py @@ -0,0 +1,231 @@ +# Copyright 2026 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. + +"""Tests for the ctrl modifier on standard gates without a dedicated controlled form.""" + +import numpy as np +import pytest + +from pyqasm.entrypoint import dumps, loads +from pyqasm.exceptions import ValidationError +from tests.utils import assert_unitary_equal, unitary_from_unrolled_ast + +ANGLES = (0.3, 0.5, 0.7, 1.1) + +_X = np.array([[0, 1], [1, 0]], dtype=complex) +_Y = np.array([[0, -1j], [1j, 0]], dtype=complex) +_H = np.array([[1, 1], [1, -1]], dtype=complex) / np.sqrt(2) +_SX = np.array([[1 + 1j, 1 - 1j], [1 - 1j, 1 + 1j]], dtype=complex) / 2 +_SWAP = np.eye(4, dtype=complex)[[0, 2, 1, 3]] + + +def _p(lam): + return np.diag([1, np.exp(1j * lam)]) + + +def _u(theta, phi, lam): + cos, sin = np.cos(theta / 2), np.sin(theta / 2) + return np.array( + [[cos, -np.exp(1j * lam) * sin], [np.exp(1j * phi) * sin, np.exp(1j * (phi + lam)) * cos]] + ) + + +def _rx(theta): + cos, sin = np.cos(theta / 2), np.sin(theta / 2) + return np.array([[cos, -1j * sin], [-1j * sin, cos]]) + + +def _prx(theta, phi): + cos, sin = np.cos(theta / 2), np.sin(theta / 2) + return np.array([[cos, -1j * np.exp(-1j * phi) * sin], [-1j * np.exp(1j * phi) * sin, cos]]) + + +# Textbook matrices, written independently of the U-form table in pyqasm.maps.gates. +ONE_QUBIT_GATES = { + "id": np.eye(2, dtype=complex), + "x": _X, + "not": _X, + "y": _Y, + "z": _p(np.pi), + "h": _H, + "s": _p(np.pi / 2), + "sdg": _p(-np.pi / 2), + "si": _p(-np.pi / 2), + "t": _p(np.pi / 4), + "tdg": _p(-np.pi / 4), + "ti": _p(-np.pi / 4), + "sx": _SX, + "v": _SX, + "sxdg": _SX.conj().T, + "vi": _SX.conj().T, + "rx(0.3)": _rx(0.3), + "ry(0.3)": np.array([[np.cos(0.15), -np.sin(0.15)], [np.sin(0.15), np.cos(0.15)]]), + "rz(0.3)": np.diag([np.exp(-0.15j), np.exp(0.15j)]), + "p(0.3)": _p(0.3), + "phaseshift(0.3)": _p(0.3), + "u1(0.3)": _p(0.3), + "U(0.3, 0.5, 0.7)": _u(*ANGLES[:3]), + "u(0.3, 0.5, 0.7)": _u(*ANGLES[:3]), + "u3(0.3, 0.5, 0.7)": _u(*ANGLES[:3]), + "u2(0.5, 0.7)": _u(np.pi / 2, 0.5, 0.7), + "prx(0.3, 0.5)": _prx(0.3, 0.5), + "gpi(0.5)": np.array([[0, np.exp(-0.5j)], [np.exp(0.5j), 0]]), + "gpi2(0.5)": _prx(np.pi / 2, 0.5), +} + + +def _controlled(matrix, num_ctrls): + """Apply ``matrix`` to the last qubits only when the first ``num_ctrls`` qubits are 1. + + Both ``matrix`` and the result are little-endian, like ``unitary_from_unrolled_ast``. + """ + num_targets = int(np.log2(matrix.shape[0])) + dim = 2 ** (num_ctrls + num_targets) + ctrl_mask = 2**num_ctrls - 1 + result = np.zeros((dim, dim), dtype=complex) + for basis in range(dim): + if basis & ctrl_mask != ctrl_mask: + result[basis, basis] = 1 + continue + for target_out in range(2**num_targets): + result[(target_out << num_ctrls) | ctrl_mask, basis] = matrix[ + target_out, basis >> num_ctrls + ] + return result + + +def _unrolled_unitary(body, num_qubits): + """Unroll ``body`` and return its operator, checking the output parses again.""" + module = loads(f'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[{num_qubits}] q;\n{body}') + module.unroll() + loads(dumps(module)).validate() + return unitary_from_unrolled_ast(module.unrolled_ast, num_qubits) + + +def _operands(num_qubits): + return ", ".join(f"q[{i}]" for i in range(num_qubits)) + + +@pytest.mark.parametrize("gate", ONE_QUBIT_GATES) +def test_one_qubit_reference_matches_unrolled_gate(gate): + """The reference matrices agree with pyqasm's own unrolling of the bare gate.""" + assert_unitary_equal(_unrolled_unitary(f"{gate} q[0];", 1), ONE_QUBIT_GATES[gate]) + + +@pytest.mark.parametrize("num_ctrls", [1, 2, 3]) +@pytest.mark.parametrize("gate", ONE_QUBIT_GATES) +def test_ctrl_one_qubit_gate_is_exact(gate, num_ctrls): + """ctrl(n) @ gate realises the controlled gate, including the base gate's phase.""" + body = f"ctrl({num_ctrls}) @ {gate} {_operands(num_ctrls + 1)};" + assert_unitary_equal( + _unrolled_unitary(body, num_ctrls + 1), + _controlled(ONE_QUBIT_GATES[gate], num_ctrls), + ) + + +@pytest.mark.parametrize("gate", ["x", "z", "tdg", "sx", "u(0.3, 0.5, 0.7)"]) +def test_ctrl_beyond_four_controls(gate): + """Five controls go past c4x, through the recursive multi-controlled X.""" + body = f"ctrl(5) @ {gate} {_operands(6)};" + assert_unitary_equal(_unrolled_unitary(body, 6), _controlled(ONE_QUBIT_GATES[gate], 5)) + + +@pytest.mark.parametrize( + "gate, num_ctrls, matrix", + [ + ("cs", 1, _controlled(_p(np.pi / 2), 1)), + ("csdg", 1, _controlled(_p(-np.pi / 2), 1)), + ("csx", 1, _controlled(_SX, 1)), + ("cy", 2, _controlled(_Y, 1)), + ("cz", 1, _controlled(_p(np.pi), 1)), + ("ch", 1, _controlled(_H, 1)), + ("crx(0.3)", 1, _controlled(_rx(0.3), 1)), + ("cp(0.3)", 2, _controlled(_p(0.3), 1)), + ("cu1(0.3)", 1, _controlled(_p(0.3), 1)), + ("cu3(0.3, 0.5, 0.7)", 1, _controlled(_u(*ANGLES[:3]), 1)), + ("cu(0.3, 0.5, 0.7, 1.1)", 1, _controlled(np.exp(1.1j) * _u(*ANGLES[:3]), 1)), + ("swap", 2, _SWAP), + ("cswap", 1, _controlled(_SWAP, 1)), + ("c3sx", 1, _controlled(_SX, 3)), + ], +) +def test_ctrl_controlled_standard_gate_is_exact(gate, num_ctrls, matrix): + """ctrl @ a standard controlled gate adds controls to its single-qubit or swap base.""" + num_qubits = num_ctrls + int(np.log2(matrix.shape[0])) + body = f"ctrl({num_ctrls}) @ {gate} {_operands(num_qubits)};" + assert_unitary_equal(_unrolled_unitary(body, num_qubits), _controlled(matrix, num_ctrls)) + + +def test_ctrl_custom_gate_with_multi_controlled_z(): + """The program from issue #252: a custom gate containing ctrl(2) @ z, controlled again.""" + body = """ + gate z_on_two3 aa, ab, ac { + x ac; + ctrl(2) @ z aa, ab, ac; + x ac; + } + ctrl @ z_on_two3 q[0], q[1], q[2], q[3]; + """ + x_on_last = np.kron(_X, np.eye(4)) + ccz = _controlled(_p(np.pi), 2) + assert_unitary_equal(_unrolled_unitary(body, 4), _controlled(x_on_last @ ccz @ x_on_last, 1)) + + +def test_ctrl_gates_from_failed_iqm_job(): + """The ctrl statements of a qBraid job on IQM Emerald that pyqasm rejected unroll.""" + body = "ctrl @ tdg q[1], q[3];\nctrl @ sx q[0], q[2];\nctrl @ sdg q[2], q[1];" + module = loads(f'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[4] q;\n{body}') + module.unroll() + loads(dumps(module)).validate() + assert "ctrl" not in dumps(module) + + +@pytest.mark.parametrize("gate", ["tdg", "sx", "u(0.3, 0.5, 0.7)"]) +def test_ctrl_one_qubit_gate_operand_order(gate): + """The last operand is the target, whichever qubit index it has.""" + swap = _SWAP.astype(complex) + assert_unitary_equal( + _unrolled_unitary(f"ctrl @ {gate} q[1], q[0];", 2), + swap @ _controlled(ONE_QUBIT_GATES[gate], 1) @ swap, + ) + + +@pytest.mark.parametrize( + "body, matrix", + [ + ("inv @ ctrl @ t q[0], q[1];", _controlled(_p(-np.pi / 4), 1)), + ("ctrl(2) @ gphase(0.3) q[0], q[1];", _controlled(_p(0.3), 1)), + ], +) +def test_ctrl_combined_with_other_modifiers(body, matrix): + """inv and gphase route through the same controlled decomposition.""" + assert_unitary_equal(_unrolled_unitary(body, 2), matrix) + + +def test_negctrl_one_qubit_gate(): + """negctrl @ tdg applies the gate when the control is |0>.""" + x_on_ctrl = np.kron(np.eye(2), _X) + assert_unitary_equal( + _unrolled_unitary("negctrl @ tdg q[0], q[1];", 2), + x_on_ctrl @ _controlled(_p(-np.pi / 4), 1) @ x_on_ctrl, + ) + + +def test_ctrl_unsupported_multi_qubit_gate_raises(): + """Gates that do not reduce to a single-qubit or swap base are still rejected.""" + module = loads( + 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[3] q;\nctrl @ iswap q[0], q[1], q[2];' + ) + with pytest.raises(ValidationError, match="Unsupported controlled QASM operation: iswap"): + module.unroll() diff --git a/tests/qasm3/test_gates.py b/tests/qasm3/test_gates.py index aaadd780..7e3d286b 100644 --- a/tests/qasm3/test_gates.py +++ b/tests/qasm3/test_gates.py @@ -626,17 +626,14 @@ def test_negctrl_chain_on_multi_controlled_x(): @pytest.mark.parametrize( "body, num_qubits", [ - # c4x has no controlled form (c5x is not defined), so a further control - # cannot be resolved and must raise rather than silently mis-decompose. + # There is no c5x, so these go through the recursive multi-controlled X. ("ctrl @ c4x q[0], q[1], q[2], q[3], q[4], q[5];", 6), ("ctrl(5) @ x q[0], q[1], q[2], q[3], q[4], q[5];", 6), ], ) -def test_too_many_controls_raises(body, num_qubits): - """Control chains requiring an undefined gate raise a clear ValidationError.""" - header = f'OPENQASM 3;\ninclude "stdgates.inc";\nqubit[{num_qubits}] q;\n' - with pytest.raises(ValidationError, match="controlled QASM operation"): - loads(header + body).unroll() +def test_controls_beyond_c4x_realise_mcx(body, num_qubits): + """Control chains past c4x decompose to the exact MCX unitary.""" + assert_unitary_equal(_unrolled_unitary(body, num_qubits), mcx_unitary(num_qubits)) def test_inverse_of_unsupported_four_qubit_gate_raises(): diff --git a/tests/utils.py b/tests/utils.py index a55f4685..e4bd786c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -72,6 +72,7 @@ def _ry(theta): "sdg": _phase(-np.pi / 2), "t": _phase(np.pi / 4), "tdg": _phase(-np.pi / 4), + "sx": np.array([[1 + 1j, 1 - 1j], [1 - 1j, 1 + 1j]], dtype=complex) / 2, } _PARAM_ONE_QUBIT = {"p": _phase, "phaseshift": _phase, "rz": _rz, "rx": _rx, "ry": _ry} _PARAM_TWO_QUBIT = {"cp": _phase, "cphaseshift": _phase, "crz": _rz, "crx": _rx, "cry": _ry} @@ -137,6 +138,8 @@ def unitary_from_unrolled_ast(unrolled_ast, num_qubits): gate = _embed_controlled( _FIXED_TWO_QUBIT_TARGET[name], qubits[0], qubits[1], num_qubits ) + elif name == "ccx": + gate = mcx_unitary(num_qubits, target=qubits[2], controls=qubits[:2]) elif name in _PARAM_TWO_QUBIT: gate = _embed_controlled( _PARAM_TWO_QUBIT[name](args[0]), qubits[0], qubits[1], num_qubits @@ -147,13 +150,16 @@ def unitary_from_unrolled_ast(unrolled_ast, num_qubits): return unitary -def mcx_unitary(num_qubits, target=None): - """Reference multi-controlled-X: controls = all qubits except ``target``.""" +def mcx_unitary(num_qubits, target=None, controls=None): + """Reference multi-controlled-X: controls default to all qubits except ``target``.""" if target is None: target = num_qubits - 1 dim = 2**num_qubits target_bit = 1 << target - control_mask = (dim - 1) ^ target_bit + if controls is None: + control_mask = (dim - 1) ^ target_bit + else: + control_mask = sum(1 << control for control in controls) unitary = np.zeros((dim, dim), dtype=complex) for basis in range(dim): dest = basis ^ target_bit if (basis & control_mask) == control_mask else basis diff --git a/tests/visualization/test_mpl_draw.py b/tests/visualization/test_mpl_draw.py index 7a643e59..2b475c3b 100644 --- a/tests/visualization/test_mpl_draw.py +++ b/tests/visualization/test_mpl_draw.py @@ -63,7 +63,9 @@ def test_draw_qasm3_simple(): def test_reverse_control_gate_map_matches_control_gate_map(): """Every controlled gate maps back to the gate it controls.""" - assert REV_CTRL_GATE_MAP == {controlled: base for base, controlled in CTRL_GATE_MAP.items()} + # cu takes an extra phase parameter, so ctrl @ u is decomposed instead of mapped to cu + expected = {controlled: base for base, controlled in CTRL_GATE_MAP.items()} | {"cu": "u"} + assert REV_CTRL_GATE_MAP == expected @pytest.mark.parametrize("gate_name,num_controls", [("c3x", 3), ("c4x", 4)]) From a8639a1ad767e8ac5f7accee93370e3f43b6e7fa Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Tue, 20 Oct 2026 20:44:54 -0500 Subject: [PATCH 2/4] fix: reject ctrl @ with more than 10 controls The ancilla-free decomposition triples in size per control past four, so ctrl(12) @ x unrolls to ~6.5M gates. Reject counts above 10 with a ValidationError instead of hanging unroll(). --- CHANGELOG.md | 2 +- src/pyqasm/maps/gates.py | 10 ++++++++++ tests/qasm3/test_controlled_gates.py | 11 +++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 794b8dd8..9a5fcb5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ Types of changes: ### Removed ### Fixed -- Fixed `ctrl @` on standard gates with no dedicated controlled form, which raised `Unsupported controlled QASM operation`. `ctrl(n) @` now works for any number of controls on every single-qubit standard gate (including `s`, `sdg`, `t`, `tdg`, `sx`, `sxdg`, `id`, `U`/`u3`, `u2`, `prx`, `gpi`, `gpi2`), on gates that are themselves controlled single-qubit gates or swap (such as `cs`, `csx`, `cu`, `cz`, `cswap`, `c4x`), and on `gphase` with two or more controls. It also fixes `ctrl @ u`, which raised a `TypeError` ([#459](https://github.com/qBraid/pyqasm/pull/459), [#252](https://github.com/qBraid/pyqasm/issues/252)). +- Fixed `ctrl @` on standard gates with no dedicated controlled form, which raised `Unsupported controlled QASM operation`. `ctrl(n) @` now works for up to 10 controls on every single-qubit standard gate (including `s`, `sdg`, `t`, `tdg`, `sx`, `sxdg`, `id`, `U`/`u3`, `u2`, `prx`, `gpi`, `gpi2`), on gates that are themselves controlled single-qubit gates or swap (such as `cs`, `csx`, `cu`, `cz`, `cswap`, `c4x`), and on `gphase` with two or more controls. It also fixes `ctrl @ u`, which raised a `TypeError` ([#459](https://github.com/qBraid/pyqasm/pull/459), [#252](https://github.com/qBraid/pyqasm/issues/252)). - 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)) diff --git a/src/pyqasm/maps/gates.py b/src/pyqasm/maps/gates.py index 21f23c74..377c8518 100644 --- a/src/pyqasm/maps/gates.py +++ b/src/pyqasm/maps/gates.py @@ -1389,6 +1389,11 @@ def map_qasm_inv_op_to_callable(op_name: str): } +# The ancilla-free decomposition triples in size with each control past four; ten controls +# already unroll to ~7e5 gates. +MAX_DECOMPOSED_CTRLS = 10 + + def _mcx_gate(ctrls: list[IndexedIdentifier], target: IndexedIdentifier) -> list[QuantumGate]: """Implements an X gate controlled on any number of qubits, without relative phase.""" if len(ctrls) == 1: @@ -1479,6 +1484,11 @@ def _decomposed_ctrl_op(op_name: str, ctrl_count: int) -> tuple[Callable, int] | """ base, implicit_ctrls = CTRL_BASE_GATE_MAP.get(op_name, (op_name, 0)) num_ctrls = ctrl_count + implicit_ctrls + if num_ctrls > MAX_DECOMPOSED_CTRLS: + raise ValidationError( + f"Unsupported controlled QASM operation: {op_name} with {num_ctrls} controls " + f"(at most {MAX_DECOMPOSED_CTRLS} are supported)" + ) if base == "swap": return (lambda *qubits: _mc_swap_gate(list(qubits[:-2]), *qubits[-2:])), num_ctrls + 2 if base not in ONE_QUBIT_U_FORM_MAP: diff --git a/tests/qasm3/test_controlled_gates.py b/tests/qasm3/test_controlled_gates.py index 37d98149..57b592fa 100644 --- a/tests/qasm3/test_controlled_gates.py +++ b/tests/qasm3/test_controlled_gates.py @@ -229,3 +229,14 @@ def test_ctrl_unsupported_multi_qubit_gate_raises(): ) with pytest.raises(ValidationError, match="Unsupported controlled QASM operation: iswap"): module.unroll() + + +@pytest.mark.parametrize("gate, num_qubits", [("ctrl(11) @ x", 12), ("ctrl(7) @ c4x", 12)]) +def test_ctrl_too_many_controls_raises(gate, num_qubits): + """Control counts past MAX_DECOMPOSED_CTRLS are rejected instead of unrolled.""" + module = loads( + f'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[{num_qubits}] q;\n' + f"{gate} {_operands(num_qubits)};" + ) + with pytest.raises(ValidationError, match="with 11 controls"): + module.unroll() From f9c1a6427407e858333fd27cd88009213caf9440 Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Wed, 28 Oct 2026 10:05:44 -0500 Subject: [PATCH 3/4] fix: invert controlled gates in U-form instead of controlling each inverse piece `inv @ ctrl(n) @ g` expanded `inv @ g` first and controlled each resulting gate. The U/u2 inverse decompositions are only correct up to a global phase, which becomes a relative phase once controlled, and two-qubit controlled gates (cs, crx, cu, ...) were not inverted at all on that path. Main raised for 2+ controls; the new decomposition fallback made those resolve to a wrong unitary. Gates whose base has a U-form are now inverted as (e^{ig} U(t, p, l))^dagger = e^{-ig} U(-t, -l, -p) and decomposed with their controls. Self-inverse bases and s/t/rx/ry/rz keep the existing path, which is exact. This also fixes pre-existing wrong output for `inv @ ctrl @ U`, `inv @ crx`, `inv @ cu` and `inv @ ctrl @ crx`, and makes `inv @ c3sx` and `inv @ ctrl(n) @ sx/p` unroll. Also check gate support before the control cap, so `ctrl(11) @ iswap` reports an unsupported gate rather than a control limit, and test `ctrl @ c4x`. --- src/pyqasm/maps/gates.py | 35 ++++++++++++++++++--- src/pyqasm/visitor.py | 10 +++++- tests/qasm3/test_controlled_gates.py | 47 +++++++++++++++++++++++++--- tests/qasm3/test_gates.py | 12 ------- 4 files changed, 82 insertions(+), 22 deletions(-) diff --git a/src/pyqasm/maps/gates.py b/src/pyqasm/maps/gates.py index 377c8518..0f905596 100644 --- a/src/pyqasm/maps/gates.py +++ b/src/pyqasm/maps/gates.py @@ -1475,14 +1475,18 @@ def _mc_swap_gate( return result -def _decomposed_ctrl_op(op_name: str, ctrl_count: int) -> tuple[Callable, int] | None: - """Build ``ctrl(ctrl_count) @ op_name`` from its single-qubit (or swap) base gate. +def _decomposed_ctrl_op( + op_name: str, ctrl_count: int, inverse: bool = False +) -> tuple[Callable, int] | None: + """Build ``ctrl(ctrl_count) @ op_name`` (or its inverse) from its single-qubit or swap base. Returns: tuple | None: The callable (gate parameters, then qubits) and its total qubit count, or None if the gate does not reduce to a supported base gate. """ base, implicit_ctrls = CTRL_BASE_GATE_MAP.get(op_name, (op_name, 0)) + if base != "swap" and base not in ONE_QUBIT_U_FORM_MAP: + return None num_ctrls = ctrl_count + implicit_ctrls if num_ctrls > MAX_DECOMPOSED_CTRLS: raise ValidationError( @@ -1491,17 +1495,38 @@ def _decomposed_ctrl_op(op_name: str, ctrl_count: int) -> tuple[Callable, int] | ) if base == "swap": return (lambda *qubits: _mc_swap_gate(list(qubits[:-2]), *qubits[-2:])), num_ctrls + 2 - if base not in ONE_QUBIT_U_FORM_MAP: - return None u_form, num_qubits = ONE_QUBIT_U_FORM_MAP[base], num_ctrls + 1 def gate(*args) -> list[QuantumGate]: params, qubits = args[:-num_qubits], args[-num_qubits:] - return _mc_one_qubit_gate(*u_form(*params), list(qubits[:-1]), qubits[-1]) + theta, phi, lam, gamma = u_form(*params) + if inverse: + # (e^{i*gamma} U(theta, phi, lam))^dagger = e^{-i*gamma} U(-theta, -lam, -phi) + theta, phi, lam, gamma = -theta, -lam, -phi, -gamma + return _mc_one_qubit_gate(theta, phi, lam, gamma, list(qubits[:-1]), qubits[-1]) return gate, num_qubits +def map_qasm_inv_ctrl_op_to_callable(op_name: str, ctrl_count: int) -> tuple[Callable, int] | None: + """Map ``inv @ ctrl(ctrl_count) @ op_name`` to a callable, when inverting piecewise is wrong. + + Inverting a gate's decomposition and then controlling each piece turns any global phase + of the pieces into a relative phase, and two-qubit controlled gates are not inverted at + all on that path. Returns None for the gates where it is exact (self-inverse bases, + and ``s``/``t``/``rx``/``ry``/``rz`` without implicit controls), so their output keeps + its current form. + """ + base, implicit_ctrls = CTRL_BASE_GATE_MAP.get(op_name, (op_name, 0)) + if base in SELF_INVERTING_ONE_QUBIT_OP_SET or base == "swap": + return None + if not implicit_ctrls and ( + ctrl_count == 0 or base in ST_GATE_INV_MAP or base in ROTATION_INVERSION_ONE_QUBIT_OP_MAP + ): + return None + return _decomposed_ctrl_op(op_name, ctrl_count, inverse=True) + + def map_qasm_ctrl_op_to_callable(op_name: str, ctrl_count: int): """ Map a controlled QASM operation to a callable. diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index 07953de3..d74e08c9 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -67,6 +67,7 @@ from pyqasm.maps.gates import ( fresh_qubits, map_qasm_ctrl_op_to_callable, + map_qasm_inv_ctrl_op_to_callable, map_qasm_inv_op_to_callable, map_qasm_op_num_params, map_qasm_op_to_callable, @@ -1218,7 +1219,14 @@ def _visit_basic_gate_operation( if ctrls is None: ctrls = [] - if not inverse: + inverse_ctrl_op = ( + map_qasm_inv_ctrl_op_to_callable(operation.name.name, len(ctrls)) if inverse else None + ) + if inverse_ctrl_op is not None: + qasm_func, op_qubit_total_count = inverse_ctrl_op + op_qubit_count = op_qubit_total_count - len(ctrls) + inverse = False + elif not inverse: if len(ctrls) > 0: qasm_func, op_qubit_total_count = map_qasm_ctrl_op_to_callable( operation.name.name, len(ctrls) diff --git a/tests/qasm3/test_controlled_gates.py b/tests/qasm3/test_controlled_gates.py index 57b592fa..5e303cd3 100644 --- a/tests/qasm3/test_controlled_gates.py +++ b/tests/qasm3/test_controlled_gates.py @@ -158,6 +158,7 @@ def test_ctrl_beyond_four_controls(gate): ("swap", 2, _SWAP), ("cswap", 1, _controlled(_SWAP, 1)), ("c3sx", 1, _controlled(_SX, 3)), + ("c4x", 1, _controlled(_X, 4)), ], ) def test_ctrl_controlled_standard_gate_is_exact(gate, num_ctrls, matrix): @@ -213,6 +214,37 @@ def test_ctrl_combined_with_other_modifiers(body, matrix): assert_unitary_equal(_unrolled_unitary(body, 2), matrix) +_CU = np.exp(1.1j) * _u(*ANGLES[:3]) + + +@pytest.mark.parametrize( + "body, num_qubits, matrix", + [ + ("inv @ ctrl(2) @ U(0.3, 0.5, 0.7)", 3, _controlled(_u(*ANGLES[:3]).conj().T, 2)), + ("pow(-1) @ ctrl(2) @ U(0.3, 0.5, 0.7)", 3, _controlled(_u(*ANGLES[:3]).conj().T, 2)), + ("inv @ ctrl @ U(0.3, 0.5, 0.7)", 2, _controlled(_u(*ANGLES[:3]).conj().T, 1)), + ("inv @ ctrl(3) @ u2(0.5, 0.7)", 4, _controlled(_u(np.pi / 2, 0.5, 0.7).conj().T, 3)), + ("inv @ ctrl(2) @ cs", 4, _controlled(_p(-np.pi / 2), 3)), + ("inv @ cs", 2, _controlled(_p(-np.pi / 2), 1)), + ("inv @ crx(0.3)", 2, _controlled(_rx(-0.3), 1)), + ("inv @ cu(0.3, 0.5, 0.7, 1.1)", 2, _controlled(_CU.conj().T, 1)), + ("inv @ ctrl @ cu(0.3, 0.5, 0.7, 1.1)", 3, _controlled(_CU.conj().T, 2)), + ("inv @ ctrl(2) @ sx", 3, _controlled(_SX.conj().T, 2)), + ("inv @ c3sx", 4, _controlled(_SX.conj().T, 3)), + ("inv @ ctrl(2) @ rz(0.3)", 3, _controlled(np.diag([np.exp(0.15j), np.exp(-0.15j)]), 2)), + ], +) +def test_inv_ctrl_is_exact(body, num_qubits, matrix): + """inv over a controlled gate keeps the relative phase between control states.""" + assert_unitary_equal(_unrolled_unitary(f"{body} {_operands(num_qubits)};", num_qubits), matrix) + + +def test_inv_ctrl_custom_gate_built_from_u(): + """Qiskit exports controlled gates as custom gates built from U; inv must stay exact.""" + body = "gate mu a { U(0.3, 0.5, 0.7) a; }\ninv @ ctrl(2) @ mu q[0], q[1], q[2];" + assert_unitary_equal(_unrolled_unitary(body, 3), _controlled(_u(*ANGLES[:3]).conj().T, 2)) + + def test_negctrl_one_qubit_gate(): """negctrl @ tdg applies the gate when the control is |0>.""" x_on_ctrl = np.kron(np.eye(2), _X) @@ -222,12 +254,19 @@ def test_negctrl_one_qubit_gate(): ) -def test_ctrl_unsupported_multi_qubit_gate_raises(): - """Gates that do not reduce to a single-qubit or swap base are still rejected.""" +@pytest.mark.parametrize("num_ctrls", [1, 11]) +def test_ctrl_unsupported_multi_qubit_gate_raises(num_ctrls): + """Gates that do not reduce to a single-qubit or swap base are still rejected, without + blaming the control count.""" + num_qubits = num_ctrls + 2 module = loads( - 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[3] q;\nctrl @ iswap q[0], q[1], q[2];' + f'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[{num_qubits}] q;\n' + f"ctrl({num_ctrls}) @ iswap {_operands(num_qubits)};" ) - with pytest.raises(ValidationError, match="Unsupported controlled QASM operation: iswap"): + with pytest.raises( + ValidationError, + match=rf"Unsupported controlled QASM operation: iswap with {num_ctrls} controls$", + ): module.unroll() diff --git a/tests/qasm3/test_gates.py b/tests/qasm3/test_gates.py index 7e3d286b..502e51da 100644 --- a/tests/qasm3/test_gates.py +++ b/tests/qasm3/test_gates.py @@ -636,18 +636,6 @@ def test_controls_beyond_c4x_realise_mcx(body, num_qubits): assert_unitary_equal(_unrolled_unitary(body, num_qubits), mcx_unitary(num_qubits)) -def test_inverse_of_unsupported_four_qubit_gate_raises(): - """c3sx has no inverse decomposition; inv @ c3sx must fail loudly, not silently.""" - qasm3_string = """ - OPENQASM 3; - include "stdgates.inc"; - qubit[4] q; - inv @ c3sx q[0], q[1], q[2], q[3]; - """ - with pytest.raises(ValidationError, match="Unsupported / undeclared QASM operation"): - loads(qasm3_string).unroll() - - def test_negctrl_gate_modifier(): qasm3_string = """ OPENQASM 3.0; From 6e8fd5a8306f83cbf8eeaf87b00f1004c46246de Mon Sep 17 00:00:00 2001 From: Ryan Hill Date: Wed, 28 Oct 2026 10:05:54 -0500 Subject: [PATCH 4/4] docs: note the inv @ ctrl fix in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7ffc54..41628d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ Types of changes: ### Removed ### Fixed -- Fixed `ctrl @` on standard gates with no dedicated controlled form, which raised `Unsupported controlled QASM operation`. `ctrl(n) @` now works for up to 10 controls on every single-qubit standard gate (including `s`, `sdg`, `t`, `tdg`, `sx`, `sxdg`, `id`, `U`/`u3`, `u2`, `prx`, `gpi`, `gpi2`), on gates that are themselves controlled single-qubit gates or swap (such as `cs`, `csx`, `cu`, `cz`, `cswap`, `c4x`), and on `gphase` with two or more controls. It also fixes `ctrl @ u`, which raised a `TypeError` ([#459](https://github.com/qBraid/pyqasm/pull/459), [#252](https://github.com/qBraid/pyqasm/issues/252)). +- Fixed `ctrl @` on standard gates with no dedicated controlled form, which raised `Unsupported controlled QASM operation`. `ctrl(n) @` now works for up to 10 controls on every single-qubit standard gate (including `s`, `sdg`, `t`, `tdg`, `sx`, `sxdg`, `id`, `U`/`u3`, `u2`, `prx`, `gpi`, `gpi2`), on gates that are themselves controlled single-qubit gates or swap (such as `cs`, `csx`, `cu`, `cz`, `cswap`, `c4x`), and on `gphase` with two or more controls. It also fixes `ctrl @ u`, which raised a `TypeError`, and `inv @` over controlled gates, which gave the wrong relative phase for `U`/`u2`/`cu` and left `cs`/`crx`/`cu` uninverted ([#459](https://github.com/qBraid/pyqasm/pull/459), [#252](https://github.com/qBraid/pyqasm/issues/252)). - `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))