Source code for mimiq_qiskit.primitives

"""Native Qiskit V2 primitives backed by MIMIQ.

Qiskit ships generic ``BackendSamplerV2`` / ``BackendEstimatorV2`` that
wrap any ``BackendV2``. Its own documentation recommends a native
primitive when the provider can do better, which MIMIQ can:

- :class:`MimiqSamplerV2` reads MIMIQ's sampled bitstrings (``cstates``)
  directly into per-register :class:`~qiskit.primitives.containers.BitArray`,
  skipping the counts/hex round trip the generic sampler needs.
- :class:`MimiqEstimatorV2` reads each Pauli term straight off the
  simulator state instead of estimating it from measurement samples, so a
  deterministic circuit carries no shot noise at any term weight. A
  circuit that ends in an ensemble rather than a state (mid-circuit
  measurement, reset, noise) is averaged over trajectories, and a
  ``shots`` budget switches it to hardware-style sampled estimation. See
  :mod:`mimiq_qiskit.estimation` for the three methods.

Both batch every circuit in a pub into a single MIMIQ submission, and the
estimator shares one submission between observables that were asked for at
the same parameter binding.
"""

from __future__ import annotations

from typing import Iterable

import numpy as np

import mimiqcircuits as mc
from qiskit.primitives import (
    BaseEstimatorV2,
    BaseSamplerV2,
    PrimitiveJob,
    PrimitiveResult,
    SamplerPubResult,
)
from qiskit.primitives.containers import BitArray, DataBin
from qiskit.primitives.containers.estimator_pub import EstimatorPub
from qiskit.primitives.containers.sampler_pub import SamplerPub

from mimiq_qiskit.backend import MimiqBackend
from mimiq_qiskit.converter import qiskit_to_mimiq
from mimiq_qiskit.estimation import EstimatorConfig, estimate_pub
from mimiq_qiskit.local_terms import local_evaluator, supports_direct_terms


def _as_backend(backend) -> MimiqBackend:
    """Accept a ``MimiqBackend`` or anything ``MimiqBackend`` can wrap."""
    if isinstance(backend, MimiqBackend):
        return backend
    return MimiqBackend(backend)


def _bits_from_cstates(cstates, shots: int, index: int) -> np.ndarray:
    """``(shots, num_bits)`` booleans from one result's classical states.

    One ``bitarray.unpack`` expands a whole cstate in C. Reading them a bit
    at a time from Python is what made packing a wide register cost more
    than the simulation that produced it.
    """
    # A BitArray has a fixed shot axis, so a runner that returns a different
    # number of samples than asked for cannot be packed into it. Say so,
    # rather than reading off the end of the list.
    if len(cstates) < shots:
        raise ValueError(
            f"MIMIQ returned {len(cstates)} samples for circuit {index} "
            f"but {shots} shots were requested; the sampler cannot "
            "pack a short result"
        )
    if shots == 0:
        return np.zeros((0, 0), dtype=bool)
    # A cstate is a `BitString` from a MIMIQ backend and a bare `bitarray`
    # from a plain runner; both wrap the same buffer.
    buf = b"".join(
        getattr(cstate, "bits", cstate).unpack(zero=b"\x00", one=b"\x01")
        for cstate in cstates[:shots]
    )
    return np.frombuffer(buf, dtype=np.uint8).reshape(shots, -1).astype(bool)


def _register_bitarray(qcs_list, shape, clbit_indices, reg_size, shots):
    """Pack one classical register's samples into a ``BitArray``.

    ``qcs_list`` holds one ``QCSResults`` per flattened parameter binding
    (in ``np.ndindex(shape)`` order). ``clbit_indices[j]`` is the global
    MIMIQ classical-bit index feeding bit ``j`` of the register (bit 0 is
    the least significant), matching Qiskit's LSB-first register packing.
    """
    arr = np.zeros(tuple(shape) + (shots, reg_size), dtype=bool)
    flat = arr.reshape((-1, shots, reg_size))

    for i, qcs in enumerate(qcs_list):
        bits = _bits_from_cstates(qcs.cstates, shots, i)
        width = bits.shape[1]
        # A result narrower than the register leaves the bits above it
        # unset, rather than reading past the end of the classical state.
        taken = [(j, g) for j, g in enumerate(clbit_indices) if g < width]
        if taken:
            flat[i][:, [j for j, _ in taken]] = bits[:, [g for _, g in taken]]

    return BitArray.from_bool_array(arr, order="little")


[docs] class MimiqSamplerV2(BaseSamplerV2): """``BaseSamplerV2`` that samples bitstrings on MIMIQ. Args: backend: A :class:`MimiqBackend`, or a connection / MIMIQ backend / runner that :class:`MimiqBackend` can wrap. default_shots: Shots used for pubs that don't specify their own. seed: Seed forwarded to MIMIQ. Defaults to the backend's. run_options: Extra MIMIQ run options (``noisemodel``, ``bonddim``, …) merged over the backend's. """ def __init__( self, backend, *, default_shots: int = 1024, seed=None, run_options=None ): self._backend = _as_backend(backend) self._default_shots = default_shots self._seed = seed if seed is not None else self._backend.options.seed self._run_opts = { **self._backend._run_options({}), **(run_options or {}), } @property def default_shots(self) -> int: return self._default_shots
[docs] def run(self, pubs: Iterable, *, shots: int | None = None) -> PrimitiveJob: coerced = [ SamplerPub.coerce(pub, shots or self._default_shots) for pub in pubs ] job = PrimitiveJob(self._run, coerced) job._submit() return job
def _run(self, pubs) -> PrimitiveResult: return PrimitiveResult( [self._run_pub(pub) for pub in pubs], metadata={"version": 2}, ) def _run_pub(self, pub: SamplerPub) -> SamplerPubResult: circuit = pub.circuit shots = pub.shots shape = pub.shape bound = pub.parameter_values.bind_all(circuit) indices = list(np.ndindex(shape)) mimiq_circuits = [qiskit_to_mimiq(bound[idx]) for idx in indices] qcs_list = self._backend._execute_batch( mimiq_circuits, shots=shots, seed=self._seed, **self._run_opts ) data = {} for reg in circuit.cregs: clbit_indices = [circuit.find_bit(b).index for b in reg] data[reg.name] = _register_bitarray( qcs_list, shape, clbit_indices, reg.size, shots ) databin = DataBin(**data, shape=shape) return SamplerPubResult(databin, metadata={"shots": shots})
[docs] class MimiqEstimatorV2(BaseEstimatorV2): """``BaseEstimatorV2`` that evaluates observables on MIMIQ. Each Pauli term is evaluated on the simulator state rather than sampled, so a circuit that ends in a definite state gives an exact value at any term weight and reports a standard error of zero. A circuit that ends in an *ensemble* has no single exact value: a mid-circuit measurement, a reset, a noise channel, or a server-side ``noisemodel`` makes MIMIQ re-evolve the circuit once per shot, and each trajectory has its own expectation value. The average over trajectories is the density-matrix value, so that is what this reports, with the sample standard error in ``stds``. Reading one trajectory would be unbiased but as noisy as the observable's range, so a stochastic circuit with no budget raises rather than returning it. A ``shots`` budget switches to hardware-style estimation from measurements in rotated bases. :mod:`mimiq_qiskit.estimation` documents the three methods and how ``method="auto"`` chooses between them. Against a *local* backend that advertises ``expectation_state``, a deterministic circuit takes a shorter route: it is evolved once and each term is read off the resulting state, so the terms never become ``ExpectationValue`` operations and nothing has to build, submit or walk them. Values are unchanged; the saving is in the term handling, so it is largest where the evolution itself is cheap. A remote backend, and any circuit that needs an evolution per trajectory, go the portable way. See :mod:`mimiq_qiskit.local_terms`. Args: backend: A :class:`MimiqBackend`, or anything it can wrap. method: ``"auto"`` (default), ``"exact"``, ``"trajectories"``, or ``"shots"``. trajectories: Trajectories to average for a stochastic circuit. ``None`` sizes it from ``precision``. Ignored for a deterministic circuit, which has one answer. shots: Shots per measurement basis. Giving this selects sampled estimation; ``None`` sizes it from ``precision`` when ``method="shots"``. emulate_shot_noise: Add Gaussian noise of width ``precision`` to an otherwise exact value, as Qiskit's ``StatevectorEstimator`` does. One evolution instead of a shot budget, for code that wants to see plausible shot noise without paying for it. default_precision: Precision for ``run`` calls and pubs that do not carry their own. ``0.0``, meaning "as exact as the simulator gets". seed: Seed forwarded to MIMIQ, and to the ``emulate_shot_noise`` generator. Defaults to the backend's. run_options: Extra MIMIQ run options merged over the backend's. Result metadata reports ``method``, whether the value is ``exact``, whether the run was ``stochastic``, the ``trajectories`` or ``shots`` spent, the ``target_precision``, and ``min_fidelity``: the lowest simulator fidelity behind the pub, which on an MPS backend is the truncation error that averaging cannot remove. """ def __init__( self, backend, *, method: str = "auto", trajectories: int | None = None, shots: int | None = None, emulate_shot_noise: bool = False, default_precision: float = 0.0, seed=None, run_options=None, ): self._backend = _as_backend(backend) self._seed = seed if seed is not None else self._backend.options.seed self._run_opts = { **self._backend._run_options({}), **(run_options or {}), } self._default_precision = default_precision self._config = EstimatorConfig( method=method, trajectories=trajectories, shots=shots, emulate_shot_noise=emulate_shot_noise, default_precision=default_precision, seed=self._seed, # A noise model applied by the runner never appears in the # circuit, so nothing else can tell that the run is stochastic. assume_stochastic=self._run_opts.get("noisemodel") is not None, ) # A local backend can be evolved once and queried, which skips the # per-term operations entirely. `estimate_pub` uses this only where # one evolution is the whole answer; everything else still goes # through `_submit`. runner = self._backend._mimiq_backend self._evaluate = ( local_evaluator(runner, self._run_opts, self._seed) if supports_direct_terms(runner) else None ) @property def default_precision(self) -> float: """Precision used when ``run`` is called without one.""" return self._default_precision
[docs] def run(self, pubs: Iterable, *, precision: float | None = None) -> PrimitiveJob: """Estimate every pub and return the job carrying the results. ``precision`` overrides :attr:`default_precision` for this call. A positive value sizes the trajectory or shot budget as ``ceil(1/precision**2)`` where one is needed. """ if precision is None: precision = self._default_precision coerced = [EstimatorPub.coerce(pub, precision) for pub in pubs] job = PrimitiveJob(self._run, coerced) job._submit() return job
def _run(self, pubs) -> PrimitiveResult: return PrimitiveResult( [ estimate_pub(pub, self._config, self._submit, self._evaluate) for pub in pubs ], metadata={"version": 2}, ) def _submit(self, mimiq_circuits, nsamples): """Run one batch of MIMIQ circuits for the shared estimation engine.""" return self._backend._execute_batch( mimiq_circuits, shots=nsamples, seed=self._seed, **self._run_opts )