ferrox_models/tokenizer.rs
1//! A real, reversible byte-level tokenizer: each UTF-8 byte maps to
2//! token id `byte as u32` (vocabulary 0..256). This is not a full
3//! BPE/tokenizer.json implementation -- GLM-5.2, DeepSeek V4 Pro, and
4//! Kimi K3 each ship their own trained BPE vocabulary alongside their
5//! weights, and none of those vocab files are guessable or available in
6//! this environment (see docs/MODELS.md) -- but unlike the
7//! previous placeholder (`byte % vocab_size`, which was lossy and could
8//! not decode back to the original text), this tokenizer is exact and
9//! round-trips perfectly. It is the honest "smallest real thing that
10//! works" rather than a fake stand-in.
11//!
12//! Loading a real BPE merge table from a GGUF file's
13//! `tokenizer.ggml.tokens` / `tokenizer.ggml.merges` metadata arrays
14//! (see `ferrox-gguf`'s `GgufValue::Array` support, already verified
15//! against a real downloaded llama.cpp vocab fixture) was the natural
16//! next step and now exists below (`GgufBpeTokenizer`,
17//! `GgufSpmTokenizer`, `GgufUnigramTokenizer`).
18//!
19//! The per-checkpoint pre-tokenization rules live next door in
20//! [`pretokenize`], which is a transcription of llama.cpp and is
21//! reviewed against it.
22//!
23//! Four of llama.cpp's six `tokenizer.ggml.model` values are covered:
24//! `gpt2`/`gemma4` by [`GgufBpeTokenizer`], `llama` by
25//! [`GgufSpmTokenizer`], `t5` by [`GgufUnigramTokenizer`], and `bert` by
26//! [`GgufWordPieceTokenizer`] in `wordpiece`, which brings its own
27//! normalizer and its own Unicode tables (`unicode`, `unicode_data`)
28//! because WordPiece does not use the pre-tokenizer regexes at all.
29//! Still missing: `rwkv`, which needs a trie tokenizer, and `none`.
30
31mod pretokenize;
32mod scored_vocab;
33mod unicode;
34mod unicode_data;
35mod wordpiece;
36
37use scored_vocab::ScoredVocab;
38pub use wordpiece::{GgufWordPieceTokenizer, NormalizerOptions};
39
40/// The `tokenizer.ggml.pre` values whose llama.cpp arm sets
41/// `add_bos = true` for a BPE vocabulary.
42///
43/// Transcribed from `.scratch/llama.cpp/src/llama-vocab.cpp`: the
44/// `LLAMA_VOCAB_PRE_TYPE_LLAMA3` arm sets it for the whole llama3 group
45/// in one statement, and `tekken` and `chameleon` set it in arms of
46/// their own. Llama-3.x GGUFs ship no explicit
47/// `tokenizer.ggml.add_bos_token`, so leaving the group out made every
48/// raw completion prompt one `<|begin_of_text|>` short of llama.cpp's.
49const ADD_BOS_PRE: &[&str] = &[
50 // LLAMA_VOCAB_PRE_TYPE_LLAMA3
51 "llama3",
52 "llama-v3",
53 "llama-bpe",
54 "falcon3",
55 "falcon-h1",
56 "pixtral",
57 "midm-2.0",
58 "lfm2",
59 "jina-v5-nano",
60 // arms of their own, same flag
61 "tekken",
62 "chameleon",
63];
64
65/// Whether prompt encoding should prepend the GGUF BOS token.
66///
67/// Port of llama.cpp `llama_vocab` add_bos defaults
68/// (`.scratch/llama.cpp/src/llama-vocab.cpp`): explicit
69/// `tokenizer.ggml.add_bos_token` wins; else SPM → true, BPE → false
70/// unless the checkpoint's `pre` is one of [`ADD_BOS_PRE`]. Qwen2-MoE
71/// ships `bos_token_id=<|endoftext|>` but `add_bos=false` — always
72/// prepending that token poisons greedy decode.
73pub fn should_add_bos_token(file: &impl ferrox_gguf::TensorSource) -> bool {
74 if let Some(v) = file.metadata_bool("tokenizer.ggml.add_bos_token") {
75 return v;
76 }
77 let model = file.metadata_str("tokenizer.ggml.model").unwrap_or("");
78 let pre = file.metadata_str("tokenizer.ggml.pre").unwrap_or("");
79 // llama.cpp: SPM/WPM default add_bos=true; BPE defaults false unless
80 // its pre-tokenizer arm opts in. qwen2 leaves false.
81 // `bert` is WPM, whose upstream arm sets add_bos AND add_sep true.
82 // It was missing here, so every WordPiece prompt was one `[CLS]`
83 // short of llama.cpp's.
84 if matches!(model, "llama" | "spm" | "bert") || model.contains("sentencepiece") {
85 return true;
86 }
87 ADD_BOS_PRE.contains(&pre)
88}
89
90/// Prepends the checkpoint's BOS id to an already-encoded prompt, unless
91/// the prompt already starts with it.
92///
93/// # The rule, stated once
94///
95/// **The chat template owns BOS when it prints one; the loader owns it
96/// otherwise.** Which of the two happens is a property of the individual
97/// checkpoint, not of the family:
98///
99/// * Many upstream templates open with `{{ bos_token }}` — gemma-2/3
100/// (`<bos>`), Mistral-Instruct and TinyLlama (`<s>`), Llama-3
101/// (`<|begin_of_text|>`). Rendering one of those already puts BOS in
102/// the *text*, and both [`GgufBpeTokenizer::encode`] and
103/// [`GgufSpmTokenizer::encode`] split on special-token text first, so
104/// it comes back as the BOS *id* in position 0.
105/// * Unsloth deliberately **strips** `{{ bos_token }}` out of the
106/// templates it bakes into its GGUF exports, precisely so that a
107/// runtime which adds BOS itself does not double it. On those
108/// checkpoints the render carries no BOS and the loader must add it.
109///
110/// So neither "always add" nor "never add" is right, and a renderer
111/// cannot be sniffed for which case it is. This function implements the
112/// only rule that is correct for both: add the id, **idempotently**.
113/// `bos` is already the gated value — pass `None` when
114/// [`should_add_bos_token`] says this vocabulary does not take one
115/// (BPE/qwen2 ship a `bos_token_id` they never prepend).
116///
117/// Note this is *stricter* than llama.cpp, whose `add_special` path
118/// pushes BOS unconditionally and leaves the duplicate to a warning.
119/// Ferrox has no user-visible "you asked for two BOS tokens" surface, so
120/// it dedupes instead of warning.
121/// Generic over the id width because the CLI and server carry prompts as
122/// `Vec<usize>` and the tokenizers emit `Vec<u32>`.
123pub fn prepend_bos<T: Copy + PartialEq>(tokens: &mut Vec<T>, bos: Option<T>) {
124 let Some(bos) = bos else { return };
125 if tokens.first() != Some(&bos) {
126 tokens.insert(0, bos);
127 }
128}
129
130/// Token texts llama.cpp treats as end-of-generation regardless of what
131/// the metadata ids say (`llama-vocab.cpp`, the literal list right above
132/// its "sanity checks" block). Copied verbatim, including the comments
133/// naming which family each entry exists for, because the set is not
134/// derivable: it is a hand-maintained list of what real checkpoints ship.
135///
136/// Note `<|end|>` *is* here. The Unsloth study recorded in
137/// `docs/plans/llama-cpp-parity-push.md` claimed gpt-oss's `<|end|>` must
138/// not be EOG or every reply truncates; llama.cpp's own source says
139/// otherwise, and llama.cpp serves gpt-oss. Following the reference
140/// implementation, and flagging the claim as contradicted.
141const EOG_TOKEN_TEXTS: &[&str] = &[
142 "<|eot_id|>",
143 "<|im_end|>",
144 "<|end|>",
145 "<|return|>", // o200k_harmony
146 "<|call|>", // o200k_harmony
147 "<|flush|>", // solar-open
148 "<|calls|>", // solar-open
149 "<end_of_turn>",
150 "<|endoftext|>",
151 "</s>", // paddleocr
152 "<|eom_id|>",
153 "<EOT>",
154 "_<EOT>",
155 "[EOT]", // Kimi-K2
156 "[EOS]", // Kimi-K2
157 "<|end_of_text|>",
158 "<end_of_utterance>", // smoldocling
159 "<eos>", // gemma4
160 "<turn|>", // gemma4
161 "<|tool_response>", // gemma4
162 "<|end▁of▁sentence|>", // deepseek-ocr
163 "[e~[", // minimax-m2/m3
164];
165
166/// Every token id that ends generation, not just `eos_token_id`.
167///
168/// A single EOS id is wrong for most modern chat checkpoints: Llama-3
169/// ends turns with `<|eot_id|>` while its `eos_token_id` is
170/// `<|end_of_text|>`, and gemma-4 ends with `<turn|>`. Stopping only on
171/// the metadata EOS means the model keeps generating past the end of its
172/// turn and starts a new one — the "it answers, then interviews itself"
173/// failure.
174///
175/// Mirrors llama.cpp: the literal-name list above, plus the
176/// `eos`/`eot`/`eom` metadata ids, which it folds in with a warning when
177/// they were not already caught by name.
178pub fn eog_token_ids(file: &impl ferrox_gguf::TensorSource) -> std::collections::HashSet<u32> {
179 let mut out = std::collections::HashSet::new();
180 for key in [
181 "tokenizer.ggml.eos_token_id",
182 "tokenizer.ggml.eot_token_id",
183 "tokenizer.ggml.eom_token_id",
184 ] {
185 if let Some(id) = file.metadata_u64(key) {
186 out.insert(id as u32);
187 }
188 }
189 if let Some(ferrox_gguf::GgufValue::Array(items)) = file.metadata("tokenizer.ggml.tokens") {
190 for (id, v) in items.iter().enumerate() {
191 if let ferrox_gguf::GgufValue::String(text) = v {
192 if EOG_TOKEN_TEXTS.contains(&text.as_str()) {
193 out.insert(id as u32);
194 }
195 }
196 }
197 }
198 out
199}
200
201/// The set of token ids a decode loop must stop on, carried as one value
202/// so a caller cannot accidentally carry only half of it.
203///
204/// This type exists because `Option<usize>` was the shape of a real bug:
205/// every `ferrox-server` decode loop threaded a single `eos_id` from the
206/// loader to the sampler, so a Llama-3 or gemma checkpoint served over
207/// HTTP ran past `<|eot_id|>` / `<end_of_turn>` to `max_tokens` even
208/// after [`eog_token_ids`] landed for the CLI. Passing a `StopTokens`
209/// makes "I only have the metadata EOS" an explicit choice
210/// ([`StopTokens::from_eos`], for the synthetic-weights and Kimi paths
211/// that have no GGUF metadata to read) rather than the default.
212#[derive(Clone, Debug, Default)]
213pub struct StopTokens {
214 ids: std::collections::HashSet<u32>,
215}
216
217impl StopTokens {
218 /// Everything [`eog_token_ids`] finds in this checkpoint: the
219 /// `eos`/`eot`/`eom` metadata ids plus every vocabulary entry whose
220 /// text is on llama.cpp's literal EOG list.
221 pub fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Self {
222 Self {
223 ids: eog_token_ids(file),
224 }
225 }
226
227 /// Just the one id. For callers with no GGUF metadata behind them —
228 /// the synthetic random-weights demo model, and the Kimi checkpoint
229 /// directory whose tokenizer is a separate file format.
230 pub fn from_eos(eos: Option<usize>) -> Self {
231 Self {
232 ids: eos.map(|e| e as u32).into_iter().collect(),
233 }
234 }
235
236 /// For checkpoints whose vocabulary is not GGUF metadata — Kimi K3
237 /// ships a `tokenizer_config.json` with a name→id special-token map.
238 /// Folds in every entry whose *text* is on llama.cpp's EOG list, so
239 /// `[EOT]` stops a turn there exactly as it does in a GGUF.
240 pub fn from_special_tokens<'a>(specials: impl IntoIterator<Item = (&'a str, u32)>) -> Self {
241 Self {
242 ids: specials
243 .into_iter()
244 .filter(|(name, _)| EOG_TOKEN_TEXTS.contains(name))
245 .map(|(_, id)| id)
246 .collect(),
247 }
248 }
249
250 /// Folds one more id in — used to keep a metadata `eos_token_id` that
251 /// a vocabulary spells in a way the literal list does not know.
252 pub fn with_id(mut self, id: Option<usize>) -> Self {
253 if let Some(id) = id {
254 self.ids.insert(id as u32);
255 }
256 self
257 }
258
259 pub fn contains(&self, id: usize) -> bool {
260 u32::try_from(id).is_ok_and(|id| self.ids.contains(&id))
261 }
262
263 pub fn is_empty(&self) -> bool {
264 self.ids.is_empty()
265 }
266
267 pub fn len(&self) -> usize {
268 self.ids.len()
269 }
270}
271
272pub struct ByteTokenizer;
273
274impl ByteTokenizer {
275 pub fn encode(text: &str) -> Vec<u32> {
276 text.bytes().map(|b| b as u32).collect()
277 }
278
279 /// Decodes token ids back to a string. Ids outside 0..256 are
280 /// dropped rather than silently corrupting output; invalid UTF-8
281 /// byte sequences are replaced per Rust's standard lossy conversion.
282 pub fn decode(ids: &[u32]) -> String {
283 String::from_utf8_lossy(&Self::decode_bytes(ids)).into_owned()
284 }
285
286 /// The raw bytes, before any UTF-8 decision is made about them.
287 ///
288 /// A caller decoding ONE token at a time must have these: a
289 /// multi-byte character split across two tokens is two invalid
290 /// fragments, and `decode` would turn each into U+FFFD and lose the
291 /// bytes for good. See `ferrox_server::utf8_stream`.
292 pub fn decode_bytes(ids: &[u32]) -> Vec<u8> {
293 ids.iter().filter_map(|&id| u8::try_from(id).ok()).collect()
294 }
295
296 pub const VOCAB_SIZE: usize = 256;
297}
298
299/// How a GGUF BPE vocabulary remaps text before merge lookup.
300///
301/// GPT-2-style vocabs store merges in the OpenAI byte↔unicode remapped
302/// space; Gemma-4 (and similar SPM-flavoured BPE) stores merges over
303/// raw UTF-8 with spaces already escaped to U+2581 (`▁`).
304#[derive(Clone, Copy, Debug, PartialEq, Eq)]
305enum BpeEncodingStyle {
306 Gpt2,
307 /// llama.cpp `LLAMA_VOCAB_PRE_TYPE_GEMMA4`: escape `" "` → `▁`,
308 /// split only on newlines, merge on raw UTF-8 codepoints
309 /// (`byte_encode = false`).
310 SpmWhitespace,
311}
312
313/// Builds the GPT2 byte-to-unicode remap table: bytes in the "already
314/// printable, unambiguous" ranges (33..=126, 161..=172, 174..=255) map
315/// to themselves as Unicode codepoints; every other byte (control
316/// characters, space, and a few others that would be ambiguous or
317/// unprintable as raw codepoints) maps to a codepoint starting at 256.
318/// This is the exact algorithm from OpenAI's GPT-2 `encoder.py`
319/// `bytes_to_unicode()`, reimplemented independently in Rust: real BPE
320/// vocabularies (llama.cpp, mistral.rs via the `tokenizers` crate) list
321/// merge-table entries in *this* remapped space (e.g. "\u{0120}the",
322/// where the leading char is U+0120, the remapped space byte 0x20), not
323/// in raw byte or `char` space, so skipping this step -- which ferrox
324/// did before this function existed -- silently fails to match any real
325/// vocabulary's merge table on space- and control-byte-adjacent tokens.
326fn gpt2_byte_to_unicode() -> ([char; 256], std::collections::HashMap<char, u8>) {
327 let is_printable =
328 |b: u16| (33..=126).contains(&b) || (161..=172).contains(&b) || (174..=255).contains(&b);
329
330 let mut forward = ['\0'; 256];
331 let mut extra_offset = 0u32;
332 for b in 0..256u16 {
333 if is_printable(b) {
334 forward[b as usize] = char::from_u32(b as u32).unwrap();
335 } else {
336 forward[b as usize] = char::from_u32(256 + extra_offset).unwrap();
337 extra_offset += 1;
338 }
339 }
340
341 let mut reverse = std::collections::HashMap::with_capacity(256);
342 for (b, &c) in forward.iter().enumerate() {
343 reverse.insert(c, b as u8);
344 }
345 (forward, reverse)
346}
347
348/// The chunking that runs before BPE: raw text is cut into
349/// contractions, letter runs, digit runs, symbol runs and whitespace
350/// runs, and each chunk is merged separately. Without it `encode_word`
351/// would treat a whole sentence as one word and could merge across word
352/// boundaries in ways no real tokenizer does.
353///
354/// Which pattern a checkpoint gets, and what happens to the text
355/// between matches, is [`pretokenize`]'s job — it is a transcription of
356/// llama.cpp's `llama-vocab.cpp` and `unicode.cpp` and is reviewed
357/// against them.
358/// U+2581 FIGURE SPACE used by SentencePiece-style BPE merge tables.
359const SPM_SPACE: char = '\u{2581}';
360
361/// A real BPE tokenizer built from a GGUF file's own
362/// `tokenizer.ggml.tokens` / `tokenizer.ggml.merges` metadata arrays.
363/// Supports GPT-2 byte-remap BPE (`tokenizer.ggml.model == "gpt2"`) and
364/// Gemma-4 SPM-style BPE (`"gemma4"`: escape spaces to `▁`, merge on
365/// raw UTF-8, newline-only pre-split).
366///
367/// Verified against `tests/fixtures/llama-bpe-vocab.gguf` (GPT-2 path).
368/// See `crates/ferrox-models/tests/gguf_vocab.rs`.
369pub struct GgufBpeTokenizer {
370 token_to_id: std::collections::HashMap<String, u32>,
371 id_to_token: Vec<String>,
372 /// merge rank: lower = merges earlier (higher priority), matching
373 /// the standard BPE convention of applying the most-frequent
374 /// (lowest-rank) merge first.
375 merge_rank: std::collections::HashMap<(String, String), usize>,
376 byte_to_unicode: [char; 256],
377 unicode_to_byte: std::collections::HashMap<char, u8>,
378 /// Control/user-defined tokens (chat-template markers and similar
379 /// added special tokens) from `tokenizer.ggml.token_type`, matched
380 /// as atomic substrings before normal BPE runs -- see
381 /// `split_on_special_tokens`.
382 special_tokens: Vec<(String, u32)>,
383 /// Compiled pre-tokenization pattern (GPT-2 word regex, or
384 /// newline-only for Gemma-4 SPM-BPE).
385 pretokenize_pattern: fancy_regex::Regex,
386 style: BpeEncodingStyle,
387}
388
389#[derive(Debug, thiserror::Error)]
390pub enum TokenizerLoadError {
391 #[error("GGUF file has no 'tokenizer.ggml.tokens' metadata array")]
392 MissingTokens,
393 #[error("'tokenizer.ggml.tokens' is present but is not a string array")]
394 TokensNotStringArray,
395 #[error("'tokenizer.ggml.tokens' is present but empty: a vocabulary with no entries cannot tokenize anything, and its scores have no minimum")]
396 EmptyVocabulary,
397 #[error(
398 "vocabulary and scores disagree about the vocabulary size: 'tokenizer.ggml.tokens' has \
399 {tokens} entries but 'tokenizer.ggml.scores' has {scores}. A score-carrying vocabulary \
400 needs one score per token; this checkpoint cannot be tokenized"
401 )]
402 ScoresVocabLengthMismatch { tokens: usize, scores: usize },
403}
404
405/// GGUF's real `tokenizer.ggml.token_type` per-token integer tag,
406/// confirmed directly against llama.cpp's real `llama_token_type` enum
407/// (`include/llama.h`) and its real GGUF-loading code
408/// (`src/llama-vocab.cpp`'s `toktypes[i]` switch), not guessed: this is
409/// a plain sequential enum on disk (`1=NORMAL, 2=UNKNOWN, 3=CONTROL,
410/// 4=USER_DEFINED, 5=UNUSED, 6=BYTE`), a different and simpler
411/// representation than llama.cpp's own *internal* bit-flag
412/// `llama_token_attr` type, which is derived from this at load time,
413/// not what's actually stored in the file.
414const GGML_TOKEN_TYPE_CONTROL: i64 = 3;
415const GGML_TOKEN_TYPE_USER_DEFINED: i64 = 4;
416
417/// Reads `tokenizer.ggml.token_type` (if present) and returns the
418/// `(token_text, id)` pairs for every CONTROL or USER_DEFINED entry
419/// (chat-template markers like `<|user|>`/`<|assistant|>`, and similar
420/// added special tokens) -- these must be recognized as atomic
421/// vocabulary entries during encoding rather than shattered into
422/// ordinary BPE/SPM/Unigram pieces, matching real llama.cpp's
423/// `tokenizer_st_partition` behavior (`src/llama-vocab.cpp`): special
424/// tokens are located as literal substrings and carved out of the
425/// input *before* normal tokenization runs on what's left, not folded
426/// into the regular vocabulary-matching pass.
427fn load_special_tokens(
428 file: &impl ferrox_gguf::TensorSource,
429 id_to_token: &[String],
430) -> Vec<(String, u32)> {
431 let Some(ferrox_gguf::GgufValue::Array(items)) = file.metadata("tokenizer.ggml.token_type")
432 else {
433 return Vec::new();
434 };
435 items
436 .iter()
437 .zip(id_to_token.iter())
438 .enumerate()
439 .filter_map(|(id, (v, text))| {
440 let ty = match v {
441 ferrox_gguf::GgufValue::I32(t) => *t as i64,
442 ferrox_gguf::GgufValue::U32(t) => *t as i64,
443 _ => return None,
444 };
445 // A checkpoint that flags its markers is the easy case.
446 //
447 // Not every one does. Yi-1.5-6B-Chat lists `<|im_start|>`
448 // (id 6) and `<|im_end|>` (id 7) as NORMAL, so this filter
449 // dropped them, the splitter never saw them, and
450 // `<|im_end|>` tokenized as SIX ordinary BPE pieces instead
451 // of the single token the model was trained on. Its prompt
452 // was malformed and its turn marker could not be matched by
453 // id, so nothing stopped and the marker came back as text
454 // mid-answer.
455 //
456 // So a token that LOOKS like a marker and exists verbatim in
457 // the vocabulary is treated as one whatever its type says.
458 // The shape test is what keeps this narrow: ordinary words
459 // are in the vocabulary too, and promoting those would split
460 // real text.
461 (ty == GGML_TOKEN_TYPE_CONTROL
462 || ty == GGML_TOKEN_TYPE_USER_DEFINED
463 || looks_like_marker(text))
464 .then(|| (text.clone(), id as u32))
465 })
466 .collect()
467}
468
469/// Is this vocabulary entry shaped like a special marker?
470///
471/// Deliberately strict: `<|...|>` and `<...>` with no whitespace, at
472/// least three characters. That covers `<|im_end|>`, `<|endoftext|>`,
473/// `<end_of_turn>` and `</s>` while excluding ordinary text, which also
474/// lives in the vocabulary and must keep tokenizing normally.
475fn looks_like_marker(text: &str) -> bool {
476 let t = text.trim();
477 if t.len() < 3 || t.contains(char::is_whitespace) {
478 return false;
479 }
480 (t.starts_with("<|") && t.ends_with("|>")) || (t.starts_with('<') && t.ends_with('>'))
481}
482
483/// One chunk of `split_on_special_tokens`'s output: either a raw text
484/// run to tokenize normally, or an already-resolved special token id.
485enum TextOrSpecial<'a> {
486 Text(&'a str),
487 Special(u32),
488}
489
490/// Splits `text` around every literal occurrence of any of `specials`
491/// (longest-match-first on ties, matching real llama.cpp's
492/// `tokenizer_st_partition`), leaving the text runs between/around
493/// them untouched for the caller's normal tokenization pass. Returns
494/// the whole input as one `Text` chunk when `specials` is empty (the
495/// overwhelmingly common fast path, since most GGUF files carry no
496/// `tokenizer.ggml.token_type` metadata at all).
497fn split_on_special_tokens<'a>(
498 text: &'a str,
499 specials: &[(String, u32)],
500) -> Vec<TextOrSpecial<'a>> {
501 if specials.is_empty() {
502 return vec![TextOrSpecial::Text(text)];
503 }
504 let mut segments = Vec::new();
505 let mut pos = 0usize;
506 while pos < text.len() {
507 let mut best: Option<(usize, usize, u32)> = None; // (start, len, id)
508 for (s, id) in specials {
509 if s.is_empty() {
510 continue;
511 }
512 if let Some(rel) = text[pos..].find(s.as_str()) {
513 let start = pos + rel;
514 let len = s.len();
515 let better = match best {
516 None => true,
517 Some((bstart, blen, _)) => start < bstart || (start == bstart && len > blen),
518 };
519 if better {
520 best = Some((start, len, *id));
521 }
522 }
523 }
524 match best {
525 None => break,
526 Some((start, len, id)) => {
527 if start > pos {
528 segments.push(TextOrSpecial::Text(&text[pos..start]));
529 }
530 segments.push(TextOrSpecial::Special(id));
531 pos = start + len;
532 }
533 }
534 }
535 if pos < text.len() {
536 segments.push(TextOrSpecial::Text(&text[pos..]));
537 }
538 segments
539}
540
541#[cfg(test)]
542mod special_token_split_tests {
543 use super::*;
544
545 fn text_of<'a>(seg: &TextOrSpecial<'a>) -> Option<&'a str> {
546 match seg {
547 TextOrSpecial::Text(t) => Some(t),
548 TextOrSpecial::Special(_) => None,
549 }
550 }
551
552 #[test]
553 fn empty_specials_list_returns_the_whole_text_unsplit() {
554 let segs = split_on_special_tokens("hello world", &[]);
555 assert_eq!(segs.len(), 1);
556 assert_eq!(text_of(&segs[0]), Some("hello world"));
557 }
558
559 #[test]
560 fn splits_around_a_single_special_token_in_the_middle() {
561 let specials = vec![("<|user|>".to_string(), 42u32)];
562 let segs = split_on_special_tokens("before<|user|>after", &specials);
563 assert_eq!(segs.len(), 3);
564 assert_eq!(text_of(&segs[0]), Some("before"));
565 assert!(matches!(segs[1], TextOrSpecial::Special(42)));
566 assert_eq!(text_of(&segs[2]), Some("after"));
567 }
568
569 #[test]
570 fn multiple_occurrences_and_multiple_distinct_specials_all_split() {
571 let specials = vec![
572 ("<|user|>".to_string(), 1u32),
573 ("<|assistant|>".to_string(), 2u32),
574 ];
575 let segs = split_on_special_tokens("<|user|>hi<|assistant|>hello<|user|>bye", &specials);
576 let kinds: Vec<Option<&str>> = segs.iter().map(text_of).collect();
577 assert_eq!(
578 kinds,
579 vec![None, Some("hi"), None, Some("hello"), None, Some("bye")]
580 );
581 assert!(matches!(segs[0], TextOrSpecial::Special(1)));
582 assert!(matches!(segs[2], TextOrSpecial::Special(2)));
583 assert!(matches!(segs[4], TextOrSpecial::Special(1)));
584 }
585
586 #[test]
587 fn longest_match_wins_on_a_tied_start_position() {
588 // "<|user|>" and a hypothetical shorter overlapping prefix
589 // starting at the same position must prefer the longer match.
590 let specials = vec![("<|user|>".to_string(), 1u32), ("<|u".to_string(), 99u32)];
591 let segs = split_on_special_tokens("<|user|>x", &specials);
592 assert!(matches!(segs[0], TextOrSpecial::Special(1)));
593 }
594
595 #[test]
596 fn no_match_at_all_returns_the_whole_text_as_one_segment() {
597 let specials = vec![("<|user|>".to_string(), 1u32)];
598 let segs = split_on_special_tokens("plain text with no specials", &specials);
599 assert_eq!(segs.len(), 1);
600 assert_eq!(text_of(&segs[0]), Some("plain text with no specials"));
601 }
602}
603
604impl GgufBpeTokenizer {
605 /// Loads the vocabulary + merge table from a GGUF file's metadata.
606 /// Merges are optional (some tokenizer types, e.g. byte-level
607 /// unigram, don't use them); if absent, encoding falls back to
608 /// per-byte token lookup. `tokenizer.ggml.model == "gemma4"` selects
609 /// SPM-whitespace BPE; everything else with merges uses GPT-2 style.
610 pub fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Result<Self, TokenizerLoadError> {
611 let tokens_value = file
612 .metadata("tokenizer.ggml.tokens")
613 .ok_or(TokenizerLoadError::MissingTokens)?;
614 let id_to_token: Vec<String> = match tokens_value {
615 ferrox_gguf::GgufValue::Array(items) => items
616 .iter()
617 .map(|v| v.as_str().map(|s| s.to_string()))
618 .collect::<Option<Vec<_>>>()
619 .ok_or(TokenizerLoadError::TokensNotStringArray)?,
620 _ => return Err(TokenizerLoadError::TokensNotStringArray),
621 };
622
623 let token_to_id: std::collections::HashMap<String, u32> = id_to_token
624 .iter()
625 .enumerate()
626 .map(|(i, t)| (t.clone(), i as u32))
627 .collect();
628
629 let style = match file.metadata_str("tokenizer.ggml.model") {
630 Some("gemma4") => BpeEncodingStyle::SpmWhitespace,
631 _ => BpeEncodingStyle::Gpt2,
632 };
633
634 let mut merge_rank = std::collections::HashMap::new();
635 if let Some(ferrox_gguf::GgufValue::Array(items)) = file.metadata("tokenizer.ggml.merges") {
636 for (rank, item) in items.iter().enumerate() {
637 if let Some(s) = item.as_str() {
638 if let Some((a, b)) = split_bpe_merge_pair(s, style) {
639 merge_rank.insert((a, b), rank);
640 }
641 }
642 }
643 }
644
645 let (byte_to_unicode, unicode_to_byte) = gpt2_byte_to_unicode();
646 let pretokenize_pattern = match style {
647 // Keyed on the checkpoint's own `tokenizer.ggml.pre`, which
648 // was previously read only to decide BOS prepending.
649 BpeEncodingStyle::Gpt2 => {
650 pretokenize::regex_for(file.metadata_str("tokenizer.ggml.pre").unwrap_or(""))
651 }
652 BpeEncodingStyle::SpmWhitespace => pretokenize::newline_regex(),
653 };
654 let special_tokens = load_special_tokens(file, &id_to_token);
655
656 Ok(GgufBpeTokenizer {
657 token_to_id,
658 id_to_token,
659 merge_rank,
660 byte_to_unicode,
661 unicode_to_byte,
662 special_tokens,
663 pretokenize_pattern,
664 style,
665 })
666 }
667
668 pub fn vocab_size(&self) -> usize {
669 self.id_to_token.len()
670 }
671
672 pub fn has_merges(&self) -> bool {
673 !self.merge_rank.is_empty()
674 }
675
676 /// Greedy BPE merge over one pre-split chunk. GPT-2 style remaps
677 /// bytes through `byte_to_unicode`; Gemma-4 style merges raw UTF-8
678 /// codepoints (after `" "` → `▁` escaping in `encode`).
679 pub fn encode_word(&self, word: &str) -> Vec<u32> {
680 let mut pieces: Vec<String> = match self.style {
681 BpeEncodingStyle::Gpt2 => word
682 .bytes()
683 .map(|b| self.byte_to_unicode[b as usize].to_string())
684 .collect(),
685 BpeEncodingStyle::SpmWhitespace => word.chars().map(|c| c.to_string()).collect(),
686 };
687 if pieces.is_empty() {
688 return Vec::new();
689 }
690
691 loop {
692 let mut best: Option<(usize, usize)> = None; // (rank, index)
693 for i in 0..pieces.len().saturating_sub(1) {
694 if let Some(&rank) = self
695 .merge_rank
696 .get(&(pieces[i].clone(), pieces[i + 1].clone()))
697 {
698 if best.map(|(r, _)| rank < r).unwrap_or(true) {
699 best = Some((rank, i));
700 }
701 }
702 }
703 match best {
704 Some((_, i)) => {
705 let merged = format!("{}{}", pieces[i], pieces[i + 1]);
706 pieces.splice(i..=i + 1, [merged]);
707 }
708 None => break,
709 }
710 }
711
712 pieces.iter().flat_map(|p| self.piece_to_ids(p)).collect()
713 }
714
715 fn piece_to_ids(&self, piece: &str) -> Vec<u32> {
716 if let Some(&id) = self.token_to_id.get(piece) {
717 return vec![id];
718 }
719 match self.style {
720 BpeEncodingStyle::Gpt2 => {
721 // Fall back to first remapped-byte character (GPT-2 base).
722 piece
723 .chars()
724 .next()
725 .and_then(|c| self.token_to_id.get(&c.to_string()))
726 .copied()
727 .map(|id| vec![id])
728 .unwrap_or_else(|| vec![0])
729 }
730 BpeEncodingStyle::SpmWhitespace => {
731 // llama.cpp non-byte-encoded BPE: unknown pieces → `<0xXX>`.
732 piece
733 .bytes()
734 .filter_map(|b| {
735 let hex = format!("<0x{b:02X}>");
736 self.token_to_id.get(&hex).copied()
737 })
738 .collect()
739 }
740 }
741 }
742
743 /// Encodes text: specials first, then style-specific pretokenize +
744 /// `encode_word`. Gemma-4 escapes spaces to `▁` and splits only on
745 /// newlines; newline-only chunks look up the whole string in vocab
746 /// (multi-newline tokens) before BPE.
747 pub fn encode(&self, text: &str) -> Vec<u32> {
748 split_on_special_tokens(text, &self.special_tokens)
749 .into_iter()
750 .flat_map(|seg| -> Vec<u32> {
751 match seg {
752 TextOrSpecial::Special(id) => vec![id],
753 TextOrSpecial::Text(t) => self.encode_text_run(t),
754 }
755 })
756 .collect()
757 }
758
759 /// `pretokenize::split_with_gaps` rather than a bare `find_iter`
760 /// loop: the text BETWEEN matches is input too, and llama.cpp emits
761 /// it as its own chunk. Dropping it lost tabs, NBSPs, form feeds and
762 /// interior newlines out of the middle of every OLMo prompt.
763 fn encode_text_run(&self, text: &str) -> Vec<u32> {
764 match self.style {
765 BpeEncodingStyle::Gpt2 => pretokenize::split_with_gaps(&self.pretokenize_pattern, text)
766 .into_iter()
767 .flat_map(|chunk| self.encode_word(chunk))
768 .collect(),
769 BpeEncodingStyle::SpmWhitespace => {
770 let escaped: String = text
771 .chars()
772 .map(|c| if c == ' ' { SPM_SPACE } else { c })
773 .collect();
774 // Manual newline split (O(n)); avoids regex stack issues
775 // on long non-newline spans (llama.cpp PR #21587).
776 let mut out = Vec::new();
777 let bytes = escaped.as_bytes();
778 let mut i = 0usize;
779 while i < bytes.len() {
780 let is_nl = bytes[i] == b'\n';
781 let mut j = i + 1;
782 while j < bytes.len() && (bytes[j] == b'\n') == is_nl {
783 j += 1;
784 }
785 // Safe: we only split on ASCII `\n`, so `i..j` is UTF-8.
786 let word = std::str::from_utf8(&bytes[i..j]).expect("newline split keeps utf8");
787 if is_nl {
788 if let Some(&id) = self.token_to_id.get(word) {
789 out.push(id);
790 } else {
791 out.extend(self.encode_word(word));
792 }
793 } else {
794 out.extend(self.encode_word(word));
795 }
796 i = j;
797 }
798 out
799 }
800 }
801 }
802
803 /// GPT-2: remapped unicode → bytes. Gemma-4: unescape `▁` → space and
804 /// expand `<0xXX>` byte tokens (same shape as SPM decode).
805 pub fn decode(&self, ids: &[u32]) -> String {
806 String::from_utf8_lossy(&self.decode_bytes(ids)).into_owned()
807 }
808
809 /// The raw bytes, before any UTF-8 decision is made about them.
810 /// See [`GgufBpeTokenizer::decode`] and `ferrox_server::utf8_stream`.
811 pub fn decode_bytes(&self, ids: &[u32]) -> Vec<u8> {
812 match self.style {
813 BpeEncodingStyle::Gpt2 => {
814 let bytes: Vec<u8> = ids
815 .iter()
816 .filter_map(|&id| self.id_to_token.get(id as usize))
817 .flat_map(|token| token.chars())
818 .filter_map(|c| self.unicode_to_byte.get(&c).copied())
819 .collect();
820 bytes
821 }
822 BpeEncodingStyle::SpmWhitespace => {
823 let mut bytes: Vec<u8> = Vec::new();
824 for &id in ids {
825 let Some(token) = self.id_to_token.get(id as usize) else {
826 continue;
827 };
828 if let Some(b) = spm_byte_fallback_value(token) {
829 bytes.push(b);
830 } else {
831 bytes.extend(token.replace(SPM_SPACE, " ").into_bytes());
832 }
833 }
834 bytes
835 }
836 }
837 }
838}
839
840/// Split a GGUF merge line `"left right"` into pair. Gemma-4 / llama.cpp
841/// use `find(' ', 1)` on the raw byte string so a leading ASCII space in
842/// `left` is not the separator; search from byte 1 (not char 1) to match.
843fn split_bpe_merge_pair(s: &str, style: BpeEncodingStyle) -> Option<(String, String)> {
844 match style {
845 BpeEncodingStyle::Gpt2 => s
846 .split_once(' ')
847 .map(|(a, b)| (a.to_string(), b.to_string())),
848 BpeEncodingStyle::SpmWhitespace => {
849 let bytes = s.as_bytes();
850 if bytes.len() < 2 {
851 return None;
852 }
853 let pos = bytes[1..].iter().position(|&b| b == b' ')? + 1;
854 // ASCII space is always a UTF-8 char boundary.
855 Some((s[..pos].to_string(), s[pos + 1..].to_string()))
856 }
857 }
858}
859
860fn spm_byte_fallback_value(token: &str) -> Option<u8> {
861 let hex = token.strip_prefix("<0x")?.strip_suffix('>')?;
862 if hex.len() != 2 {
863 return None;
864 }
865 u8::from_str_radix(hex, 16).ok()
866}
867
868/// A real SentencePiece-BPE tokenizer, built from a GGUF file's
869/// `tokenizer.ggml.tokens` + `tokenizer.ggml.scores` metadata
870/// (`tokenizer.ggml.model == "llama"` in GGUF's convention -- this is
871/// SentencePiece's *BPE* model type, not its Unigram model type,
872/// despite both living under the umbrella term "SentencePiece"; the
873/// distinction matters because the encode algorithms are different).
874///
875/// # How this differs from `GgufBpeTokenizer`
876///
877/// `GgufBpeTokenizer` implements GPT2-style BPE: a fixed merge-rank
878/// table applied greedily left-to-right after GPT2's own
879/// byte-to-unicode remap and regex pre-tokenization. SentencePiece-BPE
880/// vocabularies (used by the original LLaMA, and generally any model
881/// whose GGUF reports `tokenizer.ggml.model = "llama"`) don't ship a
882/// merge-rank table at all -- instead every vocabulary entry carries a
883/// score, and encoding works by repeatedly merging whichever *currently
884/// adjacent* pair of symbols forms the highest-scoring known vocabulary
885/// piece, using a priority queue over merge candidates (this is the
886/// `llm_tokenizer_spm` algorithm from llama.cpp, reimplemented here
887/// independently against the public GGUF metadata, not from llama.cpp
888/// source). Preprocessing replaces spaces with `▁` (U+2581) and adds a
889/// leading `▁`, matching SentencePiece's own convention, rather than
890/// GPT2's byte-to-unicode remap.
891///
892/// # A real bug found and fixed while building this
893///
894/// The first implementation of this algorithm checked merge-candidate
895/// validity by adjacency alone (`is this pair still directly next to
896/// each other in the linked list?`). That's necessary but not
897/// sufficient: a symbol's *content* can change between when a
898/// candidate merge is queued and when it's popped, if that symbol was
899/// itself the survivor of a *different* merge in the meantime, while
900/// staying adjacency-valid at the same list position. The fix is to
901/// also store the exact left/right text expected at queue time and
902/// re-check it at pop time, discarding (not re-queuing) any candidate
903/// whose content has since changed. This was caught immediately by
904/// testing against real reference data (see below) rather than by
905/// code review -- the bug produced plausible-looking but wrong output
906/// ("Hello world" tokenized as 6 pieces instead of the correct 2)
907/// which would have been easy to miss without a real ground truth to
908/// check against.
909///
910/// # Verification
911///
912/// Tested against `tests/fixtures/llama-spm-vocab.gguf` (downloaded
913/// directly from `ggml-org/llama.cpp`'s own repository, the real
914/// LLaMA-1/2 tokenizer vocabulary) and its accompanying
915/// `.gguf.inp`/`.gguf.out` files -- llama.cpp's own CI test corpus of
916/// 45 input strings and their exact expected token ID sequences,
917/// covering ASCII, whitespace runs, control characters, CJK/Khmer/
918/// Vietnamese text, emoji, and byte-fallback. All 45 match exactly.
919pub struct GgufSpmTokenizer {
920 /// The vocabulary and its per-token scores, checked against each
921 /// other at load -- see [`scored_vocab`]. Shared with
922 /// [`GgufUnigramTokenizer`] so that the score lookup exists once
923 /// rather than once per tokenizer.
924 vocab: ScoredVocab,
925 /// Control/user-defined tokens from `tokenizer.ggml.token_type`,
926 /// matched as atomic substrings before normal merging -- see
927 /// `split_on_special_tokens`.
928 special_tokens: Vec<(String, u32)>,
929 /// `tokenizer.ggml.add_space_prefix` (llama.cpp default `true` for
930 /// SPM). When true, each normal-text run after a special (and the
931 /// start of the string) is prefixed with SentencePiece `▁`. Gemma
932 /// GGUFs set this to `false` so `<start_of_turn>user` encodes as
933 /// `[start_of_turn, user]` not `[start_of_turn, ▁user]`.
934 add_space_prefix: bool,
935}
936
937/// A merge candidate in the priority queue: pairs of currently-adjacent
938/// symbol positions, ordered by score (highest first), with ties
939/// broken in favor of the LEFTMOST candidate (smallest `left` symbol
940/// index) -- confirmed against llama.cpp's own real
941/// `llm_bigram_spm::comparator` (`src/llama-vocab.cpp`):
942/// `(l.score < r.score) || (l.score == r.score && l.left > r.left)`.
943/// This matters in practice: many real GGUF vocabularies carry an
944/// exact-zero score for every merge-derived (non-base) piece, so
945/// large stretches of a real tokenization are decided by this tie
946/// rule alone, not by score magnitude. `insertion_order` is kept only
947/// as a last-resort deterministic tiebreak for the (real, possible)
948/// case of two candidates tied on both score AND left index.
949struct SpmMergeCandidate {
950 score: f32,
951 left: usize,
952 right: usize,
953 insertion_order: u64,
954 expected_left_text: String,
955 expected_right_text: String,
956}
957
958impl PartialEq for SpmMergeCandidate {
959 fn eq(&self, other: &Self) -> bool {
960 self.score == other.score && self.insertion_order == other.insertion_order
961 }
962}
963impl Eq for SpmMergeCandidate {}
964impl PartialOrd for SpmMergeCandidate {
965 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
966 Some(self.cmp(other))
967 }
968}
969impl Ord for SpmMergeCandidate {
970 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
971 // BinaryHeap is a max-heap: higher score must compare Greater.
972 // On an exact score tie, the LEFTMOST candidate (smaller
973 // `left`) must compare Greater, so it pops first -- hence the
974 // reversed comparison on `left`. A final tie on `left` too
975 // (impossible for real distinct bigrams, kept for a total
976 // order) falls back to earliest-queued-first.
977 self.score
978 .partial_cmp(&other.score)
979 .unwrap_or(std::cmp::Ordering::Equal)
980 .then_with(|| other.left.cmp(&self.left))
981 .then_with(|| other.insertion_order.cmp(&self.insertion_order))
982 }
983}
984
985impl GgufSpmTokenizer {
986 pub fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Result<Self, TokenizerLoadError> {
987 let vocab = ScoredVocab::from_gguf(file)?;
988 let special_tokens = load_special_tokens(file, vocab.tokens());
989 // llama.cpp defaults SPM `add_space_prefix` to true, then lets
990 // `tokenizer.ggml.add_space_prefix` override (Gemma sets false).
991 let add_space_prefix = match file.metadata("tokenizer.ggml.add_space_prefix") {
992 Some(ferrox_gguf::GgufValue::Bool(v)) => *v,
993 _ => true,
994 };
995
996 Ok(GgufSpmTokenizer {
997 vocab,
998 special_tokens,
999 add_space_prefix,
1000 })
1001 }
1002
1003 pub fn vocab_size(&self) -> usize {
1004 self.vocab.len()
1005 }
1006
1007 /// Encodes `text` using SentencePiece's space-replacement
1008 /// convention (`' '` -> `▁`, plus a leading `▁`) and the
1009 /// score-prioritized pairwise-merge algorithm described in this
1010 /// struct's doc comment. Characters with no direct vocabulary
1011 /// entry are expanded to UTF-8 byte-fallback tokens (`<0xXX>`,
1012 /// which every real SentencePiece-BPE vocabulary includes for
1013 /// exactly this purpose) before merging begins.
1014 ///
1015 /// Control/user-defined tokens (chat-template markers like
1016 /// `<|user|>`) are first carved out as atomic substrings via
1017 /// `split_on_special_tokens`, matching real llama.cpp's
1018 /// `tokenizer_st_partition` behavior, so they're never shattered
1019 /// into byte-fallback pieces; each remaining raw-text run between
1020 /// them is merged independently. A leading dummy `▁` is applied to
1021 /// a run only when [`Self::add_space_prefix`] is true (llama.cpp
1022 /// `add_space_prefix && is_prev_special` for each fragment).
1023 pub fn encode(&self, text: &str) -> Vec<u32> {
1024 split_on_special_tokens(text, &self.special_tokens)
1025 .into_iter()
1026 .flat_map(|seg| match seg {
1027 TextOrSpecial::Special(id) => vec![id],
1028 TextOrSpecial::Text(t) => self.encode_normal_run(t),
1029 })
1030 .collect()
1031 }
1032
1033 fn encode_normal_run(&self, text: &str) -> Vec<u32> {
1034 let replaced: String = text
1035 .chars()
1036 .map(|c| if c == ' ' { '\u{2581}' } else { c })
1037 .collect();
1038 let normalized = if self.add_space_prefix {
1039 format!("\u{2581}{replaced}")
1040 } else {
1041 replaced
1042 };
1043
1044 let mut symbols: Vec<String> = Vec::new();
1045 for ch in normalized.chars() {
1046 let s = ch.to_string();
1047 if self.vocab.id_of(&s).is_some() {
1048 symbols.push(s);
1049 } else {
1050 for byte in s.as_bytes() {
1051 symbols.push(format!("<0x{byte:02X}>"));
1052 }
1053 }
1054 }
1055
1056 let n = symbols.len();
1057 if n == 0 {
1058 return Vec::new();
1059 }
1060 let mut nexts: Vec<Option<usize>> = (1..=n)
1061 .map(|i| if i < n { Some(i) } else { None })
1062 .collect();
1063 let mut prevs: Vec<Option<usize>> = (0..n)
1064 .map(|i| if i == 0 { None } else { Some(i - 1) })
1065 .collect();
1066 let mut alive = vec![true; n];
1067
1068 let mut heap: std::collections::BinaryHeap<SpmMergeCandidate> =
1069 std::collections::BinaryHeap::new();
1070 let mut insertion_order = 0u64;
1071
1072 let try_add_merge = |l: Option<usize>,
1073 r: Option<usize>,
1074 symbols: &[String],
1075 heap: &mut std::collections::BinaryHeap<SpmMergeCandidate>,
1076 insertion_order: &mut u64| {
1077 let (Some(l), Some(r)) = (l, r) else { return };
1078 let merged = format!("{}{}", symbols[l], symbols[r]);
1079 // `lookup` hands back the score with the id it belongs to,
1080 // so there is no second, separately-written id-to-score
1081 // step here for the Unigram twin to spell differently.
1082 if let Some((_id, score)) = self.vocab.lookup(&merged) {
1083 *insertion_order += 1;
1084 heap.push(SpmMergeCandidate {
1085 score,
1086 left: l,
1087 right: r,
1088 insertion_order: *insertion_order,
1089 expected_left_text: symbols[l].clone(),
1090 expected_right_text: symbols[r].clone(),
1091 });
1092 }
1093 };
1094
1095 for i in 0..n.saturating_sub(1) {
1096 try_add_merge(
1097 Some(i),
1098 Some(i + 1),
1099 &symbols,
1100 &mut heap,
1101 &mut insertion_order,
1102 );
1103 }
1104
1105 while let Some(candidate) = heap.pop() {
1106 let (l, r) = (candidate.left, candidate.right);
1107 if !alive[l] || !alive[r] {
1108 continue;
1109 }
1110 if nexts[l] != Some(r) {
1111 continue;
1112 }
1113 if symbols[l] != candidate.expected_left_text
1114 || symbols[r] != candidate.expected_right_text
1115 {
1116 continue; // stale: content changed since this candidate was queued
1117 }
1118
1119 symbols[l] = format!("{}{}", symbols[l], symbols[r]);
1120 alive[r] = false;
1121 nexts[l] = nexts[r];
1122 if let Some(next_of_r) = nexts[r] {
1123 prevs[next_of_r] = Some(l);
1124 }
1125
1126 try_add_merge(prevs[l], Some(l), &symbols, &mut heap, &mut insertion_order);
1127 try_add_merge(Some(l), nexts[l], &symbols, &mut heap, &mut insertion_order);
1128 }
1129
1130 let mut result = Vec::new();
1131 let mut i = Some(0usize);
1132 while let Some(idx) = i {
1133 if alive[idx] {
1134 result.push(self.vocab.id_of(&symbols[idx]).unwrap_or(0));
1135 }
1136 i = nexts[idx];
1137 }
1138 result
1139 }
1140
1141 /// Reverses a real SentencePiece byte-fallback token (`<0xXX>`,
1142 /// uppercase hex -- the exact format `encode` produces, see its doc
1143 /// comment) back to the raw byte it represents. `None` for any
1144 /// other (normal vocabulary) token.
1145 fn byte_fallback_value(token: &str) -> Option<u8> {
1146 let hex = token.strip_prefix("<0x")?.strip_suffix('>')?;
1147 if hex.len() != 2 {
1148 return None;
1149 }
1150 u8::from_str_radix(hex, 16).ok()
1151 }
1152
1153 pub fn decode(&self, ids: &[u32]) -> String {
1154 String::from_utf8_lossy(&self.decode_bytes(ids)).into_owned()
1155 }
1156
1157 /// The raw bytes, before any UTF-8 decision is made about them.
1158 ///
1159 /// The comment below is about several `<0xXX>` tokens inside ONE
1160 /// call. The same character can just as easily straddle the
1161 /// boundary BETWEEN two calls, which is why this is public: a
1162 /// per-token caller has to do its own buffering, and it cannot do
1163 /// that from a `String` that has already been made lossy.
1164 pub fn decode_bytes(&self, ids: &[u32]) -> Vec<u8> {
1165 // Byte-fallback tokens must be collected as raw bytes (not
1166 // pushed as their 6-character literal token string) and
1167 // UTF-8-decoded together with the rest -- a single real
1168 // multi-byte UTF-8 character can be split across several
1169 // consecutive `<0xXX>` tokens, each individually invalid UTF-8
1170 // on its own. Found and fixed via real-world testing (a real
1171 // downloaded checkpoint's generated text was printing literal
1172 // "<0x0A>" instead of a newline).
1173 let mut bytes: Vec<u8> = Vec::new();
1174 for &id in ids {
1175 let Some(token) = self.vocab.token(id) else {
1176 continue;
1177 };
1178 if let Some(b) = Self::byte_fallback_value(token) {
1179 bytes.push(b);
1180 } else {
1181 bytes.extend(token.replace('\u{2581}', " ").into_bytes());
1182 }
1183 }
1184 bytes
1185 }
1186}
1187
1188/// A real SentencePiece Unigram (ULM) tokenizer, built from a GGUF
1189/// file's `tokenizer.ggml.tokens` + `tokenizer.ggml.scores` metadata
1190/// (`tokenizer.ggml.model == "t5"` in GGUF's convention -- confirmed
1191/// directly against llama.cpp's real vocab-type-loading source
1192/// (`src/llama-vocab.cpp`'s `tokenizer_model == "t5"` case), not
1193/// guessed; T5-family models are the real-world users of this tag).
1194///
1195/// # How this differs from `GgufSpmTokenizer`
1196///
1197/// Both are "SentencePiece" vocabularies, but with entirely different
1198/// encoding algorithms: `GgufSpmTokenizer` implements SentencePiece's
1199/// *BPE* model type (a merge-rank table, greedy pairwise merging).
1200/// Unigram has no merge table at all -- every vocabulary entry carries
1201/// a real log-probability score, and the *optimal* (highest total
1202/// log-probability) segmentation of the whole input is found by a
1203/// forward Viterbi dynamic-programming pass: `best[j]` is the highest-
1204/// scoring way to reach position `j`, computed as
1205/// `max over every vocabulary piece P that ends at j` of
1206/// `best[j - len(P)] + score(P)`. This is reimplemented independently
1207/// against real llama.cpp source read for this purpose
1208/// (`src/llama-vocab.cpp`'s `llm_tokenizer_ugm_session` class) -- not
1209/// copied, but the algorithm (including its unknown-token fallback
1210/// score and tie-breaking) is transcribed deliberately rather than
1211/// guessed, since a plausible-looking-but-wrong Viterbi variant would
1212/// silently produce different segmentations than the model was
1213/// actually trained to expect.
1214///
1215/// Preprocessing matches `GgufSpmTokenizer`'s exactly (`' '` -> `▁`
1216/// U+2581, plus a leading `▁`) -- both are real SentencePiece
1217/// conventions, this being the default `add_dummy_prefix=true` /
1218/// `treat_whitespace_as_suffix=false` behavior. Real SentencePiece
1219/// models can optionally ship a `precompiled_charsmap` (an auxiliary
1220/// normalization table, e.g. NFKC folding) via GGUF's
1221/// `tokenizer.ggml.precompiled_charsmap` key; this implementation does
1222/// not read or apply it (a real, disclosed scope decision, not an
1223/// oversight -- llama.cpp's own loader treats this key as optional
1224/// too, falling back to plain UTF-8 handling when absent).
1225///
1226/// Unlike `GgufSpmTokenizer`, Unigram has no byte-fallback token
1227/// convention in the real reference implementation: a character with
1228/// no matching vocabulary entry is scored via a fixed unknown-token
1229/// penalty (`min_score - 10.0`, matching the real
1230/// `unknown_token_score_penalty` constant) and mapped to the
1231/// vocabulary's real unknown-token id
1232/// (`tokenizer.ggml.unknown_token_id`, defaulting to `0` if absent)
1233/// rather than expanded into raw bytes.
1234///
1235/// Real user-defined/control tokens (GGUF's `tokenizer.ggml.token_type`
1236/// metadata) are not yet given longest-match priority over the
1237/// Viterbi pass the way the real reference implementation does --
1238/// deferred alongside `GgufSpmTokenizer`'s equivalent gap
1239/// (chat-template special-token handling), rather
1240/// than solved once per tokenizer independently.
1241///
1242/// # Verification
1243///
1244/// Cross-validated against a real Unigram model trained with the real
1245/// `sentencepiece` Python library (not a hand-built fixture) --
1246/// exact-match token-id-sequence comparison across ASCII text,
1247/// mixed-case, punctuation, digit runs, repeated whitespace, and
1248/// non-ASCII (accented Latin) text, plus text containing no matching
1249/// vocabulary substrings at all (exercising the unknown-token
1250/// fallback repeatedly).
1251pub struct GgufUnigramTokenizer {
1252 /// The vocabulary and its per-token scores, checked against each
1253 /// other at load -- see [`scored_vocab`]. This used to be three
1254 /// fields spelled out again here, with the Viterbi pass below
1255 /// indexing `scores[id]` raw while the SPM twin guarded the same
1256 /// lookup: a short `tokenizer.ggml.scores` array loaded and then
1257 /// panicked once per request (issue #34).
1258 vocab: ScoredVocab,
1259 unk_id: u32,
1260 /// Longest vocabulary piece, in characters -- bounds the Viterbi
1261 /// pass's inner loop so it only ever tries substrings that could
1262 /// possibly be a real vocabulary entry, rather than every possible
1263 /// substring length.
1264 max_piece_chars: usize,
1265 /// `min_score - 10.0`, the real fixed penalty score assigned to the
1266 /// single-character "unknown token" fallback transition, matching
1267 /// the real `unknown_token_score_penalty` constant.
1268 unknown_token_score: f64,
1269 /// Control/user-defined tokens from `tokenizer.ggml.token_type`,
1270 /// matched as atomic substrings before the Viterbi pass -- see
1271 /// `split_on_special_tokens`.
1272 special_tokens: Vec<(String, u32)>,
1273}
1274
1275impl GgufUnigramTokenizer {
1276 pub fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Result<Self, TokenizerLoadError> {
1277 let vocab = ScoredVocab::from_gguf(file)?;
1278
1279 let unk_id = file
1280 .metadata("tokenizer.ggml.unknown_token_id")
1281 .and_then(|v| v.as_u64())
1282 .map(|v| v as u32)
1283 .unwrap_or(0);
1284
1285 let max_piece_chars = vocab
1286 .tokens()
1287 .iter()
1288 .map(|t| t.chars().count())
1289 .max()
1290 .unwrap_or(1)
1291 .max(1);
1292 // A real score, not `+INFINITY`: `ScoredVocab` refuses an empty
1293 // vocabulary, so this fold always sees at least one entry.
1294 let unknown_token_score = vocab.min_score() as f64 - 10.0;
1295 let special_tokens = load_special_tokens(file, vocab.tokens());
1296
1297 Ok(GgufUnigramTokenizer {
1298 vocab,
1299 unk_id,
1300 max_piece_chars,
1301 unknown_token_score,
1302 special_tokens,
1303 })
1304 }
1305
1306 pub fn vocab_size(&self) -> usize {
1307 self.vocab.len()
1308 }
1309
1310 /// Encodes `text` via the real forward-Viterbi Unigram algorithm
1311 /// described in this struct's doc comment. Score accumulation uses
1312 /// `f64` (matching the real reference's `double score_sum`), since
1313 /// summing many `f32` log-probabilities over a long input can
1314 /// accumulate enough rounding error to flip which of two
1315 /// near-tied segmentations looks best.
1316 ///
1317 /// Control/user-defined tokens (chat-template markers) are first
1318 /// carved out as atomic substrings via `split_on_special_tokens`;
1319 /// each remaining raw-text run is Viterbi-segmented independently.
1320 pub fn encode(&self, text: &str) -> Vec<u32> {
1321 split_on_special_tokens(text, &self.special_tokens)
1322 .into_iter()
1323 .flat_map(|seg| match seg {
1324 TextOrSpecial::Special(id) => vec![id],
1325 TextOrSpecial::Text(t) => self.encode_normal_run(t),
1326 })
1327 .collect()
1328 }
1329
1330 fn encode_normal_run(&self, text: &str) -> Vec<u32> {
1331 // Real SentencePiece's default normalization rule ("nmt_nfkc",
1332 // used by the overwhelming majority of trained Unigram models
1333 // unless a model deliberately opts into the plain "identity"
1334 // rule) collapses any run of whitespace to a single space and
1335 // trims leading/trailing whitespace, before the dummy-prefix +
1336 // space->▁ substitution below -- confirmed empirically against
1337 // a real trained model, not assumed (a naive per-character
1338 // space->▁ substitution, `GgufSpmTokenizer`'s approach, gives
1339 // a different, wrong segmentation here: one `▁` per space
1340 // instead of one per whitespace *run*). A GGUF file does not
1341 // carry its normalization rule name as its own metadata key,
1342 // so this implements the common default rather than something
1343 // read from the file's own specific config.
1344 let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
1345 let replaced: String = collapsed
1346 .chars()
1347 .map(|c| if c == ' ' { '\u{2581}' } else { c })
1348 .collect();
1349 let normalized = format!("\u{2581}{replaced}");
1350 let chars: Vec<char> = normalized.chars().collect();
1351 let n = chars.len();
1352 if n == 0 {
1353 return Vec::new();
1354 }
1355
1356 struct Best {
1357 token_id: u32,
1358 from: usize,
1359 score: f64,
1360 }
1361 let mut dp: Vec<Best> = (0..=n)
1362 .map(|_| Best {
1363 token_id: 0,
1364 from: 0,
1365 score: f64::NEG_INFINITY,
1366 })
1367 .collect();
1368 dp[0].score = 0.0;
1369
1370 for i in 0..n {
1371 if dp[i].score == f64::NEG_INFINITY {
1372 continue; // unreachable position; never happens since the
1373 // unknown-token fallback below always advances by 1
1374 }
1375 let base = dp[i].score;
1376 let max_len = self.max_piece_chars.min(n - i);
1377 for len in 1..=max_len {
1378 let piece: String = chars[i..i + len].iter().collect();
1379 // One lookup for id and score together: this line used
1380 // to index `self.scores[id]` on its own, which is the
1381 // out-of-bounds panic of issue #34.
1382 if let Some((id, score)) = self.vocab.lookup(&piece) {
1383 let candidate = base + score as f64;
1384 let j = i + len;
1385 if candidate > dp[j].score {
1386 dp[j] = Best {
1387 token_id: id,
1388 from: i,
1389 score: candidate,
1390 };
1391 }
1392 }
1393 }
1394 let j = i + 1;
1395 let candidate = base + self.unknown_token_score;
1396 if candidate > dp[j].score {
1397 dp[j] = Best {
1398 token_id: self.unk_id,
1399 from: i,
1400 score: candidate,
1401 };
1402 }
1403 }
1404
1405 let mut result = Vec::new();
1406 let mut pos = n;
1407 while pos > 0 {
1408 result.push(dp[pos].token_id);
1409 pos = dp[pos].from;
1410 }
1411 result.reverse();
1412 result
1413 }
1414
1415 /// Reverses `encode`'s `' '` <-> `▁` convention. Unigram has no
1416 /// byte-fallback token convention (see this struct's doc comment),
1417 /// so every token here is decoded as plain text.
1418 pub fn decode(&self, ids: &[u32]) -> String {
1419 let mut out = String::new();
1420 for &id in ids {
1421 if let Some(token) = self.vocab.token(id) {
1422 out.push_str(&token.replace('\u{2581}', " "));
1423 }
1424 }
1425 out
1426 }
1427
1428 /// The raw bytes. Unigram has no byte-fallback convention, so every
1429 /// token is already whole text and this can never split a
1430 /// character -- it exists so a per-token caller can treat every
1431 /// tokenizer the same way.
1432 pub fn decode_bytes(&self, ids: &[u32]) -> Vec<u8> {
1433 self.decode(ids).into_bytes()
1434 }
1435}
1436
1437#[cfg(test)]
1438mod gguf_vocab_tests {
1439 use super::*;
1440
1441 fn load_real_fixture() -> GgufBpeTokenizer {
1442 let path = concat!(
1443 env!("CARGO_MANIFEST_DIR"),
1444 "/../../tests/fixtures/llama-bpe-vocab.gguf"
1445 );
1446 let file = ferrox_gguf::GgufFile::open(path).expect("real vocab fixture must open");
1447 GgufBpeTokenizer::from_gguf(&file).expect("real vocab fixture must parse as a tokenizer")
1448 }
1449
1450 #[test]
1451 fn loads_real_downloaded_llama_bpe_vocab() {
1452 let tok = load_real_fixture();
1453 // llama-bpe's real vocab is on the order of 128k tokens; assert
1454 // a loose lower bound so this test doesn't depend on an exact
1455 // upstream count.
1456 assert!(
1457 tok.vocab_size() > 100_000,
1458 "vocab_size={}",
1459 tok.vocab_size()
1460 );
1461 assert!(tok.has_merges(), "llama-bpe vocab ships a real merge table");
1462 }
1463
1464 #[test]
1465 fn decode_of_known_ids_is_stable() {
1466 let tok = load_real_fixture();
1467 // token id 0 exists in every llama-bpe vocab; decoding it must
1468 // not panic and must return the same string every call.
1469 let a = tok.decode(&[0]);
1470 let b = tok.decode(&[0]);
1471 assert_eq!(a, b);
1472 }
1473
1474 #[test]
1475 fn encode_word_never_panics_on_arbitrary_input() {
1476 let tok = load_real_fixture();
1477 for word in ["hello", "", "a", "the quick brown fox", "\u{1f980}"] {
1478 let ids = tok.encode_word(word);
1479 // round-trip through decode must not panic either
1480 let _ = tok.decode(&ids);
1481 }
1482 }
1483
1484 #[test]
1485 fn encode_sentence_round_trips_through_real_vocab() {
1486 let tok = load_real_fixture();
1487 for sentence in [
1488 "the quick brown fox jumps over the lazy dog",
1489 "Hello, World! 123",
1490 "ferrox is a pure-Rust inference engine.",
1491 ] {
1492 let ids = tok.encode(sentence);
1493 assert!(!ids.is_empty());
1494 let decoded = tok.decode(&ids);
1495 assert_eq!(
1496 decoded, sentence,
1497 "full sentence encode/decode through the pre-tokenizer must reproduce the input exactly"
1498 );
1499 }
1500 }
1501
1502 #[test]
1503 fn pretokenizer_splits_on_word_boundaries_not_mid_word() {
1504 let tok = load_real_fixture();
1505 // "cat dog" pre-tokenizes into ["cat", " dog"] (GPT2 convention:
1506 // leading space attaches to the following word). Encoding the
1507 // full sentence and encoding those two pieces separately with
1508 // encode_word must produce the exact same id sequence -- if
1509 // ferrox were still doing one giant merge over the whole
1510 // string (the pre-pretokenizer behavior), a cross-boundary
1511 // merge could produce a different sequence.
1512 let combined = tok.encode("cat dog");
1513 let mut separate = tok.encode_word("cat");
1514 separate.extend(tok.encode_word(" dog"));
1515 assert_eq!(
1516 combined, separate,
1517 "pre-tokenized sentence encoding must match word-by-word encoding at real word boundaries"
1518 );
1519 }
1520
1521 #[test]
1522 fn pretokenizer_keeps_contractions_as_gpt2_does() {
1523 let tok = load_real_fixture();
1524 // GPT2's pattern treats "'t" as its own pre-token (from the
1525 // 's|'t|'re|... alternatives), splitting "don't" into "don" +
1526 // "'t" pieces before BPE, not "do" + "n't" or a single
1527 // 6-character chunk. Confirm the pre-tokenizer actually
1528 // produces that split.
1529 let pieces: Vec<&str> = tok
1530 .pretokenize_pattern
1531 .find_iter("don't")
1532 .map(|m| m.expect("a fixed pattern cannot fail").as_str())
1533 .collect();
1534 assert_eq!(pieces, vec!["don", "'t"]);
1535 }
1536
1537 /// **Defect 3, at the tokenizer level.** The pre-tokenizer arms
1538 /// whose pattern has no catch-all leave text unmatched, and the
1539 /// encoder used to drop it: `find_iter(..).flat_map(..)` sees only
1540 /// the matches. That is silent data loss on the PROMPT, not a
1541 /// different segmentation, so the guard is a byte-for-byte
1542 /// round-trip rather than an id list.
1543 ///
1544 /// The fixture ships `pre = llama-bpe`, whose pattern ends in a
1545 /// catch-all `\s+` and so has no gaps to lose. The OLMo arm is
1546 /// swapped in to reproduce the checkpoint that actually broke —
1547 /// only the split rule changes, the vocabulary and merges stay real.
1548 #[test]
1549 fn every_byte_survives_encoding_on_an_arm_with_unmatched_gaps() {
1550 let mut tok = load_real_fixture();
1551 tok.pretokenize_pattern = super::pretokenize::regex_for("olmo");
1552
1553 // A tab, an NBSP, an interior newline, a form feed and a
1554 // trailing tab: every one of these was unmatched by the OLMo
1555 // pattern and vanished from the prompt.
1556 for text in [
1557 "a\tb\u{a0}c\nd\u{c}e",
1558 "\tif x:\n\t\treturn 1\n\t \treturn 2\n",
1559 "para one\n\npara two\n",
1560 "line one\r\nline two\r\n",
1561 ] {
1562 let ids = tok.encode(text);
1563 assert_eq!(
1564 tok.decode(&ids),
1565 text,
1566 "encoding {text:?} on the olmo arm lost input bytes"
1567 );
1568 }
1569
1570 // And the loss was real: the tab between `a` and `b` is its own
1571 // token, not absorbed into either neighbour.
1572 let ids = tok.encode("a\tb");
1573 assert_eq!(
1574 ids.len(),
1575 3,
1576 "a, the tab, b — the tab is a token of its own"
1577 );
1578 }
1579
1580 #[test]
1581 fn ascii_word_round_trips_through_real_vocab_encode_decode() {
1582 let tok = load_real_fixture();
1583 for word in ["hello", "ferrox", "test", "quick brown fox"] {
1584 let ids = tok.encode_word(word);
1585 assert!(!ids.is_empty(), "encoding {word:?} produced no tokens");
1586 let decoded = tok.decode(&ids);
1587 assert_eq!(
1588 decoded, word,
1589 "round-trip through the real vocab's encode/decode should reproduce ASCII text exactly"
1590 );
1591 }
1592 }
1593
1594 #[test]
1595 fn multibyte_utf8_round_trips_through_real_vocab_encode_decode() {
1596 let tok = load_real_fixture();
1597 for word in ["caf\u{e9}", "\u{1f980}", "\u{4e2d}\u{6587}"] {
1598 let ids = tok.encode_word(word);
1599 let decoded = tok.decode(&ids);
1600 assert_eq!(
1601 decoded, word,
1602 "byte-level BPE must round-trip arbitrary UTF-8, not just ASCII"
1603 );
1604 }
1605 }
1606
1607 #[test]
1608 fn gpt2_remap_matches_known_reference_points() {
1609 // These are well-known fixed points of the real GPT-2
1610 // byte-to-unicode table (verifiable against OpenAI's published
1611 // encoder.py): printable ASCII '!' (0x21) maps to itself, and
1612 // the space byte (0x20), which is NOT in the "already
1613 // printable" ranges, maps to U+0120 ("\u{120}", conventionally
1614 // rendered as "Ġ" in BPE merge tables).
1615 let (fwd, rev) = super::gpt2_byte_to_unicode();
1616 assert_eq!(fwd[0x21], '!');
1617 assert_eq!(fwd[0x20], '\u{120}');
1618 assert_eq!(rev[&'!'], 0x21);
1619 assert_eq!(rev[&'\u{120}'], 0x20);
1620 }
1621
1622 #[test]
1623 fn real_vocab_uses_gpt2_space_remap_in_its_own_tokens() {
1624 // If ferrox's remap table matches the real llama-bpe vocab's
1625 // own convention, at least one real vocabulary entry should
1626 // start with the remapped-space character (a leading-space
1627 // word piece, extremely common in any GPT2-style BPE vocab).
1628 let tok = load_real_fixture();
1629 let has_space_prefixed_token = tok.id_to_token.iter().any(|t| t.starts_with('\u{120}'));
1630 assert!(
1631 has_space_prefixed_token,
1632 "expected at least one real vocab token starting with the GPT2 remapped-space character"
1633 );
1634 }
1635}
1636
1637#[cfg(test)]
1638mod gguf_spm_tests {
1639 use super::*;
1640
1641 fn load_real_fixture() -> GgufSpmTokenizer {
1642 let path = concat!(
1643 env!("CARGO_MANIFEST_DIR"),
1644 "/../../tests/fixtures/llama-spm-vocab.gguf"
1645 );
1646 let file = ferrox_gguf::GgufFile::open(path).expect("real SPM vocab fixture must open");
1647 GgufSpmTokenizer::from_gguf(&file)
1648 .expect("real SPM vocab fixture must parse as a tokenizer")
1649 }
1650
1651 #[test]
1652 fn loads_real_downloaded_llama_spm_vocab() {
1653 let tok = load_real_fixture();
1654 assert_eq!(
1655 tok.vocab_size(),
1656 32000,
1657 "the real LLaMA-1/2 tokenizer vocab is exactly 32000 tokens"
1658 );
1659 }
1660
1661 #[test]
1662 fn matches_known_reference_encodings() {
1663 let tok = load_real_fixture();
1664 assert_eq!(tok.encode("Hello world"), vec![15043, 3186]);
1665 assert_eq!(tok.encode(" Hello world"), vec![29871, 15043, 3186]);
1666 assert_eq!(tok.encode("Hello World"), vec![15043, 2787]);
1667 }
1668
1669 /// Real regression test, found serving a real chat checkpoint:
1670 /// chat-template control tokens (`<|user|>`, `<|assistant|>`) must
1671 /// be recognized as atomic vocabulary entries, not shattered into
1672 /// byte-fallback pieces. Uses a real, hand-built GGUF fixture with
1673 /// genuine `tokenizer.ggml.token_type` CONTROL entries from the
1674 /// fixture generator, not the
1675 /// downloaded real-LLaMA fixture above (which carries no
1676 /// `token_type` array at all).
1677 #[test]
1678 fn chat_template_control_tokens_are_encoded_atomically_not_shattered() {
1679 let path = concat!(
1680 env!("CARGO_MANIFEST_DIR"),
1681 "/tests/fixtures/spm-special-tokens-test-vocab.gguf"
1682 );
1683 let file = ferrox_gguf::GgufFile::open(path).expect("fixture must open");
1684 let tok = GgufSpmTokenizer::from_gguf(&file).expect("fixture must parse");
1685
1686 let user_id = 269u32;
1687 let assistant_id = 270u32;
1688 let ids = tok.encode("<|user|>hello<|assistant|>");
1689
1690 assert_eq!(ids.first().copied(), Some(user_id), "ids={ids:?}");
1691 assert_eq!(ids.last().copied(), Some(assistant_id), "ids={ids:?}");
1692 // The control tokens' own byte-fallback expansions must NOT
1693 // appear anywhere in the output -- they'd show up as a long
1694 // run of ids >= the byte-fallback range if the old shattering
1695 // bug were still present.
1696 assert!(
1697 !ids[1..ids.len() - 1].contains(&user_id)
1698 && !ids[1..ids.len() - 1].contains(&assistant_id),
1699 "control tokens must appear exactly once each, at the boundaries: ids={ids:?}"
1700 );
1701 }
1702
1703 #[test]
1704 fn byte_fallback_handles_control_characters() {
1705 let tok = load_real_fixture();
1706 assert_eq!(
1707 tok.encode("\t"),
1708 vec![29871, 12],
1709 "tab must byte-fallback to <0x09> = token 12"
1710 );
1711 assert_eq!(
1712 tok.encode("\n"),
1713 vec![29871, 13],
1714 "newline must byte-fallback to <0x0A> = token 13"
1715 );
1716 }
1717
1718 /// The strongest test in this file: every one of llama.cpp's own
1719 /// 45 CI test cases for this exact vocabulary
1720 /// (`tests/fixtures/llama-spm-vocab.gguf.inp`/`.out`, downloaded
1721 /// directly from `ggml-org/llama.cpp`), covering ASCII, whitespace
1722 /// runs of every length, control characters, CJK/Khmer/Vietnamese
1723 /// text, emoji (including a ZWJ sequence), and mixed-script text,
1724 /// must produce EXACTLY the token IDs llama.cpp's own tokenizer
1725 /// produces for the same inputs. This is what caught the
1726 /// stale-merge-candidate bug described in `GgufSpmTokenizer`'s doc
1727 /// comment during development.
1728 #[test]
1729 fn matches_llama_cpp_full_reference_test_suite_exactly() {
1730 let tok = load_real_fixture();
1731
1732 let inp_path = concat!(
1733 env!("CARGO_MANIFEST_DIR"),
1734 "/../../tests/fixtures/llama-spm-vocab.gguf.inp"
1735 );
1736 let out_path = concat!(
1737 env!("CARGO_MANIFEST_DIR"),
1738 "/../../tests/fixtures/llama-spm-vocab.gguf.out"
1739 );
1740 let inp_raw = std::fs::read_to_string(inp_path).expect("reference .inp file must exist");
1741 let out_raw = std::fs::read_to_string(out_path).expect("reference .out file must exist");
1742
1743 let marker = "__ggml_vocab_test__\n";
1744 let mut inputs: Vec<&str> = inp_raw.split(marker).collect();
1745 // The split produces a leading/trailing artifact from the
1746 // marker boundaries; drop empty fragments and any trailing
1747 // newline each fragment carries from the format.
1748 inputs.retain(|s| !s.is_empty());
1749 let inputs: Vec<String> = inputs
1750 .iter()
1751 .map(|s| s.strip_suffix('\n').unwrap_or(s).to_string())
1752 .collect();
1753
1754 let outputs: Vec<&str> = out_raw.split('\n').collect();
1755
1756 assert!(
1757 inputs.len() >= 40,
1758 "expected the full ~45-case reference suite, got {}",
1759 inputs.len()
1760 );
1761
1762 let mut checked = 0;
1763 for (i, text) in inputs.iter().enumerate() {
1764 let Some(expected_line) = outputs.get(i) else {
1765 break;
1766 };
1767 let expected_line = expected_line.trim();
1768 if expected_line.is_empty() {
1769 continue;
1770 }
1771 let expected: Vec<u32> = expected_line
1772 .split_whitespace()
1773 .map(|s| s.parse().unwrap())
1774 .collect();
1775 let got = tok.encode(text);
1776 assert_eq!(got, expected, "case #{i}: text={text:?}");
1777 checked += 1;
1778 }
1779 assert!(
1780 checked >= 40,
1781 "expected to actually check at least 40 real cases, only checked {checked}"
1782 );
1783 }
1784
1785 #[test]
1786 fn decode_reverses_encode_for_ascii_text() {
1787 let tok = load_real_fixture();
1788 // SentencePiece's real convention (confirmed by the reference
1789 // suite above) always prepends a dummy leading space before
1790 // tokenizing, so decoding round-trips to " Hello world" (WITH
1791 // a leading space), not "Hello world" -- this is genuine
1792 // LLaMA-tokenizer behavior, not a bug in this test or the
1793 // encoder; downstream text-generation code conventionally
1794 // strips exactly one leading space from decoded output, but
1795 // the raw decode legitimately includes it.
1796 let text = "Hello world";
1797 let ids = tok.encode(text);
1798 assert_eq!(tok.decode(&ids), " Hello world");
1799 }
1800
1801 #[test]
1802 fn decode_reverses_byte_fallback_tokens_to_the_real_raw_bytes() {
1803 // Real bug found via real-world testing:
1804 // decode() used to emit the literal 6-character token string
1805 // "<0x0A>" instead of an actual newline byte.
1806 let tok = load_real_fixture();
1807 // `encode` always prepends a dummy leading space (SentencePiece
1808 // convention, see `decode_reverses_encode_for_ascii_text`
1809 // above), so the decoded round-trip carries it too.
1810 let newline_id = tok.encode("\n");
1811 assert_eq!(tok.decode(&newline_id), " \n");
1812
1813 // A multi-byte UTF-8 character split across several
1814 // consecutive byte-fallback tokens must still decode correctly
1815 // once reassembled -- not as mojibake or individually-invalid
1816 // UTF-8 fragments.
1817 let emoji = "🦀";
1818 let ids = tok.encode(emoji);
1819 assert_eq!(tok.decode(&ids), format!(" {emoji}"));
1820 }
1821}
1822
1823#[cfg(test)]
1824mod gguf_unigram_tests {
1825 use super::*;
1826
1827 /// Real trained SentencePiece Unigram model (100 pieces, trained
1828 /// with the real `sentencepiece` Python library on a small text
1829 /// corpus through a fixture generator), not a
1830 /// hand-guessed vocabulary.
1831 fn load_real_fixture() -> GgufUnigramTokenizer {
1832 let path = concat!(
1833 env!("CARGO_MANIFEST_DIR"),
1834 "/tests/fixtures/unigram-test-vocab.gguf"
1835 );
1836 let file = ferrox_gguf::GgufFile::open(path).expect("real Unigram vocab fixture must open");
1837 GgufUnigramTokenizer::from_gguf(&file)
1838 .expect("real Unigram vocab fixture must parse as a tokenizer")
1839 }
1840
1841 #[test]
1842 fn loads_real_trained_unigram_vocab() {
1843 let tok = load_real_fixture();
1844 assert_eq!(tok.vocab_size(), 100);
1845 }
1846
1847 /// Cross-validated against the exact same trained model's own
1848 /// `sentencepiece.SentencePieceProcessor.Encode` output -- not a
1849 /// hand-computed expectation. Covers ASCII, mixed case, digit runs,
1850 /// repeated whitespace, punctuation, and non-ASCII (accented Latin)
1851 /// text, plus a string with no real vocabulary substrings at all
1852 /// (exercising the unknown-token fallback repeatedly, including
1853 /// consecutive unknown tokens).
1854 #[test]
1855 fn matches_real_sentencepiece_reference_encodings() {
1856 let tok = load_real_fixture();
1857 let cases: &[(&str, &[u32])] = &[
1858 ("hello world", &[3, 63, 4, 95, 8, 3, 36, 14, 11]),
1859 (
1860 "The quick brown fox",
1861 &[34, 3, 89, 10, 65, 70, 57, 49, 73, 12, 54, 8, 30],
1862 ),
1863 (
1864 "Testing unicode: café",
1865 &[74, 44, 20, 35, 47, 4, 83, 3, 62, 13, 25, 18],
1866 ),
1867 (
1868 "Numbers 12345",
1869 &[3, 86, 50, 15, 53, 5, 3, 75, 76, 77, 81, 82],
1870 ),
1871 ("a", &[58]),
1872 (
1873 " multiple spaces ",
1874 &[55, 10, 14, 64, 16, 99, 22, 3, 5, 99, 13, 27, 5],
1875 ),
1876 (
1877 "Zurich naive resume",
1878 &[3, 88, 10, 7, 16, 51, 38, 16, 33, 60, 4, 5, 50, 4],
1879 ),
1880 (
1881 "punctuation! test? yes.",
1882 &[24, 10, 72, 43, 29, 80, 3, 64, 44, 84, 3, 28, 4, 5, 6],
1883 ),
1884 (
1885 "unknown_gibberish_xyz_qqq_zzz",
1886 &[
1887 3, 10, 12, 70, 12, 8, 73, 12, 0, 17, 16, 15, 15, 53, 56, 63, 0, 30, 28, 90, 0,
1888 89, 89, 89, 0, 90, 90, 90,
1889 ],
1890 ),
1891 ];
1892 for (text, expected) in cases {
1893 let got = tok.encode(text);
1894 assert_eq!(&got, expected, "text={text:?}");
1895 }
1896 }
1897
1898 #[test]
1899 fn decode_reverses_encode_for_ascii_text() {
1900 let tok = load_real_fixture();
1901 let ids = tok.encode("hello world");
1902 // encode's leading dummy `▁` decodes back to a leading space,
1903 // same SentencePiece convention as GgufSpmTokenizer.
1904 assert_eq!(tok.decode(&ids), " hello world");
1905 }
1906
1907 /// A hundred tokens and three scores.
1908 fn short_scores_gguf() -> scored_vocab::MetadataOnlyGguf {
1909 let tokens: Vec<String> = (0..100).map(|i| format!("\u{2581}piece{i}")).collect();
1910 let refs: Vec<&str> = tokens.iter().map(String::as_str).collect();
1911 scored_vocab::MetadataOnlyGguf::new()
1912 .with_tokens(&refs)
1913 .with_scores(&[-1.0, -2.0, -3.0])
1914 }
1915
1916 /// Issue #34, and the reason the two tokenizers now share one
1917 /// vocabulary type. This file used to LOAD CLEANLY -- accepted by
1918 /// `/admin/models/load`, listed as the loaded model -- and then
1919 /// panic with an index-out-of-bounds inside the generation task on
1920 /// the first prompt whose Viterbi pass matched a piece with id >=
1921 /// 3, once per request, forever. The SPM twin survived the same
1922 /// file only because its copy of the lookup happened to be the
1923 /// guarded spelling.
1924 ///
1925 /// Both must now refuse it at load, and refuse it the same way:
1926 /// one lookup, one check, no room for the two to disagree again.
1927 #[test]
1928 fn a_scores_array_too_short_for_the_vocabulary_is_refused_at_load_by_both_tokenizers() {
1929 let file = short_scores_gguf();
1930 let unigram = GgufUnigramTokenizer::from_gguf(&file)
1931 .err()
1932 .expect("unigram must refuse a vocabulary its scores do not cover");
1933 assert!(
1934 matches!(
1935 unigram,
1936 TokenizerLoadError::ScoresVocabLengthMismatch {
1937 tokens: 100,
1938 scores: 3
1939 }
1940 ),
1941 "unigram={unigram:?}"
1942 );
1943 let spm = GgufSpmTokenizer::from_gguf(&file)
1944 .err()
1945 .expect("spm must refuse the same file the same way");
1946 assert!(
1947 matches!(
1948 spm,
1949 TokenizerLoadError::ScoresVocabLengthMismatch {
1950 tokens: 100,
1951 scores: 3
1952 }
1953 ),
1954 "spm={spm:?}"
1955 );
1956 }
1957
1958 /// The refusal above must be about the DISAGREEMENT, not about
1959 /// synthetic vocabularies in general: the same 100 pieces with 100
1960 /// scores load and encode, reaching ids far past the three the
1961 /// broken file carried.
1962 #[test]
1963 fn the_same_vocabulary_with_one_score_per_token_loads_and_encodes() {
1964 let tokens: Vec<String> = (0..100).map(|i| format!("\u{2581}piece{i}")).collect();
1965 let refs: Vec<&str> = tokens.iter().map(String::as_str).collect();
1966 let scores: Vec<f32> = (0..100).map(|i| -(i as f32)).collect();
1967 let file = scored_vocab::MetadataOnlyGguf::new()
1968 .with_tokens(&refs)
1969 .with_scores(&scores);
1970 let tok = GgufUnigramTokenizer::from_gguf(&file).expect("lengths agree");
1971 assert_eq!(tok.vocab_size(), 100);
1972 let ids = tok.encode("piece97");
1973 assert!(
1974 ids.contains(&97),
1975 "the piece with the highest id must be reachable: ids={ids:?}"
1976 );
1977 }
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982 use super::*;
1983
1984 #[test]
1985 fn ascii_round_trips_exactly() {
1986 let text = "hello ferrox";
1987 let ids = ByteTokenizer::encode(text);
1988 assert_eq!(ids.len(), text.len());
1989 assert_eq!(ByteTokenizer::decode(&ids), text);
1990 }
1991
1992 #[test]
1993 fn utf8_multibyte_round_trips_exactly() {
1994 let text = "caffe\u{300} \u{1f980}"; // combining accent + emoji, multi-byte UTF-8
1995 let ids = ByteTokenizer::encode(text);
1996 assert_eq!(ByteTokenizer::decode(&ids), text);
1997 }
1998
1999 #[test]
2000 fn all_ids_are_within_byte_vocab_range() {
2001 let ids = ByteTokenizer::encode("mixed ASCII and \u{00e9}\u{00e8} text");
2002 assert!(ids
2003 .iter()
2004 .all(|&id| (id as usize) < ByteTokenizer::VOCAB_SIZE));
2005 }
2006
2007 #[test]
2008 fn empty_string_round_trips() {
2009 assert_eq!(ByteTokenizer::encode(""), Vec::<u32>::new());
2010 assert_eq!(ByteTokenizer::decode(&[]), "");
2011 }
2012
2013 #[test]
2014 fn out_of_range_ids_are_dropped_not_corrupting() {
2015 // 300 is outside the byte vocab; decode should simply skip it
2016 // rather than panicking or wrapping into a wrong byte.
2017 let decoded = ByteTokenizer::decode(&[104, 105, 300, 33]); // "hi" + garbage + "!"
2018 assert_eq!(decoded, "hi!");
2019 }
2020}
2021
2022#[cfg(test)]
2023mod eog_tests {
2024 use super::*;
2025 use ferrox_gguf::{GgufValue, TensorInfo, TensorSource};
2026 use std::collections::HashMap;
2027
2028 struct MetaOnly(HashMap<String, GgufValue>);
2029
2030 impl TensorSource for MetaOnly {
2031 fn metadata(&self, key: &str) -> Option<&GgufValue> {
2032 self.0.get(key)
2033 }
2034 fn find_tensor(&self, _name: &str) -> Option<&TensorInfo> {
2035 None
2036 }
2037 fn tensor_bytes(&self, name: &str) -> Result<&[u8], ferrox_gguf::GgufError> {
2038 Err(ferrox_gguf::GgufError::TensorNotFound(name.to_string()))
2039 }
2040 fn tensor_mapped_range(
2041 &self,
2042 name: &str,
2043 ) -> Result<
2044 (
2045 std::sync::Arc<ferrox_gguf::MmapHandle>,
2046 std::ops::Range<usize>,
2047 ),
2048 ferrox_gguf::GgufError,
2049 > {
2050 Err(ferrox_gguf::GgufError::TensorNotFound(name.to_string()))
2051 }
2052 }
2053
2054 fn source(tokens: &[&str], kv: &[(&str, u64)]) -> MetaOnly {
2055 let mut m = HashMap::new();
2056 m.insert(
2057 "tokenizer.ggml.tokens".to_string(),
2058 GgufValue::Array(
2059 tokens
2060 .iter()
2061 .map(|t| GgufValue::String((*t).to_string()))
2062 .collect(),
2063 ),
2064 );
2065 for (k, v) in kv {
2066 m.insert((*k).to_string(), GgufValue::U32(*v as u32));
2067 }
2068 MetaOnly(m)
2069 }
2070
2071 /// A vocabulary described only by the three metadata keys
2072 /// `should_add_bos_token` reads.
2073 fn vocab_meta(model: &str, pre: Option<&str>, add_bos: Option<bool>) -> MetaOnly {
2074 let mut m = HashMap::new();
2075 m.insert(
2076 "tokenizer.ggml.model".to_string(),
2077 GgufValue::String(model.to_string()),
2078 );
2079 if let Some(pre) = pre {
2080 m.insert(
2081 "tokenizer.ggml.pre".to_string(),
2082 GgufValue::String(pre.to_string()),
2083 );
2084 }
2085 if let Some(v) = add_bos {
2086 m.insert(
2087 "tokenizer.ggml.add_bos_token".to_string(),
2088 GgufValue::Bool(v),
2089 );
2090 }
2091 MetaOnly(m)
2092 }
2093
2094 /// **Defect 4.** llama.cpp sets `add_bos = true` for the whole
2095 /// `LLAMA_VOCAB_PRE_TYPE_LLAMA3` group (`llama-vocab.cpp`, the
2096 /// `tokenizer_pre == "llama-bpe"` arm), and Llama-3.x GGUFs ship no
2097 /// explicit `tokenizer.ggml.add_bos_token`, so a missing group
2098 /// member is a prompt one `<|begin_of_text|>` short of llama.cpp's
2099 /// on every raw completion.
2100 #[test]
2101 fn the_llama_bpe_group_takes_bos_even_with_no_metadata_flag() {
2102 for pre in [
2103 "llama3",
2104 "llama-v3",
2105 "llama-bpe",
2106 "falcon3",
2107 "falcon-h1",
2108 "pixtral",
2109 "midm-2.0",
2110 "lfm2",
2111 "jina-v5-nano",
2112 "tekken",
2113 "chameleon",
2114 ] {
2115 assert!(
2116 should_add_bos_token(&vocab_meta("gpt2", Some(pre), None)),
2117 "llama.cpp sets add_bos for pre={pre}"
2118 );
2119 }
2120 }
2121
2122 /// The other half of the same rule, so the fix cannot be "return
2123 /// true": BPE arms outside that group leave `add_bos` false, and
2124 /// Qwen2's `bos_token_id` is `<|endoftext|>` — prepending it poisons
2125 /// greedy decode.
2126 #[test]
2127 fn other_bpe_pretokenizers_still_do_not_take_bos() {
2128 for pre in ["qwen2", "deepseek-r1-qwen", "gpt-4o", "olmo", "gpt-2", ""] {
2129 assert!(
2130 !should_add_bos_token(&vocab_meta("gpt2", Some(pre), None)),
2131 "llama.cpp leaves add_bos false for pre={pre}"
2132 );
2133 }
2134 }
2135
2136 /// An explicit `tokenizer.ggml.add_bos_token` still wins in both
2137 /// directions — the group default only applies when the key is
2138 /// absent, which is what makes this a *default* rather than an
2139 /// override.
2140 #[test]
2141 fn an_explicit_add_bos_flag_beats_the_pretokenizer_default() {
2142 assert!(!should_add_bos_token(&vocab_meta(
2143 "gpt2",
2144 Some("llama-bpe"),
2145 Some(false)
2146 )));
2147 assert!(should_add_bos_token(&vocab_meta(
2148 "gpt2",
2149 Some("qwen2"),
2150 Some(true)
2151 )));
2152 // SPM still defaults to true with no flag and no pre.
2153 assert!(should_add_bos_token(&vocab_meta("llama", None, None)));
2154 }
2155
2156 /// The failure this exists to stop: a Llama-3 chat checkpoint whose
2157 /// `eos_token_id` is `<|end_of_text|>` while turns actually end with
2158 /// `<|eot_id|>`. Stopping only on the metadata id runs the model past
2159 /// its own turn and it starts interviewing itself.
2160 #[test]
2161 fn turn_enders_count_even_when_they_are_not_the_metadata_eos() {
2162 let src = source(
2163 &["hello", "<|end_of_text|>", "<|eot_id|>", "world"],
2164 &[("tokenizer.ggml.eos_token_id", 1)],
2165 );
2166 let eog = eog_token_ids(&src);
2167 assert!(eog.contains(&1), "metadata eos");
2168 assert!(eog.contains(&2), "<|eot_id|> ends the turn");
2169 assert!(
2170 !eog.contains(&0) && !eog.contains(&3),
2171 "ordinary tokens are not EOG"
2172 );
2173 }
2174
2175 /// gemma-4 ends on `<turn|>`; both it and `<eos>` are in llama.cpp's
2176 /// list, so a gemma checkpoint must stop on either.
2177 #[test]
2178 fn gemma_style_turn_and_eos_are_both_end_of_generation() {
2179 let src = source(&["<eos>", "<turn|>", "x"], &[]);
2180 let eog = eog_token_ids(&src);
2181 assert!(eog.contains(&0) && eog.contains(&1));
2182 assert!(!eog.contains(&2));
2183 }
2184
2185 /// `eot`/`eom` ids are folded in even when the vocabulary spells them
2186 /// something llama.cpp's literal list does not know.
2187 #[test]
2188 fn eot_and_eom_metadata_ids_are_included() {
2189 let src = source(
2190 &["a", "b", "c"],
2191 &[
2192 ("tokenizer.ggml.eot_token_id", 1),
2193 ("tokenizer.ggml.eom_token_id", 2),
2194 ],
2195 );
2196 let eog = eog_token_ids(&src);
2197 assert!(eog.contains(&1) && eog.contains(&2));
2198 }
2199
2200 /// A file with neither the ids nor any known name yields an empty
2201 /// set, so callers keep their previous `eos_id`-only behaviour rather
2202 /// than stopping on something arbitrary.
2203 #[test]
2204 fn a_file_with_nothing_to_go_on_yields_no_stop_tokens() {
2205 let src = source(&["a", "b"], &[]);
2206 assert!(eog_token_ids(&src).is_empty());
2207 }
2208
2209 /// The case a template-evaluating loader creates: gemma-3, Mistral,
2210 /// Phi-3 and DeepSeek-R1-Distill all open their real
2211 /// `tokenizer.chat_template` with `{{ bos_token }}`, so the encoded
2212 /// prompt already starts with the BOS id before the loader gets a
2213 /// look. Measured on the local corpus by
2214 /// `tests/bos_policy.rs::sweep_local_gguf_bos_policy`: 6 of 26
2215 /// checkpoints double their BOS if this is an unconditional insert.
2216 #[test]
2217 fn a_template_that_already_emitted_bos_is_not_given_a_second_one() {
2218 let mut ids = vec![2u32, 105, 2364];
2219 prepend_bos(&mut ids, Some(2));
2220 assert_eq!(ids, vec![2, 105, 2364]);
2221 }
2222
2223 /// The other half of the same rule: Unsloth strips `{{ bos_token }}`
2224 /// out of the templates it exports (TinyLlama's checked-in template
2225 /// is the local example), so on those checkpoints nobody adds BOS
2226 /// unless the loader does.
2227 #[test]
2228 fn a_template_that_stripped_bos_gets_one_from_the_loader() {
2229 let mut ids = vec![529u32, 29989];
2230 prepend_bos(&mut ids, Some(1));
2231 assert_eq!(ids, vec![1, 529, 29989]);
2232 }
2233
2234 /// `None` is the `should_add_bos_token` gate having said no — BPE
2235 /// vocabularies ship a `bos_token_id` they never prepend, and
2236 /// Qwen2-MoE's is `<|endoftext|>`.
2237 #[test]
2238 fn a_vocabulary_that_does_not_take_bos_gets_nothing() {
2239 let mut ids = vec![151644u32, 872];
2240 prepend_bos(&mut ids, None);
2241 assert_eq!(ids, vec![151644, 872]);
2242 }
2243}