Batching and stacking — contributor internals#
Extension rules, decision history, and test guidance excised from the user-facing
../batching_and_stacking.md — read that first for the conventions
themselves. This is the material you need when changing or extending the batching machinery.
The interpreter era (2026-07-17): the string is the spec, and the machine honours it#
The named-contraction toolkit this note used to govern — ~104 enumerated
WCa_Caib_WCi_to_WCb-style functions, each a hand-written flatten + fixed-subscript einsum — was
replaced by the grouped-einsum interpreter contractions.contract('WCa,Caib,WCi->WCb', ...).
The subscripts string plays exactly the role the function name used to (the batch-group type
signature at the call site), with one decisive difference: the machine now derives the behavior
from the string, instead of a hand-written body that could drift from its name. Group sizes are
solved from the operand ndims (identifiability is a rank condition on the subscripts — a call site
either always needs a len_<G>= supplement or never does); groups expand into fresh single-axis
letters; one einsum runs on the operands as given.
Adding a grouped contraction is now: write the string at the call site. No new function, no per-function oracle. Two obligations remain, both enforced by tests:
Record the new string’s expected supplement in the frozen
HISTORICALtable (tests/test_contractions_interpreter.py) — decide it by hand first, then let the table pin the solver against your analysis (TestVocabularyCompletenessfails until the entry exists).Nothing else. The definitional loop oracle, the call-site consistency check, and the any-axis sharding sweep all pick the new string up automatically from an AST scan of the source — there is no hand inventory to update, which is the point: four hand-maintained inventories of the old module were found wrong.
The two failure eras this design closes (recorded, because the instincts recur)#
Era 1 — delegation/fusion (the named toolkit). The old extension rule let a full-group name
delegate to a fewer-group twin (“the extra group rides along for free”). That justification was a
claim about arithmetic silently generalised into a claim about structure: the fold was numerically
exact — so no numerical test could ever see it — but it froze sharding choices, and it was found
only when a downstream consumer tried to shard. “A type error, morally speaking” (Nick,
2026-07-15). Per-site judgement missed sites twice (five t-folds found, four K+C fusions
internal to single einsums missed). Under the interpreter this failure class is inexpressible:
there is no body to drift from the string, and no reshape in which to fuse anything.
Era 2 — the unfusing rule’s own limit. The post-fusion rule (“a shared block flattens to a
letter; a passive block rides as '...'”) was the best a fixed subscript could do, and it carried
a residue: a shared block’s own axes still flattened, so only its leading sub-axis was shardable.
Per-call expansion removes the flatten and the residue with it — see the sharding section below.
Decision history#
Why the letters
W/K/C. They are deliberately disjoint from the core/variation symbols (U,P,Q,O,G,H,B). Before the rename they wereF/V/G, which clashed with the TT-coreGand the Tucker-variationV;apply/entries/dense_probeadditionally drifted to a privateX/V/I/K/Zscheme (so e.g.mu_VXabecamemu_WCa). Removing that overload was the motivation for the rename.The map-over-
Kplan was reversed. The earlier plan forK-stacked forward probing deferred the 3-block contractions in favour ofvmap/map overK; it was reversed in favour of genuine 3-block (W,K,C) contractions — consistency with thecontractions.pytoolkit, no PythonKloop on the numpy path, and low-level einsums fold into XLA at least as well as avmap.Frame-as-aux → frame-as-leaf. The old design made
T3Tangent’s frame jaxaux_dataguarded by object identity (self.frame is other.frame) — a numerical check faked as structural. It forced a recompile on every frame change (each Newton step) and false-failed after a jit round-trip. Numericalizing the guard (safety.frames_equal) let the frame become a pytree leaf: traced data, compile-once across bases. Full story:dev/archive/safe_unsafe_mode_plan.md.K-stacked residuals for theapply/entriesadjoints are deliberately deferred (aprobe_transpose-style extension; build history:dev/archive/apply_entries_handoff.md).
Test-writing guidance (batching-specific)#
Tests are RNG-order sensitive (one global seed at import) — a bug class we hit. New numerical tests are numpy-only (jax invocation is covered by
test_dispatch); seeCLAUDE.md.Stacked arrays blow up fast. In tests keep stack dims 1–2 and core dims small.
The parked weighted layer still threads
use_jax(the old pattern) — don’t take it as a model for new code. (The uniform layer follows the modern conventions: inferred dispatch, host-numpy masks — see theuniform_*notes.)
Maintenance note (blast radius)#
When you change a stacking convention, the user-facing batching_and_stacking.md, this note, and
CLAUDE.md are part of the blast radius — update all of them. The conventions are deliberate; if
you find yourself wanting to break frame-inner, re-read the user doc’s §3 first.