diff --git a/src/tsim/circuit.py b/src/tsim/circuit.py index 2ebaa29..12b7c32 100644 --- a/src/tsim/circuit.py +++ b/src/tsim/circuit.py @@ -2,6 +2,7 @@ from __future__ import annotations +from fractions import Fraction from typing import Any, Iterable, Literal, cast, overload import pyzx_param as zx @@ -11,6 +12,7 @@ from tsim.core.graph import build_sampling_graph from tsim.core.parse import parse_parametric_tag, parse_stim_circuit from tsim.noise.dem import get_detector_error_model +from tsim.utils.clifford import parametric_to_clifford_gates from tsim.utils.diagram import render_svg from tsim.utils.program_text import shorthand_to_stim, stim_to_shorthand @@ -229,8 +231,69 @@ def compile_m2d_converter( @property def stim_circuit(self) -> stim.Circuit: - """Return the underlying stim circuit.""" - return self._stim_circ.copy() + """Return the underlying stim circuit. + + Parametric rotation instructions whose angles are all half-π multiples + are expanded into their equivalent Clifford gates. + """ + circ = stim.Circuit() + for instr in self._stim_circ: + assert not isinstance(instr, stim.CircuitRepeatBlock) + + if instr.name == "I" and instr.tag: + result = parse_parametric_tag(instr.tag) + if result is not None: + gate_name, params = result + clifford_gates = parametric_to_clifford_gates(gate_name, params) + if clifford_gates is not None: + targets = [t.value for t in instr.targets_copy()] + for gate in clifford_gates: + circ.append(gate, targets, []) + continue + + circ.append(instr) + return circ + + @property + def is_clifford(self) -> bool: + """Check if the circuit is a Clifford circuit. + + A circuit is a Clifford circuit if it only contains Clifford gates (i.e. half-pi + multiples of the rotation angles). + + Returns: + True if the circuit is a Clifford circuit, otherwise False. + + """ + + def is_half_pi_multiple(phase: Fraction) -> bool: + return phase.denominator <= 2 + + for instr in self._stim_circ: + assert not isinstance(instr, stim.CircuitRepeatBlock) + + if instr.name in {"S", "S_DAG"} and instr.tag == "T": + return False + + if instr.name == "I" and instr.tag: + result = parse_parametric_tag(instr.tag) + if result is None: + return False + + gate_name, params = result + if gate_name in {"R_X", "R_Y", "R_Z"}: + if not is_half_pi_multiple(params["theta"]): + return False + elif gate_name == "U3": + if not all( + is_half_pi_multiple(params[name]) + for name in ("theta", "phi", "lambda") + ): + return False + else: + return False + + return True @property def num_measurements(self) -> int: diff --git a/src/tsim/utils/clifford.py b/src/tsim/utils/clifford.py new file mode 100644 index 0000000..851432d --- /dev/null +++ b/src/tsim/utils/clifford.py @@ -0,0 +1,97 @@ +"""Mapping tables for converting parametric rotations with half-pi angles to Clifford gates.""" + +from __future__ import annotations + +from fractions import Fraction + +# Clifford decompositions for U3(θ, φ, λ) = R_Z(φ) · R_Y(θ) · R_Z(λ). +# Keys: (θ_idx, φ_idx, λ_idx) where each index ∈ {0,1,2,3} is the angle in half-pi units. +# Values: stim gate names in circuit (time) order. +U3_CLIFFORD: dict[tuple[int, int, int], list[str]] = { + (0, 0, 0): ["I"], + (0, 0, 1): ["S"], + (0, 0, 2): ["Z"], + (0, 0, 3): ["S_DAG"], + (0, 1, 0): ["S"], + (0, 1, 1): ["Z"], + (0, 1, 2): ["S_DAG"], + (0, 1, 3): ["I"], + (1, 0, 0): ["SQRT_Y"], + (1, 0, 1): ["S", "SQRT_Y"], + (1, 0, 2): ["H"], + (1, 0, 3): ["S_DAG", "SQRT_Y"], + (1, 1, 0): ["S", "SQRT_X_DAG"], + (1, 1, 1): ["Z", "SQRT_X_DAG"], + (1, 1, 2): ["S_DAG", "SQRT_X_DAG"], + (1, 1, 3): ["SQRT_X_DAG"], + (1, 2, 0): ["Z", "SQRT_Y_DAG"], + (1, 2, 1): ["S_DAG", "SQRT_Y_DAG"], + (1, 2, 2): ["SQRT_Y_DAG"], + (1, 2, 3): ["S", "SQRT_Y_DAG"], + (1, 3, 0): ["S_DAG", "SQRT_X"], + (1, 3, 1): ["SQRT_X"], + (1, 3, 2): ["S", "SQRT_X"], + (1, 3, 3): ["Z", "SQRT_X"], + (2, 0, 0): ["Y"], + (2, 0, 1): ["S", "Y"], + (2, 0, 2): ["X"], + (2, 0, 3): ["S_DAG", "Y"], + (2, 1, 0): ["Y", "S"], + (2, 1, 1): ["Y"], + (2, 1, 2): ["S", "Y"], + (2, 1, 3): ["X"], +} + +RZ_CLIFFORD: dict[int, str] = {0: "I", 1: "S", 2: "Z", 3: "S_DAG"} +RX_CLIFFORD: dict[int, str] = {0: "I", 1: "SQRT_X", 2: "X", 3: "SQRT_X_DAG"} +RY_CLIFFORD: dict[int, str] = {0: "I", 1: "SQRT_Y", 2: "Y", 3: "SQRT_Y_DAG"} + + +def _to_half_pi_index(phase: Fraction) -> int | None: + """Convert a phase (in units of π) to a half-π index 0–3, or *None*.""" + if phase.denominator > 2: + return None + return int(phase * 2) % 4 + + +def _equivalent_u3_key(t: int, p: int, l: int) -> tuple[int, int, int]: + """U3(θ, φ, λ) ≡ U3(2π-θ, φ+π, λ+π) up to global phase.""" + return ((4 - t) % 4, (p + 2) % 4, (l + 2) % 4) + + +def parametric_to_clifford_gates( + gate_name: str, params: dict[str, Fraction] +) -> list[str] | None: + """Convert a parametric gate with half-π angles to stim Clifford gate names. + + Args: + gate_name: One of ``"R_X"``, ``"R_Y"``, ``"R_Z"``, ``"U3"``. + params: Dict as returned by :func:`~tsim.core.parse.parse_parametric_tag`. + + Returns: + Stim gate names in circuit order, + or ``None`` when the angles are not half-π multiples. + + """ + if gate_name in ("R_X", "R_Y", "R_Z"): + idx = _to_half_pi_index(params["theta"]) + if idx is None: + return None + table = {"R_Z": RZ_CLIFFORD, "R_X": RX_CLIFFORD, "R_Y": RY_CLIFFORD}[gate_name] + return [table[idx]] + + if gate_name == "U3": + theta_idx = _to_half_pi_index(params["theta"]) + phi_idx = _to_half_pi_index(params["phi"]) + lam_idx = _to_half_pi_index(params["lambda"]) + if theta_idx is None or phi_idx is None or lam_idx is None: + return None + + key = (theta_idx, phi_idx, lam_idx) + gates = U3_CLIFFORD.get(key) + if gates is None: + gates = U3_CLIFFORD.get(_equivalent_u3_key(*key)) + assert gates is not None + return list(gates) + + return None diff --git a/test/unit/test_circuit.py b/test/unit/test_circuit.py index 87acfd0..a131864 100644 --- a/test/unit/test_circuit.py +++ b/test/unit/test_circuit.py @@ -477,6 +477,31 @@ def test_tcount_with_t_gates(): assert c.tcount() == 3 +def test_is_clifford_with_stim_gates(): + c = Circuit("H 0\nCNOT 0 1\nM 0 1\nDETECTOR rec[-1]") + assert c.is_clifford + + +def test_is_clifford_with_half_pi_parametric_gates(): + c = Circuit("R_Z(0.5) 0\nR_X(-1.5) 0\nU3(0.5, -1.0, 1.5) 0") + assert c.is_clifford + + +def test_is_clifford_rejects_t_gate(): + c = Circuit("T 0") + assert not c.is_clifford + + +def test_is_clifford_rejects_non_clifford_rotation(): + c = Circuit("H 0\nR_Z(0.25) 0\nCNOT 0 1") + assert not c.is_clifford + + +def test_is_clifford_rejects_non_clifford_u3(): + c = Circuit("U3(0.5, 0.25, 1.0) 0") + assert not c.is_clifford + + def test_get_graph(): """Test get_graph returns a ZX graph.""" c = Circuit("H 0\nCNOT 0 1") diff --git a/test/unit/utils/test_clifford.py b/test/unit/utils/test_clifford.py new file mode 100644 index 0000000..014cb7d --- /dev/null +++ b/test/unit/utils/test_clifford.py @@ -0,0 +1,186 @@ +"""Comprehensive tests for parametric-to-Clifford gate conversion.""" + +from fractions import Fraction + +import numpy as np +import pytest +import stim + +from tsim.circuit import Circuit +from tsim.utils.clifford import ( + _to_half_pi_index, + parametric_to_clifford_gates, +) + + +def _unitaries_equal_up_to_global_phase(u1: np.ndarray, u2: np.ndarray) -> bool: + product = u1 @ u2.conj().T + phase = product[0, 0] + if abs(phase) < 1e-10: + return False + return np.allclose(product, phase * np.eye(u1.shape[0]), atol=1e-10) + + +def _rotation_matrix(axis: str, theta_pi: float) -> np.ndarray: + """R_axis(θ) unitary with θ given in units of π.""" + t = theta_pi * np.pi + if axis == "Z": + return np.array([[np.exp(-1j * t / 2), 0], [0, np.exp(1j * t / 2)]]) + if axis == "X": + return np.array( + [ + [np.cos(t / 2), -1j * np.sin(t / 2)], + [-1j * np.sin(t / 2), np.cos(t / 2)], + ] + ) + if axis == "Y": + return np.array( + [ + [np.cos(t / 2), -np.sin(t / 2)], + [np.sin(t / 2), np.cos(t / 2)], + ] + ) + raise ValueError(axis) + + +def _u3_matrix(theta_pi: float, phi_pi: float, lam_pi: float) -> np.ndarray: + """U3(θ, φ, λ) = R_Z(φ) · R_Y(θ) · R_Z(λ), angles in units of π.""" + return ( + _rotation_matrix("Z", phi_pi) + @ _rotation_matrix("Y", theta_pi) + @ _rotation_matrix("Z", lam_pi) + ) + + +def _clifford_matrix(gate_names: list[str]) -> np.ndarray: + """Build unitary from stim Clifford gate names.""" + circ = stim.Circuit() + circ.append("I", [0]) # type: ignore + for g in gate_names: + circ.append(g, [0]) # type: ignore + return circ.to_tableau().to_unitary_matrix(endian="big") # type: ignore + + +class TestToHalfPiIndex: + @pytest.mark.parametrize( + "phase,expected", + [ + (Fraction(0), 0), + (Fraction(1, 2), 1), + (Fraction(1), 2), + (Fraction(3, 2), 3), + (Fraction(-1, 2), 3), + (Fraction(-1), 2), + (Fraction(-3, 2), 1), + (Fraction(2), 0), + (Fraction(5, 2), 1), + (Fraction(-2), 0), + ], + ) + def test_valid(self, phase, expected): + assert _to_half_pi_index(phase) == expected + + @pytest.mark.parametrize( + "phase", + [Fraction(1, 3), Fraction(1, 4), Fraction(3, 4), Fraction(1, 7)], + ) + def test_non_half_pi_returns_none(self, phase): + assert _to_half_pi_index(phase) is None + + +class TestSingleAxisConversions: + def test_non_clifford_returns_none(self): + assert parametric_to_clifford_gates("R_Z", {"theta": Fraction(1, 4)}) is None + + def test_unknown_gate_returns_none(self): + assert parametric_to_clifford_gates("UNKNOWN", {"theta": Fraction(0)}) is None + + +class TestSingleAxisUnitaries: + @pytest.mark.parametrize( + "axis,half_pi_idx", + [(a, i) for a in ("X", "Y", "Z") for i in range(4)], + ) + def test_unitary_matches(self, axis, half_pi_idx): + phase = Fraction(half_pi_idx, 2) + original = _rotation_matrix(axis, float(phase)) + clifford_gates = parametric_to_clifford_gates(f"R_{axis}", {"theta": phase}) + assert clifford_gates is not None + assert _unitaries_equal_up_to_global_phase( + original, _clifford_matrix(clifford_gates) + ) + + +class TestU3Conversions: + @pytest.mark.parametrize( + "theta_idx,phi_idx,lam_idx", + [(t, p, l) for t in range(4) for p in range(4) for l in range(4)], + ) + def test_unitary_matches_all_half_pi(self, theta_idx, phi_idx, lam_idx): + theta = Fraction(theta_idx, 2) + phi = Fraction(phi_idx, 2) + lam = Fraction(lam_idx, 2) + + params = {"theta": theta, "phi": phi, "lambda": lam} + clifford_gates = parametric_to_clifford_gates("U3", params) + assert ( + clifford_gates is not None + ), f"U3({theta}, {phi}, {lam}) should be convertible" + + original = _u3_matrix(float(theta), float(phi), float(lam)) + assert _unitaries_equal_up_to_global_phase( + original, _clifford_matrix(clifford_gates) + ), f"U3({theta_idx},{phi_idx},{lam_idx}) → {clifford_gates} mismatch" + + def test_non_clifford_returns_none(self): + params = {"theta": Fraction(1, 4), "phi": Fraction(0), "lambda": Fraction(0)} + assert parametric_to_clifford_gates("U3", params) is None + + def test_partially_non_clifford_returns_none(self): + params = {"theta": Fraction(1, 2), "phi": Fraction(1, 3), "lambda": Fraction(0)} + assert parametric_to_clifford_gates("U3", params) is None + + +class TestStimCircuitProperty: + def test_clifford_rotation_expanded(self): + c = Circuit("R_Z(0.5) 0") + stim_str = str(c.stim_circuit) + assert "I[" not in stim_str + assert "S 0" in stim_str + + def test_non_clifford_rotation_preserved(self): + c = Circuit("R_Z(0.25) 0") + stim_str = str(c.stim_circuit) + assert "I[R_Z" in stim_str + + def test_t_gate_preserved(self): + c = Circuit("T 0") + stim_str = str(c.stim_circuit) + assert "[T]" in stim_str + + def test_u3_clifford_expanded(self): + c = Circuit("U3(0.5, 0.0, 1.0) 0") + stim_str = str(c.stim_circuit) + assert "I[" not in stim_str + assert "H 0" in stim_str + + def test_identity_rotation_becomes_I(self): + c = Circuit("R_Z(0.0) 0\nH 0") + stim_str = str(c.stim_circuit) + assert "I[" not in stim_str + assert "I 0" in stim_str + + def test_pure_clifford_circuit_unchanged(self): + c = Circuit("H 0\nCNOT 0 1\nS 0") + assert c.stim_circuit == c._stim_circ + + def test_mixed_circuit_unitary(self): + c = Circuit("H 0\nR_Z(0.5) 0\nCNOT 0 1") + expected = Circuit("H 0\nS 0\nCNOT 0 1") + assert _unitaries_equal_up_to_global_phase(c.to_matrix(), expected.to_matrix()) + + def test_multi_target_rotation(self): + c = Circuit("R_Z(1.0) 0 1 2") + stim_str = str(c.stim_circuit) + assert "I[" not in stim_str + assert "Z 0 1 2" in stim_str