Skip to main content

cortiq_engine/
tokenizer.rs

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