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