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