Skip to content

tenet.linalg

Factorizations and matrix functions. Exposed as tenet.linalg; the module lives at tenet.ops.linalg.

tenet.ops.linalg

Fixed-structure blockwise decompositions — svd, qr, eigh, polar, lq.

Each puts left into the codomain and right into the domain with repartition — inheriting its refusals — then factorizes one dense matrix per coupled sector. The only new object is the bond space, a fresh GradedSpace with degeneracy min(*layout.shape(c)) at c: static metadata, so every function here is shape-static and traceable.

Fixed structure only — the compact SVD/QR, no truncation, no tolerance, no zero-sector elimination, so a rank-deficient B_c keeps its full min bond degeneracy and carries zero singular values. Dropping them is structure-changing and belongs outside the jit boundary. svd(..., bond=B) is no exception: B is a GradedSpace decided outside the traced region.

Conventions:

  • The bond leg is non-dual on both sides and differs only in side, exactly identity's mirror convention, so the coupled sectors of U, S and Vh are literally layout.sectors.
  • S is a diagonal operator SymmetricTensor on the bond space, so U @ S @ Vh is a plain compose chain. Its raw values are {c: ar.do("diagonal", m) for c, m in tenet.to_matrices(S).items()}, real even when U and Vh are complex.
  • Reconstruction is exact against repartition(t, left, right), not against t: the factors' axis order is (*left, bond) and (bond, *right).
  • The gauge freedom (per-singular-value phases; the sign of R's diagonal) is never fixed here.

svd/qr are absent from array/dispatch.py, whose docstring owns that closed list.

BondSelection dataclass

BondSelection(
    bond: GradedSpace,
    dense_dim: float,
    reduced_dim: int,
    kept: tuple[tuple[float, Sector, int], ...],
    discarded: tuple[tuple[float, Sector, int], ...],
    discarded_weight: float,
    next_dense_cost: float,
    max_bond: int | None,
    scale: float,
)

The truncation decision: which bond survives, what it cost, what was dropped.

Parameters:

Name Type Description Default
bond GradedSpace

The truncated bond space — kept sectors with their multiplicities. This is what svd(t, axes, bond=...) consumes.

required
dense_dim float

Sum_c qdim(c)*m_c, the dimension max_bond bounds. A float because qdim is (2j+1 on SU(2), 1 on U(1) and fermionic parity, the golden ratio on a Fibonacci category, where it is not a dimension at all).

required
reduced_dim int

Sum_c m_c, what the reduced blocks are actually made of. Kept separate from dense_dim because max_bond bounds the first and callers routinely mean the second.

required
kept tuple of (float, Sector, int)

The surviving (magnitude, sector, index) triples, descending by magnitude. index is the position within that sector's spectrum in the backend's own order.

required
discarded tuple of (float, Sector, int)

The dropped triples, in the same order and the same convention. Always retained — see Notes.

required
discarded_weight float

Sum_discarded qdim(c)*sigma**2. The standard DMRG convergence datum; it equals norm(t)**2 - norm(U @ S @ Vh)**2 at renorm=False.

required
next_dense_cost float

qdim(c) of the next multiplet below the cut — what admitting one more singular value would add to dense_dim — or 0.0 when nothing was discarded.

required
max_bond int or None

The bound that produced this selection, echoed back so undershoot is a property of the object rather than of the call site.

required
scale float

The factor renorm=True applies to the kept magnitudes, sqrt(Sum_all qdim sigma**2 / Sum_kept qdim sigma**2); 1.0 at renorm=False. Every magnitude reported above is bare — this is the one place the rescaling lives.

required

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> W = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(W, OUT), Leg(W, IN)), seed=0)
>>> selection = tenet.linalg.select_bond(t, max_bond=2)
>>> selection.bond.sectors
((U1Sector(charge=0), 1), (U1Sector(charge=1), 1))
>>> (selection.dense_dim, selection.reduced_dim, len(selection.discarded))
(2.0, 2, 1)
Notes

Immutable, and deliberately not a JAX pytree. A frozen dataclass beside MapLayout, the other array-free structural record, and tenet/pytree.py registers SymmetricTensor and nothing else — so this type is neither a registered container nor an intended leaf. It is decided outside the traced region and only its bond (a hashable, array-free GradedSpace) crosses into one, as a static argument. Passing the whole record into jit would make its Python floats leaves, which is the accident the surrounding split exists to prevent.

discarded is always retained, and there is no flag to drop it. One triple costs about 116 bytes of Python object (the tuple, the float, the small-int index; the sector is one shared reference), so a spectrum of N values costs 116 N bytes against the 8 * Sum_c rows_c * cols_c bytes the blocks it came from already occupy — a ratio of 14.5 / max(rows_c, cols_c), negligible by the time a coupled sector is a few hundred dimensions wide. An opt-in flag would buy that back at the price of a keyword whose only job is to make the object's contents conditional, and the discarded weight — which every caller wants — has to walk the same list anyway. The regime where the list dominates is the regime where the tensor is small enough not to care.

next_multiplet property

next_multiplet: tuple[float, Sector, int] | None

The largest discarded (magnitude, sector, index), or None.

Returns:

Type Description
tuple of (float, Sector, int), or None

What the cut stopped just short of; pair it with next_dense_cost to decide whether admitting it is worth the dense dimension.

undershoot property

undershoot: float | None

max_bond - dense_dim, or None when no max_bond was given.

Returns:

Type Description
float or None

How much of the dense budget the greedy walk left unspent. Zero for U(1) and fermionic parity; on SU(2) it is up to max qdim(c) - 1, because the walk stops at the first multiplet that would overflow rather than scanning on for a cheaper one that still fits.

svd

svd(
    t: SymmetricTensor,
    axes: Axes = None,
    *,
    bond: GradedSpace | None = None,
) -> tuple[
    SymmetricTensor, SymmetricTensor, SymmetricTensor
]

T = U ∘ S ∘ Vh (bond=None) or T ≈ U ∘ S ∘ Vh on a pre-decided bond.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to factorize.

required
axes tuple of two axis sequences, or None

(left, right) names public axes in t's own numbering; left becomes the codomain and right the domain. None (the default) uses the current partition and is what t.as_map().svd() calls.

None
bond GradedSpace or None

None (the default) is the compact SVD: exact, on the min(rows_c, cols_c) bond, no truncation. bond=B projects the same factorization onto B, which must be a subspace of the untruncated bond; B is typically taken from a prior svd_truncated run.

None

Returns:

Name Type Description
U SymmetricTensor

Legs (*left legs, bond IN).

S SymmetricTensor

Legs (bond OUT, bond IN), diagonal, real even for complex input.

Vh SymmetricTensor

Legs (bond OUT, *right legs).

Raises:

Type Description
ValueError

If bond asks for more singular values in some sector than the untruncated bond degeneracy there (min(rows_c, cols_c), possibly zero) — a missing or short sector is never padded with zeros. Also repartition's axis refusals through the lowering.

CapabilityError

Inherited whole from the lowering's transpose and bend when the requested partition needs a braid or bend the provider cannot supply.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> u, s, vh = tenet.linalg.svd(a)
>>> bool(tenet.allclose(u @ s @ vh, a))
True
>>> s.legs[0].space.sectors
((U1Sector(charge=0), 1), (U1Sector(charge=1), 1))
Notes

A block-less t — legs that cannot couple to any total charge — factorizes rather than refusing: the bond space is empty, and U, S and Vh come back block-less on it. That is the honest answer, not a degenerate one; the factorization is exact (both sides are the zero-dimensional map) and it composes, so a caller that hit an uncouplable partition gets the same reconstruction identity as anywhere else instead of an exception it would only have to special-case.

bond=None is the compact SVD: exact, on the min(rows_c, cols_c) bond, no truncation. bond=B is the same factorization projected onto B, and then the reconstruction is no longer exactU @ S @ Vh is the best approximation of repartition(t, left, right) at those per-sector ranks (Eckart-Young), not equal to it. In each sector c of B the largest B.degeneracy(c) singular values are kept — a prefix, since sigma_c comes back descending — and every sector absent from B is dropped. The truncation error stays one line by Pythagoras: norm(t)**2 - norm(U @ S @ Vh)**2.

B must be a subspace of the untruncated bond: every sector of B is a sector of the min bond, with no larger degeneracy. Refused with a ValueError naming the sector and both degeneracies, structurally and before a single block is read, so the refusal is as traceable as the rest of the function. Nothing is ever silently zero-padded.

bond= does not make this function structure-changing, which is why it is a keyword here while truncation is a separate svd_truncated: a GradedSpace is frozen, hashable, array-free metadata that the caller decided, so svd(t, bond=B) is exactly as shape-static, jittable and differentiable as svd(t). What is never a keyword is the decision to truncate; what is a keyword here is the decision's result. The pairing::

_, s, _ = tenet.linalg.svd_truncated(t0, axes, max_bond=D)   # outside jit/grad
bond = s.structure.legs[0].space

@jax.jit
def step(t):
    u, s, vh = tenet.linalg.svd(t, axes, bond=bond)          # inside, fixed shape

The gradient under bond= is the exact truncated backward, and it needed no code. Reverse mode differentiates the per-sector prefix slice generically, zero-padding the cotangent, and the matrix SVD underneath is the compact one from tenet.ad. That composition is not the usual approximation: because the bond degeneracy is min(rows_c, cols_c) and never the numerical rank, the discarded space never leaves the factorization, and the cross block of tenet.ad's broadened F — rows i > k against columns j <= k, weighted by 1/(sigma_j - sigma_i) — is exactly the correction Francuz-Schuch-Vanhecke add in Eqs. (14)-(15) of Phys. Rev. Research 7, 013237 (2025). Against central differences it is flat at finite-difference noise across sigma_perp/sigma_min from 0.1 to 0.9, while the zeroth-order rule (the same VJP formed against the kept factors only, which is what the CTMRG/iPEPS literature runs on) carries an O(sigma_perp/sigma_min) error. Both are pinned in tests/backends/test_ad.py. Degeneracy is the one remaining caveat, and it is tenet.ad's: a multiplet straddling the cut makes the kept subspace gauge-dependent, so the gradient there is finite but meaningless.

qr

qr(
    t: SymmetricTensor, axes: Axes = None
) -> tuple[SymmetricTensor, SymmetricTensor]

T = Q ∘ R, the reduced/compact QR — svd's skeleton and bond.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to factorize.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None

Returns:

Name Type Description
Q SymmetricTensor

Legs (*left legs, bond IN); an isometry sector by sector.

R SymmetricTensor

Legs (bond OUT, *right legs), upper triangular per sector. The sign of its diagonal is the backend's; no stabilization is applied.

Raises:

Type Description
ValueError

repartition's axis refusals through the lowering.

CapabilityError

Inherited from the lowering when the partition needs a braid or bend the provider cannot supply.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> q, r = tenet.linalg.qr(a)
>>> bool(tenet.allclose(q @ r, a))
True
Notes

A block-less t gives a block-less Q and R on an empty bond, as in svd.

Differentiability: the gradient is JAX's own, and it is the standard rule (Liao-Liu-Wang-Xiang Eq. (5) for the square/tall case; Roberts-Roberts Eqs. (9)-(10) for the wide one, which is what JAX >= 0.10 implements). It is finite and correct for any sector whose R is nonsingular. A sector matrix that is exactly rank-deficient -- an exact zero on R's diagonal -- gives NaN, and that is not stabilized here: unlike svd's degeneracy, the QR of a rank-deficient matrix is itself non-unique, so there is no correct value to broaden towards. See tenet.ad.

eigh

eigh(
    t: SymmetricTensor,
    axes: Axes = None,
    *,
    bond: GradedSpace | None = None,
) -> tuple[SymmetricTensor, SymmetricTensor]

T = V ∘ W ∘ V† for a self-adjoint T. Returns (W, V).

Parameters:

Name Type Description Default
t SymmetricTensor

The map to diagonalize. It must be square space-wise — codomain and domain carry the same (space, dual) in order — and Hermiticity of the numbers is the caller's responsibility (see Notes).

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None
bond GradedSpace or None

None (the default) is the full diagonalization: every eigenvalue, in the backend's ascending order. bond=B keeps the B.degeneracy(c) eigenvalues of largest magnitude in each sector c and drops every sector absent from B; B must be a subspace of the untruncated bond, and is typically taken from a prior eigh_truncated run. Exactly svd's keyword, with the same meaning and the same refusal — see Notes for the one place the mirror is not literal.

None

Returns:

Name Type Description
W SymmetricTensor

Legs (bond OUT, bond IN), diagonal, real even for complex input. At bond=None the eigenvalues are in the backend's order — ascending within each coupled sector; at bond=B they are the kept ones, in descending order of |w|. Their signs are kept either way.

V SymmetricTensor

Legs (*left legs, bond IN), the eigenvectors.

Raises:

Type Description
ValueError

If the map is not square space-wise (check_square's refusal); if bond asks for more eigenvalues in some sector than the untruncated bond degeneracy there (_keep_counts' refusal, shared with svd and worded for it); or repartition's axis refusals through the lowering.

CapabilityError

Inherited from the lowering when the partition needs a braid or bend the provider cannot supply.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> h = a @ tenet.adjoint(a)  # Hermitian by construction
>>> w, v = tenet.linalg.eigh(h)
>>> bool(tenet.allclose(v @ w @ tenet.adjoint(v), h))
True
Notes

The map must be square space-wise — see check_square — and for a square map _lower's min(rows, cols) is a no-op, so the bond space is literally the fused domain.

Hermiticity of the numbers is the caller's responsibility and is deliberately not checked. A numerical check needs a tolerance, and a tolerance comparison is a data-dependent branch, which cannot run inside a traced region (invariant 9); eigh is fixed-structure and must stay jittable. A non-Hermitian input is not refused: the backend reads one triangle and you get whatever that gives. The user-side check needs no new API::

max(norm(B - B.conj().T) for B in tenet.to_matrices(tenet.repartition(T, l, r)).values())

run once, outside the hot loop.

At bond=None eigenvalues come back in the backend's order — ascending within each coupled sector (LAPACK's), in deliberate contrast to svd's descending S. Re-sorting would be a cosmetic permutation of W and of V's columns, and would still buy nothing across sectors, where no global order exists either way. W is real even for complex input.

bond=B is where the mirror of svd's keyword stops being literal, in exactly one place: the kept set is not a prefix. svd slices [:k] because sigma_c comes back descending; eigenvalues come back ascending and signed, so "the k largest" is an argsort over |w| and a gather, not a slice. A gather is a value-dependent permutation, never a value-dependent shape, so it traces: eigh(t, axes, bond=B) is as jittable and differentiable as eigh(t, axes), and svd's sentence applies unchanged — the decision to truncate is never a keyword; what is a keyword here is the decision's result.

The sign is kept. Only the ordering key is |w|; W's retained entries are the signed eigenvalues, so V @ W @ adjoint(V) reconstructs an indefinite operator with its signs intact. That is the whole reason the Hermitian route exists beside the SVD, which returns |w| and throws away which of them were negative.

The gradient needed no new code, for svd's reason: reverse mode differentiates the gather generically, and the matrix eigh underneath is tenet.ad's broadened one, whose 1/(w_i - w_j) factors are stabilized alongside the SVD's. The bond degeneracy is min(rows_c, cols_c) — for a square map, the fused domain — and never the numerical rank, so the discarded space never leaves the factorization.

polar

polar(
    t: SymmetricTensor,
    axes: Axes = None,
    side: str = "left",
) -> tuple[SymmetricTensor, SymmetricTensor]

T = W ∘ P (side="left") or T = P ∘ W (side="right").

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to decompose.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None
side (left, right)

Which side the isometry sits on — TensorKit's convention. Default "left".

"left"

Returns:

Name Type Description
W SymmetricTensor

The isometry, always first whichever side it sits on; it carries exactly repartition(t, left, right)'s structure.

P SymmetricTensor

The positive factor: an endomorphism of the domain (side="left") or of the codomain (side="right"), its legs mirrored from that side.

Raises:

Type Description
ValueError

If side is neither "left" nor "right", or repartition's axis refusals through the lowering.

CapabilityError

Inherited from the lowering when the partition needs a braid or bend the provider cannot supply.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> w, p = tenet.linalg.polar(a)
>>> bool(tenet.allclose(w @ p, a))
True
Notes

Always returns (W, P) — the isometry first, whichever side it sits on. The name says which side the isometry sits on, TensorKit's convention; a tuple whose order depended on a keyword would be a footgun.

No bond leg survives, so polar is the one decomposition here insensitive to the min-rank bond convention, which is what makes it the gauge-fixing primitive.

W is only a partial isometry when some B_c is rank-deficient: then W†W is an orthogonal projector rather than the identity and P is singular. Same fact as svd's zero-rank sectors — structure is metadata, rank is data.

A block-less t — legs that cannot couple to any total charge — gives a block-less W, and then W @ P reproduces t for every P: the mirrored structure it lives on does admit blocks, and the zero morphism is the representative returned. Its backend and dtype follow the same rule as everywhere a block-less operand meets a structure with blocks: NumPy float64, there being no other operand to take them from.

lq

lq(
    t: SymmetricTensor, axes: Axes = None
) -> tuple[SymmetricTensor, SymmetricTensor]

T = L ∘ Q, the reduced LQ. Same bond space as qr.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to factorize.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None

Returns:

Name Type Description
L SymmetricTensor

Legs (*left legs, bond IN), lower triangular per sector. No stabilization: the sign of its diagonal is the backend's, as in qr.

Q SymmetricTensor

Legs (bond OUT, *right legs); an isometry sector by sector.

Raises:

Type Description
ValueError

repartition's axis refusals through the lowering.

CapabilityError

Inherited from the lowering when the partition needs a braid or bend the provider cannot supply.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> ell, q = tenet.linalg.lq(a)
>>> bool(tenet.allclose(ell @ q, a))
True
Notes

Per sector B† = q r gives B = r† q†: two dense conjugate-transposes in the loop, no new backend primitive and no capability at all. The categorical spelling adjoint → qr → adjoint would give factors whose public axis order is (bond, *left), and straightening that needs tenet.transpose — i.e. PermutationCoefficients and the fermionic Koszul signs — for the same numbers.

Differentiability: the gradient is JAX's own, inherited through qr(B†) -- so a rows > cols sector, the ordinary case here, hands JAX a wide matrix and needs the wide-QR JVP of JAX >= 0.10 (Roberts-Roberts Eqs. (9)-(10); Liao-Liu-Wang-Xiang Eq. (5) for the other side). It is finite and correct for any sector whose L is nonsingular. A sector matrix that is exactly rank-deficient -- an exact zero on L's diagonal -- gives NaN, and that is not stabilized here: unlike svd's degeneracy, the LQ of a rank-deficient matrix is itself non-unique, so there is no correct value to broaden towards. See tenet.ad.

left_null

left_null(
    t: SymmetricTensor, axes: Axes = None
) -> SymmetricTensor

The isometry onto the orthogonal complement of T's image: N† T = 0.

Parameters:

Name Type Description Default
t SymmetricTensor

The map whose cokernel is taken.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None

Returns:

Type Description
SymmetricTensor

N, legs (*left legs, bond IN)qr's Q legs exactly — with adjoint(N) @ N == identity(bond) and adjoint(N) @ repartition(t, left, right) zero. The bond degeneracy is rows_c - cols_c in every coupled sector where it is positive; the sector is omitted otherwise.

Raises:

Type Description
ValueError

If no coupled sector has rows_c > cols_c — the complement is empty, and the message names every sector's (rows, cols) and points at right_null. Also repartition's axis refusals through the lowering.

CapabilityError

Inherited from the lowering when the partition needs a braid or bend the provider cannot supply.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> W = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(W, OUT), Leg(V, IN)), seed=0)
>>> n = tenet.linalg.left_null(t)
>>> round(float(tenet.norm(tenet.adjoint(n) @ t)), 6)
0.0
Notes

"Left" and "null" compose into the wrong intuition about half the time, so the identity rather than the word is the contract: this is the cokernel, the null space of T†.

The bond space is structural: degeneracy rows_c - cols_c in every coupled sector where rows_c > cols_c, and the sector is omitted where it is not. It is read off MapLayout — metadata against metadata, no block value anywhere — so this is shape-static, jittable and differentiable, the same argument svd(..., bond=) and embed make.

This is the shape null space, not the numerical one. A rank-deficient B_c has a larger true null space; what is returned is a subspace of it — always orthogonal to T, never complete. Determining the numerical rank would make the output structure depend on block values, which is _lower's standing refusal ("min(rows_c, cols_c) is metadata, never the numerical rank") applied to the complement.

Complete QR, not a full SVD, and the reason is differentiability: JAX refuses to differentiate a full SVD (_svd_jvp_rule's "not implemented for full matrices") for exactly the non-square shapes that have a null space, while the complete QR differentiates since JAX 0.10 ("and when full_matrices is True"), which is the floor this library already declares.

right_null

right_null(
    t: SymmetricTensor, axes: Axes = None
) -> SymmetricTensor

The mirror of left_null on the domain: T N† = 0.

Parameters:

Name Type Description Default
t SymmetricTensor

The map whose kernel is taken.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None

Returns:

Type Description
SymmetricTensor

N, legs (bond OUT, *right legs)lq's Q legs — with N N† = id and T N† = 0. The bond degeneracy is cols_c - rows_c where positive; the sector is omitted otherwise.

Raises:

Type Description
ValueError

If no coupled sector has cols_c > rows_c — the mirror of left_null's refusal — or repartition's axis refusals through the lowering.

CapabilityError

Inherited from the lowering when the partition needs a braid or bend the provider cannot supply.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> W = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN), Leg(W, IN)), seed=0)
>>> n = tenet.linalg.right_null(t)
>>> round(float(tenet.norm(t @ tenet.adjoint(n))), 6)
0.0
Notes

This is the kernel, where left_null is the cokernel.

Per sector B† = q r complete and N = (q[:, rows_c:])†: the same two dense conjugate-transposes lq already uses, no new backend primitive and no second implementation of the factorization. Every paragraph of left_null — the structural bond, the shape-versus-numerical stance, the complete QR and the refusal — applies unchanged.

expm

expm(
    t: SymmetricTensor,
    axes: Axes = None,
    *,
    alpha: Any = 1.0,
) -> SymmetricTensor

exp(alpha * T) for a square map, one dense exponential per coupled sector.

Parameters:

Name Type Description Default
t SymmetricTensor

The endomorphism to exponentiate. The map must be square space-wise — the same (space, dual)-in-order requirement, and the same refusal, eigh has. Hermiticity is neither required nor assumed.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as everywhere in this module; None (the default) uses the current partition and is what t.as_map().expm() calls.

None
alpha scalar

A scalar multiplying T before exponentiation; it is where -i dt goes: expm(h, alpha=-1j * dt) is the Trotter gate. Default 1.0.

1.0

Returns:

Type Description
SymmetricTensor

exp(alpha * T), carrying repartition(t, left, right)'s structure exactly — no bond leg is created and no leg changes.

Raises:

Type Description
ValueError

If the map is not square space-wise (check_square's refusal), or repartition's axis refusals through the lowering.

CapabilityError

Inherited from the lowering when the partition needs a braid or bend the provider cannot supply.

ImportError

On the NumPy backend without SciPy installed — SciPy is deliberately not a dependency, and the message names pip install scipy.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> e = tenet.linalg.expm(0.0 * a)  # exp(0) is the identity
>>> bool(tenet.allclose(e, tenet.identity(a.codomain)))
True
Notes

The result carries repartition(t, left, right)'s structure exactly: no bond leg is created and no leg changes, so expm is the second decomposition (with polar's W) that is insensitive to the min-rank bond convention.

A complex alpha promotes real blocks to complex by the backend's own rule, one coupled sector at a time, which is why it is a multiplier here rather than the caller's expm(alpha * t).

Hermiticity is neither required nor assumed. Unlike an eigh-based exponential — V diag(exp(alpha w)) V†, which reads one triangle and returns a plausible wrong answer off the Hermitian locus — this is correct for any square map, and its gradient carries no 1/(w_i - w_j), so a degenerate spectrum is finite under stock JAX and tenet.ad is not needed here at all.

The ceiling is JAX's and it is silent. jax.scipy.linalg.expm is scaling-and-squaring with Padé under a max_squarings=16 limit enforced by lax.cond(n_squarings > max_squarings, _nan, _compute, ...) (jax/_src/scipy/linalg.py): a block whose ‖alpha·B_c‖ needs more squarings comes back as NaN rather than raising. No guard is added here — a norm comparison is a data-dependent branch and could not run inside a trace (invariant 9) — and the caller's escape is the one physics already uses, exp(alpha H) = exp(alpha H / n)**n.

On the NumPy backend the exponential is SciPy's, and SciPy is not a dependency of this library; without it the call raises an ImportError naming pip install scipy.

eig

eig(
    t: SymmetricTensor, axes: Axes = None
) -> tuple[SymmetricTensor, SymmetricTensor]

T V = V W for a square, not necessarily Hermitian T. Returns (W, V).

Parameters:

Name Type Description Default
t SymmetricTensor

The map to diagonalize; square space-wise, not necessarily Hermitian.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None

Returns:

Name Type Description
W SymmetricTensor

Legs (bond OUT, bond IN), diagonal; complex always, even for a real input, in the backend's (unsorted) order.

V SymmetricTensor

Legs (*left legs, bond IN), the right eigenvectors; complex always, and not an isometry for a non-normal map.

Raises:

Type Description
ValueError

If the map is not square space-wise (check_square's refusal), or repartition's axis refusals through the lowering.

CapabilityError

Inherited from the lowering when the partition needs a braid or bend the provider cannot supply.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> w, v = tenet.linalg.eig(a)
>>> round(float(tenet.norm(a @ v - v @ w)), 6)  # the checkable residual
0.0
Notes

Legs, and the return order, are eigh's exactly: W is (bond OUT, bond IN) and diagonal, V is (*left legs, bond IN), and for a square map _lower's min(rows, cols) is a no-op so the bond space is the fused domain. Same (space, dual)-in-order square-map refusal.

Both outputs are complex, always, even for a real input — a real matrix has complex eigenvalues in conjugate pairs, so there is no real answer to return. W is complex where eigh's is real. Without x64 under JAX it is complex64, the backend's own dtype policy, as to_backend documents.

V is not an isometry. Right eigenvectors of a non-normal matrix are not orthogonal: adjoint(V) @ V is not the identity, and the reconstruction is V W V^-1, which this library cannot spell because it has no inv. The checkable statement, and the one the tests use, is the residual T @ V - V @ W.

Eigenvalues come back in the backend's order, unsorted. eigh's argument, only stronger: complex numbers have no order at all, so "sorted" would have to mean "by |λ| descending", a choice — and the caller is the one who knows whether "dominant" means largest modulus, largest real part, or largest within a chosen sector. It is one max over one comprehension out there.

Not differentiable under JAXjax.grad raises JAX's own NotImplementedError naming enable_eigvec_derivs, and that error is propagated unchanged, neither caught nor re-phrased and above all not opted into: the flag turns on an eigenvector derivative under assumptions on the input that JAX cannot check, and a library may not make an unverifiable numerical assumption on every caller's behalf. Apply it to your own blocks if you want it. Use eigvals when the objective needs only the spectrum; it is differentiable.

Platform: CPU and NVIDIA GPU (cuSolver by default since JAX 0.8.0); TPU has no lowering.

eigvals

eigvals(
    t: SymmetricTensor, axes: Axes = None
) -> SymmetricTensor

The eigenvalues of a square map, as a diagonal (bond OUT, bond IN) tensor.

Parameters:

Name Type Description Default
t SymmetricTensor

The map whose spectrum is taken; square space-wise, not necessarily Hermitian.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None

Returns:

Type Description
SymmetricTensor

The eigenvalues as a diagonal (bond OUT, bond IN) tensor; complex always, in the backend's (unsorted) order, exactly as eig's W.

Raises:

Type Description
ValueError

If the map is not square space-wise (check_square's refusal), or repartition's axis refusals through the lowering.

CapabilityError

Inherited from the lowering when the partition needs a braid or bend the provider cannot supply.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 1, U1Sector(1): 1})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> bool(tenet.allclose(tenet.linalg.eigvals(a), tenet.linalg.eig(a)[0]))
True
Notes

Differentiable, unlike eig: JAX implements the eigenvalues-only JVP unconditionally, and it needs no assumption a library cannot check. Jittable on CPU and on NVIDIA GPU — the "CPU backend" line in jax.numpy's own eigvals docstring is stale upstream text, not behaviour. Complex always, order is the backend's, exactly as eig's.

Equal to eig(t)[0] — the same LAPACK/cuSolver driver with the eigenvector job switched off — and it is the values-only call that carries the gradient.

select_bond

select_bond(
    t: SymmetricTensor,
    axes: Axes = None,
    *,
    max_bond: int | None = None,
    cutoff: float | None = None,
    cutoff_mode: str = "rsum2",
    renorm: bool = False,
) -> BondSelection

The truncation decision svd_truncated makes, returned instead of consumed. NOT jittable.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose bond is being chosen.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None
max_bond int or None

A bound on the dense bond dimension Sum_c qdim(c)*m_c, exactly as in svd_truncated, undershoot included — and here the undershoot is reported rather than only documented. None (the default) means no dimension bound.

None
cutoff float or None

The truncation threshold, interpreted by cutoff_mode. None (the default) means no cutoff; passing neither max_bond nor cutoff is refused, naming svd.

None
cutoff_mode (abs, rel, sum2, rsum2, sum1, rsum1)

Quimb's names and quimb's semantics, as in svd_truncated. Default "rsum2".

"abs"
renorm bool

True reports the rescaling in BondSelection.scale; it changes no magnitude in the record, which is bare throughout. Default False.

False

Returns:

Type Description
BondSelection

The decision: the bond space, its dense and reduced dimensions, the kept and discarded magnitudes with their sectors, the discarded weight, and the next multiplet below the cut with its dense cost.

Raises:

Type Description
StructureChangingError

Under jax.jit/jax.grad/jax.vmap, with svd_truncated's message naming this function: the decision reads the block values, so it cannot be traced.

ValueError

The same argument refusals as svd_truncated — no bound at all, a non-positive max_bond, a negative cutoff, an unknown cutoff_mode, no singular values, or a selection that keeps none.

TypeError

If renorm is not a bool.

CapabilityError

If the provider does not implement QuantumDimensionData, plus the lowering's refusals as in svd.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> W = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(W, OUT), Leg(W, IN)), seed=0)
>>> selection = tenet.linalg.select_bond(t, max_bond=2)
>>> u, s, vh = tenet.linalg.svd(t, bond=selection.bond)  # jittable half
>>> s.shape
(2, 2)
>>> round(selection.discarded_weight, 12) == round(
...     float(tenet.norm(t)) ** 2 - float(tenet.norm(u @ s @ vh)) ** 2, 12
... )
True
Notes

This is the first half of the pairing svd's bond= documents, made explicit::

selection = tenet.linalg.select_bond(t0, axes, max_bond=D)   # outside jit/grad
u, s, vh = tenet.linalg.svd(t, axes, bond=selection.bond)    # inside

svd(t, axes, bond=select_bond(t, axes, **kw).bond) returns exactly what svd_truncated(t, axes, **kw) returns at renorm=False — same factors, same bond, same numbers — because both go through the one keep rule below. At renorm=True the kept singular values differ by BondSelection.scale, which svd(..., bond=), being a projection and not a rescaling, does not apply.

Selection is over one global spectrum, qdim-weighted in cost and weight, exactly as svd_truncated describes at length; nothing about the rule is restated here, because there is only one of it.

BondSelection is where the non-Abelian case stops being invisible. max_bond bounds the dense dimension, so on SU(2) the walk can stop with budget left over — undershoot says how much, next_multiplet says what it would have bought and next_dense_cost what that costs. On U(1) and fermionic parity the undershoot is always zero and this record is a convergence log; on SU(2) it is the answer to "why is my bond smaller than I asked for".

svd_truncated

svd_truncated(
    t: SymmetricTensor,
    axes: Axes = None,
    *,
    max_bond: int | None = None,
    cutoff: float | None = None,
    cutoff_mode: str = "rsum2",
    renorm: bool = False,
) -> tuple[
    SymmetricTensor, SymmetricTensor, SymmetricTensor
]

U, S, Vh on a truncated bond space. NOT jittable.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to factorize and truncate.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None
max_bond int or None

A bound on the dense bond dimension Sum_c qdim(c)*m_c — not the reduced Sum_c m_c; may be undershot by up to max qdim(c) - 1 (see Notes). None (the default) means no dimension bound; passing neither max_bond nor cutoff is refused, naming svd.

None
cutoff float or None

The truncation threshold, interpreted by cutoff_mode. None (the default) means no cutoff. max_bond and cutoff together take the intersection.

None
cutoff_mode (abs, rel, sum2, rsum2, sum1, rsum1)

Quimb's names and quimb's semantics; only the strings are accepted, never quimb's integer codes. The table in Notes gives each mode's keep rule. Default "rsum2".

"abs"
renorm bool

True scales the kept singular values by sqrt(norm(T)**2 / Sum_kept qdim(c) sigma^2) so that tenet.norm(U @ S @ Vh) == tenet.norm(t). A bool, not quimb's p-norm power. Default False.

False

Returns:

Name Type Description
U SymmetricTensor

Legs (*left legs, bond IN), on the truncated bond.

S SymmetricTensor

Legs (bond OUT, bond IN), diagonal; the truncated bond space — the input to svd(t, axes, bond=...) — is S.structure.legs[0].space.

Vh SymmetricTensor

Legs (bond OUT, *right legs), on the truncated bond.

Raises:

Type Description
StructureChangingError

Under jax.jit/jax.grad/jax.vmap: the output structure depends on the block values, so this function cannot run inside a traced region — decide the bond out here, then use svd(..., bond=...) inside.

ValueError

If neither max_bond nor cutoff is given; if max_bond is not positive; if cutoff is negative; if cutoff_mode is not one of the six strings; if the tensor has no singular values at all; or if every singular value is dropped — a bond space with no sectors is never returned.

TypeError

If renorm is not a bool (it is not quimb's p-norm power).

CapabilityError

If the provider does not implement QuantumDimensionData, plus the lowering's refusals as in svd.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> W = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(W, OUT), Leg(W, IN)), seed=0)
>>> u, s, vh = tenet.linalg.svd_truncated(t, max_bond=2)
>>> s.shape
(2, 2)
>>> bond = s.structure.legs[0].space  # feed this to svd(..., bond=...) inside jit
>>> bond.sectors
((U1Sector(charge=0), 1), (U1Sector(charge=1), 1))
Notes

Same factor legs, same conventions and the same capability refusals as svd; the only difference is the bond GradedSpace, whose degeneracy at c is the number of kept singular values there and which omits c entirely when that number is zero. Reconstructing the graded bond space from the data is what makes this a sibling of svd rather than a keyword on it: a keyword would make one function traceable or not depending on the value of an argument, and whether a call decides a structure is a distinction the library never hides. Under jax.jit or jax.grad it raises StructureChangingError.

The bond space returned here is the input to svd(t, axes, bond=...), which is the traceable half of the pairing: decide the structure once, out here, then project onto it inside jit/grad. bond= is a keyword on svd because it carries the result of the decision, not the decision.

Selection is over one global spectrum, always, in every mode:

  • the sort key is the bare sigma (descending; ties by sector order then index) -- "how large is this singular value" has nothing to do with multiplicity;
  • the cost and the weight are qdim(c)-weighted, because the reduced index i in sector c stands for qdim(c) dense basis states. It is the same weight tenet.norm carries. Greedy-descending under a dense budget is then optimal rather than a heuristic, so the result is the best approximation of its achieved dense rank (Eckart-Young, sector-blind).

max_bond bounds the dense bond dimension Sum_c qdim(c)*m_c, not the reduced Sum_c m_c. For U(1) and fermionic parity these coincide; for SU(2) they do not, and that will surprise people. The walk stops at the first singular value that would overflow the budget rather than scanning on for a cheaper one that still fits, which is what keeps the kept set nested as max_bond grows; the documented consequence is that max_bond may be undershot by up to max qdim(c) - 1.

cutoff_mode takes quimb's names with quimb's semantics. Only the strings are accepted; the code column is quimb's own integer for cross-reference and this function refuses it.

==== ========= =========================================================== code mode keeps ==== ========= =========================================================== 1 abs sigma > cutoff 2 rel sigma > cutoff * sigma_max (the bare global max) 3 sum2 drops the largest set with Sum qdim(c) sigma^2 < cutoff 4 rsum2 as sum2, threshold cutoff * tenet.norm(T)**2 5 sum1 as sum2 at power 1, weight qdim(c) sigma 6 rsum1 as rsum2 at power 1 ==== ========= ===========================================================

max_bond and cutoff together take the intersection. None means "no truncation" -- there are no -1 sentinels, and passing neither is refused, naming svd. renorm=True scales the kept singular values by sqrt(norm(T)**2 / Sum_kept qdim(c) sigma^2) so that tenet.norm(U @ S @ Vh) == tenet.norm(t); it is a bool, not quimb's p-norm power.

No absorb enum and no fourth return value: S is a tensor, so absorbing is a one-line compose, and the truncation error is exactly tenet.norm(t)**2 - tenet.norm(U @ S @ Vh)**2 by Pythagoras.

Against TensorKit.jl. max_bond=D is TensorKit's svd_trunc(t; trunc=truncrank(D)), convention for convention: D counts dense dimensions there too (its findtruncated walk adds dim(c) per kept value under GenericFusion), whole multiplets only, and it stops at the first value that overflows rather than scanning on for a cheaper one. The one number that differs is the error: TensorKit's truncation_error is the norm of the discarded part, so it equals sqrt(BondSelection.discarded_weight) -- both absolute and both qdim-weighted, but this library reports the square. benchmarks/bench_svd_truncation.py runs the comparison. On a spectrum with exact ties the two libraries can still land on different sectors: the tie order is then decided by whichever last bit each runtime's LAPACK produced, not by either library's tie rule.

eigh_truncated

eigh_truncated(
    t: SymmetricTensor,
    axes: Axes = None,
    *,
    max_bond: int | None = None,
    cutoff: float | None = None,
    cutoff_mode: str = "rsum2",
    renorm: bool = False,
) -> tuple[SymmetricTensor, SymmetricTensor]

W, V on a truncated bond space, selected by |w|. NOT jittable.

Parameters:

Name Type Description Default
t SymmetricTensor

The self-adjoint map to diagonalize and truncate. Square space-wise, and Hermiticity of the numbers is the caller's responsibility, exactly as in eigh.

required
axes tuple of two axis sequences, or None

(left, right) in t's own numbering, as in svd; None (the default) uses the current partition.

None
max_bond int or None

A bound on the dense bond dimension Sum_c qdim(c)*m_c, with the same documented undershoot as svd_truncated. None (the default) means no dimension bound.

None
cutoff float or None

The truncation threshold on |w|, interpreted by cutoff_mode. None (the default) means no cutoff; passing neither max_bond nor cutoff is refused.

None
cutoff_mode (abs, rel, sum2, rsum2, sum1, rsum1)

Quimb's six names and quimb's semantics, read on |w|; the table is svd_truncated's. Default "rsum2".

"abs"
renorm bool

True scales the kept eigenvalues by sqrt(Sum_all qdim |w|**2 / Sum_kept qdim |w|**2), preserving tenet.norm. A bool, not quimb's p-norm power. Default False.

False

Returns:

Name Type Description
W SymmetricTensor

Legs (bond OUT, bond IN), diagonal, on the truncated bond; the signed kept eigenvalues, in descending order of |w|. The bond space — the input to eigh(t, axes, bond=...) — is W.structure.legs[0].space.

V SymmetricTensor

Legs (*left legs, bond IN), the matching eigenvectors.

Raises:

Type Description
StructureChangingError

Under jax.jit/jax.grad/jax.vmap: the output structure depends on the block values, so decide the bond out here and use eigh(..., bond=...) inside.

ValueError

If the map is not square space-wise; if neither max_bond nor cutoff is given; if max_bond is not positive; if cutoff is negative; if cutoff_mode is not one of the six strings; if the map has no eigenvalues at all; or if every eigenvalue is dropped.

TypeError

If renorm is not a bool.

CapabilityError

If the provider does not implement QuantumDimensionData, plus the lowering's refusals as in svd.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> h = a @ tenet.adjoint(a) - tenet.identity((Leg(V, OUT),))  # indefinite
>>> w, v = tenet.linalg.eigh_truncated(h, max_bond=2)
>>> w.shape
(2, 2)
>>> bond = w.structure.legs[0].space  # feed this to eigh(..., bond=...) inside jit
>>> bond.sectors
((U1Sector(charge=0), 1), (U1Sector(charge=1), 1))
Notes

svd_truncated's twin, factor for factor: the same six cutoff_mode strings, the same qdim-weighted cost and weight, the same single global spectrum, the same greedy walk under a dense max_bond with the same undershoot, the same StructureChangingError under a trace. Both call one shared keep rule, so there is no second truncation policy here — see select_bond, whose BondSelection this function consumes.

Two places where the mirror is not literal, and they are the reason the Hermitian route exists at all:

  • the ordering key is |w| and the kept set is not a prefix. Eigenvalues come back ascending, so selecting by magnitude is a gather rather than a slice; the per-sector indices are carried through the selection and gathered at the end.
  • the sign survives. W's retained entries are the signed eigenvalues, so V @ W @ adjoint(V) reconstructs an indefinite operator correctly. An SVD of the same operator returns |w| and no record of which were negative, which is a structural defect and not a tolerance: no care at the svd_truncated call site recovers it.

On a positive-definite input the two agree exactly — same bond, same magnitudes, and the same subspace up to the gauge each factorization leaves free — which is what tests/ops/test_eigh_truncated.py pins on U(1), fermionic parity and SU(2).