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