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