Skip to content

Commit 4c86224

Browse files
authored
REPEAT block handling in is_clifford (#128)
## Summary - `Circuit.is_clifford` and the half-π parametric-rotation expansion now recurse into `REPEAT` blocks. Non-Clifford gates inside loops were previously misclassified, and half-π rotations inside loops were not expanded. - Internal cleanup: rename `_is_clifford` → `is_clifford`, `_expand_clifford` → `expand_clifford_rotations`, `clifford_expansion` → `_try_clifford_expansion` to align underscore prefixes with intended visibility. Unrelated change: - Constant phase values are reduced modulo 8 during compilation to keep phase representations consistent.
1 parent 6425284 commit 4c86224

9 files changed

Lines changed: 301 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Added
1717
- `TPP` and `TPP_DAG` instructions — applies exp(-i pi/8 P) or exp(+i pi/8 P) (up to global phase) for a Pauli product P, i.e., phases the -1 eigenspace of P by exp(i pi/4) or exp(-i pi/4).
18+
- `Circuit.is_clifford` now supports `REPEAT` blocks.
1819

1920
## [0.1.3] - 2026-04-13
2021

src/tsim/circuit.py

Lines changed: 6 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
from fractions import Fraction
65
from typing import (
76
TYPE_CHECKING,
87
Any,
@@ -23,7 +22,7 @@
2322
from tsim.core.graph import build_sampling_graph
2423
from tsim.core.parse import parse_parametric_tag, parse_stim_circuit
2524
from tsim.noise.dem import get_detector_error_model
26-
from tsim.utils.clifford import clifford_expansion
25+
from tsim.utils.clifford import expand_clifford_rotations, is_clifford
2726
from tsim.utils.diagram import render_pyzx_d3, render_svg
2827
from tsim.utils.program_text import (
2928
enriched_stim_error,
@@ -359,20 +358,10 @@ def stim_circuit(self) -> stim.Circuit:
359358
"""Return the underlying stim circuit.
360359
361360
Parametric rotation instructions whose angles are all half-π multiples
362-
are expanded into their equivalent Clifford gates.
361+
are expanded into their equivalent Clifford gates. ``REPEAT`` blocks are
362+
preserved structurally; their bodies are expanded recursively.
363363
"""
364-
circ = stim.Circuit()
365-
for instr in self._stim_circ:
366-
assert not isinstance(instr, stim.CircuitRepeatBlock)
367-
368-
expansion = clifford_expansion(instr)
369-
if expansion is not None:
370-
gates, targets = expansion
371-
for gate in gates:
372-
circ.append(gate, targets, [])
373-
else:
374-
circ.append(instr)
375-
return circ
364+
return expand_clifford_rotations(self._stim_circ)
376365

377366
@property
378367
def is_clifford(self) -> bool:
@@ -385,35 +374,7 @@ def is_clifford(self) -> bool:
385374
True if the circuit is a Clifford circuit, otherwise False.
386375
387376
"""
388-
389-
def is_half_pi_multiple(phase: Fraction) -> bool:
390-
return phase.denominator <= 2
391-
392-
for instr in self._stim_circ:
393-
assert not isinstance(instr, stim.CircuitRepeatBlock)
394-
395-
if instr.name in ["S", "S_DAG", "SPP", "SPP_DAG"] and instr.tag == "T":
396-
return False
397-
398-
if instr.name == "I" and instr.tag:
399-
result = parse_parametric_tag(instr.tag)
400-
if result is None:
401-
return False
402-
403-
gate_name, params = result
404-
if gate_name in ["R_X", "R_Y", "R_Z"]:
405-
if not is_half_pi_multiple(params["theta"]):
406-
return False
407-
elif gate_name == "U3":
408-
if not all(
409-
is_half_pi_multiple(params[name])
410-
for name in ("theta", "phi", "lambda")
411-
):
412-
return False
413-
else:
414-
return False
415-
416-
return True
377+
return is_clifford(self._stim_circ)
417378

418379
@property
419380
def num_measurements(self) -> int:
@@ -823,7 +784,7 @@ def fix_tags(circuit: stim.Circuit) -> stim.Circuit:
823784
args = instr.gate_args_copy()
824785

825786
if name == "I" and tag:
826-
parsed = parse_parametric_tag(tag)
787+
parsed = parse_parametric_tag(instr)
827788
if parsed is not None:
828789
gate_name, params = parsed
829790
if gate_name == "U3":

src/tsim/compile/compile.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def _compile_node_phases(
7575

7676
for i, terms in enumerate(terms_per_graph):
7777
for j, (const_phase, param_bit) in enumerate(terms):
78-
phases[i, j] = const_phase
78+
phases[i, j] = const_phase % 8
7979
params[i, j] = param_bit
8080

8181
return NodePhases(
@@ -254,8 +254,8 @@ def _compile_phase_pairs(
254254

255255
for i, terms in enumerate(terms_per_graph):
256256
for j, (ca, cb, pa, pb) in enumerate(terms):
257-
alpha[i, j] = ca
258-
beta[i, j] = cb
257+
alpha[i, j] = ca % 8
258+
beta[i, j] = cb % 8
259259
alpha_params_arr[i, j] = pa
260260
beta_params_arr[i, j] = pb
261261

@@ -303,7 +303,7 @@ def _compile_prefactor(g_list: list[BaseGraph]) -> ScalarPrefactor:
303303
)
304304

305305
phase_indices = jnp.array(
306-
[int(float(g.scalar.phase) * 4) for g in g_list], dtype=jnp.uint8
306+
[int(float(g.scalar.phase) * 4) % 8 for g in g_list], dtype=jnp.uint8
307307
)
308308

309309
exact_floatfactor = []

src/tsim/core/parse.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,38 @@
2525
u3,
2626
)
2727

28+
_PARAMETRIC_GATE_PARAMS: dict[str, frozenset[str]] = {
29+
"R_X": frozenset({"theta"}),
30+
"R_Y": frozenset({"theta"}),
31+
"R_Z": frozenset({"theta"}),
32+
"U3": frozenset({"theta", "phi", "lambda"}),
33+
}
34+
2835

29-
def parse_parametric_tag(tag: str) -> tuple[str, dict[str, Fraction]] | None:
30-
"""Parse a parametric gate tag like R_Z(theta=0.3*pi).
36+
def parse_parametric_tag(
37+
instruction: stim.CircuitInstruction,
38+
) -> tuple[str, dict[str, Fraction]] | None:
39+
"""Parse the parametric tag on an instruction (e.g. ``I[R_Z(theta=0.3*pi)]``).
3140
3241
Supports gates: R_Z, R_X, R_Y, U3.
3342
3443
Args:
35-
tag: The instruction tag to parse, e.g. "R_Z(theta=0.3*pi)" or
36-
"U3(theta=0.3*pi, phi=0.24*pi, lambda=0.49*pi)".
44+
instruction: The stim instruction whose tag will be parsed.
3745
3846
Returns:
39-
Tuple of (gate_name, params_dict) or None if not a valid parametric tag.
47+
Tuple of (gate_name, params_dict) when the instruction's tag is a
48+
well-formed parametric tag, or ``None`` when the tag is not
49+
parametric-looking (no ``name(...)`` shape, or empty).
50+
51+
Raises:
52+
ValueError: When the tag looks parametric (matches ``name(...)``) but is
53+
malformed: a parameter value does not parse, the gate name is unknown,
54+
or the parameter keys do not match the expected set for the gate.
4055
4156
"""
57+
tag = instruction.tag
58+
err_prefix = f"Could not parse instruction {str(instruction)!r}"
59+
4260
match = re.match(r"^(\w+)\((.*)\)$", tag)
4361
if not match:
4462
return None
@@ -56,11 +74,20 @@ def parse_parametric_tag(tag: str) -> tuple[str, dict[str, Fraction]] | None:
5674
r"^(\w+)=([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\*pi$", param
5775
)
5876
if not param_match:
59-
return None
77+
raise ValueError(f"{err_prefix}. Malformed parametric tag {tag!r}")
6078
param_name = param_match.group(1)
6179
value = Fraction(param_match.group(2))
6280
params[param_name] = value
6381

82+
expected = _PARAMETRIC_GATE_PARAMS.get(gate_name)
83+
if expected is None:
84+
raise ValueError(f"{err_prefix}. Unknown parametric gate {gate_name!r}")
85+
if params.keys() != expected:
86+
raise ValueError(
87+
f"{err_prefix}. Parametric tag {tag!r} has parameters "
88+
f"{sorted(params)}, expected {sorted(expected)}"
89+
)
90+
6491
return gate_name, params
6592

6693

@@ -166,7 +193,7 @@ def parse_stim_circuit(
166193

167194
# Handle parametric gates via tags (e.g., I with tag "R_Z(theta=0.3*pi)")
168195
if name == "I" and instruction.tag:
169-
result = parse_parametric_tag(instruction.tag)
196+
result = parse_parametric_tag(instruction)
170197
if result is not None:
171198
gate_name, params = result
172199
targets = [t.value for t in instruction.targets_copy()]

src/tsim/external/vec_sim/vec_sampler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def sample_circuit_with_vec_sim_return_data(
8181
for q in inst.targets_copy():
8282
sim.do_t_dag(q.qubit_value)
8383
elif inst.name == "I" and inst.tag:
84-
result = parse_parametric_tag(inst.tag)
84+
result = parse_parametric_tag(inst)
8585
if result is not None:
8686
gate_name, params = result
8787
for q in inst.targets_copy():

src/tsim/utils/clifford.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,70 @@ def parametric_to_clifford_gates(
101101
return None
102102

103103

104-
def clifford_expansion(
104+
def is_clifford(source: stim.Circuit) -> bool:
105+
"""Return True iff every instruction in ``source`` is Clifford.
106+
107+
Recurses into ``REPEAT`` block bodies.
108+
"""
109+
110+
def is_half_pi_multiple(phase: Fraction) -> bool:
111+
return phase.denominator <= 2
112+
113+
for instr in source:
114+
if isinstance(instr, stim.CircuitRepeatBlock):
115+
if not is_clifford(instr.body_copy()):
116+
return False
117+
continue
118+
119+
if instr.name in ["S", "S_DAG", "SPP", "SPP_DAG"] and instr.tag == "T":
120+
return False
121+
122+
if instr.name == "I" and instr.tag:
123+
result = parse_parametric_tag(instr)
124+
if result is None:
125+
return False
126+
127+
gate_name, params = result
128+
if gate_name in ["R_X", "R_Y", "R_Z"]:
129+
if not is_half_pi_multiple(params["theta"]):
130+
return False
131+
elif gate_name == "U3":
132+
if not all(
133+
is_half_pi_multiple(params[name])
134+
for name in ("theta", "phi", "lambda")
135+
):
136+
return False
137+
else:
138+
return False
139+
140+
return True
141+
142+
143+
def expand_clifford_rotations(source: stim.Circuit) -> stim.Circuit:
144+
"""Return ``source`` with half-π parametric rotations expanded to Clifford gates.
145+
146+
``REPEAT`` blocks are preserved structurally and expanded recursively.
147+
"""
148+
out = stim.Circuit()
149+
for instr in source:
150+
if isinstance(instr, stim.CircuitRepeatBlock):
151+
out.append(
152+
stim.CircuitRepeatBlock(
153+
instr.repeat_count, expand_clifford_rotations(instr.body_copy())
154+
)
155+
)
156+
continue
157+
expansion = _try_clifford_expansion(instr)
158+
if expansion is not None:
159+
gates, targets = expansion
160+
for gate in gates:
161+
out.append(gate, targets, [])
162+
else:
163+
out.append(instr)
164+
return out
165+
166+
167+
def _try_clifford_expansion(
105168
instr: stim.CircuitInstruction,
106169
) -> tuple[list[str], list[int]] | None:
107170
"""Try to expand a tagged ``I`` instruction into equivalent Clifford gates.
@@ -115,7 +178,7 @@ def clifford_expansion(
115178
if instr.name != "I" or not instr.tag:
116179
return None
117180

118-
parsed = parse_parametric_tag(instr.tag)
181+
parsed = parse_parametric_tag(instr)
119182
if parsed is None:
120183
return None
121184

test/unit/compile/test_compile.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
from fractions import Fraction
2+
13
import jax.numpy as jnp
24
import numpy as np
5+
from pyzx_param.graph.base import BaseGraph
36
from pyzx_param.graph.graph_s import GraphS
47

58
from tsim.compile.compile import compile_scalar_graphs
@@ -40,3 +43,36 @@ def test_evaluate_empty_with_params(self):
4043
result = evaluate(compiled, param_vals)
4144
assert result.shape == (3,)
4245
np.testing.assert_array_equal(np.asarray(result), np.zeros(3, dtype=complex))
46+
47+
48+
class TestCompilePrefactorPhase:
49+
"""Phase indices must be reduced modulo 8 so the uint8 storage and
50+
UNIT_PHASES lookup in compile/terms.py see canonical values 0..7."""
51+
52+
def _scalar_graph(self, phase: Fraction) -> GraphS:
53+
g = GraphS()
54+
g.scalar.phase = phase
55+
return g
56+
57+
def test_negative_phase_reduced_into_range(self):
58+
# phase = -1/4 -> int(-1.0) -> -1 -> -1 % 8 == 7
59+
compiled = compile_scalar_graphs([self._scalar_graph(Fraction(-1, 4))], [])
60+
assert int(compiled.prefactor.phase_indices[0]) == 7
61+
62+
def test_phase_above_two_reduced_into_range(self):
63+
# phase = 9/4 -> int(9.0) -> 9 -> 9 % 8 == 1
64+
compiled = compile_scalar_graphs([self._scalar_graph(Fraction(9, 4))], [])
65+
assert int(compiled.prefactor.phase_indices[0]) == 1
66+
67+
def test_in_range_phase_unchanged(self):
68+
# phase = 5/4 -> int(5.0) -> 5 -> 5 % 8 == 5
69+
compiled = compile_scalar_graphs([self._scalar_graph(Fraction(5, 4))], [])
70+
assert int(compiled.prefactor.phase_indices[0]) == 5
71+
72+
def test_phase_indices_stay_within_unit_phase_table(self):
73+
graphs: list[BaseGraph] = [
74+
self._scalar_graph(Fraction(p, 4)) for p in (-7, -1, 0, 3, 7, 11)
75+
]
76+
compiled = compile_scalar_graphs(graphs, [])
77+
indices = np.asarray(compiled.prefactor.phase_indices)
78+
assert np.all((indices >= 0) & (indices < 8))

0 commit comments

Comments
 (0)