Concepts¶
This page explains the data model TensorWeaver works with, and the handful of numbers that decide whether a run is fast, accurate, or both. It assumes you are comfortable with quantum circuits (qubits, gates, measurement) but not with tensor networks.
Read it before choosing bonddim. It is the page that decides whether you
pick sane parameters.
Why not store the state vector?¶
A pure state of \(n\) qubits is a vector of \(2^n\) complex amplitudes:
Storing every \(c_x\) is exact, and doubles in cost with each added qubit. Around 30 qubits this reaches tens of gigabytes, and beyond roughly 50 it is out of reach on any machine. A state-vector simulator pays that cost whatever the circuit does.
Most circuits of practical interest do not fill the full \(2^n\)-dimensional space. They produce states with limited entanglement, and those states can be written with far fewer numbers. A Matrix Product State stores only as much as the entanglement requires.
From a state vector to an MPS¶
Take the amplitude tensor \(c_{s_1 s_2 \dots s_n}\), one index \(s_i \in \{0,1\}\) per qubit, and factor it into a chain of smaller tensors, one per qubit:
Each tensor \(A^{s_i}\) carries one physical index \(s_i\) (the qubit it represents) and one or two bond indices \(\alpha\) linking it to its neighbours. Drawn as a diagram it is a chain: a node per qubit, a horizontal bond between neighbours, and a physical leg hanging off each node.
The bond indices are where the entanglement lives. How many values \(\alpha_i\) runs over is the bond dimension at that link.
Bond dimension¶
The bond dimension, written \(\chi\), is the quantity everything else follows from. Cut the chain between qubits \(i\) and \(i+1\). By the Schmidt decomposition the state across that cut is
with Schmidt coefficients \(\lambda_k \ge 0\) in decreasing order. The number of non-zero terms is the Schmidt rank, and it is exactly the bond dimension needed at that cut.
| State | \(\chi\) required |
|---|---|
| Product state, no entanglement | 1 at every cut |
| Bell pair, GHZ of any length | 2 |
| Volume-law entangled region | \(2^{\min(i,\,n-i)}\), the worst case |
An MPS with maximum bond dimension \(\chi\) stores about \(n \chi^2\) complex numbers instead of \(2^n\). In the worst case \(\chi\) reaches \(2^{n/2}\) and the MPS is as large as the state vector, buying nothing. Everything in between is where MPS simulation pays off.
\(\chi\) also sets the runtime: the cost of applying a two-qubit gate grows like
\(\chi^3\). Doubling bonddim costs roughly eight times the work per gate and
four times the memory.
Truncation and what it costs¶
Most circuits would need a steadily growing \(\chi\) to stay exact. Two controls bound it:
| Control | Default | Effect |
|---|---|---|
bonddim |
256 | Hard cap on \(\chi\). Never exceeded. |
sv_cutoff |
1e-7 |
Discard Schmidt coefficients below this threshold. |
After a gate or operator is applied, each affected bond is re-factored with a
singular value decomposition and the smallest Schmidt coefficients are dropped
until both controls are satisfied. sv_cutoff removes weight that is
numerically negligible and usually costs nothing; bonddim is the control that
bites, because it truncates whether or not the discarded weight is small.
Discarding weight is an approximation, and the state you keep is no longer the state the circuit defines.
Reading the reported fidelity¶
Every run reports how much was discarded, as a fidelity: an estimate of the squared overlap between the truncated state and the untruncated one.
from mimiqcircuits import Circuit, GateH, GateCX
from tensorweaver import execute
c = Circuit()
c.push(GateH(), 0)
c.push(GateCX(), 0, 1)
results = execute(c, nsamples=1000, bonddim=64)
print(results.fidelities) # one entry per evolution
results.fidelities holds one value per evolution: a single entry in sampling
mode, and nsamples entries in trajectory mode. A value of 1.0 means nothing
was discarded. A value of 0.98 means the kept state carries about 98% of the
weight. results.avggateerrors reports the same information as an average
error per two-qubit gate.
Two properties matter when you act on the number.
It is a lower bound, not a measurement. The value accumulates the weight discarded at each truncation. The true overlap with the exact state is at least this large, and often larger, because discarded pieces can fail to matter for the observable you care about.
A fidelity of 1.0 is necessary, not sufficient. The estimate covers
truncation. It does not detect a state that has gone wrong for another reason,
and it is reported per evolution rather than per observable. A run can report
1.0 and still carry a state whose overlap with the exact answer is well below
that.
So use the fidelity as a screen, not as a certificate. To establish that a result is converged, vary the parameter that controls the approximation:
for chi in (16, 32, 64, 128, 256):
r = execute(c, nsamples=1000, bonddim=chi, seed=1)
print(chi, r.fidelities[0], r.histogram())
When the observable you care about stops moving as bonddim grows, the answer
is converged. When it is still moving, the fidelity was not telling you the
whole story. A mirror circuit (apply \(U\) then \(U^\dagger\) and check the return
to \(|0\dots0\rangle\)) is the other standard check, and it tests the state
rather than the bookkeeping.
Example 05 in the bundled examples runs this sweep and compares against a high-\(\chi\) reference.
Canonical form and the orthogonality center¶
Truncation is only well defined when the singular values being compared are the physical Schmidt coefficients. That requires the chain to be in canonical form around an orthogonality center: every tensor left of the center is left-orthogonal, every tensor right of it is right-orthogonal. In that gauge an SVD at the center reads off the true Schmidt spectrum, so dropping the smallest values discards the smallest physical weight.
Operations move the center to where they act and restore the form afterwards,
so ordinary use through execute() never needs to think about it.
mps.orth_center reports the current position.
It becomes visible in two places:
- Low-level editing. Writing site tensors directly with
set_tensor()can break the form. Callrecanonicalize()afterwards. - Deferred compression.
post_compress=Falseand the directapply_gate_2qpath skip the sweep that restores canonical form, trading it for speed. The state then sits in a gauge where a later truncation is comparing the wrong numbers, which is why both are paired with an explicitmps.compress()at a quiet point.post_compressis on by default and should stay on unless you have measured that deferring it is safe for your circuits.
Operators as tensor trains: the MPO¶
A Matrix Product Operator is the operator analogue of an MPS: a chain of tensors, each with an input and an output physical leg, representing a \(2^n \times 2^n\) operator in factored form.
TensorWeaver accumulates a run of unitary gates into an MPO and applies the
whole operator to the state in one sweep, which costs less than applying the
gates one at a time. entdim (default 16) caps the bond dimension of that
operator. When the accumulated gates would exceed it, the operator is applied
and a fresh one started, so entdim sets how many gates batch together rather
than limiting which circuits can run.
One case is an error rather than a flush: a single gate whose operator rank
exceeds entdim cannot be compressed at all, and raises RuntimeError telling
you to raise entdim. It is never silently approximated.
Applying an MPO to a state grows the bond dimension, which is then truncated back, so the choice of algorithm affects accuracy:
algorithm |
Character |
|---|---|
"vmpoa" (default) |
Variational sweeps, single-site. Most accurate at a given bonddim. |
"vmpob" |
Variational sweeps, two-site. |
"dmpo" |
Zip-up only. Faster per application, less accurate. |
"vmpoa" is the default because accuracy at a fixed bonddim is usually worth
more than throughput: if "dmpo" forces you to raise bonddim to recover the
same fidelity, it is not the cheaper option. Reach for "dmpo" when a \(\chi\)
sweep shows your circuit is far from the cap and the extra accuracy is not
buying anything.
Why qubit order matters¶
An MPS is a one-dimensional chain, so entanglement between adjacent qubits is cheap and entanglement between distant qubits is expensive. A gate on qubits 0 and 40 forces a large bond dimension across everything between them. Relabelling qubits so interacting pairs sit close together can lower \(\chi\) for the same circuit by a large factor.
execute() does this by default (reorderqubits=True). It reads the
two-qubit gate graph, picks a permutation that keeps interacting qubits near
each other, evolves in that order, and maps every result back to your original
qubit labelling. Samples, amplitudes, and z-variables all come back in the
labelling you used.
Reordering helps when the circuit's interaction graph has locality that the qubit numbering hides: long-range pairings, a permuted layout, a circuit generated from a graph problem. It cannot help when the interaction graph is genuinely all-to-all, and it does nothing for a circuit that is already nearest-neighbour. The cost is one pass over the gate list before evolution.
Bond observables are the one thing that is not permuted. BondDim(),
SchmidtRank(), and VonNeumannEntropy() take a bond index, naming the
bipartition {0..k-1} | {k..n-1}, not a qubit. Reordering keeps those cuts
intact and leaves the index alone, so the observable still reports the
bipartition you asked for.
Sampling mode and trajectory mode¶
execute() picks one of two modes from the circuit's content. The mode decides
what nsamples means and how much work a run is.
| Sampling mode | Trajectory mode | |
|---|---|---|
| Chosen when | The circuit is unitary, apart from trailing measurements | The circuit contains a mid-circuit measurement, reset, IfStatement, or noise channel |
| Evolutions | One | nsamples, each from a fresh state |
nsamples means |
Bitstrings drawn from the final state | Independent runs of the whole circuit |
| Cost | One evolution plus cheap sampling | Scales linearly with nsamples |
results.fidelities |
One entry | nsamples entries |
Sampling mode is far cheaper: the expensive part happens once and shots are drawn from the resulting state. Trailing measurements do not cost you this, because they are stripped before evolution and reapplied when samples are mapped onto classical bits.
Trajectory mode is required when the circuit branches. A measurement collapses
the state, so every shot follows a different history and each one needs its own
evolution. Noise channels are the same: each trajectory samples one branch of
the channel, and the ensemble over trajectories reproduces the mixed state.
Raising nsamples in trajectory mode raises the cost proportionally.
One consequence worth knowing before you rely on it: an amplitude requested in
trajectory mode, whether through bitstrings= or an Amplitude instruction,
reports the last trajectory's state rather than an ensemble average. Amplitudes
are a pure-state quantity, and a trajectory ensemble is a mixed state.
Z-variable observations (Amplitude, BondDim, SchmidtRank,
VonNeumannEntropy, ExpectationValue) read the state without collapsing it,
so they never push a circuit into trajectory mode.
Relationship to MIMIQ¶
TensorWeaver is a simulator for MIMIQ, not a separate circuit language. You
build circuits with mimiqcircuits
and the objects you already use carry over:
| MIMIQ object | Role in TensorWeaver |
|---|---|
Circuit, c.push(gate, *qubits) |
The circuit to simulate. |
Gates (GateH, GateCX, GateRX, ...) |
Decomposed to TensorWeaver's native set, then applied. |
Measure, Reset, IfStatement |
Select trajectory mode; handled inline per shot. |
BitString |
Measurement outcomes and amplitude queries. |
Z-variable ops (Amplitude, ExpectationValue, BondDim, SchmidtRank, VonNeumannEntropy) |
Non-destructive observations written to the z-register. |
QCSResults |
The return value of execute(): samples, amplitudes, fidelities, timings. |
Where a concept is a MIMIQ concept rather than a TensorWeaver one, such as how
gates are defined or what a BitString contains, the
MIMIQ documentation is the
reference.
Bitstring / qubit ordering
TensorWeaver uses little-endian by qubit index: bitstring position i
is qubit i, so bs[0] is qubit 0. This is the opposite of Qiskit's
convention, so bitstrings from results.cstates, results.histogram(),
and mps.sample_bitstring() appear reversed relative to Qiskit output.
The same convention applies to any BitString you pass to
mps.amplitude() or execute(..., bitstrings=...).