| """Confidence-aware decoding utilities. |
| |
| This module introduces a small data container for syndrome batches and a |
| weighted-MWPM decoder that can consume optional per-detector confidence values. |
| When confidence data is omitted, decoding behavior falls back to standard |
| hard-decision MWPM. |
| """ |
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Optional, Protocol |
|
|
| import numpy as np |
|
|
|
|
| @dataclass(frozen=True) |
| class SyndromeBatch: |
| """Container for batched detector outcomes. |
| |
| Attributes: |
| hard_bits: Binary detector outcomes with shape ``(shots, num_detectors)``. |
| confidence: Optional confidence values in ``[0, 1]`` with the same shape |
| as ``hard_bits``. Larger values indicate higher trust in the |
| corresponding hard detector outcome. |
| """ |
|
|
| hard_bits: np.ndarray |
| confidence: Optional[np.ndarray] = None |
|
|
| def __post_init__(self) -> None: |
| hard_bits = np.asarray(self.hard_bits, dtype=np.uint8) |
| if hard_bits.ndim != 2: |
| raise ValueError("hard_bits must be a 2D array with shape (shots, num_detectors).") |
| if np.any((hard_bits != 0) & (hard_bits != 1)): |
| raise ValueError("hard_bits must contain only 0/1 values.") |
| object.__setattr__(self, "hard_bits", hard_bits) |
|
|
| if self.confidence is None: |
| return |
|
|
| confidence = np.asarray(self.confidence, dtype=np.float64) |
| if confidence.shape != hard_bits.shape: |
| raise ValueError("confidence must have the same shape as hard_bits.") |
| if np.any((confidence < 0.0) | (confidence > 1.0)): |
| raise ValueError("confidence entries must lie in [0, 1].") |
| object.__setattr__(self, "confidence", confidence) |
|
|
|
|
| class SyndromeDecoder(Protocol): |
| """Decoder interface accepting hard syndromes with optional soft features.""" |
|
|
| def decode_batch(self, syndromes: SyndromeBatch) -> np.ndarray: |
| """Decode a batch and return logical predictions per shot.""" |
|
|
|
|
| class WeightedMWPMDecoder: |
| """Weighted MWPM decoder with per-shot confidence reweighting. |
| |
| The decoder starts from a base matching graph extracted from a |
| detector-error model. If confidence is available, edge weights are adjusted |
| by endpoint confidence before running MWPM: |
| |
| ``w' = w * (1 + confidence_scale * (1 - mean_endpoint_confidence))`` |
| |
| so low-confidence measurements get larger effective costs. |
| |
| ``decode_batch`` returns an array of shape ``(shots, num_fault_ids)`` |
| containing the predicted observable flips for every shot. |
| """ |
|
|
| def __init__(self, detector_error_model: object, *, confidence_scale: float = 1.0) -> None: |
| if confidence_scale < 0: |
| raise ValueError("confidence_scale must be non-negative.") |
|
|
| try: |
| import pymatching |
| except ModuleNotFoundError as exc: |
| raise ImportError("pymatching is required for WeightedMWPMDecoder.") from exc |
|
|
| self._pymatching = pymatching |
| self._confidence_scale = float(confidence_scale) |
| self._base_matching = pymatching.Matching.from_detector_error_model(detector_error_model) |
|
|
| |
| self._num_detectors: int = self._base_matching.num_detectors |
|
|
| |
| _zero_syndrome = np.zeros(self._num_detectors, dtype=np.uint8) |
| self._num_fault_ids: int = len(self._base_matching.decode(_zero_syndrome)) |
|
|
| |
| |
| |
| |
| |
| self._base_graph = self._base_matching.to_networkx() |
| boundary = self._num_detectors |
|
|
| edges_u: list[int] = [] |
| edges_v: list[int] = [] |
| base_weights: list[float] = [] |
|
|
| for u, v, data in self._base_graph.edges(data=True): |
| edges_u.append(int(u)) |
| edges_v.append(int(v)) |
| base_weights.append(float(data.get("weight", 1.0))) |
|
|
| |
| |
| self._edge_u = np.array(edges_u, dtype=np.intp) |
| self._edge_v = np.array(edges_v, dtype=np.intp) |
| self._base_weights = np.array(base_weights, dtype=np.float64) |
| |
| self._edge_pairs: list[tuple[int, int]] = list(zip(edges_u, edges_v)) |
|
|
| |
| self._u_is_det: np.ndarray = self._edge_u != boundary |
| self._v_is_det: np.ndarray = self._edge_v != boundary |
|
|
| |
| |
| |
| self._edge_u_safe = np.where(self._u_is_det, self._edge_u, 0) |
| self._edge_v_safe = np.where(self._v_is_det, self._edge_v, 0) |
|
|
| def _compute_adjusted_weights(self, shot_confidence: np.ndarray) -> np.ndarray: |
| """Return edge weights adjusted by per-detector confidence (vectorized). |
| |
| All per-edge arithmetic is done with NumPy so no Python-level loop over |
| edges is needed. |
| """ |
| conf_u = np.where(self._u_is_det, shot_confidence[self._edge_u_safe], 1.0) |
| conf_v = np.where(self._v_is_det, shot_confidence[self._edge_v_safe], 1.0) |
|
|
| num_real_endpoints = self._u_is_det.astype(np.float64) + self._v_is_det.astype(np.float64) |
| mean_conf = np.where( |
| num_real_endpoints > 0, |
| (conf_u * self._u_is_det + conf_v * self._v_is_det) / np.maximum(num_real_endpoints, 1.0), |
| 1.0, |
| ) |
| scale = 1.0 + self._confidence_scale * (1.0 - mean_conf) |
| return self._base_weights * scale |
|
|
| def decode_batch(self, syndromes: SyndromeBatch) -> np.ndarray: |
| """Decode each shot and return predictions shaped ``(shots, num_fault_ids)``. |
| |
| When ``syndromes.confidence`` is ``None`` the base matching (without |
| any weight adjustments) is used for every shot, which is equivalent to |
| passing an all-ones confidence array. |
| """ |
| hard = syndromes.hard_bits |
| confidence = syndromes.confidence |
| shots = hard.shape[0] |
| predictions = np.zeros((shots, self._num_fault_ids), dtype=np.uint8) |
|
|
| if confidence is None: |
| for shot_ix, shot in enumerate(hard): |
| predictions[shot_ix] = self._base_matching.decode(shot) |
| return predictions |
|
|
| for shot_ix, (shot, shot_confidence) in enumerate(zip(hard, confidence, strict=True)): |
| adjusted_weights = self._compute_adjusted_weights(shot_confidence) |
| |
| |
| |
| |
| graph = self._base_graph.copy() |
| for i, (u, v) in enumerate(self._edge_pairs): |
| graph[u][v]["weight"] = float(adjusted_weights[i]) |
| matching = self._pymatching.Matching(graph) |
| predictions[shot_ix] = matching.decode(shot) |
|
|
| return predictions |
|
|