This is the implementation contract. An agent working on this repo should treat it as authoritative and update it (with a note) rather than silently diverging.
A byte-level BPE tokenizer implemented from first principles, plus a benchmark harness comparing it to production tokenizers. Correctness and measurement quality matter more than raw speed.
- Beating
tiktokenon encode throughput in pure Python. - Training a language model. This repo stops at the tokenizer.
- Supporting every tokenizer family. Unigram/WordPiece are explicitly out of scope for v1.
class Tokenizer:
@classmethod
def train(cls, corpus: str | Path | Iterable[str], vocab_size: int,
pattern: Literal["gpt2", "gpt4", "none"] = "gpt4",
special_tokens: list[str] | None = None,
fast: bool = True,
verbose: bool = False) -> "Tokenizer": ...
def encode(self, text: str, allowed_special: set[str] | None = None) -> list[int]: ...
def decode(self, ids: list[int]) -> str: ...
def save(self, path: str | Path) -> None: ...
@classmethod
def load(cls, path: str | Path) -> "Tokenizer": ...
@property
def vocab_size(self) -> int: ...decode(encode(s)) == sfor all inputs, including emoji, mixed scripts, lone surrogates escaped as bytes, and control characters. This is the property test; run it over Hypothesis-generated text plus a fixture file of adversarial strings.encodeis deterministic across processes.save→load→encodeproduces byte-identical output to the pre-save tokenizer.- Merges are applied in training order; ties in pair frequency are broken deterministically (lowest pair id first — document the choice).
- Special tokens are matched only when explicitly allowed; otherwise their literal text is encoded as ordinary bytes.
Human-inspectable, versioned, single file. Suggested: a header line with format version, pattern name, and vocab size, followed by the merge list in application order, followed by the special-token table. Include a .vocab sidecar writer for debugging that renders each token as printable text with unprintables escaped — this is for humans, never re-read by the loader.
Corpus → pre-tokenize → for each pre-token, a list of byte ids. Loop: count all adjacent pairs across the corpus, take the argmax, merge it everywhere, record it. Cost is O(N) per merge. This is the reference implementation and stays in the repo as a correctness oracle.
- Pre-tokenize, then collapse to unique pre-tokens with occurrence counts. Most corpora shrink by 10–100x here.
- Maintain
pair_counts: dict[pair, int]andpair_locations: dict[pair, set[word_idx]]. - Use a max-heap of
(-count, pair)with lazy deletion — pop, verify the count still matchespair_counts, discard if stale. - On merging pair
p: for each word containingp, rewrite the symbol sequence and apply delta updates to the counts of only the neighbouring pairs that changed. Never rescan the corpus.
A test must assert that naive and fast produce identical merge lists on a fixed corpus with a fixed vocab size. That test is the whole point of having both.
Do not transcribe split regexes from memory. Fetch the canonical gpt2 and cl100k_base patterns from tiktoken's published source, cite the source file in a code comment, and add unit tests covering: contractions, runs of digits, leading whitespace attachment, and newline handling.
Split with the pattern, encode each pre-token independently, functools.lru_cache on pre-token → ids. Merges applied greedily in training order.
Each corpus is a small committed sample (≤5 MB) with a loader that records source, license, and SHA256. Required set: English web text, Tamil, Hindi, one more non-Latin script, Python source, and JSON/log lines. Document provenance in data/README.md. Do not commit anything license-ambiguous.
- chars/token and bytes/token — primary compression metrics, reported per corpus.
- Fertility — tokens per whitespace-delimited word. Note in the docs that this is misleading for scripts without whitespace word boundaries; report it anyway with the caveat.
- Encode throughput — MB/s, median of N≥5 runs, warm cache excluded, single core, with CPU model recorded.
- Train wall-clock and peak RSS, naive vs fast, across corpus sizes.
- Vocab overlap — fraction of our merges that also appear in the baseline's vocabulary. Interesting, cheap, rarely measured.
tiktoken (cl100k_base, o200k_base) required. HuggingFace tokenizers optional behind an extra.
granule bench --report writes results/results.json, renders Markdown tables, regenerates plots into results/, and splices the tables into README between the AUTOGEN:results-start/end markers. Committed results must be reproducible from a single command.
- Python 3.11+,
ruff+mypy --strictclean onsrc/. pytestwith coverage onsrc/granule≥ 85%.- GitHub Actions: lint, type-check, test on 3.11 and 3.12.
- Zero non-stdlib runtime dependencies for the core tokenizer. Benchmarking deps live under an optional extra.
- Every public function has a docstring stating what it does and what it assumes.
| ID | Deliverable | Done when |
|---|---|---|
| M0 | Repo scaffold, pyproject.toml, CI, empty package importable |
CI green on an empty test suite |
| M1 | Naive trainer + encode/decode + save/load | Round-trip property tests pass |
| M2 | Pre-tokenization patterns + special tokens | Pattern unit tests pass |
| M3 | Fast trainer | Merge lists identical to naive; speedup measured |
| M4 | Benchmark harness + corpora | granule bench runs end to end |
| M5 | Report generation, plots, README autofill | results/ committed and reproducible |
| M6 | Docs, demo GIF, limitations write-up | README readable by a stranger in 60 seconds |