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