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