Implementing a Custom Backend¶
The mimiqcircuits.backends module defines a small, explicit
contract that every simulator must satisfy. Writing a new backend
means declaring what your simulator can do, providing a handful of
primitives, and (optionally) overriding the high-level
execute() loop for performance.
This guide walks through the contract end-to-end and shows complete
working examples for both in-process simulators
(LocalBackend) and remote services
(RemoteBackend).
When to subclass which base¶
Base class |
Use when |
|---|---|
The simulator runs in the same Python process and exposes a state object you can mutate instruction-by-instruction. |
|
The simulator runs elsewhere (cloud service, queued executor) and you submit a request and poll for results. |
|
You need full control of |
Every backend must implement the small set of methods on
Backend:
Member |
Returns |
Default |
|---|---|---|
|
|
abstract |
|
|
abstract |
|
|
abstract |
|
all- |
|
|
||
|
|
always |
|
|
inherited from base subclass |
The full contract — including capability tokens, the Fidelity ADT, and pass-pipeline plumbing — is described in Conformance checklist at the end of this page.
Declaring capabilities honestly¶
A backend’s capabilities() method returns a set of capability
tokens drawn from
CAPABILITY_VOCABULARY. Each token is
a positive claim: “this backend can execute circuits that exercise
this feature”.
Do not advertise a capability your simulator cannot actually deliver. The conformance suite verifies that every declared capability has a working code path, and every undeclared capability is rejected — silent degradation lets bugs slip through.
A representative subset of the vocabulary:
Token |
Meaning |
|---|---|
|
The backend can compute amplitudes |
|
The backend can sample bitstrings from the final state. |
|
Measurements may appear mid-circuit, not only at the end. |
|
|
|
|
|
Trace-preserving Kraus channels are supported. |
|
Non-trace-preserving channels (e.g. amplitude damping with explicit loss) are supported. |
|
|
|
|
|
The backend implements
|
|
Tensor-network annotations are supported (MPS-class backends). |
|
The circuit is compressed lazily; peak memory is bounded even for very long circuits. |
|
Compile accepts circuits with free symbolic parameters
( |
A simulator that renormalises after every Kraus step (state-vector
sims do this) should not declare "loss": the surviving
trajectory probability is silently discarded and the user would
get a fidelity of 1.0 for a circuit that actually lost
amplitude. Declaring only "noise" is the honest choice.
The Fidelity ADT¶
evolve() must return a
typed Fidelity, not a plain float.
The variants record what the number means:
Variant |
When to return it |
|---|---|
Your simulator is exact under its own algorithm (state-vector without lossy noise). |
|
You genuinely do not track fidelity. Better than inventing a placeholder. |
|
Single scalar lower bound on
|
|
Per-step contributions. The product is a lower bound only if
successive truncation errors are independent; collapse to
|
|
Sample-based estimate with a standard error (randomised benchmarking, direct fidelity estimation, …). |
Common trap — the 1.0 collision:
Warning
A truncation-lower-bound that happens to land at exactly
1.0 (small circuit, fits in the bond budget) must still be
wrapped as
TruncationLowerBound, not
ExactFidelity. The two carry
different semantics; do not route through
_to_fidelity(1.0), which collapses to
ExactFidelity.
A worked example: writing a LocalBackend¶
The simplest possible custom backend wraps a Python-side simulator function. The example below shows every required piece.
from typing import Optional
import random
from mimiqcircuits import Circuit, QCSResults, BitString
from mimiqcircuits.backends import (
AllToAll,
Capability,
CompileMetadata,
CompiledCircuit,
DefaultCompiledCircuit,
ExactFidelity,
Fidelity,
Limits,
LocalBackend,
State,
Topology,
)
class _ToyState(State):
"""Minimal `State` for a 1-shot, sample-only simulator."""
def __init__(self, nq: int, nb: int, nz: int):
self._nq = nq
self.c = [0] * nb
self.z = [complex(0)] * nz
@property
def num_qubits(self) -> int:
return self._nq
@property
def num_bits(self) -> int:
return len(self.c)
@property
def num_zvars(self) -> int:
return len(self.z)
def amplitude(self, bs) -> complex:
# Trivial uniform distribution → all amplitudes equal.
return complex(1.0 / (2 ** self._nq) ** 0.5)
def sample(self, nsamples: int,
rng: Optional[random.Random] = None, *,
seed: Optional[int] = None) -> list:
if rng is not None and seed is not None:
raise TypeError("pass either rng= or seed=, not both")
if rng is None:
rng = random.Random(seed) # `seed=None` → fresh entropy.
return [
BitString([rng.randint(0, 1) for _ in range(self._nq)])
for _ in range(nsamples)
]
@property
def classical_bits(self):
return self.c
@property
def complex_values(self):
return self.z
_TOY_CAPS: frozenset[str] = frozenset({"sampling", "classical_bits"})
class ToySimulator(LocalBackend):
"""Sample-only simulator: returns uniformly random bitstrings.
Useful as a control in tests and as a template for real backends.
"""
@property
def name(self) -> str:
return "Toy"
@property
def version(self) -> str:
return "0.1.0"
def capabilities(self) -> set[Capability]:
return set(_TOY_CAPS)
def limits(self) -> Limits:
return Limits()
def topology(self) -> Topology:
return AllToAll()
def build_state(self, nq: int, nb: int = 0, nz: int = 0,
**kwargs) -> _ToyState:
return _ToyState(nq, nb, nz)
def compile(self, circuit: Circuit) -> CompiledCircuit:
meta = CompileMetadata(
active_qubits=list(range(circuit.num_qubits()))
)
return DefaultCompiledCircuit(_source=circuit, _metadata=meta)
def evolve(self, state, compiled, *,
rng=None, callback=None, stopped=None
) -> tuple[State, Fidelity]:
# Toy simulator: no real evolution.
return state, ExactFidelity()
The four blocks above — identity, advertisement, state construction,
and the compile/evolve pair — are everything
LocalBackend needs to drive its
default execute() loop:
Run the user-supplied pass pipeline over the circuit.
Call
compile()once.Call
prepare_trajectory()per trajectory (default identity).Allocate a fresh state via
build_state().Call
evolve()to mutate the state.Sample
nsamplesbitstrings via the state’ssample.Wrap everything in a
QCSResults.
Override execute() directly only when you need richer outputs
(per-shot amplitudes, expectation values, multi-circuit batching),
or when the per-shot cost is so low that the default loop’s
overhead matters.
Compile must be pure¶
compile() must not
consume any RNG and must not sample anything. Round-tripping the
same (backend, circuit) must produce equivalent compiled
artifacts. This is what lets generic drivers re-use the compiled
output across many trajectories or many parameter points.
If your simulator needs to sample noise (mixed-unitary,
trace-preserving Kraus) or build a stochastic lossy suffix, put
that work in prepare_trajectory(), which is called once per
trajectory with a dedicated RNG.
Optional: a parametric fast path¶
Declare the "parametric" capability if you can compile circuits
that still carry free symbolic parameters. The default
bind() substitutes
parameters and re-runs compile(); override when your backend
can re-bind a pre-compiled artifact (slot maps, pre-baked gate
templates) without paying the full compile cost again.
A worked example: writing a RemoteBackend¶
For a service that exposes a submit / poll API, subclass
RemoteBackend:
from mimiqcircuits.backends import (
AllToAll,
Limits,
RemoteBackend,
Topology,
)
_REMOTE_CAPS = frozenset({
"amplitude", "sampling", "classical_bits",
"midcircuit_measure", "feed_forward", "noise",
})
class _JobHandle:
"""Minimal job handle.
Must expose ``wait(timeout=None)`` returning a
:class:`~mimiqcircuits.QCSResults` (or a list thereof). The
``timeout`` kwarg is part of the production contract — raise
:class:`TimeoutError` rather than hang on a stuck server.
"""
def __init__(self, conn, request_id):
self._conn = conn
self._request_id = request_id
def wait(self, *, timeout=None):
return self._conn.get_results(
self._request_id, timeout=timeout
)
class MyCloudBackend(RemoteBackend):
"""Cloud backend wrapping a generic submit/poll connection."""
def __init__(self, connection, *, algorithm="auto"):
self._connection = connection
self.algorithm = algorithm
@property
def name(self) -> str:
return "MyCloud"
@property
def version(self) -> str:
return "0.1.0"
def capabilities(self) -> set[str]:
return set(_REMOTE_CAPS)
def limits(self) -> Limits:
return Limits(max_samples=1_000_000)
def topology(self) -> Topology:
return AllToAll()
def submit(self, circuits, nsamples=1000, **kwargs):
request_id = self._connection.submit(
circuits,
algorithm=self.algorithm,
nsamples=nsamples,
**kwargs,
)
return _JobHandle(self._connection, request_id)
The inherited execute() calls submit() and then
job.wait(). Override execute() only when you need
extra steps before or after the round-trip (re-typing fidelities,
shape-mirroring single circuit vs. list inputs, etc.).
Three things to watch for in a remote wrapper:
Capability honesty. Synthesise the capability set from server features you can actually deliver through the wire format. If the server takes a flat bag of booleans instead of an ordered pipeline, you cannot honestly advertise
"pass_order_honored"— and the framework will raiseRemotePassOrderErrorfor users who try.Typed fidelities on the wire. Servers usually return raw floats. Re-wrap each result’s
fidelitiesinto the appropriateFidelitysubclass based on the simulator identity string, so downstream code sees the same ADT it would from a local backend.``seed=`` / ``rng=``. Accept both, mutually exclusive — the inherited
Backend._resolve_rngs()handles the validation.
Working with passes¶
Backends interact with the pass pipeline through three optional methods:
accepts_pass()— returnFalseto reject a pass the backend cannot run. The pipeline will raiseUnacceptedPassErrorrather than silently dropping the pass.delegates_pass()— returnTrueif your backend implements the pass natively insidecompile()orevolve(). The pipeline records a marker result but does not run the pass.default_passes()— return the pipeline the backend wants applied when the caller passespasses=None.
Custom passes subclass
AbstractPass. A minimal example:
from mimiqcircuits import Circuit
from mimiqcircuits.backends import (
AbstractPass,
PassContext,
PassResult,
PassSpec,
)
class StripBarriersPass(AbstractPass):
"""Drop every Barrier from the circuit."""
def spec(self) -> PassSpec:
return PassSpec("strip_barriers")
def apply(self, ctx: PassContext, circuit
) -> tuple[Circuit, PassResult]:
from mimiqcircuits import Barrier
new_c = Circuit()
for inst in circuit.instructions:
if not isinstance(inst.operation, Barrier):
new_c.push(inst.operation, *inst.qubits,
*inst.bits, *inst.zvars)
return new_c, PassResult()
A pass that renames qubits must return the relabel as
PassResult.qubit_permutation so the pipeline can compose
permutations and unscramble downstream samples and observables.
Conformance checklist¶
Before shipping a new backend, walk through the following list. Each item corresponds to an assertion the conformance suite makes; failing one means the backend is “lying” in some way the framework can detect.
Identity and advertisement
namereturns a non-empty string.versionreturns a parseable version string.capabilities()returns asetof strings; every entry is inCAPABILITY_VOCABULARY(extras are allowed but trigger a warning).limits()returns aLimitsinstance. By convention each numeric field is eitherNone(no advertised bound) or strictly positive.topology()returns one ofAllToAll,CouplingMap,LinearChain.
Admission
can_handle()returnsAdmissible(orMarginal, whichis_admissible()also treats as accepted) for a small circuit the backend can actually run.can_handle()returnsInadmissible(orexecute()raises) for every capability the backend does not advertise.
LocalBackend primitives
build_state()returns aState-derived object with the requested register sizes.compile()is pure — no RNG, no sampling. Two consecutive calls on the same input produce equivalent artifacts.evolve()returns a tuple(state, fidelity)wherefidelityis aFidelitysubclass.A truncation-lower-bound of exactly
1.0is wrapped asTruncationLowerBound, notExactFidelity.If the backend declares
"parametric",compile()accepts a symbolic circuit andbind()resolves the parameters to a fully boundCompiledCircuit.
RemoteBackend primitives
submit()returns a job handle with a workingwait().Results’ fidelities are re-typed into
Fidelitysubclasses based on the simulator identity string.If the backend does not advertise
"pass_order_honored", a non-empty pipeline with defaultstrict_pass_order=Truemust raiseRemotePassOrderError.
Cross-cutting
The backend does not advertise any capability it cannot deliver.
Two
executecalls with the sameseed=produce identical results (within the algorithm’s own numerical tolerance).Passing both
seed=andrng=simultaneously raisesTypeError— the two are mutually exclusive.A non-empty
param_grid=iterates the circuit through Python substitution and runsexecuteper parameter point with a distinct derived seed (seederive_grid_seeds()).
See also¶
API Reference for the full API reference of the
mimiqcircuits.backendsmodule.Simulation & Execution for using the backends as a consumer rather than implementing one.
Circuit Compilation & Optimization for the built-in pass set.