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}