Skip to content

Contraction

Three entry points share one machine. tenet.tensordot pairs explicit axes of two tensors. tenet.einsum is the label-equation front end for any number of operands. tenet.compose, spelled @, is the categorical composition of two maps. The examples here use U(1); the semantics are identical for every symmetry, including the fermionic case, which is where the composition rule at the end earns its keep.

>>> 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)

What a contraction is

All three compute the same thing a dense index sum computes. For two rank-2 tensors,

\[ C_{ac} \;=\; \sum_b A_{ab}\, B_{bc}, \]

where the indices \(a\), \(b\), \(c\) run over dense basis states of three spaces, and \(A\), \(B\) are the two tensors built above (a and b in the code — the capitals are the mathematical objects, the lowercase names the Python variables). In TeNeT each index is carried by a Leg, and which legs may play the role of \(b\) is the only thing the graded version adds:

index leg side role
\(a\) a.legs[0] OUT free — survives into C unchanged
\(b\) a.legs[1] meets b.legs[0] IN meets OUT the summed wire
\(c\) b.legs[1] IN free

The sum over \(b\) is not over \(\dim V\) independent numbers. \(V = \bigoplus_s \mathbb{C}^{m_s}\otimes V_s\) is block diagonal, so the sum factorizes into one dense matrix product per allowed fusion channel, over \(m_s\) degeneracy indices each — and the channels the symmetry forbids contribute nothing because they were never stored. That is the whole computational content of the grading; nothing about the equation above changes.

tensordot — axes, paired in order

axes=((i, ...), (j, ...)) contracts axis i of a against axis j of b, pairwise. The output is a's free axes followed by b's, as in np.tensordot, and every free leg comes back unchanged — same space, side, dual and name:

>>> c = tenet.tensordot(a, b, axes=((1,), (0,)))
>>> c.legs == (a.legs[0], b.legs[1])
True

What makes a pair of legs contractible is structural: the two legs must carry the same space, and one end of the wire must be the dual object of the other (opposite dual xor (side is IN)). Dimensions are never compared, so two legs of equal dimension on different spaces do not contract, and the refusal names both legs and the reason:

>>> d = SymmetricTensor.random((Leg(V, IN), Leg(V, OUT)), seed=2)
>>> try:
...     tenet.tensordot(a, d, axes=((1,), (0,)))
... except ValueError as e:
...     print(str(e)[:23])
tensordot: pair 0, axis

d carries the same space V on both axes, so every dimension matches — and the contraction is still refused, because d's axis 0 and a's axis 1 present the same end of the wire rather than dual ends. The message is sliced here only to keep the doctest short; in full it names both legs and the reason, and the refusals below are printed the same way.

Two more refusals to know. A contraction that would leave no free leg is a ValueError: a rank-0 SymmetricTensor does not exist, and scalars leave the tensor world through tenet.norm, tenet.inner and tenet.full_trace. An axis pattern that moves a leg between domain and codomain is a line bend, and raises CapabilityError when the provider has no BendingCoefficients.

einsum — labels, with rules

The equation language is NumPy's, restricted to what is equivariant. Labels are single ASCII letters, one per axis; a label occurs at most twice in the whole equation, because a wire has two ends; with -> omitted, the output is every once-occurring label, sorted:

>>> c2 = tenet.einsum("ab,bc->ac", a, b)
>>> bool(tenet.allclose(c2, tenet.tensordot(a, b, axes=((1,), (0,)))))
True

"ab,bc->ac" is \(C_{ac} = \sum_b A_{ab}B_{bc}\) read out loud: b occurs twice, so it is the wire; a and c occur once, so they are free and appear in the output in the order written. The label letters are yours — they name axes, not sectors — but which legs a label may join is fixed by the same duality rule tensordot uses, so "ab,bc->ac" on two tensors whose middle legs are both OUT is a refusal, not a transpose.

The parser refuses, by name, everything with no equivariant meaning. A label repeated within one operand is a diagonal or a single-operand trace — tenet.trace is the trace, and the diagonal is not equivariant:

>>> try:
...     tenet.einsum("aa->", a)
... except ValueError as e:
...     print(str(e).split(", i.e.")[0])
einsum: label 'a' is repeated inside term 'aa'

Ellipsis means broadcasting over unlabelled axes, and symmetric tensors do not broadcast:

>>> try:
...     tenet.einsum("a...,...a->", a, b)
... except ValueError as e:
...     print(str(e).split(";")[0])
einsum: equation 'a...,...a->' contains '...'

An input label missing from the output would sum an axis away — also refused:

>>> try:
...     tenet.einsum("ab->a", a)
... except ValueError as e:
...     print(str(e).split(",")[0])
einsum: input label 'b' of 'ab->a' is missing from the output

Each message says what to write in place of the refused equation; the full list is in tenet.einsum's Raises.

composea @ b, codomain meets domain

Composition is the map-level operation. Reading \(A : V_b \to V_a\) and \(B : V_c \to V_b\) as maps, \(A \circ B : V_c \to V_a\) is

\[ (A\circ B)^{a}{}_{c} \;=\; \sum_b A^{a}{}_{b}\, B^{b}{}_{c}, \]

the same sum as above with the bookkeeping moved from axis positions to sides: b's codomain is consumed by a's domain, which must carry the same (space, dual) sequence in the same order. No axis labels, no reordering. Its unit is tenet.identity:

>>> bool(tenet.allclose(tenet.identity(a.codomain) @ a, a))
True

Composition never reorders legs within a side; that is tenet.transpose, and moving a leg between sides is repartition.

The composition rule

For a fermionic provider the two ends of a wire are not interchangeable: the cap \(V^{*} \otimes V \to \mathbf{1}\) and the cap \(V \otimes V^{*} \to \mathbf{1}\) differ by a Koszul sign on every odd sector. So when an einsum contracts an odd wire, which operand supplies which end is load-bearing.

The rule you follow: every two-operand einsum is a composition. Operand 1 supplies the IN end of every shared wire and operand 2 the OUT end. With three or more operands the same rule applies pairwise in caller order.

A wire that turns around in your diagram is bent explicitly with repartition before the einsum. The formal statement is in tenet.tensordot's and tenet.einsum's Notes, and tenet.network follows the rule at every call site, pinned by a hygiene test with no exemptions. For a purely bosonic symmetry every choice gives the same numbers, which is why the discipline is kept before the fermions arrive.

What optimize does

With one or two operands, optimize is not consulted and opt_einsum is not imported. With three or more, opt_einsum.contract_path chooses the pairwise order from the operands' physical shapes, 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).

A strategy name is a pure request for a search, so the path it produces is cached on (equation, shapes, name) and a repeated call re-uses it: on an eight-operand SU(2) chain the search is a third of the call. An explicit path is already a path, and a PathOptimizer object may be stateful and deliberately non-deterministic, so both are consulted on every call.

What it never changes is the mathematics: every step of the path is the same two-operand contraction, shared labels contract in a deterministic order, and the final transpose is a real categorical permutation. The path depends on static structure only, so under jax.jit it is baked in at trace time like every other structural decision.

For a graded network whose sectors are unevenly filled, a hand-written pairwise order often beats the path a cost model derived from physical leg sizes picks. Writing the contraction as a chain of two-operand einsum calls is how you take that control.

Where next