File size: 16,355 Bytes
83112d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Training utilities: FastText init, FORGETTER, checkpointing, optimizers, schedulers.
"""

import math
import copy
from pathlib import Path
from typing import Optional

import torch
import torch.nn as nn
from torch.optim import Adam, AdamW
from torch.optim.lr_scheduler import LambdaLR


ROOT = Path(__file__).resolve().parent.parent.parent


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# FastText Embedding Initialization
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

def init_embeddings_fasttext(model, tokenizer, fasttext_path: str):
    """
    Initialize model's word embeddings from pre-trained FastText vectors.
    +2-3pp BLiMP in 2025 papers.

    Args:
        model: model with .word_embedding or .embedding.word_embedding
        tokenizer: HuggingFace tokenizer
        fasttext_path: path to FastText .bin file
    """
    try:
        from gensim.models import FastText
    except ImportError:
        print("WARNING: gensim not installed, skipping FastText init. pip install gensim")
        return

    print(f"Loading FastText model from {fasttext_path}...")
    ft_model = FastText.load(fasttext_path)

    # Find the embedding layer
    if hasattr(model, 'word_embedding'):
        embed = model.word_embedding
    elif hasattr(model, 'embedding') and hasattr(model.embedding, 'word_embedding'):
        embed = model.embedding.word_embedding
    else:
        print("WARNING: Could not find word_embedding layer, skipping FastText init")
        return

    vocab = tokenizer.get_vocab()
    hidden_size = embed.weight.shape[1]
    ft_dim = ft_model.wv.vector_size

    # Project if dimensions don't match
    if ft_dim != hidden_size:
        print(f"  FastText dim={ft_dim} != hidden_size={hidden_size}, using linear projection")
        proj = torch.randn(ft_dim, hidden_size) * (1.0 / math.sqrt(ft_dim))
    else:
        proj = None

    initialized = 0
    with torch.no_grad():
        for token, idx in vocab.items():
            # Clean BPE token
            clean = token.replace('ฤ ', ' ').replace('โ–', ' ').strip()
            if not clean:
                continue
            try:
                vec = torch.tensor(ft_model.wv[clean], dtype=torch.float32)
                if proj is not None:
                    vec = vec @ proj
                embed.weight[idx] = vec
                initialized += 1
            except KeyError:
                pass

    print(f"  Initialized {initialized}/{len(vocab)} token embeddings from FastText")


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# FORGETTER: Reset optimizer state each epoch
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

def reset_optimizer_state(optimizer):
    """
    FORGETTER Sleep 1 (Light Sleep): Reset optimizer state (momentum, variance).
    Model weights are kept. From Yamamoto & Miura 2025.
    """
    for group in optimizer.param_groups:
        for p in group['params']:
            if p in optimizer.state:
                optimizer.state[p] = {}
    print("  FORGETTER Sleep 1: optimizer state reset")


def deep_sleep(model):
    """
    FORGETTER Sleep 2 (Deep Sleep): Reinitialize all model state EXCEPT weights.
    Saves weights, constructs fresh model, loads weights back.
    From Yamamoto & Miura 2025 โ€” used for the final epoch.

    Returns a new model instance with same weights but fresh buffers/state.
    """
    state_dict = {k: v.clone() for k, v in model.state_dict().items()
                  if 'weight' in k or 'bias' in k}
    # Re-initialize model
    device = next(model.parameters()).device
    model_class = type(model)
    # This requires the model to store its init args โ€” caller should handle
    print("  FORGETTER Sleep 2: deep sleep โ€” reinitialize model, keep weights")
    return state_dict


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Checkpoint Management
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

def save_checkpoint(model, optimizer, epoch: int, step: int, loss: float,
                    save_dir: str, cfg=None):
    """Save training checkpoint."""
    save_path = Path(save_dir)
    save_path.mkdir(parents=True, exist_ok=True)

    ckpt = {
        'epoch': epoch,
        'step': step,
        'loss': loss,
        'model_state_dict': model.state_dict(),
        'optimizer_state_dict': optimizer.state_dict(),
    }
    if cfg is not None:
        from .config import config_to_dict
        ckpt['config'] = config_to_dict(cfg)

    path = save_path / f"checkpoint_epoch{epoch}.pt"
    torch.save(ckpt, path)
    print(f"  Saved checkpoint: {path}")
    return path


def load_checkpoint(model, optimizer, checkpoint_path: str):
    """Load training checkpoint."""
    ckpt = torch.load(checkpoint_path, map_location='cpu')
    model.load_state_dict(ckpt['model_state_dict'])
    if optimizer is not None:
        optimizer.load_state_dict(ckpt['optimizer_state_dict'])
    print(f"  Loaded checkpoint from epoch {ckpt['epoch']}, step {ckpt['step']}")
    return ckpt['epoch'], ckpt['step']


def average_checkpoints(checkpoint_paths: list, model) -> dict:
    """
    Average multiple checkpoint model weights.
    Used as final model for evaluation.

    Args:
        checkpoint_paths: list of checkpoint file paths
        model: model instance (for structure reference)

    Returns:
        averaged state_dict
    """
    print(f"Averaging {len(checkpoint_paths)} checkpoints...")

    avg_state = None
    for i, path in enumerate(checkpoint_paths):
        ckpt = torch.load(path, map_location='cpu')
        state = ckpt['model_state_dict']

        if avg_state is None:
            avg_state = {k: v.clone().float() for k, v in state.items()}
        else:
            for k in avg_state:
                avg_state[k] += state[k].float()

    # Divide by number of checkpoints
    n = len(checkpoint_paths)
    for k in avg_state:
        avg_state[k] /= n

    model.load_state_dict(avg_state)
    print(f"  Checkpoint averaging complete")
    return avg_state


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Learning Rate Scheduler
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

def get_cosine_schedule_with_warmup(optimizer, num_warmup_steps: int,
                                      num_training_steps: int,
                                      min_lr_ratio: float = 0.1,
                                      num_cooldown_steps: int = 0):
    """
    Cosine annealing with linear warmup and optional linear cooldown.
    Matches official GPT-BERT scheduler (warmup 1.6%, cooldown 1.6%, min_lr=0.1).
    """

    def lr_lambda(current_step):
        # Warmup phase
        if current_step < num_warmup_steps:
            return float(current_step) / float(max(1, num_warmup_steps))
        # Cooldown phase
        if num_cooldown_steps > 0 and current_step > num_training_steps - num_cooldown_steps:
            remaining = num_training_steps - current_step
            return max(0.0, min_lr_ratio * float(remaining) / float(max(1, num_cooldown_steps)))
        # Cosine phase
        progress = float(current_step - num_warmup_steps) / float(
            max(1, num_training_steps - num_warmup_steps - num_cooldown_steps))
        return max(min_lr_ratio, 0.5 * (1.0 + math.cos(math.pi * progress)))

    return LambdaLR(optimizer, lr_lambda)


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Optimizer Factory
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

class _CombinedOptimizer(torch.optim.Optimizer):
    """Wraps two optimizers (e.g., Muon for 2D + AdamW for 1D) into one.

    Inherits from Optimizer so LR schedulers accept it via isinstance() check.
    We initialize the base class with a dummy param to satisfy the non-empty check,
    then replace param_groups with the real ones from both sub-optimizers.
    """

    def __init__(self, opt1, opt2):
        # Create a dummy param to satisfy Optimizer.__init__'s non-empty check
        dummy = torch.nn.Parameter(torch.zeros(1), requires_grad=False)
        super().__init__([dummy], defaults={"lr": 0.0})
        # Now replace with real param_groups from sub-optimizers
        self.param_groups = list(opt1.param_groups)
        self.opt1 = opt1
        self.opt2 = opt2

    def step(self, closure=None):
        # Muon.step() doesn't accept closure argument
        self.opt1.step()
        self.opt2.step()

    def zero_grad(self, set_to_none=False):
        self.opt1.zero_grad(set_to_none=set_to_none)
        self.opt2.zero_grad(set_to_none=set_to_none)

    def state_dict(self):
        return {"opt1": self.opt1.state_dict(), "opt2": self.opt2.state_dict()}

    def load_state_dict(self, state_dict):
        self.opt1.load_state_dict(state_dict["opt1"])
        self.opt2.load_state_dict(state_dict["opt2"])


def build_optimizer(model, opt_cfg, training_cfg):
    """Build optimizer from config."""

    # Normalize optimizer type to lowercase
    opt_cfg.type = opt_cfg.type.lower()

    # Separate weight decay groups (matching official GPT-BERT)
    no_decay = {"bias", "layer_norm"}
    params = [
        {
            "params": [p for n, p in model.named_parameters()
                      if not any(nd in n for nd in no_decay) and p.requires_grad],
            "weight_decay": training_cfg.weight_decay,
        },
        {
            "params": [p for n, p in model.named_parameters()
                      if any(nd in n for nd in no_decay) and p.requires_grad],
            "weight_decay": 0.0,
        },
    ]

    # โ”€โ”€ Adam (Kingma & Ba, 2014) โ”€โ”€
    # ๆœ€็ปๅ…ธ็š„่‡ช้€‚ๅบ”ๅญฆไน ็އไผ˜ๅŒ–ๅ™จใ€‚ไธบๆฏไธชๅ‚ๆ•ฐ็ปดๆŠคไธ€้˜ถๅŠจ้‡(ๆขฏๅบฆๅ‡ๅ€ผ m)ๅ’ŒไบŒ้˜ถๅŠจ้‡
    # (ๆขฏๅบฆๅนณๆ–นๅ‡ๅ€ผ v)๏ผŒ็”จ m/โˆšv ่‡ช้€‚ๅบ”่ฐƒๆ•ดๆฏไธชๅ‚ๆ•ฐ็š„ๆ›ดๆ–ฐๆญฅ้•ฟใ€‚
    # ไผ˜็‚น๏ผšๆ”ถๆ•›ๅฟซใ€ๅฏน่ถ…ๅ‚ๆ•ฐไธๆ•ๆ„Ÿ๏ผ›็ผบ็‚น๏ผšL2ๆญฃๅˆ™ๅŒ–ไธŽ่‡ช้€‚ๅบ”ๅญฆไน ็އ่€ฆๅˆ๏ผŒ
    # ๅฏผ่‡ดๆญฃๅˆ™ๅŒ–ๆ•ˆๆžœ่ขซๅ‰Šๅผฑ๏ผˆ่ฟ™ๆญฃๆ˜ฏ AdamW ่ฆ่งฃๅ†ณ็š„้—ฎ้ข˜๏ผ‰ใ€‚
    if opt_cfg.type == "adam":
        optimizer = Adam(
            params,
            lr=training_cfg.learning_rate,
            betas=opt_cfg.betas,
            eps=opt_cfg.eps,
        )

    # โ”€โ”€ AdamW (Loshchilov & Hutter, 2017/2019) โ”€โ”€
    # Adam ็š„ไฟฎๆญฃ็‰ˆๆœฌ๏ผŒๅฐ†ๆƒ้‡่กฐๅ‡(weight decay)ไปŽๆขฏๅบฆๆ›ดๆ–ฐไธญ่งฃ่€ฆๅ‡บๆฅใ€‚
    # ๅŽŸๅง‹ Adam ๆŠŠ L2 ๆญฃๅˆ™ๅŠ ๅœจๆขฏๅบฆไธŠๅ†ๅš่‡ช้€‚ๅบ”็ผฉๆ”พ๏ผŒๅฏผ่‡ดๅคงๆขฏๅบฆๅ‚ๆ•ฐ็š„ๆญฃๅˆ™ๅŒ–
    # ่ขซๅ‰Šๅผฑ๏ผ›AdamW ๆ”นไธบๅœจๅ‚ๆ•ฐๆ›ดๆ–ฐๅŽ็›ดๆŽฅไน˜ไปฅ่กฐๅ‡็ณปๆ•ฐ (ฮธ *= 1 - ฮปยทlr)๏ผŒ
    # ไฝฟๆญฃๅˆ™ๅŒ–ๅผบๅบฆไธŽ่‡ช้€‚ๅบ”ๅญฆไน ็އๆ— ๅ…ณใ€‚ๅ‡ ไนŽๆ‰€ๆœ‰็Žฐไปฃ Transformer ่ฎญ็ปƒ็š„ๆ ‡้…ใ€‚
    elif opt_cfg.type == "adamw":
        optimizer = AdamW(
            params,
            lr=training_cfg.learning_rate,
            betas=opt_cfg.betas,
            eps=opt_cfg.eps,
        )

    # โ”€โ”€ LAMB (You et al., 2019) โ”€โ”€
    # Layer-wise Adaptive Moments optimizer๏ผŒไธ“ไธบๅคง batch ่ฎญ็ปƒ่ฎพ่ฎกใ€‚
    # ๅœจ AdamW ๅŸบ็ก€ไธŠๅขžๅŠ ไบ†้€ๅฑ‚็š„ไฟก่ต–ๅŸŸๅฝ’ไธ€ๅŒ–(trust ratio)๏ผš
    # ๆฏๅฑ‚็š„ๆ›ดๆ–ฐๆญฅ้•ฟ = โ€–ฮธ_layerโ€– / โ€–update_layerโ€–๏ผŒไฝฟๅพ—ไธๅŒๅฑ‚็š„ๅ‚ๆ•ฐๆ›ดๆ–ฐ
    # ๅน…ๅบฆไธŽ่ฏฅๅฑ‚ๆƒ้‡่Œƒๆ•ฐๆˆๆญฃๆฏ”๏ผŒ้ฟๅ…ๅคง batch ไธ‹ๆŸไบ›ๅฑ‚ๆ›ดๆ–ฐ่ฟ‡ๅคง/่ฟ‡ๅฐใ€‚
    # GPT-BERT ๅฎ˜ๆ–นไฝฟ็”จ LAMB + lr=1.41e-2๏ผŒๅœจ BabyLM ่ง„ๆจกไธ‹ๆ•ˆๆžœไผ˜ไบŽ AdamWใ€‚
    elif opt_cfg.type == "lamb":
        try:
            from torch_optimizer import Lamb
            optimizer = Lamb(
                params,
                lr=training_cfg.learning_rate,
                betas=opt_cfg.betas,
                eps=opt_cfg.eps,
            )
        except ImportError:
            print("WARNING: torch-optimizer not installed, falling back to AdamW")
            optimizer = AdamW(params, lr=training_cfg.learning_rate,
                            betas=opt_cfg.betas, eps=opt_cfg.eps)

    # โ”€โ”€ Muon (Jordan et al., 2024) โ”€โ”€
    # Momentum + Orthogonalization ไผ˜ๅŒ–ๅ™จ๏ผŒๅฎŒๅ…จไธๅŒไบŽ Adam ็ณปๅˆ—็š„ๆ€่ทฏใ€‚
    # ๆ ธๅฟƒๆœบๅˆถ๏ผšๅ…ˆ็”จ Nesterov ๅŠจ้‡่ฎก็ฎ—ๆ›ดๆ–ฐๆ–นๅ‘๏ผŒๅ†้€š่ฟ‡ Newton-Schulz ่ฟญไปฃ
    # ๅฏนๆ›ดๆ–ฐ็Ÿฉ้˜ตๅšๆญฃไบคๅŒ–๏ผˆ่ฟ‘ไผผ SVD ๅŽๅฐ†ๆ‰€ๆœ‰ๅฅ‡ๅผ‚ๅ€ผ็ฝฎไธบ1๏ผ‰๏ผŒ็กฎไฟๆ›ดๆ–ฐๆ–นๅ‘ๅœจ
    # ๅ‚ๆ•ฐ็ฉบ้—ดไธญๅ„ๆ–นๅ‘ไธŠๅ‡ๅŒ€ๅˆ†ๅธƒใ€‚ไธ้œ€่ฆ็ปดๆŠคไบŒ้˜ถๅŠจ้‡๏ผŒๅ†…ๅญ˜ๅ ็”จๆ›ดไฝŽใ€‚
    # ๅœจๅฐๆจกๅž‹ไธŠ่กจ็Žฐๅ‡บ่‰ฒ๏ผŒไฝ†้œ€่ฆไธ“้—จ็š„่ถ…ๅ‚ๆ•ฐ่ฐƒไผ˜๏ผˆไธไฝฟ็”จ betas/eps๏ผ‰ใ€‚
    elif opt_cfg.type == "muon":
        try:
            from muon import Muon
            import torch.distributed as dist
            # Muon uses distributed communication internally; init if needed
            if not dist.is_initialized():
                dist.init_process_group(backend="gloo", init_method="tcp://127.0.0.1:29500",
                                        rank=0, world_size=1)
            # Muon only handles 2D+ params (matrices via Newton-Schulz orthogonalization).
            # 1D params (bias, LayerNorm) use a separate AdamW.
            # We wrap both into a _CombinedOptimizer so the training loop can call
            # optimizer.step() and optimizer.zero_grad() as usual.
            muon_params = [p for n, p in model.named_parameters() if p.requires_grad and p.ndim >= 2]
            adamw_1d = [p for n, p in model.named_parameters() if p.requires_grad and p.ndim < 2]
            muon_opt = Muon(muon_params, lr=training_cfg.learning_rate)
            adamw_opt = AdamW([{"params": adamw_1d, "weight_decay": 0.0}],
                              lr=training_cfg.learning_rate * 0.1,
                              betas=opt_cfg.betas, eps=opt_cfg.eps)
            optimizer = _CombinedOptimizer(muon_opt, adamw_opt)
            print(f"  Muon: {len(muon_params)} matrix params + {len(adamw_1d)} scalar params (AdamW)")
        except ImportError:
            print("WARNING: muon not installed, falling back to AdamW")
            optimizer = AdamW(params, lr=training_cfg.learning_rate,
                            betas=opt_cfg.betas, eps=opt_cfg.eps)

    else:
        raise ValueError(f"Unknown optimizer type: {opt_cfg.type}")

    return optimizer


# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# HuggingFace Model Export (for evaluation pipeline)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

def export_for_eval(model, tokenizer, cfg, export_dir: str):
    """
    Export trained model in HuggingFace format for the evaluation pipeline.
    Creates a self-contained directory loadable via:
        AutoModelForCausalLM.from_pretrained(path, trust_remote_code=True)

    Uses the hf_export module which handles all 5 architectures.
    """
    from .config import config_to_dict
    from .hf_export.export import export_state_dict

    cfg_dict = config_to_dict(cfg)
    model_cfg = cfg_dict.get("model", {})
    arch = model_cfg.get("arch", "gpt_bert")
    tokenizer_dir = cfg_dict.get("data", {}).get("tokenizer_path", "models/tokenizer")

    export_state_dict(
        state_dict=model.state_dict(),
        arch=arch,
        model_cfg=model_cfg,
        output_dir=export_dir,
        tokenizer_dir=tokenizer_dir,
    )