Andrej Karpathy commited on
Commit ·
f8a85a5
1
Parent(s): a9d0a86
add a few tests
Browse files- README.md +6 -1
- tests/test_execution.py +83 -0
- tests/test_optim.py +114 -0
- tests/test_tasks.py +88 -0
- tests/test_tokenizer.py +136 -0
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 |
-
│
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)))
|