File size: 9,288 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
"""Tests for the from-scratch model stack (tokenizer, transformer, data, training, inference)."""

import random
import textwrap

import pytest

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

from ycode.lm.data import (  # noqa: E402
    InstructionExample,
    encode_example,
    extract_python_examples,
    format_chat,
    prepare_dataset,
)
from ycode.lm.tokenizer import END, EOT, USER, BPETokenizer, split_chunks  # noqa: E402
from ycode.lm.model import GPT, GPTConfig  # noqa: E402
from ycode.lm.train import TrainConfig, load_checkpoint, save_checkpoint, train  # noqa: E402

CODE = textwrap.dedent('''
    def add(a, b):
        """Return the sum of a and b."""
        return a + b


    def greet(name):
        """Build a friendly greeting for the given name."""
        message = "Hello, " + name
        return message


    class Stack:
        def push(self, item):
            """Push an item onto the top of the stack."""
            self.items.append(item)
            return item
''')


# ------------------------------------------------------------- tokenizer


@pytest.fixture(scope="module")
def tok():
    return BPETokenizer.train([CODE * 20], vocab_size=400)


def test_tokenizer_round_trip(tok):
    for text in [CODE, "naïve café → 你好 🚀", "x\t= 1\r\n", ""]:
        assert tok.decode(tok.encode(text)) == text


def test_merges_compress(tok):
    assert len(tok.encode(CODE)) < len(CODE.encode("utf-8")) / 2


def test_special_tokens(tok):
    ids = tok.encode(f"{USER}\nhi\n{END}")
    assert ids[0] == tok.token_id(USER) and ids[-1] == tok.token_id(END)
    literal = tok.encode(END, allow_special=False)
    assert tok.token_id(END) not in literal and tok.decode(literal) == END
    assert tok.decode(ids, skip_special=True) == "\nhi\n"


def test_tokenizer_save_load(tok, tmp_path):
    tok.save(tmp_path / "t.json")
    again = BPETokenizer.load(tmp_path / "t.json")
    assert again.encode(CODE) == tok.encode(CODE) and again.vocab_size == tok.vocab_size


def test_indentation_is_one_chunk():
    assert "\n        " in split_chunks("def f():\n        return 1")


def test_vocab_size_validation():
    with pytest.raises(ValueError):
        BPETokenizer.train(["abc"], vocab_size=100)


# ----------------------------------------------------------------- model


def tiny_model(vocab=400, block=64):
    torch.manual_seed(0)
    return GPT(GPTConfig(vocab_size=vocab, block_size=block, n_layer=2, n_head=2, n_embd=32))


def test_forward_and_masked_loss():
    model = tiny_model()
    x = torch.randint(0, 400, (2, 16))
    logits, loss = model(x, x)
    assert logits.shape == (2, 16, 400) and loss.item() > 0
    mask = torch.zeros_like(x)
    mask[:, -4:] = 1
    _, masked = model(x, x, loss_mask=mask)
    assert masked.item() != pytest.approx(loss.item())


def test_kv_cache_matches_full_recompute():
    model = tiny_model().eval()
    prompt = torch.randint(0, 400, (1, 10))
    cached = model.generate(prompt, 12, temperature=0)
    seq = prompt
    with torch.no_grad():
        for _ in range(12):
            logits = model.head(model.norm(_hidden(model, seq)))[:, -1]
            seq = torch.cat([seq, logits.argmax(-1, keepdim=True)], dim=1)
    assert torch.equal(cached, seq)


def _hidden(model, idx):
    T = idx.size(1)
    x = model.embed(idx)
    for block in model.blocks:
        x = block(x, model.rope_cos[:T], model.rope_sin[:T])
    return x


def test_generate_respects_stop_and_context():
    model = tiny_model(block=32)
    out = model.generate(torch.randint(0, 400, (1, 40)), 8, temperature=0.9)
    assert out.size(1) <= 32 + 8


# ------------------------------------------------------------------- data


def test_extract_python_examples():
    examples = extract_python_examples(CODE, random.Random(0))
    prompts = " ".join(e.prompt for e in examples)
    assert "add" in prompts and "greet" in prompts and "push" in prompts
    write = [e for e in examples if e.response.startswith("```python")]
    assert any("return a + b" in e.response for e in write)
    explain = [e for e in examples if "returns the sum of a and b" in e.response]
    assert explain and '"""' not in explain[0].prompt  # docstring removed from the question


def test_encode_example_masks_only_the_answer(tok):
    ids, mask = encode_example(tok, InstructionExample("What is 1+1?", "2"))
    assert len(ids) == len(mask)
    answer = tok.decode([i for i, m in zip(ids, mask) if m])
    assert answer == f"2\n{END}"
    assert tok.decode(ids).startswith(format_chat("What is 1+1?"))


@pytest.fixture(scope="module")
def dataset(tmp_path_factory):
    src = tmp_path_factory.mktemp("src")
    for i in range(6):
        (src / f"mod{i}.py").write_text(CODE.replace("add", f"add{i}") * 3)
    (src / "notes.bin").write_bytes(b"\x00\x01")
    out = tmp_path_factory.mktemp("data")
    meta = prepare_dataset([src], out, vocab_size=400, log=lambda *_: None)
    return out, meta


def test_prepare_dataset(dataset):
    out, meta = dataset
    for name in ("tokenizer.json", "train.bin", "val.bin", "sft_tokens.bin", "sft_mask.bin", "sft_index.npy",
                 "meta.json"):
        assert (out / name).is_file(), name
    assert meta["files"] == 6 and meta["sft_examples"] >= 12
    assert np.fromfile(out / "train.bin", dtype=np.uint16).max() < meta["vocab_size"]


# --------------------------------------------------------------- training


def _cfg(**kw):
    base = dict(preset="tiny", model_overrides={"block_size": 32}, batch_size=4, max_steps=40, warmup_steps=5,
                eval_interval=40, eval_iters=2, log_interval=1000, device="cpu", precision="fp32")
    base.update(kw)
    return TrainConfig(**base)


def test_pretrain_reduces_loss_and_checkpoints(dataset, tmp_path):
    data_dir, _ = dataset
    logs = []
    summary = train(data_dir, tmp_path / "base", _cfg(lr=3e-3), log=logs.append)
    history = __import__("json").loads((tmp_path / "base" / "train_log.json").read_text())
    assert summary["steps"] == 40
    model, tok, payload = load_checkpoint(tmp_path / "base")
    assert payload["stage"] == "pretrain" and payload["step"] == 40
    # Untrained model loss is ~ln(vocab) = ln(400) ~ 6.0; training must beat it clearly.
    assert history["history"][-1]["train"] < 5.0


def test_sft_from_pretrained(dataset, tmp_path):
    data_dir, _ = dataset
    train(data_dir, tmp_path / "base", _cfg(max_steps=10), log=lambda *_: None)
    with pytest.raises(ValueError):
        train(data_dir, tmp_path / "x", _cfg(stage="sft"), log=lambda *_: None)
    summary = train(data_dir, tmp_path / "chat", _cfg(stage="sft", max_steps=10, init_from=str(tmp_path / "base")),
                    log=lambda *_: None)
    assert summary["steps"] == 10
    assert load_checkpoint(tmp_path / "chat")[2]["stage"] == "sft"


def test_resume_continues_step_count(dataset, tmp_path):
    data_dir, _ = dataset
    train(data_dir, tmp_path / "m", _cfg(max_steps=5), log=lambda *_: None)
    summary = train(data_dir, tmp_path / "m", _cfg(max_steps=8, resume=True), log=lambda *_: None)
    assert summary["steps"] == 8


def test_checkpoint_round_trip_preserves_outputs(tok, tmp_path):
    model = tiny_model().eval()
    save_checkpoint(tmp_path, model, tok, step=3, stage="pretrain", val_loss=1.0)
    loaded, tok2, _ = load_checkpoint(tmp_path)
    x = torch.randint(0, 400, (1, 8))
    assert torch.allclose(model(x, x)[0], loaded(x, x)[0])
    assert tok2.encode(CODE) == tok.encode(CODE)


def test_load_missing_checkpoint(tmp_path):
    with pytest.raises(FileNotFoundError):
        load_checkpoint(tmp_path / "nope")


# -------------------------------------------------------------- inference


def test_local_lm_chat_and_complete(dataset, tmp_path):
    from ycode.lm.generate import LocalLM

    data_dir, _ = dataset
    train(data_dir, tmp_path / "m", _cfg(max_steps=5), log=lambda *_: None)
    lm = LocalLM(tmp_path / "m", device="cpu")
    chunks = []
    answer = lm.chat([("user", "Write add")], max_new_tokens=10, on_text=chunks.append)
    assert isinstance(answer, str) and answer == "".join(chunks).strip()
    assert EOT not in answer and END not in answer
    assert isinstance(lm.complete("def add(", max_new_tokens=5), str)


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

    src = tmp_path / "src"
    src.mkdir()
    for i in range(4):
        (src / f"m{i}.py").write_text(CODE * 3)
    data, base, chat = tmp_path / "data", tmp_path / "base", tmp_path / "chat"
    assert cli.main(["prepare", "--source", str(src), "--out", str(data), "--vocab-size", "400"]) == 0
    assert cli.main(["train", "--data", str(data), "--out", str(base), "--preset", "tiny", "--context", "32",
                     "--steps", "4", "--batch-size", "2", "--device", "cpu"]) == 0
    assert cli.main(["sft", "--data", str(data), "--init-from", str(base), "--out", str(chat), "--steps", "2",
                     "--batch-size", "2", "--device", "cpu"]) == 0
    assert cli.main(["info", "--model", str(chat)]) == 0
    assert "Stage:      sft" in capsys.readouterr().out
    assert cli.main(["sample", "--model", str(base), "--prompt", "def", "--max-tokens", "3"]) == 0
    assert cli.main(["info", "--model", str(tmp_path / "missing")]) == 1