Source code for qrisp.interface.provider_backends.qiskit_backend

# ********************************************************************************
# * Copyright (c) 2026 the Qrisp authors
# *
# * This program and the accompanying materials are made available under the
# * terms of the Eclipse Public License 2.0 which is available at
# * http://www.eclipse.org/legal/epl-2.0.
# *
# * This Source Code may also be made available under the following Secondary
# * Licenses when the conditions for such availability set forth in the Eclipse
# * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
# * with the GNU Classpath Exception which is
# * available at https://www.gnu.org/software/classpath/license.html.
# *
# * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
# ********************************************************************************

"""This module defines :class:`QiskitBackend` and its associated :class:`QiskitJob`."""

from __future__ import annotations

import re
import warnings
from collections.abc import Mapping
from typing import cast

from qiskit import QuantumCircuit, transpile
from qiskit.providers import Backend as QiskitBackendBase

from qrisp.circuit.quantum_circuit import QuantumCircuit as QrispQuantumCircuit
from qrisp.interface.backend import Backend
from qrisp.interface.job import (
    JOB_FINAL_STATES,
    Job,
    JobCancelledError,
    JobFailureError,
    JobResult,
    JobStatus,
)


def _map_qiskit_status(qiskit_job) -> JobStatus:
    """Translate a Qiskit job's current status to the equivalent Qrisp JobStatus.

    Qiskit's ``VALIDATING`` state has no direct Qrisp equivalent.
    Therefore, it is mapped to ``QUEUED`` as the closest approximation.
    """
    try:
        # qiskit-ibm-runtime jobs expose .status() directly
        raw = qiskit_job.status()
    except Exception:
        return JobStatus.RUNNING

    # status() may return a string (newer runtimes) or a JobStatus enum
    name = raw.name if hasattr(raw, "name") else str(raw).upper()

    mapping = {
        "INITIALIZING": JobStatus.INITIALIZING,
        "QUEUED": JobStatus.QUEUED,
        "VALIDATING": JobStatus.QUEUED,  # no direct equivalent
        "RUNNING": JobStatus.RUNNING,
        "DONE": JobStatus.DONE,
        "CANCELLED": JobStatus.CANCELLED,
        "ERROR": JobStatus.ERROR,
    }
    return mapping.get(name, JobStatus.RUNNING)


def _is_ibm_runtime_backend(backend) -> bool:
    """Whether *backend* is a real IBM Quantum backend (fake backends run locally and are not)."""
    try:
        from qiskit_ibm_runtime import IBMBackend
    except ImportError:
        return False  # optional dependency: no IBM backend can be present
    return isinstance(backend, IBMBackend)


def _merge_counts(counts: Mapping) -> dict:
    """Strip register separators from bitstring keys, summing any keys that collide.

    Qiskit separates classical registers with a space (``"01 10"``); Qrisp
    expects a single contiguous bitstring.
    """
    merged: dict = {}
    for key, val in counts.items():
        clean_key = re.sub(r"\W", "", key)
        merged[clean_key] = merged.get(clean_key, 0) + val
    return merged


class QiskitJob(Job):
    """A :class:`~qrisp.interface.Job` that wraps a Qiskit job.

    One ``QiskitJob`` is created per :meth:`QiskitBackend.run_async` call,
    regardless of how many circuits were submitted.  Internally it holds
    the underlying Qiskit job object and the number of circuits so it can
    extract the right slice of results when :meth:`result` is called.

    :meth:`result` blocks until the Qiskit job reaches a terminal state,
    delegating the actual waiting to Qiskit's own ``job.result()`` call.
    No Qrisp-level threading primitives are required here because Qiskit's
    ``run()`` is already asynchronous.

    Parameters
    ----------
    backend : QiskitBackend
        The Qrisp backend that created this job.

    qiskit_job : Job
        The underlying Qiskit job object.

    num_circuits : int
        How many circuits were submitted in this job.

    """

    def __init__(
        self,
        backend: QiskitBackend,
        qiskit_job,
        num_circuits: int,
    ):
        """Initialise the wrapper with the Qrisp backend, the Qiskit job, and the circuit count."""
        try:
            job_id = qiskit_job.job_id()
        except Exception:
            job_id = None
        super().__init__(backend=backend, job_id=job_id)
        self._qiskit_job = qiskit_job
        self._num_circuits = num_circuits
        self._cached_result: JobResult | None = None

    def _extract_counts(self, qiskit_result) -> list[dict]:
        """Read per-circuit counts out of the ``Result`` returned by ``Backend.run()``."""
        if not hasattr(qiskit_result, "get_counts"):
            raise TypeError(
                f"Expected a Qiskit Result with a get_counts() method, got "
                f"{type(qiskit_result).__name__}. Backends whose run() does not return a "
                "Result need their own QiskitJob subclass overriding _extract_counts()."
            )
        # get_counts() returns a single Counts for one experiment, a list for several.
        counts = qiskit_result.get_counts()
        counts_list = [counts] if isinstance(counts, Mapping) else list(counts)
        return [_merge_counts(counts_list[i]) for i in range(self._num_circuits)]

    # ------------------------------------------------------------------
    # Abstract interface
    # ------------------------------------------------------------------

    def submit(self) -> None:
        """Mark the job as QUEUED. The Qiskit job is already submitted by the sampler in run_async()."""
        self._last_known_status = JobStatus.QUEUED

    def result(self, timeout: float | None = None) -> JobResult:
        """Block until the Qiskit job finishes and return the :class:`~qrisp.interface.JobResult`.

        Waiting is delegated to Qiskit's own blocking ``job.result()`` call.
        If the job is already in a terminal state, this method returns (or
        raises) immediately without making a second call to Qiskit.

        Parameters
        ----------
        timeout : float or None, optional
            Maximum number of seconds to wait. Forwarded to the underlying
            Qiskit job's ``result()`` call. ``None`` (the default) waits
            indefinitely. Raises :exc:`TimeoutError` if the deadline expires.

        Returns
        -------
        JobResult

        Raises
        ------
        JobFailureError
            If the Qiskit job failed or raised an exception.

        JobCancelledError
            If the Qiskit job was cancelled.

        TimeoutError
            If *timeout* expires before the job completes.

        """
        if self._last_known_status in JOB_FINAL_STATES:
            self._raise_for_status(self._last_known_status)
            return cast(JobResult, self._cached_result)

        try:
            # Only pass timeout when explicitly requested: local backends such
            # as AerSimulator's PrimitiveJob do not accept a timeout argument.
            if timeout is not None:
                qiskit_result = self._qiskit_job.result(timeout=timeout)
            else:
                qiskit_result = self._qiskit_job.result()
        except TimeoutError:
            self._last_known_status = _map_qiskit_status(self._qiskit_job)
            raise
        except Exception as exc:
            terminal_status = _map_qiskit_status(self._qiskit_job)
            self._last_known_status = terminal_status
            if terminal_status == JobStatus.CANCELLED:
                raise JobCancelledError(f"Qiskit job {self._job_id!r} was cancelled.") from exc
            raise JobFailureError(f"Qiskit job {self._job_id!r} failed: {exc}") from exc

        self._last_known_status = JobStatus.DONE
        self._cached_result = JobResult(self._extract_counts(qiskit_result))
        return self._cached_result

    def cancel(self) -> bool:
        """Attempt to cancel the underlying Qiskit job.

        Returns
        -------
        bool
            ``True`` if the cancellation request was accepted by Qiskit;
            ``False`` if the job is already in a terminal state or the
            backend does not support cancellation.

        """
        if self.in_final_state():
            return False
        try:
            self._qiskit_job.cancel()
            return True
        except Exception:
            return False

    def status(self) -> JobStatus:
        """Return the current :class:`~qrisp.interface.JobStatus`.

        Once the job has reached a terminal state, that cached status is
        returned directly.
        """
        if self._last_known_status in JOB_FINAL_STATES:
            return self._last_known_status
        self._last_known_status = _map_qiskit_status(self._qiskit_job)
        return self._last_known_status


[docs] class QiskitBackend(Backend): """A :class:`~qrisp.interface.Backend` that wraps a Qiskit backend. This allows easy access to any Qiskit-compatible simulator or real quantum hardware through the Qrisp backend interface. Circuits are converted from Qrisp's internal representation to Qiskit ``QuantumCircuit`` objects, transpiled for the target backend, and submitted through the backend's own ``run()`` method. A ``QiskitJob`` handle is returned immediately; call :meth:`Job.result` to block and retrieve the :class:`~qrisp.interface.JobResult`. .. note:: Submission deliberately goes through ``Backend.run()`` rather than Qiskit's ``BackendSamplerV2`` primitive. ``BackendSamplerV2`` reconstructs its output from the ``memory`` field of the result and parses each entry as a hexadecimal string. Not every provider honours that convention (``qiskit-iqm``, for instance, writes plain binary bitstrings), which yields either an ``OverflowError`` or, worse, silently wrong counts. Reading counts directly via ``Result.get_counts()`` avoids the ambiguity, and Qrisp never needs per-shot memory anyway. Parameters ---------- backend : Qiskit backend object, optional A Qiskit backend object that runs ``QuantumCircuit`` objects. Defaults to ``AerSimulator()`` if not provided. name : str or None, optional A name for the backend. Defaults to the Qiskit backend's own name. options : dict or None, optional Runtime options. Defaults to ``{"shots": 1000}``. Raises ------ TypeError If *backend* is a real IBM Quantum backend, which cannot run through ``Backend.run()``. Use :class:`QiskitRuntimeBackend` for those. IBM *fake* backends run locally and are supported here. Examples -------- **Simulation on the Aer simulator** We start by creating a ``QiskitBackend`` wrapping the Qiskit Aer simulator, and then measure a :ref:`QuantumFloat` multiplication: .. code-block:: python from qrisp import QuantumFloat from qrisp.interface import QiskitBackend from qiskit_aer import AerSimulator qiskit_backend = QiskitBackend(backend=AerSimulator()) qf = QuantumFloat(4) qf[:] = 3 res = qf * qf When ``get_measurement`` is called, Qrisp compiles the computation into a ``QuantumCircuit``, converts it directly to a Qiskit ``QuantumCircuit``, transpiles it for the target backend, and submits it through the backend's ``run()`` method. Internally, ``run_async`` returns a ``QiskitJob`` immediately; :meth:`~qrisp.interface.Backend.run` then blocks on ``job.result()`` until execution completes and the counts are returned to ``get_measurement``: >>> res.get_measurement(backend=qiskit_backend) {9: 1.0} For local simulators the job is already ``DONE`` by the time ``run_async()`` returns. For remote hardware backends the job will initially be ``QUEUED`` or ``RUNNING``, and ``result()`` will block until execution completes on the device. **Noisy simulation on a fake hardware backend** The same interface works with Qiskit's fake hardware backends, which model real device noise and connectivity constraints: .. code-block:: python from qrisp import QuantumFloat from qiskit_ibm_runtime.fake_provider import FakeWashingtonV2 from qrisp.interface import QiskitBackend qiskit_backend = QiskitBackend(backend=FakeWashingtonV2()) qf = QuantumFloat(2) qf[:] = 2 res = qf * qf Internally, ``run_async()`` transpiles the circuit to the device's native gate set and qubit connectivity before submission, so the results reflect realistic noise characteristics: >>> res.get_measurement(backend=qiskit_backend) {4: 0.6962, ...} # Note: actual counts may vary due to noise and randomness The result is no longer a sharp peak at ``{4: 1.0}`` because the noise model introduces gate errors and readout errors, spreading probability mass across neighbouring bitstrings. """ #: Job wrapper matching the submission path in :meth:`_submit`. Subclasses #: that submit differently pair their own :class:`QiskitJob` subclass here. _job_class: type[QiskitJob] = QiskitJob #: False because :meth:`_submit` uses ``Backend.run()``, which IBM removed. #: :class:`QiskitRuntimeBackend` sets it True and submits via ``SamplerV2``. _supports_ibm_runtime = False def __init__( self, backend: QiskitBackendBase | None = None, name: str | None = None, options: Mapping | None = None, ): """Initialise the QiskitBackend, defaulting to AerSimulator if no backend is provided.""" if not self._supports_ibm_runtime and _is_ibm_runtime_backend(backend): raise TypeError( "QiskitBackend cannot execute IBM Quantum backends, because IBM removed " "support for Backend.run(). Use QiskitRuntimeBackend instead." ) if backend is None: try: from qiskit_aer import AerSimulator backend = AerSimulator() except ImportError as exc: raise ImportError( "Encountered ImportError when trying to import AerSimulator. " "Install it with: pip install qiskit-aer" ) from exc self.backend = backend # Fall back to the Qiskit backend's own name/options when not provided. # Only use the backend's options when they are a plain Mapping (e.g. dict). # Non-Mapping Qiskit Options objects are ignored so that _default_options() # applies, keeping the Backend contract satisfied. if name is None: name = getattr(backend, "name", None) if options is None: candidate = getattr(backend, "options", None) options = candidate if isinstance(candidate, Mapping) else None super().__init__(name=name, options=options) @classmethod def _default_options(cls): """Return the default runtime options (shots=1024).""" return {"shots": 1024} def _submit(self, qiskit_circuits: list[QuantumCircuit], shots: int): """Hand a batch of transpiled circuits to the Qiskit backend and return its job. Subclasses override this (together with :attr:`_job_class`) when their provider requires a different submission path. """ return self.backend.run(qiskit_circuits, shots=shots) @property def max_circuits(self) -> int | None: """Maximum circuits per job, as reported by the underlying Qiskit backend. Delegates to the wrapped backend's own ``max_circuits`` attribute. Returns ``None`` if the backend does not expose a limit (e.g. local simulators such as ``AerSimulator``). """ value = getattr(self.backend, "max_circuits", None) return value if isinstance(value, int) else None
[docs] def run_async(self, circuits, shots: int | list[int] | None = None) -> QiskitJob: """Transpile and submit one or more circuits to the Qiskit backend. This method returns a :class:`QiskitJob` immediately. Call :meth:`Job.result` on the returned object to block and retrieve the :class:`~qrisp.interface.JobResult`. Parameters ---------- circuits : QuantumCircuit or Sequence[QuantumCircuit] One Qrisp circuit or a sequence of Qrisp circuits to execute. shots : int or list[int] or None, optional Number of shots. If ``None``, the backend's ``shots`` option is used. If a ``list[int]`` is provided, Qiskit applies a single shot count to the whole submission, so all circuits are run at ``max(shots)`` and a ``UserWarning`` is emitted. Returns ------- QiskitJob """ if isinstance(circuits, QrispQuantumCircuit): circuits = [circuits] else: circuits = list(circuits) if isinstance(shots, list): self._validate_shots_length(shots, circuits) warnings.warn( "QiskitBackend does not support per-circuit shot counts. " f"Running all {len(circuits)} circuits at max(shots)={max(shots)}.", UserWarning, stacklevel=2, ) n_shots = max(shots) else: self._validate_shots(shots) n_shots = shots if shots is not None else self._options.get("shots", 1024) self._check_circuit_limit(circuits) # Convert each Qrisp circuit to a Qiskit QuantumCircuit, then rebuild # with integer-indexed qubits and transpile for the target backend. qiskit_circuits = [] for circuit in circuits: qc = circuit.to_qiskit() # Rebuild with plain integer indices to avoid register-name mismatches. new_qc = QuantumCircuit(len(qc.qubits), len(qc.clbits)) for instr in qc: new_qc.append( instr.operation, [qc.qubits.index(qb) for qb in instr.qubits], [qc.clbits.index(cb) for cb in instr.clbits], ) qiskit_circuits.append(transpile(new_qc, backend=self.backend)) # Submit all circuits in a single call and wrap the resulting job. qiskit_job = self._submit(qiskit_circuits, n_shots) job = self._job_class(backend=self, qiskit_job=qiskit_job, num_circuits=len(circuits)) job.submit() return job
class QiskitRuntimeJob(QiskitJob): """A :class:`QiskitJob` whose underlying job came from a ``SamplerV2`` primitive. ``SamplerV2`` returns a ``PrimitiveResult`` rather than a ``Result``: counts live in a per-circuit ``DataBin``, one field per classical register, instead of behind a single ``get_counts()`` call. """ def _extract_counts(self, qiskit_result) -> list[dict]: """Read per-circuit counts out of the ``PrimitiveResult`` returned by ``SamplerV2``.""" counts_list = [] for i in range(self._num_circuits): counts_dict: dict = {} for reg_name in qiskit_result[i].data: reg_data = getattr(qiskit_result[i].data, reg_name) for key, val in _merge_counts(reg_data.get_counts()).items(): counts_dict[key] = counts_dict.get(key, 0) + val counts_list.append(counts_dict) return counts_list
[docs] class QiskitRuntimeBackend(QiskitBackend): """A :class:`~qrisp.interface.Backend` that wraps an IBM Quantum Runtime backend. This allows easy access to IBM Quantum Runtime backends through the Qrisp backend interface. Circuits are transpiled and submitted through Qiskit's ``SamplerV2`` primitive, using either a direct job or a persistent session. It is important to close the session after execution when using ``mode="session"`` (see :meth:`close_session`). Parameters ---------- api_token : str IBM Cloud API token used to authenticate with the Qiskit Runtime service. backend : str or None, optional Name of the IBM Quantum backend. If ``None``, the least-busy available backend is selected automatically. channel : str, optional Channel type for the Runtime service. Available: ``"ibm_cloud"`` or ``"ibm_quantum_platform"``. Defaults to ``"ibm_cloud"``. mode : str, optional Execution mode. ``"job"`` submits each circuit batch as an independent job; ``"session"`` opens a persistent session for lower latency across multiple submissions. Defaults to ``"job"``. instance : str or None, optional The Cloud Resource Name (CRN) for IBM Cloud. Passed to ``QiskitRuntimeService``. Defaults to ``None``. Attributes ---------- session : Session The `Qiskit Runtime session <https://quantum.cloud.ibm.com/docs/en/api/qiskit-ibm-runtime/session>`_. Only set when ``mode="session"`` is passed at construction time. Examples -------- >>> from qrisp import QuantumFloat >>> from qrisp.interface import QiskitRuntimeBackend >>> example_backend = QiskitRuntimeBackend(api_token="YOUR_IBM_CLOUD_TOKEN", backend="ibm_brisbane", channel="ibm_cloud") >>> qf = QuantumFloat(2) >>> qf[:] = 2 >>> res = qf * qf >>> result = res.get_measurement(backend=example_backend) >>> print(result) >>> # example_backend.close_session() # Only needed when mode="session" {4: 0.6133, 8: 0.1126, 0: 0.0838, 12: 0.0798, 5: 0.0272, 6: 0.016, 9: 0.0125, 1: 0.0117, 13: 0.0081, 14: 0.0073, 3: 0.0071, 2: 0.0062, 10: 0.0051, 7: 0.0044, 11: 0.0035, 15: 0.0014} """ _job_class = QiskitRuntimeJob _supports_ibm_runtime = True def __init__(self, api_token, backend=None, channel="ibm_cloud", mode="job", instance=None): try: from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2, Session except ImportError as exc: raise ImportError( "Please install qiskit-ibm-runtime to use QiskitRuntimeBackend: pip install qiskit-ibm-runtime" ) from exc service = QiskitRuntimeService(channel=channel, token=api_token, instance=instance) ibm_backend = service.least_busy() if backend is None else service.backend(backend) # Delegate common setup (self.backend, name, options) to QiskitBackend. # Pass _default_options() explicitly so the IBM hardware backend's internal # options (device config, noise model settings, etc.) do not bleed through # into the runtime options seen by Qrisp callers. super().__init__(backend=ibm_backend, options=self._default_options()) # Unlike the parent class, this backend submits through the SamplerV2 # primitive: IBMBackend.run() still exists but raises IBMBackendError # ("Support for backend.run() has been removed"), so primitives are the # only route to IBM hardware. if mode == "session": self.session = Session(ibm_backend) self.sampler = SamplerV2(self.session) elif mode == "job": self.sampler = SamplerV2(ibm_backend) else: raise ValueError(f"Execution mode {mode!r} not available. Choose 'job' or 'session'.") @classmethod def _default_options(cls): """Return the default runtime options (shots=1000).""" return {"shots": 1000} def _submit(self, qiskit_circuits, shots): """Submit through the IBM Runtime ``SamplerV2`` instead of ``Backend.run()``.""" return self.sampler.run(qiskit_circuits, shots=shots)
[docs] def close_session(self): """Close the IBM Runtime session opened during construction. Only call this when ``mode="session"`` was passed at construction time. """ self.session.close()