Skip to main content

ferrox_models/tokenizer/
wordpiece.rs

1//! WordPiece, for GGUF files whose `tokenizer.ggml.model` is `bert`.
2//!
3//! A transcription of llama.cpp's `LLAMA_VOCAB_TYPE_WPM` path:
4//! `llm_tokenizer_wpm_session::tokenize` and its `preprocess`, in
5//! `.scratch/llama.cpp/src/llama-vocab.cpp`. Its own module rather than
6//! a fourth section of [`super`], which is already past two thousand
7//! lines, and because everything here is reviewed *against that file*.
8//!
9//! # What a WordPiece GGUF actually holds
10//!
11//! Not what the HuggingFace `vocab.txt` holds. llama.cpp's converter
12//! (`conversion/bert.py`) rewrites every vocabulary entry on the way
13//! into the GGUF, and the tokenizer below only makes sense against the
14//! rewritten form:
15//!
16//! * a continuation piece `##ing` is stored as `ing`, with the `##`
17//!   stripped;
18//! * a word-initial piece `hello` is stored as `▁hello` (U+2581);
19//! * a `CONTROL` entry such as `[CLS]` is stored unchanged.
20//!
21//! So the `##` prefix does not appear anywhere in this file. Its job,
22//! marking "this piece may only continue a word", is done by the
23//! *absence* of the `▁` that only the first lookup of each word can
24//! match. That is why the loop below prepends `▁` to the word once and
25//! then walks straight through: position 0 can only match a
26//! word-initial piece, and every later position can only match a
27//! continuation.
28//!
29//! # The algorithm, in the order it runs
30//!
31//! 1. **Normalize and split into words** ([`preprocess`]). This is
32//!    BertNormalizer plus BertPreTokenizer, not the BPE pre-tokenizer
33//!    regex next door in [`super::pretokenize`]: NFD-fold and drop
34//!    accents, drop controls, lowercase, break on whitespace, and give
35//!    every punctuation, ASCII symbol and CJK character a word of its
36//!    own.
37//! 2. **Greedy longest-match-first** per word, from the left, over the
38//!    `▁`-prefixed word.
39//! 3. **All-or-nothing fallback.** If any position in a word has no
40//!    match at any length, every piece already emitted for that word is
41//!    discarded and the word becomes a single unknown token. WordPiece
42//!    does not fall back per character, and it does not fall back to
43//!    bytes: a word is either fully covered or it is `[UNK]`.
44//!
45//! # Bytes, not characters
46//!
47//! The match loop indexes **bytes**, because llama.cpp's does
48//! (`word1.substr(i, j - i)` over a `std::string`) and because a
49//! vocabulary piece is free to hold one byte of a multi-byte character.
50//! Cutting on `char` boundaries instead would silently skip candidate
51//! lengths and split CJK differently. The lookup table is therefore
52//! keyed by byte string; a slice that is not valid UTF-8 simply matches
53//! nothing, exactly as it matches nothing upstream.
54
55use super::unicode;
56use super::{SpecialTokenTable, SpecialTokens, TextOrSpecial, TokenizerLoadError};
57use std::collections::HashMap;
58
59/// The phantom space llama.cpp's converter puts in front of every
60/// word-initial piece, and that [`GgufWordPieceTokenizer::encode`] puts
61/// in front of every word before matching.
62const PHANTOM_SPACE: &str = "\u{2581}";
63
64/// llama.cpp's `bert` defaults for `special_unk_id`. Applied when the
65/// GGUF carries no `tokenizer.ggml.unknown_token_id`, which is what
66/// upstream does: it seeds the id from the `tokenizer_model == "bert"`
67/// arm and only then lets metadata override it.
68const DEFAULT_UNK_ID: u32 = 100;
69
70/// The `BertNormalizer` switches, and the defaults llama.cpp applies
71/// when a checkpoint does not carry them.
72///
73/// Both default to **true**, and `strip_accents` defaults to whatever
74/// `lowercase` resolved to rather than to a constant. That chain is
75/// upstream's, verbatim: `normalizer_opts.lowercase` is read first,
76/// `strip_accents` is then seeded from it, and only then is
77/// `tokenizer.ggml.normalizer.strip_accents` allowed to override. The
78/// GGUFs people actually have predate both keys, so the defaults are
79/// the live path, not the fallback.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct NormalizerOptions {
82    pub lowercase: bool,
83    pub strip_accents: bool,
84}
85
86impl NormalizerOptions {
87    fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Self {
88        let lowercase = file
89            .metadata_bool("tokenizer.ggml.normalizer.lowercase")
90            .unwrap_or(true);
91        let strip_accents = file
92            .metadata_bool("tokenizer.ggml.normalizer.strip_accents")
93            .unwrap_or(lowercase);
94        NormalizerOptions {
95            lowercase,
96            strip_accents,
97        }
98    }
99}
100
101/// A real WordPiece tokenizer built from a GGUF file's own
102/// `tokenizer.ggml.tokens` metadata array.
103///
104/// See the module docs for the algorithm and for why the vocabulary
105/// looks the way it does. Checked against llama.cpp on the same GGUF by
106/// `crates/ferrox-models/tests/wordpiece_parity.rs`.
107pub struct GgufWordPieceTokenizer {
108    /// Keyed by the piece's **bytes**. See the module docs.
109    token_to_id: HashMap<Vec<u8>, u32>,
110    id_to_token: Vec<String>,
111    /// The longest piece in the vocabulary, in bytes. Upstream's
112    /// `max_token_len`, and the reason the match loop is linear rather
113    /// than quadratic in the length of the word.
114    max_token_len: usize,
115    unk_id: u32,
116    normalizer: NormalizerOptions,
117    /// The vocabulary's special entries, carved out of raw text before
118    /// normalization runs. For a BERT vocabulary this is `[PAD]`,
119    /// `[UNK]`, `[CLS]`, `[SEP]` and `[MASK]`, which is exactly
120    /// llama.cpp's `cache_special_tokens` for the same file.
121    special_tokens: SpecialTokenTable,
122}
123
124impl GgufWordPieceTokenizer {
125    pub fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Result<Self, TokenizerLoadError> {
126        let tokens_value = file
127            .metadata("tokenizer.ggml.tokens")
128            .ok_or(TokenizerLoadError::MissingTokens)?;
129        let id_to_token: Vec<String> = match tokens_value {
130            ferrox_gguf::GgufValue::Array(items) => items
131                .iter()
132                .map(|v| v.as_str().map(|s| s.to_string()))
133                .collect::<Option<Vec<_>>>()
134                .ok_or(TokenizerLoadError::TokensNotStringArray)?,
135            _ => return Err(TokenizerLoadError::TokensNotStringArray),
136        };
137
138        // First id wins on a duplicate piece, matching upstream's
139        // `token_to_id[word] = i` only ever being written for a word it
140        // has not seen (a GGUF with a repeated piece is malformed, but
141        // it must not decide the answer by hash order).
142        let mut token_to_id: HashMap<Vec<u8>, u32> = HashMap::with_capacity(id_to_token.len());
143        for (i, text) in id_to_token.iter().enumerate() {
144            token_to_id
145                .entry(text.as_bytes().to_vec())
146                .or_insert(i as u32);
147        }
148        let max_token_len = id_to_token.iter().map(|t| t.len()).max().unwrap_or(0);
149
150        let unk_id = file
151            .metadata("tokenizer.ggml.unknown_token_id")
152            .and_then(|v| v.as_u64())
153            .map(|v| v as u32)
154            .unwrap_or(DEFAULT_UNK_ID);
155
156        let special_tokens = SpecialTokenTable::from_gguf(file, &id_to_token);
157
158        Ok(GgufWordPieceTokenizer {
159            token_to_id,
160            id_to_token,
161            max_token_len,
162            unk_id,
163            normalizer: NormalizerOptions::from_gguf(file),
164            special_tokens,
165        })
166    }
167
168    pub fn vocab_size(&self) -> usize {
169        self.id_to_token.len()
170    }
171
172    pub fn normalizer(&self) -> NormalizerOptions {
173        self.normalizer
174    }
175
176    /// Encodes `text`, with no `[CLS]`/`[SEP]` added.
177    ///
178    /// Upstream's `add_special` wraps the result in BOS and SEP; that
179    /// decision lives with [`super::should_add_bos_token`] and
180    /// [`super::prepend_bos`] for every tokenizer in this crate, and
181    /// baking it in here would double it for callers that already do it.
182    /// `specials` is llama.cpp's `parse_special`: whether a literal
183    /// `[SEP]` in the text is the separator or five characters.
184    pub fn encode(&self, text: &str, specials: SpecialTokens) -> Vec<u32> {
185        let mut out = Vec::new();
186        for seg in self.special_tokens.split(text, specials) {
187            match seg {
188                TextOrSpecial::Special(id) => out.push(id),
189                TextOrSpecial::Text(t) => self.encode_normal_run(t, &mut out),
190            }
191        }
192        out
193    }
194
195    fn encode_normal_run(&self, text: &str, out: &mut Vec<u32>) {
196        for word in preprocess(text, self.normalizer) {
197            if word.is_empty() {
198                continue;
199            }
200            let word1 = format!("{PHANTOM_SPACE}{word}");
201            let bytes = word1.as_bytes();
202            let n = bytes.len();
203            let start = out.len();
204
205            let mut i = 0usize;
206            while i < n {
207                // Longest match first, capped at the longest piece the
208                // vocabulary holds. The `+ 1` is upstream's and is
209                // deliberately kept: it lets the first probe be one byte
210                // longer than any piece, which can never match and costs
211                // one lookup.
212                let mut j = n.min(i + self.max_token_len + 1);
213                let mut matched = false;
214                while j > i {
215                    if let Some(&id) = self.token_to_id.get(&bytes[i..j]) {
216                        out.push(id);
217                        i = j;
218                        matched = true;
219                        break;
220                    }
221                    j -= 1;
222                }
223                if !matched {
224                    // All or nothing: discard the pieces already emitted
225                    // for THIS word and stop. The `[UNK]` below covers
226                    // the whole word.
227                    out.truncate(start);
228                    break;
229                }
230            }
231
232            if out.len() == start {
233                out.push(self.unk_id);
234            }
235        }
236    }
237
238    /// The text a token id stands for.
239    ///
240    /// Reverses the converter's phantom space, so `▁hello` decodes to
241    /// `" hello"` and the continuation `ing` decodes to `"ing"`.
242    /// Concatenating a sequence therefore reproduces the normalized
243    /// text with a leading space, which is what llama.cpp's
244    /// `llama_unescape_whitespace` produces before its `clean_spaces`
245    /// pass trims the first one.
246    ///
247    /// A WordPiece round trip is lossy no matter what this does: the
248    /// normalizer lowercased, stripped accents and dropped controls
249    /// before any of these ids existed. Round-tripping is therefore NOT
250    /// evidence that this tokenizer is right, which is why the tests
251    /// that matter compare ids against llama.cpp instead.
252    pub fn decode(&self, ids: &[u32]) -> String {
253        let mut out = String::new();
254        for &id in ids {
255            if let Some(token) = self.id_to_token.get(id as usize) {
256                out.push_str(&token.replace(PHANTOM_SPACE, " "));
257            }
258        }
259        out
260    }
261}
262
263/// The codepoint ranges llama.cpp counts as Chinese, and therefore
264/// splits into single-character words.
265///
266/// Verbatim from `llm_tokenizer_wpm_session::is_chinese_char`, including
267/// the range that upstream's own comment flags as wrong (`0x2B920`
268/// should be `0x2B820`; the HuggingFace Rust implementation has the same
269/// value, so matching it is the point) and including the two ranges it
270/// leaves commented out. Widening this set would split CJK punctuation
271/// differently from the reference.
272fn is_chinese_char(c: char) -> bool {
273    let cpt = c as u32;
274    (0x04E00..=0x09FFF).contains(&cpt)
275        || (0x03400..=0x04DBF).contains(&cpt)
276        || (0x20000..=0x2A6DF).contains(&cpt)
277        || (0x2A700..=0x2B73F).contains(&cpt)
278        || (0x2B740..=0x2B81F).contains(&cpt)
279        || (0x2B920..=0x2CEAF).contains(&cpt)
280        || (0x0F900..=0x0FAFF).contains(&cpt)
281        || (0x2F800..=0x2FA1F).contains(&cpt)
282}
283
284/// BertNormalizer + BertPreTokenizer: normalize `text` and cut it into
285/// words.
286///
287/// A transcription of `llm_tokenizer_wpm_session::preprocess`. The order
288/// of the tests is load-bearing and is upstream's:
289///
290/// 1. whitespace ends the current word and is otherwise dropped;
291/// 2. NUL, U+FFFD and `\p{C}` are dropped, so a soft hyphen or a
292///    zero-width space joins the characters on either side of it into
293///    one word rather than breaking it;
294/// 3. an accent mark is dropped when `strip_accents`;
295/// 4. the character is lowercased when `lowercase`;
296/// 5. punctuation, an **ASCII** symbol and a CJK character each become a
297///    word of their own;
298/// 6. anything else extends the current word.
299///
300/// Two details that a plausible re-derivation gets wrong. The accent
301/// fold in step 3 runs over the NFD-folded text, so `é` has already
302/// become `e` and there is no mark left to drop; the test exists for
303/// marks that were standalone in the input, like the `e` + U+0301 in the
304/// parity corpus. And step 5 tests `is_symbol` only below U+007F, so
305/// `+` starts a new word and `€` does not.
306///
307/// Returns owned `String`s because steps 3 and 4 mean a word is not a
308/// slice of the input.
309fn preprocess(text: &str, opts: NormalizerOptions) -> Vec<String> {
310    let mut words: Vec<String> = vec![String::new()];
311
312    for raw in text.chars() {
313        let c = if opts.strip_accents {
314            unicode::nfd_base(raw)
315        } else {
316            raw
317        };
318
319        if unicode::is_whitespace(c) {
320            if !words.last().is_some_and(String::is_empty) {
321                words.push(String::new());
322            }
323            continue;
324        }
325
326        let flags = unicode::flags(c);
327        debug_assert!(
328            !flags.is_separator(),
329            "every \\p{{Z}} codepoint is also White_Space, so the check above \
330             should have consumed it: {c:?}"
331        );
332
333        if c == '\0' || c == '\u{fffd}' || flags.is_control() {
334            continue;
335        }
336        if opts.strip_accents && flags.is_accent_mark() {
337            continue;
338        }
339
340        let c = if opts.lowercase {
341            unicode::to_lower(c)
342        } else {
343            c
344        };
345
346        if flags.is_punctuation() || ((c as u32) < 0x7F && flags.is_symbol()) || is_chinese_char(c)
347        {
348            if !words.last().is_some_and(String::is_empty) {
349                words.push(String::new());
350            }
351            // A word of exactly this character, then a fresh word for
352            // whatever follows.
353            words.last_mut().expect("just pushed or non-empty").push(c);
354            words.push(String::new());
355        } else {
356            words.last_mut().expect("seeded with one word").push(c);
357        }
358    }
359
360    if words.last().is_some_and(String::is_empty) {
361        words.pop();
362    }
363    words
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::tokenizer::SpecialKind;
370
371    const BOTH: NormalizerOptions = NormalizerOptions {
372        lowercase: true,
373        strip_accents: true,
374    };
375    const NEITHER: NormalizerOptions = NormalizerOptions {
376        lowercase: false,
377        strip_accents: false,
378    };
379
380    fn words(text: &str, opts: NormalizerOptions) -> Vec<String> {
381        preprocess(text, opts)
382    }
383
384    #[test]
385    fn whitespace_splits_words_and_is_dropped() {
386        assert_eq!(words("hello world", BOTH), ["hello", "world"]);
387        assert_eq!(words("  hello   world  ", BOTH), ["hello", "world"]);
388        assert_eq!(words("", BOTH), Vec::<String>::new());
389        assert_eq!(words("   ", BOTH), Vec::<String>::new());
390    }
391
392    /// The parity corpus's `unicode-space` case, which is the reason
393    /// this cannot be `char::is_ascii_whitespace`: NBSP and the
394    /// ideographic space break words, and ZWSP does not because it is
395    /// `Cf` and gets dropped instead.
396    #[test]
397    fn unicode_spaces_break_words_but_zero_width_ones_vanish() {
398        assert_eq!(
399            words("a\u{a0}b\u{3000}c\u{2009}d\u{200b}e", BOTH),
400            ["a", "b", "c", "de"]
401        );
402    }
403
404    #[test]
405    fn punctuation_and_ascii_symbols_become_single_character_words() {
406        assert_eq!(words("don't.", BOTH), ["don", "'", "t", "."]);
407        assert_eq!(words("a+b", BOTH), ["a", "+", "b"]);
408        assert_eq!(words("1+2=3", BOTH), ["1", "+", "2", "=", "3"]);
409    }
410
411    /// `is_symbol` is consulted only below U+007F. A non-ASCII symbol
412    /// therefore stays welded to its neighbours, which is the opposite
413    /// of what "split on symbols" would do.
414    #[test]
415    fn non_ascii_symbols_do_not_split() {
416        assert_eq!(words("a€b", BOTH), ["a€b"]);
417        assert_eq!(words("a$b", BOTH), ["a", "$", "b"]);
418    }
419
420    #[test]
421    fn cjk_characters_each_get_their_own_word() {
422        assert_eq!(words("日本語", BOTH), ["日", "本", "語"]);
423        assert_eq!(words("a日b", BOTH), ["a", "日", "b"]);
424    }
425
426    /// Hangul is not in the Chinese ranges, so it is never split into
427    /// per-character words. What happens to it instead is worse and is
428    /// the reference's behaviour, not a defect here: every Hangul
429    /// syllable has an NFD decomposition into jamo, so the accent fold
430    /// replaces it with its LEADING jamo and the rest of the syllable is
431    /// gone. `서울` becomes `ᄉᄋ`, one word, and a `[UNK]` in practice.
432    ///
433    /// Asserted rather than left implicit because it is exactly the kind
434    /// of behaviour a future "obvious fix" would break parity by
435    /// improving. The parity corpus's `cjk` case carries `서울` and the
436    /// oracle agrees with this.
437    #[test]
438    fn hangul_is_folded_to_its_leading_jamo_by_the_accent_pass() {
439        assert_eq!(words("서울", BOTH), ["\u{1109}\u{110b}"]);
440        assert_eq!(words("서울", NEITHER), ["서울"], "only the fold does this");
441    }
442
443    #[test]
444    fn controls_are_dropped_without_breaking_the_word() {
445        assert_eq!(words("a\u{1b}b", BOTH), ["ab"], "ESC is Cc");
446        assert_eq!(words("a\u{ad}b", BOTH), ["ab"], "soft hyphen is Cf");
447        assert_eq!(words("a\u{fffd}b", BOTH), ["ab"], "the replacement char");
448        assert_eq!(words("a\0b", BOTH), ["ab"], "NUL");
449    }
450
451    #[test]
452    fn lowercase_and_accent_stripping_follow_their_flags() {
453        assert_eq!(words("Café", BOTH), ["cafe"]);
454        assert_eq!(words("Café", NEITHER), ["Café"]);
455        assert_eq!(
456            words(
457                "Café",
458                NormalizerOptions {
459                    lowercase: true,
460                    strip_accents: false
461                }
462            ),
463            ["café"]
464        );
465        assert_eq!(
466            words(
467                "Café",
468                NormalizerOptions {
469                    lowercase: false,
470                    strip_accents: true
471                }
472            ),
473            ["Cafe"]
474        );
475    }
476
477    /// The corpus writes `café` precomposed and `e\u{301}clair`
478    /// decomposed on purpose. Both must land on the same bytes, and they
479    /// do so by two different routes: the NFD fold for the first, the
480    /// `is_accent_mark` drop for the second.
481    #[test]
482    fn precomposed_and_decomposed_accents_normalize_alike() {
483        assert_eq!(words("café", BOTH), words("cafe\u{301}", BOTH));
484        assert_eq!(words("cafe\u{301}", BOTH), ["cafe"]);
485    }
486
487    /// A vocabulary in the form llama.cpp's converter writes: `▁` on
488    /// word-initial pieces, bare continuations, `[...]` specials.
489    fn toy() -> GgufWordPieceTokenizer {
490        let pieces = [
491            "[PAD]", "[UNK]", "[CLS]", "[SEP]", // 0..=3
492            "▁un", "▁hello", "▁.", "▁world", // 4..=7
493            "aff", "able", "ing",    // 8..=10
494            "▁unaff", // 11, a word-initial piece that EXTENDS ▁un
495        ];
496        let id_to_token: Vec<String> = pieces.iter().map(|s| s.to_string()).collect();
497        let mut token_to_id = HashMap::new();
498        for (i, t) in id_to_token.iter().enumerate() {
499            token_to_id.entry(t.as_bytes().to_vec()).or_insert(i as u32);
500        }
501        let max_token_len = id_to_token.iter().map(|t| t.len()).max().unwrap();
502        GgufWordPieceTokenizer {
503            token_to_id,
504            id_to_token,
505            max_token_len,
506            unk_id: 1,
507            normalizer: BOTH,
508            special_tokens: SpecialTokenTable::from_entries([
509                ("[CLS]", 2, SpecialKind::Control),
510                ("[SEP]", 3, SpecialKind::Control),
511            ]),
512        }
513    }
514
515    #[test]
516    fn a_word_is_covered_word_initial_piece_first_then_continuations() {
517        let t = toy();
518        // ▁un + able, which only works because `able` carries no phantom
519        // space and so cannot match at position 0.
520        assert_eq!(t.encode("unable", SpecialTokens::AsText), [4, 9]);
521        assert_eq!(t.encode("hello", SpecialTokens::AsText), [5]);
522    }
523
524    /// Longest match first, not merely *a* match.
525    ///
526    /// `unaffable` has two full covers in this vocabulary: `▁unaff` +
527    /// `able`, and `▁un` + `aff` + `able`. Both reach the end of the
528    /// word, so the all-or-nothing rule does not choose between them and
529    /// a shortest-first loop would be just as "correct" while producing
530    /// different ids for every real checkpoint. Only the probe order
531    /// decides, which is why it is asserted on its own.
532    #[test]
533    fn the_longest_match_wins_where_a_shorter_one_would_also_cover() {
534        let t = toy();
535        assert_eq!(
536            t.encode("unaffable", SpecialTokens::AsText),
537            [11, 9],
538            "▁unaff + able"
539        );
540        assert_ne!(
541            t.encode("unaffable", SpecialTokens::AsText),
542            [4, 8, 9],
543            "▁un + aff + able is the shortest-first answer"
544        );
545    }
546
547    /// The continuation pieces must not be reachable at the start of a
548    /// word. `aff` alone has no `▁aff` entry, so it is unknown even
549    /// though its bytes are in the vocabulary. This is the `##` rule,
550    /// expressed the way a GGUF expresses it.
551    #[test]
552    fn a_continuation_piece_cannot_start_a_word() {
553        let t = toy();
554        assert_eq!(
555            t.encode("aff", SpecialTokens::AsText),
556            [1],
557            "no ▁aff, so [UNK]"
558        );
559    }
560
561    /// The whole point of the all-or-nothing rule. `worlds` matches
562    /// `▁world` at position 0 and then has nothing for the trailing `s`,
563    /// so the `▁world` already emitted is thrown away and the word
564    /// becomes ONE unknown, not `▁world` plus an unknown.
565    #[test]
566    fn a_partly_covered_word_discards_its_pieces_and_becomes_one_unknown() {
567        let t = toy();
568        assert_eq!(
569            t.encode("worlds", SpecialTokens::AsText),
570            [1],
571            "a partial cover must be discarded, not kept"
572        );
573        // The same word without the stray byte does cover, which is what
574        // makes the assertion above about the fallback rule rather than
575        // about the vocabulary being too small.
576        assert_eq!(t.encode("world", SpecialTokens::AsText), [7]);
577    }
578
579    #[test]
580    fn each_word_falls_back_independently() {
581        let t = toy();
582        assert_eq!(
583            t.encode("hello zzz world", SpecialTokens::AsText),
584            [5, 1, 7]
585        );
586    }
587
588    #[test]
589    fn punctuation_is_its_own_word_and_matches_its_own_piece() {
590        let t = toy();
591        assert_eq!(
592            t.encode("hello.", SpecialTokens::AsText),
593            [5, 6],
594            "▁hello then ▁."
595        );
596    }
597
598    #[test]
599    fn special_tokens_are_carved_out_before_normalization() {
600        let t = toy();
601        // Without the carve-out the brackets would each be their own
602        // punctuation word and `[CLS]` would come back as four unknowns.
603        assert_eq!(t.encode("[CLS]hello[SEP]", SpecialTokens::Parse), [2, 5, 3]);
604    }
605
606    /// llama.cpp's default: a `[SEP]` written in the text is text. The
607    /// toy vocabulary has no `[`, so the marker shatters to unknowns
608    /// around `hello`, which is what upstream does with
609    /// `parse_special = false`.
610    #[test]
611    fn as_text_leaves_a_written_marker_to_the_normal_pass() {
612        let t = toy();
613        let ids = t.encode("[CLS]hello[SEP]", SpecialTokens::AsText);
614        assert!(!ids.contains(&2) && !ids.contains(&3), "got {ids:?}");
615        assert!(ids.contains(&5), "hello survives: {ids:?}");
616    }
617
618    #[test]
619    fn decode_reverses_the_phantom_space() {
620        let t = toy();
621        assert_eq!(t.decode(&[4, 8, 9]), " unaffable");
622        assert_eq!(t.decode(&[5, 7]), " hello world");
623        assert_eq!(t.decode(&[2]), "[CLS]");
624    }
625
626    /// `max_token_len` caps the probe length, so a vocabulary whose
627    /// longest piece is short must still tokenize correctly rather than
628    /// missing longer matches that do not exist.
629    #[test]
630    fn the_probe_cap_is_the_longest_piece_in_bytes() {
631        let t = toy();
632        assert_eq!(t.max_token_len, "▁hello".len(), "6 bytes, not 6 chars");
633    }
634}