This document is the normative contract between trellis.model and its consumers — the Trainer Core, which trains through these interfaces, and the Inference Engine, which drives the step-mode path. It states what a call takes, what it returns, and what is guaranteed about the relationship between the two modes. The implementation must match this document exactly; tests/test_model_docs.py checks that it does. Design rationale lives in docs/modules/backbone.md, state and cache semantics in docs/architecture/state-and-cache-contract.md, and neither is restated here.
| Name | Kind | Role |
|---|---|---|
TrellisModel |
module | the backbone: graph, loop engine, heads, audit |
build_model |
function | construct and audit from a TrellisConfig |
build_preset_model |
function | construct from a named preset |
ModelSettings |
dataclass | behaviour flags; parameter-count-neutral |
RouterSettings |
dataclass | nested, and the tie-break's name |
AuxHeadSpec |
dataclass | auxiliary-head widths and label vocabularies |
TrellisOutput |
dataclass | the single typed result object |
LoopReadout |
dataclass | one executed loop's deep-supervision readout |
BankReadout |
dataclass | one read point's slot indices and statistics |
ModelState |
dataclass | every state instance, plus the decode position |
StateShape |
dataclass | how many instances a graph needs |
LoopEngine |
module | the loop, the routing, the segmentation |
CapacityRouter |
module | the expert-choice router at boundaries j ≥ 2 |
RouterDecision |
object | one boundary's mask, count and scores |
PerLoopSpecialisation |
module | per-loop adapters and norm gains, and the switch |
TiedEmbeddingHead |
module | the one 49,152 × 768 matrix, both directions |
ExitHeads |
module | one halting scorer per loop |
AuxiliaryHeads |
module | the four training-only heads |
ModelParameterReport |
dataclass | the audit |
ModelError and subclasses |
exceptions | every rejection this unit makes |
out = model(token_ids, k=None, state=None, readout_mode=None,
dfg_pairs=None, token_classes=None, plan=None, return_state=False)| Argument | Type | Meaning |
|---|---|---|
token_ids |
(batch, tokens) int64 |
ids in [0, vocab_size); a 1-D tensor is treated as one sequence |
k |
int in [1, K_max] |
loops to execute; defaults to K_max |
state |
ModelState or None |
a prefill continuation; a fresh bundle when None |
readout_mode |
"logits" or "hidden" |
overrides the instance's setting for this call |
dfg_pairs |
(batch, n_pairs, 2) int64 |
candidate (def, use) positions for the DFG head |
token_classes |
(batch, tokens) int64 |
boundary classes for the A9 attention-bias arm |
plan |
sequence of (batch, tokens) bool |
overrides the router at each boundary |
return_state |
bool | return the advanced state bundle |
k outside [1, K_max] raises LoopEngineError. Ids outside the vocabulary raise ModelConfigError. A plan mask that revives a token the previous boundary dropped raises LoopEngineError under nested selection.
out = model.step(token_id, state, k=None, readout_mode=None,
dfg_pairs=None, token_classes=None, plan=None)token_id is (batch, 1); a scalar or (batch,) is accepted and reshaped. state is required and is validated against model.state_shape() and the call's batch size; a mismatch raises ModelStateError. out.state is always populated.
step mutates nothing the caller did not pass in. ModelState is frozen and every advance returns a new bundle; the only in-place writes are into the preallocated global-KV storage inside the bundle the caller handed over, which is what preallocating it is for.
Expert-choice selection keeps the top `⌈ρ_j·n⌉ tokens of a sequence, so a token's fate depends on the scores of the tokens after it. During training and prefill the whole sequence is present and this costs nothing. At decode time it is unavailable in principle. A router that pretended otherwise — ranking against a running quantile, or against the prefix only — would produce a different selection than the forward pass and silently break every equivalence below.
So step takes the decision rather than inventing one. The Inference Engine owns producing it, from the exit heads and the conformal thresholds, which is the causal signal that exists for the purpose. With no plan, every loop runs, which is the capacity schedule's own worst case and therefore always inside the budget.
| Field | Type | Always present |
|---|---|---|
logits |
(batch, tokens, vocab) |
yes |
readouts |
tuple of LoopReadout, one per executed loop |
yes |
exit_logits |
(loops, batch, tokens) |
when K_max > 1 |
aux |
AuxiliaryOutputs, a dict keyed by head name |
empty when the heads are absent |
router_masks |
tuple of (batch, tokens) bool, one per boundary |
empty at K = 1 |
router_scores |
tuple of (batch, tokens) float |
alongside the masks |
bank |
tuple of BankReadout, one per read point |
when the bank is enabled |
loop_counts |
(batch, tokens) int64 |
yes |
executed_loops |
int | yes |
state |
ModelState |
from step, and from forward with return_state=True |
readout_mode |
"logits" or "hidden" |
yes |
readout_applicator |
callable | yes |
A LoopReadout carries hidden or logits according to the mode, and always carries applicator; as_logits() returns the logits either way. Both modes satisfy the same contract — the difference is only whether the K × n × 49,152 tensor is ever materialised. hidden is the default because at 4,096 tokens and four loops that tensor is 805M floats.
A BankReadout carries indices — the slot indices before the value gather, which is what the Inference Engine prefetches against — plus weights and the read point's utilization report.
exit_logits are reported and never acted on. Thresholding is the Inference Engine's decision, against thresholds the Training Program calibrates. Exits may never raise a token's depth above the capacity schedule.
| Guarantee | Scope | Tolerance |
|---|---|---|
| Unrolled equivalence | every capacity 1.0, K ∈ {1,2,3,4} |
bit-identical in fp32; 2e-2 relative in bf16 |
| Router counts | every boundary | exactly ⌈ρ_j·n⌉, never a function of the scores |
| Nesting | default settings | selected set ⊆ previous boundary's set |
| Tie-break | equal scores | toward the lower position index, reproducible bit-for-bit |
| Skipped-token pass-through | every boundary | bit-identical; no KV write, no GDN state advance |
| Loop-invariant sliding cache | K = 1 vs K = 4 |
identical slots, fill and bytes |
| Global KV | any K |
written once per token per global layer |
| Determinism | same input, seed and configuration | same routing, same K path, same logits |
| Auxiliary-head detachability | heads on vs off | logits, readouts, exit logits and routing bit-identical |
| Bank neutrality | deltas forced to zero | bit-identical to a bank-disabled model |
| Batch-composition invariance | routing and depth | independent of what shares the batch |
| Step vs forward | K = 1 |
1e-4 absolute in fp32 |
| Step vs forward | any K, no sliding layer in the core |
1e-4 absolute in fp32 |
| Step vs forward | any K > 1, shipped configuration |
does not hold — see below |
This is the one place where the specification asks for two things that cannot both be true, so it is stated here rather than reconciled away.
The sliding cache is loop-invariant: one entry per position, written at loop 1 and gate-updated at loops > 1. A position's cache entry therefore records how many loops that position has been through.
- In a full-sequence
forward, the loops are the outer iteration. At loopj, every position's entry has been blendedj − 1times, so a token attends to a prefix that is exactly as refined as it is. - In token-by-token decode, the tokens are the outer iteration. When token
truns its loop 1, every prefix position has already finished all of its loops, so tokentattends to a prefix that is more refined than it is.
Those are different computations, and no ordering of the same operations makes them the same one. Reconciling them needs per-loop key and value storage — K entries per position instead of one — which is precisely what loop invariance forbids and what the 2.1 MB sliding-cache figure is a figure for.
What holds, and what the suite asserts. At K = 1 there is no gated update and decode reproduces prefill exactly. With the core's sliding layer replaced by a GDN layer — a configuration-only change that touches nothing else — decode reproduces prefill exactly at every K ∈ {1,2,3,4}, which is what identifies the loop-invariant sliding cache as the sole cause: the loop engine, the per-loop LoRA switching, the per-loop GDN state instancing, the routing, the readouts, the exit heads, the prelude, the coda and the tied head are all step/forward exact. For the shipped configuration at K > 1 the divergence is measured and pinned by a regression test, so that it cannot grow quietly and cannot silently disappear.
What a consumer should do about it. Use K = 1 for the speculative drafter, where exactness is what makes checkpoint-and-rollback sound. For K > 1, treat prefill and decode as the same model under two schedules rather than as two evaluations of one function — which is what an adaptive-depth model with a shared cache is.
model.parameter_report() returns a ModelParameterReport computed at construction and compared component by component with trellis.config.budget.params.compute_parameters. Construction fails with ParameterAuditError on any mismatch outside the two named booking differences.
| Row | Default configuration |
|---|---|
prelude_untied |
13,791,744 |
core_block_tied |
44,579,328 |
coda_untied |
13,791,744 |
per_loop_lora |
2,064,384 |
routers_gates_norms |
67,335 |
embeddings_tied |
37,748,736 |
memory_bank |
68,681,728 |
serving_total |
180,724,999 — 180.72M |
training_aux_heads |
6,000,000 |
training_total |
186,724,999 — 186.72M |
Two rows are booking differences between the constructed model and Prompt 1's report, both named in RECONCILED_ROWS and neither rounded away:
swa_kv_update_gate, 768. The state-and-cache contract and this unit's specification both place the sliding layer'sw_gin the routers/gates/norms row; Prompt 1's decomposition of that row has no term for it. It is counted, and the budget side of the comparison is adjusted by exactly that amount with a note. It does not move the published totals.bank_sub_key_tables, 196,608. The product-key codebooks, which Prompt 1 books outside the 68.68M bank row deliberately andtrellis.bank's own audit books the same way.
| Method | Returns |
|---|---|
initial_state(batch, capacity, device, dtype) |
a zero ModelState; cache dtype defaults to the model's |
state_shape() |
StateShape — 22 GDN instances, 1 sliding cache, 2 global KV at the defaults |
loop_capacity_schedule() |
(1.0, 1.0, 0.5, 0.25) |
expected_loops() |
2.75 |
effective_depth(k) |
4 + 6k |
parameter_report(refresh=False) |
the audit |
strip_aux_heads() |
a serving state dictionary; removals reported by stripped_keys() |
load_serving_state_dict(sd, strict=True) |
(load_result, surplus_keys) |
all_layers / core_layers |
the ten unique layers; the core appears once |
engine.core_pass(...) |
one loop's core forward — the activation-checkpointing unit |
engine.segment_executor |
substitute torch.utils.checkpoint.checkpoint(use_reentrant=False) |
strip_aux_heads returns the dictionary rather than mutating the model, so a training process can write a serving checkpoint without disturbing the run still using the heads. The removed keys are reported through stripped_keys() rather than left for the caller to discover by diffing two key sets.
- Added the training-forward and step-mode contracts, the
TrellisOutputfield table, the routing-plan requirement for decode, the guarantee table, the parameter-audit table with its two named booking differences, and the prefill/decode incompatibility forK > 1.