Files
2026-07-13 12:03:03 +08:00

137 lines
5.4 KiB
Python

"""
Test the tokenizer: BPE round-trips, special tokens, and conversation rendering.
Trains a tiny throwaway tokenizer in-process so the test is hermetic
(no dependency on ~/.cache/nanochat).
python -m pytest tests/test_tokenizer.py -v
"""
import pytest
from nanochat.tokenizer import RustBPETokenizer, SPECIAL_TOKENS
# a small corpus is enough to exercise the BPE machinery
CORPUS = [
"The quick brown fox jumps over the lazy dog.",
"hello world, hello tokenizer, hello hello hello",
"Numbers like 12345 and unicode like naïve café 你好 🙂 should survive.",
"def f(x):\n return x + 1\n",
] * 8
@pytest.fixture(scope="module")
def tokenizer():
vocab_size = 256 + len(SPECIAL_TOKENS) + 35 # bytes + specials + a few merges
return RustBPETokenizer.train_from_iterator(iter(CORPUS), vocab_size)
def test_vocab_size(tokenizer):
assert tokenizer.get_vocab_size() == 256 + len(SPECIAL_TOKENS) + 35
def test_encode_decode_roundtrip(tokenizer):
for text in ["hello world", "naïve café 你好 🙂", "unseen tokens: zqxjkv"]:
ids = tokenizer.encode(text)
assert tokenizer.decode(ids) == text
def test_special_tokens(tokenizer):
# all special tokens encode to a unique single id
ids = [tokenizer.encode_special(t) for t in SPECIAL_TOKENS]
assert len(set(ids)) == len(SPECIAL_TOKENS)
assert tokenizer.get_bos_token_id() == tokenizer.encode_special("<|bos|>")
# specials are NOT special-cased in ordinary text encoding
ids = tokenizer.encode("<|bos|>")
assert len(ids) > 1, "special token strings in plain text should not collapse to one token"
def test_encode_prepend_append(tokenizer):
bos = tokenizer.get_bos_token_id()
ids = tokenizer.encode("hello", prepend="<|bos|>", append="<|user_end|>")
assert ids[0] == bos
assert ids[-1] == tokenizer.encode_special("<|user_end|>")
def test_encode_batch(tokenizer):
texts = ["hello", "world"]
ids = tokenizer.encode(texts)
assert isinstance(ids, list) and len(ids) == 2
assert ids[0] == tokenizer.encode("hello")
def test_render_conversation_masks(tokenizer):
conversation = {"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello!"},
{"role": "user", "content": "bye"},
{"role": "assistant", "content": "later"},
]}
ids, mask = tokenizer.render_conversation(conversation)
assert len(ids) == len(mask)
# first token is bos and is not supervised
assert ids[0] == tokenizer.get_bos_token_id() and mask[0] == 0
# supervised tokens are exactly: assistant content + assistant_end tokens
assistant_end = tokenizer.encode_special("<|assistant_end|>")
supervised_ids = [i for i, m in zip(ids, mask) if m == 1]
expected = tokenizer.encode("hello!") + [assistant_end] + tokenizer.encode("later") + [assistant_end]
assert supervised_ids == expected
# user content tokens are never supervised
user_start = tokenizer.encode_special("<|user_start|>")
assert all(m == 0 for i, m in zip(ids, mask) if i == user_start)
def test_render_conversation_system_message_merged(tokenizer):
without_system = {"messages": [
{"role": "user", "content": "sys prompt\n\nhi"},
{"role": "assistant", "content": "yo"},
]}
with_system = {"messages": [
{"role": "system", "content": "sys prompt"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"},
]}
assert tokenizer.render_conversation(with_system) == tokenizer.render_conversation(without_system)
def test_render_conversation_tool_parts(tokenizer):
# python tool calls are supervised, python outputs (come from the interpreter) are not
conversation = {"messages": [
{"role": "user", "content": "add"},
{"role": "assistant", "content": [
{"type": "text", "text": "sure"},
{"type": "python", "text": "1+1"},
{"type": "python_output", "text": "2"},
{"type": "text", "text": "it is 2"},
]},
]}
ids, mask = tokenizer.render_conversation(conversation)
python_start = tokenizer.encode_special("<|python_start|>")
output_start = tokenizer.encode_special("<|output_start|>")
output_end = tokenizer.encode_special("<|output_end|>")
# the tool call and its delimiters are supervised
assert mask[ids.index(python_start)] == 1
# the interpreter output and its delimiters are not
start, end = ids.index(output_start), ids.index(output_end)
assert all(m == 0 for m in mask[start:end + 1])
def test_render_conversation_truncation(tokenizer):
conversation = {"messages": [
{"role": "user", "content": "hello " * 100},
{"role": "assistant", "content": "world " * 100},
]}
ids, mask = tokenizer.render_conversation(conversation, max_tokens=32)
assert len(ids) == 32 and len(mask) == 32
def test_render_for_completion(tokenizer):
conversation = {"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "this gets stripped"},
]}
ids = tokenizer.render_for_completion(conversation)
# ends with assistant_start, primed for a completion
assert ids[-1] == tokenizer.encode_special("<|assistant_start|>")
# the assistant response itself must not be present
stripped = tokenizer.encode("this gets stripped")
assert not any(ids[i:i + len(stripped)] == stripped for i in range(len(ids)))