Skip to content

tenet

The top-level namespace: tensors, legs, spaces, structures and the tensor operations.

tenet

symtenet: non-Abelian symmetric tensors with ndarray-style APIs.

SymmetricTensor is the tensor type. Its categorical structure is Leg (IN/OUT, Side), GradedSpace / ProductSpace, FusionTree and TensorStructure, and as_map views it as a map from domain to codomain.

The operations group by what a caller comes for: arithmetic and reductions (add, multiply, norm, trace, inner, allclose); contraction (einsum, tensordot, compose); leg and axis moves (transpose, repartition, bend, fuse / unfuse, conj, adjoint); construction (identity, isometry, from_matrices, direct_sum, embed, to_symmetry); and block access (to_matrices, apply_blocks). save / load persist one tensor, and anything that would change block structure raises StructureChangingError. Every function that validates against an atol takes PROJECT there to mean "project, don't check" instead.

Submodules with their own pages: tenet.linalg (decompositions), tenet.network (DMRG and CTMRG), tenet.symmetry (providers and sector labels), and the opt-in JAX seams tenet.pytree and tenet.ad, which enable_jax turns on.

PROJECT module-attribute

PROJECT: float = math.inf

The atol that means "project onto the symmetric subspace, do not check".

Pass it wherever an atol is accepted — from_dense, restrict, to_symmetry — to skip the concrete-value check those functions otherwise run, which is what makes them traceable:

import math, tenet tenet.PROJECT is math.inf True

It is math.inf, not a distinct object: infinity is the limit of a tolerance, "any residual acceptable", so the mode and the tolerance value coincide and every existing atol=math.inf call site keeps working unchanged. The name exists because a call site reading atol=tenet.PROJECT says which of the two operations is happening, while one reading atol=math.inf requires the reader to know the idiom.

FusionTree dataclass

FusionTree(
    uncoupled: tuple[Sector, ...],
    inner: tuple[Sector, ...],
    multiplicities: tuple[int, ...],
    coupled: Sector,
)

Bases: _HashMemo

A left-associated fusion tree. Frozen, hashable, totally ordered.

Parameters:

Name Type Description Default
uncoupled tuple of Sector

The N external sectors, already dual-resolved.

required
inner tuple of Sector

The max(N - 2, 0) internal lines (e_1, ..., e_{N-2}).

required
multiplicities tuple of int

One label per vertex, length max(N - 1, 0) — all 0 for a multiplicity-free provider.

required
coupled Sector

The total sector e_{N-1} the tree couples to.

required

Raises:

Type Description
ValueError

If inner or multiplicities has the wrong length for the rank.

Examples:

>>> from tenet import fusion_trees
>>> from tenet.symmetry import SU2, SU2Sector
>>> half = SU2Sector(1)  # sectors are labelled by 2j
>>> trees = fusion_trees(SU2, (half, half, half), half)
>>> len(trees)  # two independent trees, told apart by the internal line
2
>>> trees[0].lines()
(SU2Sector(two_j=1), SU2Sector(two_j=0), SU2Sector(two_j=1))
>>> trees[0].rank
3

rank property

rank: int

N, the number of uncoupled sectors.

Returns:

Type Description
int

len(self.uncoupled).

lines

lines() -> tuple[Sector, ...]

The left spine (e_0, ..., e_{N-1}); () for N == 0.

Returns:

Type Description
tuple of Sector

The spine sectors, ending in coupled.

vertices

vertices() -> tuple[
    tuple[Sector, Sector, Sector, int], ...
]

((e_k, u_{k+1}, e_{k+1}, mu_k), ...) for k = 0 .. N-2.

Returns:

Type Description
tuple of (Sector, Sector, Sector, int)

One (left line, uncoupled, right line, multiplicity) entry per vertex.

validate

validate(provider: FusionRules) -> None

Raise if the tree is not a valid basis label for provider.

Parameters:

Name Type Description Default
provider FusionRules

The provider whose fusion rules the tree must satisfy.

required

Raises:

Type Description
ValueError

If a rank-0 tree does not couple to the unit, a rank-1 tree does not couple to its own uncoupled sector, a vertex violates the fusion rules, or a multiplicity label is out of range.

Leg dataclass

Leg(
    space: GradedSpace,
    side: Side,
    dual: bool = False,
    name: Hashable | None = None,
)

Bases: _HashMemo

One tensor axis: space, side, dual and an optional name.

Parameters:

Name Type Description Default
space GradedSpace

The graded representation space this axis carries.

required
side Side

OUT (codomain) or IN (domain).

required
dual bool

Whether the axis carries V* rather than V. Default False.

False
name Hashable or None

User bookkeeping label. Default None.

None

Examples:

>>> from tenet import OUT, GradedSpace, Leg
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> leg = Leg(V, OUT, name="p")
>>> leg.degeneracy(U1Sector(0))
2
>>> leg.dualized().fused_sector(U1Sector(1))
U1Sector(charge=-1)
Notes

Frozen and hashable; name participates in equality like any other field.

provider property

provider: _DualFusionRules

The space's symmetry provider.

Returns:

Type Description
provider

self.space.provider.

sectors property

sectors: tuple[Sector, ...]

Space sectors in the space's canonical order.

Returns:

Type Description
tuple of Sector

tuple(self.space).

degeneracy

degeneracy(a: Sector) -> int

m_a for a space label a (not a fused label).

Parameters:

Name Type Description Default
a Sector

A sector labelling the space, in the space's own convention.

required

Returns:

Type Description
int

The degeneracy m_a, or 0 if a is absent.

Notes

For a dual U(1) leg, fused_sector negates the charge, so feeding a fused label here would silently read the wrong degeneracy — use space_sector first.

fused_sector

fused_sector(a: Sector) -> Sector

The sector this leg contributes to a fusion tree: dual(a) if dual.

Parameters:

Name Type Description Default
a Sector

A space sector of this leg.

required

Returns:

Type Description
Sector

provider.dual(a) if the leg is dual, else a unchanged.

space_sector

space_sector(u: Sector) -> Sector

Inverse of fused_sector.

dual is an involution, so lossless.

Parameters:

Name Type Description Default
u Sector

A fused (tree-side) sector label.

required

Returns:

Type Description
Sector

The space label: provider.dual(u) if the leg is dual, else u.

dualized

dualized() -> Leg

New leg with dual flipped — a relabelling of V ↔ V* only.

Returns:

Type Description
Leg

A copy of this leg with dual negated; space, side and name are unchanged.

Notes

tenet.flip_dual is the sanctioned numerical route for changing a leg's dual flag: it also relabels the space through provider.dual and pays the Z-isomorphism's scalar per fusion tree. repartition is the route that changes side together with dual, paying the bending coefficient. dualized() itself is metadata-only, and it is not the metadata half of flip_dual: toggling the flag alone changes which sector the leg contributes to a fusion tree (fused_sector goes from a to dual(a)), so the block set genuinely changes and no scalar can express the difference — never use dualized() to build a flip.

renamed

renamed(name: Hashable | None) -> Leg

New leg with name replaced; everything else unchanged.

Parameters:

Name Type Description Default
name Hashable or None

The new name (None clears it).

required

Returns:

Type Description
Leg

A copy of this leg carrying name.

Side

Bases: Enum

Which half of Hom(domain, codomain) a leg belongs to.

MapLayout dataclass

MapLayout(
    structure: TensorStructure,
    axes_order: tuple[int, ...],
    sectors: tuple[Sector, ...],
    rows: tuple[Band, ...],
    cols: tuple[Band, ...],
    grid: tuple[
        tuple[Sector, tuple[tuple[int, ...], ...]], ...
    ],
    shapes: tuple[tuple[int, int], ...],
)

Where every block sits inside its coupled-sector matrix. Array-free, hashable.

Parameters:

Name Type Description Default
structure TensorStructure

The structure this layout was computed from.

required
axes_order tuple of int

(*out_axes, *in_axes) — the one transpose to matrix form.

required
sectors tuple of Sector

The coupled sectors, sorted; one matrix B_c per entry.

required
rows tuple of Band

(coupled sector, output tree, offset, extent) row bands, in order.

required
cols tuple of Band

(coupled sector, input tree, offset, extent) column bands, in order.

required
grid tuple

Per coupled sector, block indices in row-major (row band × column band) order.

required
shapes tuple of (int, int)

The (rows, columns) shape of each B_c, aligned with sectors.

required
Notes

Built only by map_layout, never by hand. The block for (ot, it) occupies rows [row offset, + Π m_out) and columns [col offset, + Π m_in) of B_c — an outer reshape, no data motion beyond one transpose and one reshape per block.

grid instance-attribute

grid: tuple[tuple[Sector, tuple[tuple[int, ...], ...]], ...]

Per coupled sector, block indices in row-major (row band × column band) order.

shapes instance-attribute

shapes: tuple[tuple[int, int], ...]

The shape of each B_c, aligned with sectors.

row_bands

row_bands(
    c: Sector,
) -> tuple[tuple[FusionTree, int, int], ...]

(output tree, offset, extent) for c, in row order.

Parameters:

Name Type Description Default
c Sector

A coupled sector of the layout.

required

Returns:

Type Description
tuple of (FusionTree, int, int)

The row bands of B_c.

col_bands

col_bands(
    c: Sector,
) -> tuple[tuple[FusionTree, int, int], ...]

(input tree, offset, extent) for c, in column order.

Parameters:

Name Type Description Default
c Sector

A coupled sector of the layout.

required

Returns:

Type Description
tuple of (FusionTree, int, int)

The column bands of B_c.

shape

shape(c: Sector) -> tuple[int, int]

(Σ_ot Π m_out, Σ_it Π m_in) — the shape of B_c.

Parameters:

Name Type Description Default
c Sector

A coupled sector of the layout.

required

Returns:

Type Description
tuple of int

The (rows, columns) shape of B_c.

TensorMapView dataclass

TensorMapView(tensor: SymmetricTensor)

A semantic view of a tensor as a morphism. Nothing is moved or materialized.

Parameters:

Name Type Description Default
tensor SymmetricTensor

The tensor being viewed. Held, not copied.

required

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> m = t.as_map()
>>> m.codomain.reduced_dim, m.domain.reduced_dim
(3, 3)
>>> u, s, vh = m.svd()
>>> s.ndim
2
Notes

as_map allocates nothing: the view holds the tensor and every property is derived from the side metadata the legs already carry. Materializing an (out..., in...) reordering here would violate invariant 3 and fork T.as_map().svd() from svd(T, axes=...).

codomain property

codomain: ProductSpace

OUT legs, in public axis order.

Returns:

Type Description
ProductSpace

The codomain factor.

domain property

domain: ProductSpace

IN legs, in public axis order.

Returns:

Type Description
ProductSpace

The domain factor.

matrices

matrices() -> dict[Sector, Any]

{c: B_c}to_matrices of the underlying tensor.

Returns:

Type Description
dict of Sector to array

One matrix per coupled sector.

compose

self ∘ other. See tenet.compose.

Parameters:

Name Type Description Default
other TensorMapView or SymmetricTensor

The morphism applied first; its codomain must match this map's domain.

required

Returns:

Type Description
SymmetricTensor

The composition, as a tensor.

adjoint

adjoint() -> SymmetricTensor

T† in Hom(codomain, domain). See tenet.adjoint.

Returns:

Type Description
SymmetricTensor

The adjoint tensor.

svd

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

U, S, Vh for the current partition. See svd.

Parameters:

Name Type Description Default
bond GradedSpace or None

bond=<GradedSpace> projects onto a pre-decided bond space, exactly as the free function does — still shape-static, still traceable. Default None.

None

Returns:

Type Description
U, S, Vh : SymmetricTensor

The factors, as for svd.

svd_truncated

svd_truncated(
    **kwargs: Any,
) -> tuple[
    SymmetricTensor, SymmetricTensor, SymmetricTensor
]

Truncated U, S, Vh for the current partition. Not jittable.

Parameters:

Name Type Description Default
**kwargs Any

Every keyword of svd_truncated, forwarded unchanged.

{}

Returns:

Type Description
U, S, Vh : SymmetricTensor

The truncated factors.

qr

Q, R for the current partition. See qr.

Returns:

Type Description
Q, R : SymmetricTensor

The factors.

eigh

eigh() -> tuple[SymmetricTensor, SymmetricTensor]

W, V for the current partition. See eigh.

Returns:

Type Description
W, V : SymmetricTensor

Eigenvalues and eigenvectors.

eig

W, V for the current partition. See eig.

Returns:

Type Description
W, V : SymmetricTensor

Eigenvalues and eigenvectors.

eigvals

eigvals() -> SymmetricTensor

W for the current partition. See eigvals.

Returns:

Type Description
SymmetricTensor

The eigenvalues.

expm

expm(*, alpha: Any = 1.0) -> SymmetricTensor

exp(alpha * T) for the current partition. See expm.

Parameters:

Name Type Description Default
alpha scalar

The prefactor inside the exponential. Default 1.0.

1.0

Returns:

Type Description
SymmetricTensor

The matrix exponential.

polar

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

W, P for the current partition. See polar.

Parameters:

Name Type Description Default
side str

"left" (the default) or "right".

'left'

Returns:

Type Description
W, P : SymmetricTensor

The isometric and positive factors.

left_null

left_null() -> SymmetricTensor

N with N† T = 0 for the current partition. See left_null.

Returns:

Type Description
SymmetricTensor

The left null space.

right_null

right_null() -> SymmetricTensor

N with T N† = 0 for the current partition. See right_null.

Returns:

Type Description
SymmetricTensor

The right null space.

lq

L, Q for the current partition. See lq.

Returns:

Type Description
L, Q : SymmetricTensor

The factors.

GradedSpace dataclass

GradedSpace(
    provider: _DualFusionRules,
    sectors: tuple[tuple[Sector, int], ...],
)

Bases: _HashMemo

Immutable graded space: sectors is sorted by sector, all m >= 1.

Use new to build one from a mapping; the raw constructor takes an already-canonical tuple and does not validate.

Parameters:

Name Type Description Default
provider provider

The symmetry provider whose sectors label the grading.

required
sectors tuple of (Sector, int)

Already-canonical (sector, degeneracy) pairs, sorted by sector — not validated here; go through new instead.

required

Examples:

>>> from tenet import GradedSpace
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(1): 1, U1Sector(0): 2})
>>> tuple(V)
(U1Sector(charge=0), U1Sector(charge=1))
>>> V.reduced_dim, V.dim
(3, 3)
>>> V.degeneracy(U1Sector(0))
2

reduced_dim property

reduced_dim: int

Σ m_a: what reduced ndarray blocks are made of. Any provider.

Returns:

Type Description
int

The total degeneracy dimension.

dim property

dim: int

Σ m_a d_a: the full dense dimension.

Returns:

Type Description
int

The dense carrier-space dimension.

Raises:

Type Description
CapabilityError

If the provider lacks ClebschGordanData — without integer irrep dimensions there is no dense dimension.

new classmethod

new(
    provider: _DualFusionRules,
    sectors: Mapping[Sector, int]
    | Iterable[tuple[Sector, int]],
) -> GradedSpace

Normalizing constructor: sorts, rejects duplicates and m <= 0.

Parameters:

Name Type Description Default
provider provider

The symmetry provider the sectors must belong to.

required
sectors Mapping[Sector, int] or iterable of (Sector, int)

Sector → degeneracy, in any order.

required

Returns:

Type Description
GradedSpace

The canonical space: pairs sorted by sector.

Raises:

Type Description
TypeError

If a sector is not of the provider's own sector type.

ValueError

If a degeneracy is <= 0, or a sector appears twice.

Notes

All sectors must share one type, and that type must be the provider's own sector type (taken as type(provider.unit)) — the provider protocol exposes no sector-type attribute, so unit is the proxy.

direct_sum

direct_sum(other: GradedSpace) -> GradedSpace

self ⊕ other at the label level: union of sectors, degeneracies added.

Parameters:

Name Type Description Default
other GradedSpace

The other summand; must be over the same provider.

required

Returns:

Type Description
GradedSpace

The label-level direct sum, canonically sorted.

Raises:

Type Description
TypeError

If other is over a different provider — a direct sum never casts between symmetries.

Notes

This says nothing about dual — the flag lives on the Leg (invariant 2), not on the space, so whether two legs' conventions are summable is checked where the legs are, by the tensor-level tenet.direct_sum; tenet.flip_dual is the numerical route for normalising a dual convention beforehand. TensorKit's must compare isdual here precisely because its flag lives on the space.

degeneracy

degeneracy(a: Sector) -> int

m_a, or 0 if a is absent (so filtering reads as a predicate).

Parameters:

Name Type Description Default
a Sector

The sector to look up.

required

Returns:

Type Description
int

The degeneracy of a, or 0 when a is not a sector of this space.

sector_offset

sector_offset(a: Sector) -> int

Start of a's slab in the dense layout.

Parameters:

Name Type Description Default
a Sector

A sector of this space.

required

Returns:

Type Description
int

The offset of a's contiguous slab in the dense layout.

Raises:

Type Description
CapabilityError

If the provider lacks ClebschGordanData.

KeyError

If a is not a sector of this space.

Notes

Sectors are laid out in canonical order, each contributing a contiguous slab of m_a * d_a; the within-slab index is alpha * d_a + m.

ProductSpace dataclass

ProductSpace(legs: tuple[Leg, ...])

An ordered tuple of legs, viewed as one factor of Hom(domain, codomain).

Parameters:

Name Type Description Default
legs tuple of Leg

The ordered legs making up this factor.

required

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, ProductSpace
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> out = ProductSpace((Leg(V, OUT),))
>>> out.reduced_dim
3
>>> out.matches(ProductSpace((Leg(V, IN),))) is None  # side is not compared
True
>>> out.matches(ProductSpace((Leg(V, IN, dual=True),)))  # dual is
0
Notes

A TensorMap-level value type: nobody constructs one to make an ordinary five-axis tensor, and SymmetricTensor.codomain keeps returning plain legs. It appears on TensorMapView.

Frozen, hashable, array-free; equal iff its ordered legs are equal (name included, since Leg compares it). Composition, however, ignores name — see matches.

provider property

provider: _DualFusionRules

The legs' shared provider.

Returns:

Type Description
provider

The first leg's provider.

Raises:

Type Description
ValueError

If the ProductSpace is empty — it then has no provider.

dim property

dim: int

Π leg.space.dim — the full dense dimension.

Returns:

Type Description
int

The product of the legs' dense dimensions.

Raises:

Type Description
CapabilityError

If the provider lacks ClebschGordanData (via GradedSpace.dim).

reduced_dim property

reduced_dim: int

Π leg.space.reduced_dim. Any provider.

Returns:

Type Description
int

The product of the legs' degeneracy dimensions.

matches

matches(other: ProductSpace) -> int | None

None if composable against other, else the first offending position.

Parameters:

Name Type Description Default
other ProductSpace

The factor to compare against, leg by leg.

required

Returns:

Type Description
int or None

None when composable; otherwise the first position where the two disagree as (space, dual).

Notes

Composability is (space, dual, order), exactly (invariant 2): the effective categorical objects V or V* must agree, in order. side is not compared (OUT meets IN by construction) and neither is name, which is user bookkeeping. Dimensions are never compared on their own — a charge-reversed U(1) partner has the same dimension and the wrong space.

Differing lengths report the first position past the shorter tuple; callers that can say something better about leg counts should check them first.

FusionBlockKey dataclass

FusionBlockKey(
    output_tree: FusionTree, input_tree: FusionTree
)

Bases: _HashMemo

An output/input fusion-tree pair with a shared coupled sector.

Parameters:

Name Type Description Default
output_tree FusionTree

The tree over the OUT legs.

required
input_tree FusionTree

The tree over the IN legs; must couple to the same sector.

required
Notes

Both trees list their uncoupled sectors in public axis order restricted to their side, already dual-resolved (Leg.fused_sector). Field order is load-bearing: it defines the canonical sort used by block_order.

coupled property

coupled: Sector

The shared coupled sector. Equality of the two is a validate check.

Returns:

Type Description
Sector

output_tree.coupled.

TensorStructure dataclass

TensorStructure(legs: tuple[Leg, ...])

Bases: _HashMemo

Ordered legs plus everything derivable from them. Immutable and hashable.

Parameters:

Name Type Description Default
legs tuple of Leg

The tensor's legs, in public axis order. At least one.

required

Raises:

Type Description
ValueError

If legs is empty — a leg-less scalar has no provider to enumerate against.

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, TensorStructure
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> s = TensorStructure((Leg(V, OUT), Leg(V, IN)))
>>> s.ndim, s.num_blocks
(2, 2)
>>> s.out_axes, s.in_axes
((0,), (1,))
>>> s.block_shape(s.block_order[0])
(2, 2)

provider property

provider: _DualFusionRules

The shared provider. validate() is what checks the legs agree.

Returns:

Type Description
provider

The first leg's provider.

ndim property

ndim: int

Number of legs.

Returns:

Type Description
int

len(self.legs).

out_axes property

out_axes: tuple[int, ...]

Public axis indices with side is OUT, ascending.

Returns:

Type Description
tuple of int

The OUT axes.

in_axes property

in_axes: tuple[int, ...]

Public axis indices with side is IN, ascending.

Returns:

Type Description
tuple of int

The IN axes.

block_order property

block_order: tuple[FusionBlockKey, ...]

Every structurally allowed key, sorted. The storage contract.

Returns:

Type Description
tuple of FusionBlockKey

The canonical block order: blocks[i] belongs to block_order[i].

num_blocks property

num_blocks: int

Number of structurally allowed blocks.

Returns:

Type Description
int

len(self.block_order).

Notes

Counted off the cross product, so asking how many blocks there are does not build them: the count is the reason the layout never has to.

block_shapes property

block_shapes: tuple[tuple[int, ...], ...]

Every block's shape, aligned with block_order.

The whole-table companion to block_shape, for a caller walking block_order rather than asking about one key. Same information; the difference is cost, and it is not small.

block_shape(key) enters two structure-keyed caches -- one to turn the key into an index, one for the table -- and each entry pays a hash of this TensorStructure, which reaches through its legs to their spaces and sectors. Called once per block in a loop, a tensor with a thousand blocks pays two thousand deep hashes to read a tuple that was already built. This property pays one. Hoisting it out of SymmetricTensor.__post_init__ alone took 17% off a cold SU(2) plan workload (#307).

Returns:

Type Description
tuple of tuple of int

One shape per key of block_order, in that order.

Examples:

>>> from tenet import GradedSpace, IN, OUT, Leg, TensorStructure
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> s = TensorStructure((Leg(V, OUT), Leg(V, IN)))
>>> s.block_shapes == tuple(s.block_shape(k) for k in s.block_order)
True

index_of

index_of(key: FusionBlockKey) -> int

Position of key in block_order.

Parameters:

Name Type Description Default
key FusionBlockKey

A key of this structure.

required

Returns:

Type Description
int

The index of key.

Raises:

Type Description
KeyError

If key is foreign to this structure.

axis_sectors

axis_sectors(key: FusionBlockKey) -> tuple[Sector, ...]

One space sector per public axis, de-dualized via Leg.space_sector.

Parameters:

Name Type Description Default
key FusionBlockKey

A key of this structure.

required

Returns:

Type Description
tuple of Sector

One space sector per public axis.

Raises:

Type Description
KeyError

If key is foreign to this structure.

Notes

A lookup into the per-structure table built once by _axis_sectors_table; foreign keys raise KeyError as before.

block_shape

block_shape(key: FusionBlockKey) -> tuple[int, ...]

Degeneracies in public axis order (invariant 7).

Parameters:

Name Type Description Default
key FusionBlockKey

A key of this structure.

required

Returns:

Type Description
tuple of int

The block's shape: one degeneracy per public axis.

Raises:

Type Description
KeyError

If key is foreign to this structure.

validate

validate(key: FusionBlockKey | None = None) -> None

Check the legs, and either every key in block_order or just key.

Parameters:

Name Type Description Default
key FusionBlockKey or None

The single key to check; None (the default) checks every key in block_order.

None

Raises:

Type Description
ValueError

If the legs disagree on the provider, a tree's rank does not match its side's leg count, a tree fails FusionTree.validate, an uncoupled label does not map back into its leg's space, or the two coupled sectors disagree.

Notes

Explicit and total (invariant 11): one provider for all legs; each tree's rank matches its side's leg count and passes FusionTree.validate; every uncoupled label maps back into the corresponding leg's space; the two coupled sectors agree.

StructureChangingError

Bases: TypeError

Raised when an operation whose output structure depends on block values is asked to run inside a traced (jit/grad/vmap) region.

Invariants 9 and 10: the library never hides the distinction between a shape-static operation and one that decides its own output structure from the numbers. Lives here next to CapabilityError, subclasses TypeError for the same reason it does, and is exported from tenet.

SymmetricTensor dataclass

SymmetricTensor(
    structure: TensorStructure, blocks: Sequence[Array]
)

A symmetric tensor: categorical structure plus one dense matrix per coupled sector.

Parameters:

Name Type Description Default
structure TensorStructure

The static, hashable half: legs and everything derived from them.

required
blocks tuple of array

One reduced block per key, in structure.block_order, all sharing one dtype. They are checked and then gathered into the coupled-sector matrices the tensor stores; blocks reads them back as views.

required

Raises:

Type Description
ValueError

If the number of blocks does not match block_order, a block's shape does not match structure.block_shape(key), or the blocks do not share one dtype.

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> t.ndim, len(t.blocks)
(2, 2)
>>> t.shape, t.reduced_shape
((3, 3), (3, 3))
>>> t.backend
'numpy'

data property

data: tuple[Array, ...]

One dense matrix per coupled sector, in map_layout(structure).sectors order.

Returns:

Type Description
tuple of array

The tensor's coupled-sector matrices -- its storage, and its pytree leaves.

Notes

Gathered on demand where gathering is not free. On a mutable backend the constructor gathers immediately: it is one strided copy per block and it hands back every block of the result as a view into the matrix it just wrote, so the two forms cost one pass between them and a write through either reaches the other. On an immutable backend there is no memory to alias and no destination to write through, so a gather is a fresh array built by concatenation -- and under a JAX trace, one graph node per block with a backward pass of its own. There it is deferred until something actually asks for a matrix, which the pytree does at every traced boundary and a contraction does at every lowering, and which a transpose or an elementwise map never does at all.

Either way the two forms hold the same values and the tensor is the same tensor; what the backend decides is only which of them is built first.

blocks property

blocks: tuple[Array, ...]

One reduced block per key, in structure.block_order. Views into data.

Returns:

Type Description
tuple of array

blocks[i] belongs to structure.block_order[i], in public axis order.

Notes

The storage contract, unchanged in meaning and changed in mechanism: each block is a two-dimensional slice of its coupled-sector matrix, reshaped into the block's axes and permuted back into public axis order. Nothing is copied, and writing into a block writes into the tensor. The cut is memoized, so a caller that reads blocks in a loop pays for it once.

A block is a live view and is usually not C-contiguous. Two consequences for a caller that wants a block of its own. numpy.array(block, copy=True) and numpy.ascontiguousarray(block) are both the wrong tool: the first defaults to order "K" and keeps the block's layout, so .reshape(-1) on the result hands back another copy and a write into it is lost; the second does not copy at all when the block already happens to be contiguous, so a write into it reaches this tensor. numpy.array(block, order="C", copy=True) is the one that is both a copy and flat.

legs property

legs: tuple[Leg, ...]

The structure's legs, in public axis order.

Returns:

Type Description
tuple of Leg

self.structure.legs.

ndim property

ndim: int

Number of legs.

Returns:

Type Description
int

self.structure.ndim.

provider property

provider: _DualFusionRules

The legs' shared symmetry provider.

Returns:

Type Description
provider

self.structure.provider.

codomain property

codomain: tuple[Leg, ...]

The OUT legs, in public axis order.

Returns:

Type Description
tuple of Leg

The OUT legs. ProductSpace is the fused view of the same legs, when one is wanted.

domain property

domain: tuple[Leg, ...]

IN legs in public axis order.

Returns:

Type Description
tuple of Leg

The IN legs.

shape property

shape: tuple[int, ...]

Full physical dimension per public axis: Σ_a m_a d_a.

Equal to self.to_dense().shape.

Returns:

Type Description
tuple of int

One dense dimension per public axis.

Raises:

Type Description
CapabilityError

If the provider lacks ClebschGordanData (via GradedSpace.dim) — a provider with non-integer quantum dimensions has no physical shape, and silently returning reduced_shape would violate invariant 11.

reduced_shape property

reduced_shape: tuple[int, ...]

Degeneracy dimension per public axis: Σ_a m_a. Any provider.

The storage-facing shape: what the reduced blocks are made of.

Returns:

Type Description
tuple of int

One degeneracy dimension per public axis.

dtype property

dtype: Any

The single dtype shared by all blocks (__post_init__ validates it).

Returns:

Type Description
dtype

The first block's dtype.

Raises:

Type Description
ValueError

If the tensor has no blocks — the dtype is then undefined.

backend property

backend: str

"numpy" / "jax" / "torch", inferred from the first block.

Returns:

Type Description
str

The autoray backend name.

Raises:

Type Description
ValueError

If the tensor has no blocks — the backend is then undefined.

Notes

One tensor uses one backend; construction does not re-check every block, since to_backend is the only sanctioned way to move them.

device property

device: Any

The first block's own .device (None if it has none).

Returns:

Type Description
device or None

Whatever the backend exposes.

Raises:

Type Description
ValueError

If the tensor has no blocks — the device is then undefined.

Notes

A plain getattr: autoray exposes no portable device accessor, and NumPy >= 2 arrays already carry .device == 'cpu'.

from_data classmethod

from_data(
    structure: TensorStructure, data: Sequence[Array]
) -> SymmetricTensor

Wrap coupled-sector matrices as a tensor, unchecked and zero-copy.

Parameters:

Name Type Description Default
structure TensorStructure

The structure the matrices belong to.

required
data sequence of array

One matrix per coupled sector, in map_layout(structure).sectors order and of the shape that layout gives.

required

Returns:

Type Description
SymmetricTensor

The tensor holding exactly those arrays.

Notes

The storage constructor, for a caller that already has the matrices -- from_matrices, which checks them, and the plan appliers, which built them from the structure they are being handed. The public constructor stays the trust boundary and keeps validating; this one takes the arrays as given, since re-deriving what the layout just produced would be checking our own arithmetic.

from_blocks classmethod

from_blocks(
    legs: Sequence[Leg],
    blocks: Mapping[FusionBlockKey, Array],
) -> SymmetricTensor

Build from public legs by naming fusion-block keys; absent keys are zero.

Only the blocks the caller has an opinion about are named; the keys come from TensorStructure(legs).block_order — no throwaway tensor is needed to discover the layout.

Parameters:

Name Type Description Default
legs sequence of Leg

The legs, in public axis order.

required
blocks mapping of FusionBlockKey to array

The blocks to set. Every key must belong to TensorStructure(legs).block_order; keys left out are filled with zeros of the supplied blocks' dtype and backend.

required

Returns:

Type Description
SymmetricTensor

The assembled tensor; the constructor validates every block's shape.

Raises:

Type Description
KeyError

If a key is foreign to the structure. The message names the legal keys (the first few, plus the count) and where to read the rest.

ValueError

If blocks is empty — the zero fill has no dtype or backend to take from. zeros is that tensor. Also if a supplied block has the wrong shape, from the ordinary constructor, naming the expected shape and the key.

Notes

An absent key is zero, not an error. The convenience is not the argument — strictness would be worth the inconvenience if it caught mistakes, and here it does not: a mistyped key is an unknown key, not a missing one, so it raises either way. Demanding every key would only penalise the case the constructor exists for, which is naming one block of many.

docs/tutorials/symmetric-tensors.md is this constructor at length: reading a key, the U(1) and SU(3) operators built with it, and what the foreign-key refusal catches.

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor, TensorStructure
>>> from tenet.symmetry import SU2, SU2Sector
>>> V = GradedSpace.new(SU2, {SU2Sector(1): 1})       # one spin-1/2 multiplet
>>> legs = (Leg(V, OUT), Leg(V, OUT, dual=True))      # the evaluation cup
>>> structure = TensorStructure(legs)
>>> key, = structure.block_order                      # exactly one fusion channel
>>> t = SymmetricTensor.from_blocks(legs, {key: np.ones(structure.block_shape(key))})
>>> t.blocks
(array([[1.]]),)

zeros classmethod

zeros(
    legs: Sequence[Leg], dtype: Any = float64
) -> SymmetricTensor

All-zero blocks over legs.

Parameters:

Name Type Description Default
legs sequence of Leg

The legs, in public axis order.

required
dtype dtype

The blocks' dtype. Default np.float64.

float64

Returns:

Type Description
SymmetricTensor

The zero tensor, NumPy blocks.

random classmethod

random(
    legs: Sequence[Leg],
    *,
    seed: int | None = None,
    dtype: Any = float64,
) -> SymmetricTensor

Standard-normal blocks from np.random.default_rng(seed), reproducible.

Parameters:

Name Type Description Default
legs sequence of Leg

The legs, in public axis order.

required
seed int or None

The RNG seed; None (the default) draws fresh entropy.

None
dtype dtype

The blocks' dtype. Default np.float64.

float64

Returns:

Type Description
SymmetricTensor

The random tensor, NumPy blocks.

block

block(key: FusionBlockKey) -> Array

The stored block for key — the array itself, not a copy.

Parameters:

Name Type Description Default
key FusionBlockKey

A key of this tensor's structure.

required

Returns:

Type Description
array

The block, in public axis order.

Raises:

Type Description
KeyError

If key is foreign to the structure.

items

items() -> Iterator[tuple[FusionBlockKey, Array]]

Iterate (key, block) pairs in block_order.

Yields:

Type Description
tuple of (FusionBlockKey, array)

Each key with its stored block.

with_blocks

with_blocks(
    blocks: Mapping[FusionBlockKey, Array],
) -> SymmetricTensor

Same structure, the named blocks replaced and the rest carried over.

The immutable spelling of assigning to one block: self is untouched and a new tensor is returned. The keys are this tensor's own, from self.structure.block_order or items.

Parameters:

Name Type Description Default
blocks mapping of FusionBlockKey to array

The blocks to replace. Keys absent from the mapping keep the block they already have; an empty mapping is a no-op copy.

required

Returns:

Type Description
SymmetricTensor

A new tensor over the same structure.

Raises:

Type Description
KeyError

If a key is foreign to this tensor's structure — the same message from_blocks raises.

ValueError

From the ordinary constructor, if a replacement has the wrong shape (naming the expected shape and the key) or a dtype the other blocks do not share.

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.zeros((Leg(V, OUT), Leg(V, IN)))
>>> key = t.structure.block_order[0]
>>> u = t.with_blocks({key: np.ones(t.structure.block_shape(key))})
>>> u.block(key)
array([[1., 1.],
       [1., 1.]])
>>> t.block(key).any()          # the original is untouched
np.False_

astype

astype(dtype: Any) -> SymmetricTensor

Same structure and backend, every block cast to dtype.

Parameters:

Name Type Description Default
dtype dtype

The target dtype. Any spelling NumPy recognizes — np.complex128, "complex128", np.dtype("complex128") — means the same thing on every backend; a backend-native dtype object (torch.complex128) is passed through untouched.

required

Returns:

Type Description
SymmetricTensor

A new tensor whose blocks all carry dtype. self is untouched.

Raises:

Type Description
ValueError

If the tensor has no blocks — there is nothing to cast, and the result's dtype would be undefined, as for dtype.

Notes

Blockwise ar.do("astype", b, dtype), so the backend's own casting rules apply: JAX truncates a request for a dtype its jax_enable_x64 setting does not admit, exactly as it does on any other array.

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> c = t.astype(np.complex128)
>>> c.dtype
dtype('complex128')
>>> c.structure == t.structure and c.legs == t.legs
True
>>> bool(np.allclose(np.asarray(c.blocks[0]).real, t.blocks[0]))
True

to_backend

to_backend(
    backend: str, dtype: Any = None
) -> SymmetricTensor

Same structure, blocks converted with ar.do("array", b, like=backend).

Parameters:

Name Type Description Default
backend str

The target autoray backend, e.g. "jax".

required
dtype dtype or None

Cast the blocks to this dtype after the move, via astype. None (the default) is today's behaviour: whatever dtype the backend chose is kept.

None

Returns:

Type Description
SymmetricTensor

A new tensor on backend.

Notes

A move to the backend the tensor already lives on is the identity on the blocks — no copy, and, on torch, no torch.tensor(x), which would detach them from the autograd graph. An explicit dtype still runs.

The target backend's own dtype policy applies to the move (JAX demotes float64 to float32 unless jax_enable_x64 is set). dtype runs after that move rather than as part of it, so it overrides a backend's choice — e.g. a real tensor moved to JAX and asked for np.complex128 arrives complex — but it cannot override a backend's refusal: JAX truncates np.float64 to float32 in astype too when jax_enable_x64 is unset, because in that mode the dtype does not exist for it to produce. Enabling jax_enable_x64 is the only fix for that one, and it is process-global.

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> t.to_backend("numpy", dtype=np.complex128).dtype
dtype('complex128')

get_params

get_params() -> tuple[Array, ...]

The coupled-sector matrices — a pytree of backend arrays. See data.

Returns:

Type Description
tuple of array

self.data, the identity.

Notes

The identity, deliberately: data is an ordered tuple, so no dict ordering and no key hashing ever enters the dynamic data.

These are the same leaves tenet.pytree hands JAX, and for the same reason: they are the arrays the tensor is made of. A block is a view cut out of one of them, so a torch leaf handed back as a block would be a non-leaf with no .grad of its own, and a JAX leaf handed back as a block would put the cut in the graph. The optimizer differentiates the storage; blocks is how the result is read.

set_params

set_params(params: Sequence[Array]) -> SymmetricTensor

Same structure, new numerical data. A new tensor; self is untouched.

Parameters:

Name Type Description Default
params sequence of array

The new coupled-sector matrices, in the order get_params returns them.

required

Returns:

Type Description
SymmetricTensor

A new tensor over the same structure.

Raises:

Type Description
ValueError

If the count, a shape or the dtypes do not match the structure's layout -- from_matrices's refusals, which are the trust boundary for arrays given as matrices.

copy

copy() -> SymmetricTensor

A new instance sharing the same structure and stored arrays.

Returns:

Type Description
SymmetricTensor

The shallow copy.

as_map

as_map() -> TensorMapView

View this tensor as a morphism. Zero-copy.

Returns:

Type Description
TensorMapView

The semantic view; see TensorMapView.

conj

conj() -> SymmetricTensor

Conjugate the blocks; legs unchanged. See tenet.conj.

Returns:

Type Description
SymmetricTensor

The blockwise complex conjugate.

adjoint

adjoint() -> SymmetricTensor

T†: every leg's side flips, blocks are conjugated and key-swapped.

Returns:

Type Description
SymmetricTensor

The adjoint. Not conj() (which touches no leg) and not a dualization. See tenet.adjoint.

norm

norm() -> Any

qdim-weighted Frobenius norm (a backend scalar). See tenet.norm.

Returns:

Type Description
scalar

The norm, on this tensor's backend.

apply_blocks

apply_blocks(fn: Any) -> SymmetricTensor

fn on each reduced block. See tenet.apply_blocks for the caveat.

Parameters:

Name Type Description Default
fn callable

Applied to each block; must preserve shape and be backend-generic.

required

Returns:

Type Description
SymmetricTensor

The mapped tensor, same structure.

block_sqrt

block_sqrt() -> SymmetricTensor

Blockwise sqrtnot sqrt(self.to_dense()). See tenet.block_sqrt.

Returns:

Type Description
SymmetricTensor

The blockwise square root.

block_power

block_power(p: Any) -> SymmetricTensor

Blockwise self ** p for a scalar p. See tenet.block_power.

Parameters:

Name Type Description Default
p scalar

The exponent.

required

Returns:

Type Description
SymmetricTensor

The blockwise power.

save

save(path: Any, *, compress: bool = False) -> None

Write to path as a single .npz. See tenet.save.

Parameters:

Name Type Description Default
path path - like

Where to write.

required
compress bool

Compress the archive. Default False.

False

load classmethod

load(path: Any) -> SymmetricTensor

Read a file written by save; NumPy blocks.

See tenet.load.

Parameters:

Name Type Description Default
path path - like

The .npz file to read.

required

Returns:

Type Description
SymmetricTensor

The loaded tensor, NumPy blocks.

transpose

transpose(*axes: Any) -> SymmetricTensor

T.transpose(2, 0, 1), T.transpose((2, 0, 1)) or T.transpose().

Parameters:

Name Type Description Default
*axes int or sequence of int

The new axis order; empty (or None) reverses all axes (NumPy convention).

()

Returns:

Type Description
SymmetricTensor

The permuted tensor; see tenet.transpose — no leg changes side.

repartition

repartition(
    outputs: Sequence[int], inputs: Sequence[int]
) -> SymmetricTensor

T.repartition(outputs=(0, 1), inputs=(2,)). See tenet.repartition.

Parameters:

Name Type Description Default
outputs sequence of int

The public axes to place on the OUT side.

required
inputs sequence of int

The public axes to place on the IN side.

required

Returns:

Type Description
SymmetricTensor

The repartitioned tensor.

Notes

Every leg that crosses sides is bent: its side and its dual both flip. Requires BendingCoefficients unless no leg crosses.

fuse

fuse(*axes: Any) -> SymmetricTensor

T.fuse(0, 1) or T.fuse((0, 1)). See tenet.fuse.

Parameters:

Name Type Description Default
*axes int or sequence of int

The adjacent axes to fuse into one.

()

Returns:

Type Description
SymmetricTensor

The fused tensor.

unfuse

unfuse(axis: int, legs: Sequence[Leg]) -> SymmetricTensor

Split axis into legs. See tenet.unfuse.

Parameters:

Name Type Description Default
axis int

The fused axis to split.

required
legs sequence of Leg

The constituent legs the axis splits into.

required

Returns:

Type Description
SymmetricTensor

The unfused tensor.

embed

embed(legs: Sequence[Leg]) -> SymmetricTensor

Zero-pad into larger, containing legs. See tenet.embed.

Parameters:

Name Type Description Default
legs sequence of Leg

The target legs, one per axis, each containing the current leg.

required

Returns:

Type Description
SymmetricTensor

The embedded tensor.

to_symmetry

to_symmetry(
    target: _DualFusionRules, *, atol: float | None = None
) -> SymmetricTensor

Restrict to a smaller symmetry, e.g. SU(2) -> U(1). See tenet.to_symmetry.

Parameters:

Name Type Description Default
target provider

The smaller symmetry's provider.

required
atol float or None

Symmetry-check tolerance; None (the default) uses the default.

None

Returns:

Type Description
SymmetricTensor

The tensor over target.

restrict

restrict(
    legs: Sequence[Leg], *, atol: float | None = None
) -> SymmetricTensor

Slice down to smaller, contained legs. See tenet.restrict.

Parameters:

Name Type Description Default
legs sequence of Leg

The target legs, one per axis, each contained in the current leg.

required
atol float or None

Tolerance for the discarded weight check; None (the default) skips it.

None

Returns:

Type Description
SymmetricTensor

The restricted tensor.

direct_sum

direct_sum(
    other: SymmetricTensor, axes: int | Sequence[int]
) -> SymmetricTensor

self ⊕ other along axes. See tenet.direct_sum.

Parameters:

Name Type Description Default
other SymmetricTensor

The other summand.

required
axes int or sequence of int

The axes along which the spaces are summed.

required

Returns:

Type Description
SymmetricTensor

The direct sum.

to_dense

to_dense() -> Array

T = Σ_τ A^(τ) ⊗ C^(τ) expanded into a dense array of self's backend.

Returns:

Type Description
array

The dense carrier-basis array, shape shape.

Raises:

Type Description
CapabilityError

If the provider lacks ClebschGordanData; a leg with dual=True additionally requires DualBasis.

Notes

Explicit by design (invariant 9). See tenet.ops.dense.to_dense — traceable and differentiable.

from_dense classmethod

from_dense(
    dense: Array,
    legs: Sequence[Leg],
    *,
    atol: float | None = None,
) -> SymmetricTensor

Project a dense carrier-basis array onto the symmetric subspace of legs.

Parameters:

Name Type Description Default
dense array

The dense array, in the layout convention of the module docstring.

required
legs sequence of Leg

The legs describing each dense axis.

required
atol float or None

Tolerance for the symmetry check; None (the default) uses the default.

None

Returns:

Type Description
SymmetricTensor

The projected tensor.

Notes

The inverse of to_dense; non-symmetric input is refused rather than silently projected. See tenet.ops.dense.from_dense.

coupled_sectors

coupled_sectors(
    provider: FusionRules, uncoupled: Sequence[Sector]
) -> tuple[Sector, ...]

Every sector reachable from uncoupled, canonically sorted.

Parameters:

Name Type Description Default
provider FusionRules

The provider supplying the fusion rules.

required
uncoupled sequence of Sector

The external sectors, already dual-resolved.

required

Returns:

Type Description
tuple of Sector

The reachable coupled sectors, sorted.

Examples:

>>> from tenet import coupled_sectors
>>> from tenet.symmetry import SU2, SU2Sector
>>> coupled_sectors(SU2, (SU2Sector(1), SU2Sector(1)))
(SU2Sector(two_j=0), SU2Sector(two_j=2))

fusion_trees

fusion_trees(
    provider: FusionRules,
    uncoupled: Sequence[Sector],
    coupled: Sector,
) -> tuple[FusionTree, ...]

All valid left-associated trees for uncoupled -> coupled, sorted.

Parameters:

Name Type Description Default
provider FusionRules

The provider supplying fusion rules and n_symbol.

required
uncoupled sequence of Sector

The external sectors, already dual-resolved.

required
coupled Sector

The total sector the trees must couple to.

required

Returns:

Type Description
tuple of FusionTree

Every valid tree, in canonical sorted order; empty if coupled is unreachable.

Examples:

>>> from tenet import fusion_trees
>>> from tenet.symmetry import SU2, SU2Sector
>>> half = SU2Sector(1)
>>> len(fusion_trees(SU2, (half, half), SU2Sector(0)))
1
>>> fusion_trees(SU2, (half, half), SU2Sector(1))
()

as_map

View t as a morphism. Zero-copy: no block is read, moved or allocated.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to view.

required

Returns:

Type Description
TensorMapView

The semantic view.

from_matrices

from_matrices(
    structure: TensorStructure, mats: Mapping[Sector, Any]
) -> SymmetricTensor

Inverse of to_matrices against the same structure. Exact round-trip.

Parameters:

Name Type Description Default
structure TensorStructure

The structure the matrices belong to; the matrices carry no categorical information, so the structure has to be given.

required
mats Mapping[Sector, array]

Exactly the coupled sectors of structure, each with the shape map_layout(structure).shape(c).

required

Returns:

Type Description
SymmetricTensor

The tensor whose lowering is mats.

Raises:

Type Description
ValueError

If a sector is missing, unknown, its matrix has the wrong shape (invariant 11), or the matrices do not share one dtype.

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet import from_matrices, to_matrices
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> from_matrices(t.structure, to_matrices(t)) == t
True
Notes

Zero-copy, and now trivially so: the matrices are the storage, so this checks them against the layout and hands them to the tensor. Nothing is allocated but the tuple, and the blocks are cut out of them only if someone asks for blocks.

The refusals above are the whole check, and they are deliberately spelled over the matrices rather than over the blocks that come out of them. The matrices are the untrusted input; the blocks are views cut to the shapes structure dictates, so re-reading those shapes back off them would be one pass per block -- 613,468 of them on a rank-8 SU(2) intermediate, a fifth of the contraction that builds it -- to confirm the reshape that has not even happened yet. One touch per coupled sector says the same thing (#328).

map_layout

map_layout(structure: TensorStructure) -> MapLayout

The lowering plan for structure. Cached: repeat calls return one object.

Parameters:

Name Type Description Default
structure TensorStructure

The structure to lay out.

required

Returns:

Type Description
MapLayout

The per-sector band and grid tables.

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, TensorStructure, map_layout
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> layout = map_layout(TensorStructure((Leg(V, OUT), Leg(V, IN))))
>>> layout.sectors
(U1Sector(charge=0), U1Sector(charge=1))
>>> layout.shape(U1Sector(0))
(2, 2)

to_matrices

to_matrices(t: SymmetricTensor) -> dict[Sector, Any]

{c: B_c}, one dense backend matrix per coupled sector. t is untouched.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to lower.

required

Returns:

Type Description
dict of Sector to array

One matrix per coupled sector, laid out per map_layout.

Examples:

>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor, to_matrices
>>> from tenet.symmetry import U1, U1Sector
>>> V = GradedSpace.new(U1, {U1Sector(0): 2, U1Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> mats = to_matrices(t)
>>> sorted(mats)
[U1Sector(charge=0), U1Sector(charge=1)]
>>> mats[U1Sector(0)].shape
(2, 2)
Notes

The tensor's own storage, keyed by sector rather than positionally: the matrices are the arrays t holds, not copies of them, so writing into one writes into t.

add

a + b. Requires identical structures; near-misses are errors.

Parameters:

Name Type Description Default
a SymmetricTensor

The left operand.

required
b SymmetricTensor

The right operand. Its structure must equal a's exactly — same provider, same legs (space, side, dual) in the same order.

required

Returns:

Type Description
SymmetricTensor

The blockwise sum, on the operands' shared structure.

Raises:

Type Description
ValueError

If the structures differ — different providers, different ndim, or a differing leg; the message names the first differing axis and both legs. Addition never widens a graded space (invariant 11).

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)
>>> b = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=1)
>>> bool(tenet.allclose(tenet.add(a, b), a + b))
True

adjoint

T†: the Euclidean-adjoint morphism in Hom(codomain, domain).

Parameters:

Name Type Description Default
t SymmetricTensor

The morphism to dagger.

required

Returns:

Type Description
SymmetricTensor

T†: every leg keeps its space, dual and name and flips its side; the public axis order is unchanged.

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)
>>> d = tenet.adjoint(a)
>>> d.legs[0].side, bool(tenet.allclose(tenet.adjoint(d), a))
(<Side.IN: 'in'>, True)
Notes

Every leg keeps its space, dual and name and flips its side; the public axis order is unchanged; the block for key (ot, it) becomes the conjugate of the block for key (it, ot) — no axis permutation is needed, because reduced axes travel with their own legs (invariant 7).

Deliberately no requires(...): this needs conj on the backend and the identity of the trees, nothing a provider could fail to supply. The dagger structure this leans on is named by DaggerData, today a marker every provider satisfies.

allclose

allclose(
    a: SymmetricTensor,
    b: SymmetricTensor,
    *,
    rtol: float = 1e-05,
    atol: float = 1e-08,
) -> bool

Tolerant comparison. Different structures give False, never an error.

Parameters:

Name Type Description Default
a SymmetricTensor

The left operand.

required
b SymmetricTensor

The right operand; a structure mismatch is False, not an error.

required
rtol float

Relative tolerance, forwarded to the backend's allclose. Default 1e-5.

1e-05
atol float

Absolute tolerance, forwarded likewise. Default 1e-8.

1e-08

Returns:

Type Description
bool

True iff the structures are equal and every pair of blocks is close under (rtol, atol).

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)
>>> b = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=1)
>>> tenet.allclose(a, a), tenet.allclose(a, b)
(True, False)

apply_blocks

apply_blocks(
    t: SymmetricTensor, fn: Callable[[Array], Array]
) -> SymmetricTensor

fn applied to each reduced block. Coefficient space, not dense space.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose reduced blocks fn maps over.

required
fn callable

An elementwise and shape-preserving function of one backend array. It is not checked (a shape change is caught by SymmetricTensor.__post_init__, a value-dependent one is the caller's problem), and it is not required that fn(0) == 0.

required

Returns:

Type Description
SymmetricTensor

fn of every block, on t's unchanged structure.

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)
>>> tenet.allclose(tenet.apply_blocks(a, lambda blk: 2 * blk), a + a)
True
Notes

Structure is untouched, so this is linear-algebra-free, backend-generic, traceable and differentiable: it is exactly t.set_params(map(fn, t.get_params())), which is quimb's Tensor.apply_to_arrays over the same parameter protocol.

fn not being required to satisfy fn(0) == 0 is a structural fact: the blocks hold only allowed fusion channels, so any fn returns a valid symmetric tensor and every symmetry-forbidden dense entry stays exactly zero.

What it does not do is commute with dense expansion. T = Σ_τ A^(τ) ⊗ C^(τ), so for a non-Abelian provider apply_blocks(t, f).to_dense() != f(t.to_dense()) — off by 1.673 on a dense scale of 3.82 for a rank-3 SU(2) tensor with f = sqrt. For every shipped Abelian provider (all-ones CG, d_a == 1) they agree exactly. If you want dense-elementwise semantics, densify explicitly.

bend

bend(t: SymmetricTensor, axis: int) -> SymmetricTensor

Bend axis to the other side: side flipped, dual flipped, moved last.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose leg is bent.

required
axis int

The public axis to bend. It must currently be the last leg of its own side (it need not be the last public axis).

required

Returns:

Type Description
SymmetricTensor

The bent tensor: the moved leg takes the largest public position on its new side, with side and dual both flipped; space and name are preserved, so the block shapes are a permutation of the old ones.

Raises:

Type Description
ValueError

If axis is out of range, or is not the last leg of its own side (repartition transposes first and therefore never triggers this).

CapabilityError

If the provider does not implement BendingCoefficients — the coefficient is sqrt(dim(c)/dim(a))·B(a,b,c) with a Frobenius-Schur phase, and faking it would give correct shapes with a wrong norm.

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)
>>> b = tenet.bend(a, 0)  # the OUT leg becomes a dual IN leg, moved last
>>> b.legs[-1].side, b.legs[-1].dual
(<Side.IN: 'in'>, True)

block_power

block_power(t: SymmetricTensor, p: Any) -> SymmetricTensor

Blockwise t ** p. p is a scalar exponent, never a tensor.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose blocks are raised to p, entry by entry.

required
p scalar

The exponent — a Python number or 0-d backend array, never a tensor.

required

Returns:

Type Description
SymmetricTensor

Every block raised to p, on t's structure.

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)
>>> tenet.allclose(tenet.block_power(a, 2), tenet.apply_blocks(a, lambda b: b * b))
True
Notes

Same coefficient-space semantics and the same backend-owned branch cuts and nans as block_sqrt; p = -0.5 is the inverse-√S of canonical-form and gauge-fixing loops. It runs over data for the same reason.

block_sqrt

block_sqrt(t: SymmetricTensor) -> SymmetricTensor

Blockwise sqrt. The svd splitter: u @ sqrt(s), sqrt(s) @ vh.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose blocks are square-rooted, entry by entry.

required

Returns:

Type Description
SymmetricTensor

The elementwise square root of every block, on t's structure.

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 = tenet.apply_blocks(a, abs)  # non-negative blocks
>>> tenet.allclose(tenet.block_sqrt(tenet.block_power(q, 2)), q)
True
Notes

Blockwise, i.e. on the coefficients — see apply_blocks for the blockwise/dense caveat, which for SU(2) is 44% of the array's own scale and completely silent.

For the S returned by tenet.linalg.svd this is the matrix square root, because svd builds S's blocks with ar.do("diag", ...) and a diagonal matrix's elementwise and matrix square roots coincide. That is a fact about S, not about sqrt: for a non-diagonal t, sqrt(t) @ sqrt(t) != t.

Negative or complex entries are the backend's business: sqrt(-1.0) is nan under NumPy, JAX and torch alike, and nothing here clips, guards or regularizes.

Runs over the coupled-sector matrices, not over the blocks cut out of them: a coupled sector's matrix is exactly its blocks laid side by side, so an elementwise map is the same numbers either way, and this one's fn is the library's own and reads no shape. apply_blocks keeps the block route because there it is not.

braid

braid(
    t: SymmetricTensor,
    axes: Sequence[int],
    levels: Sequence[int],
) -> SymmetricTensor

The same abstract tensor after a braid: axes reordered, levels crossed.

levels[i] is the position of axis i's line in the diagram's incoming planar order; axes is the outgoing order, as in tenet.transpose. Two lines cross exactly when those two orders disagree about them, which makes braid the two operations a planar embedding needs and transpose alone cannot spell:

  • monotone levels — the incoming order is the leg order, so the crossings are the inversions of axes and the result is tenet.transpose(t, axes), through the very same plan object.
  • axes the identity, two levels inverted — the lines cross and come back to the leg order they started in: a crossing with no net permutation, the swap gate. Its coefficient is the grading sign (-1)^(p_i p_j), which is 1 for every bosonic provider (the crossing is then the identity morphism) and YASTN's swap_gate for fermion parity.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose lines are braided.

required
axes sequence of int

axes[i] is the OLD axis that becomes new axis i; a permutation of range(t.ndim), negative indices refused.

required
levels sequence of int

One height per OLD axis. Ties never cross; only the order matters, so (0, 1, 2) and (3, 7, 9) are the same braid.

required

Returns:

Type Description
SymmetricTensor

The braided tensor; side, dual and name travel with each leg and no leg ever changes side.

Raises:

Type Description
ValueError

If axes is not a permutation of range(t.ndim), or levels is not t.ndim integers.

CapabilityError

If the braid needs coefficients the provider does not state — a within-side reorder without PermutationCoefficients, a level crossing without TwistData, or either on a provider whose braiding is chiral (R != R**-1), which is out of scope.

Notes

Differentiability and tracing: exactly tenet.transpose's status — the plan is frozen, array-free metadata and the blocks move through one transpose and one real scalar each, so braid is shape-static and traces under jax.jit/ jax.grad. No custom VJP is registered for either, and none is needed.

This is TensorKit's braid(t, p, levels) widened by one step. There levels only choose each crossing's sense, so under a symmetric braiding they drop out and braid == permute; the crossings are always the inversions of p. Here they also decide which pairs cross, which is what a planar embedding needs and what a permutation cannot say. Sense stays irrelevant, as it must be for a symmetric braiding.

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})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> tenet.braid(t, (1, 0), (0, 1)) == tenet.transpose(t, (1, 0))  # monotone: a transpose
True
>>> tenet.braid(t, (0, 1), (1, 0)).legs == t.legs  # a crossing moves no leg
True

compose

a ∘ b: b's codomain is consumed by a's domain. Spelled a @ b.

Parameters:

Name Type Description Default
a SymmetricTensor

The outer morphism; its domain consumes b's codomain.

required
b SymmetricTensor

The inner morphism. Its codomain must carry the same (space, dual) sequence, in the same order, as a's domain — side is not compared and name is ignored; dimensions alone are never enough.

required

Returns:

Type Description
SymmetricTensor

The composition; its public axis order is a's OUT legs followed by b's IN legs, each in its own public order. A coupled sector that only one operand carries — every sector, when an operand is block-less because its legs cannot couple — is zero, and those zeros take their backend and dtype from the other operand, NumPy float64 when neither has a block.

Raises:

Type Description
ValueError

If a's domain and b's codomain differ in length, or at any position in (space, dual) — the message names the offending axis on both tensors. Composition never reorders legs within a side; use tenet.transpose for that, or repartition if a leg has to change side.

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.identity(a.codomain) @ a, a))
True
Notes

For a long chain at a fixed partition in eager NumPy, the matrix form can be kept between steps by hand -- worth about 1.1x asymptotically, and the reason the library does not persist the layout itself is that real tensordot chains never hit such a cache and from_matrices is already zero-copy::

acc = to_matrices(ts[0])
for t in ts[1:]:
    mb = to_matrices(t)
    acc = {c: acc[c] @ mb[c] for c in acc}
out = from_matrices(TensorStructure((*ts[0].codomain, *ts[-1].domain)), acc)

conj

Complex-conjugate the reduced blocks; legs are left completely alone.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to conjugate.

required

Returns:

Type Description
SymmetricTensor

t with every block conjugated, on t's unchanged structure.

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)  # real blocks
>>> bool(tenet.allclose(tenet.conj(a), a))
True
Notes

No dual flip, no side change: conjugation, duality and the categorical adjoint are three different things (invariant 2). In particular t.conj() is not what you contract t against to obtain ‖t‖² — that pairing needs the adjoint, tenet.adjoint.

Blockwise conjugation equals dense-basis conjugation exactly because every provider here has real Clebsch-Gordan coefficients (all-ones for Trivial and U(1), Condon-Shortley for SU(2)).

direct_sum

direct_sum(
    t: SymmetricTensor,
    u: SymmetricTensor,
    axes: int | Sequence[int],
) -> SymmetricTensor

t ⊕ u along axes: sector-wise degeneracy sums, t leading.

Parameters:

Name Type Description Default
t SymmetricTensor

The first summand; it takes the leading degeneracy slots.

required
u SymmetricTensor

The second summand, in the trailing slots. Must be on t's backend.

required
axes int or sequence of int

The axes to sum along (negative indices allowed here). Every axis not in axes must agree exactly between the operands in space, side and dual; every axis in axes must agree in provider, side and dual, and its spaces may differ freely. name is user bookkeeping and is taken from t.

required

Returns:

Type Description
SymmetricTensor

The direct sum: on each summed axis the result's space has m_a = t.degeneracy(a) + u.degeneracy(a) for every sector of either. The dtype is promoted once, up front, across both operands.

Raises:

Type Description
ValueError

If the operands' leg counts differ; if axes is empty (with nothing to sum this would be add), out of range, or repeated; if an axis mismatches in provider, side or dual (a dual mismatch is refused, never coerced — tenet.flip_dual is the way to normalise the operands' dual conventions before summing); if an unsummed axis's spaces differ; or if the operands live on different backends (.to_backend(...) is the explicit spelling).

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)
>>> b = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=1)
>>> tenet.direct_sum(a, b, axes=1).shape
(2, 4)
Notes

Placement is t in the leading degeneracy slots and u in the trailing ones, on every summed axis — the same prefix convention embed and svd(..., bond=) use, and the same order as TensorKit's catdomain.

With ONE summed axis every block splits into exactly two slabs, t's then u's. With MORE than one, the "mixed" regions — leading on one summed axis and trailing on another — are zero, because neither pad ever writes them, and that is precisely what makes a two-sided direct sum the block-diagonal map T ⊕ U.

Structure-static and traceable: the result structure comes from the operands' legs and axes, which are metadata, so this composes inside jit and grad. Linear in each operand separately.

divide

divide(t: SymmetricTensor, s: Any) -> SymmetricTensor

t / s for a scalar s.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to scale.

required
s scalar

A Python number or a 0-d backend array; the same scalar rule as multiply.

required

Returns:

Type Description
SymmetricTensor

t with every block divided by s, on t's structure.

Raises:

Type Description
TypeError

If s is a SymmetricTensor, or neither a number nor a 0-d array.

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.divide(a, 2.0), 0.5 * a))
True

einsum

einsum(
    equation: str,
    *operands: SymmetricTensor,
    optimize: Any = "auto",
) -> SymmetricTensor

tenet.einsum("abc,cde,ef->abdf", A, B, C) — any number of operands.

Parameters:

Name Type Description Default
equation str

The label equation, one comma-separated term per operand. Labels are single ASCII letters, one per axis; a label occurs at most twice in the whole equation (a wire has two ends). -> may be omitted, in which case the output is every label occurring exactly once, sorted (the NumPy rule).

required
*operands SymmetricTensor

One or more tensors, in the equation's term order.

()
optimize str, path, or opt_einsum.paths.PathOptimizer

Consulted only with three or more operands, where it is handed to opt_einsum.contract_path unchanged; cotengra's optimizers are such objects and work here without cotengra being imported. Default "auto".

'auto'

Returns:

Type Description
SymmetricTensor

The contraction, its public axes in the output labels' order; free legs come back exactly as they went in.

Raises:

Type Description
ValueError

The parser's refusals, each naming what to write instead: no operands; ellipsis (symmetric tensors do not broadcast); a non-ASCII or non-letter label; a term/operand count or length mismatch; a label occurring more than twice; a label repeated within one operand — a diagonal (not equivariant, invariant 11) or a single-operand trace (use trace); a repeated output label; an output label appearing in no input; or an input label missing from the output, which would sum an axis away (not equivariant, invariant 11). Also tensordot's refusals for each pairwise step, e.g. a shared label whose two legs are not contractible.

CapabilityError

If a pairwise step needs a bend the provider cannot supply, as in tensordot.

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)
>>> b = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=1)
>>> c = tenet.einsum("ab,bc->ac", a, b)
>>> bool(tenet.allclose(c, tenet.tensordot(a, b, axes=((1,), (0,)))))
True
Notes

Repeated labels within one operand (a trace or a diagonal) and ellipsis are refused; see the message on each.

With one or two operands this is the pairwise lowering and optimize is not consulted (opt_einsum is not even imported). With three or more the pairwise order is chosen by opt_einsum.contract_path from the operands' physical shape\ s, and optimize is handed to it unchanged: a strategy name, an explicit path, or any opt_einsum.paths.PathOptimizer — cotengra's optimizers are such objects and work here without cotengra being imported. A strategy name asks for a search, so its path is cached on (equation, shapes, name); a path and a PathOptimizer are consulted on every call. Every step of the path is then this same two-operand call, so the mathematics is unchanged; the path is chosen from static structure only, and is therefore baked in at trace time under jax.jit like every other structural decision.

Shared labels are contracted in order of first appearance in the first operand — any order gives the same tensor, but a nondeterministic one would fragment plan caches and make jit retrace. The final transpose is a public permutation and therefore fully categorical (a Koszul sign for a fermionic provider, a braid for SU(2)); it is never skipped, and permutation_plan's case A already makes the identity permutation free.

einsum_chain

einsum_chain(
    steps: Sequence[
        tuple[
            str,
            SymmetricTensor | None,
            SymmetricTensor | None,
            str,
        ]
    ],
) -> SymmetricTensor

A run of pair-contractions with nothing materialized between them.

Parameters:

Name Type Description Default
steps sequence of (equation, a, b, bend)

One entry per pair-contraction, in order. equation is a two-operand einsum equation. a and b are its operands, and exactly one of them is None in every step after the first, standing for the previous step's result -- which side it stands on is the operand order, and operand order is categorical data (a Koszul sign for a fermionic provider), so it is written out rather than assumed. bend names the wires whose two ends are moved to the other side before the composition, as repartition would; "" is a straight composition.

required

Returns:

Type Description
SymmetricTensor

The last step's result.

Raises:

Type Description
ValueError

If steps is empty, if the first step names None, or from the delegated parsing and contraction of any step.

CapabilityError

If a step needs a bend the provider cannot supply, as in tensordot.

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)
>>> b = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=1)
>>> c = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=2)
>>> chained = tenet.einsum_chain(
...     [("ab,bc->ac", a, b, ""), ("ab,bc->ac", None, c, "")]
... )
>>> bool(tenet.allclose(chained, tenet.einsum("ab,bc->ac", tenet.einsum("ab,bc->ac", a, b), c)))
True
Notes

Step k's restore -- the repartition that puts the product back on its public legs, with the final transpose already folded in -- and step k+1's operand lowering are both plans of (source, target, coefficient) over blocks that are views into step k's sector matrices. Composing them gives one plan from step k's matrices to step k+1's: one strided pass per term, the coefficients multiplied through, and no tensor written in between. The terms and their coefficients are the ones the separate calls apply -- only when they are applied changes.

The steps are the caller's, not a path finder's: a chain states the intermediate leg order and the bends at each pair, which is what the composition rule fixes and what a single multi-operand equation would leave to opt_einsum.

flip_dual

flip_dual(
    t: SymmetricTensor,
    axes: int | Hashable | Sequence[int | Hashable],
    *,
    inv: bool = False,
) -> SymmetricTensor

Toggle the dual flag of axes, keeping the tensor the same morphism.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose legs are flipped.

required
axes int, leg name, or sequence of either

The legs to flip; flip_dual(t, ()) is t. A name must be carried by exactly one leg.

required
inv bool

flip_dual is not an involution; inv=True applies the exact inverse instead. Default False.

False

Returns:

Type Description
SymmetricTensor

The same morphism with each named leg's dual toggled and its space relabelled through provider.dual; side and name are unchanged, and so are the block set, order and shapes.

Raises:

Type Description
ValueError

If an axis is out of range or repeated, if a leg name matches no leg, or if it matches more than one (use the axis index instead).

CapabilityError

If the provider does not implement FSIndicatorData and TwistData — the scalar is chi_a * theta_a per flipped leg per fusion tree, and faking it would give correct shapes with a wrong sign.

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)
>>> f = tenet.flip_dual(a, 0)  # charge q relabelled as -q on a dual leg
>>> f.legs[0].dual, f.legs[0].space.sectors
(True, ((U1Sector(charge=-1), 1), (U1Sector(charge=0), 1)))
>>> bool(tenet.allclose(tenet.flip_dual(f, 0, inv=True), a))
True
Notes

Not numpy.flip: no axis is reversed and no element moves. Each named leg's dual flag is toggled and its space is relabelled through provider.dual (so a U(1) leg over charges {q} comes back over {-q}), which is the V_a -> V_a^* isomorphism made explicit — the operation TensorKit spells flip. The name is qualified here and not there because Python has numpy.flip, which reverses element order along an axis of the tensor while this toggles a flag on a leg -- a different operand and a different operation under one name, reachable through autoray's module lookup. YASTN, the Python API reference, qualifies the same operation the same way (flip_signature / flip_charges); dual is this package's noun for the flag. side and name are unchanged: moving a leg between domain and codomain stays repartition's job.

Two contracts, both TensorKit's: flipping the two legs of a contractible pair leaves the contraction result unchanged, and flip_dual is not an involution — flipping the same leg twice multiplies each tree by chi_a * theta_a once (-1 on an SU(2) half-integer or odd fermion-parity line), and inv=True is the exact inverse instead.

Because the relabel and the flag toggle cancel inside Leg.fused_sector, every fusion-tree leaf -- and with it the block set, order, shapes and coupled-sector layout -- is unchanged, so the whole operation is one scalar per block. That scalar factorizes over the two trees, which makes it a diagonal scaling of the rows and of the columns of each stored matrix: no block is ever cut out to be multiplied by a number.

full_trace

full_trace(t: SymmetricTensor) -> Any

Σ_c qdim(c) · tr(M_c) — the categorical trace of an endomorphism, a scalar.

Parameters:

Name Type Description Default
t SymmetricTensor

A square map: its codomain and domain must carry the same (space, dual) sequence, in order.

required

Returns:

Type Description
scalar

The backend's own scalar — no float(), which would make the function unusable under jit/grad/vmap; callers needing a Python float say float(tenet.full_trace(t)).

Raises:

Type Description
CapabilityError

If t's provider does not implement QuantumDimensionData and PivotalData, or is not spherical (qdim(c) != qdim(dual(c)) on some traced sector — the left and right traces would disagree and full_trace refuses to pick one).

ValueError

If the map is not square space-wise (check_square's refusal).

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})
>>> int(tenet.full_trace(tenet.identity((Leg(V, OUT),))))
2
Notes

Open diagrams are tensors; closed diagrams exit to backend scalars, explicitly and by name. tensordot, einsum and trace never return a scalar — a contraction that closes a network is a ValueError, and a SymmetricTensor still has no rank 0. Leaving the tensor world is a separate, named call — norm, full_trace, inner — which returns the backend's own scalar and is therefore traceable and differentiable.

The pair closed is the map view: codomain against domain, in order, the same view eigh, expm, svd and to_matrices act through. Any rank with a square map, so a rank-4 (V OUT, W OUT | V IN, W IN) gives np.einsum("abab->") and not an axis-adjacent pairing; trace remains the way to close one chosen pair and to keep a tensor.

The qdim weight is the same one norm carries, and it is what makes full_trace(t) == np.trace(t.to_dense()) hold for a rank-2 map; dropping it is wrong for any non-Abelian provider.

fuse

fuse(
    t: SymmetricTensor, axes: Sequence[int]
) -> SymmetricTensor

Fuse the given public axes into one leg, placed at min(axes).

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose axes are fused.

required
axes sequence of int

The public axes to fuse. They must share one side and, ordered by public position, be exactly the first k legs of that side; public adjacency is not required. A single-axis axes is the identity.

required

Returns:

Type Description
SymmetricTensor

The fused tensor: the new leg sits at min(axes) with dual=False, the inputs' common side and name=None; the survivors keep their relative order.

Raises:

Type Description
ValueError

If axes is empty, out of range, or repeated, or if the axes mix sides (the result would have no well-defined side).

TypeError

If an axis is not an int.

NotImplementedError

If the axes are not a prefix of their side's order; fusing a non-prefix group needs an F-move, which is not implemented.

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})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, OUT), Leg(V, IN)), seed=0)
>>> f = tenet.fuse(t, (0, 1))
>>> f.ndim, f.shape
(2, (4, 2))
>>> bool(tenet.allclose(tenet.unfuse(f, 0, t.legs[:2]), t))
True

identity

identity(
    legs: Sequence[Leg],
    *,
    dtype: Any = float64,
    like: Any = "numpy",
) -> SymmetricTensor

id on ProductSpace(legs): the legs mirrored as (OUT..., IN...).

Parameters:

Name Type Description Default
legs sequence of Leg

The legs to build the identity on. space, dual and name are kept and only side is set, so that identity(t.codomain) @ t == t.

required
dtype dtype - like

The blocks' dtype. Default np.float64.

float64
like str or array

Anything ar.do accepts — a backend name or a reference array. Default "numpy".

'numpy'

Returns:

Type Description
SymmetricTensor

The identity morphism, legs (*legs OUT, *legs IN), one eye block per coupled sector.

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)
>>> i = tenet.identity((Leg(V, OUT),))
>>> i.shape, bool(tenet.allclose(i @ a, a))
((2, 2), True)
Notes

space, dual and name are kept and only side is set, so that identity(t.codomain) @ t == t. Dualizing the mirror would build a cup, a different morphism.

B_c = eye for every coupled sector, and nothing else — which is also the sharpest test of MapLayout: this is the identity morphism only because the row and column orderings are derived from block_order rather than invented, so mirrored legs give mirrored bands.

like is anything ar.do accepts — a backend name or a reference array — and defaults to today's "numpy": identity(legs) has no tensor to infer a backend from, so a caller that does have one (ops/contraction.py::trace) passes it; hard-coding it would make trace on a torch tensor contract torch blocks against NumPy ones.

inner

inner(a: SymmetricTensor, b: SymmetricTensor) -> Any

<a|b> = Σ_τ qdim(c_τ) · <A_τ, B_τ>norm's sesquilinear sibling.

Parameters:

Name Type Description Default
a SymmetricTensor

The bra side; the pairing is sesquilinear (conjugate-linear) in a.

required
b SymmetricTensor

The ket side; must have the same structure as a.

required

Returns:

Type Description
scalar

The backend's own scalar <a|b>, traceable and differentiable; inner(a, a) equals norm(a)**2.

Raises:

Type Description
ValueError

If the structures do not match — different providers, different ndim, or a differing leg; the message names the first differing axis and both legs, exactly as zip_blocks and add do, since the aligned-blocks precondition is the same one.

CapabilityError

If a's provider does not implement QuantumDimensionData (the qdim weight).

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)
>>> round(float(tenet.inner(a, a) - tenet.norm(a) ** 2), 6)
0.0
Notes

Coefficient space, per fusion-tree block, qdim-weighted — no diagram. This is literally norm's body with the square replaced by a conjugated pair, so inner(a, a) == norm(a) ** 2 holds identically rather than numerically, and the dense Σ conj(a) · b over to_dense is its oracle. It is TensorKit's spelling as well (src/tensors/vectorinterface.jl: Σ_c dim(c) · inner(block(t1, c), block(t2, c)) in both fusion-style branches), i.e. the pairing MPSKit's Krylov machinery runs on.

Drawing the pairing as a diagram would be wrong here. Contracting every axis but the first, then closing, makes the still-open axis-0 lines cross the contracted ones, and on a graded provider each crossing of two odd lines pays -1: an invariant scalar has (axis-0 sector) = (sector of the rest), so exactly the odd-sector blocks would enter the sum with the wrong sign and inner(t, t) would differ from norm(t) ** 2. No diagram, no crossing, no twist — and no rank cap either: this works at any rank.

Returns the backend's own scalar, so the whole function stays traceable and differentiable, as norm is.

Summed per coupled sector, for the reason norm is: the weight is the coupled sector's, a sector's matrix is exactly its blocks laid side by side, and the two structures share a layout because they share a structure -- so cell i of a's matrix pairs with cell i of b's wherever the block walk paired them. Cutting either operand's blocks out to spell the same sum is a pass over both tensors in front of a reduction that was going to read every element anyway.

isometry

isometry(
    codomain: Sequence[Leg],
    domain: Sequence[Leg],
    *,
    dtype: Any = float64,
) -> SymmetricTensor

The inclusion domain -> codomain: W† W = id(domain), (W W†)² = W W†.

Parameters:

Name Type Description Default
codomain sequence of Leg

The larger side. codomain[i] must contain domain[i] sector-wise — same provider, same dual, every sector of the domain present in the codomain with a degeneracy at least as large.

required
domain sequence of Leg

The smaller side being included. side is set, not compared, exactly as identity does.

required
dtype dtype - like

The blocks' dtype. Default np.float64.

float64

Returns:

Type Description
SymmetricTensor

The inclusion isometry, legs (*codomain OUT, *domain IN).

Raises:

Type Description
ValueError

embed's refusals: a leg count, provider or dual mismatch, or a domain sector missing from its codomain partner (or present with a smaller degeneracy).

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})
>>> w = tenet.isometry((Leg(W, OUT),), (Leg(V, IN),))
>>> bool(tenet.allclose(tenet.adjoint(w) @ w, tenet.identity((Leg(V, IN),))))
True
Notes

The whole body, and every refusal, is embed of identity: the blocks come from the identity morphism and the placement — each degeneracy slot into the same slot of the larger leg — is embed's prefix convention, the one svd(..., bond=) and restrict already share. A per-coupled-sector rectangular eye would also produce an isometry, and it was rejected for naming a different map: it sends the j-th column band to the j-th row band, an arbitrary correspondence whenever the two sides' band orders do not line up.

Containment is required per leg, which is stricter than the fused, sector-wise domain ≾ codomain TensorKit imposes: a target where no single leg contains its partner but the fusion does is refused here.

map_diagonal

map_diagonal(m: SymmetricTensor) -> SymmetricTensor

The diagonal of a square map, in the reduced basis, on its codomain legs.

Parameters:

Name Type Description Default
m SymmetricTensor

A square map: its domain must be its codomain as (space, dual) in the same order. side is not compared and name is ignored.

required

Returns:

Type Description
SymmetricTensor

The diagonal entries, on m's codomain legs — the same structure the vectors m acts on carry, so zip_blocks pairs the two block for block.

Raises:

Type Description
ValueError

If the map is not square — check_square's refusal, which names the first offending position and both legs.

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})
>>> legs = (Leg(V, OUT), Leg(V, OUT, dual=True))
>>> d = tenet.map_diagonal(tenet.identity(legs))  # the identity's diagonal is all ones
>>> d.legs == legs
True
>>> [b.tolist() for b in d.blocks]
[[[1.0, 1.0], [1.0, 1.0]], [[1.0]]]
Notes

Coefficient space, not dense space, in apply_blocks' sense: the result holds the diagonal of the matrix a solver iterates on, not the diagonal of the dense expansion of m. The two coincide entry for entry only where the Clebsch-Gordan factor is all-ones. For a vector v on the same legs, entry k of the result is (m @ v)[k] when v is the k-th reduced-basis unit vector — which is also <v|m|v> / <v|v>, and that reading is basis-free and survives dense expansion.

Which basis, and why no recoupling coefficient appears. Composition is one matmul per coupled sector and nothing else — the module docstring above explains why — so the matrix of m in the reduced storage basis is to_matrices(m), and the diagonal of that matrix is the diagonal of the blocks whose two fusion trees coincide. The trees are drawn from one set: Leg.fused_sector reads dual, never side, so a square map's codomain and domain contribute identical uncoupled labels. No F-symbol, no R-symbol, no twist and no bend is read here, and the operation therefore requires no capability beyond the fusion rules every structure already needs.

That is not in tension with the fusion-tree basis being relational (invariant 4). The reduced basis of a rank-N map is labelled by a pair of trees, not by a tuple of external sectors, and this reads the labels: for SU(2) at external tuple (1, ½, ½, 1) two inner lines share one sector tuple and are two distinct blocks with unrelated diagonal entries. What cannot be done is to manufacture the diagonal by contracting per-leg diagonals of the operator's factors, which is a per-leg reading of that relational basis and loses both the inner line and the graded braiding sign. Given the map itself, both are already in its blocks.

The whole operation is one diagonal per coupled sector. The result carries m's codomain legs and no domain, so its own domain admits only the empty fusion tree and it has exactly one coupled sector: the unit. Its matrix is one column, its row bands are the codomain trees, and a row band's index flattens the codomain degeneracies in out_axes order -- which is the order m's own rows flatten in, and, m being square, the order its columns do too. The block at (tau, tau) is therefore the diagonal square of m's unit matrix at that band, and the whole column is that matrix's main diagonal. No block is cut out and no einsum runs (invariant 8); the 26-subscript ceiling the einsum spelling carried is gone with it.

The unit-coupled reading. The result carries m's codomain legs, whose blocks are the unit-coupled ones, and that is the whole diagonal of the operator on the vectors tenet can represent: a SymmetricTensor on those legs is invariant by construction (invariant 1), and a targeted charge is carried by an explicit charge leg — which is then a leg of m too. A vector stored on a different partition of the same legs (an MPS two-site tensor keeps its right bond IN) reaches this basis by bend, which is one scalar per block: a diagonal similarity, so it leaves both this diagonal and the quotient q / (lambda - diag) entry for entry unchanged.

multiply

multiply(t: SymmetricTensor, s: Any) -> SymmetricTensor

s * t for a scalar s — the only defined multiplication here.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to scale.

required
s scalar

A Python number or a 0-d backend array. Never a SymmetricTensor: elementwise products of two tensors are not a defined categorical operation.

required

Returns:

Type Description
SymmetricTensor

t with every block multiplied by s, on t's structure.

Raises:

Type Description
TypeError

If s is a SymmetricTensor (use tensordot for a contraction, or a @ b for morphism composition), or if s is neither a number nor a 0-d array.

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.multiply(a, 2.0), a + a))
True

negative

-t.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to negate.

required

Returns:

Type Description
SymmetricTensor

t with every block negated, on t's structure.

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)
>>> round(float(tenet.norm(tenet.negative(a) + a)), 6)
0.0

norm

norm(t: SymmetricTensor) -> Any

sqrt(Σ_τ qdim(c_τ) · ‖A_τ‖²) — the fusion-tree Frobenius norm.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose norm is taken.

required

Returns:

Type Description
scalar

The backend's own scalar (a float64 scalar on NumPy, a traceable 0-d array on JAX), never a Python float. Callers needing one say float(tenet.norm(t)).

Raises:

Type Description
CapabilityError

If t's provider does not implement QuantumDimensionData.

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)
>>> round(float(tenet.norm(a)), 6)
0.182373
Notes

The quantum-dimension weight is the point: each key contributes ‖A_τ‖² once per coupled basis state and there are qdim(c) of them, so this equals the dense Frobenius norm of t.to_dense() while never densifying (that identity is an acceptance test). Dropping the weight is wrong for any non-Abelian provider.

Uses qdim (capability QuantumDimensionData), not irrep_dim, so it is defined even for providers with no dense expansion at all.

Returns the backend's own scalar, so the whole function is traceable and differentiable.

Summed per coupled sector, not per fusion tree. The weight depends on the coupled sector alone and a sector's matrix is exactly its blocks laid side by side -- the grid is complete, so every cell is written once and none is left zero -- which makes Σ_τ qdim(c_τ)·‖A_τ‖² and Σ_c qdim(c)·‖B_c‖² the same sum over the same numbers, the identity the map view's own conventions are stated in. Reading blocks to spell it the first way would cut every block of the tensor out of the matrices the reduction is about to run over anyway.

random_isometry

random_isometry(
    codomain: Sequence[Leg],
    domain: Sequence[Leg],
    *,
    seed: int | None = None,
    dtype: Any = float64,
) -> SymmetricTensor

A Haar-random isometry: W† W = id(domain), independent per coupled sector.

Parameters:

Name Type Description Default
codomain sequence of Leg

The larger side. Requires rows_c >= cols_c in every coupled sector — the fused containment condition, read structurally off MapLayout, which is weaker than isometry's per-leg one.

required
domain sequence of Leg

The side the isometry is an isometry of: W† W = id(domain).

required
seed int or None

Seed for np.random.default_rng. None (the default) is non-reproducible.

None
dtype dtype - like

The blocks' dtype; a complex dtype gets a genuinely complex (Ginibre) draw. Default np.float64.

float64

Returns:

Type Description
SymmetricTensor

A Haar-random isometry, legs (*codomain OUT, *domain IN).

Raises:

Type Description
ValueError

If some coupled sector has rows_c < cols_c, so no isometry exists there — named with the sector and both dimensions, raised before any draw.

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})
>>> w = tenet.random_isometry((Leg(W, OUT),), (Leg(V, IN),), seed=0)
>>> bool(tenet.allclose(tenet.adjoint(w) @ w, tenet.identity((Leg(V, IN),))))
True
Notes

Per coupled sector c, a (rows_c, cols_c) Gaussian draw, a QR, and the sign fix that makes the result Haar-distributed.

"Haar per coupled sector" is the product of per-sector Haar measures, not Haar on the dense space. A symmetric isometry lives in a product of unitary groups, one per coupled sector, so the block-diagonal ensemble is the correct one — but a moment computed against a dense Haar formula will not match, and that is the ensemble, not a bug.

NumPy draws through np.random.default_rng(seed), exactly as SymmetricTensor.random and identity's NumPy fill already do: a constructor is not a traced operation, and t.to_backend("jax") is the route onto a device. seed=None is non-reproducible.

A complex dtype gets a genuinely complex (Ginibre) draw, deliberately departing from SymmetricTensor.random's "real draws cast to dtype" shortcut: a real orthogonal matrix cast to complex128 is an isometry and would pass every other criterion here, while being Haar on O(n) rather than on U(n). Three lines, and it is a correctness trap otherwise.

restrict

restrict(
    t: SymmetricTensor,
    legs: Sequence[Leg],
    *,
    atol: float | None = None,
) -> SymmetricTensor

t re-expressed on smaller legs: the leading degeneracy slots, nothing else.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to cut down.

required
legs sequence of Leg

The target legs, one per axis — the exact mirror of embed's: legs[i] must match t.legs[i] in provider, side and dual, and its space must be contained in t.legs[i].space — every sector a of the target appears in the source with degeneracy(a) >= target.degeneracy(a). Sectors absent from legs are dropped entirely. name is taken from legs and never compared.

required
atol float or None

The largest discarded residual accepted. None (the default) means sqrt(eps(dtype)) * ‖t‖; atol=tenet.PROJECT (which is exactly math.inf) projects without checking, and is the form that goes inside jit.

None

Returns:

Type Description
SymmetricTensor

t on the target legs — the leading degeneracy slots of every kept sector, the same prefix convention embed uses.

Raises:

Type Description
ValueError

If legs is not a contained sub-structure (mirror of embed's refusal), or if the discarded residual exceeds atol — restriction refuses to throw away data, naming the worst offending block.

CapabilityError

If the provider does not implement QuantumDimensionData, which the residual check needs (atol=tenet.PROJECT skips it).

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})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> e = tenet.embed(a, (Leg(W, OUT), Leg(V, IN)))
>>> bool(tenet.allclose(tenet.restrict(e, a.legs), a))
True
Notes

Placement is the same prefix in the degeneracy index embed uses, so the two agree about which slots are the "old" ones, and so does a subsequent svd(..., bond=B).

Data outside the kept slots is refused, not discarded: the residual sqrt(‖t‖² - ‖restrict(t)‖²) — exact by Pythagoras, since kept and dropped occupy disjoint slots under the same qdim weight — is compared against atol, which defaults to sqrt(eps(dtype)) * ‖t‖, the relative spelling from_dense uses for the same units reason. atol=tenet.PROJECT projects without checking, and that is the form that goes inside jit; the comparison is a concrete-value question and raises JAX's own ConcretizationTypeError under a trace otherwise.

This is not a truncation. The target comes from legs, static metadata the caller chose, so restrict is shape-static and traceable and sits with embed and svd(..., bond=) on the traceable side of StructureChangingError. A target decided from the block values ("drop whatever falls below 1e-8") is tenet.linalg.svd_truncated's job.

subtract

a - b. Same structure rule as add.

Parameters:

Name Type Description Default
a SymmetricTensor

The left operand.

required
b SymmetricTensor

The right operand; its structure must equal a's exactly.

required

Returns:

Type Description
SymmetricTensor

The blockwise difference, on the operands' shared structure.

Raises:

Type Description
ValueError

If the structures differ, exactly as add refuses.

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)
>>> round(float(tenet.norm(tenet.subtract(a, a))), 6)
0.0

tensordot

tensordot(
    a: SymmetricTensor, b: SymmetricTensor, axes: Axes
) -> SymmetricTensor

Contract axes[0] of a against axes[1] of b, pairwise in order.

Parameters:

Name Type Description Default
a SymmetricTensor

The left operand; its free legs lead in the output.

required
b SymmetricTensor

The right operand; must share a's provider.

required
axes tuple of two axis sequences, or int

((i, ...), (j, ...)) pairs axis i of a with axis j of b, in order; NumPy's integer form axes=n contracts the last n axes of a against the first n of b. Negative indices are refused, as in transpose and repartition. axes=((), ()) is the outer product.

required

Returns:

Type Description
SymmetricTensor

The contraction. Public axis order is a's free axes (in a's order) followed by b's free axes (in b's order), matching np.tensordot; every free leg is returned unchanged — same space, side, dual and name.

Raises:

Type Description
ValueError

If axes is malformed (negative, repeated, out of range, or mismatched pair lengths); if the operands' providers differ; if a paired pair of legs is not contractible (same space plus opposite dual xor (side is IN) — dimensions are never compared); or if the contraction would leave no free leg (a scalar leaves the tensor world through norm, inner or full_trace instead).

CapabilityError

If the axis pattern moves a leg between domain and codomain (a line bend) and the provider does not implement BendingCoefficients.

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)
>>> b = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=1)
>>> c = tenet.tensordot(a, b, axes=((1,), (0,)))
>>> c.legs == (a.legs[0], b.legs[1])
True
Notes

axes=((), ()) is the outer product and falls out of the lowering rather than being special-cased.

A block-less operand — legs that cannot couple to any total charge — is legal and needs no special case either. It lowers to no coupled-sector matrices, so every coupled sector of the output is one the operands do not both carry, and the composition's own missing-sector rule writes it as zeros: the result is the structurally implied tensor, itself block-less whenever the free legs cannot couple and explicit zeros wherever they can. Those zeros take their backend and dtype from the other operand, and NumPy float64 when both operands are block-less.

All refusals, and all axis bookkeeping, live in contraction_plan; what is left here is the execution of four already-tested operations.

to_symmetry

to_symmetry(
    t: SymmetricTensor,
    target: _DualFusionRules,
    *,
    atol: float | None = None,
) -> SymmetricTensor

Restrict t to the smaller symmetry target, in the dense basis.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to restrict. Its provider must implement BranchingRules (and ClebschGordanData, plus DualBasis for a dual leg, through to_dense).

required
target FusionRules

The smaller symmetry to restrict to. Must implement ClebschGordanData and have one-dimensional irreps (i.e. be abelian).

required
atol float or None

Forwarded to from_dense's symmetry check; None (the default) uses from_dense's own relative tolerance, and atol=tenet.PROJECT (which is exactly math.inf) skips the check entirely.

None

Returns:

Type Description
SymmetricTensor

The same physical object as a target-symmetric tensor; side, dual and name are carried through per leg unchanged, only space changes.

Raises:

Type Description
CapabilityError

If t's provider does not implement BranchingRules, if target does not implement ClebschGordanData, or if a target sector has irrep_dim > 1 — one target label per dense basis vector is only well-defined when the target's irreps are one-dimensional.

ValueError

From from_dense's symmetry check, if the densified array is not target-symmetric within atol.

Examples:

>>> import tenet
>>> from tenet import IN, OUT, GradedSpace, Leg, SymmetricTensor
>>> from tenet.symmetry import SU2, SU2Sector, U1
>>> V = GradedSpace.new(SU2, {SU2Sector(0): 1, SU2Sector(1): 1})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> u = tenet.to_symmetry(t, U1)  # each SU(2) irrep j splits into weights 2Sz
>>> u.legs[0].space.sectors
((U1Sector(charge=-1), 1), (U1Sector(charge=0), 1), (U1Sector(charge=1), 1))
>>> u.shape == t.shape
True
Notes

atol is forwarded to from_dense, whose default symmetry check is the free correctness oracle here — a wrong branching or a wrong sort produces an array that is not target-symmetric and is refused. atol=tenet.PROJECT skips it, which is what makes to_symmetry traceable.

Forgetting is not invertible: the result has strictly more free parameters than its source, so there is no inverse cast in this direction.

trace

trace(
    t: SymmetricTensor, axes: Sequence[int]
) -> SymmetricTensor

Close axis i of t onto axis j — the supertrace on a graded provider.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to close one pair of legs on. Must keep at least one free leg afterwards.

required
axes sequence of two ints

(i, j), the two public axes to contract against each other. The two legs must be contractible (same space, opposite outward dual).

required

Returns:

Type Description
SymmetricTensor

t with the pair closed; the free legs keep their order and are returned unchanged, as in tensordot.

Raises:

Type Description
ValueError

From the delegated tensordot call: a non-contractible pair, out-of-range axes, or a trace that would leave no free leg.

CapabilityError

If closing a same-side pair needs a bend and the provider does not implement BendingCoefficients, or if the provider does not implement TwistData (the closure's theta).

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(W, IN)), seed=0)
>>> tenet.trace(t, (1, 2)).ndim
1
Notes

The closed wire pays the ribbon twist, and that is what makes a loop's value unique. Of the two wires this closure runs — one to each leg of the identity — exactly one has its duality pairing against the direction the composition takes, and the categorical closure differs from the naive one by theta there. Without it the same fermionic loop takes different values depending on which of its wires was chosen to close, measured at a spread of 2.0 on a 4-cycle; with it the choice does not matter to 1e-16 (tests/ops/test_twist.py). It is PEPSKit's strtr for a bosonic braiding, where theta is 1 and tenet.twist hands the tensor straight back. The map view's closure, full_trace, is the pivotal trace and deliberately not this.

The identity's OUT leg meets whichever of the two carries the dual object it needs, so a same-side pair (which needs a bend) and an OUT/IN pair are the same code path — TensorKit keeps a separate trace_permute! as a performance special case, not as different mathematics.

The identity is built on t's own backend and dtype — the dtype=ar.get_dtype_name(ref), like=ref spelling ops/map.py::compose already uses for its zero-filled sectors. Without it a torch-backed t would meet NumPy blocks in matmul. A block-less t carries neither, and the identity is then NumPy float64 by the same rule the contraction's zeros follow.

transpose

transpose(
    t: SymmetricTensor, axes: Sequence[int] | None = None
) -> SymmetricTensor

The same abstract tensor with public axes reordered as axes.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose public axes are reordered.

required
axes sequence of int or None

axes[i] is the OLD axis that becomes new axis i (NumPy convention); a permutation of range(t.ndim), negative indices refused. None (the default) reverses the axes.

None

Returns:

Type Description
SymmetricTensor

The transposed tensor; side, dual and name travel with each leg and no leg ever changes side.

Raises:

Type Description
ValueError

If axes is not a permutation of range(t.ndim) — a non-integer, a wrong length, an out-of-range axis or a repeat, each named.

CapabilityError

If axes reorders legs within a side — a braid — and the provider does not implement PermutationCoefficients, or its BraidingData is chiral (R != R**-1), in which case axes alone underdetermine the braid and an explicit braid(t, i, over=...) API would be needed. Permutations that only change the OUT/IN interleaving work for every provider.

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(W, IN)), seed=0)
>>> tenet.transpose(t, (2, 0, 1)).legs == (t.legs[2], t.legs[0], t.legs[1])
True

twist

twist(
    t: SymmetricTensor, axes: Sequence[int] | int
) -> SymmetricTensor

The ribbon twist theta on each named leg -- TensorKit's twist!.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor. Its legs, sides and structure are untouched: a twist is a scalar per block, not a permutation and not a bend.

required
axes sequence of int, or int

The public axes whose lines are twisted. Repeats are not deduplicated -- twisting a leg twice pays theta**2, as the diagram says.

required

Returns:

Type Description
SymmetricTensor

t with each block multiplied by the product of theta over the sectors the named legs carry in that block. t itself when every factor is 1.

Raises:

Type Description
CapabilityError

If the provider does not implement TwistData, which is where theta lives.

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})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> tenet.twist(t, 0) is t  # theta = 1 here, so the tensor comes back untouched
True
Notes

The twist is what makes a closed line's value unique. sVect is a symmetric ribbon category, so a closed diagram evaluates to one number whatever order it is contracted in -- but only once every closure is the categorical one. A closure whose duality pairing runs against the direction the composition takes differs from it by theta on that line, and paying that is this function. It is PEPSKit's twistdual/twistnondual (utility/util.jl) with the isdual test left to the caller, because tenet spells V versus V* as side xor dual (outward_dual) and the caller already knows which end it holds.

theta is (-1)^parity on a fermion-parity grading and 1 on every bosonic one, the same grading datum braid reads its crossing sign from.

The structure is unchanged, so the coupled-sector layout is too, and the scalar per block factorizes: a twisted OUT leg reads the block's output tree only and a twisted IN leg its input tree only. The twist is therefore a diagonal scaling of the stored matrices, and no block is cut out to be multiplied by a sign.

unfuse

unfuse(
    t: SymmetricTensor, axis: int, legs: Sequence[Leg]
) -> SymmetricTensor

Split axis back into legs, inserted consecutively at axis.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor whose fused axis is split.

required
axis int

The public axis to split. Must be the first axis of its side.

required
legs sequence of Leg

The legs the axis should split into. They must share the axis's side and reproduce t.legs[axis] exactly (up to name) under iterated fuse_spaces — no fusion history is stored, so the caller supplies the target legs and a mismatch fails loudly rather than producing a garbage layout.

required

Returns:

Type Description
SymmetricTensor

The split tensor, legs inserted consecutively at axis.

Raises:

Type Description
ValueError

If axis is out of range; if legs is empty or a leg's side differs from the axis's; or if the given legs do not fuse back to t.legs[axis] (a dual or space mismatch, named in the message).

NotImplementedError

If axis is not the first axis of its side; splitting a non-leading leg needs an F-move, which is not implemented.

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})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, OUT), Leg(V, IN)), seed=0)
>>> f = tenet.fuse(t, (0, 1))
>>> bool(tenet.allclose(tenet.unfuse(f, 0, t.legs[:2]), t))
True
Notes

Because the split legs are inserted consecutively, fuse followed by unfuse reproduces the original tensor exactly when the fused axes were publicly consecutive; fusing publicly non-adjacent axes is not invertible without the history this deliberately does not keep.

zip_blocks

zip_blocks(
    a: SymmetricTensor,
    b: SymmetricTensor,
    fn: Callable[[Array, Array], Array],
) -> SymmetricTensor

fn over the aligned block pairs of two tensors sharing one structure.

The two-argument sibling of apply_blocks, and coefficient space, not dense space in exactly the same sense.

Parameters:

Name Type Description Default
a SymmetricTensor

The left operand; its structure is the result's.

required
b SymmetricTensor

The right operand. Its structure must equal a's exactly — same provider, same legs (space, side, dual) in the same order — so that block_order pairs the blocks index for index.

required
fn callable

An elementwise and shape-preserving function of two backend arrays of equal shape. It is not checked (a shape change is caught by SymmetricTensor.__post_init__), and it is not required that fn(0, 0) == 0.

required

Returns:

Type Description
SymmetricTensor

fn of every aligned block pair, on the operands' shared structure.

Raises:

Type Description
ValueError

If the structures differ — different providers, different ndim, or a differing leg; the message names the first differing axis and both legs, in add's style. Nothing is widened and nothing is aligned by sector label (invariant 11).

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})
>>> legs = (Leg(V, OUT), Leg(V, IN))
>>> q = SymmetricTensor.random(legs, seed=0)
>>> d = SymmetricTensor.random(legs, seed=1)
>>> p = tenet.zip_blocks(q, d, lambda x, y: x / (2.5 - y))  # a Jacobi step
>>> p.structure == q.structure
True
Notes

Why this does not reopen multiply's refusal. multiply refuses a second SymmetricTensor because a * b asks for a categorical operation and there is none: a tensor is Σ_τ A^(τ) ⊗ C^(τ), so an entrywise dense product has no expression in the reduced blocks, and the plausible-looking blockwise answer would be a silently different tensor — multiply is defined by dense semantics and must keep them. This function makes the opposite declaration in its name and signature: it is a map over coefficients, the caller supplies the map, and no claim is made that it commutes with to_dense. It cannot be reached by an operator (*, /) and cannot be reached by accident, and requiring one shared structure is what makes "the aligned block pair" mean something: block_order is a pure function of the structure, so equal structures give equal key tuples in equal order. The same argument already licenses the unary apply_blocks; the arity is not what was ever in question.

The consumer this exists for is the Jacobi preconditioner of a Davidson step, q / (lambda - diag) over the reduced storage a solver iterates on, with diag from map_diagonal. That quotient is a coefficient-space statement about the solver's own vector, not a statement about the dense tensor, which is precisely the distinction multiply's refusal draws.

Structure is untouched, so this is linear-algebra-free, backend-generic, traceable and differentiable, exactly as apply_blocks is.

embed

embed(
    t: SymmetricTensor, legs: Sequence[Leg]
) -> SymmetricTensor

t re-expressed on larger legs: same data, leading slots, zeros elsewhere.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to grow.

required
legs sequence of Leg

The target legs, one per axis. legs[i] must match t.legs[i] in provider, side and dual, and its space must contain t.legs[i].space: every sector a of the source appears in the target with degeneracy(a) >= source.degeneracy(a). New sectors are allowed and arrive as zero blocks. name is taken from legs and never compared.

required

Returns:

Type Description
SymmetricTensor

t on the target legs; source degeneracy alpha lands at target alpha (a prefix in the degeneracy index), everything else is zero.

Raises:

Type Description
ValueError

If legs is not an inclusion: a different leg count, a provider, side or dual mismatch on some axis, or a source sector whose target degeneracy is smaller (embedding never truncates).

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})
>>> a = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> e = tenet.embed(a, (Leg(W, OUT), Leg(V, IN)))
>>> e.shape
(3, 2)
>>> bool(tenet.allclose(tenet.restrict(e, a.legs), a))
True
Notes

Since _block_order enumerates product(*(leg.sectors ...)) and fusion_trees is a pure function of the sector tuple, containment makes the source's keys a subset of the target's — which is the fact the loop below rests on, and why no key can silently lose its data.

repartition

repartition(
    t: SymmetricTensor,
    outputs: Sequence[int],
    inputs: Sequence[int],
) -> SymmetricTensor

Public axes outputs become OUT and inputs become IN.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor to repartition.

required
outputs sequence of int

The public axes (in t's original numbering) that end up OUT.

required
inputs sequence of int

The public axes that end up IN. Together with outputs they must be a permutation of range(t.ndim); negatives are refused.

required

Returns:

Type Description
SymmetricTensor

The repartitioned tensor: public axis order exactly (*outputs, *inputs), and its legs are t's legs with side (and, for every axis that actually crossed, dual) adjusted.

Raises:

Type Description
ValueError

If outputs/inputs contain a non-integer, an out-of-range or repeated axis, or miss an axis — together they must be a permutation of range(t.ndim).

CapabilityError

If a leg must cross between domain and codomain — a line bend — and the provider does not implement BendingCoefficients. A repartition that moves no leg across sides works for every provider.

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(W, IN)), seed=0)
>>> r = tenet.repartition(t, (0,), (1, 2))  # axis 1 crosses to the domain
>>> r.structure.out_axes, r.structure.in_axes, r.legs[1].dual
((0,), (1, 2), True)
Notes

Owns no mathematics of its own: it transposes each crossing leg to the end, bends it, and transposes once more to the requested order — the whole chain composed once by repartition_plan and executed in a single pass, so every block is copied once instead of once per step.

load

load(path: str | PathLike) -> SymmetricTensor

Read a tensor written by save. NumPy blocks; structure exactly equal.

Parameters:

Name Type Description Default
path str or PathLike

The .npz file to read.

required

Returns:

Type Description
SymmetricTensor

The saved tensor, blocks NumPy-backed; load(path).to_backend("jax") is the documented restore.

Raises:

Type Description
ValueError

If — for SU(2), SU(N) or fZ2 — the file's gauge fingerprint is not the running build's: block coefficients are only meaningful against the CG / F / R conventions that produced them, so a gauge-mismatched file is refused rather than silently misread. The one exception is the SU(2) fingerprint listed in tenet.serialize._LEGACY_GAUGES (3j=condon-shortley;cg=condon-shortley;f=tks-su2irrep;r=tks-su2irrep;fs=tks-su2irrep), whose coefficients agree with the running build's to 4.95e-14 and which is therefore accepted. Also for a future format version, a header block count that contradicts the structure, or a member set that is not exactly the header plus b0..b{n-1}.

KeyError

For an unknown provider kind.

Examples:

See save for the round trip.

Notes

Block count and per-block shape are validated by SymmetricTensor.__post_init__, unmodified.

save

save(
    t: SymmetricTensor,
    path: str | PathLike,
    *,
    compress: bool = False,
) -> None

Write t to path as a single .npz: a JSON header plus one array per block.

Parameters:

Name Type Description Default
t SymmetricTensor

The tensor written; any backend. Blocks are converted with ar.to_numpy, so a JAX- or torch-backed tensor saves fine.

required
path str or PathLike

The destination file.

required
compress bool

False (the default) writes uncompressed; see Notes for why.

False

Raises:

Type Description
TypeError

If a Leg's name is not None, str or int — refused here, before anything is written, with the public axis named.

ValueError

If a leg's provider is not one of the serializable kinds.

Examples:

>>> import os, tempfile
>>> 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})
>>> t = SymmetricTensor.random((Leg(V, OUT), Leg(V, IN)), seed=0)
>>> with tempfile.TemporaryDirectory() as d:
...     tenet.save(t, os.path.join(d, "t.npz"))
...     t2 = tenet.load(os.path.join(d, "t.npz"))
>>> t2.structure == t.structure
True
>>> bool(tenet.allclose(t2, t))
True
Notes

A saved tensor loads back as NumPyload(...).to_backend("jax") is the documented restore, because a device placement is not a property of the tensor.

compress=False by default: reduced blocks are dense float arrays that do not compress well, so paying zlib on every checkpoint buys nothing. The zip container costs a constant overhead — a 4-block SU(2) tensor with 504 bytes of block data writes a 2282-byte file — which is not a bug.

enable_jax

enable_jax(*, ad: bool = False) -> None

Turn on the JAX-facing features, in one call instead of three statements.

Two separate effects, and only the first is on by default:

  1. The pytree registration (tenet.pytree) — SymmetricTensor becomes a JAX pytree whose leaves are its blocks and whose treedef is its TensorStructure, so jit, grad and vmap reach through it. This is local to this package: it registers our type with JAX and changes nothing about anyone else's.

  2. The broadened SVD/eigh VJPs (tenet.ad, with ad=True) — the Lorentzian-broadened rules that stay finite at the degenerate spectra a non-Abelian symmetry produces. This one is process-global and reaches other libraries: the seam is autoray.register_function("jax", "linalg.svd", ...), autoray's own extension point, so afterwards any ar.do("linalg.svd", jax_array) in the process — quimb's included — gets the broadened VJP, and the broadened gradient is correct only for an objective that is gauge-invariant on each degenerate subspace. Mutating another library's dispatch table is the user's act, so it is opted into by name rather than defaulted on; tenet.ad's module docstring is the full argument.

Parameters:

Name Type Description Default
ad bool

Whether to also install tenet.ad's broadened VJPs, effect 2 above. Defaults to False, which is the common case (the pytree alone). Pass True when differentiating through svd/eigh at a degenerate spectrum. To tune the broadening, call tenet.ad.install(epsilon=...) directly instead.

False

Returns:

Type Description
None

Raises:

Type Description
ImportError

If JAX is not installed, naming the optional extra to install.

Examples:

>>> import tenet
>>> tenet.enable_jax()          # the pytree registration; calling it twice is a no-op
>>> tenet.enable_jax()
Notes

Idempotent, in both halves: re-importing tenet.pytree is a sys.modules hit and tenet.ad.install() documents itself as idempotent, so repeat calls are harmless.

The older spellings are unchanged and keep working — import tenet.pytree and tenet.ad.install() are what this function runs, and there is one implementation of each. JAX stays an optional dependency: nothing here is imported by core.