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