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