Source code for qarp.algorithms._primitives.shadows.base
"""``ShadowProtocol`` — the classical-shadow collector, an ordinary primitive.
A ``PrimitiveAlgorithm`` (like ``TermwiseHadamardTest``): it binds an ``operator``,
builds one measurement circuit per random setting, and ``run(results) -> float``
returns the median-of-means ``<operator>``. Because the random-measurement
circuits are operator-independent, the same results estimate *any* observable, so
the collector also exposes them as a reusable :class:`~.dataset.ShadowDataset`
(``shadow.dataset``) that a :class:`~.estimator.ShadowEstimator` turns into further
expectations without re-collecting.
Two modes: **collect** (no ``dataset`` passed) samples a fresh campaign from
``ket``; **reuse** (a ``dataset`` passed) estimates ``operator`` from an existing
dataset with no circuits. ``run()`` never clears the circuits — the engine reuses
them under ``rebuild=False``/``batch_run`` — so freeing memory is an explicit
:meth:`release` (the detached dataset stays valid regardless).
"""
from __future__ import annotations
from abc import abstractmethod
from typing import Optional, Union
import numpy as np
from qarp.operators import QubitOperator
from ....blocks import AnyBlock
from ..primitive_algorithm import PrimitiveAlgorithm
from ..target import Target
from ._estimators import n_batches_for
from .dataset import ShadowDataset
from .estimator import _decompose
from .kernels import ShadowKernel
[docs]
class ShadowProtocol(PrimitiveAlgorithm):
"""Base collector for a randomized-measurement shadow ensemble."""
supported_targets = frozenset({Target.EXPECTATION_VALUE})
supports_exact = True # shot-exact per setting; NOT setting-exact
gradient_kind = "none" # median-of-means is not linear in the settings' statistics
requires_noiseless = True # the ideal inverse channel would bias a noisy campaign
def __init__(
self,
operator: Union[str, QubitOperator],
ket: Optional[AnyBlock] = None,
*,
dataset: Optional[ShadowDataset] = None,
n_settings: int = 1000,
n_shots: int = 1,
seed: Optional[int] = None,
):
if operator is None:
raise ValueError("PauliShadow requires an operator.")
super().__init__(
ket=ket,
operator=operator,
n_shots=n_shots,
target=Target.EXPECTATION_VALUE,
)
self.n_settings = n_settings
self.seed = seed
self._passed_dataset = dataset
self._dataset: Optional[ShadowDataset] = dataset
self._settings: Optional[np.ndarray] = None
# --- ensemble hooks --------------------------------------------------------
@abstractmethod
def _sample_settings(self, rng: np.random.Generator, n_settings: int, n_qubits: int):
"""Draw ``n_settings`` random settings for an ``n_qubits`` register."""
@abstractmethod
def _setting_block(self, setting) -> AnyBlock:
"""Build ``ket -> U(setting) -> measure-all`` for one setting."""
@abstractmethod
def _kernel(self, n_qubits: int) -> ShadowKernel:
"""Return this ensemble's stateless estimation kernel."""
# --- primitive lifecycle ---------------------------------------------------
[docs]
def build(self):
if self._passed_dataset is not None: # reuse mode
# Reuse runs no circuits — run() is pure arithmetic on stored snapshots,
# never touching the engine's simulator or noise. So the noiseless
# guard (which protects live measurement) is moot here; clear it on this
# instance so a pre-collected dataset re-estimates on any engine.
self.requires_noiseless = False
self.n_qubits = self._passed_dataset.n_qubits
_, terms, _ = _decompose(self.operator)
for term in terms:
for qubit, _ in term:
if qubit >= self.n_qubits:
raise ValueError(
f"operator acts on qubit {qubit}, but the passed dataset "
f"has only {self.n_qubits} qubits."
)
self.sub_blocks = []
self._dataset = self._passed_dataset
return self
# collect mode
if self.ket is None:
raise ValueError(
"PauliShadow in collect mode needs a ket (or pass dataset= to reuse "
"an existing campaign)."
)
self.ket.build()
if self.ket.symbols:
raise ValueError(
"PauliShadow requires a concrete (fully-bound) ket; bind the "
f"parameters first. Free symbols: {self.ket.symbols}"
)
self.n_qubits = self.ket.n_qubits
floor = n_batches_for(0.05)
if self.n_settings < floor:
raise ValueError(
f"n_settings={self.n_settings} is below the default batch count "
f"({floor}); use at least {floor} settings."
)
rng = np.random.default_rng(self.seed)
self._settings = self._sample_settings(rng, self.n_settings, self.n_qubits)
self.sub_blocks = [self._setting_block(s) for s in self._settings]
self._dataset = None # invalidate any previously collected dataset
return self
[docs]
def run(self, results: list) -> float:
if self._passed_dataset is not None: # reuse mode — no circuits ran
dataset = self._passed_dataset
else:
if self._settings is None:
raise RuntimeError("run() called before build() sampled settings")
shot_exact = getattr(results[0], "is_exact", False) if results else False
records = [
(np.asarray(setting, dtype=np.int8), dict(sr.counts))
for setting, sr in zip(self._settings, results, strict=True)
]
dataset = ShadowDataset(self._kernel(self.n_qubits), self.n_qubits, records, shot_exact)
self._dataset = dataset
return dataset.estimator().expval(self.operator).value
# --- reuse layer + memory --------------------------------------------------
@property
def dataset(self) -> ShadowDataset:
"""The collected (or passed) :class:`ShadowDataset`. Raises if neither a
dataset was passed nor a campaign collected yet."""
if self._dataset is None:
raise RuntimeError(
"no ShadowDataset yet: run the primitive (engine.run([shadow])) to "
"collect one, or pass dataset= at construction."
)
return self._dataset
[docs]
def release(self) -> None:
"""Free the compiled circuits in place (the detached dataset stays valid).
``run()`` never does this automatically — the engine reuses the compiled
circuits under ``rebuild=False`` and across ``batch_run`` parameter sets.
After ``release()``, a re-run needs a rebuild.
"""
self.sub_blocks = []
self.compiled_circuits = []
self._n_qubits_list = []