Source code for qarp.algorithms._primitives.shadows.kernels

"""Stateless estimation kernels — the one place the shadow inverse channel lives.

A :class:`ShadowKernel` is a small, picklable value object carrying only the
information needed to invert measurement outcomes into observable estimates:
``n_qubits``, ensemble parameters, and the pure inversion functions.  The
collector delegates to it, the :class:`~.dataset.ShadowDataset` carries it, and
the :class:`~.estimator.ShadowEstimator` uses it — so the math is defined once.

The kernel holds **no** qarpx objects and no circuits, so a dataset carrying one
is inert and serializable.  :class:`PauliKernel` ships today; matchgate /
global-Clifford kernels are later subclasses registered the same way — a new
ensemble is a registry entry, not a schema change.
"""

from __future__ import annotations

from abc import ABC, abstractmethod

import numpy as np

# Setting axis encoding: a random-Pauli setting is one axis per qubit.
_AXIS_CODE = {"X": 0, "Y": 1, "Z": 2}


[docs] class ShadowKernel(ABC): """Stateless inversion kernel for one measurement ensemble. Attributes: ensemble: registry tag used by :meth:`ShadowDataset.from_dict`. n_qubits: register width the kernel was built for. capabilities: estimator features this ensemble supports (e.g. ``{"expval"}``; matchgate would add ``"rdm"``). The estimator raises :class:`CapabilityError` for anything outside this set. """ ensemble: str capabilities: frozenset[str] def __init__(self, n_qubits: int): self.n_qubits = n_qubits
[docs] @abstractmethod def snapshot_estimate(self, setting: np.ndarray, outcome: int, term) -> float: """Single-snapshot estimate of one Pauli ``term`` under one ``(setting, outcome)``. ``term`` is a tuple of ``(qubit, axis_char)`` pairs (empty for identity — never passed here; the estimator handles the constant)."""
# --- serialization seam (kernel owns its setting encoding) -----------------
[docs] def params(self) -> dict: """Ensemble parameters for the serialized descriptor (besides ``n_qubits``/``ensemble``). Default: none.""" return {}
[docs] @classmethod def from_params(cls, n_qubits: int, params: dict) -> "ShadowKernel": return cls(n_qubits)
[docs] class PauliKernel(ShadowKernel): """Random-Pauli (local-Clifford) inverse channel. A setting is one axis per qubit (``0/1/2`` = ``X/Y/Z``). The inverse channel factorizes per qubit: for a Pauli term ``P`` a snapshot contributes ``prod_{q in supp(P)} 3 * (-1)^{b_q}`` iff the measured axis on every qubit of ``supp(P)`` matches ``P``'s axis there, and ``0`` otherwise. """ ensemble = "pauli" capabilities = frozenset({"expval"})
[docs] def snapshot_estimate(self, setting: np.ndarray, outcome: int, term) -> float: val = 1.0 for qubit, axis in term: if setting[qubit] != _AXIS_CODE[axis]: return 0.0 bit = (outcome >> qubit) & 1 val *= 3.0 * (1.0 - 2.0 * bit) # 3 * (+1 if b==0 else -1) return val
# Registry keyed by ensemble tag — ShadowDataset.from_dict rebuilds through this, # so a new ensemble is an added entry, never a schema-version bump. KERNELS: dict[str, type[ShadowKernel]] = {PauliKernel.ensemble: PauliKernel}