Skip to content

Commit f7299e4

Browse files
committed
Fix special token id persistence and id range validation
1 parent 3e38338 commit f7299e4

17 files changed

Lines changed: 216 additions & 29 deletions

File tree

configs/base.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"model": {
3-
"vocab_size": 8192,
3+
"vocab_size": 8256,
44
"context_length": 512,
55
"n_embed": 768,
66
"n_head": 12,

configs/small.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"model": {
3-
"vocab_size": 8192,
3+
"vocab_size": 8256,
44
"context_length": 256,
55
"n_embed": 384,
66
"n_head": 6,

tests/test_alignment.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ def test_sft_with_no_trainable_examples_raises_clearly(tmp_path):
2828
tok.add_special(SPECIAL_TOKENS)
2929
tok.save(tmp_path / "tok.json")
3030

31-
cfg = Config(_model_cfg(), TrainConfig(device="cpu"))
31+
from textllm.config import ModelConfig
32+
33+
model_cfg = ModelConfig(vocab_size=512, context_length=32, n_embed=64, n_head=4, n_kv_head=2, n_blocks=2)
34+
cfg = Config(model_cfg, TrainConfig(device="cpu"))
3235
save_checkpoint(tmp_path / "base.pt", Transformer(cfg.model), None, 0, cfg)
3336

3437
# user-only conversations: prompt masking leaves nothing to supervise

tests/test_cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,15 @@ def test_pretrain_rejects_token_file_too_short_for_a_window(tmp_path, monkeypatc
8989

9090
with pytest.raises(ValueError, match="context_length"):
9191
main(["pretrain", *TINY_OVERRIDES, "--set", "train.data_dir=data", "--set", "train.out_dir=ckpt"])
92+
93+
94+
def test_pretrain_rejects_token_ids_beyond_model_vocab(tmp_path, monkeypatch):
95+
import numpy as np
96+
import pytest
97+
98+
monkeypatch.chdir(tmp_path)
99+
(tmp_path / "data").mkdir()
100+
np.full(200, 600, dtype=np.uint16).tofile(tmp_path / "data" / "train.bin") # vocab is 512
101+
102+
with pytest.raises(ValueError, match="vocab_size"):
103+
main(["pretrain", *TINY_OVERRIDES, "--set", "train.data_dir=data", "--set", "train.out_dir=ckpt"])

tests/test_data.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,19 @@ def test_preference_pair_dropped_when_prompt_fills_context():
116116
long_prompt = [Message("user", "hello world how are you doing today friend " * 30)]
117117
ds = PreferenceDataset([(long_prompt, "fine", "bad")], tok, block_size=16)
118118
assert len(ds) == 0 # truncation left no supervised response tokens on either side
119+
120+
121+
def test_build_bin_bounds_the_id_range_not_the_count(tmp_path):
122+
import pytest
123+
124+
from textllm.data import build_bin
125+
126+
class SparseIds:
127+
vocab_size = 300 # small count
128+
max_token_id = 70_000 # but an id beyond uint16
129+
130+
def encode(self, text):
131+
return [0]
132+
133+
with pytest.raises(ValueError, match="overflow"):
134+
build_bin(SparseIds(), ["hello"], tmp_path / "t.bin")

tests/test_tokenizer.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,90 @@ def test_save_load_preserves_encoding(tmp_path):
3232
s = "the quick café <|end|>"
3333
assert reloaded.encode(s) == tok.encode(s)
3434
assert reloaded.decode(reloaded.encode(s)) == s
35+
36+
37+
def test_load_restores_saved_special_ids_exactly(tmp_path):
38+
import json
39+
40+
tok = _trained(tmp_path)
41+
path = tmp_path / "tok.json"
42+
tok.save(path)
43+
44+
# perturb the stored ids so they are no longer the contiguous re-derivable block
45+
data = json.loads(path.read_text())
46+
data["special"] = {t: i + 100 for t, i in data["special"].items()}
47+
path.write_text(json.dumps(data))
48+
49+
reloaded = Tokenizer.load(path)
50+
assert reloaded.special == data["special"]
51+
for text, i in data["special"].items():
52+
assert reloaded.encode(text) == [i]
53+
54+
55+
def test_add_special_across_calls_never_collides(tmp_path):
56+
tok = _trained(tmp_path)
57+
tok.add_special(["<|tool|>"])
58+
tok.add_special(["<|result|>"])
59+
ids = list(tok.special.values())
60+
assert len(ids) == len(set(ids))
61+
62+
63+
def test_add_special_after_load_does_not_collide(tmp_path):
64+
tok = _trained(tmp_path)
65+
path = tmp_path / "tok.json"
66+
tok.save(path)
67+
68+
reloaded = Tokenizer.load(path)
69+
reloaded.add_special(["<|tool|>"])
70+
ids = list(reloaded.special.values())
71+
assert len(ids) == len(set(ids))
72+
assert reloaded.special["<|tool|>"] > max(tok.special.values())
73+
74+
75+
def test_train_after_add_special_raises(tmp_path):
76+
import pytest
77+
78+
tok = Tokenizer()
79+
tok.add_special(["<|end|>"])
80+
with pytest.raises(ValueError, match="after training"):
81+
tok.train("some text " * 20, vocab_size=300)
82+
83+
84+
def test_load_rejects_colliding_special_ids(tmp_path):
85+
import json
86+
87+
import pytest
88+
89+
tok = _trained(tmp_path)
90+
path = tmp_path / "tok.json"
91+
tok.save(path)
92+
93+
data = json.loads(path.read_text())
94+
first = next(iter(data["special"]))
95+
data["special"][first] = 260 # a merge id
96+
path.write_text(json.dumps(data))
97+
with pytest.raises(ValueError, match="special token ids"):
98+
Tokenizer.load(path)
99+
100+
101+
def test_longer_special_token_wins_over_its_prefix(tmp_path):
102+
tok = _trained(tmp_path)
103+
tok.add_special(["<|t|>", "<|t|>x"])
104+
assert tok.encode("<|t|>x") == [tok.special["<|t|>x"]]
105+
106+
107+
def test_special_id_and_vocab_fit_helpers(tmp_path):
108+
import pytest
109+
110+
from textllm.tokenizer import check_vocab_fit, special_id
111+
112+
tok = _trained(tmp_path)
113+
assert special_id(tok, "<|pad|>") == tok.special["<|pad|>"]
114+
check_vocab_fit(tok, tok.max_token_id + 1)
115+
with pytest.raises(ValueError, match="vocabulary"):
116+
check_vocab_fit(tok, tok.max_token_id)
117+
118+
bare = Tokenizer()
119+
bare.train("some text here " * 20, vocab_size=300)
120+
with pytest.raises(ValueError, match="special"):
121+
special_id(bare, "<|pad|>")

textllm/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
@dataclass
1818
class ModelConfig:
19-
vocab_size: int = 8192
19+
vocab_size: int = 8256 # headroom above a vocab-8192 tokenizer plus its special ids
2020
context_length: int = 512
2121
n_embed: int = 384
2222
n_head: int = 6

textllm/data/shards.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,16 @@ def build_bin(tokenizer, texts: Iterable[str], out_path: str | Path, eot: str |
2121
Returns the number of tokens written. Documents are flushed as they're encoded so a
2222
large corpus never has to sit in memory all at once.
2323
"""
24-
max_id = np.iinfo(DTYPE).max
25-
vocab_size = getattr(tokenizer, "vocab_size", None)
26-
if vocab_size is not None and vocab_size > max_id + 1:
24+
limit = np.iinfo(DTYPE).max
25+
# vocab_size counts entries; special ids can sit far above it, so bound the id range
26+
top = getattr(tokenizer, "max_token_id", None)
27+
if top is None:
28+
vocab_size = getattr(tokenizer, "vocab_size", None)
29+
top = vocab_size - 1 if vocab_size else None
30+
if top is not None and top > limit:
2731
raise ValueError(
28-
f"tokenizer vocab ({vocab_size}) exceeds what {np.dtype(DTYPE).name} can store "
29-
f"({max_id + 1} ids) — token ids would silently overflow"
32+
f"tokenizer ids reach {top}, beyond what {np.dtype(DTYPE).name} can store "
33+
f"({limit}) — token ids would silently overflow"
3034
)
3135

3236
out_path = Path(out_path)

textllm/eval/reasoning.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ def accuracy(ckpt_path: str, tokenizer_spec: str, qa_path: str, max_new_tokens:
2222
device = pick_device()
2323
model, cfg = load_model(ckpt_path, device)
2424
tokenizer = get_tokenizer(tokenizer_spec)
25-
stops = set(tokenizer.encode(EOT))
25+
eot_ids = tokenizer.encode(EOT)
26+
stops = set(eot_ids) if len(eot_ids) == 1 else set()
2627

2728
rows = [json.loads(line) for line in Path(qa_path).read_text().splitlines() if line.strip()]
2829
correct = 0

textllm/infer/chat.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,9 @@
1212

1313

1414
def _stop_ids(tokenizer) -> set[int]:
15-
stops = set()
16-
for marker in (EOT,):
17-
try:
18-
stops.update(tokenizer.encode(marker))
19-
except Exception:
20-
pass
21-
return stops
15+
# a multi-token EOT would stop generation on ordinary text, so require a single id
16+
ids = tokenizer.encode(EOT)
17+
return set(ids) if len(ids) == 1 else set()
2218

2319

2420
def chat_repl(ckpt_path: str, tokenizer_spec: str, system: str | None = None, **sampling) -> None:

0 commit comments

Comments
 (0)