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