Andrej Karpathy commited on
Commit
f8a85a5
·
1 Parent(s): a9d0a86

add a few tests

Browse files
README.md CHANGED
@@ -186,7 +186,12 @@ I've published a number of guides that might contain helpful information, most r
186
  │ ├── mmlu.py # Multiple choice questions, broad topics
187
  │ └── smoltalk.py # Conglomerate dataset of SmolTalk from HF
188
  ├── tests
189
- │ └── test_engine.py
 
 
 
 
 
190
  └── uv.lock
191
  ```
192
 
 
186
  │ ├── mmlu.py # Multiple choice questions, broad topics
187
  │ └── smoltalk.py # Conglomerate dataset of SmolTalk from HF
188
  ├── tests
189
+ │ ├── test_attention_fallback.py # FA3/SDPA attention fallback
190
+ │ ├── test_engine.py # Inference engine, KV cache
191
+ │ ├── test_execution.py # Sandboxed code execution
192
+ │ ├── test_optim.py # MuonAdamW optimizer (needs GPU)
193
+ │ ├── test_tasks.py # Task slicing, mixtures, HubDataset
194
+ │ └── test_tokenizer.py # BPE round-trips, chat rendering
195
  └── uv.lock
196
  ```
197
 
tests/test_execution.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the sandboxed Python execution used by tool use / HumanEval.
3
+ Each test adversarially checks one property of the sandbox.
4
+
5
+ python -m pytest tests/test_execution.py -v
6
+ """
7
+
8
+ import os
9
+ import time
10
+ import pytest
11
+ from nanochat.execution import execute_code
12
+
13
+
14
+ def test_happy_path():
15
+ result = execute_code("print('hello world')")
16
+ assert result.success
17
+ assert result.stdout == "hello world\n"
18
+
19
+
20
+ def test_exception_is_captured():
21
+ result = execute_code("1/0")
22
+ assert not result.success
23
+ assert "ZeroDivisionError" in result.error
24
+
25
+
26
+ def test_timeout_kills_infinite_loop():
27
+ start = time.time()
28
+ result = execute_code("while True: pass", timeout=2.0)
29
+ elapsed = time.time() - start
30
+ assert result.timeout and not result.success
31
+ assert elapsed < 5, f"process was not killed promptly ({elapsed:.1f}s)"
32
+
33
+
34
+ def test_memory_limit():
35
+ # 1GB allocation against the 256MB default limit
36
+ result = execute_code("x = bytearray(1024 * 1024 * 1024)")
37
+ assert not result.success
38
+ assert result.memory_exceeded
39
+
40
+
41
+ @pytest.mark.parametrize("evil", [
42
+ "import os; os.system('echo pwned')",
43
+ "import shutil; shutil.rmtree('/tmp')",
44
+ "import subprocess; subprocess.Popen(['ls'])",
45
+ "import os; os.kill(1, 9)",
46
+ "import os; os.fork()",
47
+ ])
48
+ def test_destructive_functions_disabled(evil):
49
+ result = execute_code(evil)
50
+ assert not result.success
51
+
52
+
53
+ def test_stdin_disabled():
54
+ result = execute_code("input()")
55
+ assert not result.success
56
+
57
+
58
+ def test_writes_go_to_tempdir():
59
+ result = execute_code("open('landmine.txt', 'w').write('x'); print(open('landmine.txt').read())")
60
+ assert result.success and result.stdout == "x\n"
61
+ assert not os.path.exists("landmine.txt"), "file leaked outside the sandbox tempdir"
62
+
63
+
64
+ def test_environment_is_scrubbed():
65
+ os.environ["FAKE_SECRET_KEY"] = "hunter2"
66
+ try:
67
+ result = execute_code("import os; print('FAKE_SECRET_KEY' in os.environ)")
68
+ assert result.success and result.stdout.strip() == "False"
69
+ finally:
70
+ del os.environ["FAKE_SECRET_KEY"]
71
+
72
+
73
+ def test_tricky_string_content():
74
+ # quotes, backslashes and braces must survive being embedded into the runner
75
+ tricky = '''s = 'it\\'s "quoted" \\\\ {braces} \\n'; print(len(s))'''
76
+ result = execute_code(tricky)
77
+ assert result.success
78
+
79
+
80
+ def test_fresh_globals():
81
+ # user code must not see the guard prelude's imports
82
+ result = execute_code("print('GUARD' in dir())")
83
+ assert result.success and result.stdout.strip() == "False"
tests/test_optim.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the combined MuonAdamW optimizer (single rank, no process group).
3
+ The AdamW half is checked against torch.optim.AdamW as a reference;
4
+ the Muon half is checked behaviorally (determinism + convergence).
5
+
6
+ Requires a GPU (the fused kernels are compiled on first use, which
7
+ dominates the runtime of this file).
8
+
9
+ python -m pytest tests/test_optim.py -v
10
+ """
11
+
12
+ import pytest
13
+ import torch
14
+
15
+ cuda_available = torch.cuda.is_available()
16
+ pytestmark = pytest.mark.skipif(not cuda_available, reason="optimizer tests require CUDA")
17
+
18
+ if cuda_available:
19
+ from nanochat.optim import MuonAdamW
20
+
21
+ DEVICE = "cuda"
22
+
23
+ # keep shapes small and reuse them across tests so the fused kernels compile once
24
+ ADAMW_SMALL_SHAPE = (48,) # numel < 1024 path
25
+ ADAMW_LARGE_SHAPE = (1024, 4) # numel >= 1024 path
26
+ MUON_WIDE_SHAPE = (32, 64)
27
+ MUON_TALL_SHAPE = (64, 32)
28
+
29
+
30
+ def make_params_and_groups(seed=1337, offset=0.0, targets=None):
31
+ """The menagerie: small/large AdamW params, wide/tall Muon stacks."""
32
+ gen = torch.Generator(device=DEVICE).manual_seed(seed)
33
+ def rand(shape):
34
+ base = torch.randn(shape, generator=gen, device=DEVICE) * 0.05
35
+ return torch.nn.Parameter(base + offset)
36
+ params = [rand(ADAMW_SMALL_SHAPE), rand(ADAMW_LARGE_SHAPE)]
37
+ params += [rand(MUON_WIDE_SHAPE) for _ in range(3)]
38
+ params += [rand(MUON_TALL_SHAPE) for _ in range(2)]
39
+ groups = [
40
+ dict(kind="adamw", params=params[0:1], lr=0.02, betas=(0.8, 0.95), eps=1e-10, weight_decay=0.0),
41
+ dict(kind="adamw", params=params[1:2], lr=0.02, betas=(0.8, 0.96), eps=1e-10, weight_decay=0.0),
42
+ dict(kind="muon", params=params[2:5], lr=0.02, momentum=0.95, ns_steps=5, beta2=0.9, weight_decay=0.0),
43
+ dict(kind="muon", params=params[5:7], lr=0.02, momentum=0.95, ns_steps=5, beta2=0.9, weight_decay=0.0),
44
+ ]
45
+ return params, groups
46
+
47
+
48
+ def test_adamw_matches_torch_reference():
49
+ """Our fused AdamW must agree with torch.optim.AdamW (both decoupled wd)."""
50
+ hypers = dict(lr=0.01, betas=(0.9, 0.95), eps=1e-8, weight_decay=0.1)
51
+ p_ours = torch.nn.Parameter(torch.randn(64, 32, device=DEVICE))
52
+ p_ref = torch.nn.Parameter(p_ours.detach().clone())
53
+ opt_ours = MuonAdamW([dict(kind="adamw", params=[p_ours], **hypers)])
54
+ opt_ref = torch.optim.AdamW([p_ref], **hypers)
55
+ for step in range(10):
56
+ grad = torch.randn(p_ours.shape, generator=torch.Generator(device=DEVICE).manual_seed(step), device=DEVICE)
57
+ p_ours.grad = grad.clone()
58
+ p_ref.grad = grad.clone()
59
+ opt_ours.step()
60
+ opt_ref.step()
61
+ torch.testing.assert_close(p_ours, p_ref, rtol=1e-5, atol=1e-6)
62
+
63
+
64
+ def test_determinism():
65
+ """Two identical runs must produce bitwise identical parameters."""
66
+ results = []
67
+ for _ in range(2):
68
+ params, groups = make_params_and_groups()
69
+ opt = MuonAdamW(groups)
70
+ for step in range(5):
71
+ gen = torch.Generator(device=DEVICE).manual_seed(step)
72
+ for p in params:
73
+ p.grad = torch.randn(p.shape, generator=gen, device=DEVICE) * 0.01
74
+ opt.step()
75
+ results.append([p.detach().clone() for p in params])
76
+ for pa, pb in zip(*results):
77
+ assert torch.equal(pa, pb)
78
+
79
+
80
+ def test_convergence():
81
+ """Optimizing distance-to-target must actually approach the target."""
82
+ targets_params, _ = make_params_and_groups(seed=999)
83
+ targets = [p.detach().clone() for p in targets_params]
84
+ params, groups = make_params_and_groups(seed=999, offset=0.1) # start offset from the targets
85
+ opt = MuonAdamW(groups)
86
+ def distances():
87
+ return [(p.detach() - t).norm().item() for p, t in zip(params, targets)]
88
+ initial = distances()
89
+ for _ in range(50):
90
+ for p, t in zip(params, targets):
91
+ p.grad = 2 * (p.detach() - t) # gradient of ||p - t||^2
92
+ opt.step()
93
+ final = distances()
94
+ for i, (d0, d1) in enumerate(zip(initial, final)):
95
+ assert d1 < 0.5 * d0, f"param {i} did not converge: {d0:.4f} -> {d1:.4f}"
96
+ assert torch.isfinite(params[i]).all()
97
+
98
+
99
+ def test_muon_update_is_orthogonalized():
100
+ """
101
+ The very first Muon update (zero momentum state) of a full-rank gradient
102
+ should be (near) semi-orthogonal after the polar iteration: its nonzero
103
+ singular values land in a band around 1, rather than being spread out.
104
+ """
105
+ p = torch.nn.Parameter(torch.zeros(MUON_WIDE_SHAPE, device=DEVICE))
106
+ group = dict(kind="muon", params=[p], lr=1.0, momentum=0.0, ns_steps=5, beta2=1.0, weight_decay=0.0)
107
+ opt = MuonAdamW([group])
108
+ p.grad = torch.randn(p.shape, generator=torch.Generator(device=DEVICE).manual_seed(0), device=DEVICE)
109
+ opt.step()
110
+ # with lr=1, wd=0: p_new = -update, so the update is just -p
111
+ svals = torch.linalg.svdvals(-p.detach().float())
112
+ # NorMuon variance reduction rescales the magnitude, so normalize by the mean
113
+ svals = svals / svals.mean()
114
+ assert svals.max() / svals.min() < 4.0, f"update far from semi-orthogonal: {svals}"
tests/test_tasks.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the Task container machinery: slicing views, mixtures, and the
3
+ HubDataset parquet wrapper (in-memory, no network).
4
+
5
+ python -m pytest tests/test_tasks.py -v
6
+ """
7
+
8
+ import numpy as np
9
+ import pyarrow as pa
10
+ from tasks.common import Task, TaskMixture, HubDataset, render_mc
11
+
12
+
13
+ class ToyTask(Task):
14
+ """A trivial task: example i is just {'i': i, 'tag': tag}."""
15
+
16
+ def __init__(self, n=10, tag="a", **kwargs):
17
+ super().__init__(**kwargs)
18
+ self.n = n
19
+ self.tag = tag
20
+
21
+ def num_examples(self):
22
+ return self.n
23
+
24
+ def get_example(self, index):
25
+ return {"i": index, "tag": self.tag}
26
+
27
+
28
+ def test_task_full():
29
+ task = ToyTask(n=10)
30
+ assert len(task) == 10
31
+ assert task[0] == {"i": 0, "tag": "a"}
32
+ assert task[9] == {"i": 9, "tag": "a"}
33
+
34
+
35
+ def test_task_slicing():
36
+ # a view of [5, 10) has 5 examples and maps logical to physical indices
37
+ task = ToyTask(n=10, start=5, stop=10)
38
+ assert len(task) == 5
39
+ assert task[0]["i"] == 5
40
+ # step slicing uses ceil division for the length
41
+ task = ToyTask(n=10, start=0, stop=10, step=3) # 0, 3, 6, 9
42
+ assert len(task) == 4
43
+ assert [task[i]["i"] for i in range(4)] == [0, 3, 6, 9]
44
+
45
+
46
+ def test_mixture_covers_all_examples_deterministically():
47
+ mixture = TaskMixture([ToyTask(n=3, tag="a"), ToyTask(n=5, tag="b")])
48
+ assert len(mixture) == 8
49
+ examples = [mixture[i] for i in range(8)]
50
+ # every example appears exactly once
51
+ keys = sorted((ex["tag"], ex["i"]) for ex in examples)
52
+ assert keys == [("a", 0), ("a", 1), ("a", 2), ("b", 0), ("b", 1), ("b", 2), ("b", 3), ("b", 4)]
53
+ # the shuffle is deterministic: a second instance yields the same order
54
+ mixture2 = TaskMixture([ToyTask(n=3, tag="a"), ToyTask(n=5, tag="b")])
55
+ assert examples == [mixture2[i] for i in range(8)]
56
+ # and the tasks are actually interleaved, not concatenated
57
+ assert [ex["tag"] for ex in examples] != ["a"] * 3 + ["b"] * 5
58
+
59
+
60
+ def test_mixture_oversampling():
61
+ # passing a task twice doubles its examples
62
+ mixture = TaskMixture([ToyTask(n=3), ToyTask(n=3)])
63
+ assert len(mixture) == 6
64
+
65
+
66
+ def test_hub_dataset_rows():
67
+ table = pa.table({"x": list(range(100)), "y": [str(i) for i in range(100)]})
68
+ ds = HubDataset(table)
69
+ assert len(ds) == 100
70
+ assert ds[7] == {"x": 7, "y": "7"}
71
+
72
+
73
+ def test_hub_dataset_shuffle_matches_numpy():
74
+ # the shuffle must reproduce datasets.Dataset.shuffle(seed) exactly,
75
+ # which is a np.random.default_rng(seed) permutation
76
+ table = pa.table({"x": list(range(100))})
77
+ ds = HubDataset(table).shuffle(seed=42)
78
+ perm = np.random.default_rng(42).permutation(100)
79
+ assert [ds[i]["x"] for i in range(100)] == [int(p) for p in perm]
80
+ # shuffling returns a view; the original order is untouched
81
+ assert HubDataset(table)[0] == {"x": 0}
82
+
83
+
84
+ def test_render_mc_letter_binding():
85
+ query = render_mc("What is 1+1?", ("A", "B"), ("1", "2"))
86
+ # the letter must directly follow '=' with no whitespace, so that the
87
+ # prompt token for "A" matches the assistant's bare "A" response token
88
+ assert "=A\n" in query and "=B\n" in query
tests/test_tokenizer.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the tokenizer: BPE round-trips, special tokens, and conversation rendering.
3
+ Trains a tiny throwaway tokenizer in-process so the test is hermetic
4
+ (no dependency on ~/.cache/nanochat).
5
+
6
+ python -m pytest tests/test_tokenizer.py -v
7
+ """
8
+
9
+ import pytest
10
+ from nanochat.tokenizer import RustBPETokenizer, SPECIAL_TOKENS
11
+
12
+ # a small corpus is enough to exercise the BPE machinery
13
+ CORPUS = [
14
+ "The quick brown fox jumps over the lazy dog.",
15
+ "hello world, hello tokenizer, hello hello hello",
16
+ "Numbers like 12345 and unicode like naïve café 你好 🙂 should survive.",
17
+ "def f(x):\n return x + 1\n",
18
+ ] * 8
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def tokenizer():
23
+ vocab_size = 256 + len(SPECIAL_TOKENS) + 35 # bytes + specials + a few merges
24
+ return RustBPETokenizer.train_from_iterator(iter(CORPUS), vocab_size)
25
+
26
+
27
+ def test_vocab_size(tokenizer):
28
+ assert tokenizer.get_vocab_size() == 256 + len(SPECIAL_TOKENS) + 35
29
+
30
+
31
+ def test_encode_decode_roundtrip(tokenizer):
32
+ for text in ["hello world", "naïve café 你好 🙂", "unseen tokens: zqxjkv"]:
33
+ ids = tokenizer.encode(text)
34
+ assert tokenizer.decode(ids) == text
35
+
36
+
37
+ def test_special_tokens(tokenizer):
38
+ # all special tokens encode to a unique single id
39
+ ids = [tokenizer.encode_special(t) for t in SPECIAL_TOKENS]
40
+ assert len(set(ids)) == len(SPECIAL_TOKENS)
41
+ assert tokenizer.get_bos_token_id() == tokenizer.encode_special("<|bos|>")
42
+ # specials are NOT special-cased in ordinary text encoding
43
+ ids = tokenizer.encode("<|bos|>")
44
+ assert len(ids) > 1, "special token strings in plain text should not collapse to one token"
45
+
46
+
47
+ def test_encode_prepend_append(tokenizer):
48
+ bos = tokenizer.get_bos_token_id()
49
+ ids = tokenizer.encode("hello", prepend="<|bos|>", append="<|user_end|>")
50
+ assert ids[0] == bos
51
+ assert ids[-1] == tokenizer.encode_special("<|user_end|>")
52
+
53
+
54
+ def test_encode_batch(tokenizer):
55
+ texts = ["hello", "world"]
56
+ ids = tokenizer.encode(texts)
57
+ assert isinstance(ids, list) and len(ids) == 2
58
+ assert ids[0] == tokenizer.encode("hello")
59
+
60
+
61
+ def test_render_conversation_masks(tokenizer):
62
+ conversation = {"messages": [
63
+ {"role": "user", "content": "hi"},
64
+ {"role": "assistant", "content": "hello!"},
65
+ {"role": "user", "content": "bye"},
66
+ {"role": "assistant", "content": "later"},
67
+ ]}
68
+ ids, mask = tokenizer.render_conversation(conversation)
69
+ assert len(ids) == len(mask)
70
+ # first token is bos and is not supervised
71
+ assert ids[0] == tokenizer.get_bos_token_id() and mask[0] == 0
72
+ # supervised tokens are exactly: assistant content + assistant_end tokens
73
+ assistant_end = tokenizer.encode_special("<|assistant_end|>")
74
+ supervised_ids = [i for i, m in zip(ids, mask) if m == 1]
75
+ expected = tokenizer.encode("hello!") + [assistant_end] + tokenizer.encode("later") + [assistant_end]
76
+ assert supervised_ids == expected
77
+ # user content tokens are never supervised
78
+ user_start = tokenizer.encode_special("<|user_start|>")
79
+ assert all(m == 0 for i, m in zip(ids, mask) if i == user_start)
80
+
81
+
82
+ def test_render_conversation_system_message_merged(tokenizer):
83
+ without_system = {"messages": [
84
+ {"role": "user", "content": "sys prompt\n\nhi"},
85
+ {"role": "assistant", "content": "yo"},
86
+ ]}
87
+ with_system = {"messages": [
88
+ {"role": "system", "content": "sys prompt"},
89
+ {"role": "user", "content": "hi"},
90
+ {"role": "assistant", "content": "yo"},
91
+ ]}
92
+ assert tokenizer.render_conversation(with_system) == tokenizer.render_conversation(without_system)
93
+
94
+
95
+ def test_render_conversation_tool_parts(tokenizer):
96
+ # python tool calls are supervised, python outputs (come from the interpreter) are not
97
+ conversation = {"messages": [
98
+ {"role": "user", "content": "add"},
99
+ {"role": "assistant", "content": [
100
+ {"type": "text", "text": "sure"},
101
+ {"type": "python", "text": "1+1"},
102
+ {"type": "python_output", "text": "2"},
103
+ {"type": "text", "text": "it is 2"},
104
+ ]},
105
+ ]}
106
+ ids, mask = tokenizer.render_conversation(conversation)
107
+ python_start = tokenizer.encode_special("<|python_start|>")
108
+ output_start = tokenizer.encode_special("<|output_start|>")
109
+ output_end = tokenizer.encode_special("<|output_end|>")
110
+ # the tool call and its delimiters are supervised
111
+ assert mask[ids.index(python_start)] == 1
112
+ # the interpreter output and its delimiters are not
113
+ start, end = ids.index(output_start), ids.index(output_end)
114
+ assert all(m == 0 for m in mask[start:end + 1])
115
+
116
+
117
+ def test_render_conversation_truncation(tokenizer):
118
+ conversation = {"messages": [
119
+ {"role": "user", "content": "hello " * 100},
120
+ {"role": "assistant", "content": "world " * 100},
121
+ ]}
122
+ ids, mask = tokenizer.render_conversation(conversation, max_tokens=32)
123
+ assert len(ids) == 32 and len(mask) == 32
124
+
125
+
126
+ def test_render_for_completion(tokenizer):
127
+ conversation = {"messages": [
128
+ {"role": "user", "content": "hi"},
129
+ {"role": "assistant", "content": "this gets stripped"},
130
+ ]}
131
+ ids = tokenizer.render_for_completion(conversation)
132
+ # ends with assistant_start, primed for a completion
133
+ assert ids[-1] == tokenizer.encode_special("<|assistant_start|>")
134
+ # the assistant response itself must not be present
135
+ stripped = tokenizer.encode("this gets stripped")
136
+ assert not any(ids[i:i + len(stripped)] == stripped for i in range(len(ids)))