Skip to content

Exporting MPQP U gate to OpenQASM3/2 can generate gphase problems and is mathematically not correct #206

Description

@hJaffaliColibritd

U gate loses global phase across the OpenQASM 2 / 3 boundary

Checked against main (v0.6.x), the OpenQASM 2 paper, and the OpenQASM 3.1 spec + stdgates.inc.

TL;DR

MPQP's U gate is stored with the Qiskit/Braket matrix, but written to OpenQASM as the bare token u3, which the OpenQASM specs define as a different matrix. Nothing warns and nothing compensates. The two agree up to a global phase, so it stays invisible until someone downstream controls the gate, at which point the same exported circuit gives a different answer depending on who reads it.

import numpy as np
from mpqp import QCircuit
from mpqp.gates import U
from mpqp.core.languages import Language

th = ph = ga = np.pi / 2
circ = QCircuit([U(th, ph, ga, 0)])

print(circ.to_other_language(Language.QASM3))
print(circ.to_matrix()[0, 0])

Output:

OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
u3(1.5707963,1.5707963,1.5707963) q[0];

(0.7071067811865476+0j)

No gphase, no warning. But stdgates.inc defines u3 as e^-i(phi+lam)/2 times the matrix MPQP means, so a conformant OpenQASM 3 reader evaluates that exact same line to -0.7071j in the top-left corner instead of +0.7071. The ratio is e^i(phi+gamma)/2, here exactly i.

Why there are three matrices called U

Let

M(t,p,l) = [[ cos(t/2)      , -e^{il} sin(t/2)     ],
            [ e^{ip} sin(t/2),  e^{i(p+l)} cos(t/2) ]]
matrix who means this
(A) e^{-i(p+l)/2} * M OpenQASM 2 builtin U, qelib1.inc u3, stdgates.inc u3
(B) M Qiskit UGate/U3Gate, Braket gates.U, MPQP U
(C) e^{it/2} * M OpenQASM 3.0/3.1 builtin U

The OpenQASM 3 spec is explicit:

Both OpenQASM 2.0 and OpenQASM 3 define the builtin U gate (though note that OpenQASM 3 differs from OpenQASM 2 by a phase; u3 is identical to the U of OpenQASM 2).

and stdgates.inc encodes it directly:

gate u3(t, p, l) q { gphase(-(p+l+t)/2); U(t, p, l) q; }

All three identities are asserted numerically in section 0 of the script below.

To be fair to the library: MPQP is internally self-consistent. It writes u3 meaning (B) and it also reads u3 as (B) — see MyQasmUGate in qasm_to_cirq.py, which deliberately prepends GlobalPhaseGate(exp(i(lam+phi)/2)) to cirq's spec-conformant u3. Round trips that stay inside MPQP are fine. The problem is strictly at the boundary, which is the one place a multi-platform library cannot afford it.

Why it matters: the phase stops being global under ctrl @

A downstream user reuses the exported line and controls it, i.e. a Hadamard test:

gate my_u q { u3(pi/2, pi/2, pi/2) q; }
h c;  ctrl @ my_u c, t;  h c;
reading of u3 P(control = 0)
stdgates.inc (A) 0.500000
MPQP / Qiskit (B) 0.853553
OQ3 builtin U (C) 0.750000

Same angles, same text, three answers. Note that convert_instruction_2_to_3 already warns about exactly this failure mode ("the phase can become non-global"), but the warning never fires on the path that actually causes it — see point 1 below.

The four concrete divergences

1. Export path silently downgrades (B) to (A) — the main one

mpqp/core/instruction/gates/native_gates.py, class U:

qiskit_string = "u3"

@classproperty
def qasm2_gate(cls) -> str:
    return "u3"

def to_canonical_matrix(self):
    ...
    return np.array([[c, -eg * s], [ep * s, eg * ep * c]])   # this is (B)

circuit.py::to_other_language(Language.QASM3) goes QASM2 then open_qasm_2_to_3, so a U gate ends up as u3(...) in a file that also carries include "stdgates.inc", declaring it to be (A). Error: e^{i(phi+gamma)/2}.

No warning is raised, because the OpenQASMTranslationWarning in convert_instruction_2_to_3 sits behind instr_name.lower() == "u", and MPQP already emits u3, so that branch is never reached for MPQP-generated code.

2. convert_instruction_2_to_3 conflates OQ2 U with Qiskit-flavoured u

elif instr_name.lower() == "u":
    ...
    instructions_code += "u3" + instr[1:] + ";\n"
  • For a genuine OQ2 builtin U (= A) mapped to u3 (= A): correct. The phase is handled by delegation to stdgates.inc. The warning text ("We handled that for you by adding the extra phase at the right place") is misleading, since nothing is added and it works by delegation, but the result is right.
  • For a lowercase u coming from Qiskit's extended qelib1.inc, where Qiskit means (B): wrong by e^{i(phi+lam)/2}. The .lower() erases the distinction between the two.

3. OQ3 to OQ2 maps U to u with no phase accounting and no include

std_gates_3_to_2_map = {"U": "u", "phase": "u1", "cphase": "cu1"}
...
elif instr_name in std_gates_3_to_2_map:
    converted_instr_name = std_gates_3_to_2_map[instr_name]
    instructions_code += re.sub(r"\b" + instr_name + r"\b", converted_instr_name, instr) + ";\n"

Two problems:

  • No gphase is emitted or accumulated. The input U is (C); the output u is (A) per spec, or (B) under MPQP's de-facto reading. Either way the difference is non-zero and uncompensated. A full open_qasm_3_to_2 then open_qasm_2_to_3 round trip turns U(t,p,l) into u3(t,p,l), drifting by e^{-i(t+p+l)/2}. Ironically open_qasm_3_to_2 already carries a gphase accumulator that materialises as a // gphase: line; it is simply never fed by this branch.
  • add_qe_lib() is not called, so include "qelib1.inc" can be missing from OQ2 output that uses u.

One thing I could not settle: what does the shipped mpqp/qasm/header_codes/qelib1.inc define u as? That determines whether the drift here is e^{-i(t+p+l)/2} or e^{-it/2}. Either way the fix has the same shape.

4. gphase is dropped when importing OpenQASM 3

circuit.py::from_other_language:

elif line.startswith("OPENQASM 3.0"):
    qasm2_code = open_qasm_3_to_2(qcircuit)
    qc = qasm2_parse(qasm2_code)
    return qc                                  # returns immediately, gphase lost

versus the OQ2 branch just above, which does the right thing:

qasm2_code, gphase = parse_qasm2_gates(qcircuit)
qc = qasm2_parse(qasm2_code)
qc.input_g_phase += gphase

So open_qasm_3_to_2 faithfully accumulates any explicit gphase(...) into a // gphase: line, and the OQ3 import path then throws it away.

Suggested fix

Keep (B) as the canonical internal semantics, since it is already what every backend adapter uses, and stop emitting the bare token u3.

  1. QASM3 export. Emit U(t,p,g) q[i]; gphase(-t/2);, which is exact and spec-clean. Or keep u3 and add gphase((p+g)/2);.
  2. QASM2 export. Keep u3, but push (p+g)/2 into _generated_g_phase. mpqp_to_qasm2 already returns it and open_qasm_2_to_3 already turns it into a trailing gphase(...). The plumbing exists, U is just not wired into it.
  3. convert_instruction_2_to_3. Stop lowercasing: uppercase U (OQ2 builtin) and lowercase u (Qiskit's qelib1 extension) need different handling. Reword the warning so it does not claim a phase was inserted when the mechanism is delegation.
  4. convert_instruction_3_to_2. Add the compensating term to the gphase accumulator in the std_gates_3_to_2_map branch, and call add_qe_lib() there.
  5. from_other_language. Route the OQ3 branch through parse_qasm2_gates, or otherwise pick the // gphase: value back up, so input_g_phase is set.
  6. Regression test. For random (theta, phi, gamma), assert that QCircuit([U(...)]).to_matrix() equals a spec-conformant evaluation of to_other_language(Language.QASM3) including phase, and repeat with a control qubit so the test can actually fail. The current suite would not catch any of this, because everything either compares up to global phase or stays inside MPQP's own reader.

Reproducer

Save as repro_u_phase.py and run with python repro_u_phase.py. Sections 0 and 2 need only numpy and always run; the rest self-skip and print the expected values if mpqp or cirq are absent. It prints a PASS/FAIL summary and exits non-zero, so it can be dropped into CI as-is.

"""Reproducer for the MPQP `U` / `u3` global phase issue.

Sections 0 and 2 need only numpy and always run.
Sections 1, 3, 4, 5 need mpqp (5 also needs cirq) and self-skip if absent.
Prints a PASS/FAIL summary and exits non-zero, so it can go straight into CI.
"""

import sys
import numpy as np

RESULTS = []
TH, PH, GA = np.pi / 2, np.pi / 2, np.pi / 2  # chosen so the error is exactly a factor i


def check(name, ok, detail=""):
    RESULTS.append((name, bool(ok)))
    print(f"  [{'PASS' if ok else 'FAIL'}] {name}" + (f"  {detail}" if detail else ""))


def sep(t):
    print("\n" + "=" * 74 + "\n" + t + "\n" + "=" * 74)


def M(t, p, l):
    """The 'IBM-flavoured' matrix, used by Qiskit UGate, Braket gates.U and MPQP U."""
    c, s = np.cos(t / 2), np.sin(t / 2)
    return np.array([[c, -np.exp(1j * l) * s],
                     [np.exp(1j * p) * s, np.exp(1j * (p + l)) * c]])


def U_oq2(t, p, l):
    """(A) OpenQASM 2 builtin U == qelib1 u3 == stdgates.inc u3."""
    return np.exp(-1j * (p + l) / 2) * M(t, p, l)


def U_mpqp(t, p, l):
    """(B) MPQP U, Qiskit UGate/U3Gate, Braket gates.U."""
    return M(t, p, l)


def U_oq3(t, p, l):
    """(C) OpenQASM 3.0/3.1 builtin U, transcribed from the spec matrix."""
    e = np.exp(1j * t)
    return 0.5 * np.array([[1 + e, -1j * np.exp(1j * l) * (1 - e)],
                           [1j * np.exp(1j * p) * (1 - e),
                            np.exp(1j * (p + l)) * (1 + e)]])


def section0():
    sep("0. The three conventions agree with the specs")
    t, p, l = np.random.default_rng(0).uniform(-3, 3, 3)
    check("U_oq2 == e^-i(phi+lam)/2 * M",
          np.allclose(U_oq2(t, p, l), np.exp(-1j * (p + l) / 2) * M(t, p, l)))
    check("U_oq3 == e^+i(theta)/2 * M",
          np.allclose(U_oq3(t, p, l), np.exp(1j * t / 2) * M(t, p, l)))
    # stdgates.inc:  gate u3(t,p,l) q { gphase(-(p+l+t)/2); U(t,p,l) q; }
    check("stdgates.inc u3 definition reproduces U_oq2",
          np.allclose(np.exp(-1j * (t + p + l) / 2) * U_oq3(t, p, l), U_oq2(t, p, l)))


def section1():
    sep("1. Export: QCircuit([U(pi/2, pi/2, pi/2, 0)]) -> OpenQASM")
    try:
        from mpqp import QCircuit
        from mpqp.core.languages import Language
        from mpqp.gates import U
    except ImportError:
        print("  [skipped: mpqp not installed] expected QASM3 body: "
              "u3(1.5707963,1.5707963,1.5707963) q[0];")
        return
    circ = QCircuit([U(TH, PH, GA, 0)])
    print("--- QASM2 ---\n" + str(circ.to_other_language(Language.QASM2)))
    print("--- QASM3 ---\n" + str(circ.to_other_language(Language.QASM3)))
    print("  (no OpenQASMTranslationWarning here: the warning in "
          "convert_instruction_2_to_3\n   only fires on the token `u`/`U`, "
          "and MPQP already writes `u3`.)\n")
    internal = np.asarray(circ.to_matrix())   # (B)
    conformant = U_oq2(TH, PH, GA)            # what stdgates.inc says u3 is, (A)
    check("exported QASM3 has the same unitary as QCircuit.to_matrix()",
          np.allclose(internal, conformant),
          f"ratio = {(internal / conformant)[0, 0]:+.4f} (= e^i(phi+gamma)/2)")


def hadamard_test(V):
    """|+>|0>, ctrl-V, H on the control. Returns P(control == 0)."""
    psi = V @ np.array([1.0, 0.0])
    return float(np.linalg.norm((np.array([1.0, 0.0]) + psi) / 2) ** 2)


def section2():
    sep("2. Same text, controlled downstream, three different answers")
    print("      gate my_u q { u3(pi/2, pi/2, pi/2) q; }")
    print("      h c;  ctrl @ my_u c, t;  h c;    // Hadamard test on the control\n")
    for name, f in [("stdgates.inc `u3`   (A)", U_oq2),
                    ("MPQP / Qiskit `u3`  (B)", U_mpqp),
                    ("OQ3 builtin `U`     (C)", U_oq3)]:
        V = f(TH, PH, GA)
        print(f"    {name}   P(control=0) = {hadamard_test(V):.6f}"
              f"   phase vs (B) = {(V / U_mpqp(TH, PH, GA))[0, 0]:+.4f}")
    check("the three readings of the same circuit agree",
          abs(hadamard_test(U_oq2(TH, PH, GA))
              - hadamard_test(U_mpqp(TH, PH, GA))) < 1e-9,
          "0.500 vs 0.854 vs 0.750 -- not a cosmetic phase")


def section3():
    sep("3. Round trip through mpqp.qasm:  U -> u -> u3")
    src = (f'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[1] q;\n'
           f'U({TH},{PH},{GA}) q[0];\n')
    try:
        from mpqp.qasm import open_qasm_2_to_3, open_qasm_3_to_2
    except ImportError:
        print("  [skipped: mpqp not installed] expected: "
              "U(a,b,c) -> u(a,b,c) -> u3(a,b,c)")
    else:
        print("--- input (OQ3) ---\n" + src)
        two = open_qasm_3_to_2(src)
        three = open_qasm_2_to_3(two)
        print("--- open_qasm_3_to_2 ---\n" + two)
        print("--- open_qasm_2_to_3 ---\n" + three)
        check("a gphase correction was emitted somewhere",
              "gphase" in two or "gphase" in three,
              "std_gates_3_to_2_map emits none, although open_qasm_3_to_2 "
              "already has a gphase accumulator")
        check('include "qelib1.inc" is present in the OQ2 output',
              "qelib1.inc" in two,
              "the std_gates_3_to_2_map branch never calls add_qe_lib()")
    check("OQ3->OQ2->OQ3 preserves the unitary including phase",
          np.allclose(U_oq3(TH, PH, GA), U_oq2(TH, PH, GA)),
          f"drift = {(U_oq2(TH, PH, GA) / U_oq3(TH, PH, GA))[0, 0]:+.4f} "
          f"(= e^-i(theta+phi+lambda)/2)")


def section4():
    sep("4. QCircuit.from_other_language drops `gphase` from OpenQASM 3")
    src = ('OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[1] q;\n'
           'gphase(0.7);\nx q[0];\n')
    try:
        from mpqp import QCircuit
        from mpqp.qasm import open_qasm_3_to_2
    except ImportError:
        print("  [skipped: mpqp not installed]")
        return
    print("--- open_qasm_3_to_2 output (note the // gphase: line) ---\n"
          + open_qasm_3_to_2(src))
    qc = QCircuit.from_other_language(src)
    check("input_g_phase survives the OQ3 import",
          abs(qc.input_g_phase - 0.7) < 1e-9,
          f"got {qc.input_g_phase}, expected 0.7")


def section5():
    sep("5. Vanilla cirq and MPQP's cirq disagree on the same QASM2 text")
    qasm2 = (f'OPENQASM 2.0;\ninclude "qelib1.inc";\nqreg q[1];\n'
             f'u3({TH},{PH},{GA}) q[0];\n')
    try:
        from cirq import unitary
        from cirq.contrib.qasm_import import circuit_from_qasm
        from mpqp.qasm.qasm_to_cirq import qasm2_to_cirq_Circuit
    except ImportError:
        print("  [skipped: cirq and/or mpqp not installed]")
        print(f"  expected vanilla cirq [0,0] = {U_oq2(TH, PH, GA)[0, 0]:+.4f}")
        print(f"  expected MPQP    cirq [0,0] = {U_mpqp(TH, PH, GA)[0, 0]:+.4f}")
        return
    a = unitary(circuit_from_qasm(qasm2))
    b = unitary(qasm2_to_cirq_Circuit(qasm2))
    print(f"  vanilla cirq [0,0] = {a[0, 0]:+.4f}")
    print(f"  MPQP    cirq [0,0] = {b[0, 0]:+.4f}")
    check("the two cirq parsers agree on `u3`", np.allclose(a, b),
          "MPQP's MyQasmUGate adds GlobalPhaseGate(exp(i(lam+phi)/2)); "
          "cirq's QasmUGate does not")


if __name__ == "__main__":
    for s in (section0, section1, section2, section3, section4, section5):
        s()
    sep("summary")
    for n, ok in RESULTS:
        print(f"  [{'PASS' if ok else 'FAIL'}] {n}")
    failed = [n for n, ok in RESULTS if not ok]
    print(f"\n  {len(RESULTS) - len(failed)} passed, {len(failed)} failed")
    sys.exit(1 if failed else 0)

Summary line of the output with only numpy available:

  [PASS] U_oq2 == e^-i(phi+lam)/2 * M
  [PASS] U_oq3 == e^+i(theta)/2 * M
  [PASS] stdgates.inc u3 definition reproduces U_oq2
  [FAIL] the three readings of the same circuit agree
  [FAIL] OQ3->OQ2->OQ3 preserves the unitary including phase
  3 passed, 2 failed

With mpqp installed it additionally checks:

  • exported QASM3 has the same unitary as QCircuit.to_matrix()
  • a gphase correction is emitted somewhere in the U -> u -> u3 round trip
  • include "qelib1.inc" is present in the OQ2 output
  • input_g_phase survives the OQ3 import
  • vanilla cirq and mpqp.qasm.qasm_to_cirq agree on the same QASM2 text

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions