# """
# ********************************************************************************
# * 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 the abstract :class:`Backend` interface for Qrisp-compatible backends."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Protocol, overload, runtime_checkable
from qrisp.circuit.quantum_circuit import QuantumCircuit
from qrisp.interface.measurement_result import MeasurementResult
from .job import Job
if TYPE_CHECKING:
from qrisp.interface.batched_backend import BatchedBackend
# This protocol is required because `BatchedBackend` intentionally
# does not inherit from `Backend`, as this would violate the Liskov
# Substitution Principle (the `run` method in `BatchedBackend`
# has different behaviour and return type than in `Backend`).
@runtime_checkable
class BackendLike(Protocol):
"""Structural protocol satisfied by both :class:`Backend` and
:class:`~qrisp.interface.BatchedBackend`.
Use this as the type hint for parameters that accept either a concrete
backend or a :class:`~qrisp.interface.BatchedBackend`.
Both :class:`Backend` and :class:`~qrisp.interface.BatchedBackend`
satisfy this protocol structurally (i.e. without explicit inheritance),
so type checkers accept either wherever :class:`BackendLike` is required.
"""
@property
def name(self) -> str:
"""Human-readable name of the backend."""
...
@overload
def run(self, circuits: QuantumCircuit, shots: int | None = None) -> MeasurementResult: ...
@overload
def run(self, circuits: Sequence[QuantumCircuit], shots: int | None = None) -> list[MeasurementResult]: ...
def run(
self,
circuits: QuantumCircuit | Sequence[QuantumCircuit],
shots: int | None = None,
) -> MeasurementResult | list[MeasurementResult]:
"""Submit circuits and return measurement result(s)."""
...
@property
def options(self) -> Mapping:
"""Current runtime options (read-only)."""
...
def update_options(self, **kwargs) -> None:
"""Update existing runtime options."""
...
[docs]
class Backend(ABC):
"""Abstract base class for Qrisp-compatible backends.
This class provides a minimal, hardware-agnostic interface that all
backends (simulators or quantum hardware clients) must follow.
The only mandatory method for child classes is :meth:`run_async`, which
submits one or more circuits for execution and returns a :class:`Job`
handle immediately. The caller then decides when to wait for the result
by calling :meth:`Job.result <qrisp.interface.Job.result>`.
A synchronous :meth:`run` method is also provided. It calls
:meth:`run_async` internally, blocks until the job completes, and returns
the results as :class:`~qrisp.interface.MeasurementResult` objects. This
is the standard execution path for most users, and is what
:meth:`~qrisp.QuantumVariable.get_measurement` uses internally. Use
:meth:`run_async` directly only when you need the :class:`Job` handle
itself (e.g. to poll status, cancel, or wait concurrently).
.. rubric:: Design contract
The ``Backend`` class defines the minimal execution interface required by
Qrisp. It does not assume universality of the gate set, specific
connectivity constraints, or the presence of calibration data. Any such
assumptions must be made explicit by concrete backend implementations or
higher-level compilation policies.
This class intentionally avoids prescribing compilation, scheduling, or
execution policies, which are delegated to concrete backends or external
components.
For example, simulators are expected to implement :meth:`run_async` but may
return ``None`` for all hardware metadata properties.
.. rubric:: Execution model
:meth:`run_async` accepts a single circuit *or* a sequence of circuits
and always returns a :class:`Job` instance immediately.
This follows the *Future* pattern: execution happens independently of the
caller, and the caller decides *when* to block by calling
:meth:`Job.result <qrisp.interface.Job.result>`.
:meth:`run` is the standard synchronous execution path.
It blocks until execution completes and returns the measurement results
(one per submitted circuit).
.. rubric:: Runtime options
Runtime options describe *how* the backend executes circuits (e.g. the
number of shots). These can be provided:
* by overriding :meth:`_default_options` at class level, or
* by passing a custom ``options`` mapping to :meth:`__init__`.
The number of shots may also be overridden per-execution by passing
the ``shots`` argument to :meth:`run_async` or :meth:`run`. It is
treated as a conventional execution parameter for gate-based, shot-based
backends, and may be ignored by backends for which it is not meaningful.
Options are updated through :meth:`update_options`, but only keys that
were present at initialisation may be modified.
.. rubric:: Hardware metadata
The backend may optionally expose hardware metadata such as:
* backend health or diagnostics,
* backend queue status,
* number of qubits,
* connectivity information,
* supported native gates,
* gate errors or calibration data.
These properties are intentionally left loosely typed at the base-class
level. Their purpose is to *signal the presence of a capability*, not to
enforce a universal hardware schema.
The absence of a value for a given property (``None``) explicitly means
*"capability not exposed by this backend"*. This is common for simulators,
abstract backends, or backends that choose not to provide hardware details.
Concrete backend implementations (e.g. vendor-specific backends) are
expected to override these properties and may return structured,
vendor-defined objects with richer semantics. Transpilers or compilation
passes may then either:
* require specific capabilities and raise if they are missing, or
* ignore hardware metadata entirely and operate in a backend-agnostic mode.
Backend-specific capabilities that are not covered by the base interface
should be exposed as concrete typed properties on the subclass.
Parameters
----------
name : str or None
Optional user-defined name for the backend.
Defaults to the class name.
options : Mapping or None
Runtime execution options for the backend.
If omitted, :meth:`_default_options` is used.
**kwargs :
Additional backend-specific parameters. These are not defined at the
base-class level but may be accepted by concrete backend
implementations (e.g. for authentication, provider selection, or
other configuration).
"""
[docs]
def __init__(self, name: str | None = None, options: Mapping | None = None, **kwargs):
"""Initialise the backend."""
self.name = name or self.__class__.__name__
if options is None:
options = self._default_options()
if not isinstance(options, Mapping):
raise TypeError(f"'options' must be a dict-like Mapping, got {type(options).__name__}")
# Shallow-copy and convert to dict so that:
# (a) external mutations of the original mapping do not affect the
# backend's internal state, and
# (b) update_options can use __setitem__ regardless of the original
# Mapping type.
self._options = dict(options)
self.metadata = kwargs
# ------------------------------------------------------------------
# Abstract method
# ------------------------------------------------------------------
[docs]
@abstractmethod
def run_async(
self,
circuits: QuantumCircuit | Sequence[QuantumCircuit],
shots: int | list[int] | None = None,
) -> Job:
"""Submit one or more circuits for execution and return a :class:`Job`.
The returned job must not be in
:attr:`~qrisp.interface.JobStatus.INITIALIZING` state: by the time
``run_async`` returns, execution must have been handed off to the
backend. The exact state the job enters depends on the backend type:
* :attr:`~qrisp.interface.JobStatus.QUEUED`: for asynchronous or remote
backends where the job waits in a queue before execution begins.
* :attr:`~qrisp.interface.JobStatus.RUNNING` or
:attr:`~qrisp.interface.JobStatus.DONE`: for synchronous simulators
that begin (or complete) execution before returning.
Parameters
----------
circuits : QuantumCircuit or Sequence[QuantumCircuit]
A single circuit or a sequence of circuits to execute.
No validation or introspection is performed at the base-class level.
When a sequence is provided, the backend decides internally how to
handle the circuit execution. Hardware backends may impose a limit
on how many circuits a single job may contain. This is a
backend-defined constraint.
shots : int or list[int] or None, optional
Number of shots (repetitions) for the execution. If ``None``,
the value from the backend's runtime options should be used.
When a ``list[int]`` is provided, each entry specifies the shot
count for the circuit at the corresponding index.
Backends whose SDK does not natively support per-circuit shot
counts should fall back to ``max(shots)`` and issue a
``UserWarning``.
Returns
-------
Job
A handle to the submitted execution. Call
:meth:`Job.result <qrisp.interface.Job.result>` to wait for
completion and retrieve the
:class:`~qrisp.interface.JobResult`.
"""
raise NotImplementedError
# ------------------------------------------------------------------
# Synchronous convenience method
# ------------------------------------------------------------------
@overload
def run(self, circuits: QuantumCircuit, shots: int | None = None) -> MeasurementResult: ...
@overload
def run(self, circuits: Sequence[QuantumCircuit], shots: int | None = None) -> list[MeasurementResult]: ...
[docs]
def run(
self,
circuits: QuantumCircuit | Sequence[QuantumCircuit],
shots: int | None = None,
) -> MeasurementResult | list[MeasurementResult]:
"""Submit one or more circuits, block until completion, and return results.
This is a synchronous convenience wrapper around :meth:`run_async`.
It calls :meth:`run_async`, waits for the :class:`Job` to finish, and
returns the measurement results wrapped in
:class:`~qrisp.interface.MeasurementResult` objects. The result type
mirrors the input: a single :class:`~qrisp.interface.MeasurementResult`
for a single circuit, or a ``list`` of them for a sequence.
Parameters
----------
circuits : QuantumCircuit or Sequence[QuantumCircuit]
A single circuit or a sequence of circuits to execute.
shots : int or None, optional
Number of shots. If ``None``, the backend's ``shots`` option
is used by default.
Returns
-------
MeasurementResult or list[MeasurementResult]
A pre-populated :class:`~qrisp.interface.MeasurementResult` when
one circuit is submitted, or a list of them for multiple circuits.
Raises
------
TypeError
If *shots* is not an integer.
ValueError
If *shots* is not a positive integer.
"""
self._validate_shots(shots)
self._check_circuit_limit(circuits)
batch = not isinstance(circuits, QuantumCircuit)
shots = shots if shots is not None else self.options.get("shots")
all_counts = self.run_async(circuits, shots).result().all_counts
results = []
for counts in all_counts:
raw = MeasurementResult()
raw._inject(counts)
results.append(raw)
return results if batch else results[0]
# ------------------------------------------------------------------
# Optional job retrieval and utility methods
# ------------------------------------------------------------------
[docs]
def batched(self) -> "BatchedBackend":
"""Return a :class:`BatchedBackend` that wraps this backend.
The returned object buffers circuits submitted via :meth:`run` and
executes them by forwarding each to this backend's :meth:`run` when
:meth:`BatchedBackend.dispatch` is called.
Returns
-------
BatchedBackend
"""
from qrisp.interface.batched_backend import BatchedBackend
return BatchedBackend(self)
[docs]
def retrieve_job(self, job_id: str) -> Job:
"""Reconnect to a previously submitted job by its identifier.
This method allows users to recover a :class:`Job` handle after a
process restart or network interruption, provided the backend
stores job history server-side.
This is an *optional capability*. The default implementation
raises :exc:`NotImplementedError`. Backends that support job
recovery must override this method.
Parameters
----------
job_id : str
The identifier of the job to retrieve. This is the value
returned by :attr:`Job.job_id <qrisp.interface.Job.job_id>`
after a previous call to :meth:`run_async`.
Returns
-------
Job
A handle to the previously submitted job, in whatever state
it currently is.
Raises
------
NotImplementedError
If this backend does not support job recovery (the default).
LookupError
If no job with the given *job_id* can be found on the backend.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support job recovery. Override retrieve_job() to enable this feature."
)
def _check_circuit_limit(
self,
circuits: QuantumCircuit | Sequence[QuantumCircuit],
) -> None:
"""Raise :exc:`ValueError` if the number of submitted circuits exceeds
:attr:`max_circuits`.
This helper is called automatically by :meth:`run`. Implementations
of :meth:`run_async` can call it before submitting to the
hardware so that users who bypass :meth:`run` get the same early
error.
Parameters
----------
circuits : QuantumCircuit or Sequence[QuantumCircuit]
The circuit(s) about to be submitted.
Raises
------
ValueError
If *circuits* is a sequence whose length exceeds
:attr:`max_circuits`.
"""
limit = self.max_circuits
if limit is None:
return
if isinstance(circuits, QuantumCircuit):
return # a single circuit is always within any positive limit
try:
n = len(circuits)
except TypeError:
return # length unknown (e.g. a lazy iterable). We skip the check
if n > limit:
raise ValueError(
f"{self.__class__.__name__} accepts at most {limit} "
f"circuit(s) per job, but {n} were submitted. "
"Split the batch into smaller chunks and submit separately."
)
@staticmethod
def _validate_shots(shots: int | None) -> None:
"""Raise an informative error if *shots* is not a valid positive integer.
Intended to be called at the top of :meth:`run` (and optionally
inside :meth:`run_async` implementations) before submitting circuits
to the backend.
Parameters
----------
shots : int or None
The shot count to validate. ``None`` is accepted and means
"use the backend default". No error is raised.
Raises
------
TypeError
If *shots* is not an :class:`int` (or is a :class:`bool`,
which is a subclass of :class:`int` but almost certainly a
caller mistake).
ValueError
If *shots* is zero or negative.
"""
if shots is None:
return
if isinstance(shots, bool) or not isinstance(shots, int):
raise TypeError(f"'shots' must be a positive integer, got {type(shots).__name__!r}")
if shots <= 0:
raise ValueError(f"'shots' must be a positive integer, got {shots!r}")
@staticmethod
def _validate_shots_length(
shots: list[int],
circuits: "QuantumCircuit | Sequence[QuantumCircuit]",
) -> None:
"""Raise :exc:`ValueError` if the length of a per-circuit *shots* list
does not match the number of submitted circuits.
This method can be called inside :meth:`run_async` implementations
immediately after normalising *circuits* to a list and before any execution,
whenever *shots* is a ``list``.
Parameters
----------
shots : list[int]
The per-circuit shot counts to validate.
circuits : QuantumCircuit or Sequence[QuantumCircuit]
The circuits that will be executed. A single
:class:`~qrisp.circuit.QuantumCircuit` counts as one circuit.
Raises
------
ValueError
If ``len(shots)`` does not equal the number of circuits.
"""
n = 1 if isinstance(circuits, QuantumCircuit) else len(circuits)
if len(shots) != n:
raise ValueError(
f"'shots' list has {len(shots)} element(s) but {n} circuit(s) "
"were submitted. When passing per-circuit shot counts, provide "
"exactly one value per circuit."
)
# ------------------------------------------------------------------
# Runtime options
# ------------------------------------------------------------------
[docs]
@classmethod
def _default_options(cls) -> Mapping[str, Any]:
"""Default runtime options for the backend.
Child classes may override this method to provide custom default
options, or the defaults may be overridden entirely by passing an
``options`` mapping to the constructor.
Returns
-------
Mapping[str, Any]
A mapping of option names to their default values.
"""
return {"shots": 1024}
@property
def options(self) -> Mapping[str, Any]:
"""Current runtime options for the backend.
These options may influence execution behaviour (e.g. the number of
shots) and therefore may affect :meth:`run_async` and :meth:`run`.
The returned mapping is read-only. Use :meth:`update_options` to
change existing keys. Direct mutation (e.g.
``backend.options["shots"] = n``) raises :exc:`TypeError`.
"""
return MappingProxyType(self._options)
[docs]
def update_options(self, **kwargs) -> None:
"""Update existing runtime options for the backend.
Only keys that were present at initialisation (i.e. defined in
:meth:`_default_options` or the ``options`` argument passed to the
constructor) may be updated. Attempting to set an unknown key raises
an :exc:`AttributeError`.
This method is *atomic*: all keys are validated before any value
is written. If one key is invalid, no options are changed.
Parameters
----------
**kwargs :
Key-value pairs to update in the backend's runtime options.
"""
unknown = [k for k in kwargs if k not in self._options]
if unknown:
raise AttributeError(
f"'{unknown[0]}' is not a valid backend option for "
f"{self.__class__.__name__}. "
f"Valid options: {list(self._options.keys())}"
)
if "shots" in kwargs:
self._validate_shots(kwargs["shots"])
self._options.update(kwargs)
# ------------------------------------------------------------------
# Hardware / backend-specific status information
# ------------------------------------------------------------------
@property
def health(self):
"""Current health status or diagnostics of the backend.
This may include uptime statistics or other backend-specific health
indicators.
Returns ``None`` if the backend does not expose health information.
"""
return None
@property
def info(self):
"""General information about the backend.
This may include backend version, provider details, or other
backend-specific information.
Returns ``None`` if the backend does not expose such information.
"""
return None
@property
def queue(self):
"""Current queue status or job backlog of the backend.
This may include estimated wait times, number of pending jobs, or
other backend-specific queue indicators.
Returns ``None`` if the backend does not expose queue information.
"""
return None
# ------------------------------------------------------------------
# Hardware / backend-specific metadata
# ------------------------------------------------------------------
@property
def num_qubits(self):
"""Total number of physical qubits the backend exposes.
This reflects the full physical qubit count of the device, not a
snapshot of currently healthy or calibrated qubits. A qubit whose
calibration has degraded or whose gates have been removed from the
active gate set should still be counted here.
Returns ``None`` if the backend does not expose a fixed or meaningful
qubit count (e.g. simulators, abstract backends, or backends where
this information is intentionally omitted).
"""
return None
@property
def max_circuits(self) -> int | None:
"""Maximum number of circuits this backend can execute in a single job.
Many hardware backends impose a per-job circuit limit (e.g. a
cloud provider may accept at most 300 circuits per submission).
Submitting a batch larger than this limit causes an opaque error
from the vendor SDK. Exposing the limit here lets Qrisp provide an
early, clear :exc:`ValueError` via :meth:`run` and
:meth:`_check_circuit_limit` before any network call is made.
Concrete backends should override this property and return the
actual limit. Implementations of :meth:`run_async` should also
call :meth:`_check_circuit_limit` before submitting to the
hardware so that users who bypass :meth:`run` still get the same
early error.
Returns ``None`` if the backend imposes no limit (simulators) or
does not expose this information.
"""
return None
@property
def connectivity(self):
"""Currently executable qubit connectivity for the backend.
This property describes which qubit pairs currently
have at least one multi-qubit gate available for execution. It
reflects the active gate set at the time the property is queried
(not the physical wiring of the device, not a theoretical maximum,
and not a snapshot of all pairs that have ever been calibrated).
If the physical device consists of multiple disconnected components,
the backend is expected to expose a single connected component.
The choice of which component to expose (e.g. largest component,
highest-fidelity subset) is left to the concrete backend
implementation.
Gates involving more than two qubits are not representable as edges
in a connectivity graph. Backends that support such operations should
document them separately (e.g. via :attr:`gate_set`).
The returned object may encode qubit adjacency, coupling constraints,
or other topology information in a backend-specific format.
Returns ``None`` if the backend does not expose connectivity
information. Concrete backend implementations may return structured,
vendor-defined objects.
"""
return None
@property
def gate_set(self):
"""Native gate set supported by the backend.
This property describes which operations the backend can execute
natively. The gate set is purely descriptive.
Qrisp does not assume the set is universal. Compilation passes that
require universality must verify this themselves.
Different qubit pairs may support different gates (e.g. CZ on some
pairs, iSWAP on others, or both). Backends are expected to encode
this granularity in the returned object.
Measurement is not assumed. The availability of a measurement
operation on a given qubit is not guaranteed by this interface.
Backends that require explicit measurement declarations should
include them in the gate set.
The format of the returned object is backend-specific. For hardware
backends it typically refers to native operations. For simulators it
may be omitted or ignored.
Returns ``None`` if the backend does not expose gate availability.
"""
return None
@property
def error_rates(self):
"""Error rates or calibration-related information for the backend.
This property is calibration-dependent: the values it exposes are
only meaningful relative to a specific calibration run. Concrete
backend implementations currently must include metadata that identifies
which calibration snapshot the data refers to. For example, a
timestamp, a calibration ID, or a version string. Returning bare
error values without any calibration anchor is discouraged,
as callers have no way to determine whether the data is current.
The format of the returned object is hardware (and vendor) specific.
Returns ``None`` if the backend does not expose error or calibration
data.
"""
return None
# ------------------------------------------------------------------
# Dunder methods
# ------------------------------------------------------------------
def __repr__(self) -> str:
"""Return a concise string representation of the backend."""
return f"{self.__class__.__name__}(name={self.name!r}, options={dict(self._options)!r})"