Skip to main content

ferrox_models/
tokenizer.rs

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