Signature style — shape comments as the real type system#
A house convention for T3Toolbox function signatures. Read this once; apply the principle
everywhere, the mechanics within reason. Reference exemplar: t3toolbox/backend/probing.py.
Shape vocabulary (C/W/K blocks, elm_shape, single-axis letters): docs/batching_and_stacking.md.
The principle (this is the part that matters)#
In array code the nominal type is nearly useless — the shape is the type. Every argument is
NDArray (or Union[Sequence[NDArray], NDArray]), so Python’s annotations tell a caller almost
nothing: not the rank, not which axes are batch vs core, not the length of a core tuple, not which
bond matches which. We recover that lost contract with a trailing comment after each argument and
each return element. The comment is not prose decoration — it is the type annotation that the
language cannot express.
Two goals, both about the reader:
Fill the gap in Python’s type system for array/tensor code.
Make a caller understand the arguments at a glance — jump-to-definition should answer “what shapes does this need, and what comes back?” without reading the docstring or the body. That is where people actually look.
If an edit serves those two goals, it’s in the spirit of the rule. If a mechanical detail ever works against them, the goals win — see “Within reason” below.
Annotations vs comments: express what the type system can, comment the rest#
The comment-as-type technique is a patch for a specific deficiency — array shape. It is not a license to write ordinary types as comments. The division of labor:
Whatever Python can express, put in the annotation. A plain
int | Sequence[int] | None,Optional[float],bool, a callable — fully expressible, so annotate them (real, checkable, IDE-visible; this is a library for outside users whose tooling consumes annotations). Be correct-and-explicit: includeNonein the union / useOptional.Whatever it can’t, put in the comment. Array shapes (
NDArrayis content-free), the length of a sequence (Sequence[int]can’t saylen=d+1), cross-argument constraints (1 <= max_rank <= min(N,M)), and semantics (requires unstacked).
The two coexist on one argument, exactly like array args do (a weak NDArray / Union[...] annotation
plus a load-bearing shape comment). Example:
max_tt_ranks: typ.Union[int, typ.Sequence[int], None] = None, # scalar caps all, or len=d+1
rtol: typ.Optional[float] = None, # requires unstacked
Why not comment-only? Because annotating the expressible part keeps the presence of a comment
meaningful: a # then signals “there is a contract here the type system cannot capture.” If comments
also carried ordinary types, you couldn’t distinguish load-bearing shape comments from
types-written-as-comments. (It also keeps the annotation column uniform — every arg carries its Python
type; the comment carries the rest.)
The mechanics#
def compute_mu(
left_tt_cores: typ.Union[typ.Sequence[NDArray], NDArray], # len=d-1, elm_shape=C+(rLi,nUi,rL(i+1))
xis: typ.Union[typ.Sequence[NDArray], NDArray], # len=d, elm_shape=W+C+(nUi,)
) -> typ.Union[
typ.Sequence[NDArray], # mus. len=d, elm_shape=W+C+(rLi,)
NDArray,
]:
def name(on its own line; one argument per line asname: type, # contract.The closing
) -> ...:carries the return shape(s), each return element on its own line with its own trailing contract — expand return tuples one-element-per-line even when they would fit on one line.Three vertically-aligned columns: argument name · type annotation ·
#contract. The block reads as a table; a single-arg change touches a single line.The contract uses the shared shape vocabulary — the same letters as the body-local suffixes (
mu_WCa,B0_b_j_c) and the grouped-contraction subscripts ('WCa,Caib,WCi->WCb'). One vocabulary across signature, body, and helper names: learn it once, read it everywhere.
Micro-grammar (keep the table cells uniform)#
Arrays:
# len=<n>, elm_shape=<...>—lenfirst (for core tuples), thenelm_shape, comma separator. Useshape=<...>when there is no tuple wrapper (a bare array).Non-array / scalar args: a short role or constraint note, e.g.
# 1 <= max_rank <= min(N,M),# 'left' | 'right',# requires unstacked. If a scalar’s role is obvious from its name, a note may add nothing — then keep it short or omit (see below).Placement / dtype, when load-bearing:
NDArraysays nothing about dtype or host-vs-device either — so when those are part of the contract (not just shape), put them in the comment too. The motivating case is the uniform masks, taggedHOST bool, staticalongside their shape. TheHOSTtoken means “must be a host (numpy) array, never jax/traced — it is static structure, runtime-enforced by the mask guard” (under jit a traced mask breaks; seeuniform_pytree_composition.md). No tag = the default: dtype-agnostic andxnp/backend-polymorphic (numpy or jax). Spell a dtype (bool,int) only when it actually matters. This keeps the old “a barenp.is a backend-agnosticism tell” intuition usable: aHOSTarg is meant to be numpy.Prefer the comma form over a period (
# len=d, elm_shape=..., not# len=d. elm_shape=...).
Aligning the comment column: one block, or grouped?#
Names and types are always column-aligned — each is an O(1) jump for the eye. The only open
question is the comment column. The cost of forcing every comment into a single column is the
type-length spread: the gap an argument gets is (longest type+default) − (its type+default). So:
Small spread → one aligned comment column. Cheap, tidy, every comment O(1). The common case (e.g.
probing.py, where every arg is the same array type — the column falls out for free).Large spread → group, and align within each group. Split the args into runs of similar-length types (which usually coincide with roles: operands / rank caps / tolerances / flags), put a blank line between groups, and align the comment column within each group. Group by type-length, not role — length is what sets the gap; role is only the usual proxy. When they disagree (e.g. a short
strselector sitting among longUnioncaps), an argument whose type-length matches no group gets its own blank-delimited group rather than being force-aligned into a column it doesn’t fit — otherwise you reintroduce the very gap grouping exists to remove. The test is the reader’s eye, not a column count: split an arg out when force-aligning it would open a gap big enough to lose the row (a few spaces is fine; a tab-plus usually is not). The comment column resets per group; names/types stay aligned across the whole signature (still O(1)) — only the comment column is per-group. This keeps every comment O(1) within its group, keeps lines short, and makes the grouping visible. It is strictly better than one column with 30-space gaps, and far better than the staircase (each comment 2 spaces after its own type, no cross-row alignment): the staircase minimizes gaps but makes the comment column unpredictable — O(n) to find — which is the worst to scan. Don’t use the staircase.
Rule of thumb: group when the args span clearly different type-lengths (a long array/Union next to a
bare float/bool) and there are enough rows for a staircase to bite (≳4). For two or three args
a single column is already fine. (t3m, frontend and backend, is the worked example: groups
operands / max_* caps / rtol,atol / oversample, blank-line-separated.)
Within reason (principle over dogma)#
This is a convention, not a contract enforced by anything.
The iron imperative for every comment is that it be useful to a user of the code or a maintainer of the library. A comment that only repeats what the signature already says is not neutral — it is a liability: a second, unverified copy of the contract, free to drift out of sync with the first. The shape comment exists for one reason: to carry type information the language cannot express (shapes, sequence lengths, which bond matches which). Where that information is already expressed — by an annotation, or by the name itself — the comment has no job to do.
Apply the convention where it earns its keep; handle the exceptions case by case. These rules cannot anticipate every case: the deviations below are illustrative, not exhaustive, and it matters more to follow their spirit than their wording. Reasonable deviations:
Trivial scalars (
use_jax: bool, an obviousaxis: int) may need no comment, or only a brief role note. Don’t manufacture filler to fill a column.Self-evident or non-array types (a callable, a plain Python
intcount, an enum-like string) need only a short note, or none.Pathologically long types/contracts (a deep nested
Unionof two full representations, as int3_probe’sx) may wrap across several lines; perfect single-line alignment is impossible there, so keep it readable rather than rigidly tabular.One-off / throwaway internal closures (
_funcinside anxmap) don’t need the full treatment.Grouped-contraction call sites (
contractions.contract) — an explicit exception. There the subscripts string already is the shape contract ('WKCa,Caib,WCi->WKCb'says operand 1 isW+K+C+(a,), operand 2 isC+(a,i,b)), so a comment restating it tells the reader nothing the call has not already told them — exactly the liability above. The supplement keywords state their own contract by name (len_C== the number ofCaxes). Prose describing an operand’s role at one call site (“mu jet”, “probe vector w”) belongs in the calling function, never insidecontractions.py: the interpreter is deliberately caller-agnostic, a mechanical workaround for einsum’s lack of grouped indices.
The two costs we accept knowingly: the comments are unverified (they can drift — tests and review are the only guard, so update them when shapes change), and the alignment is hand-maintained (no autoformatter; one long name re-flows the block). Both are the price of using comments as types; we pay it on purpose.
Scope notes#
These trailing comments are the source-reader / jump-to-def surface. The numpydoc
Parameters/Returnsdocstring is the rendered-docs surface. They may both carry shapes today; if so, keep them consistent. (Whether docstrings should stop repeating shapes is a separate, undecided question — don’t conflate it with signature cleanup.)Applies to the frontend (
TuckerTensorTrain& friends) too, not only the backend — and to the uniform layer (whoseHOST bool, staticmask tags are the motivating case above).The parked weighted layer (
wt3_*) is not held to this until its post-1.0 revival.The reader-side half of this convention (the decode table users need) is
../reading_signatures.md.