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