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 std::sync::OnceLock;
16use unicode_normalization::UnicodeNormalization;
17
18/// GPT-2 pre-tokenizer pattern — used when tokenizer.json carries no
19/// explicit Split regex (Qwen files carry their own; see `from_json`).
20const 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+";
21
22/// Tool-call grammar tokens that decode as TEXT even when the vocabulary
23/// marks them special.
24///
25/// `decode` drops special tokens, which is right for control tokens
26/// (`<|im_end|>`, `</s>` …) and wrong for these: they ARE the tool call.
27/// MiniCPM5's tokenizer marks `<function`, `</function>`, `<param`,
28/// `</param>` (and `<tool_call>` …) special, so a call came out as
29/// ` name="get_weather">…Paris` — nothing a parser can find. Qwen's
30/// tokenizer marks its `<tool_call>` non-special for the same reason;
31/// this list gives every vocabulary that behaviour. Only exact tool
32/// markup is listed, so no chat control token can leak into content.
33pub const TOOL_MARKUP_TOKENS: &[&str] = &[
34    "<tool_call>",
35    "</tool_call>",
36    "<function",
37    "</function>",
38    "<param",
39    "</param>",
40];
41
42/// A loaded BPE tokenizer.
43pub struct Tokenizer {
44    /// Token string → ID
45    vocab: HashMap<String, u32>,
46    /// ID → Token string
47    id_to_token: Vec<String>,
48    /// BPE merge ranks: (left, right) → rank (lower merges first)
49    ranks: HashMap<(String, String), u32>,
50    /// The SentencePiece merges over token ids (built on first use; `None`
51    /// when a merge names a string outside the vocabulary — the string
52    /// path then runs)
53    sp_ids: OnceLock<Option<SpMerges>>,
54    /// All added tokens (split during encode; emitted raw at decode)
55    added: Vec<(String, u32)>,
56    /// IDs of added tokens (decode: emit content raw, no byte-map)
57    added_ids: HashSet<u32>,
58    /// IDs of special tokens (skipped by `decode`)
59    special_ids: HashSet<u32>,
60    /// Pre-tokenizer split pattern (None = whitespace fallback for the
61    /// synthetic `byte_level()` tokenizer)
62    /// Applied in order; each subdivides the previous stage's pieces.
63    split_res: Vec<fancy_regex::Regex>,
64    /// SentencePiece Prepend("▁") normalizer present (llama family).
65    /// Gemma replaces spaces with ▁ but does NOT prepend one.
66    sp_prepend: bool,
67    /// Metaspace `prepend_scheme: "first"` in the PRE-tokenizer (no
68    /// Prepend normalizer): the ▁ goes on the very first section of the
69    /// input only, so text after an added token gets none. Nanbeige 4.2
70    /// is this shape — reading only the normalizer left every raw prompt
71    /// short one leading ▁ ("Hello" instead of "▁Hello").
72    sp_prepend_first: bool,
73    /// SentencePiece family (TinyLlama/Llama-2/Mistral): metaspace ▁
74    /// normalization + byte_fallback, no byte-level alphabet.
75    metaspace: bool,
76    /// NFC only when the file's normalizer declares it (Qwen does,
77    /// TinyLlama does not — forcing it broke combining-accent parity).
78    nfc: bool,
79    /// byte → byte-level char (GPT-2 visible-alphabet mapping)
80    byte_to_char: [char; 256],
81    /// byte-level char → byte
82    char_to_byte: HashMap<char, u8>,
83    /// Special tokens
84    pub bos_token_id: Option<u32>,
85    pub eos_token_id: Option<u32>,
86    pub pad_token_id: Option<u32>,
87    /// Chat template special tokens
88    pub im_start_id: Option<u32>,
89    pub im_end_id: Option<u32>,
90    /// Jinja chat template carried by the container (spec §6.1);
91    /// None → hardcoded ChatML fallback.
92    pub chat_template: Option<String>,
93    /// Extra stop ids from the container's generation config.
94    pub extra_eos: HashSet<u32>,
95    /// Generation prepends BOS (llama post_processor semantics).
96    pub add_bos: bool,
97}
98
99impl std::fmt::Debug for Tokenizer {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("Tokenizer")
102            .field("vocab", &self.vocab.len())
103            .field("merges", &self.ranks.len())
104            .field("added", &self.added.len())
105            .finish()
106    }
107}
108
109/// GPT-2 byte↔unicode bijection: printable bytes map to themselves,
110/// the rest get consecutive codepoints from U+0100 up.
111fn bytes_to_unicode() -> ([char; 256], HashMap<char, u8>) {
112    let mut b2c = ['\0'; 256];
113    let mut c2b = HashMap::with_capacity(256);
114    let mut n = 0u32;
115    for b in 0..=255u16 {
116        let printable =
117            (0x21..=0x7E).contains(&b) || (0xA1..=0xAC).contains(&b) || (0xAE..=0xFF).contains(&b);
118        let c = if printable {
119            char::from_u32(b as u32).unwrap()
120        } else {
121            let c = char::from_u32(256 + n).unwrap();
122            n += 1;
123            c
124        };
125        b2c[b as usize] = c;
126        c2b.insert(c, b as u8);
127    }
128    (b2c, c2b)
129}
130
131/// HuggingFace tokenizer.json schema (the parts we execute).
132#[derive(Deserialize)]
133struct HfTokenizerJson {
134    model: HfModel,
135    #[serde(default)]
136    added_tokens: Vec<HfAddedToken>,
137    #[serde(default)]
138    pre_tokenizer: Option<serde_json::Value>,
139    #[serde(default)]
140    normalizer: Option<serde_json::Value>,
141    #[serde(default)]
142    post_processor: Option<serde_json::Value>,
143}
144
145#[derive(Deserialize)]
146struct HfModel {
147    vocab: HashMap<String, u32>,
148    #[serde(default)]
149    merges: Vec<HfMerge>,
150    #[serde(default)]
151    byte_fallback: bool,
152}
153
154/// Merge rules come in two HF flavours: legacy `"a b"` strings and
155/// modern `["a", "b"]` pairs (Qwen3.5 tokenizer.json uses pairs).
156#[derive(Deserialize)]
157#[serde(untagged)]
158enum HfMerge {
159    Pair([String; 2]),
160    Text(String),
161}
162
163#[derive(Deserialize)]
164struct HfAddedToken {
165    id: u32,
166    content: String,
167    special: bool,
168}
169
170/// Collect EVERY Split regex from a pre_tokenizer subtree, in order.
171///
172/// A Sequence applies its splits one after another, each subdividing what
173/// the previous one produced — taking only the first is not an
174/// approximation, it is a different tokenizer. DeepSeek-V4 puts the digit
175/// rule first and the word rule third, so reading one pattern sent whole
176/// sentences into BPE as a single piece and produced ids the model has
177/// never seen.
178fn collect_split_patterns(pt: &serde_json::Value, out: &mut Vec<String>) {
179    if pt.get("type").and_then(|t| t.as_str()) == Some("Split") {
180        if let Some(r) = pt
181            .get("pattern")
182            .and_then(|p| p.get("Regex"))
183            .and_then(|r| r.as_str())
184        {
185            out.push(r.to_string());
186        }
187        return;
188    }
189    if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
190        for p in list {
191            collect_split_patterns(p, out);
192        }
193    }
194}
195
196/// `prepend_scheme` of a Metaspace pre-tokenizer ("always" | "first" |
197/// "never"), searched through a Sequence.
198fn find_prepend_scheme(pt: &serde_json::Value) -> Option<String> {
199    if pt.get("type").and_then(|t| t.as_str()) == Some("Metaspace") {
200        return pt
201            .get("prepend_scheme")
202            .and_then(|p| p.as_str())
203            .map(String::from);
204    }
205    if let Some(list) = pt.get("pretokenizers").and_then(|l| l.as_array()) {
206        return list.iter().find_map(find_prepend_scheme);
207    }
208    None
209}
210
211/// Neutralise `{% generation %}` / `{% endgeneration %}`.
212///
213/// Transformers uses that pair to mark which span of the render is the
214/// assistant's own tokens, so a trainer can build a loss mask. For
215/// INFERENCE it is transparent — the body renders either way — but
216/// minijinja does not know the statement and fails the whole template,
217/// after which the caller quietly serves a ChatML approximation and the
218/// model answers a differently-shaped prompt than the one it was tuned
219/// on. LiquidAI's LFM2.5 templates use it.
220///
221/// Deleting the tag is not enough: `{%- generation -%}` also carries
222/// whitespace control, and dropping it would leave the newline and the
223/// indentation around it in the output. Each tag becomes an assignment
224/// that does nothing, carrying the SAME dashes, so minijinja trims
225/// exactly what the original would have.
226pub(crate) fn strip_generation_tags(tpl: &str) -> std::borrow::Cow<'_, str> {
227    if !tpl.contains("generation") {
228        return std::borrow::Cow::Borrowed(tpl);
229    }
230    let mut out = String::with_capacity(tpl.len());
231    let mut rest = tpl;
232    let mut touched = false;
233    while let Some(open) = rest.find("{%") {
234        let Some(close_rel) = rest[open..].find("%}") else {
235            break;
236        };
237        let close = open + close_rel + 2;
238        let tag = &rest[open..close];
239        let inner = tag[2..tag.len() - 2].trim();
240        let lead = inner.starts_with('-');
241        let trail = inner.ends_with('-');
242        let name = inner.trim_matches('-').trim();
243        out.push_str(&rest[..open]);
244        if name == "generation" || name == "endgeneration" {
245            out.push_str(if lead { "{%-" } else { "{%" });
246            out.push_str(" set _generation_span = true ");
247            out.push_str(if trail { "-%}" } else { "%}" });
248            touched = true;
249        } else {
250            out.push_str(tag);
251        }
252        rest = &rest[close..];
253    }
254    if !touched {
255        return std::borrow::Cow::Borrowed(tpl);
256    }
257    out.push_str(rest);
258    std::borrow::Cow::Owned(out)
259}
260
261/// Close a template's generation-time `<think>` marker for direct answers.
262///
263/// GLM-5's template ends in `<|assistant|><think>` and controls the reasoning
264/// budget with `reasoning_effort`; unlike Qwen templates it does not inspect
265/// `enable_thinking`.  The marker must be closed after rendering without
266/// splitting the complete `<|assistant|>` special token.  Keep this helper
267/// shared by pair- and JSON-shaped chat rendering so both APIs use identical
268/// token sequences.
269fn close_direct_think(rendered: String, enable_thinking: Option<bool>) -> String {
270    if enable_thinking != Some(false) || rendered.contains("</think>") {
271        return rendered;
272    }
273    if let Some(pos) = rendered.rfind("<|assistant|>") {
274        let marker_end = pos + "<|assistant|>".len();
275        let after = &rendered[marker_end..];
276        let mut out = String::with_capacity(rendered.len() + 24);
277        out.push_str(&rendered[..marker_end]);
278        if let Some(rest) = after.strip_prefix("<think>") {
279            out.push_str("<think></think>\n\n");
280            out.push_str(rest);
281        } else {
282            out.push_str(after);
283            if !out.ends_with('\n') {
284                out.push('\n');
285            }
286            out.push_str("<think>\n\n</think>\n\n");
287        }
288        return out;
289    }
290    // Templates without the angle-bracket assistant marker use a
291    // line-oriented role prefix.  Keep that marker intact and append the
292    // empty think block after its newline.
293    if let Some(pos) = rendered.rfind("\nassistant") {
294        let mut insert_at = pos + 1 + "assistant".len();
295        if let Some(idx) = rendered[insert_at..].find('\n') {
296            insert_at += idx + 1;
297        }
298        let mut out = String::with_capacity(rendered.len() + 24);
299        out.push_str(&rendered[..insert_at]);
300        if !out.ends_with('\n') {
301            out.push('\n');
302        }
303        out.push_str("<think>\n\n</think>\n\n");
304        out.push_str(&rendered[insert_at..]);
305        return out;
306    }
307    rendered
308}
309
310impl Tokenizer {
311    /// Load tokenizer from HuggingFace tokenizer.json file.
312    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TokenizerError> {
313        let data = std::fs::read_to_string(path.as_ref())
314            .map_err(|e| TokenizerError::Io(e.to_string()))?;
315        Self::from_json(&data)
316    }
317
318    /// Load tokenizer from raw tokenizer.json bytes (CMF VOCAB section).
319    pub fn from_bytes(bytes: &[u8]) -> Result<Self, TokenizerError> {
320        let s = std::str::from_utf8(bytes)
321            .map_err(|e| TokenizerError::Parse(format!("vocab is not UTF-8: {e}")))?;
322        Self::from_json(s)
323    }
324
325    /// Load tokenizer from JSON string.
326    pub fn from_json(json: &str) -> Result<Self, TokenizerError> {
327        let hf: HfTokenizerJson =
328            serde_json::from_str(json).map_err(|e| TokenizerError::Parse(e.to_string()))?;
329
330        let mut vocab = hf.model.vocab;
331        let mut ranks = HashMap::new();
332        for (rank, m) in hf.model.merges.into_iter().enumerate() {
333            let (a, b) = match m {
334                HfMerge::Pair([a, b]) => (a, b),
335                HfMerge::Text(s) => {
336                    let mut it = s.splitn(2, ' ');
337                    match (it.next(), it.next()) {
338                        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
339                        _ => continue,
340                    }
341                }
342            };
343            ranks.insert((a, b), rank as u32);
344        }
345
346        // Family detection: SentencePiece carries byte_fallback and/or a
347        // Prepend("▁") normalizer; byte-level BPE carries a Split regex.
348        // Llama-family post_processor prepends <s> at add_special_tokens
349        // time; generation must honor it (word salad without BOS).
350        let mut saw_gemma_bos = false;
351        let add_bos_detected = hf
352            .post_processor
353            .as_ref()
354            .map(|p| {
355                let pp = p.to_string();
356                pp.contains("\"<s>\"") || pp.contains("\"<bos>\"")
357            })
358            .unwrap_or(false);
359        let nfc = hf
360            .normalizer
361            .as_ref()
362            .map(|n| n.to_string().contains("NFC"))
363            .unwrap_or(false);
364        let metaspace = hf.model.byte_fallback
365            || hf
366                .normalizer
367                .as_ref()
368                .map(|n| n.to_string().contains("\u{2581}") || n.to_string().contains("▁"))
369                .unwrap_or(false);
370        let sp_prepend = hf
371            .normalizer
372            .as_ref()
373            .map(|n| n.to_string().contains("Prepend"))
374            .unwrap_or(false);
375        // Metaspace can also live in the pre-tokenizer, carrying its own
376        // prepend_scheme: "always" behaves like the llama normalizer,
377        // "first" only marks the head of the input (see `sp_prepend_first`).
378        let (sp_prepend, sp_prepend_first) = if sp_prepend {
379            (true, false)
380        } else {
381            match hf.pre_tokenizer.as_ref().and_then(find_prepend_scheme) {
382                Some(s) if s == "always" => (true, false),
383                Some(s) if s == "first" => (false, true),
384                _ => (false, false),
385            }
386        };
387        let split_res = if metaspace {
388            Vec::new()
389        } else {
390            let mut pats = Vec::new();
391            if let Some(pt) = hf.pre_tokenizer.as_ref() {
392                collect_split_patterns(pt, &mut pats);
393            }
394            if pats.is_empty() {
395                pats.push(DEFAULT_SPLIT.to_string());
396            }
397            pats.iter()
398                .map(|p| {
399                    fancy_regex::Regex::new(p)
400                        .map_err(|e| TokenizerError::Parse(format!("pre-tokenizer regex: {e}")))
401                })
402                .collect::<Result<Vec<_>, _>>()?
403        };
404
405        // Added tokens: longest-first so overlapping contents match right.
406        let mut bos_token_id = None;
407        let mut eos_token_id = None;
408        let mut pad_token_id = None;
409        let mut im_start_id = None;
410        let mut im_end_id = None;
411        let mut special_ids = HashSet::new();
412        let mut added_ids = HashSet::new();
413        let mut added = Vec::new();
414
415        for at in &hf.added_tokens {
416            vocab.insert(at.content.clone(), at.id);
417            added.push((at.content.clone(), at.id));
418            added_ids.insert(at.id);
419            if at.special && !TOOL_MARKUP_TOKENS.contains(&at.content.as_str()) {
420                special_ids.insert(at.id);
421            }
422            match at.content.as_str() {
423                "<|endoftext|>" | "</s>" | "[EOS]" => eos_token_id = Some(at.id),
424                "<|im_start|>" => im_start_id = Some(at.id),
425                "<|im_end|>" => im_end_id = Some(at.id),
426                "<s>" | "[BOS]" => bos_token_id = Some(at.id),
427                // Gemma spells BOS as literal "<bos>" — the family
428                // REQUIRES it on every sequence, and newer tokenizers
429                // (gemma-4) no longer say so in a post_processor.
430                "<bos>" => {
431                    bos_token_id = Some(at.id);
432                    saw_gemma_bos = true;
433                }
434                "<pad>" => pad_token_id = Some(at.id),
435                _ => {}
436            }
437        }
438
439        // DeepSeek-V4.1 keeps harmony/DSML markers in the ordinary vocabulary
440        // on some tokenizer revisions and in `added_tokens` on others. Treat
441        // either spelling as atomic so image placeholders cannot split into
442        // byte-BPE pieces, and make the configured BOS/EOS ids discoverable.
443        const DSV41_SPECIALS: &[&str] = &[
444            "<|begin▁of▁sentence|>",
445            "<|end▁of▁sentence|>",
446            "<|User|>",
447            "<|Assistant|>",
448            "<|System|>",
449            "<|latest_reminder|>",
450            "<|deepseek_image|>",
451            "<|action|>",
452            "<|query|>",
453            "<|authority|>",
454            "<|domain|>",
455            "<|title|>",
456            "<|read_url|>",
457            "<think>",
458            "</think>",
459            "|DSML|",
460        ];
461        for token in DSV41_SPECIALS {
462            if let Some(&id) = vocab.get(*token) {
463                if !added.iter().any(|(content, _)| content.as_str() == *token) {
464                    added.push(((*token).to_string(), id));
465                }
466                added_ids.insert(id);
467                match *token {
468                    "<|begin▁of▁sentence|>" => bos_token_id = Some(id),
469                    "<|end▁of▁sentence|>" => eos_token_id = Some(id),
470                    _ => {}
471                }
472            }
473        }
474        added.sort_by_key(|(c, _)| std::cmp::Reverse(c.len()));
475
476        // Gemma REQUIRES a leading <bos> on every sequence, but newer
477        // tokenizers (gemma-4, 262k vocab) no longer spell it in a
478        // post_processor template — the family marker <start_of_turn>
479        // is the reliable tell. Without this, raw-text scoring runs
480        // unanchored and the first ~30 positions read worse than
481        // uniform (the chat path masked it: the template carries <bos>).
482        let gemma_family = saw_gemma_bos
483            || vocab.contains_key("<start_of_turn>")
484            || added.iter().any(|(c, _)| c == "<start_of_turn>");
485
486        // The post_processor template names the exact BOS content
487        // (llama "<s>", gemma "<bos>" — gemma's vocab carries BOTH, so
488        // added-token scan order must not decide).
489        if let Some(pp) = hf.post_processor.as_ref() {
490            let pp = pp.to_string();
491            for name in ["<bos>", "<s>"] {
492                if pp.contains(&format!("\"{name}\"")) {
493                    if let Some(&id) = vocab.get(name) {
494                        bos_token_id = Some(id);
495                    }
496                    break;
497                }
498            }
499        }
500
501        // Build reverse map
502        let max_id = vocab.values().copied().max().unwrap_or(0) as usize;
503        let mut id_to_token = vec![String::new(); max_id + 1];
504        for (token, &id) in &vocab {
505            if (id as usize) < id_to_token.len() {
506                id_to_token[id as usize] = token.clone();
507            }
508        }
509
510        let (byte_to_char, char_to_byte) = bytes_to_unicode();
511
512        tracing::info!(
513            "Tokenizer loaded: {} vocab, {} merges, {} added, eos={:?}",
514            vocab.len(),
515            ranks.len(),
516            added.len(),
517            eos_token_id
518        );
519
520        Ok(Self {
521            vocab,
522            id_to_token,
523            ranks,
524            sp_ids: OnceLock::new(),
525            added,
526            added_ids,
527            special_ids,
528            split_res,
529            metaspace,
530            sp_prepend,
531            sp_prepend_first,
532            nfc,
533            byte_to_char,
534            char_to_byte,
535            bos_token_id,
536            eos_token_id,
537            pad_token_id,
538            im_start_id,
539            im_end_id,
540            chat_template: None,
541            extra_eos: HashSet::new(),
542            add_bos: add_bos_detected || gemma_family,
543        })
544    }
545
546    /// Create a minimal tokenizer for testing (byte tokens, no merges).
547    pub fn byte_level() -> Self {
548        let mut vocab = HashMap::new();
549        let mut id_to_token = Vec::with_capacity(256);
550        for i in 0..256u32 {
551            let tok = format!("<0x{:02X}>", i);
552            vocab.insert(tok.clone(), i);
553            id_to_token.push(tok);
554        }
555        let (byte_to_char, char_to_byte) = bytes_to_unicode();
556        Self {
557            vocab,
558            id_to_token,
559            ranks: HashMap::new(),
560            sp_ids: OnceLock::new(),
561            added: Vec::new(),
562            added_ids: HashSet::new(),
563            special_ids: HashSet::new(),
564            split_res: Vec::new(),
565            metaspace: false,
566            sp_prepend: false,
567            sp_prepend_first: false,
568            nfc: false,
569            byte_to_char,
570            char_to_byte,
571            bos_token_id: None,
572            eos_token_id: None,
573            pad_token_id: None,
574            im_start_id: None,
575            im_end_id: None,
576            chat_template: None,
577            extra_eos: HashSet::new(),
578            add_bos: false,
579        }
580    }
581
582    /// Encode text to token IDs.
583    pub fn encode(&self, text: &str) -> Vec<u32> {
584        let mut ids = Vec::new();
585        // Added tokens match on raw text (normalized: false), longest first.
586        let mut rest = text;
587        // `prepend_scheme: "first"` marks only the section that starts at
588        // offset 0 — HF drops the ▁ for everything after an added token,
589        // which is why a chat prompt opening with <|im_start|> tokenizes
590        // the same either way and only raw prompts were wrong.
591        let mut head = true;
592        'outer: while !rest.is_empty() {
593            let mut best: Option<(usize, usize, u32)> = None; // (pos, len, id)
594            for (content, id) in &self.added {
595                if let Some(pos) = rest.find(content.as_str()) {
596                    let better = match best {
597                        None => true,
598                        Some((bp, bl, _)) => pos < bp || (pos == bp && content.len() > bl),
599                    };
600                    if better {
601                        best = Some((pos, content.len(), *id));
602                    }
603                    if pos == 0 {
604                        break; // earliest possible; added is longest-first
605                    }
606                }
607            }
608            match best {
609                Some((pos, len, id)) => {
610                    self.encode_segment_at(&rest[..pos], head, &mut ids);
611                    ids.push(id);
612                    rest = &rest[pos + len..];
613                    head = false;
614                }
615                None => {
616                    self.encode_segment_at(rest, head, &mut ids);
617                    break 'outer;
618                }
619            }
620        }
621        ids
622    }
623
624    /// Encode text as PLAIN text: the whole input is one added-token-free
625    /// segment (NFC → split → byte-map → BPE), so a literal `<|im_end|>`
626    /// in it stays bytes instead of becoming the special id. This is the
627    /// trainer's `Bpe::encode` (user text, never the template frame) —
628    /// router v2 tokenizes the user message with it (spec §9.4).
629    pub fn encode_plain(&self, text: &str) -> Vec<u32> {
630        let mut ids = Vec::new();
631        self.encode_segment_at(text, true, &mut ids);
632        ids
633    }
634
635    /// Encode one added-token-free segment: NFC → split → byte-map →
636    /// BPE. `head` says whether this section starts at offset 0 of the
637    /// input — only that one takes a `prepend_scheme: "first"` ▁.
638    fn encode_segment_at(&self, segment: &str, head: bool, out: &mut Vec<u32>) {
639        if segment.is_empty() {
640            return;
641        }
642        let norm: String = if self.nfc {
643            segment.nfc().collect()
644        } else {
645            segment.to_string()
646        };
647        if self.metaspace {
648            // SentencePiece: [Prepend("▁") +] Replace(" "→"▁"), BPE over
649            // chars of the whole span (no pre-tokenizer, no byte map).
650            // Gemma's normalizer replaces only — no dummy prefix.
651            let sp = if self.sp_prepend {
652                // Normalizer order: Prepend THEN Replace, unguarded — so
653                // " hello" really does become ▁▁hello on llama.
654                format!("\u{2581}{}", norm).replace(' ', "\u{2581}")
655            } else {
656                // Pre-tokenizer Metaspace: Replace, then prepend only if
657                // the span does not already start with ▁ (HF's guard, so
658                // " hello" stays one ▁, not two).
659                let replaced = norm.replace(' ', "\u{2581}");
660                if self.sp_prepend_first && head && !replaced.starts_with('\u{2581}') {
661                    format!("\u{2581}{replaced}")
662                } else {
663                    replaced
664                }
665            };
666            self.bpe_piece_sp(&sp, out);
667            return;
668        }
669        if !self.split_res.is_empty() {
670            // Each stage subdivides the pieces the previous one left, with
671            // Isolated behaviour: both the matches and the gaps survive.
672            let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
673            for re in &self.split_res {
674                let mut next: Vec<(usize, usize)> = Vec::with_capacity(pieces.len() * 2);
675                for (ps, pe) in pieces {
676                    let seg = &norm[ps..pe];
677                    let mut last = 0usize;
678                    for m in re.find_iter(seg) {
679                        let m = match m {
680                            Ok(m) => m,
681                            Err(e) => {
682                                tracing::error!("pre-tokenizer regex failed: {e}");
683                                break;
684                            }
685                        };
686                        if m.start() > last {
687                            next.push((ps + last, ps + m.start()));
688                        }
689                        if m.end() > m.start() {
690                            next.push((ps + m.start(), ps + m.end()));
691                        }
692                        last = m.end();
693                    }
694                    if last < seg.len() {
695                        next.push((ps + last, pe));
696                    }
697                }
698                pieces = next;
699            }
700            for (ps, pe) in pieces {
701                self.bpe_piece(&norm[ps..pe], out);
702            }
703        } else {
704            {
705                // Synthetic byte_level() tokenizer: raw byte tokens.
706                for b in norm.bytes() {
707                    let tok = format!("<0x{:02X}>", b);
708                    if let Some(&id) = self.vocab.get(&tok) {
709                        out.push(id);
710                    }
711                }
712            }
713        }
714    }
715
716    /// The id-level merge table of a SentencePiece vocabulary.
717    fn sp_merges(&self) -> Option<&SpMerges> {
718        self.sp_ids
719            .get_or_init(|| SpMerges::build(&self.vocab, &self.ranks))
720            .as_ref()
721    }
722
723    /// SentencePiece BPE: symbols are chars (no byte-level alphabet);
724    /// unknown symbols fall back to <0xNN> tokens per UTF-8 byte.
725    ///
726    /// The rule is "merge every occurrence of the lowest-ranked adjacent
727    /// pair, left to right, until none is left". [`SpMerges::encode`] runs
728    /// it over ids with a heap (O(n log n)); this string form (O(n²) with a
729    /// clone per pair) remains for vocabularies whose merges leave the
730    /// vocabulary and as the reference the fast form is tested against.
731    fn bpe_piece_sp(&self, piece: &str, out: &mut Vec<u32>) {
732        if piece.is_empty() {
733            return;
734        }
735        if !SP_STRINGS.with(|f| f.get()) {
736            if let Some(m) = self.sp_merges() {
737                m.encode(&self.vocab, piece, out);
738                return;
739            }
740        }
741        self.bpe_piece_sp_strings(piece, out);
742    }
743
744    /// [`encode`](Self::encode) with the SentencePiece string form forced:
745    /// the reference the id-level BPE is checked against.
746    #[doc(hidden)]
747    pub fn encode_reference(&self, text: &str) -> Vec<u32> {
748        SP_STRINGS.with(|f| f.set(true));
749        let ids = self.encode(text);
750        SP_STRINGS.with(|f| f.set(false));
751        ids
752    }
753
754    fn bpe_piece_sp_strings(&self, piece: &str, out: &mut Vec<u32>) {
755        let mut sym: Vec<String> = piece.chars().map(|c| c.to_string()).collect();
756        loop {
757            let mut best: Option<(u32, usize)> = None;
758            for i in 0..sym.len().saturating_sub(1) {
759                if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
760                    if best.map(|(br, _)| r < br).unwrap_or(true) {
761                        best = Some((r, i));
762                    }
763                }
764            }
765            let Some((_, i)) = best else { break };
766            let merged = format!("{}{}", sym[i], sym[i + 1]);
767            let (left, right) = (sym[i].clone(), sym[i + 1].clone());
768            let mut j = 0;
769            while j + 1 < sym.len() {
770                if sym[j] == left && sym[j + 1] == right {
771                    sym[j] = merged.clone();
772                    sym.remove(j + 1);
773                }
774                j += 1;
775            }
776        }
777        for t in &sym {
778            if let Some(&id) = self.vocab.get(t) {
779                out.push(id);
780            } else {
781                let mut ok = true;
782                for byte in t.bytes() {
783                    let tok = format!("<0x{:02X}>", byte);
784                    match self.vocab.get(&tok) {
785                        Some(&id) => out.push(id),
786                        None => {
787                            ok = false;
788                            break;
789                        }
790                    }
791                }
792                if !ok {
793                    tracing::error!("tokenizer: no id for SP symbol {t:?} — dropped");
794                }
795            }
796        }
797    }
798
799    /// Byte-level map one pre-token piece, then ranked BPE merges.
800    fn bpe_piece(&self, piece: &str, out: &mut Vec<u32>) {
801        if piece.is_empty() {
802            return;
803        }
804        let mapped: Vec<String> = piece
805            .bytes()
806            .map(|b| self.byte_to_char[b as usize].to_string())
807            .collect();
808        let mut sym = mapped;
809
810        // Classic BPE: repeatedly merge the lowest-rank adjacent pair.
811        loop {
812            let mut best: Option<(u32, usize)> = None;
813            for i in 0..sym.len().saturating_sub(1) {
814                if let Some(&r) = self.ranks.get(&(sym[i].clone(), sym[i + 1].clone())) {
815                    if best.map(|(br, _)| r < br).unwrap_or(true) {
816                        best = Some((r, i));
817                    }
818                }
819            }
820            let Some((_, i)) = best else { break };
821            let merged = format!("{}{}", sym[i], sym[i + 1]);
822            // Merge ALL occurrences of this exact pair, left to right.
823            let (left, right) = (sym[i].clone(), sym[i + 1].clone());
824            let mut j = 0;
825            while j + 1 < sym.len() {
826                if sym[j] == left && sym[j + 1] == right {
827                    sym[j] = merged.clone();
828                    sym.remove(j + 1);
829                }
830                j += 1;
831            }
832        }
833
834        for s in &sym {
835            if let Some(&id) = self.vocab.get(s) {
836                out.push(id);
837            } else {
838                // Byte-fallback (synthetic vocabs); never drop silently.
839                let mut ok = true;
840                for ch in s.chars() {
841                    let Some(&b) = self.char_to_byte.get(&ch) else {
842                        ok = false;
843                        break;
844                    };
845                    let tok = format!("<0x{:02X}>", b);
846                    if let Some(&id) = self.vocab.get(&tok) {
847                        out.push(id);
848                    } else {
849                        ok = false;
850                        break;
851                    }
852                }
853                if !ok {
854                    tracing::error!("tokenizer: no id for symbol {s:?} — dropped");
855                }
856            }
857        }
858    }
859
860    /// Decode token IDs back to text. Special tokens are skipped; added
861    /// tokens are raw text; everything else reverses the byte-level map.
862    pub fn decode(&self, ids: &[u32]) -> String {
863        let mut bytes: Vec<u8> = Vec::new();
864        for &id in ids {
865            if self.special_ids.contains(&id) {
866                continue;
867            }
868            let idx = id as usize;
869            if idx >= self.id_to_token.len() {
870                continue;
871            }
872            let tok = &self.id_to_token[idx];
873            if self.added_ids.contains(&id) {
874                // Gemma-3n declares its multi-space ▁-runs as ADDED
875                // tokens — verbatim passthrough leaked ▁ into output.
876                if self.metaspace && tok.contains('\u{2581}') {
877                    bytes.extend_from_slice(tok.replace('\u{2581}', " ").as_bytes());
878                } else {
879                    bytes.extend_from_slice(tok.as_bytes());
880                }
881                continue;
882            }
883            // Byte-fallback / legacy byte tokens
884            if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
885                if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
886                    bytes.push(b);
887                    continue;
888                }
889            }
890            if self.metaspace {
891                // SP decoder: Replace(▁→" "); UTF-8 chars pass through.
892                for ch in tok.chars() {
893                    if ch == '\u{2581}' {
894                        bytes.push(b' ');
895                    } else {
896                        let mut buf = [0u8; 4];
897                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
898                    }
899                }
900                continue;
901            }
902            for ch in tok.chars() {
903                match self.char_to_byte.get(&ch) {
904                    Some(&b) => bytes.push(b),
905                    // Not a byte-level char (shouldn't happen for real
906                    // vocabs) — pass the char through as UTF-8.
907                    None => {
908                        let mut buf = [0u8; 4];
909                        bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
910                    }
911                }
912            }
913        }
914        let text = String::from_utf8_lossy(&bytes).into_owned();
915        if self.metaspace && (self.sp_prepend || self.sp_prepend_first) {
916            // SP decoder Strip(start=1): one leading space from Prepend.
917            if let Some(stripped) = text.strip_prefix(' ') {
918                return stripped.to_string();
919            }
920        }
921        text
922    }
923
924    /// Streaming decode of ONE token: no sequence-level Strip — a
925    /// per-token strip would eat the ▁-spaces of every SP word.
926    pub fn decode_token(&self, id: u32) -> String {
927        if self.special_ids.contains(&id) {
928            return String::new();
929        }
930        let idx = id as usize;
931        if idx >= self.id_to_token.len() {
932            return String::new();
933        }
934        let tok = &self.id_to_token[idx];
935        if self.added_ids.contains(&id) {
936            if self.metaspace && tok.contains('\u{2581}') {
937                return tok.replace('\u{2581}', " ");
938            }
939            return tok.clone();
940        }
941        if tok.starts_with("<0x") && tok.ends_with('>') && tok.len() == 6 {
942            if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) {
943                return String::from_utf8_lossy(&[b]).into_owned();
944            }
945        }
946        if self.metaspace {
947            return tok.replace('\u{2581}', " ");
948        }
949        let mut bytes = Vec::new();
950        for ch in tok.chars() {
951            match self.char_to_byte.get(&ch) {
952                Some(&b) => bytes.push(b),
953                None => {
954                    let mut buf = [0u8; 4];
955                    bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
956                }
957            }
958        }
959        String::from_utf8_lossy(&bytes).into_owned()
960    }
961
962    /// Decode one vocabulary entry for Engram's compressed token map while
963    /// retaining special tokens.
964    pub fn decode_token_for_hash(&self, id: u32) -> String {
965        let idx = id as usize;
966        if idx >= self.id_to_token.len() {
967            return String::new();
968        }
969        if self.special_ids.contains(&id) {
970            return self.id_to_token[idx].clone();
971        }
972        self.decode_token(id)
973    }
974
975    /// Decode generated protocol text while retaining special markers. The
976    /// V4.1 harmony parser needs `<think>`, EOS, and spaced DSML tags.
977    pub fn decode_for_protocol(&self, ids: &[u32]) -> String {
978        let mut out = String::new();
979        for &id in ids {
980            let idx = id as usize;
981            if self.special_ids.contains(&id) {
982                if let Some(token) = self.id_to_token.get(idx) {
983                    out.push_str(token);
984                }
985            } else {
986                out.push_str(&self.decode_token(id));
987            }
988        }
989        out
990    }
991
992    /// Return the backend vocabulary spelling for an Engram map entry.
993    pub fn raw_token_for_hash(&self, id: u32) -> String {
994        self.id_to_token
995            .get(id as usize)
996            .cloned()
997            .unwrap_or_default()
998    }
999
1000    /// Render the container's Jinja chat template (HF semantics:
1001    /// trim_blocks + lstrip_blocks + loop controls) and encode it.
1002    /// Falls back to hardcoded ChatML when the file carries none.
1003    pub fn apply_chat_template(&self, messages: &[(String, String)]) -> Vec<u32> {
1004        self.apply_chat_template_opts(messages, None)
1005    }
1006
1007    /// Like `apply_chat_template`, with an explicit `enable_thinking` value for
1008    /// reasoning-model templates (Qwen3/3.5 emit an empty <think> block when it
1009    /// is false, so the model answers directly). `None` leaves the variable
1010    /// undefined — the template's own default applies.
1011
1012    /// Chat template with the FULL message shape and a tool list.
1013    ///
1014    /// The pair-based API below flattens every message to (role, text),
1015    /// which silently drops exactly what agentic use needs: the `tools`
1016    /// array, `role: "tool"` results, and `tool_calls` on assistant
1017    /// turns. The templates this format embeds — Qwen-family, Nanbeige —
1018    /// have carried a `{%- if tools %}` branch all along; this is the
1019    /// call that finally feeds it. Messages arrive as JSON objects in
1020    /// the OpenAI shape and pass through to minijinja unflattened, so a
1021    /// template sees the same fields a Python `apply_chat_template`
1022    /// would.
1023    pub fn apply_chat_template_json(
1024        &self,
1025        messages: &[serde_json::Value],
1026        tools: Option<&[serde_json::Value]>,
1027        enable_thinking: Option<bool>,
1028    ) -> Vec<u32> {
1029        match self.try_apply_chat_template_json(messages, tools, enable_thinking) {
1030            Ok(ids) => ids,
1031            Err(e) => {
1032                tracing::error!("chat template render failed ({e}); ChatML fallback");
1033                self.chatml_json_fallback(messages, enable_thinking)
1034            }
1035        }
1036    }
1037
1038    /// Like [`Self::apply_chat_template_json`], but a template that fails
1039    /// to render is an ERROR instead of a quiet ChatML approximation.
1040    ///
1041    /// The fallback flattens every message to (role, text): it has no
1042    /// place for `tools`, `tool_calls` or `role: "tool"`. For a plain chat
1043    /// that is a tolerable degradation; for a request with tools it means
1044    /// the model never sees the functions and answers as if none were
1045    /// offered — a failure no client can detect. The server calls this
1046    /// variant when tools are present and reports the error instead.
1047    /// Files without a template still take the ChatML path (Ok).
1048    pub fn try_apply_chat_template_json(
1049        &self,
1050        messages: &[serde_json::Value],
1051        tools: Option<&[serde_json::Value]>,
1052        enable_thinking: Option<bool>,
1053    ) -> Result<Vec<u32>, String> {
1054        if let Some(tpl) = &self.chat_template {
1055            return self
1056                .render_template_json(tpl, messages, tools, enable_thinking)
1057                .map(|text| self.with_bos(self.encode(&text)))
1058                .map_err(|e| format!("{e:#}"));
1059        }
1060        Ok(self.chatml_json_fallback(messages, enable_thinking))
1061    }
1062
1063    fn chatml_json_fallback(
1064        &self,
1065        messages: &[serde_json::Value],
1066        enable_thinking: Option<bool>,
1067    ) -> Vec<u32> {
1068        let pairs: Vec<(String, String)> = messages
1069            .iter()
1070            .map(|m| {
1071                (
1072                    m.get("role")
1073                        .and_then(|v| v.as_str())
1074                        .unwrap_or("user")
1075                        .to_string(),
1076                    m.get("content")
1077                        .and_then(|v| v.as_str())
1078                        .unwrap_or("")
1079                        .to_string(),
1080                )
1081            })
1082            .collect();
1083        self.with_bos(self.chatml_fallback_opts(&pairs, enable_thinking))
1084    }
1085
1086    /// Render the template against JSON-shaped messages (parity surface).
1087    pub fn render_chat_json(
1088        &self,
1089        messages: &[serde_json::Value],
1090        tools: Option<&[serde_json::Value]>,
1091        enable_thinking: Option<bool>,
1092    ) -> Option<String> {
1093        let tpl = self.chat_template.as_ref()?;
1094        match self.render_template_json(tpl, messages, tools, enable_thinking) {
1095            Ok(t) => Some(t),
1096            Err(e) => {
1097                tracing::error!("chat template render (json): {e:#}");
1098                eprintln!("chat template render (json): {e:#}");
1099                None
1100            }
1101        }
1102    }
1103
1104    fn render_template_json(
1105        &self,
1106        tpl: &str,
1107        messages: &[serde_json::Value],
1108        tools: Option<&[serde_json::Value]>,
1109        enable_thinking: Option<bool>,
1110    ) -> Result<String, minijinja::Error> {
1111        let mut env = crate::chat_template::environment();
1112        let tpl_src = strip_generation_tags(tpl);
1113        env.add_template("chat", &tpl_src)?;
1114        let msgs: Vec<minijinja::Value> = messages
1115            .iter()
1116            .map(minijinja::Value::from_serialize)
1117            .collect();
1118        let tools_v: Option<Vec<minijinja::Value>> =
1119            tools.map(|ts| ts.iter().map(minijinja::Value::from_serialize).collect());
1120        let tpl = env.get_template("chat")?;
1121        // Three axes, each present only when meaningful: templates guard
1122        // with `is defined`, and an explicit null flips those guards.
1123        // `tool_call_format` picks the grammar in two-mode templates
1124        // (Nanbeige): undefined falls into their XML branch. The JSON
1125        // grammar is the one every parser downstream speaks, so choose
1126        // it explicitly; templates without the knob never read it.
1127        let rendered = match (tools_v, enable_thinking) {
1128            (Some(ts), Some(v)) => tpl.render(minijinja::context! {
1129                messages => msgs, tools => ts, add_generation_prompt => true, enable_thinking => v,
1130                // GLM-5's template controls its reasoning budget with
1131                // `reasoning_effort` rather than `enable_thinking`.  A direct
1132                // answer therefore needs the low budget when thinking is
1133                // disabled; otherwise the template defaults to `max`.
1134                reasoning_effort => if !v { Some("low") } else { None::<&str> },
1135                tool_call_format => "json",
1136            })?,
1137            (Some(ts), None) => tpl.render(minijinja::context! {
1138                messages => msgs, tools => ts, add_generation_prompt => true,
1139                tool_call_format => "json",
1140            })?,
1141            (None, Some(v)) => tpl.render(minijinja::context! {
1142                messages => msgs, add_generation_prompt => true, enable_thinking => v,
1143                // See the tool-bearing branch above.
1144                reasoning_effort => if !v { Some("low") } else { None::<&str> },
1145                tool_call_format => "json",
1146            })?,
1147            (None, None) => tpl.render(minijinja::context! {
1148                messages => msgs, add_generation_prompt => true,
1149                tool_call_format => "json",
1150            })?,
1151        };
1152        Ok(close_direct_think(rendered, enable_thinking))
1153    }
1154
1155    pub fn apply_chat_template_opts(
1156        &self,
1157        messages: &[(String, String)],
1158        enable_thinking: Option<bool>,
1159    ) -> Vec<u32> {
1160        if let Some(tpl) = &self.chat_template {
1161            match self.render_template(tpl, messages, enable_thinking) {
1162                Ok(text) => return self.with_bos(self.encode(&text)),
1163                Err(e) => {
1164                    tracing::error!("chat template render failed ({e}); ChatML fallback");
1165                }
1166            }
1167        }
1168        self.with_bos(self.chatml_fallback_opts(messages, enable_thinking))
1169    }
1170
1171    /// Prepend BOS when the tokenizer declares it (llama family).
1172    pub fn with_bos(&self, mut ids: Vec<u32>) -> Vec<u32> {
1173        if self.add_bos {
1174            if let Some(b) = self.bos_token_id {
1175                if ids.first() != Some(&b) {
1176                    ids.insert(0, b);
1177                }
1178            }
1179        }
1180        ids
1181    }
1182
1183    /// Render the carried template to text (parity-testable surface).
1184    pub fn render_chat(&self, messages: &[(String, String)]) -> Option<String> {
1185        self.render_chat_opts(messages, None)
1186    }
1187
1188    /// Render the carried template to text with explicit thinking mode.
1189    pub fn render_chat_opts(
1190        &self,
1191        messages: &[(String, String)],
1192        enable_thinking: Option<bool>,
1193    ) -> Option<String> {
1194        let tpl = self.chat_template.as_ref()?;
1195        match self.render_template(tpl, messages, enable_thinking) {
1196            Ok(t) => Some(t),
1197            Err(e) => {
1198                tracing::error!("chat template render: {e:#}");
1199                None
1200            }
1201        }
1202    }
1203
1204    fn render_template(
1205        &self,
1206        tpl: &str,
1207        messages: &[(String, String)],
1208        enable_thinking: Option<bool>,
1209    ) -> Result<String, minijinja::Error> {
1210        let mut env = crate::chat_template::environment();
1211        let tpl_src = strip_generation_tags(tpl);
1212        env.add_template("chat", &tpl_src)?;
1213        let msgs: Vec<minijinja::Value> = messages
1214            .iter()
1215            .map(|(role, content)| {
1216                minijinja::context! { role => role, content => content }
1217            })
1218            .collect();
1219        // `enable_thinking` stays UNDEFINED when None — reasoning templates
1220        // check `enable_thinking is defined` and fall back to their default.
1221        let rendered = match enable_thinking {
1222            Some(v) => env.get_template("chat")?.render(minijinja::context! {
1223                messages => msgs,
1224                add_generation_prompt => true,
1225                enable_thinking => v,
1226                // GLM-5 uses this variable (not `enable_thinking`) to select
1227                // its reasoning budget and defaults to max when omitted.
1228                reasoning_effort => if !v { Some("low") } else { None::<&str> },
1229            })?,
1230            None => env.get_template("chat")?.render(minijinja::context! {
1231                messages => msgs,
1232                add_generation_prompt => true,
1233            })?,
1234        };
1235        Ok(close_direct_think(rendered, enable_thinking))
1236    }
1237
1238    /// Hardcoded Qwen ChatML (pre-§6.1 files).
1239    fn chatml_fallback(&self, messages: &[(String, String)]) -> Vec<u32> {
1240        self.chatml_fallback_opts(messages, None)
1241    }
1242
1243    /// Hardcoded Qwen ChatML (pre-§6.1 files) with optional thinking suppression.
1244    fn chatml_fallback_opts(
1245        &self,
1246        messages: &[(String, String)],
1247        enable_thinking: Option<bool>,
1248    ) -> Vec<u32> {
1249        let mut tokens = Vec::new();
1250
1251        for (role, content) in messages {
1252            // <|im_start|>role\ncontent<|im_end|>\n
1253            if let Some(start_id) = self.im_start_id {
1254                tokens.push(start_id);
1255            }
1256            tokens.extend(self.encode(&format!("{}\n{}", role, content)));
1257            if let Some(end_id) = self.im_end_id {
1258                tokens.push(end_id);
1259            }
1260            tokens.extend(self.encode("\n"));
1261        }
1262
1263        // Add assistant prefix
1264        if let Some(start_id) = self.im_start_id {
1265            tokens.push(start_id);
1266        }
1267        tokens.extend(self.encode("assistant\n"));
1268        if enable_thinking == Some(false) {
1269            tokens.extend(self.encode("<think>\n\n</think>\n\n"));
1270        }
1271
1272        tokens
1273    }
1274
1275    /// Vocabulary size.
1276    pub fn vocab_size(&self) -> usize {
1277        self.id_to_token.len()
1278    }
1279
1280    /// Return the ID for an exact token spelling, including added/special
1281    /// tokens. Multimodal prompt preparation uses this to validate the image
1282    /// placeholder against the model configuration.
1283    pub fn token_to_id(&self, token: &str) -> Option<u32> {
1284        self.vocab.get(token).copied()
1285    }
1286
1287    /// Alias matching the HuggingFace tokenizer API used by the official
1288    /// DeepSeek image processor.
1289    pub fn convert_tokens_to_ids(&self, token: &str) -> Option<u32> {
1290        self.token_to_id(token)
1291    }
1292
1293    /// Check if token ID is EOS.
1294    pub fn is_eos(&self, id: u32) -> bool {
1295        self.eos_token_id == Some(id) || self.im_end_id == Some(id) || self.extra_eos.contains(&id)
1296    }
1297}
1298
1299#[derive(Debug, thiserror::Error)]
1300pub enum TokenizerError {
1301    #[error("IO error: {0}")]
1302    Io(String),
1303    #[error("Parse error: {0}")]
1304    Parse(String),
1305}
1306
1307/// SentencePiece merges over token ids: `(left, right)` → `(rank, merged)`.
1308struct SpMerges {
1309    pairs: HashMap<u64, (u32, u32)>,
1310    /// `<0xNN>` ids for the byte fallback (`u32::MAX` = absent)
1311    bytes: [u32; 256],
1312}
1313
1314const SP_NONE: u32 = u32::MAX;
1315
1316thread_local! {
1317    /// [`Tokenizer::encode_reference`] in progress on this thread.
1318    static SP_STRINGS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1319}
1320
1321impl SpMerges {
1322    fn build(vocab: &HashMap<String, u32>, ranks: &HashMap<(String, String), u32>) -> Option<Self> {
1323        if ranks.is_empty() {
1324            return None;
1325        }
1326        let mut pairs = HashMap::with_capacity(ranks.len());
1327        let mut cat = String::new();
1328        for ((a, b), &r) in ranks {
1329            let (&ia, &ib) = (vocab.get(a)?, vocab.get(b)?);
1330            cat.clear();
1331            cat.push_str(a);
1332            cat.push_str(b);
1333            let &m = vocab.get(&cat)?;
1334            pairs.insert(((ia as u64) << 32) | ib as u64, (r, m));
1335        }
1336        let mut bytes = [SP_NONE; 256];
1337        for (b, slot) in bytes.iter_mut().enumerate() {
1338            if let Some(&id) = vocab.get(&format!("<0x{b:02X}>")) {
1339                *slot = id;
1340            }
1341        }
1342        Some(SpMerges { pairs, bytes })
1343    }
1344
1345    #[inline]
1346    fn rank(&self, a: u32, b: u32) -> Option<(u32, u32)> {
1347        if a == SP_NONE || b == SP_NONE {
1348            return None;
1349        }
1350        self.pairs.get(&(((a as u64) << 32) | b as u64)).copied()
1351    }
1352
1353    /// The string form's rule over ids: take the lowest rank among the
1354    /// adjacent pairs, merge every live occurrence of that pair left to
1355    /// right (non-overlapping), and only then admit the pairs the merges
1356    /// formed — exactly the order of `bpe_piece_sp_strings`.
1357    fn encode(&self, vocab: &HashMap<String, u32>, piece: &str, out: &mut Vec<u32>) {
1358        use std::cmp::Reverse;
1359        use std::collections::BinaryHeap;
1360        let spans: Vec<(usize, usize)> = piece
1361            .char_indices()
1362            .map(|(i, c)| (i, i + c.len_utf8()))
1363            .collect();
1364        let n = spans.len();
1365        let mut buf = [0u8; 4];
1366        let mut id: Vec<u32> = piece
1367            .chars()
1368            .map(|c| *vocab.get(c.encode_utf8(&mut buf) as &str).unwrap_or(&SP_NONE))
1369            .collect();
1370        let mut next: Vec<usize> = (1..=n).collect();
1371        let mut prev: Vec<usize> = (0..n).map(|i| i.wrapping_sub(1)).collect();
1372        let mut alive = vec![true; n];
1373        let mut heap: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::with_capacity(n);
1374        for i in 0..n.saturating_sub(1) {
1375            if let Some((r, _)) = self.rank(id[i], id[i + 1]) {
1376                heap.push(Reverse((r, i)));
1377            }
1378        }
1379        let mut batch: Vec<usize> = Vec::new();
1380        let mut merged: Vec<usize> = Vec::new();
1381        while let Some(&Reverse((r, _))) = heap.peek() {
1382            batch.clear();
1383            while let Some(&Reverse((r2, pos))) = heap.peek() {
1384                if r2 != r {
1385                    break;
1386                }
1387                heap.pop();
1388                batch.push(pos);
1389            }
1390            batch.sort_unstable();
1391            batch.dedup();
1392            merged.clear();
1393            for &pos in &batch {
1394                if !alive[pos] || next[pos] >= n {
1395                    continue;
1396                }
1397                let j = next[pos];
1398                match self.rank(id[pos], id[j]) {
1399                    Some((rr, m)) if rr == r => {
1400                        id[pos] = m;
1401                        alive[j] = false;
1402                        next[pos] = next[j];
1403                        if next[j] < n {
1404                            prev[next[j]] = pos;
1405                        }
1406                        merged.push(pos);
1407                    }
1408                    _ => {}
1409                }
1410            }
1411            for &pos in &merged {
1412                if !alive[pos] {
1413                    continue;
1414                }
1415                let pv = prev[pos];
1416                if pv < n && alive[pv] {
1417                    if let Some((r2, _)) = self.rank(id[pv], id[pos]) {
1418                        heap.push(Reverse((r2, pv)));
1419                    }
1420                }
1421                let nx = next[pos];
1422                if nx < n {
1423                    if let Some((r2, _)) = self.rank(id[pos], id[nx]) {
1424                        heap.push(Reverse((r2, pos)));
1425                    }
1426                }
1427            }
1428        }
1429        let mut i = 0usize;
1430        while i < n {
1431            if id[i] != SP_NONE {
1432                out.push(id[i]);
1433            } else {
1434                // a character outside the vocabulary: its UTF-8 bytes
1435                let (a, b) = spans[i];
1436                let mut ok = true;
1437                for &byte in piece[a..b].as_bytes() {
1438                    match self.bytes[byte as usize] {
1439                        SP_NONE => {
1440                            ok = false;
1441                            break;
1442                        }
1443                        t => out.push(t),
1444                    }
1445                }
1446                if !ok {
1447                    tracing::error!("tokenizer: no id for SP symbol {:?} — dropped", &piece[a..b]);
1448                }
1449            }
1450            i = next[i];
1451        }
1452    }
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457    use super::*;
1458
1459    #[test]
1460    fn byte_unicode_bijection() {
1461        let (b2c, c2b) = bytes_to_unicode();
1462        for b in 0..=255u8 {
1463            assert_eq!(c2b[&b2c[b as usize]], b);
1464        }
1465        // GPT-2 well-known mappings: space → Ġ, newline → Ċ
1466        assert_eq!(b2c[b' ' as usize], 'Ġ');
1467        assert_eq!(b2c[b'\n' as usize], 'Ċ');
1468    }
1469
1470    #[test]
1471    fn byte_level_roundtrip_utf8() {
1472        let tok = Tokenizer::byte_level();
1473        let text = "hello 🌍 hi\n";
1474        let ids = tok.encode(text);
1475        assert_eq!(ids.len(), text.len()); // one id per byte
1476        assert_eq!(tok.decode(&ids), text);
1477    }
1478
1479    /// A tiny real-format tokenizer.json exercising the full pipeline:
1480    /// GPT-2 regex, byte-level alphabet, one merge, an added token.
1481    fn mini_json() -> String {
1482        // vocab: byte-level chars for h,e,l,o,Ġ,w,r,d + merged "he"
1483        let vocab: Vec<(&str, u32)> = vec![
1484            ("h", 0),
1485            ("e", 1),
1486            ("l", 2),
1487            ("o", 3),
1488            ("Ġ", 4),
1489            ("w", 5),
1490            ("r", 6),
1491            ("d", 7),
1492            ("he", 8),
1493            ("Ġw", 9),
1494        ];
1495        let vocab_json: String = vocab
1496            .iter()
1497            .map(|(t, i)| format!("\"{t}\": {i}"))
1498            .collect::<Vec<_>>()
1499            .join(", ");
1500        format!(
1501            r#"{{
1502              "model": {{
1503                "type": "BPE",
1504                "vocab": {{ {vocab_json} }},
1505                "merges": [["h", "e"], ["Ġ", "w"]]
1506              }},
1507              "added_tokens": [
1508                {{"id": 10, "content": "<|eot|>", "special": true}}
1509              ]
1510            }}"#
1511        )
1512    }
1513
1514    /// The id-level SentencePiece BPE gives the string form's ids on every
1515    /// input, including merges whose products rank below the merge that
1516    /// made them, overlapping runs ("aaa"), two merges producing one
1517    /// string, and characters outside the vocabulary (byte fallback, with
1518    /// one byte missing).
1519    #[test]
1520    fn sp_heap_bpe_matches_the_string_form() {
1521        let toks = [
1522            "a", "b", "c", "▁", "bc", "abc", "ab", "aa", "aab", "ca", "▁a", "aabc",
1523            "aaa", "<0x64>", "<0xC3>", "▁▁",
1524        ];
1525        let vocab: Vec<String> = toks
1526            .iter()
1527            .enumerate()
1528            .map(|(i, t)| format!("\"{t}\": {i}"))
1529            .collect();
1530        let merges = [
1531            ("aab", "c"),
1532            ("b", "c"),
1533            ("a", "bc"),
1534            ("a", "b"),
1535            ("ab", "c"),
1536            ("a", "a"),
1537            ("aa", "b"),
1538            ("c", "a"),
1539            ("▁", "a"),
1540            ("aa", "a"),
1541            ("▁", "▁"),
1542        ];
1543        let merges: Vec<String> = merges
1544            .iter()
1545            .map(|(a, b)| format!("[\"{a}\", \"{b}\"]"))
1546            .collect();
1547        let json = format!(
1548            r#"{{"model": {{"type": "BPE", "byte_fallback": true, "vocab": {{ {} }}, "merges": [{}]}},
1549               "normalizer": {{"type": "Replace", "pattern": {{"String": " "}}, "content": "\u2581"}},
1550               "added_tokens": []}}"#,
1551            vocab.join(", "),
1552            merges.join(", ")
1553        );
1554        let t = Tokenizer::from_json(&json).unwrap();
1555        assert!(t.metaspace, "a byte_fallback vocabulary takes the SentencePiece path");
1556        assert!(t.sp_merges().is_some());
1557        let alphabet = ['a', 'b', 'c', '\u{2581}', 'd', 'é', 'a', 'a', 'b'];
1558        let mut state = 0x2545_f491_4f6c_dd1du64;
1559        for len in 0..40 {
1560            for _ in 0..60 {
1561                let piece: String = (0..len)
1562                    .map(|_| {
1563                        state ^= state << 13;
1564                        state ^= state >> 7;
1565                        state ^= state << 17;
1566                        alphabet[(state % alphabet.len() as u64) as usize]
1567                    })
1568                    .collect();
1569                let (mut fast, mut slow) = (Vec::new(), Vec::new());
1570                t.sp_merges().unwrap().encode(&t.vocab, &piece, &mut fast);
1571                t.bpe_piece_sp_strings(&piece, &mut slow);
1572                assert_eq!(fast, slow, "{piece:?}");
1573            }
1574        }
1575    }
1576
1577    /// MiniCPM5 marks its tool grammar special. Decoding must keep the
1578    /// markup (the call IS that text) and still drop the chat control
1579    /// tokens around it.
1580    #[test]
1581    fn tool_markup_decodes_even_when_special() {
1582        let json = r#"{
1583          "model": {"type": "BPE", "vocab": {"h": 0, "e": 1, "l": 2, "o": 3}, "merges": []},
1584          "added_tokens": [
1585            {"id": 10, "content": "<|im_end|>", "special": true},
1586            {"id": 11, "content": "<function", "special": true},
1587            {"id": 12, "content": "</function>", "special": true},
1588            {"id": 13, "content": "<param", "special": true},
1589            {"id": 14, "content": "</param>", "special": true},
1590            {"id": 15, "content": "<tool_call>", "special": true}
1591          ]
1592        }"#;
1593        let t = Tokenizer::from_json(json).unwrap();
1594        let ids = [11, 0, 1, 13, 2, 14, 12, 15, 10];
1595        assert_eq!(
1596            t.decode(&ids),
1597            "<functionhe<paraml</param></function><tool_call>"
1598        );
1599        let streamed: String = ids.iter().map(|&i| t.decode_token(i)).collect();
1600        assert_eq!(streamed, t.decode(&ids), "streaming must agree with decode");
1601        assert!(
1602            !t.decode(&[10]).contains("im_end"),
1603            "control tokens stay hidden"
1604        );
1605    }
1606
1607    /// Parity against HuggingFace on a real tokenizer, run only when the
1608    /// file is present (CMF_TOK_PARITY=/path/to/tokenizer.json).
1609    #[test]
1610    fn real_tokenizer_parity_when_available() {
1611        let Ok(path) = std::env::var("CMF_TOK_PARITY") else {
1612            return;
1613        };
1614        let t = Tokenizer::from_file(&path).expect("load");
1615        for (text, want) in [
1616            (
1617                "The capital of France is",
1618                vec![671u32, 6102, 294, 8760, 344],
1619            ),
1620            ("2 + 2 =", vec![20, 940, 223, 20, 438]),
1621        ] {
1622            let got = t.encode(text);
1623            assert_eq!(got, want, "«{text}»");
1624        }
1625    }
1626
1627    /// Granite 4.2 ships its prompt grammar as a sidecar
1628    /// `chat_template.jinja` (not tokenizer_config.chat_template).  Exercise
1629    /// the exact upstream file when supplied so macro/namespace support and
1630    /// both reasoning prefixes cannot silently fall back to generic ChatML.
1631    #[test]
1632    fn granite_42_chat_template_when_available() {
1633        let Ok(path) = std::env::var("CMF_GRANITE_CHAT_TEMPLATE") else {
1634            return;
1635        };
1636        let mut tok = Tokenizer::byte_level();
1637        tok.chat_template = Some(std::fs::read_to_string(path).expect("read Granite template"));
1638        let messages = vec![("user".to_string(), "Hello".to_string())];
1639
1640        let thinking = tok
1641            .render_chat_opts(&messages, Some(true))
1642            .expect("render Granite thinking prompt");
1643        assert_eq!(
1644            thinking,
1645            "<|im_start|>system\n<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
1646        );
1647
1648        let direct = tok
1649            .render_chat_opts(&messages, Some(false))
1650            .expect("render Granite direct prompt");
1651        assert_eq!(
1652            direct,
1653            "<|im_start|>system\n<|im_end|>\n<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think></think>"
1654        );
1655    }
1656
1657    /// GLM-5's generation suffix is `<|assistant|><think>`.  The direct-answer
1658    /// path must close that marker without splitting the `<|assistant|>` token
1659    /// (a bare `rfind("assistant")` inserts bytes in the middle of the marker).
1660    #[test]
1661    fn glm_direct_template_keeps_assistant_special_token_intact() {
1662        let mut tok = Tokenizer::byte_level();
1663        tok.chat_template = Some(
1664            "[gMASK]<sop>{%- set effort = reasoning_effort if reasoning_effort is defined and reasoning_effort in ['low', 'high'] else 'max' -%}<|system|>Reasoning Effort: {{ effort | capitalize }}{%- for m in messages -%}<|user|>{{ m.content }}{%- endfor -%}<|assistant|><think>"
1665                .to_string(),
1666        );
1667        let messages = vec![("user".to_string(), "2+2?".to_string())];
1668        let rendered = tok
1669            .render_chat_opts(&messages, Some(false))
1670            .expect("render GLM direct prompt");
1671        assert!(rendered.contains("Reasoning Effort: Low"));
1672        assert!(rendered.contains("<|assistant|><think></think>"));
1673        assert!(!rendered.contains("<|assistant\n"));
1674
1675        // Leaving thinking enabled must preserve GLM's template default
1676        // (`max`) rather than serializing our direct-answer hint.
1677        let thinking = tok
1678            .render_chat_opts(&messages, Some(true))
1679            .expect("render GLM thinking prompt");
1680        assert!(thinking.contains("Reasoning Effort: Max"));
1681
1682        let json_messages = vec![serde_json::json!({
1683            "role": "user",
1684            "content": "2+2?"
1685        })];
1686        let rendered_json = tok
1687            .render_chat_json(&json_messages, None, Some(false))
1688            .expect("render GLM JSON direct prompt");
1689        assert!(rendered_json.contains("Reasoning Effort: Low"));
1690        assert!(rendered_json.contains("<|assistant|><think></think>"));
1691        assert!(!rendered_json.contains("<|assistant\n"));
1692    }
1693
1694    /// A Sequence of Splits applies ALL of them, in order. Reading only the
1695    /// first is not a near-miss: DeepSeek-V4 puts a digit rule first and the
1696    /// word rule third, so one-pattern behaviour hands BPE a whole sentence
1697    /// as a single piece and the ids that come back are ones the model was
1698    /// never trained on.
1699    #[test]
1700    fn every_split_in_a_sequence_is_applied() {
1701        let pt = serde_json::json!({
1702            "type": "Sequence",
1703            "pretokenizers": [
1704                {"type": "Split", "behavior": "Isolated",
1705                 "pattern": {"Regex": r"\p{N}{1,3}"}},
1706                {"type": "Split", "behavior": "Isolated",
1707                 "pattern": {"Regex": r" ?[\p{L}]+"}},
1708                {"type": "ByteLevel", "add_prefix_space": false, "use_regex": false}
1709            ]
1710        });
1711        let mut pats = Vec::new();
1712        collect_split_patterns(&pt, &mut pats);
1713        assert_eq!(
1714            pats.len(),
1715            2,
1716            "both Split stages must be collected: {pats:?}"
1717        );
1718        assert!(pats[0].contains("p{N}"), "digit rule first");
1719        assert!(pats[1].contains("p{L}"), "word rule second");
1720
1721        // And the staged subdivision reaches the word boundaries. Building a
1722        // byte-level tokenizer over this pre_tokenizer, "ab cd" has to become
1723        // two pieces rather than one.
1724        let re: Vec<fancy_regex::Regex> = pats
1725            .iter()
1726            .map(|p| fancy_regex::Regex::new(p).unwrap())
1727            .collect();
1728        let norm = "ab cd12";
1729        let mut pieces: Vec<(usize, usize)> = vec![(0, norm.len())];
1730        for r in &re {
1731            let mut next = Vec::new();
1732            for (ps, pe) in pieces {
1733                let seg = &norm[ps..pe];
1734                let mut last = 0;
1735                for m in r.find_iter(seg).flatten() {
1736                    if m.start() > last {
1737                        next.push((ps + last, ps + m.start()));
1738                    }
1739                    if m.end() > m.start() {
1740                        next.push((ps + m.start(), ps + m.end()));
1741                    }
1742                    last = m.end();
1743                }
1744                if last < seg.len() {
1745                    next.push((ps + last, pe));
1746                }
1747            }
1748            pieces = next;
1749        }
1750        let got: Vec<&str> = pieces.iter().map(|(a, b)| &norm[*a..*b]).collect();
1751        assert_eq!(
1752            got,
1753            vec!["ab", " cd", "12"],
1754            "staged split produced {got:?}"
1755        );
1756    }
1757
1758    #[test]
1759    fn full_pipeline_merges_and_added_tokens() {
1760        let tok = Tokenizer::from_json(&mini_json()).unwrap();
1761        // "hello world" → [he,l,l,o, Ġw,o,r,l,d]
1762        let ids = tok.encode("hello world");
1763        assert_eq!(ids, vec![8, 2, 2, 3, 9, 3, 6, 2, 7]);
1764        assert_eq!(tok.decode(&ids), "hello world");
1765        // Added token splits and is skipped at decode (special).
1766        let ids2 = tok.encode("he<|eot|>he");
1767        assert_eq!(ids2, vec![8, 10, 8]);
1768        assert_eq!(tok.decode(&ids2), "hehe");
1769    }
1770
1771    #[test]
1772    fn non_ascii_is_never_silently_dropped() {
1773        let tok = Tokenizer::from_json(&mini_json()).unwrap();
1774        // A non-ASCII char is not encodable by the mini vocab (no byte tokens either):
1775        // the id list may be empty, but ASCII around it must survive.
1776        let ids = tok.encode("hello");
1777        assert!(!ids.is_empty());
1778    }
1779}
1780
1781#[cfg(test)]
1782mod generation_tag_tests {
1783    use super::strip_generation_tags;
1784
1785    /// LFM2.5's template wraps the assistant branch in `{%- generation -%}`.
1786    /// minijinja does not know the statement, the whole template failed,
1787    /// and the caller served a ChatML approximation instead — the model
1788    /// then answers a differently-shaped prompt than it was tuned on.
1789    #[test]
1790    fn a_generation_block_becomes_a_no_op_keeping_its_whitespace_control() {
1791        let tpl = "a{%- generation -%}b{%- endgeneration -%}c";
1792        let out = strip_generation_tags(tpl);
1793        assert!(!out.contains("{%- generation"));
1794        assert!(!out.contains("endgeneration"));
1795        // Both dashes survive on both tags: the trimming must not change.
1796        assert_eq!(out.matches("{%-").count(), 2);
1797        assert_eq!(out.matches("-%}").count(), 2);
1798        assert!(out.starts_with('a') && out.ends_with('c'));
1799    }
1800
1801    /// Whitespace control is per-side, and a tag without dashes must not
1802    /// grow any.
1803    #[test]
1804    fn each_side_keeps_its_own_dash() {
1805        let out = strip_generation_tags("{% generation %}x{%- endgeneration %}");
1806        assert!(out.starts_with("{% set"), "no dash added on the left");
1807        assert!(out.contains("{%- set"), "the right tag keeps its dash");
1808        assert!(!out.contains("-%}"), "no trailing dash invented");
1809    }
1810
1811    /// Templates that never use it are returned untouched, and other
1812    /// statements are never rewritten.
1813    #[test]
1814    fn everything_else_is_left_alone() {
1815        let plain = "{%- if x -%}{{ y }}{%- endif -%}";
1816        assert_eq!(strip_generation_tags(plain), plain);
1817        // The word appearing in TEXT is not a statement.
1818        let prose = "{{ 'the generation of tokens' }}";
1819        assert_eq!(strip_generation_tags(prose), prose);
1820    }
1821}