# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""`Tokenizer.from_pretrained(path)`: a Hugging Face `tokenizer.json` for the
byte-level BPE families, with the pre-tokenization pattern a PARAMETER
(lane/model-loader, 2026-09-17; DEVIATION 2960).
WHAT THE FILE HOLDS AND WHAT IS TAKEN FROM IT. `model.vocab` (byte-level
spelling -> id) and `model.merges` become a rank table of token bytes, the
same table `BpeTokenizer.from_files` builds from `encoder.json` and
`vocab.bpe`, checked the same way (ids contiguous, every token a single
byte or the result of a merge, merge results rising above both parts, so
that merging by rank IS the merge list). `added_tokens` are the special
tokens; `pre_tokenizer` names the pattern; `normalizer` is null or NFC;
`post_processor` is null, ByteLevel, or a TemplateProcessing whose leading
and trailing special tokens become the BOS/EOS ids `encode` adds.
THE PATTERN IS A PARAMETER (DEVIATION 2960). Byte-level BPE families share
the merge loop and the byte-to-unicode table but not the regex that cuts
text into pre-tokens, and a different cut is a different id sequence:
gpt2 '(?:[sdmt]|ll|ve|re)| ?\\p{L}++| ?\\p{N}++| ?[^\\s\\p{L}\\p{N}]++|\\s++$|\\s+(?!\\S)|\\s
(tiktoken's spelling; Hugging Face's `ByteLevel use_regex` spelling
without possessive quantifiers cuts identically) -- GPT-2, GPT-NeoX,
Pythia, the Mamba checkpoints
llama3 (?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}{1,3}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+
-- Llama 3.x, SmolLM2 (Llama-3-style digits by threes)
qwen2 the same with `\\p{N}` (one digit at a time) -- Qwen 2, Qwen 2.5, Qwen 3
WHERE EACH PATTERN RUNS (lane/pyglue-text-io, 2026-10-03). All three
patterns are cut in Mojo, in the tokenizer host binding
(`tokenizer/impl/pretokenize.mojo`: `pretoken_end` for GPT-2,
`pretoken_end_llama` for Llama 3 and Qwen 2, leftmost-first over the seven
alternatives), and every pre-token goes to the binding's merge loop
(`tokenizer/impl/bpe.mojo`); the vocabulary checks, the special-token split
and the decode are `tokenizer/vocab.mojo` and
`tokenizer/impl/vocab_build.mojo`. This module only reads the JSON
configuration, validates it, passes the vocabulary objects to the binding and
returns results. The Python spellings of the cuts that used to live here
are kept as the verification oracle in `_tokenizer_synthetic.py` (GPT-2).
UNICODE CLASSES. `\\p{L}`, `\\p{N}` and `\\s` are the binding's pinned
tables (`tokenizer/impl/unicode_class.mojo`, generated at build time), the
same for every pattern. NFC normalization, when the file asks for it, is
the running Python's `unicodedata.normalize` (`Tokenizer.unicode_version`).
SENTENCEPIECE FAMILIES ARE REFUSED BY NAME. Llama 2, Mistral (v1/v2),
Gemma and Phi-3 ship a SentencePiece model (`tokenizer.model`, or a
`tokenizer.json` with `model.type: Unigram`, `byte_fallback: true`, a
Metaspace pre-tokenizer or `▁` spellings); none of that is byte-level
BPE and this lane does not load it.
SPECIAL TOKENS. Every added token is recognized in the text only with
`allow_special=True` (the existing door's `allow_endoftext` rule); with the
default `False` its characters are ordinary text. `decode` renders an added
token's content. The BOS/EOS the post-processor's template names are added
by `encode(..., add_special_tokens=True)`, the default, as Hugging Face's
`encode` does.
"""
import array
import itertools
import json
import os
import unicodedata
from .._buffer import addr, addr_ro
from ..tokenizer import _binding, _blob, _native, _ro
__all__ = ["Tokenizer", "PATTERNS", "pretokenize", "pattern_name"]
_GPT2_TIKTOKEN = "'(?:[sdmt]|ll|ve|re)| ?\\p{L}++| ?\\p{N}++| ?[^\\s\\p{L}\\p{N}]++|\\s++$|\\s+(?!\\S)|\\s"
_GPT2_HF = "'s|'t|'re|'ve|'m|'ll|'d| ?\\p{L}+| ?\\p{N}+| ?[^\\s\\p{L}\\p{N}]+|\\s+(?!\\S)|\\s+"
_LLAMA3 = "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}{1,3}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+"
_QWEN2 = "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+"
#: pattern name -> the regex spellings that name it (the first is canonical)
PATTERNS = {
"gpt2": (_GPT2_TIKTOKEN, _GPT2_HF),
"llama3": (_LLAMA3,),
"qwen2": (_QWEN2,),
}
_NAME_OF = {spelling: name for name, spellings in PATTERNS.items() for spelling in spellings}
_DIGITS = {"llama3": 3, "qwen2": 1}
def pattern_name(regex):
"""The name of a known pattern spelling, or None."""
return _NAME_OF.get(regex)
_PATTERN_CODE = {"gpt2": 0, "llama3": 1, "qwen2": 2}
def _text_bytes(data):
if isinstance(data, str):
return data.encode("utf-8")
if isinstance(data, (bytes, bytearray, memoryview)):
return bytes(data)
raise TypeError(f"mojolearn.models.Tokenizer: text must be str or bytes-like, got {type(data).__name__}")
def pretokenize(data, pattern):
"""Pre-token boundaries of `data` (bytes) under `pattern` ("gpt2",
"llama3" or "qwen2"): `m + 1` offsets, first 0 and last `len(data)`.
The cut is the binding's (`tokenizer_pretokenize`)."""
if pattern not in _PATTERN_CODE:
raise ValueError(f"mojolearn.models.Tokenizer: unknown pattern {pattern!r}; one of {sorted(PATTERNS)}")
raw = _text_bytes(data)
out = array.array("q", bytes(8 * (len(raw) + 1)))
m = _binding()
count = int(_native(m.tokenizer_pretokenize, _ro(raw, "text"), len(raw), _PATTERN_CODE[pattern],
addr(out, name="bounds"), prefix="mojolearn.models.Tokenizer: "))
return out[:count].tolist()
# --------------------------------------------------------- the reader
def _refuse(path, what):
raise ValueError(f"mojolearn.models.Tokenizer: {path}: {what}")
def _pattern_of(pre, path):
"""The pattern name a `pre_tokenizer` entry selects, refusing by name."""
if pre is None:
_refuse(path, "pre_tokenizer is null (no cut at all); the byte-level BPE families all cut")
kind = pre.get("type")
if kind == "ByteLevel":
if pre.get("use_regex", True):
return "gpt2"
_refuse(path, "pre_tokenizer ByteLevel with use_regex false cuts nothing; not a known family")
if kind == "Metaspace":
_refuse(path, "pre_tokenizer Metaspace: a SentencePiece family (Llama 2, Mistral, Gemma, Phi-3), refused by name")
if kind == "Sequence":
found = None
for step in pre.get("pretokenizers", []): # cpu-route: parses the tokenizer.json pretokenizer config (file input)
t = step.get("type")
if t == "Split":
pat = step.get("pattern", {})
regex = pat.get("Regex") if isinstance(pat, dict) else None
if regex is None:
_refuse(path, f"pre_tokenizer Split with pattern {pat!r}; only a Regex is a known cut")
if step.get("behavior") not in ("Isolated", None) or step.get("invert"):
_refuse(path, f"pre_tokenizer Split behavior={step.get('behavior')!r} invert={step.get('invert')!r}; only Isolated is a known cut")
name = pattern_name(regex)
if name is None:
_refuse(path, f"pre_tokenizer regex {regex!r} is not one of the known patterns {sorted(PATTERNS)}; a different cut is a different id sequence, refused by name")
if found is not None and found != name:
_refuse(path, "two Split steps with different patterns")
found = name
elif t == "ByteLevel":
if step.get("use_regex", False):
if found is not None and found != "gpt2":
_refuse(path, "a Split pattern followed by ByteLevel use_regex true would cut twice")
found = found or "gpt2"
elif t == "Metaspace":
_refuse(path, "pre_tokenizer Metaspace: a SentencePiece family, refused by name")
else:
_refuse(path, f"pre_tokenizer step {t!r} is not a known cut (ByteLevel, Split Regex)")
if found is None:
_refuse(path, "pre_tokenizer Sequence names no cut")
return found
_refuse(path, f"pre_tokenizer type {kind!r} is not a known cut (ByteLevel, Sequence of Split Regex + ByteLevel)")
[docs]
class Tokenizer:
"""See the module header. `tokens` are the rank-ordered token bytes,
`merges` the `(left, right)` byte pairs, `pattern` a name in
`PATTERNS`, `added` `{content: id}`."""
unicode_version = unicodedata.unidata_version
def __init__(self, tokens, merges, *, pattern, added=None, normalizer=None,
bos_ids=(), eos_ids=(), ignore_merges=False, source="<tokens>"):
self._settle(pattern, added, normalizer, bos_ids, eos_ids, source)
toks = list(tokens)
pairs = list(merges)
tblob, tlen = _blob(toks, "every token")
try:
if set(map(len, pairs)) - {2}:
raise TypeError
except TypeError:
raise ValueError("mojolearn.models.Tokenizer: every merge must be a pair of bytes") from None
mparts = list(itertools.chain.from_iterable(pairs))
mblob, mlen = _blob(mparts, "every merge part")
sblob, slen, sids = self._special_buffers()
handle = _native(self._m.vocab_load_bytes,
[_ro(tblob, "tokens"), _ro(tlen, "token_lengths"), _ro(mblob, "merges"),
_ro(mlen, "merge_lengths"), _ro(sblob, "specials"), _ro(slen, "special_lengths"),
_ro(sids, "special_ids")],
[len(toks), len(pairs), len(slen), _PATTERN_CODE[pattern], int(bool(ignore_merges))],
prefix=f"mojolearn.models.Tokenizer: {source}: ")
self._adopt(handle)
def _settle(self, pattern, added, normalizer, bos_ids, eos_ids, source):
"""The configuration: checked here (scalars and the added-token map),
the vocabulary itself is the binding's."""
if pattern not in PATTERNS:
raise ValueError(f"mojolearn.models.Tokenizer: unknown pattern {pattern!r}; one of {sorted(PATTERNS)}")
if normalizer not in (None, "NFC"):
raise ValueError(f"mojolearn.models.Tokenizer: normalizer {normalizer!r} is not honored; null or NFC")
self.pattern = pattern
self.normalizer = normalizer
self.source = source
self.added = dict(added or {})
for content, i in self.added.items(): # cpu-route: walks the added special tokens (text input)
if not isinstance(content, str) or not content or type(i) is not int or i < 0:
raise ValueError(f"mojolearn.models.Tokenizer: added token {content!r} -> {i!r} is not a non-empty string to a non-negative id")
self._added_by_id = {}
for content, i in self.added.items(): # cpu-route: walks the added special tokens (text input)
if i in self._added_by_id:
raise ValueError(f"mojolearn.models.Tokenizer: added tokens {self._added_by_id[i]!r} and {content!r} share id {i}")
self._added_by_id[i] = content
self.bos_ids = tuple(int(i) for i in bos_ids) # glue: converts the bos id argument
self.eos_ids = tuple(int(i) for i in eos_ids) # glue: converts the eos id argument
self._m = _binding()
def _special_buffers(self):
contents = [c.encode("utf-8") for c in self.added] # cpu-route: encodes the added special tokens (text input)
blob, lengths = _blob(contents, "every added token")
return blob, lengths, array.array("q", self.added.values())
def _adopt(self, handle):
self._handle = handle
n_ranks, n_vocab, longest = (int(x) for x in self._m.vocab_info(handle)) # glue: unpacks three vocabulary sizes
self._n_ranks, self._n_vocab, self._max_token_bytes = n_ranks, n_vocab, longest
@classmethod
def _from_spelled(cls, vocab, merges, *, pattern, added, normalizer, bos_ids, eos_ids, ignore_merges,
source):
tok = cls.__new__(cls)
tok._settle(pattern, added, normalizer, bos_ids, eos_ids, source)
sblob, slen, sids = tok._special_buffers()
handle = _native(tok._m.vocab_load_spelled, vocab, merges,
[_ro(sblob, "specials"), _ro(slen, "special_lengths"), _ro(sids, "special_ids")],
[len(slen), _PATTERN_CODE[pattern], int(bool(ignore_merges))],
prefix=f"mojolearn.models.Tokenizer: {source}: ")
tok._adopt(handle)
return tok
# ------------------------------------------------------------ loading
[docs]
@classmethod
def from_pretrained(cls, path):
"""`path` is a directory holding `tokenizer.json` (and optionally
`tokenizer_config.json`), or the `tokenizer.json` itself."""
path = os.fspath(path)
if os.path.isdir(path):
tj = os.path.join(path, "tokenizer.json")
if not os.path.isfile(tj):
if os.path.isfile(os.path.join(path, "tokenizer.model")):
raise ValueError(
f"mojolearn.models.Tokenizer: {path} holds tokenizer.model and no tokenizer.json: a "
"SentencePiece family (Llama 2, Mistral, Gemma, Phi-3), refused by name")
raise FileNotFoundError(f"mojolearn.models.Tokenizer: {path} holds no tokenizer.json")
cfg_path = os.path.join(path, "tokenizer_config.json")
else:
tj = path
cfg_path = os.path.join(os.path.dirname(path) or ".", "tokenizer_config.json")
with open(tj, "r", encoding="utf-8") as fh:
data = json.load(fh)
cfg = None
if os.path.isfile(cfg_path):
with open(cfg_path, "r", encoding="utf-8") as fh:
cfg = json.load(fh)
return cls._from_json(data, tj, cfg)
@classmethod
def _from_json(cls, data, path, cfg=None):
if not isinstance(data, dict):
_refuse(path, "not a JSON object")
model = data.get("model") or {}
mtype = model.get("type")
if mtype != "BPE":
_refuse(path, f"model.type {mtype!r}" + (": a SentencePiece Unigram model, refused by name" if mtype == "Unigram" else "; only BPE is byte-level BPE"))
if model.get("byte_fallback"):
_refuse(path, "model.byte_fallback true: a SentencePiece-style BPE (Llama 2, Mistral), refused by name")
if model.get("continuing_subword_prefix") or model.get("end_of_word_suffix"):
_refuse(path, "model.continuing_subword_prefix / end_of_word_suffix: not byte-level BPE")
dec = data.get("decoder")
if dec is not None and dec.get("type") != "ByteLevel":
_refuse(path, f"decoder type {dec.get('type')!r}; ByteLevel is the byte-level BPE decoder")
norm = data.get("normalizer")
normalizer = None
if norm is not None:
if norm.get("type") == "NFC":
normalizer = "NFC"
elif norm.get("type") == "Sequence" and all(s.get("type") == "NFC" for s in norm.get("normalizers", [])) and norm.get("normalizers"): # cpu-route: parses the tokenizer.json normalizer config (file input)
normalizer = "NFC"
else:
_refuse(path, f"normalizer {norm.get('type')!r} is not honored; null or NFC")
pattern = _pattern_of(data.get("pre_tokenizer"), path)
# added tokens
added = {}
for entry in data.get("added_tokens", []) or []: # cpu-route: parses the tokenizer.json added tokens (file input)
content, i = entry.get("content"), entry.get("id")
if not isinstance(content, str) or type(i) is not int:
_refuse(path, f"added token {entry!r} lacks a string content and an int id")
added[content] = i
# the vocabulary
vocab = model.get("vocab")
if not isinstance(vocab, dict):
_refuse(path, "model.vocab is not an object")
# The vocabulary without the added tokens; the binding decodes the
# spellings, places the ids and checks the merge list.
base = dict(vocab)
for content in added: # cpu-route: parses the tokenizer.json added tokens (file input)
base.pop(content, None)
merges = model.get("merges", []) or []
if not isinstance(merges, list):
_refuse(path, "model.merges is not a list")
# the template's specials
bos_ids, eos_ids = [], []
post = data.get("post_processor")
if post is not None:
kind = post.get("type")
if kind == "TemplateProcessing":
single = post.get("single", [])
specials = post.get("special_tokens", {})
seen_seq = False
for item in single: # cpu-route: parses the tokenizer.json post processor (file input)
if "Sequence" in item:
seen_seq = True
elif "SpecialToken" in item:
name = item["SpecialToken"].get("id")
ids = specials.get(name, {}).get("ids")
if not ids:
_refuse(path, f"post_processor names special token {name!r} with no ids")
(eos_ids if seen_seq else bos_ids).extend(int(v) for v in ids) # cpu-route: parses the tokenizer.json post processor ids (file input)
else:
_refuse(path, f"post_processor template item {item!r} is not Sequence or SpecialToken")
elif kind not in ("ByteLevel",):
_refuse(path, f"post_processor type {kind!r} is not honored (null, ByteLevel, TemplateProcessing)")
if cfg is not None and isinstance(cfg, dict):
if cfg.get("add_bos_token") is False:
bos_ids = []
if cfg.get("add_eos_token") is False:
eos_ids = []
tok = cls._from_spelled(base, merges, pattern=pattern, added=added, normalizer=normalizer,
bos_ids=bos_ids, eos_ids=eos_ids,
ignore_merges=bool(model.get("ignore_merges", False)),
source=os.path.abspath(path))
tok.bos_token_id = tok.bos_ids[0] if tok.bos_ids else None
tok.eos_token_id = tok.eos_ids[-1] if tok.eos_ids else None
if cfg is not None and isinstance(cfg, dict):
for key, attr in (("bos_token", "bos_token_id"), ("eos_token", "eos_token_id")): # cpu-route: parses the tokenizer config special tokens (file input)
v = cfg.get(key)
if isinstance(v, dict):
v = v.get("content")
if isinstance(v, str) and v in added:
setattr(tok, attr, added[v])
return tok
# ------------------------------------------------------------ facts
@property
def n_vocab(self):
"""The ranks, extended by the added tokens above them."""
return self._n_vocab
@property
def n_ranks(self):
return self._n_ranks
bos_token_id = None
eos_token_id = None
# ------------------------------------------------------------ encode
[docs]
def pretokenize(self, data):
"""The pre-tokens of `data` (bytes) under this tokenizer's pattern,
as a list of bytes; the cut before any merge."""
raw = _text_bytes(data)
out = array.array("q", bytes(8 * (len(raw) + 1)))
count = int(self._m.vocab_pretokenize(self._handle, _ro(raw, "text"), len(raw), addr(out, name="bounds")))
return [raw[a:b] for a, b in zip(out[:count - 1], out[1:count])] # cpu-route: slices the text into pretokens at native bounds (text input)
_bytes = staticmethod(_text_bytes)
[docs]
def encode_bytes(self, data, *, allow_special=False):
"""The ids of a byte string. Added tokens are recognized only with
`allow_special=True`; the segments between them are encoded alone
(a special token ends the text before it, as the binding's
`<|endoftext|>` rule has it)."""
if isinstance(data, str):
raise TypeError("mojolearn.models.Tokenizer: encode_bytes takes bytes; use encode for str")
raw = _text_bytes(data)
if not raw:
return []
out = array.array("i", bytes(4 * len(raw)))
count = int(_native(self._m.vocab_encode, self._handle, addr_ro(raw, name="text"), len(raw),
addr(out, name="ids"), len(raw), bool(allow_special),
prefix="mojolearn.models.Tokenizer: "))
return out[:count].tolist()
[docs]
def encode(self, text, *, add_special_tokens=True, allow_special=False):
"""The ids of `text` (str, NFC-normalized when the file says so, then
UTF-8; or bytes-like), with the template's BOS/EOS ids around them
when `add_special_tokens` is True."""
if isinstance(text, str) and self.normalizer == "NFC":
text = unicodedata.normalize("NFC", text)
ids = self.encode_bytes(_text_bytes(text), allow_special=allow_special)
if add_special_tokens:
return list(self.bos_ids) + ids + list(self.eos_ids)
return ids
# ------------------------------------------------------------ decode
[docs]
def decode_bytes(self, ids):
"""The bytes of an id sequence; an added token renders its content.
An id outside the vocabulary is refused by value and position."""
if isinstance(ids, (str, bytes, bytearray)):
raise TypeError(f"mojolearn.models.Tokenizer: ids must be a sequence of int, got {type(ids).__name__}")
seq = list(ids)
if not seq:
return b""
cap = len(seq) * self._max_token_bytes
out = bytearray(cap)
count = int(_native(self._m.vocab_decode, self._handle, seq, addr(out, name="text"), cap,
prefix="mojolearn.models.Tokenizer: "))
return bytes(out[:count])
def decode(self, ids, errors="replace"):
return self.decode_bytes(ids).decode("utf-8", errors)
def __repr__(self):
return (f"Tokenizer(pattern={self.pattern!r}, n_ranks={self._n_ranks}, added={len(self.added)}, "
f"source={self.source!r})")