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
768    /// Chat template with the FULL message shape and a tool list.
769    ///
770    /// The pair-based API below flattens every message to (role, text),
771    /// which silently drops exactly what agentic use needs: the `tools`
772    /// array, `role: "tool"` results, and `tool_calls` on assistant
773    /// turns. The templates this format embeds — Qwen-family, Nanbeige —
774    /// have carried a `{%- if tools %}` branch all along; this is the
775    /// call that finally feeds it. Messages arrive as JSON objects in
776    /// the OpenAI shape and pass through to minijinja unflattened, so a
777    /// template sees the same fields a Python `apply_chat_template`
778    /// would.
779    pub fn apply_chat_template_json(
780        &self,
781        messages: &[serde_json::Value],
782        tools: Option<&[serde_json::Value]>,
783        enable_thinking: Option<bool>,
784    ) -> Vec<u32> {
785        if let Some(tpl) = &self.chat_template {
786            match self.render_template_json(tpl, messages, tools, enable_thinking) {
787                Ok(text) => return self.with_bos(self.encode(&text)),
788                Err(e) => {
789                    tracing::error!("chat template render failed ({e}); ChatML fallback");
790                }
791            }
792        }
793        let pairs: Vec<(String, String)> = messages
794            .iter()
795            .map(|m| {
796                (
797                    m.get("role").and_then(|v| v.as_str()).unwrap_or("user").to_string(),
798                    m.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(),
799                )
800            })
801            .collect();
802        self.with_bos(self.chatml_fallback_opts(&pairs, enable_thinking))
803    }
804
805    /// Render the template against JSON-shaped messages (parity surface).
806    pub fn render_chat_json(
807        &self,
808        messages: &[serde_json::Value],
809        tools: Option<&[serde_json::Value]>,
810        enable_thinking: Option<bool>,
811    ) -> Option<String> {
812        let tpl = self.chat_template.as_ref()?;
813        match self.render_template_json(tpl, messages, tools, enable_thinking) {
814            Ok(t) => Some(t),
815            Err(e) => {
816                tracing::error!("chat template render (json): {e:#}");
817                eprintln!("chat template render (json): {e:#}");
818                None
819            }
820        }
821    }
822
823    fn render_template_json(
824        &self,
825        tpl: &str,
826        messages: &[serde_json::Value],
827        tools: Option<&[serde_json::Value]>,
828        enable_thinking: Option<bool>,
829    ) -> Result<String, minijinja::Error> {
830        let mut env = minijinja::Environment::new();
831        env.set_trim_blocks(true);
832        env.set_lstrip_blocks(true);
833        env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
834        // `visible_text` is a helper transformers injects into its
835        // template env (it flattens multimodal content to its text).
836        // Nanbeige's template calls it unconditionally in the tools
837        // branch; without it the render errors and the fallback quietly
838        // serves a TOOLLESS prompt.
839        env.add_function("visible_text", |v: minijinja::Value| -> String {
840            if let Some(s) = v.as_str() {
841                return s.to_string();
842            }
843            if let Ok(iter) = v.try_iter() {
844                let mut out = Vec::new();
845                for item in iter {
846                    if let Some(s) = item.as_str() {
847                        out.push(s.to_string());
848                    } else if let Ok(t) = item.get_attr("text") {
849                        if let Some(s) = t.as_str() {
850                            out.push(s.to_string());
851                        }
852                    }
853                }
854                return out.join("\n");
855            }
856            String::new()
857        });
858        env.add_template("chat", tpl)?;
859        let msgs: Vec<minijinja::Value> = messages
860            .iter()
861            .map(minijinja::Value::from_serialize)
862            .collect();
863        let tools_v: Option<Vec<minijinja::Value>> = tools.map(|ts| {
864            ts.iter().map(minijinja::Value::from_serialize).collect()
865        });
866        let tpl = env.get_template("chat")?;
867        // Three axes, each present only when meaningful: templates guard
868        // with `is defined`, and an explicit null flips those guards.
869        // `tool_call_format` picks the grammar in two-mode templates
870        // (Nanbeige): undefined falls into their XML branch. The JSON
871        // grammar is the one every parser downstream speaks, so choose
872        // it explicitly; templates without the knob never read it.
873        let rendered = match (tools_v, enable_thinking) {
874            (Some(ts), Some(v)) => tpl.render(minijinja::context! {
875                messages => msgs, tools => ts, add_generation_prompt => true, enable_thinking => v,
876                tool_call_format => "json",
877            })?,
878            (Some(ts), None) => tpl.render(minijinja::context! {
879                messages => msgs, tools => ts, add_generation_prompt => true,
880                tool_call_format => "json",
881            })?,
882            (None, Some(v)) => tpl.render(minijinja::context! {
883                messages => msgs, add_generation_prompt => true, enable_thinking => v,
884                tool_call_format => "json",
885            })?,
886            (None, None) => tpl.render(minijinja::context! {
887                messages => msgs, add_generation_prompt => true,
888                tool_call_format => "json",
889            })?,
890        };
891        Ok(rendered)
892    }
893
894    pub fn apply_chat_template_opts(
895        &self,
896        messages: &[(String, String)],
897        enable_thinking: Option<bool>,
898    ) -> Vec<u32> {
899        if let Some(tpl) = &self.chat_template {
900            match self.render_template(tpl, messages, enable_thinking) {
901                Ok(text) => return self.with_bos(self.encode(&text)),
902                Err(e) => {
903                    tracing::error!("chat template render failed ({e}); ChatML fallback");
904                }
905            }
906        }
907        self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
908    }
909
910    /// Prepend BOS when the tokenizer declares it (llama family).
911    pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
912        if self.add_bos {
913            if let Some(b) = self.bos_token_id {
914                if ids.first() != Some(&b) {
915                    ids.insert(0, b);
916                }
917            }
918        }
919        ids
920    }
921
922    /// Render the carried template to text (parity-testable surface).
923    pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
924        self.render_chat_opts(messages, None)
925    }
926
927    /// Render the carried template to text with explicit thinking mode.
928    pub fn render_chat_opts(
929        &self,
930        messages: &[(String, String)],
931        enable_thinking: Option<bool>,
932    ) -> Option<String> {
933        let tpl = self.chat_template.as_ref()?;
934        match self.render_template(tpl, messages, enable_thinking) {
935            Ok(t) => Some(t),
936            Err(e) => {
937                tracing::error!("chat template render: {e:#}");
938                None
939            }
940        }
941    }
942
943    fn render_template(
944        &self,
945        tpl: &str,
946        messages: &[(String, String)],
947        enable_thinking: Option<bool>,
948    ) -> Result<String, minijinja::Error> {
949        let mut env = minijinja::Environment::new();
950        env.set_trim_blocks(true);
951        env.set_lstrip_blocks(true);
952        // HF templates use python string methods (.startswith, .strip…).
953        env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
954        env.add_template("chat", tpl)?;
955        let msgs: Vec<minijinja::Value> = messages
956            .iter()
957            .map(|(role, content)| {
958                minijinja::context! { role => role, content => content }
959            })
960            .collect();
961        // `enable_thinking` stays UNDEFINED when None — reasoning templates
962        // check `enable_thinking is defined` and fall back to their default.
963        let rendered = match enable_thinking {
964            Some(v) => env.get_template("chat")?.render(minijinja::context! {
965                messages => msgs,
966                add_generation_prompt => true,
967                enable_thinking => v,
968            })?,
969            None => env.get_template("chat")?.render(minijinja::context! {
970                messages => msgs,
971                add_generation_prompt => true,
972            })?,
973        };
974        // Templates that ignore `enable_thinking` (e.g. Nanbeige/Qwen-legacy)
975        // always emit a generation prompt. When thinking is explicitly disabled,
976        // prefill an empty <think>…</think> block so the model answers directly.
977        if enable_thinking == Some(false) && !rendered.contains("</think>") {
978            if let Some(pos) = rendered.rfind("assistant") {
979                let mut insert_at = pos + "assistant".len();
980                if let Some(idx) = rendered[insert_at..].find('\n') {
981                    insert_at += idx + 1;
982                }
983                let mut out = String::with_capacity(rendered.len() + 24);
984                out.push_str(&rendered[..insert_at]);
985                if !out.ends_with('\n') {
986                    out.push('\n');
987                }
988                out.push_str("<think>\n\n</think>\n\n");
989                out.push_str(&rendered[insert_at..]);
990                return Ok(out);
991            }
992        }
993        Ok(rendered)
994    }
995
996    /// Hardcoded Qwen ChatML (pre-§6.1 files).
997    fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
998        self.chatml_fallback_opts(messages, None)
999    }
1000
1001    /// Hardcoded Qwen ChatML (pre-§6.1 files) with optional thinking suppression.
1002    fn chatml_fallback_opts(
1003        &self,
1004        messages: &[(String, String)],
1005        enable_thinking: Option<bool>,
1006    ) -> Vec<u32> {
1007        let mut tokens = Vec::new();
1008
1009        for (role, content) in messages {
1010            // <|im_start|>role\ncontent<|im_end|>\n
1011            if let Some(start_id) = self.im_start_id {
1012                tokens.push(start_id);
1013            }
1014            tokens.extend(self.encode(&format!("{}\n{}", role, content)));
1015            if let Some(end_id) = self.im_end_id {
1016                tokens.push(end_id);
1017            }
1018            tokens.extend(self.encode("\n"));
1019        }
1020
1021        // Add assistant prefix
1022        if let Some(start_id) = self.im_start_id {
1023            tokens.push(start_id);
1024        }
1025        tokens.extend(self.encode("assistant\n"));
1026        if enable_thinking == Some(false) {
1027            tokens.extend(self.encode("<think>\n\n</think>\n\n"));
1028        }
1029
1030        tokens
1031    }
1032
1033    /// Vocabulary size.
1034    pub fn vocab_size(&self) -> usize {
1035        self.id_to_token.len()
1036    }
1037
1038    /// Check if token ID is EOS.
1039    pub fn is_eos(&self, id: u32) -> bool {
1040        self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
1041    }
1042}
1043
1044#[derive(Debug, thiserror::Error)]
1045pub enum TokenizerError {
1046    #[error("IO error: {0}")]
1047    Io(String),
1048    #[error("Parse error: {0}")]
1049    Parse(String),
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    #[test]
1057    fn byte_unicode_bijection() {
1058        let (b2c, c2b) = bytes_to_unicode();
1059        for b in 0..=255u8 {
1060            assert_eq!(c2b[&b2c[b as usize]], b);
1061        }
1062        // GPT-2 well-known mappings: space → Ġ, newline → Ċ
1063        assert_eq!(b2c[b' ' as usize], 'Ġ');
1064        assert_eq!(b2c[b'\n' as usize], 'Ċ');
1065    }
1066
1067    #[test]
1068    fn byte_level_roundtrip_utf8() {
1069        let tok = Tokenizer::byte_level();
1070        let text = "hello 🌍 hi\n";
1071        let ids = tok.encode(text);
1072        assert_eq!(ids.len(), text.len()); // one id per byte
1073        assert_eq!(tok.decode(&ids), text);
1074    }
1075
1076    /// A tiny real-format tokenizer.json exercising the full pipeline:
1077    /// GPT-2 regex, byte-level alphabet, one merge, an added token.
1078    fn mini_json() -> String {
1079        // vocab: byte-level chars for h,e,l,o,Ġ,w,r,d + merged "he"
1080        let vocab: Vec<(&str, u32)> = vec![
1081            ("h", 0),
1082            ("e", 1),
1083            ("l", 2),
1084            ("o", 3),
1085            ("Ġ", 4),
1086            ("w", 5),
1087            ("r", 6),
1088            ("d", 7),
1089            ("he", 8),
1090            ("Ġw", 9),
1091        ];
1092        let vocab_json: String = vocab
1093            .iter()
1094            .map(|(t, i)| format!("\"{t}\": {i}"))
1095            .collect::<Vec<_>>()
1096            .join(", ");
1097        format!(
1098            r#"{{
1099              "model": {{
1100                "type": "BPE",
1101                "vocab": {{ {vocab_json} }},
1102                "merges": [["h", "e"], ["Ġ", "w"]]
1103              }},
1104              "added_tokens": [
1105                {{"id": 10, "content": "<|eot|>", "special": true}}
1106              ]
1107            }}"#
1108        )
1109    }
1110
1111    /// Parity against HuggingFace on a real tokenizer, run only when the
1112    /// file is present (CMF_TOK_PARITY=/path/to/tokenizer.json).
1113    #[test]
1114    fn real_tokenizer_parity_when_available() {
1115        let Ok(path) = std::env::var("CMF_TOK_PARITY") else {
1116            return;
1117        };
1118        let t = Tokenizer::from_file(&path).expect("load");
1119        for (text, want) in [
1120            (
1121                "The capital of France is",
1122                vec![671u32, 6102, 294, 8760, 344],
1123            ),
1124            ("2 + 2 =", vec![20, 940, 223, 20, 438]),
1125        ] {
1126            let got = t.encode(text);
1127            assert_eq!(got, want, "«{text}»");
1128        }
1129    }
1130
1131    /// A Sequence of Splits applies ALL of them, in order. Reading only the
1132    /// first is not a near-miss: DeepSeek-V4 puts a digit rule first and the
1133    /// word rule third, so one-pattern behaviour hands BPE a whole sentence
1134    /// as a single piece and the ids that come back are ones the model was
1135    /// never trained on.
1136    #[test]
1137    fn every_split_in_a_sequence_is_applied() {
1138        let pt = serde_json::json!({
1139            "type": "Sequence",
1140            "pretokenizers": [
1141                {"type": "Split", "behavior": "Isolated",
1142                 "pattern": {"Regex": r"\p{N}{1,3}"}},
1143                {"type": "Split", "behavior": "Isolated",
1144                 "pattern": {"Regex": r" ?[\p{L}]+"}},
1145                {"type": "ByteLevel", "add_prefix_space": false, "use_regex": false}
1146            ]
1147        });
1148        let mut pats = Vec::new();
1149        collect_split_patterns(&pt, &mut pats);
1150        assert_eq!(
1151            pats.len(),
1152            2,
1153            "both Split stages must be collected: {pats:?}"
1154        );
1155        assert!(pats[0].contains("p{N}"), "digit rule first");
1156        assert!(pats[1].contains("p{L}"), "word rule second");
1157
1158        // And the staged subdivision reaches the word boundaries. Building a
1159        // byte-level tokenizer over this pre_tokenizer, "ab cd" has to become
1160        // two pieces rather than one.
1161        let re: Vec<fancy_regex::Regex> = pats
1162            .iter()
1163            .map(|p| fancy_regex::Regex::new(p).unwrap())
1164            .collect();
1165        let norm = "ab cd12";
1166        let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
1167        for r in &re {
1168            let mut next = Vec::new();
1169            for (ps, pe) in pieces {
1170                let seg = &norm[ps..pe];
1171                let mut last = 0;
1172                for m in r.find_iter(seg).flatten() {
1173                    if m.start() > last {
1174                        next.push((ps + last, ps + m.start()));
1175                    }
1176                    if m.end() > m.start() {
1177                        next.push((ps + m.start(), ps + m.end()));
1178                    }
1179                    last = m.end();
1180                }
1181                if last < seg.len() {
1182                    next.push((ps + last, pe));
1183                }
1184            }
1185            pieces = next;
1186        }
1187        let got: Vec<&str> = pieces.iter().map(|(a, b)| &norm[*a..*b]).collect();
1188        assert_eq!(
1189            got,
1190            vec!["ab", " cd", "12"],
1191            "staged split produced {got:?}"
1192        );
1193    }
1194
1195    #[test]
1196    fn full_pipeline_merges_and_added_tokens() {
1197        let tok = Tokenizer::from_json(&mini_json()).unwrap();
1198        // "hello world" → [he,l,l,o, Ġw,o,r,l,d]
1199        let ids = tok.encode("hello world");
1200        assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
1201        assert_eq!(tok.decode(&ids), "hello world");
1202        // Added token splits and is skipped at decode (special).
1203        let ids2 = tok.encode("he<|eot|>he");
1204        assert_eq!(ids2, vec![8, 10, 8]);
1205        assert_eq!(tok.decode(&ids2), "hehe");
1206    }
1207
1208    #[test]
1209    fn non_ascii_is_never_silently_dropped() {
1210        let tok = Tokenizer::from_json(&mini_json()).unwrap();
1211        // A non-ASCII char is not encodable by the mini vocab (no byte tokens either):
1212        // the id list may be empty, but ASCII around it must survive.
1213        let ids = tok.encode("hello");
1214        assert!(!ids.is_empty());
1215    }
1216}