Contributing¶
A change is a branch off main, the family's test file run before and after, the whole suite before the pull request, and the docs page the change touches in the same pull request. Code follows nnsight's STYLE.md with the additions below, and every value, family and helper is written so that support(), the repr and the suite describe it without anyone opening the source. The example is a family for one load, written the way a shipped module is.
from types import SimpleNamespace
import torch
import nnterp
from nnterp import StandardizedTransformer
from nnterp.components import Attention, EProperty, Layer, Mlp, Residual
from transformers.models.gpt2.modeling_gpt2 import GPT2Attention, GPT2Block, GPT2MLP
RENAME = {"transformer.wte": "embed_tokens", "transformer.h": "layers", "transformer.ln_f": "norm", "ln_1": "input_layernorm", "attn": "self_attn", "ln_2": "post_attention_layernorm"}
class Layer(Layer):
"""GPT-2's decoder block; returns a bare tensor, so the base holds."""
class Attention(Attention):
"""GPT-2's attention; the shared eager forward and the residual added in the block, so the base holds."""
class Mlp(Mlp):
"""GPT-2's MLP, with one value of its own."""
@EProperty("c_fc.output", description="The up projection's output, before the activation")
def mlp_preactivation(self, value: torch.Tensor) -> Residual:
return value
def intermediate_size(model):
"""The MLP width is ``n_inner``, ``None`` meaning four times the hidden size."""
return model.config.n_inner or 4 * model.hidden_size
family = SimpleNamespace(RENAME=RENAME, ENVOYS={GPT2Block: Layer, GPT2Attention: Attention, GPT2MLP: Mlp}, intermediate_size=intermediate_size)
model = StandardizedTransformer("openai-community/gpt2", family=family, dispatch=True, attn_implementation="eager", device="cpu")
print(model.family is family, model.intermediate_size) # True 3072
print(model.support()["mlp.mlp_preactivation"], str(type(model.layers[0].mlp).mlp_preactivation))
# None (mlp_preactivation) -> Residual [batch seq hidden_size]: The up projection's output, before the activation
with model.trace("The Eiffel Tower is in"):
pre = model.layers[0].mlp.mlp_preactivation.save()
print(tuple(pre.shape)) # (1, 7, 3072)
A shipped family is the same content as a module under nnterp/families/ named after the model_type, with the transformers import at its top and a module docstring that teaches the family (Adding a family). The annotation -> Residual is what gives the value its layout and its repr line.
House style¶
- Present tense, current behavior. The source and the docs say what is: no "used to", no issue numbers, no "fixed", no TODO in prose. Where code exists because something once broke, write the constraint and its reason, not the incident.
- A docstring states the contract; a comment says why. A module docstring teaches the family or the concept, a value's docstring says what the tensor is, its layout and how writes behave, and an inline comment names the failure averted or the alternative rejected. A comment that paraphrases the line under it is deleted.
- One word, one meaning: family, name, value, contribution, available and unavailable, interface, hybrid, as the Glossary fixes them.
- A description on a value is a capitalized sentence without a trailing period that names what the tensor is, which is what
repr(model)andsupport()show. - One family per module, named after its
model_type: The file name is the registry entry. Families import their modeling module at the top of the family module only, never fromnnterp/components/ornnterp/standardized.py, soimport nnterploads no modeling code. - Every value is annotated with a layout name (
-> Residual,-> Pattern,-> Keys), never an inlineFloat[Tensor, "..."]. A name is defined in the file of the envoy that serves it, with a comment above it, and exported fromnnterp.components. A family that redefines a value writes the base's name, solayoutanddimscannot drift. Axis names are the constants innnterp/components/standard.py, and an axis that is a fact about the model is named as the size that holds it, which letsaxis_sizeresolve it. - Every value located inside a forward carries an
unavailable=predicate (needs_eager,interface_reason,needs_torch_kernels, or the family's own), sosupport()answers before a trace runs and a read raisesUnavailablerather thanSourceNotAvailablewhere the reason is known in advance. A value a family lacks is declared,attention_probabilities = unavailable("..."), never omitted. - A root size a config spells its own way is
def <size>(model)in the family module, and a model that does something of its own after the head isdef project_on_vocab(model, hidden)there. Neither is a subclass of the root. - One method per value in
FamilySuite, one file per family. A new value gets a method that reads it, checks its layout and writes it, and runs on every family. A family that lacks it declaresunavailable(...)and lists the key in itsEXPECTED_UNAVAILABLE. from __future__ import annotations,X | None,list[str]. Raise by default, and warn only where the program can proceed.
Workflow¶
- Branch from
main. - Make the change: a family module, a component value, or both, and the test that pins it.
-
Run the family's file, then the whole suite, offline and on the CPU (Testing):
-
For a new family, add
tests/families/test_<model_type>.pywith a tiny checkpoint that is cached offline, preferring thehf-internal-testing,trl-internal-testing,yujiepanandhf-tiny-v2organizations. A config that does not parse is patched in a copy of the snapshot, the waytests/families/test_olmo3.pydoes. - For a new value, add a method to
FamilySuiteand expect it to run on every family. - Update the page under
site/docs/that the change touches, and run every snippet on it before publishing. A page other than transformers compatibility names no version. - A test that routes kernels restores
"default"in afinally, since the routing is process-wide. A degenerate tiny checkpoint getsLOAD_KWARGSor a family-specific override, not a weaker assertion. - Commit as
area: lowercase imperative summary, with a body that says why.
The skills in ndif-team/skills state the same facts as these pages for an agent. A change that alters what an agent should do, a renamed value, a new condition on one, a new family's quirk, is a change to the matching skill as well.
Design decisions and open items¶
Design decisions nnterp holds to, stated as what nnterp does not do:
- Values are on the envoy that serves them,
model.layers[i].layer_output, not on the model as an indexed row, and there is no table of them to query apart fromsupport()andorder(). - Nothing runs at construction except the default family's shape check, and the suite carries the causal checks. There is no load-time refusal of a block with a class not in
ENVOYS: Such a block is a plainEnvoywith nolayer_output. - Eager attention is not forced at load, and
support()sayseageris needed when the checkpoint runssdpa. - A remote run needs nnterp installed on the server. The family travels by reference, since an
EPropertycannot be pickled by value. - The kernels of a recurrent mixer are not routed at load: With
flash-linear-attention,causal-conv1dormamba_ssminstalled the kernel values are unavailable until the caller routes them. - The sequence axis is 2 on softmax
attention_queries,attention_keysandattention_values, as transformers hands them to its interface, and 1 on every other value. Nothing normalizes it. hasattr(envoy, name)andgetattr(envoy, name, None)raiseUnavailable, and there is norequireoravailablehelper on the model that answers for a set of values at once.- There is no wider tolerance for half precision: The suite loads each checkpoint in its own dtype and holds the contribution identity and the pattern's row sums to 8 ulp of it, and a bf16 checkpoint that needs float32 for the identity is loaded that way through
LOAD_KWARGS.
Not done yet:
- Interventions and display helpers over the values: a logit lens over layers, patchscopes, prompt batches with targets, repeated-prompt builders, a top-k plot, a prompts-to-dataframe table.
nnterp/prompt_utils.pyandnnterp/nnsight_utils.pyhold target-token probabilities and activation collection only, typed onStandardizedTransformer. - Index lists of a hybrid's attention and recurrent blocks, and a block-structure constant per family. The blocks are read off
model.layerswithgetattr(layer, "self_attn", None). device_map="auto"as a default: nnsight's default stands.- Mllama's cross-attention blocks, which need their own family and a cross-attention component. Gemma 4's and NemotronH Omni's audio towers, and video values, have no names or values. MoonViT has no family until a matching tiny checkpoint exists.
scatter_hostwalks nnsight's private_named_children.