Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions src/tsim/circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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")
Comment on lines +285 to +290
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_clifford can raise KeyError on malformed-but-parseable parametric tags (e.g. I[R_Z(phi=0.5*pi)]), because parse_parametric_tag doesn't validate parameter names but this code unconditionally indexes params["theta"]/params["phi"]/params["lambda"]. Since this is a boolean probe, it should return False instead of throwing; consider guarding missing keys (or reusing parametric_to_clifford_gates and treating failures as non-Clifford).

Suggested change
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")
theta = params.get("theta")
if theta is None or not is_half_pi_multiple(theta):
return False
elif gate_name == "U3":
required_params = ("theta", "phi", "lambda")
if not all(
(name in params) and is_half_pi_multiple(params[name])
for name in required_params

Copilot uses AI. Check for mistakes.
):
return False
else:
Comment on lines +261 to +293
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_clifford currently only checks that U3/R_* parameters are half-π multiples (via denominator<=2). To keep this property consistent with stim_circuit’s actual expansion logic (and avoid reporting True while leaving a tagged I[...] unexpanded), consider determining Clifford-ness by calling parametric_to_clifford_gates(gate_name, params) and requiring a non-None result.

Suggested change
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:
A circuit is a Clifford circuit if it only contains Clifford gates.
Returns:
True if the circuit is a Clifford circuit, otherwise False.
"""
for instr in self._stim_circ:
assert not isinstance(instr, stim.CircuitRepeatBlock)
# S / S_DAG tagged as T are treated as non-Clifford.
if instr.name in {"S", "S_DAG"} and instr.tag == "T":
return False
# Parametric single-qubit gates are encoded as tagged identity operations.
if instr.name == "I" and instr.tag:
result = parse_parametric_tag(instr.tag)
if result is None:
# Unrecognized parametric tag; conservatively treat as non-Clifford.
return False
gate_name, params = result
# Delegate Clifford-ness to the same logic used for expansion.
expanded = parametric_to_clifford_gates(gate_name, params)
if expanded is None:

Copilot uses AI. Check for mistakes.
return False

return True

@property
def num_measurements(self) -> int:
Expand Down
97 changes: 97 additions & 0 deletions src/tsim/utils/clifford.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions test/unit/test_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading