Design rationale: t3svd vs rank_adjustment_sweep#
Why T3-SVD and rank minimization are two separate operations, why t3svd makes no minimal-rank
guarantee, and why the minimizer is a directional sweep rather than a minimize() method. This is the
reasoning behind the current API — read it before “fixing” t3svd to return minimal ranks again.
(Companion: t3svd_minimal_ranks.md is the user-facing “what t3svd returns
and how to minimize”; this note is the why.)
The starting point: t3svd was doing too much#
The first version of the fix for “t3svd returns non-minimal ranks under truncation” (the orphan
mechanism — see the companion note) folded a rank re-tightening pass into t3svd, behind a
minimize_ranks flag. That bundled three concerns into one call: truncation, rank minimization, and
(implicitly) output-gauge management. It was complex and, worse, it made the output gauge
unpredictable.
The gauge problem (and why reversing can’t fix it)#
The truncating sweep ends left-orthogonal. The cheapest re-tightening pass ends right-orthogonal (it moves the orthogonality center the other way). The re-tighten was gated — it ran only when a hard cap actually orphaned a rank. So the output gauge depended on whether an orphan happened to occur: left-orthogonal usually, right-orthogonal when a cap bit a certain way. The user could not know which without a numerical check (as costly as just re-orthogonalizing).
We asked whether a reverse (cheap — just rank-sized TT transposes) could realign the gauge. It cannot, by a parity argument:
A valid sweep flips the gauge (from a given gauge, only the flipping sweep direction is well-defined: L→R needs a right-orthogonal input and yields left-orthogonal; R→L the reverse).
A reverse flips the gauge and the mode order.
The no-orphan path is one sweep (→ left-orthogonal); the orphan path is two (truncate + re-tighten → right-orthogonal). They differ by one sweep, i.e. opposite gauge parity.
To preserve the mode order you need an even number of reverses, which leaves gauge parity unchanged.
So no amount of reversing can make the two paths land in the same gauge. The only ways to a consistent
gauge are to always re-tighten (an extra sweep even when nothing is orphaned — wasteful) or to not
re-tighten inside t3svd at all. We chose the latter.
The decision: separate the basic algorithm from minimization#
t3svdis the basic algorithm (Algorithm 10 / Oseledets’ TT-SVD): orthogonalize + one left-to-right truncating sweep. It is always left-orthogonal and returns the raw sweep ranks, with no minimal-rank guarantee under truncation. One job, predictable gauge, no hidden sweeps.rank_adjustment_sweep(direction)is a separate, opt-in lossless reduction that drops structurally-redundant ranks. Minimization is now the caller’s explicit choice.
This satisfies the three goals that were in tension: no excessive sweeps (t3svd does the minimum;
minimization is paid only when asked), user control (the user decides whether/when to minimize), and
simplicity (one fixed gauge out of t3svd, no option-dependent return).
Why a directional sweep, not minimize()#
A single directional reduction sweep reaches the minimal ranks only if the input is already orthogonal
in the opposite direction: a left-orthogonal input (e.g. a t3svd result) already satisfies the
forward bounds, so a 'right_to_left' sweep adds the backward bounds and the result is fully minimal. On
a general input one sweep is a partial reduction. So minimize_ranks() would over-promise — the name
rank_adjustment_sweep says what it actually is, and the direction argument also picks the output
gauge ('right_to_left' → right-orthogonal, 'left_to_right' → left-orthogonal). Compose both
directions for a guaranteed minimal result in a chosen gauge.
We chose the single-pass version (over a “robust” variant that orthogonalizes internally and always
minimizes) because it is cheaper for the common case (minimize a t3svd output — one pass) and keeps the
operation honest about being one sweep. The opposite-orthogonality precondition is not enforced — the
same treatment as assume_orthogonal; verify with is_left_orthogonal / is_right_orthogonal first.
The ragged/uniform asymmetry on misuse#
On the wrong direction the two layers behave differently, and this is by design:
Ragged truncates each edge to
min(rows, cols)with no cap, so it can only discard genuinely-zero directions: wrong direction → under-minimized but lossless (tensor intact).Uniform has static array shapes, so it must commit to the (minimal) output shape up front and force the supercore into it: wrong direction → lossy (it discards real content).
Uniform cannot match ragged’s lossless-partial behavior here: its left-to-right scan structurally requires a right-orthogonal starting gauge, so it can only assume the precondition (cheap, lossy if violated) or orthogonalize internally (always minimal, but then it is no longer a single directional pass). We keep the precondition required and not-enforced, consistent at the contract level, with the failure modes documented (and doctested) on both methods.
Other decisions#
assume_orthogonalis one type (a bool: “the input is already right-orthogonal — the form the L→R sweep needs”). Supporting the other gauge would mean reversing internally and returning a different gauge; a caller with a left-orthogonal T3 reverses it themselves. Not enforced.Uniform has no
rtol/atol. A tolerance makes the truncated shape data-dependent, which the uniform (static-shape) layer forbids. Ragged keeps them.t3mstays raw rounding. It rounds viat3svd, so its output is now correct and within the max ranks but not auto-minimized. Re-minimizing every round is the caller’s choice (rank_adjustment_sweep), not a hidden cost baked intot3m.
See also#
t3svd_minimal_ranks.md— user-facing: why ranks may be non-minimal, how to minimize, the matricization/TT background.backend/t3_svd.py(t3svd,rank_adjustment_sweep),backend/ut3_svd.py(ut3svd,ut3_rank_adjustment_sweep).