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, exactlyidentity's mirror convention, so the coupled sectors ofU,SandVhare literallylayout.sectors. Sis a diagonal operatorSymmetricTensoron the bond space, soU @ S @ Vhis a plain compose chain. Its raw values are{c: ar.do("diagonal", m) for c, m in tenet.to_matrices(S).items()}, real even whenUandVhare complex.- Reconstruction is exact against
repartition(t, left, right), not againstt: 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 |
required |
dense_dim
|
float
|
|
required |
reduced_dim
|
int
|
|
required |
kept
|
tuple of (float, Sector, int)
|
The surviving |
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
|
|
required |
next_dense_cost
|
float
|
|
required |
max_bond
|
int or None
|
The bound that produced this selection, echoed back so |
required |
scale
|
float
|
The factor |
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 |
undershoot
property
¶
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 |
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
|
|
None
|
bond
|
GradedSpace or None
|
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
U |
SymmetricTensor
|
Legs |
S |
SymmetricTensor
|
Legs |
Vh |
SymmetricTensor
|
Legs |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
CapabilityError
|
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 exact — U @ 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
|
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
Q |
SymmetricTensor
|
Legs |
R |
SymmetricTensor
|
Legs |
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 |
required |
axes
|
tuple of two axis sequences, or None
|
|
None
|
bond
|
GradedSpace or None
|
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
W |
SymmetricTensor
|
Legs |
V |
SymmetricTensor
|
Legs |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the map is not square space-wise ( |
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
|
|
None
|
side
|
(left, right)
|
Which side the isometry sits on — TensorKit's convention. Default
|
"left"
|
Returns:
| Name | Type | Description |
|---|---|---|
W |
SymmetricTensor
|
The isometry, always first whichever side it sits on; it carries
exactly |
P |
SymmetricTensor
|
The positive factor: an endomorphism of the domain ( |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
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
|
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
L |
SymmetricTensor
|
Legs |
Q |
SymmetricTensor
|
Legs |
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
|
|
None
|
Returns:
| Type | Description |
|---|---|
SymmetricTensor
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If no coupled sector has |
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
|
|
None
|
Returns:
| Type | Description |
|---|---|
SymmetricTensor
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If no coupled sector has |
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 |
required |
axes
|
tuple of two axis sequences, or None
|
|
None
|
alpha
|
scalar
|
A scalar multiplying |
1.0
|
Returns:
| Type | Description |
|---|---|
SymmetricTensor
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the map is not square space-wise ( |
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 |
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
|
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
W |
SymmetricTensor
|
Legs |
V |
SymmetricTensor
|
Legs |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the map is not square space-wise ( |
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 JAX — jax.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
|
|
None
|
Returns:
| Type | Description |
|---|---|
SymmetricTensor
|
The eigenvalues as a diagonal |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the map is not square space-wise ( |
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
|
|
None
|
max_bond
|
int or None
|
A bound on the dense bond dimension |
None
|
cutoff
|
float or None
|
The truncation threshold, interpreted by |
None
|
cutoff_mode
|
(abs, rel, sum2, rsum2, sum1, rsum1)
|
Quimb's names and quimb's semantics, as in
svd_truncated. Default |
"abs"
|
renorm
|
bool
|
|
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 |
ValueError
|
The same argument refusals as
svd_truncated — no bound at all, a
non-positive |
TypeError
|
If |
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
|
|
None
|
max_bond
|
int or None
|
A bound on the dense bond dimension |
None
|
cutoff
|
float or None
|
The truncation threshold, interpreted by |
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 |
"abs"
|
renorm
|
bool
|
|
False
|
Returns:
| Name | Type | Description |
|---|---|---|
U |
SymmetricTensor
|
Legs |
S |
SymmetricTensor
|
Legs |
Vh |
SymmetricTensor
|
Legs |
Raises:
| Type | Description |
|---|---|
StructureChangingError
|
Under |
ValueError
|
If neither |
TypeError
|
If |
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 indexiin sectorcstands forqdim(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
|
|
None
|
max_bond
|
int or None
|
A bound on the dense bond dimension |
None
|
cutoff
|
float or None
|
The truncation threshold on |
None
|
cutoff_mode
|
(abs, rel, sum2, rsum2, sum1, rsum1)
|
Quimb's six names and quimb's semantics, read on |
"abs"
|
renorm
|
bool
|
|
False
|
Returns:
| Name | Type | Description |
|---|---|---|
W |
SymmetricTensor
|
Legs |
V |
SymmetricTensor
|
Legs |
Raises:
| Type | Description |
|---|---|
StructureChangingError
|
Under |
ValueError
|
If the map is not square space-wise; if neither |
TypeError
|
If |
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, soV @ 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 thesvd_truncatedcall 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).