Skip to main content

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