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(messages))
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        let tpl = self.chat_template.as_ref()?;
675        match self.render_template(tpl, messages, None) {
676            Ok(t) => Some(t),
677            Err(e) => {
678                tracing::error!("chat template render: {e:#}");
679                None
680            }
681        }
682    }
683
684    fn render_template(
685        &self,
686        tpl: &str,
687        messages: &[(String, String)],
688        enable_thinking: Option<bool>,
689    ) -> Result<String, minijinja::Error> {
690        let mut env = minijinja::Environment::new();
691        env.set_trim_blocks(true);
692        env.set_lstrip_blocks(true);
693        // HF templates use python string methods (.startswith, .strip…).
694        env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
695        env.add_template("chat", tpl)?;
696        let msgs: Vec<minijinja::Value> = messages
697            .iter()
698            .map(|(role, content)| {
699                minijinja::context! { role => role, content => content }
700            })
701            .collect();
702        // `enable_thinking` stays UNDEFINED when None — reasoning templates
703        // check `enable_thinking is defined` and fall back to their default.
704        let rendered = match enable_thinking {
705            Some(v) => env.get_template("chat")?.render(minijinja::context! {
706                messages => msgs,
707                add_generation_prompt => true,
708                enable_thinking => v,
709            })?,
710            None => env.get_template("chat")?.render(minijinja::context! {
711                messages => msgs,
712                add_generation_prompt => true,
713            })?,
714        };
715        // Templates that ignore `enable_thinking` (e.g. Nanbeige/Qwen-legacy)
716        // always emit a generation prompt. When thinking is explicitly disabled,
717        // prefill an empty <think>…</think> block so the model answers directly.
718        if enable_thinking == Some(false) && !rendered.contains("</think>") {
719            if let Some(pos) = rendered.rfind("assistant") {
720                let mut insert_at = pos + "assistant".len();
721                if let Some(idx) = rendered[insert_at..].find('\n') {
722                    insert_at += idx + 1;
723                }
724                let mut out = String::with_capacity(rendered.len() + 24);
725                out.push_str(&rendered[..insert_at]);
726                if !out.ends_with('\n') {
727                    out.push('\n');
728                }
729                out.push_str("<think>\n\n</think>\n\n");
730                out.push_str(&rendered[insert_at..]);
731                return Ok(out);
732            }
733        }
734        Ok(rendered)
735    }
736
737    /// Hardcoded Qwen ChatML (pre-§6.1 files).
738    fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
739        let mut tokens = Vec::new();
740
741        for (role, content) in messages {
742            // <|im_start|>role\ncontent<|im_end|>\n
743            if let Some(start_id) = self.im_start_id {
744                tokens.push(start_id);
745            }
746            tokens.extend(self.encode(&format!("{}\n{}", role, content)));
747            if let Some(end_id) = self.im_end_id {
748                tokens.push(end_id);
749            }
750            tokens.extend(self.encode("\n"));
751        }
752
753        // Add assistant prefix
754        if let Some(start_id) = self.im_start_id {
755            tokens.push(start_id);
756        }
757        tokens.extend(self.encode("assistant\n"));
758
759        tokens
760    }
761
762    /// Vocabulary size.
763    pub fn vocab_size(&self) -> usize {
764        self.id_to_token.len()
765    }
766
767    /// Check if token ID is EOS.
768    pub fn is_eos(&self, id: u32) -> bool {
769        self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
770    }
771}
772
773#[derive(Debug, thiserror::Error)]
774pub enum TokenizerError {
775    #[error("IO error: {0}")]
776    Io(String),
777    #[error("Parse error: {0}")]
778    Parse(String),
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    #[test]
786    fn byte_unicode_bijection() {
787        let (b2c, c2b) = bytes_to_unicode();
788        for b in 0..=255u8 {
789            assert_eq!(c2b[&b2c[b as usize]], b);
790        }
791        // GPT-2 well-known mappings: space → Ġ, newline → Ċ
792        assert_eq!(b2c[b' ' as usize], 'Ġ');
793        assert_eq!(b2c[b'\n' as usize], 'Ċ');
794    }
795
796    #[test]
797    fn byte_level_roundtrip_utf8() {
798        let tok = Tokenizer::byte_level();
799        let text = "hello 🌍 hi\n";
800        let ids = tok.encode(text);
801        assert_eq!(ids.len(), text.len()); // one id per byte
802        assert_eq!(tok.decode(&ids), text);
803    }
804
805    /// A tiny real-format tokenizer.json exercising the full pipeline:
806    /// GPT-2 regex, byte-level alphabet, one merge, an added token.
807    fn mini_json() -> String {
808        // vocab: byte-level chars for h,e,l,o,Ġ,w,r,d + merged "he"
809        let vocab: Vec<(&str, u32)> = vec![
810            ("h", 0),
811            ("e", 1),
812            ("l", 2),
813            ("o", 3),
814            ("Ġ", 4),
815            ("w", 5),
816            ("r", 6),
817            ("d", 7),
818            ("he", 8),
819            ("Ġw", 9),
820        ];
821        let vocab_json: String = vocab
822            .iter()
823            .map(|(t, i)| format!("\"{t}\": {i}"))
824            .collect::<Vec<_>>()
825            .join(", ");
826        format!(
827            r#"{{
828              "model": {{
829                "type": "BPE",
830                "vocab": {{ {vocab_json} }},
831                "merges": [["h", "e"], ["Ġ", "w"]]
832              }},
833              "added_tokens": [
834                {{"id": 10, "content": "<|eot|>", "special": true}}
835              ]
836            }}"#
837        )
838    }
839
840    #[test]
841    fn full_pipeline_merges_and_added_tokens() {
842        let tok = Tokenizer::from_json(&mini_json()).unwrap();
843        // "hello world" → [he,l,l,o, Ġw,o,r,l,d]
844        let ids = tok.encode("hello world");
845        assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
846        assert_eq!(tok.decode(&ids), "hello world");
847        // Added token splits and is skipped at decode (special).
848        let ids2 = tok.encode("he<|eot|>he");
849        assert_eq!(ids2, vec![8, 10, 8]);
850        assert_eq!(tok.decode(&ids2), "hehe");
851    }
852
853    #[test]
854    fn non_ascii_is_never_silently_dropped() {
855        let tok = Tokenizer::from_json(&mini_json()).unwrap();
856        // A non-ASCII char is not encodable by the mini vocab (no byte tokens either):
857        // the id list may be empty, but ASCII around it must survive.
858        let ids = tok.encode("hello");
859        assert!(!ids.is_empty());
860    }
861}