Source code for qarp.algorithms._primitives.sampler
"""``Sampler`` primitive — consumes a :class:`qarp.blocks.AnyBlock` and returns
a probability distribution dict whose keys are tuples of ints (LSB = lowest-
index measured qubit) and values are probabilities normalised by n_shots.
"""
from typing import Optional, Self, Union
import numpy as np
from ..._types import SamplingDictionary, Shots, outcome_arrays
from ...blocks import AnyBlock
from .primitive_algorithm import PrimitiveAlgorithm
from .target import Target
[docs]
class Sampler(PrimitiveAlgorithm):
"""qarpx-backed sampling primitive.
Args:
ket: Block to execute.
n_shots: Number of measurement shots (default: use engine default).
``qarp.EXACT`` returns the exact Born distribution ``|ψ|²`` — note:
probabilities, not amplitudes (phases are discarded; raw
amplitudes are simulator internals, ``sim.statevector``).
measured_qubits: Qubit indices whose outcomes appear in the output
tuple. Defaults to ``range(ket.n_qubits)``. Non-measured
qubits are marginalised out by summing probabilities over their
bit values.
initial_state: Optional LSB-indexed amplitudes seeding the register
instead of ``|0…0⟩`` (length ``2**n_qubits``, unit norm within
``1e-10`` — ``ValueError`` at run otherwise). QarpEngine only;
other engines raise ``CapabilityError`` at build. Mutable
between runs for step → snapshot → re-seed loops.
"""
supported_targets = frozenset({Target.SAMPLING})
accepts_initial_state = True
def __init__(
self,
ket: Optional[AnyBlock] = None,
n_shots: Optional[Union[int, Shots]] = None,
measured_qubits: Optional[list[int]] = None,
initial_state=None,
):
super().__init__(ket=ket, n_shots=n_shots, target=Target.SAMPLING)
self.measured_qubits = measured_qubits
self.initial_state = initial_state
self.result: Optional[SamplingDictionary] = None
[docs]
def build(self) -> Self:
if self.ket is None:
raise ValueError("Sampler requires a ket Block.")
self.ket.build()
# The ket is itself a Block, so it becomes this sampler's sole sub-block.
self.sub_blocks = [self.ket]
self.n_qubits = self.ket.n_qubits
if self.measured_qubits is None:
self.measured_qubits = list(range(self.n_qubits))
return self
[docs]
def run(self, results: list) -> SamplingDictionary:
"""Convert the first result (``qx.SamplingResult``, or the duck-typed
``ExactResult`` under ``n_shots=qarp.EXACT``) to a
{bitstring-tuple: probability} dict."""
sr = results[0]
n_shots = sr.n_shots
measured = self.measured_qubits or list(range(sr.n_qubits))
# The vectorized path packs outcomes and keys into int64, which caps
# the register at 63 qubits; wider registers (tensor-network backends)
# take the exact arbitrary-precision loop instead.
if sr.n_qubits <= 63:
outcomes, weights = outcome_arrays(sr)
weights = weights / n_shots
# Project each outcome onto the measured qubits, repacked LSB-first
# so equal projections share one integer key; marginalising over
# non-measured qubits then reduces to accumulating weights per key.
qubits = np.asarray(measured, dtype=np.int64)
packed = ((outcomes[:, None] >> qubits) & 1) @ (np.int64(1) << np.arange(len(qubits)))
unique_keys, inverse = np.unique(packed, return_inverse=True)
probabilities = np.bincount(inverse, weights=weights)
unique_bits = (unique_keys[:, None] >> np.arange(len(qubits))) & 1
distribution: SamplingDictionary = dict(
zip(map(tuple, unique_bits.tolist()), probabilities.tolist(), strict=True)
)
else:
distribution = {}
for outcome, count in sr.counts.items():
bits = tuple((outcome >> q) & 1 for q in measured)
distribution[bits] = distribution.get(bits, 0.0) + count / n_shots
self.result = distribution
return distribution