# SDK: metering and cost

> Reference for qsolve.metering and qsolve.sim: how the SDK records what crosses the backend, how fidelity and circuit size are measured, and how Estimate and run-time prediction feed the price quote.

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

Qsolve does not take your word for how big or how faithful your quantum run was. Everything your quantum method sends to a backend passes through a recorder, and the numbers are computed from that record. This page documents the recorder, the measurement functions, the pre-run estimate and the run-time model. Back to the [SDK reference](/docs/build/sdk-reference).

Most of this is harness machinery: as a workflow author you call `ctx.report_mitigation` and return an `Estimate`; the rest you read to understand what the platform measures. For the concepts see [Error mitigation and fidelity](/docs/build/error-mitigation-and-fidelity) and [Benchmarks](/docs/build/benchmarks).

## qsolve.metering

```python
from qsolve import metering
```

### Constants

| Name | Value | Meaning |
|---|---|---|
| `IDEAL_MAX_QUBITS` | `20` | Largest circuit whose ideal output is simulated (exactly, noise-free). Bigger circuits cannot be scored. |
| `MAX_IDEAL_CIRCUITS` | `200` | Distinct unreported circuits simulated for fidelity, spread evenly over the run. |
| `MAX_STATS_CIRCUITS` | `40` | Distinct circuits transpiled for depth and gate counts: the 20 largest, then an even spread of the rest, so a workflow cannot hide its deepest circuit among many small ones. |
| `BASIS` | `["cx", "rz", "sx", "x"]` | The gate set circuits are transpiled to for depth and two-qubit counts. |

### Dispatch

```python
@dataclass
class Dispatch:
    index: int
    method: str
    circuit: QuantumCircuit
    shots: int
    counts: dict[str, int]
    metadata: dict[str, Any]
    seconds: float
```

One circuit that crossed the backend boundary. `index` is its position in the run's record (the number you cite in `report_mitigation`), `method` the id of the manifest method that sent it, `circuit` the copy that was sent, `counts` and `metadata` what came back, `seconds` the backend time.

### MitigationReport

```python
@dataclass
class MitigationReport:
    circuits: list[int]
    distribution: dict[str, float]
    methods: list[str]
    kept: float | None
    steps: list[dict[str, Any]]
```

What a workflow declared through `ctx.report_mitigation`, after validation.

### Recorder

```python
@dataclass
class Recorder:
    dispatches: list[Dispatch]
    reports: list[MitigationReport]
    method: str = "quantum"
    lock: threading.Lock
```

Everything that crossed the backend boundary during one run. One is created per `run_workflow`.

| Member | Meaning |
|---|---|
| `add(circuit, shots, counts, metadata, seconds) -> int` | Appends a `Dispatch` tagged with the current `method` and returns its index. Thread-safe. Called by `QsolveBackend.dispatch`. |
| `of(method) -> list[Dispatch]` | The dispatches sent by one method id. |
| `report(circuits, distribution, methods, kept, steps) -> None` | Stores a `MitigationReport`. Called by `ctx.report_mitigation` after it has validated the arguments. |
| `method` | The method currently running; the comparison sets it before each method, which is how a dispatch is attributed. |

```python
rec = metering.Recorder()
backend = QsolveBackend(AerBackend(), rec)       # see the bridge page
backend.run(qc, shots=100)
print(len(rec.dispatches), rec.of("quantum")[0].counts)
```

### fidelity and shot_noise_floor

```python
fidelity(p: dict[str, float], q: dict[str, float]) -> float
shot_noise_floor(ideal: dict[str, float], shots: int, seed: int) -> float
```

`fidelity` is the classical (Bhattacharyya) fidelity `F = (sum_i sqrt(p_i q_i))^2` of two distributions over bit strings. Both are normalised inside the function; spaces in bit-string keys are ignored; non-positive weights are dropped; it returns 0.0 if either side has no weight. 1 means identical, 0 disjoint.

`shot_noise_floor` draws `shots` noise-free samples from `ideal` (multinomial, seeded) and returns their fidelity with `ideal`. With finite shots even a perfect device scores below 1; this is that score, reported as `fidelity_floor` next to every figure.

### circuit_key

```python
circuit_key(qc: QuantumCircuit) -> tuple[Any, ...]
```

A hashable identity for a bound circuit: width, and per operation its name, qubit and clbit positions, parameter values (rounded to 12 places; arrays by shape and bytes) and, for non-standard operations such as Pauli-evolution gates, the identity of their decomposition. Two dispatches with equal keys are the same circuit; their counts are pooled before scoring.

### ideal_distribution

```python
ideal_distribution(qc: QuantumCircuit) -> dict[str, float] | None
```

The noise-free output distribution over the circuit's classical bits (bit string with classical bit 0 on the right; probabilities above 1e-14 only), or `None` when it cannot be simulated exactly: more than 20 qubits, no qubits, mid-circuit measurement (a gate after a measurement), `reset`, `delay`, `if_else`, `while_loop`, `for_loop`, `switch_case`, a classical condition, or classical bits that are never measured. Barriers are ignored. Uses a statevector simulation.

### active_qubits

```python
active_qubits(qc: QuantumCircuit) -> int
```

The number of distinct qubits the circuit's operations touch, barriers excluded. (Different from `qsolve.circuit_record.active_qubits`, which returns the sorted list of qubit indices.)

### circuit_stats

```python
circuit_stats(dispatches: list[Dispatch]) -> CircuitStats | None
```

The measured size of what was sent: `qubits` (the widest circuit by active qubits), `depth` and `two_qubit_gates` (the maxima over a sample of at most 40 distinct circuits transpiled to `BASIS` at optimisation level 1, the `cx` count being the two-qubit count), `circuits` (the number of dispatches) and `shots` (the sum of shots over all of them). `None` when there are no dispatches. The platform's `max_two_qubit_gates` bound in a benchmark case is checked against this number.

### mitigation

```python
mitigation(rec: Recorder, method: str, source: str) -> Mitigation | None
```

Raw fidelity from the recorded circuits, mitigated fidelity from the workflow's reports. Returns `None` when the method sent nothing or none of its circuits can be scored.

Which circuits count:

1. Circuits a valid report covers, and every other circuit whose ideal output is not a single bit string. A circuit whose output is a single bit string (a mirror or calibration circuit, an idle register) computes nothing the answer depends on and is left out; if every circuit is like that, they are scored anyway. A circuit left unreported that does compute something counts at its raw fidelity, so reporting a flattering subset changes nothing.
2. Of those, only circuits with at least half the two-qubit operations (after opening composite gates) of the largest. A trivial circuit sent alongside carries no weight.
3. A report is used only when all the circuits it cites share one ideal output and were sent by this method.
4. Circuits that cannot be scored are left out; `coverage` is the share of the method's shots that could be scored.

The figures are shot-weighted averages. With no usable report, `fidelity_mitigated` equals `fidelity_raw`, `postselection_rate` is 1.0, `methods` is empty and `reported` is false. With reports, `fidelity_mitigated` is the fidelity of your reported distribution against the ideal output (circuits without a report contribute their raw fidelity), `postselection_rate` is the mean of your `kept` values (1 where omitted), `methods` the union of your names, `reported` is true and `details["steps"]` your ladder.

`source` is stored as `Mitigation.source`: `hardware`, `noise_model` or `ideal_simulator`. `run_workflow` derives it from the backend: any non-`qpu` target is `ideal_simulator`; a `qpu` whose device name starts with `fake` is `noise_model`; otherwise `hardware`.

### quality

```python
quality(rec: Recorder, method: str, source: str) -> Quality
```

`Quality(mitigation=mitigation(...), circuit=circuit_stats(rec.of(method)))`. Each part is `None` when nothing was sent or scorable. This is what `run_workflow` stores in `result.quality`, for the quantum method.

## Estimate

`Estimate` is defined in `qsolve.results` and exported from `qsolve`; it is the one metering type you construct. Your `Workflow.estimate(target)` returns it.

```python
class Estimate(BaseModel):
    qubits: int
    circuits_on_target: int
    shots_on_target: int
    classical_cost_class: str = "low"
    runtime_s: float | None = None     # at least 0
    notes: list[str] = []
    by_default: bool = False
```

| Field | Meaning |
|---|---|
| `qubits` | Qubits the run uses. |
| `circuits_on_target` | Circuits submitted to the chosen backend over the whole run. |
| `shots_on_target` | Total shots across those circuits. |
| `classical_cost_class` | `"low"`, `"medium"` or `"high"`: local CPU work in the sandbox. Not validated by the model; use those three values. |
| `runtime_s` | Predicted seconds for the whole run on one reference core per simulator thread, excluding queue time at a hardware provider. `None` means not predicted. Must be at least 0. The runner adds `RUN_OVERHEAD_S` (20 s). The platform refuses a run whose prediction exceeds `resources.timeout_s` (a platform rule; the SDK itself does not check it). |
| `notes` | Free text shown with the quote. |
| `by_default` | Set by the SDK, never by you: true when the workflow gave no estimate and the SDK used one circuit and `params["shots"]` shots. |

What the estimate is used for:

- **The price quote and credit hold.** The platform turns the numbers into credits ([Credits and pricing](/docs/use/credits-and-pricing)).
- **The `estimate_honest` gate.** `run_benchmarks` compares your estimate with what the first benchmark case really sent. Circuits and shots must each be within a factor of two (`0.5 <= estimated / actual <= 2`). With no estimate it passes with a note; if no circuits were sent it passes.

```python
def estimate(self, target):
    return Estimate(qubits=self.n, circuits_on_target=1,
                    shots_on_target=int(self.params["shots"]), classical_cost_class="low")
```

Count every circuit your quantum method sends, including one per measurement basis and per parameter set; the Estimator primitive sends one circuit per basis group per value.

## qsolve.sim

```python
from qsolve.sim import (RUN_OVERHEAD_S, S_PER_CIRCUIT, S_PER_GATE_AMPLITUDE, S_PER_SHOT,
                        S_PER_TRANSPILE, sim_threads, simulator_threads, statevector_seconds)
```

Simulator threading and run-time prediction. Aer updates each amplitude of a statevector independently, so a simulation gives bit-identical results at any thread count; simulations may therefore use every core the run was given, while BLAS and other OpenMP code stay on one thread, where summation order could change the result.

### Constants (seconds, for one core of a current server CPU)

| Name | Value | Meaning |
|---|---|---|
| `S_PER_GATE_AMPLITUDE` | `1.8e-9` | One gate applied to one amplitude. |
| `S_PER_CIRCUIT` | `0.012` | Aer call, result decoding and Python around one circuit. |
| `S_PER_SHOT` | `2e-6` | Drawing one shot and decoding it into counts. |
| `S_PER_TRANSPILE` | `0.15` | Transpiling a circuit the simulator cannot run as written. |
| `RUN_OVERHEAD_S` | `20.0` | Sandbox start, imports, classical baseline and interpretation. |

The platform scales the prediction to its own machines. The private constants `_PARALLEL_FROM_QUBITS = 15` (Aer parallelises statevector updates from 15 qubits up) and `_THREAD_EFFICIENCY = 0.7` (measured: 2 threads run 20 to 24 qubit circuits 1.6 to 1.7 times faster) are used by `statevector_seconds`.

### sim_threads

```python
sim_threads() -> int
```

Threads a simulation may use: the positive integer in `QSOLVE_SIM_THREADS` (set by the platform to the sandbox's CPUs) if there is one, otherwise the number of cores this process may run on (`os.sched_getaffinity`, else `os.cpu_count()`), at least 1.

### simulator_threads

```python
@contextmanager
simulator_threads() -> Iterator[None]
```

A context manager that lets OpenMP (Aer) use `sim_threads()` threads for the duration of the block, via `threadpoolctl`. Used by `AerBackend.sample`. If you run your own `AerSimulator` calls inside a method, wrap them in it to use the cores the run was given:

```python
from qsolve.sim import simulator_threads
with simulator_threads():
    counts = sim.run(qc, shots=1000).result().get_counts()
```

### statevector_seconds

```python
statevector_seconds(qubits: int, gates: int, circuits: int = 1, shots: int = 0,
                    threads: int | None = None) -> float
```

Predicted seconds to simulate `circuits` circuits of `gates` gates on `qubits` qubits, drawing `shots` shots from each.

| Parameter | Meaning |
|---|---|
| `qubits` | Circuit width. |
| `gates` | Gates per circuit. |
| `circuits` | Number of circuits. Default 1. |
| `shots` | Shots drawn from each circuit. Default 0. |
| `threads` | Threads to assume; default `sim_threads()`. |

The model: `work = S_PER_GATE_AMPLITUDE * gates * 2**qubits`; for 15 or more qubits and more than one thread, `work /= 1 + 0.7 * (threads - 1)`; the result is `circuits * (work + S_PER_CIRCUIT + S_PER_SHOT * shots)`. It is an estimate for use in `Estimate.runtime_s`, not a measurement. Add `S_PER_TRANSPILE` per circuit the simulator cannot run as written, and the time of any classical baseline you run.

```python
Estimate(qubits=n, circuits_on_target=1, shots_on_target=shots,
         runtime_s=statevector_seconds(n, gates=400, circuits=1, shots=shots))
```
