File size: 23,148 Bytes
17a55cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Groot 1 — Tree-Reduce Decision Transformer.

Forward pass (one request, Q questions):

    chunks  = split(state)                      # parallel leaves
    H_c     = Encoder(chunks)                   # one batched pass, shared weights
    leaves  = mean_pool(H_c) + seg_emb          # one vector per chunk
    nodes   = TreeReduce(leaves)                # fork/join merge, log depth
    memory  = [H_c tokens ; nodes]              # state memory, built ONCE
    for each option of each question (parallel, batched):
        h_i = Encoder([CLS] head [SEP] [MASK] option_i [SEP])[MASK]   # pointwise
    for each question (parallel):
        S   = [q_token ; h_1 .. h_K]            # a SET: identical positions
        S   = Fusion(S, memory)                 # set self-attn + cross-attn + GeGLU
        logits = scorer(S[1:])                  # one logit per option
        p = softmax(logits / T[qtype])

The state is encoded once and every question reads it through cross-attention,
so the latency of a request grows only with the (small) question blocks. The
same dataflow is a fork/join tree, which is exactly what Bend parallelises
(see bend/groot.bend).
"""
from __future__ import annotations

import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence

import torch
import torch.nn.functional as F
from safetensors.torch import load_file, save_file
from torch import nn

from .config import GrootConfig, QTYPES
from .serialize import EncodedQuestion, EncodedState

NEG = -1e4


# --------------------------------------------------------------------- RoPE
def rope_cos_sin(position_ids: torch.Tensor, dim: int, theta: float, dtype: torch.dtype):
    """cos/sin tables for rotary embeddings; position_ids (B, L) -> (B, L, dim)."""
    inv = 1.0 / (theta ** (torch.arange(0, dim, 2, device=position_ids.device, dtype=torch.float32) / dim))
    freqs = position_ids[..., None].float() * inv  # (B, L, dim/2)
    emb = torch.cat((freqs, freqs), dim=-1)
    return emb.cos().to(dtype), emb.sin().to(dtype)


def rotate_half(x: torch.Tensor) -> torch.Tensor:
    x1, x2 = x.chunk(2, dim=-1)
    return torch.cat((-x2, x1), dim=-1)


def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
    # x: (B, H, L, Dh); cos/sin: (B, L, Dh)
    return x * cos[:, None] + rotate_half(x) * sin[:, None]


# ------------------------------------------------------------------ fusion
class SelfAttention(nn.Module):
    def __init__(self, d: int, heads: int, dropout: float):
        super().__init__()
        self.h, self.dh = heads, d // heads
        self.Wqkv = nn.Linear(d, 3 * d, bias=False)
        self.Wo = nn.Linear(d, d, bias=False)
        self.dropout = dropout

    def forward(self, x, cos, sin, mask):
        B, L, d = x.shape
        q, k, v = self.Wqkv(x).view(B, L, 3, self.h, self.dh).permute(2, 0, 3, 1, 4)
        q, k = apply_rope(q, cos, sin), apply_rope(k, cos, sin)
        out = F.scaled_dot_product_attention(
            q, k, v, attn_mask=mask[:, None, None, :], dropout_p=self.dropout if self.training else 0.0
        )
        return self.Wo(out.transpose(1, 2).reshape(B, L, d))


class CrossAttention(nn.Module):
    def __init__(self, d: int, heads: int, dropout: float):
        super().__init__()
        self.h, self.dh = heads, d // heads
        self.Wq = nn.Linear(d, d, bias=False)
        self.Wkv = nn.Linear(d, 2 * d, bias=False)
        self.Wo = nn.Linear(d, d, bias=False)
        nn.init.zeros_(self.Wo.weight)  # start as identity residual
        self.dropout = dropout

    def forward(self, x, memory, memory_mask):
        B, L, d = x.shape
        M = memory.shape[1]
        q = self.Wq(x).view(B, L, self.h, self.dh).transpose(1, 2)
        k, v = self.Wkv(memory).view(B, M, 2, self.h, self.dh).permute(2, 0, 3, 1, 4)
        out = F.scaled_dot_product_attention(
            q, k, v, attn_mask=memory_mask[:, None, None, :], dropout_p=self.dropout if self.training else 0.0
        )
        return self.Wo(out.transpose(1, 2).reshape(B, L, d))


class GeGLU(nn.Module):
    def __init__(self, d: int, inter: int, dropout: float):
        super().__init__()
        self.Wi = nn.Linear(d, 2 * inter, bias=False)
        self.Wo = nn.Linear(inter, d, bias=False)
        self.drop = nn.Dropout(dropout)

    def forward(self, x):
        a, g = self.Wi(x).chunk(2, dim=-1)
        return self.Wo(self.drop(F.gelu(a) * g))


class FusionLayer(nn.Module):
    """Pre-norm block: self-attention (RoPE) -> cross-attention to memory -> GeGLU."""

    def __init__(self, cfg: GrootConfig):
        super().__init__()
        d = cfg.hidden_size
        self.attn_norm = nn.LayerNorm(d, eps=cfg.norm_eps, bias=False)
        self.attn = SelfAttention(d, cfg.fusion_heads, cfg.dropout)
        self.cross_norm = nn.LayerNorm(d, eps=cfg.norm_eps, bias=False)
        self.cross = CrossAttention(d, cfg.fusion_heads, cfg.dropout)
        self.mlp_norm = nn.LayerNorm(d, eps=cfg.norm_eps, bias=False)
        self.mlp = GeGLU(d, cfg.fusion_intermediate, cfg.dropout)
        self.drop = nn.Dropout(cfg.dropout)

    def forward(self, x, cos, sin, mask, memory, memory_mask):
        x = x + self.drop(self.attn(self.attn_norm(x), cos, sin, mask))
        x = x + self.drop(self.cross(self.cross_norm(x), memory, memory_mask))
        x = x + self.drop(self.mlp(self.mlp_norm(x)))
        return x


# ------------------------------------------------------------------- tree
class TreeReducer(nn.Module):
    """Balanced binary fork/join merge of chunk summaries.

    node = LN(l + r + W [l ; r ; l*r ; |l-r|]) + level_emb[level]
    A node with a single valid child passes that child through unchanged.
    Returns every internal node (multi-scale memory) and the root.
    """

    def __init__(self, cfg: GrootConfig):
        super().__init__()
        d = cfg.hidden_size
        self.merge = nn.Linear(4 * d, d)
        if cfg.tree_merge_zero_init:
            nn.init.zeros_(self.merge.weight)
            nn.init.zeros_(self.merge.bias)
        self.norm = nn.LayerNorm(d, eps=cfg.norm_eps, bias=False)
        self.level_emb = nn.Embedding(cfg.tree_levels, d)
        nn.init.normal_(self.level_emb.weight, std=0.02)
        self.levels = cfg.tree_levels

    def forward(self, leaves: torch.Tensor, mask: torch.Tensor):
        B, m, d = leaves.shape
        P = 1 << max(1, (m - 1).bit_length())  # next power of two >= m (at least 2)
        x = F.pad(leaves, (0, 0, 0, P - m))
        valid = F.pad(mask, (0, P - m), value=False)
        nodes, node_masks = [], []
        level = 0
        while x.shape[1] > 1:
            l, r = x[:, 0::2], x[:, 1::2]
            vl, vr = valid[:, 0::2], valid[:, 1::2]
            merged = self.norm(l + r + self.merge(torch.cat((l, r, l * r, (l - r).abs()), dim=-1)))
            merged = merged + self.level_emb.weight[min(level, self.levels - 1)]
            both = (vl & vr)[..., None]
            only_l = (vl & ~vr)[..., None]
            x = torch.where(both, merged, torch.where(only_l, l, r))
            valid = vl | vr
            keep = (vl & vr)  # only true merges are new information
            nodes.append(x)
            node_masks.append(keep)
            level += 1
        root = x[:, 0]
        if nodes:
            nodes_t = torch.cat(nodes, dim=1)
            masks_t = torch.cat(node_masks, dim=1)
        else:
            nodes_t = leaves[:, :0]
            masks_t = mask[:, :0]
        return nodes_t, masks_t, root


# ------------------------------------------------------------------ model
@dataclass
class Batch:
    """Padded tensors for a batch of states and questions (one encoder sequence per question)."""

    chunk_ids: torch.Tensor  # (C, Lc)
    chunk_mask: torch.Tensor  # (C, Lc) bool
    chunk_owner: torch.Tensor  # (C,) state index of each chunk
    chunk_index: torch.Tensor  # (C,) position of the chunk inside its state
    q_ids: torch.Tensor  # (Q, Lq)
    q_mask: torch.Tensor  # (Q, Lq) bool
    q_pos: torch.Tensor  # (Q, Lq) RoPE positions (option blocks share a base)
    q_markers: torch.Tensor  # (Q, K) [MASK] index of each option (0 where padded)
    q_block: torch.Tensor  # (Q, Lq) 0 = prefix, k+1 = option k, -1 = padding
    q_state: torch.Tensor  # (Q,) state index per question
    q_type: torch.Tensor  # (Q,)
    q_count: torch.Tensor  # (Q,) number of options

    def to(self, device):
        return Batch(**{k: v.to(device) for k, v in self.__dict__.items()})

    @property
    def n_states(self) -> int:
        return int(self.chunk_owner.max()) + 1

    @property
    def n_questions(self) -> int:
        return int(self.q_type.shape[0])


def _pad(seqs, L, value):
    out = torch.full((len(seqs), L), value, dtype=torch.long)
    for i, s in enumerate(seqs):
        out[i, : len(s)] = torch.tensor(s, dtype=torch.long)
    return out


def collate(states: Sequence[EncodedState], questions: Sequence[EncodedQuestion], pad_id: int, q_state: Sequence[int] | None = None) -> Batch:
    """Build a Batch; `q_state[i]` is the state index of question i (default: question i -> state i)."""
    if q_state is None:
        q_state = list(range(len(questions)))
    chunk_ids, chunk_owner, chunk_index = [], [], []
    for s, st in enumerate(states):
        for i, c in enumerate(st.chunks):
            chunk_ids.append(c)
            chunk_owner.append(s)
            chunk_index.append(i)
    cids = _pad(chunk_ids, max(len(c) for c in chunk_ids), pad_id)
    Lq = max(len(q.ids) for q in questions)
    Lq = ((Lq + 7) // 8) * 8
    qids = _pad([q.ids for q in questions], Lq, pad_id)
    qmask = torch.zeros_like(qids, dtype=torch.bool)
    for i, q in enumerate(questions):
        qmask[i, : len(q.ids)] = True
    qpos = _pad([q.position_ids for q in questions], Lq, 0)
    qblock = _pad([q.blocks if q.blocks is not None else [0] * len(q.ids) for q in questions], Lq, -1)
    K = max(q.n_options for q in questions)
    return Batch(
        chunk_ids=cids,
        chunk_mask=cids.ne(pad_id),
        chunk_owner=torch.tensor(chunk_owner),
        chunk_index=torch.tensor(chunk_index),
        q_ids=qids,
        q_mask=qmask,
        q_pos=qpos,
        q_markers=_pad([q.markers for q in questions], K, 0),
        q_block=qblock,
        q_state=torch.tensor(list(q_state)),
        q_type=torch.tensor([q.qtype for q in questions]),
        q_count=torch.tensor([q.n_options for q in questions]),
    )


class GrootDecisionModel(nn.Module):
    def __init__(self, config: GrootConfig, encoder: nn.Module):
        super().__init__()
        self.cfg = config
        self.encoder = encoder
        d = config.hidden_size
        if getattr(encoder.config, "hidden_size", d) != d:
            raise ValueError("config.hidden_size must match the backbone hidden size")
        if config.encoder_layers is not None and hasattr(encoder, "layers"):
            encoder.layers = encoder.layers[: config.encoder_layers]
            encoder.config.num_hidden_layers = config.encoder_layers
        self.seg_emb = nn.Embedding(config.max_chunks, d)
        nn.init.normal_(self.seg_emb.weight, std=0.02)
        # sinusoidal chunk positions generalise to any number of chunks; the learned table
        # stays in the state dict so older checkpoints load unchanged
        self.chunk_pos_scale = nn.Parameter(torch.tensor(0.02))
        self.qtype_emb = nn.Embedding(len(config.qtypes), d)
        nn.init.zeros_(self.qtype_emb.weight)
        self.level_emb = nn.Embedding(256, d)  # ordinal roles for score levels / noul outcomes
        nn.init.normal_(self.level_emb.weight, std=0.02)
        self.tree = TreeReducer(config)
        self.fusion = nn.ModuleList([FusionLayer(config) for _ in range(config.fusion_layers)])
        self.final_norm = nn.LayerNorm(d, eps=config.norm_eps, bias=False)
        dh = config.scorer_hidden or d
        self.scorer = nn.Sequential(nn.LayerNorm(d, eps=config.norm_eps), nn.Linear(d, dh), nn.GELU(), nn.Linear(dh, 1))
        self.conf_head = nn.Sequential(nn.Linear(2 * d + 4, config.conf_hidden), nn.GELU(), nn.Linear(config.conf_hidden, 1))
        self.register_buffer("temperature", torch.full((len(config.qtypes),), float(config.temperature_init)))
        if config.fusion_init_from_encoder:
            self._init_fusion_from_encoder()

    # ------------------------------------------------------------ building
    @classmethod
    def from_backbone(cls, config: GrootConfig, backbone: str | None = None) -> "GrootDecisionModel":
        from transformers import AutoModel

        kw = {}
        if config.global_attention:
            from transformers import AutoConfig

            n = AutoConfig.from_pretrained(backbone or config.backbone).num_hidden_layers
            kw["layer_types"] = ["full_attention"] * n
        encoder = AutoModel.from_pretrained(backbone or config.backbone, attn_implementation="sdpa", **kw)
        encoder.config.reference_compile = False
        return cls(config, encoder)

    @classmethod
    def tiny(cls, config: GrootConfig, vocab_size: int = 1024, layers: int = 2) -> "GrootDecisionModel":
        """A randomly initialised miniature (for tests and the Bend parity harness)."""
        from transformers import AutoConfig, AutoModel

        enc_cfg = AutoConfig.for_model(
            "modernbert",
            vocab_size=vocab_size,
            hidden_size=config.hidden_size,
            intermediate_size=config.fusion_intermediate,
            num_hidden_layers=layers,
            num_attention_heads=config.fusion_heads,
            max_position_embeddings=8192,
            global_attn_every_n_layers=3,
            local_attention=128,
            global_rope_theta=config.rope_theta,
            local_rope_theta=config.rope_theta,
            pad_token_id=0,
            cls_token_id=2,
            sep_token_id=1,
            mask_token_id=4,
            **({"layer_types": ["full_attention"] * layers} if config.global_attention else {}),
        )
        enc_cfg.reference_compile = False
        encoder = AutoModel.from_config(enc_cfg, attn_implementation="sdpa")
        cfg = GrootConfig.from_dict({**config.to_dict(), "encoder_layers": None})
        return cls(cfg, encoder)

    def _init_fusion_from_encoder(self) -> None:
        layers = getattr(self.encoder, "layers", None)
        if not layers:
            return
        src = list(layers)[-len(self.fusion) :]
        for fl, el in zip(self.fusion, src):
            try:
                fl.attn.Wqkv.weight.data.copy_(el.attn.Wqkv.weight.data)
                fl.attn.Wo.weight.data.copy_(el.attn.Wo.weight.data)
                fl.mlp.Wi.weight.data.copy_(el.mlp.Wi.weight.data)
                fl.mlp.Wo.weight.data.copy_(el.mlp.Wo.weight.data)
                if hasattr(el.attn_norm, "weight"):
                    fl.attn_norm.weight.data.copy_(el.attn_norm.weight.data)
                fl.mlp_norm.weight.data.copy_(el.mlp_norm.weight.data)
            except (AttributeError, RuntimeError):
                break  # shapes differ (custom backbone): keep random init

    @property
    def device(self) -> torch.device:
        return self.temperature.device

    # -------------------------------------------------------------- memory
    def sync_chunk_pos_trainable(self) -> None:
        """Freeze the parameters of the chunk-position scheme that is not in use (DDP needs every trainable param used)."""
        sinus = getattr(self.cfg, "chunk_pos", "learned") == "sinusoidal"
        self.seg_emb.weight.requires_grad_(not sinus)
        self.chunk_pos_scale.requires_grad_(sinus)

    def chunk_position(self, idx: torch.Tensor) -> torch.Tensor:
        if getattr(self.cfg, "chunk_pos", "learned") != "sinusoidal":
            return self.seg_emb(idx.clamp(max=self.seg_emb.num_embeddings - 1))
        d = self.cfg.hidden_size
        inv = torch.exp(torch.arange(0, d, 2, device=idx.device, dtype=torch.float32) * (-torch.log(torch.tensor(10000.0)) / d))
        ang = idx.float()[:, None] * inv[None, :]
        pe = torch.zeros(idx.shape[0], d, device=idx.device)
        pe[:, 0::2], pe[:, 1::2] = torch.sin(ang), torch.cos(ang)
        return (pe * self.chunk_pos_scale).to(self.seg_emb.weight.dtype)


    def encode_chunks(self, chunk_ids, chunk_mask):
        return self.encoder(input_ids=chunk_ids, attention_mask=chunk_mask.long()).last_hidden_state

    def build_memory(self, batch: Batch, n_examples: int):
        """State memory (tokens + tree nodes) for every example in the batch."""
        H = self.encode_chunks(batch.chunk_ids, batch.chunk_mask)  # (C, Lc, d)
        H = H + self.chunk_position(batch.chunk_index)[:, None, :]
        C, Lc, d = H.shape
        # per-example chunk counts
        counts = torch.bincount(batch.chunk_owner, minlength=n_examples)
        m = int(counts.max())
        # leaves: masked mean per chunk
        denom = batch.chunk_mask.sum(-1, keepdim=True).clamp_min(1)
        leaf = (H * batch.chunk_mask[..., None]).sum(1) / denom  # (C, d)
        leaves = H.new_zeros(n_examples, m, d)
        leaf_mask = torch.zeros(n_examples, m, dtype=torch.bool, device=H.device)
        leaves[batch.chunk_owner, batch.chunk_index] = leaf
        leaf_mask[batch.chunk_owner, batch.chunk_index] = True
        nodes, node_mask, root = self.tree(leaves, leaf_mask)
        # token memory: (B, m*Lc, d) laid out by chunk index
        tokens = H.new_zeros(n_examples, m, Lc, d)
        token_mask = torch.zeros(n_examples, m, Lc, dtype=torch.bool, device=H.device)
        tokens[batch.chunk_owner, batch.chunk_index] = H
        token_mask[batch.chunk_owner, batch.chunk_index] = batch.chunk_mask
        memory = torch.cat((tokens.view(n_examples, m * Lc, d), nodes), dim=1)
        memory_mask = torch.cat((token_mask.view(n_examples, m * Lc), node_mask), dim=1)
        return memory, memory_mask, root

    # ------------------------------------------------------------- decide
    def encode_questions(self, batch: Batch):
        """Early fusion: each question sequence (head + state head + option blocks) through the encoder."""
        blk = batch.q_block
        # query i may attend key j iff j is real and (j is prefix or j is in i's own option block)
        allowed = (blk[:, None, :] >= 0) & ((blk[:, None, :] == 0) | (blk[:, None, :] == blk[:, :, None]))
        mask4d = allowed[:, None]  # (Q, 1, Lq, Lq) bool, True = attend
        masks = {"full_attention": mask4d, "sliding_attention": mask4d}
        Hq = self.encoder(input_ids=batch.q_ids, attention_mask=masks, position_ids=batch.q_pos).last_hidden_state
        marker = Hq.gather(1, batch.q_markers[..., None].expand(-1, -1, Hq.shape[-1]))  # (Q, K, d)
        return marker, Hq[:, 0]

    def decide(self, batch: Batch, memory, memory_mask, root):
        """Set-level fusion of the options of every question against the state memory."""
        marker, cls = self.encode_questions(batch)
        Q, K, d = marker.shape
        slots = torch.arange(K, device=marker.device)
        valid = slots[None, :] < batch.q_count[:, None]
        ordinal = (batch.q_type != 0).to(marker.dtype)[:, None, None]  # score & noul keep their level roles
        marker = marker + ordinal * self.level_emb(slots.clamp(max=255))[None]
        S = torch.cat((cls[:, None] + self.qtype_emb(batch.q_type)[:, None], marker), dim=1)
        S_mask = torch.cat((torch.ones(Q, 1, dtype=torch.bool, device=marker.device), valid), dim=1)
        pos = torch.ones(Q, 1 + K, dtype=torch.long, device=marker.device)
        pos[:, 0] = 0  # the question token at 0, every option at 1: a set, not a list
        cos, sin = rope_cos_sin(pos, d // self.cfg.fusion_heads, self.cfg.rope_theta, S.dtype)
        mem, mem_mask, rt = memory[batch.q_state], memory_mask[batch.q_state], root[batch.q_state]
        for layer in self.fusion:
            S = layer(S, cos, sin, S_mask, mem, mem_mask)
        S = self.final_norm(S)
        logits = self.scorer(S[:, 1:]).squeeze(-1).float().masked_fill(~S_mask[:, 1:], NEG)
        probs = (logits / self.temperature[batch.q_type][:, None]).softmax(-1)
        top = probs.topk(min(2, probs.shape[1]), dim=-1).values
        if top.shape[1] == 1:
            top = torch.cat((top, torch.zeros_like(top)), dim=-1)
        count = batch.q_count.clamp_min(2).float()
        entropy = -(probs * probs.clamp_min(1e-9).log()).sum(-1) / count.log()
        feats = torch.stack((top[:, 0], top[:, 0] - top[:, 1], entropy, count / 255.0), dim=-1)
        conf_in = torch.cat((rt.float(), S[:, 0].float(), feats.detach()), dim=-1)
        conf_logit = self.conf_head(conf_in).squeeze(-1)
        return {"logits": logits, "probabilities": probs, "confidence_logit": conf_logit}

    def forward(self, batch: Batch):
        memory, memory_mask, root = self.build_memory(batch, batch.n_states)
        return self.decide(batch, memory, memory_mask, root)

    # ------------------------------------------------------------ persist
    def save_pretrained(self, directory: str | Path, tokenizer=None) -> None:
        root = Path(directory)
        root.mkdir(parents=True, exist_ok=True)
        self.cfg.save(root)
        self.encoder.config.save_pretrained(root / "encoder")
        if tokenizer is not None:
            tokenizer.save_pretrained(root / "tokenizer")
        state = {k: v.detach().cpu().contiguous() for k, v in self.state_dict().items()}
        save_file(state, str(root / "model.safetensors"), metadata={"format": "pt", "family": "groot", "version": "1"})
        (root / "calibration.json").write_text(json.dumps({"temperature": self.temperature.tolist()}, indent=2) + "\n")

    @classmethod
    def from_pretrained(cls, directory: str | Path, device: str | torch.device = "cpu") -> "GrootDecisionModel":
        from transformers import AutoConfig, AutoModel

        root = Path(directory)
        cfg = GrootConfig.load(root)
        enc_cfg = AutoConfig.from_pretrained(root / "encoder")
        enc_cfg.reference_compile = False
        encoder = AutoModel.from_config(enc_cfg, attn_implementation="sdpa")
        cfg = GrootConfig.from_dict({**cfg.to_dict(), "encoder_layers": None, "fusion_init_from_encoder": False})
        model = cls(cfg, encoder)
        missing, unexpected = model.load_state_dict(load_file(str(root / "model.safetensors")), strict=False)
        if unexpected or set(missing) - {"chunk_pos_scale"}:
            raise RuntimeError(f"checkpoint mismatch: missing={missing} unexpected={unexpected}")
        calib = root / "calibration.json"
        if calib.exists():
            temps = json.loads(calib.read_text()).get("temperature")
            if temps:
                model.temperature.copy_(torch.tensor(temps, dtype=model.temperature.dtype))
        return model.to(device).eval()

    def count_parameters(self) -> dict[str, int]:
        enc = sum(p.numel() for p in self.encoder.parameters())
        total = sum(p.numel() for p in self.parameters())
        emb = self.encoder.get_input_embeddings().weight.numel()
        return {"total": total, "encoder": enc, "embeddings": emb, "head": total - enc}