From 69ea76084f0aeb6fe89ef8c3c9f5f59261423fd9 Mon Sep 17 00:00:00 2001 From: Draco Glasser Date: Wed, 3 Jun 2026 17:18:41 +0800 Subject: [PATCH 1/2] Support unitaryHACK CCZ bounty with explicit Toffoli gates Issue #113 asks for native CCZ and CCX/Toffoli support without taking on visualization. The implementation keeps the simulator path simple by expanding both shorthand and append API usage into the existing Clifford+T instruction set, using the collaborator-approved 7-T CCZ decomposition and deriving CCX by H-conjugating the target. Constraint: Stim does not parse CCZ or CCX instructions directly Constraint: Visualization support is explicitly out of scope for the bounty issue Rejected: Add direct Stim/GATE_TABLE native instructions | stim.Circuit would still reject source program text before tsim parsing Confidence: high Scope-risk: narrow Directive: Keep CCZ and CCX behavior aligned across program text parsing and Circuit.append Tested: PYTHONPATH=src uv run pytest -q (1087 passed) Tested: PYTHONPATH=src uv run ruff check src/tsim/utils/program_text.py src/tsim/circuit.py test/unit/test_circuit.py Tested: PYTHONPATH=src uv run black --check src/tsim/utils/program_text.py src/tsim/circuit.py test/unit/test_circuit.py Tested: PYTHONPATH=src uv run pyright src/tsim/circuit.py src/tsim/utils/program_text.py test/unit/test_circuit.py Not-tested: SVG/timeline visualization for CCZ/CCX, per issue scope --- README.md | 11 +++++- docs/index.md | 2 +- src/tsim/circuit.py | 47 +++++++++++++++++++++++ src/tsim/utils/program_text.py | 69 +++++++++++++++++++++++++++++++++- test/unit/test_circuit.py | 28 ++++++++++++++ 5 files changed, 153 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e57cb47e..cc5530dd 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ An introductory tutorial is available [here](https://tsim.mintlify.app/tutorials For many existing scripts, replacing `stim` with `tsim` should just work. Tsim mirrors the Stim API and currently supports all [Stim instructions](https://github.com/quantumlib/Stim/wiki/Stim-v1.13-Gate-Reference). -Additionally, Tsim supports the instructions `T`, `T_DAG`, `R_Z`, `R_X`, `R_Y`, `U3`, `TPP`, and `TPP_DAG`. +Additionally, Tsim supports the instructions `T`, `T_DAG`, `R_Z`, `R_X`, `R_Y`, `U3`, `TPP`, `TPP_DAG`, `CCZ`, and `CCX`. ```python import tsim @@ -144,6 +144,15 @@ TPP X0*Y1 # Apply exp(-i π/8 · X0⊗Y1) (up to global phase) TPP_DAG Z0 # Apply exp(+i π/8 · Z) = T_DAG (up to global phase) ``` +### `CCZ` and `CCX` Gates + +`CCZ` applies a controlled-controlled Z gate, and `CCX` applies the controlled-controlled X gate (Toffoli). Both gates are expanded internally into a Clifford+T decomposition: + +``` +CCZ 0 1 2 # Apply CCZ with controls 0 and 1, target 2 +CCX 0 1 2 # Apply Toffoli/CCX with controls 0 and 1, target 2 +``` + ## Publications Using Tsim - [Simulating magic state cultivation with few Clifford terms](https://arxiv.org/abs/2509.08658) by Kwok Ho Wan and Zhenghao Zhong (2025). diff --git a/docs/index.md b/docs/index.md index f648655b..b819c89c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ A detailed description of Tsim is given in [arXiv:2604.01059](https://arxiv.org/ An introductory tutorial is available [here](demos/encoding_demo.ipynb). For many existing scripts, replacing `stim` with `tsim` should just work. Tsim mirrors the Stim API and supports all [Stim instructions](https://github.com/quantumlib/Stim/wiki/Stim-v1.13-Gate-Reference). -Additionally, Tsim supports the instructions `T`, `T_DAG`, `R_Z`, `R_X`, `R_Y`, and `U3`. +Additionally, Tsim supports the instructions `T`, `T_DAG`, `R_Z`, `R_X`, `R_Y`, `U3`, `CCZ`, and `CCX`. ```python import tsim diff --git a/src/tsim/circuit.py b/src/tsim/circuit.py index 09210567..dcfc5bdc 100644 --- a/src/tsim/circuit.py +++ b/src/tsim/circuit.py @@ -25,12 +25,38 @@ from tsim.utils.clifford import expand_clifford_rotations, is_clifford from tsim.utils.diagram import render_pyzx_d3, render_svg from tsim.utils.program_text import ( + controlled_gate_decomposition_lines, enriched_stim_error, shorthand_to_stim, stim_to_shorthand, ) +def _bare_qubit_targets( + gate_name: str, + targets: ( + int + | stim.GateTarget + | stim.PauliString + | Iterable[int | stim.GateTarget | stim.PauliString] + ), +) -> list[int]: + if isinstance(targets, int | stim.GateTarget | stim.PauliString): + target_items: list[int | stim.GateTarget | stim.PauliString] = [targets] + else: + target_items = list(targets) + + qubits: list[int] = [] + for target in target_items: + if isinstance(target, int): + qubits.append(target) + elif isinstance(target, stim.GateTarget) and target.is_qubit_target: + qubits.append(target.value) + else: + raise ValueError(f"{gate_name} only supports bare qubit targets.") + return qubits + + class Circuit: """Quantum circuit as a thin wrapper around stim.Circuit. @@ -150,6 +176,27 @@ def append( """ if isinstance(name, str): + if name in ("CCZ", "CCX"): + if arg is not None: + raise ValueError(f"For {name} gates, no arguments are accepted.") + if tag: + raise ValueError(f"For {name} gates, tags are not supported.") + qubits = _bare_qubit_targets(name, targets) + if len(qubits) % 3 != 0: + raise ValueError( + f"{name} expects qubit targets in groups of three." + ) + self.append_from_stim_program_text( + "\n".join( + line + for i in range(0, len(qubits), 3) + for line in controlled_gate_decomposition_lines( + name, qubits[i], qubits[i + 1], qubits[i + 2] + ) + ) + ) + return + if name == "TPP": name = "SPP" tag = "T" diff --git a/src/tsim/utils/program_text.py b/src/tsim/utils/program_text.py index ed8f6f41..b8933e9b 100644 --- a/src/tsim/utils/program_text.py +++ b/src/tsim/utils/program_text.py @@ -5,9 +5,70 @@ # 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 = {"CCZ", "CCX", "R_X", "R_Y", "R_Z", "U3"} _GATE_NOT_FOUND_RE = re.compile(r"Gate not found: '(\w+)'") -_GATE_USAGE_RE = re.compile(r"(? list[str]: + """Return a Clifford+T decomposition for a controlled-controlled gate.""" + if gate not in ("CCZ", "CCX"): + raise ValueError(f"Unsupported controlled-controlled gate: {gate!r}") + + a, b, c = str(control1), str(control2), str(target) + ccz_lines = [ + f"CNOT {b} {c}", + f"T_DAG {c}", + f"CNOT {a} {c}", + f"T {c}", + f"CNOT {b} {c}", + f"T_DAG {c}", + f"CNOT {a} {c}", + f"T {b}", + f"T {c}", + f"CNOT {a} {b}", + f"T {a}", + f"T_DAG {b}", + f"CNOT {a} {b}", + ] + if gate == "CCZ": + return ccz_lines + return [f"H {c}", *ccz_lines, f"H {c}"] + + +def _expand_controlled_gates(text: str) -> str: + lines: list[str] = [] + for line in text.splitlines(): + body, sep, comment = line.partition("#") + match = re.match(r"^(\s*)(CCZ|CCX)\s+(.+?)\s*$", body) + if not match: + lines.append(line) + continue + + indent, gate, targets_text = match.groups() + targets = targets_text.split() + if len(targets) % 3 != 0 or not all(target.isdecimal() for target in targets): + raise ValueError( + f"{gate} expects bare qubit integer targets in groups of three." + ) + + if sep: + lines.append(f"{indent}{sep}{comment}") + for i in range(0, len(targets), 3): + lines.extend( + f"{indent}{decomp_line}" + for decomp_line in controlled_gate_decomposition_lines( + gate, targets[i], targets[i + 1], targets[i + 2] + ) + ) + return "\n".join(lines) def enriched_stim_error(exc: ValueError, converted_text: str) -> ValueError: @@ -31,6 +92,8 @@ def shorthand_to_stim(text: str) -> str: """Convert tsim shorthand syntax to valid stim instructions. Converts: + CCZ 0 1 2 → Clifford+T decomposition of CCZ + CCX 0 1 2 → Clifford+T decomposition of CCX/Toffoli T 0 1 → S[T] 0 1 T_DAG 0 1 → S_DAG[T] 0 1 TPP X0*Y1 → SPP[T] X0*Y1 @@ -41,6 +104,8 @@ def shorthand_to_stim(text: str) -> str: U3(0.3, 0.24, 0.49) 0 → I[U3(theta=0.3*pi, phi=0.24*pi, lambda=0.49*pi)] 0 """ + text = _expand_controlled_gates(text) + # TPP_DAG/TPP must come before T_DAG/T to avoid partial matches # (? Date: Sat, 6 Jun 2026 05:20:30 +0800 Subject: [PATCH 2/2] Preserve tags when expanding controlled magic gates Maintainer review pointed out that tagged CCX/CCZ program-text instructions should expand into tagged primitive instructions. The fix carries the user tag through the decomposition while preserving the internal T-family marker used to model non-Clifford gates on top of Stim. Constraint: Stim tags already encode tsim T-family gates, so user tags need a composed internal representation. Confidence: high Scope-risk: narrow Tested: PYTHONPATH=src uv run pytest test/unit/utils/test_program_text.py test/unit/test_circuit.py test/unit/core/test_parse.py test/unit/external/test_vec_sampler.py test/unit/utils/test_diagram.py test/unit/utils/test_clifford.py -q Tested: PYTHONPATH=src uv run pytest -q Tested: PYTHONPATH=src uv run ruff check src test Tested: PYTHONPATH=src uv run black --check src/tsim/core/tags.py src/tsim/utils/program_text.py src/tsim/circuit.py src/tsim/core/parse.py src/tsim/external/vec_sim/vec_sampler.py src/tsim/utils/clifford.py src/tsim/utils/diagram.py test/unit/utils/test_program_text.py test/unit/test_circuit.py Tested: PYTHONPATH=src uv run pyright src/tsim/core/tags.py src/tsim/utils/program_text.py src/tsim/circuit.py src/tsim/core/parse.py src/tsim/external/vec_sim/vec_sampler.py src/tsim/utils/clifford.py src/tsim/utils/diagram.py --- src/tsim/circuit.py | 13 ++- src/tsim/core/parse.py | 7 +- src/tsim/core/tags.py | 25 ++++++ src/tsim/external/vec_sim/vec_sampler.py | 5 +- src/tsim/utils/clifford.py | 3 +- src/tsim/utils/diagram.py | 5 +- src/tsim/utils/program_text.py | 106 +++++++++++++++++------ test/unit/test_circuit.py | 55 ++++++++++++ test/unit/utils/test_program_text.py | 12 +++ 9 files changed, 191 insertions(+), 40 deletions(-) create mode 100644 src/tsim/core/tags.py diff --git a/src/tsim/circuit.py b/src/tsim/circuit.py index dcfc5bdc..bf28c753 100644 --- a/src/tsim/circuit.py +++ b/src/tsim/circuit.py @@ -21,6 +21,7 @@ from tsim.core.graph import build_sampling_graph from tsim.core.parse import parse_parametric_tag, parse_stim_circuit +from tsim.core.tags import encode_t_tag from tsim.noise.dem import get_detector_error_model from tsim.utils.clifford import expand_clifford_rotations, is_clifford from tsim.utils.diagram import render_pyzx_d3, render_svg @@ -179,8 +180,6 @@ def append( if name in ("CCZ", "CCX"): if arg is not None: raise ValueError(f"For {name} gates, no arguments are accepted.") - if tag: - raise ValueError(f"For {name} gates, tags are not supported.") qubits = _bare_qubit_targets(name, targets) if len(qubits) % 3 != 0: raise ValueError( @@ -191,7 +190,7 @@ def append( line for i in range(0, len(qubits), 3) for line in controlled_gate_decomposition_lines( - name, qubits[i], qubits[i + 1], qubits[i + 2] + name, qubits[i], qubits[i + 1], qubits[i + 2], tag=tag ) ) ) @@ -199,16 +198,16 @@ def append( if name == "TPP": name = "SPP" - tag = "T" + tag = encode_t_tag(tag) elif name == "TPP_DAG": name = "SPP_DAG" - tag = "T" + tag = encode_t_tag(tag) elif name == "T": name = "S" - tag = "T" + tag = encode_t_tag(tag) elif name == "T_DAG": name = "S_DAG" - tag = "T" + tag = encode_t_tag(tag) elif name in ("R_X", "R_Y", "R_Z"): if arg is None: raise ValueError(f"For {name} gates, an angle must be provided.") diff --git a/src/tsim/core/parse.py b/src/tsim/core/parse.py index 0e74c712..2b919db4 100644 --- a/src/tsim/core/parse.py +++ b/src/tsim/core/parse.py @@ -24,6 +24,7 @@ tpp, u3, ) +from tsim.core.tags import is_t_tag _PARAMETRIC_GATE_PARAMS: dict[str, frozenset[str]] = { "R_X": frozenset({"theta"}), @@ -192,9 +193,9 @@ def parse_stim_circuit( f"in instruction {str(instruction)!r}" ) - if name == "S" and instruction.tag == "T": + if name == "S" and is_t_tag(instruction.tag): name = "T" - elif name == "S_DAG" and instruction.tag == "T": + elif name == "S_DAG" and is_t_tag(instruction.tag): name = "T_DAG" # Handle parametric gates via tags (e.g., I with tag "R_Z(theta=0.3*pi)") @@ -225,7 +226,7 @@ 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 == "T": + if name in ("SPP", "SPP_DAG") and is_t_tag(instruction.tag): is_dag = name == "SPP_DAG" for paulis, invert in _iter_pauli_products(instruction): tpp(b, paulis, dagger=is_dag ^ invert) diff --git a/src/tsim/core/tags.py b/src/tsim/core/tags.py new file mode 100644 index 00000000..7c7cd9df --- /dev/null +++ b/src/tsim/core/tags.py @@ -0,0 +1,25 @@ +"""Helpers for tags that encode tsim-specific gate metadata.""" + +T_TAG = "T" +_T_USER_TAG_PREFIX = f"{T_TAG}:" + + +def encode_t_tag(user_tag: str = "") -> str: + """Encode a T-family gate tag while preserving an optional user tag.""" + if not user_tag: + return T_TAG + return f"{_T_USER_TAG_PREFIX}{user_tag}" + + +def is_t_tag(tag: str) -> bool: + """Return whether a Stim tag encodes a tsim T-family gate.""" + return tag == T_TAG or tag.startswith(_T_USER_TAG_PREFIX) + + +def decode_t_user_tag(tag: str) -> str: + """Return the user tag attached to an encoded T-family gate tag.""" + if tag == T_TAG: + return "" + if tag.startswith(_T_USER_TAG_PREFIX): + return tag[len(_T_USER_TAG_PREFIX) :] + raise ValueError(f"Tag does not encode a T-family gate: {tag!r}") diff --git a/src/tsim/external/vec_sim/vec_sampler.py b/src/tsim/external/vec_sim/vec_sampler.py index d32ff79e..99696ac7 100644 --- a/src/tsim/external/vec_sim/vec_sampler.py +++ b/src/tsim/external/vec_sim/vec_sampler.py @@ -18,6 +18,7 @@ import stim from tsim.core.parse import parse_parametric_tag +from tsim.core.tags import is_t_tag from tsim.external.vec_sim import VecSim @@ -74,10 +75,10 @@ def sample_circuit_with_vec_sim_return_data( sim.do_qalloc_z(q) for inst in circuit: assert not isinstance(inst, stim.CircuitRepeatBlock) - if inst.name == "S" and inst.tag == "T": + if inst.name == "S" and is_t_tag(inst.tag): for q in inst.targets_copy(): sim.do_t(q.qubit_value) - elif inst.name == "S_DAG" and inst.tag == "T": + elif inst.name == "S_DAG" and is_t_tag(inst.tag): for q in inst.targets_copy(): sim.do_t_dag(q.qubit_value) elif inst.name == "I" and inst.tag: diff --git a/src/tsim/utils/clifford.py b/src/tsim/utils/clifford.py index f27cc06f..4c921e65 100644 --- a/src/tsim/utils/clifford.py +++ b/src/tsim/utils/clifford.py @@ -7,6 +7,7 @@ import stim from tsim.core.parse import parse_parametric_tag +from tsim.core.tags import is_t_tag # 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. @@ -116,7 +117,7 @@ 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 is_t_tag(instr.tag): return False if instr.name == "I" and instr.tag: diff --git a/src/tsim/utils/diagram.py b/src/tsim/utils/diagram.py index 3920438f..166c7bba 100644 --- a/src/tsim/utils/diagram.py +++ b/src/tsim/utils/diagram.py @@ -14,6 +14,7 @@ from tsim.core.graph import scale_horizontally from tsim.core.parse import parse_stim_circuit +from tsim.core.tags import is_t_tag from tsim.utils.program_text import FLOAT_RE @@ -390,7 +391,7 @@ def _replace_tagged_gates( # Double each Pauli target so the SVG contains duplicate rect+text # pairs at the same position, which _deduplicate_doubled_spp() later # detects and renames SPP → TPP. - if instr.tag == "T" and instr.name in ["SPP", "SPP_DAG"]: + if is_t_tag(instr.tag) and instr.name in ["SPP", "SPP_DAG"]: targets = instr.targets_copy() doubled: list[stim.GateTarget] = [] for target in targets: @@ -405,7 +406,7 @@ def _replace_tagged_gates( continue # Handle T gates (S[T] and S_DAG[T]) - if instr.tag == "T" and instr.name in ["S", "S_DAG"]: + if is_t_tag(instr.tag) and instr.name in ["S", "S_DAG"]: for target in instr.targets_copy(): identifier = np.round(np.random.rand(), 6) DAG = '' diff --git a/src/tsim/utils/program_text.py b/src/tsim/utils/program_text.py index b8933e9b..cb7b2a5f 100644 --- a/src/tsim/utils/program_text.py +++ b/src/tsim/utils/program_text.py @@ -1,6 +1,9 @@ """Conversion utilities between tsim shorthand and stim program text.""" import re +from collections.abc import Callable + +from tsim.core.tags import decode_t_user_tag, encode_t_tag # Matches valid numeric literals including scientific notation (e.g. 0.5, 4e-4, 1.2e3) FLOAT_RE = r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?" @@ -17,42 +20,47 @@ def controlled_gate_decomposition_lines( control1: int | str, control2: int | str, target: int | str, + *, + tag: str = "", ) -> list[str]: """Return a Clifford+T decomposition for a controlled-controlled gate.""" if gate not in ("CCZ", "CCX"): raise ValueError(f"Unsupported controlled-controlled gate: {gate!r}") + def tagged(name: str) -> str: + return f"{name}[{tag}]" if tag else name + a, b, c = str(control1), str(control2), str(target) ccz_lines = [ - f"CNOT {b} {c}", - f"T_DAG {c}", - f"CNOT {a} {c}", - f"T {c}", - f"CNOT {b} {c}", - f"T_DAG {c}", - f"CNOT {a} {c}", - f"T {b}", - f"T {c}", - f"CNOT {a} {b}", - f"T {a}", - f"T_DAG {b}", - f"CNOT {a} {b}", + f"{tagged('CNOT')} {b} {c}", + f"{tagged('T_DAG')} {c}", + f"{tagged('CNOT')} {a} {c}", + f"{tagged('T')} {c}", + f"{tagged('CNOT')} {b} {c}", + f"{tagged('T_DAG')} {c}", + f"{tagged('CNOT')} {a} {c}", + f"{tagged('T')} {b}", + f"{tagged('T')} {c}", + f"{tagged('CNOT')} {a} {b}", + f"{tagged('T')} {a}", + f"{tagged('T_DAG')} {b}", + f"{tagged('CNOT')} {a} {b}", ] if gate == "CCZ": return ccz_lines - return [f"H {c}", *ccz_lines, f"H {c}"] + return [f"{tagged('H')} {c}", *ccz_lines, f"{tagged('H')} {c}"] def _expand_controlled_gates(text: str) -> str: lines: list[str] = [] for line in text.splitlines(): body, sep, comment = line.partition("#") - match = re.match(r"^(\s*)(CCZ|CCX)\s+(.+?)\s*$", body) + match = re.match(r"^(\s*)(CCZ|CCX)(?:\[([^\]\n]*)\])?\s+(.+?)\s*$", body) if not match: lines.append(line) continue - indent, gate, targets_text = match.groups() + indent, gate, tag, targets_text = match.groups() targets = targets_text.split() if len(targets) % 3 != 0 or not all(target.isdecimal() for target in targets): raise ValueError( @@ -65,12 +73,28 @@ def _expand_controlled_gates(text: str) -> str: lines.extend( f"{indent}{decomp_line}" for decomp_line in controlled_gate_decomposition_lines( - gate, targets[i], targets[i + 1], targets[i + 2] + gate, targets[i], targets[i + 1], targets[i + 2], tag=tag or "" ) ) return "\n".join(lines) +def _replace_t_family(stim_gate: str) -> Callable[[re.Match[str]], str]: + def replace(m: re.Match[str]) -> str: + return f"{stim_gate}[{encode_t_tag(m.group(1) or '')}]" + + return replace + + +def _replace_t_family_shorthand(tsim_gate: str) -> Callable[[re.Match[str]], str]: + def replace(m: re.Match[str]) -> str: + user_tag = decode_t_user_tag(m.group(1)) + tag_suffix = f"[{user_tag}]" if user_tag else "" + return f"{tsim_gate}{tag_suffix}" + + return replace + + def enriched_stim_error(exc: ValueError, converted_text: str) -> ValueError: """Improve stim parse errors for tsim-specific gates. @@ -108,10 +132,26 @@ def shorthand_to_stim(text: str) -> str: # TPP_DAG/TPP must come before T_DAG/T to avoid partial matches # (? str: axis = m.group(1) @@ -182,12 +222,28 @@ def replace_rotation(m: re.Match) -> str: # 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"(?