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::{load_special_tokens, split_on_special_tokens, 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    /// `CONTROL`/`USER_DEFINED` 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: Vec<(String, u32)>,
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 = load_special_tokens(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    pub fn encode(&self, text: &str) -> Vec<u32> {
183        let mut out = Vec::new();
184        for seg in split_on_special_tokens(text, &self.special_tokens) {
185            match seg {
186                TextOrSpecial::Special(id) => out.push(id),
187                TextOrSpecial::Text(t) => self.encode_normal_run(t, &mut out),
188            }
189        }
190        out
191    }
192
193    fn encode_normal_run(&self, text: &str, out: &mut Vec<u32>) {
194        for word in preprocess(text, self.normalizer) {
195            if word.is_empty() {
196                continue;
197            }
198            let word1 = format!("{PHANTOM_SPACE}{word}");
199            let bytes = word1.as_bytes();
200            let n = bytes.len();
201            let start = out.len();
202
203            let mut i = 0usize;
204            while i < n {
205                // Longest match first, capped at the longest piece the
206                // vocabulary holds. The `+ 1` is upstream's and is
207                // deliberately kept: it lets the first probe be one byte
208                // longer than any piece, which can never match and costs
209                // one lookup.
210                let mut j = n.min(i + self.max_token_len + 1);
211                let mut matched = false;
212                while j > i {
213                    if let Some(&id) = self.token_to_id.get(&bytes[i..j]) {
214                        out.push(id);
215                        i = j;
216                        matched = true;
217                        break;
218                    }
219                    j -= 1;
220                }
221                if !matched {
222                    // All or nothing: discard the pieces already emitted
223                    // for THIS word and stop. The `[UNK]` below covers
224                    // the whole word.
225                    out.truncate(start);
226                    break;
227                }
228            }
229
230            if out.len() == start {
231                out.push(self.unk_id);
232            }
233        }
234    }
235
236    /// The text a token id stands for.
237    ///
238    /// Reverses the converter's phantom space, so `▁hello` decodes to
239    /// `" hello"` and the continuation `ing` decodes to `"ing"`.
240    /// Concatenating a sequence therefore reproduces the normalized
241    /// text with a leading space, which is what llama.cpp's
242    /// `llama_unescape_whitespace` produces before its `clean_spaces`
243    /// pass trims the first one.
244    ///
245    /// A WordPiece round trip is lossy no matter what this does: the
246    /// normalizer lowercased, stripped accents and dropped controls
247    /// before any of these ids existed. Round-tripping is therefore NOT
248    /// evidence that this tokenizer is right, which is why the tests
249    /// that matter compare ids against llama.cpp instead.
250    pub fn decode(&self, ids: &[u32]) -> String {
251        let mut out = String::new();
252        for &id in ids {
253            if let Some(token) = self.id_to_token.get(id as usize) {
254                out.push_str(&token.replace(PHANTOM_SPACE, " "));
255            }
256        }
257        out
258    }
259}
260
261/// The codepoint ranges llama.cpp counts as Chinese, and therefore
262/// splits into single-character words.
263///
264/// Verbatim from `llm_tokenizer_wpm_session::is_chinese_char`, including
265/// the range that upstream's own comment flags as wrong (`0x2B920`
266/// should be `0x2B820`; the HuggingFace Rust implementation has the same
267/// value, so matching it is the point) and including the two ranges it
268/// leaves commented out. Widening this set would split CJK punctuation
269/// differently from the reference.
270fn is_chinese_char(c: char) -> bool {
271    let cpt = c as u32;
272    (0x04E00..=0x09FFF).contains(&cpt)
273        || (0x03400..=0x04DBF).contains(&cpt)
274        || (0x20000..=0x2A6DF).contains(&cpt)
275        || (0x2A700..=0x2B73F).contains(&cpt)
276        || (0x2B740..=0x2B81F).contains(&cpt)
277        || (0x2B920..=0x2CEAF).contains(&cpt)
278        || (0x0F900..=0x0FAFF).contains(&cpt)
279        || (0x2F800..=0x2FA1F).contains(&cpt)
280}
281
282/// BertNormalizer + BertPreTokenizer: normalize `text` and cut it into
283/// words.
284///
285/// A transcription of `llm_tokenizer_wpm_session::preprocess`. The order
286/// of the tests is load-bearing and is upstream's:
287///
288/// 1. whitespace ends the current word and is otherwise dropped;
289/// 2. NUL, U+FFFD and `\p{C}` are dropped, so a soft hyphen or a
290///    zero-width space joins the characters on either side of it into
291///    one word rather than breaking it;
292/// 3. an accent mark is dropped when `strip_accents`;
293/// 4. the character is lowercased when `lowercase`;
294/// 5. punctuation, an **ASCII** symbol and a CJK character each become a
295///    word of their own;
296/// 6. anything else extends the current word.
297///
298/// Two details that a plausible re-derivation gets wrong. The accent
299/// fold in step 3 runs over the NFD-folded text, so `é` has already
300/// become `e` and there is no mark left to drop; the test exists for
301/// marks that were standalone in the input, like the `e` + U+0301 in the
302/// parity corpus. And step 5 tests `is_symbol` only below U+007F, so
303/// `+` starts a new word and `€` does not.
304///
305/// Returns owned `String`s because steps 3 and 4 mean a word is not a
306/// slice of the input.
307fn preprocess(text: &str, opts: NormalizerOptions) -> Vec<String> {
308    let mut words: Vec<String> = vec![String::new()];
309
310    for raw in text.chars() {
311        let c = if opts.strip_accents {
312            unicode::nfd_base(raw)
313        } else {
314            raw
315        };
316
317        if unicode::is_whitespace(c) {
318            if !words.last().is_some_and(String::is_empty) {
319                words.push(String::new());
320            }
321            continue;
322        }
323
324        let flags = unicode::flags(c);
325        debug_assert!(
326            !flags.is_separator(),
327            "every \\p{{Z}} codepoint is also White_Space, so the check above \
328             should have consumed it: {c:?}"
329        );
330
331        if c == '\0' || c == '\u{fffd}' || flags.is_control() {
332            continue;
333        }
334        if opts.strip_accents && flags.is_accent_mark() {
335            continue;
336        }
337
338        let c = if opts.lowercase {
339            unicode::to_lower(c)
340        } else {
341            c
342        };
343
344        if flags.is_punctuation() || ((c as u32) < 0x7F && flags.is_symbol()) || is_chinese_char(c)
345        {
346            if !words.last().is_some_and(String::is_empty) {
347                words.push(String::new());
348            }
349            // A word of exactly this character, then a fresh word for
350            // whatever follows.
351            words.last_mut().expect("just pushed or non-empty").push(c);
352            words.push(String::new());
353        } else {
354            words.last_mut().expect("seeded with one word").push(c);
355        }
356    }
357
358    if words.last().is_some_and(String::is_empty) {
359        words.pop();
360    }
361    words
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    const BOTH: NormalizerOptions = NormalizerOptions {
369        lowercase: true,
370        strip_accents: true,
371    };
372    const NEITHER: NormalizerOptions = NormalizerOptions {
373        lowercase: false,
374        strip_accents: false,
375    };
376
377    fn words(text: &str, opts: NormalizerOptions) -> Vec<String> {
378        preprocess(text, opts)
379    }
380
381    #[test]
382    fn whitespace_splits_words_and_is_dropped() {
383        assert_eq!(words("hello world", BOTH), ["hello", "world"]);
384        assert_eq!(words("  hello   world  ", BOTH), ["hello", "world"]);
385        assert_eq!(words("", BOTH), Vec::<String>::new());
386        assert_eq!(words("   ", BOTH), Vec::<String>::new());
387    }
388
389    /// The parity corpus's `unicode-space` case, which is the reason
390    /// this cannot be `char::is_ascii_whitespace`: NBSP and the
391    /// ideographic space break words, and ZWSP does not because it is
392    /// `Cf` and gets dropped instead.
393    #[test]
394    fn unicode_spaces_break_words_but_zero_width_ones_vanish() {
395        assert_eq!(
396            words("a\u{a0}b\u{3000}c\u{2009}d\u{200b}e", BOTH),
397            ["a", "b", "c", "de"]
398        );
399    }
400
401    #[test]
402    fn punctuation_and_ascii_symbols_become_single_character_words() {
403        assert_eq!(words("don't.", BOTH), ["don", "'", "t", "."]);
404        assert_eq!(words("a+b", BOTH), ["a", "+", "b"]);
405        assert_eq!(words("1+2=3", BOTH), ["1", "+", "2", "=", "3"]);
406    }
407
408    /// `is_symbol` is consulted only below U+007F. A non-ASCII symbol
409    /// therefore stays welded to its neighbours, which is the opposite
410    /// of what "split on symbols" would do.
411    #[test]
412    fn non_ascii_symbols_do_not_split() {
413        assert_eq!(words("a€b", BOTH), ["a€b"]);
414        assert_eq!(words("a$b", BOTH), ["a", "$", "b"]);
415    }
416
417    #[test]
418    fn cjk_characters_each_get_their_own_word() {
419        assert_eq!(words("日本語", BOTH), ["日", "本", "語"]);
420        assert_eq!(words("a日b", BOTH), ["a", "日", "b"]);
421    }
422
423    /// Hangul is not in the Chinese ranges, so it is never split into
424    /// per-character words. What happens to it instead is worse and is
425    /// the reference's behaviour, not a defect here: every Hangul
426    /// syllable has an NFD decomposition into jamo, so the accent fold
427    /// replaces it with its LEADING jamo and the rest of the syllable is
428    /// gone. `서울` becomes `ᄉᄋ`, one word, and a `[UNK]` in practice.
429    ///
430    /// Asserted rather than left implicit because it is exactly the kind
431    /// of behaviour a future "obvious fix" would break parity by
432    /// improving. The parity corpus's `cjk` case carries `서울` and the
433    /// oracle agrees with this.
434    #[test]
435    fn hangul_is_folded_to_its_leading_jamo_by_the_accent_pass() {
436        assert_eq!(words("서울", BOTH), ["\u{1109}\u{110b}"]);
437        assert_eq!(words("서울", NEITHER), ["서울"], "only the fold does this");
438    }
439
440    #[test]
441    fn controls_are_dropped_without_breaking_the_word() {
442        assert_eq!(words("a\u{1b}b", BOTH), ["ab"], "ESC is Cc");
443        assert_eq!(words("a\u{ad}b", BOTH), ["ab"], "soft hyphen is Cf");
444        assert_eq!(words("a\u{fffd}b", BOTH), ["ab"], "the replacement char");
445        assert_eq!(words("a\0b", BOTH), ["ab"], "NUL");
446    }
447
448    #[test]
449    fn lowercase_and_accent_stripping_follow_their_flags() {
450        assert_eq!(words("Café", BOTH), ["cafe"]);
451        assert_eq!(words("Café", NEITHER), ["Café"]);
452        assert_eq!(
453            words(
454                "Café",
455                NormalizerOptions {
456                    lowercase: true,
457                    strip_accents: false
458                }
459            ),
460            ["café"]
461        );
462        assert_eq!(
463            words(
464                "Café",
465                NormalizerOptions {
466                    lowercase: false,
467                    strip_accents: true
468                }
469            ),
470            ["Cafe"]
471        );
472    }
473
474    /// The corpus writes `café` precomposed and `e\u{301}clair`
475    /// decomposed on purpose. Both must land on the same bytes, and they
476    /// do so by two different routes: the NFD fold for the first, the
477    /// `is_accent_mark` drop for the second.
478    #[test]
479    fn precomposed_and_decomposed_accents_normalize_alike() {
480        assert_eq!(words("café", BOTH), words("cafe\u{301}", BOTH));
481        assert_eq!(words("cafe\u{301}", BOTH), ["cafe"]);
482    }
483
484    /// A vocabulary in the form llama.cpp's converter writes: `▁` on
485    /// word-initial pieces, bare continuations, `[...]` specials.
486    fn toy() -> GgufWordPieceTokenizer {
487        let pieces = [
488            "[PAD]", "[UNK]", "[CLS]", "[SEP]", // 0..=3
489            "▁un", "▁hello", "▁.", "▁world", // 4..=7
490            "aff", "able", "ing",    // 8..=10
491            "▁unaff", // 11, a word-initial piece that EXTENDS ▁un
492        ];
493        let id_to_token: Vec<String> = pieces.iter().map(|s| s.to_string()).collect();
494        let mut token_to_id = HashMap::new();
495        for (i, t) in id_to_token.iter().enumerate() {
496            token_to_id.entry(t.as_bytes().to_vec()).or_insert(i as u32);
497        }
498        let max_token_len = id_to_token.iter().map(|t| t.len()).max().unwrap();
499        GgufWordPieceTokenizer {
500            token_to_id,
501            id_to_token,
502            max_token_len,
503            unk_id: 1,
504            normalizer: BOTH,
505            special_tokens: vec![("[CLS]".to_string(), 2), ("[SEP]".to_string(), 3)],
506        }
507    }
508
509    #[test]
510    fn a_word_is_covered_word_initial_piece_first_then_continuations() {
511        let t = toy();
512        // ▁un + able, which only works because `able` carries no phantom
513        // space and so cannot match at position 0.
514        assert_eq!(t.encode("unable"), [4, 9]);
515        assert_eq!(t.encode("hello"), [5]);
516    }
517
518    /// Longest match first, not merely *a* match.
519    ///
520    /// `unaffable` has two full covers in this vocabulary: `▁unaff` +
521    /// `able`, and `▁un` + `aff` + `able`. Both reach the end of the
522    /// word, so the all-or-nothing rule does not choose between them and
523    /// a shortest-first loop would be just as "correct" while producing
524    /// different ids for every real checkpoint. Only the probe order
525    /// decides, which is why it is asserted on its own.
526    #[test]
527    fn the_longest_match_wins_where_a_shorter_one_would_also_cover() {
528        let t = toy();
529        assert_eq!(t.encode("unaffable"), [11, 9], "▁unaff + able");
530        assert_ne!(
531            t.encode("unaffable"),
532            [4, 8, 9],
533            "▁un + aff + able is the shortest-first answer"
534        );
535    }
536
537    /// The continuation pieces must not be reachable at the start of a
538    /// word. `aff` alone has no `▁aff` entry, so it is unknown even
539    /// though its bytes are in the vocabulary. This is the `##` rule,
540    /// expressed the way a GGUF expresses it.
541    #[test]
542    fn a_continuation_piece_cannot_start_a_word() {
543        let t = toy();
544        assert_eq!(t.encode("aff"), [1], "no ▁aff, so [UNK]");
545    }
546
547    /// The whole point of the all-or-nothing rule. `worlds` matches
548    /// `▁world` at position 0 and then has nothing for the trailing `s`,
549    /// so the `▁world` already emitted is thrown away and the word
550    /// becomes ONE unknown, not `▁world` plus an unknown.
551    #[test]
552    fn a_partly_covered_word_discards_its_pieces_and_becomes_one_unknown() {
553        let t = toy();
554        assert_eq!(
555            t.encode("worlds"),
556            [1],
557            "a partial cover must be discarded, not kept"
558        );
559        // The same word without the stray byte does cover, which is what
560        // makes the assertion above about the fallback rule rather than
561        // about the vocabulary being too small.
562        assert_eq!(t.encode("world"), [7]);
563    }
564
565    #[test]
566    fn each_word_falls_back_independently() {
567        let t = toy();
568        assert_eq!(t.encode("hello zzz world"), [5, 1, 7]);
569    }
570
571    #[test]
572    fn punctuation_is_its_own_word_and_matches_its_own_piece() {
573        let t = toy();
574        assert_eq!(t.encode("hello."), [5, 6], "▁hello then ▁.");
575    }
576
577    #[test]
578    fn special_tokens_are_carved_out_before_normalization() {
579        let t = toy();
580        // Without the carve-out the brackets would each be their own
581        // punctuation word and `[CLS]` would come back as four unknowns.
582        assert_eq!(t.encode("[CLS]hello[SEP]"), [2, 5, 3]);
583    }
584
585    #[test]
586    fn decode_reverses_the_phantom_space() {
587        let t = toy();
588        assert_eq!(t.decode(&[4, 8, 9]), " unaffable");
589        assert_eq!(t.decode(&[5, 7]), " hello world");
590        assert_eq!(t.decode(&[2]), "[CLS]");
591    }
592
593    /// `max_token_len` caps the probe length, so a vocabulary whose
594    /// longest piece is short must still tokenize correctly rather than
595    /// missing longer matches that do not exist.
596    #[test]
597    fn the_probe_cap_is_the_longest_piece_in_bytes() {
598        let t = toy();
599        assert_eq!(t.max_token_len, "▁hello".len(), "6 bytes, not 6 chars");
600    }
601}