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#

  • t3svd is 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_orthogonal is 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.

  • t3m stays raw rounding. It rounds via t3svd, 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 into t3m.

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