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