Skip to content

Core

The circuit IR, gates, observables, execution and backends. Everything else in the library reads or writes these types.

qmlkit.core.ir

ir

The backend-neutral circuit IR.

A circuit is data: a list of :class:Op. Backends compile it; gradients read it; resource counting and drawing read it. One representation, and every downstream capability falls out of it.

The piece that matters most for correctness is the slot abstraction. A circuit has n_params logical parameters, but those map onto slots — one per (operation, parameter position). A single logical parameter may fill several slots (weight tying, as in a QCNN's shared convolution block). The parameter-shift rule must shift one slot at a time and sum the results; shifting every occurrence together computes a different derivative entirely. Making slots explicit here is what keeps that from being a subtle, silent bug in the gradient code.

ParamRef dataclass

ParamRef(
    index: int, scale: float = 1.0, offset: float = 0.0
)

A reference to logical parameter index, optionally linearly rescaled.

scale and offset let one logical parameter drive a gate angle of scale * theta[index] + offset without introducing a new parameter. The chain rule for that is handled in the gradient code.

Op dataclass

Op(
    gate: str,
    qubits: tuple[int, ...],
    params: tuple[ParamLike, ...] = (),
)

One gate application.

Slot dataclass

Slot(
    op_index: int, param_pos: int, ref: ParamRef, gate: str
)

One concrete (operation, parameter-position) angle site.

CircuitSpec dataclass

CircuitSpec(
    n_qubits: int,
    ops: tuple[Op, ...] = (),
    n_params: int = 0,
)

An immutable circuit description.

compose
compose(
    other: CircuitSpec, param_offset: int | None = None
) -> CircuitSpec

Concatenate other after self.

By default the two parameter vectors are concatenated, so the composed circuit has self.n_params + other.n_params parameters. Pass param_offset=0 to share the parameter vector instead.

Source code in src/qmlkit/core/ir.py
def compose(self, other: CircuitSpec, param_offset: int | None = None) -> CircuitSpec:
    """Concatenate ``other`` after ``self``.

    By default the two parameter vectors are concatenated, so the composed
    circuit has ``self.n_params + other.n_params`` parameters. Pass
    ``param_offset=0`` to *share* the parameter vector instead.
    """
    shift = self.n_params if param_offset is None else param_offset
    ops = list(self.ops)
    for op in other.ops:
        ops.append(
            Op(
                op.gate,
                op.qubits,
                tuple(
                    ParamRef(p.index + shift, p.scale, p.offset)
                    if isinstance(p, ParamRef)
                    else p
                    for p in op.params
                ),
            )
        )
    return CircuitSpec(
        n_qubits=max(self.n_qubits, other.n_qubits),
        ops=tuple(ops),
        n_params=max(self.n_params, shift + other.n_params),
    )
adjoint
adjoint() -> CircuitSpec

Reverse the circuit and invert every gate — the U†(x) of an inversion test.

Source code in src/qmlkit/core/ir.py
def adjoint(self) -> CircuitSpec:
    """Reverse the circuit and invert every gate — the U†(x) of an inversion test."""
    ops: list[Op] = []
    for op in reversed(self.ops):
        g = get_gate(op.gate)
        if g.is_parametric:
            ops.append(
                Op(
                    op.gate,
                    op.qubits,
                    tuple(
                        ParamRef(p.index, -p.scale, -p.offset)
                        if isinstance(p, ParamRef)
                        else -float(p)
                        for p in op.params
                    ),
                )
            )
        elif g.adjoint_name is not None:
            ops.append(Op(g.adjoint_name, op.qubits))
        else:
            ops.append(op)  # self-inverse
    return CircuitSpec(self.n_qubits, tuple(ops), self.n_params)
slots
slots() -> tuple[Slot, ...]

Every parameterised angle site, in circuit order.

Source code in src/qmlkit/core/ir.py
def slots(self) -> tuple[Slot, ...]:
    """Every parameterised angle site, in circuit order."""
    out: list[Slot] = []
    for i, op in enumerate(self.ops):
        for pos, p in enumerate(op.params):
            if isinstance(p, ParamRef):
                out.append(Slot(i, pos, p, op.gate))
    return tuple(out)
occurrences_of
occurrences_of(param_index: int) -> tuple[Slot, ...]

Slots driven by logical parameter param_index (>1 means weight tying).

Source code in src/qmlkit/core/ir.py
def occurrences_of(self, param_index: int) -> tuple[Slot, ...]:
    """Slots driven by logical parameter ``param_index`` (>1 means weight tying)."""
    return tuple(s for s in self.slots() if s.ref.index == param_index)
bind_slots
bind_slots(theta: ArrayLike) -> NDArray[Any]

Resolve the logical parameter vector into one angle per slot.

Source code in src/qmlkit/core/ir.py
def bind_slots(self, theta: ArrayLike) -> npt.NDArray[Any]:
    """Resolve the logical parameter vector into one angle per slot."""
    arr = np.asarray(theta, dtype=float).ravel()
    if arr.size != self.n_params:
        raise ValueError(f"expected {self.n_params} parameters, got {arr.size}")
    return np.array([s.ref.resolve(arr) for s in self.slots()], dtype=float)
bind_slots_batch
bind_slots_batch(thetas: ArrayLike) -> NDArray[Any]

Resolve many logical parameter vectors into slot angles at once.

(batch, n_params) -> (batch, n_slots), with no Python loop over the batch: the slot map is a gather plus an affine transform, so it is three NumPy operations however large the batch is. Doing this row by row would put an O(batch x n_slots) interpreter loop in front of every batched evaluation and give most of the batching win straight back.

Source code in src/qmlkit/core/ir.py
def bind_slots_batch(self, thetas: ArrayLike) -> npt.NDArray[Any]:
    """Resolve many logical parameter vectors into slot angles at once.

    ``(batch, n_params) -> (batch, n_slots)``, with no Python loop over the batch:
    the slot map is a gather plus an affine transform, so it is three NumPy
    operations however large the batch is. Doing this row by row would put an
    ``O(batch x n_slots)`` interpreter loop in front of every batched evaluation
    and give most of the batching win straight back.
    """
    values = np.atleast_2d(np.asarray(thetas, dtype=float))
    if values.shape[1] != self.n_params:
        raise ValueError(f"expected {self.n_params} parameters, got {values.shape[1]}")
    slots = self.slots()
    if not slots:
        return np.zeros((values.shape[0], 0), dtype=float)
    index = np.fromiter((s.ref.index for s in slots), dtype=int, count=len(slots))
    scale = np.fromiter((s.ref.scale for s in slots), dtype=float, count=len(slots))
    offset = np.fromiter((s.ref.offset for s in slots), dtype=float, count=len(slots))
    angles: npt.NDArray[Any] = values[:, index] * scale + offset
    return angles
with_slot_angles
with_slot_angles(angles: ArrayLike) -> CircuitSpec

Return a fully-bound copy in which every slot takes a literal angle.

Source code in src/qmlkit/core/ir.py
def with_slot_angles(self, angles: ArrayLike) -> CircuitSpec:
    """Return a fully-bound copy in which every slot takes a literal angle."""
    arr = np.asarray(angles, dtype=float).ravel()
    slots = self.slots()
    if arr.size != len(slots):
        raise ValueError(f"expected {len(slots)} slot angles, got {arr.size}")
    by_op: dict[int, dict[int, float]] = {}
    for value, s in zip(arr, slots, strict=False):
        by_op.setdefault(s.op_index, {})[s.param_pos] = float(value)
    ops: list[Op] = []
    for i, op in enumerate(self.ops):
        if i in by_op:
            overrides = by_op[i]
            ops.append(
                Op(
                    op.gate,
                    op.qubits,
                    tuple(
                        overrides.get(pos, p if not isinstance(p, ParamRef) else 0.0)
                        for pos, p in enumerate(op.params)
                    ),
                )
            )
        else:
            ops.append(op)
    return CircuitSpec(self.n_qubits, tuple(ops), 0)
bind
bind(theta: ArrayLike | None = None) -> CircuitSpec

Fully bind the circuit with a logical parameter vector.

Source code in src/qmlkit/core/ir.py
def bind(self, theta: ArrayLike | None = None) -> CircuitSpec:
    """Fully bind the circuit with a logical parameter vector."""
    if self.n_params == 0:
        return self
    if theta is None:
        raise ValueError(f"circuit has {self.n_params} parameters; theta is required")
    return self.with_slot_angles(self.bind_slots(theta))
depth
depth() -> int

Circuit depth: the longest chain of gates sharing a qubit.

Source code in src/qmlkit/core/ir.py
def depth(self) -> int:
    """Circuit depth: the longest chain of gates sharing a qubit."""
    frontier = [0] * self.n_qubits
    for op in self.ops:
        layer = max(frontier[q] for q in op.qubits) + 1
        for q in op.qubits:
            frontier[q] = layer
    return max(frontier, default=0)

bound_angle

bound_angle(
    param: ParamLike, context: str = "this circuit"
) -> float

A gate parameter as a number, refusing one that is still a reference.

Anything reading angles off a circuit - a drawer, a decomposition, a shadow - needs the circuit bound. float(ParamRef(0)) raises a TypeError about __float__ that says nothing about circuits, so this says it instead.

Source code in src/qmlkit/core/ir.py
def bound_angle(param: ParamLike, context: str = "this circuit") -> float:
    """A gate parameter as a number, refusing one that is still a reference.

    Anything reading angles off a circuit - a drawer, a decomposition, a shadow -
    needs the circuit bound. ``float(ParamRef(0))`` raises a ``TypeError`` about
    ``__float__`` that says nothing about circuits, so this says it instead.
    """
    if isinstance(param, ParamRef):
        raise ValueError(
            f"{context} still has free parameters; call spec.bind(theta) before reading its angles"
        )
    return float(param)

concat

concat(specs: Iterable[CircuitSpec]) -> CircuitSpec

Compose a sequence of circuits left to right.

Source code in src/qmlkit/core/ir.py
def concat(specs: Iterable[CircuitSpec]) -> CircuitSpec:
    """Compose a sequence of circuits left to right."""
    it = iter(specs)
    try:
        out = next(it)
    except StopIteration:
        raise ValueError("concat() needs at least one circuit") from None
    for s in it:
        out = out.compose(s)
    return out

qmlkit.core.gates

gates

Gate registry: matrices, adjoints, and — critically — generator frequencies.

The frequencies field is what keeps the parameter-shift rule correct. A gate of the form exp(-i θ G / 2) has a derivative determined entirely by the set of unique positive differences between the eigenvalues of its generator. Declare that set and :mod:qmlkit.gradients derives the right shift rule automatically; omit it and differentiation of that gate is refused rather than silently wrong.

GateDef dataclass

GateDef(
    name: str,
    n_qubits: int,
    n_params: int,
    matrix: Callable[..., Matrix],
    frequencies: tuple[float, ...] = (),
    dmatrix: Callable[..., Matrix] | None = None,
    adjoint_name: str | None = None,
    aliases: tuple[str, ...] = (),
)

Everything the library needs to know about one gate.

has_derivative property
has_derivative: bool

True if the exact derivative matrix is known (adjoint differentiation).

register_gate

register_gate(gate: GateDef) -> GateDef

Add a gate to the registry. Re-registering the same name is an error.

Source code in src/qmlkit/core/gates.py
def register_gate(gate: GateDef) -> GateDef:
    """Add a gate to the registry. Re-registering the same name is an error."""
    for key in (gate.name, *gate.aliases):
        if key in _REGISTRY:
            raise ValueError(f"gate {key!r} is already registered")
    for key in (gate.name, *gate.aliases):
        _REGISTRY[key] = gate
    return gate

gate_derivative

gate_derivative(
    name: str, params: Sequence[float] = ()
) -> Matrix

Exact d(matrix)/d(theta) for a one-parameter gate.

Source code in src/qmlkit/core/gates.py
def gate_derivative(name: str, params: Sequence[float] = ()) -> Matrix:
    """Exact d(matrix)/d(theta) for a one-parameter gate."""
    g = get_gate(name)
    if g.dmatrix is None:
        raise ValueError(
            f"gate {name!r} has no derivative matrix, so it cannot be differentiated by "
            "the adjoint method. Register it with dmatrix=..., or use "
            'grad_method="parameter-shift".'
        )
    if len(params) != g.n_params:
        raise ValueError(f"gate {name!r} takes {g.n_params} parameter(s), got {len(params)}")
    return g.dmatrix(*params)

gate_matrix

gate_matrix(
    name: str, params: Sequence[float] = ()
) -> Matrix

Return the unitary for name bound to params.

Source code in src/qmlkit/core/gates.py
def gate_matrix(name: str, params: Sequence[float] = ()) -> Matrix:
    """Return the unitary for ``name`` bound to ``params``."""
    g = get_gate(name)
    if len(params) != g.n_params:
        raise ValueError(f"gate {name!r} takes {g.n_params} parameter(s), got {len(params)}")
    return g.matrix(*params)

qmlkit.core.observables

observables

Pauli observables.

One expectation() that takes an observable and is correct for any register width. The hand-rolled expz(counts) helper this replaces is the usual shortcut, and it divides by n0 + n1 — which is right on one qubit and silently wrong on more, because those are two outcomes out of 2**n.

PauliString dataclass

PauliString(
    paulis: tuple[tuple[int, str], ...] = (),
    coeff: complex = 1.0,
)

A weighted tensor product of Paulis, e.g. 0.5 * Z0 X2.

Qubits not named act as identity.

from_label classmethod
from_label(label: str, coeff: complex = 1.0) -> PauliString

PauliString.from_label("ZIX") -> Z on qubit 0, X on qubit 2.

Source code in src/qmlkit/core/observables.py
@classmethod
def from_label(cls, label: str, coeff: complex = 1.0) -> PauliString:
    """``PauliString.from_label("ZIX")`` -> Z on qubit 0, X on qubit 2."""
    items = tuple((i, ch.upper()) for i, ch in enumerate(label) if ch.upper() != "I")
    return cls(items, coeff)

PauliSum dataclass

PauliSum(terms: tuple[PauliString, ...] = ())

A linear combination of Pauli strings.

I

I() -> PauliString

The identity observable.

Source code in src/qmlkit/core/observables.py
def I() -> PauliString:  # noqa: E743 - deliberate single-letter API
    """The identity observable."""
    return PauliString()

expectation_from_statevector

expectation_from_statevector(
    obs: Observable, state: NDArray[Any], n_qubits: int
) -> float

Exact . state is a flat 2**n complex vector, qubit 0 most significant.

Source code in src/qmlkit/core/observables.py
def expectation_from_statevector(obs: Observable, state: npt.NDArray[Any], n_qubits: int) -> float:
    """Exact <psi|O|psi>. ``state`` is a flat 2**n complex vector, qubit 0 most significant."""
    psi = np.asarray(state, dtype=complex).reshape((2,) * n_qubits)
    total = 0.0 + 0.0j
    for term in as_sum(obs).terms:
        out = psi
        for q, p in term.paulis:
            if p == "I":
                continue
            out = np.tensordot(_pauli_matrix(p), out, axes=([1], [q]))
            out = np.moveaxis(out, 0, q)
        total += term.coeff * np.vdot(psi, out)
    if abs(total.imag) > 1e-9:  # pragma: no cover - guards a genuine bug
        raise ValueError(f"non-real expectation {total!r}; observable is not Hermitian")
    return float(total.real)

expectation_from_statevectors

expectation_from_statevectors(
    obs: Observable, states: NDArray[Any], n_qubits: int
) -> NDArray[Any]

<psi|O|psi> for a stack of states — one value per row of states.

states is (batch, 2**n). This is the same arithmetic as :func:expectation_from_statevector with the batch carried as a leading axis, so each Pauli term is applied once for the whole batch instead of once per sample. tests/test_batch.py asserts the two agree exactly.

Source code in src/qmlkit/core/observables.py
def expectation_from_statevectors(
    obs: Observable, states: npt.NDArray[Any], n_qubits: int
) -> npt.NDArray[Any]:
    """``<psi|O|psi>`` for a stack of states — one value per row of ``states``.

    ``states`` is ``(batch, 2**n)``. This is the same arithmetic as
    :func:`expectation_from_statevector` with the batch carried as a leading axis, so
    each Pauli term is applied once for the whole batch instead of once per sample.
    ``tests/test_batch.py`` asserts the two agree exactly.
    """
    psi = np.asarray(states, dtype=complex).reshape((-1,) + (2,) * n_qubits)
    batch = psi.shape[0]
    flat = psi.reshape(batch, -1)
    total = np.zeros(batch, dtype=complex)
    for term in as_sum(obs).terms:
        out = psi
        for q, p in term.paulis:
            if p == "I":
                continue
            # +1 on the qubit axis: axis 0 is the batch
            out = np.tensordot(_pauli_matrix(p), out, axes=([1], [q + 1]))
            out = np.moveaxis(out, 0, q + 1)
        total += term.coeff * np.einsum("bi,bi->b", flat.conj(), out.reshape(batch, -1))
    if np.any(np.abs(total.imag) > 1e-9):  # pragma: no cover - guards a genuine bug
        raise ValueError("non-real expectation; observable is not Hermitian")
    return np.asarray(total.real, dtype=float)

diagonal_eigenvalues

diagonal_eigenvalues(
    term: PauliString, n_qubits: int
) -> NDArray[Any]

+-1 eigenvalue per computational basis state, for a Z-only Pauli string.

Source code in src/qmlkit/core/observables.py
def diagonal_eigenvalues(term: PauliString, n_qubits: int) -> npt.NDArray[Any]:
    """+-1 eigenvalue per computational basis state, for a Z-only Pauli string."""
    if any(p not in ("I", "Z") for _, p in term.paulis):
        raise ValueError("diagonal_eigenvalues expects a Z-only string (rotate the basis first)")
    idx = np.arange(2**n_qubits)
    signs = np.ones(2**n_qubits, dtype=float)
    for q, p in term.paulis:
        if p == "Z":
            bit = (idx >> (n_qubits - 1 - q)) & 1  # qubit 0 is most significant
            signs *= np.where(bit == 0, 1.0, -1.0)
    return signs

expectation_from_counts

expectation_from_counts(
    term: PauliString,
    counts: Mapping[str, int],
    n_qubits: int,
) -> float

from measurement counts already taken in the term's own basis.

Source code in src/qmlkit/core/observables.py
def expectation_from_counts(term: PauliString, counts: Mapping[str, int], n_qubits: int) -> float:
    """<P> from measurement counts already taken in the term's own basis."""
    total = sum(counts.values())
    if total == 0:
        raise ValueError("cannot take an expectation from zero shots")
    acc = 0.0
    support = {q for q, p in term.paulis if p != "I"}
    for bits, n in counts.items():
        if len(bits) != n_qubits:
            raise ValueError(f"bitstring {bits!r} does not match {n_qubits} qubits")
        parity = sum(bits[q] == "1" for q in support) % 2
        acc += n * (1.0 if parity == 0 else -1.0)
    return float(term.coeff.real * acc / total)

basis_rotation

basis_rotation(
    term: PauliString,
) -> list[tuple[str, tuple[int, ...]]]

Gates that rotate term into the computational (Z) basis.

X is diagonalised by H; Y by S-dagger then H.

Source code in src/qmlkit/core/observables.py
def basis_rotation(term: PauliString) -> list[tuple[str, tuple[int, ...]]]:
    """Gates that rotate ``term`` into the computational (Z) basis.

    X is diagonalised by H; Y by S-dagger then H.
    """
    ops: list[tuple[str, tuple[int, ...]]] = []
    for q, p in term.paulis:
        if p == "X":
            ops.append(("h", (q,)))
        elif p == "Y":
            ops.append(("sdg", (q,)))
            ops.append(("h", (q,)))
    return ops

group_qubit_wise_commuting

group_qubit_wise_commuting(
    obs: Observable,
) -> list[list[PauliString]]

Partition terms into qubit-wise-commuting groups (one circuit per group).

Simple greedy first-fit. Cheap, and enough to matter for multi-term observables.

Source code in src/qmlkit/core/observables.py
def group_qubit_wise_commuting(obs: Observable) -> list[list[PauliString]]:
    """Partition terms into qubit-wise-commuting groups (one circuit per group).

    Simple greedy first-fit. Cheap, and enough to matter for multi-term observables.
    """
    groups: list[list[PauliString]] = []
    for term in as_sum(obs).terms:
        placed = False
        for g in groups:
            if all(_qwc(term, other) for other in g):
                g.append(term)
                placed = True
                break
        if not placed:
            groups.append([term])
    return groups

qmlkit.core.builder

builder

A small fluent builder for circuits.

QCircuit is sugar over :class:~qmlkit.core.ir.CircuitSpec; anything it can build can also be assembled by hand from Op objects. param() and params() hand out :class:ParamRef values, and share lets one logical parameter drive several gates — the weight-tying case the gradient code handles per occurrence.

QCircuit

QCircuit(n_qubits: int, n_params: int = 0)

Builds a :class:CircuitSpec step by step.

Source code in src/qmlkit/core/builder.py
def __init__(self, n_qubits: int, n_params: int = 0) -> None:
    if n_qubits <= 0:
        raise ValueError("n_qubits must be positive")
    self.n_qubits = n_qubits
    self._ops: list[Op] = []
    self._n_params = n_params
param
param(scale: float = 1.0, offset: float = 0.0) -> ParamRef

Allocate one new logical parameter.

Source code in src/qmlkit/core/builder.py
def param(self, scale: float = 1.0, offset: float = 0.0) -> ParamRef:
    """Allocate one new logical parameter."""
    ref = ParamRef(self._n_params, scale, offset)
    self._n_params += 1
    return ref
params
params(n: int) -> tuple[ParamRef, ...]

Allocate n new logical parameters.

Source code in src/qmlkit/core/builder.py
def params(self, n: int) -> tuple[ParamRef, ...]:
    """Allocate ``n`` new logical parameters."""
    return tuple(self.param() for _ in range(n))
rotation_layer
rotation_layer(
    gates: Sequence[str] = ("ry",),
    wires: Iterable[int] | None = None,
    shared: ParamRef | None = None,
) -> QCircuit

One rotation per gate per wire.

Pass shared to tie every rotation in the layer to one logical parameter — the weight-tying case worth testing gradients against.

Source code in src/qmlkit/core/builder.py
def rotation_layer(
    self,
    gates: Sequence[str] = ("ry",),
    wires: Iterable[int] | None = None,
    shared: ParamRef | None = None,
) -> QCircuit:
    """One rotation per gate per wire.

    Pass ``shared`` to tie every rotation in the layer to one logical
    parameter — the weight-tying case worth testing gradients against.
    """
    qs = list(range(self.n_qubits)) if wires is None else list(wires)
    for q in qs:
        for g in gates:
            self.apply(g.lower(), q, shared if shared is not None else self.param())
    return self
entangle
entangle(
    pattern: str = "chain", gate: str = "cx"
) -> QCircuit

A layer of two-qubit gates following a named pattern.

Source code in src/qmlkit/core/builder.py
def entangle(self, pattern: str = "chain", gate: str = "cx") -> QCircuit:
    """A layer of two-qubit gates following a named pattern."""
    if get_gate(gate).n_params:
        raise ValueError(
            f"{gate!r} is parameterised; use parametric_entangle() to allocate its angles"
        )
    for a, b in entangler_pairs(self.n_qubits, pattern):
        self.apply(gate, (a, b))
    return self
parametric_entangle
parametric_entangle(
    gate: str = "crz", pattern: str = "ring"
) -> QCircuit

A layer of trainable two-qubit gates — exercises the four-term rule.

Source code in src/qmlkit/core/builder.py
def parametric_entangle(self, gate: str = "crz", pattern: str = "ring") -> QCircuit:
    """A layer of *trainable* two-qubit gates — exercises the four-term rule."""
    for a, b in entangler_pairs(self.n_qubits, pattern):
        self.apply(gate, (a, b), self.param())
    return self

entangler_pairs

entangler_pairs(
    n_qubits: int, pattern: str = "chain"
) -> tuple[tuple[int, int], ...]

Qubit pairs for a named entanglement pattern.

On two qubits a "ring" would revisit the same pair, so it collapses to a single (0, 1). PennyLane's templates run their loop uniformly and emit both CNOT(0, 1) and CNOT(1, 0) there, which is a genuinely different circuit — worth knowing when porting a two-qubit ansatz between the two libraries.

Source code in src/qmlkit/core/builder.py
def entangler_pairs(n_qubits: int, pattern: str = "chain") -> tuple[tuple[int, int], ...]:
    """Qubit pairs for a named entanglement pattern.

    On two qubits a ``"ring"`` would revisit the same pair, so it collapses to a
    single ``(0, 1)``. PennyLane's templates run their loop uniformly and emit both
    ``CNOT(0, 1)`` and ``CNOT(1, 0)`` there, which is a genuinely different circuit —
    worth knowing when porting a two-qubit ansatz between the two libraries.
    """
    if n_qubits < 2:
        return ()
    p = pattern.lower()
    if p in ("chain", "linear"):
        return tuple((i, i + 1) for i in range(n_qubits - 1))
    if p == "ring":
        if n_qubits == 2:
            return ((0, 1),)
        return tuple((i, (i + 1) % n_qubits) for i in range(n_qubits))
    if p in ("full", "all"):
        return tuple((i, j) for i in range(n_qubits) for j in range(i + 1, n_qubits))
    if p == "alternating":
        even = tuple((i, i + 1) for i in range(0, n_qubits - 1, 2))
        odd = tuple((i, i + 1) for i in range(1, n_qubits - 1, 2))
        return even + odd
    raise unknown(
        "entanglement pattern",
        pattern,
        ("chain", "linear", "ring", "full", "all", "alternating"),
    )

qmlkit.core.execute

execute

Running circuits and reading answers out.

One entry point per question, each taking an optional theta so a parameterised circuit can be run without binding it by hand first. shots=None means exact — the default, because 0.x is simulator-only and paying for sampling noise you did not ask for is not a feature. Pass shots=N to model a real device.

statevector

statevector(
    spec: CircuitSpec,
    theta: ArrayLike | None = None,
    backend: BackendLike = None,
) -> NDArray[Any]

Final state as a flat 2**n complex vector.

Source code in src/qmlkit/core/execute.py
def statevector(
    spec: CircuitSpec,
    theta: ArrayLike | None = None,
    backend: BackendLike = None,
) -> npt.NDArray[Any]:
    """Final state as a flat ``2**n`` complex vector."""
    return get_backend(backend).statevector(_prepare(spec, theta))

run_counts

run_counts(
    spec: CircuitSpec,
    shots: int = 8192,
    theta: ArrayLike | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
) -> dict[str, int]

Sample the computational basis. Keys are n_qubits-wide bitstrings.

Source code in src/qmlkit/core/execute.py
def run_counts(
    spec: CircuitSpec,
    shots: int = 8192,
    theta: ArrayLike | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
) -> dict[str, int]:
    """Sample the computational basis. Keys are ``n_qubits``-wide bitstrings."""
    return get_backend(backend).counts(_prepare(spec, theta), shots, seed)

probabilities

probabilities(
    spec: CircuitSpec,
    theta: ArrayLike | None = None,
    backend: BackendLike = None,
) -> NDArray[Any]

Exact outcome probabilities over the 2**n basis states.

Source code in src/qmlkit/core/execute.py
def probabilities(
    spec: CircuitSpec,
    theta: ArrayLike | None = None,
    backend: BackendLike = None,
) -> npt.NDArray[Any]:
    """Exact outcome probabilities over the ``2**n`` basis states."""
    return get_backend(backend).probabilities(_prepare(spec, theta))

expectation

expectation(
    spec: CircuitSpec,
    obs: Observable | None = ...,
    theta: ArrayLike | None = ...,
    shots: int | None = ...,
    backend: BackendLike = ...,
    seed: int | None = ...,
    return_std: Literal[False] = ...,
) -> float
expectation(
    spec: CircuitSpec,
    obs: Observable | None = ...,
    theta: ArrayLike | None = ...,
    shots: int | None = ...,
    backend: BackendLike = ...,
    seed: int | None = ...,
    *,
    return_std: Literal[True],
) -> tuple[float, float]
expectation(
    spec: CircuitSpec,
    obs: Observable | None = None,
    theta: ArrayLike | None = None,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
    return_std: bool = False,
) -> float | tuple[float, float]

<O> for a circuit.

shots=None (default) returns the exact value. With shots=N the value is sampled; return_std=True then also gives the standard error, which is the honest thing to report alongside any sampled number.

Source code in src/qmlkit/core/execute.py
def expectation(
    spec: CircuitSpec,
    obs: Observable | None = None,
    theta: ArrayLike | None = None,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
    return_std: bool = False,
) -> float | tuple[float, float]:
    """``<O>`` for a circuit.

    ``shots=None`` (default) returns the exact value. With ``shots=N`` the value is
    sampled; ``return_std=True`` then also gives the standard error, which is the
    honest thing to report alongside any sampled number.
    """
    obs = Z(0) if obs is None else obs
    value = get_backend(backend).expectation(_prepare(spec, theta), obs, shots, seed)
    if not return_std:
        return value
    if shots is None:
        return value, 0.0
    # The single-Pauli formula `sqrt((1 - z^2)/shots)` is wrong for a sum, and wrong
    # in the worst direction: it reports exactly zero once |<O>| reaches 1, so every
    # molecular Hamiltonian used to come back with an error bar of +-0.00000 that did
    # not move with the shot count. The backend computes the real thing.
    device = get_backend(backend)
    prepared = _prepare(spec, theta)
    try:
        single_shot_variance = device.expectation_variance(prepared, obs)
    except ValueError:
        # a sampling-only device: fall back to the single-term formula, which is
        # exact when there is one term and a bound otherwise
        scale = sum(abs(float(t.coeff.real)) for t in iter_terms(obs))
        return value, standard_error(value, shots, scale=scale)
    return value, float(np.sqrt(single_shot_variance / shots))

expectation_batch

expectation_batch(
    specs: Sequence[CircuitSpec],
    obs: Observable | None = None,
    thetas: Sequence[ArrayLike] | None = None,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
) -> NDArray[Any]

<O> for several circuits, resolving the backend once.

Source code in src/qmlkit/core/execute.py
def expectation_batch(
    specs: Sequence[CircuitSpec],
    obs: Observable | None = None,
    thetas: Sequence[ArrayLike] | None = None,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
) -> npt.NDArray[Any]:
    """``<O>`` for several circuits, resolving the backend once."""
    be = get_backend(backend)
    obs = Z(0) if obs is None else obs
    if thetas is None:
        thetas = [None] * len(specs)  # type: ignore[list-item]
    if len(thetas) != len(specs):
        raise ValueError(f"got {len(specs)} circuits but {len(thetas)} parameter vectors")
    return np.array(
        [
            be.expectation(_prepare(s, t), obs, shots, seed)
            for s, t in zip(specs, thetas, strict=False)
        ],
        dtype=float,
    )

expectation_over

expectation_over(
    spec: CircuitSpec,
    thetas: ArrayLike,
    obs: Observable | None = None,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
) -> NDArray[Any]

<O> for one circuit at many parameter vectors — the batched path.

expectation_batch takes many circuits; this takes one circuit and a (batch, n_params) array, which is the shape a training loop actually has: the same ansatz, one parameter vector per sample, because the encoding differs per sample and the weights do not.

Knowing the structure is shared is what lets a backend do better than a loop. The NumPy backend carries the batch as a leading axis and applies each gate to the whole stack at once, which is 4-30x faster than one-at-a-time up to 10 qubits. Backends that cannot do better inherit a loop, so this is always correct and never slower than calling :func:expectation yourself.

>>> import numpy as np, qmlkit as qk
>>> a = qk.hardware_efficient(3, 2)
>>> thetas = np.zeros((4, a.n_params))
>>> qk.expectation_over(a.build(), thetas, qk.Z(0)).shape
(4,)
Source code in src/qmlkit/core/execute.py
def expectation_over(
    spec: CircuitSpec,
    thetas: ArrayLike,
    obs: Observable | None = None,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
) -> npt.NDArray[Any]:
    """``<O>`` for **one** circuit at many parameter vectors — the batched path.

    ``expectation_batch`` takes many circuits; this takes one circuit and a
    ``(batch, n_params)`` array, which is the shape a training loop actually has: the
    same ansatz, one parameter vector per sample, because the encoding differs per
    sample and the weights do not.

    Knowing the structure is shared is what lets a backend do better than a loop. The
    NumPy backend carries the batch as a leading axis and applies each gate to the
    whole stack at once, which is 4-30x faster than one-at-a-time up to 10 qubits.
    Backends that cannot do better inherit a loop, so this is always correct and never
    slower than calling :func:`expectation` yourself.

        >>> import numpy as np, qmlkit as qk
        >>> a = qk.hardware_efficient(3, 2)
        >>> thetas = np.zeros((4, a.n_params))
        >>> qk.expectation_over(a.build(), thetas, qk.Z(0)).shape
        (4,)
    """
    return get_backend(backend).expectation_over(
        spec,
        np.atleast_2d(np.asarray(thetas, dtype=float)),
        Z(0) if obs is None else obs,
        shots,
        seed,
    )

expval

expval(
    spec: CircuitSpec,
    obs: Observable | None = None,
    theta: ArrayLike | None = None,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
) -> float

<O> as a plain float -- :func:expectation without the optional error bar.

Source code in src/qmlkit/core/execute.py
def expval(
    spec: CircuitSpec,
    obs: Observable | None = None,
    theta: ArrayLike | None = None,
    shots: int | None = None,
    backend: BackendLike = None,
    seed: int | None = None,
) -> float:
    """``<O>`` as a plain float -- :func:`expectation` without the optional error bar."""
    value = expectation(spec, obs, theta, shots, backend, seed, return_std=False)
    assert not isinstance(value, tuple)
    return float(value)

qmlkit.core.backends.base

The protocol every backend implements. A simulator supplies statevector; a device supplies counts. Everything else — sampling, basis rotation, qubit-wise-commuting grouping, expectation values, batched execution — is derived here once, which is what makes agreement between backends a property rather than a coincidence.

base

The backend protocol.

A backend supplies primitives; this base class supplies semantics. A simulator backend needs to implement only :meth:statevector — sampling, basis rotation and expectation values are derived here, so every backend agrees on what a shot is and what an expectation means. A sampling-only device overrides :meth:counts instead.

That split is what makes cross-backend equivalence testable rather than hopeful: if two backends disagree, the disagreement is in the circuit translation, not in four separate re-implementations of the measurement logic.

BackendNotAvailable

Bases: RuntimeError

Raised when a backend's underlying SDK is not installed or not importable.

Backend

Backend(seed: int | None = None)

A device or simulator that can run a :class:CircuitSpec.

Source code in src/qmlkit/core/backends/base.py
def __init__(self, seed: int | None = None) -> None:
    self._rng = np.random.default_rng(seed)
statevector
statevector(spec: CircuitSpec) -> NDArray[Any]

Final state as a flat 2**n complex vector, qubit 0 most significant.

Source code in src/qmlkit/core/backends/base.py
def statevector(self, spec: CircuitSpec) -> npt.NDArray[Any]:
    """Final state as a flat ``2**n`` complex vector, qubit 0 most significant."""
    raise NotImplementedError(
        f"the {self.name!r} backend cannot produce a statevector; use shots=N to sample instead"
    )
counts
counts(
    spec: CircuitSpec, shots: int, seed: int | None = None
) -> dict[str, int]

Sample the computational basis. Keys are n_qubits-wide bitstrings.

The default samples the exact probability distribution — correct for any statevector simulator. A shot-based device overrides this.

Source code in src/qmlkit/core/backends/base.py
def counts(self, spec: CircuitSpec, shots: int, seed: int | None = None) -> dict[str, int]:
    """Sample the computational basis. Keys are ``n_qubits``-wide bitstrings.

    The default samples the exact probability distribution — correct for any
    statevector simulator. A shot-based device overrides this.
    """
    self._check_bound(spec)
    if shots <= 0:
        raise ValueError("shots must be positive")
    rng = np.random.default_rng(seed) if seed is not None else self._rng
    return sample_counts_from_probs(self.probabilities(spec), shots, spec.n_qubits, rng)
probabilities
probabilities(spec: CircuitSpec) -> NDArray[Any]

Exact outcome probabilities over the 2**n basis states.

Source code in src/qmlkit/core/backends/base.py
def probabilities(self, spec: CircuitSpec) -> npt.NDArray[Any]:
    """Exact outcome probabilities over the ``2**n`` basis states."""
    return np.abs(self.statevector(spec)) ** 2
statevector_batch_slots
statevector_batch_slots(
    spec: CircuitSpec, slot_angles: NDArray[Any]
) -> NDArray[Any]

States for one circuit at many slot-angle vectors: (batch, 2**n).

Slot space rather than logical-parameter space is the primitive because that is what differentiation needs: a shift rule moves one occurrence of a parameter, and a weight-tied parameter has several. Anything expressed in logical parameters cannot say that.

The default binds and simulates one row at a time, so every backend has a working implementation the moment it can produce a statevector. A backend that can do better overrides this one method and everything above it — batched expectations, batched gradients, the torch layer — speeds up with it.

This loop is also the one place a backend without a native batch spends a long workload, so it is where progress is reported from. A Gram matrix on Qiskit is a single call from the caller's side and thousands of simulations from here; anything reporting at the call would sit silent for minutes.

Source code in src/qmlkit/core/backends/base.py
def statevector_batch_slots(
    self, spec: CircuitSpec, slot_angles: npt.NDArray[Any]
) -> npt.NDArray[Any]:
    """States for one circuit at many **slot-angle** vectors: ``(batch, 2**n)``.

    Slot space rather than logical-parameter space is the primitive because that is
    what differentiation needs: a shift rule moves one *occurrence* of a parameter,
    and a weight-tied parameter has several. Anything expressed in logical
    parameters cannot say that.

    The default binds and simulates one row at a time, so every backend has a
    working implementation the moment it can produce a statevector. A backend that
    can do better overrides this one method and everything above it — batched
    expectations, batched gradients, the torch layer — speeds up with it.

    This loop is also the one place a backend *without* a native batch spends a
    long workload, so it is where progress is reported from. A Gram matrix on
    Qiskit is a single call from the caller's side and thousands of simulations
    from here; anything reporting at the call would sit silent for minutes.
    """
    from qmlkit.progress import task as progress_task

    rows = np.atleast_2d(np.asarray(slot_angles, dtype=float))
    states: npt.NDArray[Any] = np.empty((len(rows), 2**spec.n_qubits), dtype=complex)
    with progress_task(f"{self.name} circuits", len(rows)) as tracked:
        for i, row in enumerate(rows):
            states[i] = self.statevector(spec.with_slot_angles(row))
            tracked.advance()
    return states
statevector_batch
statevector_batch(
    spec: CircuitSpec, thetas: NDArray[Any]
) -> NDArray[Any]

States for one circuit at many logical parameter vectors.

Source code in src/qmlkit/core/backends/base.py
def statevector_batch(self, spec: CircuitSpec, thetas: npt.NDArray[Any]) -> npt.NDArray[Any]:
    """States for one circuit at many logical parameter vectors."""
    values = np.atleast_2d(np.asarray(thetas, dtype=float))
    return self.statevector_batch_slots(spec, spec.bind_slots_batch(values))
expectation_over_slots
expectation_over_slots(
    spec: CircuitSpec,
    slot_angles: NDArray[Any],
    obs: Observable,
    shots: int | None = None,
    seed: int | None = None,
) -> NDArray[Any]

<O> at many slot-angle vectors, in chunks of :attr:max_batch_rows.

This is the one call a batched gradient makes, and the one a device would turn into a job submission.

Source code in src/qmlkit/core/backends/base.py
def expectation_over_slots(
    self,
    spec: CircuitSpec,
    slot_angles: npt.NDArray[Any],
    obs: Observable,
    shots: int | None = None,
    seed: int | None = None,
) -> npt.NDArray[Any]:
    """``<O>`` at many slot-angle vectors, in chunks of :attr:`max_batch_rows`.

    This is the one call a batched gradient makes, and the one a device would turn
    into a job submission.
    """
    rows = np.atleast_2d(np.asarray(slot_angles, dtype=float))
    if shots is not None:
        return np.array(
            [self.expectation(spec.with_slot_angles(row), obs, shots, seed) for row in rows],
            dtype=float,
        )
    if not self.supports_exact:
        raise ValueError(f"the {self.name!r} backend has no exact mode; pass shots=N to sample")
    if not self.supports_statevector:
        # exact, but with no state to stack: fall back to one exact evaluation per
        # row. Slower, and still the path a batched parameter-shift gradient needs.
        return np.array(
            [self.expectation(spec.with_slot_angles(row), obs) for row in rows],
            dtype=float,
        )
    out = np.empty(rows.shape[0], dtype=float)
    for start in range(0, rows.shape[0], self.max_batch_rows):
        block = rows[start : start + self.max_batch_rows]
        states = self.statevector_batch_slots(spec, block)
        out[start : start + block.shape[0]] = expectation_from_statevectors(
            obs, states, spec.n_qubits
        )
    return out
expectation_over
expectation_over(
    spec: CircuitSpec,
    thetas: NDArray[Any],
    obs: Observable,
    shots: int | None = None,
    seed: int | None = None,
) -> NDArray[Any]

<O> for one circuit at many logical parameter vectors.

Source code in src/qmlkit/core/backends/base.py
def expectation_over(
    self,
    spec: CircuitSpec,
    thetas: npt.NDArray[Any],
    obs: Observable,
    shots: int | None = None,
    seed: int | None = None,
) -> npt.NDArray[Any]:
    """``<O>`` for one circuit at many logical parameter vectors."""
    values = np.atleast_2d(np.asarray(thetas, dtype=float))
    return self.expectation_over_slots(spec, spec.bind_slots_batch(values), obs, shots, seed)
expectation
expectation(
    spec: CircuitSpec,
    obs: Observable,
    shots: int | None = None,
    seed: int | None = None,
) -> float

<O>. shots=None means exact, where the backend supports it.

When sampling, terms are partitioned into qubit-wise-commuting groups and each group costs one circuit rather than one per term. On a simulator that is a modest saving; on a device, where circuit count is the binding constraint, it is the difference between Z0 + Z1 + Z2 + Z0Z2 costing four circuits and costing one.

Source code in src/qmlkit/core/backends/base.py
def expectation(
    self,
    spec: CircuitSpec,
    obs: Observable,
    shots: int | None = None,
    seed: int | None = None,
) -> float:
    """``<O>``. ``shots=None`` means exact, where the backend supports it.

    When sampling, terms are partitioned into qubit-wise-commuting groups and each
    group costs **one** circuit rather than one per term. On a simulator that is a
    modest saving; on a device, where circuit count is the binding constraint, it
    is the difference between ``Z0 + Z1 + Z2 + Z0Z2`` costing four circuits and
    costing one.
    """
    self._check_bound(spec)
    if shots is None:
        if not self.supports_exact:
            raise ValueError(
                f"the {self.name!r} backend has no exact mode; pass shots=N to sample"
            )
        if self.supports_statevector:
            return expectation_from_statevector(obs, self.statevector(spec), spec.n_qubits)
        # Exact probabilities without a statevector - a density-matrix simulator,
        # say. The basis rotation and the grouping are the ones the sampled path
        # uses; only the estimator differs, so "exact" and "sampled" cannot drift
        # apart in their measurement semantics.
        return sum(self._exact_group(spec, group) for group in group_qubit_wise_commuting(obs))
    return sum(
        self._sampled_group(spec, group, shots, seed)
        for group in group_qubit_wise_commuting(obs)
    )
expectation_variance
expectation_variance(
    spec: CircuitSpec, obs: Observable
) -> float

Variance of a single shot of the estimator for obs.

The estimator measures one circuit per qubit-wise-commuting group. Inside a group every term is diagonal in the measured basis, so the group contributes a diagonal operator whose variance comes straight from the outcome probabilities; groups use independent shots, so the variances add. Divide by the shot count for the squared standard error.

Exact, not a bound, and it reduces to c**2 - z**2 on a single term. Needs exact probabilities, so a sampling-only device cannot answer.

Source code in src/qmlkit/core/backends/base.py
def expectation_variance(self, spec: CircuitSpec, obs: Observable) -> float:
    """Variance of a *single shot* of the estimator for ``obs``.

    The estimator measures one circuit per qubit-wise-commuting group. Inside a
    group every term is diagonal in the measured basis, so the group contributes
    a diagonal operator whose variance comes straight from the outcome
    probabilities; groups use independent shots, so the variances add. Divide by
    the shot count for the squared standard error.

    Exact, not a bound, and it reduces to ``c**2 - z**2`` on a single term. Needs
    exact probabilities, so a sampling-only device cannot answer.
    """
    if not self.supports_exact:
        raise ValueError(
            f"the {self.name!r} backend cannot give an exact variance; it has no "
            "shot-free probabilities to compute one from"
        )
    self._check_bound(spec)
    total = 0.0
    for group in group_qubit_wise_commuting(obs):
        _, measured, rotated = self._group_circuit(spec, group)
        if rotated is None:
            continue  # identity terms are deterministic and contribute nothing
        probabilities = np.asarray(self.probabilities(rotated), dtype=float)
        diagonal = np.zeros_like(probabilities)
        for term in measured:
            zs = PauliString(tuple((q, "Z") for q, p in term.paulis if p != "I"), 1.0)
            diagonal += float(term.coeff.real) * diagonal_eigenvalues(zs, spec.n_qubits)
        mean = float(probabilities @ diagonal)
        total += float(probabilities @ diagonal**2) - mean**2
    return max(total, 0.0)

qmlkit.core.backends.registry

registry

Backend lookup, availability detection, and the process-wide default.

Every SDK is optional. Backends are constructed lazily and their imports deferred, so import qmlkit never requires Qiskit, Cirq or SpinQit to be installed - and asking for one that is missing produces an explanation and an install command rather than an ImportError traceback.

QMLKIT_BACKEND in the environment sets the default, which is the least intrusive way to run an existing script against a different SDK.

register_backend

register_backend(
    name: str,
    factory: Callable[..., Backend],
    requires: str | None = None,
    extra: str | None = None,
) -> None

Register a backend factory.

requires is the importable module the backend needs; extra is the pip extra that installs it. Both are used to report availability without importing.

Source code in src/qmlkit/core/backends/registry.py
def register_backend(
    name: str,
    factory: Callable[..., Backend],
    requires: str | None = None,
    extra: str | None = None,
) -> None:
    """Register a backend factory.

    ``requires`` is the importable module the backend needs; ``extra`` is the pip
    extra that installs it. Both are used to report availability without importing.
    """
    _REGISTRY[name] = (factory, requires, extra)

is_available

is_available(name: str) -> bool

True if this backend's SDK can be imported in the current interpreter.

Source code in src/qmlkit/core/backends/registry.py
def is_available(name: str) -> bool:
    """True if this backend's SDK can be imported in the current interpreter."""
    if name not in _REGISTRY:
        return False
    _, requires, _ = _REGISTRY[name]
    if requires is None:
        return True
    try:
        return importlib.util.find_spec(requires) is not None
    except (ImportError, ValueError):  # pragma: no cover - broken installs
        return False

list_backends

list_backends() -> tuple[str, ...]

Every registered backend name, installed or not.

Source code in src/qmlkit/core/backends/registry.py
def list_backends() -> tuple[str, ...]:
    """Every registered backend name, installed or not."""
    return tuple(sorted(_REGISTRY))

available_backends

available_backends() -> tuple[str, ...]

Only the backends whose SDK is actually importable right now.

Source code in src/qmlkit/core/backends/registry.py
def available_backends() -> tuple[str, ...]:
    """Only the backends whose SDK is actually importable right now."""
    return tuple(name for name in list_backends() if is_available(name))

backend_report

backend_report() -> str

A human-readable summary of which backends this interpreter can run.

Source code in src/qmlkit/core/backends/registry.py
def backend_report() -> str:
    """A human-readable summary of which backends this interpreter can run."""
    lines = ["qmlkit backends:"]
    for name in list_backends():
        _, requires, extra = _REGISTRY[name]
        if is_available(name):
            lines.append(f"  [ok]      {name}")
        else:
            hint = f"pip install 'qmlkit[{extra}]'" if extra else f"pip install {requires}"
            lines.append(f"  [missing] {name:8s} -> {hint}")
    return "\n".join(lines)

get_backend

get_backend(
    backend: str | Backend | None = None, **kwargs: object
) -> Backend

Resolve a backend name, instance, or None (the default) to an instance.

Source code in src/qmlkit/core/backends/registry.py
def get_backend(backend: str | Backend | None = None, **kwargs: object) -> Backend:
    """Resolve a backend name, instance, or ``None`` (the default) to an instance."""
    if isinstance(backend, Backend):
        return backend
    if backend is None:
        if "noise" in kwargs:
            raise _noise_needs_a_named_backend(None)
        return default_backend()
    try:
        factory, requires, extra = _REGISTRY[backend]
    except KeyError:
        raise unknown(
            "backend",
            backend,
            list_backends(),
            hint=f"Importable in this interpreter right now: {', '.join(available_backends())}.",
            error=KeyError,
        ) from None
    if requires is not None and not is_available(backend):
        hint = f"pip install 'qmlkit[{extra}]'" if extra else f"pip install {requires}"
        raise BackendNotAvailable(
            f"the {backend!r} backend needs {requires!r}, which is not installed here.\n"
            f"    {hint}\n"
            f"Available now: {', '.join(available_backends())}"
        )
    try:
        return factory(**kwargs)
    except TypeError as exc:
        if "noise" in kwargs and backend not in NOISY_BACKENDS:
            raise _noise_needs_a_named_backend(backend) from exc
        raise

require_statevector

require_statevector(
    backend: str | Backend | None, measure: str
) -> Backend

Resolve a backend for a quantity that only exists on a pure state.

Expressibility, Meyer-Wallach entanglement and the Fubini-Study metric are defined between state vectors. Handed a density-matrix backend they used to raise NotImplementedError from inside statevector(), several frames below anything the caller wrote. Naming the measure and the backend turns that into an answer.

This refuses rather than substituting the reference. Where the question is about the ansatz rather than the device - :func:~qmlkit.diagnostics.diagnose - the substitution is the right move and the report says so. Here the caller named a backend and asked for a measure on it.

Source code in src/qmlkit/core/backends/registry.py
def require_statevector(backend: str | Backend | None, measure: str) -> Backend:
    """Resolve a backend for a quantity that only exists on a pure state.

    Expressibility, Meyer-Wallach entanglement and the Fubini-Study metric are defined
    between *state vectors*. Handed a density-matrix backend they used to raise
    ``NotImplementedError`` from inside ``statevector()``, several frames below
    anything the caller wrote. Naming the measure and the backend turns that into an
    answer.

    This refuses rather than substituting the reference. Where the question is about
    the ansatz rather than the device - :func:`~qmlkit.diagnostics.diagnose` - the
    substitution is the right move and the report says so. Here the caller named a
    backend and asked for a measure on it.
    """
    device = get_backend(backend)
    if not device.supports_statevector:
        raise ValueError(
            f"{measure} is defined on a pure state, and the {device.name!r} backend "
            "evolves a density matrix. Run it on a statevector backend to ask about the "
            "ansatz itself; to ask what the noise did to a particular circuit, use that "
            "backend's purity() or density_matrix()."
        )
    return device

default_backend

default_backend() -> Backend

The process-wide default.

NumPy unless QMLKIT_BACKEND says otherwise - exact, always present, and the reference every other backend is tested against.

Source code in src/qmlkit/core/backends/registry.py
def default_backend() -> Backend:
    """The process-wide default.

    NumPy unless ``QMLKIT_BACKEND`` says otherwise - exact, always present, and the
    reference every other backend is tested against.
    """
    global _default
    if _default is None:
        requested = os.environ.get("QMLKIT_BACKEND")
        _default = get_backend(requested) if requested else NumpyBackend()
    return _default

set_default_backend

set_default_backend(
    backend: str | Backend, **kwargs: object
) -> Backend

Set the process-wide default backend and return it.

Source code in src/qmlkit/core/backends/registry.py
def set_default_backend(backend: str | Backend, **kwargs: object) -> Backend:
    """Set the process-wide default backend and return it."""
    global _default
    _default = get_backend(backend, **kwargs)
    return _default

qmlkit.interop

interop

Reading circuits in, so people can migrate rather than only start here.

to_qiskit, to_cirq and to_spinqit have always existed. The reverse did not, and one-way interop is the difference between a library someone tries and one someone adopts: an existing project has circuits already, and a tool that cannot read them asks for a rewrite before it has proved anything.

Three entry points, in order of how much they carry:

:func:from_qasm OpenQASM 2.0, parsed with the standard library alone. Qiskit, Cirq, Braket, t|ket> and Q# all export it, so this one function reaches every one of them without qmlkit taking a dependency on any. :func:from_qiskit A QuantumCircuit object directly, including unbound Parameters, which QASM cannot represent. :func:from_pennylane A tape or a QNode, for the migration this library is most often compared against. :func:from_cirq A cirq.Circuit, including sympy symbols, which QASM cannot represent either. Cirq identifies gates by class and exponent rather than by name, so this one classifies rather than looks up.

Qubit order is the thing to get right. qmlkit is big-endian: qubit 0 is the most significant bit of a basis state. Qiskit and OpenQASM are little-endian, so importing maps their qubit j to qmlkit's n-1-j — the exact inverse of what :meth:~qmlkit.core.backends.qiskit_backend.QiskitBackend.to_qiskit does on the way out, which is what makes the round trip exact rather than merely plausible. PennyLane is big-endian like qmlkit, so nothing is flipped there. All three are asserted against real statevectors in tests/test_import.py.

Gates outside the supported set are refused, not approximated. The one exception is the u/u3 family, which is decomposed into rotations and drops a global phase — unobservable on its own, and observable if the circuit is later used as a controlled block, so the import says so.

UnsupportedGate

Bases: ValueError

A source circuit used a gate qmlkit has no definition for.

from_qasm

from_qasm(
    text: str, little_endian: bool = True
) -> CircuitSpec

Parse OpenQASM 2.0 into a :class:~qmlkit.core.ir.CircuitSpec.

Uses the standard library only — no Qiskit, no parser generator — so this works in a bare pip install qmlkit. Every major SDK exports QASM 2.0, which makes this the widest import path the library has.

Parameters:

Name Type Description Default
text str

The QASM source. OPENQASM/include headers, comments, creg and barrier are accepted and ignored.

required
little_endian bool

Whether the producing tool treats qubit 0 as the least significant bit. True for Qiskit and for QASM as it is normally used, which is why it is the default. Set False for a big-endian producer, where indices pass through.

True

Raises:

Type Description
UnsupportedGate

For a gate qmlkit has no definition for, or for measure/reset, naming what was found rather than dropping it.

Notes

Only a single quantum register is supported, which is what exported circuits almost always have. Classical registers are ignored, since nothing here is conditioned on them.

Source code in src/qmlkit/interop.py
def from_qasm(text: str, little_endian: bool = True) -> CircuitSpec:
    """Parse OpenQASM 2.0 into a :class:`~qmlkit.core.ir.CircuitSpec`.

    Uses the standard library only — no Qiskit, no parser generator — so this works
    in a bare ``pip install qmlkit``. Every major SDK exports QASM 2.0, which makes
    this the widest import path the library has.

    Parameters
    ----------
    text:
        The QASM source. ``OPENQASM``/``include`` headers, comments, ``creg`` and
        ``barrier`` are accepted and ignored.
    little_endian:
        Whether the producing tool treats qubit ``0`` as the least significant bit.
        True for Qiskit and for QASM as it is normally used, which is why it is the
        default. Set False for a big-endian producer, where indices pass through.

    Raises
    ------
    UnsupportedGate
        For a gate qmlkit has no definition for, or for ``measure``/``reset``, naming
        what was found rather than dropping it.

    Notes
    -----
    Only a single quantum register is supported, which is what exported circuits
    almost always have. Classical registers are ignored, since nothing here is
    conditioned on them.
    """
    lines = _strip_comments(text)
    n_qubits, register = _find_register(lines)
    imp = _Importer(n_qubits, _QASM_GATES, "the QASM circuit", flip=little_endian)

    for line in lines:
        if not line.strip() or _QREG.match(line) or _CREG.match(line):
            continue
        if line.lstrip().startswith(("OPENQASM", "include", "gate ", "opaque ")):
            if line.lstrip().startswith(("gate ", "opaque ")):
                raise UnsupportedGate(
                    "the QASM circuit declares a custom gate, which this parser does not "
                    "expand. Flatten it with the producing tool first (in Qiskit: "
                    "`circuit.decompose()`), or register it with qk.register_gate()."
                )
            continue

        parsed = _split_instruction(line)
        if parsed is None:
            raise ValueError(f"could not parse the QASM line: {line.strip()!r}")
        name, args, targets = parsed
        if name in ("measure", "reset"):
            # checked before the register scan, so `measure q[0] -> c[0];` reports the
            # measurement rather than complaining about the classical register
            imp.add(name, [], [])
        params = [_eval_angle(a) for a in args.split(",")] if args else []
        qubits = []
        for reg, index in _ARG.findall(targets):
            if reg != register:
                raise ValueError(
                    f"the QASM circuit uses register {reg!r}; only the single register "
                    f"{register!r} is supported"
                )
            qubits.append(int(index))
        if not qubits and name not in _IGNORED:
            raise ValueError(f"could not read qubit arguments from: {line.strip()!r}")
        imp.add(name, qubits, params)
    return imp.finish()

from_qiskit

from_qiskit(circuit: Any) -> CircuitSpec

Convert a Qiskit QuantumCircuit, bound or parameterised.

Unbound Parameter objects become :class:~qmlkit.core.ir.ParamRef\ s, indexed in Qiskit's own sorted-by-name parameter order, so theta in qmlkit lines up with circuit.parameters. That is the part QASM cannot carry, and the reason this exists alongside :func:from_qasm.

Qiskit's qubit j becomes qmlkit's n-1-j, inverting what to_qiskit does, so from_qiskit(to_qiskit(spec)) reproduces the circuit exactly.

Source code in src/qmlkit/interop.py
def from_qiskit(circuit: Any) -> CircuitSpec:
    """Convert a Qiskit ``QuantumCircuit``, bound or parameterised.

    Unbound ``Parameter`` objects become :class:`~qmlkit.core.ir.ParamRef`\\ s, indexed
    in Qiskit's own sorted-by-name parameter order, so ``theta`` in qmlkit lines up
    with ``circuit.parameters``. That is the part QASM cannot carry, and the reason
    this exists alongside :func:`from_qasm`.

    Qiskit's qubit ``j`` becomes qmlkit's ``n-1-j``, inverting what ``to_qiskit`` does,
    so ``from_qiskit(to_qiskit(spec))`` reproduces the circuit exactly.
    """
    try:
        from qiskit.circuit import Parameter, ParameterExpression
    except ImportError as exc:  # pragma: no cover - depends on the environment
        raise ImportError(
            "from_qiskit needs Qiskit, which is an optional extra:\n"
            "    pip install 'qmlkit[qiskit]'"
        ) from exc

    n = circuit.num_qubits
    imp = _Importer(n, _QISKIT_GATES, "the Qiskit circuit", flip=True)
    index_of = {p: i for i, p in enumerate(circuit.parameters)}
    imp.n_params = len(index_of)

    for instruction in circuit.data:
        operation = instruction.operation
        qubits = [circuit.find_bit(q).index for q in instruction.qubits]
        params: list[Any] = []
        for raw in operation.params:
            if isinstance(raw, ParameterExpression) and raw.parameters:
                symbols = list(raw.parameters)
                if len(symbols) != 1 or not isinstance(raw, Parameter):
                    raise UnsupportedGate(
                        f"the Qiskit circuit has the compound parameter expression {raw!r}. "
                        "qmlkit's ParamRef carries a scale and offset, not arbitrary "
                        "expressions — bind it, or use a plain Parameter."
                    )
                params.append(ParamRef(index_of[symbols[0]]))
            else:
                params.append(float(raw))
        imp.add(operation.name, qubits, params)
    return imp.finish()

from_pennylane

from_pennylane(
    source: Any, *args: Any, **kwargs: Any
) -> CircuitSpec

Convert a PennyLane tape, QNode or quantum function.

A QNode is called with *args/**kwargs to produce its tape, so the parameters are bound at import time — PennyLane's trainable parameters are positional arguments rather than named symbols, so there is nothing to carry across symbolically the way :func:from_qiskit does.

PennyLane orders wires big-endian, the same as qmlkit, so indices pass through unchanged. tests/test_import.py asserts that against real statevectors rather than taking it on trust.

Source code in src/qmlkit/interop.py
def from_pennylane(source: Any, *args: Any, **kwargs: Any) -> CircuitSpec:
    """Convert a PennyLane tape, QNode or quantum function.

    A QNode is called with ``*args``/``**kwargs`` to produce its tape, so the
    parameters are bound at import time — PennyLane's trainable parameters are
    positional arguments rather than named symbols, so there is nothing to carry
    across symbolically the way :func:`from_qiskit` does.

    PennyLane orders wires big-endian, the same as qmlkit, so indices pass through
    unchanged. ``tests/test_import.py`` asserts that against real statevectors rather
    than taking it on trust.
    """
    try:
        import pennylane as qml
    except ImportError as exc:  # pragma: no cover - depends on the environment
        raise ImportError("from_pennylane needs PennyLane:\n    pip install pennylane") from exc

    tape = _as_tape(source, qml, args, kwargs)
    wires = list(tape.wires)
    position = {w: i for i, w in enumerate(wires)}
    imp = _Importer(len(wires), _PENNYLANE_GATES, "the PennyLane circuit", flip=False)

    for op in _flatten(tape.operations):
        imp.add(op.name, [position[w] for w in op.wires], _scalars(op))
    return imp.finish()

from_cirq

from_cirq(circuit: Any) -> CircuitSpec

Convert a cirq.Circuit, bound or carrying sympy symbols.

Cirq orders qubits big-endian, the same as qmlkit, so indices pass through unflipped - asserted against real statevectors in tests/test_import.py rather than taken on trust. Qubits are numbered by Cirq's own sort order, the order its simulator uses, so a circuit on LineQubit(0) and LineQubit(2) becomes a two-qubit qmlkit circuit.

Symbolic exponents become :class:~qmlkit.core.ir.ParamRef\ s indexed by sorted symbol name, so theta lines up with sorted(cirq.parameter_names(circuit)). That is what :func:from_qasm cannot carry, and the reason this exists beside it.

Source code in src/qmlkit/interop.py
def from_cirq(circuit: Any) -> CircuitSpec:
    """Convert a ``cirq.Circuit``, bound or carrying ``sympy`` symbols.

    Cirq orders qubits big-endian, the same as qmlkit, so indices pass through
    unflipped - asserted against real statevectors in ``tests/test_import.py`` rather
    than taken on trust. Qubits are numbered by Cirq's own sort order, the order its
    simulator uses, so a circuit on ``LineQubit(0)`` and ``LineQubit(2)`` becomes a
    two-qubit qmlkit circuit.

    Symbolic exponents become :class:`~qmlkit.core.ir.ParamRef`\\ s indexed by sorted
    symbol name, so ``theta`` lines up with ``sorted(cirq.parameter_names(circuit))``.
    That is what :func:`from_qasm` cannot carry, and the reason this exists beside it.
    """
    try:
        import cirq
    except ImportError as exc:  # pragma: no cover - depends on the environment
        raise ImportError(
            "from_cirq needs Cirq, which is an optional extra:\n    pip install 'qmlkit[cirq]'"
        ) from exc

    qubits = sorted(circuit.all_qubits())
    position = {q: i for i, q in enumerate(qubits)}
    imp = _Importer(len(qubits), _CIRQ_GATES, "the Cirq circuit", flip=False)

    index_of = {name: i for i, name in enumerate(sorted(cirq.parameter_names(circuit)))}
    imp.n_params = len(index_of)

    for op in circuit.all_operations():
        wires = [position[q] for q in op.qubits]
        # a multi-qubit IdentityGate is one op in Cirq and one gate per qubit here
        if isinstance(op.gate, cirq.IdentityGate):
            for wire in wires:
                imp.add("I", [wire])
            continue
        label, params = _classify_cirq(op.gate, index_of, cirq)
        imp.add(label, wires, params)
    return imp.finish()

register_importer

register_importer(
    name: str, fn: Callable[..., CircuitSpec]
) -> None

Add an importer, so a new source format is reachable by name.

The same registry pattern as register_gate/register_backend: registering makes the format a first-class citizen of :func:get_importer.

Source code in src/qmlkit/interop.py
def register_importer(name: str, fn: Callable[..., CircuitSpec]) -> None:
    """Add an importer, so a new source format is reachable by name.

    The same registry pattern as ``register_gate``/``register_backend``: registering
    makes the format a first-class citizen of :func:`get_importer`.
    """
    _IMPORTERS[name] = fn