Summary
bloqade.native.stdlib.broadcast.phased_xz advertises itself as taking radians, but the user-facing wrapper multiplies every input by an extra 2.0 before handing off to _phased_xz_turns, so the resulting unitary rotates by twice the angles a user would reasonably expect from reading the docstring.
Where
src/bloqade/native/stdlib/broadcast.py, lines 61–80:
@kernel
def phased_xz(
x_rad: float,
z_rad: float,
axis_phase_rad: float,
qubits: ilist.IList[qubit.Qubit, Any],
):
"""Apply a PhasedXZ gate on a group of qubits using radian inputs.
Args:
x_rad (float): X rotation angle in radians.
z_rad (float): Z rotation angle in radians.
axis_phase_rad (float): Axis phase in radians.
qubits (ilist.IList[qubit.Qubit, Any]): Target qubits.
"""
_phased_xz_turns(
2.0 * _radian_to_turn(x_rad), # ← the 2.0 * is the bug
2.0 * _radian_to_turn(z_rad), # ← same
2.0 * _radian_to_turn(axis_phase_rad), # ← same
qubits,
)
Introduced in commit 33fb2dc2 ("Add PhasedXZ Gate to Squin (#722)", 2026-03-11) and never modified since. The _phased_xz_turns internal helper (above it) is consistent with pyqrack's direct interpretation of the stmts.PhasedXZ statement, so the bug is purely in the radian wrapper.
Concrete check
_phased_xz_turns treats its exponent inputs as full-turn fractions (consistent with native.r / native.rz, which multiply by 2π internally). The correct radians-to-turns conversion is therefore
angle_turns = angle_rad / (2π) # i.e. _radian_to_turn(angle_rad)
Multiplying by an extra 2.0 doubles the actual rotation. Working through the current code path for a single-axis case phased_xz(x_rad, 0, 0):
x_rad input |
x_exp passed to _phased_xz_turns |
Actual rotation applied |
What docstring claims |
π/2 |
0.5 turns |
Rx(2π · 0.5) = Rx(π) = X |
Rx(π/2) = √X |
π/4 |
0.25 turns |
Rx(π/2) = √X |
Rx(π/4) |
π |
1.0 turns |
Rx(2π) = I (up to phase) |
Rx(π) = X |
So phased_xz(π/2, 0, 0) is an X gate when it should be √X, and so on.
Proposed fix
Drop the 2.0 * factors:
@kernel
def phased_xz(
x_rad: float,
z_rad: float,
axis_phase_rad: float,
qubits: ilist.IList[qubit.Qubit, Any],
):
_phased_xz_turns(
_radian_to_turn(x_rad),
_radian_to_turn(z_rad),
_radian_to_turn(axis_phase_rad),
qubits,
)
After the fix, phased_xz(π/2, 0, 0) = Rx(π/2) = √X (up to phase), matching the docstring.
Alternatively, if the 2.0 * factor is intentional because the author meant exponents in Cirq's half-turns convention (x_exponent=1 = X gate = π rad), then the docstring is wrong and the argument names should lose the _rad suffix. Either way the current combination — argument named x_rad, documented as radians, but actually half-turns — is a source of silent errors.
Scope check
Related
- Discovered while investigating bloqade-lanes#539 / upstream #754 (unrelated
cx bug, but in the same file).
Summary
bloqade.native.stdlib.broadcast.phased_xzadvertises itself as taking radians, but the user-facing wrapper multiplies every input by an extra2.0before handing off to_phased_xz_turns, so the resulting unitary rotates by twice the angles a user would reasonably expect from reading the docstring.Where
src/bloqade/native/stdlib/broadcast.py, lines 61–80:Introduced in commit
33fb2dc2("Add PhasedXZ Gate to Squin (#722)", 2026-03-11) and never modified since. The_phased_xz_turnsinternal helper (above it) is consistent with pyqrack's direct interpretation of thestmts.PhasedXZstatement, so the bug is purely in the radian wrapper.Concrete check
_phased_xz_turnstreats its exponent inputs as full-turn fractions (consistent withnative.r/native.rz, which multiply by2πinternally). The correct radians-to-turns conversion is thereforeMultiplying by an extra
2.0doubles the actual rotation. Working through the current code path for a single-axis casephased_xz(x_rad, 0, 0):x_radinputx_exppassed to_phased_xz_turnsπ/20.5turnsRx(2π · 0.5) = Rx(π) = XRx(π/2) = √Xπ/40.25turnsRx(π/2) = √XRx(π/4)π1.0turnsRx(2π) = I(up to phase)Rx(π) = XSo
phased_xz(π/2, 0, 0)is an X gate when it should be √X, and so on.Proposed fix
Drop the
2.0 *factors:After the fix,
phased_xz(π/2, 0, 0) = Rx(π/2) = √X(up to phase), matching the docstring.Alternatively, if the
2.0 *factor is intentional because the author meant exponents in Cirq's half-turns convention (x_exponent=1= X gate = π rad), then the docstring is wrong and the argument names should lose the_radsuffix. Either way the current combination — argument namedx_rad, documented as radians, but actually half-turns — is a source of silent errors.Scope check
broadcast.phased_xz— wrong, fix the wrappersimple.phased_xz— delegates; picks up fix automatically_phased_xz_turns— internally consistent withstmts.PhasedXZ's pyqrack interpreter; no change neededphased_xz(π, 0, 0) ~ X,phased_xz(π/2, 0, 0) ~ √X,phased_xz(0, π, 0) ~ Zup to global phaseRelated
cxbug, but in the same file).