From 64f4ee4aa429fdb0cdd0a3715d346216bc76f152 Mon Sep 17 00:00:00 2001 From: ahkatlio Date: Thu, 11 Jun 2026 01:50:26 +0600 Subject: [PATCH 1/2] Implement R_XX, R_ZZ, R_YY, and R_PAULI gates --- src/tsim/circuit.py | 56 ++++ src/tsim/core/instructions.py | 38 +++ src/tsim/core/parse.py | 16 +- src/tsim/utils/clifford.py | 16 +- src/tsim/utils/program_text.py | 73 ++++- test/unit/test_r_pauli_rotations.py | 432 ++++++++++++++++++++++++++++ 6 files changed, 626 insertions(+), 5 deletions(-) create mode 100644 test/unit/test_r_pauli_rotations.py diff --git a/src/tsim/circuit.py b/src/tsim/circuit.py index 09210567..c69579cc 100644 --- a/src/tsim/circuit.py +++ b/src/tsim/circuit.py @@ -173,6 +173,49 @@ def append( tag = f"{name}(theta={args[0]}*pi)" name = "I" arg = None + elif name in ("R_XX", "R_YY", "R_ZZ"): + if arg is None: + raise ValueError(f"For {name} gates, an angle must be provided.") + args = list(arg) if isinstance(arg, Iterable) else [arg] + if len(args) != 1: + raise ValueError( + f"For {name} gates, a single angle must be provided." + ) + t_list: list[int] = ( + [targets] if isinstance(targets, int) else list(targets) # type: ignore[arg-type] + ) + if len(t_list) != 2: + raise ValueError( + f"For {name} gates, exactly two target qubits must be provided." + ) + q0, q1 = ( + t.value if isinstance(t, stim.GateTarget) else t for t in t_list + ) + if q0 == q1: + raise ValueError( + f"Duplicate target qubits in {name}: both targets are qubit {q0}." + ) + pauli = name[2] # 'X', 'Y', or 'Z' + target_fn = { + "X": stim.target_x, + "Y": stim.target_y, + "Z": stim.target_z, + }[pauli] + tag = f"{name}(theta={args[0]}*pi)" + targets = [target_fn(q0), stim.target_combiner(), target_fn(q1)] + name = "SPP" + arg = None + elif name == "R_PAULI": + if arg is None: + raise ValueError("For R_PAULI gates, an angle must be provided.") + args = list(arg) if isinstance(arg, Iterable) else [arg] + if len(args) != 1: + raise ValueError( + "For R_PAULI gates, a single angle must be provided." + ) + tag = f"R_PAULI(theta={args[0]}*pi)" + name = "SPP" + arg = None elif name == "U3": args = list(arg) if isinstance(arg, Iterable) else [] if arg is None or len(args) != 3: @@ -806,6 +849,19 @@ def fix_tags(circuit: stim.Circuit) -> stim.Circuit: result.append("I", targets, args, tag=new_tag) continue + # Parametric Pauli rotations (R_PAULI, R_XX, R_YY, R_ZZ) are stored + # as SPP[tag]. Stim's inverse() correctly flips SPP <-> SPP_DAG and + # reverses target order. Our parse.py interprets SPP_DAG[tag] with + # dagger=True, which is exactly the inverse we want. So we just pass + # these instructions through unchanged. + if name in ("SPP", "SPP_DAG") and tag and tag != "T": + parsed = parse_parametric_tag(instr) + if parsed is not None: + gate_name, params = parsed + if gate_name in ("R_PAULI", "R_XX", "R_YY", "R_ZZ"): + result.append(instr) + continue + result.append(instr) return result diff --git a/src/tsim/core/instructions.py b/src/tsim/core/instructions.py index 0e62d734..acf78590 100644 --- a/src/tsim/core/instructions.py +++ b/src/tsim/core/instructions.py @@ -963,6 +963,44 @@ def _pauli_product_phase( s(b, qubit) +def r_pauli( + b: GraphRepresentation, + paulis: list[tuple[Literal["X", "Y", "Z"], int]], + theta: Fraction, + dagger: bool = False, +) -> None: + """Apply ``exp(-i theta pi/2 P)`` for an arbitrary Pauli product ``P``. + + Generalises the single-qubit rotations :func:`r_x`, :func:`r_y`, and + :func:`r_z` to multi-qubit Pauli strings. ``R_XX``, ``R_YY``, and + ``R_ZZ`` are the two-qubit special cases with ``paulis = [(P, q0), (P, q1)]``. + + This reuses the ladder-of-CNOTs decomposition shared by :func:`spp` and + :func:`tpp`: rotate each qubit into the Z basis, accumulate Z-parity onto + the last qubit, apply the parametric Z rotation, then uncompute. + + Args: + b: The graph representation to modify. + paulis: List of ``(pauli_type, qubit)`` pairs defining the Pauli + product ``P``. Must be non-empty. + theta: Rotation angle in *half-turns* (units of π), so the unitary + is ``exp(-i theta π/2 P)``. For example, ``theta=1/2`` gives the + S-gate for ``P = Z`` (matching ``SPP``). + dagger: If ``True``, negate the rotation angle, applying + ``exp(+i theta π/2 P)`` instead. + + """ + if dagger: + theta = -theta + _pauli_product_phase( + b, + paulis, + lambda b_, q: r_z(b_, q, theta), + lambda b_, q: r_z(b_, q, -theta), + dagger=False, + ) + + def spp( b: GraphRepresentation, paulis: list[tuple[Literal["X", "Y", "Z"], int]], diff --git a/src/tsim/core/parse.py b/src/tsim/core/parse.py index 0e74c712..9a037ef1 100644 --- a/src/tsim/core/parse.py +++ b/src/tsim/core/parse.py @@ -16,6 +16,7 @@ mpad, mpp, observable_include, + r_pauli, r_x, r_y, r_z, @@ -29,6 +30,10 @@ "R_X": frozenset({"theta"}), "R_Y": frozenset({"theta"}), "R_Z": frozenset({"theta"}), + "R_XX": frozenset({"theta"}), + "R_YY": frozenset({"theta"}), + "R_ZZ": frozenset({"theta"}), + "R_PAULI": frozenset({"theta"}), "U3": frozenset({"theta", "phi", "lambda"}), } @@ -38,7 +43,7 @@ def parse_parametric_tag( ) -> tuple[str, dict[str, Fraction]] | None: """Parse the parametric tag on an instruction (e.g. ``I[R_Z(theta=0.3*pi)]``). - Supports gates: R_Z, R_X, R_Y, U3. + Supports gates: R_Z, R_X, R_Y, R_XX, R_YY, R_ZZ, R_PAULI, U3. Args: instruction: The stim instruction whose tag will be parsed. @@ -225,6 +230,15 @@ def parse_stim_circuit( for paulis, invert in _iter_pauli_products(instruction): mpp(b, paulis, invert, p=p) continue + if name in ("SPP", "SPP_DAG") and instruction.tag and instruction.tag != "T": + parsed = parse_parametric_tag(instruction) + if parsed is not None: + gate_name, params = parsed + if gate_name in ("R_PAULI", "R_XX", "R_YY", "R_ZZ"): + is_dag = name == "SPP_DAG" + for paulis, invert in _iter_pauli_products(instruction): + r_pauli(b, paulis, params["theta"], dagger=is_dag ^ invert) + continue if name in ("SPP", "SPP_DAG") and instruction.tag == "T": is_dag = name == "SPP_DAG" for paulis, invert in _iter_pauli_products(instruction): diff --git a/src/tsim/utils/clifford.py b/src/tsim/utils/clifford.py index f27cc06f..5e5daaa9 100644 --- a/src/tsim/utils/clifford.py +++ b/src/tsim/utils/clifford.py @@ -116,7 +116,12 @@ def is_half_pi_multiple(phase: Fraction) -> bool: return False continue - if instr.name in ["S", "S_DAG", "SPP", "SPP_DAG"] and instr.tag == "T": + if instr.name in [ + "S", + "S_DAG", + "SPP", + "SPP_DAG", + ] and instr.tag == "T": return False if instr.name == "I" and instr.tag: @@ -137,6 +142,15 @@ def is_half_pi_multiple(phase: Fraction) -> bool: else: return False + # SPP with parametric theta tag: Clifford iff theta is a half-π multiple + if instr.name in ("SPP", "SPP_DAG") and instr.tag and instr.tag != "T": + result = parse_parametric_tag(instr) + if result is not None: + gate_name, params = result + if gate_name in ("R_PAULI", "R_XX", "R_YY", "R_ZZ"): + if not is_half_pi_multiple(params["theta"]): + return False + return True diff --git a/src/tsim/utils/program_text.py b/src/tsim/utils/program_text.py index ed8f6f41..6b6c25ef 100644 --- a/src/tsim/utils/program_text.py +++ b/src/tsim/utils/program_text.py @@ -5,9 +5,11 @@ # Matches valid numeric literals including scientific notation (e.g. 0.5, 4e-4, 1.2e3) FLOAT_RE = r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?" -_TSIM_GATES = {"R_X", "R_Y", "R_Z", "U3"} +_TSIM_GATES = {"R_X", "R_Y", "R_Z", "R_XX", "R_YY", "R_ZZ", "R_PAULI", "U3"} _GATE_NOT_FOUND_RE = re.compile(r"Gate not found: '(\w+)'") -_GATE_USAGE_RE = re.compile(r"(? ValueError: @@ -30,7 +32,8 @@ def enriched_stim_error(exc: ValueError, converted_text: str) -> ValueError: def shorthand_to_stim(text: str) -> str: """Convert tsim shorthand syntax to valid stim instructions. - Converts: + Converts:: + T 0 1 → S[T] 0 1 T_DAG 0 1 → S_DAG[T] 0 1 TPP X0*Y1 → SPP[T] X0*Y1 @@ -39,6 +42,10 @@ def shorthand_to_stim(text: str) -> str: R_X(0.25) 0 → I[R_X(theta=0.25*pi)] 0 R_Y(-0.5) 0 → I[R_Y(theta=-0.5*pi)] 0 U3(0.3, 0.24, 0.49) 0 → I[U3(theta=0.3*pi, phi=0.24*pi, lambda=0.49*pi)] 0 + R_XX(0.25) 0 1 → SPP[R_XX(theta=0.25*pi)] X0*X1 + R_YY(0.25) 0 1 → SPP[R_YY(theta=0.25*pi)] Y0*Y1 + R_ZZ(0.25) 0 1 → SPP[R_ZZ(theta=0.25*pi)] Z0*Z1 + R_PAULI(0.25) X0*Y1 → SPP[R_PAULI(theta=0.25*pi)] X0*Y1 """ # TPP_DAG/TPP must come before T_DAG/T to avoid partial matches @@ -48,6 +55,35 @@ def shorthand_to_stim(text: str) -> str: text = re.sub(r"(? str: + pauli = m.group(1) # "XX", "YY", or "ZZ" + angle = float(m.group(2)) + q0, q1 = m.group(3), m.group(4) + if q0 == q1: + raise ValueError( + f"Duplicate target qubits in R_{pauli}: both targets are qubit {q0}." + ) + p = pauli[0] + return f"SPP[R_{pauli}(theta={angle}*pi)] {p}{q0}*{p}{q1}" + + text = re.sub( + rf"\bR_(XX|YY|ZZ)\(({FLOAT_RE})\)\s+(\d+)\s+(\d+)", + replace_r_pp, + text, + ) + + def replace_r_pauli(m: re.Match) -> str: + angle = float(m.group(1)) + targets = m.group(2) + return f"SPP[R_PAULI(theta={angle}*pi)] {targets}" + + text = re.sub( + rf"\bR_PAULI\(({FLOAT_RE})\)\s+((?:[XYZ]\d+)(?:\*[XYZ]\d+)*)", + replace_r_pauli, + text, + ) + def replace_rotation(m: re.Match) -> str: axis = m.group(1) return f"I[R_{axis}(theta={float(m.group(2))}*pi)]" @@ -86,6 +122,10 @@ def stim_to_shorthand(text: str) -> str: Rewrites: - I[U3(theta=θ*pi, phi=φ*pi, lambda=λ*pi)] → U3(θ, φ, λ) - I[R_X(theta=α*pi)] / I[R_Y(...)] / I[R_Z(...)] → R_X(α) / R_Y(α) / R_Z(α) + - SPP[R_XX(theta=α*pi)] X0*X1 → R_XX(α) 0 1 + - SPP[R_YY(theta=α*pi)] Y0*Y1 → R_YY(α) 0 1 + - SPP[R_ZZ(theta=α*pi)] Z0*Z1 → R_ZZ(α) 0 1 + - SPP[R_PAULI(theta=α*pi)] X0*Y1*Z2 → R_PAULI(α) X0*Y1*Z2 - SPP[T] → TPP - SPP_DAG[T] → TPP_DAG - S[T] → T @@ -115,6 +155,33 @@ def replace_rotation(m: re.Match) -> str: text, ) + # Replace SPP[R_XX/R_YY/R_ZZ(...)] P0*P1 with R_XX/R_YY/R_ZZ(α) q0 q1 + # Must come before the general R_PAULI rewrite below. + def replace_spp_r_pp(m: re.Match) -> str: + pauli = m.group(1) # "XX", "YY", or "ZZ" + angle = m.group(2) + q0 = m.group(3) + q1 = m.group(4) + return f"R_{pauli}({angle}) {q0} {q1}" + + text = re.sub( + rf"\bSPP\[R_(XX|YY|ZZ)\(theta=({FLOAT_RE})\*pi\)\]\s+[XYZ](\d+)\*[XYZ](\d+)", + replace_spp_r_pp, + text, + ) + + # Replace SPP[R_PAULI(...)] with R_PAULI(α) + def replace_spp_r_pauli(m: re.Match) -> str: + angle = m.group(1) + targets = m.group(2) + return f"R_PAULI({angle}) {targets}" + + text = re.sub( + rf"\bSPP\[R_PAULI\(theta=({FLOAT_RE})\*pi\)\]\s+((?:[XYZ]\d+)(?:\*[XYZ]\d+)*)", + replace_spp_r_pauli, + text, + ) + # Replace SPP[T] and SPP_DAG[T] with TPP and TPP_DAG # Must come before S[T]/S_DAG[T] to avoid partial matches text = re.sub(r"(? bool: + """Return True iff a and b are equal up to a global phase factor.""" + if a.shape != b.shape: + return False + # Find first non-trivially-small entry to get the phase + flat_a = a.flatten() + flat_b = b.flatten() + idx = np.argmax(np.abs(flat_b)) + if abs(flat_b[idx]) < 1e-12: + return bool(np.allclose(a, 0, atol=atol)) + phase = flat_a[idx] / flat_b[idx] + return bool(np.allclose(a, phase * b, atol=atol)) + + +def _analytic_r_xx_matrix(alpha: float) -> np.ndarray: + """Return the 4×4 unitary of R_XX(alpha) = exp(-i alpha pi/2 XX). + + R_XX(α) = cos(α π/2) I⊗I − i sin(α π/2) X⊗X + in the computational basis {|00⟩, |01⟩, |10⟩, |11⟩}. + """ + c = math.cos(alpha * math.pi / 2) + s = math.sin(alpha * math.pi / 2) + return np.array( + [ + [c, 0, 0, -1j * s], + [0, c, -1j * s, 0], + [0, -1j * s, c, 0], + [-1j * s, 0, 0, c], + ], + dtype=complex, + ) + + +def _analytic_r_zz_matrix(alpha: float) -> np.ndarray: + """Return the 4×4 unitary of R_ZZ(alpha) = exp(-i alpha pi/2 ZZ).""" + c = math.cos(alpha * math.pi / 2) + s = math.sin(alpha * math.pi / 2) + return np.diag( + [ + c - 1j * s, + c + 1j * s, + c + 1j * s, + c - 1j * s, + ] + ) + + +# --------------------------------------------------------------------------- +# 1. Shorthand parsing and round-trips +# --------------------------------------------------------------------------- + + +class TestShorthandRoundTrip: + def test_r_xx_shorthand_parses(self): + c = Circuit("R_XX(0.25) 0 1") + assert str(c) == "R_XX(0.25) 0 1" + + def test_r_yy_shorthand_parses(self): + c = Circuit("R_YY(-0.5) 2 3") + assert str(c) == "R_YY(-0.5) 2 3" + + def test_r_zz_shorthand_parses(self): + c = Circuit("R_ZZ(0.3) 1 2") + assert str(c) == "R_ZZ(0.3) 1 2" + + def test_r_pauli_shorthand_parses(self): + c = Circuit("R_PAULI(0.25) X0*Y1*Z2") + assert str(c) == "R_PAULI(0.25) X0*Y1*Z2" + + def test_r_xx_round_trips_through_stim(self): + c1 = Circuit("R_XX(0.25) 0 1") + c2 = Circuit("SPP[R_XX(theta=0.25*pi)] X0*X1") + assert c1._stim_circ == c2._stim_circ + + def test_r_yy_round_trips_through_stim(self): + c1 = Circuit("R_YY(0.3) 0 1") + c2 = Circuit("SPP[R_YY(theta=0.3*pi)] Y0*Y1") + assert c1._stim_circ == c2._stim_circ + + def test_r_zz_round_trips_through_stim(self): + c1 = Circuit("R_ZZ(-0.1) 0 1") + c2 = Circuit("SPP[R_ZZ(theta=-0.1*pi)] Z0*Z1") + assert c1._stim_circ == c2._stim_circ + + def test_r_pauli_round_trips_through_stim(self): + c1 = Circuit("R_PAULI(0.25) X0*Y1") + c2 = Circuit("SPP[R_PAULI(theta=0.25*pi)] X0*Y1") + assert c1._stim_circ == c2._stim_circ + + def test_str_round_trip_r_xx(self): + c = Circuit("R_XX(0.25) 0 1") + assert Circuit(str(c)) == c + + def test_str_round_trip_r_pauli_multi(self): + c = Circuit("R_PAULI(0.3) X0*X1*X2") + assert Circuit(str(c)) == c + + def test_r_pauli_three_qubits_str(self): + c = Circuit("R_PAULI(0.3) X0*X1*X2") + # Must NOT collapse to R_XX shorthand for >2 qubits + assert str(c) == "R_PAULI(0.3) X0*X1*X2" + + def test_negative_angle_round_trip(self): + c = Circuit("R_ZZ(-0.3) 0 1") + assert Circuit(str(c)) == c + + def test_scientific_notation_angle(self): + c = Circuit("R_XX(0.25) 0 1") + c2 = Circuit("SPP[R_XX(theta=2.5e-1*pi)] X0*X1") + assert c == c2 + + +# --------------------------------------------------------------------------- +# 2. Circuit.append API +# --------------------------------------------------------------------------- + + +class TestAppendAPI: + def test_append_r_xx(self): + c = Circuit() + c.append("R_XX", [0, 1], arg=0.25) + assert str(c) == "R_XX(0.25) 0 1" + + def test_append_r_yy(self): + c = Circuit() + c.append("R_YY", [2, 3], arg=-0.5) + assert str(c) == "R_YY(-0.5) 2 3" + + def test_append_r_zz(self): + c = Circuit() + c.append("R_ZZ", [0, 1], arg=[0.3]) + assert str(c) == "R_ZZ(0.3) 0 1" + + def test_append_r_pauli(self): + c = Circuit() + c.append( + "R_PAULI", + [stim.target_x(0), stim.target_combiner(), stim.target_y(1)], + arg=0.25, + ) + assert str(c) == "R_PAULI(0.25) X0*Y1" + + def test_append_r_xx_no_angle_raises(self): + c = Circuit() + with pytest.raises(ValueError, match="angle"): + c.append("R_XX", [0, 1]) + + def test_append_r_xx_duplicate_qubits_raises(self): + c = Circuit() + with pytest.raises(ValueError, match="Duplicate target qubits"): + c.append("R_XX", [3, 3], arg=0.5) + + def test_append_r_xx_wrong_target_count_raises(self): + c = Circuit() + with pytest.raises(ValueError, match="exactly two"): + c.append("R_XX", [0], arg=0.5) + + def test_append_r_pauli_no_angle_raises(self): + c = Circuit() + with pytest.raises(ValueError, match="angle"): + c.append( + "R_PAULI", + [stim.target_x(0), stim.target_combiner(), stim.target_z(1)], + ) + + +# --------------------------------------------------------------------------- +# 3. Clifford-angle parity with stim +# --------------------------------------------------------------------------- + + +class TestCliffordAngleParity: + """At integer alpha, R_PP(alpha) = exp(-i alpha pi/2 PP). + + alpha=0 (mod 4): identity (up to global phase) + alpha=1 (mod 4): PP rotation by π/2 — equivalent to SPP + alpha=2 (mod 4): PP^2 = I applied with a phase, effectively II (up to gphase) + alpha=3 (mod 4): inverse of SPP + """ + + @pytest.mark.parametrize("gate", ["R_XX", "R_YY", "R_ZZ"]) + @pytest.mark.parametrize("alpha", [0, 1, 2, 3]) + def test_clifford_angle_matches_stim(self, gate: str, alpha: int): + pauli = gate[2] # 'X', 'Y', or 'Z' + # For even alpha, result is identity (mod global phase); for odd, it's PP. + if alpha % 2 == 0: + stim_prog = "I 0\nI 1" + else: + stim_prog = f"{pauli} 0\n{pauli} 1" + c = Circuit(f"{gate}({alpha}) 0 1") + ref = np.array( + stim.Circuit(stim_prog).to_tableau().to_unitary_matrix(endian="big") + ) + assert unitaries_equal_up_to_global_phase(c.to_matrix(), ref) + + @pytest.mark.parametrize("alpha", [0.0, 0.5, 1.0, 1.5, 2.0]) + def test_r_xx_is_clifford_for_half_pi_multiples(self, alpha: float): + c = Circuit(f"R_XX({alpha}) 0 1") + assert c.is_clifford + + @pytest.mark.parametrize("alpha", [0.3, 0.1, 0.7, 1.3]) + def test_r_xx_not_clifford_for_arbitrary_angles(self, alpha: float): + c = Circuit(f"R_XX({alpha}) 0 1") + assert not c.is_clifford + + def test_r_pauli_clifford_half_pi(self): + c = Circuit("R_PAULI(0.5) X0*Z1") + assert c.is_clifford + + def test_r_pauli_not_clifford_arbitrary(self): + c = Circuit("R_PAULI(0.3) X0*Z1") + assert not c.is_clifford + + +# --------------------------------------------------------------------------- +# 4. Correctness against analytic matrices +# --------------------------------------------------------------------------- + + +class TestAnalyticCorrectness: + @pytest.mark.parametrize("alpha", [0.0, 0.1, 0.25, 0.5, 0.7, 1.0, 1.3, 1.5]) + def test_r_xx_matches_analytic_formula(self, alpha: float): + c = Circuit(f"R_XX({alpha}) 0 1") + expected = _analytic_r_xx_matrix(alpha) + assert unitaries_equal_up_to_global_phase(c.to_matrix(), expected), ( + f"R_XX({alpha}) unitary mismatch" + ) + + @pytest.mark.parametrize("alpha", [0.0, 0.1, 0.25, 0.5, 0.7, 1.0, 1.3, 1.5]) + def test_r_zz_matches_analytic_formula(self, alpha: float): + c = Circuit(f"R_ZZ({alpha}) 0 1") + expected = _analytic_r_zz_matrix(alpha) + assert unitaries_equal_up_to_global_phase(c.to_matrix(), expected), ( + f"R_ZZ({alpha}) unitary mismatch" + ) + + def test_r_yy_matches_r_xx_conjugated_by_s(self): + """R_YY(α) = (S⊗S) R_XX(α) (S†⊗S†), so unitaries differ by S-conjugation.""" + alpha = 0.3 + c_yy = Circuit(f"R_YY({alpha}) 0 1") + # Validate via: exp(-i α π/2 YY) where Y = S X S† + # So R_YY(α) conjugated by S†⊗S† on both sides should give R_XX(α). + u_yy = c_yy.to_matrix() + # Build S and S† matrices + s_mat = np.array([[1, 0], [0, 1j]], dtype=complex) + s_dag = np.array([[1, 0], [0, -1j]], dtype=complex) + s2 = np.kron(s_mat, s_mat) + s2d = np.kron(s_dag, s_dag) + u_mapped = s2d @ u_yy @ s2 + expected_xx = _analytic_r_xx_matrix(alpha) + assert unitaries_equal_up_to_global_phase(u_mapped, expected_xx) + + def test_r_pauli_single_qubit_reduces_to_r_z(self): + """R_PAULI with a single Z0 should equal R_Z(alpha).""" + alpha = 0.37 + c_rpauli = Circuit(f"R_PAULI({alpha}) Z0") + c_rz = Circuit(f"R_Z({alpha}) 0") + assert unitaries_equal_up_to_global_phase(c_rpauli.to_matrix(), c_rz.to_matrix()) + + def test_r_pauli_single_x_reduces_to_r_x(self): + alpha = 0.42 + c_rpauli = Circuit(f"R_PAULI({alpha}) X0") + c_rx = Circuit(f"R_X({alpha}) 0") + assert unitaries_equal_up_to_global_phase(c_rpauli.to_matrix(), c_rx.to_matrix()) + + +# --------------------------------------------------------------------------- +# 5. Inverse and dagger +# --------------------------------------------------------------------------- + + +class TestInverse: + @pytest.mark.parametrize("alpha", [0.25, -0.3, 0.5, 1.0, 1.37]) + def test_r_xx_inverse_is_identity(self, alpha: float): + c = Circuit(f"R_XX({alpha}) 0 1") + c_inv = c.inverse() + combined = (c + c_inv).to_matrix() + n = combined.shape[0] + phase = combined[0, 0] + assert np.allclose(combined, phase * np.eye(n), atol=1e-8) + + @pytest.mark.parametrize("alpha", [0.25, -0.3, 0.5, 1.37]) + def test_r_yy_inverse_is_identity(self, alpha: float): + c = Circuit(f"R_YY({alpha}) 0 1") + c_inv = c.inverse() + combined = (c + c_inv).to_matrix() + n = combined.shape[0] + phase = combined[0, 0] + assert np.allclose(combined, phase * np.eye(n), atol=1e-8) + + @pytest.mark.parametrize("alpha", [0.25, -0.3, 0.5, 1.37]) + def test_r_zz_inverse_is_identity(self, alpha: float): + c = Circuit(f"R_ZZ({alpha}) 0 1") + c_inv = c.inverse() + combined = (c + c_inv).to_matrix() + n = combined.shape[0] + phase = combined[0, 0] + assert np.allclose(combined, phase * np.eye(n), atol=1e-8) + + @pytest.mark.parametrize("alpha", [0.25, -0.3, 0.5, 0.123]) + def test_r_pauli_inverse_is_identity(self, alpha: float): + c = Circuit(f"R_PAULI({alpha}) X0*Y1*Z2") + c_inv = c.inverse() + combined = (c + c_inv).to_matrix() + n = combined.shape[0] + phase = combined[0, 0] + assert np.allclose(combined, phase * np.eye(n), atol=1e-8) + + def test_inverse_r_xx_negates_rotation(self): + """Inverse of R_XX(alpha) should apply the opposite rotation. + + Stim represents the inverse as SPP_DAG with the same theta tag. + The key thing is that the combined circuit C + C^-1 is identity. + """ + alpha = 0.3 + c = Circuit(f"R_XX({alpha}) 0 1") + c_inv = c.inverse() + # The inverse is stored as SPP_DAG (stim's convention), same theta + assert "SPP_DAG" in str(c_inv._stim_circ) + # And the combination is identity + combined = (c + c_inv).to_matrix() + n = combined.shape[0] + phase = combined[0, 0] + assert np.allclose(combined, phase * np.eye(n), atol=1e-8) + + def test_r_pauli_spp_dag_in_stim_round_trips(self): + """SPP_DAG with R_PAULI tag from stim must also invert correctly.""" + inner = stim.Circuit("SPP_DAG[R_PAULI(theta=0.25*pi)] X0") + c = Circuit.from_stim_program(inner) + c_inv = c.inverse() + combined = (c + c_inv).to_matrix() + n = combined.shape[0] + phase = combined[0, 0] + assert np.allclose(combined, phase * np.eye(n), atol=1e-8) + + def test_r_xx_inverse_str(self): + """str(R_XX(α).inverse()) should be parseable and produce the right gate.""" + c = Circuit("R_XX(0.25) 0 1") + c_inv = c.inverse() + # The inverse must be parseable + c_inv2 = Circuit(str(c_inv)) + assert unitaries_equal_up_to_global_phase(c_inv.to_matrix(), c_inv2.to_matrix()) + + +# --------------------------------------------------------------------------- +# 6. Validation errors +# --------------------------------------------------------------------------- + + +class TestValidationErrors: + def test_duplicate_qubits_in_shorthand_r_xx_raises(self): + with pytest.raises(ValueError, match="Duplicate"): + Circuit("R_XX(0.5) 3 3") + + def test_duplicate_qubits_in_shorthand_r_yy_raises(self): + with pytest.raises(ValueError, match="Duplicate"): + Circuit("R_YY(0.5) 0 0") + + def test_duplicate_qubits_in_shorthand_r_zz_raises(self): + with pytest.raises(ValueError, match="Duplicate"): + Circuit("R_ZZ(0.5) 5 5") + + +# --------------------------------------------------------------------------- +# 7. Mixed circuits and composition +# --------------------------------------------------------------------------- + + +class TestMixedCircuits: + def test_r_xx_composed_with_cnot(self): + """Composition with a CNOT should produce a valid matrix.""" + c = Circuit("R_XX(0.25) 0 1\nCNOT 0 1") + mat = c.to_matrix() + assert mat.shape == (4, 4) + assert np.allclose(mat @ mat.conj().T, np.eye(4), atol=1e-8) + + def test_r_pauli_three_qubit_circuit(self): + """R_PAULI on three qubits should produce an 8×8 unitary.""" + c = Circuit("R_PAULI(0.3) X0*Y1*Z2") + mat = c.to_matrix() + assert mat.shape == (8, 8) + assert np.allclose(mat @ mat.conj().T, np.eye(8), atol=1e-8) + + def test_r_xx_twice_same_angle_opposite_sign(self): + """R_XX(α) followed by R_XX(-α) should be identity.""" + alpha = 0.37 + c = Circuit(f"R_XX({alpha}) 0 1\nR_XX(-{alpha}) 0 1") + mat = c.to_matrix() + n = mat.shape[0] + phase = mat[0, 0] + assert np.allclose(mat, phase * np.eye(n), atol=1e-8) + + def test_r_pauli_in_repeat_block(self): + """R_PAULI inside a REPEAT block must survive the full pipeline.""" + c = Circuit("REPEAT 3 {\n R_ZZ(0.1) 0 1\n}") + mat = c.to_matrix() + # Three repetitions of R_ZZ(0.1) should equal R_ZZ(0.3) + expected = Circuit("R_ZZ(0.3) 0 1").to_matrix() + assert unitaries_equal_up_to_global_phase(mat, expected) + + def test_r_xx_r_zz_mixed(self): + """R_XX followed by R_ZZ: result is unitary.""" + c = Circuit("R_XX(0.25) 0 1\nR_ZZ(0.13) 0 1") + mat = c.to_matrix() + assert np.allclose(mat @ mat.conj().T, np.eye(4), atol=1e-8) From 3461bdca8077f060545d0678c7e32e5f91db981d Mon Sep 17 00:00:00 2001 From: ahkatlio Date: Thu, 11 Jun 2026 10:03:56 +0600 Subject: [PATCH 2/2] ruff test --- src/tsim/utils/clifford.py | 5 ++--- test/unit/test_r_pauli_rotations.py | 5 +---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/tsim/utils/clifford.py b/src/tsim/utils/clifford.py index 5e5daaa9..2aa5a015 100644 --- a/src/tsim/utils/clifford.py +++ b/src/tsim/utils/clifford.py @@ -147,9 +147,8 @@ def is_half_pi_multiple(phase: Fraction) -> bool: result = parse_parametric_tag(instr) if result is not None: gate_name, params = result - if gate_name in ("R_PAULI", "R_XX", "R_YY", "R_ZZ"): - if not is_half_pi_multiple(params["theta"]): - return False + if gate_name in ("R_PAULI", "R_XX", "R_YY", "R_ZZ") and not is_half_pi_multiple(params["theta"]): + return False return True diff --git a/test/unit/test_r_pauli_rotations.py b/test/unit/test_r_pauli_rotations.py index a33dc00b..c22c7b58 100644 --- a/test/unit/test_r_pauli_rotations.py +++ b/test/unit/test_r_pauli_rotations.py @@ -210,10 +210,7 @@ class TestCliffordAngleParity: def test_clifford_angle_matches_stim(self, gate: str, alpha: int): pauli = gate[2] # 'X', 'Y', or 'Z' # For even alpha, result is identity (mod global phase); for odd, it's PP. - if alpha % 2 == 0: - stim_prog = "I 0\nI 1" - else: - stim_prog = f"{pauli} 0\n{pauli} 1" + stim_prog = "I 0\nI 1" if alpha % 2 == 0 else f"{pauli} 0\n{pauli} 1" c = Circuit(f"{gate}({alpha}) 0 1") ref = np.array( stim.Circuit(stim_prog).to_tableau().to_unitary_matrix(endian="big")