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