Rank masks — the decision record (why they exist, and why boolean)#

The consolidated mask decision record, in two parts: why the masks exist at all (they cannot be dropped — they are the functional rank constraint that makes variable-rank optimization work), and why the representation is boolean masks rather than integer ranks (the closure argument, the honest ledger, and the rejected alternatives). The user-facing “how masks behave” story is docs/uniform_masks_vs_ranks.md. Pairs with docs/uniform_ranks_and_varieties.md (what the object represents), numerical_contract_catalog.md (the minimal-rank audit), uniform_svd_prefix_orthogonalization.md, and uniform_pytree_composition.md.

Part I — why the masks exist (and why “just inflate to uniform rank” fails)#

What the masks do#

A uniform Tucker tensor train pads each edge to a common size R — so a whole stack of T3s can live in one array, lax.scan-friendly — and marks slots [0, rank) real with a prefix mask. The real rank may differ per edge and per stack element: that is the determinantal variety — fixed shape, ranks that vary (docs/uniform_ranks_and_varieties.md). In the tangent / optimization layer the masks zero the variation in the padded slots, so a gradient step cannot put content beyond the real rank.

Why variable ranks matter#

Real data rarely wants one rank everywhere. A good T3 fit may need a small rank on one edge and a large one on another; forcing a single rank either underfits (rank too low — accuracy left on the table) or overfits (rank too high — fitting noise, wasting compute). Rank continuation exploits exactly this: start low and grow ranks edge-by-edge under a conditioning criterion (docs/rank_continuation.md, examples/fit_varied_rank_tensor_newton_cg.py). All of it presumes you can hold ranks at chosen, possibly-varied values — i.e. that the optimizer respects the rank structure.

The tempting simplification — and why it fails#

The minimal-rank audit (numerical_contract_catalog.md, empirically verified against the dense oracle) found that no manifold operation requires minimal rank: inner/norm need only an orthonormal, gauged frame; project/transport/gauge need only orthonormality; retract is valid on a non-minimal frame. Equivalently, for a given tangent vector, inflating its ranks to the padded size (orthonormally completing the frame, zero-extending the variations) leaves every operation’s result unchanged:

op(inflate(tangent)).to_dense()  ==  op(tangent).to_dense()

So why not drop the masks entirely, work on the padded supercores, and treat every slot as real? It would be a large simplification — no static structure in the jit cache key, so the per-step-recompile problem would vanish for the tangent layer.

Because optimization does not operate on a fixed tangent — it computes a new one (the gradient) every step. At a rank-deficient frame the projected gradient generally has content in the padded (“completion”) directions — moving there increases the rank. The masks are exactly what zero that content. Drop them and the gradient grows every edge toward the common padded rank R: you lose rank control and overfit — the precise failure the variable-rank feature exists to prevent.

So the maskless equivalence is genuine, but only for operations on a fixed tangent. The masks are not bookkeeping; they are a functional rank constraint that makes variable-rank fitting work. Keeping them is the deliberate choice.

The cost of keeping masks, and how it is paid#

Masks are static structure, so they ride in jax aux_data and become part of the jit cache key — which risks a recompile every time the frame is rebuilt (i.e. every optimization step). Two design choices remove that cost:

  • SVD-based orthogonalization places the real content in the leading slots, so the masks come out a deterministic prefix — bit-identical every step at fixed rank (uniform_svd_prefix_orthogonalization.md).

  • Value-hashed mask holders key the jit cache on mask content, so a rebuilt-but-identical holder is a cache hit (uniform_pytree_composition.md).

Together: no recompile within a fixed-rank stage; a recompile only when the ranks genuinely change (e.g. a rank-continuation step) — which is correct, and rare.

Part II — why boolean masks rather than integer ranks (the representation decision)#

(Excised from the pre-split uniform_masks_vs_ranks.md, whose user-facing successor carries the “how masks behave” story; this part preserves the decision ledger.)

The two candidate representations#

The rank metadata of a uniform T3 must be stored, not derived: a padded core cannot tell you its rank (a “real” slot may be numerically zero; a padded slot may carry garbage). The question is the format:

  • (a) Boolean edge masks (chosen): for each edge, a 0/1 vector over the padded size marking which slots are real. Equivalently, a diagonal projector onto the real subspace.

  • (b) Integer rank arrays: store the counts (tucker_ranks, tt_ranks) and rebuild a prefix mask arange(pad) < k on demand. This presumes the real block is always the prefix [0, k).

These coincide only if every mask is prefix-contiguous — and the natural operations do not preserve prefix-contiguity (the concat/Kronecker gap examples are in the user doc). The boolean algebra of subsets (under disjoint union and product) is closed under exactly the operations T3 algebra is made of; the integers-as-prefixes representation is not: every + and × breaks the prefix invariant, and the only way to restore it is to gather/scatter the data to the top-left. In the stacked setting that compaction is ragged per element — precisely the irregularity the uniform layout exists to avoid. Read this way, the mask is “more minimal” in the sense that matters: it is the minimal state the operations preserve. The integer rank is minimal per object, but only stays valid if you continuously renormalize positions — i.e. pay for data movement, or secretly re-derive the positions (reinventing the mask).

Honest pros and cons#

Boolean masks (a):

  • ✅ Closed under ⊕/⊗ (concatenate / Kronecker); zero data movement. The composition niceties (add → concat masks, multiply → Kronecker masks, sweep → apply masks) are consequences, not coincidences.

  • ✅ Directly usable in the masking einsums.

  • ✅ Minimal in the operationally relevant sense (state the algebra preserves).

  • ➖ The rank is implicit (mask.sum(-1)), so it reads less directly than an integer field.

  • ➖ A mask can sit in a non-canonical or meaningless pattern that validate cannot cheaply vet — the same non-enforced status as orthogonal/minimal/gauged.

  • ➖ Masks do not save you from rank growth (stated user-side too).

Integer ranks (b):

  • ✅ Smallest per object; reads literally as the stratum label of the determinantal variety.

  • ✅ Trivially the canonical form.

  • Not closed: every +/× requires a per-stack-element compaction to restore the prefix — ragged data movement — or it must carry positions anyway, which is the mask.

Other representations considered (and why they’re off the map)#

Each fails one of the required properties — closure under ⊕/⊗, uniform shape across the stack, and (for jit) host-static (uniform_pytree_composition.md):

representation

closed under +/×

uniform shape across stack

notes

bool mask (dense array) ✅ chosen

✅ concat / Kronecker

✅ fixed padded size

the working representation; rank reported as mask.sum(-1)

integer rank counts

❌ gappy ⇒ per-element compaction

a count can’t denote a gap; compaction is ragged data movement and data-dependent shapes ⇒ not jittable

hot-position lists / “hot rank” tuples

✅ position arithmetic

❌ varying length ⇒ ragged

a sparse mask; per-element lengths differ ⇒ jagged, defeating the uniform layout (you’d pad+remask — reinvent the mask)

bool tuples (vs arrays)

value-hashable, but non-contiguous/boxed ⇒ must materialize for the multiply; slower eager, worse stacked memory; no win over a bool array (which can be value-hashed via its bytes)

float mask doubling as edge weights

conflates structure (static aux, non-differentiable, defines rank, value-neutral) with a parameter (traced leaf, differentiable, scales the contraction, value-affecting): opposite jax treatment, and autodiff would silently differentiate — and a grad step corrupt — the “mask.” Weights are a separate (parked) concept

The bool mask is the unique fixed point: closed ∧ uniform-shaped ∧ host-static. The one attractive property of the integer/tuple forms — value-based hashing for jit-cache hits — is real but separable: get it on the bool mask via a byte hash (uniform_pytree_composition.md). (Caveat: a rank count is an insufficient cache key off canonical form — two gappy masks with equal counts but different positions are different computations — so the value hash keys on the mask bytes; in canonical form the count suffices.)

Maintainer guard: host numpy is intentional#

Whichever encoding, the structure must live as numpy (host) static aux_data: any jax op on it inside a trace becomes a tracer. Under jit the host masks fold into the compiled program as device constants (no per-call transfer); the eager cost and the deferred jax.device_put option are in uniform_pytree_composition.md. The np.* in mask code is intentional — do not “fix” it to xnp.