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