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