Skip to content

phased_xz(x_rad, z_rad, axis_phase_rad) applies rotations 2× the declared radians #756

@yzcj105

Description

@yzcj105

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 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

  • broadcast.phased_xz — wrong, fix the wrapper
  • simple.phased_xz — delegates; picks up fix automatically
  • _phased_xz_turns — internally consistent with stmts.PhasedXZ's pyqrack interpreter; no change needed
  • Worth adding a regression test: phased_xz(π, 0, 0) ~ X, phased_xz(π/2, 0, 0) ~ √X, phased_xz(0, π, 0) ~ Z up to global phase

Related

  • Discovered while investigating bloqade-lanes#539 / upstream #754 (unrelated cx bug, but in the same file).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: stdlibArea: standard library related issues.category: bugCategory: this is a bug or something isn't working as expected.priority: highPriority: high priority issues and tasks, blocking milestones, time sensitive.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions