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