Skip to main content

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}