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