Skip to main content

cortiq_engine/
tokenizer.rs

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