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 add_bos = hf
195            .post_processor
196            .as_ref()
197            .map(|p| {
198                let pp = p.to_string();
199                pp.contains("\"<s>\"") || pp.contains("\"<bos>\"")
200            })
201            .unwrap_or(false);
202        let nfc = hf
203            .normalizer
204            .as_ref()
205            .map(|n| n.to_string().contains("NFC"))
206            .unwrap_or(false);
207        let metaspace = hf.model.byte_fallback
208            || hf
209                .normalizer
210                .as_ref()
211                .map(|n| n.to_string().contains("\u{2581}") || n.to_string().contains("▁"))
212                .unwrap_or(false);
213        let sp_prepend = hf
214            .normalizer
215            .as_ref()
216            .map(|n| n.to_string().contains("Prepend"))
217            .unwrap_or(false);
218        let split_re = if metaspace {
219            None
220        } else {
221            let pattern = hf
222                .pre_tokenizer
223                .as_ref()
224                .and_then(find_split_pattern)
225                .unwrap_or_else(|| DEFAULT_SPLIT.to_string());
226            Some(
227                fancy_regex::Regex::new(&pattern)
228                    .map_err(|e| TokenizerError::Parse(format!("pre-tokenizer regex: {e}")))?,
229            )
230        };
231
232        // Added tokens: longest-first so overlapping contents match right.
233        let mut bos_token_id = None;
234        let mut eos_token_id = None;
235        let mut pad_token_id = None;
236        let mut im_start_id = None;
237        let mut im_end_id = None;
238        let mut special_ids = HashSet::new();
239        let mut added_ids = HashSet::new();
240        let mut added = Vec::new();
241
242        for at in &hf.added_tokens {
243            vocab.insert(at.content.clone(), at.id);
244            added.push((at.content.clone(), at.id));
245            added_ids.insert(at.id);
246            if at.special {
247                special_ids.insert(at.id);
248            }
249            match at.content.as_str() {
250                "<|endoftext|>" | "</s>" => eos_token_id = Some(at.id),
251                "<|im_start|>" => im_start_id = Some(at.id),
252                "<|im_end|>" => im_end_id = Some(at.id),
253                "<s>" | "<bos>" => bos_token_id = Some(at.id),
254                "<pad>" => pad_token_id = Some(at.id),
255                _ => {}
256            }
257        }
258        added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
259
260        // Build reverse map
261        let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
262        let mut id_to_token = vec![String::new(); max_id + 1];
263        for (token, &id) in &vocab {
264            if (id as usize) < id_to_token.len() {
265                id_to_token[id as usize] = token.clone();
266            }
267        }
268
269        let (byte_to_char, char_to_byte) = bytes_to_unicode();
270
271        tracing::info!(
272            "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
273            vocab.len(),
274            ranks.len(),
275            added.len(),
276            eos_token_id
277        );
278
279        Ok(Self {
280            vocab,
281            id_to_token,
282            ranks,
283            added,
284            added_ids,
285            special_ids,
286            split_re,
287            metaspace,
288            sp_prepend,
289            nfc,
290            byte_to_char,
291            char_to_byte,
292            bos_token_id,
293            eos_token_id,
294            pad_token_id,
295            im_start_id,
296            im_end_id,
297            chat_template: None,
298            extra_eos: HashSet::new(),
299            add_bos,
300        })
301    }
302
303    /// Create a minimal tokenizer for testing (byte tokens, no merges).
304    pub fn byte_level() -> Self {
305        let mut vocab = HashMap::new();
306        let mut id_to_token = Vec::with_capacity(256);
307        for i in 0..256u32 {
308            let tok = format!("<0x{:02X}>", i);
309            vocab.insert(tok.clone(), i);
310            id_to_token.push(tok);
311        }
312        let (byte_to_char, char_to_byte) = bytes_to_unicode();
313        Self {
314            vocab,
315            id_to_token,
316            ranks: HashMap::new(),
317            added: Vec::new(),
318            added_ids: HashSet::new(),
319            special_ids: HashSet::new(),
320            split_re: None,
321            metaspace: false,
322            sp_prepend: false,
323            nfc: false,
324            byte_to_char,
325            char_to_byte,
326            bos_token_id: None,
327            eos_token_id: None,
328            pad_token_id: None,
329            im_start_id: None,
330            im_end_id: None,
331            chat_template: None,
332            extra_eos: HashSet::new(),
333            add_bos: false,
334        }
335    }
336
337    /// Encode text to token IDs.
338    pub fn encode(&self, text: &str) -> Vec<u32> {
339        let mut ids = Vec::new();
340        // Added tokens match on raw text (normalized: false), longest first.
341        let mut rest = text;
342        'outer: while !rest.is_empty() {
343            let mut best: Option<(usize, usize, u32)> = None; // (pos, len, id)
344            for (content, id) in &self.added {
345                if let Some(pos) = rest.find(content.as_str()) {
346                    let better = match best {
347                        None => true,
348                        Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
349                    };
350                    if better {
351                        best = Some((pos, content.len(), *id));
352                    }
353                    if pos == 0 {
354                        break; // earliest possible; added is longest-first
355                    }
356                }
357            }
358            match best {
359                Some((pos, len, id)) => {
360                    self.encode_segment(&rest[..pos], &mut ids);
361                    ids.push(id);
362                    rest = &rest[pos + len..];
363                }
364                None => {
365                    self.encode_segment(rest, &mut ids);
366                    break 'outer;
367                }
368            }
369        }
370        ids
371    }
372
373    /// Encode one added-token-free segment: NFC → split → byte-map → BPE.
374    fn encode_segment(&self, segment: &str, out: &mut Vec<u32>) {
375        if segment.is_empty() {
376            return;
377        }
378        let norm: String = if self.nfc {
379            segment.nfc().collect()
380        } else {
381            segment.to_string()
382        };
383        if self.metaspace {
384            // SentencePiece: [Prepend("▁") +] Replace(" "→"▁"), BPE over
385            // chars of the whole span (no pre-tokenizer, no byte map).
386            // Gemma's normalizer replaces only — no dummy prefix.
387            let sp = if self.sp_prepend {
388                format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
389            } else {
390                norm.replace(' ', "\u{2581}")
391            };
392            self.bpe_piece_sp(&sp, out);
393            return;
394        }
395        match &self.split_re {
396            Some(re) => {
397                let mut last = 0;
398                for m in re.find_iter(&norm) {
399                    let m = match m {
400                        Ok(m) => m,
401                        Err(e) => {
402                            tracing::error!("pre-tokenizer regex failed: {e}");
403                            break;
404                        }
405                    };
406                    if m.start() > last {
407                        // Isolated behavior: gaps are their own pieces.
408                        self.bpe_piece(&norm[last..m.start()], out);
409                    }
410                    self.bpe_piece(m.as_str(), out);
411                    last = m.end();
412                }
413                if last < norm.len() {
414                    self.bpe_piece(&norm[last..], out);
415                }
416            }
417            None => {
418                // Synthetic byte_level() tokenizer: raw byte tokens.
419                for b in norm.bytes() {
420                    let tok = format!("<0x{:02X}>", b);
421                    if let Some(&id) = self.vocab.get(&tok) {
422                        out.push(id);
423                    }
424                }
425            }
426        }
427    }
428
429    /// SentencePiece BPE: symbols are chars (no byte-level alphabet);
430    /// unknown symbols fall back to <0xNN> tokens per UTF-8 byte.
431    fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
432        if piece.is_empty() {
433            return;
434        }
435        let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
436        loop {
437            let mut best: Option<(u32, usize)> = None;
438            for i in 0..sym.len().saturating_sub(1) {
439                if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
440                    if best.map(|(br, _)| r < br).unwrap_or(true) {
441                        best = Some((r, i));
442                    }
443                }
444            }
445            let Some((_, i)) = best else { break };
446            let merged = format!("{}{}", sym[i], sym[i + 1]);
447            let (left, right) = (sym[i].clone(), sym[i + 1].clone());
448            let mut j = 0;
449            while j + 1 < sym.len() {
450                if sym[j] == left && sym[j + 1] == right {
451                    sym[j] = merged.clone();
452                    sym.remove(j + 1);
453                }
454                j += 1;
455            }
456        }
457        for t in &sym {
458            if let Some(&id) = self.vocab.get(t) {
459                out.push(id);
460            } else {
461                let mut ok = true;
462                for byte in t.bytes() {
463                    let tok = format!("<0x{:02X}>", byte);
464                    match self.vocab.get(&tok) {
465                        Some(&id) => out.push(id),
466                        None => {
467                            ok = false;
468                            break;
469                        }
470                    }
471                }
472                if !ok {
473                    tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
474                }
475            }
476        }
477    }
478
479    /// Byte-level map one pre-token piece, then ranked BPE merges.
480    fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
481        if piece.is_empty() {
482            return;
483        }
484        let mapped: Vec<String> = piece
485            .bytes()
486            .map(|b| self.byte_to_char[b as usize].to_string())
487            .collect();
488        let mut sym = mapped;
489
490        // Classic BPE: repeatedly merge the lowest-rank adjacent pair.
491        loop {
492            let mut best: Option<(u32, usize)> = None;
493            for i in 0..sym.len().saturating_sub(1) {
494                if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
495                    if best.map(|(br, _)| r < br).unwrap_or(true) {
496                        best = Some((r, i));
497                    }
498                }
499            }
500            let Some((_, i)) = best else { break };
501            let merged = format!("{}{}", sym[i], sym[i + 1]);
502            // Merge ALL occurrences of this exact pair, left to right.
503            let (left, right) = (sym[i].clone(), sym[i + 1].clone());
504            let mut j = 0;
505            while j + 1 < sym.len() {
506                if sym[j] == left && sym[j + 1] == right {
507                    sym[j] = merged.clone();
508                    sym.remove(j + 1);
509                }
510                j += 1;
511            }
512        }
513
514        for s in &sym {
515            if let Some(&id) = self.vocab.get(s) {
516                out.push(id);
517            } else {
518                // Byte-fallback (synthetic vocabs); never drop silently.
519                let mut ok = true;
520                for ch in s.chars() {
521                    let Some(&b) = self.char_to_byte.get(&ch) else {
522                        ok = false;
523                        break;
524                    };
525                    let tok = format!("<0x{:02X}>", b);
526                    if let Some(&id) = self.vocab.get(&tok) {
527                        out.push(id);
528                    } else {
529                        ok = false;
530                        break;
531                    }
532                }
533                if !ok {
534                    tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
535                }
536            }
537        }
538    }
539
540    /// Decode token IDs back to text. Special tokens are skipped; added
541    /// tokens are raw text; everything else reverses the byte-level map.
542    pub fn decode(&self, ids: &[u32]) -> String {
543        let mut bytes: Vec<u8> = Vec::new();
544        for &id in ids {
545            if self.special_ids.contains(&id) {
546                continue;
547            }
548            let idx = id as usize;
549            if idx >= self.id_to_token.len() {
550                continue;
551            }
552            let tok = &self.id_to_token[idx];
553            if self.added_ids.contains(&id) {
554                bytes.extend_from_slice(tok.as_bytes());
555                continue;
556            }
557            // Byte-fallback / legacy byte tokens
558            if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
559                if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
560                    bytes.push(b);
561                    continue;
562                }
563            }
564            if self.metaspace {
565                // SP decoder: Replace(▁→" "); UTF-8 chars pass through.
566                for ch in tok.chars() {
567                    if ch == '\u{2581}' {
568                        bytes.push(b' ');
569                    } else {
570                        let mut buf = [0u8; 4];
571                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
572                    }
573                }
574                continue;
575            }
576            for ch in tok.chars() {
577                match self.char_to_byte.get(&ch) {
578                    Some(&b) => bytes.push(b),
579                    // Not a byte-level char (shouldn't happen for real
580                    // vocabs) — pass the char through as UTF-8.
581                    None => {
582                        let mut buf = [0u8; 4];
583                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
584                    }
585                }
586            }
587        }
588        let text = String::from_utf8_lossy(&bytes).into_owned();
589        if self.metaspace && self.sp_prepend {
590            // SP decoder Strip(start=1): one leading space from Prepend.
591            if let Some(stripped) = text.strip_prefix(' ') {
592                return stripped.to_string();
593            }
594        }
595        text
596    }
597
598    /// Streaming decode of ONE token: no sequence-level Strip — a
599    /// per-token strip would eat the ▁-spaces of every SP word.
600    pub fn decode_token(&self, id: u32) -> String {
601        if self.special_ids.contains(&id) {
602            return String::new();
603        }
604        let idx = id as usize;
605        if idx >= self.id_to_token.len() {
606            return String::new();
607        }
608        let tok = &self.id_to_token[idx];
609        if self.added_ids.contains(&id) {
610            return tok.clone();
611        }
612        if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
613            if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
614                return String::from_utf8_lossy(&[b]).into_owned();
615            }
616        }
617        if self.metaspace {
618            return tok.replace('\u{2581}', " ");
619        }
620        let mut bytes = Vec::new();
621        for ch in tok.chars() {
622            match self.char_to_byte.get(&ch) {
623                Some(&b) => bytes.push(b),
624                None => {
625                    let mut buf = [0u8; 4];
626                    bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
627                }
628            }
629        }
630        String::from_utf8_lossy(&bytes).into_owned()
631    }
632
633    /// Render the container's Jinja chat template (HF semantics:
634    /// trim_blocks + lstrip_blocks + loop controls) and encode it.
635    /// Falls back to hardcoded ChatML when the file carries none.
636    pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
637        self.apply_chat_template_opts(messages, None)
638    }
639
640    /// Like `apply_chat_template`, with an explicit `enable_thinking` value for
641    /// reasoning-model templates (Qwen3/3.5 emit an empty <think> block when it
642    /// is false, so the model answers directly). `None` leaves the variable
643    /// undefined — the template's own default applies.
644    pub fn apply_chat_template_opts(
645        &self,
646        messages: &[(String, String)],
647        enable_thinking: Option<bool>,
648    ) -> Vec<u32> {
649        if let Some(tpl) = &self.chat_template {
650            match self.render_template(tpl, messages, enable_thinking) {
651                Ok(text) => return self.with_bos(self.encode(&text)),
652                Err(e) => {
653                    tracing::error!("chat template render failed ({e}); ChatML fallback");
654                }
655            }
656        }
657        self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
658    }
659
660    /// Prepend BOS when the tokenizer declares it (llama family).
661    pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
662        if self.add_bos {
663            if let Some(b) = self.bos_token_id {
664                if ids.first() != Some(&b) {
665                    ids.insert(0, b);
666                }
667            }
668        }
669        ids
670    }
671
672    /// Render the carried template to text (parity-testable surface).
673    pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
674        self.render_chat_opts(messages, None)
675    }
676
677    /// Render the carried template to text with explicit thinking mode.
678    pub fn render_chat_opts(
679        &self,
680        messages: &[(String, String)],
681        enable_thinking: Option<bool>,
682    ) -> Option<String> {
683        let tpl = self.chat_template.as_ref()?;
684        match self.render_template(tpl, messages, enable_thinking) {
685            Ok(t) => Some(t),
686            Err(e) => {
687                tracing::error!("chat template render: {e:#}");
688                None
689            }
690        }
691    }
692
693    fn render_template(
694        &self,
695        tpl: &str,
696        messages: &[(String, String)],
697        enable_thinking: Option<bool>,
698    ) -> Result<String, minijinja::Error> {
699        let mut env = minijinja::Environment::new();
700        env.set_trim_blocks(true);
701        env.set_lstrip_blocks(true);
702        // HF templates use python string methods (.startswith, .strip…).
703        env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
704        env.add_template("chat", tpl)?;
705        let msgs: Vec<minijinja::Value> = messages
706            .iter()
707            .map(|(role, content)| {
708                minijinja::context! { role => role, content => content }
709            })
710            .collect();
711        // `enable_thinking` stays UNDEFINED when None — reasoning templates
712        // check `enable_thinking is defined` and fall back to their default.
713        let rendered = match enable_thinking {
714            Some(v) => env.get_template("chat")?.render(minijinja::context! {
715                messages => msgs,
716                add_generation_prompt => true,
717                enable_thinking => v,
718            })?,
719            None => env.get_template("chat")?.render(minijinja::context! {
720                messages => msgs,
721                add_generation_prompt => true,
722            })?,
723        };
724        // Templates that ignore `enable_thinking` (e.g. Nanbeige/Qwen-legacy)
725        // always emit a generation prompt. When thinking is explicitly disabled,
726        // prefill an empty <think>…</think> block so the model answers directly.
727        if enable_thinking == Some(false) && !rendered.contains("</think>") {
728            if let Some(pos) = rendered.rfind("assistant") {
729                let mut insert_at = pos + "assistant".len();
730                if let Some(idx) = rendered[insert_at..].find('\n') {
731                    insert_at += idx + 1;
732                }
733                let mut out = String::with_capacity(rendered.len() + 24);
734                out.push_str(&rendered[..insert_at]);
735                if !out.ends_with('\n') {
736                    out.push('\n');
737                }
738                out.push_str("<think>\n\n</think>\n\n");
739                out.push_str(&rendered[insert_at..]);
740                return Ok(out);
741            }
742        }
743        Ok(rendered)
744    }
745
746    /// Hardcoded Qwen ChatML (pre-§6.1 files).
747    fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
748        self.chatml_fallback_opts(messages, None)
749    }
750
751    /// Hardcoded Qwen ChatML (pre-§6.1 files) with optional thinking suppression.
752    fn chatml_fallback_opts(
753        &self,
754        messages: &[(String, String)],
755        enable_thinking: Option<bool>,
756    ) -> Vec<u32> {
757        let mut tokens = Vec::new();
758
759        for (role, content) in messages {
760            // <|im_start|>role\ncontent<|im_end|>\n
761            if let Some(start_id) = self.im_start_id {
762                tokens.push(start_id);
763            }
764            tokens.extend(self.encode(&format!("{}\n{}", role, content)));
765            if let Some(end_id) = self.im_end_id {
766                tokens.push(end_id);
767            }
768            tokens.extend(self.encode("\n"));
769        }
770
771        // Add assistant prefix
772        if let Some(start_id) = self.im_start_id {
773            tokens.push(start_id);
774        }
775        tokens.extend(self.encode("assistant\n"));
776        if enable_thinking == Some(false) {
777            tokens.extend(self.encode("<think>\n\n</think>\n\n"));
778        }
779
780        tokens
781    }
782
783    /// Vocabulary size.
784    pub fn vocab_size(&self) -> usize {
785        self.id_to_token.len()
786    }
787
788    /// Check if token ID is EOS.
789    pub fn is_eos(&self, id: u32) -> bool {
790        self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
791    }
792}
793
794#[derive(Debug, thiserror::Error)]
795pub enum TokenizerError {
796    #[error("IO error: {0}")]
797    Io(String),
798    #[error("Parse error: {0}")]
799    Parse(String),
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn byte_unicode_bijection() {
808        let (b2c, c2b) = bytes_to_unicode();
809        for b in 0..=255u8 {
810            assert_eq!(c2b[&b2c[b as usize]], b);
811        }
812        // GPT-2 well-known mappings: space → Ġ, newline → Ċ
813        assert_eq!(b2c[b' ' as usize], 'Ġ');
814        assert_eq!(b2c[b'\n' as usize], 'Ċ');
815    }
816
817    #[test]
818    fn byte_level_roundtrip_utf8() {
819        let tok = Tokenizer::byte_level();
820        let text = "hello 🌍 hi\n";
821        let ids = tok.encode(text);
822        assert_eq!(ids.len(), text.len()); // one id per byte
823        assert_eq!(tok.decode(&ids), text);
824    }
825
826    /// A tiny real-format tokenizer.json exercising the full pipeline:
827    /// GPT-2 regex, byte-level alphabet, one merge, an added token.
828    fn mini_json() -> String {
829        // vocab: byte-level chars for h,e,l,o,Ġ,w,r,d + merged "he"
830        let vocab: Vec<(&str, u32)> = vec![
831            ("h", 0),
832            ("e", 1),
833            ("l", 2),
834            ("o", 3),
835            ("Ġ", 4),
836            ("w", 5),
837            ("r", 6),
838            ("d", 7),
839            ("he", 8),
840            ("Ġw", 9),
841        ];
842        let vocab_json: String = vocab
843            .iter()
844            .map(|(t, i)| format!("\"{t}\": {i}"))
845            .collect::<Vec<_>>()
846            .join(", ");
847        format!(
848            r#"{{
849              "model": {{
850                "type": "BPE",
851                "vocab": {{ {vocab_json} }},
852                "merges": [["h", "e"], ["Ġ", "w"]]
853              }},
854              "added_tokens": [
855                {{"id": 10, "content": "<|eot|>", "special": true}}
856              ]
857            }}"#
858        )
859    }
860
861    #[test]
862    fn full_pipeline_merges_and_added_tokens() {
863        let tok = Tokenizer::from_json(&mini_json()).unwrap();
864        // "hello world" → [he,l,l,o, Ġw,o,r,l,d]
865        let ids = tok.encode("hello world");
866        assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
867        assert_eq!(tok.decode(&ids), "hello world");
868        // Added token splits and is skipped at decode (special).
869        let ids2 = tok.encode("he<|eot|>he");
870        assert_eq!(ids2, vec![8, 10, 8]);
871        assert_eq!(tok.decode(&ids2), "hehe");
872    }
873
874    #[test]
875    fn non_ascii_is_never_silently_dropped() {
876        let tok = Tokenizer::from_json(&mini_json()).unwrap();
877        // A non-ASCII char is not encodable by the mini vocab (no byte tokens either):
878        // the id list may be empty, but ASCII around it must survive.
879        let ids = tok.encode("hello");
880        assert!(!ids.is_empty());
881    }
882}