Source code for graphqomb.noise_model

r"""Noise model interface for Stim circuit compilation.

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.
- `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.
- `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.

See Also
--------
stim_compile : The main compilation function that accepts a NoiseModel.

Notes
-----
- **Placement control**: Each `NoiseOp` has a ``placement`` attribute.
  ``AUTO`` defers to :func:`default_noise_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.

Examples
--------
Create a simple depolarizing noise model:

>>> from graphqomb.noise_model import (
...     NoiseModel,
...     PrepareEvent,
...     EntangleEvent,
...     PauliChannel1,
...     PauliChannel2,
...     depolarize1_probs,
...     depolarize2_probs,
... )
>>>
>>> class DepolarizingNoise(NoiseModel):
...     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]:
...         return [PauliChannel1(**depolarize1_probs(self.p1), targets=[event.node.id])]
...
...     def on_entangle(self, event: EntangleEvent) -> list[PauliChannel2]:
...         return [
...             PauliChannel2.from_mapping(
...                 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_models=[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])]
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum, auto
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Mapping, Sequence

    from graphqomb.common import Axis


PAULI_CHANNEL_2_ORDER: tuple[str, ...] = (
    "IX",
    "IY",
    "IZ",
    "XI",
    "XX",
    "XY",
    "XZ",
    "YI",
    "YX",
    "YY",
    "YZ",
    "ZI",
    "ZX",
    "ZY",
    "ZZ",
)
_PAULI_CHANNEL_2_KEYS = frozenset(PAULI_CHANNEL_2_ORDER)
_PAULI_CHANNEL_2_ARG_COUNT = len(PAULI_CHANNEL_2_ORDER)


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 _validate_noise_placement(name: str, placement: NoisePlacement) -> NoisePlacement:
    """Validate and return a noise placement value.

    Parameters
    ----------
    name : `str`
        Human-readable name used in error messages.
    placement : `NoisePlacement`
        Placement value to validate.

    Returns
    -------
    `NoisePlacement`
        The validated placement value.

    Raises
    ------
    TypeError
        If ``placement`` is not a `NoisePlacement`.
    """
    if not isinstance(placement, NoisePlacement):
        msg = f"{name} must be a NoisePlacement, got {placement!r}"
        raise TypeError(msg)
    return placement


[docs] def depolarize1_probs(p: float) -> dict[str, float]: r"""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 = _validate_probability("depolarize1_probs.p", p) p_each = p / 3 return {"px": p_each, "py": p_each, "pz": p_each}
[docs] def depolarize2_probs(p: float) -> dict[str, float]: r"""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 = _validate_probability("depolarize2_probs.p", p) p_each = p / 15 return dict.fromkeys(PAULI_CHANNEL_2_ORDER, p_each)
[docs] class NoisePlacement(Enum): """Where to insert noise relative to the main operation.""" AUTO = auto() BEFORE = auto() AFTER = auto()
[docs] @dataclass(frozen=True) class Coordinate: r"""N-dimensional coordinate for a node. Parameters ---------- values : `tuple`\[`float`, ...\] The coordinate values as a tuple of floats. Examples -------- >>> 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])
[docs] @dataclass(frozen=True) class NodeInfo: """Node identifier with optional coordinate. Parameters ---------- id : `int` The unique node index in the pattern. coord : `Coordinate` | `None` The spatial coordinate of the node, if available. """ id: int coord: Coordinate | None
[docs] @dataclass(frozen=True) 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
[docs] @dataclass(frozen=True) class EntangleEvent: r"""Event emitted when two qubits are entangled (E command / CZ gate). Parameters ---------- time : `int` The current tick (time step) in the pattern execution. 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]
[docs] @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
[docs] @dataclass(frozen=True) class IdleEvent: r"""Event emitted for qubits that are idle during a TICK. Parameters ---------- time : `int` The current tick (time step) in the pattern execution. nodes : `collections.abc.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."""
[docs] 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
[docs] @dataclass(frozen=True) class PauliChannel1: 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` Probability of X error. py : `float` Probability of Y error. pz : `float` Probability of Z error. targets : `collections.abc.Sequence`\[`int`\] Target qubit indices. placement : `NoisePlacement` Whether to insert before or after the main operation. ``AUTO`` defers to :func:`default_noise_placement`. Examples -------- >>> 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) """ px: float py: float pz: float targets: Sequence[int] placement: NoisePlacement = NoisePlacement.AUTO def __post_init__(self) -> None: px = _validate_probability("PauliChannel1.px", self.px) py = _validate_probability("PauliChannel1.py", self.py) pz = _validate_probability("PauliChannel1.pz", self.pz) _validate_probability_sum("PauliChannel1", (px, py, pz)) placement = _validate_noise_placement("PauliChannel1.placement", self.placement) object.__setattr__(self, "px", px) object.__setattr__(self, "py", py) object.__setattr__(self, "pz", pz) object.__setattr__(self, "targets", tuple(self.targets)) object.__setattr__(self, "placement", placement)
[docs] @dataclass(frozen=True) class PauliChannel2: r"""Two-qubit Pauli channel noise operation. Applies correlated two-qubit Pauli errors. Corresponds to Stim's ``PAULI_CHANNEL_2`` instruction. Parameters ---------- probabilities : `tuple`\[`float`, ...\] The canonical 15 probabilities in Stim's ``PAULI_CHANNEL_2`` order: ``(IX, IY, IZ, XI, XX, XY, XZ, YI, YX, YY, YZ, ZI, ZX, ZY, ZZ)``. Prefer using `from_mapping` or `from_sequence` to construct instances. targets : `tuple`\[`tuple`\[`int`, `int`\], ...\] Target qubit pairs as ``((q0, q1), ...)``. placement : `NoisePlacement` Whether to insert before or after the main operation. ``AUTO`` defers to :func:`default_noise_placement`. Examples -------- Using a mapping (recommended for sparse errors): >>> op = PauliChannel2.from_mapping(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.from_sequence(probabilities=probs, targets=[(2, 3)]) """ probabilities: tuple[float, ...] targets: tuple[tuple[int, int], ...] placement: NoisePlacement = NoisePlacement.AUTO def __post_init__(self) -> None: probabilities = _normalize_pauli_channel_2_sequence(self.probabilities) targets = _normalize_pauli_channel_2_targets(self.targets) placement = _validate_noise_placement("PauliChannel2.placement", self.placement) object.__setattr__(self, "probabilities", probabilities) object.__setattr__(self, "targets", targets) object.__setattr__(self, "placement", placement)
[docs] @classmethod def from_mapping( cls: type[PauliChannel2], probabilities: Mapping[str, float], targets: Sequence[tuple[int, int]], placement: NoisePlacement = NoisePlacement.AUTO, ) -> PauliChannel2: """Build a Pauli channel from sparse Pauli-pair probabilities. Returns ------- `PauliChannel2` A normalized two-qubit Pauli channel. """ return cls( probabilities=_normalize_pauli_channel_2_mapping(probabilities), targets=_normalize_pauli_channel_2_targets(targets), placement=placement, )
[docs] @classmethod def from_sequence( cls: type[PauliChannel2], probabilities: Sequence[float], targets: Sequence[tuple[int, int]], placement: NoisePlacement = NoisePlacement.AUTO, ) -> PauliChannel2: """Build a Pauli channel from probabilities in Stim's required order. Returns ------- `PauliChannel2` A normalized two-qubit Pauli channel. """ return cls( probabilities=_normalize_pauli_channel_2_sequence(probabilities), targets=_normalize_pauli_channel_2_targets(targets), placement=placement, )
[docs] @dataclass(frozen=True) class HeraldedPauliChannel1: 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 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 : `collections.abc.Sequence`\[`int`\] Target qubit indices. placement : `NoisePlacement` Whether to insert before or after the main operation. ``AUTO`` defers to :func:`default_noise_placement`. Notes ----- This instruction adds one measurement record per target qubit. The compiler automatically tracks this when computing detector indices. Examples -------- >>> 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: pi = _validate_probability("HeraldedPauliChannel1.pi", self.pi) px = _validate_probability("HeraldedPauliChannel1.px", self.px) py = _validate_probability("HeraldedPauliChannel1.py", self.py) pz = _validate_probability("HeraldedPauliChannel1.pz", self.pz) _validate_probability_sum("HeraldedPauliChannel1", (pi, px, py, pz)) placement = _validate_noise_placement("HeraldedPauliChannel1.placement", self.placement) object.__setattr__(self, "pi", pi) object.__setattr__(self, "px", px) object.__setattr__(self, "py", py) object.__setattr__(self, "pz", pz) object.__setattr__(self, "targets", tuple(self.targets)) object.__setattr__(self, "placement", placement)
[docs] @dataclass(frozen=True) class HeraldedErase: 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` Probability of erasure. targets : `collections.abc.Sequence`\[`int`\] Target qubit indices. placement : `NoisePlacement` Whether to insert before or after the main operation. ``AUTO`` defers to :func:`default_noise_placement`. Notes ----- 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: p = _validate_probability("HeraldedErase.p", self.p) placement = _validate_noise_placement("HeraldedErase.placement", self.placement) object.__setattr__(self, "p", p) object.__setattr__(self, "targets", tuple(self.targets)) object.__setattr__(self, "placement", placement)
[docs] @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 :func:`default_noise_placement`. 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 def __post_init__(self) -> None: placement = _validate_noise_placement("RawStimOp.placement", self.placement) object.__setattr__(self, "placement", placement) 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) # Only enforce record_delta when the opcode makes the record count # unambiguous without needing a full Stim parser. 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)
[docs] @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 def __post_init__(self) -> None: p = _validate_probability("MeasurementFlip.p", self.p) placement = _validate_noise_placement("MeasurementFlip.placement", self.placement) object.__setattr__(self, "p", p) object.__setattr__(self, "placement", placement)
NoiseOp = PauliChannel1 | PauliChannel2 | HeraldedPauliChannel1 | HeraldedErase | RawStimOp | MeasurementFlip """Union type of all noise operation types."""
[docs] 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) ... ] """
[docs] def on_prepare(self, event: PrepareEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 r"""Return noise operations to inject at qubit preparation. Parameters ---------- event : `PrepareEvent` Context about the preparation operation. Returns ------- `collections.abc.Sequence`\[`NoiseOp`\] Zero or more noise operations to inject. """ return []
[docs] def on_entangle(self, event: EntangleEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 r"""Return noise operations to inject at entanglement. Parameters ---------- event : `EntangleEvent` Context about the entanglement operation. Returns ------- `collections.abc.Sequence`\[`NoiseOp`\] Zero or more noise operations to inject. """ return []
[docs] def on_measure(self, event: MeasureEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 r"""Return noise operations to inject at measurement. Parameters ---------- event : `MeasureEvent` Context about the measurement operation. Returns ------- `collections.abc.Sequence`\[`NoiseOp`\] Zero or more noise operations to inject. """ return []
[docs] def on_idle(self, event: IdleEvent) -> Sequence[NoiseOp]: # noqa: ARG002, PLR6301 r"""Return noise operations to inject during idle periods. Parameters ---------- event : `IdleEvent` Context about the idle period. Returns ------- `collections.abc.Sequence`\[`NoiseOp`\] Zero or more noise operations to inject. """ return []
[docs] def noise_op_to_stim(op: NoiseOp) -> tuple[str, int]: # noqa: PLR0911, C901 r"""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 flat_targets = _flatten_pairs(op.targets) targets_str = " ".join(str(t) for t in flat_targets) args_str = ",".join(str(v) for v in op.probabilities) 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) 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)
def _normalize_pauli_channel_2_mapping(probabilities: Mapping[str, float]) -> tuple[float, ...]: unknown = set(probabilities) - _PAULI_CHANNEL_2_KEYS if unknown: msg = f"Unknown PAULI_CHANNEL_2 keys: {sorted(unknown)}" raise ValueError(msg) 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 def _normalize_pauli_channel_2_sequence(probabilities: Sequence[float]) -> tuple[float, ...]: values = tuple(float(v) for v in probabilities) if len(values) != _PAULI_CHANNEL_2_ARG_COUNT: msg = f"PAULI_CHANNEL_2 expects {_PAULI_CHANNEL_2_ARG_COUNT} 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 def _normalize_pauli_channel_2_targets(targets: Sequence[tuple[int, int]]) -> tuple[tuple[int, int], ...]: normalized: list[tuple[int, int]] = [] for pair in targets: if len(pair) != 2: # noqa: PLR2004 msg = f"PAULI_CHANNEL_2 targets must be pairs, got: {pair!r}" raise ValueError(msg) normalized.append((pair[0], pair[1])) return tuple(normalized) 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) _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 ----
[docs] 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]) """
[docs] def __init__(self, p1: float, p2: float | None = None) -> None: self._p1 = _validate_probability("DepolarizingNoiseModel.p1", p1) self._p2 = self._p1 if p2 is None else _validate_probability("DepolarizingNoiseModel.p2", p2)
[docs] def on_prepare(self, event: PrepareEvent) -> Sequence[NoiseOp]: r"""Add single-qubit depolarizing noise after preparation. Returns ------- `collections.abc.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}"),)
[docs] def on_entangle(self, event: EntangleEvent) -> Sequence[NoiseOp]: r"""Add two-qubit depolarizing noise after entanglement. Returns ------- `collections.abc.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}"),)
[docs] 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]) """
[docs] def __init__(self, p: float) -> None: self._p = _validate_probability("MeasurementFlipNoiseModel.p", p)
[docs] def on_measure(self, event: MeasureEvent) -> Sequence[NoiseOp]: r"""Add measurement flip error. Returns ------- `collections.abc.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),)