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