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
- 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)).
- 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)).
- 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)).
Expand Down
218 changes: 216 additions & 2 deletions src/pyqasm/maps/gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -1315,6 +1314,219 @@ 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),
}


# 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:
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


# 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, 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(
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
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:]
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.
Expand Down Expand Up @@ -1343,7 +1555,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)

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.

[H1] inv/pow(-k) with 2+ controls now yields a wrong unitary without an error — Implementation · High

Rationale: The defect is in unchanged code: the inverse recursion in _visit_basic_gate_operation (visitor.py:1255–1264). It first expands inv @ g (through u3_inv_gate, gates.py:118–134, or the two-qubit NO_OP branch of map_qasm_inv_op_to_callable) and then controls each resulting gate. u3_inv_gate equals U⁻¹ only up to a global phase. Once each piece is controlled, that phase becomes a relative phase between control states. On main, the controlled rz pieces with 2+ controls had no mapping, so these programs raised. The new fallback here makes them resolve, so they now return wrong output with no error. Checked against Qiskit's Gate.inverse().control(n):

program main head
inv @ ctrl(2) @ U(0.3,0.5,0.7) ValidationError wrong unitary
inv @ ctrl(3) @ u2(0.5,0.7) ValidationError wrong unitary
pow(-1) @ ctrl(2) @ U(…) ValidationError wrong unitary
inv @ ctrl(2) @ cs ValidationError wrong unitary
inv @ ctrl(2) @ mu / ctrl(2) @ inv @ mu (gate mu x { U(…) x; }) ValidationError wrong unitary

The same programs without inv match. Qiskit-exported programs use custom gates built from U, so the custom-gate case is reachable in practice. The new test_ctrl_combined_with_other_modifiers covers only inv @ ctrl @ t with one control. That path is already exact, so the test cannot catch this.

Change requested: When inverse is set and ctrls is non-empty, invert in U-form instead of controlling each gate of the inverse expansion. Note that (e^{iγ}U(θ,φ,λ))† = e^{-iγ}U(-θ,-λ,-φ). One way is to give _decomposed_ctrl_op an inverse flag, and route the visitor's inverse branch through it whenever the base gate (after CTRL_BASE_GATE_MAP resolution) has a U-form:

# gates.py, inside _decomposed_ctrl_op
def gate(*args) -> list[QuantumGate]:
    params, qubits = args[:-num_qubits], args[-num_qubits:]
    theta, phi, lam, gamma = u_form(*params)
    if inverse:
        theta, phi, lam, gamma = -theta, -lam, -phi, -gamma
    return _mc_one_qubit_gate(theta, phi, lam, gamma, list(qubits[:-1]), qubits[-1])
# visitor.py, _visit_basic_gate_operation: try this before the existing inverse branch
if inverse and ctrls:
    decomposed = _decomposed_ctrl_op(operation.name.name, len(ctrls), inverse=True)
    # if not None, use it like the non-inverse ctrl path; else keep the current recursion

This covers U/u3/u2 and the CTRL_BASE_GATE_MAP gates (cs resolves to base s, whose inverse U-form is (0, 0, -π/2, 0)). The current recursion stays for rz/rx/ry/s/t, which is already exact. If this is out of scope, raise ValidationError for inverse and ctrls on U-form or two-qubit bases instead of emitting output.

Add regression tests to tests/qasm3/test_controlled_gates.py: inv @ ctrl(2) @ U(0.3, 0.5, 0.7) and inv @ ctrl(2) @ cs, each compared with _controlled(<inverse matrix>, n).

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.

Fixed in f9c1a64, along the lines you suggested. _decomposed_ctrl_op now takes inverse=True and applies (e^{iγ}U(θ, φ, λ))† = e^{-iγ}U(-θ, -λ, -φ). A new map_qasm_inv_ctrl_op_to_callable routes the visitor's inv branch through it, so the existing recursion isn't used for these gates.

That recursion stays only where it's exact: self-inverse bases (x/h/…, swap), and s/t/rx/ry/rz with no implicit controls.

Checking more widely showed that the same path was already wrong on main for some cases:

Program Main Before this commit Now
inv @ ctrl @ U(…) (1 control) wrong unitary wrong exact
inv @ crx(0.3) (no modifier controls) wrong: emitted crx(0.3) uninverted wrong exact
inv @ cu(…, γ), inv @ ctrl @ crx wrong wrong exact
Your six cases, and pow(-2) @ ctrl(2) @ U ValidationError wrong exact
inv @ ctrl(2) @ sx, inv @ ctrl(2) @ p, inv @ c3sx ValidationError ValidationError exact

inv @ c3sx now unrolls, so I replaced test_inverse_of_unsupported_four_qubit_gate_raises with an exactness case.

I added your two regression tests, plus nine more, in test_inv_ctrl_is_exact and test_inv_ctrl_custom_gate_built_from_u. They're checked against textbook matrices.

I also checked 28 programs against Qiskit's importer, and 27 match. The exception is inv @ ctrl @ cu(θ, φ, λ, γ) with γ ≠ 0. On that one Qiskit drops γ, and so does its own CUGate(...).control(1).inverse(). Our output equals ctrl @ cu(-θ, -λ, -φ, -γ) and the hand-built controlled(e^{-iγ}U†, 2), so the test for it uses the hand-built matrix.

Suite: 1179 passed, 3 skipped, after merging main. format-check is clean.

if decomposed is not None:
return decomposed
raise ValidationError(
f"Unsupported controlled QASM operation: {op_name} with {ctrl_count} controls"
)
12 changes: 10 additions & 2 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1575,7 +1583,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,
Expand Down
Loading
Loading