# SDK: backends and the Qiskit bridge

> Reference for qsolve.backends and qsolve.bridge: targets and get_backend, Backend and AsyncBackend, AerBackend, FakeHardwareBackend, IBMBackend, RemoteBackend, QsolveBackend, QsolveSampler and QsolveEstimator.

Source: https://qsolve.omlabs.co/docs/build/sdk-backends-and-bridge
Updated: 2026-11-13

How circuits get from your Qiskit code to a simulator, a noise model or a quantum computer. In your workflow you only touch the bridge objects in `ctx` (`ctx.backend`, `ctx.sampler`, `ctx.estimator`); the backends behind them are the platform's. Targets and what they cost are explained in [Backends and targets](/docs/build/backends-and-targets). Back to the [SDK reference](/docs/build/sdk-reference).

## Targets

| Target string | Backend class | `target` / `provider` / `device` attributes | Notes |
|---|---|---|---|
| `sim-cpu` | `AerBackend(gpu=False)` | `sim-cpu` / `aer` / `aer-cpu` | Noise-free Qiskit Aer, up to 26 qubits. |
| `sim-gpu` | `AerBackend(gpu=True)` | `sim-gpu` / `aer` / `aer-gpu` | Needs `qiskit-aer-gpu`; up to 30 qubits. |
| `fake:<device>[@scale]` | `FakeHardwareBackend` | `qpu` / `fake` / `fake_<device>[@scale]` | An IBM device's published noise on Aer; needs the `ibm` extra; free. |
| `ibm:<device>` | `IBMBackend` | `qpu` / `ibm` / `<device>` | Real IBM hardware; needs the `ibm` extra and `QSOLVE_IBM_TOKEN`. |
| (platform only) | `RemoteBackend` | whatever the executor reports | Used inside the sandbox; see below. |

## qsolve.backends

```python
from qsolve.backends import (AerBackend, AsyncBackend, Backend, ProviderJobFailed,
                             SampleResult, get_backend)
```

The package `__all__` holds exactly those six names. `FakeHardwareBackend`, `IBMBackend` and `RemoteBackend` live in the submodules `qsolve.backends.fake`, `.ibm` and `.remote` and are imported lazily so the optional dependencies load only when needed.

### get_backend

```python
get_backend(target: str) -> Backend
```

Resolves a target string to a backend object. Called by `run_workflow` when you pass no `backend`. It takes no other arguments: an unknown keyword argument raises `TypeError`, and an unknown or malformed target raises `ValueError` (for example a non-numeric noise scale in `fake:fez@fast`).

| Rule (checked in this order) | Result |
|---|---|
| Environment variable `QSOLVE_EXECUTOR_ADDR` is set | `RemoteBackend()`, whatever `target` says. On the platform the workflow only ever gets the credential-free remote backend; the executor decides the real device. |
| `sim-cpu` | `AerBackend(gpu=False)` |
| `sim-gpu` | `AerBackend(gpu=True)` |
| starts with `ibm:` | `IBMBackend(device=<text after the colon>)` |
| starts with `fake:` | `FakeHardwareBackend(device, scale)`, parsing `device[@scale]` with `scale = float(...)`, default 1.0 |
| anything else | `ValueError("Unknown target '...'. Use sim-cpu, sim-gpu, ibm:<device> or fake:<device>[@scale].")` |

Other exceptions: `RuntimeError` when the `ibm` extra is missing or `QSOLVE_IBM_TOKEN` is not set; `ValueError` for an unknown fake device, a scale outside 1 to 10, or a scale that is not a number. `run_workflow` turns `ValueError`, `RuntimeError` and `ImportError` from this call into `WorkflowError`.

### SampleResult

```python
@dataclass
class SampleResult:
    counts: dict[str, int]
    shots: int
    exec_time_s: float = 0.0
    metadata: dict[str, Any] = field(default_factory=dict)
```

What a backend returns for one circuit. `counts` maps bit strings to counts in Qiskit order (qubit 0 is the rightmost character; spaces from multiple registers are tolerated by consumers). `metadata` varies by backend:

| Key | From | Meaning |
|---|---|---|
| `dispatch` | every run through the bridge | Index of the recorded circuit; cite it in `ctx.report_mitigation`. Added by `QsolveBackend.dispatch`. |
| `readout` | `fake`, `ibm` | Per measured bit, `[P(read 1 given 0), P(read 0 given 1)]` of the physical qubit it came from, or `None` when the device does not report it. |
| `two_qubit_gates` | `fake`, `ibm` | Two-qubit gates in the transpiled circuit. |
| `physical_qubits` | `fake`, `ibm` | Physical qubits the transpiled circuit used. |
| `device_circuit` | `fake`, `ibm` | The transpiled circuit as drawable ops (`circuit_ops`), for the first four distinct circuits of a batch, each of at most 5,000 operations. |
| `job_id`, `batch_index`, `batch_size`, `quantum_seconds` | `ibm` | The provider job; `quantum_seconds` (the job's billed quantum time) appears on the first circuit of a batch, 0 on the others. |

### MAX_BATCH and sample_all

`MAX_BATCH = 300` (in `qsolve.backends.base`): circuits per batch, bounded by provider job limits and the executor's message size.

```python
sample_all(backend: Backend, circuits: Sequence[QuantumCircuit], shots: int,
           seeds: Sequence[int]) -> list[SampleResult]
```

`backend.sample_batch` in chunks of `MAX_BATCH`. For code that talks to a `Backend` directly, with circuits that do not depend on each other's results.

### Backend

```python
class Backend(ABC):
    target: str      # "sim-cpu" | "sim-gpu" | "qpu"
    provider: str
    device: str

    @abstractmethod
    def sample(self, circuit: QuantumCircuit, shots: int, seed: int) -> SampleResult
    def sample_batch(self, circuits: Sequence[QuantumCircuit], shots: int,
                     seeds: Sequence[int]) -> list[SampleResult]
    def calibration_snapshot(self) -> dict[str, Any]
    @property
    def max_qubits(self) -> int
```

Executes measured circuits and returns counts. Workflows never touch a `Backend`; the harness does. Subclass it only to test the harness with your own fake.

| Member | Meaning |
|---|---|
| `target`, `provider`, `device` | Class or instance attributes a subclass must set. `target` decides `source` in the fidelity measurement: anything other than `qpu` is an ideal simulator. |
| `sample(circuit, shots, seed)` | Abstract. Run one circuit. `seed` seeds simulators; hardware ignores it. |
| `sample_batch(circuits, shots, seeds)` | Run several independent circuits, each with its own seed. Default: one `sample` call per circuit (a simulator then gives exactly the counts of separate calls). Raises `ValueError("one seed per circuit")` when the lengths differ. Overridden by backends that can send a batch as one job. |
| `calibration_snapshot()` | Device facts stored in `provenance.calibration_snapshot`. Default `{}`. |
| `max_qubits` | Largest circuit accepted. Default 30. The bridge sizes its Qiskit `Target` with it. |

### AsyncBackend

```python
class AsyncBackend(Backend):
    batches: bool = False
    def submit(self, circuit, shots, seed) -> str            # abstract
    def collect(self, job_id: str) -> SampleResult           # abstract
    def cancel(self, job_id: str) -> None                    # default: does nothing
    def submit_batch(self, circuits, shots, seeds) -> str    # default: NotImplementedError
    def collect_batch(self, job_id: str) -> list[SampleResult]  # default: NotImplementedError
```

A backend whose jobs live at the provider and can be collected later by id (QPUs). The platform records the job id between `submit` and `collect`, so a run interrupted while waiting in a hardware queue re-attaches to the same job instead of paying twice. `sample` is `collect(submit(...))`. `sample_batch` uses `submit_batch` and `collect_batch` when `batches` is true (one provider job, one queue wait) and otherwise falls back to one job per circuit. `submit` and `collect` are abstract; the batch pair must be implemented when `batches` is true. `cancel` should cancel a job still waiting at the provider.

### ProviderJobFailed

`class ProviderJobFailed(RuntimeError)`. The provider finished a job without a result (failed or cancelled on their side). Resubmitting is the right response; any other exception may be transient. Raised by `IBMBackend.collect_batch` when IBM reports `ERROR` or `CANCELLED`.

### AerBackend

```python
AerBackend(gpu: bool = False)
```

Local Qiskit Aer simulator. `gpu=True` needs `qiskit-aer-gpu` and sets `target="sim-gpu"`, `device="aer-gpu"`; otherwise `sim-cpu` and `aer-cpu`. `provider` is `aer`. `max_qubits` is 30 on GPU, 26 on CPU.

| Method | Behaviour |
|---|---|
| `sample(circuit, shots, seed)` | Simulates one circuit with `seed_simulator=seed` inside `qsolve.sim.simulator_threads()`, so every core the run was given is used. Returns counts and wall-clock seconds. The same seed gives the same counts at any thread count. |
| `runnable(circuit, seed=0)` | Returns `circuit` itself when every gate in it is native to Aer (or is `barrier` or `measure`), else the circuit transpiled for Aer with `seed_transpiler=seed`. Transpiling a small circuit costs far more than simulating it, so it is skipped when possible. |

### FakeHardwareBackend

```python
from qsolve.backends.fake import FakeHardwareBackend
FakeHardwareBackend(device: str = "fez", scale: float = 1.0)
```

A real device's published noise, simulated: the free rehearsal for hardware runs. `target="qpu"`, `provider="fake"`, `device="fake_<name>"` with `"@<scale:g>"` appended when `scale != 1`. Needs the `ibm` extra.

| Parameter | Meaning |
|---|---|
| `device` | Name of a fake IBM device: `fez`, `marrakesh`, `torino`, ... The class `Fake<Name>` is looked up in `qiskit_ibm_runtime.fake_provider`. `ValueError("no fake device 'x' (e.g. fez, marrakesh, torino)")` if absent. |
| `scale` | Multiplier on every error rate (gate, readout, preparation errors, capped at 0.5 for readout and 0.75 for gates); T1 and T2 are divided by it. Must be from 1 to 10, else `ValueError("noise scale must be between 1 and 10")`. `fez@1.6` is a pessimistic rehearsal: a real device has tended to be worse than its own published model (1.6 is an empirical rule of thumb, not a fitted constant). |

`sample_batch` transpiles the circuits exactly as for the device (`transpile_best`) and runs them as one Aer job with the noise model. Aer takes one seed per job, so for a batch every circuit's seed is mixed into it (a hash of the whole list): changing any one seed changes the job, and the same seeds always give the same counts. A single circuit keeps its own seed. Each result carries `readout`, `two_qubit_gates`, `physical_qubits` and (for a few circuits) `device_circuit` metadata, the same as `IBMBackend`, so workflows exercise their hardware path end to end. `sample` is a batch of one. `calibration_snapshot()` returns `{"backend": device, "noise_scale": scale}`. `max_qubits` is the simulated device's qubit count. Building the noise model takes several seconds for a 156-qubit device.

### IBMBackend

```python
from qsolve.backends.ibm import IBMBackend
IBMBackend(device: str, token: str | None = None, instance: str | None = None)
```

IBM Quantum hardware through `qiskit-ibm-runtime`. An `AsyncBackend` with `batches = True`. Credentials come from the environment, never from workflow code.

| Parameter | Meaning |
|---|---|
| `device` | IBM backend name, for example `ibm_fez`. |
| `token` | Defaults to `QSOLVE_IBM_TOKEN`. `RuntimeError("QSOLVE_IBM_TOKEN is not set")` if neither exists. |
| `instance` | Defaults to `QSOLVE_IBM_INSTANCE`. |

`RuntimeError` if the `ibm` extra is missing. The constructor connects (channel `ibm_quantum_platform`) and looks up the device, so it needs network access.

| Method | Behaviour |
|---|---|
| `submit_batch(circuits, shots, seeds)` | Transpiles with `transpile_best` (keeping the cheapest of four tries), sends one `SamplerV2` job with dynamical decoupling (`XpXm`) and gate twirling on (measurement twirling off, so readout errors stay as calibrated), and caps the job's quantum time with `max_execution_seconds`. Returns the IBM job id. Costs your IBM quota. |
| `collect_batch(job_id)` | Waits for the job; one `SampleResult` per circuit in submission order, metadata as in the table above. `exec_time_s` is the wall wait divided by the batch size. Raises `ProviderJobFailed` when IBM ended the job as `ERROR` or `CANCELLED`, other exceptions as they are. |
| `submit`, `collect` | One-circuit versions of the above. |
| `cancel(job_id)` | Cancels a job that is not yet `DONE`, `ERROR` or `CANCELLED`. |
| `calibration_snapshot()` | `{"backend": device, "n_qubits": n}`. |
| `max_qubits` | The device's qubit count. |

Module constants in `qsolve.backends.ibm`: `S_PER_JOB = 2.4` and `S_PER_SHOT = 0.00023`, an empirical fit to billed quantum time on `ibm_fez` (IBM can change its billing; re-fit if estimates drift from the bill), and

```python
max_execution_seconds(n_circuits: int, shots: int) -> int
```

which returns `ceil(10 + 3 * (S_PER_JOB + S_PER_SHOT * n_circuits * shots))`: three times the expected bill plus 10 s, so a job that somehow needs more is stopped by IBM instead of draining the account.

### Hardware helpers (qsolve.backends.hardware)

Shared by the fake and IBM backends; not needed by workflows.

| Name | Meaning |
|---|---|
| `TRANSPILE_TRIES = 4` | Seeds tried per circuit. |
| `transpile_best(circuits, backend, seed, tries=4) -> list[QuantumCircuit]` | Transpile at optimisation level 3 (noise-aware layout) with `tries` seeds and keep, per circuit, the result with the fewest two-qubit gates, then the shallowest. Deterministic. Circuits that differ only in rotation angles are compiled once as a template and then given their angles, so copies sent to split shots share one result. |
| `two_qubit_count(circuit)`, `two_qubit_counts(circuits)` | Two-qubit operations, barriers excluded. |
| `measured_qubits(circuit) -> dict[int, int]` | Classical bit to physical qubit, from the measurements of a transpiled circuit. |
| `readout_errors(circuit, properties) -> list[list[float]] \| None` | `[[P(1\|0), P(0\|1)], ...]` per classical bit, or `None` when unavailable. |
| `device_metadata(compiled) -> list[dict]` | Per circuit `two_qubit_gates`, `physical_qubits`, and for the first `DEVICE_CIRCUITS = 4` distinct circuits (of at most `DEVICE_CIRCUIT_MAX_OPS = 5000` operations) the transpiled circuit as `device_circuit`. |

### RemoteBackend and the executor (internal)

`qsolve.backends.remote` is the protocol by which a sandboxed workflow reaches a backend without holding any credentials. The project's contributing notes list it as internal and subject to change, so this is a summary, not a contract.

Inside the sandbox there is no network and no provider secret. `RemoteBackend(addr=None, token=None)` talks to the platform's executor over a local socket: `addr` defaults to `QSOLVE_EXECUTOR_ADDR` (`unix:<path>` or `tcp:<host>:<port>`), `token` to `QSOLVE_EXECUTOR_TOKEN`. It asks the executor for `info` and copies its `target`, `provider`, `device`, `max_qubits` and calibration. `sample` and `sample_batch` send circuits as base64 QPY, one JSON object per line, in chunks of `MAX_BATCH`; replies larger than `MAX_LINE_BYTES` (8 MiB) are refused. Executor errors become `RuntimeError("executor error: ...")`. The executor owns credentials and enforces credit holds, shot and qubit caps and writes the provenance.

`serve_executor(backend, host="127.0.0.1", port=0, *, address=None, max_shots=100_000, max_qubits=32, token=None) -> (server, address)` is the reference executor the tests use: it serves `backend` on `unix:<path>` or `tcp:<host>:<port>` and returns the server and the resolved address. The platform wraps it with metering. A request with `shots` outside `1..max_shots`, a circuit above the qubit cap or a wrong token is refused.

## qsolve.bridge

```python
from qsolve.bridge import DEFAULT_SHOTS, QsolveBackend, QsolveEstimator, QsolveSampler
```

Qiskit-native execution. A workflow written in plain Qiskit runs through these unchanged. Behind them is whatever target the run was given. Circuits are sent as the workflow built them (the platform transpiles for the device); every dispatch is recorded by the run's `Recorder` ([Metering and cost](/docs/build/sdk-metering-and-cost)). `DEFAULT_SHOTS = 1024`.

### QsolveBackend

```python
QsolveBackend(inner: Backend, recorder: Recorder, *, seed: int = 0, allow_quantum: bool = True)
```

A Qiskit `BackendV2` over a Qsolve target. It is `ctx.backend`. The harness builds it; you use it.

| Parameter | Meaning |
|---|---|
| `inner` | The Qsolve `Backend` to run on. |
| `recorder` | Where every dispatch is recorded. |
| `seed` | Base seed. The n-th circuit sent through this object gets seed `seed + n`. |
| `allow_quantum` | When false, `dispatch` (and so `run`, the sampler and the estimator) raises `QuantumNotAllowed`. The classical methods' context is built this way. |

Attributes: `name` is `qsolve-<inner.device>`, `backend_version` is `"1"`, `inner`, `recorder`, `allow_quantum`. `target` is a Qiskit `Target` with a generic gate set (`id x y z h s sdg t tdg sx sxdg rx ry rz p u cx cy cz ch swap crx cry crz cp rxx ryy rzz ecr ccx`) sized to `inner.max_qubits`. `max_circuits` is `None` (batches are split internally). Default options: `shots=1024` and `seed_simulator=None`. With `seed_simulator` set (as an option of `run`, or with `set_options`), circuit `k` of that call is seeded `seed_simulator + k`, so a call is reproducible whatever ran before it; left `None`, seeds continue from `seed` plus a running count.

#### dispatch

```python
dispatch(self, circuits: Sequence[QuantumCircuit], shots: int) -> list[SampleResult]
```

The one place circuits leave the workflow. Splits into batches of `MAX_BATCH`, copies each circuit (so what is recorded is what is sent, even if your code edits its circuit afterwards), assigns seeds, calls `inner.sample_batch`, records circuit, shots, counts, metadata and time in the `Recorder`, and adds `metadata["dispatch"]` (the recorded index) to each result. Thread-safe. Raises `QuantumNotAllowed` when `allow_quantum` is false. Called by `run`, by the sampler and by the estimator; you rarely call it directly.

#### run

```python
run(self, run_input: QuantumCircuit | Sequence[QuantumCircuit], **options) -> JobV1
```

Qiskit's `BackendV2.run`. `run_input` is one circuit or a sequence. The only option read is `shots` (default 1024). Returns an already-finished job (runs are synchronous from the workflow's point of view) whose `result()` is a Qiskit `Result` with counts per circuit, so `backend.run(qc, shots=2000).result().get_counts()` works as usual.

```python
qc = QuantumCircuit(2); qc.h(0); qc.cx(0, 1); qc.measure_all()
counts = ctx.backend.run(qc, shots=2000).result().get_counts()
```

### QsolveSampler

```python
QsolveSampler(backend: QsolveBackend, *, default_shots: int = 1024)
```

A `SamplerV2` over a `QsolveBackend`. It is `ctx.sampler`.

```python
run(self, pubs: Iterable[SamplerPubLike], *, shots: int | None = None) -> BasePrimitiveJob
```

| Parameter | Meaning |
|---|---|
| `pubs` | Sampler pubs: a circuit, or `(circuit, parameter_values, shots)` tuples. A shot count inside a pub wins over `shots`. |
| `shots` | Default shots for pubs that give none; if also `None`, `default_shots` is used. |

Returns a finished job; `.result()` is a `PrimitiveResult` with one pub result per pub. Each pub result has a data field per classical register of the circuit (so `measure_all()` gives `data.meas`), each a `BitArray`, and `metadata` with `shots`, `circuit_metadata` and `qsolve`, a list with one entry per bound circuit (in flattened parameter order) holding the backend's metadata, including `dispatch` and, on `qpu` targets, `readout` and `two_qubit_gates`. **All circuits of all pubs with the same shot count go to the backend as one batch** (on a QPU: one job, one queue wait). A result always holds exactly the requested number of shots: when a device rounds the count up (5000 asked, 5024 returned) a random subset of the requested size is kept; fewer than requested raises `ValueError`.

```python
counts = ctx.sampler.run([(measured, theta, shots)]).result()[0].data.meas.get_counts()
```

**Gotchas.** A circuit must contain measurements. Bit strings are in Qiskit order (clbit 0 on the right), so variable 0 is the last character; reverse before decoding. The result is always synchronous.

### QsolveEstimator

```python
QsolveEstimator(backend: QsolveBackend, *, default_precision: float = 0.015625)
```

An `EstimatorV2` over a `QsolveBackend`. It is `ctx.estimator`. It measures each group of qubit-wise commuting Pauli terms of an observable in its own basis and combines the results. **No error mitigation is applied.**

```python
run(self, pubs: Iterable[EstimatorPubLike], *, precision: float | None = None) -> BasePrimitiveJob
```

| Parameter | Meaning |
|---|---|
| `pubs` | Estimator pubs: `(circuit, observables, parameter_values)`. |
| `precision` | Target standard error of each value. Defaults to `default_precision` (0.015625, which is 4096 shots per measurement group). A pub's own precision wins. |

For each value the shots per group are `ceil(1 / precision**2)`. Returns a finished job; each pub result holds `data.evs` and `data.stds` (arrays of the broadcast shape) and `metadata["target_precision"]`. **Cost note:** every parameter set and every observable is a separate dispatch with one circuit per basis group, so many observables or tight precision quickly mean many circuits and shots; the run is billed for what is sent, and `estimate()` should account for it.
