File size: 19,619 Bytes
5ef3875
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Tests for YCode-LM v3: the 40M preset and the Muon optimizer."""

import pytest

torch = pytest.importorskip("torch")
pytest.importorskip("numpy")

from ycode.lm.model import GPT, PRESETS, GPTConfig  # noqa: E402
from ycode.lm.optim import Muon, build_optimizer, orthogonalize  # noqa: E402
from ycode.lm.train import TrainConfig, load_checkpoint, train  # noqa: E402


def test_v3_preset_is_about_40m_params():
    cfg = GPTConfig(vocab_size=8192, **PRESETS["v3-40m"])
    params = GPT(cfg).num_params()
    assert 39e6 <= params <= 42e6
    assert cfg.arch_version == 3 and cfg.block_size == 1024 and cfg.kv_heads == 2


@pytest.mark.parametrize("shape", [(64, 32), (32, 64), (48, 48)])
def test_orthogonalize_gives_near_unit_singular_values(shape):
    torch.manual_seed(0)
    s = torch.linalg.svdvals(orthogonalize(torch.randn(*shape)).float())
    # Muon's 5-step quintic iteration maps singular values into roughly [0.7, 1.2] by design.
    assert s.max() < 1.3 and s.min() > 0.5


def test_orthogonalize_improves_conditioning():
    torch.manual_seed(0)
    u, _, v = torch.linalg.svd(torch.randn(48, 48))
    g = u @ torch.diag(torch.logspace(-1, 1, 48)) @ v  # condition number 100
    s = torch.linalg.svdvals(orthogonalize(g).float())
    assert s.max() / s.min() < 5


def test_muon_rejects_non_matrix_params():
    with pytest.raises(ValueError):
        Muon([torch.nn.Parameter(torch.ones(4))])


def test_build_optimizer_splits_parameters():
    model = GPT(GPTConfig(vocab_size=100, block_size=16, n_layer=2, n_head=2, n_embd=16))
    opt = build_optimizer(model, kind="muon", lr=1e-3, muon_lr=0.02, weight_decay=0.1)
    muon, adam = opt.optimizers
    muon_ids = {id(p) for g in muon.param_groups for p in g["params"]}
    assert id(model.embed.weight) not in muon_ids  # embeddings stay on AdamW
    assert id(model.blocks[0].attn.qkv.weight) in muon_ids
    total = sum(p.numel() for g in opt.param_groups for p in g["params"])
    assert total == model.num_params()
    opt.set_lr_scale(0.5)
    assert muon.param_groups[0]["lr"] == pytest.approx(0.01) and adam.param_groups[0]["lr"] == pytest.approx(5e-4)
    with pytest.raises(ValueError):
        build_optimizer(model, kind="sgd", lr=1e-3, muon_lr=0.02, weight_decay=0.0)


def test_muon_step_reduces_loss():
    torch.manual_seed(0)
    model = GPT(GPTConfig(vocab_size=50, block_size=16, n_layer=2, n_head=2, n_embd=32))
    opt = build_optimizer(model, kind="muon", lr=3e-3, muon_lr=0.02, weight_decay=0.0)
    x = torch.randint(0, 50, (8, 16))
    first = None
    for _ in range(30):
        _, loss = model(x, x)
        first = first if first is not None else loss.item()
        loss.backward()
        opt.step()
        opt.zero_grad()
    assert loss.item() < first * 0.7


def _cfg(**kw):
    base = dict(preset="v3-tiny", model_overrides={"block_size": 32}, batch_size=4, max_steps=20, warmup_steps=2,
                eval_interval=20, eval_iters=2, log_interval=1000, device="cpu", precision="fp32",
                optimizer="muon", lr=3e-3)
    base.update(kw)
    return TrainConfig(**base)


def test_v3_training_with_muon_and_resume(data_dir, tmp_path):
    logs = []
    train(data_dir, tmp_path / "m", _cfg(max_steps=10), log=logs.append)
    assert any("YCode-LM v3" in line and "optimizer muon" in line for line in logs)
    summary = train(data_dir, tmp_path / "m", _cfg(max_steps=14, resume=True), log=logs.append)
    assert summary["steps"] == 14
    assert not any("starting it fresh" in line for line in logs)  # Muon state resumed
    model, _, payload = load_checkpoint(tmp_path / "m")
    assert payload["version"] == 3


def test_resume_with_different_optimizer_is_handled(data_dir, tmp_path):
    logs = []
    train(data_dir, tmp_path / "m", _cfg(max_steps=4, optimizer="adamw"), log=logs.append)
    train(data_dir, tmp_path / "m", _cfg(max_steps=6, resume=True), log=logs.append)
    assert any("starting it fresh" in line for line in logs)


def test_cli_defaults_to_muon_for_v3(data_dir, tmp_path, capsys):
    from ycode.lm import cli

    assert cli.main(["train", "--data", str(data_dir), "--out", str(tmp_path / "m"), "--preset", "v3-tiny",
                     "--context", "32", "--steps", "2", "--batch-size", "2", "--device", "cpu"]) == 0
    assert "optimizer muon" in capsys.readouterr().out


def test_segmented_training_matches_schedule(data_dir, tmp_path):
    from ycode.lm.train import lr_at

    logs = []
    first = train(data_dir, tmp_path / "m", _cfg(max_steps=12, until_step=5), log=logs.append)
    assert first["steps"] == 5
    second = train(data_dir, tmp_path / "m", _cfg(max_steps=12, resume=True), log=logs.append)
    assert second["steps"] == 12
    cfg = _cfg(max_steps=12)
    assert lr_at(5, cfg) == pytest.approx(cfg.lr)  # segment 1 ended mid-run at full LR, not decayed


def test_sft_resume_continues_in_out_dir(data_dir, tmp_path):
    train(data_dir, tmp_path / "base", _cfg(max_steps=4), log=lambda *_: None)
    sft = dict(stage="sft", init_from=str(tmp_path / "base"), optimizer="adamw")
    train(data_dir, tmp_path / "chat", _cfg(max_steps=8, until_step=3, **sft), log=lambda *_: None)
    logs = []
    summary = train(data_dir, tmp_path / "chat", _cfg(max_steps=8, resume=True, **sft), log=logs.append)
    assert summary["steps"] == 8
    assert any("chat" in line and "step 3" in line for line in logs)  # resumed the SFT run, not the base


def test_save_interval_checkpoints_between_evals(data_dir, tmp_path):
    import json

    logs = []
    train(data_dir, tmp_path / "m", _cfg(max_steps=20, until_step=7, eval_interval=20, save_interval=3),
          log=logs.append)
    assert [line for line in logs if "checkpoint saved" in line] == [
        "checkpoint saved at step 3", "checkpoint saved at step 6"]
    assert json.loads((tmp_path / "m" / "info.json").read_text())["step"] == 7  # final save at the segment end


def test_resume_without_checkpoint_starts_fresh(data_dir, tmp_path):
    logs = []
    summary = train(data_dir, tmp_path / "new", _cfg(max_steps=3, resume=True), log=logs.append)
    assert summary["steps"] == 3 and any("starting a new run" in line for line in logs)


def test_export_bf16_is_smaller_and_loads(data_dir, tmp_path):
    from ycode.lm import cli
    from ycode.lm.generate import LocalLM

    train(data_dir, tmp_path / "m", _cfg(max_steps=3), log=lambda *_: None)
    assert cli.main(["export", "--model", str(tmp_path / "m"), "--out", str(tmp_path / "slim")]) == 0
    full, slim = tmp_path / "m" / "model.pt", tmp_path / "slim" / "model.pt"
    assert slim.stat().st_size < full.stat().st_size * 0.6
    original, _, _ = load_checkpoint(tmp_path / "m")
    exported, _, payload = load_checkpoint(tmp_path / "slim")
    assert payload["exported_dtype"] == "bf16"
    state = torch.load(slim, weights_only=True)["model"]  # the file itself (the loader drops its copy)
    assert state["embed.weight"].data_ptr() == state["head.weight"].data_ptr()  # tied weights stored once
    import json
    assert json.loads((tmp_path / "slim" / "info.json").read_text())["params"] == original.num_params()
    assert next(exported.parameters()).dtype == torch.float32  # converted back on load
    x = torch.randint(0, original.cfg.vocab_size, (1, 8))
    assert torch.allclose(original(x, x)[0], exported(x, x)[0], atol=0.1)
    assert isinstance(LocalLM(tmp_path / "slim", device="cpu").chat([("user", "hi")], max_new_tokens=3), str)
    assert cli.main(["export", "--model", str(tmp_path / "nope"), "--out", str(tmp_path / "x")]) == 1


def test_no_repeat_ngram_blocks_loops():
    torch.manual_seed(0)
    model = GPT(GPTConfig(vocab_size=20, block_size=128, n_layer=1, n_head=2, n_embd=16))
    plain = model.generate(torch.tensor([[1, 2, 3]]), 60, temperature=0)[0, 3:].tolist()
    blocked = model.generate(torch.tensor([[1, 2, 3]]), 60, temperature=0, no_repeat_ngram=4)[0, 3:].tolist()

    def repeats(seq):
        grams = [tuple(seq[i:i + 4]) for i in range(len(seq) - 3)]
        return len(grams) - len(set(grams))

    assert repeats(plain) > 0  # an untrained model loops under greedy decoding
    assert repeats(blocked) == 0


def test_write_examples_are_compact_standalone_functions():
    import random

    from ycode.lm.data import extract_python_examples

    source = '''
def area(width, height):
    """Return the area of a rectangle.

    A much longer explanation that should not appear in answers.

    >>> area(2, 3)
    6
    """
    result = width * height
    return result


class Shape:
    def scale(self, factor):
        """Scale the shape by the given factor."""
        self.size = self.size * factor
        return self.size
'''
    examples = []
    for seed in range(5):
        examples += extract_python_examples(source, random.Random(seed))
    writes = [e for e in examples if e.response.startswith("```python") and "bug" not in e.response.lower()]
    assert writes and all("area" in e.response for e in writes)  # no method `scale` in write answers
    for e in writes:
        assert '"""Return the area of a rectangle."""' in e.response
        assert ">>>" not in e.response and "longer explanation" not in e.response


def test_prepare_sft_only_reuses_tokenizer(data_dir, tmp_path):
    from ycode.lm import cli
    from ycode.lm.tokenizer import BPETokenizer

    src = tmp_path / "src"
    src.mkdir()
    (src / "m.py").write_text('def area(w, h):\n    """Return the area of a rectangle."""\n'
                              '    if w < 0:\n        return 0\n    return w * h\n')
    out = tmp_path / "sft"
    assert cli.main(["prepare", "--source", str(src), "--out", str(out), "--sft-only"]) == 2  # needs --tokenizer
    assert cli.main(["prepare", "--source", str(src), "--out", str(out), "--sft-only",
                     "--tokenizer", str(data_dir / "tokenizer.json")]) == 0
    assert BPETokenizer.load(out / "tokenizer.json").merges == BPETokenizer.load(data_dir / "tokenizer.json").merges
    assert (out / "sft_index.npy").is_file() and not (out / "train.bin").exists()


def test_insertion_plan():
    from ycode.lm.grow import insertion_plan

    assert sum(insertion_plan(13, 34)) == 21 and max(insertion_plan(13, 34)) - min(insertion_plan(13, 34)) <= 1
    assert insertion_plan(4, 4) == [0, 0, 0, 0]
    with pytest.raises(ValueError):
        insertion_plan(5, 3)


def test_grown_model_computes_the_same_function_and_trains(data_dir, tmp_path):
    from ycode.lm import cli

    torch.manual_seed(0)
    small = GPT(GPTConfig(vocab_size=120, block_size=32, n_layer=3, n_head=4, n_kv_head=2, n_embd=32,
                          qk_norm=True, arch_version=3)).eval()
    from ycode.lm.grow import grow_depth

    big = grow_depth(small, 8, arch_version=4).eval()
    assert big.cfg.n_layer == 8 and big.cfg.arch_version == 4 and big.num_params() > small.num_params()
    x = torch.randint(0, 120, (2, 16))
    assert torch.allclose(small(x, x)[0], big(x, x)[0], atol=1e-5)  # identical at step 0
    assert torch.equal(small.generate(x[:1], 6, temperature=0), big.generate(x[:1], 6, temperature=0))

    # CLI round trip, then continued pretraining from the grown checkpoint.
    train(data_dir, tmp_path / "v3", _cfg(max_steps=3), log=lambda *_: None)
    assert cli.main(["grow", "--model", str(tmp_path / "v3"), "--out", str(tmp_path / "v4"), "--layers", "5",
                     "--version", "4"]) == 0
    grown, _, payload = load_checkpoint(tmp_path / "v4")
    assert grown.cfg.n_layer == 5 and payload["stage"] == "grown"
    summary = train(data_dir, tmp_path / "v4b", _cfg(max_steps=3, init_from=str(tmp_path / "v4")),
                    log=lambda *_: None)
    assert summary["steps"] == 3


def test_v4_preset_is_about_100m_params():
    cfg = GPTConfig(vocab_size=8192, **PRESETS["v4-100m"])
    assert 99e6 <= GPT(cfg).num_params() <= 101e6 and cfg.arch_version == 4


def test_train_cli_accepts_init_from(data_dir, tmp_path):
    from ycode.lm import cli

    train(data_dir, tmp_path / "base", _cfg(max_steps=2), log=lambda *_: None)
    assert cli.main(["train", "--data", str(data_dir), "--init-from", str(tmp_path / "base"), "--out",
                     str(tmp_path / "more"), "--steps", "2", "--batch-size", "2", "--device", "cpu"]) == 0
    assert load_checkpoint(tmp_path / "more")[2]["step"] == 2


def test_sharded_export_round_trip(data_dir, tmp_path):
    from ycode.lm import cli

    train(data_dir, tmp_path / "m", _cfg(max_steps=2), log=lambda *_: None)
    assert cli.main(["export", "--model", str(tmp_path / "m"), "--out", str(tmp_path / "s"),
                     "--max-shard-mb", "0.05"]) == 0
    shards = sorted((tmp_path / "s").glob("model-*-of-*.pt"))
    assert len(shards) >= 3 and all(f.stat().st_size < 0.2e6 for f in shards)
    original, _, _ = load_checkpoint(tmp_path / "m")
    loaded, _, payload = load_checkpoint(tmp_path / "s")
    assert "head.weight" in payload["tied"]  # tied weights stored once
    x = torch.randint(0, original.cfg.vocab_size, (1, 8))
    assert torch.allclose(original(x, x)[0], loaded(x, x)[0], atol=0.1)
    # Re-exporting unsharded removes stale shards; a missing shard is a clear error.
    assert cli.main(["export", "--model", str(tmp_path / "m"), "--out", str(tmp_path / "s")]) == 0
    assert not list((tmp_path / "s").glob("model-*-of-*.pt"))
    assert cli.main(["export", "--model", str(tmp_path / "m"), "--out", str(tmp_path / "t"),
                     "--max-shard-mb", "0.05"]) == 0
    sorted((tmp_path / "t").glob("model-*-of-*.pt"))[0].unlink()
    with pytest.raises(FileNotFoundError, match="Missing model shard"):
        load_checkpoint(tmp_path / "t")


def test_width_growth_preserves_function_and_new_capacity_learns():
    from ycode.lm.grow import grow_depth, grow_width

    torch.manual_seed(0)
    small = GPT(GPTConfig(vocab_size=100, block_size=32, n_layer=2, n_head=4, n_kv_head=2, n_embd=32,
                          qk_norm=True, arch_version=4)).eval()
    with torch.no_grad():  # make norms non-trivial so the rescaling is really exercised
        for p in small.parameters():
            if p.dim() == 1:
                p.add_(torch.rand_like(p) * 0.5)
    wide = grow_width(small, 64, arch_version=5)
    assert (wide.cfg.n_embd, wide.cfg.n_head, wide.cfg.kv_heads, wide.cfg.arch_version) == (64, 8, 4, 5)
    x = torch.randint(0, 100, (2, 12))
    # Equal up to RMSNorm's tiny eps term, which cannot be rescaled exactly (~1e-4).
    assert torch.allclose(small(x, x)[0], wide.eval()(x, x)[0], atol=2e-3)
    both = grow_depth(wide, 5).eval()
    assert torch.allclose(small(x, x)[0], both(x, x)[0], atol=2e-3)
    assert torch.equal(small.generate(x[:1], 6, temperature=0), both.generate(x[:1], 6, temperature=0))

    # One training step must reach the new channels / heads / MLP units.
    wide.train()
    _, loss = wide(x, x)
    loss.backward()
    blk = wide.blocks[0]
    assert blk.attn.proj.weight.grad[32:].abs().sum() > 0          # writes into new channels
    assert blk.attn.proj.weight.grad[:, 32:].abs().sum() > 0       # reads new heads
    old_hidden = small.blocks[0].mlp.gate.weight.shape[0]
    assert blk.mlp.down.weight.shape[1] > old_hidden
    assert blk.mlp.down.weight.grad[:, old_hidden:].abs().sum() > 0  # reads new MLP units
    assert wide.embed.weight.grad[:, 32:].abs().sum() > 0


def test_grow_width_rejects_bad_shapes():
    from ycode.lm.grow import grow_width

    m = GPT(GPTConfig(vocab_size=50, block_size=16, n_layer=1, n_head=4, n_kv_head=2, n_embd=32, qk_norm=True))
    with pytest.raises(ValueError):
        grow_width(m, 16)          # shrinking
    with pytest.raises(ValueError):
        grow_width(m, 36)          # not a multiple of the head size (8)
    with pytest.raises(ValueError):
        grow_width(m, 64, new_heads=8, new_kv_heads=8)  # changes the query/kv ratio


def test_v5_presets_sizes():
    small = GPTConfig(vocab_size=8192, **PRESETS["v5-385m"])
    assert 380e6 <= GPT(small).num_params() <= 390e6 and small.arch_version == 5
    big = GPTConfig(vocab_size=8192, **PRESETS["v5-1.5b"])
    # Count without allocating 6 GB: per-layer parameters x layers + embedding.
    C, hd = big.n_embd, big.n_embd // big.n_head
    hidden = 64 * ((int(8 * C / 3) + 63) // 64)
    per_layer = C * (C + 2 * big.kv_heads * hd) + C * C + 3 * C * hidden + 2 * C + 2 * hd
    assert 1.45e9 <= big.n_layer * per_layer + 8192 * C + C <= 1.6e9


def test_grad_checkpointing_gives_same_loss_and_grads():
    torch.manual_seed(0)
    model = GPT(GPTConfig(vocab_size=50, block_size=16, n_layer=2, n_head=2, n_embd=16))
    x = torch.randint(0, 50, (2, 16))
    _, loss_a = model(x, x)
    loss_a.backward()
    grads_a = [p.grad.clone() for p in model.parameters()]
    model.zero_grad()
    model.grad_checkpoint = True
    _, loss_b = model(x, x)
    loss_b.backward()
    assert torch.allclose(loss_a, loss_b)
    assert all(torch.allclose(a, p.grad, atol=1e-6) for a, p in zip(grads_a, model.parameters()))


def test_loss_scaler_skips_overflow_and_adapts():
    from ycode.lm.train import LossScaler

    p = torch.nn.Parameter(torch.ones(3))
    s = LossScaler(init_scale=8.0, growth_interval=2)
    p.grad = torch.full((3,), 16.0)
    assert s.unscale_and_check([p]) and torch.equal(p.grad, torch.full((3,), 2.0))
    s.update(True)
    s.update(True)
    assert s.scale == 16.0  # grew after 2 good steps
    p.grad = torch.tensor([1.0, float("inf"), 0.0])
    assert not s.unscale_and_check([p])
    s.update(False)
    assert s.scale == 8.0 and s.skipped == 1


def test_fp16_training_runs_and_learns(data_dir, tmp_path):
    logs = []
    summary = train(data_dir, tmp_path / "m", _cfg(max_steps=25, precision="fp16", optimizer="adamw", lr=3e-3,
                                                    grad_checkpoint=True), log=logs.append)
    assert summary["precision"] == "fp16" and summary["steps"] == 25
    assert any("precision fp16" in line and "grad checkpointing" in line for line in logs)
    assert summary["final"]["train"] < 5.5  # untrained ~ ln(400) = 6.0


def test_distributed_training_two_processes(data_dir, tmp_path):
    """Real multi-process data-parallel training (gloo on CPU), as used on 2x T4."""
    import subprocess
    import sys

    out = tmp_path / "ddp"
    cmd = [sys.executable, "-m", "torch.distributed.run", "--nproc_per_node=2", "--master_port=29517",
           "-m", "ycode.lm.cli", "train", "--data", str(data_dir), "--out", str(out), "--preset", "v3-tiny",
           "--context", "32", "--steps", "6", "--batch-size", "2", "--device", "cpu", "--precision", "fp32",
           "--eval-interval", "6", "--optimizer", "muon"]
    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
    assert proc.returncode == 0, proc.stderr[-2000:]
    assert "x2" in proc.stdout  # logged once, by rank 0, with the world size
    assert proc.stdout.count("Model: YCode-LM") == 1
    model, _, payload = load_checkpoint(out)
    assert payload["step"] == 6


def test_prepare_reuses_tokenizer_and_caps_size(data_dir, tmp_path):
    from ycode.lm.data import prepare_dataset
    from ycode.lm.tokenizer import BPETokenizer

    src = tmp_path / "src"
    src.mkdir()
    for i in range(10):
        (src / f"m{i}.py").write_text(f"def f{i}(x):\n    return x + {i}\n" * 200)
    meta = prepare_dataset([src], tmp_path / "d", tokenizer_path=data_dir / "tokenizer.json", max_mb=0.01,
                           workers=1, log=lambda *_: None)
    assert meta["files"] < 10  # capped
    assert BPETokenizer.load(tmp_path / "d" / "tokenizer.json").merges == \
        BPETokenizer.load(data_dir / "tokenizer.json").merges