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 = (0x21..=0x7E).contains(&b)
85            || (0xA1..=0xAC).contains(&b)
86            || (0xAE..=0xFF).contains(&b);
87        let c = if printable {
88            char::from_u32(b as u32).unwrap()
89        } else {
90            let c = char::from_u32(256 + n).unwrap();
91            n += 1;
92            c
93        };
94        b2c[b as usize] = c;
95        c2b.insert(c, b as u8);
96    }
97    (b2c, c2b)
98}
99
100/// HuggingFace tokenizer.json schema (the parts we execute).
101#[derive(Deserialize)]
102struct HfTokenizerJson {
103    model: HfModel,
104    #[serde(default)]
105    added_tokens: Vec<HfAddedToken>,
106    #[serde(default)]
107    pre_tokenizer: Option<serde_json::Value>,
108    #[serde(default)]
109    normalizer: Option<serde_json::Value>,
110    #[serde(default)]
111    post_processor: Option<serde_json::Value>,
112}
113
114#[derive(Deserialize)]
115struct HfModel {
116    vocab: HashMap<String, u32>,
117    #[serde(default)]
118    merges: Vec<HfMerge>,
119    #[serde(default)]
120    byte_fallback: bool,
121}
122
123/// Merge rules come in two HF flavours: legacy `"a b"` strings and
124/// modern `["a", "b"]` pairs (Qwen3.5 tokenizer.json uses pairs).
125#[derive(Deserialize)]
126#[serde(untagged)]
127enum HfMerge {
128    Pair([String; 2]),
129    Text(String),
130}
131
132#[derive(Deserialize)]
133struct HfAddedToken {
134    id: u32,
135    content: String,
136    special: bool,
137}
138
139/// Extract the Split regex from a pre_tokenizer JSON subtree
140/// (handles both bare Split and Sequence-of-pretokenizers).
141fn find_split_pattern(pt: &serde_json::Value) -> Option<String> {
142    if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
143        return pt
144            .get("pattern")
145            .and_then(|p| p.get("Regex"))
146            .and_then(|r| r.as_str())
147            .map(String::from);
148    }
149    if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
150        return list.iter().find_map(find_split_pattern);
151    }
152    None
153}
154
155impl Tokenizer {
156    /// Load tokenizer from HuggingFace tokenizer.json file.
157    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TokenizerError> {
158        let data = std::fs::read_to_string(path.as_ref())
159            .map_err(|e| TokenizerError::Io(e.to_string()))?;
160        Self::from_json(&data)
161    }
162
163    /// Load tokenizer from raw tokenizer.json bytes (CMF VOCAB section).
164    pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenizerError> {
165        let s = std::str::from_utf8(bytes)
166            .map_err(|e| TokenizerError::Parse(format!("vocab is not UTF-8: {e}")))?;
167        Self::from_json(s)
168    }
169
170    /// Load tokenizer from JSON string.
171    pub fn from_json(json: &str) -> Result<Self, TokenizerError> {
172        let hf: HfTokenizerJson =
173            serde_json::from_str(json).map_err(|e| TokenizerError::Parse(e.to_string()))?;
174
175        let mut vocab = hf.model.vocab;
176        let mut ranks = HashMap::new();
177        for (rank, m) in hf.model.merges.into_iter().enumerate() {
178            let (a, b) = match m {
179                HfMerge::Pair([a, b]) => (a, b),
180                HfMerge::Text(s) => {
181                    let mut it = s.splitn(2, ' ');
182                    match (it.next(), it.next()) {
183                        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
184                        _ => continue,
185                    }
186                }
187            };
188            ranks.insert((a, b), rank as u32);
189        }
190
191        // Family detection: SentencePiece carries byte_fallback and/or a
192        // Prepend("▁") normalizer; byte-level BPE carries a Split regex.
193        // Llama-family post_processor prepends <s> at add_special_tokens
194        // time; generation must honor it (word salad without BOS).
195        let add_bos = 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(fancy_regex::Regex::new(&pattern).map_err(|e| {
228                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
495                    .ranks
496                    .get(&(sym[i].clone(), sym[i + 1].clone()))
497                {
498                    if best.map(|(br, _)| r < br).unwrap_or(true) {
499                        best = Some((r, i));
500                    }
501                }
502            }
503            let Some((_, i)) = best else { break };
504            let merged = format!("{}{}", sym[i], sym[i + 1]);
505            // Merge ALL occurrences of this exact pair, left to right.
506            let (left, right) = (sym[i].clone(), sym[i + 1].clone());
507            let mut j = 0;
508            while j + 1 < sym.len() {
509                if sym[j] == left && sym[j + 1] == right {
510                    sym[j] = merged.clone();
511                    sym.remove(j + 1);
512                }
513                j += 1;
514            }
515        }
516
517        for s in &sym {
518            if let Some(&id) = self.vocab.get(s) {
519                out.push(id);
520            } else {
521                // Byte-fallback (synthetic vocabs); never drop silently.
522                let mut ok = true;
523                for ch in s.chars() {
524                    let Some(&b) = self.char_to_byte.get(&ch) else {
525                        ok = false;
526                        break;
527                    };
528                    let tok = format!("<0x{:02X}>", b);
529                    if let Some(&id) = self.vocab.get(&tok) {
530                        out.push(id);
531                    } else {
532                        ok = false;
533                        break;
534                    }
535                }
536                if !ok {
537                    tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
538                }
539            }
540        }
541    }
542
543    /// Decode token IDs back to text. Special tokens are skipped; added
544    /// tokens are raw text; everything else reverses the byte-level map.
545    pub fn decode(&self, ids: &[u32]) -> String {
546        let mut bytes: Vec<u8> = Vec::new();
547        for &id in ids {
548            if self.special_ids.contains(&id) {
549                continue;
550            }
551            let idx = id as usize;
552            if idx >= self.id_to_token.len() {
553                continue;
554            }
555            let tok = &self.id_to_token[idx];
556            if self.added_ids.contains(&id) {
557                bytes.extend_from_slice(tok.as_bytes());
558                continue;
559            }
560            // Byte-fallback / legacy byte tokens
561            if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
562                if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
563                    bytes.push(b);
564                    continue;
565                }
566            }
567            if self.metaspace {
568                // SP decoder: Replace(▁→" "); UTF-8 chars pass through.
569                for ch in tok.chars() {
570                    if ch == '\u{2581}' {
571                        bytes.push(b' ');
572                    } else {
573                        let mut buf = [0u8; 4];
574                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
575                    }
576                }
577                continue;
578            }
579            for ch in tok.chars() {
580                match self.char_to_byte.get(&ch) {
581                    Some(&b) => bytes.push(b),
582                    // Not a byte-level char (shouldn't happen for real
583                    // vocabs) — pass the char through as UTF-8.
584                    None => {
585                        let mut buf = [0u8; 4];
586                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
587                    }
588                }
589            }
590        }
591        let text = String::from_utf8_lossy(&bytes).into_owned();
592        if self.metaspace && self.sp_prepend {
593            // SP decoder Strip(start=1): one leading space from Prepend.
594            if let Some(stripped) = text.strip_prefix(' ') {
595                return stripped.to_string();
596            }
597        }
598        text
599    }
600
601    /// Streaming decode of ONE token: no sequence-level Strip — a
602    /// per-token strip would eat the ▁-spaces of every SP word.
603    pub fn decode_token(&self, id: u32) -> String {
604        if self.special_ids.contains(&id) {
605            return String::new();
606        }
607        let idx = id as usize;
608        if idx >= self.id_to_token.len() {
609            return String::new();
610        }
611        let tok = &self.id_to_token[idx];
612        if self.added_ids.contains(&id) {
613            return tok.clone();
614        }
615        if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
616            if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
617                return String::from_utf8_lossy(&[b]).into_owned();
618            }
619        }
620        if self.metaspace {
621            return tok.replace('\u{2581}', " ");
622        }
623        let mut bytes = Vec::new();
624        for ch in tok.chars() {
625            match self.char_to_byte.get(&ch) {
626                Some(&b) => bytes.push(b),
627                None => {
628                    let mut buf = [0u8; 4];
629                    bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
630                }
631            }
632        }
633        String::from_utf8_lossy(&bytes).into_owned()
634    }
635
636    /// Render the container's Jinja chat template (HF semantics:
637    /// trim_blocks + lstrip_blocks + loop controls) and encode it.
638    /// Falls back to hardcoded ChatML when the file carries none.
639    pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
640        self.apply_chat_template_opts(messages, None)
641    }
642
643    /// Like `apply_chat_template`, with an explicit `enable_thinking` value for
644    /// reasoning-model templates (Qwen3/3.5 emit an empty <think> block when it
645    /// is false, so the model answers directly). `None` leaves the variable
646    /// undefined — the template's own default applies.
647    pub fn apply_chat_template_opts(
648        &self,
649        messages: &[(String, String)],
650        enable_thinking: Option<bool>,
651    ) -> Vec<u32> {
652        if let Some(tpl) = &self.chat_template {
653            match self.render_template(tpl, messages, enable_thinking) {
654                Ok(text) => return self.with_bos(self.encode(&text)),
655                Err(e) => {
656                    tracing::error!("chat template render failed ({e}); ChatML fallback");
657                }
658            }
659        }
660        self.with_bos(self.chatml_fallback(messages))
661    }
662
663    /// Prepend BOS when the tokenizer declares it (llama family).
664    pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
665        if self.add_bos {
666            if let Some(b) = self.bos_token_id {
667                if ids.first() != Some(&b) {
668                    ids.insert(0, b);
669                }
670            }
671        }
672        ids
673    }
674
675    /// Render the carried template to text (parity-testable surface).
676    pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
677        let tpl = self.chat_template.as_ref()?;
678        match self.render_template(tpl, messages, None) {
679            Ok(t) => Some(t),
680            Err(e) => {
681                tracing::error!("chat template render: {e:#}");
682                None
683            }
684        }
685    }
686
687    fn render_template(
688        &self,
689        tpl: &str,
690        messages: &[(String, String)],
691        enable_thinking: Option<bool>,
692    ) -> Result<String, minijinja::Error> {
693        let mut env = minijinja::Environment::new();
694        env.set_trim_blocks(true);
695        env.set_lstrip_blocks(true);
696        // HF templates use python string methods (.startswith, .strip…).
697        env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
698        env.add_template("chat", tpl)?;
699        let msgs: Vec<minijinja::Value> = messages
700            .iter()
701            .map(|(role, content)| {
702                minijinja::context! { role => role, content => content }
703            })
704            .collect();
705        // `enable_thinking` stays UNDEFINED when None — reasoning templates
706        // check `enable_thinking is defined` and fall back to their default.
707        match enable_thinking {
708            Some(v) => env.get_template("chat")?.render(minijinja::context! {
709                messages => msgs,
710                add_generation_prompt => true,
711                enable_thinking => v,
712            }),
713            None => env.get_template("chat")?.render(minijinja::context! {
714                messages => msgs,
715                add_generation_prompt => true,
716            }),
717        }
718    }
719
720    /// Hardcoded Qwen ChatML (pre-§6.1 files).
721    fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
722        let mut tokens = Vec::new();
723
724        for (role, content) in messages {
725            // <|im_start|>role\ncontent<|im_end|>\n
726            if let Some(start_id) = self.im_start_id {
727                tokens.push(start_id);
728            }
729            tokens.extend(self.encode(&format!("{}\n{}", role, content)));
730            if let Some(end_id) = self.im_end_id {
731                tokens.push(end_id);
732            }
733            tokens.extend(self.encode("\n"));
734        }
735
736        // Add assistant prefix
737        if let Some(start_id) = self.im_start_id {
738            tokens.push(start_id);
739        }
740        tokens.extend(self.encode("assistant\n"));
741
742        tokens
743    }
744
745    /// Vocabulary size.
746    pub fn vocab_size(&self) -> usize {
747        self.id_to_token.len()
748    }
749
750    /// Check if token ID is EOS.
751    pub fn is_eos(&self, id: u32) -> bool {
752        self.eos_token_id == Some(id)
753            || self.im_end_id == Some(id)
754            || self.extra_eos.contains(&id)
755    }
756}
757
758#[derive(Debug, thiserror::Error)]
759pub enum TokenizerError {
760    #[error("IO error: {0}")]
761    Io(String),
762    #[error("Parse error: {0}")]
763    Parse(String),
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769
770    #[test]
771    fn byte_unicode_bijection() {
772        let (b2c, c2b) = bytes_to_unicode();
773        for b in 0..=255u8 {
774            assert_eq!(c2b[&b2c[b as usize]], b);
775        }
776        // GPT-2 well-known mappings: space → Ġ, newline → Ċ
777        assert_eq!(b2c[b' ' as usize], 'Ġ');
778        assert_eq!(b2c[b'\n' as usize], 'Ċ');
779    }
780
781    #[test]
782    fn byte_level_roundtrip_utf8() {
783        let tok = Tokenizer::byte_level();
784        let text = "hello 🌍 hi\n";
785        let ids = tok.encode(text);
786        assert_eq!(ids.len(), text.len()); // one id per byte
787        assert_eq!(tok.decode(&ids), text);
788    }
789
790    /// A tiny real-format tokenizer.json exercising the full pipeline:
791    /// GPT-2 regex, byte-level alphabet, one merge, an added token.
792    fn mini_json() -> String {
793        // vocab: byte-level chars for h,e,l,o,Ġ,w,r,d + merged "he"
794        let vocab: Vec<(&str, u32)> = vec![
795            ("h", 0),
796            ("e", 1),
797            ("l", 2),
798            ("o", 3),
799            ("Ġ", 4),
800            ("w", 5),
801            ("r", 6),
802            ("d", 7),
803            ("he", 8),
804            ("Ġw", 9),
805        ];
806        let vocab_json: String = vocab
807            .iter()
808            .map(|(t, i)| format!("\"{t}\": {i}"))
809            .collect::<Vec<_>>()
810            .join(", ");
811        format!(
812            r#"{{
813              "model": {{
814                "type": "BPE",
815                "vocab": {{ {vocab_json} }},
816                "merges": [["h", "e"], ["Ġ", "w"]]
817              }},
818              "added_tokens": [
819                {{"id": 10, "content": "<|eot|>", "special": true}}
820              ]
821            }}"#
822        )
823    }
824
825    #[test]
826    fn full_pipeline_merges_and_added_tokens() {
827        let tok = Tokenizer::from_json(&mini_json()).unwrap();
828        // "hello world" → [he,l,l,o, Ġw,o,r,l,d]
829        let ids = tok.encode("hello world");
830        assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
831        assert_eq!(tok.decode(&ids), "hello world");
832        // Added token splits and is skipped at decode (special).
833        let ids2 = tok.encode("he<|eot|>he");
834        assert_eq!(ids2, vec![8, 10, 8]);
835        assert_eq!(tok.decode(&ids2), "hehe");
836    }
837
838    #[test]
839    fn non_ascii_is_never_silently_dropped() {
840        let tok = Tokenizer::from_json(&mini_json()).unwrap();
841        // A non-ASCII char is not encodable by the mini vocab (no byte tokens either):
842        // the id list may be empty, but ASCII around it must survive.
843        let ids = tok.encode("hello");
844        assert!(!ids.is_empty());
845    }
846}