Source code for qmlhc.backends.qiskit_backend

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Qiskit Backend Adapter
----------------------
Adapter for Qiskit-based quantum execution backends that conforms to the
``QuantumBackend`` contract.

This wrapper uses Qiskit's ``Sampler`` primitive to execute a (possibly
custom) parameterized circuit that encodes the last input ``x`` via RY
rotations. It returns an expectation-like vector per wire derived from
measured bitstring counts.

Example
-------
>>> import numpy as np
>>> from qmlhc.core.backend import BackendConfig
>>> from qmlhc.backends.qiskit_backend import QiskitBackend
>>> cfg = BackendConfig(output_dim=3, shots=1024)
>>> be = QiskitBackend(cfg, num_qubits=3)
>>> be.encode(np.array([0.1, 0.2, 0.3], dtype=float))
>>> s_t = be.run()
>>> fut = be.project_future(s_t, branches=5)
>>> s_t.shape
(3,)
>>> fut.shape
(5, 3)

Note
----
This example demonstrates the standard Qiskit-based workflow within the unified
``QuantumBackend`` API: initialize the backend, encode a numeric state, execute
the sampling run, and obtain future projections.

Because execution relies on the :class:`~qiskit.primitives.Sampler`, the numerical
outputs are **stochastic**. While the individual expectation-like values in ``s_t``
and ``fut`` vary across runs, their **dimensional structure** and **bounded range**
(within [-1, 1]) remain invariant. This behavior reflects the physical sampling
nature of quantum backends rather than a computational instability.
"""

from __future__ import annotations

from typing import Any, Callable, Mapping, Optional

import numpy as np

from qiskit import QuantumCircuit

# Qiskit primitives import (compatible with 1.x and 2.x)
try:
    # Qiskit ≤ 1.x: classic Sampler is available
    from qiskit.primitives import Sampler
except Exception:
    # Qiskit ≥ 2.x: use StatevectorSampler as a drop-in
    from qiskit.primitives import StatevectorSampler as Sampler


from ..core.backend import QuantumBackend, BackendConfig
from ..core.types import Array, Capabilities, GradientKind


[docs] class QiskitBackend(QuantumBackend): """ Wrap Qiskit primitives into the ``QuantumBackend`` API. Parameters ---------- config : BackendConfig Backend configuration (e.g., ``output_dim``, ``shots``). num_qubits : int Number of qubits / wires. Must equal ``config.output_dim``. circuit_builder : Callable[[np.ndarray], QuantumCircuit], optional Custom function that builds a circuit from the encoded input vector ``x``. If not provided, a default RY + barrier circuit is used. Raises ------ ValueError If ``output_dim`` does not match ``num_qubits``. Notes ----- - Execution is always **shot-based**; there is no analytic (deterministic) mode. - If ``config.shots`` is not provided, defaults to **1024**. - The default circuit applies one ``RY`` per qubit and a ``barrier`` (no entanglement). - Qiskit bitstrings are **big-endian** and are reversed when mapping bits → wires. """ def __init__( self, config: BackendConfig, num_qubits: int, circuit_builder: Optional[Callable[[np.ndarray], QuantumCircuit]] = None, ): super().__init__(config) self._num_qubits = int(num_qubits) self._sampler = Sampler() self._circuit_builder = circuit_builder or self._default_circuit if self.output_dim != self._num_qubits: raise ValueError("output_dim must match number of qubits for QiskitBackend") def _default_circuit(self, x: np.ndarray) -> QuantumCircuit: """ Build a default encoding circuit using RY rotations followed by a barrier. Parameters ---------- x : np.ndarray Encoded input vector of shape ``(D,)``, one angle per qubit. Returns ------- QuantumCircuit The constructed circuit. """ qc = QuantumCircuit(self._num_qubits) for i, val in enumerate(x): qc.ry(float(val), i) qc.barrier() # Measurement is handled implicitly by Sampler in modern Qiskit; # counts/expectations are inferred from the primitive result. qc.measure_all() return qc # ---------------------------------------------------------------------- # Contract methods # ----------------------------------------------------------------------
[docs] def run(self, params: Mapping[str, Any] | None = None) -> Array: """ Execute the circuit via Qiskit's Sampler and return an expectation-like vector. Notes ----- - ``encode(x)`` must be called before ``run()``; this is enforced by ``_require_input()``. - Uses ``shots = config.shots or 1024`` when running the sampler. - Computes signed per-wire averages ('1'→+1, '0'→−1) after endian correction. Note ---- - Wraps the circuit as ``[qc]`` for Qiskit 2.x compatibility (accepted by 1.x as well). - Reads results from ``quasi_dists`` when available, otherwise from ``data.meas['counts']``. Parameters ---------- params : dict or None, optional Unused in this minimal adapter; reserved for future extensions. Returns ------- Array Vector of shape ``(D,)`` containing per-wire signed averages. """ x = self._require_input() qc = self._circuit_builder(x) # Use provided shots if available; otherwise default to 1024 res = self._sampler.run([qc], shots=self._cfg.shots or 1024).result() # Extract counts safely (compatible with Qiskit 1.x and 2.x) counts: dict[str, int] = {} qd_list = getattr(res, "quasi_dists", None) if qd_list: qd = qd_list[0] if isinstance(qd_list, (list, tuple)) else qd_list for k, p in qd.items(): counts[str(k)] = int(round(float(p) * (self._cfg.shots or 1024))) else: data_seq = getattr(res, "data", None) if isinstance(data_seq, (list, tuple)) and data_seq: data0 = data_seq[0] meas = getattr(data0, "meas", None) if isinstance(meas, dict) and "counts" in meas: counts = {str(k): int(v) for k, v in meas["counts"].items()} # Fallback if no results available if not counts: counts = {"0" * self._num_qubits: (self._cfg.shots or 1024)} # Extract counts from the Sampler result and compute signed averages vec = np.zeros(self._num_qubits, dtype=float) total = sum(counts.values()) or 1 for bitstring, freq in counts.items(): # Qiskit uses big-endian bitstrings; reverse to map to wire index 0..n-1 for i, bit in enumerate(reversed(bitstring)): vec[i] += (1.0 if bit == "1" else -1.0) * freq vec /= total return vec
[docs] def project_future(self, s_t: np.ndarray, branches: int = 2) -> Array: """ Generate future state projections by applying smooth additive perturbations. Parameters ---------- s_t : np.ndarray Current state vector. branches : int, optional Number of projected branches (K), by default 2. Returns ------- Array Matrix of shape ``(K, D)`` with ``tanh``-stabilized perturbations. """ s_t = self._validate_state(s_t) k = max(2, int(branches)) noise = np.linspace(-0.1, 0.1, k, dtype=float) futures = np.stack([np.tanh(s_t + n) for n in noise], axis=0) return futures
[docs] def capabilities(self) -> Capabilities: """ Report merged capabilities (base + Qiskit-specific). Returns ------- Capabilities Capability dictionary including backend name/version, qubit count, shot/noise support, batching, and gradient method. """ caps = super().capabilities() caps.update( { "backend_name": "QiskitSampler", "backend_version": "1.x", "max_qubits": self._num_qubits, "supports_shots": True, "supports_noise": True, "supports_batch": True, "gradient": GradientKind.PARAMETER_SHIFT, } ) return caps