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}