File size: 23,420 Bytes
439e7b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
"""From-scratch pre-LayerNorm BERT-style encoder for the BITS Phishing Detector.

Plain PyTorch only (torch / math / dataclasses / typing) so this file can be copied
verbatim to app/model.py for the Hugging Face Space. No pretrained weights, no
``transformers``.

Architecture (docs/research/research_model-math.md sec. 1 and 5):

* token + learned absolute position embeddings -> LayerNorm -> dropout (no segment
  embeddings: the classifier sees a single "subject\\n\\nbody" segment).
* ``n_layers`` pre-LN blocks::

      x = x + Dropout(OutProj(SDPA(QKV(LN1(x)))))
      x = x + Dropout(FFN_down(GELU(FFN_up(LN2(x)))))

  Pre-LN is chosen over BERT's post-LN because a 24-layer stack trained from
  scratch at batch 256 is far more tolerant of the learning rate and warmup under
  pre-LN (Xiong et al. 2020), and we have no pretrained weights to fall back on.
* a final LayerNorm (pre-LN needs it: the residual stream is otherwise un-normalised).
* attention through ``torch.nn.functional.scaled_dot_product_attention`` so the
  fused Flash / memory-efficient kernels run and the L x L score matrix is never
  stored for backward. No mask -> ``attn_mask=None`` (Flash-eligible, the packed
  pretraining path); a ``BoolTensor[B, L]`` key-padding mask (True = keep) ->
  ``[B, 1, 1, L]`` (memory-efficient backend, the fine-tune / inference path).

Default config (24L / d1024 / 16 heads / FFN 4096 / V 32768 / L 512) has exactly
337,476,608 parameters with the tied MLM head and 337,443,842 as the classifier.
"""

import math
from dataclasses import asdict, dataclass, fields
from typing import Any, Mapping

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor
from torch.utils.checkpoint import checkpoint


@dataclass
class EncoderConfig:
    vocab_size: int = 32768
    max_seq_len: int = 512
    d_model: int = 1024
    n_layers: int = 24
    n_heads: int = 16
    d_ff: int = 4096
    dropout: float = 0.1
    attention_dropout: float = 0.1
    # 1e-5 rather than BERT's 1e-12. Under autocast LayerNorm runs in fp32 so both
    # behave identically in training; 1e-5 is the safer value if the deployed model
    # is ever run in pure bf16 (8-bit mantissa: var + 1e-12 == var), and it is the
    # value every modern pre-LN encoder (RoBERTa-style, GPT-2) uses.
    layer_norm_eps: float = 1e-5
    pad_token_id: int = 0
    tie_mlm_head: bool = True
    grad_checkpointing: bool = False
    num_labels: int = 2

    def __post_init__(self) -> None:
        if self.d_model % self.n_heads:
            raise ValueError(f"d_model={self.d_model} not divisible by n_heads={self.n_heads}")
        if not 0 <= self.pad_token_id < self.vocab_size:
            raise ValueError("pad_token_id outside the vocabulary")

    @property
    def head_dim(self) -> int:
        return self.d_model // self.n_heads

    @classmethod
    def from_dict(cls, d: Mapping[str, Any]) -> "EncoderConfig":
        """Build from the config.yaml ``model`` section.

        Keys that describe choices this file implements only one way (``norm``,
        ``activation``) are validated rather than silently ignored; any other
        unknown key is dropped.
        """
        norm = d.get("norm", "pre_ln")
        if norm != "pre_ln":
            raise ValueError(f"only norm=pre_ln is implemented, got {norm!r}")
        act = d.get("activation", "gelu")
        if act != "gelu":
            raise ValueError(f"only activation=gelu is implemented, got {act!r}")
        known = {f.name for f in fields(cls)}
        return cls(**{k: v for k, v in d.items() if k in known})

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)


# --------------------------------------------------------------------------- init
def _init_module(module: nn.Module) -> None:
    """BERT init: N(0, 0.02) weights, zero biases, identity LayerNorm."""
    if isinstance(module, nn.Linear):
        nn.init.normal_(module.weight, mean=0.0, std=0.02)
        if module.bias is not None:
            nn.init.zeros_(module.bias)
    elif isinstance(module, nn.Embedding):
        nn.init.normal_(module.weight, mean=0.0, std=0.02)
        if module.padding_idx is not None:
            with torch.no_grad():
                module.weight[module.padding_idx].zero_()
    elif isinstance(module, nn.LayerNorm):
        nn.init.ones_(module.weight)
        nn.init.zeros_(module.bias)


def _zero_pad_row_grad(pad_token_id: int):
    """Post-accumulate hook keeping the [PAD] embedding row exactly zero through training.

    ``nn.Embedding(padding_idx)`` routes no gradient into that row, but with the tied MLM
    decoder the output matmul does (observed grad norm ~0.06 after one backward), so
    the row would drift from zero on the first optimizer step. Zeroing ``.grad[pad]``
    after accumulation is enough: AdamW then has zero moments for the row and decoupled
    weight decay leaves a zero weight at zero.
    """
    def hook(param: Tensor) -> None:
        if param.grad is not None:
            param.grad[pad_token_id].zero_()
    return hook


def _expand_key_padding_mask(attention_mask: Tensor | None) -> Tensor | None:
    """``BoolTensor[B, L]`` (True = keep) -> ``[B, 1, 1, L]`` broadcast over heads and queries."""
    if attention_mask is None:
        return None
    if attention_mask.dtype is not torch.bool:
        attention_mask = attention_mask.bool()
    return attention_mask[:, None, None, :]


# ------------------------------------------------------------------------ modules
class Embeddings(nn.Module):
    """Token + learned absolute position embeddings -> LayerNorm -> dropout.

    The [PAD] row of the token table is zero at init and stays zero while training
    (see ``_zero_pad_row_grad``). Tensor hooks are not carried by ``state_dict`` or
    ``copy.deepcopy``: a deep-copied module must not be trained, a module rebuilt from
    its config and ``load_state_dict`` is fine.
    """

    def __init__(self, cfg: EncoderConfig) -> None:
        super().__init__()
        self.tok = nn.Embedding(cfg.vocab_size, cfg.d_model, padding_idx=cfg.pad_token_id)
        self.pos = nn.Embedding(cfg.max_seq_len, cfg.d_model)
        self.norm = nn.LayerNorm(cfg.d_model, eps=cfg.layer_norm_eps)
        self.dropout = nn.Dropout(cfg.dropout)
        self.register_buffer("position_ids", torch.arange(cfg.max_seq_len), persistent=False)
        self.tok.weight.register_post_accumulate_grad_hook(_zero_pad_row_grad(cfg.pad_token_id))

    def forward(self, input_ids: Tensor) -> Tensor:
        L = input_ids.shape[1]
        if L > self.pos.num_embeddings:
            raise ValueError(f"sequence length {L} > max_seq_len {self.pos.num_embeddings}")
        x = self.tok(input_ids) + self.pos(self.position_ids[:L])
        return self.dropout(self.norm(x))


class EncoderLayer(nn.Module):
    def __init__(self, cfg: EncoderConfig) -> None:
        super().__init__()
        self.n_heads = cfg.n_heads
        self.head_dim = cfg.head_dim
        self.attention_dropout = cfg.attention_dropout
        self.ln1 = nn.LayerNorm(cfg.d_model, eps=cfg.layer_norm_eps)
        self.qkv = nn.Linear(cfg.d_model, 3 * cfg.d_model)
        self.out_proj = nn.Linear(cfg.d_model, cfg.d_model)
        self.ln2 = nn.LayerNorm(cfg.d_model, eps=cfg.layer_norm_eps)
        self.ffn_up = nn.Linear(cfg.d_model, cfg.d_ff)
        self.ffn_down = nn.Linear(cfg.d_ff, cfg.d_model)
        self.dropout = nn.Dropout(cfg.dropout)

    def _qkv(self, x: Tensor) -> tuple[Tensor, Tensor, Tensor]:
        B, L, _ = x.shape
        qkv = self.qkv(x).view(B, L, 3, self.n_heads, self.head_dim)
        q, k, v = qkv.unbind(dim=2)
        return q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2)  # [B, h, L, dh]

    def forward(self, x: Tensor, attn_mask: Tensor | None = None) -> Tensor:
        q, k, v = self._qkv(self.ln1(x))
        o = F.scaled_dot_product_attention(
            q, k, v,
            attn_mask=attn_mask,
            dropout_p=self.attention_dropout if self.training else 0.0,
        )
        o = o.transpose(1, 2).reshape(x.shape)
        x = x + self.dropout(self.out_proj(o))
        x = x + self.dropout(self.ffn_down(F.gelu(self.ffn_up(self.ln2(x)))))
        return x

    @torch.no_grad()
    def attention_probs(self, x: Tensor, attn_mask: Tensor | None = None) -> Tensor:
        """Explicit softmax(QK^T / sqrt(dh)) for inspection. Off the hot path.

        fp32, no dropout: this is the quantity the app shows as a signal, not the
        stochastic one used in training. Returns ``[B, h, L, L]``.
        """
        q, k, _ = self._qkv(self.ln1(x))
        scores = (q.float() @ k.float().transpose(-1, -2)) / math.sqrt(self.head_dim)
        if attn_mask is not None:
            scores = scores.masked_fill(~attn_mask, float("-inf"))
        return scores.softmax(dim=-1)


class Encoder(nn.Module):
    def __init__(self, cfg: EncoderConfig) -> None:
        super().__init__()
        self.config = cfg
        self.embeddings = Embeddings(cfg)
        self.layers = nn.ModuleList(EncoderLayer(cfg) for _ in range(cfg.n_layers))
        self.final_norm = nn.LayerNorm(cfg.d_model, eps=cfg.layer_norm_eps)
        self.apply(_init_module)
        self._scale_residual_projections()

    def _scale_residual_projections(self) -> None:
        """GPT-2 style 1/sqrt(2 * n_layers) on the two projections that write into the
        residual stream (OutProj, FFN_down).

        Each of the 2 * n_layers residual branches adds an N(0, 0.02^2)-scaled
        contribution; without the rescale the residual variance grows linearly with
        depth, so the final-norm input and the early-training gradient magnitude
        would be ~sqrt(48) ~ 7x larger for 24 layers than for one. Keeping the stream
        O(1) at init is what lets a from-scratch 24-layer pre-LN model take a 1.5e-4
        peak LR with a short warmup instead of diverging in the first few hundred steps.
        """
        scale = 1.0 / math.sqrt(2 * self.config.n_layers)
        with torch.no_grad():
            for layer in self.layers:
                layer.out_proj.weight.mul_(scale)
                layer.ffn_down.weight.mul_(scale)

    def forward(
        self,
        input_ids: Tensor,
        attention_mask: Tensor | None = None,
        return_last_attn: bool = False,
    ) -> Tensor | tuple[Tensor, Tensor]:
        mask = _expand_key_padding_mask(attention_mask)
        x = self.embeddings(input_ids)
        use_ckpt = self.config.grad_checkpointing and self.training and torch.is_grad_enabled()
        last_attn: Tensor | None = None
        n = len(self.layers)
        for i, layer in enumerate(self.layers):
            if return_last_attn and i == n - 1:
                last_attn = layer.attention_probs(x, mask)
            if use_ckpt:
                x = checkpoint(layer, x, mask, use_reentrant=False)
            else:
                x = layer(x, mask)
        x = self.final_norm(x)
        if return_last_attn:
            assert last_attn is not None
            return x, last_attn
        return x


class MLMHead(nn.Module):
    """Linear(d, d) -> GELU -> LayerNorm -> decoder Linear(d, V) tied to the token embedding.

    Tying shares the Parameter object, so ``state_dict()`` carries two keys over one
    storage (``encoder.embeddings.tok.weight`` and ``head.decoder.weight``) and
    ``safetensors.torch.save_file`` refuses it ("Some tensors share memory"). Write
    checkpoints from ``EncoderForMLM.state_dict_for_safetensors()`` (or with
    ``safetensors.torch.save_model``, which drops the same alias) and read them back
    with ``EncoderForMLM.load_tied_state_dict``.
    """

    def __init__(self, cfg: EncoderConfig, token_embedding: nn.Embedding | None) -> None:
        super().__init__()
        self.dense = nn.Linear(cfg.d_model, cfg.d_model)
        self.norm = nn.LayerNorm(cfg.d_model, eps=cfg.layer_norm_eps)
        self.decoder = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
        # Own bias so the tied matrix is not forced to also fit unigram frequencies.
        self.bias = nn.Parameter(torch.zeros(cfg.vocab_size))
        self.apply(_init_module)
        if token_embedding is not None:
            # Same Parameter object: one set of weights, one gradient accumulator.
            # Tying saves 33.5M parameters and (BERT/RoBERTa) regularises the
            # output distribution toward the input geometry.
            self.decoder.weight = token_embedding.weight

    def forward(self, hidden: Tensor) -> Tensor:
        h = self.norm(F.gelu(self.dense(hidden)))
        return self.decoder(h) + self.bias


class EncoderForMLM(nn.Module):
    """Encoder + MLM head for pretraining. Checkpoints: see ``state_dict_for_safetensors``."""

    TIED_KEY = "head.decoder.weight"
    EMBEDDING_KEY = "encoder.embeddings.tok.weight"

    def __init__(self, cfg: EncoderConfig) -> None:
        super().__init__()
        self.config = cfg
        self.encoder = Encoder(cfg)
        self.head = MLMHead(cfg, self.encoder.embeddings.tok if cfg.tie_mlm_head else None)

    def state_dict_for_safetensors(self) -> dict[str, Tensor]:
        """``state_dict()`` minus the tied decoder's alias key.

        With ``tie_mlm_head`` the decoder weight IS the token embedding, and safetensors
        rejects two keys over one storage, so the pretraining trainer must save this
        dict (``safetensors.torch.save_file(model.state_dict_for_safetensors(), path)``)
        rather than the raw ``state_dict()``. ``from_pretrained_encoder`` reads only the
        ``encoder.*`` keys and accepts either form.
        """
        sd = self.state_dict()
        if self.config.tie_mlm_head:
            sd.pop(self.TIED_KEY)
        return sd

    def load_tied_state_dict(self, state_dict: Mapping[str, Tensor], strict: bool = True) -> Any:
        """``load_state_dict`` that accepts the dedup'd dict from ``state_dict_for_safetensors``
        / ``save_model``: the dropped alias is re-pointed at the embedding tensor before
        loading, so strict mode still reports every genuine mismatch."""
        sd = dict(state_dict)
        if self.config.tie_mlm_head and self.TIED_KEY not in sd and self.EMBEDDING_KEY in sd:
            sd[self.TIED_KEY] = sd[self.EMBEDDING_KEY]
        return self.load_state_dict(sd, strict=strict)

    def forward(
        self,
        input_ids: Tensor,
        attention_mask: Tensor | None = None,
        masked_positions: Tensor | None = None,
    ) -> Tensor:
        """Logits over the vocabulary.

        With ``masked_positions`` (``BoolTensor[B, L]``) the hidden states are
        gathered BEFORE the head, so logits are ``[N_masked, V]`` and the 85% of
        positions that carry no loss never materialise a V-wide row (saves ~136 MiB
        per 512-token sample at peak; research_model-math.md sec. 2).
        Without it, ``[B, L, V]``.
        """
        hidden = self.encoder(input_ids, attention_mask)
        if masked_positions is not None:
            hidden = hidden[masked_positions]
        return self.head(hidden)


class ClassificationHead(nn.Module):
    """CLS -> dropout -> Linear(d, d) -> tanh -> dropout -> Linear(d, num_labels)."""

    def __init__(self, cfg: EncoderConfig) -> None:
        super().__init__()
        self.dropout = nn.Dropout(cfg.dropout)
        self.dense = nn.Linear(cfg.d_model, cfg.d_model)
        self.out = nn.Linear(cfg.d_model, cfg.num_labels)
        self.apply(_init_module)

    def forward(self, cls_hidden: Tensor) -> Tensor:
        h = torch.tanh(self.dense(self.dropout(cls_hidden)))
        return self.out(self.dropout(h))


class EncoderForClassification(nn.Module):
    def __init__(self, cfg: EncoderConfig) -> None:
        super().__init__()
        self.config = cfg
        self.encoder = Encoder(cfg)
        self.classifier = ClassificationHead(cfg)

    def forward(
        self,
        input_ids: Tensor,
        attention_mask: Tensor | None = None,
        return_last_attn: bool = False,
    ) -> Tensor | tuple[Tensor, Tensor]:
        out = self.encoder(input_ids, attention_mask, return_last_attn=return_last_attn)
        if return_last_attn:
            hidden, last_attn = out
            return self.classifier(hidden[:, 0]), last_attn
        return self.classifier(out[:, 0])

    @classmethod
    def from_pretrained_encoder(
        cls,
        mlm: "EncoderForMLM | Mapping[str, Tensor]",
        cfg: EncoderConfig | None = None,
        num_labels: int | None = None,
    ) -> "EncoderForClassification":
        """Classifier whose encoder is initialised from a pretrained ``EncoderForMLM``
        (module or its ``state_dict``). The MLM head is dropped; the classification
        head keeps its fresh init. Strict key matching: any missing/unexpected encoder
        key raises, because a silently half-loaded encoder would train but be garbage.
        """
        if isinstance(mlm, EncoderForMLM):
            cfg = cfg or mlm.config
            state = mlm.state_dict()
        else:
            if cfg is None:
                raise ValueError("cfg is required when loading from a state_dict")
            state = mlm
        if num_labels is not None and num_labels != cfg.num_labels:
            cfg = EncoderConfig(**{**cfg.to_dict(), "num_labels": num_labels})
        model = cls(cfg)
        load_encoder_weights(model.encoder, state, prefix="encoder.", strict=True)
        return model


def load_encoder_weights(
    encoder: Encoder,
    state_dict: Mapping[str, Tensor],
    prefix: str = "encoder.",
    strict: bool = True,
) -> tuple[list[str], list[str]]:
    """Load ``Encoder`` weights from a wrapper's state_dict (keys ``encoder.*``).

    Returns ``(missing_keys, unexpected_keys)`` relative to the encoder. Keys outside
    ``prefix`` (``head.*``, ``classifier.*``) are ignored, not reported. With
    ``strict`` any mismatch raises after reporting the exact keys.
    """
    sub = {k[len(prefix):]: v for k, v in state_dict.items() if k.startswith(prefix)}
    result = encoder.load_state_dict(sub, strict=False)
    missing, unexpected = list(result.missing_keys), list(result.unexpected_keys)
    if strict and (missing or unexpected):
        raise RuntimeError(
            f"encoder weights do not match: missing={missing[:8]}{'...' if len(missing) > 8 else ''} "
            f"unexpected={unexpected[:8]}{'...' if len(unexpected) > 8 else ''}"
        )
    return missing, unexpected


def count_parameters(module: nn.Module) -> dict[str, Any]:
    """Unique-parameter counts (a tied weight is counted once, under the encoder's
    embedding where ``named_parameters`` first sees it).

    Breakdown keys: ``embeddings`` (token + position + embedding LN),
    ``encoder_layers``, ``final_norm``, ``head`` (MLM head or classifier).
    """
    total = sum(p.numel() for p in module.parameters())
    trainable = sum(p.numel() for p in module.parameters() if p.requires_grad)
    breakdown = {"embeddings": 0, "encoder_layers": 0, "final_norm": 0, "head": 0}
    for name, p in module.named_parameters():
        parts = name.split(".")
        if "embeddings" in parts:
            breakdown["embeddings"] += p.numel()
        elif "layers" in parts:
            breakdown["encoder_layers"] += p.numel()
        elif "final_norm" in parts:
            breakdown["final_norm"] += p.numel()
        else:
            breakdown["head"] += p.numel()
    return {"total": total, "trainable": trainable, **breakdown}


def _format_count_table(title: str, counts: Mapping[str, int]) -> str:
    rows = [f"{title}"]
    for k in ("embeddings", "encoder_layers", "final_norm", "head", "total", "trainable"):
        rows.append(f"  {k:<15} {counts[k]:>15,}")
    return "\n".join(rows)


if __name__ == "__main__":
    import sys
    from pathlib import Path

    import yaml  # only here: the module body must stay torch-only for app/model.py

    cfg_path = Path(__file__).resolve().parent.parent / "config.yaml"
    with open(cfg_path) as fh:
        model_cfg = EncoderConfig.from_dict(yaml.safe_load(fh)["model"])
    print(f"config: {model_cfg.to_dict()}")

    mlm = EncoderForMLM(model_cfg)
    mlm_counts = count_parameters(mlm)
    print(_format_count_table("EncoderForMLM (tied head)", mlm_counts))
    assert mlm.head.decoder.weight is mlm.encoder.embeddings.tok.weight, "MLM head is not tied"

    cls = EncoderForClassification.from_pretrained_encoder(mlm)
    cls_counts = count_parameters(cls)
    print(_format_count_table("EncoderForClassification", cls_counts))

    is_default = (
        model_cfg.n_layers, model_cfg.d_model, model_cfg.n_heads, model_cfg.d_ff,
        model_cfg.vocab_size, model_cfg.max_seq_len, model_cfg.tie_mlm_head, model_cfg.num_labels,
    ) == (24, 1024, 16, 4096, 32768, 512, True, 2)
    if is_default:
        # research_model-math.md sec. 1: per layer 12,596,224; encoder 302,311,424;
        # embeddings 34,080,768; tied MLM head 1,084,416; classifier head 1,051,650.
        assert mlm_counts["total"] == 337_476_608, mlm_counts["total"]
        assert cls_counts["total"] == 337_443_842, cls_counts["total"]
        assert mlm_counts["encoder_layers"] == 302_309_376, mlm_counts["encoder_layers"]
        assert mlm_counts["embeddings"] == 34_080_768, mlm_counts["embeddings"]
        assert mlm_counts["head"] == 1_084_416, mlm_counts["head"]
        assert cls_counts["head"] == 1_051_650, cls_counts["head"]
        print("parameter counts match research_model-math.md sec. 1")
    else:
        print("non-default config: skipping the 337,476,608 / 337,443,842 assertion")

    # Checkpoint format: the tied alias is dropped for safetensors and restored on load.
    sd = mlm.state_dict_for_safetensors()
    assert EncoderForMLM.TIED_KEY not in sd and len(sd) == len(mlm.state_dict()) - 1
    mlm.load_tied_state_dict(sd, strict=True)
    EncoderForClassification.from_pretrained_encoder(sd, model_cfg)
    print(f"safetensors-safe state_dict: {len(sd)} tensors, strict round-trip OK")

    # The [PAD] embedding row must receive no gradient despite the tied decoder.
    ids = torch.randint(5, model_cfg.vocab_size, (2, 16))
    mlm.train()
    mlm(ids).float().mean().backward()
    pad_grad = mlm.encoder.embeddings.tok.weight.grad[model_cfg.pad_token_id]
    assert float(pad_grad.abs().max()) == 0.0, "pad row received gradient"
    assert float(mlm.encoder.embeddings.tok.weight.grad[5:].abs().max()) > 0.0
    mlm.zero_grad(set_to_none=True)
    print("pad-row gradient is zero under the tied decoder")

    # Round-trip check of the shared contract on a tiny CPU batch.
    mlm.eval()
    mask = torch.ones(2, 16, dtype=torch.bool)
    mask[1, 10:] = False
    with torch.inference_mode():
        full = mlm(ids, mask)
        pos = torch.zeros(2, 16, dtype=torch.bool)
        pos[0, 3] = pos[1, 7] = True
        gathered = mlm(ids, mask, masked_positions=pos)
        logits, attn = cls(ids, mask, return_last_attn=True)
    assert full.shape == (2, 16, model_cfg.vocab_size) and gathered.shape == (2, model_cfg.vocab_size)
    assert logits.shape == (2, model_cfg.num_labels) and attn.shape == (2, model_cfg.n_heads, 16, 16)
    assert torch.allclose(attn[1, :, :, 10:], torch.zeros(()), atol=0), "padded keys received attention"
    print("forward shapes OK:", tuple(full.shape), tuple(gathered.shape), tuple(logits.shape), tuple(attn.shape))
    sys.exit(0)