Skip to content

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:

\[ |\psi\rangle = \sum_{x \in \{0,1\}^n} c_x \, |x\rangle . \]

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:

\[ c_{s_1 s_2 \dots s_n} = \sum_{\alpha_1, \dots, \alpha_{n-1}} A^{s_1}_{\alpha_1} \, A^{s_2}_{\alpha_1 \alpha_2} \, \cdots \, A^{s_n}_{\alpha_{n-1}} . \]

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

\[ |\psi\rangle = \sum_{k=1}^{\chi_i} \lambda_k \, |L_k\rangle \otimes |R_k\rangle , \]

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. Call recanonicalize() afterwards.
  • Deferred compression. post_compress=False and the direct apply_gate_2q path 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 explicit mps.compress() at a quiet point. post_compress is 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=...).