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
¶
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
¶
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
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
slots ¶
slots() -> tuple[Slot, ...]
Every parameterised angle site, in circuit order.
Source code in src/qmlkit/core/ir.py
occurrences_of ¶
occurrences_of(param_index: int) -> tuple[Slot, ...]
Slots driven by logical parameter param_index (>1 means weight tying).
bind_slots ¶
Resolve the logical parameter vector into one angle per slot.
Source code in src/qmlkit/core/ir.py
bind_slots_batch ¶
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
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
bind ¶
bind(theta: ArrayLike | None = None) -> CircuitSpec
Fully bind the circuit with a logical parameter vector.
Source code in src/qmlkit/core/ir.py
depth ¶
Circuit depth: the longest chain of gates sharing a qubit.
Source code in src/qmlkit/core/ir.py
bound_angle ¶
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
concat ¶
concat(specs: Iterable[CircuitSpec]) -> CircuitSpec
Compose a sequence of circuits left to right.
Source code in src/qmlkit/core/ir.py
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
¶
True if the exact derivative matrix is known (adjoint differentiation).
register_gate ¶
Add a gate to the registry. Re-registering the same name is an error.
Source code in src/qmlkit/core/gates.py
gate_derivative ¶
Exact d(matrix)/d(theta) for a one-parameter gate.
Source code in src/qmlkit/core/gates.py
gate_matrix ¶
Return the unitary for name bound to params.
Source code in src/qmlkit/core/gates.py
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
¶
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
PauliSum
dataclass
¶
PauliSum(terms: tuple[PauliString, ...] = ())
A linear combination of Pauli strings.
I ¶
I() -> PauliString
expectation_from_statevector ¶
Exact state is a flat 2**n complex vector, qubit 0 most significant.
Source code in src/qmlkit/core/observables.py
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
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
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
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
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
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 ¶
Builds a :class:CircuitSpec step by step.
Source code in src/qmlkit/core/builder.py
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
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
entangler_pairs ¶
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
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.
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
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
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
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
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
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
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 ¶
A device or simulator that can run a :class:CircuitSpec.
Source code in src/qmlkit/core/backends/base.py
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
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
probabilities ¶
probabilities(spec: CircuitSpec) -> NDArray[Any]
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
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
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
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
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
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
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
is_available ¶
True if this backend's SDK can be imported in the current interpreter.
Source code in src/qmlkit/core/backends/registry.py
list_backends ¶
available_backends ¶
Only the backends whose SDK is actually importable right now.
backend_report ¶
A human-readable summary of which backends this interpreter can run.
Source code in src/qmlkit/core/backends/registry.py
get_backend ¶
Resolve a backend name, instance, or None (the default) to an instance.
Source code in src/qmlkit/core/backends/registry.py
require_statevector ¶
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
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
set_default_backend ¶
Set the process-wide default backend and return it.
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. |
required |
little_endian
|
bool
|
Whether the producing tool treats qubit |
True
|
Raises:
| Type | Description |
|---|---|
UnsupportedGate
|
For a gate qmlkit has no definition for, or for |
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
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
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
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
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.