Custom Observables
Every built-in observable in QuantumCircuitsMPS.jl — EntanglementEntropy, Magnetization, PauliString, … — is a callable struct: an instance holds the observable's parameters and is evaluated on a state as obs(state). The tracking API generalizes this all the way down: track! accepts any callable f(state), not just the built-in types. A closure, a plain function, or a struct of your own all work identically through track! / record! / simulate!, on all three backends (MPS, state vector, Clifford).
track!(state, :edge => s -> born_probability(s, 1, 0)) # that's it
simulate!(circuit, state; n_steps = 50, record_when = :every_step)
state.observables[:edge] # 50 recorded valuesThere is no plugin framework and no registration macro — the contract below IS the extension mechanism.
The callable contract
A tracked callable f is invoked as f(state) at every record point (an eager record!(state) call, or the points selected by simulate!'s record_when policy). It must obey three rules:
- Read-only physics.
fmay freely read the state — through the building blocks below, orstate.L,state.bc,state.local_dim, the event log, … — but it must NOT mutate the quantum state: noapply!, no measurements, no RNG draws. Recording an observable must never change the trajectory. - Return a scalar or a vector.
f(state)returns aNumber(typicallyFloat64) or anAbstractVector(e.g. a per-site profile). Each record point appends exactly ONE entry tostate.observables[name]— a returned vector is stored as a single element, not splatted. - Errors propagate. An exception thrown by
faborts therecord!call. For custom callables it is wrapped in anErrorExceptionnaming the observable key (with the original exception attached ascaused by:), so a failing tracker is diagnosable among many.
Storage: generic callables record into a Vector{Any}; built-in (AbstractObservable) instances keep the scalar Vector{Float64} container (transparently widened to Vector{Any} if such an observable returns a vector).
Public building blocks
Custom observables are compositions of the same public pieces the built-ins use. Since built-in observables are callable structs, any instance can be evaluated inline inside your own callable:
| Building block | What it computes |
|---|---|
born_probability(state, site, outcome) | Born probability $P(\text{outcome})$ at a physical site |
PauliString(i => :Z, j => :Z, ...)(state) | $\langle \prod_k P_k \rangle$ for any single-qubit Pauli product |
EntanglementEntropy(cut = k, renyi_index = n)(state) | Rényi-$n$ / von Neumann entropy across a cut |
MutualInformation (new in v0.4.0) | mutual information $I(A\!:\!B)$ between two regions |
Magnetization(:Z)(state) | $\tfrac{1}{L}\sum_i \langle Z_i \rangle$ |
StringOrder(i, j; order)(state) | spin-1 string order parameter |
measurements(state), events(state) | typed event log (requires SimulationState(...; log_events = true)) |
The three worked examples below go from a one-line closure to a fully dispatched struct observable.
Example (a): a custom order parameter from a closure
An edge-polarization imbalance $\Delta = P(q_1{=}0) - P(q_L{=}0)$ — useful, for instance, to watch how a control protocol pins the chain ends — is a one-liner composed from born_probability:
using QuantumCircuitsMPS
L = 8
circuit = Circuit(L = L, bc = :open) do c
apply!(c, HaarRandom(), Bricklayer(:odd))
apply_with_prob!(c; outcomes = [
(probability = 0.2, gate = Measure(:Z), geometry = AllSites()),
])
end
state = SimulationState(L = L, bc = :open, maxdim = 64,
rng = RNGRegistry(gates_spacetime = 42, gates_realization = 2,
born_measurement = 3))
initialize!(state, ProductState(binary_int = 0))
# ANY callable f(state) — closures included. `s.L` reads the system size.
edge_imbalance = s -> born_probability(s, 1, 0) - born_probability(s, s.L, 0)
track!(state, :edge => edge_imbalance)
simulate!(circuit, state; n_steps = 10, record_when = :every_step)
state.observables[:edge] # 10 values, one per stepVector returns work the same way — track a full Born-probability profile and each record point appends one Vector{Float64}:
track!(state, :profile => s -> [born_probability(s, i, 0) for i in 1:s.L])Example (b): a custom correlator composing PauliString
PauliString evaluates $\langle \prod_k P_k \rangle$ for arbitrary Pauli products, which makes connected correlation functions $C(i,j) = \langle Z_i Z_j \rangle - \langle Z_i \rangle \langle Z_j \rangle$ a three-line composition. Writing a small factory function keeps the site indices as parameters:
using QuantumCircuitsMPS
connected_correlator(i, j) = state ->
PauliString(i => :Z, j => :Z)(state) -
PauliString(i => :Z)(state) * PauliString(j => :Z)(state)
# Bell pair on sites (1, 2): ⟨Z₁Z₂⟩ = 1, ⟨Z₁⟩ = ⟨Z₂⟩ = 0 ⇒ C(1,2) = 1
state = SimulationState(L = 2, bc = :open, backend = :statevector,
rng = RNGRegistry(gates_spacetime = 1, gates_realization = 2,
born_measurement = 3))
initialize!(state, ProductState(binary_int = 0))
apply!(state, Hadamard(), SingleSite(1))
apply!(state, CNOT(), Sites([1, 2]))
track!(state, :c12 => connected_correlator(1, 2))
record!(state)
state.observables[:c12] # Any[1.0]The same factory works tracked through simulate!, and — because PauliString is implemented on all three backends — on :mps, :statevector, and :clifford states alike.
Example (c): a struct-based observable (advanced path)
When a custom observable carries parameters you want visible in dispatch, or needs recording-time behavior beyond a plain call, subtype AbstractObservable and (optionally) override the record_value hook — exactly the pattern the built-in DomainWall uses to resolve its sampling site at record time:
using QuantumCircuitsMPS
"""
ClampedProbability(site)
Born probability P(site = 0), clamped to [0, 1] at record time to scrub
float dust (e.g. 1.0000000000000002 from an MPS contraction).
"""
struct ClampedProbability <: QuantumCircuitsMPS.AbstractObservable
site::Int
end
# the observable's call protocol — same as every built-in
(obs::ClampedProbability)(state) = born_probability(state, obs.site, 0)
# OPTIONAL: hook the recording step itself (record! consumes this, not
# obs(state) directly). Keep the `i1` keyword in the signature.
function QuantumCircuitsMPS.record_value(obs::ClampedProbability, state;
i1 = nothing)
return clamp(obs(state), 0.0, 1.0)
end
state = SimulationState(L = 4, bc = :open, maxdim = 16,
rng = RNGRegistry(gates_spacetime = 1, gates_realization = 2,
born_measurement = 3))
initialize!(state, ProductState(binary_int = 0))
track!(state, :p1 => ClampedProbability(1))
record!(state)
state.observables[:p1] # [1.0] — stored in a Vector{Float64}What subtyping buys you over a closure:
- Scalar storage:
AbstractObservablespecs record into aVector{Float64}instead of aVector{Any}. - The
record_valuehook: recording-time context (likerecord!'si1keyword) and value post-processing live in one overridable method. - Backend dispatch: you can provide per-backend methods (
(obs::MyObs)(state::SimulationState{StateVectorBackend}) = ...) or throw an informativeArgumentErroron unsupported backends, exactly like the built-ins (see the developer docs on the backend interface).
For everything else, a closure is the shorter path.
Error behavior
A tracked callable that throws does not fail silently and does not record a placeholder — the error aborts record! and names the offending key:
track!(state, :bad => s -> error("boom"))
record!(state)
# ERROR: record!: custom observable :bad (var"#3#4") threw while recording —
# see the underlying exception below (`caused by:`).
# ...
# caused by: boomBuilt-in observables are not wrapped: they throw their own typed, documented errors (e.g. DomainWall's ArgumentError when no sampling site is available, or StringOrder's rejection on the Clifford backend).