"""IR-level decomposition of OriginIR-native gates to QASM 2.0 stdlib gates.
Some OriginIR opcodes (``RPhi``, ``RPhi90``, ``RPhi180``, ``PHASE2Q``,
``UU15``) have no corresponding name in the OpenQASM 2.0 standard library
(``qelib1.inc``). ``Circuit.qasm`` works around this by inlining
``gate ... { ... }`` definitions from
:data:`uniqc.circuit_builder.translate_qasm2_oir.QASM2_CUSTOM_GATE_DEFS`,
but the cloud parsers used by Quafu / QuarkStudio / IBM frequently reject
QASM source that depends on such custom gate blocks.
This module rewrites those opcodes into mathematically-equivalent sequences
of gates that *are* in ``qelib1.inc``:
============== ==================================================
OriginIR gate Replacement (OriginIR opcode names, equivalent up to
global phase)
============== ==================================================
``RPhi`` ``RZ(-phi); RX(theta); RZ(phi)``
``RPhi90`` ``RZ(-phi); RX(pi/2); RZ(phi)``
``RPhi180`` ``RZ(-phi); RX(pi); RZ(phi)``
``PHASE2Q`` ``U1(t1) q1; U1(t2) q2; CU1(tzz) (q1->q2)``
(CU1 is emitted as ``U1`` with ``control_qubits=[q1]``)
``UU15`` ``U3 a; U3 b; XX; YY; ZZ; U3 a; U3 b`` (KAK form)
============== ==================================================
This is a lightweight, qiskit-free pre-processing pass. It runs *before*
:func:`uniqc.compile.compile_for_backend` so that subsequent basis-gate
and topology compilation can take over.
The decomposition preserves the ``dagger`` flag by reversing the
replacement sequence and negating angle parameters where appropriate.
Gate instances wrapped with ``control_qubits`` are not currently
decomposed; doing so requires lifting each replacement gate to its
controlled form, which is out of scope for this pass — call ``compile()``
explicitly first if you need that.
"""
from __future__ import annotations
import math
from collections.abc import Iterable
from copy import deepcopy
from uniqc.circuit_builder.qcircuit import Circuit, OpCode
__all__ = [
"QASM2_UNREPRESENTABLE_GATES",
"decompose_opcode_for_qasm2",
"decompose_for_qasm2",
]
# Set of normalised (upper-case) gate names that this pass rewrites.
QASM2_UNREPRESENTABLE_GATES: frozenset[str] = frozenset({"RPHI", "RPHI90", "RPHI180", "PHASE2Q", "UU15"})
def _as_param_list(params) -> list[float]:
if params is None:
return []
if isinstance(params, (list, tuple)):
return [float(p) for p in params]
return [float(params)]
def _check_target_qubits(name: str, qubits, expected: int) -> list[int]:
if isinstance(qubits, int):
qubit_list = [qubits]
else:
qubit_list = [int(q) for q in qubits]
if len(qubit_list) != expected:
raise ValueError(
f"Decomposition of {name} expects {expected} target qubit(s), got {len(qubit_list)}: {qubit_list}"
)
return qubit_list
def _rphi_replacement(theta: float, phi: float, qubit: int) -> list[OpCode]:
"""RPhi(θ, φ) = Rz(φ) Rx(θ) Rz(-φ).
Matches both ``_rphi`` in :mod:`uniqc.circuit_builder.matrix` and the
``gate rphi(theta, phi) a { rz(-phi); rx(theta); rz(phi); }`` definition
in :data:`uniqc.circuit_builder.translate_qasm2_oir.QASM2_CUSTOM_GATE_DEFS`.
The replacement applies, top-to-bottom, ``rz(-phi); rx(theta); rz(phi)``,
yielding the same matrix product ``Rz(phi) · Rx(theta) · Rz(-phi)``.
"""
return [
("RZ", qubit, None, -phi, False, None),
("RX", qubit, None, theta, False, None),
("RZ", qubit, None, phi, False, None),
]
def _phase2q_replacement(
t1: float,
t2: float,
tzz: float,
q1: int,
q2: int,
) -> list[OpCode]:
"""PHASE2Q(t1, t2, tzz) on (q1, q2) — q1 is LSB.
``diag(1, e^{i·t1}, e^{i·t2}, e^{i·(t1+t2+tzz)})`` factors as
``[U1(t1) ⊗ I] · [I ⊗ U1(t2)] · CU1(tzz, ctrl=q2, tgt=q1)``; we encode
the controlled phase as a ``U1`` opcode on ``q1`` with
``control_qubits=[q2]`` so the QASM emitter renders it as ``cu1``
(qelib1.inc).
"""
return [
("U1", q1, None, t1, False, None),
("U1", q2, None, t2, False, None),
("U1", q1, None, tzz, False, [q2]),
]
def _uu15_replacement(
params: list[float],
qa: int,
qb: int,
) -> list[OpCode]:
"""UU15 KAK form: U3⊗U3 · (XX·YY·ZZ) · U3⊗U3.
Mirrors the ``gate uu15(...)`` body in
:data:`uniqc.circuit_builder.translate_qasm2_oir.QASM2_CUSTOM_GATE_DEFS`,
which is itself the canonical Cartan / KAK decomposition of the
most-general 2-qubit unitary. All replacement opcodes are in
qelib1.inc (or, for ``YY``, are emitted alongside the ``ryy``
auxiliary definition that ``qelib1.inc`` parsers already accept).
"""
if len(params) != 15:
raise ValueError(f"UU15 expects exactly 15 parameters, got {len(params)}")
a0, a1, a2, b0, b1, b2, txx, tyy, tzz, c0, c1, c2, d0, d1, d2 = params
return [
("U3", qa, None, [a0, a1, a2], False, None),
("U3", qb, None, [b0, b1, b2], False, None),
("XX", [qa, qb], None, txx, False, None),
("YY", [qa, qb], None, tyy, False, None),
("ZZ", [qa, qb], None, tzz, False, None),
("U3", qa, None, [c0, c1, c2], False, None),
("U3", qb, None, [d0, d1, d2], False, None),
]
def _u3_dagger_params(p0: float, p1: float, p2: float) -> list[float]:
"""``u3(θ, φ, λ)† = u3(-θ, -λ, -φ)`` (qelib1 convention)."""
return [-p0, -p2, -p1]
def _apply_dagger(replacement: list[OpCode], gate_name: str) -> list[OpCode]:
"""Return the dagger of ``replacement`` (reversed + each opcode inverted).
The replacement sequences emitted by this module use only well-known
1- and 2-qubit gates whose adjoint is obtained by negating angle
parameters — except ``U3``, which needs the qelib1 swap of ``φ`` and
``λ`` after negation.
"""
inverted: list[OpCode] = []
for op in reversed(replacement):
name, qubits, cbits, params, _dag, ctrls = op
upper = name.upper()
if upper in {"RX", "RY", "RZ", "U1", "XX", "YY", "ZZ", "PHASE2Q"}:
new_params = -float(params) if not isinstance(params, (list, tuple)) else [-float(p) for p in params]
elif upper == "U3":
new_params = _u3_dagger_params(*[float(p) for p in params])
elif upper in {"RPHI", "RPHI90", "RPHI180"}:
# Should not occur — replacements never include these names —
# but be defensive.
raise NotImplementedError(
f"Internal error: cannot dagger replacement opcode {upper!r} for gate {gate_name!r}."
)
else:
raise NotImplementedError(
f"Cannot dagger replacement opcode {upper!r} for gate {gate_name!r}; missing inverse rule."
)
inverted.append((name, qubits, cbits, new_params, False, ctrls))
return inverted
[docs]
def decompose_opcode_for_qasm2(op: OpCode) -> list[OpCode]:
"""Return replacement opcodes for ``op`` if it is QASM2-unrepresentable.
Returns ``[op]`` unchanged when ``op`` is already a QASM2-friendly
opcode. Raises :class:`NotImplementedError` when the gate is in
:data:`QASM2_UNREPRESENTABLE_GATES` but is wrapped with
``control_qubits`` (controlled-RPhi / controlled-UU15 etc. require
a more general lift that this lightweight pass does not provide).
"""
name, qubits, cbits, params, dagger, control_qubits = op
upper = str(name).upper()
if upper not in QASM2_UNREPRESENTABLE_GATES:
return [op]
if control_qubits:
raise NotImplementedError(
f"decompose_for_qasm2 cannot rewrite controlled {name!r} "
f"(control_qubits={control_qubits}); call uniqc.compile() first "
f"or remove the control wrapper."
)
values = _as_param_list(params)
if upper == "RPHI":
if len(values) != 2:
raise ValueError(f"RPhi expects 2 parameters, got {values}")
target = _check_target_qubits(name, qubits, 1)[0]
replacement = _rphi_replacement(values[0], values[1], target)
elif upper == "RPHI90":
if len(values) != 1:
raise ValueError(f"RPhi90 expects 1 parameter, got {values}")
target = _check_target_qubits(name, qubits, 1)[0]
replacement = _rphi_replacement(math.pi / 2, values[0], target)
elif upper == "RPHI180":
if len(values) != 1:
raise ValueError(f"RPhi180 expects 1 parameter, got {values}")
target = _check_target_qubits(name, qubits, 1)[0]
replacement = _rphi_replacement(math.pi, values[0], target)
elif upper == "PHASE2Q":
if len(values) != 3:
raise ValueError(f"PHASE2Q expects 3 parameters, got {values}")
q1, q2 = _check_target_qubits(name, qubits, 2)
replacement = _phase2q_replacement(values[0], values[1], values[2], q1, q2)
elif upper == "UU15":
qa, qb = _check_target_qubits(name, qubits, 2)
replacement = _uu15_replacement(values, qa, qb)
else: # pragma: no cover - guarded by membership check above
return [op]
if dagger:
replacement = _apply_dagger(replacement, str(name))
return replacement
[docs]
def decompose_for_qasm2(circuit: Circuit) -> Circuit:
"""Return a new :class:`Circuit` with QASM2-unrepresentable gates rewritten.
The original circuit is not mutated. When the input contains no gate
in :data:`QASM2_UNREPRESENTABLE_GATES`, this returns a shallow copy.
Examples
--------
>>> from uniqc.circuit_builder import Circuit
>>> c = Circuit(2)
>>> c.rphi(0, 0.4, 0.7)
>>> c.add_gate("PHASE2Q", [0, 1], params=[0.1, 0.2, 0.3])
>>> c2 = decompose_for_qasm2(c)
>>> {op[0] for op in c2.opcode_list}.isdisjoint({"RPhi", "PHASE2Q"})
True
"""
has_target = any(str(op[0]).upper() in QASM2_UNREPRESENTABLE_GATES for op in circuit.opcode_list)
if not has_target:
return circuit
new_circuit = deepcopy(circuit)
new_opcodes: list[OpCode] = []
for op in circuit.opcode_list:
new_opcodes.extend(decompose_opcode_for_qasm2(op))
new_circuit.opcode_list = new_opcodes
# Some replacements introduce qubits that may not have been ``record``-ed
# on the source circuit (they always were, since we only touch existing
# qubits, but keep ``qubit_num`` honest just in case).
max_qubit = new_circuit.max_qubit
for op in new_opcodes:
_, qubits, _, _, _, control_qubits = op
for q in _flatten_qubits(qubits):
if q > max_qubit:
max_qubit = q
if control_qubits:
for q in _flatten_qubits(control_qubits):
if q > max_qubit:
max_qubit = q
if max_qubit + 1 > new_circuit.qubit_num:
new_circuit.qubit_num = max_qubit + 1
new_circuit.max_qubit = max_qubit
return new_circuit
def _flatten_qubits(qubits) -> Iterable[int]:
if qubits is None:
return ()
if isinstance(qubits, int):
return (qubits,)
return tuple(int(q) for q in qubits)