Skip to main content

cortiq_engine/
tokenizer.rs

1//! Byte-level BPE tokenizer — HF tokenizer.json parity.
2//!
3//! Faithful pipeline (matches `tokenizers` for Qwen-style files):
4//!   added-token split (raw text) → NFC → pre-tokenizer regex
5//!   (GPT-2 style, needs lookahead) → byte-level mapping → ranked BPE
6//!   merges → vocab ids. Decode reverses through the byte-level map,
7//!   assembling UTF-8 across token boundaries.
8//!
9//! No silent corruption: a symbol that cannot be encoded is reported
10//! (tracing::error), never dropped without a trace.
11
12use serde::Deserialize;
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15use unicode_normalization::UnicodeNormalization;
16
17/// GPT-2 pre-tokenizer pattern — used when tokenizer.json carries no
18/// explicit Split regex (Qwen files carry their own; see `from_json`).
19const DEFAULT_SPLIT: &str = r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+";
20
21/// A loaded BPE tokenizer.
22pub struct Tokenizer {
23    /// Token string → ID
24    vocab: HashMap<String, u32>,
25    /// ID → Token string
26    id_to_token: Vec<String>,
27    /// BPE merge ranks: (left, right) → rank (lower merges first)
28    ranks: HashMap<(String, String), u32>,
29    /// All added tokens (split during encode; emitted raw at decode)
30    added: Vec<(String, u32)>,
31    /// IDs of added tokens (decode: emit content raw, no byte-map)
32    added_ids: HashSet<u32>,
33    /// IDs of special tokens (skipped by `decode`)
34    special_ids: HashSet<u32>,
35    /// Pre-tokenizer split pattern (None = whitespace fallback for the
36    /// synthetic `byte_level()` tokenizer)
37    /// Applied in order; each subdivides the previous stage's pieces.
38    split_res: Vec<fancy_regex::Regex>,
39    /// SentencePiece Prepend("▁") normalizer present (llama family).
40    /// Gemma replaces spaces with ▁ but does NOT prepend one.
41    sp_prepend: bool,
42    /// Metaspace `prepend_scheme: "first"` in the PRE-tokenizer (no
43    /// Prepend normalizer): the ▁ goes on the very first section of the
44    /// input only, so text after an added token gets none. Nanbeige 4.2
45    /// is this shape — reading only the normalizer left every raw prompt
46    /// short one leading ▁ ("Hello" instead of "▁Hello").
47    sp_prepend_first: bool,
48    /// SentencePiece family (TinyLlama/Llama-2/Mistral): metaspace ▁
49    /// normalization + byte_fallback, no byte-level alphabet.
50    metaspace: bool,
51    /// NFC only when the file's normalizer declares it (Qwen does,
52    /// TinyLlama does not — forcing it broke combining-accent parity).
53    nfc: bool,
54    /// byte → byte-level char (GPT-2 visible-alphabet mapping)
55    byte_to_char: [char; 256],
56    /// byte-level char → byte
57    char_to_byte: HashMap<char, u8>,
58    /// Special tokens
59    pub bos_token_id: Option<u32>,
60    pub eos_token_id: Option<u32>,
61    pub pad_token_id: Option<u32>,
62    /// Chat template special tokens
63    pub im_start_id: Option<u32>,
64    pub im_end_id: Option<u32>,
65    /// Jinja chat template carried by the container (spec §6.1);
66    /// None → hardcoded ChatML fallback.
67    pub chat_template: Option<String>,
68    /// Extra stop ids from the container's generation config.
69    pub extra_eos: HashSet<u32>,
70    /// Generation prepends BOS (llama post_processor semantics).
71    pub add_bos: bool,
72}
73
74impl std::fmt::Debug for Tokenizer {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("Tokenizer")
77            .field("vocab", &self.vocab.len())
78            .field("merges", &self.ranks.len())
79            .field("added", &self.added.len())
80            .finish()
81    }
82}
83
84/// GPT-2 byte↔unicode bijection: printable bytes map to themselves,
85/// the rest get consecutive codepoints from U+0100 up.
86fn bytes_to_unicode() -> ([char; 256], HashMap<char, u8>) {
87    let mut b2c = ['\0'; 256];
88    let mut c2b = HashMap::with_capacity(256);
89    let mut n = 0u32;
90    for b in 0..=255u16 {
91        let printable =
92            (0x21..=0x7E).contains(&b) || (0xA1..=0xAC).contains(&b) || (0xAE..=0xFF).contains(&b);
93        let c = if printable {
94            char::from_u32(b as u32).unwrap()
95        } else {
96            let c = char::from_u32(256 + n).unwrap();
97            n += 1;
98            c
99        };
100        b2c[b as usize] = c;
101        c2b.insert(c, b as u8);
102    }
103    (b2c, c2b)
104}
105
106/// HuggingFace tokenizer.json schema (the parts we execute).
107#[derive(Deserialize)]
108struct HfTokenizerJson {
109    model: HfModel,
110    #[serde(default)]
111    added_tokens: Vec<HfAddedToken>,
112    #[serde(default)]
113    pre_tokenizer: Option<serde_json::Value>,
114    #[serde(default)]
115    normalizer: Option<serde_json::Value>,
116    #[serde(default)]
117    post_processor: Option<serde_json::Value>,
118}
119
120#[derive(Deserialize)]
121struct HfModel {
122    vocab: HashMap<String, u32>,
123    #[serde(default)]
124    merges: Vec<HfMerge>,
125    #[serde(default)]
126    byte_fallback: bool,
127}
128
129/// Merge rules come in two HF flavours: legacy `"a b"` strings and
130/// modern `["a", "b"]` pairs (Qwen3.5 tokenizer.json uses pairs).
131#[derive(Deserialize)]
132#[serde(untagged)]
133enum HfMerge {
134    Pair([String; 2]),
135    Text(String),
136}
137
138#[derive(Deserialize)]
139struct HfAddedToken {
140    id: u32,
141    content: String,
142    special: bool,
143}
144
145/// Collect EVERY Split regex from a pre_tokenizer subtree, in order.
146///
147/// A Sequence applies its splits one after another, each subdividing what
148/// the previous one produced — taking only the first is not an
149/// approximation, it is a different tokenizer. DeepSeek-V4 puts the digit
150/// rule first and the word rule third, so reading one pattern sent whole
151/// sentences into BPE as a single piece and produced ids the model has
152/// never seen.
153fn collect_split_patterns(pt: &serde_json::Value, out: &mut Vec<String>) {
154    if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
155        if let Some(r) = pt
156            .get("pattern")
157            .and_then(|p| p.get("Regex"))
158            .and_then(|r| r.as_str())
159        {
160            out.push(r.to_string());
161        }
162        return;
163    }
164    if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
165        for p in list {
166            collect_split_patterns(p, out);
167        }
168    }
169}
170
171/// `prepend_scheme` of a Metaspace pre-tokenizer ("always" | "first" |
172/// "never"), searched through a Sequence.
173fn find_prepend_scheme(pt: &serde_json::Value) -> Option<String> {
174    if pt.get("type").and_then(|t| t.as_str()) == Some("Metaspace") {
175        return pt
176            .get("prepend_scheme")
177            .and_then(|p| p.as_str())
178            .map(String::from);
179    }
180    if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
181        return list.iter().find_map(find_prepend_scheme);
182    }
183    None
184}
185
186impl Tokenizer {
187    /// Load tokenizer from HuggingFace tokenizer.json file.
188    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TokenizerError> {
189        let data = std::fs::read_to_string(path.as_ref())
190            .map_err(|e| TokenizerError::Io(e.to_string()))?;
191        Self::from_json(&data)
192    }
193
194    /// Load tokenizer from raw tokenizer.json bytes (CMF VOCAB section).
195    pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenizerError> {
196        let s = std::str::from_utf8(bytes)
197            .map_err(|e| TokenizerError::Parse(format!("vocab is not UTF-8: {e}")))?;
198        Self::from_json(s)
199    }
200
201    /// Load tokenizer from JSON string.
202    pub fn from_json(json: &str) -> Result<Self, TokenizerError> {
203        let hf: HfTokenizerJson =
204            serde_json::from_str(json).map_err(|e| TokenizerError::Parse(e.to_string()))?;
205
206        let mut vocab = hf.model.vocab;
207        let mut ranks = HashMap::new();
208        for (rank, m) in hf.model.merges.into_iter().enumerate() {
209            let (a, b) = match m {
210                HfMerge::Pair([a, b]) => (a, b),
211                HfMerge::Text(s) => {
212                    let mut it = s.splitn(2, ' ');
213                    match (it.next(), it.next()) {
214                        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
215                        _ => continue,
216                    }
217                }
218            };
219            ranks.insert((a, b), rank as u32);
220        }
221
222        // Family detection: SentencePiece carries byte_fallback and/or a
223        // Prepend("▁") normalizer; byte-level BPE carries a Split regex.
224        // Llama-family post_processor prepends <s> at add_special_tokens
225        // time; generation must honor it (word salad without BOS).
226        let mut saw_gemma_bos = false;
227        let add_bos_detected = hf
228            .post_processor
229            .as_ref()
230            .map(|p| {
231                let pp = p.to_string();
232                pp.contains("\"<s>\"") || pp.contains("\"<bos>\"")
233            })
234            .unwrap_or(false);
235        let nfc = hf
236            .normalizer
237            .as_ref()
238            .map(|n| n.to_string().contains("NFC"))
239            .unwrap_or(false);
240        let metaspace = hf.model.byte_fallback
241            || hf
242                .normalizer
243                .as_ref()
244                .map(|n| n.to_string().contains("\u{2581}") || n.to_string().contains("▁"))
245                .unwrap_or(false);
246        let sp_prepend = hf
247            .normalizer
248            .as_ref()
249            .map(|n| n.to_string().contains("Prepend"))
250            .unwrap_or(false);
251        // Metaspace can also live in the pre-tokenizer, carrying its own
252        // prepend_scheme: "always" behaves like the llama normalizer,
253        // "first" only marks the head of the input (see `sp_prepend_first`).
254        let (sp_prepend, sp_prepend_first) = if sp_prepend {
255            (true, false)
256        } else {
257            match hf.pre_tokenizer.as_ref().and_then(find_prepend_scheme) {
258                Some(s) if s == "always" => (true, false),
259                Some(s) if s == "first" => (false, true),
260                _ => (false, false),
261            }
262        };
263        let split_res = if metaspace {
264            Vec::new()
265        } else {
266            let mut pats = Vec::new();
267            if let Some(pt) = hf.pre_tokenizer.as_ref() {
268                collect_split_patterns(pt, &mut pats);
269            }
270            if pats.is_empty() {
271                pats.push(DEFAULT_SPLIT.to_string());
272            }
273            pats.iter()
274                .map(|p| {
275                    fancy_regex::Regex::new(p)
276                        .map_err(|e| TokenizerError::Parse(format!("pre-tokenizer regex: {e}")))
277                })
278                .collect::<Result<Vec<_>, _>>()?
279        };
280
281        // Added tokens: longest-first so overlapping contents match right.
282        let mut bos_token_id = None;
283        let mut eos_token_id = None;
284        let mut pad_token_id = None;
285        let mut im_start_id = None;
286        let mut im_end_id = None;
287        let mut special_ids = HashSet::new();
288        let mut added_ids = HashSet::new();
289        let mut added = Vec::new();
290
291        for at in &hf.added_tokens {
292            vocab.insert(at.content.clone(), at.id);
293            added.push((at.content.clone(), at.id));
294            added_ids.insert(at.id);
295            if at.special {
296                special_ids.insert(at.id);
297            }
298            match at.content.as_str() {
299                "<|endoftext|>" | "</s>" | "[EOS]" => eos_token_id = Some(at.id),
300                "<|im_start|>" => im_start_id = Some(at.id),
301                "<|im_end|>" => im_end_id = Some(at.id),
302                "<s>" | "[BOS]" => bos_token_id = Some(at.id),
303                // Gemma spells BOS as literal "<bos>" — the family
304                // REQUIRES it on every sequence, and newer tokenizers
305                // (gemma-4) no longer say so in a post_processor.
306                "<bos>" => {
307                    bos_token_id = Some(at.id);
308                    saw_gemma_bos = true;
309                }
310                "<pad>" => pad_token_id = Some(at.id),
311                _ => {}
312            }
313        }
314        added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
315
316        // Gemma REQUIRES a leading <bos> on every sequence, but newer
317        // tokenizers (gemma-4, 262k vocab) no longer spell it in a
318        // post_processor template — the family marker <start_of_turn>
319        // is the reliable tell. Without this, raw-text scoring runs
320        // unanchored and the first ~30 positions read worse than
321        // uniform (the chat path masked it: the template carries <bos>).
322        let gemma_family = saw_gemma_bos
323            || vocab.contains_key("<start_of_turn>")
324            || added.iter().any(|(c, _)| c == "<start_of_turn>");
325
326        // The post_processor template names the exact BOS content
327        // (llama "<s>", gemma "<bos>" — gemma's vocab carries BOTH, so
328        // added-token scan order must not decide).
329        if let Some(pp) = hf.post_processor.as_ref() {
330            let pp = pp.to_string();
331            for name in ["<bos>", "<s>"] {
332                if pp.contains(&format!("\"{name}\"")) {
333                    if let Some(&id) = vocab.get(name) {
334                        bos_token_id = Some(id);
335                    }
336                    break;
337                }
338            }
339        }
340
341        // Build reverse map
342        let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
343        let mut id_to_token = vec![String::new(); max_id + 1];
344        for (token, &id) in &vocab {
345            if (id as usize) < id_to_token.len() {
346                id_to_token[id as usize] = token.clone();
347            }
348        }
349
350        let (byte_to_char, char_to_byte) = bytes_to_unicode();
351
352        tracing::info!(
353            "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
354            vocab.len(),
355            ranks.len(),
356            added.len(),
357            eos_token_id
358        );
359
360        Ok(Self {
361            vocab,
362            id_to_token,
363            ranks,
364            added,
365            added_ids,
366            special_ids,
367            split_res,
368            metaspace,
369            sp_prepend,
370            sp_prepend_first,
371            nfc,
372            byte_to_char,
373            char_to_byte,
374            bos_token_id,
375            eos_token_id,
376            pad_token_id,
377            im_start_id,
378            im_end_id,
379            chat_template: None,
380            extra_eos: HashSet::new(),
381            add_bos: add_bos_detected || gemma_family,
382        })
383    }
384
385    /// Create a minimal tokenizer for testing (byte tokens, no merges).
386    pub fn byte_level() -> Self {
387        let mut vocab = HashMap::new();
388        let mut id_to_token = Vec::with_capacity(256);
389        for i in 0..256u32 {
390            let tok = format!("<0x{:02X}>", i);
391            vocab.insert(tok.clone(), i);
392            id_to_token.push(tok);
393        }
394        let (byte_to_char, char_to_byte) = bytes_to_unicode();
395        Self {
396            vocab,
397            id_to_token,
398            ranks: HashMap::new(),
399            added: Vec::new(),
400            added_ids: HashSet::new(),
401            special_ids: HashSet::new(),
402            split_res: Vec::new(),
403            metaspace: false,
404            sp_prepend: false,
405            sp_prepend_first: false,
406            nfc: false,
407            byte_to_char,
408            char_to_byte,
409            bos_token_id: None,
410            eos_token_id: None,
411            pad_token_id: None,
412            im_start_id: None,
413            im_end_id: None,
414            chat_template: None,
415            extra_eos: HashSet::new(),
416            add_bos: false,
417        }
418    }
419
420    /// Encode text to token IDs.
421    pub fn encode(&self, text: &str) -> Vec<u32> {
422        let mut ids = Vec::new();
423        // Added tokens match on raw text (normalized: false), longest first.
424        let mut rest = text;
425        // `prepend_scheme: "first"` marks only the section that starts at
426        // offset 0 — HF drops the ▁ for everything after an added token,
427        // which is why a chat prompt opening with <|im_start|> tokenizes
428        // the same either way and only raw prompts were wrong.
429        let mut head = true;
430        'outer: while !rest.is_empty() {
431            let mut best: Option<(usize, usize, u32)> = None; // (pos, len, id)
432            for (content, id) in &self.added {
433                if let Some(pos) = rest.find(content.as_str()) {
434                    let better = match best {
435                        None => true,
436                        Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
437                    };
438                    if better {
439                        best = Some((pos, content.len(), *id));
440                    }
441                    if pos == 0 {
442                        break; // earliest possible; added is longest-first
443                    }
444                }
445            }
446            match best {
447                Some((pos, len, id)) => {
448                    self.encode_segment_at(&rest[..pos], head, &mut ids);
449                    ids.push(id);
450                    rest = &rest[pos + len..];
451                    head = false;
452                }
453                None => {
454                    self.encode_segment_at(rest, head, &mut ids);
455                    break 'outer;
456                }
457            }
458        }
459        ids
460    }
461
462    /// Encode one added-token-free segment: NFC → split → byte-map →
463    /// BPE. `head` says whether this section starts at offset 0 of the
464    /// input — only that one takes a `prepend_scheme: "first"` ▁.
465    fn encode_segment_at(&self, segment: &str, head: bool, out: &mut Vec<u32>) {
466        if segment.is_empty() {
467            return;
468        }
469        let norm: String = if self.nfc {
470            segment.nfc().collect()
471        } else {
472            segment.to_string()
473        };
474        if self.metaspace {
475            // SentencePiece: [Prepend("▁") +] Replace(" "→"▁"), BPE over
476            // chars of the whole span (no pre-tokenizer, no byte map).
477            // Gemma's normalizer replaces only — no dummy prefix.
478            let sp = if self.sp_prepend {
479                // Normalizer order: Prepend THEN Replace, unguarded — so
480                // " hello" really does become ▁▁hello on llama.
481                format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
482            } else {
483                // Pre-tokenizer Metaspace: Replace, then prepend only if
484                // the span does not already start with ▁ (HF's guard, so
485                // " hello" stays one ▁, not two).
486                let replaced = norm.replace(' ', "\u{2581}");
487                if self.sp_prepend_first && head && !replaced.starts_with('\u{2581}') {
488                    format!("\u{2581}{replaced}")
489                } else {
490                    replaced
491                }
492            };
493            self.bpe_piece_sp(&sp, out);
494            return;
495        }
496        if !self.split_res.is_empty() {
497            // Each stage subdivides the pieces the previous one left, with
498            // Isolated behaviour: both the matches and the gaps survive.
499            let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
500            for re in &self.split_res {
501                let mut next: Vec<(usize, usize)> = Vec::with_capacity(pieces.len() * 2);
502                for (ps, pe) in pieces {
503                    let seg = &norm[ps..pe];
504                    let mut last = 0usize;
505                    for m in re.find_iter(seg) {
506                        let m = match m {
507                            Ok(m) => m,
508                            Err(e) => {
509                                tracing::error!("pre-tokenizer regex failed: {e}");
510                                break;
511                            }
512                        };
513                        if m.start() > last {
514                            next.push((ps + last, ps + m.start()));
515                        }
516                        if m.end() > m.start() {
517                            next.push((ps + m.start(), ps + m.end()));
518                        }
519                        last = m.end();
520                    }
521                    if last < seg.len() {
522                        next.push((ps + last, pe));
523                    }
524                }
525                pieces = next;
526            }
527            for (ps, pe) in pieces {
528                self.bpe_piece(&norm[ps..pe], out);
529            }
530        } else {
531            {
532                // Synthetic byte_level() tokenizer: raw byte tokens.
533                for b in norm.bytes() {
534                    let tok = format!("<0x{:02X}>", b);
535                    if let Some(&id) = self.vocab.get(&tok) {
536                        out.push(id);
537                    }
538                }
539            }
540        }
541    }
542
543    /// SentencePiece BPE: symbols are chars (no byte-level alphabet);
544    /// unknown symbols fall back to <0xNN> tokens per UTF-8 byte.
545    fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
546        if piece.is_empty() {
547            return;
548        }
549        let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
550        loop {
551            let mut best: Option<(u32, usize)> = None;
552            for i in 0..sym.len().saturating_sub(1) {
553                if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
554                    if best.map(|(br, _)| r < br).unwrap_or(true) {
555                        best = Some((r, i));
556                    }
557                }
558            }
559            let Some((_, i)) = best else { break };
560            let merged = format!("{}{}", sym[i], sym[i + 1]);
561            let (left, right) = (sym[i].clone(), sym[i + 1].clone());
562            let mut j = 0;
563            while j + 1 < sym.len() {
564                if sym[j] == left && sym[j + 1] == right {
565                    sym[j] = merged.clone();
566                    sym.remove(j + 1);
567                }
568                j += 1;
569            }
570        }
571        for t in &sym {
572            if let Some(&id) = self.vocab.get(t) {
573                out.push(id);
574            } else {
575                let mut ok = true;
576                for byte in t.bytes() {
577                    let tok = format!("<0x{:02X}>", byte);
578                    match self.vocab.get(&tok) {
579                        Some(&id) => out.push(id),
580                        None => {
581                            ok = false;
582                            break;
583                        }
584                    }
585                }
586                if !ok {
587                    tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
588                }
589            }
590        }
591    }
592
593    /// Byte-level map one pre-token piece, then ranked BPE merges.
594    fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
595        if piece.is_empty() {
596            return;
597        }
598        let mapped: Vec<String> = piece
599            .bytes()
600            .map(|b| self.byte_to_char[b as usize].to_string())
601            .collect();
602        let mut sym = mapped;
603
604        // Classic BPE: repeatedly merge the lowest-rank adjacent pair.
605        loop {
606            let mut best: Option<(u32, usize)> = None;
607            for i in 0..sym.len().saturating_sub(1) {
608                if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
609                    if best.map(|(br, _)| r < br).unwrap_or(true) {
610                        best = Some((r, i));
611                    }
612                }
613            }
614            let Some((_, i)) = best else { break };
615            let merged = format!("{}{}", sym[i], sym[i + 1]);
616            // Merge ALL occurrences of this exact pair, left to right.
617            let (left, right) = (sym[i].clone(), sym[i + 1].clone());
618            let mut j = 0;
619            while j + 1 < sym.len() {
620                if sym[j] == left && sym[j + 1] == right {
621                    sym[j] = merged.clone();
622                    sym.remove(j + 1);
623                }
624                j += 1;
625            }
626        }
627
628        for s in &sym {
629            if let Some(&id) = self.vocab.get(s) {
630                out.push(id);
631            } else {
632                // Byte-fallback (synthetic vocabs); never drop silently.
633                let mut ok = true;
634                for ch in s.chars() {
635                    let Some(&b) = self.char_to_byte.get(&ch) else {
636                        ok = false;
637                        break;
638                    };
639                    let tok = format!("<0x{:02X}>", b);
640                    if let Some(&id) = self.vocab.get(&tok) {
641                        out.push(id);
642                    } else {
643                        ok = false;
644                        break;
645                    }
646                }
647                if !ok {
648                    tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
649                }
650            }
651        }
652    }
653
654    /// Decode token IDs back to text. Special tokens are skipped; added
655    /// tokens are raw text; everything else reverses the byte-level map.
656    pub fn decode(&self, ids: &[u32]) -> String {
657        let mut bytes: Vec<u8> = Vec::new();
658        for &id in ids {
659            if self.special_ids.contains(&id) {
660                continue;
661            }
662            let idx = id as usize;
663            if idx >= self.id_to_token.len() {
664                continue;
665            }
666            let tok = &self.id_to_token[idx];
667            if self.added_ids.contains(&id) {
668                // Gemma-3n declares its multi-space ▁-runs as ADDED
669                // tokens — verbatim passthrough leaked ▁ into output.
670                if self.metaspace && tok.contains('\u{2581}') {
671                    bytes.extend_from_slice(tok.replace('\u{2581}', " ").as_bytes());
672                } else {
673                    bytes.extend_from_slice(tok.as_bytes());
674                }
675                continue;
676            }
677            // Byte-fallback / legacy byte tokens
678            if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
679                if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
680                    bytes.push(b);
681                    continue;
682                }
683            }
684            if self.metaspace {
685                // SP decoder: Replace(▁→" "); UTF-8 chars pass through.
686                for ch in tok.chars() {
687                    if ch == '\u{2581}' {
688                        bytes.push(b' ');
689                    } else {
690                        let mut buf = [0u8; 4];
691                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
692                    }
693                }
694                continue;
695            }
696            for ch in tok.chars() {
697                match self.char_to_byte.get(&ch) {
698                    Some(&b) => bytes.push(b),
699                    // Not a byte-level char (shouldn't happen for real
700                    // vocabs) — pass the char through as UTF-8.
701                    None => {
702                        let mut buf = [0u8; 4];
703                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
704                    }
705                }
706            }
707        }
708        let text = String::from_utf8_lossy(&bytes).into_owned();
709        if self.metaspace && (self.sp_prepend || self.sp_prepend_first) {
710            // SP decoder Strip(start=1): one leading space from Prepend.
711            if let Some(stripped) = text.strip_prefix(' ') {
712                return stripped.to_string();
713            }
714        }
715        text
716    }
717
718    /// Streaming decode of ONE token: no sequence-level Strip — a
719    /// per-token strip would eat the ▁-spaces of every SP word.
720    pub fn decode_token(&self, id: u32) -> String {
721        if self.special_ids.contains(&id) {
722            return String::new();
723        }
724        let idx = id as usize;
725        if idx >= self.id_to_token.len() {
726            return String::new();
727        }
728        let tok = &self.id_to_token[idx];
729        if self.added_ids.contains(&id) {
730            if self.metaspace && tok.contains('\u{2581}') {
731                return tok.replace('\u{2581}', " ");
732            }
733            return tok.clone();
734        }
735        if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
736            if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
737                return String::from_utf8_lossy(&[b]).into_owned();
738            }
739        }
740        if self.metaspace {
741            return tok.replace('\u{2581}', " ");
742        }
743        let mut bytes = Vec::new();
744        for ch in tok.chars() {
745            match self.char_to_byte.get(&ch) {
746                Some(&b) => bytes.push(b),
747                None => {
748                    let mut buf = [0u8; 4];
749                    bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
750                }
751            }
752        }
753        String::from_utf8_lossy(&bytes).into_owned()
754    }
755
756    /// Render the container's Jinja chat template (HF semantics:
757    /// trim_blocks + lstrip_blocks + loop controls) and encode it.
758    /// Falls back to hardcoded ChatML when the file carries none.
759    pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
760        self.apply_chat_template_opts(messages, None)
761    }
762
763    /// Like `apply_chat_template`, with an explicit `enable_thinking` value for
764    /// reasoning-model templates (Qwen3/3.5 emit an empty <think> block when it
765    /// is false, so the model answers directly). `None` leaves the variable
766    /// undefined — the template's own default applies.
767    pub fn apply_chat_template_opts(
768        &self,
769        messages: &[(String, String)],
770        enable_thinking: Option<bool>,
771    ) -> Vec<u32> {
772        if let Some(tpl) = &self.chat_template {
773            match self.render_template(tpl, messages, enable_thinking) {
774                Ok(text) => return self.with_bos(self.encode(&text)),
775                Err(e) => {
776                    tracing::error!("chat template render failed ({e}); ChatML fallback");
777                }
778            }
779        }
780        self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
781    }
782
783    /// Prepend BOS when the tokenizer declares it (llama family).
784    pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
785        if self.add_bos {
786            if let Some(b) = self.bos_token_id {
787                if ids.first() != Some(&b) {
788                    ids.insert(0, b);
789                }
790            }
791        }
792        ids
793    }
794
795    /// Render the carried template to text (parity-testable surface).
796    pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
797        self.render_chat_opts(messages, None)
798    }
799
800    /// Render the carried template to text with explicit thinking mode.
801    pub fn render_chat_opts(
802        &self,
803        messages: &[(String, String)],
804        enable_thinking: Option<bool>,
805    ) -> Option<String> {
806        let tpl = self.chat_template.as_ref()?;
807        match self.render_template(tpl, messages, enable_thinking) {
808            Ok(t) => Some(t),
809            Err(e) => {
810                tracing::error!("chat template render: {e:#}");
811                None
812            }
813        }
814    }
815
816    fn render_template(
817        &self,
818        tpl: &str,
819        messages: &[(String, String)],
820        enable_thinking: Option<bool>,
821    ) -> Result<String, minijinja::Error> {
822        let mut env = minijinja::Environment::new();
823        env.set_trim_blocks(true);
824        env.set_lstrip_blocks(true);
825        // HF templates use python string methods (.startswith, .strip…).
826        env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
827        env.add_template("chat", tpl)?;
828        let msgs: Vec<minijinja::Value> = messages
829            .iter()
830            .map(|(role, content)| {
831                minijinja::context! { role => role, content => content }
832            })
833            .collect();
834        // `enable_thinking` stays UNDEFINED when None — reasoning templates
835        // check `enable_thinking is defined` and fall back to their default.
836        let rendered = match enable_thinking {
837            Some(v) => env.get_template("chat")?.render(minijinja::context! {
838                messages => msgs,
839                add_generation_prompt => true,
840                enable_thinking => v,
841            })?,
842            None => env.get_template("chat")?.render(minijinja::context! {
843                messages => msgs,
844                add_generation_prompt => true,
845            })?,
846        };
847        // Templates that ignore `enable_thinking` (e.g. Nanbeige/Qwen-legacy)
848        // always emit a generation prompt. When thinking is explicitly disabled,
849        // prefill an empty <think>…</think> block so the model answers directly.
850        if enable_thinking == Some(false) && !rendered.contains("</think>") {
851            if let Some(pos) = rendered.rfind("assistant") {
852                let mut insert_at = pos + "assistant".len();
853                if let Some(idx) = rendered[insert_at..].find('\n') {
854                    insert_at += idx + 1;
855                }
856                let mut out = String::with_capacity(rendered.len() + 24);
857                out.push_str(&rendered[..insert_at]);
858                if !out.ends_with('\n') {
859                    out.push('\n');
860                }
861                out.push_str("<think>\n\n</think>\n\n");
862                out.push_str(&rendered[insert_at..]);
863                return Ok(out);
864            }
865        }
866        Ok(rendered)
867    }
868
869    /// Hardcoded Qwen ChatML (pre-§6.1 files).
870    fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
871        self.chatml_fallback_opts(messages, None)
872    }
873
874    /// Hardcoded Qwen ChatML (pre-§6.1 files) with optional thinking suppression.
875    fn chatml_fallback_opts(
876        &self,
877        messages: &[(String, String)],
878        enable_thinking: Option<bool>,
879    ) -> Vec<u32> {
880        let mut tokens = Vec::new();
881
882        for (role, content) in messages {
883            // <|im_start|>role\ncontent<|im_end|>\n
884            if let Some(start_id) = self.im_start_id {
885                tokens.push(start_id);
886            }
887            tokens.extend(self.encode(&format!("{}\n{}", role, content)));
888            if let Some(end_id) = self.im_end_id {
889                tokens.push(end_id);
890            }
891            tokens.extend(self.encode("\n"));
892        }
893
894        // Add assistant prefix
895        if let Some(start_id) = self.im_start_id {
896            tokens.push(start_id);
897        }
898        tokens.extend(self.encode("assistant\n"));
899        if enable_thinking == Some(false) {
900            tokens.extend(self.encode("<think>\n\n</think>\n\n"));
901        }
902
903        tokens
904    }
905
906    /// Vocabulary size.
907    pub fn vocab_size(&self) -> usize {
908        self.id_to_token.len()
909    }
910
911    /// Check if token ID is EOS.
912    pub fn is_eos(&self, id: u32) -> bool {
913        self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
914    }
915}
916
917#[derive(Debug, thiserror::Error)]
918pub enum TokenizerError {
919    #[error("IO error: {0}")]
920    Io(String),
921    #[error("Parse error: {0}")]
922    Parse(String),
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928
929    #[test]
930    fn byte_unicode_bijection() {
931        let (b2c, c2b) = bytes_to_unicode();
932        for b in 0..=255u8 {
933            assert_eq!(c2b[&b2c[b as usize]], b);
934        }
935        // GPT-2 well-known mappings: space → Ġ, newline → Ċ
936        assert_eq!(b2c[b' ' as usize], 'Ġ');
937        assert_eq!(b2c[b'\n' as usize], 'Ċ');
938    }
939
940    #[test]
941    fn byte_level_roundtrip_utf8() {
942        let tok = Tokenizer::byte_level();
943        let text = "hello 🌍 hi\n";
944        let ids = tok.encode(text);
945        assert_eq!(ids.len(), text.len()); // one id per byte
946        assert_eq!(tok.decode(&ids), text);
947    }
948
949    /// A tiny real-format tokenizer.json exercising the full pipeline:
950    /// GPT-2 regex, byte-level alphabet, one merge, an added token.
951    fn mini_json() -> String {
952        // vocab: byte-level chars for h,e,l,o,Ġ,w,r,d + merged "he"
953        let vocab: Vec<(&str, u32)> = vec![
954            ("h", 0),
955            ("e", 1),
956            ("l", 2),
957            ("o", 3),
958            ("Ġ", 4),
959            ("w", 5),
960            ("r", 6),
961            ("d", 7),
962            ("he", 8),
963            ("Ġw", 9),
964        ];
965        let vocab_json: String = vocab
966            .iter()
967            .map(|(t, i)| format!("\"{t}\": {i}"))
968            .collect::<Vec<_>>()
969            .join(", ");
970        format!(
971            r#"{{
972              "model": {{
973                "type": "BPE",
974                "vocab": {{ {vocab_json} }},
975                "merges": [["h", "e"], ["Ġ", "w"]]
976              }},
977              "added_tokens": [
978                {{"id": 10, "content": "<|eot|>", "special": true}}
979              ]
980            }}"#
981        )
982    }
983
984    /// Parity against HuggingFace on a real tokenizer, run only when the
985    /// file is present (CMF_TOK_PARITY=/path/to/tokenizer.json).
986    #[test]
987    fn real_tokenizer_parity_when_available() {
988        let Ok(path) = std::env::var("CMF_TOK_PARITY") else {
989            return;
990        };
991        let t = Tokenizer::from_file(&path).expect("load");
992        for (text, want) in [
993            (
994                "The capital of France is",
995                vec![671u32, 6102, 294, 8760, 344],
996            ),
997            ("2 + 2 =", vec![20, 940, 223, 20, 438]),
998        ] {
999            let got = t.encode(text);
1000            assert_eq!(got, want, "«{text}»");
1001        }
1002    }
1003
1004    /// A Sequence of Splits applies ALL of them, in order. Reading only the
1005    /// first is not a near-miss: DeepSeek-V4 puts a digit rule first and the
1006    /// word rule third, so one-pattern behaviour hands BPE a whole sentence
1007    /// as a single piece and the ids that come back are ones the model was
1008    /// never trained on.
1009    #[test]
1010    fn every_split_in_a_sequence_is_applied() {
1011        let pt = serde_json::json!({
1012            "type": "Sequence",
1013            "pretokenizers": [
1014                {"type": "Split", "behavior": "Isolated",
1015                 "pattern": {"Regex": r"\p{N}{1,3}"}},
1016                {"type": "Split", "behavior": "Isolated",
1017                 "pattern": {"Regex": r" ?[\p{L}]+"}},
1018                {"type": "ByteLevel", "add_prefix_space": false, "use_regex": false}
1019            ]
1020        });
1021        let mut pats = Vec::new();
1022        collect_split_patterns(&pt, &mut pats);
1023        assert_eq!(
1024            pats.len(),
1025            2,
1026            "both Split stages must be collected: {pats:?}"
1027        );
1028        assert!(pats[0].contains("p{N}"), "digit rule first");
1029        assert!(pats[1].contains("p{L}"), "word rule second");
1030
1031        // And the staged subdivision reaches the word boundaries. Building a
1032        // byte-level tokenizer over this pre_tokenizer, "ab cd" has to become
1033        // two pieces rather than one.
1034        let re: Vec<fancy_regex::Regex> = pats
1035            .iter()
1036            .map(|p| fancy_regex::Regex::new(p).unwrap())
1037            .collect();
1038        let norm = "ab cd12";
1039        let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
1040        for r in &re {
1041            let mut next = Vec::new();
1042            for (ps, pe) in pieces {
1043                let seg = &norm[ps..pe];
1044                let mut last = 0;
1045                for m in r.find_iter(seg).flatten() {
1046                    if m.start() > last {
1047                        next.push((ps + last, ps + m.start()));
1048                    }
1049                    if m.end() > m.start() {
1050                        next.push((ps + m.start(), ps + m.end()));
1051                    }
1052                    last = m.end();
1053                }
1054                if last < seg.len() {
1055                    next.push((ps + last, pe));
1056                }
1057            }
1058            pieces = next;
1059        }
1060        let got: Vec<&str> = pieces.iter().map(|(a, b)| &norm[*a..*b]).collect();
1061        assert_eq!(
1062            got,
1063            vec!["ab", " cd", "12"],
1064            "staged split produced {got:?}"
1065        );
1066    }
1067
1068    #[test]
1069    fn full_pipeline_merges_and_added_tokens() {
1070        let tok = Tokenizer::from_json(&mini_json()).unwrap();
1071        // "hello world" → [he,l,l,o, Ġw,o,r,l,d]
1072        let ids = tok.encode("hello world");
1073        assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
1074        assert_eq!(tok.decode(&ids), "hello world");
1075        // Added token splits and is skipped at decode (special).
1076        let ids2 = tok.encode("he<|eot|>he");
1077        assert_eq!(ids2, vec![8, 10, 8]);
1078        assert_eq!(tok.decode(&ids2), "hehe");
1079    }
1080
1081    #[test]
1082    fn non_ascii_is_never_silently_dropped() {
1083        let tok = Tokenizer::from_json(&mini_json()).unwrap();
1084        // A non-ASCII char is not encodable by the mini vocab (no byte tokens either):
1085        // the id list may be empty, but ASCII around it must survive.
1086        let ids = tok.encode("hello");
1087        assert!(!ids.is_empty());
1088    }
1089}