ShourenWSR commited on
Commit
5750feb
·
verified ·
1 Parent(s): ca598db

SYNKA 27571: verified modeling_loop_lm.py

Browse files
Files changed (1) hide show
  1. modeling_loop_lm.py +936 -0
modeling_loop_lm.py ADDED
@@ -0,0 +1,936 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Looped-MoE modeling code (port of the seedvar `loop-lm` architecture).
2
+
3
+ Provenance
4
+ ----------
5
+ Ported from ``modeling_loop_lm.py`` as published with
6
+ ``ml-ryanlee/seedvar-looped-moe-1e18-d704-seed42..47`` (arXiv 2605.09165,
7
+ *Sparse Layers are Critical to Scaling Looped Language Models*). That file is the
8
+ architecture specification: the published checkpoints are its training product,
9
+ and the diagnostics pipeline is already validated against it.
10
+
11
+ This is a **port, not a copy**. The numerics are kept faithful (see "Faithful to
12
+ the original" below) because the baseline's whole job is to reproduce the
13
+ published architecture; the deviations are all in service of three requirements
14
+ from HANDOVER §4.3 that the original file does not meet:
15
+
16
+ 1. **Semantic parameters have no defaults.** The original ``LoopLMConfig``
17
+ defaults ``d_model=1024``, ``num_experts=8`` and so on. Defaults are how a
18
+ silently-wrong run happens: a typo'd key name falls back to a plausible
19
+ number and the run looks fine. Every shape/semantic field here is required
20
+ and a missing one raises.
21
+ 2. **Every loop step is hookable, by explicit index.** The loop counter is
22
+ threaded down to each block, so a consumer never recovers the loop axis by
23
+ reshaping a flattened layer axis (which silently yields transposed
24
+ semantics).
25
+ 3. **Router logits are exposed with their semantics labelled.** Both the
26
+ pre-softmax logits and the post-softmax probabilities are handed out, tagged
27
+ via ``src.model.loop_trace.ROUTER_LOGITS_ARE_PRESOFTMAX``.
28
+
29
+ Scope
30
+ -----
31
+ Only the ``looped-moe`` variant is implemented. The original file carries four
32
+ variants (base / looped / moe / looped-moe); all four architectures this project
33
+ trains -- baseline and DVF-a/b/c -- are looped-moe, differing only in the (L, R, E)
34
+ triple. Porting the unused three would be dead code (project rule: no entities
35
+ beyond necessity). They remain available in the original file if ever needed.
36
+
37
+ Shape parameters, and what the experiment varies
38
+ ------------------------------------------------
39
+ ==================== ====== =========================================
40
+ config field symbol meaning
41
+ ==================== ====== =========================================
42
+ num_layers_in_stack L physical layers in the shared stack
43
+ num_stacks R times the stack is called (loop count)
44
+ num_experts E experts per MoE layer
45
+ num_active k experts activated per token
46
+ ==================== ====== =========================================
47
+
48
+ The DVF ("dual vector foil") series holds L*R = 16 and E*L = 64 fixed and only
49
+ moves capacity around: baseline (8,2,8), DVF-a (4,4,16), DVF-b (2,8,32),
50
+ DVF-c (1,16,64).
51
+
52
+ Faithful to the original (do not "fix" these -- they are muP, not bugs)
53
+ ----------------------------------------------------------------------
54
+ * ``RMSNorm`` has **no** gain parameter.
55
+ * Attention is scaled by ``1/d_k``, not ``1/sqrt(d_k)``.
56
+ * Softmax upcasts to float32.
57
+ * Expert FFN width is ``d_ff // num_active`` -- divided by k, *not* by E. This is
58
+ what makes E*L=64 hold total expert parameters constant across the DVF series.
59
+ * Initialisation is muP: ``std = std_base / sqrt(width_ratio)`` with
60
+ ``std_base = sqrt(2/(fan_in_base + fan_out_base))`` against a d_base=128 proxy.
61
+
62
+ Deliberate deviation
63
+ --------------------
64
+ ``RotaryPositionalEmbedding`` builds its rotation table with vectorised torch ops
65
+ instead of the original's ``max_seq_len * d_k/2`` nested Python loop, which costs
66
+ minutes at seq_len 4096. ``tests/test_rope_equivalence.py`` pins the vectorised
67
+ table against a literal transcription of the original loop.
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ import math
73
+ from typing import Any, Optional
74
+
75
+ import torch
76
+ import torch.nn as nn
77
+ import torch.nn.functional as F
78
+ from einops import einsum, rearrange, reduce, repeat
79
+ from torch import Tensor
80
+ from torch.nn.functional import grouped_mm, silu
81
+ from transformers import PretrainedConfig, PreTrainedModel
82
+ from transformers.generation import GenerationMixin
83
+ from transformers.modeling_outputs import CausalLMOutputWithPast
84
+
85
+ # This file is loaded in three different ways, and the trace types have to
86
+ # resolve in all of them:
87
+ # 1. as part of this repo -> `src.model.loop_trace`
88
+ # 2. via HuggingFace trust_remote_code -> copied into a generated package under
89
+ # `transformers_modules/<ckpt>/`, where the sibling is a *relative* import
90
+ # 3. as a loose script with the checkpoint directory on sys.path
91
+ # Case 2 is the one that matters for HANDOVER §4.7: the diagnostics pipeline is a
92
+ # separate repository that has never heard of `pretrain`, and `save_trajectory`
93
+ # bundles `loop_trace.py` next to this file so the checkpoint stands alone.
94
+ try:
95
+ from src.model.loop_trace import ACTIVE_TRACE_SINK, TraceMeta, TracePoint, TraceSink
96
+ except ImportError: # pragma: no cover - covered by the cold-load test
97
+ try:
98
+ from .loop_trace import ACTIVE_TRACE_SINK, TraceMeta, TracePoint, TraceSink # type: ignore[no-redef]
99
+ except ImportError:
100
+ from loop_trace import ACTIVE_TRACE_SINK, TraceMeta, TracePoint, TraceSink # type: ignore[no-redef]
101
+
102
+ __all__ = ["LoopMoEConfig", "LoopMoEForCausalLM", "LoopedMoETransformer"]
103
+
104
+ # muP proxy-model widths. The initialisation std of every weight is derived from
105
+ # a d_base=128 model and rescaled by width_ratio = d_model / 128.
106
+ HEAD_TAIL_NUM_EXPERTS = 8
107
+ """Experts in a head/tail layer, fixed across every ablation configuration.
108
+
109
+ Recipe 2026-09-12 section 2.1: head and tail are "identical in all configurations"
110
+ (1 layer, 8 experts, top-2). It is deliberately independent of the loop block's E --
111
+ S4 gives the loop 64 experts per layer and its head still has 8 -- so the loop block's
112
+ count must not be reused here.
113
+ """
114
+
115
+ BASE_D_MODEL = 128
116
+ BASE_D_FF = 384
117
+
118
+
119
+ def softmax(logits: Tensor, dim: int) -> Tensor:
120
+ """Max-shifted softmax in float32 (verbatim semantics from the original)."""
121
+ logits = logits.float()
122
+ max_values = torch.max(logits, dim=dim, keepdim=True).values
123
+ shifted = logits - max_values
124
+ shifted_exps = torch.exp(shifted)
125
+ shifted_exp_sums = torch.sum(shifted_exps, dim=dim, keepdim=True)
126
+ return shifted_exps / shifted_exp_sums
127
+
128
+
129
+ class Linear(nn.Module):
130
+ """Bias-free linear layer with muP initialisation."""
131
+
132
+ def __init__(self, in_features, out_features, width_ratio, std_base, device=None, dtype=None):
133
+ super().__init__()
134
+ # Registered before init so the shape exists under HF meta-device loading.
135
+ self.weight = nn.Parameter(torch.empty(out_features, in_features, dtype=dtype, device=device))
136
+ # Kept so the init can be replayed: torchtitan builds the model on the
137
+ # meta device and then calls `init_weights()` on materialised (but
138
+ # uninitialised) storage, so constructor-time init alone leaves the
139
+ # model full of garbage.
140
+ self._init_std = std_base / math.sqrt(width_ratio)
141
+ self.reset_parameters()
142
+
143
+ def reset_parameters(self) -> None:
144
+ std = self._init_std
145
+ nn.init.trunc_normal_(self.weight, mean=0.0, std=std, a=-3 * std, b=3 * std)
146
+
147
+ def forward(self, x: Tensor) -> Tensor:
148
+ return einsum(self.weight, x, "d_out d_in, ... d_in -> ... d_out")
149
+
150
+
151
+ class Embedding(nn.Module):
152
+ def __init__(self, num_embeddings, embedding_dim, device=None, dtype=None):
153
+ super().__init__()
154
+ self.weight = nn.Parameter(torch.empty(num_embeddings, embedding_dim, dtype=dtype, device=device))
155
+ self.reset_parameters()
156
+
157
+ def reset_parameters(self) -> None:
158
+ nn.init.trunc_normal_(self.weight, mean=0.0, std=1.0, a=-3, b=3)
159
+
160
+ def forward(self, token_ids: Tensor) -> Tensor:
161
+ return self.weight[token_ids]
162
+
163
+
164
+ class RMSNorm(nn.Module):
165
+ """RMS norm **without** a gain parameter (muP convention)."""
166
+
167
+ def __init__(self, d_model: int, eps: float = 1e-5, device=None, dtype=None):
168
+ super().__init__()
169
+ self.d_model = d_model
170
+ self.eps = eps
171
+
172
+ def forward(self, x: Tensor) -> Tensor:
173
+ in_dtype = x.dtype
174
+ x = x.to(torch.float32)
175
+ mean_squared_sum = (1 / self.d_model) * einsum(x, x, "... seq d, ... seq d -> ... seq")
176
+ rms = torch.sqrt(mean_squared_sum + self.eps)
177
+ rms_norm = einsum(x, 1 / rms, "... seq d, ... seq -> ... seq d")
178
+ return rms_norm.to(in_dtype)
179
+
180
+
181
+ class PositionwiseFeedforward(nn.Module):
182
+ """SwiGLU: W2(SiLU(W1 x) * W3 x)."""
183
+
184
+ def __init__(self, d_model: int, d_ff: int, width_ratio: float, device=None, dtype=None):
185
+ super().__init__()
186
+ w_std_base = math.sqrt(2 / (BASE_D_MODEL + BASE_D_FF))
187
+ self.w1 = Linear(d_model, d_ff, width_ratio, w_std_base, device=device, dtype=dtype)
188
+ self.w2 = Linear(d_ff, d_model, width_ratio, w_std_base, device=device, dtype=dtype)
189
+ self.w3 = Linear(d_model, d_ff, width_ratio, w_std_base, device=device, dtype=dtype)
190
+
191
+ def forward(self, x: Tensor) -> Tensor:
192
+ return self.w2(silu(self.w1(x)) * self.w3(x))
193
+
194
+
195
+ class RotaryPositionalEmbedding(nn.Module):
196
+ """RoPE with a precomputed [seq, d_k/2, 2, 2] rotation table.
197
+
198
+ Vectorised rebuild of the original's nested Python loop; see the module
199
+ docstring and ``tests/test_rope_equivalence.py``.
200
+ """
201
+
202
+ def __init__(self, theta: float, d_k: int, max_seq_len: int, device=None, dtype=None):
203
+ super().__init__()
204
+ # Retained so the table can be rebuilt: `to_empty()` replaces buffer
205
+ # storage with uninitialised memory just as it does for parameters, so a
206
+ # meta-device build leaves the rotation table as garbage unless
207
+ # `reset_parameters()` regenerates it.
208
+ self._rope_theta, self._rope_d_k = theta, d_k
209
+ self._rope_max_seq_len, self._rope_dtype = max_seq_len, dtype
210
+ rotations = self._build_table(theta, d_k, max_seq_len, device, dtype)
211
+ self.register_buffer("rotations", rotations, persistent=True)
212
+
213
+ @staticmethod
214
+ def _build_table(theta: float, d_k: int, max_seq_len: int, device, dtype) -> Tensor:
215
+ """[seq, d_k/2, 2, 2] rotation table.
216
+
217
+ Angles are built in float64 and only then cast down. At seq_len 4096 the
218
+ largest angle is ~4096 rad, where float32 spacing is ~2.4e-4; computing
219
+ cos/sin at float32 there loses ~4 decimal digits. The original does this
220
+ implicitly (Python floats are float64), so float64 here is both more
221
+ accurate and what keeps the table equal to the reference.
222
+ """
223
+ positions = torch.arange(max_seq_len, device=device, dtype=torch.float64)
224
+ pair_idx = torch.arange(d_k // 2, device=device, dtype=torch.float64)
225
+ inv_freq = theta ** (2 * pair_idx / d_k)
226
+ angles = positions[:, None] / inv_freq[None, :]
227
+ cos, sin = torch.cos(angles), torch.sin(angles)
228
+ # rows of the 2x2 rotation: [[cos, -sin], [sin, cos]]
229
+ table = torch.stack(
230
+ [torch.stack([cos, -sin], dim=-1), torch.stack([sin, cos], dim=-1)], dim=-2
231
+ )
232
+ return table.to(dtype if dtype is not None else torch.float32)
233
+
234
+ @torch.no_grad()
235
+ def reset_parameters(self) -> None:
236
+ """Regenerate the rotation table in place (buffers survive nothing)."""
237
+ self.rotations.copy_(
238
+ self._build_table(
239
+ self._rope_theta, self._rope_d_k, self._rope_max_seq_len,
240
+ self.rotations.device, self.rotations.dtype,
241
+ )
242
+ )
243
+
244
+ def forward(self, x: Tensor, token_positions: Tensor) -> Tensor:
245
+ rot = self.rotations[token_positions].to(dtype=x.dtype)
246
+ x_pairs = rearrange(x, "... seq_dim (feature_dim i) -> ... seq_dim feature_dim i", i=2)
247
+ y_pairs = einsum(
248
+ rot,
249
+ x_pairs,
250
+ "... seq_dim feature_dim i j, ... seq_dim feature_dim j -> ... seq_dim feature_dim i",
251
+ )
252
+ return rearrange(y_pairs, "... seq_dim feature_dim i -> ... seq_dim (feature_dim i)")
253
+
254
+
255
+ class MultiheadSelfAttention(nn.Module):
256
+ """Causal MHSA with RoPE. muP: attention logits scaled by 1/d_k."""
257
+
258
+ def __init__(self, d_model: int, num_heads: int, max_seq_len: int, theta: float,
259
+ width_ratio: float, device=None, dtype=None):
260
+ super().__init__()
261
+ if d_model % num_heads != 0:
262
+ raise ValueError(f"d_model ({d_model}) must be divisible by num_heads ({num_heads})")
263
+ self.d_model = d_model
264
+ self.num_heads = num_heads
265
+
266
+ attn_std_base = math.sqrt(2 / (BASE_D_MODEL + BASE_D_MODEL))
267
+ self.q_proj = Linear(d_model, d_model, width_ratio, attn_std_base, device=device, dtype=dtype)
268
+ self.k_proj = Linear(d_model, d_model, width_ratio, attn_std_base, device=device, dtype=dtype)
269
+ self.v_proj = Linear(d_model, d_model, width_ratio, attn_std_base, device=device, dtype=dtype)
270
+ self.output_proj = Linear(d_model, d_model, width_ratio, attn_std_base, device=device, dtype=dtype)
271
+ self.rope = RotaryPositionalEmbedding(theta, d_model // num_heads, max_seq_len, device, dtype)
272
+
273
+ def forward(self, x: Tensor, token_positions: Optional[Tensor] = None) -> Tensor:
274
+ d_k = self.d_model // self.num_heads
275
+ q_heads = rearrange(self.q_proj(x), "... seq (heads d_k) -> ... heads seq d_k", d_k=d_k)
276
+ k_heads = rearrange(self.k_proj(x), "... seq (heads d_k) -> ... heads seq d_k", d_k=d_k)
277
+ v_heads = rearrange(self.v_proj(x), "... seq (heads d_v) -> ... heads seq d_v", d_v=d_k)
278
+
279
+ if token_positions is None:
280
+ token_positions = rearrange(torch.arange(x.shape[-2], device=x.device), "seq -> 1 seq")
281
+ q_heads = self.rope(q_heads, token_positions)
282
+ k_heads = self.rope(k_heads, token_positions)
283
+
284
+ mha_heads = F.scaled_dot_product_attention(
285
+ q_heads, k_heads, v_heads, is_causal=True, scale=1.0 / d_k
286
+ )
287
+ return self.output_proj(rearrange(mha_heads, "... heads seq d_v -> ... seq (heads d_v)"))
288
+
289
+
290
+ class Router(nn.Module):
291
+ """Top-k softmax router. Returns pre-softmax logits *and* probabilities.
292
+
293
+ The two are returned side by side, and the caller labels which is which via
294
+ ``ROUTER_LOGITS_ARE_PRESOFTMAX``. There is no jitter noise and no temperature;
295
+ routing is deterministic given the input (matching the original).
296
+ """
297
+
298
+ def __init__(self, d_model: int, num_experts: int, num_active: int, width_ratio: float,
299
+ device=None, dtype=None):
300
+ super().__init__()
301
+ std_base = math.sqrt(2 / (BASE_D_MODEL + num_experts))
302
+ self.gate = Linear(d_model, num_experts, width_ratio, std_base, device=device, dtype=dtype)
303
+ self.num_active = num_active
304
+
305
+ def forward(self, x: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]:
306
+ logits = self.gate(x) # [B, S, E] -- pre-softmax
307
+ probs = softmax(logits, dim=-1) # [B, S, E] -- over all E experts
308
+ top_scores, top_experts = torch.topk(probs, k=self.num_active, dim=-1)
309
+ # Renormalise within the selected set so the combine weights sum to 1.
310
+ top_scores = top_scores / torch.sum(top_scores, dim=-1, keepdim=True)
311
+ return logits, probs, top_scores, top_experts
312
+
313
+
314
+ class GroupedMoEPrenormBlock(nn.Module):
315
+ """Pre-norm block whose FFN is a grouped top-k MoE.
316
+
317
+ Layout: x -> +attn(ln1(x)) -> +moe(ln2(.)). Aux losses are returned rather
318
+ than stashed on the module, so nothing has to be reset between loop steps.
319
+ """
320
+
321
+ @staticmethod
322
+ def _init_expert_weights(num_experts, in_features, out_features, width_ratio, std_base,
323
+ device, dtype) -> nn.Parameter:
324
+ w = torch.empty(num_experts, in_features, out_features, device=device, dtype=dtype)
325
+ std_scaled = std_base / math.sqrt(width_ratio)
326
+ nn.init.trunc_normal_(w, mean=0.0, std=std_scaled, a=-3 * std_scaled, b=3 * std_scaled)
327
+ return nn.Parameter(w)
328
+
329
+ @torch.no_grad()
330
+ def reset_parameters(self) -> None:
331
+ """Re-init the grouped expert weights (see Linear.reset_parameters)."""
332
+ std = self._expert_init_std
333
+ for w in (self.experts_w1, self.experts_w2, self.experts_w3):
334
+ nn.init.trunc_normal_(w, mean=0.0, std=std, a=-3 * std, b=3 * std)
335
+
336
+ def __init__(self, d_model: int, num_heads: int, d_ff: int, num_experts: int, num_active: int,
337
+ max_seq_len: int, theta: float, width_ratio: float, device=None, dtype=None,
338
+ num_attention_sets: int = 1):
339
+ """`num_attention_sets` = how many independent attention parameter sets
340
+ this block holds, one per pass over the looped stack.
341
+
342
+ 1 is the shared-attention architecture every run before 2026-09-18 used.
343
+ It keeps `self.attn` a single module, so the state dict key stays
344
+ `attn.q_proj.weight` and existing checkpoints load unchanged -- a
345
+ ModuleList of one would rename every key to `attn.0.*` and silently
346
+ invalidate every archive we hold.
347
+
348
+ >1 makes `self.attn` a ModuleList of that many attention modules, each
349
+ initialised independently under the same muP standard deviation, chosen
350
+ at forward time by `loop_step`. Everything else in the block -- norms,
351
+ router, experts -- remains shared across passes, which is the point of
352
+ the comparison.
353
+ """
354
+ super().__init__()
355
+ if num_attention_sets < 1:
356
+ raise ValueError(f"num_attention_sets must be >= 1, got {num_attention_sets}")
357
+ self.num_attention_sets = num_attention_sets
358
+ self.ln1 = RMSNorm(d_model, device=device, dtype=dtype)
359
+ _make_attn = lambda: MultiheadSelfAttention( # noqa: E731 -- one expression, used twice
360
+ d_model, num_heads, max_seq_len, theta, width_ratio, device, dtype
361
+ )
362
+ self.attn = (
363
+ _make_attn() if num_attention_sets == 1
364
+ else nn.ModuleList([_make_attn() for _ in range(num_attention_sets)])
365
+ )
366
+ self.ln2 = RMSNorm(d_model, device=device, dtype=dtype)
367
+ self.router = Router(d_model, num_experts, num_active, width_ratio, device=device, dtype=dtype)
368
+
369
+ self.num_experts = num_experts
370
+ self.num_active = num_active
371
+
372
+ # NOTE: divided by num_active (k), not by num_experts (E). This is what
373
+ # keeps total expert parameters constant across the DVF series.
374
+ d_ff_expert = d_ff // num_active
375
+ w_std_base = math.sqrt(2 / (BASE_D_MODEL + BASE_D_FF))
376
+ self._expert_init_std = w_std_base / math.sqrt(width_ratio)
377
+ self.experts_w1 = self._init_expert_weights(num_experts, d_model, d_ff_expert, width_ratio, w_std_base, device, dtype)
378
+ self.experts_w2 = self._init_expert_weights(num_experts, d_ff_expert, d_model, width_ratio, w_std_base, device, dtype)
379
+ self.experts_w3 = self._init_expert_weights(num_experts, d_model, d_ff_expert, width_ratio, w_std_base, device, dtype)
380
+
381
+ def attention_for(self, loop_step: Optional[int]):
382
+ """The attention module this pass uses.
383
+
384
+ `loop_step` is the explicit loop variable of
385
+ `LoopedMoETransformer.forward` -- the index of the current pass over the
386
+ shared stack -- threaded down as an argument. This block keeps **no
387
+ cross-call state**: nothing here remembers which pass ran last, and two
388
+ calls with the same `loop_step` select the same module. That is why the
389
+ looped model can be traced, resumed and re-entered in any order.
390
+
391
+ There is deliberately **no loop-step embedding** (owner's ruling
392
+ 2026-09-18): the passes differ only by their attention parameters, which
393
+ is what keeps U1-U4 a controlled comparison against S1-S4.
394
+
395
+ Refuses rather than defaults when `loop_step` is missing on a per-pass
396
+ block: falling back to set 0 would run every pass through the first
397
+ pass's attention, which trains, converges and reports nothing unusual
398
+ while being a different architecture from the one on the config.
399
+ """
400
+ if self.num_attention_sets == 1:
401
+ return self.attn
402
+ assert self.num_attention_sets == len(self.attn), (
403
+ f"{self.num_attention_sets} sets declared but {len(self.attn)} modules held; "
404
+ "the count and the container have drifted apart"
405
+ )
406
+ if loop_step is None:
407
+ raise ValueError(
408
+ "this block has per-pass attention "
409
+ f"({self.num_attention_sets} sets) and needs loop_step to choose one; "
410
+ "got None. Every caller in this file passes it -- a new caller must too."
411
+ )
412
+ if not 0 <= loop_step < len(self.attn):
413
+ raise IndexError(
414
+ f"loop_step {loop_step} is outside the {self.num_attention_sets} attention "
415
+ "sets this block holds. The block is built with one set per pass, so a "
416
+ "loop_step beyond that means the model and the config disagree about R."
417
+ )
418
+ return self.attn[loop_step]
419
+
420
+ def forward(
421
+ self,
422
+ x: Tensor,
423
+ token_positions: Optional[Tensor] = None,
424
+ *,
425
+ loop_step: Optional[int] = None,
426
+ layer_idx: Optional[int] = None,
427
+ block: Optional[str] = None,
428
+ unrolled_pos: Optional[int] = None,
429
+ trace_sink: Optional[TraceSink] = None,
430
+ ) -> tuple[Tensor, Tensor, Tensor]:
431
+ batch, seq, dim = x.shape
432
+ total_tokens = batch * seq
433
+
434
+ norm1_out = self.ln1(x)
435
+ attn_out = self.attention_for(loop_step)(norm1_out, token_positions)
436
+ assert x.shape == attn_out.shape
437
+ resid1_out = attn_out + x
438
+
439
+ norm2_out = self.ln2(resid1_out)
440
+ logits, probs, top_scores, top_experts = self.router(norm2_out)
441
+
442
+ # `softmax` computes in float32 and does not cast back, so `top_scores`
443
+ # is float32 regardless of the activation dtype. The combine weights get
444
+ # multiplied into bf16 expert outputs below, so they must match, or
445
+ # einsum raises "expected m1 and m2 to have the same dtype".
446
+ #
447
+ # This is invisible in the original: seedvar's published checkpoints are
448
+ # float32, where the cast is a no-op. Under the bf16 training this
449
+ # project uses (LT2 runs pure bf16, HANDOVER §4.1) it is a hard failure
450
+ # on the first forward.
451
+ #
452
+ # Only the combine weights are cast. `probs` and `logits` stay float32
453
+ # for the aux-loss and z-loss reductions, which is where the extra
454
+ # precision is worth having.
455
+ top_scores = top_scores.to(x.dtype)
456
+
457
+ # Flatten and sort by expert so grouped_mm can run one matmul per expert.
458
+ x_flat = rearrange(norm2_out, "b s d -> (b s) d")
459
+ flat_expert_ids = rearrange(top_experts, "b s k -> (b s k)")
460
+ flat_scores = rearrange(top_scores, "b s k -> (b s k)")
461
+ flat_positions = torch.arange(total_tokens, device=x.device)
462
+ flat_token_ids = repeat(flat_positions, "n -> (n k)", k=self.num_active)
463
+
464
+ sort_indices = flat_expert_ids.argsort(stable=True)
465
+ sorted_expert_ids = flat_expert_ids[sort_indices]
466
+ sorted_token_ids = flat_token_ids[sort_indices]
467
+ sorted_scores = flat_scores[sort_indices]
468
+ sorted_x = x_flat[sorted_token_ids]
469
+
470
+ counts = torch.bincount(sorted_expert_ids, minlength=self.num_experts)
471
+ offs = counts.cumsum(0).to(torch.int32)
472
+
473
+ h1 = grouped_mm(sorted_x, self.experts_w1, offs=offs)
474
+ h3 = grouped_mm(sorted_x, self.experts_w3, offs=offs)
475
+ gated = silu(h1) * h3
476
+ expert_out = grouped_mm(gated, self.experts_w2, offs=offs)
477
+
478
+ expert_out = einsum(expert_out, sorted_scores, "n d, n -> n d")
479
+ output_flat = torch.zeros(total_tokens, dim, device=x.device, dtype=expert_out.dtype)
480
+ output_flat.index_add_(0, sorted_token_ids, expert_out)
481
+ experts_out = rearrange(output_flat, "(b s) d -> b s d", b=batch, s=seq)
482
+
483
+ # Aux losses, per HANDOVER §3.3':
484
+ # L_LB = E * sum_i f_i * p_i (switch-style load balancing)
485
+ # L_RZ = mean( (logsumexp logits)^2 ) (router z-loss)
486
+ # Both are computed per layer per loop step; the caller averages over the
487
+ # unrolled depth (num_stacks * num_layers_in_stack).
488
+ fi = counts.float() / (total_tokens * self.num_active)
489
+ pi = reduce(probs, "b s e -> e", "mean")
490
+ lb = self.num_experts * einsum(fi, pi, "e, e ->")
491
+
492
+ logsumexp = torch.logsumexp(logits.float(), dim=-1)
493
+ lz = reduce(logsumexp**2, "... -> ", "mean")
494
+
495
+ assert experts_out.shape == resid1_out.shape
496
+ final_out = resid1_out + experts_out
497
+
498
+ # Write into every sink that is armed. Under FSDP2 the `trace_sink`
499
+ # keyword arrives as a per-block COPY (see `_ActiveTraceSink`), so the
500
+ # module-level one is the only sink the trainer can actually read back;
501
+ # the keyword remains for callers that pass their own dict directly
502
+ # (the toy launcher, the offline probes), where it is the same object.
503
+ # Writing to both is harmless: each is first-write-wins.
504
+ sinks = [s for s in (ACTIVE_TRACE_SINK.sink, trace_sink) if s is not None]
505
+ if sinks:
506
+ if loop_step is None or layer_idx is None or block is None or unrolled_pos is None:
507
+ raise ValueError(
508
+ "trace_sink was provided but loop_step/layer_idx/block/unrolled_pos "
509
+ "were not. Both axes must be explicit counters, never inferred."
510
+ )
511
+ if block not in ("head", "loop", "tail"):
512
+ raise ValueError(f"block must be head/loop/tail, got {block!r}")
513
+ # Keyed by the depth coordinate: head, the loop's first layer and tail all
514
+ # carry loop_step=layer_idx=0, so the old pair-key silently collapsed them.
515
+ key = unrolled_pos
516
+ # First write wins. Under selective activation checkpointing the
517
+ # block's forward runs a second time during backward to recompute
518
+ # activations, so every key is legitimately visited twice -- that is
519
+ # how AC works, not a bug. The recomputed values are identical by
520
+ # construction, so keeping the first and ignoring the rest is both
521
+ # correct and cheap.
522
+ #
523
+ # An earlier version raised on the second visit. That guard was aimed
524
+ # at double-*counting*, which first-write-wins prevents directly; as
525
+ # written it instead killed every AC-enabled run at the first traced
526
+ # step. `test_ac_recomputation_does_not_disturb_the_trace` pins the
527
+ # property that actually matters: same keys, same values, with AC on.
528
+ # Detached views, not copies -- see the lifetime contract in
529
+ # src/model/loop_trace.py.
530
+ point = TracePoint(
531
+ block=block,
532
+ unrolled_pos=unrolled_pos,
533
+ # From the tensors themselves, never from the model config: this layer's
534
+ # E and k are what produced these numbers, and head/tail differ from the
535
+ # loop block.
536
+ num_experts=int(logits.shape[-1]),
537
+ top_k=int(top_experts.shape[-1]),
538
+ loop_step=loop_step,
539
+ layer_idx=layer_idx,
540
+ router_logits=logits.detach(),
541
+ router_probs=probs.detach(),
542
+ topk_idx=top_experts.detach(),
543
+ topk_weights=top_scores.detach(),
544
+ residual=final_out.detach(),
545
+ )
546
+ for sink in sinks:
547
+ sink.setdefault(key, point)
548
+
549
+ return final_out, lb, lz
550
+
551
+
552
+ class LoopedStack(nn.Module):
553
+ """The stack of L MoE blocks that gets called R times."""
554
+
555
+ def __init__(self, context_length: int, d_model: int, num_layers_in_stack: int, num_heads: int,
556
+ d_ff: int, rope_theta: float, width_ratio: float, num_experts: int,
557
+ num_active: int, device=None, dtype=None, num_attention_sets: int = 1):
558
+ super().__init__()
559
+ #: One attention set per pass when per-pass attention is on, 1 otherwise.
560
+ #: Only the LOOPED layers get this: head and tail run once, so "per pass"
561
+ #: has no meaning for them and they keep a single set in every variant.
562
+ self.num_attention_sets = num_attention_sets
563
+ self.layers = nn.ModuleList(
564
+ [
565
+ GroupedMoEPrenormBlock(
566
+ d_model, num_heads, d_ff, num_experts, num_active,
567
+ context_length, rope_theta, width_ratio, device, dtype,
568
+ num_attention_sets=num_attention_sets,
569
+ )
570
+ for _ in range(num_layers_in_stack)
571
+ ]
572
+ )
573
+
574
+ def forward(
575
+ self,
576
+ x: Tensor,
577
+ *,
578
+ loop_step: int,
579
+ unrolled_pos_start: int,
580
+ trace_sink: Optional[TraceSink] = None,
581
+ ) -> tuple[Tensor, Tensor, Tensor]:
582
+ """`unrolled_pos_start` is the depth coordinate this call's first layer occupies.
583
+
584
+ Passed in rather than recomputed from `loop_step`, so the caller owns the depth
585
+ axis in one place: the stack does not need to know how many layers ran before it.
586
+ """
587
+ lb_total = x.new_zeros(())
588
+ lz_total = x.new_zeros(())
589
+ for layer_idx, layer in enumerate(self.layers):
590
+ x, lb, lz = layer(
591
+ x, loop_step=loop_step, layer_idx=layer_idx, block="loop",
592
+ unrolled_pos=unrolled_pos_start + layer_idx, trace_sink=trace_sink,
593
+ )
594
+ lb_total = lb_total + lb
595
+ lz_total = lz_total + lz
596
+ return x, lb_total, lz_total
597
+
598
+
599
+ class LoopedMoETransformer(nn.Module):
600
+ """Looped MoE transformer: one shared stack applied ``num_stacks`` times.
601
+
602
+ The loop is an explicit Python ``for``; ``loop_step`` is the loop variable and
603
+ is threaded all the way down to each block. Nothing downstream ever has to
604
+ recover it from tensor shapes.
605
+ """
606
+
607
+ def __init__(self, vocab_size: int, context_length: int, d_model: int, num_layers_in_stack: int,
608
+ num_stacks: int, num_heads: int, d_ff: int, num_experts: int, num_active: int,
609
+ rope_theta: float, width_ratio: float, num_head_layers: int = 0,
610
+ num_tail_layers: int = 0, head_tail_num_experts: int = HEAD_TAIL_NUM_EXPERTS,
611
+ device=None, dtype=None, per_pass_attention: bool = False):
612
+ super().__init__()
613
+ self.num_stacks = num_stacks
614
+ self.num_layers_in_stack = num_layers_in_stack
615
+ self.total_layers = num_stacks * num_layers_in_stack
616
+ self.num_head_layers = num_head_layers
617
+ self.num_tail_layers = num_tail_layers
618
+ # The unrolled depth, and the denominator the aux losses are averaged over.
619
+ # Written as head + loop + tail rather than the recipe's shorthand "2 + D*L":
620
+ # S15 has two head and two tail layers, so a literal 2 would divide by the wrong
621
+ # number there -- and it would not fail, it would just make the auxiliary losses
622
+ # quietly larger than intended.
623
+ self.unrolled_depth = num_head_layers + self.total_layers + num_tail_layers
624
+
625
+ self.token_embeddings = Embedding(vocab_size, d_model, device=device, dtype=dtype)
626
+ # Head and tail are ordinary MoE layers that run once. They keep E=8/top-2
627
+ # regardless of the loop block's expert count (recipe section 2.1: "identical in
628
+ # every configuration"), so the loop block's E is deliberately not passed here.
629
+ make_outer = lambda: GroupedMoEPrenormBlock(
630
+ d_model, num_heads, d_ff, head_tail_num_experts, num_active,
631
+ context_length, rope_theta, width_ratio, device, dtype,
632
+ )
633
+ self.head_layers = nn.ModuleList([make_outer() for _ in range(num_head_layers)])
634
+ self.tail_layers = nn.ModuleList([make_outer() for _ in range(num_tail_layers)])
635
+ self.per_pass_attention = per_pass_attention
636
+ self.stack = LoopedStack(
637
+ context_length, d_model, num_layers_in_stack, num_heads, d_ff, rope_theta,
638
+ width_ratio, num_experts, num_active, device=device, dtype=dtype,
639
+ # R sets when on: the stack is entered `num_stacks` times, and each
640
+ # entry is what "a pass" means here.
641
+ num_attention_sets=num_stacks if per_pass_attention else 1,
642
+ )
643
+ self.ln_final = RMSNorm(d_model, device=device, dtype=dtype)
644
+ std_base_lm_head = math.sqrt(2 / (BASE_D_MODEL + vocab_size))
645
+ self.lm_head = Linear(d_model, vocab_size, width_ratio, std_base_lm_head, device=device, dtype=dtype)
646
+
647
+ @classmethod
648
+ def from_config(cls, config: "LoopMoEConfig", *, device=None, dtype=None) -> "LoopedMoETransformer":
649
+ """THE way to build this model from a config. Both call sites use it.
650
+
651
+ There were two: the HF wrapper and `pretrain/train_spec.py`, each with its own
652
+ hand-written keyword list. When head/tail layers were added, the training path's
653
+ list was not updated, so it silently built a model with no head or tail while its
654
+ config said otherwise -- it trained, the loss looked plausible, and every artifact
655
+ recorded the config's depth rather than the depth that ran. Nothing could raise,
656
+ because a shorter model is a perfectly valid model.
657
+
658
+ A single entry point makes that class of drift impossible rather than merely
659
+ tested-for: a field added to the config is read here once, and both paths get it.
660
+ """
661
+ return cls(
662
+ vocab_size=config.vocab_size,
663
+ context_length=config.context_length,
664
+ d_model=config.d_model,
665
+ num_layers_in_stack=config.num_layers_in_stack,
666
+ num_stacks=config.num_stacks,
667
+ num_heads=config.num_heads,
668
+ d_ff=config.d_ff,
669
+ num_experts=config.num_experts,
670
+ num_active=config.num_active,
671
+ rope_theta=config.rope_theta,
672
+ width_ratio=config.width_ratio,
673
+ num_head_layers=config.num_head_layers,
674
+ num_tail_layers=config.num_tail_layers,
675
+ # `getattr` with the config's own default, not a bare False: a config
676
+ # object built by older code has no such attribute, and this is the
677
+ # one entry point, so a silent False here would be the only place the
678
+ # variant could be lost.
679
+ per_pass_attention=getattr(config, "per_pass_attention", False),
680
+ device=device,
681
+ dtype=dtype,
682
+ )
683
+
684
+ def forward(
685
+ self,
686
+ x: Tensor,
687
+ *,
688
+ trace_sink: Optional[TraceSink] = None,
689
+ ) -> tuple[Tensor, Tensor, Tensor]:
690
+ lb_total = None
691
+ lz_total = None
692
+
693
+ x = self.token_embeddings(x)
694
+
695
+ def run_outer(layers, block: str, pos: int, x, lb_total, lz_total):
696
+ for i, layer in enumerate(layers):
697
+ x, lb, lz = layer(
698
+ x, loop_step=0, layer_idx=0, block=block, unrolled_pos=pos + i,
699
+ trace_sink=trace_sink,
700
+ )
701
+ lb_total = lb if lb_total is None else lb_total + lb
702
+ lz_total = lz if lz_total is None else lz_total + lz
703
+ return x, lb_total, lz_total
704
+
705
+ # head -> loop x num_stacks -> tail, with one running depth coordinate. head and
706
+ # tail record loop_step=layer_idx=0 because neither coordinate means anything
707
+ # outside the loop; `block` and `unrolled_pos` are what identifies them.
708
+ x, lb_total, lz_total = run_outer(self.head_layers, "head", 0, x, lb_total, lz_total)
709
+ pos = self.num_head_layers
710
+ for loop_step in range(self.num_stacks):
711
+ x, lb, lz = self.stack(
712
+ x, loop_step=loop_step, unrolled_pos_start=pos, trace_sink=trace_sink,
713
+ )
714
+ pos += self.num_layers_in_stack
715
+ lb_total = lb if lb_total is None else lb_total + lb
716
+ lz_total = lz if lz_total is None else lz_total + lz
717
+ x, lb_total, lz_total = run_outer(self.tail_layers, "tail", pos, x, lb_total, lz_total)
718
+
719
+ x = self.lm_head(self.ln_final(x))
720
+
721
+ # Averaged over the *unrolled* depth: every physical layer contributes once per
722
+ # loop step, and head/tail contribute once each (recipe section 2.1). Equals
723
+ # `total_layers` exactly when there are no head/tail layers, which is every
724
+ # pre-ablation configuration -- so their published losses are unchanged.
725
+ return x, lb_total / self.unrolled_depth, lz_total / self.unrolled_depth
726
+
727
+
728
+ def _require(kwargs: dict[str, Any], name: str) -> Any:
729
+ """Fetch a required config field or raise.
730
+
731
+ Project rule (HANDOVER §4.3 / §5.6): semantic parameters get no defaults.
732
+ A default is a silent-wrong-answer generator -- a mistyped or dropped key
733
+ becomes a plausible number instead of an error.
734
+ """
735
+ if name not in kwargs or kwargs[name] is None:
736
+ raise ValueError(
737
+ f"LoopMoEConfig: required field {name!r} is missing. Semantic "
738
+ "parameters have no defaults in this project; state it explicitly."
739
+ )
740
+ return kwargs.pop(name)
741
+
742
+
743
+ class LoopMoEConfig(PretrainedConfig):
744
+ """Config for the looped-MoE architecture. **Every field is required.**
745
+
746
+ Compatible with ``save_pretrained``/``from_pretrained``: a config.json written
747
+ by this class round-trips, and one is rejected loudly if a field is absent.
748
+ """
749
+
750
+ model_type = "loop-moe"
751
+
752
+ # Tells transformers not to introspect defaults by constructing `cls()` with
753
+ # no arguments -- which this class deliberately rejects. Without it,
754
+ # `save_pretrained` fails inside `_get_generation_parameters`. This is the
755
+ # supported escape hatch for configs whose fields are all required.
756
+ has_no_defaults_at_init = True
757
+
758
+ def __init__(self, **kwargs: Any):
759
+ # `from_pretrained` on a *torch-saved* config, and some HF-internal paths,
760
+ # construct with no arguments at all; only a fully-specified call is valid.
761
+ self.vocab_size = _require(kwargs, "vocab_size")
762
+ self.context_length = _require(kwargs, "context_length")
763
+ self.d_model = _require(kwargs, "d_model")
764
+ self.num_heads = _require(kwargs, "num_heads")
765
+ self.d_ff = _require(kwargs, "d_ff")
766
+ self.rope_theta = _require(kwargs, "rope_theta")
767
+ self.width_ratio = _require(kwargs, "width_ratio")
768
+ self.num_layers_in_stack = _require(kwargs, "num_layers_in_stack") # L
769
+ self.num_stacks = _require(kwargs, "num_stacks") # R
770
+ self.num_experts = _require(kwargs, "num_experts") # E
771
+ self.num_active = _require(kwargs, "num_active") # k
772
+ self.lb_loss_factor = _require(kwargs, "lb_loss_factor")
773
+ # Head/tail layers: MoE layers run once, outside the loop (ablation recipe 2026-09-12
774
+ # section 2.1). 0 means the architecture has none, which is not a guess -- it is what
775
+ # every configuration built before this recipe actually is, and
776
+ # `test_config_registry` pins their parameter counts as unchanged. Real ablation
777
+ # configs never rely on the fallback: `config_registry.build_ablation` states both
778
+ # counts for every entry, and a test asserts it does.
779
+ self.num_head_layers = int(kwargs.pop("num_head_layers", 0))
780
+ self.num_tail_layers = int(kwargs.pop("num_tail_layers", 0))
781
+
782
+ # Per-pass attention: each of the R passes over the shared stack gets its
783
+ # OWN attention parameters, while the MoE, the norms and the router stay
784
+ # shared. Default False, and defaulted rather than required precisely
785
+ # because every config written before 2026-09-18 lacks the key: those
786
+ # files must keep loading, and they must keep meaning what they meant.
787
+ # False reproduces the previous model bit for bit, including the state
788
+ # dict's key names -- see GroupedMoEPrenormBlock.
789
+ self.per_pass_attention = bool(kwargs.pop("per_pass_attention", False))
790
+ self.lz_loss_factor = _require(kwargs, "lz_loss_factor")
791
+
792
+ # Stated, not inherited. `PretrainedConfig` defaults this to True, and the
793
+ # only reason the embedding and the LM head are not already sharing storage
794
+ # is that this model never implemented `get_output_embeddings()`. The day
795
+ # someone adds it for tool compatibility, every configuration would start
796
+ # tying weights -- a different model, trained to a different loss, with
797
+ # nothing in any artifact saying so. The architecture uses untied weights
798
+ # (the parameter counts in the recipe assume it), so the config says so.
799
+ kwargs.pop("tie_word_embeddings", None)
800
+ self.tie_word_embeddings = False
801
+
802
+ self._validate()
803
+
804
+ # Derived, for readers; never an input. The unrolled depth now includes the
805
+ # layers that run once outside the loop.
806
+ self.num_layers = self.num_stacks * self.num_layers_in_stack
807
+ self.unrolled_depth = self.num_head_layers + self.num_layers + self.num_tail_layers
808
+
809
+ # The original config mirrored `context_length` into `max_length` for
810
+ # lm-evaluation-harness. transformers >=5 classifies `max_length` as a
811
+ # generation parameter and refuses to serialise a config carrying one
812
+ # (the check is `hasattr`, so even a property trips it). `context_length`
813
+ # is therefore the single source of truth for sequence length; pass
814
+ # `max_length` to the harness explicitly at eval time instead.
815
+ # Popped so that loading a seedvar-era config.json cannot reintroduce it.
816
+ kwargs.pop("max_length", None)
817
+
818
+ super().__init__(**kwargs)
819
+
820
+ def _validate(self) -> None:
821
+ """Reject out-of-domain values loudly rather than failing deep in a kernel."""
822
+ positive = (
823
+ "vocab_size", "context_length", "d_model", "num_heads", "d_ff",
824
+ "num_layers_in_stack", "num_stacks", "num_experts", "num_active",
825
+ )
826
+ for name in positive:
827
+ value = getattr(self, name)
828
+ if not isinstance(value, int) or value < 1:
829
+ raise ValueError(f"LoopMoEConfig.{name} must be a positive int, got {value!r}")
830
+ if self.d_model % self.num_heads != 0:
831
+ raise ValueError(
832
+ f"d_model ({self.d_model}) must be divisible by num_heads ({self.num_heads})"
833
+ )
834
+ if self.num_active > self.num_experts:
835
+ raise ValueError(
836
+ f"num_active ({self.num_active}) cannot exceed num_experts ({self.num_experts})"
837
+ )
838
+ if self.d_ff % self.num_active != 0:
839
+ raise ValueError(
840
+ f"d_ff ({self.d_ff}) must be divisible by num_active ({self.num_active}); "
841
+ "expert width is d_ff // num_active and truncation would silently "
842
+ "change the parameter count."
843
+ )
844
+ for name in ("num_head_layers", "num_tail_layers"):
845
+ value = getattr(self, name)
846
+ if not isinstance(value, int) or value < 0:
847
+ raise ValueError(f"LoopMoEConfig.{name} must be a non-negative int, got {value!r}")
848
+ if (self.d_model // self.num_heads) % 2 != 0:
849
+ raise ValueError(
850
+ f"head dim ({self.d_model // self.num_heads}) must be even for RoPE"
851
+ )
852
+
853
+ @property
854
+ def trace_meta(self) -> TraceMeta:
855
+ """Shape/provenance block handed to the metrics collector."""
856
+ return TraceMeta(
857
+ num_stacks=self.num_stacks,
858
+ num_layers_in_stack=self.num_layers_in_stack,
859
+ num_experts=self.num_experts,
860
+ num_active=self.num_active,
861
+ )
862
+
863
+
864
+ class LoopMoEForCausalLM(PreTrainedModel, GenerationMixin):
865
+ """HF-compatible causal LM wrapper.
866
+
867
+ Kept HF-shaped on purpose: HANDOVER §4.7 makes "the diagnostics pipeline
868
+ ingests our checkpoints unchanged" an acceptance criterion, and that pipeline
869
+ loads models through ``from_pretrained``.
870
+ """
871
+
872
+ config_class = LoopMoEConfig
873
+
874
+ def __init__(self, config: LoopMoEConfig):
875
+ super().__init__(config)
876
+ self.model = LoopedMoETransformer.from_config(config)
877
+ self.post_init()
878
+
879
+ def get_input_embeddings(self):
880
+ return self.model.token_embeddings
881
+
882
+ def set_input_embeddings(self, value):
883
+ self.model.token_embeddings = value
884
+
885
+ def forward(
886
+ self,
887
+ input_ids: torch.LongTensor,
888
+ attention_mask: Optional[Tensor] = None, # unused: the mask is built in
889
+ labels: Optional[torch.LongTensor] = None,
890
+ trace_sink: Optional[TraceSink] = None,
891
+ **kwargs: Any,
892
+ ) -> CausalLMOutputWithPast:
893
+ """Forward pass.
894
+
895
+ Returns a ``CausalLMOutputWithPast`` whose ``loss`` is the *total* loss
896
+ (CE + weighted aux). The unweighted components are attached as
897
+ ``task_loss`` / ``lb_loss`` / ``z_loss`` so the training loop can log the
898
+ breakdown without recomputing anything.
899
+
900
+ **Label contract (documented here 2026-08-21, `ABCI_ERR_20260821_0405_
901
+ gate1_label_shift_root_cause.md`)**: ``labels`` must already be
902
+ next-token-shifted by the caller -- ``labels[..., t] == input_ids[..., t+1]``,
903
+ with the last position set to ``-100`` (no target exists after it). This
904
+ method does **not** shift internally; it passes ``labels`` to
905
+ ``F.cross_entropy`` exactly as given. Before this date the only written
906
+ record of this contract was a comment in
907
+ ``src/model/loop_trace.py`` ("the dataloader supplies pre-shifted
908
+ labels during training, so the shift is explicit here") -- not here, at
909
+ the definition itself. That gap let four separate call sites
910
+ (the pretraining dataloader path aside, which was correct) independently
911
+ get this wrong the same way, rather than it being four unrelated
912
+ mistakes. Any caller not shifting first -- e.g. ``model(input_ids=ids,
913
+ labels=ids)`` -- silently trains/evaluates on the trivial
914
+ copy-the-current-token target instead of next-token prediction.
915
+ """
916
+ logits, lb, lz = self.model(input_ids, trace_sink=trace_sink)
917
+
918
+ loss = task_loss = None
919
+ if labels is not None:
920
+ task_loss = F.cross_entropy(logits.view(-1, logits.size(-1)), labels.view(-1))
921
+ loss = (
922
+ task_loss
923
+ + self.config.lb_loss_factor * lb
924
+ + self.config.lz_loss_factor * lz
925
+ )
926
+
927
+ out = CausalLMOutputWithPast(loss=loss, logits=logits)
928
+ # Unweighted components; the trainer pairs them with the factors from
929
+ # config to build LossComponents.
930
+ out.task_loss = task_loss
931
+ out.lb_loss = lb
932
+ out.z_loss = lz
933
+ return out
934
+
935
+ def prepare_inputs_for_generation(self, input_ids, **kwargs):
936
+ return {"input_ids": input_ids}