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.
- 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);.
- 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.
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.
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.
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.
- 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
Ugate loses global phase across the OpenQASM 2 / 3 boundaryChecked against
main(v0.6.x), the OpenQASM 2 paper, and the OpenQASM 3.1 spec +stdgates.inc.TL;DR
MPQP's
Ugate is stored with the Qiskit/Braket matrix, but written to OpenQASM as the bare tokenu3, 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.Output:
No
gphase, no warning. Butstdgates.incdefinesu3ase^-i(phi+lam)/2times the matrix MPQP means, so a conformant OpenQASM 3 reader evaluates that exact same line to-0.7071jin the top-left corner instead of+0.7071. The ratio ise^i(phi+gamma)/2, here exactlyi.Why there are three matrices called
ULet
e^{-i(p+l)/2} * MU,qelib1.incu3,stdgates.incu3MUGate/U3Gate, Braketgates.U, MPQPUe^{it/2} * MUThe OpenQASM 3 spec is explicit:
and
stdgates.incencodes it directly: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
u3meaning (B) and it also readsu3as (B) — seeMyQasmUGateinqasm_to_cirq.py, which deliberately prependsGlobalPhaseGate(exp(i(lam+phi)/2))to cirq's spec-conformantu3. 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:
u3stdgates.inc(A)U(C)Same angles, same text, three answers. Note that
convert_instruction_2_to_3already 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:circuit.py::to_other_language(Language.QASM3)goes QASM2 thenopen_qasm_2_to_3, so aUgate ends up asu3(...)in a file that also carriesinclude "stdgates.inc", declaring it to be (A). Error:e^{i(phi+gamma)/2}.No warning is raised, because the
OpenQASMTranslationWarninginconvert_instruction_2_to_3sits behindinstr_name.lower() == "u", and MPQP already emitsu3, so that branch is never reached for MPQP-generated code.2.
convert_instruction_2_to_3conflates OQ2Uwith Qiskit-flavoureduU(= A) mapped tou3(= A): correct. The phase is handled by delegation tostdgates.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.ucoming from Qiskit's extendedqelib1.inc, where Qiskit means (B): wrong bye^{i(phi+lam)/2}. The.lower()erases the distinction between the two.3. OQ3 to OQ2 maps
Utouwith no phase accounting and no includeTwo problems:
gphaseis emitted or accumulated. The inputUis (C); the outputuis (A) per spec, or (B) under MPQP's de-facto reading. Either way the difference is non-zero and uncompensated. A fullopen_qasm_3_to_2thenopen_qasm_2_to_3round trip turnsU(t,p,l)intou3(t,p,l), drifting bye^{-i(t+p+l)/2}. Ironicallyopen_qasm_3_to_2already carries agphaseaccumulator that materialises as a// gphase:line; it is simply never fed by this branch.add_qe_lib()is not called, soinclude "qelib1.inc"can be missing from OQ2 output that usesu.One thing I could not settle: what does the shipped
mpqp/qasm/header_codes/qelib1.incdefineuas? That determines whether the drift here ise^{-i(t+p+l)/2}ore^{-it/2}. Either way the fix has the same shape.4.
gphaseis dropped when importing OpenQASM 3circuit.py::from_other_language:versus the OQ2 branch just above, which does the right thing:
So
open_qasm_3_to_2faithfully accumulates any explicitgphase(...)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.U(t,p,g) q[i]; gphase(-t/2);, which is exact and spec-clean. Or keepu3and addgphase((p+g)/2);.u3, but push(p+g)/2into_generated_g_phase.mpqp_to_qasm2already returns it andopen_qasm_2_to_3already turns it into a trailinggphase(...). The plumbing exists,Uis just not wired into it.convert_instruction_2_to_3. Stop lowercasing: uppercaseU(OQ2 builtin) and lowercaseu(Qiskit's qelib1 extension) need different handling. Reword the warning so it does not claim a phase was inserted when the mechanism is delegation.convert_instruction_3_to_2. Add the compensating term to thegphaseaccumulator in thestd_gates_3_to_2_mapbranch, and calladd_qe_lib()there.from_other_language. Route the OQ3 branch throughparse_qasm2_gates, or otherwise pick the// gphase:value back up, soinput_g_phaseis set.(theta, phi, gamma), assert thatQCircuit([U(...)]).to_matrix()equals a spec-conformant evaluation ofto_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.pyand run withpython repro_u_phase.py. Sections 0 and 2 need only numpy and always run; the rest self-skip and print the expected values ifmpqporcirqare absent. It prints a PASS/FAIL summary and exits non-zero, so it can be dropped into CI as-is.Summary line of the output with only numpy available:
With
mpqpinstalled it additionally checks:QCircuit.to_matrix()gphasecorrection is emitted somewhere in theU -> u -> u3round tripinclude "qelib1.inc"is present in the OQ2 outputinput_g_phasesurvives the OQ3 importcirqandmpqp.qasm.qasm_to_cirqagree on the same QASM2 textReferences
Uand the footnote on the phase change from earlier revisions: https://openqasm.com/versions/3.1/language/gates.htmlstdgates.inc: https://github.com/openqasm/openqasm/blob/main/examples/stdgates.inc