From 727e4848d7375ec18a10c0e0cb102c474f7e62f4 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Mon, 2 Feb 2026 18:17:17 +0900 Subject: [PATCH 01/14] add custom noise model --- graphqomb/noise_model.py | 62 ++++++++ graphqomb/stim_compiler.py | 284 +++++++++++++++++++++++++++--------- pyproject.toml | 5 +- tests/test_circuit.py | 4 +- tests/test_stim_compiler.py | 74 ++++++++++ 5 files changed, 353 insertions(+), 76 deletions(-) create mode 100644 graphqomb/noise_model.py diff --git a/graphqomb/noise_model.py b/graphqomb/noise_model.py new file mode 100644 index 00000000..fc9820b6 --- /dev/null +++ b/graphqomb/noise_model.py @@ -0,0 +1,62 @@ +"""Noise model interface for Stim circuit compilation.""" + +from __future__ import annotations + +import abc +import enum +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + + from graphqomb.common import Axis + + +class NoiseKind(enum.Enum): + """Kind of noise injection event.""" + + PREPARE = enum.auto() + ENTANGLE = enum.auto() + MEASURE = enum.auto() + IDLE = enum.auto() + + +@dataclass(frozen=True) +class NoiseOp: + r"""A single Stim instruction plus its measurement record delta. + + Parameters + ---------- + text : `str` + A single Stim instruction line (without trailing newline). + record_delta : `int`, optional + The number of measurement records appended by this instruction. + For example, HERALDED_* instructions add one record per target. + """ + + text: str + record_delta: int = 0 + + +@dataclass(frozen=True) +class NoiseEvent: + """Context describing where noise should be injected.""" + + kind: NoiseKind + tick: int + nodes: tuple[int, ...] + edge: tuple[int, int] | None + coords: tuple[tuple[float, float] | None, ...] + axis: Axis | None + duration: float | None = None + is_input: bool = False + + +class NoiseModel(abc.ABC): + """Abstract base class for custom noise injection during Stim compilation.""" + + @abc.abstractmethod + def emit(self, event: NoiseEvent) -> Iterable[NoiseOp]: + r"""Return Stim instructions to inject for the given event.""" + raise NotImplementedError diff --git a/graphqomb/stim_compiler.py b/graphqomb/stim_compiler.py index b1eceab1..779e5b42 100644 --- a/graphqomb/stim_compiler.py +++ b/graphqomb/stim_compiler.py @@ -14,6 +14,7 @@ from graphqomb.command import TICK, E, M, N from graphqomb.common import Axis, MeasBasis, determine_pauli_axis +from graphqomb.noise_model import NoiseEvent, NoiseKind, NoiseModel if TYPE_CHECKING: from collections.abc import Collection, Iterable, Mapping, Sequence @@ -101,52 +102,67 @@ def _entangle_nodes( stim_io.write(f"DEPOLARIZE2({p_depol_after_clifford}) {q1} {q2}\n") -def _measure_node( +def _emit_measurement_noise( stim_io: StringIO, - meas_basis: MeasBasis, + axis: Axis, node: int, p_before_meas_flip: float, ) -> None: - r"""Measure a node in the specified basis (M command). - - Parameters - ---------- - stim_io : `StringIO` - The output stream to write to. - meas_basis : `MeasBasis` - The measurement basis. - node : `int` - The node to measure. - p_before_meas_flip : `float` - The probability of flipping a measurement result before measurement. - - Raises - ------ - ValueError - If an unsupported measurement basis is encountered. - """ - axis = determine_pauli_axis(meas_basis) - if axis is None: - msg = f"Unsupported measurement basis: {meas_basis.plane, meas_basis.angle}" - raise ValueError(msg) - + r"""Emit measurement noise before a measurement operation.""" if axis == Axis.X: if p_before_meas_flip > 0.0: stim_io.write(f"Z_ERROR({p_before_meas_flip}) {node}\n") - stim_io.write(f"MX {node}\n") elif axis == Axis.Y: if p_before_meas_flip > 0.0: stim_io.write(f"X_ERROR({p_before_meas_flip}) {node}\n") stim_io.write(f"Z_ERROR({p_before_meas_flip}) {node}\n") - stim_io.write(f"MY {node}\n") elif axis == Axis.Z: if p_before_meas_flip > 0.0: stim_io.write(f"X_ERROR({p_before_meas_flip}) {node}\n") + else: + typing_extensions.assert_never(axis) + + +def _emit_measurement( + stim_io: StringIO, + axis: Axis, + node: int, +) -> None: + r"""Emit a measurement operation.""" + if axis == Axis.X: + stim_io.write(f"MX {node}\n") + elif axis == Axis.Y: + stim_io.write(f"MY {node}\n") + elif axis == Axis.Z: stim_io.write(f"MZ {node}\n") else: typing_extensions.assert_never(axis) +def _emit_noise( + stim_io: StringIO, + noise_model: NoiseModel | None, + event: NoiseEvent, +) -> int: + if noise_model is None: + return 0 + record_delta = 0 + for op in noise_model.emit(event): + if op.text: + stim_io.write(f"{op.text}\n") + record_delta += op.record_delta + return record_delta + + +_MIN_COORD_DIMS = 2 + + +def _xy_coords(coordinate: tuple[float, ...] | None) -> tuple[float, float] | None: + if coordinate is None or len(coordinate) < _MIN_COORD_DIMS: + return None + return (coordinate[0], coordinate[1]) + + def _add_detectors( stim_io: StringIO, check_groups: Sequence[Collection[int]], @@ -199,13 +215,169 @@ def _add_observables( stim_io.write(f"OBSERVABLE_INCLUDE({log_idx}) {' '.join(targets)}\n") -def stim_compile( +class _StimCompiler: + def __init__( # noqa: PLR0913 + self, + pattern: Pattern, + *, + p_depol_after_clifford: float, + p_before_meas_flip: float, + emit_qubit_coords: bool, + noise_model: NoiseModel | None, + tick_duration: float, + ) -> None: + self._pattern = pattern + self._pframe = pattern.pauli_frame + self._coord_lookup = pattern.coordinates + self._p_depol_after_clifford = p_depol_after_clifford + self._p_before_meas_flip = p_before_meas_flip + self._emit_qubit_coords = emit_qubit_coords + self._noise_model = noise_model + self._tick_duration = tick_duration + self._stim_io = StringIO() + self._meas_order: dict[int, int] = {} + self._rec_index = 0 + self._alive_nodes: set[int] = set(pattern.input_node_indices) + self._touched_nodes: set[int] = set() + self._tick = 0 + + def compile(self, logical_observables: Mapping[int, Collection[int]] | None) -> str: + self._emit_input_nodes() + self._process_commands() + total_measurements = self._rec_index + _add_detectors(self._stim_io, self._pframe.detector_groups(), self._meas_order, total_measurements) + if logical_observables is not None: + _add_observables( + self._stim_io, + logical_observables, + self._pframe, + self._meas_order, + total_measurements, + ) + return self._stim_io.getvalue().strip() + + def _emit_input_nodes(self) -> None: + coordinates = self._pattern.input_coordinates if self._emit_qubit_coords else None + for node in self._pattern.input_node_indices: + _prepare_nodes( + self._stim_io, + node, + self._p_depol_after_clifford, + coordinates=coordinates, + emit_qubit_coords=self._emit_qubit_coords, + ) + self._after_prepare(node, is_input=True) + + def _process_commands(self) -> None: + for cmd in self._pattern: + if isinstance(cmd, N): + self._handle_prepare(cmd.node, cmd.coordinate) + elif isinstance(cmd, E): + self._handle_entangle(cmd.nodes) + elif isinstance(cmd, M): + self._handle_measure(cmd.node, cmd.meas_basis) + elif isinstance(cmd, TICK): + self._handle_tick() + + def _handle_prepare(self, node: int, coordinate: tuple[float, ...] | None) -> None: + coordinates = {node: coordinate} if self._emit_qubit_coords and coordinate is not None else None + _prepare_nodes( + self._stim_io, + node, + self._p_depol_after_clifford, + coordinates=coordinates, + emit_qubit_coords=self._emit_qubit_coords, + ) + self._after_prepare(node, is_input=False) + + def _after_prepare(self, node: int, *, is_input: bool) -> None: + self._alive_nodes.add(node) + self._touched_nodes.add(node) + self._apply_noise( + NoiseEvent( + kind=NoiseKind.PREPARE, + tick=self._tick, + nodes=(node,), + edge=None, + coords=(self._coords_for(node),), + axis=None, + is_input=is_input, + ), + ) + + def _handle_entangle(self, nodes: tuple[int, int]) -> None: + _entangle_nodes(self._stim_io, nodes, self._p_depol_after_clifford) + self._touched_nodes.update(nodes) + n0, n1 = nodes + edge: tuple[int, int] = (n0, n1) if n0 < n1 else (n1, n0) + self._apply_noise( + NoiseEvent( + kind=NoiseKind.ENTANGLE, + tick=self._tick, + nodes=nodes, + edge=edge, + coords=(self._coords_for(nodes[0]), self._coords_for(nodes[1])), + axis=None, + ), + ) + + def _handle_measure(self, node: int, meas_basis: MeasBasis) -> None: + axis = determine_pauli_axis(meas_basis) + if axis is None: + msg = f"Unsupported measurement basis: {meas_basis.plane, meas_basis.angle}" + raise ValueError(msg) + _emit_measurement_noise(self._stim_io, axis, node, self._p_before_meas_flip) + self._apply_noise( + NoiseEvent( + kind=NoiseKind.MEASURE, + tick=self._tick, + nodes=(node,), + edge=None, + coords=(self._coords_for(node),), + axis=axis, + ), + ) + _emit_measurement(self._stim_io, axis, node) + self._meas_order[node] = self._rec_index + self._rec_index += 1 + self._alive_nodes.discard(node) + self._touched_nodes.add(node) + + def _handle_tick(self) -> None: + if self._noise_model is not None: + idle_nodes = sorted(self._alive_nodes - self._touched_nodes) + if idle_nodes: + self._apply_noise( + NoiseEvent( + kind=NoiseKind.IDLE, + tick=self._tick, + nodes=tuple(idle_nodes), + edge=None, + coords=tuple(self._coords_for(node) for node in idle_nodes), + axis=None, + duration=self._tick_duration, + ), + ) + self._stim_io.write("TICK\n") + self._touched_nodes.clear() + self._tick += 1 + + def _apply_noise(self, event: NoiseEvent) -> None: + self._rec_index += _emit_noise(self._stim_io, self._noise_model, event) + + def _coords_for(self, node: int) -> tuple[float, float] | None: + return _xy_coords(self._coord_lookup.get(node)) + + +def stim_compile( # noqa: PLR0913 pattern: Pattern, logical_observables: Mapping[int, Collection[int]] | None = None, *, p_depol_after_clifford: float = 0.0, p_before_meas_flip: float = 0.0, emit_qubit_coords: bool = True, + noise_model: NoiseModel | None = None, + tick_duration: float = 1.0, ) -> str: r"""Compile a pattern to stim format. @@ -222,6 +394,10 @@ def stim_compile( emit_qubit_coords : `bool`, optional Whether to emit QUBIT_COORDS instructions for nodes with coordinates, by default True. + noise_model : `NoiseModel` | `None`, optional + Custom noise model for injecting Stim noise instructions, by default None. + tick_duration : `float`, optional + Duration associated with each TICK for idle noise, by default 1.0. Returns ------- @@ -234,50 +410,12 @@ def stim_compile( Pauli measurements (X, Y, Z basis) which correspond to Clifford operations. Non-Pauli measurements will raise a ValueError. """ - stim_io = StringIO() - pframe = pattern.pauli_frame - - # Build measurement order lookup dict - meas_order: dict[int, int] = {} - meas_idx = 0 - for cmd in pattern: - if isinstance(cmd, M): - meas_order[cmd.node] = meas_idx - meas_idx += 1 - total_measurements = meas_idx - - # Initialize input nodes (with coordinates if available) - _prepare_nodes( - stim_io, - pattern.input_node_indices, - p_depol_after_clifford, - coordinates=pattern.input_coordinates if emit_qubit_coords else None, + compiler = _StimCompiler( + pattern, + p_depol_after_clifford=p_depol_after_clifford, + p_before_meas_flip=p_before_meas_flip, emit_qubit_coords=emit_qubit_coords, + noise_model=noise_model, + tick_duration=tick_duration, ) - - # Process pattern commands - for cmd in pattern: - if isinstance(cmd, N): - _prepare_nodes( - stim_io, - cmd.node, - p_depol_after_clifford, - coordinates={cmd.node: cmd.coordinate} if cmd.coordinate else None, - emit_qubit_coords=emit_qubit_coords, - ) - elif isinstance(cmd, E): - _entangle_nodes(stim_io, cmd.nodes, p_depol_after_clifford) - elif isinstance(cmd, M): - _measure_node(stim_io, cmd.meas_basis, cmd.node, p_before_meas_flip) - elif isinstance(cmd, TICK): - stim_io.write("TICK\n") - - # Add detectors - check_groups = pframe.detector_groups() - _add_detectors(stim_io, check_groups, meas_order, total_measurements) - - # Add logical observables - if logical_observables is not None: - _add_observables(stim_io, logical_observables, pframe, meas_order, total_measurements) - - return stim_io.getvalue().strip() + return compiler.compile(logical_observables) diff --git a/pyproject.toml b/pyproject.toml index ea35c112..cf71c0e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,11 +115,14 @@ docstring-code-format = true "S101", # `assert` detected "SLF001", # private method "PLC2701", # private method - "PLR2004", # magic value in test(should be removed) + "PLR2004", # magic value in test + "PLR6301", # method could be static "D100", "D103", "D104", "D400", + "D417", # return not documented (numpy style) + "DOC201", # return not documented (pydoclint) ] "examples/*.py" = [ "T201", # print diff --git a/tests/test_circuit.py b/tests/test_circuit.py index b5252153..29afeb86 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -301,10 +301,10 @@ class MockCircuit(BaseCircuit): def num_qubits(self) -> int: return 1 - def instructions(self) -> list[Gate]: # noqa: PLR6301 + def instructions(self) -> list[Gate]: return [X(qubit=0)] - def unit_instructions(self) -> list[UnitGate]: # noqa: PLR6301 + def unit_instructions(self) -> list[UnitGate]: # Return a non-UnitGate object to trigger error return [X(qubit=0)] # type: ignore[list-item] diff --git a/tests/test_stim_compiler.py b/tests/test_stim_compiler.py index 7c3d783f..ed28888a 100644 --- a/tests/test_stim_compiler.py +++ b/tests/test_stim_compiler.py @@ -10,6 +10,7 @@ from graphqomb.command import TICK, E from graphqomb.common import Axis, AxisMeasBasis, Plane, PlannerMeasBasis, Sign from graphqomb.graphstate import GraphState +from graphqomb.noise_model import NoiseEvent, NoiseKind, NoiseModel, NoiseOp from graphqomb.qompiler import qompile from graphqomb.schedule_solver import ScheduleConfig, Strategy from graphqomb.scheduler import Scheduler @@ -239,6 +240,79 @@ def test_stim_compile_with_detectors() -> None: # This is valid behavior for certain graph configurations +class _HeraldedNoise(NoiseModel): + """Test noise model that adds heralded Pauli channel on measurements.""" + + def emit(self, event: NoiseEvent) -> list[NoiseOp]: + if event.kind == NoiseKind.MEASURE: + node = event.nodes[0] + return [NoiseOp(f"HERALDED_PAULI_CHANNEL_1(0,0,0,0.1) {node}", record_delta=1)] + return [] + + +def _parse_stim_measurements(stim_str: str) -> tuple[dict[int, int], int]: + """Parse stim string to extract measurement order and total record count.""" + rec_index = 0 + actual_meas_order: dict[int, int] = {} + for raw_line in stim_str.splitlines(): + stripped = raw_line.strip() + if not stripped: + continue + if stripped.startswith("HERALDED_PAULI_CHANNEL_1"): + targets = stripped.split(")", 1)[1].strip().split() + rec_index += len(targets) + elif stripped.startswith(("MX ", "MY ", "MZ ")): + node = int(stripped.split()[1]) + actual_meas_order[node] = rec_index + rec_index += 1 + return actual_meas_order, rec_index + + +def _normalize_detector(line: str) -> str: + """Normalize detector line by sorting targets.""" + parts = line.strip().split() + if len(parts) <= 1: + return "DETECTOR" + targets = sorted(parts[1:]) + return f"DETECTOR {' '.join(targets)}" + + +def test_stim_compile_with_heralded_noise_updates_detectors() -> None: + """Heralded noise should shift rec indices used by detectors.""" + graph = GraphState() + in_node = graph.add_physical_node() + meas_node = graph.add_physical_node() + out_node = graph.add_physical_node() + + q_idx = 0 + graph.register_input(in_node, q_idx) + graph.register_output(out_node, q_idx) + + graph.add_physical_edge(in_node, meas_node) + graph.add_physical_edge(meas_node, out_node) + + graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(meas_node, PlannerMeasBasis(Plane.XY, 0.0)) + + xflow = {in_node: {meas_node}, meas_node: {out_node}} + parity_check_group = [{in_node}] + pattern = qompile(graph, xflow, parity_check_group=parity_check_group) + + stim_str = stim_compile(pattern, noise_model=_HeraldedNoise()) + + actual_meas_order, total_measurements = _parse_stim_measurements(stim_str) + + check_groups = pattern.pauli_frame.detector_groups() + expected_detectors = { + _normalize_detector( + f"DETECTOR {' '.join(f'rec[{actual_meas_order[check] - total_measurements}]' for check in checks)}" + ) + for checks in check_groups + } + actual_detectors = {_normalize_detector(line) for line in stim_str.splitlines() if line.startswith("DETECTOR")} + assert expected_detectors == actual_detectors + + def test_stim_compile_with_logical_observables() -> None: """Test OBSERVABLE_INCLUDE generation.""" pattern, meas_node, _ = create_simple_pattern_x_measurement() From c4c4e4421ce17f9f64d416c6fc8c83a210f4bde9 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Mon, 2 Feb 2026 18:23:27 +0900 Subject: [PATCH 02/14] update measurement error impl --- graphqomb/stim_compiler.py | 49 +++++++++++++++++-------------------- tests/test_stim_compiler.py | 25 +++++-------------- 2 files changed, 28 insertions(+), 46 deletions(-) diff --git a/graphqomb/stim_compiler.py b/graphqomb/stim_compiler.py index 779e5b42..e582d94a 100644 --- a/graphqomb/stim_compiler.py +++ b/graphqomb/stim_compiler.py @@ -102,41 +102,37 @@ def _entangle_nodes( stim_io.write(f"DEPOLARIZE2({p_depol_after_clifford}) {q1} {q2}\n") -def _emit_measurement_noise( - stim_io: StringIO, - axis: Axis, - node: int, - p_before_meas_flip: float, -) -> None: - r"""Emit measurement noise before a measurement operation.""" - if axis == Axis.X: - if p_before_meas_flip > 0.0: - stim_io.write(f"Z_ERROR({p_before_meas_flip}) {node}\n") - elif axis == Axis.Y: - if p_before_meas_flip > 0.0: - stim_io.write(f"X_ERROR({p_before_meas_flip}) {node}\n") - stim_io.write(f"Z_ERROR({p_before_meas_flip}) {node}\n") - elif axis == Axis.Z: - if p_before_meas_flip > 0.0: - stim_io.write(f"X_ERROR({p_before_meas_flip}) {node}\n") - else: - typing_extensions.assert_never(axis) - - def _emit_measurement( stim_io: StringIO, axis: Axis, node: int, + p_meas_flip: float, ) -> None: - r"""Emit a measurement operation.""" + r"""Emit a measurement operation with optional measurement error. + + Parameters + ---------- + stim_io : `StringIO` + The output stream to write to. + axis : `Axis` + The measurement axis (X, Y, or Z). + node : `int` + The qubit index to measure. + p_meas_flip : `float` + The probability of a measurement bit flip error. + """ if axis == Axis.X: - stim_io.write(f"MX {node}\n") + meas_instr = "MX" elif axis == Axis.Y: - stim_io.write(f"MY {node}\n") + meas_instr = "MY" elif axis == Axis.Z: - stim_io.write(f"MZ {node}\n") + meas_instr = "MZ" else: typing_extensions.assert_never(axis) + if p_meas_flip > 0.0: + stim_io.write(f"{meas_instr}({p_meas_flip}) {node}\n") + else: + stim_io.write(f"{meas_instr} {node}\n") def _emit_noise( @@ -326,7 +322,6 @@ def _handle_measure(self, node: int, meas_basis: MeasBasis) -> None: if axis is None: msg = f"Unsupported measurement basis: {meas_basis.plane, meas_basis.angle}" raise ValueError(msg) - _emit_measurement_noise(self._stim_io, axis, node, self._p_before_meas_flip) self._apply_noise( NoiseEvent( kind=NoiseKind.MEASURE, @@ -337,7 +332,7 @@ def _handle_measure(self, node: int, meas_basis: MeasBasis) -> None: axis=axis, ), ) - _emit_measurement(self._stim_io, axis, node) + _emit_measurement(self._stim_io, axis, node, self._p_before_meas_flip) self._meas_order[node] = self._rec_index self._rec_index += 1 self._alive_nodes.discard(node) diff --git a/tests/test_stim_compiler.py b/tests/test_stim_compiler.py index ed28888a..3e88d0a3 100644 --- a/tests/test_stim_compiler.py +++ b/tests/test_stim_compiler.py @@ -173,14 +173,8 @@ def test_stim_compile_with_measurement_errors_x() -> None: stim_str = stim_compile(pattern, p_before_meas_flip=0.01) - # For X measurement, Z_ERROR should be inserted before MX - assert "Z_ERROR(0.01)" in stim_str - lines = stim_str.split("\n") - for i, line in enumerate(lines): - if "Z_ERROR(0.01)" in line and i + 1 < len(lines): - # Next non-empty line should be MX - next_line = lines[i + 1] - assert "MX" in next_line + # For X measurement, error probability is attached to MX instruction + assert "MX(0.01)" in stim_str def test_stim_compile_with_measurement_errors_y() -> None: @@ -189,9 +183,8 @@ def test_stim_compile_with_measurement_errors_y() -> None: stim_str = stim_compile(pattern, p_before_meas_flip=0.01) - # For Y measurement, both X_ERROR and Z_ERROR should be inserted before MY - assert "X_ERROR(0.01)" in stim_str - assert "Z_ERROR(0.01)" in stim_str + # For Y measurement, error probability is attached to MY instruction + assert "MY(0.01)" in stim_str def test_stim_compile_with_measurement_errors_z() -> None: @@ -200,14 +193,8 @@ def test_stim_compile_with_measurement_errors_z() -> None: stim_str = stim_compile(pattern, p_before_meas_flip=0.01) - # For Z measurement, X_ERROR should be inserted before MZ - assert "X_ERROR(0.01)" in stim_str - lines = stim_str.split("\n") - for i, line in enumerate(lines): - if "X_ERROR(0.01)" in line and i + 1 < len(lines): - # Next non-empty line should be MZ - next_line = lines[i + 1] - assert "MZ" in next_line + # For Z measurement, error probability is attached to MZ instruction + assert "MZ(0.01)" in stim_str def test_stim_compile_with_detectors() -> None: From e2d1177835dc4afb27ab5fdfea24928a5fb4ad17 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Fri, 6 Feb 2026 14:57:04 +0900 Subject: [PATCH 03/14] Add comprehensive docstrings and Sphinx documentation for noise_model Enhance noise_model.py with detailed NumPy-style docstrings including: - Module-level documentation with usage examples - NoiseKind enum descriptions for each event type - NoiseOp and NoiseEvent parameter documentation with examples - NoiseModel abstract class with comprehensive examples and notes Add Sphinx documentation: - Create docs/source/noise_model.rst for API reference - Add noise_model to module reference in references.rst Co-Authored-By: Claude Opus 4.5 --- docs/source/noise_model.rst | 7 ++ docs/source/references.rst | 1 + graphqomb/noise_model.py | 191 ++++++++++++++++++++++++++++++++++-- 3 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 docs/source/noise_model.rst diff --git a/docs/source/noise_model.rst b/docs/source/noise_model.rst new file mode 100644 index 00000000..ea1bc015 --- /dev/null +++ b/docs/source/noise_model.rst @@ -0,0 +1,7 @@ +Noise Model +=========== + +.. automodule:: graphqomb.noise_model + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/references.rst b/docs/source/references.rst index 5bc98498..984e0783 100644 --- a/docs/source/references.rst +++ b/docs/source/references.rst @@ -21,4 +21,5 @@ Module reference qompiler scheduler stim_compiler + noise_model visualizer diff --git a/graphqomb/noise_model.py b/graphqomb/noise_model.py index fc9820b6..57d7a9cd 100644 --- a/graphqomb/noise_model.py +++ b/graphqomb/noise_model.py @@ -1,4 +1,45 @@ -"""Noise model interface for Stim circuit compilation.""" +r"""Noise model interface for Stim circuit compilation. + +This module provides an abstract interface for injecting custom noise into +Stim circuits during pattern compilation. Users can implement the +:class:`NoiseModel` abstract base class to define noise behavior for +different events (preparation, entanglement, measurement, and idle). + +Examples +-------- +Create a simple depolarizing noise model: + +>>> from graphqomb.noise_model import NoiseModel, NoiseEvent, NoiseKind, NoiseOp +>>> +>>> class DepolarizingNoise(NoiseModel): +... def __init__(self, p: float) -> None: +... self.p = p +... +... def emit(self, event: NoiseEvent) -> list[NoiseOp]: +... if event.kind == NoiseKind.PREPARE: +... node = event.nodes[0] +... return [NoiseOp(f"DEPOLARIZE1({self.p}) {node}")] +... elif event.kind == NoiseKind.ENTANGLE: +... n0, n1 = event.nodes +... return [NoiseOp(f"DEPOLARIZE2({self.p}) {n0} {n1}")] +... return [] + +Use a heralded noise model that adds measurement records: + +>>> class HeraldedNoise(NoiseModel): +... def emit(self, event: NoiseEvent) -> list[NoiseOp]: +... if event.kind == NoiseKind.MEASURE: +... node = event.nodes[0] +... # HERALDED_PAULI_CHANNEL_1 adds one measurement record per target +... return [NoiseOp(f"HERALDED_PAULI_CHANNEL_1(0,0,0,0.1) {node}", record_delta=1)] +... return [] + +Pass the noise model to stim_compile: + +>>> from graphqomb.stim_compiler import stim_compile +>>> # pattern = ... # your compiled pattern +>>> # stim_str = stim_compile(pattern, noise_model=DepolarizingNoise(0.001)) +""" from __future__ import annotations @@ -14,7 +55,13 @@ class NoiseKind(enum.Enum): - """Kind of noise injection event.""" + """Kind of noise injection event. + + - ``PREPARE``: Noise injected after qubit preparation (N command). + - ``ENTANGLE``: Noise injected after entanglement (E command). + - ``MEASURE``: Noise injected before measurement (M command). + - ``IDLE``: Noise injected for qubits that are idle during a TICK. + """ PREPARE = enum.auto() ENTANGLE = enum.auto() @@ -30,9 +77,19 @@ class NoiseOp: ---------- text : `str` A single Stim instruction line (without trailing newline). + For example: ``"DEPOLARIZE1(0.001) 0"`` or ``"X_ERROR(0.01) 5"``. record_delta : `int`, optional The number of measurement records appended by this instruction. - For example, HERALDED_* instructions add one record per target. + Most noise instructions do not add records (default 0). + However, ``HERALDED_*`` instructions add one record per target qubit. + + Examples + -------- + >>> NoiseOp("DEPOLARIZE1(0.001) 0") + NoiseOp(text='DEPOLARIZE1(0.001) 0', record_delta=0) + + >>> NoiseOp("HERALDED_PAULI_CHANNEL_1(0,0,0,0.1) 5", record_delta=1) + NoiseOp(text='HERALDED_PAULI_CHANNEL_1(0,0,0,0.1) 5', record_delta=1) """ text: str @@ -41,7 +98,65 @@ class NoiseOp: @dataclass(frozen=True) class NoiseEvent: - """Context describing where noise should be injected.""" + r"""Context describing where noise should be injected. + + This dataclass is passed to :meth:`NoiseModel.emit` to provide full context + about the current compilation state and the operation being performed. + + Parameters + ---------- + kind : `NoiseKind` + The type of event triggering noise injection. + tick : `int` + The current tick (time step) in the pattern execution. + Starts at 0 and increments with each TICK command. + nodes : `tuple`\[`int`, ...] + The node indices involved in this event. + For PREPARE/MEASURE: single node ``(node,)``. + For ENTANGLE: two nodes ``(node1, node2)``. + For IDLE: all idle nodes ``(node1, node2, ...)``. + edge : `tuple`\[`int`, `int`\] | `None` + For ENTANGLE events, the edge as ``(min_node, max_node)``. + None for other event kinds. + coords : `tuple`\[`tuple`\[`float`, `float`\] | `None`, ...] + The (x, y) coordinates for each node in ``nodes``, if available. + None for nodes without assigned coordinates. + axis : `Axis` | `None` + For MEASURE events, the measurement axis (X, Y, or Z). + None for other event kinds. + duration : `float` | `None`, optional + For IDLE events, the duration of the idle period (from ``tick_duration``). + None for other event kinds. Default is None. + is_input : `bool`, optional + For PREPARE events, whether this is an input node of the pattern. + Input nodes may require different noise treatment. Default is False. + + Examples + -------- + A preparation event for node 5 at tick 0: + + >>> from graphqomb.noise_model import NoiseEvent, NoiseKind + >>> event = NoiseEvent( + ... kind=NoiseKind.PREPARE, + ... tick=0, + ... nodes=(5,), + ... edge=None, + ... coords=((1.0, 2.0),), + ... axis=None, + ... is_input=True, + ... ) + + An entanglement event between nodes 3 and 7: + + >>> event = NoiseEvent( + ... kind=NoiseKind.ENTANGLE, + ... tick=1, + ... nodes=(3, 7), + ... edge=(3, 7), + ... coords=((0.0, 0.0), (1.0, 0.0)), + ... axis=None, + ... ) + """ kind: NoiseKind tick: int @@ -54,9 +169,73 @@ class NoiseEvent: class NoiseModel(abc.ABC): - """Abstract base class for custom noise injection during Stim compilation.""" + r"""Abstract base class for custom noise injection during Stim compilation. + + Subclass this to define custom noise behavior. The :meth:`emit` method + is called at each event during pattern compilation, allowing you to + inject arbitrary Stim noise instructions. + + Examples + -------- + A noise model that adds different noise for each event type: + + >>> from graphqomb.noise_model import NoiseModel, NoiseEvent, NoiseKind, NoiseOp + >>> + >>> class CustomNoise(NoiseModel): + ... def emit(self, event: NoiseEvent) -> list[NoiseOp]: + ... ops: list[NoiseOp] = [] + ... if event.kind == NoiseKind.PREPARE: + ... # Add preparation error + ... ops.append(NoiseOp(f"X_ERROR(0.001) {event.nodes[0]}")) + ... elif event.kind == NoiseKind.ENTANGLE: + ... # Add two-qubit depolarizing noise + ... n0, n1 = event.nodes + ... ops.append(NoiseOp(f"DEPOLARIZE2(0.01) {n0} {n1}")) + ... elif event.kind == NoiseKind.MEASURE: + ... # Add measurement error via heralded channel + ... node = event.nodes[0] + ... ops.append(NoiseOp(f"HERALDED_PAULI_CHANNEL_1(0,0,0,0.005) {node}", record_delta=1)) + ... elif event.kind == NoiseKind.IDLE: + ... # Add idle noise based on duration + ... if event.duration is not None: + ... p = 0.0001 * event.duration + ... for node in event.nodes: + ... ops.append(NoiseOp(f"DEPOLARIZE1({p}) {node}")) + ... return ops + + Notes + ----- + When using ``HERALDED_*`` instructions or other measurement-like operations, + set ``record_delta`` appropriately so that detector indices are computed + correctly. Each ``HERALDED_*`` instruction adds one measurement record + per target qubit. + + See Also + -------- + stim_compile : The main compilation function that accepts a NoiseModel. + """ @abc.abstractmethod def emit(self, event: NoiseEvent) -> Iterable[NoiseOp]: - r"""Return Stim instructions to inject for the given event.""" + r"""Return Stim instructions to inject for the given event. + + Parameters + ---------- + event : `NoiseEvent` + The context describing where noise should be injected. + + Returns + ------- + `collections.abc.Iterable`\[`NoiseOp`\] + Zero or more Stim instructions to insert at this point. + Return an empty iterable to skip noise injection for this event. + + Examples + -------- + >>> class SimpleNoise(NoiseModel): + ... def emit(self, event: NoiseEvent) -> list[NoiseOp]: + ... if event.kind == NoiseKind.PREPARE: + ... return [NoiseOp(f"DEPOLARIZE1(0.001) {event.nodes[0]}")] + ... return [] + """ raise NotImplementedError From 8a05457d0c8645400b159aadb044ff774396c3da Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Fri, 6 Feb 2026 16:26:22 +0900 Subject: [PATCH 04/14] Refactor noise model API to use typed dataclasses and Sequence[NoiseModel] - Replace string-based noise API with typed NoiseOp dataclasses (PauliChannel1, PauliChannel2, HeraldedPauliChannel1, HeraldedErase, RawStimOp) - Add typed event classes (PrepareEvent, EntangleEvent, MeasureEvent, IdleEvent) with NodeInfo and Coordinate for position-dependent noise models - Change NoiseModel method return type from Iterable[NoiseOp] to Sequence[NoiseOp] for covariance (allows subclasses to return Sequence[PauliChannel1] etc.) - Update stim_compile to accept Sequence[NoiseModel] instead of single NoiseModel for composing multiple independent noise sources - Add NoisePlacement enum (AUTO, BEFORE, AFTER) for controlling noise insertion - Add comprehensive tests for noise_model module Co-Authored-By: Claude Opus 4.5 --- graphqomb/noise_model.py | 755 ++++++++++++++++++++++++++++-------- graphqomb/stim_compiler.py | 187 +++++---- tests/test_noise_model.py | 484 +++++++++++++++++++++++ tests/test_stim_compiler.py | 11 +- 4 files changed, 1166 insertions(+), 271 deletions(-) create mode 100644 tests/test_noise_model.py diff --git a/graphqomb/noise_model.py b/graphqomb/noise_model.py index 57d7a9cd..c2a7d94e 100644 --- a/graphqomb/noise_model.py +++ b/graphqomb/noise_model.py @@ -1,241 +1,660 @@ r"""Noise model interface for Stim circuit compilation. -This module provides an abstract interface for injecting custom noise into -Stim circuits during pattern compilation. Users can implement the -:class:`NoiseModel` abstract base class to define noise behavior for -different events (preparation, entanglement, measurement, and idle). +This module provides an event-based API for injecting custom noise instructions +into Stim circuits during pattern compilation. Instead of raw strings, the API +uses typed dataclass objects that are converted to valid Stim syntax. + +Overview +-------- +The noise model system consists of three main components: + +1. **Events**: Dataclasses describing when noise can be injected + (``PrepareEvent``, ``EntangleEvent``, ``MeasureEvent``, ``IdleEvent``). + +2. **NoiseOp types**: Typed representations of Stim noise instructions + (``PauliChannel1``, ``PauliChannel2``, ``HeraldedPauliChannel1``, + ``HeraldedErase``, ``RawStimOp``). + +3. **NoiseModel base class**: Subclass this to define custom noise behavior + by overriding the ``on_prepare``, ``on_entangle``, ``on_measure``, and + ``on_idle`` methods. Examples -------- Create a simple depolarizing noise model: ->>> from graphqomb.noise_model import NoiseModel, NoiseEvent, NoiseKind, NoiseOp +>>> from graphqomb.noise_model import ( +... NoiseModel, PrepareEvent, EntangleEvent, PauliChannel1, PauliChannel2 +... ) >>> >>> class DepolarizingNoise(NoiseModel): -... def __init__(self, p: float) -> None: -... self.p = p +... def __init__(self, p1: float, p2: float) -> None: +... self.p1 = p1 # Single-qubit depolarizing probability +... self.p2 = p2 # Two-qubit depolarizing probability +... +... def on_prepare(self, event: PrepareEvent) -> list[PauliChannel1]: +... p = self.p1 / 3 # Equal probability for X, Y, Z +... return [PauliChannel1(px=p, py=p, pz=p, targets=[event.node.id])] ... -... def emit(self, event: NoiseEvent) -> list[NoiseOp]: -... if event.kind == NoiseKind.PREPARE: -... node = event.nodes[0] -... return [NoiseOp(f"DEPOLARIZE1({self.p}) {node}")] -... elif event.kind == NoiseKind.ENTANGLE: -... n0, n1 = event.nodes -... return [NoiseOp(f"DEPOLARIZE2({self.p}) {n0} {n1}")] -... return [] - -Use a heralded noise model that adds measurement records: - ->>> class HeraldedNoise(NoiseModel): -... def emit(self, event: NoiseEvent) -> list[NoiseOp]: -... if event.kind == NoiseKind.MEASURE: -... node = event.nodes[0] -... # HERALDED_PAULI_CHANNEL_1 adds one measurement record per target -... return [NoiseOp(f"HERALDED_PAULI_CHANNEL_1(0,0,0,0.1) {node}", record_delta=1)] -... return [] - -Pass the noise model to stim_compile: +... def on_entangle(self, event: EntangleEvent) -> list[PauliChannel2]: +... # Simplified: only add ZZ error +... return [PauliChannel2( +... probabilities={"ZZ": self.p2}, +... targets=[(event.node0.id, event.node1.id)] +... )] + +Use with stim_compile: >>> from graphqomb.stim_compiler import stim_compile >>> # pattern = ... # your compiled pattern ->>> # stim_str = stim_compile(pattern, noise_model=DepolarizingNoise(0.001)) +>>> # stim_str = stim_compile(pattern, noise_model=DepolarizingNoise(0.001, 0.01)) + +Use heralded noise that adds measurement records: + +>>> from graphqomb.noise_model import NoiseModel, MeasureEvent, HeraldedPauliChannel1 +>>> +>>> class HeraldedMeasurementNoise(NoiseModel): +... def on_measure(self, event: MeasureEvent) -> list[HeraldedPauliChannel1]: +... # Heralded erasure with 10% probability +... return [HeraldedPauliChannel1( +... pi=0.1, px=0.0, py=0.0, pz=0.0, +... targets=[event.node.id] +... )] + +Notes +----- +- **Placement control**: Each ``NoiseOp`` has a ``placement`` attribute. + ``AUTO`` defers to :meth:`NoiseModel.default_placement`, while + ``BEFORE``/``AFTER`` force insertion side. + +- **Record delta**: Heralded instructions (``HeraldedPauliChannel1``, + ``HeraldedErase``) add measurement records. The compiler automatically + tracks these to compute correct detector indices. + +- **Coordinate access**: Events provide ``NodeInfo`` objects with optional + coordinates, useful for position-dependent noise models. + +See Also +-------- +stim_compile : The main compilation function that accepts a NoiseModel. """ from __future__ import annotations -import abc -import enum +from collections.abc import Mapping, Sequence from dataclasses import dataclass +from enum import Enum, auto from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Iterable - from graphqomb.common import Axis -class NoiseKind(enum.Enum): - """Kind of noise injection event. +PAULI_CHANNEL_2_ORDER: tuple[str, ...] = ( + "IX", + "IY", + "IZ", + "XI", + "XX", + "XY", + "XZ", + "YI", + "YX", + "YY", + "YZ", + "ZI", + "ZX", + "ZY", + "ZZ", +) - - ``PREPARE``: Noise injected after qubit preparation (N command). - - ``ENTANGLE``: Noise injected after entanglement (E command). - - ``MEASURE``: Noise injected before measurement (M command). - - ``IDLE``: Noise injected for qubits that are idle during a TICK. - """ - PREPARE = enum.auto() - ENTANGLE = enum.auto() - MEASURE = enum.auto() - IDLE = enum.auto() +class NoisePlacement(Enum): + """Where to insert noise relative to the main operation.""" + + AUTO = auto() + BEFORE = auto() + AFTER = auto() @dataclass(frozen=True) -class NoiseOp: - r"""A single Stim instruction plus its measurement record delta. +class Coordinate: + """N-dimensional coordinate for a node. Parameters ---------- - text : `str` - A single Stim instruction line (without trailing newline). - For example: ``"DEPOLARIZE1(0.001) 0"`` or ``"X_ERROR(0.01) 5"``. - record_delta : `int`, optional - The number of measurement records appended by this instruction. - Most noise instructions do not add records (default 0). - However, ``HERALDED_*`` instructions add one record per target qubit. + values : tuple[float, ...] + The coordinate values as a tuple of floats. Examples -------- - >>> NoiseOp("DEPOLARIZE1(0.001) 0") - NoiseOp(text='DEPOLARIZE1(0.001) 0', record_delta=0) + >>> coord = Coordinate((1.0, 2.0, 3.0)) + >>> coord.xy + (1.0, 2.0) + >>> coord.xyz + (1.0, 2.0, 3.0) + """ + + values: tuple[float, ...] + + @property + def xy(self) -> tuple[float, float] | None: + """Return the first two dimensions as (x, y), or None if fewer than 2 dimensions.""" + if len(self.values) < 2: # noqa: PLR2004 + return None + return (self.values[0], self.values[1]) + + @property + def xyz(self) -> tuple[float, float, float] | None: + """Return the first three dimensions as (x, y, z), or None if fewer than 3 dimensions.""" + if len(self.values) < 3: # noqa: PLR2004 + return None + return (self.values[0], self.values[1], self.values[2]) + + +@dataclass(frozen=True) +class NodeInfo: + """Node identifier with optional coordinate. - >>> NoiseOp("HERALDED_PAULI_CHANNEL_1(0,0,0,0.1) 5", record_delta=1) - NoiseOp(text='HERALDED_PAULI_CHANNEL_1(0,0,0,0.1) 5', record_delta=1) + Parameters + ---------- + id : int + The unique node index in the pattern. + coord : Coordinate | None + The spatial coordinate of the node, if available. """ - text: str - record_delta: int = 0 + id: int + coord: Coordinate | None @dataclass(frozen=True) -class NoiseEvent: - r"""Context describing where noise should be injected. +class PrepareEvent: + """Event emitted when a qubit is prepared (N command). + + Parameters + ---------- + time : int + The current tick (time step) in the pattern execution. + node : NodeInfo + Information about the node being prepared. + is_input : bool + Whether this node is an input node of the pattern. + Input nodes may require different noise treatment. + """ + + time: int + node: NodeInfo + is_input: bool - This dataclass is passed to :meth:`NoiseModel.emit` to provide full context - about the current compilation state and the operation being performed. + +@dataclass(frozen=True) +class EntangleEvent: + """Event emitted when two qubits are entangled (E command / CZ gate). Parameters ---------- - kind : `NoiseKind` - The type of event triggering noise injection. - tick : `int` + time : int The current tick (time step) in the pattern execution. - Starts at 0 and increments with each TICK command. - nodes : `tuple`\[`int`, ...] - The node indices involved in this event. - For PREPARE/MEASURE: single node ``(node,)``. - For ENTANGLE: two nodes ``(node1, node2)``. - For IDLE: all idle nodes ``(node1, node2, ...)``. - edge : `tuple`\[`int`, `int`\] | `None` - For ENTANGLE events, the edge as ``(min_node, max_node)``. - None for other event kinds. - coords : `tuple`\[`tuple`\[`float`, `float`\] | `None`, ...] - The (x, y) coordinates for each node in ``nodes``, if available. - None for nodes without assigned coordinates. - axis : `Axis` | `None` - For MEASURE events, the measurement axis (X, Y, or Z). - None for other event kinds. - duration : `float` | `None`, optional - For IDLE events, the duration of the idle period (from ``tick_duration``). - None for other event kinds. Default is None. - is_input : `bool`, optional - For PREPARE events, whether this is an input node of the pattern. - Input nodes may require different noise treatment. Default is False. + node0 : NodeInfo + Information about the first node in the entanglement. + node1 : NodeInfo + Information about the second node in the entanglement. + edge : tuple[int, int] + The edge as ``(min_node_id, max_node_id)``. + """ + + time: int + node0: NodeInfo + node1: NodeInfo + edge: tuple[int, int] + + +@dataclass(frozen=True) +class MeasureEvent: + """Event emitted when a qubit is measured (M command). + + Parameters + ---------- + time : int + The current tick (time step) in the pattern execution. + node : NodeInfo + Information about the node being measured. + axis : Axis + The measurement axis (X, Y, or Z). + """ + + time: int + node: NodeInfo + axis: Axis + + +@dataclass(frozen=True) +class IdleEvent: + """Event emitted for qubits that are idle during a TICK. + + Parameters + ---------- + time : int + The current tick (time step) in the pattern execution. + nodes : Sequence[NodeInfo] + Information about all nodes that are idle during this tick. + duration : float + The duration of the idle period (from ``tick_duration`` parameter). + """ + + time: int + nodes: Sequence[NodeInfo] + duration: float + + +NoiseEvent = PrepareEvent | EntangleEvent | MeasureEvent | IdleEvent +"""Union type of all noise event types.""" + + +@dataclass(frozen=True) +class PauliChannel1: + """Single-qubit Pauli channel noise operation. + + Applies independent X, Y, Z errors with given probabilities. + Corresponds to Stim's ``PAULI_CHANNEL_1`` instruction. + + Parameters + ---------- + px : float + Probability of X error. + py : float + Probability of Y error. + pz : float + Probability of Z error. + targets : Sequence[int] + Target qubit indices. + placement : NoisePlacement + Whether to insert before or after the main operation. + ``AUTO`` defers to the model's default placement for the event. Examples -------- - A preparation event for node 5 at tick 0: - - >>> from graphqomb.noise_model import NoiseEvent, NoiseKind - >>> event = NoiseEvent( - ... kind=NoiseKind.PREPARE, - ... tick=0, - ... nodes=(5,), - ... edge=None, - ... coords=((1.0, 2.0),), - ... axis=None, - ... is_input=True, - ... ) - - An entanglement event between nodes 3 and 7: - - >>> event = NoiseEvent( - ... kind=NoiseKind.ENTANGLE, - ... tick=1, - ... nodes=(3, 7), - ... edge=(3, 7), - ... coords=((0.0, 0.0), (1.0, 0.0)), - ... axis=None, - ... ) + >>> op = PauliChannel1(px=0.01, py=0.01, pz=0.01, targets=[0, 1]) + >>> noise_op_to_stim(op) + ('PAULI_CHANNEL_1(0.01,0.01,0.01) 0 1', 0) """ - kind: NoiseKind - tick: int - nodes: tuple[int, ...] - edge: tuple[int, int] | None - coords: tuple[tuple[float, float] | None, ...] - axis: Axis | None - duration: float | None = None - is_input: bool = False + px: float + py: float + pz: float + targets: Sequence[int] + placement: NoisePlacement = NoisePlacement.AUTO + def __post_init__(self) -> None: + object.__setattr__(self, "targets", tuple(self.targets)) -class NoiseModel(abc.ABC): - r"""Abstract base class for custom noise injection during Stim compilation. - Subclass this to define custom noise behavior. The :meth:`emit` method - is called at each event during pattern compilation, allowing you to - inject arbitrary Stim noise instructions. +@dataclass(frozen=True) +class PauliChannel2: + """Two-qubit Pauli channel noise operation. + + Applies correlated two-qubit Pauli errors. + Corresponds to Stim's ``PAULI_CHANNEL_2`` instruction. + + Parameters + ---------- + probabilities : Sequence[float] | Mapping[str, float] + Either a sequence of 15 probabilities in the order + (IX, IY, IZ, XI, XX, XY, XZ, YI, YX, YY, YZ, ZI, ZX, ZY, ZZ), + or a mapping from Pauli string keys to probabilities. + Missing keys default to 0. + targets : Sequence[tuple[int, int]] + Target qubit pairs as ``[(q0, q1), ...]``. + placement : NoisePlacement + Whether to insert before or after the main operation. + ``AUTO`` defers to the model's default placement for the event. + + Examples + -------- + Using a mapping (recommended for sparse errors): + + >>> op = PauliChannel2(probabilities={"ZZ": 0.01}, targets=[(0, 1)]) + >>> text, delta = noise_op_to_stim(op) + >>> "PAULI_CHANNEL_2" in text + True + + Using a full probability sequence: + + >>> probs = [0.0] * 14 + [0.01] # Only ZZ error + >>> op = PauliChannel2(probabilities=probs, targets=[(2, 3)]) + """ + + probabilities: Sequence[float] | Mapping[str, float] + targets: Sequence[tuple[int, int]] + placement: NoisePlacement = NoisePlacement.AUTO + + def __post_init__(self) -> None: + object.__setattr__(self, "targets", tuple(tuple(pair) for pair in self.targets)) + + +@dataclass(frozen=True) +class HeraldedPauliChannel1: + """Heralded single-qubit Pauli channel noise operation. + + Similar to ``PauliChannel1`` but produces a herald measurement record + indicating whether an error occurred. The herald outcome is 1 if any + error occurred (including identity with probability ``pi``). + Corresponds to Stim's ``HERALDED_PAULI_CHANNEL_1`` instruction. + + Parameters + ---------- + pi : float + Probability of heralded identity (no error but flagged). + px : float + Probability of heralded X error. + py : float + Probability of heralded Y error. + pz : float + Probability of heralded Z error. + targets : Sequence[int] + Target qubit indices. + placement : NoisePlacement + Whether to insert before or after the main operation. + ``AUTO`` defers to the model's default placement for the event. + + Notes + ----- + This instruction adds one measurement record per target qubit. + The compiler automatically tracks this when computing detector indices. Examples -------- - A noise model that adds different noise for each event type: - - >>> from graphqomb.noise_model import NoiseModel, NoiseEvent, NoiseKind, NoiseOp - >>> - >>> class CustomNoise(NoiseModel): - ... def emit(self, event: NoiseEvent) -> list[NoiseOp]: - ... ops: list[NoiseOp] = [] - ... if event.kind == NoiseKind.PREPARE: - ... # Add preparation error - ... ops.append(NoiseOp(f"X_ERROR(0.001) {event.nodes[0]}")) - ... elif event.kind == NoiseKind.ENTANGLE: - ... # Add two-qubit depolarizing noise - ... n0, n1 = event.nodes - ... ops.append(NoiseOp(f"DEPOLARIZE2(0.01) {n0} {n1}")) - ... elif event.kind == NoiseKind.MEASURE: - ... # Add measurement error via heralded channel - ... node = event.nodes[0] - ... ops.append(NoiseOp(f"HERALDED_PAULI_CHANNEL_1(0,0,0,0.005) {node}", record_delta=1)) - ... elif event.kind == NoiseKind.IDLE: - ... # Add idle noise based on duration - ... if event.duration is not None: - ... p = 0.0001 * event.duration - ... for node in event.nodes: - ... ops.append(NoiseOp(f"DEPOLARIZE1({p}) {node}")) - ... return ops + >>> op = HeraldedPauliChannel1(pi=0.0, px=0.01, py=0.0, pz=0.0, targets=[5]) + >>> text, delta = noise_op_to_stim(op) + >>> text + 'HERALDED_PAULI_CHANNEL_1(0.0,0.01,0.0,0.0) 5' + >>> delta # One record added per target + 1 + """ + + pi: float + px: float + py: float + pz: float + targets: Sequence[int] + placement: NoisePlacement = NoisePlacement.AUTO + + def __post_init__(self) -> None: + object.__setattr__(self, "targets", tuple(self.targets)) + + +@dataclass(frozen=True) +class HeraldedErase: + """Heralded erasure noise operation. + + Models photon loss or erasure errors with a herald signal. + Corresponds to Stim's ``HERALDED_ERASE`` instruction. + + Parameters + ---------- + p : float + Probability of erasure. + targets : Sequence[int] + Target qubit indices. + placement : NoisePlacement + Whether to insert before or after the main operation. + ``AUTO`` defers to the model's default placement for the event. Notes ----- - When using ``HERALDED_*`` instructions or other measurement-like operations, - set ``record_delta`` appropriately so that detector indices are computed - correctly. Each ``HERALDED_*`` instruction adds one measurement record - per target qubit. + This instruction adds one measurement record per target qubit. + The compiler automatically tracks this when computing detector indices. + + Examples + -------- + >>> op = HeraldedErase(p=0.05, targets=[0, 1, 2]) + >>> text, delta = noise_op_to_stim(op) + >>> text + 'HERALDED_ERASE(0.05) 0 1 2' + >>> delta # One record added per target + 3 + """ + + p: float + targets: Sequence[int] + placement: NoisePlacement = NoisePlacement.AUTO + + def __post_init__(self) -> None: + object.__setattr__(self, "targets", tuple(self.targets)) + + +@dataclass(frozen=True) +class RawStimOp: + """Raw Stim instruction for advanced use cases. + + Use this when the typed noise operations don't cover your use case. + The text is inserted directly into the Stim circuit. + + Parameters + ---------- + text : str + A single Stim instruction line (without trailing newline). + record_delta : int + The number of measurement records added by this instruction. + Most noise instructions do not add records (default 0). + placement : NoisePlacement + Whether to insert before or after the main operation. + ``AUTO`` defers to the model's default placement for the event. + + Examples + -------- + >>> op = RawStimOp("X_ERROR(0.001) 0 1 2") + >>> noise_op_to_stim(op) + ('X_ERROR(0.001) 0 1 2', 0) + + With custom record delta for measurement-like instructions: + + >>> op = RawStimOp("MR 5", record_delta=1) + >>> noise_op_to_stim(op) + ('MR 5', 1) + """ + + text: str + record_delta: int = 0 + placement: NoisePlacement = NoisePlacement.AUTO + + +NoiseOp = PauliChannel1 | PauliChannel2 | HeraldedPauliChannel1 | HeraldedErase | RawStimOp +"""Union type of all noise operation types.""" + + +class NoiseModel: + """Base class for custom noise injection during Stim compilation. + + Subclass this to define custom noise behavior by overriding one or more + of the event handler methods. Each method receives an event object with + context about the current operation and returns noise operations to inject. + + Examples + -------- + >>> class SimpleNoise(NoiseModel): + ... def on_prepare(self, event: PrepareEvent) -> list[PauliChannel1]: + ... # Add depolarizing noise after preparation + ... p = 0.001 / 3 + ... return [PauliChannel1(px=p, py=p, pz=p, targets=[event.node.id])] + ... + ... def on_measure(self, event: MeasureEvent) -> list[PauliChannel1]: + ... # Add bit-flip noise before measurement + ... return [PauliChannel1( + ... px=0.01, py=0.0, pz=0.0, + ... targets=[event.node.id], + ... placement=NoisePlacement.BEFORE + ... )] See Also -------- stim_compile : The main compilation function that accepts a NoiseModel. """ - @abc.abstractmethod - def emit(self, event: NoiseEvent) -> Iterable[NoiseOp]: - r"""Return Stim instructions to inject for the given event. + def on_prepare(self, event: PrepareEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 + """Return noise operations to inject at qubit preparation. Parameters ---------- - event : `NoiseEvent` - The context describing where noise should be injected. + event : PrepareEvent + Context about the preparation operation. Returns ------- - `collections.abc.Iterable`\[`NoiseOp`\] - Zero or more Stim instructions to insert at this point. - Return an empty iterable to skip noise injection for this event. - - Examples - -------- - >>> class SimpleNoise(NoiseModel): - ... def emit(self, event: NoiseEvent) -> list[NoiseOp]: - ... if event.kind == NoiseKind.PREPARE: - ... return [NoiseOp(f"DEPOLARIZE1(0.001) {event.nodes[0]}")] - ... return [] + Sequence[NoiseOp] + Zero or more noise operations to inject. """ - raise NotImplementedError + return [] + + def on_entangle(self, event: EntangleEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 + """Return noise operations to inject at entanglement. + + Parameters + ---------- + event : EntangleEvent + Context about the entanglement operation. + + Returns + ------- + Sequence[NoiseOp] + Zero or more noise operations to inject. + """ + return [] + + def on_measure(self, event: MeasureEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 + """Return noise operations to inject at measurement. + + Parameters + ---------- + event : MeasureEvent + Context about the measurement operation. + + Returns + ------- + Sequence[NoiseOp] + Zero or more noise operations to inject. + """ + return [] + + def on_idle(self, event: IdleEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 + """Return noise operations to inject during idle periods. + + Parameters + ---------- + event : IdleEvent + Context about the idle period. + + Returns + ------- + Sequence[NoiseOp] + Zero or more noise operations to inject. + """ + return [] + + def default_placement(self, event: NoiseEvent) -> NoisePlacement: # noqa: PLR6301 + """Return the default placement for AUTO noise operations. + + By default, measurement noise is injected before measurement and all + other noise is injected after the main operation. + + Parameters + ---------- + event : NoiseEvent + The event for which to determine the default placement. + + Returns + ------- + NoisePlacement + ``BEFORE`` for measurement events, ``AFTER`` for all others. + """ + if isinstance(event, MeasureEvent): + return NoisePlacement.BEFORE + return NoisePlacement.AFTER + + +def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911 + """Convert a NoiseOp into a Stim instruction line and record delta. + + Parameters + ---------- + op : NoiseOp + The noise operation to convert. + + Returns + ------- + tuple[str, int] + A tuple of ``(stim_instruction, record_delta)`` where + ``stim_instruction`` is a single line of Stim code and + ``record_delta`` is the number of measurement records added. + + Raises + ------ + TypeError + If ``op`` is not a recognized NoiseOp type. + + Examples + -------- + >>> op = PauliChannel1(px=0.01, py=0.02, pz=0.03, targets=[0]) + >>> noise_op_to_stim(op) + ('PAULI_CHANNEL_1(0.01,0.02,0.03) 0', 0) + """ + if isinstance(op, RawStimOp): + return op.text, op.record_delta + + if isinstance(op, PauliChannel1): + if not op.targets: + return "", 0 + targets = " ".join(str(t) for t in op.targets) + return f"PAULI_CHANNEL_1({op.px},{op.py},{op.pz}) {targets}", 0 + + if isinstance(op, PauliChannel2): + if not op.targets: + return "", 0 + args = _pauli_channel_2_args(op.probabilities) + flat_targets = _flatten_pairs(op.targets) + targets_str = " ".join(str(t) for t in flat_targets) + args_str = ",".join(str(v) for v in args) + return f"PAULI_CHANNEL_2({args_str}) {targets_str}", 0 + + if isinstance(op, HeraldedPauliChannel1): + if not op.targets: + return "", 0 + targets = " ".join(str(t) for t in op.targets) + return ( + f"HERALDED_PAULI_CHANNEL_1({op.pi},{op.px},{op.py},{op.pz}) {targets}", + len(op.targets), + ) + + if isinstance(op, HeraldedErase): + if not op.targets: + return "", 0 + targets = " ".join(str(t) for t in op.targets) + return f"HERALDED_ERASE({op.p}) {targets}", len(op.targets) + + msg = f"Unsupported noise op type: {type(op)!r}" + raise TypeError(msg) + + +def _pauli_channel_2_args(probabilities: Sequence[float] | Mapping[str, float]) -> tuple[float, ...]: + if isinstance(probabilities, Mapping): + unknown = set(probabilities) - set(PAULI_CHANNEL_2_ORDER) + if unknown: + msg = f"Unknown PAULI_CHANNEL_2 keys: {sorted(unknown)}" + raise ValueError(msg) + return tuple(float(probabilities.get(key, 0.0)) for key in PAULI_CHANNEL_2_ORDER) + values = tuple(float(v) for v in probabilities) + if len(values) != len(PAULI_CHANNEL_2_ORDER): + msg = f"PAULI_CHANNEL_2 expects {len(PAULI_CHANNEL_2_ORDER)} probabilities, got {len(values)}" + raise ValueError(msg) + return values + + +def _flatten_pairs(pairs: Sequence[tuple[int, int]]) -> tuple[int, ...]: + flat: list[int] = [] + for pair in pairs: + if len(pair) != 2: # noqa: PLR2004 + msg = f"PAULI_CHANNEL_2 targets must be pairs, got: {pair!r}" + raise ValueError(msg) + flat.extend(pair) + return tuple(flat) diff --git a/graphqomb/stim_compiler.py b/graphqomb/stim_compiler.py index e582d94a..d95bf5ff 100644 --- a/graphqomb/stim_compiler.py +++ b/graphqomb/stim_compiler.py @@ -14,11 +14,23 @@ from graphqomb.command import TICK, E, M, N from graphqomb.common import Axis, MeasBasis, determine_pauli_axis -from graphqomb.noise_model import NoiseEvent, NoiseKind, NoiseModel +from graphqomb.noise_model import ( + Coordinate, + EntangleEvent, + IdleEvent, + MeasureEvent, + NodeInfo, + NoiseModel, + NoiseOp, + NoisePlacement, + PrepareEvent, + noise_op_to_stim, +) if TYPE_CHECKING: - from collections.abc import Collection, Iterable, Mapping, Sequence + from collections.abc import Callable, Collection, Iterable, Mapping, Sequence + from graphqomb.noise_model import NoiseEvent from graphqomb.pattern import Pattern from graphqomb.pauli_frame import PauliFrame @@ -135,30 +147,6 @@ def _emit_measurement( stim_io.write(f"{meas_instr} {node}\n") -def _emit_noise( - stim_io: StringIO, - noise_model: NoiseModel | None, - event: NoiseEvent, -) -> int: - if noise_model is None: - return 0 - record_delta = 0 - for op in noise_model.emit(event): - if op.text: - stim_io.write(f"{op.text}\n") - record_delta += op.record_delta - return record_delta - - -_MIN_COORD_DIMS = 2 - - -def _xy_coords(coordinate: tuple[float, ...] | None) -> tuple[float, float] | None: - if coordinate is None or len(coordinate) < _MIN_COORD_DIMS: - return None - return (coordinate[0], coordinate[1]) - - def _add_detectors( stim_io: StringIO, check_groups: Sequence[Collection[int]], @@ -219,7 +207,7 @@ def __init__( # noqa: PLR0913 p_depol_after_clifford: float, p_before_meas_flip: float, emit_qubit_coords: bool, - noise_model: NoiseModel | None, + noise_models: Sequence[NoiseModel], tick_duration: float, ) -> None: self._pattern = pattern @@ -228,7 +216,7 @@ def __init__( # noqa: PLR0913 self._p_depol_after_clifford = p_depol_after_clifford self._p_before_meas_flip = p_before_meas_flip self._emit_qubit_coords = emit_qubit_coords - self._noise_model = noise_model + self._noise_models = noise_models self._tick_duration = tick_duration self._stim_io = StringIO() self._meas_order: dict[int, int] = {} @@ -255,19 +243,13 @@ def compile(self, logical_observables: Mapping[int, Collection[int]] | None) -> def _emit_input_nodes(self) -> None: coordinates = self._pattern.input_coordinates if self._emit_qubit_coords else None for node in self._pattern.input_node_indices: - _prepare_nodes( - self._stim_io, - node, - self._p_depol_after_clifford, - coordinates=coordinates, - emit_qubit_coords=self._emit_qubit_coords, - ) - self._after_prepare(node, is_input=True) + coord = coordinates.get(node) if coordinates else None + self._process_prepare(node, coord, is_input=True) def _process_commands(self) -> None: for cmd in self._pattern: if isinstance(cmd, N): - self._handle_prepare(cmd.node, cmd.coordinate) + self._process_prepare(cmd.node, cmd.coordinate, is_input=False) elif isinstance(cmd, E): self._handle_entangle(cmd.nodes) elif isinstance(cmd, M): @@ -275,7 +257,12 @@ def _process_commands(self) -> None: elif isinstance(cmd, TICK): self._handle_tick() - def _handle_prepare(self, node: int, coordinate: tuple[float, ...] | None) -> None: + def _process_prepare(self, node: int, coordinate: tuple[float, ...] | None, *, is_input: bool) -> None: + event = PrepareEvent(time=self._tick, node=self._node_info(node), is_input=is_input) + ops = self._collect_noise_ops_from_models(lambda m: m.on_prepare(event)) + default_placement = self._get_default_placement(event) + self._rec_index += self._emit_noise_ops(ops, NoisePlacement.BEFORE, default_placement) + coordinates = {node: coordinate} if self._emit_qubit_coords and coordinate is not None else None _prepare_nodes( self._stim_io, @@ -284,84 +271,91 @@ def _handle_prepare(self, node: int, coordinate: tuple[float, ...] | None) -> No coordinates=coordinates, emit_qubit_coords=self._emit_qubit_coords, ) - self._after_prepare(node, is_input=False) - def _after_prepare(self, node: int, *, is_input: bool) -> None: + self._rec_index += self._emit_noise_ops(ops, NoisePlacement.AFTER, default_placement) self._alive_nodes.add(node) self._touched_nodes.add(node) - self._apply_noise( - NoiseEvent( - kind=NoiseKind.PREPARE, - tick=self._tick, - nodes=(node,), - edge=None, - coords=(self._coords_for(node),), - axis=None, - is_input=is_input, - ), - ) def _handle_entangle(self, nodes: tuple[int, int]) -> None: - _entangle_nodes(self._stim_io, nodes, self._p_depol_after_clifford) - self._touched_nodes.update(nodes) n0, n1 = nodes edge: tuple[int, int] = (n0, n1) if n0 < n1 else (n1, n0) - self._apply_noise( - NoiseEvent( - kind=NoiseKind.ENTANGLE, - tick=self._tick, - nodes=nodes, - edge=edge, - coords=(self._coords_for(nodes[0]), self._coords_for(nodes[1])), - axis=None, - ), - ) + event = EntangleEvent(time=self._tick, node0=self._node_info(n0), node1=self._node_info(n1), edge=edge) + ops = self._collect_noise_ops_from_models(lambda m: m.on_entangle(event)) + default_placement = self._get_default_placement(event) + self._rec_index += self._emit_noise_ops(ops, NoisePlacement.BEFORE, default_placement) + + _entangle_nodes(self._stim_io, nodes, self._p_depol_after_clifford) + self._touched_nodes.update(nodes) + self._rec_index += self._emit_noise_ops(ops, NoisePlacement.AFTER, default_placement) def _handle_measure(self, node: int, meas_basis: MeasBasis) -> None: axis = determine_pauli_axis(meas_basis) if axis is None: msg = f"Unsupported measurement basis: {meas_basis.plane, meas_basis.angle}" raise ValueError(msg) - self._apply_noise( - NoiseEvent( - kind=NoiseKind.MEASURE, - tick=self._tick, - nodes=(node,), - edge=None, - coords=(self._coords_for(node),), - axis=axis, - ), - ) + event = MeasureEvent(time=self._tick, node=self._node_info(node), axis=axis) + ops = self._collect_noise_ops_from_models(lambda m: m.on_measure(event)) + default_placement = self._get_default_placement(event) + self._rec_index += self._emit_noise_ops(ops, NoisePlacement.BEFORE, default_placement) _emit_measurement(self._stim_io, axis, node, self._p_before_meas_flip) self._meas_order[node] = self._rec_index self._rec_index += 1 self._alive_nodes.discard(node) self._touched_nodes.add(node) + self._rec_index += self._emit_noise_ops(ops, NoisePlacement.AFTER, default_placement) def _handle_tick(self) -> None: - if self._noise_model is not None: - idle_nodes = sorted(self._alive_nodes - self._touched_nodes) - if idle_nodes: - self._apply_noise( - NoiseEvent( - kind=NoiseKind.IDLE, - tick=self._tick, - nodes=tuple(idle_nodes), - edge=None, - coords=tuple(self._coords_for(node) for node in idle_nodes), - axis=None, - duration=self._tick_duration, - ), - ) + idle_nodes = sorted(self._alive_nodes - self._touched_nodes) + if idle_nodes and self._noise_models: + event = IdleEvent( + time=self._tick, + nodes=tuple(self._node_info(node) for node in idle_nodes), + duration=self._tick_duration, + ) + ops = self._collect_noise_ops_from_models(lambda m: m.on_idle(event)) + default_placement = self._get_default_placement(event) + else: + ops = () + default_placement = NoisePlacement.AFTER + self._rec_index += self._emit_noise_ops(ops, NoisePlacement.BEFORE, default_placement) self._stim_io.write("TICK\n") + self._rec_index += self._emit_noise_ops(ops, NoisePlacement.AFTER, default_placement) self._touched_nodes.clear() self._tick += 1 - def _apply_noise(self, event: NoiseEvent) -> None: - self._rec_index += _emit_noise(self._stim_io, self._noise_model, event) - - def _coords_for(self, node: int) -> tuple[float, float] | None: - return _xy_coords(self._coord_lookup.get(node)) + def _node_info(self, node: int) -> NodeInfo: + coord_raw = self._coord_lookup.get(node) + coord = Coordinate(tuple(coord_raw)) if coord_raw is not None else None + return NodeInfo(id=node, coord=coord) + + def _collect_noise_ops_from_models( + self, get_ops: Callable[[NoiseModel], Iterable[NoiseOp]] + ) -> tuple[NoiseOp, ...]: + ops: list[NoiseOp] = [] + for model in self._noise_models: + ops.extend(get_ops(model)) + return tuple(ops) + + def _get_default_placement(self, event: NoiseEvent) -> NoisePlacement: + if self._noise_models: + return self._noise_models[0].default_placement(event) + return NoisePlacement.AFTER + + def _emit_noise_ops( + self, ops: Iterable[NoiseOp], placement: NoisePlacement, default_placement: NoisePlacement + ) -> int: + record_delta = 0 + for op in ops: + op_placement = op.placement + if op_placement is NoisePlacement.AUTO: + op_placement = default_placement + if op_placement is not placement: + continue + text, delta = noise_op_to_stim(op) + if text: + self._stim_io.write(f"{text}\n") + record_delta += delta + return record_delta def stim_compile( # noqa: PLR0913 @@ -371,7 +365,7 @@ def stim_compile( # noqa: PLR0913 p_depol_after_clifford: float = 0.0, p_before_meas_flip: float = 0.0, emit_qubit_coords: bool = True, - noise_model: NoiseModel | None = None, + noise_models: Sequence[NoiseModel] | None = None, tick_duration: float = 1.0, ) -> str: r"""Compile a pattern to stim format. @@ -389,8 +383,9 @@ def stim_compile( # noqa: PLR0913 emit_qubit_coords : `bool`, optional Whether to emit QUBIT_COORDS instructions for nodes with coordinates, by default True. - noise_model : `NoiseModel` | `None`, optional - Custom noise model for injecting Stim noise instructions, by default None. + noise_models : `collections.abc.Sequence`\[`NoiseModel`\] | `None`, optional + Custom noise models for injecting Stim noise instructions, by default None. + Multiple models are combined using ``CompositeNoiseModel``. tick_duration : `float`, optional Duration associated with each TICK for idle noise, by default 1.0. @@ -410,7 +405,7 @@ def stim_compile( # noqa: PLR0913 p_depol_after_clifford=p_depol_after_clifford, p_before_meas_flip=p_before_meas_flip, emit_qubit_coords=emit_qubit_coords, - noise_model=noise_model, + noise_models=noise_models or (), tick_duration=tick_duration, ) return compiler.compile(logical_observables) diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py new file mode 100644 index 00000000..b85c3856 --- /dev/null +++ b/tests/test_noise_model.py @@ -0,0 +1,484 @@ +"""Tests for noise_model module.""" + +from __future__ import annotations + +import pytest + +from graphqomb.common import Axis +from graphqomb.noise_model import ( + PAULI_CHANNEL_2_ORDER, + Coordinate, + EntangleEvent, + HeraldedErase, + HeraldedPauliChannel1, + IdleEvent, + MeasureEvent, + NodeInfo, + NoiseModel, + NoisePlacement, + PauliChannel1, + PauliChannel2, + PrepareEvent, + RawStimOp, + noise_op_to_stim, +) + +# ---- Coordinate Tests ---- + + +class TestCoordinate: + """Tests for Coordinate dataclass.""" + + def test_xy_with_2d(self) -> None: + """Test xy property with 2D coordinates.""" + coord = Coordinate((1.0, 2.0)) + assert coord.xy == (1.0, 2.0) + + def test_xy_with_3d(self) -> None: + """Test xy property with 3D coordinates.""" + coord = Coordinate((1.0, 2.0, 3.0)) + assert coord.xy == (1.0, 2.0) + + def test_xy_with_1d(self) -> None: + """Test xy property with 1D coordinates returns None.""" + coord = Coordinate((1.0,)) + assert coord.xy is None + + def test_xyz_with_3d(self) -> None: + """Test xyz property with 3D coordinates.""" + coord = Coordinate((1.0, 2.0, 3.0)) + assert coord.xyz == (1.0, 2.0, 3.0) + + def test_xyz_with_2d(self) -> None: + """Test xyz property with 2D coordinates returns None.""" + coord = Coordinate((1.0, 2.0)) + assert coord.xyz is None + + def test_xyz_with_4d(self) -> None: + """Test xyz property with 4D coordinates.""" + coord = Coordinate((1.0, 2.0, 3.0, 4.0)) + assert coord.xyz == (1.0, 2.0, 3.0) + + +# ---- NodeInfo Tests ---- + + +class TestNodeInfo: + """Tests for NodeInfo dataclass.""" + + def test_with_coordinate(self) -> None: + """Test NodeInfo with coordinate.""" + coord = Coordinate((1.0, 2.0)) + info = NodeInfo(id=5, coord=coord) + assert info.id == 5 + assert info.coord is not None + assert info.coord.xy == (1.0, 2.0) + + def test_without_coordinate(self) -> None: + """Test NodeInfo without coordinate.""" + info = NodeInfo(id=3, coord=None) + assert info.id == 3 + assert info.coord is None + + +# ---- Event Tests ---- + + +class TestPrepareEvent: + """Tests for PrepareEvent dataclass.""" + + def test_basic(self) -> None: + """Test basic PrepareEvent creation.""" + node = NodeInfo(id=0, coord=None) + event = PrepareEvent(time=0, node=node, is_input=True) + assert event.time == 0 + assert event.node.id == 0 + assert event.is_input is True + + +class TestEntangleEvent: + """Tests for EntangleEvent dataclass.""" + + def test_basic(self) -> None: + """Test basic EntangleEvent creation.""" + node0 = NodeInfo(id=0, coord=None) + node1 = NodeInfo(id=1, coord=None) + event = EntangleEvent(time=1, node0=node0, node1=node1, edge=(0, 1)) + assert event.time == 1 + assert event.node0.id == 0 + assert event.node1.id == 1 + assert event.edge == (0, 1) + + +class TestMeasureEvent: + """Tests for MeasureEvent dataclass.""" + + def test_basic(self) -> None: + """Test basic MeasureEvent creation.""" + node = NodeInfo(id=2, coord=None) + event = MeasureEvent(time=2, node=node, axis=Axis.X) + assert event.time == 2 + assert event.node.id == 2 + assert event.axis == Axis.X + + +class TestIdleEvent: + """Tests for IdleEvent dataclass.""" + + def test_basic(self) -> None: + """Test basic IdleEvent creation.""" + nodes = [NodeInfo(id=i, coord=None) for i in range(3)] + event = IdleEvent(time=1, nodes=nodes, duration=1.0) + assert event.time == 1 + assert len(event.nodes) == 3 + assert event.duration == 1.0 + + +# ---- NoiseOp Tests ---- + + +class TestPauliChannel1: + """Tests for PauliChannel1 noise operation.""" + + def test_basic(self) -> None: + """Test basic PauliChannel1 creation and conversion.""" + op = PauliChannel1(px=0.01, py=0.02, pz=0.03, targets=[0, 1]) + text, delta = noise_op_to_stim(op) + assert text == "PAULI_CHANNEL_1(0.01,0.02,0.03) 0 1" + assert delta == 0 + + def test_single_target(self) -> None: + """Test PauliChannel1 with single target.""" + op = PauliChannel1(px=0.1, py=0.0, pz=0.0, targets=[5]) + text, delta = noise_op_to_stim(op) + assert text == "PAULI_CHANNEL_1(0.1,0.0,0.0) 5" + assert delta == 0 + + def test_empty_targets(self) -> None: + """Test PauliChannel1 with empty targets returns empty string.""" + op = PauliChannel1(px=0.01, py=0.01, pz=0.01, targets=[]) + text, delta = noise_op_to_stim(op) + assert not text + assert delta == 0 + + def test_placement_before(self) -> None: + """Test PauliChannel1 with BEFORE placement.""" + op = PauliChannel1(px=0.01, py=0.0, pz=0.0, targets=[0], placement=NoisePlacement.BEFORE) + assert op.placement == NoisePlacement.BEFORE + + def test_targets_converted_to_tuple(self) -> None: + """Test that targets list is converted to tuple.""" + op = PauliChannel1(px=0.01, py=0.0, pz=0.0, targets=[0, 1, 2]) + assert isinstance(op.targets, tuple) + assert op.targets == (0, 1, 2) + + +class TestPauliChannel2: + """Tests for PauliChannel2 noise operation.""" + + def test_with_mapping(self) -> None: + """Test PauliChannel2 with mapping probabilities.""" + op = PauliChannel2(probabilities={"ZZ": 0.01, "XX": 0.005}, targets=[(0, 1)]) + text, delta = noise_op_to_stim(op) + assert "PAULI_CHANNEL_2" in text + assert "0 1" in text + assert delta == 0 + + def test_with_sequence(self) -> None: + """Test PauliChannel2 with sequence probabilities.""" + probs = [0.0] * 15 + probs[14] = 0.01 # ZZ + op = PauliChannel2(probabilities=probs, targets=[(2, 3)]) + text, delta = noise_op_to_stim(op) + assert "PAULI_CHANNEL_2" in text + assert "2 3" in text + assert delta == 0 + + def test_multiple_pairs(self) -> None: + """Test PauliChannel2 with multiple target pairs.""" + op = PauliChannel2(probabilities={"ZZ": 0.01}, targets=[(0, 1), (2, 3)]) + text, delta = noise_op_to_stim(op) + assert "0 1 2 3" in text + assert delta == 0 + + def test_empty_targets(self) -> None: + """Test PauliChannel2 with empty targets returns empty string.""" + op = PauliChannel2(probabilities={"ZZ": 0.01}, targets=[]) + text, delta = noise_op_to_stim(op) + assert not text + assert delta == 0 + + def test_unknown_key_raises(self) -> None: + """Test PauliChannel2 with unknown key raises ValueError.""" + op = PauliChannel2(probabilities={"ZZZ": 0.01}, targets=[(0, 1)]) + with pytest.raises(ValueError, match="Unknown PAULI_CHANNEL_2 keys"): + noise_op_to_stim(op) + + def test_wrong_sequence_length_raises(self) -> None: + """Test PauliChannel2 with wrong sequence length raises ValueError.""" + op = PauliChannel2(probabilities=[0.01] * 10, targets=[(0, 1)]) + with pytest.raises(ValueError, match="PAULI_CHANNEL_2 expects 15 probabilities"): + noise_op_to_stim(op) + + def test_targets_converted_to_tuple(self) -> None: + """Test that targets list is converted to tuple of tuples.""" + op = PauliChannel2(probabilities={"ZZ": 0.01}, targets=[(0, 1), (2, 3)]) + assert isinstance(op.targets, tuple) + assert all(isinstance(pair, tuple) for pair in op.targets) + + def test_pauli_channel_2_order_has_15_elements(self) -> None: + """Test that PAULI_CHANNEL_2_ORDER has exactly 15 elements.""" + assert len(PAULI_CHANNEL_2_ORDER) == 15 + + +class TestHeraldedPauliChannel1: + """Tests for HeraldedPauliChannel1 noise operation.""" + + def test_basic(self) -> None: + """Test basic HeraldedPauliChannel1 creation and conversion.""" + op = HeraldedPauliChannel1(pi=0.0, px=0.01, py=0.0, pz=0.0, targets=[5]) + text, delta = noise_op_to_stim(op) + assert text == "HERALDED_PAULI_CHANNEL_1(0.0,0.01,0.0,0.0) 5" + assert delta == 1 + + def test_multiple_targets(self) -> None: + """Test HeraldedPauliChannel1 with multiple targets.""" + op = HeraldedPauliChannel1(pi=0.1, px=0.0, py=0.0, pz=0.0, targets=[0, 1, 2]) + text, delta = noise_op_to_stim(op) + assert text == "HERALDED_PAULI_CHANNEL_1(0.1,0.0,0.0,0.0) 0 1 2" + assert delta == 3 + + def test_empty_targets(self) -> None: + """Test HeraldedPauliChannel1 with empty targets returns empty string.""" + op = HeraldedPauliChannel1(pi=0.0, px=0.01, py=0.0, pz=0.0, targets=[]) + text, delta = noise_op_to_stim(op) + assert not text + assert delta == 0 + + def test_targets_converted_to_tuple(self) -> None: + """Test that targets list is converted to tuple.""" + op = HeraldedPauliChannel1(pi=0.0, px=0.01, py=0.0, pz=0.0, targets=[0, 1]) + assert isinstance(op.targets, tuple) + + +class TestHeraldedErase: + """Tests for HeraldedErase noise operation.""" + + def test_basic(self) -> None: + """Test basic HeraldedErase creation and conversion.""" + op = HeraldedErase(p=0.05, targets=[0]) + text, delta = noise_op_to_stim(op) + assert text == "HERALDED_ERASE(0.05) 0" + assert delta == 1 + + def test_multiple_targets(self) -> None: + """Test HeraldedErase with multiple targets.""" + op = HeraldedErase(p=0.1, targets=[0, 1, 2]) + text, delta = noise_op_to_stim(op) + assert text == "HERALDED_ERASE(0.1) 0 1 2" + assert delta == 3 + + def test_empty_targets(self) -> None: + """Test HeraldedErase with empty targets returns empty string.""" + op = HeraldedErase(p=0.05, targets=[]) + text, delta = noise_op_to_stim(op) + assert not text + assert delta == 0 + + def test_targets_converted_to_tuple(self) -> None: + """Test that targets list is converted to tuple.""" + op = HeraldedErase(p=0.05, targets=[0, 1, 2]) + assert isinstance(op.targets, tuple) + + +class TestRawStimOp: + """Tests for RawStimOp noise operation.""" + + def test_basic(self) -> None: + """Test basic RawStimOp creation and conversion.""" + op = RawStimOp(text="X_ERROR(0.001) 0 1 2") + text, delta = noise_op_to_stim(op) + assert text == "X_ERROR(0.001) 0 1 2" + assert delta == 0 + + def test_with_record_delta(self) -> None: + """Test RawStimOp with custom record delta.""" + op = RawStimOp(text="MR 5", record_delta=1) + text, delta = noise_op_to_stim(op) + assert text == "MR 5" + assert delta == 1 + + def test_empty_text(self) -> None: + """Test RawStimOp with empty text.""" + op = RawStimOp(text="") + text, delta = noise_op_to_stim(op) + assert not text + assert delta == 0 + + def test_placement_before(self) -> None: + """Test RawStimOp with BEFORE placement.""" + op = RawStimOp(text="Z_ERROR(0.01) 0", placement=NoisePlacement.BEFORE) + assert op.placement == NoisePlacement.BEFORE + + +# ---- NoiseModel Tests ---- + + +class TestNoiseModel: + """Tests for NoiseModel base class.""" + + def test_default_on_prepare_returns_empty(self) -> None: + """Test that default on_prepare returns empty list.""" + model = NoiseModel() + node = NodeInfo(id=0, coord=None) + event = PrepareEvent(time=0, node=node, is_input=False) + result = list(model.on_prepare(event)) + assert result == [] + + def test_default_on_entangle_returns_empty(self) -> None: + """Test that default on_entangle returns empty list.""" + model = NoiseModel() + node0 = NodeInfo(id=0, coord=None) + node1 = NodeInfo(id=1, coord=None) + event = EntangleEvent(time=0, node0=node0, node1=node1, edge=(0, 1)) + result = list(model.on_entangle(event)) + assert result == [] + + def test_default_on_measure_returns_empty(self) -> None: + """Test that default on_measure returns empty list.""" + model = NoiseModel() + node = NodeInfo(id=0, coord=None) + event = MeasureEvent(time=0, node=node, axis=Axis.X) + result = list(model.on_measure(event)) + assert result == [] + + def test_default_on_idle_returns_empty(self) -> None: + """Test that default on_idle returns empty list.""" + model = NoiseModel() + nodes = [NodeInfo(id=i, coord=None) for i in range(2)] + event = IdleEvent(time=0, nodes=nodes, duration=1.0) + result = list(model.on_idle(event)) + assert result == [] + + def test_default_placement_for_measure_is_before(self) -> None: + """Test that default_placement returns BEFORE for MeasureEvent.""" + model = NoiseModel() + node = NodeInfo(id=0, coord=None) + event = MeasureEvent(time=0, node=node, axis=Axis.X) + assert model.default_placement(event) == NoisePlacement.BEFORE + + def test_default_placement_for_prepare_is_after(self) -> None: + """Test that default_placement returns AFTER for PrepareEvent.""" + model = NoiseModel() + node = NodeInfo(id=0, coord=None) + event = PrepareEvent(time=0, node=node, is_input=False) + assert model.default_placement(event) == NoisePlacement.AFTER + + def test_default_placement_for_entangle_is_after(self) -> None: + """Test that default_placement returns AFTER for EntangleEvent.""" + model = NoiseModel() + node0 = NodeInfo(id=0, coord=None) + node1 = NodeInfo(id=1, coord=None) + event = EntangleEvent(time=0, node0=node0, node1=node1, edge=(0, 1)) + assert model.default_placement(event) == NoisePlacement.AFTER + + def test_default_placement_for_idle_is_after(self) -> None: + """Test that default_placement returns AFTER for IdleEvent.""" + model = NoiseModel() + nodes = [NodeInfo(id=i, coord=None) for i in range(2)] + event = IdleEvent(time=0, nodes=nodes, duration=1.0) + assert model.default_placement(event) == NoisePlacement.AFTER + + +class TestNoisePlacementAuto: + """Tests for AUTO placement behavior.""" + + def test_auto_is_default_for_pauli_channel_1(self) -> None: + """Test that AUTO is the default placement for PauliChannel1.""" + op = PauliChannel1(px=0.01, py=0.0, pz=0.0, targets=[0]) + assert op.placement == NoisePlacement.AUTO + + def test_auto_is_default_for_pauli_channel_2(self) -> None: + """Test that AUTO is the default placement for PauliChannel2.""" + op = PauliChannel2(probabilities={"ZZ": 0.01}, targets=[(0, 1)]) + assert op.placement == NoisePlacement.AUTO + + def test_auto_is_default_for_heralded_pauli_channel_1(self) -> None: + """Test that AUTO is the default placement for HeraldedPauliChannel1.""" + op = HeraldedPauliChannel1(pi=0.0, px=0.01, py=0.0, pz=0.0, targets=[0]) + assert op.placement == NoisePlacement.AUTO + + def test_auto_is_default_for_heralded_erase(self) -> None: + """Test that AUTO is the default placement for HeraldedErase.""" + op = HeraldedErase(p=0.01, targets=[0]) + assert op.placement == NoisePlacement.AUTO + + def test_auto_is_default_for_raw_stim_op(self) -> None: + """Test that AUTO is the default placement for RawStimOp.""" + op = RawStimOp(text="X_ERROR(0.01) 0") + assert op.placement == NoisePlacement.AUTO + + +class _CustomNoiseModel(NoiseModel): + """Test noise model that adds noise on all events.""" + + def __init__(self, p: float) -> None: + self.p = p + + def on_prepare(self, event: PrepareEvent) -> list[PauliChannel1]: + return [PauliChannel1(px=self.p, py=0.0, pz=0.0, targets=[event.node.id])] + + def on_entangle(self, event: EntangleEvent) -> list[PauliChannel2]: + return [PauliChannel2(probabilities={"ZZ": self.p}, targets=[(event.node0.id, event.node1.id)])] + + def on_measure(self, event: MeasureEvent) -> list[HeraldedPauliChannel1]: + return [HeraldedPauliChannel1(pi=0.0, px=self.p, py=0.0, pz=0.0, targets=[event.node.id])] + + def on_idle(self, event: IdleEvent) -> list[PauliChannel1]: + p = self.p * event.duration + targets = [n.id for n in event.nodes] + return [PauliChannel1(px=p, py=p, pz=p, targets=targets)] + + +class TestCustomNoiseModel: + """Tests for custom NoiseModel subclass.""" + + def test_on_prepare(self) -> None: + """Test custom on_prepare implementation.""" + model = _CustomNoiseModel(p=0.01) + node = NodeInfo(id=5, coord=None) + event = PrepareEvent(time=0, node=node, is_input=False) + ops = list(model.on_prepare(event)) + assert len(ops) == 1 + assert isinstance(ops[0], PauliChannel1) + assert ops[0].px == 0.01 + assert 5 in ops[0].targets + + def test_on_entangle(self) -> None: + """Test custom on_entangle implementation.""" + model = _CustomNoiseModel(p=0.02) + node0 = NodeInfo(id=0, coord=None) + node1 = NodeInfo(id=1, coord=None) + event = EntangleEvent(time=1, node0=node0, node1=node1, edge=(0, 1)) + ops = list(model.on_entangle(event)) + assert len(ops) == 1 + assert isinstance(ops[0], PauliChannel2) + + def test_on_measure(self) -> None: + """Test custom on_measure implementation.""" + model = _CustomNoiseModel(p=0.03) + node = NodeInfo(id=2, coord=None) + event = MeasureEvent(time=2, node=node, axis=Axis.Z) + ops = list(model.on_measure(event)) + assert len(ops) == 1 + assert isinstance(ops[0], HeraldedPauliChannel1) + + def test_on_idle(self) -> None: + """Test custom on_idle implementation.""" + model = _CustomNoiseModel(p=0.001) + nodes = [NodeInfo(id=i, coord=None) for i in range(3)] + event = IdleEvent(time=1, nodes=nodes, duration=2.0) + ops = list(model.on_idle(event)) + assert len(ops) == 1 + assert isinstance(ops[0], PauliChannel1) + assert ops[0].px == 0.002 # p * duration diff --git a/tests/test_stim_compiler.py b/tests/test_stim_compiler.py index 3e88d0a3..135e6fdc 100644 --- a/tests/test_stim_compiler.py +++ b/tests/test_stim_compiler.py @@ -10,7 +10,7 @@ from graphqomb.command import TICK, E from graphqomb.common import Axis, AxisMeasBasis, Plane, PlannerMeasBasis, Sign from graphqomb.graphstate import GraphState -from graphqomb.noise_model import NoiseEvent, NoiseKind, NoiseModel, NoiseOp +from graphqomb.noise_model import HeraldedPauliChannel1, MeasureEvent, NoiseModel from graphqomb.qompiler import qompile from graphqomb.schedule_solver import ScheduleConfig, Strategy from graphqomb.scheduler import Scheduler @@ -230,11 +230,8 @@ def test_stim_compile_with_detectors() -> None: class _HeraldedNoise(NoiseModel): """Test noise model that adds heralded Pauli channel on measurements.""" - def emit(self, event: NoiseEvent) -> list[NoiseOp]: - if event.kind == NoiseKind.MEASURE: - node = event.nodes[0] - return [NoiseOp(f"HERALDED_PAULI_CHANNEL_1(0,0,0,0.1) {node}", record_delta=1)] - return [] + def on_measure(self, event: MeasureEvent) -> list[HeraldedPauliChannel1]: + return [HeraldedPauliChannel1(0.0, 0.0, 0.0, 0.1, targets=[event.node.id])] def _parse_stim_measurements(stim_str: str) -> tuple[dict[int, int], int]: @@ -285,7 +282,7 @@ def test_stim_compile_with_heralded_noise_updates_detectors() -> None: parity_check_group = [{in_node}] pattern = qompile(graph, xflow, parity_check_group=parity_check_group) - stim_str = stim_compile(pattern, noise_model=_HeraldedNoise()) + stim_str = stim_compile(pattern, noise_models=[_HeraldedNoise()]) actual_meas_order, total_measurements = _parse_stim_measurements(stim_str) From c121378cb832e114a0c46ad97a7df1947fe74252 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Fri, 6 Feb 2026 17:31:52 +0900 Subject: [PATCH 05/14] Unify docstring style across noise_model and stim_compiler modules Update module docstrings to use the standard "This module provides:" format with a list of public exports. Replace all double backticks with single backticks for inline code references to match the project's documentation style conventions. Co-Authored-By: Claude Opus 4.5 --- graphqomb/noise_model.py | 204 +++++++++++++++++++++------- graphqomb/stim_compiler.py | 266 +++++++++---------------------------- 2 files changed, 222 insertions(+), 248 deletions(-) diff --git a/graphqomb/noise_model.py b/graphqomb/noise_model.py index c2a7d94e..48ed62e6 100644 --- a/graphqomb/noise_model.py +++ b/graphqomb/noise_model.py @@ -1,23 +1,19 @@ r"""Noise model interface for Stim circuit compilation. -This module provides an event-based API for injecting custom noise instructions -into Stim circuits during pattern compilation. Instead of raw strings, the API -uses typed dataclass objects that are converted to valid Stim syntax. - -Overview --------- -The noise model system consists of three main components: - -1. **Events**: Dataclasses describing when noise can be injected - (``PrepareEvent``, ``EntangleEvent``, ``MeasureEvent``, ``IdleEvent``). - -2. **NoiseOp types**: Typed representations of Stim noise instructions - (``PauliChannel1``, ``PauliChannel2``, ``HeraldedPauliChannel1``, - ``HeraldedErase``, ``RawStimOp``). - -3. **NoiseModel base class**: Subclass this to define custom noise behavior - by overriding the ``on_prepare``, ``on_entangle``, ``on_measure``, and - ``on_idle`` methods. +This module provides: + +- `NoisePlacement`: Enum for noise placement. +- `Coordinate`: N-dimensional coordinate dataclass. +- `NodeInfo`: Node identifier with optional coordinate. +- `PrepareEvent`, `EntangleEvent`, `MeasureEvent`, `IdleEvent`: Event dataclasses. +- `NoiseEvent`: Union type of all event types. +- `PauliChannel1`, `PauliChannel2`, `HeraldedPauliChannel1`, `HeraldedErase`, `RawStimOp`, + `MeasurementFlip`: NoiseOp types. +- `NoiseOp`: Union type of all noise operation types. +- `NoiseModel`: Base class for noise models. +- `DepolarizingNoiseModel`, `MeasurementFlipNoiseModel`: Built-in noise models. +- `noise_op_to_stim`: Conversion function. +- `PAULI_CHANNEL_2_ORDER`: Constant for Pauli channel order. Examples -------- @@ -63,15 +59,15 @@ Notes ----- -- **Placement control**: Each ``NoiseOp`` has a ``placement`` attribute. - ``AUTO`` defers to :meth:`NoiseModel.default_placement`, while - ``BEFORE``/``AFTER`` force insertion side. +- **Placement control**: Each `NoiseOp` has a `placement` attribute. + `AUTO` defers to :meth:`NoiseModel.default_placement`, while + `BEFORE`/`AFTER` force insertion side. -- **Record delta**: Heralded instructions (``HeraldedPauliChannel1``, - ``HeraldedErase``) add measurement records. The compiler automatically +- **Record delta**: Heralded instructions (`HeraldedPauliChannel1`, + `HeraldedErase`) add measurement records. The compiler automatically tracks these to compute correct detector indices. -- **Coordinate access**: Events provide ``NodeInfo`` objects with optional +- **Coordinate access**: Events provide `NodeInfo` objects with optional coordinates, useful for position-dependent noise models. See Also @@ -201,7 +197,7 @@ class EntangleEvent: node1 : NodeInfo Information about the second node in the entanglement. edge : tuple[int, int] - The edge as ``(min_node_id, max_node_id)``. + The edge as `(min_node_id, max_node_id)`. """ time: int @@ -240,7 +236,7 @@ class IdleEvent: nodes : Sequence[NodeInfo] Information about all nodes that are idle during this tick. duration : float - The duration of the idle period (from ``tick_duration`` parameter). + The duration of the idle period (from `tick_duration` parameter). """ time: int @@ -257,7 +253,7 @@ class PauliChannel1: """Single-qubit Pauli channel noise operation. Applies independent X, Y, Z errors with given probabilities. - Corresponds to Stim's ``PAULI_CHANNEL_1`` instruction. + Corresponds to Stim's `PAULI_CHANNEL_1` instruction. Parameters ---------- @@ -271,7 +267,7 @@ class PauliChannel1: Target qubit indices. placement : NoisePlacement Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + `AUTO` defers to the model's default placement for the event. Examples -------- @@ -295,7 +291,7 @@ class PauliChannel2: """Two-qubit Pauli channel noise operation. Applies correlated two-qubit Pauli errors. - Corresponds to Stim's ``PAULI_CHANNEL_2`` instruction. + Corresponds to Stim's `PAULI_CHANNEL_2` instruction. Parameters ---------- @@ -305,10 +301,10 @@ class PauliChannel2: or a mapping from Pauli string keys to probabilities. Missing keys default to 0. targets : Sequence[tuple[int, int]] - Target qubit pairs as ``[(q0, q1), ...]``. + Target qubit pairs as `[(q0, q1), ...]`. placement : NoisePlacement Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + `AUTO` defers to the model's default placement for the event. Examples -------- @@ -337,10 +333,10 @@ def __post_init__(self) -> None: class HeraldedPauliChannel1: """Heralded single-qubit Pauli channel noise operation. - Similar to ``PauliChannel1`` but produces a herald measurement record + Similar to `PauliChannel1` but produces a herald measurement record indicating whether an error occurred. The herald outcome is 1 if any - error occurred (including identity with probability ``pi``). - Corresponds to Stim's ``HERALDED_PAULI_CHANNEL_1`` instruction. + error occurred (including identity with probability `pi`). + Corresponds to Stim's `HERALDED_PAULI_CHANNEL_1` instruction. Parameters ---------- @@ -356,7 +352,7 @@ class HeraldedPauliChannel1: Target qubit indices. placement : NoisePlacement Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + `AUTO` defers to the model's default placement for the event. Notes ----- @@ -389,7 +385,7 @@ class HeraldedErase: """Heralded erasure noise operation. Models photon loss or erasure errors with a herald signal. - Corresponds to Stim's ``HERALDED_ERASE`` instruction. + Corresponds to Stim's `HERALDED_ERASE` instruction. Parameters ---------- @@ -399,7 +395,7 @@ class HeraldedErase: Target qubit indices. placement : NoisePlacement Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + `AUTO` defers to the model's default placement for the event. Notes ----- @@ -440,7 +436,7 @@ class RawStimOp: Most noise instructions do not add records (default 0). placement : NoisePlacement Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + `AUTO` defers to the model's default placement for the event. Examples -------- @@ -460,7 +456,31 @@ class RawStimOp: placement: NoisePlacement = NoisePlacement.AUTO -NoiseOp = PauliChannel1 | PauliChannel2 | HeraldedPauliChannel1 | HeraldedErase | RawStimOp +@dataclass(frozen=True) +class MeasurementFlip: + """Measurement flip error applied to measurement instruction. + + Unlike other NoiseOp types that insert separate instructions, + this modifies the measurement instruction itself to use Stim's + built-in measurement error probability: MX(p) instead of MX. + + Parameters + ---------- + p : float + Probability of measurement result flip. + target : int + Target qubit index (must match the measurement target). + placement : NoisePlacement + Placement attribute for compatibility (ignored, as this modifies + the measurement instruction itself). + """ + + p: float + target: int + placement: NoisePlacement = NoisePlacement.AUTO + + +NoiseOp = PauliChannel1 | PauliChannel2 | HeraldedPauliChannel1 | HeraldedErase | RawStimOp | MeasurementFlip """Union type of all noise operation types.""" @@ -566,14 +586,14 @@ def default_placement(self, event: NoiseEvent) -> NoisePlacement: # noqa: PLR63 Returns ------- NoisePlacement - ``BEFORE`` for measurement events, ``AFTER`` for all others. + `BEFORE` for measurement events, `AFTER` for all others. """ if isinstance(event, MeasureEvent): return NoisePlacement.BEFORE return NoisePlacement.AFTER -def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911 +def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911, C901 """Convert a NoiseOp into a Stim instruction line and record delta. Parameters @@ -584,14 +604,14 @@ def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911 Returns ------- tuple[str, int] - A tuple of ``(stim_instruction, record_delta)`` where - ``stim_instruction`` is a single line of Stim code and - ``record_delta`` is the number of measurement records added. + A tuple of `(stim_instruction, record_delta)` where + `stim_instruction` is a single line of Stim code and + `record_delta` is the number of measurement records added. Raises ------ TypeError - If ``op`` is not a recognized NoiseOp type. + If `op` is not a recognized NoiseOp type. Examples -------- @@ -632,6 +652,11 @@ def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911 targets = " ".join(str(t) for t in op.targets) return f"HERALDED_ERASE({op.p}) {targets}", len(op.targets) + if isinstance(op, MeasurementFlip): + # MeasurementFlip is handled specially in the compiler by modifying + # the measurement instruction. It should not be emitted as a separate op. + return "", 0 + msg = f"Unsupported noise op type: {type(op)!r}" raise TypeError(msg) @@ -658,3 +683,92 @@ def _flatten_pairs(pairs: Sequence[tuple[int, int]]) -> tuple[int, ...]: raise ValueError(msg) flat.extend(pair) return tuple(flat) + + +# ---- Built-in NoiseModel implementations ---- + + +class DepolarizingNoiseModel(NoiseModel): + """Depolarizing noise after single and two-qubit gates. + + This model adds depolarizing noise after qubit preparation (RX) and + entanglement (CZ) operations. + + Parameters + ---------- + p1 : float + Single-qubit depolarizing probability (after RX preparation). + p2 : float | None + Two-qubit depolarizing probability (after CZ). + If None, defaults to p1. + + Examples + -------- + >>> from graphqomb.noise_model import DepolarizingNoiseModel + >>> model = DepolarizingNoiseModel(p1=0.001, p2=0.01) + >>> # Use with stim_compile: + >>> # stim_compile(pattern, noise_models=[model]) + """ + + def __init__(self, p1: float, p2: float | None = None) -> None: + self._p1 = p1 + self._p2 = p2 if p2 is not None else p1 + + def on_prepare(self, event: PrepareEvent) -> Sequence[NoiseOp]: + """Add single-qubit depolarizing noise after preparation. + + Returns + ------- + Sequence[NoiseOp] + A tuple containing DEPOLARIZE1 instruction, or empty if p1 <= 0. + """ + if self._p1 <= 0: + return () + return (RawStimOp(f"DEPOLARIZE1({self._p1}) {event.node.id}"),) + + def on_entangle(self, event: EntangleEvent) -> Sequence[NoiseOp]: + """Add two-qubit depolarizing noise after entanglement. + + Returns + ------- + Sequence[NoiseOp] + A tuple containing DEPOLARIZE2 instruction, or empty if p2 <= 0. + """ + if self._p2 <= 0: + return () + return (RawStimOp(f"DEPOLARIZE2({self._p2}) {event.node0.id} {event.node1.id}"),) + + +class MeasurementFlipNoiseModel(NoiseModel): + """Measurement bit-flip noise using Stim's built-in measurement error. + + This model produces MX(p), MY(p), MZ(p) instead of MX, MY, MZ, + which adds measurement flip error with probability p. + + Parameters + ---------- + p : float + Probability of measurement result flip. + + Examples + -------- + >>> from graphqomb.noise_model import MeasurementFlipNoiseModel + >>> model = MeasurementFlipNoiseModel(p=0.001) + >>> # Use with stim_compile: + >>> # stim_compile(pattern, noise_models=[model]) + """ + + def __init__(self, p: float) -> None: + self._p = p + + def on_measure(self, event: MeasureEvent) -> Sequence[NoiseOp]: + """Add measurement flip error. + + Returns + ------- + Sequence[NoiseOp] + A tuple containing MeasurementFlip operation, or empty if p <= 0. + """ + if self._p <= 0: + return () + return (MeasurementFlip(p=self._p, target=event.node.id),) diff --git a/graphqomb/stim_compiler.py b/graphqomb/stim_compiler.py index d95bf5ff..d25643b3 100644 --- a/graphqomb/stim_compiler.py +++ b/graphqomb/stim_compiler.py @@ -10,8 +10,6 @@ from io import StringIO from typing import TYPE_CHECKING -import typing_extensions - from graphqomb.command import TICK, E, M, N from graphqomb.common import Axis, MeasBasis, determine_pauli_axis from graphqomb.noise_model import ( @@ -19,6 +17,7 @@ EntangleEvent, IdleEvent, MeasureEvent, + MeasurementFlip, NodeInfo, NoiseModel, NoiseOp, @@ -32,180 +31,13 @@ from graphqomb.noise_model import NoiseEvent from graphqomb.pattern import Pattern - from graphqomb.pauli_frame import PauliFrame - - -def _emit_qubit_coords( - stim_io: StringIO, - node: int, - coordinate: tuple[float, ...] | None, -) -> None: - r"""Emit QUBIT_COORDS instruction if coordinate is available. - - Parameters - ---------- - stim_io : `StringIO` - The output stream to write to. - node : `int` - The qubit index. - coordinate : `tuple`\[`float`, ...\] | `None` - The coordinate tuple (2D or 3D), or None if no coordinate. - """ - if coordinate is not None: - coords_str = ", ".join(str(c) for c in coordinate) - stim_io.write(f"QUBIT_COORDS({coords_str}) {node}\n") - - -def _prepare_nodes( - stim_io: StringIO, - nodes: int | Iterable[int], - p_depol_after_clifford: float, - coordinates: Mapping[int, tuple[float, ...]] | None = None, - emit_qubit_coords: bool = True, -) -> None: - r"""Prepare nodes in |+> state. - - This function handles both single nodes (N command) and multiple nodes - (input nodes initialization). - - Parameters - ---------- - stim_io : `StringIO` - The output stream to write to. - nodes : `int` | `collections.abc.Iterable`\[`int`\] - A single node index or an iterable of node indices to prepare. - p_depol_after_clifford : `float` - The probability of depolarization after Clifford gates. - coordinates : `collections.abc.Mapping`\[`int`, `tuple`\[`float`, ...\]\] | `None`, optional - Coordinates for nodes, by default None. - emit_qubit_coords : `bool`, optional - Whether to emit QUBIT_COORDS instructions, by default True. - """ - if isinstance(nodes, int): - nodes = [nodes] - for node in nodes: - coord = coordinates.get(node) if coordinates else None - if emit_qubit_coords: - _emit_qubit_coords(stim_io, node, coord) - stim_io.write(f"RX {node}\n") - if p_depol_after_clifford > 0.0: - stim_io.write(f"DEPOLARIZE1({p_depol_after_clifford}) {node}\n") - - -def _entangle_nodes( - stim_io: StringIO, - nodes: tuple[int, int], - p_depol_after_clifford: float, -) -> None: - r"""Entangle two nodes with CZ gate (E command). - - Parameters - ---------- - stim_io : `StringIO` - The output stream to write to. - nodes : `tuple`\[`int`, `int`\] - The pair of nodes to entangle. - p_depol_after_clifford : `float` - The probability of depolarization after Clifford gates. - """ - q1, q2 = nodes - stim_io.write(f"CZ {q1} {q2}\n") - if p_depol_after_clifford > 0.0: - stim_io.write(f"DEPOLARIZE2({p_depol_after_clifford}) {q1} {q2}\n") - - -def _emit_measurement( - stim_io: StringIO, - axis: Axis, - node: int, - p_meas_flip: float, -) -> None: - r"""Emit a measurement operation with optional measurement error. - - Parameters - ---------- - stim_io : `StringIO` - The output stream to write to. - axis : `Axis` - The measurement axis (X, Y, or Z). - node : `int` - The qubit index to measure. - p_meas_flip : `float` - The probability of a measurement bit flip error. - """ - if axis == Axis.X: - meas_instr = "MX" - elif axis == Axis.Y: - meas_instr = "MY" - elif axis == Axis.Z: - meas_instr = "MZ" - else: - typing_extensions.assert_never(axis) - if p_meas_flip > 0.0: - stim_io.write(f"{meas_instr}({p_meas_flip}) {node}\n") - else: - stim_io.write(f"{meas_instr} {node}\n") - - -def _add_detectors( - stim_io: StringIO, - check_groups: Sequence[Collection[int]], - meas_order: Mapping[int, int], - total_measurements: int, -) -> None: - r"""Add detector declarations to the circuit. - - Parameters - ---------- - stim_io : `StringIO` - The output stream to write to. - check_groups : `collections.abc.Sequence`\[`collections.abc.Collection`\[`int`\]\] - The parity check groups for detectors. - meas_order : `collections.abc.Mapping`\[`int`, `int`\] - The measurement order lookup dict mapping node to measurement index. - total_measurements : `int` - The total number of measurements. - """ - for checks in check_groups: - targets = [f"rec[{meas_order[check] - total_measurements}]" for check in checks] - stim_io.write(f"DETECTOR {' '.join(targets)}\n") - - -def _add_observables( - stim_io: StringIO, - logical_observables: Mapping[int, Collection[int]], - pframe: PauliFrame, - meas_order: Mapping[int, int], - total_measurements: int, -) -> None: - r"""Add logical observable declarations to the circuit. - - Parameters - ---------- - stim_io : `StringIO` - The output stream to write to. - logical_observables : `collections.abc.Mapping`\[`int`, `collections.abc.Collection`\[`int`\]\] - A mapping from logical observable index to a collection of node indices. - pframe : `PauliFrame` - The Pauli frame object. - meas_order : `collections.abc.Mapping`\[`int`, `int`\] - The measurement order lookup dict mapping node to measurement index. - total_measurements : `int` - The total number of measurements. - """ - for log_idx, obs in logical_observables.items(): - logical_observables_group = pframe.logical_observables_group(obs) - targets = [f"rec[{meas_order[node] - total_measurements}]" for node in logical_observables_group] - stim_io.write(f"OBSERVABLE_INCLUDE({log_idx}) {' '.join(targets)}\n") class _StimCompiler: - def __init__( # noqa: PLR0913 + def __init__( self, pattern: Pattern, *, - p_depol_after_clifford: float, - p_before_meas_flip: float, emit_qubit_coords: bool, noise_models: Sequence[NoiseModel], tick_duration: float, @@ -213,8 +45,6 @@ def __init__( # noqa: PLR0913 self._pattern = pattern self._pframe = pattern.pauli_frame self._coord_lookup = pattern.coordinates - self._p_depol_after_clifford = p_depol_after_clifford - self._p_before_meas_flip = p_before_meas_flip self._emit_qubit_coords = emit_qubit_coords self._noise_models = noise_models self._tick_duration = tick_duration @@ -228,18 +58,25 @@ def __init__( # noqa: PLR0913 def compile(self, logical_observables: Mapping[int, Collection[int]] | None) -> str: self._emit_input_nodes() self._process_commands() - total_measurements = self._rec_index - _add_detectors(self._stim_io, self._pframe.detector_groups(), self._meas_order, total_measurements) + total = self._rec_index + self._emit_detectors(total) if logical_observables is not None: - _add_observables( - self._stim_io, - logical_observables, - self._pframe, - self._meas_order, - total_measurements, - ) + self._emit_observables(logical_observables, total) return self._stim_io.getvalue().strip() + def _emit_detectors(self, total_measurements: int) -> None: + for checks in self._pframe.detector_groups(): + targets = [f"rec[{self._meas_order[c] - total_measurements}]" for c in checks] + self._stim_io.write(f"DETECTOR {' '.join(targets)}\n") + + def _emit_observables( + self, logical_observables: Mapping[int, Collection[int]], total_measurements: int + ) -> None: + for log_idx, obs in logical_observables.items(): + group = self._pframe.logical_observables_group(obs) + targets = [f"rec[{self._meas_order[n] - total_measurements}]" for n in group] + self._stim_io.write(f"OBSERVABLE_INCLUDE({log_idx}) {' '.join(targets)}\n") + def _emit_input_nodes(self) -> None: coordinates = self._pattern.input_coordinates if self._emit_qubit_coords else None for node in self._pattern.input_node_indices: @@ -263,14 +100,10 @@ def _process_prepare(self, node: int, coordinate: tuple[float, ...] | None, *, i default_placement = self._get_default_placement(event) self._rec_index += self._emit_noise_ops(ops, NoisePlacement.BEFORE, default_placement) - coordinates = {node: coordinate} if self._emit_qubit_coords and coordinate is not None else None - _prepare_nodes( - self._stim_io, - node, - self._p_depol_after_clifford, - coordinates=coordinates, - emit_qubit_coords=self._emit_qubit_coords, - ) + coord = coordinate if self._emit_qubit_coords else None + if coord is not None: + self._stim_io.write(f"QUBIT_COORDS({', '.join(str(c) for c in coord)}) {node}\n") + self._stim_io.write(f"RX {node}\n") self._rec_index += self._emit_noise_ops(ops, NoisePlacement.AFTER, default_placement) self._alive_nodes.add(node) @@ -284,7 +117,7 @@ def _handle_entangle(self, nodes: tuple[int, int]) -> None: default_placement = self._get_default_placement(event) self._rec_index += self._emit_noise_ops(ops, NoisePlacement.BEFORE, default_placement) - _entangle_nodes(self._stim_io, nodes, self._p_depol_after_clifford) + self._stim_io.write(f"CZ {n0} {n1}\n") self._touched_nodes.update(nodes) self._rec_index += self._emit_noise_ops(ops, NoisePlacement.AFTER, default_placement) @@ -295,14 +128,31 @@ def _handle_measure(self, node: int, meas_basis: MeasBasis) -> None: raise ValueError(msg) event = MeasureEvent(time=self._tick, node=self._node_info(node), axis=axis) ops = self._collect_noise_ops_from_models(lambda m: m.on_measure(event)) + + # Separate MeasurementFlip from other noise ops + meas_flip_p = 0.0 + other_ops: list[NoiseOp] = [] + for op in ops: + if isinstance(op, MeasurementFlip) and op.target == node: + meas_flip_p = max(meas_flip_p, op.p) + else: + other_ops.append(op) + default_placement = self._get_default_placement(event) - self._rec_index += self._emit_noise_ops(ops, NoisePlacement.BEFORE, default_placement) - _emit_measurement(self._stim_io, axis, node, self._p_before_meas_flip) + self._rec_index += self._emit_noise_ops(other_ops, NoisePlacement.BEFORE, default_placement) + + # Emit measurement with optional flip probability + meas_instr = {Axis.X: "MX", Axis.Y: "MY", Axis.Z: "MZ"}[axis] + if meas_flip_p > 0.0: + self._stim_io.write(f"{meas_instr}({meas_flip_p}) {node}\n") + else: + self._stim_io.write(f"{meas_instr} {node}\n") + self._meas_order[node] = self._rec_index self._rec_index += 1 self._alive_nodes.discard(node) self._touched_nodes.add(node) - self._rec_index += self._emit_noise_ops(ops, NoisePlacement.AFTER, default_placement) + self._rec_index += self._emit_noise_ops(other_ops, NoisePlacement.AFTER, default_placement) def _handle_tick(self) -> None: idle_nodes = sorted(self._alive_nodes - self._touched_nodes) @@ -358,12 +208,10 @@ def _emit_noise_ops( return record_delta -def stim_compile( # noqa: PLR0913 +def stim_compile( pattern: Pattern, logical_observables: Mapping[int, Collection[int]] | None = None, *, - p_depol_after_clifford: float = 0.0, - p_before_meas_flip: float = 0.0, emit_qubit_coords: bool = True, noise_models: Sequence[NoiseModel] | None = None, tick_duration: float = 1.0, @@ -376,16 +224,13 @@ def stim_compile( # noqa: PLR0913 The pattern to compile. logical_observables : `collections.abc.Mapping`\[`int`, `collections.abc.Collection`\[`int`\]\], optional A mapping from logical observable index to a collection of node indices, by default None. - p_depol_after_clifford : `float`, optional - The probability of depolarization after a Clifford gate, by default 0.0. - p_before_meas_flip : `float`, optional - The probability of flipping a measurement result before measurement, by default 0.0. emit_qubit_coords : `bool`, optional Whether to emit QUBIT_COORDS instructions for nodes with coordinates, by default True. noise_models : `collections.abc.Sequence`\[`NoiseModel`\] | `None`, optional Custom noise models for injecting Stim noise instructions, by default None. - Multiple models are combined using ``CompositeNoiseModel``. + Use `DepolarizingNoiseModel` for gate noise and `MeasurementFlipNoiseModel` + for measurement errors. tick_duration : `float`, optional Duration associated with each TICK for idle noise, by default 1.0. @@ -399,11 +244,26 @@ def stim_compile( # noqa: PLR0913 Stim only supports Clifford gates, therefore this compiler only supports Pauli measurements (X, Y, Z basis) which correspond to Clifford operations. Non-Pauli measurements will raise a ValueError. + + Examples + -------- + Basic compilation without noise: + + >>> # stim_str = stim_compile(pattern) + + With depolarizing and measurement flip noise: + + >>> from graphqomb.noise_model import DepolarizingNoiseModel, MeasurementFlipNoiseModel + >>> # stim_str = stim_compile( + >>> # pattern, + >>> # noise_models=[ + >>> # DepolarizingNoiseModel(p1=0.001, p2=0.01), + >>> # MeasurementFlipNoiseModel(p=0.001) + >>> # ] + >>> # ) """ compiler = _StimCompiler( pattern, - p_depol_after_clifford=p_depol_after_clifford, - p_before_meas_flip=p_before_meas_flip, emit_qubit_coords=emit_qubit_coords, noise_models=noise_models or (), tick_duration=tick_duration, From 5fed6f0926e0d6a10458fce6dd36385cb2b72197 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Fri, 6 Feb 2026 17:38:13 +0900 Subject: [PATCH 06/14] Fix Sphinx documentation warnings in noise_model docstrings Use double backticks for non-Python references (Stim instruction names, parameter names, example values) and proper Sphinx roles (:data:) for module constants. Single backticks remain only for Python object cross- references. Co-Authored-By: Claude Opus 4.5 --- graphqomb/noise_model.py | 42 ++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/graphqomb/noise_model.py b/graphqomb/noise_model.py index 48ed62e6..78c3ff59 100644 --- a/graphqomb/noise_model.py +++ b/graphqomb/noise_model.py @@ -13,7 +13,7 @@ - `NoiseModel`: Base class for noise models. - `DepolarizingNoiseModel`, `MeasurementFlipNoiseModel`: Built-in noise models. - `noise_op_to_stim`: Conversion function. -- `PAULI_CHANNEL_2_ORDER`: Constant for Pauli channel order. +- :data:`PAULI_CHANNEL_2_ORDER`: Constant for Pauli channel order. Examples -------- @@ -59,9 +59,9 @@ Notes ----- -- **Placement control**: Each `NoiseOp` has a `placement` attribute. - `AUTO` defers to :meth:`NoiseModel.default_placement`, while - `BEFORE`/`AFTER` force insertion side. +- **Placement control**: Each `NoiseOp` has a ``placement`` attribute. + ``AUTO`` defers to :meth:`NoiseModel.default_placement`, while + ``BEFORE``/``AFTER`` force insertion side. - **Record delta**: Heralded instructions (`HeraldedPauliChannel1`, `HeraldedErase`) add measurement records. The compiler automatically @@ -197,7 +197,7 @@ class EntangleEvent: node1 : NodeInfo Information about the second node in the entanglement. edge : tuple[int, int] - The edge as `(min_node_id, max_node_id)`. + The edge as ``(min_node_id, max_node_id)``. """ time: int @@ -236,7 +236,7 @@ class IdleEvent: nodes : Sequence[NodeInfo] Information about all nodes that are idle during this tick. duration : float - The duration of the idle period (from `tick_duration` parameter). + The duration of the idle period (from ``tick_duration`` parameter). """ time: int @@ -253,7 +253,7 @@ class PauliChannel1: """Single-qubit Pauli channel noise operation. Applies independent X, Y, Z errors with given probabilities. - Corresponds to Stim's `PAULI_CHANNEL_1` instruction. + Corresponds to Stim's ``PAULI_CHANNEL_1`` instruction. Parameters ---------- @@ -267,7 +267,7 @@ class PauliChannel1: Target qubit indices. placement : NoisePlacement Whether to insert before or after the main operation. - `AUTO` defers to the model's default placement for the event. + ``AUTO`` defers to the model's default placement for the event. Examples -------- @@ -291,7 +291,7 @@ class PauliChannel2: """Two-qubit Pauli channel noise operation. Applies correlated two-qubit Pauli errors. - Corresponds to Stim's `PAULI_CHANNEL_2` instruction. + Corresponds to Stim's ``PAULI_CHANNEL_2`` instruction. Parameters ---------- @@ -301,10 +301,10 @@ class PauliChannel2: or a mapping from Pauli string keys to probabilities. Missing keys default to 0. targets : Sequence[tuple[int, int]] - Target qubit pairs as `[(q0, q1), ...]`. + Target qubit pairs as ``[(q0, q1), ...]``. placement : NoisePlacement Whether to insert before or after the main operation. - `AUTO` defers to the model's default placement for the event. + ``AUTO`` defers to the model's default placement for the event. Examples -------- @@ -335,8 +335,8 @@ class HeraldedPauliChannel1: Similar to `PauliChannel1` but produces a herald measurement record indicating whether an error occurred. The herald outcome is 1 if any - error occurred (including identity with probability `pi`). - Corresponds to Stim's `HERALDED_PAULI_CHANNEL_1` instruction. + error occurred (including identity with probability ``pi``). + Corresponds to Stim's ``HERALDED_PAULI_CHANNEL_1`` instruction. Parameters ---------- @@ -352,7 +352,7 @@ class HeraldedPauliChannel1: Target qubit indices. placement : NoisePlacement Whether to insert before or after the main operation. - `AUTO` defers to the model's default placement for the event. + ``AUTO`` defers to the model's default placement for the event. Notes ----- @@ -385,7 +385,7 @@ class HeraldedErase: """Heralded erasure noise operation. Models photon loss or erasure errors with a herald signal. - Corresponds to Stim's `HERALDED_ERASE` instruction. + Corresponds to Stim's ``HERALDED_ERASE`` instruction. Parameters ---------- @@ -395,7 +395,7 @@ class HeraldedErase: Target qubit indices. placement : NoisePlacement Whether to insert before or after the main operation. - `AUTO` defers to the model's default placement for the event. + ``AUTO`` defers to the model's default placement for the event. Notes ----- @@ -436,7 +436,7 @@ class RawStimOp: Most noise instructions do not add records (default 0). placement : NoisePlacement Whether to insert before or after the main operation. - `AUTO` defers to the model's default placement for the event. + ``AUTO`` defers to the model's default placement for the event. Examples -------- @@ -604,14 +604,14 @@ def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911, C901 Returns ------- tuple[str, int] - A tuple of `(stim_instruction, record_delta)` where - `stim_instruction` is a single line of Stim code and - `record_delta` is the number of measurement records added. + A tuple of ``(stim_instruction, record_delta)`` where + ``stim_instruction`` is a single line of Stim code and + ``record_delta`` is the number of measurement records added. Raises ------ TypeError - If `op` is not a recognized NoiseOp type. + If ``op`` is not a recognized NoiseOp type. Examples -------- From f59abeaf57ab502194bb2ac8ad09a8e12ae80fee Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Fri, 6 Feb 2026 17:41:36 +0900 Subject: [PATCH 07/14] Add depolarize2_probs utility for 2-qubit depolarizing noise Add utility function that creates a probability dict with all 15 Pauli pairs having equal probability p/15. Update the module docstring example to use this utility for proper 2-qubit depolarizing noise instead of the simplified ZZ-only version. Co-Authored-By: Claude Opus 4.5 --- graphqomb/noise_model.py | 32 +++++++- tests/test_noise_model.py | 161 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 3 deletions(-) diff --git a/graphqomb/noise_model.py b/graphqomb/noise_model.py index 78c3ff59..5e03507a 100644 --- a/graphqomb/noise_model.py +++ b/graphqomb/noise_model.py @@ -13,6 +13,7 @@ - `NoiseModel`: Base class for noise models. - `DepolarizingNoiseModel`, `MeasurementFlipNoiseModel`: Built-in noise models. - `noise_op_to_stim`: Conversion function. +- `depolarize2_probs`: Utility to create 2-qubit depolarizing probabilities. - :data:`PAULI_CHANNEL_2_ORDER`: Constant for Pauli channel order. Examples @@ -20,7 +21,8 @@ Create a simple depolarizing noise model: >>> from graphqomb.noise_model import ( -... NoiseModel, PrepareEvent, EntangleEvent, PauliChannel1, PauliChannel2 +... NoiseModel, PrepareEvent, EntangleEvent, PauliChannel1, PauliChannel2, +... depolarize2_probs ... ) >>> >>> class DepolarizingNoise(NoiseModel): @@ -33,9 +35,8 @@ ... return [PauliChannel1(px=p, py=p, pz=p, targets=[event.node.id])] ... ... def on_entangle(self, event: EntangleEvent) -> list[PauliChannel2]: -... # Simplified: only add ZZ error ... return [PauliChannel2( -... probabilities={"ZZ": self.p2}, +... probabilities=depolarize2_probs(self.p2), ... targets=[(event.node0.id, event.node1.id)] ... )] @@ -105,6 +106,31 @@ ) +def depolarize2_probs(p: float) -> dict[str, float]: + """Create probability dict for 2-qubit depolarizing channel. + + Parameters + ---------- + p : float + Total depolarizing probability. + + Returns + ------- + dict[str, float] + Mapping from Pauli pair to probability ``p/15``. + + Examples + -------- + >>> probs = depolarize2_probs(0.15) + >>> probs["ZZ"] + 0.01 + >>> len(probs) + 15 + """ + p_each = p / 15 + return dict.fromkeys(PAULI_CHANNEL_2_ORDER, p_each) + + class NoisePlacement(Enum): """Where to insert noise relative to the main operation.""" diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py index b85c3856..18657186 100644 --- a/tests/test_noise_model.py +++ b/tests/test_noise_model.py @@ -8,11 +8,14 @@ from graphqomb.noise_model import ( PAULI_CHANNEL_2_ORDER, Coordinate, + DepolarizingNoiseModel, EntangleEvent, HeraldedErase, HeraldedPauliChannel1, IdleEvent, MeasureEvent, + MeasurementFlip, + MeasurementFlipNoiseModel, NodeInfo, NoiseModel, NoisePlacement, @@ -20,6 +23,7 @@ PauliChannel2, PrepareEvent, RawStimOp, + depolarize2_probs, noise_op_to_stim, ) @@ -231,6 +235,37 @@ def test_pauli_channel_2_order_has_15_elements(self) -> None: assert len(PAULI_CHANNEL_2_ORDER) == 15 +class TestDepolarize2Probs: + """Tests for depolarize2_probs utility function.""" + + def test_returns_15_elements(self) -> None: + """Test that depolarize2_probs returns 15 Pauli pairs.""" + probs = depolarize2_probs(0.15) + assert len(probs) == 15 + + def test_each_probability_is_p_over_15(self) -> None: + """Test that each probability is p/15.""" + p = 0.15 + probs = depolarize2_probs(p) + expected = p / 15 + for pauli, prob in probs.items(): + assert prob == expected, f"Expected {expected} for {pauli}, got {prob}" + + def test_contains_all_pauli_pairs(self) -> None: + """Test that all 15 Pauli pairs are present.""" + probs = depolarize2_probs(0.1) + for pauli in PAULI_CHANNEL_2_ORDER: + assert pauli in probs + + def test_can_be_used_with_pauli_channel_2(self) -> None: + """Test that depolarize2_probs works with PauliChannel2.""" + probs = depolarize2_probs(0.15) + op = PauliChannel2(probabilities=probs, targets=[(0, 1)]) + text, delta = noise_op_to_stim(op) + assert "PAULI_CHANNEL_2" in text + assert delta == 0 + + class TestHeraldedPauliChannel1: """Tests for HeraldedPauliChannel1 noise operation.""" @@ -482,3 +517,129 @@ def test_on_idle(self) -> None: assert len(ops) == 1 assert isinstance(ops[0], PauliChannel1) assert ops[0].px == 0.002 # p * duration + + +# ---- MeasurementFlip Tests ---- + + +class TestMeasurementFlip: + """Tests for MeasurementFlip noise operation.""" + + def test_basic(self) -> None: + """Test basic MeasurementFlip creation.""" + op = MeasurementFlip(p=0.01, target=5) + assert op.p == 0.01 + assert op.target == 5 + assert op.placement == NoisePlacement.AUTO + + def test_to_stim_returns_empty(self) -> None: + """Test that noise_op_to_stim returns empty string for MeasurementFlip. + + MeasurementFlip is handled specially by modifying the measurement + instruction itself, so it should not emit a separate instruction. + """ + op = MeasurementFlip(p=0.01, target=0) + text, delta = noise_op_to_stim(op) + assert text == "" + assert delta == 0 + + +# ---- DepolarizingNoiseModel Tests ---- + + +class TestDepolarizingNoiseModel: + """Tests for DepolarizingNoiseModel built-in noise model.""" + + def test_on_prepare_emits_depolarize1(self) -> None: + """Test that on_prepare returns DEPOLARIZE1 instruction.""" + model = DepolarizingNoiseModel(p1=0.01) + node = NodeInfo(id=5, coord=None) + event = PrepareEvent(time=0, node=node, is_input=False) + ops = list(model.on_prepare(event)) + assert len(ops) == 1 + text, _ = noise_op_to_stim(ops[0]) + assert text == "DEPOLARIZE1(0.01) 5" + + def test_on_entangle_emits_depolarize2(self) -> None: + """Test that on_entangle returns DEPOLARIZE2 instruction.""" + model = DepolarizingNoiseModel(p1=0.01) + node0 = NodeInfo(id=0, coord=None) + node1 = NodeInfo(id=1, coord=None) + event = EntangleEvent(time=1, node0=node0, node1=node1, edge=(0, 1)) + ops = list(model.on_entangle(event)) + assert len(ops) == 1 + text, _ = noise_op_to_stim(ops[0]) + assert text == "DEPOLARIZE2(0.01) 0 1" + + def test_p2_defaults_to_p1(self) -> None: + """Test that p2 defaults to p1 when not specified.""" + model = DepolarizingNoiseModel(p1=0.02) + node0 = NodeInfo(id=2, coord=None) + node1 = NodeInfo(id=3, coord=None) + event = EntangleEvent(time=1, node0=node0, node1=node1, edge=(2, 3)) + ops = list(model.on_entangle(event)) + text, _ = noise_op_to_stim(ops[0]) + assert "DEPOLARIZE2(0.02)" in text + + def test_different_p1_and_p2(self) -> None: + """Test DepolarizingNoiseModel with different p1 and p2.""" + model = DepolarizingNoiseModel(p1=0.001, p2=0.01) + # Check prepare uses p1 + node = NodeInfo(id=0, coord=None) + prepare_event = PrepareEvent(time=0, node=node, is_input=False) + ops = list(model.on_prepare(prepare_event)) + text, _ = noise_op_to_stim(ops[0]) + assert "DEPOLARIZE1(0.001)" in text + + # Check entangle uses p2 + node0 = NodeInfo(id=0, coord=None) + node1 = NodeInfo(id=1, coord=None) + entangle_event = EntangleEvent(time=1, node0=node0, node1=node1, edge=(0, 1)) + ops = list(model.on_entangle(entangle_event)) + text, _ = noise_op_to_stim(ops[0]) + assert "DEPOLARIZE2(0.01)" in text + + def test_zero_probability_returns_empty(self) -> None: + """Test that zero probability returns empty sequence.""" + model = DepolarizingNoiseModel(p1=0.0) + node = NodeInfo(id=0, coord=None) + event = PrepareEvent(time=0, node=node, is_input=False) + ops = list(model.on_prepare(event)) + assert len(ops) == 0 + + +# ---- MeasurementFlipNoiseModel Tests ---- + + +class TestMeasurementFlipNoiseModel: + """Tests for MeasurementFlipNoiseModel built-in noise model.""" + + def test_on_measure_returns_measurement_flip(self) -> None: + """Test that on_measure returns MeasurementFlip operation.""" + model = MeasurementFlipNoiseModel(p=0.01) + node = NodeInfo(id=5, coord=None) + event = MeasureEvent(time=0, node=node, axis=Axis.X) + ops = list(model.on_measure(event)) + assert len(ops) == 1 + assert isinstance(ops[0], MeasurementFlip) + assert ops[0].p == 0.01 + assert ops[0].target == 5 + + def test_zero_probability_returns_empty(self) -> None: + """Test that zero probability returns empty sequence.""" + model = MeasurementFlipNoiseModel(p=0.0) + node = NodeInfo(id=0, coord=None) + event = MeasureEvent(time=0, node=node, axis=Axis.Z) + ops = list(model.on_measure(event)) + assert len(ops) == 0 + + def test_different_axes(self) -> None: + """Test MeasurementFlipNoiseModel works with all measurement axes.""" + model = MeasurementFlipNoiseModel(p=0.005) + for axis in [Axis.X, Axis.Y, Axis.Z]: + node = NodeInfo(id=0, coord=None) + event = MeasureEvent(time=0, node=node, axis=axis) + ops = list(model.on_measure(event)) + assert len(ops) == 1 + assert isinstance(ops[0], MeasurementFlip) + assert ops[0].p == 0.005 From 432d3184161f4678ebba08f0781089605b467966 Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Fri, 6 Feb 2026 17:43:01 +0900 Subject: [PATCH 08/14] Add depolarize1_probs utility for single-qubit depolarizing noise Add utility function that creates px, py, pz probabilities each set to p/3 for single-qubit depolarizing channel. Update the module docstring example to use both depolarize1_probs and depolarize2_probs. Co-Authored-By: Claude Opus 4.5 --- graphqomb/noise_model.py | 31 ++++++++++++++++++++++++++++--- tests/test_noise_model.py | 27 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/graphqomb/noise_model.py b/graphqomb/noise_model.py index 5e03507a..de57dcd3 100644 --- a/graphqomb/noise_model.py +++ b/graphqomb/noise_model.py @@ -13,6 +13,7 @@ - `NoiseModel`: Base class for noise models. - `DepolarizingNoiseModel`, `MeasurementFlipNoiseModel`: Built-in noise models. - `noise_op_to_stim`: Conversion function. +- `depolarize1_probs`: Utility to create single-qubit depolarizing probabilities. - `depolarize2_probs`: Utility to create 2-qubit depolarizing probabilities. - :data:`PAULI_CHANNEL_2_ORDER`: Constant for Pauli channel order. @@ -22,7 +23,7 @@ >>> from graphqomb.noise_model import ( ... NoiseModel, PrepareEvent, EntangleEvent, PauliChannel1, PauliChannel2, -... depolarize2_probs +... depolarize1_probs, depolarize2_probs ... ) >>> >>> class DepolarizingNoise(NoiseModel): @@ -31,8 +32,7 @@ ... self.p2 = p2 # Two-qubit depolarizing probability ... ... def on_prepare(self, event: PrepareEvent) -> list[PauliChannel1]: -... p = self.p1 / 3 # Equal probability for X, Y, Z -... return [PauliChannel1(px=p, py=p, pz=p, targets=[event.node.id])] +... return [PauliChannel1(**depolarize1_probs(self.p1), targets=[event.node.id])] ... ... def on_entangle(self, event: EntangleEvent) -> list[PauliChannel2]: ... return [PauliChannel2( @@ -106,6 +106,31 @@ ) +def depolarize1_probs(p: float) -> dict[str, float]: + """Create probability dict for single-qubit depolarizing channel. + + Parameters + ---------- + p : float + Total depolarizing probability. + + Returns + ------- + dict[str, float] + Mapping with keys ``px``, ``py``, ``pz`` each set to ``p/3``. + + Examples + -------- + >>> probs = depolarize1_probs(0.03) + >>> probs["px"] + 0.01 + >>> probs["py"] + 0.01 + """ + p_each = p / 3 + return {"px": p_each, "py": p_each, "pz": p_each} + + def depolarize2_probs(p: float) -> dict[str, float]: """Create probability dict for 2-qubit depolarizing channel. diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py index 18657186..f1d5d624 100644 --- a/tests/test_noise_model.py +++ b/tests/test_noise_model.py @@ -23,6 +23,7 @@ PauliChannel2, PrepareEvent, RawStimOp, + depolarize1_probs, depolarize2_probs, noise_op_to_stim, ) @@ -235,6 +236,32 @@ def test_pauli_channel_2_order_has_15_elements(self) -> None: assert len(PAULI_CHANNEL_2_ORDER) == 15 +class TestDepolarize1Probs: + """Tests for depolarize1_probs utility function.""" + + def test_returns_3_elements(self) -> None: + """Test that depolarize1_probs returns px, py, pz.""" + probs = depolarize1_probs(0.03) + assert len(probs) == 3 + assert set(probs.keys()) == {"px", "py", "pz"} + + def test_each_probability_is_p_over_3(self) -> None: + """Test that each probability is p/3.""" + p = 0.03 + probs = depolarize1_probs(p) + expected = p / 3 + for key, prob in probs.items(): + assert prob == expected, f"Expected {expected} for {key}, got {prob}" + + def test_can_be_used_with_pauli_channel_1(self) -> None: + """Test that depolarize1_probs works with PauliChannel1.""" + probs = depolarize1_probs(0.03) + op = PauliChannel1(**probs, targets=[0]) + text, delta = noise_op_to_stim(op) + assert "PAULI_CHANNEL_1" in text + assert delta == 0 + + class TestDepolarize2Probs: """Tests for depolarize2_probs utility function.""" From b2b4bfe88b813ef429bc8f738a0c62a5ee4e268a Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Fri, 6 Feb 2026 17:44:49 +0900 Subject: [PATCH 09/14] Update CHANGELOG for noise model feature Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3210ebb3..57c90440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Noise Model API**: Event-based noise injection system for Stim circuit compilation + - Added `NoiseModel` base class for custom noise behavior with `on_prepare`, `on_entangle`, `on_measure`, and `on_idle` hooks + - Added typed `NoiseOp` dataclasses: `PauliChannel1`, `PauliChannel2`, `HeraldedPauliChannel1`, `HeraldedErase`, `RawStimOp`, `MeasurementFlip` + - Added event dataclasses: `PrepareEvent`, `EntangleEvent`, `MeasureEvent`, `IdleEvent` with `NodeInfo` and `Coordinate` + - Added `NoisePlacement` enum for controlling noise insertion timing (AUTO, BEFORE, AFTER) + - Added `noise_op_to_stim()` conversion function + - Added `depolarize1_probs()` and `depolarize2_probs()` utility functions for depolarizing noise + +- **Built-in Noise Models**: Ready-to-use noise model implementations + - Added `DepolarizingNoiseModel` for single and two-qubit depolarizing noise + - Added `MeasurementFlipNoiseModel` for measurement bit-flip errors using Stim's built-in `MX(p)` syntax + +- **Stim Compiler Enhancement**: Extended `stim_compile()` to accept `noise_models` parameter + - Support for multiple noise models via `Sequence[NoiseModel]` + - Added `tick_duration` parameter for idle noise calculations + - Automatic measurement record tracking for heralded noise operations + +- **Documentation**: Added comprehensive Sphinx documentation for the noise model module + +### Changed + +- **Stim Compiler**: Refactored internal structure to support noise model integration + ## [0.2.1] - 2026-01-16 ### Added From a571d0fef9d2025b1109a22a017999974be0217e Mon Sep 17 00:00:00 2001 From: Masato Fukushima Date: Fri, 6 Feb 2026 17:46:55 +0900 Subject: [PATCH 10/14] Update stim_compiler tests to use NoiseModel API Replace old parameter-based noise API with the new NoiseModel classes: - Use DepolarizingNoiseModel instead of p_depol_after_clifford - Use MeasurementFlipNoiseModel instead of p_before_meas_flip Co-Authored-By: Claude Opus 4.5 --- tests/test_stim_compiler.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/test_stim_compiler.py b/tests/test_stim_compiler.py index 135e6fdc..848ed054 100644 --- a/tests/test_stim_compiler.py +++ b/tests/test_stim_compiler.py @@ -10,7 +10,13 @@ from graphqomb.command import TICK, E from graphqomb.common import Axis, AxisMeasBasis, Plane, PlannerMeasBasis, Sign from graphqomb.graphstate import GraphState -from graphqomb.noise_model import HeraldedPauliChannel1, MeasureEvent, NoiseModel +from graphqomb.noise_model import ( + DepolarizingNoiseModel, + HeraldedPauliChannel1, + MeasureEvent, + MeasurementFlipNoiseModel, + NoiseModel, +) from graphqomb.qompiler import qompile from graphqomb.schedule_solver import ScheduleConfig, Strategy from graphqomb.scheduler import Scheduler @@ -157,10 +163,10 @@ def test_stim_compile_z_measurement() -> None: def test_stim_compile_with_depolarization() -> None: - """Test that depolarization error is correctly inserted.""" + """Test that depolarization error is correctly inserted using DepolarizingNoiseModel.""" pattern, _, _ = create_simple_pattern_x_measurement() - stim_str = stim_compile(pattern, p_depol_after_clifford=0.01) + stim_str = stim_compile(pattern, noise_models=[DepolarizingNoiseModel(p1=0.01)]) # Check DEPOLARIZE instructions are present assert "DEPOLARIZE1(0.01)" in stim_str @@ -168,30 +174,30 @@ def test_stim_compile_with_depolarization() -> None: def test_stim_compile_with_measurement_errors_x() -> None: - """Test that X measurement errors are correctly inserted.""" + """Test that X measurement errors are correctly inserted using MeasurementFlipNoiseModel.""" pattern, _, _ = create_simple_pattern_x_measurement() - stim_str = stim_compile(pattern, p_before_meas_flip=0.01) + stim_str = stim_compile(pattern, noise_models=[MeasurementFlipNoiseModel(p=0.01)]) # For X measurement, error probability is attached to MX instruction assert "MX(0.01)" in stim_str def test_stim_compile_with_measurement_errors_y() -> None: - """Test that Y measurement errors are correctly inserted.""" + """Test that Y measurement errors are correctly inserted using MeasurementFlipNoiseModel.""" pattern, _, _ = create_simple_pattern_y_measurement() - stim_str = stim_compile(pattern, p_before_meas_flip=0.01) + stim_str = stim_compile(pattern, noise_models=[MeasurementFlipNoiseModel(p=0.01)]) # For Y measurement, error probability is attached to MY instruction assert "MY(0.01)" in stim_str def test_stim_compile_with_measurement_errors_z() -> None: - """Test that Z measurement errors are correctly inserted.""" + """Test that Z measurement errors are correctly inserted using MeasurementFlipNoiseModel.""" pattern, _, _ = create_simple_pattern_z_measurement() - stim_str = stim_compile(pattern, p_before_meas_flip=0.01) + stim_str = stim_compile(pattern, noise_models=[MeasurementFlipNoiseModel(p=0.01)]) # For Z measurement, error probability is attached to MZ instruction assert "MZ(0.01)" in stim_str From 5ddc8c663abb4dc6350bf4254aa79e9381354591 Mon Sep 17 00:00:00 2001 From: masa10-f Date: Sat, 7 Feb 2026 20:47:35 +0900 Subject: [PATCH 11/14] Unify AUTO placement and noise model docstring style --- graphqomb/noise_model.py | 235 ++++++++++++++++++------------------- graphqomb/stim_compiler.py | 23 ++-- tests/test_noise_model.py | 31 +++-- 3 files changed, 138 insertions(+), 151 deletions(-) diff --git a/graphqomb/noise_model.py b/graphqomb/noise_model.py index de57dcd3..6926eb7a 100644 --- a/graphqomb/noise_model.py +++ b/graphqomb/noise_model.py @@ -10,6 +10,7 @@ - `PauliChannel1`, `PauliChannel2`, `HeraldedPauliChannel1`, `HeraldedErase`, `RawStimOp`, `MeasurementFlip`: NoiseOp types. - `NoiseOp`: Union type of all noise operation types. +- `default_noise_placement`: Global default placement policy for AUTO operations. - `NoiseModel`: Base class for noise models. - `DepolarizingNoiseModel`, `MeasurementFlipNoiseModel`: Built-in noise models. - `noise_op_to_stim`: Conversion function. @@ -22,8 +23,13 @@ Create a simple depolarizing noise model: >>> from graphqomb.noise_model import ( -... NoiseModel, PrepareEvent, EntangleEvent, PauliChannel1, PauliChannel2, -... depolarize1_probs, depolarize2_probs +... NoiseModel, +... PrepareEvent, +... EntangleEvent, +... PauliChannel1, +... PauliChannel2, +... depolarize1_probs, +... depolarize2_probs, ... ) >>> >>> class DepolarizingNoise(NoiseModel): @@ -35,16 +41,13 @@ ... return [PauliChannel1(**depolarize1_probs(self.p1), targets=[event.node.id])] ... ... def on_entangle(self, event: EntangleEvent) -> list[PauliChannel2]: -... return [PauliChannel2( -... probabilities=depolarize2_probs(self.p2), -... targets=[(event.node0.id, event.node1.id)] -... )] +... return [PauliChannel2(probabilities=depolarize2_probs(self.p2), targets=[(event.node0.id, event.node1.id)])] Use with stim_compile: >>> from graphqomb.stim_compiler import stim_compile >>> # pattern = ... # your compiled pattern ->>> # stim_str = stim_compile(pattern, noise_model=DepolarizingNoise(0.001, 0.01)) +>>> # stim_str = stim_compile(pattern, noise_models=[DepolarizingNoise(0.001, 0.01)]) Use heralded noise that adds measurement records: @@ -53,15 +56,12 @@ >>> class HeraldedMeasurementNoise(NoiseModel): ... def on_measure(self, event: MeasureEvent) -> list[HeraldedPauliChannel1]: ... # Heralded erasure with 10% probability -... return [HeraldedPauliChannel1( -... pi=0.1, px=0.0, py=0.0, pz=0.0, -... targets=[event.node.id] -... )] +... return [HeraldedPauliChannel1(pi=0.1, px=0.0, py=0.0, pz=0.0, targets=[event.node.id])] Notes ----- - **Placement control**: Each `NoiseOp` has a ``placement`` attribute. - ``AUTO`` defers to :meth:`NoiseModel.default_placement`, while + ``AUTO`` defers to :func:`default_noise_placement`, while ``BEFORE``/``AFTER`` force insertion side. - **Record delta**: Heralded instructions (`HeraldedPauliChannel1`, @@ -107,16 +107,16 @@ def depolarize1_probs(p: float) -> dict[str, float]: - """Create probability dict for single-qubit depolarizing channel. + r"""Create probability dict for single-qubit depolarizing channel. Parameters ---------- - p : float + p : `float` Total depolarizing probability. Returns ------- - dict[str, float] + `dict`\[`str`, `float`\] Mapping with keys ``px``, ``py``, ``pz`` each set to ``p/3``. Examples @@ -132,16 +132,16 @@ def depolarize1_probs(p: float) -> dict[str, float]: def depolarize2_probs(p: float) -> dict[str, float]: - """Create probability dict for 2-qubit depolarizing channel. + r"""Create probability dict for 2-qubit depolarizing channel. Parameters ---------- - p : float + p : `float` Total depolarizing probability. Returns ------- - dict[str, float] + `dict`\[`str`, `float`\] Mapping from Pauli pair to probability ``p/15``. Examples @@ -166,11 +166,11 @@ class NoisePlacement(Enum): @dataclass(frozen=True) class Coordinate: - """N-dimensional coordinate for a node. + r"""N-dimensional coordinate for a node. Parameters ---------- - values : tuple[float, ...] + values : `tuple`\[`float`, ...\] The coordinate values as a tuple of floats. Examples @@ -205,9 +205,9 @@ class NodeInfo: Parameters ---------- - id : int + id : `int` The unique node index in the pattern. - coord : Coordinate | None + coord : `Coordinate` | `None` The spatial coordinate of the node, if available. """ @@ -221,11 +221,11 @@ class PrepareEvent: Parameters ---------- - time : int + time : `int` The current tick (time step) in the pattern execution. - node : NodeInfo + node : `NodeInfo` Information about the node being prepared. - is_input : bool + is_input : `bool` Whether this node is an input node of the pattern. Input nodes may require different noise treatment. """ @@ -237,17 +237,17 @@ class PrepareEvent: @dataclass(frozen=True) class EntangleEvent: - """Event emitted when two qubits are entangled (E command / CZ gate). + r"""Event emitted when two qubits are entangled (E command / CZ gate). Parameters ---------- - time : int + time : `int` The current tick (time step) in the pattern execution. - node0 : NodeInfo + node0 : `NodeInfo` Information about the first node in the entanglement. - node1 : NodeInfo + node1 : `NodeInfo` Information about the second node in the entanglement. - edge : tuple[int, int] + edge : `tuple`\[`int`, `int`\] The edge as ``(min_node_id, max_node_id)``. """ @@ -263,11 +263,11 @@ class MeasureEvent: Parameters ---------- - time : int + time : `int` The current tick (time step) in the pattern execution. - node : NodeInfo + node : `NodeInfo` Information about the node being measured. - axis : Axis + axis : `Axis` The measurement axis (X, Y, or Z). """ @@ -278,15 +278,15 @@ class MeasureEvent: @dataclass(frozen=True) class IdleEvent: - """Event emitted for qubits that are idle during a TICK. + r"""Event emitted for qubits that are idle during a TICK. Parameters ---------- - time : int + time : `int` The current tick (time step) in the pattern execution. - nodes : Sequence[NodeInfo] + nodes : `collections.abc.Sequence`\[`NodeInfo`\] Information about all nodes that are idle during this tick. - duration : float + duration : `float` The duration of the idle period (from ``tick_duration`` parameter). """ @@ -299,26 +299,47 @@ class IdleEvent: """Union type of all noise event types.""" +def default_noise_placement(event: NoiseEvent) -> NoisePlacement: + """Return the global default placement for AUTO noise operations. + + Measurement noise is inserted before measurement operations. Noise for all + other events is inserted after the corresponding operation. + + Parameters + ---------- + event : `NoiseEvent` + The event for which to determine the default placement. + + Returns + ------- + `NoisePlacement` + ``BEFORE`` for measurement events, ``AFTER`` for all others. + """ + if isinstance(event, MeasureEvent): + return NoisePlacement.BEFORE + return NoisePlacement.AFTER + + @dataclass(frozen=True) class PauliChannel1: - """Single-qubit Pauli channel noise operation. + r"""Single-qubit Pauli channel noise operation. Applies independent X, Y, Z errors with given probabilities. Corresponds to Stim's ``PAULI_CHANNEL_1`` instruction. Parameters ---------- - px : float + px : `float` Probability of X error. - py : float + py : `float` Probability of Y error. - pz : float + pz : `float` Probability of Z error. - targets : Sequence[int] + targets : `collections.abc.Sequence`\[`int`\] Target qubit indices. - placement : NoisePlacement + placement : `NoisePlacement` Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + ``AUTO`` defers to :func:`default_noise_placement`. Examples -------- @@ -339,23 +360,23 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class PauliChannel2: - """Two-qubit Pauli channel noise operation. + r"""Two-qubit Pauli channel noise operation. Applies correlated two-qubit Pauli errors. Corresponds to Stim's ``PAULI_CHANNEL_2`` instruction. Parameters ---------- - probabilities : Sequence[float] | Mapping[str, float] + probabilities : `collections.abc.Sequence`\[`float`\] | `collections.abc.Mapping`\[`str`, `float`\] Either a sequence of 15 probabilities in the order (IX, IY, IZ, XI, XX, XY, XZ, YI, YX, YY, YZ, ZI, ZX, ZY, ZZ), or a mapping from Pauli string keys to probabilities. Missing keys default to 0. - targets : Sequence[tuple[int, int]] + targets : `collections.abc.Sequence`\[`tuple`\[`int`, `int`\]\] Target qubit pairs as ``[(q0, q1), ...]``. - placement : NoisePlacement + placement : `NoisePlacement` Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + ``AUTO`` defers to :func:`default_noise_placement`. Examples -------- @@ -382,7 +403,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class HeraldedPauliChannel1: - """Heralded single-qubit Pauli channel noise operation. + r"""Heralded single-qubit Pauli channel noise operation. Similar to `PauliChannel1` but produces a herald measurement record indicating whether an error occurred. The herald outcome is 1 if any @@ -391,19 +412,19 @@ class HeraldedPauliChannel1: Parameters ---------- - pi : float + pi : `float` Probability of heralded identity (no error but flagged). - px : float + px : `float` Probability of heralded X error. - py : float + py : `float` Probability of heralded Y error. - pz : float + pz : `float` Probability of heralded Z error. - targets : Sequence[int] + targets : `collections.abc.Sequence`\[`int`\] Target qubit indices. - placement : NoisePlacement + placement : `NoisePlacement` Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + ``AUTO`` defers to :func:`default_noise_placement`. Notes ----- @@ -433,20 +454,20 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class HeraldedErase: - """Heralded erasure noise operation. + r"""Heralded erasure noise operation. Models photon loss or erasure errors with a herald signal. Corresponds to Stim's ``HERALDED_ERASE`` instruction. Parameters ---------- - p : float + p : `float` Probability of erasure. - targets : Sequence[int] + targets : `collections.abc.Sequence`\[`int`\] Target qubit indices. - placement : NoisePlacement + placement : `NoisePlacement` Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + ``AUTO`` defers to :func:`default_noise_placement`. Notes ----- @@ -480,14 +501,14 @@ class RawStimOp: Parameters ---------- - text : str + text : `str` A single Stim instruction line (without trailing newline). - record_delta : int + record_delta : `int` The number of measurement records added by this instruction. Most noise instructions do not add records (default 0). - placement : NoisePlacement + placement : `NoisePlacement` Whether to insert before or after the main operation. - ``AUTO`` defers to the model's default placement for the event. + ``AUTO`` defers to :func:`default_noise_placement`. Examples -------- @@ -517,11 +538,11 @@ class MeasurementFlip: Parameters ---------- - p : float + p : `float` Probability of measurement result flip. - target : int + target : `int` Target qubit index (must match the measurement target). - placement : NoisePlacement + placement : `NoisePlacement` Placement attribute for compatibility (ignored, as this modifies the measurement instruction itself). """ @@ -552,11 +573,9 @@ class NoiseModel: ... ... def on_measure(self, event: MeasureEvent) -> list[PauliChannel1]: ... # Add bit-flip noise before measurement - ... return [PauliChannel1( - ... px=0.01, py=0.0, pz=0.0, - ... targets=[event.node.id], - ... placement=NoisePlacement.BEFORE - ... )] + ... return [ + ... PauliChannel1(px=0.01, py=0.0, pz=0.0, targets=[event.node.id], placement=NoisePlacement.BEFORE) + ... ] See Also -------- @@ -564,97 +583,77 @@ class NoiseModel: """ def on_prepare(self, event: PrepareEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 - """Return noise operations to inject at qubit preparation. + r"""Return noise operations to inject at qubit preparation. Parameters ---------- - event : PrepareEvent + event : `PrepareEvent` Context about the preparation operation. Returns ------- - Sequence[NoiseOp] + `collections.abc.Sequence`\[`NoiseOp`\] Zero or more noise operations to inject. """ return [] def on_entangle(self, event: EntangleEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 - """Return noise operations to inject at entanglement. + r"""Return noise operations to inject at entanglement. Parameters ---------- - event : EntangleEvent + event : `EntangleEvent` Context about the entanglement operation. Returns ------- - Sequence[NoiseOp] + `collections.abc.Sequence`\[`NoiseOp`\] Zero or more noise operations to inject. """ return [] def on_measure(self, event: MeasureEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 - """Return noise operations to inject at measurement. + r"""Return noise operations to inject at measurement. Parameters ---------- - event : MeasureEvent + event : `MeasureEvent` Context about the measurement operation. Returns ------- - Sequence[NoiseOp] + `collections.abc.Sequence`\[`NoiseOp`\] Zero or more noise operations to inject. """ return [] def on_idle(self, event: IdleEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 - """Return noise operations to inject during idle periods. + r"""Return noise operations to inject during idle periods. Parameters ---------- - event : IdleEvent + event : `IdleEvent` Context about the idle period. Returns ------- - Sequence[NoiseOp] + `collections.abc.Sequence`\[`NoiseOp`\] Zero or more noise operations to inject. """ return [] - def default_placement(self, event: NoiseEvent) -> NoisePlacement: # noqa: PLR6301 - """Return the default placement for AUTO noise operations. - - By default, measurement noise is injected before measurement and all - other noise is injected after the main operation. - - Parameters - ---------- - event : NoiseEvent - The event for which to determine the default placement. - - Returns - ------- - NoisePlacement - `BEFORE` for measurement events, `AFTER` for all others. - """ - if isinstance(event, MeasureEvent): - return NoisePlacement.BEFORE - return NoisePlacement.AFTER - def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911, C901 - """Convert a NoiseOp into a Stim instruction line and record delta. + r"""Convert a NoiseOp into a Stim instruction line and record delta. Parameters ---------- - op : NoiseOp + op : `NoiseOp` The noise operation to convert. Returns ------- - tuple[str, int] + `tuple`\[`str`, `int`\] A tuple of ``(stim_instruction, record_delta)`` where ``stim_instruction`` is a single line of Stim code and ``record_delta`` is the number of measurement records added. @@ -747,9 +746,9 @@ class DepolarizingNoiseModel(NoiseModel): Parameters ---------- - p1 : float + p1 : `float` Single-qubit depolarizing probability (after RX preparation). - p2 : float | None + p2 : `float` | `None` Two-qubit depolarizing probability (after CZ). If None, defaults to p1. @@ -766,11 +765,11 @@ def __init__(self, p1: float, p2: float | None = None) -> None: self._p2 = p2 if p2 is not None else p1 def on_prepare(self, event: PrepareEvent) -> Sequence[NoiseOp]: - """Add single-qubit depolarizing noise after preparation. + r"""Add single-qubit depolarizing noise after preparation. Returns ------- - Sequence[NoiseOp] + `collections.abc.Sequence`\[`NoiseOp`\] A tuple containing DEPOLARIZE1 instruction, or empty if p1 <= 0. """ if self._p1 <= 0: @@ -778,11 +777,11 @@ def on_prepare(self, event: PrepareEvent) -> Sequence[NoiseOp]: return (RawStimOp(f"DEPOLARIZE1({self._p1}) {event.node.id}"),) def on_entangle(self, event: EntangleEvent) -> Sequence[NoiseOp]: - """Add two-qubit depolarizing noise after entanglement. + r"""Add two-qubit depolarizing noise after entanglement. Returns ------- - Sequence[NoiseOp] + `collections.abc.Sequence`\[`NoiseOp`\] A tuple containing DEPOLARIZE2 instruction, or empty if p2 <= 0. """ if self._p2 <= 0: @@ -798,7 +797,7 @@ class MeasurementFlipNoiseModel(NoiseModel): Parameters ---------- - p : float + p : `float` Probability of measurement result flip. Examples @@ -813,11 +812,11 @@ def __init__(self, p: float) -> None: self._p = p def on_measure(self, event: MeasureEvent) -> Sequence[NoiseOp]: - """Add measurement flip error. + r"""Add measurement flip error. Returns ------- - Sequence[NoiseOp] + `collections.abc.Sequence`\[`NoiseOp`\] A tuple containing MeasurementFlip operation, or empty if p <= 0. """ if self._p <= 0: diff --git a/graphqomb/stim_compiler.py b/graphqomb/stim_compiler.py index d25643b3..d5730bd6 100644 --- a/graphqomb/stim_compiler.py +++ b/graphqomb/stim_compiler.py @@ -23,13 +23,13 @@ NoiseOp, NoisePlacement, PrepareEvent, + default_noise_placement, noise_op_to_stim, ) if TYPE_CHECKING: from collections.abc import Callable, Collection, Iterable, Mapping, Sequence - from graphqomb.noise_model import NoiseEvent from graphqomb.pattern import Pattern @@ -69,9 +69,7 @@ def _emit_detectors(self, total_measurements: int) -> None: targets = [f"rec[{self._meas_order[c] - total_measurements}]" for c in checks] self._stim_io.write(f"DETECTOR {' '.join(targets)}\n") - def _emit_observables( - self, logical_observables: Mapping[int, Collection[int]], total_measurements: int - ) -> None: + def _emit_observables(self, logical_observables: Mapping[int, Collection[int]], total_measurements: int) -> None: for log_idx, obs in logical_observables.items(): group = self._pframe.logical_observables_group(obs) targets = [f"rec[{self._meas_order[n] - total_measurements}]" for n in group] @@ -97,7 +95,7 @@ def _process_commands(self) -> None: def _process_prepare(self, node: int, coordinate: tuple[float, ...] | None, *, is_input: bool) -> None: event = PrepareEvent(time=self._tick, node=self._node_info(node), is_input=is_input) ops = self._collect_noise_ops_from_models(lambda m: m.on_prepare(event)) - default_placement = self._get_default_placement(event) + default_placement = default_noise_placement(event) self._rec_index += self._emit_noise_ops(ops, NoisePlacement.BEFORE, default_placement) coord = coordinate if self._emit_qubit_coords else None @@ -114,7 +112,7 @@ def _handle_entangle(self, nodes: tuple[int, int]) -> None: edge: tuple[int, int] = (n0, n1) if n0 < n1 else (n1, n0) event = EntangleEvent(time=self._tick, node0=self._node_info(n0), node1=self._node_info(n1), edge=edge) ops = self._collect_noise_ops_from_models(lambda m: m.on_entangle(event)) - default_placement = self._get_default_placement(event) + default_placement = default_noise_placement(event) self._rec_index += self._emit_noise_ops(ops, NoisePlacement.BEFORE, default_placement) self._stim_io.write(f"CZ {n0} {n1}\n") @@ -138,7 +136,7 @@ def _handle_measure(self, node: int, meas_basis: MeasBasis) -> None: else: other_ops.append(op) - default_placement = self._get_default_placement(event) + default_placement = default_noise_placement(event) self._rec_index += self._emit_noise_ops(other_ops, NoisePlacement.BEFORE, default_placement) # Emit measurement with optional flip probability @@ -163,7 +161,7 @@ def _handle_tick(self) -> None: duration=self._tick_duration, ) ops = self._collect_noise_ops_from_models(lambda m: m.on_idle(event)) - default_placement = self._get_default_placement(event) + default_placement = default_noise_placement(event) else: ops = () default_placement = NoisePlacement.AFTER @@ -178,19 +176,12 @@ def _node_info(self, node: int) -> NodeInfo: coord = Coordinate(tuple(coord_raw)) if coord_raw is not None else None return NodeInfo(id=node, coord=coord) - def _collect_noise_ops_from_models( - self, get_ops: Callable[[NoiseModel], Iterable[NoiseOp]] - ) -> tuple[NoiseOp, ...]: + def _collect_noise_ops_from_models(self, get_ops: Callable[[NoiseModel], Iterable[NoiseOp]]) -> tuple[NoiseOp, ...]: ops: list[NoiseOp] = [] for model in self._noise_models: ops.extend(get_ops(model)) return tuple(ops) - def _get_default_placement(self, event: NoiseEvent) -> NoisePlacement: - if self._noise_models: - return self._noise_models[0].default_placement(event) - return NoisePlacement.AFTER - def _emit_noise_ops( self, ops: Iterable[NoiseOp], placement: NoisePlacement, default_placement: NoisePlacement ) -> int: diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py index f1d5d624..7a67fb2d 100644 --- a/tests/test_noise_model.py +++ b/tests/test_noise_model.py @@ -23,6 +23,7 @@ PauliChannel2, PrepareEvent, RawStimOp, + default_noise_placement, depolarize1_probs, depolarize2_probs, noise_op_to_stim, @@ -422,34 +423,30 @@ def test_default_on_idle_returns_empty(self) -> None: result = list(model.on_idle(event)) assert result == [] - def test_default_placement_for_measure_is_before(self) -> None: - """Test that default_placement returns BEFORE for MeasureEvent.""" - model = NoiseModel() + def test_default_noise_placement_for_measure_is_before(self) -> None: + """Test that default_noise_placement returns BEFORE for MeasureEvent.""" node = NodeInfo(id=0, coord=None) event = MeasureEvent(time=0, node=node, axis=Axis.X) - assert model.default_placement(event) == NoisePlacement.BEFORE + assert default_noise_placement(event) == NoisePlacement.BEFORE - def test_default_placement_for_prepare_is_after(self) -> None: - """Test that default_placement returns AFTER for PrepareEvent.""" - model = NoiseModel() + def test_default_noise_placement_for_prepare_is_after(self) -> None: + """Test that default_noise_placement returns AFTER for PrepareEvent.""" node = NodeInfo(id=0, coord=None) event = PrepareEvent(time=0, node=node, is_input=False) - assert model.default_placement(event) == NoisePlacement.AFTER + assert default_noise_placement(event) == NoisePlacement.AFTER - def test_default_placement_for_entangle_is_after(self) -> None: - """Test that default_placement returns AFTER for EntangleEvent.""" - model = NoiseModel() + def test_default_noise_placement_for_entangle_is_after(self) -> None: + """Test that default_noise_placement returns AFTER for EntangleEvent.""" node0 = NodeInfo(id=0, coord=None) node1 = NodeInfo(id=1, coord=None) event = EntangleEvent(time=0, node0=node0, node1=node1, edge=(0, 1)) - assert model.default_placement(event) == NoisePlacement.AFTER + assert default_noise_placement(event) == NoisePlacement.AFTER - def test_default_placement_for_idle_is_after(self) -> None: - """Test that default_placement returns AFTER for IdleEvent.""" - model = NoiseModel() + def test_default_noise_placement_for_idle_is_after(self) -> None: + """Test that default_noise_placement returns AFTER for IdleEvent.""" nodes = [NodeInfo(id=i, coord=None) for i in range(2)] event = IdleEvent(time=0, nodes=nodes, duration=1.0) - assert model.default_placement(event) == NoisePlacement.AFTER + assert default_noise_placement(event) == NoisePlacement.AFTER class TestNoisePlacementAuto: @@ -567,7 +564,7 @@ def test_to_stim_returns_empty(self) -> None: """ op = MeasurementFlip(p=0.01, target=0) text, delta = noise_op_to_stim(op) - assert text == "" + assert not text assert delta == 0 From f931f65c905ffa73011d932913f2db2dba6d3db4 Mon Sep 17 00:00:00 2001 From: masa10-f Date: Sat, 7 Feb 2026 20:54:05 +0900 Subject: [PATCH 12/14] Fix type-safe PauliChannel1 construction in tests --- tests/test_noise_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py index 7a67fb2d..841174e7 100644 --- a/tests/test_noise_model.py +++ b/tests/test_noise_model.py @@ -257,7 +257,7 @@ def test_each_probability_is_p_over_3(self) -> None: def test_can_be_used_with_pauli_channel_1(self) -> None: """Test that depolarize1_probs works with PauliChannel1.""" probs = depolarize1_probs(0.03) - op = PauliChannel1(**probs, targets=[0]) + op = PauliChannel1(px=probs["px"], py=probs["py"], pz=probs["pz"], targets=[0]) text, delta = noise_op_to_stim(op) assert "PAULI_CHANNEL_1" in text assert delta == 0 From 81ff050b98d622f6971efeba0c5b66958f07c224 Mon Sep 17 00:00:00 2001 From: masa10-f Date: Sun, 8 Feb 2026 18:52:11 +0900 Subject: [PATCH 13/14] Add stim compile compatibility and noise validation checks --- graphqomb/noise_model.py | 127 +++++++++++++++++++++++++++++++-- graphqomb/stim_compiler.py | 135 ++++++++++++++++++++++++++++++++++-- tests/test_noise_model.py | 67 ++++++++++++++++++ tests/test_stim_compiler.py | 116 ++++++++++++++++++++++++++++++- 4 files changed, 433 insertions(+), 12 deletions(-) diff --git a/graphqomb/noise_model.py b/graphqomb/noise_model.py index 6926eb7a..d6c3d6fa 100644 --- a/graphqomb/noise_model.py +++ b/graphqomb/noise_model.py @@ -106,6 +106,56 @@ ) +def _validate_probability(name: str, value: float) -> float: + """Validate a probability value and return it as float. + + Parameters + ---------- + name : `str` + Human-readable probability name used in error messages. + value : `float` + Probability value to validate. + + Returns + ------- + `float` + The validated probability value. + + Raises + ------ + ValueError + If the probability is outside the range ``[0, 1]``. + """ + p = float(value) + if not 0.0 <= p <= 1.0: + msg = f"{name} must be within [0, 1], got {value!r}" + raise ValueError(msg) + return p + + +def _validate_probability_sum(name: str, probabilities: Sequence[float], *, atol: float = 1e-12) -> None: + r"""Validate that probabilities sum to at most 1 within tolerance. + + Parameters + ---------- + name : `str` + Human-readable name used in error messages. + probabilities : `collections.abc.Sequence`\[`float`\] + Probability values to validate. + atol : `float`, optional + Absolute tolerance for sum comparison, by default ``1e-12``. + + Raises + ------ + ValueError + If the total probability exceeds ``1 + atol``. + """ + total = float(sum(probabilities)) + if total > 1.0 + atol: + msg = f"{name} probabilities must sum to <= 1, got {total}" + raise ValueError(msg) + + def depolarize1_probs(p: float) -> dict[str, float]: r"""Create probability dict for single-qubit depolarizing channel. @@ -127,6 +177,7 @@ def depolarize1_probs(p: float) -> dict[str, float]: >>> probs["py"] 0.01 """ + p = _validate_probability("depolarize1_probs.p", p) p_each = p / 3 return {"px": p_each, "py": p_each, "pz": p_each} @@ -152,6 +203,7 @@ def depolarize2_probs(p: float) -> dict[str, float]: >>> len(probs) 15 """ + p = _validate_probability("depolarize2_probs.p", p) p_each = p / 15 return dict.fromkeys(PAULI_CHANNEL_2_ORDER, p_each) @@ -527,6 +579,21 @@ class RawStimOp: record_delta: int = 0 placement: NoisePlacement = NoisePlacement.AUTO + def __post_init__(self) -> None: + if "\n" in self.text or "\r" in self.text: + msg = "RawStimOp.text must be a single Stim instruction line without newlines" + raise ValueError(msg) + if self.record_delta < 0: + msg = f"RawStimOp.record_delta must be non-negative, got {self.record_delta}" + raise ValueError(msg) + expected_delta = _infer_raw_record_delta(self.text) + if expected_delta is not None and self.record_delta != expected_delta: + msg = ( + f"RawStimOp.record_delta mismatch for instruction {self.text!r}: " + f"expected {expected_delta}, got {self.record_delta}" + ) + raise ValueError(msg) + @dataclass(frozen=True) class MeasurementFlip: @@ -675,8 +742,12 @@ def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911, C901 if isinstance(op, PauliChannel1): if not op.targets: return "", 0 + px = _validate_probability("PauliChannel1.px", op.px) + py = _validate_probability("PauliChannel1.py", op.py) + pz = _validate_probability("PauliChannel1.pz", op.pz) + _validate_probability_sum("PauliChannel1", (px, py, pz)) targets = " ".join(str(t) for t in op.targets) - return f"PAULI_CHANNEL_1({op.px},{op.py},{op.pz}) {targets}", 0 + return f"PAULI_CHANNEL_1({px},{py},{pz}) {targets}", 0 if isinstance(op, PauliChannel2): if not op.targets: @@ -690,19 +761,26 @@ def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911, C901 if isinstance(op, HeraldedPauliChannel1): if not op.targets: return "", 0 + pi = _validate_probability("HeraldedPauliChannel1.pi", op.pi) + px = _validate_probability("HeraldedPauliChannel1.px", op.px) + py = _validate_probability("HeraldedPauliChannel1.py", op.py) + pz = _validate_probability("HeraldedPauliChannel1.pz", op.pz) + _validate_probability_sum("HeraldedPauliChannel1", (pi, px, py, pz)) targets = " ".join(str(t) for t in op.targets) return ( - f"HERALDED_PAULI_CHANNEL_1({op.pi},{op.px},{op.py},{op.pz}) {targets}", + f"HERALDED_PAULI_CHANNEL_1({pi},{px},{py},{pz}) {targets}", len(op.targets), ) if isinstance(op, HeraldedErase): if not op.targets: return "", 0 + p = _validate_probability("HeraldedErase.p", op.p) targets = " ".join(str(t) for t in op.targets) - return f"HERALDED_ERASE({op.p}) {targets}", len(op.targets) + return f"HERALDED_ERASE({p}) {targets}", len(op.targets) if isinstance(op, MeasurementFlip): + _validate_probability("MeasurementFlip.p", op.p) # MeasurementFlip is handled specially in the compiler by modifying # the measurement instruction. It should not be emitted as a separate op. return "", 0 @@ -717,11 +795,18 @@ def _pauli_channel_2_args(probabilities: Sequence[float] | Mapping[str, float]) if unknown: msg = f"Unknown PAULI_CHANNEL_2 keys: {sorted(unknown)}" raise ValueError(msg) - return tuple(float(probabilities.get(key, 0.0)) for key in PAULI_CHANNEL_2_ORDER) + values = tuple(float(probabilities.get(key, 0.0)) for key in PAULI_CHANNEL_2_ORDER) + for key, value in zip(PAULI_CHANNEL_2_ORDER, values, strict=True): + _validate_probability(f"PauliChannel2.probabilities[{key}]", value) + _validate_probability_sum("PauliChannel2", values) + return values values = tuple(float(v) for v in probabilities) if len(values) != len(PAULI_CHANNEL_2_ORDER): msg = f"PAULI_CHANNEL_2 expects {len(PAULI_CHANNEL_2_ORDER)} probabilities, got {len(values)}" raise ValueError(msg) + for index, value in enumerate(values): + _validate_probability(f"PauliChannel2.probabilities[{index}]", value) + _validate_probability_sum("PauliChannel2", values) return values @@ -735,6 +820,40 @@ def _flatten_pairs(pairs: Sequence[tuple[int, int]]) -> tuple[int, ...]: return tuple(flat) +_PER_TARGET_RECORD_DELTA_INSTRUCTIONS: frozenset[str] = frozenset( + { + "M", + "MX", + "MY", + "MZ", + "MR", + "MRX", + "MRY", + "MRZ", + "HERALDED_ERASE", + "HERALDED_PAULI_CHANNEL_1", + } +) + + +def _infer_raw_record_delta(text: str) -> int | None: + """Infer record delta from a raw instruction when the rule is unambiguous. + + Returns + ------- + int | None + Number of records produced if it can be inferred, otherwise None. + """ + stripped = text.strip() + if not stripped: + return 0 + parts = stripped.split() + instruction = parts[0].split("(", 1)[0] + if instruction in _PER_TARGET_RECORD_DELTA_INSTRUCTIONS: + return len(parts) - 1 + return None + + # ---- Built-in NoiseModel implementations ---- diff --git a/graphqomb/stim_compiler.py b/graphqomb/stim_compiler.py index d5730bd6..26466d1f 100644 --- a/graphqomb/stim_compiler.py +++ b/graphqomb/stim_compiler.py @@ -7,17 +7,21 @@ from __future__ import annotations +import math from io import StringIO from typing import TYPE_CHECKING +from warnings import warn -from graphqomb.command import TICK, E, M, N +from graphqomb.command import TICK, E, M, N, X, Z from graphqomb.common import Axis, MeasBasis, determine_pauli_axis from graphqomb.noise_model import ( Coordinate, + DepolarizingNoiseModel, EntangleEvent, IdleEvent, MeasureEvent, MeasurementFlip, + MeasurementFlipNoiseModel, NodeInfo, NoiseModel, NoiseOp, @@ -91,6 +95,16 @@ def _process_commands(self) -> None: self._handle_measure(cmd.node, cmd.meas_basis) elif isinstance(cmd, TICK): self._handle_tick() + elif isinstance(cmd, (X, Z)): + cmd_name = type(cmd).__name__ + msg = ( + f"Unsupported command for stim compilation: {cmd_name}. " + "X/Z correction commands are not supported." + ) + raise NotImplementedError(msg) + else: + msg = f"Unsupported command for stim compilation: {type(cmd).__name__}" + raise TypeError(msg) def _process_prepare(self, node: int, coordinate: tuple[float, ...] | None, *, is_input: bool) -> None: event = PrepareEvent(time=self._tick, node=self._node_info(node), is_input=is_input) @@ -128,13 +142,26 @@ def _handle_measure(self, node: int, meas_basis: MeasBasis) -> None: ops = self._collect_noise_ops_from_models(lambda m: m.on_measure(event)) # Separate MeasurementFlip from other noise ops - meas_flip_p = 0.0 + meas_flip_probs: list[float] = [] other_ops: list[NoiseOp] = [] for op in ops: - if isinstance(op, MeasurementFlip) and op.target == node: - meas_flip_p = max(meas_flip_p, op.p) + if isinstance(op, MeasurementFlip): + if op.target != node: + msg = ( + f"MeasurementFlip target mismatch: measurement on node {node}, " + f"but flip targets node {op.target}" + ) + raise ValueError(msg) + meas_flip_probs.append(op.p) else: other_ops.append(op) + if not meas_flip_probs: + meas_flip_p = 0.0 + elif len(meas_flip_probs) == 1: + meas_flip_p = meas_flip_probs[0] + else: + meas_flip_p = 1.0 - math.prod(1.0 - p for p in meas_flip_probs) + meas_flip_p = min(max(meas_flip_p, 0.0), 1.0) default_placement = default_noise_placement(event) self._rec_index += self._emit_noise_ops(other_ops, NoisePlacement.BEFORE, default_placement) @@ -199,10 +226,91 @@ def _emit_noise_ops( return record_delta -def stim_compile( +def _validate_probability_parameter(name: str, value: float) -> None: + """Validate that a probability parameter is within [0, 1]. + + Parameters + ---------- + name : `str` + Parameter name used in error messages. + value : `float` + Probability value to validate. + + Raises + ------ + ValueError + If ``value`` is outside the inclusive range [0, 1]. + """ + if not 0.0 <= value <= 1.0: + msg = f"{name} must be within [0, 1], got {value}" + raise ValueError(msg) + + +def _normalize_noise_models( + noise_models: Sequence[NoiseModel] | None, + p_depol_after_clifford: float | None, + p_before_meas_flip: float | None, +) -> tuple[NoiseModel, ...]: + r"""Normalize legacy noise parameters and new noise model API. + + Parameters + ---------- + noise_models : `collections.abc.Sequence`\[`NoiseModel`\] | `None` + New noise model API input. + p_depol_after_clifford : `float` | `None` + Legacy depolarizing noise parameter. + p_before_meas_flip : `float` | `None` + Legacy measurement flip noise parameter. + + Returns + ------- + `tuple`\[`NoiseModel`, ...\] + Normalized noise model sequence. + + Raises + ------ + ValueError + If legacy parameters are invalid or mixed with ``noise_models``. + """ + used_legacy: list[str] = [] + legacy_models: list[NoiseModel] = [] + + if p_depol_after_clifford is not None: + _validate_probability_parameter("p_depol_after_clifford", p_depol_after_clifford) + used_legacy.append("p_depol_after_clifford") + if p_depol_after_clifford > 0.0: + legacy_models.append(DepolarizingNoiseModel(p1=p_depol_after_clifford, p2=p_depol_after_clifford)) + + if p_before_meas_flip is not None: + _validate_probability_parameter("p_before_meas_flip", p_before_meas_flip) + used_legacy.append("p_before_meas_flip") + if p_before_meas_flip > 0.0: + legacy_models.append(MeasurementFlipNoiseModel(p=p_before_meas_flip)) + + if noise_models is not None and used_legacy: + legacy_args = ", ".join(used_legacy) + msg = f"{legacy_args} cannot be used together with noise_models." + raise ValueError(msg) + + if used_legacy: + warn( + "p_depol_after_clifford and p_before_meas_flip are deprecated in 0.3.0 and will be removed in 0.4.0. " + "Use noise_models with DepolarizingNoiseModel and MeasurementFlipNoiseModel instead.", + DeprecationWarning, + stacklevel=3, + ) + + if noise_models is not None: + return tuple(noise_models) + return tuple(legacy_models) + + +def stim_compile( # noqa: PLR0913 pattern: Pattern, logical_observables: Mapping[int, Collection[int]] | None = None, *, + p_depol_after_clifford: float | None = None, + p_before_meas_flip: float | None = None, emit_qubit_coords: bool = True, noise_models: Sequence[NoiseModel] | None = None, tick_duration: float = 1.0, @@ -215,6 +323,12 @@ def stim_compile( The pattern to compile. logical_observables : `collections.abc.Mapping`\[`int`, `collections.abc.Collection`\[`int`\]\], optional A mapping from logical observable index to a collection of node indices, by default None. + p_depol_after_clifford : `float` | `None`, optional + Legacy depolarizing probability after Clifford gates. Deprecated in 0.3.0. + Use ``noise_models=[DepolarizingNoiseModel(p1=..., p2=...)]`` instead. + p_before_meas_flip : `float` | `None`, optional + Legacy measurement bit-flip probability. Deprecated in 0.3.0. + Use ``noise_models=[MeasurementFlipNoiseModel(p=...)]`` instead. emit_qubit_coords : `bool`, optional Whether to emit QUBIT_COORDS instructions for nodes with coordinates, by default True. @@ -232,9 +346,13 @@ def stim_compile( Notes ----- + Deprecated parameters ``p_depol_after_clifford`` and ``p_before_meas_flip`` emit + a `DeprecationWarning` and will be removed in 0.4.0. + Legacy parameters cannot be mixed with ``noise_models``. Stim only supports Clifford gates, therefore this compiler only supports Pauli measurements (X, Y, Z basis) which correspond to Clifford operations. Non-Pauli measurements will raise a ValueError. + Patterns containing X or Z correction commands will raise a NotImplementedError. Examples -------- @@ -253,10 +371,15 @@ def stim_compile( >>> # ] >>> # ) """ + normalized_noise_models = _normalize_noise_models( + noise_models=noise_models, + p_depol_after_clifford=p_depol_after_clifford, + p_before_meas_flip=p_before_meas_flip, + ) compiler = _StimCompiler( pattern, emit_qubit_coords=emit_qubit_coords, - noise_models=noise_models or (), + noise_models=normalized_noise_models, tick_duration=tick_duration, ) return compiler.compile(logical_observables) diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py index 841174e7..0cd6d588 100644 --- a/tests/test_noise_model.py +++ b/tests/test_noise_model.py @@ -178,6 +178,18 @@ def test_targets_converted_to_tuple(self) -> None: assert isinstance(op.targets, tuple) assert op.targets == (0, 1, 2) + def test_negative_probability_raises(self) -> None: + """Test PauliChannel1 with negative probability raises ValueError.""" + op = PauliChannel1(px=-0.01, py=0.0, pz=0.0, targets=[0]) + with pytest.raises(ValueError, match=r"PauliChannel1\.px must be within \[0, 1\]"): + noise_op_to_stim(op) + + def test_probability_sum_greater_than_one_raises(self) -> None: + """Test PauliChannel1 with probabilities summing to >1 raises ValueError.""" + op = PauliChannel1(px=0.5, py=0.4, pz=0.2, targets=[0]) + with pytest.raises(ValueError, match=r"PauliChannel1 probabilities must sum to <= 1"): + noise_op_to_stim(op) + class TestPauliChannel2: """Tests for PauliChannel2 noise operation.""" @@ -236,6 +248,18 @@ def test_pauli_channel_2_order_has_15_elements(self) -> None: """Test that PAULI_CHANNEL_2_ORDER has exactly 15 elements.""" assert len(PAULI_CHANNEL_2_ORDER) == 15 + def test_probability_out_of_range_raises(self) -> None: + """Test PauliChannel2 with out-of-range probability raises ValueError.""" + op = PauliChannel2(probabilities={"ZZ": 1.1}, targets=[(0, 1)]) + with pytest.raises(ValueError, match=r"PauliChannel2\.probabilities\[ZZ\] must be within \[0, 1\]"): + noise_op_to_stim(op) + + def test_probability_sum_greater_than_one_raises(self) -> None: + """Test PauliChannel2 with probabilities summing to >1 raises ValueError.""" + op = PauliChannel2(probabilities={"ZZ": 0.8, "XX": 0.3}, targets=[(0, 1)]) + with pytest.raises(ValueError, match=r"PauliChannel2 probabilities must sum to <= 1"): + noise_op_to_stim(op) + class TestDepolarize1Probs: """Tests for depolarize1_probs utility function.""" @@ -262,6 +286,11 @@ def test_can_be_used_with_pauli_channel_1(self) -> None: assert "PAULI_CHANNEL_1" in text assert delta == 0 + def test_invalid_probability_raises(self) -> None: + """Test that depolarize1_probs validates probability range.""" + with pytest.raises(ValueError, match=r"depolarize1_probs\.p must be within \[0, 1\]"): + depolarize1_probs(-0.1) + class TestDepolarize2Probs: """Tests for depolarize2_probs utility function.""" @@ -293,6 +322,11 @@ def test_can_be_used_with_pauli_channel_2(self) -> None: assert "PAULI_CHANNEL_2" in text assert delta == 0 + def test_invalid_probability_raises(self) -> None: + """Test that depolarize2_probs validates probability range.""" + with pytest.raises(ValueError, match=r"depolarize2_probs\.p must be within \[0, 1\]"): + depolarize2_probs(1.2) + class TestHeraldedPauliChannel1: """Tests for HeraldedPauliChannel1 noise operation.""" @@ -323,6 +357,12 @@ def test_targets_converted_to_tuple(self) -> None: op = HeraldedPauliChannel1(pi=0.0, px=0.01, py=0.0, pz=0.0, targets=[0, 1]) assert isinstance(op.targets, tuple) + def test_probability_sum_greater_than_one_raises(self) -> None: + """Test HeraldedPauliChannel1 with probabilities summing to >1 raises ValueError.""" + op = HeraldedPauliChannel1(pi=0.4, px=0.4, py=0.3, pz=0.0, targets=[0]) + with pytest.raises(ValueError, match=r"HeraldedPauliChannel1 probabilities must sum to <= 1"): + noise_op_to_stim(op) + class TestHeraldedErase: """Tests for HeraldedErase noise operation.""" @@ -353,6 +393,12 @@ def test_targets_converted_to_tuple(self) -> None: op = HeraldedErase(p=0.05, targets=[0, 1, 2]) assert isinstance(op.targets, tuple) + def test_probability_out_of_range_raises(self) -> None: + """Test HeraldedErase with out-of-range probability raises ValueError.""" + op = HeraldedErase(p=1.1, targets=[0]) + with pytest.raises(ValueError, match=r"HeraldedErase\.p must be within \[0, 1\]"): + noise_op_to_stim(op) + class TestRawStimOp: """Tests for RawStimOp noise operation.""" @@ -383,6 +429,21 @@ def test_placement_before(self) -> None: op = RawStimOp(text="Z_ERROR(0.01) 0", placement=NoisePlacement.BEFORE) assert op.placement == NoisePlacement.BEFORE + def test_multiline_text_raises(self) -> None: + """Test RawStimOp rejects multiline text.""" + with pytest.raises(ValueError, match="single Stim instruction line"): + RawStimOp(text="M 0\nM 1", record_delta=0) + + def test_negative_record_delta_raises(self) -> None: + """Test RawStimOp rejects negative record_delta.""" + with pytest.raises(ValueError, match="must be non-negative"): + RawStimOp(text="X_ERROR(0.01) 0", record_delta=-1) + + def test_record_delta_mismatch_raises(self) -> None: + """Test RawStimOp validates record_delta consistency for inferable instructions.""" + with pytest.raises(ValueError, match="record_delta mismatch"): + RawStimOp(text="MR 5", record_delta=0) + # ---- NoiseModel Tests ---- @@ -567,6 +628,12 @@ def test_to_stim_returns_empty(self) -> None: assert not text assert delta == 0 + def test_invalid_probability_raises(self) -> None: + """Test MeasurementFlip validates probability range.""" + op = MeasurementFlip(p=-0.1, target=0) + with pytest.raises(ValueError, match=r"MeasurementFlip\.p must be within \[0, 1\]"): + noise_op_to_stim(op) + # ---- DepolarizingNoiseModel Tests ---- diff --git a/tests/test_stim_compiler.py b/tests/test_stim_compiler.py index 848ed054..74bd5f8e 100644 --- a/tests/test_stim_compiler.py +++ b/tests/test_stim_compiler.py @@ -14,6 +14,7 @@ DepolarizingNoiseModel, HeraldedPauliChannel1, MeasureEvent, + MeasurementFlip, MeasurementFlipNoiseModel, NoiseModel, ) @@ -49,6 +50,7 @@ def create_simple_pattern_x_measurement() -> tuple[Pattern, int, int]: # X measurement: XY plane with angle 0 graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) graph.assign_meas_basis(meas_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.0)) xflow = {in_node: {meas_node}, meas_node: {out_node}} pattern = qompile(graph, xflow) @@ -79,6 +81,7 @@ def create_simple_pattern_y_measurement() -> tuple[Pattern, int, int]: # Y measurement: XY plane with angle π/2 graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, math.pi / 2)) graph.assign_meas_basis(meas_node, PlannerMeasBasis(Plane.XY, math.pi / 2)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, math.pi / 2)) xflow = {in_node: {meas_node}, meas_node: {out_node}} pattern = qompile(graph, xflow) @@ -109,6 +112,7 @@ def create_simple_pattern_z_measurement() -> tuple[Pattern, int, int]: # Z measurement: XZ plane with angle 0 graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XZ, 0.0)) graph.assign_meas_basis(meas_node, PlannerMeasBasis(Plane.XZ, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XZ, 0.0)) xflow = {in_node: {meas_node}, meas_node: {out_node}} pattern = qompile(graph, xflow) @@ -173,6 +177,17 @@ def test_stim_compile_with_depolarization() -> None: assert "DEPOLARIZE2(0.01)" in stim_str +def test_stim_compile_with_legacy_depolarization_parameter() -> None: + """Legacy depolarization parameter should still work with a deprecation warning.""" + pattern, _, _ = create_simple_pattern_x_measurement() + + with pytest.warns(DeprecationWarning, match="p_depol_after_clifford"): + stim_str = stim_compile(pattern, p_depol_after_clifford=0.01) + + assert "DEPOLARIZE1(0.01)" in stim_str + assert "DEPOLARIZE2(0.01)" in stim_str + + def test_stim_compile_with_measurement_errors_x() -> None: """Test that X measurement errors are correctly inserted using MeasurementFlipNoiseModel.""" pattern, _, _ = create_simple_pattern_x_measurement() @@ -183,6 +198,16 @@ def test_stim_compile_with_measurement_errors_x() -> None: assert "MX(0.01)" in stim_str +def test_stim_compile_with_legacy_measurement_flip_parameter() -> None: + """Legacy measurement flip parameter should still work with a deprecation warning.""" + pattern, _, _ = create_simple_pattern_x_measurement() + + with pytest.warns(DeprecationWarning, match="p_before_meas_flip"): + stim_str = stim_compile(pattern, p_before_meas_flip=0.01) + + assert "MX(0.01)" in stim_str + + def test_stim_compile_with_measurement_errors_y() -> None: """Test that Y measurement errors are correctly inserted using MeasurementFlipNoiseModel.""" pattern, _, _ = create_simple_pattern_y_measurement() @@ -203,6 +228,46 @@ def test_stim_compile_with_measurement_errors_z() -> None: assert "MZ(0.01)" in stim_str +def test_stim_compile_combines_measurement_flip_probabilities() -> None: + """Multiple MeasurementFlip models should combine as independent events.""" + pattern, _, _ = create_simple_pattern_x_measurement() + + stim_str = stim_compile( + pattern, + noise_models=[ + MeasurementFlipNoiseModel(p=0.1), + MeasurementFlipNoiseModel(p=0.2), + ], + ) + + expected = 1.0 - (1.0 - 0.1) * (1.0 - 0.2) + mx_lines = [line for line in stim_str.splitlines() if line.startswith("MX(")] + assert mx_lines + for line in mx_lines: + prob = float(line.split("(", 1)[1].split(")", 1)[0]) + assert math.isclose(prob, expected) + + +def test_stim_compile_rejects_mixed_legacy_and_noise_models() -> None: + """Legacy noise parameters cannot be used together with noise_models.""" + pattern, _, _ = create_simple_pattern_x_measurement() + + with pytest.raises(ValueError, match="cannot be used together with noise_models"): + stim_compile( + pattern, + noise_models=[DepolarizingNoiseModel(p1=0.01)], + p_depol_after_clifford=0.01, + ) + + +def test_stim_compile_validates_legacy_probability_parameters() -> None: + """Legacy probability parameters should be validated before compilation.""" + pattern, _, _ = create_simple_pattern_x_measurement() + + with pytest.raises(ValueError, match="must be within \\[0, 1\\]"): + stim_compile(pattern, p_before_meas_flip=1.1) + + def test_stim_compile_with_detectors() -> None: """Test DETECTOR generation with parity check groups.""" graph = GraphState() @@ -219,6 +284,7 @@ def test_stim_compile_with_detectors() -> None: graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) graph.assign_meas_basis(meas_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.0)) xflow = {in_node: {meas_node}, meas_node: {out_node}} # Add parity check groups @@ -240,6 +306,13 @@ def on_measure(self, event: MeasureEvent) -> list[HeraldedPauliChannel1]: return [HeraldedPauliChannel1(0.0, 0.0, 0.0, 0.1, targets=[event.node.id])] +class _MismatchedMeasurementFlipNoise(NoiseModel): + """Test noise model with intentionally mismatched MeasurementFlip target.""" + + def on_measure(self, event: MeasureEvent) -> list[MeasurementFlip]: + return [MeasurementFlip(p=0.1, target=event.node.id + 999)] + + def _parse_stim_measurements(stim_str: str) -> tuple[dict[int, int], int]: """Parse stim string to extract measurement order and total record count.""" rec_index = 0 @@ -248,10 +321,11 @@ def _parse_stim_measurements(stim_str: str) -> tuple[dict[int, int], int]: stripped = raw_line.strip() if not stripped: continue - if stripped.startswith("HERALDED_PAULI_CHANNEL_1"): + opcode = stripped.split()[0].split("(", 1)[0] + if opcode == "HERALDED_PAULI_CHANNEL_1": targets = stripped.split(")", 1)[1].strip().split() rec_index += len(targets) - elif stripped.startswith(("MX ", "MY ", "MZ ")): + elif opcode in {"MX", "MY", "MZ"}: node = int(stripped.split()[1]) actual_meas_order[node] = rec_index rec_index += 1 @@ -283,6 +357,7 @@ def test_stim_compile_with_heralded_noise_updates_detectors() -> None: graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) graph.assign_meas_basis(meas_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.0)) xflow = {in_node: {meas_node}, meas_node: {out_node}} parity_check_group = [{in_node}] @@ -303,6 +378,14 @@ def test_stim_compile_with_heralded_noise_updates_detectors() -> None: assert expected_detectors == actual_detectors +def test_stim_compile_rejects_mismatched_measurement_flip_target() -> None: + """MeasurementFlip target must match the current measurement node.""" + pattern, _, _ = create_simple_pattern_x_measurement() + + with pytest.raises(ValueError, match="MeasurementFlip target mismatch"): + stim_compile(pattern, noise_models=[_MismatchedMeasurementFlipNoise()]) + + def test_stim_compile_with_logical_observables() -> None: """Test OBSERVABLE_INCLUDE generation.""" pattern, meas_node, _ = create_simple_pattern_x_measurement() @@ -335,6 +418,7 @@ def test_stim_compile_unsupported_basis() -> None: # Non-Pauli measurement: XY plane with arbitrary angle graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.1)) graph.assign_meas_basis(meas_node, PlannerMeasBasis(Plane.XY, 0.1)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.1)) xflow = {in_node: {meas_node}, meas_node: {out_node}} pattern = qompile(graph, xflow) @@ -344,6 +428,26 @@ def test_stim_compile_unsupported_basis() -> None: stim_compile(pattern) +def test_stim_compile_unsupported_output_corrections() -> None: + """Test that X/Z correction commands in a pattern raise NotImplementedError.""" + graph = GraphState() + in_node = graph.add_physical_node() + out_node = graph.add_physical_node() + + q_idx = 0 + graph.register_input(in_node, q_idx) + graph.register_output(out_node, q_idx) + + graph.add_physical_edge(in_node, out_node) + graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) + + xflow = {in_node: {out_node}} + pattern = qompile(graph, xflow) + + with pytest.raises(NotImplementedError, match="X/Z correction commands are not supported"): + stim_compile(pattern) + + def test_stim_compile_empty_pattern() -> None: """Test compilation of minimal pattern.""" graph = GraphState() @@ -356,6 +460,7 @@ def test_stim_compile_empty_pattern() -> None: graph.add_physical_edge(in_node, out_node) graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.0)) xflow = {in_node: {out_node}} pattern = qompile(graph, xflow) @@ -384,6 +489,7 @@ def test_stim_compile_axis_meas_basis() -> None: # Use AxisMeasBasis instead of PlannerMeasBasis graph.assign_meas_basis(in_node, AxisMeasBasis(Axis.X, Sign.PLUS)) graph.assign_meas_basis(meas_node, AxisMeasBasis(Axis.Y, Sign.PLUS)) + graph.assign_meas_basis(out_node, AxisMeasBasis(Axis.X, Sign.PLUS)) xflow = {in_node: {meas_node}, meas_node: {out_node}} pattern = qompile(graph, xflow) @@ -410,6 +516,7 @@ def test_stim_compile_with_tick_commands() -> None: graph.assign_meas_basis(node0, PlannerMeasBasis(Plane.XY, 0.0)) graph.assign_meas_basis(node1, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(node2, PlannerMeasBasis(Plane.XY, 0.0)) flow = {node0: {node1}, node1: {node2}} scheduler = Scheduler(graph, flow) @@ -499,6 +606,7 @@ def test_stim_compile_respects_manual_entangle_time() -> None: graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) graph.assign_meas_basis(mid_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.0)) scheduler = Scheduler(graph, {in_node: {mid_node}, mid_node: {out_node}}) @@ -543,6 +651,7 @@ def test_stim_compile_with_coordinates() -> None: graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) graph.assign_meas_basis(mid_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.0)) pattern = qompile(graph, {in_node: {mid_node}, mid_node: {out_node}}) stim_str = stim_compile(pattern) @@ -564,6 +673,7 @@ def test_stim_compile_with_3d_coordinates() -> None: graph.add_physical_edge(in_node, out_node) graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.0)) pattern = qompile(graph, {in_node: {out_node}}) stim_str = stim_compile(pattern) @@ -583,6 +693,7 @@ def test_stim_compile_without_coordinates() -> None: graph.add_physical_edge(in_node, out_node) graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.0)) pattern = qompile(graph, {in_node: {out_node}}) stim_str = stim_compile(pattern, emit_qubit_coords=False) @@ -605,6 +716,7 @@ def test_pattern_coordinates_property() -> None: graph.assign_meas_basis(in_node, PlannerMeasBasis(Plane.XY, 0.0)) graph.assign_meas_basis(mid_node, PlannerMeasBasis(Plane.XY, 0.0)) + graph.assign_meas_basis(out_node, PlannerMeasBasis(Plane.XY, 0.0)) pattern = qompile(graph, {in_node: {mid_node}, mid_node: {out_node}}) From 13476408b083facdfe2f2ee51a0b880ddf3c5793 Mon Sep 17 00:00:00 2001 From: masa10-f Date: Sun, 8 Feb 2026 19:04:23 +0900 Subject: [PATCH 14/14] format --- graphqomb/stim_compiler.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/graphqomb/stim_compiler.py b/graphqomb/stim_compiler.py index 26466d1f..363ddaf9 100644 --- a/graphqomb/stim_compiler.py +++ b/graphqomb/stim_compiler.py @@ -98,8 +98,7 @@ def _process_commands(self) -> None: elif isinstance(cmd, (X, Z)): cmd_name = type(cmd).__name__ msg = ( - f"Unsupported command for stim compilation: {cmd_name}. " - "X/Z correction commands are not supported." + f"Unsupported command for stim compilation: {cmd_name}. X/Z correction commands are not supported." ) raise NotImplementedError(msg) else: