0050 the sentencepiece bpe lineage stays a bpe model - CyrilB1531/lodestar GitHub Wiki

0050 โ€” The SentencePiece-BPE lineage stays a BPE model, and metaspace becomes one transform

Status: accepted ยท Date: 2026-08-21 ยท Amends: 0017 ยง3

Context

#175 opens on files a user actually has: Llama-2 and Mistral v0.1, which neither tokenizer here loads. #315 is the design lot it demands before any code โ€” "one tokenizer or two, and where Metaspace lives" โ€” because the file is refused twice over, and a shape chosen while implementing one half is chosen without the other in view.

ADR 0017 ยง3 named these two models and gave them nowhere to go: "a third pipeline, distinct from both the classic and byte-level lineages BpeTokenizer implements and from the Unigram + Metaspace pipeline SentencePieceTokenizer implements. Neither class reproduces it."

What the files actually declare

Read from two ungated mirrors before deciding, because the issue's own framing turned out to be under-specified:

Llama-2 (NousResearch/Llama-2-7b-hf) Mistral v0.1 (mistralai/Mistral-7B-v0.1)
normalizer Sequence[Prepend "โ–", Replace " "โ†’"โ–"] null
pre_tokenizer null Metaspace{replacement "โ–", prepend_scheme "first", split false}
model BPE, fuse_unk: true, byte_fallback: true identical

The two models express the same intent through different blocks. Llama-2 never says Metaspace: it spells it out as a Prepend and a Replace in the normalizer, and its pre-tokenizer is null. Mistral does the reverse. A design that teaches BpePreTokenizer about Metaspace would load Mistral and still refuse Llama-2 โ€” which is exactly the half-a-view failure #315 exists to prevent, and it would not have been visible from the issue text.

What unifies them is split: false. Mistral's Metaspace does not split; it replaces and prepends. It is a text transform rather than a splitting pre-tokenizer, which is precisely why Llama-2's normalizer spelling is equivalent to it. Both say: prepend โ–, replace spaces with โ–.

Two things checked and found not to be problems: fuse_unk: true is already carried by BpeVocabulary.FuseUnk and pinned by BpeFuseUnkTests, so it is not a third refusal; and BpeVocabulary is publicly constructible with init properties, which existing tests already use, so each implementation lot can reach its own code without waiting for the other's refusal to lift โ€” #208's constraint that a lot must check its corpus reaches the code it is about.

Decision

1. The lineage stays a BPE model. No third tokenizer type. LoadBpe โ†’ BpeVocabulary โ†’ BpeTokenizer, because that is what model.type declares. A reader who opens tokenizer.json, sees "type": "BPE" and calls LoadBpe is right, and stays right.

The alternative โ€” a SentencePieceBpeTokenizer named after the lineage rather than the declared model โ€” was argued from 0017 ยง3's own concern, that a reader picking the nearest-sounding class gets silently wrong embeddings. It loses because that concern is answered better by the file than by a class name: the file says BPE, and a type whose name contradicts it moves the ambiguity rather than removing it. Two types would also duplicate the merge loop, fuse_unk, added tokens and the byte-level machinery, all of which this lineage shares unchanged.

2. Metaspace is one internal transform, fed by either declaration. A MetaspaceEscape carrying the replacement character, the prepend scheme (never/first/always) and remove_extra_whitespaces. It does one thing โ€” prepend, replace โ€” and is testable alone. The loader normalises both spellings into it: the Metaspace pre-tokenizer block and the Prepend + Replace normalizer sequence are two writings of one value, and absorbing that variation is the loader's job, as it already is for Metaspace's pre-0.14 spelling.

BpeVocabulary gains a nullable MetaspaceEscape?; null is today's behaviour word for word. SentencePieceTokenizer uses the same transform with remove_extra_whitespaces set, which is what its current ten lines do and what neither of these two models declares โ€” so the flag is not decoration, it is the difference between the unigram path and this one.

3. byte_fallback is reproduced, not refused. 0017 ยง3 refused it by name. That refusal ends for BPE models: an uncovered character resolves into byte pieces rather than the unknown token. This is stated here as a decision rather than deferred to the lot that implements it, because an ADR that kept the refusal would describe an endpoint #175 contradicts.

4. What stays refused, and this one is a decision too. A normalizer Sequence that is not exactly Prepend + Replace is refused, not silently reduced to the two steps we reproduce. Three steps, refuse. This is 0017's rule surviving while two of its clauses fall: refusing beats producing embeddings that are quietly wrong, and loosening it here would empty it of meaning everywhere else.

Consequences

Sequencing: #316, then #317, then #318. The order is chosen by risk, not by the parent's numbering. #316 carries an extraction โ€” the transform leaves SentencePieceTokenizer to be shared โ€” and an extraction must return exactly the same answers. The unigram oracles prove that for free, and only while nothing else moves in the same lot. Landing it before any new behaviour keeps that witness.

After #316 both files are still refused, on byte_fallback; after #317 the refusal goes. Each lot visibly moves the lock and none pretends to open the door alone. Saying so here is what stops #316 reading as a failure.

Testing. #316: the unigram oracles unchanged, as the control; the BPE path exercised through a directly constructed BpeVocabulary; the loader exercised by three synthetic tokenizer.json fixtures โ€” one declaring Metaspace, one declaring the normalizer sequence, and one with a three-step sequence that must be refused. The third is the one that matters: it proves the loosening is bounded. #317: byte_fallback on a hand-built vocabulary. #318 is the only lot that needs the network.

No benchmark row, in any of the four. None of these is a performance lot, and a comparison against Python belongs in the oracles rather than in a table of measurements.

0017 is amended, not corrected. Its ยง3 refused byte_fallback and recorded that these two models had no path here; both clauses fall, and the index carries the relation so a reader does not meet two accepted decisions where one denies what the other ships. 0017's body stands as written โ€” an accepted decision is append-only, and what it said was true when it was written.